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:
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,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
@@ -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));
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in new issue
Block a user