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:flutter/material.dart';
|
||||||
import 'package:window_manager/window_manager.dart';
|
import 'package:window_manager/window_manager.dart';
|
||||||
|
|
||||||
import '../../update/update_controller.dart';
|
import '../../update/update_controller.dart';
|
||||||
import '../../widgets/window_buttons.dart';
|
import '../../widgets/window_buttons.dart';
|
||||||
|
import '../demo/shell_demo.dart';
|
||||||
import 'profile.dart';
|
import 'profile.dart';
|
||||||
import 'update_indicator.dart';
|
import 'update_indicator.dart';
|
||||||
|
|
||||||
@@ -66,6 +68,13 @@ class HomeTopBar extends StatelessWidget {
|
|||||||
const Spacer(),
|
const Spacer(),
|
||||||
UpdateIndicator(controller: updates),
|
UpdateIndicator(controller: updates),
|
||||||
const SizedBox(width: 8),
|
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)
|
for (final (v, icon, selectedIcon, label) in _pages)
|
||||||
IconButton(
|
IconButton(
|
||||||
tooltip: label,
|
tooltip: label,
|
||||||
|
|||||||
Reference in new issue
Block a user