Restructure step 4: local library and media identity
- L1 (core, offline): release names with kinds (episode, movie, extra: OP/ED/Opening/Ending/NC/PV/OVA…), folder hints (season and name from parentheticals, ranges), one show across folders, gaps and next with skipped numbers, junk ignored, stable keys. - L2 (app, device-owned cache): AniList match with synonyms and sequel walking, episode mapping across cours (AnimeFlow runs when signed in), art and episode titles, "Wrong show?" pins per show or folder, episode offsets; lazy, spaced requests, works offline. - Files tab as a library: poster cards, show pages with banner, gap prompt, extras, movies, Wrong show? and Episode numbers. - media.ref produced for local files, Jellyfin (provider ids) and links, carried in swaps and Up next; receivers name media from it. - Hosting relays each Jellyfin item through its own server's login. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
4d00b7455e
commit
bf9fc193b3
44 files changed
+4350
-891
No files matched your search
@@ -1,196 +0,0 @@
|
||||
// Release file names -> series / season / episode, anitomy-style but small:
|
||||
// enough to group a downloads folder into shows. Unknown shapes fall back to
|
||||
// the cleaned name with no episode, never to a wrong number.
|
||||
|
||||
/// What a release file name says.
|
||||
class ParsedName {
|
||||
/// Title without group, season marker, episode, year or release tags.
|
||||
final String series;
|
||||
final int? season;
|
||||
final int? episode;
|
||||
/// Release group: leading `[Group]` or scene-style trailing `-GRP`.
|
||||
final String? group;
|
||||
/// Release year, taken out of [series] (movies, `Title (2019)`).
|
||||
final int? year;
|
||||
/// Non-episode marker (`NCOP1`, `NCED`, `OVA 2`, `Special`): such files
|
||||
/// carry no [episode] so they never sort among the real ones.
|
||||
final String? extra;
|
||||
|
||||
const ParsedName(
|
||||
this.series, {
|
||||
this.season,
|
||||
this.episode,
|
||||
this.group,
|
||||
this.year,
|
||||
this.extra,
|
||||
});
|
||||
|
||||
/// `S01E03`, `E25`, the [extra] marker, `S02` for a season pack, else ''.
|
||||
String get label {
|
||||
final s = season == null ? '' : 'S${season.toString().padLeft(2, '0')}';
|
||||
if (episode != null) return '${s}E${episode.toString().padLeft(2, '0')}';
|
||||
return extra ?? s;
|
||||
}
|
||||
|
||||
/// Grouping key: case, punctuation and spacing do not split a series.
|
||||
String get seriesKey => normalizeSeries(series);
|
||||
|
||||
@override
|
||||
String toString() => 'ParsedName($series, s=$season, e=$episode, '
|
||||
'group=$group, year=$year, extra=$extra)';
|
||||
}
|
||||
|
||||
/// Lowercase letters and digits of [s], single spaces between words.
|
||||
String normalizeSeries(String s) => s
|
||||
.toLowerCase()
|
||||
.replaceAll(RegExp(r"['’`]"), '')
|
||||
.replaceAll(RegExp(r'[^\p{L}\p{N}]+', unicode: true), ' ')
|
||||
.trim();
|
||||
|
||||
// Release tags: the title ends where the first of these starts.
|
||||
final _junk = RegExp(
|
||||
r'\b(?:2160p|1440p|1080[pi]|720p|576p|480p|4K|UHD|[xh]\.?26[45]|HEVC|AVC|'
|
||||
r'AV1|VP9|WEB(?:-?DL|-?Rip)?|BD(?:Rip|Remux)?|Blu-?Ray|BRRip|DVD(?:Rip)?|'
|
||||
r'HDTV|HDR(?:10)?|DV|REMUX|AAC(?:\d\.\d)?|FLAC|Opus|DTS(?:-HD)?|E?-?AC-?3|'
|
||||
r'DDP?\d?|TrueHD|Atmos|10-?bit|8-?bit|Hi10P?|PROPER|REPACK|Dual[ -]Audio|'
|
||||
r'Multi(?:ple)?[ -]Subs?(?:titles?)?|AMZN|NF|CR|DSNP|HMAX|ATVP|'
|
||||
r'Batch|Complete)\b',
|
||||
caseSensitive: false,
|
||||
);
|
||||
final _ext = RegExp(r'\.(?=[a-z0-9]*[a-z])[a-z0-9]{2,4}$', caseSensitive: false);
|
||||
final _leadingGroup = RegExp(r'^\s*\[([^\]]+)\]');
|
||||
final _sceneGroup = RegExp(r'-([A-Za-z0-9]+)$');
|
||||
final _bracketYear = RegExp(r'[(\[]((?:19|20)\d{2})[)\]]');
|
||||
final _brackets = RegExp(r'\[[^\]]*\]|\([^)]*\)|\{[^}]*\}|【[^】]*】');
|
||||
// S01E02, S01.E02, S1 E2; a batch `S01E01-E02` / `S01E01E02` keeps the first.
|
||||
final _sxe = RegExp(r'\bS(\d{1,2})[ .]?E(\d{1,4})(?:v\d)?(?!\d)',
|
||||
caseSensitive: false);
|
||||
final _nxn = RegExp(r'\b(\d{1,2})x(\d{2,3})\b');
|
||||
// Anime: `Title - 25`, `- 03v2`, `- 01-02` (batch keeps the first).
|
||||
final _dashNumber = RegExp(r'\s[-–]\s(\d{1,4})(?:v\d)?(?=$|[\s\-~.])');
|
||||
final _episodeWord = RegExp(
|
||||
r'\b(?:Episode|Ep\.?|E)\s?(\d{1,4})(?:v\d)?\b',
|
||||
caseSensitive: false,
|
||||
);
|
||||
// Extras: unambiguous words anywhere, short ones only right after the dash.
|
||||
final _extraAnywhere = RegExp(
|
||||
r'(?:^|\s)(NCOP|NCED|OVA|OAD|ONA)\s?(\d{1,2})?(?:v\d)?(?=$|\s)',
|
||||
caseSensitive: false,
|
||||
);
|
||||
final _extraAfterDash = RegExp(
|
||||
r'\s[-–]\s(OP|ED|SP|Specials?|Extras?|Recap|Preview|PV|Menu)\s?(\d{1,2})?'
|
||||
r'(?:v\d)?(?=$|\s)',
|
||||
caseSensitive: false,
|
||||
);
|
||||
final _seasonInTitle = RegExp(
|
||||
r'\s(?:S(\d{1,2})|Season\s?(\d{1,2})|(\d{1,2})(?:st|nd|rd|th)\s+Season)\s*$',
|
||||
caseSensitive: false,
|
||||
);
|
||||
final _trailingYear = RegExp(r'\s((?:19|20)\d{2})$');
|
||||
|
||||
/// Parses a file name (with or without the extension, no directories).
|
||||
ParsedName parseReleaseName(String fileName) {
|
||||
var s = fileName.replaceFirst(_ext, '').trim();
|
||||
final original = s;
|
||||
String? group = _leadingGroup.firstMatch(s)?.group(1)?.trim();
|
||||
int? year;
|
||||
final by = _bracketYear.firstMatch(s);
|
||||
if (by != null) year = int.parse(by.group(1)!);
|
||||
s = s.replaceAll(_brackets, ' ');
|
||||
|
||||
// Dots and underscores as separators (scene style), unless the name
|
||||
// already has spaces and only the odd `Dr. Stone` dot.
|
||||
final dotted = !s.contains(' ') || RegExp(r'\w\.\w').allMatches(s).length > 2;
|
||||
if (dotted) {
|
||||
// `...H264-GRP`, but not `...WEB-DL`.
|
||||
final tags = _junk.allMatches(s);
|
||||
if (group == null && tags.isNotEmpty && tags.last.end < s.length) {
|
||||
final g = _sceneGroup.firstMatch(s);
|
||||
if (g != null) {
|
||||
group = g.group(1);
|
||||
s = s.substring(0, g.start);
|
||||
}
|
||||
}
|
||||
s = s.replaceAll('.', ' ');
|
||||
}
|
||||
s = s.replaceAll('_', ' ').replaceAll(RegExp(r'\s+'), ' ').trim();
|
||||
|
||||
// Release tags end the interesting part.
|
||||
final junk = _junk.firstMatch(s);
|
||||
if (junk != null && junk.start > 0) s = s.substring(0, junk.start).trim();
|
||||
|
||||
int? season, episode;
|
||||
String? extra;
|
||||
var cut = s.length;
|
||||
final extraMatch =
|
||||
_extraAfterDash.firstMatch(s) ?? _extraAnywhere.firstMatch(s);
|
||||
final sxe = _sxe.firstMatch(s);
|
||||
if (extraMatch != null) {
|
||||
final word = extraMatch.group(1)!;
|
||||
final upper = word.length <= 4 ? word.toUpperCase() : _title(word);
|
||||
// `OVA - 02`: the number may follow the dash instead.
|
||||
final n = extraMatch.group(2) ??
|
||||
_dashNumber.firstMatch(s.substring(extraMatch.end))?.group(1);
|
||||
extra = n == null
|
||||
? upper
|
||||
: (upper.startsWith('NC') || upper.length <= 2)
|
||||
? '$upper${int.parse(n)}'
|
||||
: '$upper ${int.parse(n)}';
|
||||
cut = extraMatch.start;
|
||||
if (sxe != null && sxe.start < cut) {
|
||||
season = int.parse(sxe.group(1)!);
|
||||
cut = sxe.start;
|
||||
}
|
||||
} else if (sxe != null) {
|
||||
season = int.parse(sxe.group(1)!);
|
||||
episode = int.parse(sxe.group(2)!);
|
||||
cut = sxe.start;
|
||||
} else {
|
||||
final nxn = _nxn.firstMatch(s);
|
||||
if (nxn != null) {
|
||||
season = int.parse(nxn.group(1)!);
|
||||
episode = int.parse(nxn.group(2)!);
|
||||
cut = nxn.start;
|
||||
} else {
|
||||
final m = _dashNumber.firstMatch(s) ?? _episodeWord.firstMatch(s);
|
||||
if (m != null) {
|
||||
episode = int.parse(m.group(1)!);
|
||||
cut = m.start;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var series = s.substring(0, cut);
|
||||
// Season pack or season marker kept in the title: `Title S2`,
|
||||
// `Title Season 2`, `Title 2nd Season`, `Title.S01.1080p`.
|
||||
final sIn = _seasonInTitle.firstMatch(series);
|
||||
if (sIn != null) {
|
||||
season ??= int.parse(sIn.group(1) ?? sIn.group(2) ?? sIn.group(3)!);
|
||||
series = series.substring(0, sIn.start);
|
||||
}
|
||||
series = series.replaceAll(RegExp(r'[\s\-–:,~]+$'), '').trim();
|
||||
// A trailing year is the release year, unless it is the whole title or
|
||||
// too far ahead to be one (`Blade Runner 2049`).
|
||||
final ty = _trailingYear.firstMatch(series);
|
||||
if (ty != null) {
|
||||
final y = int.parse(ty.group(1)!);
|
||||
if (y <= DateTime.now().year + 1) {
|
||||
year ??= y;
|
||||
series = series.substring(0, ty.start).trim();
|
||||
}
|
||||
}
|
||||
series = series.replaceAll(RegExp(r'[\s\-–:,~]+$'), '').trim();
|
||||
if (series.isEmpty) {
|
||||
series = original.replaceAll(RegExp(r'[._]+'), ' ').trim();
|
||||
}
|
||||
return ParsedName(
|
||||
series,
|
||||
season: season,
|
||||
episode: episode,
|
||||
group: group,
|
||||
year: year,
|
||||
extra: extra,
|
||||
);
|
||||
}
|
||||
|
||||
String _title(String w) => w[0].toUpperCase() + w.substring(1).toLowerCase();
|
||||
@@ -1,16 +1,13 @@
|
||||
import 'dart:io';
|
||||
import 'dart:isolate';
|
||||
|
||||
import 'name_parser.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
// Folder scan for the Files source: videos with their parsed names,
|
||||
// sidecar subtitles, everything else counted and hidden. The walk is
|
||||
// synchronous I/O, so it runs in its own isolate.
|
||||
export 'package:sync_relay_core/core.dart' show FileKind, LocalFile, kindOf, subtitleExtensions, videoExtensions;
|
||||
|
||||
const videoExtensions = {
|
||||
'mkv', 'mp4', 'webm', 'avi', 'mov', 'm4v', 'ts', 'm2ts', 'wmv', 'flv', //
|
||||
};
|
||||
const subtitleExtensions = {'ass', 'ssa', 'srt', 'vtt', 'sup'};
|
||||
// Folder scan for the Files source: the videos (grouped into shows by
|
||||
// core's `groupLocal`, L1), sidecar subtitles, everything else counted
|
||||
// and hidden. The walk is synchronous I/O, so it runs in its own isolate.
|
||||
|
||||
/// Files looked at per scan; past this the result is [FolderScan.truncated].
|
||||
const maxScannedFiles = 20000;
|
||||
@@ -18,27 +15,8 @@ const maxScannedFiles = 20000;
|
||||
/// Samples and trailers under this size are hidden next to the real thing.
|
||||
const _sampleMaxBytes = 300 << 20;
|
||||
|
||||
enum FileKind { video, subtitle, other }
|
||||
|
||||
FileKind kindOf(String path) {
|
||||
final ext = _extension(_baseName(path));
|
||||
if (videoExtensions.contains(ext)) return FileKind.video;
|
||||
if (subtitleExtensions.contains(ext)) return FileKind.subtitle;
|
||||
return FileKind.other;
|
||||
}
|
||||
|
||||
class VideoFile {
|
||||
final String path;
|
||||
final int size;
|
||||
final ParsedName parsed;
|
||||
final DateTime modified;
|
||||
const VideoFile(this.path, this.size, this.parsed, this.modified);
|
||||
|
||||
String get name => _baseName(path);
|
||||
}
|
||||
|
||||
class FolderScan {
|
||||
final List<VideoFile> videos;
|
||||
final List<LocalFile> videos;
|
||||
/// Non-video files and samples (subtitles and dot-entries not counted).
|
||||
final int hiddenCount;
|
||||
/// Up to 6 of the hidden file names, for "and N other files" hints.
|
||||
@@ -75,7 +53,7 @@ Future<FolderScan> scanFolder(String dir, {int maxFiles = maxScannedFiles}) =>
|
||||
|
||||
/// The walk behind [scanFolder]; blocks, so call it from an isolate.
|
||||
FolderScan scanFolderSync(String dir, {int maxFiles = maxScannedFiles}) {
|
||||
final videos = <VideoFile>[];
|
||||
final videos = <LocalFile>[];
|
||||
final subs = <String>[];
|
||||
final examples = <String>[];
|
||||
var hidden = 0, seen = 0;
|
||||
@@ -123,8 +101,7 @@ FolderScan scanFolderSync(String dir, {int maxFiles = maxScannedFiles}) {
|
||||
if (examples.length < 6) examples.add(name);
|
||||
continue;
|
||||
}
|
||||
videos.add(VideoFile(e.path, size, parseReleaseName(name),
|
||||
stat?.modified ?? DateTime.fromMillisecondsSinceEpoch(0)));
|
||||
videos.add(LocalFile(e.path, size, stat?.modified ?? DateTime.fromMillisecondsSinceEpoch(0)));
|
||||
}
|
||||
}
|
||||
return FolderScan(
|
||||
@@ -138,7 +115,7 @@ FolderScan scanFolderSync(String dir, {int maxFiles = maxScannedFiles}) {
|
||||
|
||||
/// Subtitles in the same folder whose name is the video's base name,
|
||||
/// optionally followed by `.lang` / `.forced` style suffixes.
|
||||
Map<String, List<String>> _sidecars(List<VideoFile> videos, List<String> subs) {
|
||||
Map<String, List<String>> _sidecars(List<LocalFile> videos, List<String> subs) {
|
||||
final byStem = <String, List<String>>{};
|
||||
for (final s in subs) {
|
||||
final stem = _stripExtension(s);
|
||||
@@ -163,93 +140,11 @@ Map<String, List<String>> _sidecars(List<VideoFile> videos, List<String> subs) {
|
||||
};
|
||||
}
|
||||
|
||||
/// One show: its episodes in season/episode order plus its extras
|
||||
/// (NCOP, OVA...) in name order.
|
||||
class SeriesGroup {
|
||||
/// Display title: the most common spelling among the files.
|
||||
final String title;
|
||||
final String key;
|
||||
final List<VideoFile> episodes;
|
||||
final List<VideoFile> extras;
|
||||
const SeriesGroup(this.title, this.key, this.episodes, this.extras);
|
||||
|
||||
/// Distinct seasons, ascending; files without one count as season 1.
|
||||
List<int> get seasons =>
|
||||
({for (final e in episodes) e.parsed.season ?? 1}.toList()..sort());
|
||||
}
|
||||
|
||||
/// [groupBySeries] result: shows, and videos that are not episodes.
|
||||
class GroupedVideos {
|
||||
final List<SeriesGroup> series;
|
||||
final List<VideoFile> loose;
|
||||
const GroupedVideos(this.series, this.loose);
|
||||
}
|
||||
|
||||
/// Groups videos with an episode number (or an extra marker) by
|
||||
/// [ParsedName.seriesKey]; the rest (movies, clips) stay loose, by name.
|
||||
/// An extra whose series has no episodes in the list is loose too.
|
||||
GroupedVideos groupBySeries(Iterable<VideoFile> videos) {
|
||||
final episodes = <String, List<VideoFile>>{};
|
||||
final extras = <String, List<VideoFile>>{};
|
||||
final loose = <VideoFile>[];
|
||||
for (final v in videos) {
|
||||
final p = v.parsed;
|
||||
final key = p.seriesKey;
|
||||
if (p.episode != null && key.isNotEmpty) {
|
||||
episodes.putIfAbsent(key, () => []).add(v);
|
||||
} else if (p.extra != null && key.isNotEmpty) {
|
||||
extras.putIfAbsent(key, () => []).add(v);
|
||||
} else {
|
||||
loose.add(v);
|
||||
}
|
||||
}
|
||||
for (final e in extras.entries) {
|
||||
if (!episodes.containsKey(e.key)) loose.addAll(e.value);
|
||||
}
|
||||
int byName(VideoFile a, VideoFile b) =>
|
||||
a.name.toLowerCase().compareTo(b.name.toLowerCase());
|
||||
final groups = [
|
||||
for (final e in episodes.entries)
|
||||
SeriesGroup(
|
||||
_commonTitle(e.value),
|
||||
e.key,
|
||||
e.value..sort(_byEpisode),
|
||||
(extras[e.key] ?? [])..sort(byName),
|
||||
),
|
||||
]..sort((a, b) => a.title.toLowerCase().compareTo(b.title.toLowerCase()));
|
||||
return GroupedVideos(groups, loose..sort(byName));
|
||||
}
|
||||
|
||||
int _byEpisode(VideoFile a, VideoFile b) {
|
||||
final pa = a.parsed, pb = b.parsed;
|
||||
final s = (pa.season ?? 1).compareTo(pb.season ?? 1);
|
||||
if (s != 0) return s;
|
||||
final e = pa.episode!.compareTo(pb.episode!);
|
||||
if (e != 0) return e;
|
||||
// Same episode twice (v2, another group): newest release first.
|
||||
return b.name.compareTo(a.name);
|
||||
}
|
||||
|
||||
String _commonTitle(List<VideoFile> files) {
|
||||
final counts = <String, int>{};
|
||||
for (final f in files) {
|
||||
counts.update(f.parsed.series, (n) => n + 1, ifAbsent: () => 1);
|
||||
}
|
||||
return counts.entries
|
||||
.reduce((a, b) => b.value > a.value ? b : a)
|
||||
.key;
|
||||
}
|
||||
|
||||
String _baseName(String path) {
|
||||
final i = path.lastIndexOf(RegExp(r'[/\\]'));
|
||||
return i < 0 ? path : path.substring(i + 1);
|
||||
}
|
||||
|
||||
String _extension(String name) {
|
||||
final dot = name.lastIndexOf('.');
|
||||
return dot <= 0 ? '' : name.substring(dot + 1).toLowerCase();
|
||||
}
|
||||
|
||||
String _stripExtension(String path) {
|
||||
final dot = path.lastIndexOf('.');
|
||||
final sep = path.lastIndexOf(RegExp(r'[/\\]'));
|
||||
|
||||
@@ -2,13 +2,13 @@ import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:sync_relay_core/core.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.
|
||||
// The Files page's rows and cards: an episode row (also used for Recent),
|
||||
// a loose-video card, and the empty states.
|
||||
|
||||
/// What a play control is doing right now.
|
||||
enum PlayState { idle, starting, playing }
|
||||
@@ -25,76 +25,8 @@ 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})';
|
||||
|
||||
class SeriesHeader extends StatelessWidget {
|
||||
final SeriesGroup group;
|
||||
final ThumbCache thumbs;
|
||||
/// Its episodes are listed below; [onTap] opens or closes them.
|
||||
final bool expanded;
|
||||
final VoidCallback? onTap;
|
||||
const SeriesHeader(
|
||||
{super.key, required this.group, required this.thumbs, this.expanded = true, this.onTap});
|
||||
|
||||
@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: 14),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 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)),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (onTap != null)
|
||||
AnimatedRotation(
|
||||
turns: expanded ? 0.5 : 0,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
child: Icon(Icons.expand_more, color: scheme.onSurfaceVariant),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// One file as a row: an episode under its series header, or a Recent
|
||||
/// entry ([title] given, [indent] off).
|
||||
/// One file as a row: an episode on a show's page, or a Recent entry
|
||||
/// ([indent] off).
|
||||
class FileRow extends StatelessWidget {
|
||||
final String path;
|
||||
final String title;
|
||||
@@ -207,7 +139,7 @@ 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 LocalEpisode video;
|
||||
final PlayState state;
|
||||
final ThumbCache thumbs;
|
||||
final VoidCallback? onTap;
|
||||
@@ -281,7 +213,7 @@ class VideoCard extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(formatSize(video.size),
|
||||
Text(formatSize(video.file.size),
|
||||
style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12)),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -3,51 +3,57 @@ import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:signals/signals_flutter.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
import '../../library/show_matcher.dart';
|
||||
import '../../shell/motion.dart';
|
||||
import '../../platform/ports/folder_access.dart';
|
||||
import '../../app/app_graph.dart';
|
||||
import '../../widgets/app_menu.dart';
|
||||
import '../folder_store.dart';
|
||||
import '../name_parser.dart';
|
||||
import '../scanner.dart';
|
||||
import 'file_tiles.dart';
|
||||
import 'library_view.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. Series start collapsed; the ones
|
||||
// opened stay open for the app session. Playing goes through [onPlay].
|
||||
// The Files source as a library: the watched folders on the left (chips
|
||||
// on top when narrow): the Library (every folder together), Recent, then
|
||||
// each folder. A library is shows and movies as poster cards, grouped by
|
||||
// the library's L1 and named by its L2 match, plus the videos that are
|
||||
// neither; a show opens its own page. Folders are scanned when first
|
||||
// needed and on Rescan; scans, the open place and the open show 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;
|
||||
|
||||
/// The place that shows every watched folder together.
|
||||
const libraryPlace = '\u0000library';
|
||||
|
||||
/// Scans kept for the app session, by folder.
|
||||
final _scans = <String, FolderScan>{};
|
||||
final _running = <String, Future<FolderScan>>{};
|
||||
String? _lastOpen;
|
||||
/// Series opened this session, by `folder\nseries key`.
|
||||
final _expanded = <String>{};
|
||||
String? _lastOpen = libraryPlace;
|
||||
/// The show open in each place (by show key) for the app session.
|
||||
final _openShows = <String, String>{};
|
||||
final _showRequests = ValueNotifier(0);
|
||||
|
||||
|
||||
/// Opens [dir] (null: Recent) in the Files page: now if one is showing,
|
||||
/// else when it next opens.
|
||||
/// Opens [dir] (null: Recent; [libraryPlace]: the library) in the Files
|
||||
/// page: now if one is showing, else when it next opens.
|
||||
void showFilesFolder(String? dir) {
|
||||
_lastOpen = dir;
|
||||
_showRequests.value++;
|
||||
}
|
||||
|
||||
/// Forgets the session's scans, open series and the open folder.
|
||||
/// Forgets the session's scans, open shows and the open place.
|
||||
@visibleForTesting
|
||||
void resetFilesPageSession() {
|
||||
_scans.clear();
|
||||
_running.clear();
|
||||
_expanded.clear();
|
||||
_lastOpen = null;
|
||||
_openShows.clear();
|
||||
_lastOpen = libraryPlace;
|
||||
}
|
||||
|
||||
class FilesPage extends StatefulWidget {
|
||||
@@ -66,6 +72,8 @@ class FilesPage extends StatefulWidget {
|
||||
final Future<FolderScan> Function(String dir)? scan;
|
||||
/// Defaults to [FolderStore.instance].
|
||||
final FolderStore? store;
|
||||
/// Defaults to the app's (`AppGraph.library`).
|
||||
final ShowMatcher? matcher;
|
||||
/// Default: the system folder / file dialogs.
|
||||
final Future<String?> Function()? pickFolder;
|
||||
final Future<String?> Function()? pickVideo;
|
||||
@@ -80,6 +88,7 @@ class FilesPage extends StatefulWidget {
|
||||
this.thumbnails,
|
||||
this.scan,
|
||||
this.store,
|
||||
this.matcher,
|
||||
this.pickFolder,
|
||||
this.pickVideo,
|
||||
});
|
||||
@@ -88,52 +97,56 @@ class FilesPage extends StatefulWidget {
|
||||
State<FilesPage> createState() => _FilesPageState();
|
||||
}
|
||||
|
||||
/// A scan grouped for display, made once per scan.
|
||||
/// A place's scans grouped (L1), made once per set of scans.
|
||||
class _Library {
|
||||
final GroupedVideos grouped;
|
||||
final int subtitleFiles;
|
||||
final List<FolderScan> scans;
|
||||
final LocalLibrary lib;
|
||||
final Map<String, LocalEpisode> byPath;
|
||||
|
||||
_Library(FolderScan scan)
|
||||
: grouped = groupBySeries(scan.videos),
|
||||
subtitleFiles = {for (final l in scan.subtitles.values) ...l}.length;
|
||||
_Library(this.scans, List<String> roots)
|
||||
: lib = groupLocal([for (final s in scans) ...s.videos], roots: roots),
|
||||
byPath = {} {
|
||||
for (final e in lib.files) {
|
||||
byPath[e.path] = e;
|
||||
}
|
||||
}
|
||||
|
||||
/// Series rows in order: a header (video null), then, for the series
|
||||
/// [open] says, its episodes and extras.
|
||||
List<(SeriesGroup, VideoFile?)> rows(bool Function(SeriesGroup g) open) => [
|
||||
for (final g in grouped.series) ...[
|
||||
(g, null),
|
||||
if (open(g))
|
||||
for (final v in [...g.episodes, ...g.extras]) (g, v),
|
||||
],
|
||||
];
|
||||
int get videos => scans.fold(0, (n, s) => n + s.videos.length);
|
||||
int get hidden => scans.fold(0, (n, s) => n + s.hiddenCount);
|
||||
int get subtitleFiles => {
|
||||
for (final s in scans)
|
||||
for (final l in s.subtitles.values) ...l
|
||||
}.length;
|
||||
bool get truncated => scans.any((s) => s.truncated);
|
||||
}
|
||||
|
||||
final _libraries = Expando<_Library>();
|
||||
|
||||
class _FilesPageState extends State<FilesPage> {
|
||||
FolderAccess get _folders => AppScope.of(context).ports.folders;
|
||||
ShowMatcher get _matcher => widget.matcher ?? AppScope.of(context).library;
|
||||
|
||||
late final ThumbCache _thumbs =
|
||||
widget.thumbnails == null ? ThumbCache.shared : ThumbCache(widget.thumbnails!);
|
||||
FolderStore get _store => widget.store ?? FolderStore.instance;
|
||||
|
||||
/// Null: Recent.
|
||||
/// Null: Recent; [libraryPlace]: every folder.
|
||||
String? _open = _lastOpen;
|
||||
bool _showHidden = false;
|
||||
String? _starting;
|
||||
String? _error;
|
||||
final _libs = <String, _Library>{};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_showRequests.addListener(_shown);
|
||||
unawaited(_matcher.ensureLoaded());
|
||||
_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);
|
||||
if (open != null && open != libraryPlace && !_store.folders.value.contains(open)) {
|
||||
_select(libraryPlace);
|
||||
} else {
|
||||
_scanFor(open);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -148,19 +161,32 @@ class _FilesPageState extends State<FilesPage> {
|
||||
|
||||
Future<FolderScan> _runScan(String dir) => (widget.scan ?? _folders.scan)(dir);
|
||||
|
||||
void _select(String? dir) {
|
||||
/// The folders a place needs scanned.
|
||||
List<String> _foldersOf(String? place) => place == null
|
||||
? const []
|
||||
: place == libraryPlace
|
||||
? _store.folders.peek()
|
||||
: [place];
|
||||
|
||||
void _scanFor(String? place) {
|
||||
for (final f in _foldersOf(place)) {
|
||||
if (!_scans.containsKey(f) && !_running.containsKey(f)) _scan(f);
|
||||
}
|
||||
}
|
||||
|
||||
void _select(String? place) {
|
||||
setState(() {
|
||||
_open = _lastOpen = dir;
|
||||
_open = _lastOpen = place;
|
||||
_showHidden = false;
|
||||
});
|
||||
if (dir != null && !_scans.containsKey(dir)) _scan(dir);
|
||||
_scanFor(place);
|
||||
}
|
||||
|
||||
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 (rescan) _thumbs.forget([for (final v in _scans[dir]?.videos ?? const <LocalFile>[]) v.path]);
|
||||
}
|
||||
if (mounted) setState(() {});
|
||||
FolderScan result;
|
||||
@@ -176,6 +202,26 @@ class _FilesPageState extends State<FilesPage> {
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
|
||||
/// The grouped library of [place], once its folders are scanned (the
|
||||
/// library: those scanned so far).
|
||||
_Library? _libraryOf(String place) {
|
||||
final folders = _foldersOf(place);
|
||||
final scans = [for (final f in folders) ?_scans[f]];
|
||||
if (place != libraryPlace && scans.isEmpty) return null;
|
||||
final known = _libs[place];
|
||||
if (known != null && known.scans.length == scans.length && _same(known.scans, scans)) return known;
|
||||
final lib = _libs[place] = _Library(scans, folders);
|
||||
_matcher.remember(lib.lib);
|
||||
return lib;
|
||||
}
|
||||
|
||||
static bool _same(List<FolderScan> a, List<FolderScan> b) {
|
||||
for (var i = 0; i < a.length; i++) {
|
||||
if (!identical(a[i], b[i])) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<void> _addFolder() async {
|
||||
String? dir;
|
||||
try {
|
||||
@@ -207,7 +253,8 @@ class _FilesPageState extends State<FilesPage> {
|
||||
void _removeFolder(String dir) {
|
||||
_store.removeFolder(dir);
|
||||
_scans.remove(dir);
|
||||
if (_open == dir) _select(null);
|
||||
if (_open == dir) _select(libraryPlace);
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
Future<void> _play(String path) async {
|
||||
@@ -271,6 +318,22 @@ class _FilesPageState extends State<FilesPage> {
|
||||
child: Builder(
|
||||
builder: (context) => GestureDetector(onSecondaryTap: () => _menu(context, path), child: child)));
|
||||
|
||||
late final _actions = FilesActions(
|
||||
play: (p) => unawaited(_play(p)),
|
||||
stateOf: _stateOf,
|
||||
menuable: _menuable,
|
||||
thumbs: _thumbs,
|
||||
);
|
||||
|
||||
/// The last episode of [show] played here (Recent), by key.
|
||||
String? _lastPlayed(_Library lib, LocalShow show) {
|
||||
for (final p in _store.recent.value) {
|
||||
final e = lib.byPath[p];
|
||||
if (e != null && e.showKey == show.key && e.kind == LocalKind.episode) return e.key;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => LayoutBuilder(
|
||||
builder: (context, box) {
|
||||
@@ -278,11 +341,13 @@ class _FilesPageState extends State<FilesPage> {
|
||||
return SignalBuilder(builder: (context) {
|
||||
final folders = _store.folders.value;
|
||||
final recent = _store.recent.value;
|
||||
final open = _open != null && folders.contains(_open) ? _open : null;
|
||||
_matcher.revision.value;
|
||||
final open = _open == null || _open == libraryPlace || folders.contains(_open) ? _open : libraryPlace;
|
||||
final showKey = open == null ? null : _openShows[open];
|
||||
final main = fadeThrough(
|
||||
KeyedSubtree(
|
||||
key: ValueKey(open ?? ''),
|
||||
child: _main(context, narrow, folders, recent, open),
|
||||
key: ValueKey('${open ?? ''}\n${showKey ?? ''}'),
|
||||
child: _main(context, narrow, folders, recent, open, showKey),
|
||||
),
|
||||
);
|
||||
if (narrow) return main;
|
||||
@@ -302,12 +367,23 @@ class _FilesPageState extends State<FilesPage> {
|
||||
Widget _sidebar(BuildContext context, List<String> folders, String? open) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final shape = RoundedRectangleBorder(borderRadius: BorderRadius.circular(10));
|
||||
Widget? status(String? s) =>
|
||||
s == null ? null : Text(s, style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 11));
|
||||
return ListView(
|
||||
padding: const EdgeInsets.fromLTRB(20, 20, 8, 20),
|
||||
children: [
|
||||
const Text('Folders', style: TextStyle(fontWeight: FontWeight.w600)),
|
||||
const SizedBox(height: 8),
|
||||
ListTile(
|
||||
key: const ValueKey('place:library'),
|
||||
dense: true,
|
||||
selected: open == libraryPlace,
|
||||
shape: shape,
|
||||
leading: const Icon(Icons.video_library_outlined, size: 18),
|
||||
title: const Text('Library'),
|
||||
subtitle: status(_libraryStatus(folders)),
|
||||
onTap: () => _select(libraryPlace),
|
||||
),
|
||||
ListTile(
|
||||
key: const ValueKey('place:recent'),
|
||||
dense: true,
|
||||
selected: open == null,
|
||||
shape: shape,
|
||||
@@ -315,6 +391,9 @@ class _FilesPageState extends State<FilesPage> {
|
||||
title: const Text('Recent'),
|
||||
onTap: () => _select(null),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const Text('Folders', style: TextStyle(fontWeight: FontWeight.w600)),
|
||||
const SizedBox(height: 8),
|
||||
for (final f in folders)
|
||||
ListTile(
|
||||
key: ValueKey('folder:$f'),
|
||||
@@ -323,10 +402,7 @@ class _FilesPageState extends State<FilesPage> {
|
||||
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,
|
||||
},
|
||||
subtitle: status(_folderStatus(f)),
|
||||
trailing: IconButton(
|
||||
tooltip: 'Remove folder',
|
||||
icon: const Icon(Icons.close, size: 16),
|
||||
@@ -364,33 +440,77 @@ class _FilesPageState extends State<FilesPage> {
|
||||
return n == null ? null : '$n ${n == 1 ? 'video' : 'videos'}';
|
||||
}
|
||||
|
||||
String? _libraryStatus(List<String> folders) {
|
||||
if (folders.any(_running.containsKey)) return 'scanning…';
|
||||
if (folders.isEmpty || folders.any((f) => !_scans.containsKey(f))) return null;
|
||||
final n = _libraryOf(libraryPlace)?.lib.shows.length ?? 0;
|
||||
return '$n ${n == 1 ? 'show' : 'shows'}';
|
||||
}
|
||||
|
||||
List<Widget> _notices() => [
|
||||
if (!widget.alone && 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)),
|
||||
),
|
||||
];
|
||||
|
||||
Widget _main(
|
||||
BuildContext context,
|
||||
bool narrow,
|
||||
List<String> folders,
|
||||
List<String> recent,
|
||||
String? open,
|
||||
String? showKey,
|
||||
) {
|
||||
final scan = open == null ? null : _scans[open];
|
||||
final scanning = open != null && _running.containsKey(open);
|
||||
final lib = open == null ? null : _libraryOf(open);
|
||||
final show = showKey == null ? null : lib?.lib.show(showKey);
|
||||
if (open != null && lib != null && show != null) {
|
||||
return ShowPage(
|
||||
key: ValueKey('show:$open:${show.key}'),
|
||||
show: show,
|
||||
matcher: _matcher,
|
||||
actions: _actions,
|
||||
lastPlayed: _lastPlayed(lib, show),
|
||||
backLabel: open == libraryPlace ? 'Library' : baseName(open),
|
||||
onBack: () => setState(() => _openShows.remove(open)),
|
||||
top: [if (narrow) _chips(folders, open), ..._notices()],
|
||||
);
|
||||
}
|
||||
final scanning = _foldersOf(open).any(_running.containsKey);
|
||||
// A folder added elsewhere (a drop, the handoff) joins the library.
|
||||
if (open == libraryPlace && folders.any((f) => !_scans.containsKey(f) && !_running.containsKey(f))) {
|
||||
scheduleMicrotask(() {
|
||||
if (mounted) _scanFor(libraryPlace);
|
||||
});
|
||||
}
|
||||
final top = <Widget>[
|
||||
_header(context, open),
|
||||
if (narrow) _chips(folders, open),
|
||||
if (!widget.alone && 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)),
|
||||
),
|
||||
..._notices(),
|
||||
];
|
||||
final slivers = <Widget>[
|
||||
SliverList.list(children: top),
|
||||
if (open == null)
|
||||
..._recent(context, recent)
|
||||
else if (scan == null)
|
||||
else if (open == libraryPlace && folders.isEmpty)
|
||||
SliverToBoxAdapter(
|
||||
child: FilesEmpty(
|
||||
Icons.video_library_outlined,
|
||||
'Your library is empty',
|
||||
'Add a folder with your videos: shows, seasons and movies are found by their names.',
|
||||
action: FilledButton.icon(
|
||||
onPressed: _addFolder,
|
||||
icon: const Icon(Icons.create_new_folder_outlined),
|
||||
label: const Text('Add folder'),
|
||||
),
|
||||
),
|
||||
)
|
||||
else if (lib == null)
|
||||
const SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(top: 60),
|
||||
@@ -398,7 +518,7 @@ class _FilesPageState extends State<FilesPage> {
|
||||
),
|
||||
)
|
||||
else
|
||||
..._folder(context, open, scan, scanning),
|
||||
..._library(context, open, lib, scanning),
|
||||
];
|
||||
return CustomScrollView(
|
||||
slivers: [
|
||||
@@ -412,25 +532,30 @@ class _FilesPageState extends State<FilesPage> {
|
||||
|
||||
Widget _header(BuildContext context, String? open) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final (icon, title, sub) = switch (open) {
|
||||
null => (Icons.history, 'Recent', null),
|
||||
libraryPlace => (Icons.video_library, 'Library', null),
|
||||
final dir => (Icons.folder, baseName(dir), dir),
|
||||
};
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 14),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(open == null ? Icons.history : Icons.folder, color: scheme.primary),
|
||||
Icon(icon, color: scheme.primary),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
open == null ? 'Recent' : baseName(open),
|
||||
title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.headlineSmall,
|
||||
),
|
||||
if (open != null)
|
||||
if (sub != null)
|
||||
Text(
|
||||
open,
|
||||
sub,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12),
|
||||
@@ -449,6 +574,11 @@ class _FilesPageState extends State<FilesPage> {
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
ChoiceChip(
|
||||
label: const Text('Library'),
|
||||
selected: open == libraryPlace,
|
||||
onSelected: (_) => _select(libraryPlace),
|
||||
),
|
||||
ChoiceChip(
|
||||
label: const Text('Recent'),
|
||||
selected: open == null,
|
||||
@@ -506,6 +636,7 @@ class _FilesPageState extends State<FilesPage> {
|
||||
_RecentRow(
|
||||
key: ValueKey(path),
|
||||
path: path,
|
||||
matcher: _matcher,
|
||||
state: _stateOf(path),
|
||||
thumbs: _thumbs,
|
||||
onTap: _tapOf(path),
|
||||
@@ -516,21 +647,32 @@ class _FilesPageState extends State<FilesPage> {
|
||||
];
|
||||
}
|
||||
|
||||
List<Widget> _folder(BuildContext context, String dir, FolderScan scan, bool scanning) {
|
||||
Widget _section(BuildContext context, String title, {String? count}) => Padding(
|
||||
padding: const EdgeInsets.only(top: 22, bottom: 10),
|
||||
child: Row(children: [
|
||||
Text(title, style: Theme.of(context).textTheme.titleMedium),
|
||||
if (count != null) ...[
|
||||
const SizedBox(width: 8),
|
||||
Text(count, style: Theme.of(context).textTheme.bodySmall?.copyWith(color: Colors.grey)),
|
||||
],
|
||||
]),
|
||||
);
|
||||
|
||||
List<Widget> _library(BuildContext context, String place, _Library lib, bool scanning) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final lib = _libraries[scan] ??= _Library(scan);
|
||||
final series = lib.grouped.series.length;
|
||||
final l = lib.lib;
|
||||
final folderScan = place == libraryPlace ? null : _scans[place];
|
||||
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',
|
||||
count(lib.videos, 'video', 'videos') +
|
||||
(l.shows.isEmpty ? '' : ' in ${count(l.shows.length, 'show', 'shows')}'),
|
||||
if (lib.hidden > 0) '${count(lib.hidden, 'other file', 'other files')} hidden',
|
||||
if (lib.subtitleFiles > 0) count(lib.subtitleFiles, 'subtitle file', 'subtitle files'),
|
||||
if (scan.truncated) 'stopped after $maxScannedFiles files',
|
||||
if (lib.truncated) 'stopped after $maxScannedFiles files',
|
||||
].join(' · ');
|
||||
final loose = lib.grouped.loose;
|
||||
String seriesId(SeriesGroup g) => '$dir\n${g.key}';
|
||||
final rows = lib.rows((g) => _expanded.contains(seriesId(g)));
|
||||
final folders = _foldersOf(place);
|
||||
final movies = l.movies;
|
||||
final other = l.other;
|
||||
return [
|
||||
SliverToBoxAdapter(
|
||||
child: Wrap(
|
||||
@@ -539,7 +681,7 @@ class _FilesPageState extends State<FilesPage> {
|
||||
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) ...[
|
||||
if (folderScan != null && folderScan.hiddenCount > 0) ...[
|
||||
const SizedBox(width: 8),
|
||||
TextButton(
|
||||
onPressed: () => setState(() => _showHidden = !_showHidden),
|
||||
@@ -547,49 +689,55 @@ class _FilesPageState extends State<FilesPage> {
|
||||
),
|
||||
],
|
||||
TextButton.icon(
|
||||
onPressed: scanning ? null : () => _scan(dir, rescan: true),
|
||||
onPressed: scanning
|
||||
? null
|
||||
: () {
|
||||
for (final f in folders) {
|
||||
_scan(f, rescan: true);
|
||||
}
|
||||
},
|
||||
icon: scanning
|
||||
? const SizedBox.square(
|
||||
dimension: 14, child: CircularProgressIndicator(strokeWidth: 2))
|
||||
? 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)
|
||||
if (folderScan != null)
|
||||
SliverToBoxAdapter(
|
||||
child: _folders.folderExists(dir)
|
||||
child: AnimatedSize(
|
||||
duration: easeTime,
|
||||
curve: ease,
|
||||
alignment: Alignment.topLeft,
|
||||
child: !_showHidden
|
||||
? const SizedBox(width: double.infinity)
|
||||
: Column(
|
||||
children: [
|
||||
for (final f in folderScan.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 (folderScan.hiddenCount > folderScan.hiddenExamples.length)
|
||||
ListTile(
|
||||
dense: true,
|
||||
enabled: false,
|
||||
title: Text('and ${folderScan.hiddenCount - folderScan.hiddenExamples.length} more'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (lib.videos == 0)
|
||||
SliverToBoxAdapter(
|
||||
child: place == libraryPlace || _folders.folderExists(place)
|
||||
? const FilesEmpty(
|
||||
Icons.search_off,
|
||||
'No videos here',
|
||||
@@ -601,46 +749,25 @@ class _FilesPageState extends State<FilesPage> {
|
||||
'It may have been moved, renamed or be on a drive that is not connected.',
|
||||
),
|
||||
),
|
||||
// Series: files whose names carry an episode number; a header
|
||||
// click opens or closes its episodes.
|
||||
SliverList.builder(
|
||||
itemCount: rows.length,
|
||||
itemBuilder: (context, i) {
|
||||
final (group, v) = rows[i];
|
||||
if (v == null) {
|
||||
final id = seriesId(group);
|
||||
return SeriesHeader(
|
||||
key: ValueKey('series:${group.key}'),
|
||||
group: group,
|
||||
thumbs: _thumbs,
|
||||
expanded: _expanded.contains(id),
|
||||
onTap: () => setState(() => _expanded.contains(id) ? _expanded.remove(id) : _expanded.add(id)),
|
||||
);
|
||||
}
|
||||
return _menuable(
|
||||
v.path,
|
||||
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),
|
||||
));
|
||||
},
|
||||
),
|
||||
// Everything else (movies, clips) as a thumbnail grid.
|
||||
if (loose.isNotEmpty) ...[
|
||||
if (l.shows.isNotEmpty) ...[
|
||||
SliverToBoxAdapter(child: _section(context, 'Shows', count: '${l.shows.length}')),
|
||||
SliverGrid.builder(
|
||||
gridDelegate: posterGrid(),
|
||||
itemCount: l.shows.length,
|
||||
itemBuilder: (context, i) => _showCard(place, lib, l.shows[i]),
|
||||
),
|
||||
],
|
||||
if (movies.isNotEmpty) ...[
|
||||
SliverToBoxAdapter(child: _section(context, 'Movies', count: '${movies.length}')),
|
||||
SliverGrid.builder(
|
||||
gridDelegate: posterGrid(),
|
||||
itemCount: movies.length,
|
||||
itemBuilder: (context, i) => _movieCard(movies[i]),
|
||||
),
|
||||
],
|
||||
if (other.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,
|
||||
),
|
||||
),
|
||||
child: _section(context, l.shows.isEmpty && movies.isEmpty ? 'Videos' : 'Other videos'),
|
||||
),
|
||||
SliverLayoutBuilder(builder: (context, c) {
|
||||
const gap = 12.0;
|
||||
@@ -654,9 +781,9 @@ class _FilesPageState extends State<FilesPage> {
|
||||
crossAxisSpacing: gap,
|
||||
mainAxisExtent: cardW * 9 / 16 + videoCardTextHeight,
|
||||
),
|
||||
itemCount: loose.length,
|
||||
itemCount: other.length,
|
||||
itemBuilder: (context, i) {
|
||||
final v = loose[i];
|
||||
final v = other[i];
|
||||
return _menuable(
|
||||
v.path,
|
||||
VideoCard(
|
||||
@@ -672,12 +799,60 @@ class _FilesPageState extends State<FilesPage> {
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
Widget _showCard(String place, _Library lib, LocalShow show) {
|
||||
final m = _matcher.showMatch(show);
|
||||
final last = _lastPlayed(lib, show);
|
||||
final String subtitle;
|
||||
if (last == null) {
|
||||
final n = show.episodeCount;
|
||||
subtitle = '$n ${n == 1 ? 'episode' : 'episodes'}';
|
||||
} else {
|
||||
final next = show.nextAfter(last);
|
||||
subtitle = next == null ? 'Watched to the end' : 'Next: ${episodeNumber(next.episode)}';
|
||||
}
|
||||
final playing = widget.nowPlayingPath != null && show.files.any((e) => e.path == widget.nowPlayingPath);
|
||||
return PosterCard(
|
||||
key: ValueKey('showcard:${show.key}'),
|
||||
title: m?.entry.title ?? show.title,
|
||||
subtitle: subtitle,
|
||||
coverUrl: m?.entry.cover,
|
||||
framePath: show.episodes.firstOrNull?.path,
|
||||
thumbs: _thumbs,
|
||||
playing: playing,
|
||||
onShown: () => _matcher.want(show),
|
||||
onTap: () => setState(() => _openShows[place] = show.key),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _movieCard(LocalEpisode movie) {
|
||||
final m = _matcher.seasonMatch(movie);
|
||||
return _menuable(
|
||||
movie.path,
|
||||
PosterCard(
|
||||
key: ValueKey('moviecard:${movie.path}'),
|
||||
title: m?.entry.title ?? (movie.parsed.year == null ? movie.title : '${movie.title} (${movie.parsed.year})'),
|
||||
subtitle: [
|
||||
if (movie.movieNumber != null) movie.label,
|
||||
if (m?.entry.year != null) '${m!.entry.year}' else if (movie.parsed.year != null) '${movie.parsed.year}',
|
||||
formatSize(movie.file.size),
|
||||
].join(' · '),
|
||||
coverUrl: m?.entry.cover,
|
||||
framePath: movie.path,
|
||||
thumbs: _thumbs,
|
||||
playing: _stateOf(movie.path) == PlayState.playing,
|
||||
onShown: () => _matcher.wantMovie(movie),
|
||||
onTap: _tapOf(movie.path),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// A Recent entry: `Series · S01E03` over the file name, or the cleaned
|
||||
/// title over its folder; greyed when the file is gone.
|
||||
/// A Recent entry: `Show · E02` over the file name (the library's names
|
||||
/// when it knows the show), or the cleaned title over its folder; greyed
|
||||
/// when the file is gone.
|
||||
class _RecentRow extends StatefulWidget {
|
||||
final String path;
|
||||
final ShowMatcher matcher;
|
||||
final PlayState state;
|
||||
final ThumbCache thumbs;
|
||||
final VoidCallback? onTap;
|
||||
@@ -685,6 +860,7 @@ class _RecentRow extends StatefulWidget {
|
||||
const _RecentRow({
|
||||
super.key,
|
||||
required this.path,
|
||||
required this.matcher,
|
||||
required this.state,
|
||||
required this.thumbs,
|
||||
required this.onTap,
|
||||
@@ -711,12 +887,14 @@ class _RecentRowState extends State<_RecentRow> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final path = widget.path;
|
||||
final p = parseReleaseName(baseName(path));
|
||||
final e = widget.matcher.episodeAt(path) ??
|
||||
readLocalFile(LocalFile(path, 0, DateTime.fromMillisecondsSinceEpoch(0)));
|
||||
final missing = _stat?.type == FileSystemEntityType.notFound;
|
||||
final episode = p.episode != null || p.extra != null;
|
||||
final episode = e.kind == LocalKind.episode || e.kind == LocalKind.extra;
|
||||
final name = widget.matcher.seasonMatch(e)?.entry.title ?? e.title;
|
||||
return FileRow(
|
||||
path: path,
|
||||
title: episode ? '${p.series} · ${p.label}' : cleanTitle(p),
|
||||
title: episode ? '$name · ${e.label}' : (e.kind == LocalKind.other ? cleanTitle(e.parsed) : name),
|
||||
subtitle: missing ? 'File not found' : (episode ? baseName(path) : parentName(path)),
|
||||
size: _stat == null || missing ? null : formatSize(_stat!.size),
|
||||
state: widget.state,
|
||||
|
||||
@@ -0,0 +1,712 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:signals/signals_flutter.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
import '../../library/show_matcher.dart';
|
||||
import '../../schedule/anilist.dart' show AniListEntry;
|
||||
import '../../shell/avatar.dart' show Pill;
|
||||
import 'file_tiles.dart';
|
||||
import 'thumb.dart';
|
||||
|
||||
// The Files library: shows and movies as poster cards (like the Jellyfin
|
||||
// browser's), a show's own page (banner, the next episode with what is
|
||||
// missing on the way, seasons, extras, movies) and the corrections
|
||||
// ("Wrong show?", "Episode numbers…"). Names and art come from the
|
||||
// library's AniList match when there is one, else from the files.
|
||||
|
||||
/// What the page hands its views: playing, the right-click menu, the
|
||||
/// thumbnails, what plays now.
|
||||
class FilesActions {
|
||||
final void Function(String path) play;
|
||||
final PlayState Function(String path) stateOf;
|
||||
final Widget Function(String path, Widget child) menuable;
|
||||
final ThumbCache thumbs;
|
||||
const FilesActions({required this.play, required this.stateOf, required this.menuable, required this.thumbs});
|
||||
}
|
||||
|
||||
/// "66–107 aren't in your folders", "66 isn't in your folders".
|
||||
String missingText(EpisodeGap gap) =>
|
||||
gap.first == gap.last ? '${gap.label} isn\'t in your folders' : '${gap.label} aren\'t in your folders';
|
||||
|
||||
/// The next-episode prompt: "66–107 aren't in your folders — skip to
|
||||
/// 108?", or at the start "1–55 … — start at 56?"; null when nothing is
|
||||
/// skipped.
|
||||
String? skipPrompt(LocalNext next, {required bool started}) {
|
||||
final gap = next.skipped;
|
||||
if (gap == null) return null;
|
||||
final n = next.episode.episode;
|
||||
return started ? '${missingText(gap)} — skip to $n?' : '${missingText(gap)} — start at $n?';
|
||||
}
|
||||
|
||||
/// A show's label for an episode: `56`, `S3 E1`.
|
||||
String episodeNumber(LocalEpisode e) =>
|
||||
e.season == null ? '${e.episode}' : 'S${e.season} E${e.episode}';
|
||||
|
||||
/// The 2:3 picture of a show or movie: its AniList cover, else a frame of
|
||||
/// [framePath].
|
||||
class PosterImage extends StatelessWidget {
|
||||
final String? url;
|
||||
final String? framePath;
|
||||
final ThumbCache thumbs;
|
||||
const PosterImage({super.key, this.url, this.framePath, required this.thumbs});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final frame = framePath == null
|
||||
? const ThumbImage(null)
|
||||
: ThumbBuilder(cache: thumbs, path: framePath!, builder: (context, t, _) => ThumbImage(t));
|
||||
final u = url;
|
||||
if (u == null) return frame;
|
||||
return Image.network(
|
||||
u,
|
||||
fit: BoxFit.cover,
|
||||
gaplessPlayback: true,
|
||||
errorBuilder: (_, _, _) => frame,
|
||||
loadingBuilder: (context, child, progress) => progress == null ? child : frame,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A show (or movie) as a poster card: picture, title, one line under it.
|
||||
class PosterCard extends StatefulWidget {
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final String? coverUrl;
|
||||
final String? framePath;
|
||||
final ThumbCache thumbs;
|
||||
final VoidCallback onTap;
|
||||
final bool playing;
|
||||
/// Called once when the card is first built (the library matches what
|
||||
/// is on screen).
|
||||
final VoidCallback? onShown;
|
||||
const PosterCard({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
required this.thumbs,
|
||||
required this.onTap,
|
||||
this.coverUrl,
|
||||
this.framePath,
|
||||
this.playing = false,
|
||||
this.onShown,
|
||||
});
|
||||
|
||||
@override
|
||||
State<PosterCard> createState() => _PosterCardState();
|
||||
}
|
||||
|
||||
class _PosterCardState extends State<PosterCard> {
|
||||
bool _hover = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
widget.onShown?.call();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final t = Theme.of(context).textTheme;
|
||||
return InkWell(
|
||||
onTap: widget.onTap,
|
||||
onHover: (h) => setState(() => _hover = h),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: widget.playing ? scheme.primary : (_hover ? scheme.onSurfaceVariant : Colors.transparent),
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: PosterImage(url: widget.coverUrl, framePath: widget.framePath, thumbs: widget.thumbs),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(widget.title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: t.titleSmall?.copyWith(color: widget.playing ? scheme.primary : null)),
|
||||
Text(widget.subtitle,
|
||||
maxLines: 1, overflow: TextOverflow.ellipsis, style: t.bodySmall?.copyWith(color: Colors.grey)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Title and meta lines under a [PosterCard]'s picture.
|
||||
const posterTextHeight = 46.0;
|
||||
|
||||
/// Poster cards in a grid: about 170 px wide each.
|
||||
SliverGridDelegate posterGrid() => const _PosterGrid();
|
||||
|
||||
class _PosterGrid extends SliverGridDelegate {
|
||||
const _PosterGrid();
|
||||
|
||||
@override
|
||||
SliverGridLayout getLayout(SliverConstraints constraints) {
|
||||
const gap = 12.0;
|
||||
final w = constraints.crossAxisExtent;
|
||||
final cols = (w / 170).floor().clamp(2, 12);
|
||||
final cardW = (w - gap * (cols - 1)) / cols;
|
||||
return SliverGridRegularTileLayout(
|
||||
crossAxisCount: cols,
|
||||
mainAxisStride: cardW * 1.5 + posterTextHeight + gap,
|
||||
crossAxisStride: cardW + gap,
|
||||
childMainAxisExtent: cardW * 1.5 + posterTextHeight,
|
||||
childCrossAxisExtent: cardW,
|
||||
reverseCrossAxis: false,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRelayout(covariant SliverGridDelegate oldDelegate) => false;
|
||||
}
|
||||
|
||||
/// One show's page: header (banner, proper name, what the files are,
|
||||
/// the match), the next episode with what is missing on the way, the
|
||||
/// season's episodes with their holes, extras and the show's movies.
|
||||
class ShowPage extends StatefulWidget {
|
||||
final LocalShow show;
|
||||
final ShowMatcher matcher;
|
||||
final FilesActions actions;
|
||||
/// The last episode of this show played here ([LocalEpisode.key]).
|
||||
final String? lastPlayed;
|
||||
final String backLabel;
|
||||
final VoidCallback onBack;
|
||||
/// Notices above the page (the relay notice, errors).
|
||||
final List<Widget> top;
|
||||
const ShowPage({
|
||||
super.key,
|
||||
required this.show,
|
||||
required this.matcher,
|
||||
required this.actions,
|
||||
required this.lastPlayed,
|
||||
required this.backLabel,
|
||||
required this.onBack,
|
||||
this.top = const [],
|
||||
});
|
||||
|
||||
@override
|
||||
State<ShowPage> createState() => _ShowPageState();
|
||||
}
|
||||
|
||||
class _ShowPageState extends State<ShowPage> {
|
||||
int? _season;
|
||||
bool _seasonPicked = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
widget.matcher.want(widget.show);
|
||||
}
|
||||
|
||||
LocalShow get show => widget.show;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => SignalBuilder(builder: (context) {
|
||||
widget.matcher.revision.value;
|
||||
final busy = widget.matcher.busy.value.contains(show.key);
|
||||
final m = widget.matcher.showMatch(show);
|
||||
final next = show.nextAfter(widget.lastPlayed);
|
||||
final seasons = show.seasons;
|
||||
if (!_seasonPicked) _season = next?.episode.season ?? seasons.firstOrNull?.number;
|
||||
final season = show.season(_season) ?? seasons.firstOrNull;
|
||||
return CustomScrollView(
|
||||
key: PageStorageKey('show:${show.key}'),
|
||||
slivers: [
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 12, 20, 24),
|
||||
sliver: SliverMainAxisGroup(slivers: [
|
||||
SliverToBoxAdapter(
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: TextButton.icon(
|
||||
onPressed: widget.onBack,
|
||||
icon: const Icon(Icons.arrow_back, size: 18),
|
||||
label: Text(widget.backLabel),
|
||||
),
|
||||
),
|
||||
),
|
||||
SliverList.list(children: widget.top),
|
||||
SliverToBoxAdapter(child: _header(context, m, next, busy)),
|
||||
if (seasons.length > 1)
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 14),
|
||||
child: Wrap(spacing: 8, runSpacing: 8, children: [
|
||||
for (final s in seasons)
|
||||
ChoiceChip(
|
||||
key: ValueKey('season:${s.number}'),
|
||||
label: Text(s.title),
|
||||
selected: identical(s, season),
|
||||
onSelected: (_) => setState(() {
|
||||
_season = s.number;
|
||||
_seasonPicked = true;
|
||||
}),
|
||||
),
|
||||
]),
|
||||
),
|
||||
),
|
||||
if (season != null) ..._episodes(context, season),
|
||||
if (show.extras.isNotEmpty) ...[
|
||||
_section(context, 'Extras', show.extras.length),
|
||||
SliverList.list(children: [for (final e in show.extras) _row(e, e.label, e.name)]),
|
||||
],
|
||||
if (show.movies.isNotEmpty) ...[
|
||||
_section(context, show.movies.length == 1 ? 'Movie' : 'Movies', show.movies.length),
|
||||
SliverList.list(children: [for (final e in show.movies) _row(e, e.label, e.name)]),
|
||||
],
|
||||
]),
|
||||
),
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
Widget _section(BuildContext context, String title, int n) => SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 22, bottom: 6),
|
||||
child: Row(children: [
|
||||
Text(title, style: Theme.of(context).textTheme.titleMedium),
|
||||
const SizedBox(width: 8),
|
||||
Text('$n', style: Theme.of(context).textTheme.bodySmall?.copyWith(color: Colors.grey)),
|
||||
]),
|
||||
),
|
||||
);
|
||||
|
||||
Widget _row(LocalEpisode e, String title, String subtitle) => widget.actions.menuable(
|
||||
e.path,
|
||||
FileRow(
|
||||
key: ValueKey(e.path),
|
||||
path: e.path,
|
||||
title: title,
|
||||
subtitle: subtitle,
|
||||
size: formatSize(e.file.size),
|
||||
state: widget.actions.stateOf(e.path),
|
||||
thumbs: widget.actions.thumbs,
|
||||
onTap: () => widget.actions.play(e.path),
|
||||
));
|
||||
|
||||
List<Widget> _episodes(BuildContext context, LocalSeason season) {
|
||||
final rows = <Widget>[];
|
||||
var expect = 1;
|
||||
for (final e in season.episodes) {
|
||||
final n = e.episode!;
|
||||
final gap = season.gaps.where((g) => g.first >= expect && g.last < n).firstOrNull;
|
||||
if (gap != null) rows.add(_GapRow(missingText(gap)));
|
||||
if (n >= expect) expect = n + 1;
|
||||
final offset = widget.matcher.offset(show.key, season.number);
|
||||
final shown = n + offset;
|
||||
final name = widget.matcher.episodeTitle(e);
|
||||
// Like the Jellyfin browser's rows: "12. Name", else "Episode 12".
|
||||
final title = name == null ? 'Episode $shown' : '$shown. $name';
|
||||
rows.add(_row(e, title, e.name));
|
||||
}
|
||||
final tail = season.gaps.where((g) => g.first >= expect).firstOrNull;
|
||||
if (tail != null) rows.add(_GapRow(missingText(tail)));
|
||||
return [
|
||||
_section(context, season.title, season.distinct.length),
|
||||
SliverList.list(children: rows),
|
||||
];
|
||||
}
|
||||
|
||||
Widget _header(BuildContext context, SeasonMatch? m, LocalNext? next, bool busy) {
|
||||
final theme = Theme.of(context);
|
||||
final t = theme.textTheme;
|
||||
final scheme = theme.colorScheme;
|
||||
final entry = m?.entry;
|
||||
final title = entry?.title ?? show.title;
|
||||
final count = show.episodeCount;
|
||||
final meta = [
|
||||
if (show.seasons.length > 1) '${show.seasons.length} seasons',
|
||||
'$count ${count == 1 ? 'episode' : 'episodes'}',
|
||||
if (show.extras.isNotEmpty) '${show.extras.length} ${show.extras.length == 1 ? 'extra' : 'extras'}',
|
||||
if (show.movies.isNotEmpty) '${show.movies.length} ${show.movies.length == 1 ? 'movie' : 'movies'}',
|
||||
?show.group,
|
||||
if (show.folders.length > 1) 'in ${show.folders.length} folders',
|
||||
].join(' · ');
|
||||
final String matchLine;
|
||||
if (entry != null) {
|
||||
final what = [
|
||||
if (entry.format.isNotEmpty) entry.format.replaceAll('_', ' '),
|
||||
if (entry.year != null) '${entry.year}',
|
||||
if (entry.episodes != null) '${entry.episodes} eps',
|
||||
].join(', ');
|
||||
matchLine = [
|
||||
'AniList: ${entry.romaji.isNotEmpty && entry.romaji != title ? entry.romaji : entry.title}'
|
||||
'${what.isEmpty ? '' : ' ($what)'}',
|
||||
if (title != show.title) 'files say "${show.title}"',
|
||||
].join(' · ');
|
||||
} else {
|
||||
matchLine = busy ? 'Looking it up on AniList…' : 'Not matched on AniList';
|
||||
}
|
||||
final started = widget.lastPlayed != null;
|
||||
final prompt = next == null ? null : skipPrompt(next, started: started);
|
||||
final nextLabel = next == null
|
||||
? 'Watched to the end'
|
||||
: next.skipped != null
|
||||
? (started ? 'Skip to ${next.episode.episode}' : 'Start at ${next.episode.episode}')
|
||||
: '${started ? 'Next' : 'Play'}: ${episodeNumber(next.episode)}';
|
||||
return LayoutBuilder(builder: (context, box) {
|
||||
final h = (box.maxWidth * 0.28).clamp(220.0, 340.0);
|
||||
final cover = entry?.cover;
|
||||
return ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: SizedBox(
|
||||
height: h,
|
||||
child: Stack(fit: StackFit.expand, children: [
|
||||
PosterImage(
|
||||
url: entry?.banner ?? entry?.cover,
|
||||
framePath: show.episodes.firstOrNull?.path,
|
||||
thumbs: widget.actions.thumbs),
|
||||
DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.centerLeft,
|
||||
end: Alignment.centerRight,
|
||||
colors: [
|
||||
scheme.surface.withValues(alpha: 0.96),
|
||||
scheme.surface.withValues(alpha: 0.8),
|
||||
scheme.surface.withValues(alpha: 0.15),
|
||||
],
|
||||
stops: const [0, 0.5, 1],
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
if (cover != null && box.maxWidth > 560) ...[
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: SizedBox(
|
||||
width: (h - 40) / 1.5,
|
||||
child: PosterImage(url: cover, thumbs: widget.actions.thumbs),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 18),
|
||||
],
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Row(children: [
|
||||
Flexible(
|
||||
child: Text(title,
|
||||
maxLines: 2, overflow: TextOverflow.ellipsis, style: t.headlineMedium),
|
||||
),
|
||||
if (m?.pinned != null) ...[
|
||||
const SizedBox(width: 8),
|
||||
Pill(m!.pinned == PinScope.folder ? 'pinned for this folder' : 'pinned', scheme.primary),
|
||||
],
|
||||
]),
|
||||
const SizedBox(height: 6),
|
||||
Text(meta, style: t.bodyMedium?.copyWith(color: Colors.grey)),
|
||||
const SizedBox(height: 2),
|
||||
Text(matchLine,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: t.bodySmall?.copyWith(color: scheme.onSurfaceVariant)),
|
||||
if (prompt != null) ...[
|
||||
const SizedBox(height: 10),
|
||||
Row(children: [
|
||||
Icon(Icons.info_outline, size: 16, color: scheme.tertiary),
|
||||
const SizedBox(width: 6),
|
||||
Flexible(child: Text(prompt, style: t.bodyMedium?.copyWith(color: scheme.tertiary))),
|
||||
]),
|
||||
],
|
||||
const SizedBox(height: 12),
|
||||
Wrap(spacing: 8, runSpacing: 8, children: [
|
||||
FilledButton.icon(
|
||||
key: const ValueKey('show-next'),
|
||||
onPressed: next == null ? null : () => widget.actions.play(next.episode.path),
|
||||
icon: const Icon(Icons.play_arrow),
|
||||
label: Text(nextLabel),
|
||||
),
|
||||
OutlinedButton.icon(
|
||||
onPressed: () => showWrongShowDialog(context, show, widget.matcher),
|
||||
icon: const Icon(Icons.manage_search, size: 18),
|
||||
label: const Text('Wrong show?'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => showEpisodeNumbersDialog(context, show, widget.matcher),
|
||||
child: const Text('Episode numbers…'),
|
||||
),
|
||||
]),
|
||||
]),
|
||||
),
|
||||
),
|
||||
]),
|
||||
),
|
||||
]),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class _GapRow extends StatelessWidget {
|
||||
final String text;
|
||||
const _GapRow(this.text);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 6, 8, 6),
|
||||
child: Row(children: [
|
||||
Icon(Icons.more_vert, size: 18, color: scheme.onSurfaceVariant),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(text,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: scheme.onSurfaceVariant, fontStyle: FontStyle.italic, fontSize: 13)),
|
||||
),
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// "Wrong show?": search AniList, pick the right entry, pin it for the
|
||||
/// show everywhere or for one of its folders.
|
||||
Future<void> showWrongShowDialog(BuildContext context, LocalShow show, ShowMatcher matcher) =>
|
||||
showDialog<void>(context: context, builder: (context) => WrongShowDialog(show: show, matcher: matcher));
|
||||
|
||||
class WrongShowDialog extends StatefulWidget {
|
||||
final LocalShow show;
|
||||
final ShowMatcher matcher;
|
||||
const WrongShowDialog({super.key, required this.show, required this.matcher});
|
||||
|
||||
@override
|
||||
State<WrongShowDialog> createState() => _WrongShowDialogState();
|
||||
}
|
||||
|
||||
class _WrongShowDialogState extends State<WrongShowDialog> {
|
||||
late final _query = TextEditingController(text: widget.show.title.replaceAll(RegExp(r'[._]+'), ' '));
|
||||
List<AniListEntry>? _hits;
|
||||
String? _error;
|
||||
bool _searching = false;
|
||||
/// Null: the whole show; else one of its folders.
|
||||
String? _folder;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_search();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_query.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _search() async {
|
||||
setState(() {
|
||||
_searching = true;
|
||||
_error = null;
|
||||
});
|
||||
try {
|
||||
final hits = await widget.matcher.search(_query.text);
|
||||
if (mounted) setState(() => _hits = hits);
|
||||
} catch (e) {
|
||||
if (mounted) setState(() => _error = 'AniList did not answer ($e)');
|
||||
} finally {
|
||||
if (mounted) setState(() => _searching = false);
|
||||
}
|
||||
}
|
||||
|
||||
void _pin(AniListEntry e) {
|
||||
final folder = _folder;
|
||||
folder == null ? widget.matcher.pinShow(widget.show.key, e) : widget.matcher.pinFolder(folder, e);
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final show = widget.show;
|
||||
final m = widget.matcher;
|
||||
final showPin = m.showPin(show.key);
|
||||
final folderPins = [for (final f in show.folders) if (m.folderPin(f)?.folder == f) f];
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
return AlertDialog(
|
||||
title: const Text('Wrong show?'),
|
||||
content: SizedBox(
|
||||
width: 520,
|
||||
child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text('Search AniList and pick what "${show.title}" really is. '
|
||||
'A pin wins over the automatic match and survives rescans.',
|
||||
style: TextStyle(color: scheme.onSurfaceVariant)),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: _query,
|
||||
autofocus: true,
|
||||
decoration: InputDecoration(
|
||||
isDense: true,
|
||||
border: const OutlineInputBorder(),
|
||||
hintText: 'Title on AniList',
|
||||
suffixIcon: IconButton(tooltip: 'Search', icon: const Icon(Icons.search), onPressed: _search),
|
||||
),
|
||||
onSubmitted: (_) => _search(),
|
||||
),
|
||||
if (show.folders.length > 1 || folderPins.isNotEmpty) ...[
|
||||
const SizedBox(height: 10),
|
||||
DropdownButton<String?>(
|
||||
value: _folder,
|
||||
isExpanded: true,
|
||||
items: [
|
||||
const DropdownMenuItem(value: null, child: Text('For this show everywhere')),
|
||||
for (final f in show.folders)
|
||||
DropdownMenuItem(value: f, child: Text('Only the folder "${baseName(f)}"', overflow: TextOverflow.ellipsis)),
|
||||
],
|
||||
onChanged: (v) => setState(() => _folder = v),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 8),
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxHeight: 340),
|
||||
child: _searching && _hits == null
|
||||
? const Padding(padding: EdgeInsets.all(24), child: Center(child: CircularProgressIndicator()))
|
||||
: _error != null
|
||||
? Text(_error!, style: TextStyle(color: scheme.error))
|
||||
: (_hits ?? const []).isEmpty
|
||||
? const Padding(padding: EdgeInsets.all(12), child: Text('Nothing found'))
|
||||
: ListView(shrinkWrap: true, children: [
|
||||
for (final e in _hits!)
|
||||
ListTile(
|
||||
key: ValueKey('hit:${e.id}'),
|
||||
dense: true,
|
||||
leading: SizedBox(
|
||||
width: 32,
|
||||
height: 48,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: e.coverSmall == null
|
||||
? const ColoredBox(color: Colors.black26)
|
||||
: Image.network(e.coverSmall!,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, _, _) => const ColoredBox(color: Colors.black26)),
|
||||
),
|
||||
),
|
||||
title: Text(e.title, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
subtitle: Text([
|
||||
if (e.romaji.isNotEmpty && e.romaji != e.title) e.romaji,
|
||||
if (e.format.isNotEmpty) e.format.replaceAll('_', ' '),
|
||||
if (e.year != null) '${e.year}',
|
||||
if (e.episodes != null) '${e.episodes} eps',
|
||||
].join(' · '), maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
onTap: () => _pin(e),
|
||||
),
|
||||
]),
|
||||
),
|
||||
]),
|
||||
),
|
||||
actions: [
|
||||
if (showPin != null)
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
m.unpinShow(show.key);
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: const Text('Remove the pin'),
|
||||
),
|
||||
for (final f in folderPins)
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
m.unpinFolder(f);
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: Text('Unpin "${baseName(f)}"'),
|
||||
),
|
||||
TextButton(onPressed: () => Navigator.of(context).pop(), child: const Text('Cancel')),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// "Episode numbers…": which episode the first file of each season is
|
||||
/// ("these files are episodes N..M").
|
||||
Future<void> showEpisodeNumbersDialog(BuildContext context, LocalShow show, ShowMatcher matcher) =>
|
||||
showDialog<void>(context: context, builder: (context) => _EpisodeNumbersDialog(show: show, matcher: matcher));
|
||||
|
||||
class _EpisodeNumbersDialog extends StatefulWidget {
|
||||
final LocalShow show;
|
||||
final ShowMatcher matcher;
|
||||
const _EpisodeNumbersDialog({required this.show, required this.matcher});
|
||||
|
||||
@override
|
||||
State<_EpisodeNumbersDialog> createState() => _EpisodeNumbersDialogState();
|
||||
}
|
||||
|
||||
class _EpisodeNumbersDialogState extends State<_EpisodeNumbersDialog> {
|
||||
late final _fields = {
|
||||
for (final s in widget.show.seasons)
|
||||
s.number: TextEditingController(
|
||||
text: '${s.distinct.first.episode! + widget.matcher.offset(widget.show.key, s.number)}'),
|
||||
};
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
for (final c in _fields.values) {
|
||||
c.dispose();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _save() {
|
||||
for (final s in widget.show.seasons) {
|
||||
final first = s.distinct.first.episode!;
|
||||
final want = int.tryParse(_fields[s.number]!.text.trim());
|
||||
if (want != null && want > 0) widget.matcher.setOffset(widget.show.key, s.number, want - first);
|
||||
}
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => AlertDialog(
|
||||
title: const Text('Episode numbers'),
|
||||
content: SizedBox(
|
||||
width: 420,
|
||||
child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
const Text('When the files count differently from AniList, say which episode the first file is; '
|
||||
'the others follow.'),
|
||||
const SizedBox(height: 12),
|
||||
for (final s in widget.show.seasons)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Row(children: [
|
||||
Expanded(
|
||||
child: Text('${s.title}: the file numbered ${s.distinct.first.episode} is episode',
|
||||
maxLines: 2),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
SizedBox(
|
||||
width: 72,
|
||||
child: TextField(
|
||||
key: ValueKey('offset:${s.number}'),
|
||||
controller: _fields[s.number],
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: const InputDecoration(isDense: true, border: OutlineInputBorder()),
|
||||
),
|
||||
),
|
||||
]),
|
||||
),
|
||||
]),
|
||||
),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.of(context).pop(), child: const Text('Cancel')),
|
||||
FilledButton(onPressed: _save, child: const Text('Save')),
|
||||
],
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user