diff --git a/apps/desktop/lib/src/screens/demo/demo_files.dart b/apps/desktop/lib/src/screens/demo/demo_files.dart new file mode 100644 index 0000000..edbf19e --- /dev/null +++ b/apps/desktop/lib/src/screens/demo/demo_files.dart @@ -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 hiddenExamples; + final bool truncated; + const _FolderScan( + this.videos, + this.hidden, + this.subtitles, + this.hiddenExamples, + this.truncated, + ); +} + +final _folders = ValueNotifier>([]); +final _scans = {}; +final _recentFiles = ValueNotifier>([]); + +/// 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 = []; + 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 _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 _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 _browse() async { + List 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 _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 = [ + // 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 = >{}; + 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 = >{}; +var _thumbRunning = 0; +final _thumbQueue = >[]; + +Future<_ThumbInfo> _thumbFor(String path) => + _thumbs.putIfAbsent(path, () async { + // At most 3 ffmpeg runs at a time. + if (_thumbRunning >= 3) { + final c = Completer(); + _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), + ), + ); + }, + ); +} diff --git a/apps/desktop/lib/src/screens/demo/demo_player.dart b/apps/desktop/lib/src/screens/demo/demo_player.dart new file mode 100644 index 0000000..dc7c025 --- /dev/null +++ b/apps/desktop/lib/src/screens/demo/demo_player.dart @@ -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 _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 _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 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( + 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( + 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( + 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 items, ValueChanged on) => + DropdownButton( + 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( + 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, + ), + ), + ], + ), + ), + ); + } +} diff --git a/apps/desktop/lib/src/screens/demo/demo_schedule.dart b/apps/desktop/lib/src/screens/demo/demo_schedule.dart new file mode 100644 index 0000000..0e9b700 --- /dev/null +++ b/apps/desktop/lib/src/screens/demo/demo_schedule.dart @@ -0,0 +1,1146 @@ +part of 'shell_demo.dart'; + +// DEBUG ONLY: the Schedule tab on real data. +// - AniList airingSchedules (public, no login): every episode airing from +// last week's Monday to four weeks out, exact times, covers, banners. +// - AnimeFlow (https://anime.senkochan.moe/api/app/v1, authenticated with +// the linked Senko Jellyfin account's own token): status per episode, +// matched by the episode's AniList id + episode number, else by AniList +// id + air day. Read-only: requests are simulated, nothing is sent. + +const _animeFlowBase = 'https://anime.senkochan.moe/api/app/v1'; + +enum _EpStatus { + available, + downloading, + upcoming, + missing, + failed, + unmonitored, + notTracked, +} + +class _Airing { + final int anilistId; + final String title; + final int episode; + final DateTime at; // local + final String? cover, banner; + final Color tint; + final int popularity; + _EpStatus status; + double? progress; + String? reason; + bool followed = false; + bool tracked = false; + _Airing({ + required this.anilistId, + required this.title, + required this.episode, + required this.at, + this.cover, + this.banner, + required this.tint, + required this.popularity, + required this.status, + }); +} + +class _Req { + final String who; + final String what; + String status; + final String? reason; + _Req(this.who, this.what, this.status, [this.reason]); +} + +final _requests = ValueNotifier>([]); + +Color _hex(String? s, Color fallback) { + if (s == null || !s.startsWith('#') || s.length != 7) return fallback; + return Color(int.parse('ff${s.substring(1)}', radix: 16)); +} + +Future _getJson( + HttpClient http, + Uri url, { + Map headers = const {}, +}) async { + final req = await http.getUrl(url).timeout(const Duration(seconds: 15)); + headers.forEach(req.headers.set); + final res = await req.close().timeout(const Duration(seconds: 20)); + final body = await res.transform(const Utf8Decoder()).join(); + if (res.statusCode != 200) { + throw HttpException( + 'HTTP ${res.statusCode} ${body.length > 120 ? body.substring(0, 120) : body}', + uri: url, + ); + } + return jsonDecode(body); +} + +/// AniList: all airings in [from, to), paged (50 per page; AniList allows +/// 90 requests/min). +/// AniList: all airings in [from, to), paged (50 per page). AniList's +/// limit is per minute, so the raw pages are cached: in memory for 10 min +/// (tab switches), on disk for an hour (restarts). A 429 waits out +/// Retry-After once; if AniList still refuses, stale cached data is used. +class _AniListCache { + final DateTime fetched; + final int from; + final List raw; + const _AniListCache(this.fetched, this.from, this.raw); +} + +_AniListCache? _aniMem; + +File get _aniDisk => + File('${Directory.systemTemp.path}/wt-demo-anilist-schedule.json'); + +Future<_AniListCache?> _aniFromDisk(int from) async { + try { + final j = jsonDecode(await _aniDisk.readAsString()) as Map; + if (j['from'] != from) return null; + return _AniListCache( + DateTime.fromMillisecondsSinceEpoch(j['fetched'] as int), + from, + (j['raw'] as List).cast(), + ); + } catch (_) { + return null; + } +} + +/// The raw airings, and when they were fetched if AniList refused and +/// older cached data is returned instead. +Future<(List, DateTime?)> _anilistRaw( + HttpClient http, + DateTime from, + DateTime to, +) async { + final key = from.millisecondsSinceEpoch ~/ 1000; + final mem = _aniMem; + if (mem != null && + mem.from == key && + DateTime.now().difference(mem.fetched) < const Duration(minutes: 10)) { + return (mem.raw, null); + } + final disk = await _aniFromDisk(key); + if (disk != null && + DateTime.now().difference(disk.fetched) < const Duration(hours: 1)) { + _aniMem = disk; + return (disk.raw, null); + } + try { + final raw = await _anilistFetch(http, from, to); + final c = _aniMem = _AniListCache(DateTime.now(), key, raw); + unawaited( + _aniDisk + .writeAsString( + jsonEncode({ + 'fetched': c.fetched.millisecondsSinceEpoch, + 'from': key, + 'raw': raw, + }), + ) + .then((_) {}, onError: (_) {}), + ); + return (raw, null); + } catch (e) { + final stale = mem?.from == key ? mem : disk; + if (stale != null) return (stale.raw, stale.fetched); + rethrow; + } +} + +Future> _anilistFetch( + HttpClient http, + DateTime from, + DateTime to, +) async { + const query = r""" +query($from:Int,$to:Int,$page:Int){Page(page:$page,perPage:50){pageInfo{hasNextPage} +airingSchedules(airingAt_greater:$from,airingAt_lesser:$to,sort:TIME){airingAt episode +media{id title{romaji english} coverImage{large color} bannerImage isAdult popularity format}}}}"""; + final out = []; + for (var page = 1; page <= 20; page++) { + Map? data; + for (var attempt = 0; attempt < 2 && data == null; attempt++) { + final req = await http + .postUrl(Uri.parse('https://graphql.anilist.co')) + .timeout(const Duration(seconds: 15)); + req.headers + ..contentType = ContentType.json + ..set('Accept', 'application/json'); + req.write( + jsonEncode({ + 'query': query, + 'variables': { + 'from': from.millisecondsSinceEpoch ~/ 1000, + 'to': to.millisecondsSinceEpoch ~/ 1000, + 'page': page, + }, + }), + ); + final res = await req.close().timeout(const Duration(seconds: 20)); + final body = await res.transform(const Utf8Decoder()).join(); + if (res.statusCode == 429 && attempt == 0) { + final wait = int.tryParse(res.headers.value('retry-after') ?? '') ?? 30; + await Future.delayed(Duration(seconds: wait.clamp(1, 65))); + continue; + } + if (res.statusCode != 200) { + throw HttpException('AniList HTTP ${res.statusCode}'); + } + data = (jsonDecode(body) as Map)['data']['Page'] as Map; + } + if (data == null) throw const HttpException('AniList HTTP 429'); + out.addAll((data['airingSchedules'] as List).cast()); + if ((data['pageInfo'] as Map)['hasNextPage'] != true) break; + } + return out; +} + +/// Fresh [_Airing]s from raw AniList entries (the overlay mutates them). +List<_Airing> _airingsFrom(List raw) { + final out = <_Airing>[]; + for (final a in raw) { + final m = a['media'] as Map; + if (m['isAdult'] == true || m['format'] == 'MUSIC') continue; + final t = m['title'] as Map; + final at = DateTime.fromMillisecondsSinceEpoch( + (a['airingAt'] as int) * 1000, + ); + out.add( + _Airing( + anilistId: m['id'] as int, + title: (t['english'] as String?) ?? (t['romaji'] as String?) ?? '?', + episode: a['episode'] as int, + at: at, + cover: (m['coverImage'] as Map?)?['large'] as String?, + banner: m['bannerImage'] as String?, + tint: _hex( + (m['coverImage'] as Map?)?['color'] as String?, + Colors.blueGrey, + ), + popularity: (m['popularity'] as int?) ?? 0, + status: at.isAfter(DateTime.now()) + ? _EpStatus.upcoming + : _EpStatus.notTracked, + ), + ); + } + return out; +} + +class _FlowState { + final String? user; + final List scopes; + final String? error; + const _FlowState({this.user, this.scopes = const [], this.error}); +} + +/// The linked Jellyfin account on senkochan.moe (AnimeFlow accepts its +/// token), or null. +Future _senkoToken() async { + final acc = JellyfinAccounts.instance; + await acc.ensureLoaded(validate: false); + for (final a in acc.accounts) { + if (a.urls.any((u) => u.contains('senkochan.moe'))) return a.token; + } + return null; +} + +String _utc(DateTime d) => '${d.toUtc().toIso8601String().split('.').first}Z'; + +/// Overlays AnimeFlow's statuses onto [items]; returns who we are there. +Future<_FlowState> _overlayAnimeFlow( + HttpClient http, + List<_Airing> items, + DateTime from, + DateTime to, +) async { + final token = await _senkoToken(); + if (token == null) { + return const _FlowState( + error: 'Link the Senko Jellyfin server to see AnimeFlow statuses', + ); + } + final h = {'X-Emby-Token': token}; + final me = + await _getJson(http, Uri.parse('$_animeFlowBase/me'), headers: h) as Map; + final sched = await _getJson( + http, + Uri.parse('$_animeFlowBase/schedule') + .replace(queryParameters: {'from': _utc(from), 'to': _utc(to)}), + headers: h, + ) as Map; + final series = await _getJson( + http, + Uri.parse('$_animeFlowBase/series'), + headers: h, + ) as Map; + final tracked = {}; + final followed = {}; + for (final s in (series['items'] as List).cast()) { + final id = s['anilist_id']; + if (id is int) { + tracked.add(id); + if (s['followed'] == true) followed.add(id); + } + } + final byEp = {}; + final byDay = {}; + for (final e in (sched['items'] as List).cast()) { + final id = e['anilist_id']; + if (id is! int) continue; + tracked.add(id); + if (e['followed'] == true) followed.add(id); + byEp['$id#${e['episode']}'] = e; + final air = e['air_date'] is String + ? DateTime.tryParse(e['air_date'] as String)?.toLocal() + : null; + if (air != null) byDay['$id#${air.year}-${air.month}-${air.day}'] = e; + } + for (final a in items) { + a.tracked = tracked.contains(a.anilistId); + a.followed = followed.contains(a.anilistId); + final e = + byEp['${a.anilistId}#${a.episode}'] ?? + byDay['${a.anilistId}#${a.at.year}-${a.at.month}-${a.at.day}']; + if (e != null) { + a.status = switch (e['status']) { + 'available' => _EpStatus.available, + 'downloading' => _EpStatus.downloading, + 'missing' => _EpStatus.missing, + 'failed' => _EpStatus.failed, + 'unmonitored' => _EpStatus.unmonitored, + _ => _EpStatus.upcoming, + }; + a.progress = (e['progress'] as num?)?.toDouble(); + a.reason = e['reason'] as String?; + } else if (a.tracked && a.at.isBefore(DateTime.now())) { + a.status = + _EpStatus.missing; // tracked and aired, AnimeFlow has nothing for it + } + } + final scopes = ((me['scopes'] as List?) ?? const []).cast(); + // Requests, read-only. + try { + final reqs = await _getJson( + http, + Uri.parse('$_animeFlowBase/requests').replace( + queryParameters: scopes.contains('manage') ? {'scope': 'all'} : null, + ), + headers: h, + ) as Map; + _requests.value = [ + for (final r in (reqs['items'] as List).cast().take(40)) + _Req( + (r['user_name'] as String?) ?? (me['name'] as String? ?? 'you'), + (r['title'] as String?) ?? '?', + (r['status'] as String?) ?? '?', + r['reason'] as String?, + ), + ]; + } catch (_) {} + return _FlowState(user: me['name'] as String?, scopes: scopes); +} + +class _ScheduleTab extends StatefulWidget { + final bool online; + final VoidCallback onPlay; + final void Function(String text) onToast; + const _ScheduleTab({ + required this.online, + required this.onPlay, + required this.onToast, + }); + + @override + State<_ScheduleTab> createState() => _ScheduleTabState(); +} + +class _ScheduleTabState extends State<_ScheduleTab> { + List<_Airing> _items = const []; + String? _anilistError; + + /// Set when AniList refused and cached data from then is shown. + DateTime? _staleSince; + _FlowState? _flow; + bool _loading = true; + final _scroll = ScrollController(); + String _filter = 'Jellyfin'; + bool _compact = false; + bool _niche = false; + bool _sheet = false; + final _requested = {}; + static const _colW = 252.0; + + late final DateTime _start = () { + final now = DateTime.now(); + final today = DateTime(now.year, now.month, now.day); + return today.subtract( + Duration(days: today.weekday - 1 + 7), + ); // last week's Monday + }(); + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + final http = HttpClient(); + final end = _start.add(const Duration(days: 28)); + try { + final (raw, staleSince) = await _anilistRaw(http, _start, end); + final items = _airingsFrom(raw); + _staleSince = staleSince; + _FlowState flow; + try { + flow = await _overlayAnimeFlow(http, items, _start, end); + } catch (e) { + flow = _FlowState(error: 'AnimeFlow: $e'); + } + if (!mounted) return; + setState(() { + _items = items; + _flow = flow; + _loading = false; + if (flow.error != null) _filter = 'All'; + }); + WidgetsBinding.instance.addPostFrameCallback((_) => _jumpToday()); + } catch (e) { + if (mounted) { + setState(() { + _anilistError = '$e'; + _loading = false; + }); + } + } finally { + http.close(); + } + } + + void _jumpToday({bool animate = false}) { + if (!_scroll.hasClients) return; + final x = (7 * (_colW + 12) - 12).clamp( + 0.0, + _scroll.position.maxScrollExtent, + ); + animate + ? _scroll.animateTo(x, duration: _easeTime, curve: _ease) + : _scroll.jumpTo(x); + } + + @override + void dispose() { + _scroll.dispose(); + super.dispose(); + } + + List<_Airing> get _shown => _items + .where( + (a) => switch (_filter) { + 'Jellyfin' => a.tracked, + 'Following' => a.followed, + _ => _niche || a.popularity >= 5000 || a.tracked, + }, + ) + .toList(); + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + final now = DateTime.now(); + final today = DateTime(now.year, now.month, now.day); + final days = [for (var d = 0; d < 28; d++) _start.add(Duration(days: d))]; + final shown = _shown; + final flow = _flow; + final owner = flow?.scopes.contains('manage') ?? false; + final header = Padding( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 8), + child: Wrap( + spacing: 12, + runSpacing: 8, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + SegmentedButton( + showSelectedIcon: false, + segments: const [ + ButtonSegment( + value: 'Jellyfin', + icon: Icon(Icons.video_library_outlined), + label: Text('On Jellyfin'), + ), + ButtonSegment( + value: 'Following', + icon: Icon(Icons.bookmark_outline), + label: Text('Following'), + ), + ButtonSegment( + value: 'All', + icon: Icon(Icons.public), + label: Text('All airing'), + ), + ], + selected: {_filter}, + onSelectionChanged: (s) => setState(() => _filter = s.first), + ), + FilterChip( + label: const Text('Compact'), + selected: _compact, + onSelected: (v) => setState(() => _compact = v), + ), + if (_filter == 'All') + FilterChip( + label: const Text('Niche too'), + selected: _niche, + onSelected: (v) => setState(() => _niche = v), + ), + TextButton.icon( + onPressed: () => _jumpToday(animate: true), + icon: const Icon(Icons.today, size: 18), + label: const Text('Today'), + ), + OutlinedButton.icon( + onPressed: () => setState(() => _sheet = !_sheet), + icon: const Icon(Icons.inbox_outlined, size: 18), + label: Text(owner ? 'Requests' : 'My requests'), + ), + if (_staleSince != null) + _Pill( + 'AniList busy · showing data from ' + '${_staleSince!.hour.toString().padLeft(2, '0')}:${_staleSince!.minute.toString().padLeft(2, '0')}', + Colors.amber, + ), + if (flow != null) + flow.error != null + ? _Pill(flow.error!, Colors.orangeAccent) + : _Pill( + 'AnimeFlow · ${flow.user} · ${flow.scopes.join(', ')}', + Colors.greenAccent.shade400, + ), + ], + ), + ); + Widget board; + if (_loading) { + board = const Center(child: CircularProgressIndicator()); + } else if (_anilistError != null) { + board = Center( + child: Text( + 'AniList unreachable: $_anilistError', + style: TextStyle(color: scheme.error), + ), + ); + } else { + board = _HScroll( + controller: _scroll, + builder: (c) => ListView.separated( + controller: c, + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.fromLTRB(20, 4, 20, 16), + itemCount: days.length, + separatorBuilder: (_, __) => const SizedBox(width: 12), + itemBuilder: (context, i) { + final d = days[i]; + final items = shown + .where( + (a) => + a.at.year == d.year && + a.at.month == d.month && + a.at.day == d.day, + ) + .toList(); + return SizedBox( + width: _colW, + child: _DayColumn( + day: d, + today: d == today, + past: d.isBefore(today), + items: items, + compact: _compact, + online: widget.online, + requested: _requested, + onPlay: (a) { + _playMedia(_DemoMedia(a.title, 'Episode ${a.episode}')); + widget.onPlay(); + }, + onRequest: (a) { + setState(() => _requested.add('${a.anilistId}#${a.episode}')); + _requests.value = [ + _Req( + _me.value.name, + a.tracked + ? '${a.title} E${a.episode}' + : '${a.title} · track series', + 'approved', + ), + ..._requests.value, + ]; + widget.onToast( + 'Demo: would request ${a.title} (nothing sent)', + ); + }, + ), + ); + }, + ), + ); + } + return Row( + children: [ + Expanded( + child: Column( + children: [ + header, + Expanded(child: board), + ], + ), + ), + ClipRect( + child: AnimatedContainer( + duration: _easeTime, + curve: _ease, + width: _sheet ? 360 : 0, + child: OverflowBox( + alignment: Alignment.centerLeft, + minWidth: 360, + maxWidth: 360, + child: _RequestsSheet( + owner: owner, + onClose: () => setState(() => _sheet = false), + ), + ), + ), + ), + ], + ); + } +} + +class _DayColumn extends StatelessWidget { + final DateTime day; + final bool today, past, compact, online; + final List<_Airing> items; + final Set requested; + final void Function(_Airing) onPlay, onRequest; + const _DayColumn({ + required this.day, + required this.today, + required this.past, + required this.items, + required this.compact, + required this.online, + required this.requested, + required this.onPlay, + required this.onRequest, + }); + + static const _wd = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + return Container( + decoration: BoxDecoration( + color: today + ? scheme.primary.withValues(alpha: 0.06) + : scheme.surfaceContainerLow.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(14), + border: today + ? Border.all(color: scheme.primary.withValues(alpha: 0.5)) + : null, + ), + child: Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(12, 10, 12, 8), + child: Row( + children: [ + Text( + today ? 'Today' : _wd[day.weekday - 1], + style: TextStyle( + fontWeight: FontWeight.w700, + color: today ? scheme.primary : null, + ), + ), + const SizedBox(width: 6), + Text( + '${day.day}.${day.month}.', + style: TextStyle(color: scheme.onSurfaceVariant), + ), + const Spacer(), + if (items.isNotEmpty) + Text( + '${items.length}', + style: TextStyle( + color: scheme.onSurfaceVariant, + fontSize: 12, + ), + ), + ], + ), + ), + Expanded( + child: Opacity( + opacity: past && !today ? 0.85 : 1, + child: items.isEmpty + ? Center( + child: Text( + '—', + style: TextStyle(color: scheme.onSurfaceVariant), + ), + ) + : ListView.separated( + padding: const EdgeInsets.fromLTRB(8, 0, 8, 10), + itemCount: items.length, + separatorBuilder: (_, __) => + SizedBox(height: compact ? 6 : 10), + itemBuilder: (context, i) => _AiringCard( + a: items[i], + compact: compact, + online: online, + requested: requested.contains( + '${items[i].anilistId}#${items[i].episode}', + ), + onPlay: () => onPlay(items[i]), + onRequest: () => onRequest(items[i]), + ), + ), + ), + ), + ], + ), + ); + } +} + +class _AiringCard extends StatelessWidget { + final _Airing a; + final bool compact, online, requested; + final VoidCallback onPlay, onRequest; + const _AiringCard({ + required this.a, + required this.compact, + required this.online, + required this.requested, + required this.onPlay, + required this.onRequest, + }); + + (String, Color, IconData) _badge() => switch (a.status) { + _EpStatus.available => ( + 'on Jellyfin', + Colors.greenAccent, + Icons.check_circle, + ), + _EpStatus.downloading => ( + a.progress == null + ? 'downloading' + : 'downloading ${(a.progress! * 100).round()}%', + Colors.lightBlueAccent, + Icons.downloading, + ), + _EpStatus.upcoming => (_countdown(), Colors.amber, Icons.schedule), + _EpStatus.missing => ( + 'missing', + Colors.orangeAccent, + Icons.report_gmailerrorred, + ), + _EpStatus.failed => ( + 'failed${a.reason == null ? '' : ' · ${a.reason}'}', + Colors.redAccent, + Icons.error_outline, + ), + _EpStatus.unmonitored => ( + 'not monitored', + Colors.grey, + Icons.visibility_off_outlined, + ), + _EpStatus.notTracked => ('not in AnimeFlow', Colors.grey, Icons.public), + }; + + String _countdown() { + final d = a.at.difference(DateTime.now()); + if (d.isNegative) return 'aired'; + if (d.inDays > 0) return 'in ${d.inDays}d ${d.inHours % 24}h'; + if (d.inHours > 0) return 'in ${d.inHours}h ${d.inMinutes % 60}m'; + return 'in ${d.inMinutes}m'; + } + + Widget _image( + String? url, + Widget fallback, { + Alignment align = Alignment.center, + }) => url == null + ? fallback + : Image.network( + url, + fit: BoxFit.cover, + alignment: align, + errorBuilder: (_, __, ___) => fallback, + ); + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + final (label, color, icon) = _badge(); + final time = + '${a.at.hour.toString().padLeft(2, '0')}:${a.at.minute.toString().padLeft(2, '0')}'; + final action = switch (a.status) { + _EpStatus.available => FilledButton.tonalIcon( + onPressed: onPlay, + icon: const Icon(Icons.play_arrow, size: 18), + label: Text(online ? 'Put it up' : 'Play'), + ), + _EpStatus.missing || _EpStatus.failed || _EpStatus.notTracked => + requested + ? const OutlinedButton(onPressed: null, child: Text('Requested')) + : OutlinedButton.icon( + onPressed: onRequest, + icon: const Icon(Icons.download, size: 18), + label: Text( + a.status == _EpStatus.notTracked + ? 'Request series' + : 'Request', + ), + ), + _ => null, + }; + final tint = Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + a.tint.withValues(alpha: 0.85), + a.tint.withValues(alpha: 0.35), + ], + ), + ), + ); + if (compact) { + return Material( + color: scheme.surfaceContainer, + borderRadius: BorderRadius.circular(10), + child: InkWell( + borderRadius: BorderRadius.circular(10), + onTap: a.status == _EpStatus.available ? onPlay : null, + child: Padding( + padding: const EdgeInsets.all(6), + child: Row( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(6), + child: SizedBox( + width: 34, + height: 48, + child: _image(a.cover, tint), + ), + ), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + a.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontWeight: FontWeight.w600, + fontSize: 13, + ), + ), + Text( + '$time · E${a.episode}', + style: TextStyle( + color: scheme.onSurfaceVariant, + fontSize: 11, + ), + ), + ], + ), + ), + Tooltip( + message: label, + child: Icon(icon, size: 16, color: color), + ), + ], + ), + ), + ), + ); + } + // The cover poster leads: it is what people recognise at a glance. + return Material( + color: scheme.surfaceContainer, + borderRadius: BorderRadius.circular(12), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: a.status == _EpStatus.available ? onPlay : null, + child: Padding( + padding: const EdgeInsets.all(8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: SizedBox( + width: 84, + height: 120, + child: Stack( + fit: StackFit.expand, + children: [ + _image(a.cover, tint), + Positioned( + left: 4, + top: 4, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 5, + vertical: 1, + ), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.7), + borderRadius: BorderRadius.circular(5), + ), + child: Text( + time, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w700, + fontSize: 12, + ), + ), + ), + ), + if (a.status == _EpStatus.downloading) + Positioned( + left: 0, + right: 0, + bottom: 0, + child: LinearProgressIndicator( + value: a.progress, + minHeight: 3, + color: Colors.lightBlueAccent, + ), + ), + ], + ), + ), + ), + const SizedBox(width: 10), + Expanded( + child: SizedBox( + height: 120, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + a.title, + maxLines: 3, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontWeight: FontWeight.w600, + height: 1.25, + ), + ), + const SizedBox(height: 4), + Row( + children: [ + Text( + 'Episode ${a.episode}', + style: TextStyle( + color: scheme.onSurfaceVariant, + fontSize: 12, + ), + ), + if (a.followed) ...[ + const SizedBox(width: 6), + Icon( + Icons.bookmark, + size: 14, + color: scheme.primary, + ), + ], + ], + ), + const Spacer(), + Row( + children: [ + Icon(icon, size: 14, color: color), + const SizedBox(width: 4), + Expanded( + child: Text( + label, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: color, fontSize: 12), + ), + ), + ], + ), + ], + ), + ), + ), + ], + ), + if (action != null) ...[ + const SizedBox(height: 8), + SizedBox(width: double.infinity, child: action), + ], + ], + ), + ), + ), + ); + } +} + +class _RequestsSheet extends StatelessWidget { + final bool owner; + final VoidCallback onClose; + const _RequestsSheet({required this.owner, required this.onClose}); + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + return Material( + color: scheme.surfaceContainerLow, + child: ValueListenableBuilder( + valueListenable: _requests, + builder: (context, reqs, _) { + Widget tile(_Req r) { + final color = switch (r.status) { + 'pending' || 'searching' => Colors.amber, + 'approved' => Colors.lightBlueAccent, + 'rejected' || 'failed' => Colors.redAccent, + _ => Colors.greenAccent, + }; + return Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _Avatar( + member: _members.firstWhere( + (m) => m.name == r.who, + orElse: () => _Member(r.who, Colors.blueGrey), + ), + size: 32, + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + r.what, + style: const TextStyle(fontWeight: FontWeight.w600), + ), + const SizedBox(height: 2), + Wrap( + spacing: 8, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + Text( + r.who, + style: TextStyle( + color: scheme.onSurfaceVariant, + fontSize: 12, + ), + ), + _Pill(r.status, color), + ], + ), + if (r.reason != null) + Text( + r.reason!, + style: TextStyle( + color: scheme.onSurfaceVariant, + fontSize: 11, + ), + ), + if (owner && + (r.status == 'failed' || + r.status == 'approved' || + r.status == 'searching')) + Padding( + padding: const EdgeInsets.only(top: 6), + child: Row( + children: [ + if (r.status == 'failed') + FilledButton.tonal( + onPressed: () {}, + child: const Text('Retry'), + ), + if (r.status != 'failed') + TextButton( + onPressed: () {}, + child: const Text('Decline'), + ), + ], + ), + ), + ], + ), + ), + ], + ), + ); + } + + return ListView( + padding: const EdgeInsets.fromLTRB(16, 10, 8, 16), + children: [ + Row( + children: [ + Text( + owner ? 'Requests' : 'My requests', + style: const TextStyle( + fontWeight: FontWeight.w700, + fontSize: 16, + ), + ), + const Spacer(), + IconButton( + onPressed: onClose, + icon: const Icon(Icons.close, size: 18), + ), + ], + ), + Text( + owner + ? 'Everyone\'s requests, read from AnimeFlow. Requests are approved automatically; ' + 'you can decline or retry. (Demo: these buttons do nothing.)' + : 'AnimeFlow downloads requests automatically; they appear on Jellyfin when done.', + style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12), + ), + const SizedBox(height: 12), + if (reqs.isEmpty) + Padding( + padding: const EdgeInsets.only(top: 24), + child: Center( + child: Text( + 'No requests yet', + style: TextStyle(color: scheme.onSurfaceVariant), + ), + ), + ), + for (final r in reqs) tile(r), + ], + ); + }, + ), + ); + } +} diff --git a/apps/desktop/lib/src/screens/demo/demo_settings.dart b/apps/desktop/lib/src/screens/demo/demo_settings.dart new file mode 100644 index 0000000..7736e68 --- /dev/null +++ b/apps/desktop/lib/src/screens/demo/demo_settings.dart @@ -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 = { + '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 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 _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), + ], + ); + }, + ); + } +} diff --git a/apps/desktop/lib/src/screens/demo/shell_demo.dart b/apps/desktop/lib/src/screens/demo/shell_demo.dart new file mode 100644 index 0000000..1520402 --- /dev/null +++ b/apps/desktop/lib/src/screens/demo/shell_demo.dart @@ -0,0 +1,2787 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:math' as math; +import 'dart:ui' as ui; + +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:media_kit/media_kit.dart'; +import 'package:media_kit_video/media_kit_video.dart'; +import 'package:window_manager/window_manager.dart'; + +import '../../jellyfin/accounts.dart'; +import '../../widgets/window_buttons.dart'; + +part 'demo_files.dart'; +part 'demo_player.dart'; +part 'demo_schedule.dart'; +part 'demo_settings.dart'; + +// DEBUG ONLY: a placeholder mock of the single-window layout (party strip, +// library with Jellyfin / Link / Files, click-to-select details, floating +// mini player that snaps to corners, chat panel and toasts). No real data, +// no networking. Opened from the bug button in Home's top bar (debug +// builds only). Edit freely with hot reload. + +const _ease = Curves.fastEaseInToSlowEaseOut; +const _easeTime = Duration(milliseconds: 320); + +/// Fade through (Material motion): the old view is gone after the first +/// 35%, then the new one fades and scales in; they never overlap. +Widget _fadeSwitch(Widget child, {Key? key}) => AnimatedSwitcher( + key: key, + duration: const Duration(milliseconds: 360), + layoutBuilder: (current, previous) => Stack( + fit: StackFit.expand, + children: [...previous, if (current != null) current], + ), + transitionBuilder: (c, anim) { + if (c.key == child.key) { + // Incoming (anim 0 -> 1): wait, then fade and scale up. + final a = CurvedAnimation( + parent: anim, + curve: const Interval(0.35, 1, curve: _ease), + ); + return FadeTransition( + opacity: a, + child: ScaleTransition( + scale: Tween(begin: 0.985, end: 1.0).animate(a), + child: c, + ), + ); + } + // Outgoing (anim 1 -> 0): fully faded by the time it reaches 0.65. + return FadeTransition( + opacity: CurvedAnimation( + parent: anim, + curve: const Interval(0.65, 1, curve: Curves.easeIn), + ), + child: c, + ); + }, + child: child, +); + +/// Popover: fades and scales in from its anchor instead of popping. +Future _popover( + BuildContext context, { + required Alignment at, + required Widget child, + bool dim = false, +}) => showGeneralDialog( + context: context, + barrierDismissible: true, + barrierLabel: 'close', + barrierColor: dim ? Colors.black26 : Colors.transparent, + transitionDuration: _easeTime, + // Short windows: the card scrolls instead of overflowing. + pageBuilder: (context, _, __) => Align( + alignment: at, + child: ConstrainedBox( + constraints: BoxConstraints( + maxHeight: MediaQuery.sizeOf(context).height - 32, + ), + child: SingleChildScrollView(child: child), + ), + ), + transitionBuilder: (context, anim, _, child) { + final a = CurvedAnimation( + parent: anim, + curve: _ease, + reverseCurve: Curves.easeIn, + ); + return FadeTransition( + opacity: a, + child: ScaleTransition( + scale: Tween(begin: 0.96, end: 1.0).animate(a), + alignment: at, + child: child, + ), + ); + }, +); + +class ShellDemo extends StatefulWidget { + const ShellDemo({super.key}); + + @override + State createState() => _ShellDemoState(); +} + +enum _Player { none, mini, full } + +enum _Source { jellyfin, link, file } + +class _Member { + final String name; + final Color color; + final bool ready, buffering, relaying, stale, host; + + /// Cropped 256 px PNG; null shows the initial on [color]. + final Uint8List? avatar; + const _Member( + this.name, + this.color, { + this.avatar, + this.ready = false, + this.buffering = false, + this.relaying = false, + this.stale = false, + this.host = false, + }); + + _Member copyWith({ + String? name, + Color? color, + Uint8List? avatar, + bool clearAvatar = false, + }) => _Member( + name ?? this.name, + color ?? this.color, + avatar: clearAvatar ? null : (avatar ?? this.avatar), + ready: ready, + buffering: buffering, + relaying: relaying, + stale: stale, + host: host, + ); +} + +/// This app's own profile: editable mid-session, everything rebuilds from it. +final _me = ValueNotifier( + const _Member('Foxyz', Colors.deepPurple, ready: true, host: true), +); + +List<_Member> get _members => [_me.value, ..._others]; + +const _others = [ + _Member('Bob', Colors.teal, ready: true, relaying: true), + _Member('Alex', Colors.orange, buffering: true), + _Member('Mika', Colors.pink), + _Member('Sam', Colors.blue, ready: true), + _Member('Kei', Colors.green, stale: true), +]; + +_Member _m(String name) => _members.firstWhere((m) => m.name == name); + +class _Toast { + final int id; + final _Member who; + final IconData? icon; + final String text; + final bool chat; + bool leaving = false; + _Toast(this.id, this.who, this.text, {this.icon, this.chat = false}); +} + +class _ShellDemoState extends State { + void _meChanged() => setState(() {}); + bool _fullscreen = false; + + @override + void initState() { + super.initState(); + _me.addListener(_meChanged); + _nowPlaying.addListener(_meChanged); + } + + @override + void dispose() { + _me.removeListener(_meChanged); + _nowPlaying.removeListener(_meChanged); + if (_fullscreen) _setWindowFullscreen(false); + unawaited(_disposeDemoPlayer()); + super.dispose(); + } + + Future _setWindowFullscreen(bool on) async { + try { + await windowManager.setFullScreen(on); + } catch (_) { + // No window manager (tests). + } + } + + void _toggleFullscreen() { + setState(() { + _fullscreen = !_fullscreen; + if (_fullscreen) _player = _Player.full; + }); + _setWindowFullscreen(_fullscreen); + } + + void _say(String text) => + _toast(_Toast(++_toastId, _me.value, text, icon: Icons.info_outline)); + + bool _online = true; + _Player _player = _Player.mini; + int? _selected; + String _tab = 'Library'; + _Source _source = _Source.jellyfin; + bool _chatOpen = false; + int _unread = 2; + final _toasts = <_Toast>[]; + var _toastId = 0; + + void _toast(_Toast t) { + if (!mounted) return; + setState(() => _toasts.add(t)); + Timer(const Duration(seconds: 5), () { + if (mounted) setState(() => t.leaving = true); + }); + Timer(const Duration(seconds: 5) + _easeTime, () { + if (mounted) setState(() => _toasts.removeWhere((x) => x.id == t.id)); + }); + } + + void _demoToasts() { + _toast( + _Toast( + ++_toastId, + _m('Bob'), + 'anyone else think the OP slaps?', + chat: true, + ), + ); + Timer( + const Duration(milliseconds: 500), + () => _toast( + _Toast(++_toastId, _m('Alex'), 'is ready', icon: Icons.check_circle), + ), + ); + Timer( + const Duration(milliseconds: 1000), + () => _toast( + _Toast( + ++_toastId, + _m('Sam'), + 'skipped to 1:30', + icon: Icons.fast_forward, + ), + ), + ); + Timer( + const Duration(milliseconds: 1500), + () => _toast( + _Toast( + ++_toastId, + _m('Bob'), + 'put up S1E4 · passing it on', + icon: Icons.swap_horiz, + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + if (_fullscreen) { + return Scaffold( + backgroundColor: Colors.black, + body: Stack( + children: [ + Positioned.fill( + child: _RealFullPlayer( + fullscreen: true, + onCollapse: _toggleFullscreen, + onToggleFullscreen: _toggleFullscreen, + partyOverlay: _online ? _AvatarStack(onTap: () {}) : null, + ), + ), + Positioned( + left: 16, + bottom: 90, + child: _ToastColumn(toasts: _toasts), + ), + ], + ), + ); + } + final body = _player == _Player.full + ? _RealFullPlayer( + fullscreen: false, + onCollapse: () => setState(() => _player = _Player.mini), + onToggleFullscreen: _toggleFullscreen, + ) + : switch (_tab) { + 'Library' => _Library( + source: _source, + onSource: (s) => setState(() => _source = s), + selected: _selected, + online: _online, + onSelect: (i) => + setState(() => _selected = _selected == i ? null : i), + onPutUp: () => setState(() => _player = _Player.full), + ), + 'Schedule' => _ScheduleTab( + online: _online, + onPlay: () => setState(() => _player = _Player.full), + onToast: _say, + ), + _ => _SettingsTab( + onOpenPlayerSettings: () => _popover( + context, + at: Alignment.center, + dim: true, + child: const _PlayerSettingsCard(), + ), + ), + }; + return Scaffold( + backgroundColor: scheme.surface, + body: Column( + children: [ + _PartyStrip( + onProfileChanged: (before, after) => _toast( + _Toast( + ++_toastId, + _me.value, + 'was $before', + icon: Icons.badge_outlined, + ), + ), + online: _online, + tab: _tab, + unread: _unread, + chatOpen: _chatOpen, + onTab: (t) => setState(() { + _tab = t; + if (_player == _Player.full) _player = _Player.mini; + }), + onChat: () => setState(() { + _chatOpen = !_chatOpen; + _unread = 0; + }), + onDemoToasts: _demoToasts, + onToggleOnline: () => setState(() { + _online = !_online; + if (!_online) _player = _Player.none; + }), + ), + Expanded( + child: Row( + children: [ + Expanded( + child: Stack( + children: [ + Positioned.fill( + child: _fadeSwitch( + KeyedSubtree( + key: ValueKey( + _player == _Player.full ? 'player' : _tab, + ), + child: body, + ), + ), + ), + Positioned.fill( + child: IgnorePointer( + ignoring: _player != _Player.mini, + child: AnimatedOpacity( + duration: _easeTime, + curve: _ease, + opacity: _player == _Player.mini ? 1 : 0, + child: AnimatedScale( + duration: _easeTime, + curve: _ease, + scale: _player == _Player.mini ? 1 : 0.97, + child: Stack( + children: [ + _FloatingMini( + active: _player == _Player.mini, + onExpand: () => + setState(() => _player = _Player.full), + onClose: () => + setState(() => _player = _Player.none), + ), + ], + ), + ), + ), + ), + ), + Positioned( + left: 16, + bottom: 16, + child: _ToastColumn(toasts: _toasts), + ), + ], + ), + ), + AnimatedSize( + duration: _easeTime, + curve: _ease, + alignment: Alignment.centerLeft, + child: _chatOpen && _online + ? _ChatPanel( + onClose: () => setState(() => _chatOpen = false), + ) + : const SizedBox(height: double.infinity), + ), + ], + ), + ), + ], + ), + ); + } +} + +// ------------------------------------------------------------ party strip + +class _PartyStrip extends StatelessWidget { + final bool online, chatOpen; + final String tab; + final int unread; + final ValueChanged onTab; + final VoidCallback onToggleOnline, onChat, onDemoToasts; + final void Function(String before, String after) onProfileChanged; + const _PartyStrip({ + required this.onProfileChanged, + required this.online, + required this.tab, + required this.unread, + required this.chatOpen, + required this.onTab, + required this.onChat, + required this.onDemoToasts, + required this.onToggleOnline, + }); + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + // Narrow windows: fewer avatars, no server text, so the tabs keep room. + final compact = MediaQuery.sizeOf(context).width < 1100; + final row = Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + IconButton( + tooltip: 'Back to the current app (demo)', + icon: const Icon(Icons.arrow_back, size: 20), + onPressed: () => Navigator.of(context).pop(), + ), + const SizedBox(width: 4), + Tooltip( + message: 'Your profile', + child: InkWell( + customBorder: const CircleBorder(), + onTap: () => _showProfile( + context, + online: online, + onChanged: onProfileChanged, + ), + child: _Avatar(member: _me.value, size: 32), + ), + ), + const SizedBox(width: 12), + Expanded( + child: _HScroll( + builder: (c) => SingleChildScrollView( + controller: c, + scrollDirection: Axis.horizontal, + child: Row( + children: [ + for (final t in ['Library', 'Schedule', 'Settings']) + _StripTab( + label: t, + selected: t == tab, + onTap: () => onTab(t), + ), + ], + ), + ), + ), + ), + if (!compact) + IconButton( + tooltip: 'Demo: fire some toasts', + onPressed: onDemoToasts, + icon: const Icon( + Icons.notifications_active_outlined, + size: 20, + color: Colors.amber, + ), + ), + if (online) ...[ + _AvatarStack( + onTap: () => _showMembers(context), + shown: compact ? 3 : 5, + ), + const SizedBox(width: 10), + if (!compact) + Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'VPS', + style: TextStyle(fontWeight: FontWeight.w600, height: 1.1), + ), + Text( + 'ready 3/6', + style: TextStyle( + color: scheme.onSurfaceVariant, + fontSize: 12, + height: 1.2, + ), + ), + ], + ), + const SizedBox(width: 6), + Badge( + isLabelVisible: unread > 0, + label: Text('$unread'), + child: IconButton( + tooltip: 'Chat & activity', + isSelected: chatOpen, + onPressed: onChat, + icon: const Icon(Icons.chat_bubble_outline, size: 20), + selectedIcon: Icon( + Icons.chat_bubble, + size: 20, + color: scheme.primary, + ), + ), + ), + const SizedBox(width: 4), + OutlinedButton(onPressed: onToggleOnline, child: const Text('Leave')), + ] else ...[ + Text('Offline', style: TextStyle(color: scheme.onSurfaceVariant)), + const SizedBox(width: 12), + FilledButton.tonal( + onPressed: () => + _showConnect(context, host: false, onDone: onToggleOnline), + child: const Text('Join'), + ), + const SizedBox(width: 8), + FilledButton( + onPressed: () => + _showConnect(context, host: true, onDone: onToggleOnline), + child: const Text('Host'), + ), + ], + const SizedBox(width: 8), + SizedBox( + height: 20, + child: VerticalDivider(width: 12, color: scheme.outlineVariant), + ), + const WindowButtons(), + ], + ); + return DecoratedBox( + decoration: BoxDecoration( + color: scheme.surfaceContainerLowest, + border: Border( + bottom: BorderSide( + color: scheme.outlineVariant.withValues(alpha: 0.4), + ), + ), + ), + child: SizedBox( + height: 60, + child: Stack( + children: [ + const Positioned.fill( + child: DragToMoveArea(child: SizedBox.expand()), + ), + Positioned.fill(child: row), + ], + ), + ), + ); + } + + void _showMembers(BuildContext context) { + _popover( + context, + at: const Alignment(0.7, -0.84), + child: Builder( + builder: (context) => Material( + elevation: 12, + borderRadius: BorderRadius.circular(14), + child: SizedBox( + width: 340, + child: ListView( + shrinkWrap: true, + padding: const EdgeInsets.all(12), + children: [ + const Padding( + padding: EdgeInsets.fromLTRB(8, 4, 8, 8), + child: Text( + 'In the party · vps.example:8080', + style: TextStyle(fontWeight: FontWeight.w600), + ), + ), + for (final m in _members) + ListTile( + dense: true, + leading: _Avatar(member: m, size: 36), + title: Wrap( + spacing: 6, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + Text(m.name), + if (m.host) const _Pill('host', Colors.amber), + if (m.relaying) + const _Pill( + 'passing the video on', + Colors.lightBlueAccent, + ), + ], + ), + subtitle: Text( + m.stale + ? 'reconnecting…' + : m.buffering + ? 'buffering 2.1 s' + : m.ready + ? 'ready' + : 'not ready', + ), + trailing: const Icon(Icons.more_vert, size: 18), + ), + const Divider(), + TextButton.icon( + onPressed: () {}, + icon: const Icon(Icons.copy, size: 16), + label: const Text('Copy invite address'), + ), + ], + ), + ), + ), + ), + ); + } + + void _showConnect( + BuildContext context, { + required bool host, + required VoidCallback onDone, + }) { + _popover( + context, + dim: true, + at: const Alignment(0.8, -0.75), + child: Builder( + builder: (context) => Material( + elevation: 12, + borderRadius: BorderRadius.circular(14), + child: SizedBox( + width: 360, + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + host ? 'Host a server' : 'Join a server', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 12), + if (!host) ...[ + const Text('Recent', style: TextStyle(color: Colors.grey)), + for (final s in [ + 'VPS · vps.example:8080', + 'Bob · 192.168.1.20:8080', + ]) + ListTile( + dense: true, + leading: const Icon(Icons.history, size: 18), + title: Text(s), + trailing: const Icon(Icons.login, size: 18), + onTap: () { + Navigator.of(context).pop(); + onDone(); + }, + ), + const Divider(), + const TextField( + decoration: InputDecoration( + labelText: 'Address', + isDense: true, + ), + ), + ] else + const TextField( + decoration: InputDecoration( + labelText: 'Port', + hintText: '8080', + isDense: true, + ), + ), + const SizedBox(height: 8), + const TextField( + decoration: InputDecoration( + labelText: 'Password (optional)', + isDense: true, + ), + ), + const SizedBox(height: 16), + FilledButton( + onPressed: () { + Navigator.of(context).pop(); + onDone(); + }, + child: Text(host ? 'Host' : 'Join'), + ), + if (host) + const Padding( + padding: EdgeInsets.only(top: 8), + child: Text( + 'You pick what to watch afterwards, in the library.', + style: TextStyle(color: Colors.grey, fontSize: 12), + ), + ), + ], + ), + ), + ), + ), + ), + ); + } +} + +class _StripTab extends StatelessWidget { + final String label; + final bool selected; + final VoidCallback onTap; + const _StripTab({ + required this.label, + required this.selected, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(8), + child: Padding( + padding: EdgeInsets.symmetric( + horizontal: MediaQuery.sizeOf(context).width < 1100 ? 8 : 12, + ), + child: SizedBox( + height: 60, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const SizedBox(height: 3), + Text( + label, + style: TextStyle( + fontWeight: selected ? FontWeight.w700 : FontWeight.w500, + color: selected ? scheme.primary : scheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 4), + AnimatedContainer( + duration: _easeTime, + curve: _ease, + height: 3, + width: selected ? 22 : 0, + decoration: BoxDecoration( + color: scheme.primary, + borderRadius: BorderRadius.circular(2), + ), + ), + ], + ), + ), + ), + ); + } +} + +class _Pill extends StatelessWidget { + final String text; + final Color color; + const _Pill(this.text, this.color); + + @override + Widget build(BuildContext context) => Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(6), + ), + child: Text( + text, + style: TextStyle(color: color, fontSize: 10, fontWeight: FontWeight.w600), + ), + ); +} + +class _AvatarStack extends StatefulWidget { + final VoidCallback onTap; + + /// Avatars before "+N". + final int shown; + const _AvatarStack({required this.onTap, this.shown = 5}); + + @override + State<_AvatarStack> createState() => _AvatarStackState(); +} + +class _AvatarStackState extends State<_AvatarStack> { + bool _hover = false; + + @override + Widget build(BuildContext context) { + const size = 42.0; + final shown = widget.shown; + // Host first, then ready: the stack itself reads as progress. + int rank(_Member m) => m.host ? 0 : (m.ready ? 1 : 2); + final sorted = [..._members]..sort((a, b) => rank(a) - rank(b)); + final visible = sorted.take(shown).toList(); + final extra = _members.length - visible.length; + final step = _hover ? size + 6 : size * 0.62; + final width = step * (visible.length - 1) + size + (extra > 0 ? step : 0); + return MouseRegion( + onEnter: (_) => setState(() => _hover = true), + onExit: (_) => setState(() => _hover = false), + cursor: SystemMouseCursors.click, + child: GestureDetector( + onTap: widget.onTap, + child: AnimatedContainer( + duration: _easeTime, + curve: _ease, + width: width, + height: size, + child: Stack( + clipBehavior: Clip.none, + children: [ + for (var i = visible.length - 1; i >= 0; i--) + AnimatedPositioned( + duration: _easeTime, + curve: _ease, + left: i * step, + top: 0, + child: Tooltip( + message: visible[i].name, + child: _Avatar(member: visible[i], size: size), + ), + ), + if (extra > 0) + AnimatedPositioned( + duration: _easeTime, + curve: _ease, + left: visible.length * step, + top: 0, + child: CircleAvatar( + radius: size / 2, + child: Text( + '+$extra', + style: const TextStyle(fontSize: 12), + ), + ), + ), + ], + ), + ), + ), + ); + } +} + +/// Circle avatar with a status ring outside it and a gap in the background +/// colour, so overlapping neighbours never hide the status. Crown = host, +/// tower = passing the video on. +class _Avatar extends StatelessWidget { + final _Member member; + final double size; + + /// Host crown and relay tower; off where the context already says it. + final bool badges; + const _Avatar({required this.member, required this.size, this.badges = true}); + + @override + Widget build(BuildContext context) { + final bg = Theme.of(context).colorScheme.surfaceContainerLowest; + final ring = member.stale + ? Colors.grey.shade700 + : member.ready + ? Colors.greenAccent.shade400 + : member.buffering + ? Colors.amber + : Colors.grey; + final badge = size * 0.42; + // Ring and gap scale with the avatar (2 px at strip size). + final stroke = (size * 0.055).clamp(2.0, 4.0); + return SizedBox( + width: size, + height: size, + child: Stack( + clipBehavior: Clip.none, + children: [ + // Fills the box: a non-positioned child of a Stack only gets loose + // constraints, and CircleAvatar then falls back to its 40 px default. + Positioned.fill( + child: Container( + decoration: BoxDecoration(shape: BoxShape.circle, color: ring), + padding: EdgeInsets.all(stroke), + child: Container( + decoration: BoxDecoration(shape: BoxShape.circle, color: bg), + padding: EdgeInsets.all(stroke), + child: Opacity( + opacity: member.stale ? 0.4 : 1, + child: CircleAvatar( + radius: size / 2, + backgroundColor: member.color, + backgroundImage: member.avatar == null + ? null + : MemoryImage(member.avatar!), + child: member.avatar != null + ? null + : Text( + member.name[0], + style: TextStyle( + fontSize: size * 0.38, + color: Colors.white, + ), + ), + ), + ), + ), + ), + ), + if (member.buffering) + Positioned.fill( + child: CircularProgressIndicator( + strokeWidth: stroke, + color: ring, + ), + ), + if (badges && member.host) + Positioned( + left: -4, + top: -6, + child: Transform.rotate( + angle: -0.4, + child: Icon( + Icons.workspace_premium, + size: badge, + color: Colors.amber, + shadows: const [Shadow(blurRadius: 3, color: Colors.black)], + ), + ), + ), + if (badges && member.relaying) + Positioned( + right: -3, + bottom: -3, + child: Container( + padding: const EdgeInsets.all(2), + decoration: BoxDecoration(shape: BoxShape.circle, color: bg), + child: Icon( + Icons.cell_tower, + size: badge * 0.8, + color: Colors.lightBlueAccent, + ), + ), + ), + ], + ), + ); + } +} + +// ---------------------------------------------------------- chat & toasts + +class _ToastColumn extends StatelessWidget { + final List<_Toast> toasts; + const _ToastColumn({required this.toasts}); + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (final t in toasts) + TweenAnimationBuilder( + key: ValueKey(t.id), + tween: Tween(begin: 0, end: 1), + duration: _easeTime, + curve: _ease, + builder: (context, v, child) => Opacity( + opacity: v, + child: Transform.translate( + offset: Offset(-24 * (1 - v), 0), + child: child, + ), + ), + // Leaving: slide back out, fade, and collapse the gap it leaves. + child: AnimatedAlign( + duration: _easeTime, + curve: _ease, + alignment: Alignment.topLeft, + heightFactor: t.leaving ? 0 : 1, + child: AnimatedSlide( + duration: _easeTime, + curve: _ease, + offset: t.leaving ? const Offset(-0.25, 0) : Offset.zero, + child: AnimatedOpacity( + duration: _easeTime, + curve: _ease, + opacity: t.leaving ? 0 : 1, + child: Padding( + padding: const EdgeInsets.only(top: 8), + child: _ToastCard(toast: t), + ), + ), + ), + ), + ), + ], + ); + } +} + +class _ToastCard extends StatelessWidget { + final _Toast toast; + const _ToastCard({required this.toast}); + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + return Material( + elevation: 8, + color: scheme.surfaceContainerHigh.withValues(alpha: 0.95), + borderRadius: BorderRadius.circular(14), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 380), + child: Padding( + padding: const EdgeInsets.fromLTRB(10, 10, 16, 10), + child: Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _Avatar(member: toast.who, size: 38), + const SizedBox(width: 12), + Flexible( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + toast.who.name, + style: const TextStyle( + fontWeight: FontWeight.w700, + fontSize: 15, + ), + ), + if (toast.icon != null) ...[ + const SizedBox(width: 6), + Icon(toast.icon, size: 16, color: scheme.primary), + ], + ], + ), + const SizedBox(height: 2), + Text( + toast.text, + style: TextStyle( + fontSize: toast.chat ? 15 : 14, + color: toast.chat + ? scheme.onSurface + : scheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ], + ), + ), + ), + ); + } +} + +class _ChatPanel extends StatelessWidget { + final VoidCallback onClose; + const _ChatPanel({required this.onClose}); + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + final log = <(String, String, IconData?)>[ + ('Bob', 'joined', Icons.login), + ('Bob', 'put up S1E3 · passing it on', Icons.swap_horiz), + ('Foxyz', 'everyone here? starting in a sec', null), + ('Alex', 'is ready', Icons.check_circle), + ('Mika', 'brb 2 min', null), + ('Sam', 'skipped to 1:30', Icons.fast_forward), + ('Bob', 'anyone else think the OP slaps?', null), + ]; + return Container( + width: 340, + color: scheme.surfaceContainerLow, + child: Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 4, 0), + child: Row( + children: [ + const Text( + 'Chat & activity', + style: TextStyle(fontWeight: FontWeight.w600), + ), + const Spacer(), + IconButton( + onPressed: onClose, + icon: const Icon(Icons.close, size: 18), + ), + ], + ), + ), + Expanded( + child: ListView( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + children: [ + for (final (who, text, icon) in log) + icon != null + ? Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + children: [ + const SizedBox(width: 8), + Icon( + icon, + size: 14, + color: scheme.onSurfaceVariant, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + '$who $text', + style: TextStyle( + color: scheme.onSurfaceVariant, + fontSize: 12, + ), + ), + ), + ], + ), + ) + : Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _Avatar(member: _m(who), size: 34), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + who, + style: const TextStyle( + fontWeight: FontWeight.w700, + ), + ), + Text(text), + ], + ), + ), + ], + ), + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.all(12), + child: TextField( + decoration: InputDecoration( + hintText: 'Message (Enter)', + isDense: true, + filled: true, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + suffixIcon: const Icon(Icons.send, size: 18), + ), + ), + ), + ], + ), + ); + } +} + +// ---------------------------------------------------------------- library + +class _Library extends StatelessWidget { + final _Source source; + final ValueChanged<_Source> onSource; + final int? selected; + final bool online; + final ValueChanged onSelect; + final VoidCallback onPutUp; + const _Library({ + required this.source, + required this.onSource, + required this.selected, + required this.online, + required this.onSelect, + required this.onPutUp, + }); + + @override + Widget build(BuildContext context) { + final header = Padding( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 0), + child: Wrap( + spacing: 16, + runSpacing: 8, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + SegmentedButton<_Source>( + showSelectedIcon: false, + segments: const [ + ButtonSegment( + value: _Source.jellyfin, + icon: Icon(Icons.video_library_outlined), + label: Text('Jellyfin'), + ), + ButtonSegment( + value: _Source.link, + icon: Icon(Icons.link), + label: Text('Link'), + ), + ButtonSegment( + value: _Source.file, + icon: Icon(Icons.folder_outlined), + label: Text('Files'), + ), + ], + selected: {source}, + onSelectionChanged: (s) => onSource(s.first), + ), + if (source == _Source.jellyfin) + Wrap( + spacing: 8, + children: [ + for (final l in ['Anime', 'Movies', 'Shows']) + ActionChip( + label: Text(l), + avatar: const Icon(Icons.video_library_outlined, size: 16), + onPressed: () {}, + ), + ], + ), + ], + ), + ); + final content = switch (source) { + _Source.jellyfin => _JellyfinHome( + selected: selected, + onSelect: onSelect, + onPutUp: onPutUp, + ), + _Source.link => _LinkTab(online: online, onPutUp: onPutUp), + _Source.file => _RealFilesTab(online: online, onPlay: onPutUp), + }; + final open = source == _Source.jellyfin && selected != null; + // Content cross-fades when another series is picked; the panel itself + // slides in and out. + final details = _fadeSwitch( + selected == null + ? const SizedBox.shrink() + : KeyedSubtree( + key: ValueKey(selected), + child: _Details( + id: selected!, + online: online, + onPutUp: onPutUp, + onClose: () => onSelect(selected!), + ), + ), + ); + final main = Column( + children: [ + header, + Expanded( + child: _fadeSwitch( + KeyedSubtree(key: ValueKey(source), child: content), + ), + ), + ], + ); + Widget slide(Widget child) => AnimatedSlide( + duration: _easeTime, + curve: _ease, + offset: open ? Offset.zero : const Offset(1, 0), + child: AnimatedOpacity( + duration: _easeTime, + curve: _ease, + opacity: open ? 1 : 0, + child: child, + ), + ); + // Wide: details dock beside the library. Narrow: they float over it. + return LayoutBuilder( + builder: (context, box) { + if (box.maxWidth >= 1000) { + return Row( + children: [ + Expanded(child: main), + ClipRect( + child: AnimatedContainer( + duration: _easeTime, + curve: _ease, + width: open ? 400 : 0, + child: OverflowBox( + alignment: Alignment.centerLeft, + minWidth: 400, + maxWidth: 400, + child: slide(SizedBox(width: 400, child: details)), + ), + ), + ), + ], + ); + } + return Stack( + children: [ + Positioned.fill(child: main), + Positioned( + top: 0, + bottom: 0, + right: 0, + width: 400, + child: IgnorePointer( + ignoring: !open, + child: slide(Material(elevation: 16, child: details)), + ), + ), + ], + ); + }, + ); + } +} + +class _JellyfinHome extends StatelessWidget { + final int? selected; + final ValueChanged onSelect; + final VoidCallback onPutUp; + const _JellyfinHome({ + required this.selected, + required this.onSelect, + required this.onPutUp, + }); + + @override + Widget build(BuildContext context) { + final rows = ['Continue', 'Latest in Anime', 'Latest in Movies']; + return ListView( + padding: const EdgeInsets.fromLTRB(20, 20, 20, 24), + children: [ + for (var r = 0; r < rows.length; r++) ...[ + Row( + children: [ + Expanded( + child: Text( + rows[r], + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + TextButton(onPressed: () {}, child: const Text('Show all')), + ], + ), + const SizedBox(height: 8), + SizedBox( + height: 264, + child: _HScroll( + builder: (c) => ListView.separated( + controller: c, + scrollDirection: Axis.horizontal, + itemCount: 12, + separatorBuilder: (_, __) => const SizedBox(width: 12), + itemBuilder: (context, i) { + final id = r * 100 + i; + return _Card( + title: 'Series ${id + 1}', + sub: r == 0 ? 'S1E${i + 2} · 12 min left' : 'S1E${i + 1}', + selected: selected == id, + onTap: () => onSelect(id), + onDouble: onPutUp, + ); + }, + ), + ), + ), + const SizedBox(height: 20), + ], + ], + ); + } +} + +class _LinkTab extends StatefulWidget { + final bool online; + final VoidCallback onPutUp; + const _LinkTab({required this.online, required this.onPutUp}); + + @override + State<_LinkTab> createState() => _LinkTabState(); +} + +class _LinkTabState extends State<_LinkTab> { + int _mode = 0; + static const _modes = [ + ( + Icons.groups_outlined, + 'Everyone fetches it', + 'Each app plays the link itself.', + ), + ( + Icons.dns_outlined, + 'Server fetches it', + 'Downloaded once by the server. For links slow or blocked for some.', + ), + ( + Icons.cell_tower, + 'Through me', + 'Your computer passes it on. For links only you can open.', + ), + ]; + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + final t = Theme.of(context).textTheme; + return SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(24, 40, 24, 40), + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 780), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Hero + Center( + child: Container( + width: 64, + height: 64, + decoration: BoxDecoration( + color: scheme.primary.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(18), + ), + child: Icon(Icons.link, size: 32, color: scheme.primary), + ), + ), + const SizedBox(height: 14), + Text( + 'Watch from a link', + textAlign: TextAlign.center, + style: t.headlineSmall, + ), + const SizedBox(height: 6), + Text( + 'A video file, YouTube, a Jellyfin link… Ctrl+V anywhere pastes it here.', + textAlign: TextAlign.center, + style: TextStyle(color: scheme.onSurfaceVariant), + ), + const SizedBox(height: 24), + Row( + children: [ + Expanded( + child: TextField( + style: const TextStyle(fontSize: 16), + decoration: InputDecoration( + hintText: 'https://…', + prefixIcon: const Icon(Icons.link), + suffixIcon: IconButton( + onPressed: () {}, + tooltip: 'Paste', + icon: const Icon(Icons.content_paste), + ), + filled: true, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: BorderSide.none, + ), + ), + ), + ), + const SizedBox(width: 12), + FilledButton.icon( + onPressed: widget.onPutUp, + icon: const Icon(Icons.play_arrow), + label: Text(widget.online ? 'Put it up' : 'Play'), + style: FilledButton.styleFrom( + minimumSize: const Size(0, 56), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + ), + ), + ], + ), + const SizedBox(height: 14), + // What the pasted link resolves to. + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: scheme.surfaceContainerLow, + borderRadius: BorderRadius.circular(16), + ), + child: Row( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(10), + child: Container( + width: 176, + height: 99, + color: scheme.surfaceContainerHighest, + child: const Center( + child: Icon(Icons.image_outlined, color: Colors.grey), + ), + ), + ), + const SizedBox(width: 16), + const Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Resolved title of the link', + style: TextStyle( + fontWeight: FontWeight.w600, + fontSize: 16, + ), + ), + SizedBox(height: 4), + Text( + 'youtube.com · 23:41', + style: TextStyle(color: Colors.grey), + ), + ], + ), + ), + ], + ), + ), + if (widget.online) ...[ + const SizedBox(height: 28), + Text('How the others get it', style: t.titleSmall), + const SizedBox(height: 10), + IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (var i = 0; i < _modes.length; i++) ...[ + if (i > 0) const SizedBox(width: 10), + Expanded( + child: _ModeCard( + mode: _modes[i], + selected: _mode == i, + onTap: () => setState(() => _mode = i), + ), + ), + ], + ], + ), + ), + ], + const SizedBox(height: 32), + Text('Recent', style: t.titleSmall), + const SizedBox(height: 10), + LayoutBuilder( + builder: (context, box) { + const gap = 12.0; + final cols = box.maxWidth > 600 ? 3 : 2; + final w = (box.maxWidth - gap * (cols - 1)) / cols; + return Wrap( + spacing: gap, + runSpacing: gap, + children: [ + for (var i = 0; i < 6; i++) + SizedBox( + width: w, + child: InkWell( + onTap: widget.onPutUp, + borderRadius: BorderRadius.circular(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + AspectRatio( + aspectRatio: 16 / 9, + child: Container( + decoration: BoxDecoration( + color: scheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(12), + ), + child: const Center( + child: Icon( + Icons.image_outlined, + color: Colors.grey, + ), + ), + ), + ), + const SizedBox(height: 6), + Text( + 'Recent link title ${i + 1}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontWeight: FontWeight.w600, + ), + ), + Text( + i.isEven + ? 'youtube.com · 2 days ago' + : 'cdn.example · last week', + style: const TextStyle( + color: Colors.grey, + fontSize: 12, + ), + ), + ], + ), + ), + ), + ], + ); + }, + ), + ], + ), + ), + ), + ); + } +} + +class _ModeCard extends StatelessWidget { + final (IconData, String, String) mode; + final bool selected; + final VoidCallback onTap; + const _ModeCard({ + required this.mode, + required this.selected, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + final (icon, title, sub) = mode; + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(14), + child: AnimatedContainer( + duration: _easeTime, + curve: _ease, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: selected + ? scheme.primary.withValues(alpha: 0.10) + : scheme.surfaceContainerLow, + borderRadius: BorderRadius.circular(14), + border: Border.all( + color: selected ? scheme.primary : Colors.transparent, + width: 2, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + icon, + color: selected ? scheme.primary : scheme.onSurfaceVariant, + ), + const SizedBox(height: 8), + Text( + title, + style: TextStyle( + fontWeight: FontWeight.w600, + color: selected ? scheme.primary : null, + ), + ), + const SizedBox(height: 2), + Text( + sub, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: Colors.grey, fontSize: 12), + ), + ], + ), + ), + ); + } +} + +class _Card extends StatelessWidget { + final String title, sub; + final bool selected; + final VoidCallback onTap, onDouble; + const _Card({ + required this.title, + required this.sub, + required this.selected, + required this.onTap, + required this.onDouble, + }); + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + return GestureDetector( + onTap: onTap, + onDoubleTap: onDouble, + child: SizedBox( + width: 140, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Stack( + children: [ + AnimatedContainer( + duration: const Duration(milliseconds: 160), + curve: _ease, + height: 200, + decoration: BoxDecoration( + color: scheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: selected ? scheme.primary : Colors.transparent, + width: 3, + ), + ), + child: const Center( + child: Icon( + Icons.image_outlined, + size: 36, + color: Colors.grey, + ), + ), + ), + if (selected) + Positioned( + right: 6, + top: 6, + child: CircleAvatar( + radius: 11, + backgroundColor: scheme.primary, + child: Icon( + Icons.check, + size: 14, + color: scheme.onPrimary, + ), + ), + ), + ], + ), + const SizedBox(height: 6), + Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontWeight: FontWeight.w600, + color: selected ? scheme.primary : null, + ), + ), + Text(sub, style: const TextStyle(color: Colors.grey, fontSize: 12)), + ], + ), + ), + ); + } +} + +class _Details extends StatelessWidget { + final int id; + final bool online; + final VoidCallback onPutUp, onClose; + const _Details({ + required this.id, + required this.online, + required this.onPutUp, + required this.onClose, + }); + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + return Container( + width: 400, + color: scheme.surfaceContainerLow, + child: ListView( + children: [ + Stack( + children: [ + Container( + height: 180, + color: scheme.surfaceContainerHighest, + child: const Center( + child: Text( + 'banner / backdrop', + style: TextStyle(color: Colors.grey), + ), + ), + ), + Positioned( + right: 4, + top: 4, + child: IconButton( + onPressed: onClose, + icon: const Icon(Icons.close), + ), + ), + ], + ), + Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Series ${id + 1}', + style: Theme.of(context).textTheme.titleLarge, + ), + const Text( + '2025 · 12 episodes · TV-14', + style: TextStyle(color: Colors.grey), + ), + const SizedBox(height: 12), + Wrap( + spacing: 4, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + FilledButton.icon( + onPressed: onPutUp, + icon: const Icon(Icons.play_arrow), + label: Text( + online ? 'Put it up · S1E3' : 'Play alone · S1E3', + ), + ), + IconButton( + onPressed: () {}, + tooltip: 'Mark watched', + icon: const Icon(Icons.check_circle_outline), + ), + IconButton( + onPressed: () {}, + tooltip: 'Remove from Continue', + icon: const Icon(Icons.remove_circle_outline), + ), + ], + ), + const SizedBox(height: 12), + const Text( + 'Overview placeholder text that describes the series in two or three lines, ' + 'so the panel has something to show at its real size.', + ), + const SizedBox(height: 16), + const Text( + 'Episodes', + style: TextStyle(fontWeight: FontWeight.w600), + ), + for (var e = 1; e <= 6; e++) + ListTile( + dense: true, + contentPadding: EdgeInsets.zero, + leading: Container( + width: 64, + height: 36, + color: scheme.surfaceContainerHighest, + ), + title: Text('E$e · Episode title'), + subtitle: Text(e < 3 ? 'watched' : '24 min'), + trailing: e == 3 + ? Icon(Icons.play_circle, color: scheme.primary) + : null, + ), + ], + ), + ), + ], + ), + ); + } +} + +// ----------------------------------------------------------------- player + +/// Picture-in-picture box: drag it anywhere, it snaps to the nearest +/// corner on release (eased). Double-click or ⤢ expands. +class _FloatingMini extends StatefulWidget { + final VoidCallback onExpand, onClose; + + /// Shows the video (false while the full player has it). + final bool active; + const _FloatingMini({ + required this.onExpand, + required this.onClose, + this.active = true, + }); + + @override + State<_FloatingMini> createState() => _FloatingMiniState(); +} + +class _FloatingMiniState extends State<_FloatingMini> { + static const _w = 384.0, _h = 216.0, _margin = 16.0; + Alignment _corner = Alignment.bottomRight; + Offset? _drag; // top-left while dragging + bool _hover = false; + + Offset _cornerPos(Size area, Alignment a) => Offset( + a.x < 0 ? _margin : area.width - _w - _margin, + a.y < 0 ? _margin : area.height - _h - _margin, + ); + + @override + Widget build(BuildContext context) { + return Positioned.fill( + child: LayoutBuilder( + builder: (context, box) { + final area = box.biggest; + final pos = _drag ?? _cornerPos(area, _corner); + return Stack( + children: [ + AnimatedPositioned( + duration: _drag == null ? _easeTime : Duration.zero, + curve: _ease, + left: pos.dx, + top: pos.dy, + width: _w, + height: _h, + child: MouseRegion( + onEnter: (_) => setState(() => _hover = true), + onExit: (_) => setState(() => _hover = false), + child: GestureDetector( + onPanStart: (_) => setState(() => _drag = pos), + onPanUpdate: (d) => + setState(() => _drag = _drag! + d.delta), + onPanEnd: (_) => setState(() { + final c = _drag! + const Offset(_w / 2, _h / 2); + _corner = Alignment( + c.dx < area.width / 2 ? -1 : 1, + c.dy < area.height / 2 ? -1 : 1, + ); + _drag = null; + }), + onDoubleTap: widget.onExpand, + child: Material( + elevation: 16, + borderRadius: BorderRadius.circular(14), + clipBehavior: Clip.antiAlias, + color: Colors.black, + child: Stack( + children: [ + // Only one Video per controller: not while the full + // player shows it. + Positioned.fill( + child: widget.active + ? const _VideoSurface(placeholderSize: 18) + : const ColoredBox(color: Colors.black), + ), + AnimatedOpacity( + duration: const Duration(milliseconds: 160), + opacity: _hover ? 1 : 0, + child: Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.black54, + Colors.transparent, + Colors.black87, + ], + ), + ), + child: Column( + children: [ + Row( + children: [ + const SizedBox(width: 10), + Expanded( + child: Text( + [ + _nowPlaying.value?.title, + _nowPlaying.value?.subtitle, + ].whereType().join(' · '), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: Colors.white, + fontWeight: FontWeight.w600, + ), + ), + ), + IconButton( + onPressed: widget.onExpand, + tooltip: 'Expand', + icon: const Icon( + Icons.open_in_full, + color: Colors.white, + size: 18, + ), + ), + IconButton( + onPressed: widget.onClose, + tooltip: + 'Stop watching (stay in the party)', + icon: const Icon( + Icons.close, + color: Colors.white, + size: 18, + ), + ), + ], + ), + const Spacer(), + const _Transport( + compact: true, + trailing: [ + Text( + 'waiting for Alex', + style: TextStyle( + color: Colors.amber, + fontSize: 12, + ), + ), + SizedBox(width: 10), + ], + ), + ], + ), + ), + ), + // Progress line while the controls are hidden. + if (!_hover && _demoPlayer != null) + const Positioned( + left: 0, + right: 0, + bottom: 0, + child: _ThinProgress(), + ), + ], + ), + ), + ), + ), + ), + ], + ); + }, + ), + ); + } +} + +/// Your profile: name and avatar apply live (the party sees the change at +/// once, an activity line says who you were); links to the app-wide pages. +Future _showProfile( + BuildContext context, { + required bool online, + required void Function(String before, String after) onChanged, +}) async { + final before = _me.value.name; + await _popover( + context, + at: const Alignment(-0.97, -0.84), + child: _ProfileCard(online: online), + ); + if (_me.value.name != before) onChanged(before, _me.value.name); +} + +class _ProfileCard extends StatefulWidget { + final bool online; + const _ProfileCard({required this.online}); + + @override + State<_ProfileCard> createState() => _ProfileCardState(); +} + +class _ProfileCardState extends State<_ProfileCard> { + late final _name = TextEditingController(text: _me.value.name); + bool _hoverPic = false; + static const _colors = [ + Colors.deepPurple, + Colors.indigo, + Colors.teal, + Colors.pink, + Colors.orange, + Colors.blueGrey, + ]; + + @override + void dispose() { + _name.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + return Material( + elevation: 12, + color: scheme.surfaceContainer, + borderRadius: BorderRadius.circular(18), + clipBehavior: Clip.antiAlias, + child: SizedBox( + width: 360, + child: ValueListenableBuilder( + valueListenable: _me, + builder: (context, me, _) => Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Header tinted with your colour. + AnimatedContainer( + duration: _easeTime, + curve: _ease, + padding: const EdgeInsets.fromLTRB(20, 22, 20, 18), + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + me.color.withValues(alpha: 0.55), + me.color.withValues(alpha: 0.08), + ], + ), + ), + child: Row( + children: [ + MouseRegion( + cursor: SystemMouseCursors.click, + onEnter: (_) => setState(() => _hoverPic = true), + onExit: (_) => setState(() => _hoverPic = false), + child: GestureDetector( + onTap: () => _popover( + context, + at: Alignment.center, + dim: true, + child: const _CropPicker(), + ), + child: SizedBox( + width: 88, + height: 88, + child: Stack( + children: [ + _Avatar(member: me, size: 88, badges: false), + Positioned.fill( + child: AnimatedOpacity( + duration: _easeTime, + curve: _ease, + opacity: _hoverPic ? 1 : 0, + child: Container( + margin: const EdgeInsets.all(4), + decoration: const BoxDecoration( + shape: BoxShape.circle, + color: Colors.black54, + ), + child: const Column( + mainAxisAlignment: + MainAxisAlignment.center, + children: [ + Icon( + Icons.photo_camera_outlined, + color: Colors.white, + size: 22, + ), + Text( + 'Change', + style: TextStyle( + color: Colors.white, + fontSize: 11, + ), + ), + ], + ), + ), + ), + ), + ], + ), + ), + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: _name, + maxLength: 32, + style: Theme.of(context).textTheme.titleLarge + ?.copyWith(fontWeight: FontWeight.w700), + decoration: const InputDecoration( + isDense: true, + counterText: '', + border: InputBorder.none, + hintText: 'Your name', + suffixIcon: Icon(Icons.edit_outlined, size: 16), + suffixIconConstraints: BoxConstraints( + minWidth: 20, + ), + ), + onChanged: (v) { + if (v.trim().isNotEmpty) { + _me.value = me.copyWith(name: v.trim()); + } + }, + ), + const SizedBox(height: 4), + Wrap( + spacing: 6, + runSpacing: 4, + children: [ + if (me.host) const _Pill('host', Colors.amber), + _Pill( + widget.online ? 'in VPS' : 'offline', + widget.online + ? Colors.greenAccent + : Colors.grey, + ), + if (widget.online) + _Pill( + me.ready ? 'ready' : 'not ready', + Colors.lightBlueAccent, + ), + ], + ), + ], + ), + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(20, 14, 20, 4), + child: Row( + children: [ + Text( + 'Colour', + style: TextStyle( + color: scheme.onSurfaceVariant, + fontSize: 12, + ), + ), + const Spacer(), + for (final c in _colors) + Padding( + padding: const EdgeInsets.only(left: 8), + child: InkWell( + customBorder: const CircleBorder(), + onTap: () => _me.value = me.copyWith(color: c), + child: AnimatedContainer( + duration: _easeTime, + curve: _ease, + width: 24, + height: 24, + decoration: BoxDecoration( + color: c, + shape: BoxShape.circle, + border: Border.all( + color: me.color == c + ? scheme.onSurface + : Colors.transparent, + width: 2, + ), + ), + ), + ), + ), + ], + ), + ), + if (me.avatar != null) + Padding( + padding: const EdgeInsets.fromLTRB(20, 4, 8, 0), + child: Row( + children: [ + Icon( + Icons.image_outlined, + size: 16, + color: scheme.onSurfaceVariant, + ), + const SizedBox(width: 6), + ValueListenableBuilder( + valueListenable: _avatarInfo, + builder: (context, info, _) => Text( + info ?? 'picture', + style: TextStyle( + color: scheme.onSurfaceVariant, + fontSize: 12, + ), + ), + ), + const Spacer(), + TextButton( + onPressed: () => + _me.value = me.copyWith(clearAvatar: true), + child: const Text('Remove picture'), + ), + ], + ), + ), + if (widget.online) + Padding( + padding: const EdgeInsets.fromLTRB(20, 6, 20, 0), + child: Text( + 'Name, picture and colour update for everyone in the party right away.', + style: TextStyle( + color: scheme.onSurfaceVariant, + fontSize: 12, + ), + ), + ), + const Padding( + padding: EdgeInsets.fromLTRB(12, 12, 12, 0), + child: Divider(height: 1), + ), + Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Column( + children: [ + for (final (icon, label, trailing) in [ + ( + Icons.extension_outlined, + 'Jellyfin & integrations', + '2 linked', + ), + (Icons.settings_outlined, 'Settings', null), + (Icons.system_update_alt, 'Update to 0.3.0', 'new'), + (Icons.info_outline, 'About', null), + ]) + ListTile( + dense: true, + contentPadding: const EdgeInsets.symmetric( + horizontal: 20, + ), + leading: Icon(icon, size: 20), + title: Text(label), + trailing: trailing == null + ? null + : trailing == 'new' + ? const _Pill('new', Colors.greenAccent) + : Text( + trailing, + style: TextStyle( + color: scheme.onSurfaceVariant, + fontSize: 12, + ), + ), + onTap: () {}, + ), + ], + ), + ), + ], + ), + ), + ), + ); + } +} + +/// Avatar crop: pick a file, drag to move, scroll or slide to zoom; live +/// previews at the sizes it is shown. "Use this" renders a 256 px PNG. +class _CropPicker extends StatefulWidget { + const _CropPicker(); + + @override + State<_CropPicker> createState() => _CropPickerState(); +} + +class _CropPickerState extends State<_CropPicker> { + static const _view = 300.0, _out = 256; + ui.Image? _img; + double _zoom = 1; // 1 = the image just covers the circle + Offset _pan = Offset.zero; // in viewport pixels + String? _error; + bool _busy = false; + + @override + void initState() { + super.initState(); + // Straight to the file dialog: that's why the picker was opened. + WidgetsBinding.instance.addPostFrameCallback((_) => _pick()); + } + + @override + void dispose() { + _img?.dispose(); + super.dispose(); + } + + Future _pick() async { + final List files; + try { + files = await FilePicker.pickFiles( + dialogTitle: 'Pick a picture', + type: FileType.custom, + allowedExtensions: const ['png', 'jpg', 'jpeg', 'webp', 'gif', 'bmp'], + ); + } catch (e) { + setState(() => _error = 'file dialog unavailable: $e'); + return; + } + final path = files.isEmpty ? null : files.first.path; + if (path == null) return; + try { + final codec = await ui.instantiateImageCodec( + await File(path).readAsBytes(), + ); + final frame = await codec.getNextFrame(); + _img?.dispose(); + setState(() { + _img = frame.image; + _zoom = 1; + _pan = Offset.zero; + _error = null; + }); + } catch (e) { + setState(() => _error = 'could not read that image'); + } + } + + double get _scale { + final img = _img!; + return math.max(_view / img.width, _view / img.height) * _zoom; + } + + /// Keeps the circle covered: the image edge never shows inside it. + Offset _clamp(Offset p) { + final img = _img!; + final mx = (img.width * _scale - _view) / 2, + my = (img.height * _scale - _view) / 2; + return Offset(p.dx.clamp(-mx, mx), p.dy.clamp(-my, my)); + } + + void _setZoom(double z) => setState(() { + _zoom = z.clamp(1.0, 6.0); + _pan = _clamp(_pan); + }); + + Future _use() async { + final img = _img; + if (img == null) return; + setState(() => _busy = true); + final rec = ui.PictureRecorder(); + final canvas = Canvas(rec); + canvas.scale(_out / _view); + _CropPainter(img, _scale, _pan).paint(canvas, const Size(_view, _view)); + final out = await rec.endRecording().toImage(_out, _out); + final png = await out.toByteData(format: ui.ImageByteFormat.png); + out.dispose(); + if (!mounted || png == null) return; + final bytes = png.buffer.asUint8List(); + _me.value = _me.value.copyWith(avatar: bytes); + _avatarInfo.value = + '${_out}x$_out PNG · ${(bytes.length / 1024).toStringAsFixed(0)} KB'; + Navigator.of(context).pop(); + } + + Widget _preview(double size) => ClipOval( + child: SizedBox( + width: size, + height: size, + child: CustomPaint( + painter: _CropPainter( + _img!, + _scale * size / _view, + _pan * (size / _view), + ), + ), + ), + ); + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + final img = _img; + final editor = img == null + ? InkWell( + customBorder: const CircleBorder(), + onTap: _pick, + child: Container( + width: _view, + height: _view, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: scheme.surfaceContainerHighest, + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.add_photo_alternate_outlined, + size: 48, + color: scheme.onSurfaceVariant, + ), + const SizedBox(height: 8), + Text( + _error ?? 'Choose a picture', + style: TextStyle(color: scheme.onSurfaceVariant), + ), + ], + ), + ), + ) + : Listener( + onPointerSignal: (e) { + if (e is PointerScrollEvent) { + _setZoom(_zoom * (e.scrollDelta.dy > 0 ? 0.93 : 1.07)); + } + }, + child: GestureDetector( + onPanUpdate: (d) => setState(() => _pan = _clamp(_pan + d.delta)), + child: MouseRegion( + cursor: SystemMouseCursors.move, + // The dimmed surroundings stay inside this rounded square. + child: ClipRRect( + borderRadius: BorderRadius.circular(18), + child: SizedBox( + width: _view, + height: _view, + child: Stack( + children: [ + // The whole image, dimmed outside the circle. + Positioned.fill( + child: Opacity( + opacity: 0.3, + child: CustomPaint( + painter: _CropPainter( + img, + _scale, + _pan, + clipToView: false, + ), + ), + ), + ), + ClipOval( + child: CustomPaint( + size: const Size(_view, _view), + painter: _CropPainter(img, _scale, _pan), + ), + ), + IgnorePointer( + child: Container( + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: scheme.primary, + width: 2, + ), + ), + ), + ), + ], + ), + ), + ), + ), + ), + ); + return Material( + elevation: 16, + borderRadius: BorderRadius.circular(18), + child: SizedBox( + width: 560, + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Your picture', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 4), + Text( + img == null + ? 'PNG, JPG, WebP, GIF or BMP' + : 'Drag to move · scroll to zoom', + style: const TextStyle(color: Colors.grey, fontSize: 12), + ), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + editor, + if (img != null) ...[ + const SizedBox(width: 24), + Column( + children: [ + _preview(88), + const SizedBox(height: 4), + const Text( + 'profile', + style: TextStyle(fontSize: 11, color: Colors.grey), + ), + const SizedBox(height: 14), + _preview(42), + const SizedBox(height: 4), + const Text( + 'party', + style: TextStyle(fontSize: 11, color: Colors.grey), + ), + const SizedBox(height: 14), + _preview(24), + const SizedBox(height: 4), + const Text( + 'chat', + style: TextStyle(fontSize: 11, color: Colors.grey), + ), + ], + ), + ], + ], + ), + if (img != null) + Row( + children: [ + const Icon(Icons.zoom_out, size: 18), + Expanded( + child: Slider( + value: _zoom, + min: 1, + max: 6, + onChanged: _setZoom, + ), + ), + const Icon(Icons.zoom_in, size: 18), + ], + ) + else + const SizedBox(height: 16), + Row( + children: [ + TextButton.icon( + onPressed: _pick, + icon: const Icon(Icons.folder_open, size: 18), + label: Text(img == null ? 'Choose…' : 'Other file…'), + ), + const Spacer(), + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + const SizedBox(width: 8), + FilledButton( + onPressed: img == null || _busy ? null : _use, + child: const Text('Use this'), + ), + ], + ), + ], + ), + ), + ), + ); + } +} + +/// Draws [img] scaled by [scale], centred in the box and moved by [pan]. +class _CropPainter extends CustomPainter { + final ui.Image img; + final double scale; + final Offset pan; + final bool clipToView; + _CropPainter(this.img, this.scale, this.pan, {this.clipToView = true}); + + @override + void paint(Canvas canvas, Size size) { + if (clipToView) canvas.clipRect(Offset.zero & size); + final w = img.width * scale, h = img.height * scale; + final dst = Rect.fromLTWH( + (size.width - w) / 2 + pan.dx, + (size.height - h) / 2 + pan.dy, + w, + h, + ); + canvas.drawImageRect( + img, + Rect.fromLTWH(0, 0, img.width.toDouble(), img.height.toDouble()), + dst, + Paint()..filterQuality = FilterQuality.high, + ); + } + + @override + bool shouldRepaint(_CropPainter old) => + old.img != img || old.scale != scale || old.pan != pan; +} + +/// Size of the last exported picture (shown under the profile avatar). +final _avatarInfo = ValueNotifier(null); + +/// Horizontal lists for mouse users: drag with the mouse, and the plain +/// (vertical) wheel moves them sideways. At either end the wheel is left +/// to the page, so a row never traps vertical scrolling; lists inside +/// (schedule day columns) keep their own vertical wheel. +class _HScroll extends StatefulWidget { + final ScrollController? controller; + final Widget Function(ScrollController controller) builder; + const _HScroll({this.controller, required this.builder}); + + @override + State<_HScroll> createState() => _HScrollState(); +} + +class _HScrollState extends State<_HScroll> { + ScrollController? _own; + ScrollController get _c => widget.controller ?? (_own ??= ScrollController()); + + @override + void dispose() { + _own?.dispose(); + super.dispose(); + } + + void _wheel(PointerSignalEvent e) { + if (e is! PointerScrollEvent) return; + final dy = e.scrollDelta.dy; + if (dy == 0 || e.scrollDelta.dx != 0 || !_c.hasClients) return; + final p = _c.position; + final target = (p.pixels + dy * 1.5).clamp( + p.minScrollExtent, + p.maxScrollExtent, + ); + if (target == p.pixels) return; // at the end: let the page scroll + GestureBinding.instance.pointerSignalResolver.register(e, (_) { + _c.animateTo( + target, + duration: const Duration(milliseconds: 140), + curve: Curves.easeOutCubic, + ); + }); + } + + @override + Widget build(BuildContext context) => ScrollConfiguration( + behavior: ScrollConfiguration.of(context) + .copyWith(dragDevices: PointerDeviceKind.values.toSet()), + child: Listener(onPointerSignal: _wheel, child: widget.builder(_c)), + ); +} diff --git a/apps/desktop/lib/src/screens/home/top_bar.dart b/apps/desktop/lib/src/screens/home/top_bar.dart index 38643b5..94fed35 100644 --- a/apps/desktop/lib/src/screens/home/top_bar.dart +++ b/apps/desktop/lib/src/screens/home/top_bar.dart @@ -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(builder: (_) => const ShellDemo())), + ), for (final (v, icon, selectedIcon, label) in _pages) IconButton( tooltip: label,