diff --git a/apps/desktop/lib/src/files/ui/file_tiles.dart b/apps/desktop/lib/src/files/ui/file_tiles.dart new file mode 100644 index 0000000..75d7b2d --- /dev/null +++ b/apps/desktop/lib/src/files/ui/file_tiles.dart @@ -0,0 +1,329 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; + +import '../../shell/avatar.dart'; +import '../name_parser.dart'; +import '../scanner.dart'; +import 'thumb.dart'; + +// The Files page's rows and cards: a series header, an episode row (also +// used for Recent), a loose-video card, and the empty states. + +/// Shown on play controls while there is nobody to watch with. +const offlinePlayHint = 'Join or host to watch'; + +/// What a play control is doing right now. +enum PlayState { idle, starting, playing } + +/// Last path segment, ignoring trailing separators (`/media/anime/`). +String baseName(String path) { + final parts = path.split(RegExp(r'[/\\]')).where((p) => p.isNotEmpty).toList(); + return parts.isEmpty ? path : parts.last; +} + +/// Name of the folder [path] is in. +String parentName(String path) => baseName(File(path).parent.path); + +/// Cleaned title of a loose video: `Title (2019)`. +String cleanTitle(ParsedName p) => p.year == null ? p.series : '${p.series} (${p.year})'; + +/// Disabled with [offlinePlayHint] when [onTap] is null because offline. +Widget _offlineTip(bool offline, Widget child) => + offline ? Tooltip(message: offlinePlayHint, child: child) : child; + +class SeriesHeader extends StatelessWidget { + final SeriesGroup group; + final ThumbCache thumbs; + const SeriesHeader({super.key, required this.group, required this.thumbs}); + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + final first = group.episodes.first; + final n = group.episodes.length; + final extras = group.extras.length; + final detail = [ + '$n ${n == 1 ? 'episode' : 'episodes'}', + if (extras > 0) '$extras ${extras == 1 ? 'extra' : 'extras'}', + ?first.parsed.group, + ].join(' · '); + return Padding( + padding: const EdgeInsets.only(top: 18, bottom: 4), + child: Row( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: ThumbBuilder( + cache: thumbs, + path: first.path, + builder: (context, t, _) => ThumbImage(t, width: 96, height: 54), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(group.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleMedium), + Text(detail, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12)), + ], + ), + ), + ], + ), + ); + } +} + +/// One file as a row: an episode under its series header, or a Recent +/// entry ([title] given, [indent] off). +class FileRow extends StatelessWidget { + final String path; + final String title; + final String subtitle; + /// Size text; null while unknown. + final String? size; + final PlayState state; + final ThumbCache thumbs; + /// Null disables the row ([offline] adds the reason as a tooltip). + final VoidCallback? onTap; + final bool offline; + final bool indent; + final Widget? trailing; + const FileRow({ + super.key, + required this.path, + required this.title, + required this.subtitle, + required this.size, + required this.state, + required this.thumbs, + required this.onTap, + required this.offline, + this.indent = true, + this.trailing, + }); + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + final playing = state == PlayState.playing; + final enabled = onTap != null; + final muted = scheme.onSurfaceVariant; + final icon = switch (state) { + PlayState.starting => const SizedBox.square( + dimension: 16, child: CircularProgressIndicator(strokeWidth: 2)), + PlayState.playing => Icon(Icons.graphic_eq, size: 20, color: scheme.primary), + PlayState.idle => Icon(Icons.play_circle_outline, + size: 20, color: enabled ? scheme.primary : scheme.onSurface.withValues(alpha: 0.38)), + }; + return ThumbBuilder( + cache: thumbs, + path: path, + builder: (context, t, _) { + final duration = durationText(t); + final meta = [?duration, ?size].join(' · '); + return _offlineTip( + offline, + Material( + color: playing ? scheme.primary.withValues(alpha: 0.08) : Colors.transparent, + borderRadius: BorderRadius.circular(10), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(10), + child: Padding( + padding: EdgeInsets.fromLTRB(indent ? 20 : 4, 5, 8, 5), + child: Row( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(6), + child: ThumbImage(t, width: 80, height: 45), + ), + const SizedBox(width: 10), + SizedBox(width: 20, child: Center(child: icon)), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Flexible( + child: Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontWeight: FontWeight.w600, + color: playing ? scheme.primary : null, + ), + ), + ), + if (playing) ...[ + const SizedBox(width: 8), + Pill('now playing', scheme.primary), + ], + ], + ), + Text(subtitle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: muted, fontSize: 12)), + ], + ), + ), + if (meta.isNotEmpty) ...[ + const SizedBox(width: 12), + Text(meta, style: TextStyle(color: muted, fontSize: 12)), + ], + ?trailing, + ], + ), + ), + ), + ), + ); + }, + ); + } +} + +/// Text under a [VideoCard]'s 16:9 frame (title up to two lines, size). +const videoCardTextHeight = 66.0; + +/// A loose video (movie, clip): 16:9 frame with its duration, then the +/// cleaned title and size. +class VideoCard extends StatelessWidget { + final VideoFile video; + final PlayState state; + final ThumbCache thumbs; + final VoidCallback? onTap; + final bool offline; + const VideoCard({ + super.key, + required this.video, + required this.state, + required this.thumbs, + required this.onTap, + required this.offline, + }); + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + final playing = state == PlayState.playing; + return ThumbBuilder( + cache: thumbs, + path: video.path, + builder: (context, t, _) { + final duration = durationText(t); + return _offlineTip( + offline, + InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + AspectRatio( + aspectRatio: 16 / 9, + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: playing ? scheme.primary : Colors.transparent, + width: 2, + ), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(10), + child: Stack( + fit: StackFit.expand, + children: [ + ThumbImage(t), + if (duration != null) + Positioned(right: 6, bottom: 6, child: _Badge(duration)), + if (state != PlayState.idle) + Container( + color: Colors.black38, + alignment: Alignment.center, + child: state == PlayState.playing + ? const Icon(Icons.graphic_eq, color: Colors.white, size: 32) + : const SizedBox.square( + dimension: 28, + child: CircularProgressIndicator( + strokeWidth: 3, color: Colors.white)), + ), + ], + ), + ), + ), + ), + const SizedBox(height: 6), + Flexible( + child: Text( + cleanTitle(video.parsed), + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontWeight: FontWeight.w600, + fontSize: 13, + color: playing ? scheme.primary : null, + ), + ), + ), + Text(formatSize(video.size), + style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12)), + ], + ), + ), + ); + }, + ); + } +} + +class _Badge extends StatelessWidget { + final String text; + const _Badge(this.text); + + @override + Widget build(BuildContext context) => Container( + padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1), + decoration: BoxDecoration(color: Colors.black87, borderRadius: BorderRadius.circular(4)), + child: Text(text, style: const TextStyle(color: Colors.white, fontSize: 11)), + ); +} + +class FilesEmpty extends StatelessWidget { + final IconData icon; + final String title; + final String detail; + final Widget? action; + const FilesEmpty(this.icon, this.title, this.detail, {super.key, this.action}); + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + return Padding( + padding: const EdgeInsets.only(top: 60, bottom: 20), + child: Column( + children: [ + Icon(icon, size: 44, color: scheme.onSurfaceVariant), + const SizedBox(height: 10), + Text(title, style: Theme.of(context).textTheme.titleMedium, textAlign: TextAlign.center), + const SizedBox(height: 4), + Text(detail, style: TextStyle(color: scheme.onSurfaceVariant), textAlign: TextAlign.center), + if (action != null) ...[const SizedBox(height: 14), action!], + ], + ), + ); + } +} diff --git a/apps/desktop/lib/src/files/ui/files_page.dart b/apps/desktop/lib/src/files/ui/files_page.dart new file mode 100644 index 0000000..a76f973 --- /dev/null +++ b/apps/desktop/lib/src/files/ui/files_page.dart @@ -0,0 +1,727 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/material.dart'; +import 'package:signals/signals_flutter.dart'; + +import '../../shell/motion.dart'; +import '../folder_store.dart'; +import '../name_parser.dart'; +import '../scanner.dart'; +import 'file_tiles.dart'; +import 'thumb.dart'; + +export 'thumb.dart' show ThumbnailLoader; + +// The Files source: watched folders on the left (chips on top when +// narrow), the open one on the right grouped into series and loose +// videos, or Recent. A folder is scanned when first opened and on Rescan; +// scans are kept for the app session. Playing goes through [onPlay]. + +/// Below this width the sidebar turns into chips above the list. +const filesNarrowWidth = 700.0; + +/// Scans kept for the app session, by folder. +final _scans = {}; +final _running = >{}; +String? _lastOpen; + +/// Forgets the session's scans and the open folder. +@visibleForTesting +void resetFilesPageSession() { + _scans.clear(); + _running.clear(); + _lastOpen = null; +} + +class FilesPage extends StatefulWidget { + /// In a session: false disables playing (browsing still works). + final bool online; + /// The file will stream from this computer through the server. + final bool relayed; + /// Plays [path] in the session; returns an error text, or null. + final Future Function(String path) onPlay; + final String? nowPlayingPath; + /// Defaults to [thumbnailFor]. + final ThumbnailLoader? thumbnails; + /// Defaults to [scanFolder]. + final Future Function(String dir)? scan; + /// Defaults to [FolderStore.instance]. + final FolderStore? store; + /// Default: the system folder / file dialogs. + final Future Function()? pickFolder; + final Future Function()? pickVideo; + + const FilesPage({ + super.key, + required this.online, + required this.relayed, + required this.onPlay, + this.nowPlayingPath, + this.thumbnails, + this.scan, + this.store, + this.pickFolder, + this.pickVideo, + }); + + @override + State createState() => _FilesPageState(); +} + +/// A scan grouped for display, made once per scan. +class _Library { + final GroupedVideos grouped; + /// Series rows in order: a header (video null), then its episodes and + /// extras. + final List<(SeriesGroup, VideoFile?)> rows; + final int subtitleFiles; + + _Library(FolderScan scan) + : grouped = groupBySeries(scan.videos), + rows = [], + subtitleFiles = {for (final l in scan.subtitles.values) ...l}.length { + for (final g in grouped.series) { + rows.add((g, null)); + for (final v in [...g.episodes, ...g.extras]) { + rows.add((g, v)); + } + } + } +} + +final _libraries = Expando<_Library>(); + +class _FilesPageState extends State { + late final ThumbCache _thumbs = + widget.thumbnails == null ? ThumbCache.shared : ThumbCache(widget.thumbnails!); + FolderStore get _store => widget.store ?? FolderStore.instance; + + /// Null: Recent. + String? _open = _lastOpen; + bool _showHidden = false; + String? _starting; + String? _error; + + @override + void initState() { + super.initState(); + _store.ensureLoaded().then((_) { + if (!mounted) return; + final open = _open; + if (open != null && !_store.folders.value.contains(open)) { + _select(null); + } else if (open != null) { + _scan(open); + } + }); + } + + Future _runScan(String dir) => (widget.scan ?? scanFolder)(dir); + + void _select(String? dir) { + setState(() { + _open = _lastOpen = dir; + _showHidden = false; + }); + if (dir != null && !_scans.containsKey(dir)) _scan(dir); + } + + Future _scan(String dir, {bool rescan = false}) async { + var f = _running[dir]; + if (f == null || rescan) { + f = _running[dir] = _runScan(dir); + if (rescan) _thumbs.forget([for (final v in _scans[dir]?.videos ?? const []) v.path]); + } + if (mounted) setState(() {}); + FolderScan result; + try { + result = await f; + } catch (_) { + result = FolderScan.empty; + } + if (_running[dir] == f) { + _running.remove(dir); + _scans[dir] = result; + } + if (mounted) setState(() {}); + } + + Future _addFolder() async { + String? dir; + try { + dir = await (widget.pickFolder ?? + () => FilePicker.getDirectoryPath(dialogTitle: 'Add a folder with videos'))(); + } catch (e) { + _showError('Folder dialog unavailable (${e.runtimeType})'); + } + if (dir == null || !mounted) return; + _store.addFolder(dir); + _select(dir); + } + + Future _browse() async { + String? path; + try { + path = await (widget.pickVideo ?? _pickVideo)(); + } catch (e) { + _showError('File dialog unavailable (${e.runtimeType})'); + } + if (path != null && mounted) await _play(path); + } + + static Future _pickVideo() async { + final files = await FilePicker.pickFiles( + dialogTitle: 'Pick a video', + type: FileType.custom, + allowedExtensions: videoExtensions.toList(), + ); + return files.isEmpty ? null : files.first.path; + } + + void _removeFolder(String dir) { + _store.removeFolder(dir); + _scans.remove(dir); + if (_open == dir) _select(null); + } + + Future _play(String path) async { + if (!widget.online || _starting != null) return; + setState(() { + _starting = path; + _error = null; + }); + String? error; + try { + error = await widget.onPlay(path); + } catch (e) { + error = '$e'; + } + if (error == null) _store.addRecent(path); + if (!mounted) return; + setState(() { + _starting = null; + _error = error; + }); + } + + void _showError(String text) { + if (mounted) setState(() => _error = text); + } + + PlayState _stateOf(String path) => path == _starting + ? PlayState.starting + : path == widget.nowPlayingPath + ? PlayState.playing + : PlayState.idle; + + VoidCallback? _tapOf(String path) => widget.online ? () => _play(path) : null; + + @override + Widget build(BuildContext context) => LayoutBuilder( + builder: (context, box) { + final narrow = box.maxWidth < filesNarrowWidth; + return SignalBuilder(builder: (context) { + final folders = _store.folders.value; + final recent = _store.recent.value; + final open = _open != null && folders.contains(_open) ? _open : null; + final main = fadeThrough( + KeyedSubtree( + key: ValueKey(open ?? ''), + child: _main(context, narrow, folders, recent, open), + ), + ); + if (narrow) return main; + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox(width: 260, child: _sidebar(context, folders, open)), + Expanded(child: main), + ], + ); + }); + }, + ); + + Widget _browseButton(Widget Function(VoidCallback? onPressed) build) => widget.online + ? build(_browse) + : Tooltip(message: offlinePlayHint, child: build(null)); + + Widget _sidebar(BuildContext context, List folders, String? open) { + final scheme = Theme.of(context).colorScheme; + final shape = RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)); + return ListView( + padding: const EdgeInsets.fromLTRB(20, 20, 8, 20), + children: [ + const Text('Folders', style: TextStyle(fontWeight: FontWeight.w600)), + const SizedBox(height: 8), + ListTile( + dense: true, + selected: open == null, + shape: shape, + leading: const Icon(Icons.history, size: 18), + title: const Text('Recent'), + onTap: () => _select(null), + ), + for (final f in folders) + ListTile( + key: ValueKey('folder:$f'), + dense: true, + selected: open == f, + shape: shape, + leading: const Icon(Icons.folder_outlined, size: 18), + title: Text(baseName(f), maxLines: 1, overflow: TextOverflow.ellipsis), + subtitle: switch (_folderStatus(f)) { + final s? => Text(s, style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 11)), + null => null, + }, + trailing: IconButton( + tooltip: 'Remove folder', + icon: const Icon(Icons.close, size: 16), + onPressed: () => _removeFolder(f), + ), + onTap: () => _select(f), + ), + Align( + alignment: Alignment.centerLeft, + child: TextButton.icon( + onPressed: _addFolder, + icon: const Icon(Icons.add, size: 18), + label: const Text('Add folder'), + ), + ), + const Divider(), + _browseButton((onPressed) => OutlinedButton.icon( + onPressed: onPressed, + icon: const Icon(Icons.folder_open, size: 18), + label: const Text('Browse…'), + )), + const SizedBox(height: 6), + Text( + 'The system file picker, for anything outside your folders.', + style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 11), + ), + ], + ); + } + + /// "scanning…", "N videos", or null before the first scan. + String? _folderStatus(String dir) { + if (_running.containsKey(dir)) return 'scanning…'; + final n = _scans[dir]?.videos.length; + return n == null ? null : '$n ${n == 1 ? 'video' : 'videos'}'; + } + + Widget _main( + BuildContext context, + bool narrow, + List folders, + List recent, + String? open, + ) { + final scan = open == null ? null : _scans[open]; + final scanning = open != null && _running.containsKey(open); + final top = [ + _header(context, open), + if (narrow) _chips(folders, open), + if (widget.online && widget.relayed) const _RelayNotice(), + AnimatedSize( + duration: easeTime, + curve: ease, + alignment: Alignment.topLeft, + child: _error == null + ? const SizedBox(width: double.infinity) + : _ErrorBanner(_error!, onClose: () => setState(() => _error = null)), + ), + ]; + final slivers = [ + SliverList.list(children: top), + if (open == null) + ..._recent(context, recent) + else if (scan == null) + const SliverToBoxAdapter( + child: Padding( + padding: EdgeInsets.only(top: 60), + child: Center(child: CircularProgressIndicator()), + ), + ) + else + ..._folder(context, open, scan, scanning), + ]; + return CustomScrollView( + slivers: [ + SliverPadding( + padding: EdgeInsets.fromLTRB(narrow ? 20 : 12, 20, 20, 20), + sliver: SliverMainAxisGroup(slivers: slivers), + ), + ], + ); + } + + Widget _header(BuildContext context, String? open) { + final scheme = Theme.of(context).colorScheme; + return Padding( + padding: const EdgeInsets.only(bottom: 14), + child: Row( + children: [ + Icon(open == null ? Icons.history : Icons.folder, color: scheme.primary), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + open == null ? 'Recent' : baseName(open), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.headlineSmall, + ), + if (open != null) + Text( + open, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12), + ), + ], + ), + ), + ], + ), + ); + } + + Widget _chips(List folders, String? open) => Padding( + padding: const EdgeInsets.only(bottom: 14), + child: Wrap( + spacing: 8, + runSpacing: 8, + children: [ + ChoiceChip( + label: const Text('Recent'), + selected: open == null, + onSelected: (_) => _select(null), + ), + for (final f in folders) + // InputChip: selectable like the others, plus the remove x + // the sidebar has. + InputChip( + key: ValueKey('chip:$f'), + label: Text(baseName(f)), + selected: open == f, + onSelected: (_) => _select(f), + onDeleted: () => _removeFolder(f), + deleteButtonTooltipMessage: 'Remove folder', + ), + ActionChip( + avatar: const Icon(Icons.add, size: 16), + label: const Text('Folder'), + onPressed: _addFolder, + ), + _browseButton((onPressed) => ActionChip( + avatar: const Icon(Icons.folder_open, size: 16), + label: const Text('Browse…'), + onPressed: onPressed, + )), + ], + ), + ); + + List _recent(BuildContext context, List recent) { + if (recent.isEmpty) { + return [ + SliverToBoxAdapter( + child: FilesEmpty( + Icons.video_library_outlined, + 'Nothing played yet', + 'Add a folder with your videos, or Browse for a single file.', + action: FilledButton.icon( + onPressed: _addFolder, + icon: const Icon(Icons.create_new_folder_outlined), + label: const Text('Add folder'), + ), + ), + ), + ]; + } + return [ + SliverList.builder( + itemCount: recent.length, + itemBuilder: (context, i) { + final path = recent[i]; + return _RecentRow( + key: ValueKey(path), + path: path, + state: _stateOf(path), + thumbs: _thumbs, + onTap: _tapOf(path), + offline: !widget.online, + onRemove: () => _store.removeRecent(path), + ); + }, + ), + ]; + } + + List _folder(BuildContext context, String dir, FolderScan scan, bool scanning) { + final scheme = Theme.of(context).colorScheme; + final lib = _libraries[scan] ??= _Library(scan); + final series = lib.grouped.series.length; + String count(int n, String one, String many) => '$n ${n == 1 ? one : many}'; + final summary = [ + count(scan.videos.length, 'video', 'videos') + (series > 0 ? ' in $series series' : ''), + if (scan.hiddenCount > 0) + '${count(scan.hiddenCount, 'other file', 'other files')} hidden', + if (lib.subtitleFiles > 0) count(lib.subtitleFiles, 'subtitle file', 'subtitle files'), + if (scan.truncated) 'stopped after $maxScannedFiles files', + ].join(' · '); + final loose = lib.grouped.loose; + return [ + SliverToBoxAdapter( + child: Wrap( + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + Icon(Icons.filter_alt_outlined, size: 16, color: scheme.onSurfaceVariant), + const SizedBox(width: 6), + Text(summary, style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12)), + if (scan.hiddenCount > 0) ...[ + const SizedBox(width: 8), + TextButton( + onPressed: () => setState(() => _showHidden = !_showHidden), + child: Text(_showHidden ? 'Hide them' : 'Show some'), + ), + ], + TextButton.icon( + onPressed: scanning ? null : () => _scan(dir, rescan: true), + icon: scanning + ? const SizedBox.square( + dimension: 14, child: CircularProgressIndicator(strokeWidth: 2)) + : const Icon(Icons.refresh, size: 16), + label: Text(scanning ? 'Scanning…' : 'Rescan'), + ), + ], + ), + ), + SliverToBoxAdapter( + child: AnimatedSize( + duration: easeTime, + curve: ease, + alignment: Alignment.topLeft, + child: !_showHidden + ? const SizedBox(width: double.infinity) + : Column( + children: [ + for (final f in scan.hiddenExamples) + ListTile( + dense: true, + enabled: false, + leading: const Icon(Icons.insert_drive_file_outlined, size: 18), + title: Text(f, maxLines: 1, overflow: TextOverflow.ellipsis), + trailing: Text( + kindOf(f) == FileKind.video ? 'sample' : 'not a video', + style: const TextStyle(fontSize: 12), + ), + ), + if (scan.hiddenCount > scan.hiddenExamples.length) + ListTile( + dense: true, + enabled: false, + title: Text('and ${scan.hiddenCount - scan.hiddenExamples.length} more'), + ), + ], + ), + ), + ), + if (scan.videos.isEmpty) + SliverToBoxAdapter( + child: Directory(dir).existsSync() + ? const FilesEmpty( + Icons.search_off, + 'No videos here', + 'Only video files are listed (mkv, mp4, webm…).', + ) + : const FilesEmpty( + Icons.folder_off_outlined, + 'Folder not found', + 'It may have been moved, renamed or be on a drive that is not connected.', + ), + ), + // Series: files whose names carry an episode number. + SliverList.builder( + itemCount: lib.rows.length, + itemBuilder: (context, i) { + final (group, v) = lib.rows[i]; + if (v == null) { + return SeriesHeader(key: ValueKey('series:${group.key}'), group: group, thumbs: _thumbs); + } + return FileRow( + key: ValueKey(v.path), + path: v.path, + title: v.parsed.label.isEmpty ? v.parsed.series : v.parsed.label, + subtitle: v.name, + size: formatSize(v.size), + state: _stateOf(v.path), + thumbs: _thumbs, + onTap: _tapOf(v.path), + offline: !widget.online, + ); + }, + ), + // Everything else (movies, clips) as a thumbnail grid. + if (loose.isNotEmpty) ...[ + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.only(top: 22, bottom: 10), + child: Text( + series == 0 ? 'Videos' : 'Other videos', + style: Theme.of(context).textTheme.titleMedium, + ), + ), + ), + SliverLayoutBuilder(builder: (context, c) { + const gap = 12.0; + final w = c.crossAxisExtent; + final cols = (w / 240).floor().clamp(2, 8); + final cardW = (w - gap * (cols - 1)) / cols; + return SliverGrid.builder( + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: cols, + mainAxisSpacing: gap, + crossAxisSpacing: gap, + mainAxisExtent: cardW * 9 / 16 + videoCardTextHeight, + ), + itemCount: loose.length, + itemBuilder: (context, i) { + final v = loose[i]; + return VideoCard( + key: ValueKey(v.path), + video: v, + state: _stateOf(v.path), + thumbs: _thumbs, + onTap: _tapOf(v.path), + offline: !widget.online, + ); + }, + ); + }), + ], + ]; + } +} + +/// A Recent entry: `Series · S01E03` over the file name, or the cleaned +/// title over its folder; greyed when the file is gone. +class _RecentRow extends StatefulWidget { + final String path; + final PlayState state; + final ThumbCache thumbs; + final VoidCallback? onTap; + final bool offline; + final VoidCallback onRemove; + const _RecentRow({ + super.key, + required this.path, + required this.state, + required this.thumbs, + required this.onTap, + required this.offline, + required this.onRemove, + }); + + @override + State<_RecentRow> createState() => _RecentRowState(); +} + +class _RecentRowState extends State<_RecentRow> { + FileStat? _stat; + + @override + void initState() { + super.initState(); + File(widget.path).stat().then((s) { + if (mounted) setState(() => _stat = s); + }, onError: (Object _) {}); + } + + @override + Widget build(BuildContext context) { + final path = widget.path; + final p = parseReleaseName(baseName(path)); + final missing = _stat?.type == FileSystemEntityType.notFound; + final episode = p.episode != null || p.extra != null; + return FileRow( + path: path, + title: episode ? '${p.series} · ${p.label}' : cleanTitle(p), + subtitle: missing ? 'File not found' : (episode ? baseName(path) : parentName(path)), + size: _stat == null || missing ? null : formatSize(_stat!.size), + state: widget.state, + thumbs: widget.thumbs, + onTap: missing ? null : widget.onTap, + offline: widget.offline && !missing, + indent: false, + trailing: IconButton( + tooltip: 'Remove from recent', + icon: const Icon(Icons.close, size: 16), + onPressed: widget.onRemove, + ), + ); + } +} + +class _RelayNotice extends StatelessWidget { + const _RelayNotice(); + + @override + Widget build(BuildContext context) => Container( + padding: const EdgeInsets.all(10), + margin: const EdgeInsets.only(bottom: 16), + decoration: BoxDecoration( + color: Colors.lightBlueAccent.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(10), + ), + child: const Row( + children: [ + Icon(Icons.cell_tower, size: 18, color: Colors.lightBlueAccent), + SizedBox(width: 10), + Expanded( + child: Text( + 'Files stream from this computer through the server. ' + 'Keep the app open while it plays.', + ), + ), + ], + ), + ); +} + +class _ErrorBanner extends StatelessWidget { + final String text; + final VoidCallback onClose; + const _ErrorBanner(this.text, {required this.onClose}); + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + return Container( + padding: const EdgeInsets.fromLTRB(10, 4, 4, 4), + margin: const EdgeInsets.only(bottom: 16), + decoration: BoxDecoration( + color: scheme.errorContainer, + borderRadius: BorderRadius.circular(10), + ), + child: Row( + children: [ + Icon(Icons.error_outline, size: 18, color: scheme.onErrorContainer), + const SizedBox(width: 10), + Expanded(child: Text(text, style: TextStyle(color: scheme.onErrorContainer))), + IconButton( + tooltip: 'Dismiss', + icon: Icon(Icons.close, size: 16, color: scheme.onErrorContainer), + onPressed: onClose, + ), + ], + ), + ); + } +} diff --git a/apps/desktop/lib/src/files/ui/thumb.dart b/apps/desktop/lib/src/files/ui/thumb.dart new file mode 100644 index 0000000..9ce1454 --- /dev/null +++ b/apps/desktop/lib/src/files/ui/thumb.dart @@ -0,0 +1,213 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/material.dart'; + +import '../../shell/motion.dart'; +import '../thumbnails.dart'; + +// Lazy poster frames for the Files page. A tile asks for its frame only +// after it has stayed on screen for a moment, so a fast scroll requests +// nothing; tiles sharing a file share one request, and the request is +// cancelled once no tile on screen wants it any more. + +/// [thumbnailFor]'s shape; swapped in tests. +typedef ThumbnailLoader = Future Function(String path, {bool Function()? cancelled}); + +/// A tile waits this long on screen before asking for its frame. +const thumbRequestDelay = Duration(milliseconds: 120); + +/// Results of a [ThumbnailLoader] and who is waiting for them. +class ThumbCache { + final ThumbnailLoader load; + ThumbCache(this.load); + + final _done = {}; + final _pending = >{}; + final _waiting = {}; + + /// Shared by every page using [thumbnailFor]. + static final shared = ThumbCache(thumbnailFor); + + bool has(String path) => _done.containsKey(path); + Thumbnail? cached(String path) => _done[path]; + + /// Pair every call with one [release]. + Future request(String path) { + _waiting.update(path, (n) => n + 1, ifAbsent: () => 1); + if (_done.containsKey(path)) return Future.value(_done[path]); + return _pending[path] ??= _start(path); + } + + void release(String path) { + final n = (_waiting[path] ?? 0) - 1; + if (n > 0) { + _waiting[path] = n; + } else { + _waiting.remove(path); + } + } + + /// Drops results, so the next request asks the loader again (rescan). + void forget(Iterable paths) { + for (final p in paths) { + _done.remove(p); + } + } + + Future _start(String path) { + var gaveUp = false; + return load(path, cancelled: () => gaveUp = !_waiting.containsKey(path)) + .catchError((Object _) => null) + .then((t) { + if (t == null && gaveUp) { + // Cancelled: not a result. Someone may have come back meanwhile. + if (_waiting.containsKey(path)) return _start(path); + _pending.remove(path); + return null; + } + _pending.remove(path); + _done[path] = t; + return t; + }); + } +} + +/// Builds with the frame of [path] (null while loading or when there is +/// none); [loaded] is false until the request finished. +class ThumbBuilder extends StatefulWidget { + final ThumbCache cache; + final String path; + final Widget Function(BuildContext context, Thumbnail? thumb, bool loaded) builder; + const ThumbBuilder({super.key, required this.cache, required this.path, required this.builder}); + + @override + State createState() => _ThumbBuilderState(); +} + +class _ThumbBuilderState extends State { + Thumbnail? _thumb; + bool _loaded = false; + Timer? _delay; + // The request held right now: its path, and a token so a late answer to + // an older request is ignored. + String? _holding; + Object? _token; + + @override + void initState() { + super.initState(); + _begin(); + } + + @override + void didUpdateWidget(ThumbBuilder old) { + super.didUpdateWidget(old); + if (old.path != widget.path || old.cache != widget.cache) { + _end(old.cache); + _begin(); + } + } + + @override + void dispose() { + _end(widget.cache); + super.dispose(); + } + + void _begin() { + final cache = widget.cache, path = widget.path; + _loaded = cache.has(path); + _thumb = cache.cached(path); + if (_loaded) return; + _delay = Timer(thumbRequestDelay, () { + _delay = null; + _holding = path; + final token = _token = Object(); + cache.request(path).then((t) { + if (_token != token) return; + _holding = _token = null; + cache.release(path); + if (!mounted) return; + setState(() { + _thumb = t; + _loaded = true; + }); + }); + }); + } + + void _end(ThumbCache cache) { + _delay?.cancel(); + _delay = null; + final held = _holding; + _holding = _token = null; + if (held != null) cache.release(held); + } + + @override + Widget build(BuildContext context) => widget.builder(context, _thumb, _loaded); +} + +/// The frame, fading in over a placeholder. +class ThumbImage extends StatelessWidget { + final Thumbnail? thumb; + final double? width, height; + const ThumbImage(this.thumb, {super.key, this.width, this.height}); + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + final placeholder = Container( + width: width, + height: height, + color: scheme.surfaceContainerHighest, + alignment: Alignment.center, + child: Icon(Icons.movie_outlined, color: scheme.onSurfaceVariant, size: 20), + ); + final t = thumb; + if (t == null) return placeholder; + final dpr = MediaQuery.devicePixelRatioOf(context); + return Stack( + fit: width == null ? StackFit.expand : StackFit.loose, + children: [ + placeholder, + Image.file( + File(t.imagePath), + width: width, + height: height, + fit: BoxFit.cover, + cacheWidth: width == null ? null : (width! * dpr).round(), + gaplessPlayback: true, + errorBuilder: (_, _, _) => const SizedBox.shrink(), + frameBuilder: (context, child, frame, sync) => sync + ? child + : AnimatedOpacity( + opacity: frame == null ? 0 : 1, + duration: easeTime, + curve: ease, + child: child, + ), + ), + ], + ); + } +} + +/// `1:02:03` / `24:05`. +String formatDuration(Duration d) { + final h = d.inHours, m = d.inMinutes % 60, s = d.inSeconds % 60; + String two(int n) => n.toString().padLeft(2, '0'); + return h > 0 ? '$h:${two(m)}:${two(s)}' : '${two(m)}:${two(s)}'; +} + +/// `1.4 GB` / `350 MB` / `12 KB`. +String formatSize(int bytes) { + if (bytes >= 1 << 30) return '${(bytes / (1 << 30)).toStringAsFixed(1)} GB'; + if (bytes >= 1 << 20) return '${(bytes / (1 << 20)).toStringAsFixed(0)} MB'; + return '${(bytes / 1024).ceil()} KB'; +} + +/// The frame's duration, or null when unknown (no frame, 0 length). +String? durationText(Thumbnail? t) => + t == null || t.duration <= Duration.zero ? null : formatDuration(t.duration); diff --git a/apps/desktop/test/files_page_test.dart b/apps/desktop/test/files_page_test.dart new file mode 100644 index 0000000..10c768e --- /dev/null +++ b/apps/desktop/test/files_page_test.dart @@ -0,0 +1,331 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hive_ce/hive.dart'; +import 'package:watch_together/src/files/folder_store.dart'; +import 'package:watch_together/src/files/scanner.dart'; +import 'package:watch_together/src/files/thumbnails.dart'; +import 'package:watch_together/src/files/ui/file_tiles.dart'; +import 'package:watch_together/src/files/ui/files_page.dart'; + +// The Files page over a real (tiny) folder tree: the scan runs in-line +// instead of in an isolate and thumbnails come from a fake loader, so +// nothing here decodes video. + +late Directory tmp; +late String anime; +late FolderStore store; +late List thumbRequests; +late int scans; + +void touch(String path) => File(path) + ..createSync(recursive: true) + ..writeAsStringSync('x'); + +Future inlineScan(String dir) async { + scans++; + return scanFolderSync(dir); +} + +Future fakeThumb(String path, {bool Function()? cancelled}) async { + thumbRequests.add(path); + return const Thumbnail('/nonexistent/thumb.jpg', Duration(minutes: 24)); +} + +Future frames(WidgetTester tester, [int n = 10]) async { + for (var i = 0; i < n; i++) { + await tester.pump(const Duration(milliseconds: 100)); + } +} + +Future pumpPage( + WidgetTester tester, { + Size size = const Size(1280, 800), + bool online = true, + bool relayed = false, + Future Function(String path)? onPlay, + ThumbnailLoader? thumbnails, + Future Function()? pickFolder, +}) async { + tester.view.physicalSize = size; + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: FilesPage( + online: online, + relayed: relayed, + onPlay: onPlay ?? (_) async => null, + thumbnails: thumbnails ?? fakeThumb, + scan: inlineScan, + store: store, + pickFolder: pickFolder, + ), + ), + )); + await frames(tester); +} + +Future openFolder(WidgetTester tester, String name) async { + await tester.tap(find.text(name).first); + await frames(tester); +} + +void main() { + setUp(() async { + tmp = Directory.systemTemp.createTempSync('files_page_'); + anime = '${tmp.path}/anime'; + for (final f in [ + '[Grp] Show - 01.mkv', + '[Grp] Show - 02.mkv', + '[Grp] Show - 03.mkv', + '[Grp] Show - NCOP1.mkv', + '[Grp] Show - 01.srt', + 'Some Movie (2019).mkv', + 'notes.txt', + 'extras/cover.jpg', + ]) { + touch('$anime/$f'); + } + // An in-memory box: the store loads and writes through it, with no + // file I/O left pending in the widget tests' fake-async zone. + await Hive.openBox(filesBoxName, bytes: Uint8List(0)); + store = FolderStore(); + await store.ensureLoaded(); + thumbRequests = []; + scans = 0; + resetFilesPageSession(); + }); + + tearDown(() async { + await Hive.close(); + tmp.deleteSync(recursive: true); + }); + + testWidgets('sidebar names folders by their last segment, header follows', (tester) async { + store.addFolder('$anime/'); + await pumpPage(tester, pickFolder: () async => '${tmp.path}/movies'); + expect(find.text('Folders'), findsOneWidget); + expect(find.text('anime'), findsOneWidget); + expect(find.text('Recent'), findsNWidgets(2)); // sidebar + header + expect(find.text('Nothing played yet'), findsOneWidget); + expect(scans, 0, reason: 'folders are scanned when opened'); + + await openFolder(tester, 'anime'); + expect(find.text('anime'), findsNWidgets(2)); // sidebar + header + expect(find.text('$anime/'), findsOneWidget); // full path under it + expect(find.text('Recent'), findsOneWidget); + expect(find.text('5 videos'), findsOneWidget); // sidebar subtitle + expect(scans, 1); + + // Back and forth: the scan is kept. + await openFolder(tester, 'Recent'); + await openFolder(tester, 'anime'); + expect(scans, 1); + + // Add folder: picked, stored, opened. + Directory('${tmp.path}/movies').createSync(); + await tester.tap(find.text('Add folder')); + await frames(tester); + expect(store.folders.value, ['$anime/', '${tmp.path}/movies']); + expect(find.text('movies'), findsNWidgets(2)); + expect(find.text('No videos here'), findsOneWidget); + + await tester.tap(find.byTooltip('Remove folder').last); + await frames(tester); + expect(store.folders.value, ['$anime/']); + expect(find.text('Recent'), findsNWidgets(2)); + }); + + testWidgets('episodes group into series, the rest is a card grid', (tester) async { + store.addFolder(anime); + await pumpPage(tester); + await openFolder(tester, 'anime'); + expect(find.byType(SeriesHeader), findsOneWidget); + expect(find.text('Show'), findsOneWidget); + expect(find.text('3 episodes · 1 extra · Grp'), findsOneWidget); + for (final l in ['E01', 'E02', 'E03', 'NCOP1']) { + expect(find.text(l), findsOneWidget); + } + expect(find.text('[Grp] Show - 02.mkv'), findsOneWidget); + expect(find.byType(VideoCard), findsOneWidget); + expect(find.text('Some Movie (2019)'), findsOneWidget); + expect(find.text('Other videos'), findsOneWidget); + // Durations come from the thumbnail. + expect(find.text('24:00 · 1 KB'), findsNWidgets(4)); + expect(find.text('24:00'), findsOneWidget); // card badge + }); + + testWidgets('hidden files are counted and some can be shown', (tester) async { + store.addFolder(anime); + await pumpPage(tester); + await openFolder(tester, 'anime'); + expect(find.text('5 videos in 1 series · 2 other files hidden · 1 subtitle file'), + findsOneWidget); + expect(find.text('notes.txt'), findsNothing); + await tester.tap(find.text('Show some')); + await frames(tester); + expect(find.text('notes.txt'), findsOneWidget); + expect(find.text('cover.jpg'), findsOneWidget); + expect(find.text('not a video'), findsNWidgets(2)); + await tester.tap(find.text('Hide them')); + await frames(tester); + expect(find.text('notes.txt'), findsNothing); + }); + + testWidgets('rescan picks up new files', (tester) async { + store.addFolder(anime); + await pumpPage(tester); + await openFolder(tester, 'anime'); + touch('$anime/[Grp] Show - 04.mkv'); + expect(find.text('E04'), findsNothing); + await tester.tap(find.text('Rescan')); + await frames(tester); + expect(scans, 2); + expect(find.text('E04'), findsOneWidget); + expect(find.text('4 episodes · 1 extra · Grp'), findsOneWidget); + }); + + testWidgets('offline: browsing works, playing is disabled', (tester) async { + store.addFolder(anime); + final played = []; + await pumpPage(tester, online: false, onPlay: (p) async { + played.add(p); + return null; + }); + await openFolder(tester, 'anime'); + expect(find.text('E01'), findsOneWidget); + await tester.tap(find.text('E01')); + await tester.tap(find.text('Some Movie (2019)')); + await frames(tester); + expect(played, isEmpty); + expect(store.recent.value, isEmpty); + expect(find.byTooltip(offlinePlayHint), findsWidgets); + }); + + testWidgets('a play goes to onPlay and into Recent', (tester) async { + store.addFolder(anime); + final played = []; + String? reply; + await pumpPage(tester, relayed: true, onPlay: (p) async { + played.add(p); + return reply; + }); + expect(find.textContaining('Keep the app open while it plays'), findsOneWidget); + await openFolder(tester, 'anime'); + + await tester.tap(find.text('E02')); + await frames(tester); + expect(played, ['$anime/[Grp] Show - 02.mkv']); + expect(store.recent.value, ['$anime/[Grp] Show - 02.mkv']); + + reply = 'Nobody can play mkv here'; + await tester.tap(find.text('Some Movie (2019)')); + await frames(tester); + expect(played, hasLength(2)); + expect(find.text('Nobody can play mkv here'), findsOneWidget); + expect(store.recent.value, hasLength(1), reason: 'failed plays are not recent'); + + await openFolder(tester, 'Recent'); + expect(find.text('Show · E02'), findsOneWidget); + expect(find.text('[Grp] Show - 02.mkv'), findsOneWidget); + }); + + testWidgets('now playing is marked', (tester) async { + store.addFolder(anime); + tester.view.physicalSize = const Size(1280, 800); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: FilesPage( + online: true, + relayed: false, + onPlay: (_) async => null, + nowPlayingPath: '$anime/[Grp] Show - 03.mkv', + thumbnails: fakeThumb, + scan: inlineScan, + store: store, + ), + ), + )); + await frames(tester); + await openFolder(tester, 'anime'); + expect(find.text('now playing'), findsOneWidget); + expect(find.byIcon(Icons.graphic_eq), findsOneWidget); + }); + + testWidgets('narrow: folders become chips above the list', (tester) async { + store.addFolder('$anime/'); + await pumpPage(tester, size: const Size(600, 800)); + expect(find.text('Folders'), findsNothing); + expect(find.widgetWithText(InputChip, 'anime'), findsOneWidget); + expect(find.widgetWithText(ChoiceChip, 'Recent'), findsOneWidget); + await tester.tap(find.widgetWithText(InputChip, 'anime')); + await frames(tester); + expect(find.text('$anime/'), findsOneWidget); + expect(find.text('E01'), findsOneWidget); + // The chip's x removes the folder, like the sidebar's. + await tester.tap(find.byTooltip('Remove folder')); + await frames(tester); + expect(store.folders.value, isEmpty); + expect(find.widgetWithText(InputChip, 'anime'), findsNothing); + expect(tester.takeException(), isNull); + }); + + for (final size in const [Size(2560, 1080), Size(1100, 700), Size(900, 600), Size(420, 700)]) { + testWidgets('no overflow at ${size.width.toInt()}x${size.height.toInt()}', (tester) async { + store.addFolder(anime); + for (var i = 0; i < 7; i++) { + touch('$anime/A Movie With A Rather Long Title Indeed Part $i (2020).mkv'); + } + store.addRecent('$anime/[Grp] Show - 01.mkv'); + await pumpPage(tester, size: size, relayed: true); + expect(tester.takeException(), isNull); + await tester.tap(find.text('anime').first); + await frames(tester); + expect(find.text('E01'), findsOneWidget); + expect(tester.takeException(), isNull); + await tester.tap(find.text('Show some')); + await frames(tester); + await tester.drag(find.byType(CustomScrollView), const Offset(0, -2000)); + await frames(tester); + expect(tester.takeException(), isNull); + }); + } + + testWidgets('thumbnails load for visible tiles only, and cancel when scrolled away', + (tester) async { + final big = '${tmp.path}/big'; + for (var i = 1; i <= 120; i++) { + touch('$big/Long Show - ${i.toString().padLeft(3, '0')}.mkv'); + } + store.addFolder(big); + final gates = {}; + await pumpPage(tester, size: const Size(1100, 700), + thumbnails: (path, {cancelled}) { + thumbRequests.add(path); + gates[path] = cancelled!; + return Completer().future; // never finishes + }); + await openFolder(tester, 'big'); + expect(find.text('E01'), findsOneWidget); + final first = thumbRequests.toSet(); + expect(first, isNotEmpty); + expect(first.length, lessThan(30)); + expect(first, contains('$big/Long Show - 001.mkv')); + expect(gates['$big/Long Show - 001.mkv']!(), isFalse); + + // Fly past the middle: tiles that only flash by never ask. + await tester.drag(find.byType(CustomScrollView), const Offset(0, -6000)); + await tester.pump(); + await tester.drag(find.byType(CustomScrollView), const Offset(0, -6000)); + await frames(tester); + expect(gates['$big/Long Show - 001.mkv']!(), isTrue); + expect(thumbRequests, contains('$big/Long Show - 120.mkv')); + expect(thumbRequests.toSet().length, lessThan(60)); + }); +}