Files page: folders, video-only listing grouped by series, lazy mpv thumbnails

Standalone FilesPage (plugged into the shell later): watched folders
(sidebar, chips with remove when narrow), a header saying where you are,
series sections and a 16:9 grid for loose videos, thumbnails requested
only for tiles that stay visible and cancelled when scrolled away,
hidden-files count with examples, rescan, Recent with missing-file
state, play errors in a banner, offline browsing with play disabled.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
foxyzandClaude Opus 5.5 committed 2026-10-09 00:13:28 +03:00
1 parent 2eb6840ec4
commit f790470935
4 files changed
+1600

No files matched your search

@@ -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!],
],
),
);
}
}
@@ -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 = <String, FolderScan>{};
final _running = <String, Future<FolderScan>>{};
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<String?> Function(String path) onPlay;
final String? nowPlayingPath;
/// Defaults to [thumbnailFor].
final ThumbnailLoader? thumbnails;
/// Defaults to [scanFolder].
final Future<FolderScan> Function(String dir)? scan;
/// Defaults to [FolderStore.instance].
final FolderStore? store;
/// Default: the system folder / file dialogs.
final Future<String?> Function()? pickFolder;
final Future<String?> 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<FilesPage> 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<FilesPage> {
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<FolderScan> _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<void> _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 <VideoFile>[]) 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<void> _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<void> _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<String?> _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<void> _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<String> 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<String> folders,
List<String> recent,
String? open,
) {
final scan = open == null ? null : _scans[open];
final scanning = open != null && _running.containsKey(open);
final top = <Widget>[
_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 = <Widget>[
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<String> 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<Widget> _recent(BuildContext context, List<String> 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<Widget> _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,
),
],
),
);
}
}
+213
View File
@@ -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<Thumbnail?> 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 = <String, Thumbnail?>{};
final _pending = <String, Future<Thumbnail?>>{};
final _waiting = <String, int>{};
/// 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<Thumbnail?> 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<String> paths) {
for (final p in paths) {
_done.remove(p);
}
}
Future<Thumbnail?> _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<ThumbBuilder> createState() => _ThumbBuilderState();
}
class _ThumbBuilderState extends State<ThumbBuilder> {
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);
+331
View File
@@ -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<String> thumbRequests;
late int scans;
void touch(String path) => File(path)
..createSync(recursive: true)
..writeAsStringSync('x');
Future<FolderScan> inlineScan(String dir) async {
scans++;
return scanFolderSync(dir);
}
Future<Thumbnail?> fakeThumb(String path, {bool Function()? cancelled}) async {
thumbRequests.add(path);
return const Thumbnail('/nonexistent/thumb.jpg', Duration(minutes: 24));
}
Future<void> frames(WidgetTester tester, [int n = 10]) async {
for (var i = 0; i < n; i++) {
await tester.pump(const Duration(milliseconds: 100));
}
}
Future<void> pumpPage(
WidgetTester tester, {
Size size = const Size(1280, 800),
bool online = true,
bool relayed = false,
Future<String?> Function(String path)? onPlay,
ThumbnailLoader? thumbnails,
Future<String?> 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<void> 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 = <String>[];
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>[];
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 = <String, bool Function()>{};
await pumpPage(tester, size: const Size(1100, 700),
thumbnails: (path, {cancelled}) {
thumbRequests.add(path);
gates[path] = cancelled!;
return Completer<Thumbnail?>().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));
});
}