Debug-only layout demo of the 1.0 shell (reference for the redesign)
Behind a bug button in Home's top bar, debug builds only: single window with a party strip, Library (Jellyfin / Link / Files), Schedule on real AniList data with AnimeFlow statuses (read-only), Settings, profile with live rename and avatar crop, floating mini player, full and fullscreen player with a real media_kit player, chat panel and avatar toasts. Not wired to sessions; the real implementation replaces it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
e7f33b5dc3
commit
6f136bfc4d
6 files changed
+5794
No files matched your search
@@ -0,0 +1,920 @@
|
||||
part of 'shell_demo.dart';
|
||||
|
||||
// DEBUG ONLY: the Files source for real. Folders you add are scanned for
|
||||
// videos (everything else is counted and hidden), grouped into series by
|
||||
// a small anitomy-style name parser; a click plays the file in the demo
|
||||
// player. Kept in memory for the demo session.
|
||||
|
||||
const _videoExt = {
|
||||
'mkv',
|
||||
'mp4',
|
||||
'webm',
|
||||
'avi',
|
||||
'mov',
|
||||
'm4v',
|
||||
'ts',
|
||||
'm2ts',
|
||||
'wmv',
|
||||
'flv',
|
||||
};
|
||||
const _subExt = {'ass', 'ssa', 'srt', 'vtt', 'sup'};
|
||||
|
||||
class _VideoFile {
|
||||
final String path;
|
||||
final int size;
|
||||
final _ParsedName parsed;
|
||||
const _VideoFile(this.path, this.size, this.parsed);
|
||||
String get name => _baseName(path);
|
||||
String get folder => _baseName(File(path).parent.path);
|
||||
}
|
||||
|
||||
class _FolderScan {
|
||||
final List<_VideoFile> videos;
|
||||
final int hidden;
|
||||
final int subtitles;
|
||||
final List<String> hiddenExamples;
|
||||
final bool truncated;
|
||||
const _FolderScan(
|
||||
this.videos,
|
||||
this.hidden,
|
||||
this.subtitles,
|
||||
this.hiddenExamples,
|
||||
this.truncated,
|
||||
);
|
||||
}
|
||||
|
||||
final _folders = ValueNotifier<List<String>>([]);
|
||||
final _scans = <String, _FolderScan>{};
|
||||
final _recentFiles = ValueNotifier<List<_VideoFile>>([]);
|
||||
|
||||
/// What a release file name says: series, season, episode, group.
|
||||
class _ParsedName {
|
||||
final String series;
|
||||
final int? season;
|
||||
final int? episode;
|
||||
final String? group;
|
||||
const _ParsedName(this.series, {this.season, this.episode, this.group});
|
||||
|
||||
String get label {
|
||||
if (episode == null) return series;
|
||||
final ep = 'E${episode.toString().padLeft(2, '0')}';
|
||||
return season == null ? ep : 'S${season.toString().padLeft(2, '0')}$ep';
|
||||
}
|
||||
}
|
||||
|
||||
_ParsedName _parseName(String file) {
|
||||
var s = file;
|
||||
final dot = s.lastIndexOf('.');
|
||||
if (dot > 0) s = s.substring(0, dot);
|
||||
final group = RegExp(r'^\s*\[([^\]]+)\]').firstMatch(s)?.group(1);
|
||||
// Brackets/parens hold group, resolution, codec, CRC.
|
||||
s = s.replaceAll(RegExp(r'\[[^\]]*\]|\([^)]*\)'), ' ');
|
||||
// Dots/underscores as separators (scene style), unless it has spaces.
|
||||
if (!s.contains(' ') || RegExp(r'\w\.\w').allMatches(s).length > 2) {
|
||||
s = s.replaceAll(RegExp(r'[._]'), ' ');
|
||||
}
|
||||
int? season, episode;
|
||||
var cut = s.length;
|
||||
final se = RegExp(
|
||||
r'\bS(\d{1,2})\s?E(\d{1,4})\b',
|
||||
caseSensitive: false,
|
||||
).firstMatch(s);
|
||||
if (se != null) {
|
||||
season = int.parse(se.group(1)!);
|
||||
episode = int.parse(se.group(2)!);
|
||||
cut = se.start;
|
||||
} else {
|
||||
final dash = RegExp(r'\s-\s(\d{1,4})(?:v\d)?\b').firstMatch(s);
|
||||
final ep = RegExp(
|
||||
r'\b(?:E|EP|Episode)\s?(\d{1,4})\b',
|
||||
caseSensitive: false,
|
||||
).firstMatch(s);
|
||||
final m = dash ?? ep;
|
||||
if (m != null) {
|
||||
episode = int.parse(m.group(1)!);
|
||||
cut = m.start;
|
||||
}
|
||||
}
|
||||
var series = s.substring(0, cut);
|
||||
// Season markers inside the title ("Title S2", "Title 2nd Season").
|
||||
final sInTitle = RegExp(
|
||||
r'\s(?:S(\d{1,2})|(\d)(?:st|nd|rd|th) Season)\s*$',
|
||||
caseSensitive: false,
|
||||
).firstMatch(series);
|
||||
if (sInTitle != null && season == null) {
|
||||
season = int.parse(sInTitle.group(1) ?? sInTitle.group(2)!);
|
||||
}
|
||||
// Release junk after the title.
|
||||
series = series
|
||||
.replaceAll(
|
||||
RegExp(
|
||||
r'\b(1080p|720p|2160p|480p|x26[45]|HEVC|WEB(-?DL|Rip)?|BD(Rip)?|BluRay|AAC|FLAC|10bit)\b.*',
|
||||
caseSensitive: false,
|
||||
),
|
||||
'',
|
||||
)
|
||||
.replaceAll(RegExp(r'\s+'), ' ')
|
||||
.replaceAll(RegExp(r'[\s\-–:]+$'), '')
|
||||
.trim();
|
||||
if (series.isEmpty) series = file;
|
||||
return _ParsedName(series, season: season, episode: episode, group: group);
|
||||
}
|
||||
|
||||
Future<_FolderScan> _scanFolder(String dir) async {
|
||||
final videos = <_VideoFile>[];
|
||||
var hidden = 0, subs = 0;
|
||||
final examples = <String>[];
|
||||
var seen = 0;
|
||||
var truncated = false;
|
||||
try {
|
||||
await for (final e in Directory(
|
||||
dir,
|
||||
).list(recursive: true, followLinks: false)) {
|
||||
if (e is! File) continue;
|
||||
if (++seen > 20000) {
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
final name = e.path.split(Platform.pathSeparator).last;
|
||||
if (name.startsWith('.')) continue;
|
||||
final ext = name.contains('.') ? name.split('.').last.toLowerCase() : '';
|
||||
if (_subExt.contains(ext)) {
|
||||
subs++;
|
||||
continue;
|
||||
}
|
||||
final isVideo = _videoExt.contains(ext);
|
||||
final lower = name.toLowerCase();
|
||||
int size = 0;
|
||||
try {
|
||||
size = await e.length();
|
||||
} catch (_) {}
|
||||
// Samples and trailers next to the real thing.
|
||||
final junk =
|
||||
isVideo &&
|
||||
(lower.contains('sample') || lower.contains('trailer')) &&
|
||||
size < 300 << 20;
|
||||
if (!isVideo || junk) {
|
||||
hidden++;
|
||||
if (examples.length < 6) examples.add(name);
|
||||
continue;
|
||||
}
|
||||
videos.add(_VideoFile(e.path, size, _parseName(name)));
|
||||
}
|
||||
} catch (_) {
|
||||
// Unreadable subfolders: show what was found.
|
||||
}
|
||||
return _FolderScan(videos, hidden, subs, examples, truncated);
|
||||
}
|
||||
|
||||
String _size(int b) => b >= 1 << 30
|
||||
? '${(b / (1 << 30)).toStringAsFixed(1)} GB'
|
||||
: '${(b / (1 << 20)).toStringAsFixed(0)} MB';
|
||||
|
||||
class _RealFilesTab extends StatefulWidget {
|
||||
final bool online;
|
||||
final VoidCallback onPlay;
|
||||
const _RealFilesTab({required this.online, required this.onPlay});
|
||||
|
||||
@override
|
||||
State<_RealFilesTab> createState() => _RealFilesTabState();
|
||||
}
|
||||
|
||||
class _RealFilesTabState extends State<_RealFilesTab> {
|
||||
String? _open; // null = Recent
|
||||
bool _scanning = false;
|
||||
bool _showOther = false;
|
||||
|
||||
Future<void> _addFolder() async {
|
||||
String? dir;
|
||||
try {
|
||||
dir = await FilePicker.getDirectoryPath(
|
||||
dialogTitle: 'Add a folder with videos',
|
||||
);
|
||||
} catch (_) {}
|
||||
if (dir == null || _folders.value.contains(dir)) return;
|
||||
_folders.value = [..._folders.value, dir];
|
||||
await _select(dir, rescan: true);
|
||||
}
|
||||
|
||||
Future<void> _select(String? dir, {bool rescan = false}) async {
|
||||
setState(() {
|
||||
_open = dir;
|
||||
_showOther = false;
|
||||
});
|
||||
if (dir == null || (_scans.containsKey(dir) && !rescan)) return;
|
||||
setState(() => _scanning = true);
|
||||
final scan = await _scanFolder(dir);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_scans[dir] = scan;
|
||||
_scanning = false;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _browse() async {
|
||||
List<PlatformFile> files = const [];
|
||||
try {
|
||||
files = await FilePicker.pickFiles(
|
||||
dialogTitle: 'Pick a video',
|
||||
type: FileType.custom,
|
||||
allowedExtensions: _videoExt.toList(),
|
||||
);
|
||||
} catch (_) {}
|
||||
final path = files.isEmpty ? null : files.first.path;
|
||||
if (path == null) return;
|
||||
final f = File(path);
|
||||
await _play(
|
||||
_VideoFile(path, await f.length(), _parseName(f.uri.pathSegments.last)),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _play(_VideoFile v) async {
|
||||
_recentFiles.value = [
|
||||
v,
|
||||
..._recentFiles.value.where((x) => x.path != v.path),
|
||||
].take(30).toList();
|
||||
// Structured names read as "Series · S01E03"; anything else as the
|
||||
// cleaned name and the folder it is in.
|
||||
await _playMedia(
|
||||
_DemoMedia(
|
||||
v.parsed.series,
|
||||
v.parsed.episode == null ? v.folder : v.parsed.label,
|
||||
v.path,
|
||||
),
|
||||
);
|
||||
widget.onPlay();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return LayoutBuilder(
|
||||
builder: (context, box) {
|
||||
final narrow = box.maxWidth < 700;
|
||||
final list = _list(context, narrow);
|
||||
if (narrow) return list;
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(width: 260, child: _sidebar(context)),
|
||||
Expanded(child: list),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _sidebar(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
return ValueListenableBuilder(
|
||||
valueListenable: _folders,
|
||||
builder: (context, folders, _) => 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: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
leading: const Icon(Icons.history, size: 18),
|
||||
title: const Text('Recent'),
|
||||
onTap: () => _select(null),
|
||||
),
|
||||
for (final f in folders)
|
||||
ListTile(
|
||||
dense: true,
|
||||
selected: _open == f,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
leading: const Icon(Icons.folder_outlined, size: 18),
|
||||
title: Text(
|
||||
_baseName(f),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
subtitle: Text(
|
||||
_scans[f] == null
|
||||
? 'scanning…'
|
||||
: '${_scans[f]!.videos.length} videos',
|
||||
style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 11),
|
||||
),
|
||||
trailing: IconButton(
|
||||
tooltip: 'Remove folder',
|
||||
icon: const Icon(Icons.close, size: 16),
|
||||
onPressed: () {
|
||||
_folders.value = [...folders]..remove(f);
|
||||
_scans.remove(f);
|
||||
if (_open == f) _select(null);
|
||||
},
|
||||
),
|
||||
onTap: () => _select(f),
|
||||
),
|
||||
TextButton.icon(
|
||||
onPressed: _addFolder,
|
||||
icon: const Icon(Icons.add, size: 18),
|
||||
label: const Text('Add folder'),
|
||||
),
|
||||
const Divider(),
|
||||
OutlinedButton.icon(
|
||||
onPressed: _browse,
|
||||
icon: const Icon(Icons.folder_open, size: 18),
|
||||
label: const Text('Browse…'),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
const Text(
|
||||
'The system file picker, for anything outside your folders.',
|
||||
style: TextStyle(color: Colors.grey, fontSize: 11),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _list(BuildContext context, bool narrow) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final open = _open;
|
||||
final scan = open == null ? null : _scans[open];
|
||||
final children = <Widget>[
|
||||
// Where you are: Recent, or the folder and its path.
|
||||
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),
|
||||
style: Theme.of(context).textTheme.headlineSmall,
|
||||
),
|
||||
if (open != null)
|
||||
Text(
|
||||
open,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: scheme.onSurfaceVariant,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (narrow)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 14),
|
||||
child: ValueListenableBuilder(
|
||||
valueListenable: _folders,
|
||||
builder: (context, folders, _) => Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
ChoiceChip(
|
||||
label: const Text('Recent'),
|
||||
selected: open == null,
|
||||
onSelected: (_) => _select(null),
|
||||
),
|
||||
for (final f in folders)
|
||||
ChoiceChip(
|
||||
label: Text(_baseName(f)),
|
||||
selected: open == f,
|
||||
onSelected: (_) => _select(f),
|
||||
),
|
||||
ActionChip(
|
||||
avatar: const Icon(Icons.add, size: 16),
|
||||
label: const Text('Folder'),
|
||||
onPressed: _addFolder,
|
||||
),
|
||||
ActionChip(
|
||||
avatar: const Icon(Icons.folder_open, size: 16),
|
||||
label: const Text('Browse…'),
|
||||
onPressed: _browse,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (widget.online)
|
||||
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.',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
];
|
||||
if (open == null) {
|
||||
children.add(
|
||||
ValueListenableBuilder(
|
||||
valueListenable: _recentFiles,
|
||||
builder: (context, recent, _) => recent.isEmpty
|
||||
? _empty(
|
||||
context,
|
||||
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'),
|
||||
),
|
||||
)
|
||||
: Column(
|
||||
children: [
|
||||
for (final v in recent)
|
||||
_episodeTile(context, v, showSeries: true),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
} else if (_scanning || scan == null) {
|
||||
children.add(
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(top: 60),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
final groups = <String, List<_VideoFile>>{};
|
||||
for (final v in scan.videos) {
|
||||
groups.putIfAbsent(v.parsed.series, () => []).add(v);
|
||||
}
|
||||
final names = groups.keys.toList()
|
||||
..sort((a, b) => a.toLowerCase().compareTo(b.toLowerCase()));
|
||||
for (final g in groups.values) {
|
||||
g.sort((a, b) {
|
||||
final s = (a.parsed.season ?? 0).compareTo(b.parsed.season ?? 0);
|
||||
if (s != 0) return s;
|
||||
final e = (a.parsed.episode ?? 0).compareTo(b.parsed.episode ?? 0);
|
||||
return e != 0 ? e : a.name.compareTo(b.name);
|
||||
});
|
||||
}
|
||||
children.add(
|
||||
Wrap(
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.filter_alt_outlined,
|
||||
size: 16,
|
||||
color: scheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
'${scan.videos.length} videos in ${groups.length} series'
|
||||
'${scan.hidden > 0 ? ' · ${scan.hidden} other files hidden' : ''}'
|
||||
'${scan.subtitles > 0 ? ' · ${scan.subtitles} subtitle files' : ''}'
|
||||
'${scan.truncated ? ' · stopped after 20 000 files' : ''}',
|
||||
style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12),
|
||||
),
|
||||
if (scan.hidden > 0) ...[
|
||||
const SizedBox(width: 8),
|
||||
TextButton(
|
||||
onPressed: () => setState(() => _showOther = !_showOther),
|
||||
child: Text(_showOther ? 'Hide them' : 'Show some'),
|
||||
),
|
||||
],
|
||||
TextButton.icon(
|
||||
onPressed: () => _select(open, rescan: true),
|
||||
icon: const Icon(Icons.refresh, size: 16),
|
||||
label: const Text('Rescan'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
children.add(
|
||||
AnimatedSize(
|
||||
duration: _easeTime,
|
||||
curve: _ease,
|
||||
alignment: Alignment.topLeft,
|
||||
child: !_showOther
|
||||
? 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),
|
||||
trailing: const Text(
|
||||
'not a video',
|
||||
style: TextStyle(fontSize: 12),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
if (scan.videos.isEmpty) {
|
||||
children.add(
|
||||
_empty(
|
||||
context,
|
||||
Icons.search_off,
|
||||
'No videos here',
|
||||
'Only video files are listed (mkv, mp4, webm…).',
|
||||
),
|
||||
);
|
||||
}
|
||||
// Series: files whose names carry an episode number. Everything
|
||||
// else is a loose video, shown as a thumbnail grid.
|
||||
final loose = <_VideoFile>[];
|
||||
for (final name in names) {
|
||||
final eps = groups[name]!;
|
||||
if (eps.every((e) => e.parsed.episode == null)) {
|
||||
loose.addAll(eps);
|
||||
continue;
|
||||
}
|
||||
children.add(
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 18, bottom: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: _Thumb(eps.first.path, width: 96, height: 54),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
name,
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
Text(
|
||||
'${eps.length} ${eps.length == 1 ? 'episode' : 'episodes'}'
|
||||
'${eps.first.parsed.group == null ? '' : ' · ${eps.first.parsed.group}'}',
|
||||
style: const TextStyle(
|
||||
color: Colors.grey,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
for (final v in eps) {
|
||||
children.add(_episodeTile(context, v));
|
||||
}
|
||||
}
|
||||
if (loose.isNotEmpty) {
|
||||
children.add(
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 22, bottom: 10),
|
||||
child: Text(
|
||||
names.length == loose.length ? 'Videos' : 'Other videos',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
),
|
||||
);
|
||||
children.add(
|
||||
LayoutBuilder(
|
||||
builder: (context, box) {
|
||||
const gap = 12.0;
|
||||
final cols = (box.maxWidth / 240).floor().clamp(2, 8);
|
||||
final w = (box.maxWidth - gap * (cols - 1)) / cols;
|
||||
return Wrap(
|
||||
spacing: gap,
|
||||
runSpacing: gap,
|
||||
children: [
|
||||
for (final v in loose)
|
||||
SizedBox(width: w, child: _videoCard(context, v)),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
return ListView(
|
||||
padding: EdgeInsets.fromLTRB(narrow ? 20 : 12, 20, 20, 20),
|
||||
children: children,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _episodeTile(
|
||||
BuildContext context,
|
||||
_VideoFile v, {
|
||||
bool showSeries = false,
|
||||
}) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
return ValueListenableBuilder(
|
||||
valueListenable: _nowPlaying,
|
||||
builder: (context, now, _) {
|
||||
final playing = now?.path == v.path;
|
||||
return ListTile(
|
||||
dense: true,
|
||||
contentPadding: EdgeInsets.only(left: showSeries ? 0 : 20),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
selected: playing,
|
||||
leading: SizedBox(
|
||||
width: 112,
|
||||
child: Row(
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: _Thumb(v.path, width: 80, height: 45),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Icon(
|
||||
playing ? Icons.graphic_eq : Icons.play_circle_outline,
|
||||
size: 20,
|
||||
color: scheme.primary,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
minLeadingWidth: 112,
|
||||
title: Text(
|
||||
showSeries
|
||||
? '${v.parsed.series} · ${v.parsed.label}'
|
||||
: v.parsed.label,
|
||||
),
|
||||
subtitle: Text(
|
||||
showSeries && v.parsed.episode == null ? v.folder : v.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
trailing: Text(
|
||||
_size(v.size),
|
||||
style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12),
|
||||
),
|
||||
onTap: () => _play(v),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _videoCard(BuildContext context, _VideoFile v) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
return ValueListenableBuilder(
|
||||
valueListenable: _nowPlaying,
|
||||
builder: (context, now, _) {
|
||||
final playing = now?.path == v.path;
|
||||
return InkWell(
|
||||
onTap: () => _play(v),
|
||||
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: [
|
||||
_Thumb(v.path),
|
||||
Positioned(
|
||||
right: 6,
|
||||
bottom: 6,
|
||||
child: _DurationBadge(v.path),
|
||||
),
|
||||
if (playing)
|
||||
const Center(
|
||||
child: Icon(
|
||||
Icons.graphic_eq,
|
||||
color: Colors.white,
|
||||
size: 32,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
v.parsed.series,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: playing ? scheme.primary : null,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
_size(v.size),
|
||||
style: const TextStyle(color: Colors.grey, fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _empty(
|
||||
BuildContext context,
|
||||
IconData icon,
|
||||
String title,
|
||||
String sub, {
|
||||
Widget? action,
|
||||
}) => Padding(
|
||||
padding: const EdgeInsets.only(top: 60),
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: 44,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Text(title, style: Theme.of(context).textTheme.titleMedium),
|
||||
const SizedBox(height: 4),
|
||||
Text(sub, style: const TextStyle(color: Colors.grey)),
|
||||
if (action != null) ...[const SizedBox(height: 14), action],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _baseName(String path) {
|
||||
final parts = path
|
||||
.split(RegExp(r'[/\\]'))
|
||||
.where((p) => p.isNotEmpty)
|
||||
.toList();
|
||||
return parts.isEmpty ? path : parts.last;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ thumbnails
|
||||
//
|
||||
// Demo: ffmpeg/ffprobe when installed (a frame at 25%, cached in the temp
|
||||
// dir). The real app would ask mpv for the frame instead (no extra tool).
|
||||
|
||||
class _ThumbInfo {
|
||||
final File? image;
|
||||
final Duration? duration;
|
||||
const _ThumbInfo(this.image, this.duration);
|
||||
}
|
||||
|
||||
final _thumbs = <String, Future<_ThumbInfo>>{};
|
||||
var _thumbRunning = 0;
|
||||
final _thumbQueue = <Completer<void>>[];
|
||||
|
||||
Future<_ThumbInfo> _thumbFor(String path) =>
|
||||
_thumbs.putIfAbsent(path, () async {
|
||||
// At most 3 ffmpeg runs at a time.
|
||||
if (_thumbRunning >= 3) {
|
||||
final c = Completer<void>();
|
||||
_thumbQueue.add(c);
|
||||
await c.future;
|
||||
}
|
||||
_thumbRunning++;
|
||||
try {
|
||||
return await _makeThumb(path);
|
||||
} finally {
|
||||
_thumbRunning--;
|
||||
if (_thumbQueue.isNotEmpty) _thumbQueue.removeAt(0).complete();
|
||||
}
|
||||
});
|
||||
|
||||
Future<_ThumbInfo> _makeThumb(String path) async {
|
||||
Duration? dur;
|
||||
try {
|
||||
final r = await Process.run('ffprobe', [
|
||||
'-v',
|
||||
'error',
|
||||
'-show_entries',
|
||||
'format=duration',
|
||||
'-of',
|
||||
'csv=p=0',
|
||||
path,
|
||||
]);
|
||||
final secs = double.tryParse('${r.stdout}'.trim());
|
||||
if (secs != null) dur = Duration(milliseconds: (secs * 1000).round());
|
||||
} catch (_) {
|
||||
return const _ThumbInfo(null, null); // no ffmpeg on this machine
|
||||
}
|
||||
final dir = Directory('${Directory.systemTemp.path}/wt-demo-thumbs')
|
||||
..createSync(recursive: true);
|
||||
final key = path.hashCode.toUnsigned(32).toRadixString(16);
|
||||
final out = File('${dir.path}/$key.jpg');
|
||||
if (out.existsSync() && out.lengthSync() > 0) return _ThumbInfo(out, dur);
|
||||
final at = dur == null ? 5.0 : dur.inMilliseconds / 1000 * 0.25;
|
||||
try {
|
||||
final r = await Process.run('ffmpeg', [
|
||||
'-v',
|
||||
'error',
|
||||
'-y',
|
||||
'-ss',
|
||||
at.toStringAsFixed(2),
|
||||
'-i',
|
||||
path,
|
||||
'-frames:v',
|
||||
'1',
|
||||
'-vf',
|
||||
'scale=480:-2',
|
||||
'-q:v',
|
||||
'4',
|
||||
out.path,
|
||||
]);
|
||||
if (r.exitCode == 0 && out.existsSync()) return _ThumbInfo(out, dur);
|
||||
} catch (_) {}
|
||||
return _ThumbInfo(null, dur);
|
||||
}
|
||||
|
||||
class _Thumb extends StatelessWidget {
|
||||
final String path;
|
||||
final double? width, height;
|
||||
const _Thumb(this.path, {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,
|
||||
child: Icon(
|
||||
Icons.movie_outlined,
|
||||
color: scheme.onSurfaceVariant,
|
||||
size: 20,
|
||||
),
|
||||
);
|
||||
return FutureBuilder<_ThumbInfo>(
|
||||
future: _thumbFor(path),
|
||||
builder: (context, snap) {
|
||||
final img = snap.data?.image;
|
||||
return AnimatedSwitcher(
|
||||
duration: _easeTime,
|
||||
child: img == null
|
||||
? placeholder
|
||||
: Image.file(
|
||||
img,
|
||||
key: ValueKey(img.path),
|
||||
width: width,
|
||||
height: height,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DurationBadge extends StatelessWidget {
|
||||
final String path;
|
||||
const _DurationBadge(this.path);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => FutureBuilder<_ThumbInfo>(
|
||||
future: _thumbFor(path),
|
||||
builder: (context, snap) {
|
||||
final d = snap.data?.duration;
|
||||
if (d == null) return const SizedBox.shrink();
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black87,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
_fmt(d),
|
||||
style: const TextStyle(color: Colors.white, fontSize: 11),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,577 @@
|
||||
part of 'shell_demo.dart';
|
||||
|
||||
// DEBUG ONLY: a real media_kit player for the layout demo. One Player and
|
||||
// VideoController, shown by whichever view is active (mini, full or
|
||||
// fullscreen); only one Video widget is mounted at a time.
|
||||
|
||||
class _DemoMedia {
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final String? path;
|
||||
const _DemoMedia(this.title, this.subtitle, [this.path]);
|
||||
}
|
||||
|
||||
/// What is playing; null = nothing.
|
||||
final _nowPlaying = ValueNotifier<_DemoMedia?>(null);
|
||||
Player? _demoPlayer;
|
||||
VideoController? _demoVideo;
|
||||
|
||||
Future<void> _playMedia(_DemoMedia m) async {
|
||||
_nowPlaying.value = m;
|
||||
if (m.path == null) return;
|
||||
final p = _demoPlayer ??= Player();
|
||||
_demoVideo ??= VideoController(p);
|
||||
await p.open(Media(m.path!));
|
||||
}
|
||||
|
||||
Future<void> _disposeDemoPlayer() async {
|
||||
final p = _demoPlayer;
|
||||
_demoPlayer = null;
|
||||
_demoVideo = null;
|
||||
_nowPlaying.value = null;
|
||||
await p?.dispose();
|
||||
}
|
||||
|
||||
String _fmt(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)}';
|
||||
}
|
||||
|
||||
/// The video itself, or a placeholder when nothing real is open.
|
||||
class _VideoSurface extends StatelessWidget {
|
||||
final double placeholderSize;
|
||||
const _VideoSurface({this.placeholderSize = 32});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final v = _demoVideo;
|
||||
if (v == null || _nowPlaying.value?.path == null) {
|
||||
return Container(
|
||||
color: Colors.black,
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
'video · play something from Files',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: Colors.white24,
|
||||
fontSize: placeholderSize * 0.6,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return Video(controller: v, controls: NoVideoControls, fill: Colors.black);
|
||||
}
|
||||
}
|
||||
|
||||
/// Position bar + play/pause + time, bound to the real player (inert
|
||||
/// when nothing is open).
|
||||
class _Transport extends StatelessWidget {
|
||||
final bool compact;
|
||||
final List<Widget> trailing;
|
||||
const _Transport({this.compact = false, this.trailing = const []});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final p = _demoPlayer;
|
||||
if (p == null) {
|
||||
return Row(
|
||||
children: [
|
||||
const IconButton(
|
||||
onPressed: null,
|
||||
icon: Icon(Icons.play_arrow, color: Colors.white38),
|
||||
),
|
||||
const Text('--:-- / --:--', style: TextStyle(color: Colors.white38)),
|
||||
const Spacer(),
|
||||
...trailing,
|
||||
],
|
||||
);
|
||||
}
|
||||
return StreamBuilder<Duration>(
|
||||
stream: p.stream.position,
|
||||
builder: (context, _) {
|
||||
final pos = p.state.position, dur = p.state.duration;
|
||||
final frac = dur.inMilliseconds == 0
|
||||
? 0.0
|
||||
: pos.inMilliseconds / dur.inMilliseconds;
|
||||
final bar = SliderTheme(
|
||||
data: SliderTheme.of(context).copyWith(
|
||||
trackHeight: compact ? 3 : 4,
|
||||
thumbShape: RoundSliderThumbShape(
|
||||
enabledThumbRadius: compact ? 0 : 7,
|
||||
),
|
||||
overlayShape: SliderComponentShape.noOverlay,
|
||||
),
|
||||
child: Slider(
|
||||
value: frac.clamp(0.0, 1.0),
|
||||
onChanged: dur == Duration.zero ? null : (v) => p.seek(dur * v),
|
||||
),
|
||||
);
|
||||
final row = Row(
|
||||
children: [
|
||||
StreamBuilder<bool>(
|
||||
stream: p.stream.playing,
|
||||
builder: (context, _) => IconButton(
|
||||
onPressed: p.playOrPause,
|
||||
icon: Icon(
|
||||
p.state.playing ? Icons.pause : Icons.play_arrow,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${_fmt(pos)} / ${_fmt(dur)}',
|
||||
style: TextStyle(
|
||||
color: Colors.white70,
|
||||
fontSize: compact ? 12 : 14,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
...trailing,
|
||||
],
|
||||
);
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [if (!compact) bar, row, if (compact) bar],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Full and fullscreen player. Controls fade out after 2.5 s without the
|
||||
/// mouse moving; in fullscreen the party sits top-right and toasts stay.
|
||||
class _RealFullPlayer extends StatefulWidget {
|
||||
final bool fullscreen;
|
||||
final VoidCallback onCollapse, onToggleFullscreen;
|
||||
final Widget? partyOverlay;
|
||||
const _RealFullPlayer({
|
||||
required this.fullscreen,
|
||||
required this.onCollapse,
|
||||
required this.onToggleFullscreen,
|
||||
this.partyOverlay,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_RealFullPlayer> createState() => _RealFullPlayerState();
|
||||
}
|
||||
|
||||
class _RealFullPlayerState extends State<_RealFullPlayer> {
|
||||
bool _shown = true;
|
||||
Timer? _hide;
|
||||
|
||||
void _poke() {
|
||||
if (!_shown) setState(() => _shown = true);
|
||||
_hide?.cancel();
|
||||
_hide = Timer(const Duration(milliseconds: 2500), () {
|
||||
if (mounted) setState(() => _shown = false);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_poke();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_hide?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final m = _nowPlaying.value;
|
||||
final controls = AnimatedOpacity(
|
||||
duration: _easeTime,
|
||||
curve: _ease,
|
||||
opacity: _shown ? 1 : 0,
|
||||
child: IgnorePointer(
|
||||
ignoring: !_shown,
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 8, 28),
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [Colors.black87, Colors.transparent],
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 34,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white12,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
m?.title ?? 'Nothing playing',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
m?.subtitle ?? '',
|
||||
style: const TextStyle(color: Colors.white60),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (widget.partyOverlay != null) ...[
|
||||
widget.partyOverlay!,
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
if (!widget.fullscreen)
|
||||
IconButton(
|
||||
onPressed: widget.onCollapse,
|
||||
tooltip: 'Back to the library (keeps playing)',
|
||||
icon: const Icon(
|
||||
Icons.close_fullscreen,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.fromLTRB(16, 32, 16, 10),
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.bottomCenter,
|
||||
end: Alignment.topCenter,
|
||||
colors: [Colors.black87, Colors.transparent],
|
||||
),
|
||||
),
|
||||
child: _Transport(
|
||||
trailing: [
|
||||
Builder(
|
||||
builder: (context) => IconButton(
|
||||
tooltip: 'Player settings',
|
||||
onPressed: () => _popover(
|
||||
context,
|
||||
at: const Alignment(0.97, 0.78),
|
||||
child: const _PlayerSettingsCard(),
|
||||
),
|
||||
icon: const Icon(Icons.tune, color: Colors.white),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: widget.fullscreen
|
||||
? 'Exit fullscreen (Esc)'
|
||||
: 'Fullscreen (F)',
|
||||
onPressed: widget.onToggleFullscreen,
|
||||
icon: Icon(
|
||||
widget.fullscreen
|
||||
? Icons.fullscreen_exit
|
||||
: Icons.fullscreen,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
return CallbackShortcuts(
|
||||
bindings: {
|
||||
const SingleActivator(LogicalKeyboardKey.keyF):
|
||||
widget.onToggleFullscreen,
|
||||
const SingleActivator(LogicalKeyboardKey.escape): () {
|
||||
if (widget.fullscreen) widget.onToggleFullscreen();
|
||||
},
|
||||
const SingleActivator(LogicalKeyboardKey.space): () =>
|
||||
_demoPlayer?.playOrPause(),
|
||||
},
|
||||
child: Focus(
|
||||
autofocus: true,
|
||||
child: MouseRegion(
|
||||
onHover: (_) => _poke(),
|
||||
cursor: _shown ? MouseCursor.defer : SystemMouseCursors.none,
|
||||
child: GestureDetector(
|
||||
onDoubleTap: widget.onToggleFullscreen,
|
||||
onTap: () => _demoPlayer?.playOrPause(),
|
||||
child: Container(
|
||||
color: Colors.black,
|
||||
child: Stack(
|
||||
children: [
|
||||
const Positioned.fill(child: _VideoSurface()),
|
||||
Positioned.fill(child: controls),
|
||||
// A thin progress line stays when the controls hide.
|
||||
if (!_shown && _demoPlayer != null)
|
||||
const Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: _ThinProgress(),
|
||||
),
|
||||
// Paused: who everyone is waiting for, always visible.
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: _shown ? 96 : 28,
|
||||
child: const Center(child: _WaitingPill()),
|
||||
),
|
||||
// Fullscreen with the controls hidden: the party stays
|
||||
// in the corner while paused.
|
||||
if (widget.fullscreen &&
|
||||
!_shown &&
|
||||
widget.partyOverlay != null)
|
||||
Positioned(
|
||||
right: 16,
|
||||
top: 14,
|
||||
child: _PausedOnly(child: widget.partyOverlay!),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ThinProgress extends StatelessWidget {
|
||||
const _ThinProgress();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final p = _demoPlayer!;
|
||||
return StreamBuilder<Duration>(
|
||||
stream: p.stream.position,
|
||||
builder: (context, _) {
|
||||
final d = p.state.duration.inMilliseconds;
|
||||
return LinearProgressIndicator(
|
||||
value: d == 0 ? 0 : p.state.position.inMilliseconds / d,
|
||||
minHeight: 2,
|
||||
backgroundColor: Colors.transparent,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The gear in the player: everything about the player lives here.
|
||||
class _PlayerSettingsCard extends StatefulWidget {
|
||||
const _PlayerSettingsCard();
|
||||
|
||||
@override
|
||||
State<_PlayerSettingsCard> createState() => _PlayerSettingsCardState();
|
||||
}
|
||||
|
||||
class _PlayerSettingsCardState extends State<_PlayerSettingsCard> {
|
||||
String _design = 'Standard';
|
||||
bool _line = true;
|
||||
String _shader = 'Off';
|
||||
String _hwdec = 'Auto';
|
||||
double _subSize = 1;
|
||||
|
||||
Widget _row(String label, Widget control, {String? help}) => Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(label),
|
||||
if (help != null)
|
||||
Text(
|
||||
help,
|
||||
style: const TextStyle(color: Colors.grey, fontSize: 11),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
control,
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
Widget _drop(String value, List<String> items, ValueChanged<String> on) =>
|
||||
DropdownButton<String>(
|
||||
value: value,
|
||||
underline: const SizedBox.shrink(),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
items: [
|
||||
for (final i in items) DropdownMenuItem(value: i, child: Text(i)),
|
||||
],
|
||||
onChanged: (v) => setState(() => on(v!)),
|
||||
);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
elevation: 12,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: SizedBox(
|
||||
width: 380,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(18, 14, 18, 10),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const Text(
|
||||
'Player',
|
||||
style: TextStyle(fontWeight: FontWeight.w700, fontSize: 16),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
_row(
|
||||
'Player design',
|
||||
_drop(_design, const ['Standard', 'uosc'], (v) => _design = v),
|
||||
),
|
||||
_row(
|
||||
'Progress line when hidden',
|
||||
Switch(
|
||||
value: _line,
|
||||
onChanged: (v) => setState(() => _line = v),
|
||||
),
|
||||
help: 'A thin bar at the bottom while the controls are hidden',
|
||||
),
|
||||
_row(
|
||||
'Upscaling shader',
|
||||
_drop(_shader, const [
|
||||
'Off',
|
||||
'Anime4K A (HQ)',
|
||||
'Anime4K B (HQ)',
|
||||
'Anime4K A (Fast)',
|
||||
], (v) => _shader = v),
|
||||
help: 'Runs on your GPU; only you see it',
|
||||
),
|
||||
_row(
|
||||
'Hardware decoding',
|
||||
_drop(_hwdec, const ['Auto', 'On', 'Off'], (v) => _hwdec = v),
|
||||
),
|
||||
const Divider(),
|
||||
_row(
|
||||
'Subtitles',
|
||||
_drop('English · ASS', const [
|
||||
'Off',
|
||||
'English · ASS',
|
||||
'Signs only',
|
||||
], (_) {}),
|
||||
),
|
||||
_row(
|
||||
'Subtitle size',
|
||||
SizedBox(
|
||||
width: 140,
|
||||
child: Slider(
|
||||
value: _subSize,
|
||||
min: 0.6,
|
||||
max: 1.6,
|
||||
onChanged: (v) => setState(() => _subSize = v),
|
||||
),
|
||||
),
|
||||
),
|
||||
_row(
|
||||
'Audio',
|
||||
_drop('Japanese', const ['Japanese', 'English'], (_) {}),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
const Text(
|
||||
'Track choices carry to the next episode.',
|
||||
style: TextStyle(color: Colors.grey, fontSize: 11),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Shows [child] only while the demo player is paused (or has nothing).
|
||||
class _PausedOnly extends StatelessWidget {
|
||||
final Widget child;
|
||||
const _PausedOnly({required this.child});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final p = _demoPlayer;
|
||||
return StreamBuilder<bool>(
|
||||
stream: p?.stream.playing,
|
||||
builder: (context, _) {
|
||||
final paused = p == null || !p.state.playing;
|
||||
return AnimatedOpacity(
|
||||
duration: _easeTime,
|
||||
curve: _ease,
|
||||
opacity: paused ? 1 : 0,
|
||||
child: IgnorePointer(ignoring: !paused, child: child),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// "Waiting for Alex, Mika" or "Waiting for you · press Space", with
|
||||
/// their avatars; only while paused.
|
||||
class _WaitingPill extends StatelessWidget {
|
||||
const _WaitingPill();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final notReady = _members.where((m) => !m.ready && !m.stale).toList();
|
||||
final meWaiting = !_me.value.ready;
|
||||
final text = meWaiting
|
||||
? 'Waiting for you · press Space when ready'
|
||||
: notReady.isEmpty
|
||||
? 'Paused'
|
||||
: 'Waiting for ${notReady.map((m) => m.name).join(', ')}';
|
||||
return _PausedOnly(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.fromLTRB(8, 6, 16, 6),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(alpha: 0.72),
|
||||
borderRadius: BorderRadius.circular(30),
|
||||
border: Border.all(color: Colors.white12),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
for (final m in (meWaiting ? [_me.value] : notReady).take(4))
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 4),
|
||||
child: _Avatar(member: m, size: 28),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
text,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,355 @@
|
||||
part of 'shell_demo.dart';
|
||||
|
||||
// DEBUG ONLY: the Settings tab. App-wide things only; everything about
|
||||
// the player lives behind the gear in the player (pointer card at the top
|
||||
// of every page). Controls are placeholders that hold their own state.
|
||||
|
||||
class _SettingsTab extends StatefulWidget {
|
||||
final VoidCallback onOpenPlayerSettings;
|
||||
const _SettingsTab({required this.onOpenPlayerSettings});
|
||||
|
||||
@override
|
||||
State<_SettingsTab> createState() => _SettingsTabState();
|
||||
}
|
||||
|
||||
class _SettingsTabState extends State<_SettingsTab> {
|
||||
static const _sections = [
|
||||
(Icons.person_outline, 'Profile'),
|
||||
(Icons.hub_outlined, 'Party & network'),
|
||||
(Icons.extension_outlined, 'Jellyfin & integrations'),
|
||||
(Icons.notifications_none, 'Notifications & sounds'),
|
||||
(Icons.palette_outlined, 'Appearance'),
|
||||
(Icons.system_update_alt, 'Updates'),
|
||||
(Icons.info_outline, 'About'),
|
||||
];
|
||||
int _at = 0;
|
||||
final _bools = <String, bool>{
|
||||
'toast-chat': true,
|
||||
'toast-ready': true,
|
||||
'toast-seek': false,
|
||||
'unfocused': true,
|
||||
'sounds': true,
|
||||
'reduce-motion': false,
|
||||
'auto-update': true,
|
||||
'auto-next': true,
|
||||
'remember-folders': true,
|
||||
};
|
||||
|
||||
Widget _switch(String key, String title, [String? help]) => SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: Text(title),
|
||||
subtitle: help == null ? null : Text(help),
|
||||
value: _bools[key]!,
|
||||
onChanged: (v) => setState(() => _bools[key] = v),
|
||||
);
|
||||
|
||||
// Material, not a coloured box: list tiles paint their ripple on it.
|
||||
Widget _card(String title, List<Widget> children) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 16),
|
||||
child: Material(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerLow,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(18, 14, 18, 10),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(title, style: const TextStyle(fontWeight: FontWeight.w700)),
|
||||
const SizedBox(height: 6),
|
||||
...children,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
List<Widget> _page() {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
switch (_at) {
|
||||
case 0:
|
||||
return [
|
||||
_card('You', [
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: _Avatar(member: _me.value, size: 48, badges: false),
|
||||
title: Text(_me.value.name),
|
||||
subtitle: const Text(
|
||||
'Name, picture and colour: click your avatar in the top left',
|
||||
),
|
||||
),
|
||||
]),
|
||||
];
|
||||
case 1:
|
||||
return [
|
||||
_card('Hosting', [
|
||||
const ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: Text('Default port'),
|
||||
trailing: Text('8080'),
|
||||
),
|
||||
const ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: Text('Password'),
|
||||
subtitle: Text(
|
||||
'Asked when you host; blank = anyone who reaches the port can join',
|
||||
),
|
||||
),
|
||||
const ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: Text('Relay buffer'),
|
||||
subtitle: Text('Memory the server keeps for relayed videos'),
|
||||
trailing: Text('256 MB ahead · 64 MB back'),
|
||||
),
|
||||
]),
|
||||
_card('Recent servers', [
|
||||
for (final s in [
|
||||
'VPS · vps.example:8080',
|
||||
'Bob · 192.168.1.20:8080',
|
||||
])
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: const Icon(Icons.dns_outlined),
|
||||
title: Text(s),
|
||||
trailing: IconButton(
|
||||
onPressed: () {},
|
||||
icon: const Icon(Icons.close, size: 18),
|
||||
),
|
||||
),
|
||||
]),
|
||||
];
|
||||
case 2:
|
||||
return [
|
||||
_card('Jellyfin', [
|
||||
for (final (name, user) in [
|
||||
('Senko Jellyfin', 'foxyz'),
|
||||
('Bob\'s box', 'guest'),
|
||||
])
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: const Icon(Icons.video_library_outlined),
|
||||
title: Text(name),
|
||||
subtitle: Text('signed in as $user'),
|
||||
trailing: TextButton(
|
||||
onPressed: () {},
|
||||
child: const Text('Sign out'),
|
||||
),
|
||||
),
|
||||
TextButton.icon(
|
||||
onPressed: () {},
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('Add a Jellyfin server'),
|
||||
),
|
||||
]),
|
||||
_card('AnimeFlow', [
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: const Icon(Icons.downloading_outlined),
|
||||
title: const Text('anime.senkochan.moe'),
|
||||
subtitle: const Text(
|
||||
'Uses your Senko Jellyfin sign-in · can view, request, approve',
|
||||
),
|
||||
trailing: _Pill('connected', Colors.greenAccent.shade400),
|
||||
),
|
||||
Text(
|
||||
'Powers the Schedule tab: what is downloaded, downloading or missing, and requests.',
|
||||
style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12),
|
||||
),
|
||||
]),
|
||||
_card('Local files', [
|
||||
_switch(
|
||||
'remember-folders',
|
||||
'Remember folders',
|
||||
'Folders added in Files stay between launches',
|
||||
),
|
||||
]),
|
||||
];
|
||||
case 3:
|
||||
return [
|
||||
_card('Pop-ups', [
|
||||
_switch('toast-chat', 'Chat messages'),
|
||||
_switch('toast-ready', 'Ready / joined / left'),
|
||||
_switch(
|
||||
'toast-seek',
|
||||
'Seeks and skips',
|
||||
'Always in the activity log',
|
||||
),
|
||||
_switch(
|
||||
'unfocused',
|
||||
'Desktop notification when the app is in the background',
|
||||
),
|
||||
]),
|
||||
_card('Sounds', [
|
||||
_switch(
|
||||
'sounds',
|
||||
'Play sounds',
|
||||
'Soft cues for ready, join and chat; seeks stay silent',
|
||||
),
|
||||
]),
|
||||
];
|
||||
case 4:
|
||||
return [
|
||||
_card('Look', [
|
||||
const ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: Text('Theme'),
|
||||
trailing: Text('Dark'),
|
||||
),
|
||||
_switch(
|
||||
'reduce-motion',
|
||||
'Reduce motion',
|
||||
'Fades instead of slides, no spreading avatars',
|
||||
),
|
||||
]),
|
||||
_card('Playback', [
|
||||
_switch(
|
||||
'auto-next',
|
||||
'Play the next episode automatically',
|
||||
'10 s countdown at the end',
|
||||
),
|
||||
]),
|
||||
];
|
||||
case 5:
|
||||
return [
|
||||
_card('Updates', [
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: const Text('Watch together 0.2.2'),
|
||||
subtitle: const Text('0.3.0 is available'),
|
||||
trailing: FilledButton(
|
||||
onPressed: () {},
|
||||
child: const Text('Update and restart'),
|
||||
),
|
||||
),
|
||||
_switch('auto-update', 'Check for updates on launch'),
|
||||
]),
|
||||
];
|
||||
default:
|
||||
return [
|
||||
_card('About', [
|
||||
const Text(
|
||||
'Watch together for friends: synced playback, Jellyfin, relay.',
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'mpv · media_kit · Flutter',
|
||||
style: TextStyle(color: scheme.onSurfaceVariant),
|
||||
),
|
||||
]),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final pointer = Container(
|
||||
margin: const EdgeInsets.only(bottom: 16),
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: scheme.primary.withValues(alpha: 0.08),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.tune, color: scheme.primary),
|
||||
const SizedBox(width: 12),
|
||||
const Expanded(
|
||||
child: Text(
|
||||
'Player design, shaders, subtitles and audio live in the player: the ⚙ in its controls.',
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: widget.onOpenPlayerSettings,
|
||||
child: const Text('Open player settings'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
return LayoutBuilder(
|
||||
builder: (context, box) {
|
||||
final narrow = box.maxWidth < 760;
|
||||
final nav = narrow
|
||||
? SizedBox(
|
||||
height: 52,
|
||||
child: _HScroll(
|
||||
builder: (c) => ListView(
|
||||
controller: c,
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.fromLTRB(20, 8, 20, 4),
|
||||
children: [
|
||||
for (var i = 0; i < _sections.length; i++)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: ChoiceChip(
|
||||
label: Text(_sections[i].$2),
|
||||
selected: _at == i,
|
||||
onSelected: (_) => setState(() => _at = i),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
: SizedBox(
|
||||
width: 250,
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(16, 20, 8, 20),
|
||||
children: [
|
||||
for (var i = 0; i < _sections.length; i++)
|
||||
ListTile(
|
||||
selected: _at == i,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
leading: Icon(_sections[i].$1, size: 20),
|
||||
title: Text(_sections[i].$2),
|
||||
onTap: () => setState(() => _at = i),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
final page = _fadeSwitch(
|
||||
KeyedSubtree(
|
||||
key: ValueKey(_at),
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(20, 20, 24, 24),
|
||||
children: [
|
||||
Align(
|
||||
alignment: Alignment.topLeft,
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 760),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
_sections[_at].$2,
|
||||
style: Theme.of(context).textTheme.headlineSmall,
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
pointer,
|
||||
..._page(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
return narrow
|
||||
? Column(
|
||||
children: [
|
||||
nav,
|
||||
Expanded(child: page),
|
||||
],
|
||||
)
|
||||
: Row(
|
||||
children: [
|
||||
nav,
|
||||
Expanded(child: page),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,8 +1,10 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
|
||||
import '../../update/update_controller.dart';
|
||||
import '../../widgets/window_buttons.dart';
|
||||
import '../demo/shell_demo.dart';
|
||||
import 'profile.dart';
|
||||
import 'update_indicator.dart';
|
||||
|
||||
@@ -66,6 +68,13 @@ class HomeTopBar extends StatelessWidget {
|
||||
const Spacer(),
|
||||
UpdateIndicator(controller: updates),
|
||||
const SizedBox(width: 8),
|
||||
if (kDebugMode)
|
||||
IconButton(
|
||||
tooltip: 'Layout demo (debug builds only)',
|
||||
icon: const Icon(Icons.bug_report_outlined, size: 20, color: Colors.amber),
|
||||
onPressed: () => Navigator.of(context)
|
||||
.push(MaterialPageRoute<void>(builder: (_) => const ShellDemo())),
|
||||
),
|
||||
for (final (v, icon, selectedIcon, label) in _pages)
|
||||
IconButton(
|
||||
tooltip: label,
|
||||
|
||||
Reference in new issue
Block a user