From fce392e425946958ce24691ae9d5d9bbfc9eb984 Mon Sep 17 00:00:00 2001 From: foxyz Date: Fri, 9 Oct 2026 06:23:22 +0300 Subject: [PATCH] Sounds: Japanese-house sound set in the app, layout demo removed Eleven sounds for events that carry news (ready, unready, everyone ready, join, leave, kick, new video, others' chat, idle warning, connection lost/back); none for what the video already says. New voices: koto (Karplus-Strong, bendable), furin chime, rin bowl, bamboo, hyoshigi, shoji slide, in the Yo scale on D. Played by SynthSfx at the Settings volume with per-sound switches and previews. The debug layout mock is gone. Co-Authored-By: Claude Opus 5.5 --- README.md | 4 +- .../lib/src/screens/demo/demo_files.dart | 920 ------ .../lib/src/screens/demo/demo_player.dart | 577 ---- .../lib/src/screens/demo/demo_schedule.dart | 1146 ------- .../lib/src/screens/demo/demo_settings.dart | 345 -- .../lib/src/screens/demo/demo_sounds.dart | 268 -- .../lib/src/screens/demo/shell_demo.dart | 2792 ----------------- .../lib/src/session/session_coordinator.dart | 19 +- apps/desktop/lib/src/sfx/sfx.dart | 65 +- apps/desktop/lib/src/sfx/sound_bank.dart | 178 +- apps/desktop/lib/src/sfx/synth.dart | 291 +- apps/desktop/lib/src/sfx/synth_sfx.dart | 27 +- apps/desktop/lib/src/shell/party_strip.dart | 14 - apps/desktop/lib/src/shell/settings_tab.dart | 81 +- apps/desktop/lib/src/state/session_state.dart | 7 +- apps/desktop/lib/src/state/settings.dart | 20 + apps/desktop/test/coord_presence_test.dart | 16 +- apps/desktop/test/sfx_test.dart | 59 + docs/ARCHITECTURE.md | 23 +- 19 files changed, 546 insertions(+), 6306 deletions(-) delete mode 100644 apps/desktop/lib/src/screens/demo/demo_files.dart delete mode 100644 apps/desktop/lib/src/screens/demo/demo_player.dart delete mode 100644 apps/desktop/lib/src/screens/demo/demo_schedule.dart delete mode 100644 apps/desktop/lib/src/screens/demo/demo_settings.dart delete mode 100644 apps/desktop/lib/src/screens/demo/demo_sounds.dart delete mode 100644 apps/desktop/lib/src/screens/demo/shell_demo.dart create mode 100644 apps/desktop/test/sfx_test.dart diff --git a/README.md b/README.md index a50f892..4b7a768 100644 --- a/README.md +++ b/README.md @@ -149,8 +149,8 @@ of help: Profile; Party & network (the Host card's default port and password, recent servers with remove); Player (below); Jellyfin & integrations (linked Jellyfin servers, below, the AnimeFlow connection: "connected as " with what you may do, or why not, and playing the -next episode automatically); Notifications & sounds (what pops up; there -is nothing to switch yet); Updates; Keyboard shortcuts (read-only); About +next episode automatically); Notifications & sounds (what pops up; the sound +volume, and every sound with a preview and its own switch); Updates; Keyboard shortcuts (read-only); About (the version). Player settings live in the player: ⚙ at the right end of its controls diff --git a/apps/desktop/lib/src/screens/demo/demo_files.dart b/apps/desktop/lib/src/screens/demo/demo_files.dart deleted file mode 100644 index edbf19e..0000000 --- a/apps/desktop/lib/src/screens/demo/demo_files.dart +++ /dev/null @@ -1,920 +0,0 @@ -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 deleted file mode 100644 index dc7c025..0000000 --- a/apps/desktop/lib/src/screens/demo/demo_player.dart +++ /dev/null @@ -1,577 +0,0 @@ -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 deleted file mode 100644 index 0e9b700..0000000 --- a/apps/desktop/lib/src/screens/demo/demo_schedule.dart +++ /dev/null @@ -1,1146 +0,0 @@ -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 deleted file mode 100644 index e017e7c..0000000 --- a/apps/desktop/lib/src/screens/demo/demo_settings.dart +++ /dev/null @@ -1,345 +0,0 @@ -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', - ), - ]), - const _SoundBoard(), - ]; - 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/demo_sounds.dart b/apps/desktop/lib/src/screens/demo/demo_sounds.dart deleted file mode 100644 index 19443eb..0000000 --- a/apps/desktop/lib/src/screens/demo/demo_sounds.dart +++ /dev/null @@ -1,268 +0,0 @@ -part of 'shell_demo.dart'; - -// DEBUG ONLY: every app sound, playable, with where it plays. The sounds -// are synthesized (lib/src/sfx/sound_bank.dart); edit a spec, hot reload, -// press "Re-render" and play it again. - -typedef _Sound = ({String key, String name, String where, SoundSpec spec, bool proposal}); - -List<(String, List<_Sound>)> _soundGroups() { - _Sound k(SfxKind kind, String name, String where) => - (key: kind.name, name: name, where: where, spec: soundFor(kind), proposal: false); - _Sound x(ExtraSound s, String name, String where) => - (key: 'x-${s.name}', name: name, where: where, spec: extraSound(s), proposal: true); - return [ - ('Ready', [ - k(SfxKind.ready, 'Ready', 'Someone presses Space (you too). The second note climbs with each ready person: try the ladder below'), - k(SfxKind.unready, 'Not ready', 'Someone takes their ready back, or idle auto-unready'), - k(SfxKind.forceReady, 'Everyone ready', '"Mark everyone ready" from the member list or the player'), - k(SfxKind.forceUnready, 'Made not ready', 'Someone sets another member not ready'), - x(ExtraSound.start, 'Starting', 'Everyone is ready and playback starts together'), - ]), - ('People', [ - k(SfxKind.join, 'Joined', 'Someone joins the server'), - k(SfxKind.leave, 'Left', 'Someone leaves (a little quieter than join)'), - k(SfxKind.stale, 'Lagging', 'Someone stops reporting for a few seconds (bad connection)'), - k(SfxKind.kick, 'Removed', 'Someone is kicked; also when it is you'), - ]), - ('Playback', [ - k(SfxKind.swap, 'New video', 'Someone puts up a different video or episode'), - k(SfxKind.forcePlay, 'Forced play', 'Someone starts playback without waiting for everyone'), - k(SfxKind.forceHold, 'Held', 'Someone holds playback for everyone'), - k(SfxKind.pause, 'Paused', 'Playback pauses (someone unready, buffering)'), - k(SfxKind.resume, 'Resumed', 'Playback resumes after a pause'), - k(SfxKind.seek, 'Seek', 'Someone seeks or skips (off by default: seeks are frequent)'), - x(ExtraSound.finished, 'Finished', 'The video reaches its end'), - ]), - ('Chat & alerts', [ - k(SfxKind.chat, 'Message', 'A chat message from someone else'), - x(ExtraSound.mention, 'Mention', 'A message with your name in it'), - x(ExtraSound.image, 'Image', 'Someone sends an image to chat'), - k(SfxKind.warn, 'Idle warning', '"Auto-unready in 2 min unless you press Space"'), - x(ExtraSound.lost, 'Connection lost', 'The server stops answering; reconnecting'), - x(ExtraSound.back, 'Reconnected', 'Back in the server after a drop'), - ]), - ]; -} - -class _SoundBoard extends StatefulWidget { - const _SoundBoard(); - - @override - State<_SoundBoard> createState() => _SoundBoardState(); -} - -class _SoundBoardState extends State<_SoundBoard> { - static final _sfx = SynthSfx(); - final _off = {'seek'}; - String? _playing; - bool _sequence = false; - - Future _play(String key, SoundSpec spec) async { - setState(() => _playing = key); - await _sfx.playSpec(key, spec); - final ms = (spec.length * 1000).round(); - await Future.delayed(Duration(milliseconds: ms)); - if (mounted && _playing == key) setState(() => _playing = null); - } - - Future _playAll() async { - setState(() => _sequence = true); - for (final (_, sounds) in _soundGroups()) { - for (final s in sounds) { - if (!mounted || !_sequence) return; - await _play(s.key, s.spec); - await Future.delayed(const Duration(milliseconds: 250)); - } - } - if (mounted) setState(() => _sequence = false); - } - - @override - Widget build(BuildContext context) { - final scheme = Theme.of(context).colorScheme; - final muted = TextStyle(color: scheme.onSurfaceVariant, fontSize: 12); - final pct = (_sfx.volume * 100).round(); - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - _panel(context, 'Sounds', [ - Row( - children: [ - Icon(_sfx.volume == 0 ? Icons.volume_off_outlined : Icons.volume_down_outlined, - color: scheme.onSurfaceVariant), - Expanded( - child: Slider( - value: _sfx.volume, - onChanged: (v) => setState(() => _sfx.volume = v), - onChangeEnd: (_) => _play('ready', soundFor(SfxKind.ready)), - ), - ), - SizedBox(width: 44, child: Text('$pct%', textAlign: TextAlign.end)), - ], - ), - Text('Soft and short on purpose; the slider previews the ready chime when you let go.', style: muted), - const SizedBox(height: 12), - Wrap( - spacing: 8, - runSpacing: 8, - crossAxisAlignment: WrapCrossAlignment.center, - children: [ - FilledButton.tonalIcon( - onPressed: _sequence ? () => setState(() => _sequence = false) : _playAll, - icon: Icon(_sequence ? Icons.stop : Icons.playlist_play), - label: Text(_sequence ? 'Stop' : 'Play all in order'), - ), - OutlinedButton.icon( - onPressed: () { - _sfx.forget(); - _Waveform._cache.clear(); - setState(() {}); - }, - icon: const Icon(Icons.refresh, size: 18), - label: const Text('Re-render (after hot reload)'), - ), - const SizedBox(width: 8), - Text('Ready ladder:', style: muted), - for (var i = 1; i <= 6; i++) - ActionChip( - label: Text('$i'), - tooltip: '$i${i == 1 ? 'st' : i == 2 ? 'nd' : i == 3 ? 'rd' : 'th'} person ready', - onPressed: () => _play('ready-$i', readySound(i)), - ), - ], - ), - ]), - for (final (title, sounds) in _soundGroups()) - _panel(context, title, [ - for (final s in sounds) _row(context, s), - ]), - ], - ); - } - - Widget _panel(BuildContext context, 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.stretch, - children: [ - Text(title, style: const TextStyle(fontWeight: FontWeight.w700)), - const SizedBox(height: 6), - ...children, - ], - ), - ), - ), - ); - - Widget _row(BuildContext context, _Sound s) { - final scheme = Theme.of(context).colorScheme; - final on = !_off.contains(s.key); - final playing = _playing == s.key; - return Padding( - padding: const EdgeInsets.symmetric(vertical: 4), - child: Row( - children: [ - IconButton.filledTonal( - tooltip: 'Play', - onPressed: () => _play(s.key, s.spec), - icon: Icon(playing ? Icons.graphic_eq : Icons.play_arrow_rounded), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Text(s.name, style: const TextStyle(fontWeight: FontWeight.w600)), - if (s.proposal) ...[ - const SizedBox(width: 8), - _Pill('new', scheme.tertiary), - ], - ], - ), - Text(s.where, style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12)), - ], - ), - ), - const SizedBox(width: 12), - SizedBox( - width: 120, - height: 32, - child: _Waveform(id: s.key, spec: s.spec, playing: playing, color: on ? scheme.primary : scheme.outlineVariant), - ), - Switch( - value: on, - onChanged: (v) => setState(() => v ? _off.remove(s.key) : _off.add(s.key)), - ), - ], - ), - ); - } -} - -/// The sound's envelope (peak per column), drawn once per spec. -class _Waveform extends StatelessWidget { - final String id; - final SoundSpec spec; - final bool playing; - final Color color; - const _Waveform({required this.id, required this.spec, required this.playing, required this.color}); - - static final _cache = >{}; - - List _peaks() => _cache[id] ??= () { - final samples = render(spec); - const cols = 48; - final per = (samples.length / cols).ceil(); - final peaks = []; - var max = 0.0; - for (var c = 0; c < cols; c++) { - var p = 0.0; - for (var i = c * per; i < (c + 1) * per && i < samples.length; i++) { - p = math.max(p, samples[i].abs()); - } - peaks.add(p); - max = math.max(max, p); - } - return [for (final p in peaks) max == 0 ? 0.0 : math.sqrt(p / max)]; - }(); - - @override - Widget build(BuildContext context) => AnimatedOpacity( - duration: _easeTime, - curve: _ease, - opacity: playing ? 1 : 0.55, - child: CustomPaint(painter: _WavePainter(_peaks(), color)), - ); -} - -class _WavePainter extends CustomPainter { - final List peaks; - final Color color; - _WavePainter(this.peaks, this.color); - - @override - void paint(Canvas canvas, Size size) { - final paint = Paint() - ..color = color - ..strokeWidth = 2 - ..strokeCap = StrokeCap.round; - final step = size.width / peaks.length; - final mid = size.height / 2; - for (var i = 0; i < peaks.length; i++) { - final h = math.max(1.0, peaks[i] * mid); - final x = step * (i + 0.5); - canvas.drawLine(Offset(x, mid - h), Offset(x, mid + h), paint); - } - } - - @override - bool shouldRepaint(_WavePainter old) => old.peaks != peaks || old.color != color; -} diff --git a/apps/desktop/lib/src/screens/demo/shell_demo.dart b/apps/desktop/lib/src/screens/demo/shell_demo.dart deleted file mode 100644 index a8a5c11..0000000 --- a/apps/desktop/lib/src/screens/demo/shell_demo.dart +++ /dev/null @@ -1,2792 +0,0 @@ -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 '../../sfx/sfx.dart'; -import '../../sfx/sound_bank.dart'; -import '../../sfx/synth.dart'; -import '../../sfx/synth_sfx.dart'; -import '../../widgets/window_buttons.dart'; - -part 'demo_files.dart'; -part 'demo_player.dart'; -part 'demo_schedule.dart'; -part 'demo_settings.dart'; -part 'demo_sounds.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/session/session_coordinator.dart b/apps/desktop/lib/src/session/session_coordinator.dart index 4b63911..ae13e72 100644 --- a/apps/desktop/lib/src/session/session_coordinator.dart +++ b/apps/desktop/lib/src/session/session_coordinator.dart @@ -260,6 +260,8 @@ class SessionCoordinator { int _toastId = 0; ConnectParams? _params; bool _everConnected = false; + /// The connection-lost sound played; the reconnected one answers it once. + bool _lostSounded = false; Map? _openHeaders; // Jellyfin: the resolved ref, the mediaId it belongs to, a generation @@ -356,6 +358,7 @@ class SessionCoordinator { _restoringReady = false; _wasPlayAllowed = false; _everConnected = false; + _lostSounded = false; _resumeToken = ''; _seekGate.reset(); _clearPendingSeek(); @@ -423,6 +426,7 @@ class SessionCoordinator { if (s == SyncStatus.closed && !_manualClose && kickReason.peek() == null) { health.value = const HealthStatus( HealthSeverity.warn, 'connection lost — retrying…'); + _soundLost(); _scheduleReconnect(); } }); @@ -727,7 +731,9 @@ class SessionCoordinator { ]; } final kind = sfxForFeedAction(f.action); - if (kind != null) { + // Your own message needs no sound. + final own = f.action == 'chat' && f.actor == selfMember.peek()?.name; + if (kind != null && !own) { var readyIndex = 0; if (f.action == 'ready') { readyIndex = serverState.peek()?.members.where((m) => m.ready).length ?? 0; @@ -813,6 +819,7 @@ class SessionCoordinator { if (!reconnecting.peek()) { health.value = const HealthStatus( HealthSeverity.warn, 'connection lost — retrying…'); + _soundLost(); _scheduleReconnect(); } } @@ -1507,6 +1514,10 @@ class SessionCoordinator { if (stale()) return; reconnecting.value = false; _everConnected = true; + if (_lostSounded) { + _lostSounded = false; + unawaited(sfx.play(const SfxCue(SfxKind.back))); + } _resumeToken = sync.token; _lastStateAt = DateTime.now().toUtc(); health.value = const HealthStatus(HealthSeverity.ok, 'connected'); @@ -1531,6 +1542,12 @@ class SessionCoordinator { } } + void _soundLost() { + if (!_everConnected || _lostSounded) return; + _lostSounded = true; + unawaited(sfx.play(const SfxCue(SfxKind.lost))); + } + void _invalidateAttempts() { _epoch++; _reconnectTimer?.cancel(); diff --git a/apps/desktop/lib/src/sfx/sfx.dart b/apps/desktop/lib/src/sfx/sfx.dart index 87485ff..ff406c3 100644 --- a/apps/desktop/lib/src/sfx/sfx.dart +++ b/apps/desktop/lib/src/sfx/sfx.dart @@ -1,20 +1,22 @@ +/// The app's sounds. Only events that carry news get one: no sound for +/// what the video itself makes obvious (start, pause, end, seeks). enum SfxKind { + /// Someone readies up; pitch climbs with [SfxCue.readyIndex]. ready, unready, + /// Someone marks everyone ready. forceReady, - forceUnready, - forcePlay, - forceHold, - kick, join, leave, - stale, + kick, + /// A new video or episode is put up. swap, - seek, - pause, - resume, chat, + /// Idle warning: auto-unready soon. warn, + /// The connection to the server dropped / came back. + lost, + back, } class SfxCue { @@ -23,42 +25,17 @@ class SfxCue { const SfxCue(this.kind, {this.readyIndex = 0}); } -SfxKind? sfxForFeedAction(String action) { - switch (action) { - case 'ready': - return SfxKind.ready; - case 'unready': - return SfxKind.unready; - case 'force-ready': - return SfxKind.forceReady; - case 'force-unready': - return SfxKind.forceUnready; - case 'force-play': - return SfxKind.forcePlay; - case 'force-hold': - return SfxKind.forceHold; - case 'kick': - return SfxKind.kick; - case 'join': - return SfxKind.join; - case 'leave': - return SfxKind.leave; - case 'stale': - return SfxKind.stale; - case 'swap': - return SfxKind.swap; - case 'seek': - return SfxKind.seek; - case 'pause': - return SfxKind.pause; - case 'resume': - return SfxKind.resume; - case 'chat': - return SfxKind.chat; - default: - return null; - } -} +SfxKind? sfxForFeedAction(String action) => switch (action) { + 'ready' => SfxKind.ready, + 'unready' => SfxKind.unready, + 'force-ready' => SfxKind.forceReady, + 'kick' => SfxKind.kick, + 'join' => SfxKind.join, + 'leave' => SfxKind.leave, + 'swap' => SfxKind.swap, + 'chat' => SfxKind.chat, + _ => null, + }; abstract class SfxService { Future play(SfxCue cue); diff --git a/apps/desktop/lib/src/sfx/sound_bank.dart b/apps/desktop/lib/src/sfx/sound_bank.dart index 6d5a076..4d42ea8 100644 --- a/apps/desktop/lib/src/sfx/sound_bank.dart +++ b/apps/desktop/lib/src/sfx/sound_bank.dart @@ -1,130 +1,98 @@ import 'sfx.dart'; import 'synth.dart'; -// The app's sound set, all in D major pentatonic so any two that overlap -// still agree. Rising = someone arrives / gets ready, falling = leaves / -// unready; bells for people, soft sines for the player, plucks for ticks. -// Everything is quiet (-16 dBFS peaks or lower) and short. +// The sound of the house: koto for readiness, the wind chime for people +// arriving and leaving, the shoji door for a new video, bamboo for chat, +// clappers for a kick, the singing bowl for the idle warning, a string +// going slack and re-tuned for the connection. All in the Yo scale on D +// (D E G A B), so overlapping sounds agree; quiet and short. -// Pitches, in semitones from A4. -const _d4 = -7, _e4 = -5, _a4 = 0; -const _d5 = 5, _e5 = 7, _fs5 = 9, _a5 = 12, _b5 = 14; -const _d6 = 17, _e6 = 19, _fs6 = 21, _a6 = 24; +const _d4 = -7, _a4 = 0, _b4 = 2; +const _d5 = 5, _e5 = 7, _g5 = 10, _a5 = 12, _b5 = 14; +const _d6 = 17, _e6 = 19, _g6 = 22, _a6 = 24, _b6 = 26; double _p(int s) => semi(s); -/// The ready chime's second note climbs with how many are ready (1st, 2nd…). -const _readyLadder = [_fs5, _a5, _b5, _d6, _e6, _fs6]; +/// The ready pluck's answer climbs with each person ready. +const _readyLadder = [_g5, _a5, _b5, _d6, _e6, _g6]; SoundSpec readySound(int readyIndex) { final top = _readyLadder[(readyIndex - 1).clamp(0, _readyLadder.length - 1)]; return SoundSpec([ - Note(0, _p(_d5), 0.35, gain: 0.8), - Note(0.075, _p(top), 0.55), - ]); + Note(0, _p(_d5), 0.9, gain: 0.75, bright: 0.55), + Note(0.085, _p(top), 1.1, bright: 0.65), + ], peakDb: -17); } SoundSpec soundFor(SfxKind kind, {int readyIndex = 1}) => switch (kind) { SfxKind.ready => readySound(readyIndex), + // The same strings, muted and stepping down. SfxKind.unready => SoundSpec([ - Note(0, _p(_a5), 0.3, gain: 0.8), - Note(0.08, _p(_e5), 0.45, gain: 0.7), - ], cutoff: 4200, peakDb: -19), + Note(0, _p(_a5), 0.35, gain: 0.8, bright: 0.2), + Note(0.09, _p(_g5), 0.45, bright: 0.15), + ], cutoff: 3800, peakDb: -20, room: 0.1), + // A strum up the open strings, a chime on top. SfxKind.forceReady => SoundSpec([ - Note(0, _p(_d5), 0.35, gain: 0.7), - Note(0.06, _p(_fs5), 0.35, gain: 0.8), - Note(0.12, _p(_a5), 0.6), - ]), - SfxKind.forceUnready => SoundSpec([ - Note(0, _p(_a5), 0.3, gain: 0.8), - Note(0.06, _p(_fs5), 0.3, gain: 0.7), - Note(0.12, _p(_d5), 0.5, gain: 0.7), - ], cutoff: 4200, peakDb: -19), - SfxKind.forcePlay => SoundSpec([ - Note(0, _p(_d5), 0.7, gain: 0.6, voice: Voice.soft), - Note(0, _p(_a5), 0.7, gain: 0.5, voice: Voice.soft), - Note(0.12, _p(_d6), 0.8), - ]), - SfxKind.forceHold => SoundSpec([ - Note(0, _p(_a4), 0.35, voice: Voice.soft), - Note(0.1, _p(_d4), 0.55, voice: Voice.soft), - ], cutoff: 3000, peakDb: -18), - SfxKind.kick => SoundSpec([ - Note(0, _p(_e4), 0.4, voice: Voice.soft, to: _p(_d4 - 2)), - Note(0.14, _p(_d4 - 5), 0.6, voice: Voice.soft, gain: 0.8), - ], cutoff: 1800, peakDb: -19), + for (final (i, s) in [_d5, _e5, _g5, _a5, _b5, _d6].indexed) + Note(i * 0.032, _p(s), 1.2, gain: 0.55 + i * 0.05, bright: 0.55), + Note(0.22, _p(_d6) * 2, 1.4, voice: Voice.chime, gain: 0.18), + ], peakDb: -17), + // Wind through the furin: three uneven tinkles. SfxKind.join => SoundSpec([ - Note(0, _p(_d5), 0.3, gain: 0.7), - Note(0.07, _p(_e5), 0.3, gain: 0.75), - Note(0.14, _p(_a5), 0.6), - ]), + Note(0, _p(_a6), 1.3, voice: Voice.chime, gain: 0.8), + Note(0.11, _p(_d6) * 2, 1.2, voice: Voice.chime, gain: 0.55), + Note(0.26, _p(_e6) * 2, 1.4, voice: Voice.chime, gain: 0.45), + ], cutoff: 7000, peakDb: -19, room: 0.18), + // The wind settling: two softer, lower tinkles. SfxKind.leave => SoundSpec([ - Note(0, _p(_a5), 0.28, gain: 0.8), - Note(0.08, _p(_e5), 0.28, gain: 0.7), - Note(0.16, _p(_d5), 0.5, gain: 0.6), - ], cutoff: 3800, peakDb: -20), - SfxKind.stale => SoundSpec([ - Note(0, _p(_fs5), 0.6, voice: Voice.soft, wobble: 0.25), - ], cutoff: 3500, peakDb: -21), + Note(0, _p(_b6), 1.0, voice: Voice.chime, gain: 0.6), + Note(0.17, _p(_e6), 1.3, voice: Voice.chime, gain: 0.5), + ], cutoff: 5000, peakDb: -22, room: 0.18), + // Hyoshigi: clack… clack, then a low muted string. + SfxKind.kick => SoundSpec([ + Note(0, 1900, 0.09, voice: Voice.clack), + Note(0.16, 1750, 0.11, voice: Voice.clack, gain: 0.9), + Note(0.3, _p(_d4), 0.7, bright: 0.15, gain: 0.7), + ], cutoff: 5000, peakDb: -19, room: 0.12), + // The door slides open on something new: air, then a fifth. SfxKind.swap => SoundSpec([ - Note(0, _p(_d5), 0.6, voice: Voice.soft, gain: 0.6), - Note(0.05, _p(_a5), 0.3, gain: 0.5), - Note(0.1, _p(_d6), 0.3, gain: 0.5), - Note(0.15, _p(_fs6), 0.5, gain: 0.45), - ], cutoff: 6000, peakDb: -18), - SfxKind.seek => const SoundSpec([ - Note(0, 987.77, 0.06, voice: Voice.pluck), - ], cutoff: 3500, peakDb: -26, room: 0), - SfxKind.pause => SoundSpec([ - Note(0, _p(_a4), 0.28, voice: Voice.soft, to: _p(_e4)), - ], cutoff: 3000, peakDb: -21, room: 0.08), - SfxKind.resume => SoundSpec([ - Note(0, _p(_a4), 0.28, voice: Voice.soft, to: _p(_d5)), - ], cutoff: 3500, peakDb: -21, room: 0.08), + Note(0, 500, 0.42, voice: Voice.slide, to: 1700, gain: 0.5), + Note(0.33, _p(_d5), 1.1, bright: 0.6, gain: 0.8), + Note(0.36, _p(_a5), 1.1, bright: 0.6, gain: 0.7), + ], peakDb: -18), + // Bamboo: a hollow tok-tok, the second higher. SfxKind.chat => SoundSpec([ - Note(0, _p(_b5), 0.18, gain: 0.7), - Note(0.055, _p(_e6), 0.3), + Note(0, _p(_g5), 0.13, voice: Voice.bamboo, gain: 0.85), + Note(0.075, _p(_d6), 0.16, voice: Voice.bamboo), ], cutoff: 5500, peakDb: -19, room: 0.1), + // The singing bowl: one soft strike that hums. SfxKind.warn => SoundSpec([ - Note(0, _p(_fs5), 0.25, voice: Voice.soft), - Note(0.22, _p(_fs5), 0.25, voice: Voice.soft, gain: 0.85), - Note(0.44, _p(_d5), 0.45, voice: Voice.soft, gain: 0.8), - ], cutoff: 3800, peakDb: -18), + Note(0, _p(_d5), 2.6, voice: Voice.rin), + ], cutoff: 4500, peakDb: -20, room: 0.1), + // A string going slack: plucked, its pitch sagging away. + SfxKind.lost => SoundSpec([ + Note(0, _p(_a5), 1.1, to: _p(_d5), bright: 0.4), + Note(0.05, _p(_d5), 1.0, to: _p(_a4), bright: 0.3, gain: 0.6), + ], cutoff: 3500, peakDb: -19), + // Re-tuned: a quick bend up that lands, and a tinkle. + SfxKind.back => SoundSpec([ + Note(0, _p(_b4), 0.9, to: _p(_d5), bright: 0.5), + Note(0.2, _p(_a5), 1.0, bright: 0.6, gain: 0.7), + Note(0.3, _p(_d6) * 2, 1.0, voice: Voice.chime, gain: 0.2), + ], peakDb: -18), }; -/// Sounds the app has no event for yet (proposals, shown in the demo). -enum ExtraSound { start, finished, lost, back, image, mention } - -SoundSpec extraSound(ExtraSound s) => switch (s) { - ExtraSound.start => SoundSpec([ - Note(0, _p(_d5), 0.9, voice: Voice.soft, gain: 0.5), - Note(0.04, _p(_fs5), 0.9, voice: Voice.soft, gain: 0.45), - Note(0.08, _p(_a5), 0.9, voice: Voice.soft, gain: 0.45), - Note(0.12, _p(_d6), 0.9, gain: 0.6), - ], cutoff: 5000, peakDb: -18, room: 0.2), - ExtraSound.finished => SoundSpec([ - Note(0, _p(_a5), 0.5, gain: 0.8), - Note(0.16, _p(_fs5), 0.5, gain: 0.75), - Note(0.32, _p(_d5), 1.1, gain: 0.8), - Note(0.32, _p(_a4), 1.1, voice: Voice.soft, gain: 0.4), - ], cutoff: 4500, peakDb: -18, room: 0.22), - ExtraSound.lost => SoundSpec([ - Note(0, _p(_e4), 0.35, voice: Voice.soft), - Note(0.18, _p(_d4), 0.6, voice: Voice.soft), - ], cutoff: 2500, peakDb: -18), - ExtraSound.back => SoundSpec([ - Note(0, _p(_d4 + 12), 0.3, voice: Voice.soft), - Note(0.12, _p(_a4), 0.5, voice: Voice.soft), - Note(0.12, _p(_d5), 0.5, gain: 0.5), - ], cutoff: 4000, peakDb: -18), - ExtraSound.image => SoundSpec([ - Note(0, _p(_e6), 0.12, voice: Voice.pluck, gain: 0.6), - Note(0.05, _p(_b5), 0.18, gain: 0.7), - Note(0.1, _p(_a6), 0.35, gain: 0.6), - ], cutoff: 6000, peakDb: -20, room: 0.1), - ExtraSound.mention => SoundSpec([ - Note(0, _p(_d6), 0.25), - Note(0.09, _p(_a5), 0.25, gain: 0.8), - Note(0.18, _p(_d6), 0.45), - ], cutoff: 6000, peakDb: -17), - }; +/// Shown in Settings → Notifications & sounds: name and when it plays. +const soundInfo = { + SfxKind.ready: ('Ready', 'Someone presses Space; climbs with each ready person'), + SfxKind.unready: ('Not ready', 'Someone takes their ready back'), + SfxKind.forceReady: ('Everyone ready', 'Someone marks everyone ready'), + SfxKind.join: ('Joined', 'Someone joins the server'), + SfxKind.leave: ('Left', 'Someone leaves'), + SfxKind.kick: ('Removed', 'Someone is kicked'), + SfxKind.swap: ('New video', 'A different video or episode is put up'), + SfxKind.chat: ('Message', 'A chat message from someone else'), + SfxKind.warn: ('Idle warning', 'Auto-unready soon unless you press Space'), + SfxKind.lost: ('Connection lost', 'The server stopped answering; reconnecting'), + SfxKind.back: ('Reconnected', 'Back in the server after a drop'), +}; diff --git a/apps/desktop/lib/src/sfx/synth.dart b/apps/desktop/lib/src/sfx/synth.dart index dfcc3e1..19c773b 100644 --- a/apps/desktop/lib/src/sfx/synth.dart +++ b/apps/desktop/lib/src/sfx/synth.dart @@ -1,78 +1,93 @@ import 'dart:math' as math; import 'dart:typed_data'; -// A tiny offline synthesizer for UI sounds: notes with soft attacks and -// exponential decays, a low-pass to take the edge off and a short room -// tail. Everything renders to 16-bit mono WAV bytes. Pure Dart, no Flutter. +// A tiny offline synthesizer for UI sounds, voiced as a Japanese house: +// koto strings (Karplus-Strong), a glass wind chime (furin), a singing +// bowl (rin), bamboo knocks, wooden clappers (hyoshigi) and the air of a +// sliding door (shoji). Renders to 16-bit mono WAV bytes. Pure Dart. const sampleRate = 44100; -/// Timbre of a note. enum Voice { - /// Sine with a touch of octave: round, flute-like. - soft, - /// Glass/marimba: a few partials, the upper ones die first. - bell, - /// Short and dry, for ticks. - pluck, + /// Plucked string; [Note.to] bends it (the delay line stretches). + koto, + /// Small glass bell: inharmonic partials, a slow shimmer. + chime, + /// Singing bowl: a soft strike and a long beating hum. + rin, + /// Hollow bamboo knock, pitched. + bamboo, + /// Two hardwood blocks struck together. + clack, + /// Breathy air sweeping up (a sliding door); [Note.hz]→[Note.to] is the + /// sweep of its band. + slide, } class Note { - /// Start (s) and pitch (Hz); [to] glides there over the note. final double at; final double hz; final double? to; - /// Length (s) of the decay to about -40 dB. + /// Length (s) to about -40 dB. final double len; final double gain; final Voice voice; - /// Vibrato depth in semitones (0 = none), ~5.5 Hz. - final double wobble; + /// Koto only: 0 = dark/muted pluck, 1 = bright. + final double bright; const Note(this.at, this.hz, this.len, - {this.to, this.gain = 1, this.voice = Voice.bell, this.wobble = 0}); + {this.to, this.gain = 1, this.voice = Voice.koto, this.bright = 0.6}); } class SoundSpec { final List notes; - /// One-pole low-pass cutoff (Hz): lower = softer, darker. + /// Gentle low-pass cutoff (Hz) over the mix. final double cutoff; - /// Peak level after normalising, in dBFS (quiet by design). + /// Peak level after normalising, dBFS (quiet by design). final double peakDb; - /// Room tail mix (0..1). + /// Room tail mix (0..1): the veranda, not a hall. final double room; - const SoundSpec(this.notes, {this.cutoff = 5200, this.peakDb = -16, this.room = 0.16}); + const SoundSpec(this.notes, {this.cutoff = 6000, this.peakDb = -17, this.room = 0.14}); double get length => notes.fold(0, (m, n) => math.max(m, n.at + n.len)) + (room > 0 ? 0.25 : 0.02); } -/// Note frequency by name-free index: semitones from A4 (440 Hz). +/// Semitones from A4 (440 Hz) to Hz. double semi(num n) => 440 * math.pow(2, n / 12).toDouble(); -Float64List render(SoundSpec spec) { +Float64List render(SoundSpec spec, {int seed = 7}) { final total = (spec.length * sampleRate).ceil(); final out = Float64List(total); + final rnd = math.Random(seed); for (final n in spec.notes) { - _addNote(out, n); - } - // Low-pass: one pole, run twice for a gentler 12 dB/oct slope. - final a = math.exp(-2 * math.pi * spec.cutoff / sampleRate); - for (var pass = 0; pass < 2; pass++) { - var y = 0.0; - for (var i = 0; i < total; i++) { - y = (1 - a) * out[i] + a * y; - out[i] = y; + final start = (n.at * sampleRate).round(); + if (start >= total) continue; + final s = switch (n.voice) { + Voice.koto => _koto(n, rnd), + Voice.chime => _bell(n, const [(1.0, 1.0, 1.0), (2.76, 0.45, 1.9), (5.40, 0.2, 3.2), (8.93, 0.08, 4.5)], 1.6, 0.003), + Voice.rin => _bell(n, const [(1.0, 1.0, 1.0), (2.71, 0.35, 1.5), (5.15, 0.12, 2.4)], 2.4, 0.012), + Voice.bamboo => _bamboo(n, rnd), + Voice.clack => _clack(n, rnd), + Voice.slide => _slide(n, rnd), + }; + for (var i = 0; i < s.length && start + i < total; i++) { + out[start + i] += n.gain * s[i]; } } + _lowpass(out, spec.cutoff); if (spec.room > 0) _room(out, spec.room); - // Fade the last 10 ms so nothing clicks, then normalise to the peak. + // 1 ms in, 10 ms out: no clicks at either end. + final fadeIn = (0.001 * sampleRate).round(); + for (var i = 0; i < fadeIn && i < total; i++) { + out[i] *= i / fadeIn; + } final fade = (0.01 * sampleRate).round(); for (var i = 0; i < fade && i < total; i++) { out[total - 1 - i] *= i / fade; } var peak = 0.0; - for (final s in out) { - peak = math.max(peak, s.abs()); + for (final v in out) { + peak = math.max(peak, v.abs()); } if (peak > 0) { final g = math.pow(10, spec.peakDb / 20) / peak; @@ -83,51 +98,193 @@ Float64List render(SoundSpec spec) { return out; } -void _addNote(Float64List out, Note n) { - final start = (n.at * sampleRate).round(); - final len = (n.len * sampleRate).round(); - final attack = switch (n.voice) { - Voice.soft => 0.012, - Voice.bell => 0.004, - Voice.pluck => 0.002, - } * sampleRate; - // exp(-k * len) = 0.01 (-40 dB). - final k = math.log(100) / len; - final partials = switch (n.voice) { - Voice.soft => const [(1.0, 1.0, 1.0), (2.0, 0.12, 1.6)], - Voice.bell => const [(1.0, 1.0, 1.0), (2.0, 0.28, 1.8), (3.01, 0.08, 2.6), (4.2, 0.05, 3.5)], - Voice.pluck => const [(1.0, 1.0, 1.0), (2.0, 0.35, 2.0), (3.0, 0.15, 3.0)], - }; - final phases = List.filled(partials.length, 0); - for (var i = 0; i < len && start + i < out.length; i++) { +int _n(double secs) => (secs * sampleRate).round(); +double _smooth(double t) => t * t * (3 - 2 * t); + +/// Karplus-Strong with a fractional, movable delay: a noise burst in a +/// loop that loses a little of its highs each pass. +Float64List _koto(Note n, math.Random rnd) { + final len = _n(n.len); + final out = Float64List(len); + final maxPeriod = sampleRate / math.min(n.hz, n.to ?? n.hz) + 4; + final buf = Float64List(maxPeriod.ceil() + 4); + final size = buf.length; + // Excitation: noise, smoothed more for a darker pluck. + final period0 = sampleRate / n.hz; + var prev = 0.0; + final smooth = 1 - (0.25 + 0.7 * n.bright); + for (var i = 0; i < period0.ceil(); i++) { + final x = rnd.nextDouble() * 2 - 1; + prev = smooth * prev + (1 - smooth) * x; + buf[i % size] = prev; + } + // Loop gain so the note reaches -40 dB at len. + final loss = math.pow(0.01, 1 / (n.len * n.hz)).toDouble(); + var w = period0.ceil(); + var last = 0.0; + for (var i = 0; i < len; i++) { final t = i / len; - var hz = n.to == null ? n.hz : n.hz * math.pow(n.to! / n.hz, _smooth(t)); - if (n.wobble > 0) { - hz *= math.pow(2, n.wobble / 12 * math.sin(2 * math.pi * 5.5 * i / sampleRate)); + final hz = n.to == null ? n.hz : n.hz * math.pow(n.to! / n.hz, _smooth(math.min(1, t * 1.6))); + // The two-point average below delays half a sample: take it off. + final period = sampleRate / hz - 0.5; + final r = w - period; + final ri = r.floor(); + final f = r - ri; + final a = buf[(ri % size + size) % size]; + final b = buf[((ri + 1) % size + size) % size]; + final y = a + (b - a) * f; + // Two-point average = the string's damping; the 0.5 mix keeps it bright. + final v = loss * (0.5 * y + 0.5 * last); + last = y; + buf[w % size] = v; + w++; + // A 3 ms attack takes the click off the pluck. + out[i] = v * (i < 132 ? i / 132 : 1); + } + return out; +} + +/// Inharmonic partials (ratio, amplitude, decay multiplier), each doubled +/// with a slightly detuned twin so the tail shimmers. +Float64List _bell(Note n, List<(double, double, double)> partials, double beatHz, double attackSecs) { + final len = _n(n.len); + final out = Float64List(len); + final k = math.log(100) / len; + final attack = _n(attackSecs); + for (final (ratio, amp, decay) in partials) { + final f1 = n.hz * ratio, f2 = f1 + beatHz * ratio; + for (var i = 0; i < len; i++) { + final env = math.exp(-k * decay * i) * (i < attack ? _smooth(i / attack) : 1); + final ph = 2 * math.pi * i / sampleRate; + out[i] += amp * env * 0.5 * (math.sin(ph * f1) + math.sin(ph * f2)); } - final env = (i < attack ? _smooth(i / attack) : 1.0) * math.exp(-k * i); - var s = 0.0; - for (var p = 0; p < partials.length; p++) { - final (mult, amp, decay) = partials[p]; - phases[p] += 2 * math.pi * hz * mult / sampleRate; - // Upper partials fade faster: the attack has shimmer, the tail is round. - s += amp * math.sin(phases[p]) * math.exp(-k * (decay - 1) * i); + } + return out; +} + +/// A hollow tube: a pitched thump that drops into its note, an overtone +/// that dies fast, and a short click of air. +Float64List _bamboo(Note n, math.Random rnd) { + final len = _n(n.len); + final out = Float64List(len); + final k = math.log(100) / len; + var ph = 0.0, ph2 = 0.0; + for (var i = 0; i < len; i++) { + final drop = math.exp(-i / _n(0.012)); + final hz = n.hz * (1 + 0.5 * drop); + ph += 2 * math.pi * hz / sampleRate; + ph2 += 2 * math.pi * hz * 2.31 / sampleRate; + out[i] = math.exp(-k * i) * (math.sin(ph) + 0.35 * math.sin(ph2) * math.exp(-k * 3 * i)); + } + final click = _bandNoise(_n(0.006), n.hz * 3, 2.0, rnd); + for (var i = 0; i < click.length && i < len; i++) { + out[i] += 0.5 * click[i] * (1 - i / click.length); + } + return out; +} + +/// Hardwood: two resonances excited by a very short noise burst. +Float64List _clack(Note n, math.Random rnd) { + final len = _n(n.len); + final out = Float64List(len); + final burst = Float64List(len); + for (var i = 0; i < _n(0.002) && i < len; i++) { + burst[i] = rnd.nextDouble() * 2 - 1; + } + for (final (f, q, g) in [(n.hz, 18.0, 1.0), (n.hz * 1.47, 14.0, 0.6), (n.hz * 2.6, 10.0, 0.25)]) { + final r = _biquadBand(burst, f, q); + for (var i = 0; i < len; i++) { + out[i] += g * r[i]; + } + } + final k = math.log(100) / len; + for (var i = 0; i < len; i++) { + out[i] *= math.exp(-k * i); + } + return out; +} + +/// Air through a band that sweeps from [Note.hz] to [Note.to], swelling in +/// and out (a paper door sliding along its track). +Float64List _slide(Note n, math.Random rnd) { + final len = _n(n.len); + final noise = Float64List(len); + for (var i = 0; i < len; i++) { + noise[i] = rnd.nextDouble() * 2 - 1; + } + final out = Float64List(len); + // Sweep in short blocks (a fixed band per block is plenty). + const block = 256; + var z1 = 0.0, z2 = 0.0; + for (var b0 = 0; b0 < len; b0 += block) { + final t = b0 / len; + final f = n.hz * math.pow((n.to ?? n.hz) / n.hz, t); + final c = _bandCoeffs(f.toDouble(), 2.5); + for (var i = b0; i < math.min(len, b0 + block); i++) { + final y = c.$1 * noise[i] + z1; + z1 = c.$2 * noise[i] - c.$4 * y + z2; + z2 = c.$3 * noise[i] - c.$5 * y; + final tt = i / len; + // Rises, holds, eases off: sin^2 swell with a tail. + final env = math.pow(math.sin(math.pi * math.min(1, tt * 1.25)), 2).toDouble(); + // The track's grain: a slow flutter. + final grain = 1 + 0.25 * math.sin(2 * math.pi * 23 * i / sampleRate); + out[i] = y * env * grain; + } + } + return out; +} + +Float64List _bandNoise(int len, double f, double q, math.Random rnd) { + final x = Float64List(len); + for (var i = 0; i < len; i++) { + x[i] = rnd.nextDouble() * 2 - 1; + } + return _biquadBand(x, f, q); +} + +/// RBJ band-pass (constant 0 dB peak): (b0, b1, b2, a1, a2), b1 = 0. +(double, double, double, double, double) _bandCoeffs(double f, double q) { + final w = 2 * math.pi * math.min(f, sampleRate * 0.45) / sampleRate; + final alpha = math.sin(w) / (2 * q); + final a0 = 1 + alpha; + return (alpha / a0, 0, -alpha / a0, -2 * math.cos(w) / a0, (1 - alpha) / a0); +} + +Float64List _biquadBand(Float64List x, double f, double q) { + final c = _bandCoeffs(f, q); + final y = Float64List(x.length); + var z1 = 0.0, z2 = 0.0; + for (var i = 0; i < x.length; i++) { + final v = c.$1 * x[i] + z1; + z1 = c.$2 * x[i] - c.$4 * v + z2; + z2 = c.$3 * x[i] - c.$5 * v; + y[i] = v; + } + return y; +} + +/// One pole, twice: a soft 12 dB/oct roll-off. +void _lowpass(Float64List out, double cutoff) { + final a = math.exp(-2 * math.pi * cutoff / sampleRate); + for (var pass = 0; pass < 2; pass++) { + var y = 0.0; + for (var i = 0; i < out.length; i++) { + y = (1 - a) * out[i] + a * y; + out[i] = y; } - out[start + i] += n.gain * env * s; } } -double _smooth(double t) => t * t * (3 - 2 * t); - -/// A few feedback delays (a small room): a soft tail instead of a dry stop. +/// A few feedback delays: wood and paper walls, a short tail. void _room(Float64List out, double mix) { - const taps = [0.031, 0.047, 0.067]; + const taps = [0.029, 0.041, 0.059]; final dry = Float64List.fromList(out); for (final d in taps) { - final delay = (d * sampleRate).round(); + final delay = _n(d); final buf = Float64List(out.length); for (var i = delay; i < out.length; i++) { - buf[i] = dry[i - delay] + 0.45 * buf[i - delay]; + buf[i] = dry[i - delay] + 0.42 * buf[i - delay]; } for (var i = 0; i < out.length; i++) { out[i] += mix / taps.length * buf[i]; diff --git a/apps/desktop/lib/src/sfx/synth_sfx.dart b/apps/desktop/lib/src/sfx/synth_sfx.dart index 9be2186..0747db1 100644 --- a/apps/desktop/lib/src/sfx/synth_sfx.dart +++ b/apps/desktop/lib/src/sfx/synth_sfx.dart @@ -11,10 +11,12 @@ import 'synth.dart'; /// Plays [SoundSpec]s: each is rendered once to a WAV in the temp dir and /// played on a small pool of audio-only mpv players (so two sounds can -/// overlap). [volume] is 0..1, applied on a square curve (ears are log). +/// overlap). [volume] (0..1) is read per play and applied on a square +/// curve (ears are log); [enabled] filters cues. class SynthSfx implements SfxService { - double volume; - SynthSfx({this.volume = 0.6}); + final double Function() volume; + final bool Function(SfxKind kind) enabled; + SynthSfx({required this.volume, bool Function(SfxKind kind)? enabled}) : enabled = enabled ?? ((_) => true); static const _poolSize = 3; final List _pool = []; @@ -23,18 +25,26 @@ class SynthSfx implements SfxService { Directory? _dir; @override - Future play(SfxCue cue) => - playSpec('${cue.kind.name}-${cue.readyIndex}', soundFor(cue.kind, readyIndex: cue.readyIndex)); + Future play(SfxCue cue) async { + if (!enabled(cue.kind)) return; + await playSpec('${cue.kind.name}-${cue.readyIndex}', soundFor(cue.kind, readyIndex: cue.readyIndex)); + } + + /// Plays [kind] even when it is switched off (Settings' preview). + Future preview(SfxKind kind, {int readyIndex = 1}) => + playSpec('${kind.name}-$readyIndex', soundFor(kind, readyIndex: readyIndex)); /// Plays [spec], cached under [key] (same key = same sound). Future playSpec(String key, SoundSpec spec) async { - if (volume <= 0) return; + final v = volume(); + if (v <= 0) return; try { final path = await (_files[key] ??= _write(key, spec)); final player = await _player(); - await player.setVolume(100 * volume * volume); + await player.setVolume(100 * v * v); await player.open(Media(path)); } catch (e) { + _files.remove(key); debugPrint('sfx $key: $e'); } } @@ -58,9 +68,6 @@ class SynthSfx implements SfxService { return _pool[_next++ % _poolSize]; } - /// Drops the cached renders (after editing a spec with hot reload). - void forget() => _files.clear(); - Future dispose() async { for (final p in _pool) { await p.dispose(); diff --git a/apps/desktop/lib/src/shell/party_strip.dart b/apps/desktop/lib/src/shell/party_strip.dart index d2a36c2..f2d30f7 100644 --- a/apps/desktop/lib/src/shell/party_strip.dart +++ b/apps/desktop/lib/src/shell/party_strip.dart @@ -1,13 +1,11 @@ import 'dart:io'; -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:signals/signals_flutter.dart'; import 'package:sync_relay_core/core.dart'; import 'package:window_manager/window_manager.dart'; -import '../screens/demo/shell_demo.dart'; import '../screens/home/update_indicator.dart'; import '../screens/session/jellyfin_widgets.dart' show ViaHostBadge; import '../state/session_state.dart'; @@ -126,18 +124,6 @@ class PartyStrip extends StatelessWidget { ), ), ), - 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()), - ), - ), UpdateIndicator(controller: updates), const SizedBox(width: 8), if (online) ...[ diff --git a/apps/desktop/lib/src/shell/settings_tab.dart b/apps/desktop/lib/src/shell/settings_tab.dart index 484feef..03f05eb 100644 --- a/apps/desktop/lib/src/shell/settings_tab.dart +++ b/apps/desktop/lib/src/shell/settings_tab.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:signals/signals_flutter.dart'; @@ -7,7 +9,11 @@ import '../jellyfin/accounts_panel.dart'; import '../schedule/animeflow.dart'; import '../screens/home/about_page.dart'; import '../screens/home/settings_page.dart'; +import '../sfx/sfx.dart'; +import '../sfx/sound_bank.dart'; +import '../sfx/synth_sfx.dart'; import '../state/session_state.dart'; +import '../state/settings.dart'; import '../update/update_controller.dart'; import 'avatar.dart'; import 'motion.dart'; @@ -431,11 +437,78 @@ class NotificationsInfo extends StatelessWidget { ], ), ), - SettingsPanel( - title: 'Sounds', - child: line('No sounds yet', 'The app is silent; only the video makes noise'), - ), + const SettingsPanel(title: 'Sounds', child: SoundSettings()), ], ); } } + +/// Volume, then every sound with a preview button and its own switch. +class SoundSettings extends StatelessWidget { + const SoundSettings({super.key}); + + void _preview(SfxKind kind) { + final sfx = SessionState.instance.sfx; + if (sfx is SynthSfx) unawaited(sfx.preview(kind, readyIndex: 2)); + } + + @override + Widget build(BuildContext context) { + final s = AppSettings.instance; + final scheme = Theme.of(context).colorScheme; + final muted = TextStyle(color: scheme.onSurfaceVariant, fontSize: 12); + return SignalBuilder(builder: (context) { + final volume = s.soundVolume.value; + final off = s.soundsOff.value; + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Icon(volume == 0 ? Icons.volume_off_outlined : Icons.volume_down_outlined, + color: scheme.onSurfaceVariant), + Expanded( + child: Slider( + key: const ValueKey('sound-volume'), + value: volume, + onChanged: s.setSoundVolume, + onChangeEnd: (_) => _preview(SfxKind.ready), + ), + ), + SizedBox(width: 44, child: Text('${(volume * 100).round()}%', textAlign: TextAlign.end)), + ], + ), + Text('Soft cues for people and readiness; the video itself covers play, pause and the end. ' + 'Letting go of the slider plays the ready sound.', style: muted), + const SizedBox(height: 8), + for (final MapEntry(key: kind, value: (name, help)) in soundInfo.entries) + Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Row( + children: [ + IconButton( + key: ValueKey('sound-play-${kind.name}'), + tooltip: 'Play', + onPressed: volume == 0 ? null : () => _preview(kind), + icon: const Icon(Icons.play_arrow_rounded), + ), + const SizedBox(width: 4), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [Text(name), Text(help, style: muted)], + ), + ), + Switch( + key: ValueKey('sound-on-${kind.name}'), + value: !off.contains(kind.name), + onChanged: volume == 0 ? null : (on) => s.setSoundOn(kind.name, on), + ), + ], + ), + ), + ], + ); + }); + } +} diff --git a/apps/desktop/lib/src/state/session_state.dart b/apps/desktop/lib/src/state/session_state.dart index bd05f8c..a232e10 100644 --- a/apps/desktop/lib/src/state/session_state.dart +++ b/apps/desktop/lib/src/state/session_state.dart @@ -9,11 +9,13 @@ import 'package:sync_relay_core/core.dart'; import '../jellyfin/accounts.dart'; import '../jellyfin/session_media.dart'; import '../sfx/sfx.dart'; +import '../sfx/synth_sfx.dart'; import '../session/health.dart'; import '../session/session_coordinator.dart'; import '../widgets/avatar_pipeline.dart'; import 'connect_prefs.dart'; import 'server_isolate.dart'; +import 'settings.dart'; enum AppRoute { home, @@ -153,7 +155,10 @@ class SessionState { final route = signal(AppRoute.home); final recents = signal>(const []); final status = signal(const HealthStatus(HealthSeverity.info, 'idle')); - final SfxService sfx = const NoopSfxService(); + final SfxService sfx = SynthSfx( + volume: () => AppSettings.instance.soundVolume.peek(), + enabled: (k) => !AppSettings.instance.soundsOff.peek().contains(k.name), + ); late final SessionCoordinator coordinator = SessionCoordinator(sfx: sfx); final lastForm = ConnectForm(); JellyfinAccounts get jellyfin => JellyfinAccounts.instance; diff --git a/apps/desktop/lib/src/state/settings.dart b/apps/desktop/lib/src/state/settings.dart index f74f172..9791f3b 100644 --- a/apps/desktop/lib/src/state/settings.dart +++ b/apps/desktop/lib/src/state/settings.dart @@ -20,6 +20,8 @@ const _keyAutoAdvance = 'autoAdvance'; const _keyMpvControls = 'mpvControls'; const _keyHideReReleases = 'hideReReleases'; const _keyProgressLine = 'progressLine'; +const _keySoundVolume = 'soundVolume'; +const _keySoundsOff = 'soundsOff'; enum HwdecMode { auto, off, custom } @@ -40,6 +42,10 @@ class AppSettings { /// A 2 px progress line at the bottom of the video while the controls /// are hidden (both designs). Applies live. final progressLine = signal(true); + /// App sounds (`sfx/`): 0..1, 0 = silent. + final soundVolume = signal(0.5); + /// `SfxKind` names switched off one by one. + final soundsOff = signal>(const {}); /// Video shaders: one choice, used on join and changed both here and /// in the session (Ctrl+0..6, the shader button). @@ -82,6 +88,10 @@ class AppSettings { if (hideRe is bool) hideReReleases.value = hideRe; final line = box.get(_keyProgressLine); if (line is bool) progressLine.value = line; + final vol = box.get(_keySoundVolume); + if (vol is num) soundVolume.value = vol.toDouble().clamp(0, 1); + final off = box.get(_keySoundsOff); + if (off is List) soundsOff.value = Set.unmodifiable(off.whereType()); final preset = box.get(_keyShaderPreset); shaderPreset.value = ShaderPreset.values.where((p) => p.name == preset).firstOrNull ?? @@ -129,6 +139,16 @@ class AppSettings { _put(_keyProgressLine, on); } + void setSoundVolume(double v) { + soundVolume.value = v.clamp(0, 1); + _put(_keySoundVolume, soundVolume.value); + } + + void setSoundOn(String kind, bool on) { + soundsOff.value = Set.unmodifiable(on ? ({...soundsOff.value}..remove(kind)) : {...soundsOff.value, kind}); + _put(_keySoundsOff, soundsOff.value.toList()); + } + void setHideReReleases(bool on) { hideReReleases.value = on; _put(_keyHideReReleases, on); diff --git a/apps/desktop/test/coord_presence_test.dart b/apps/desktop/test/coord_presence_test.dart index 72282b7..df0ed56 100644 --- a/apps/desktop/test/coord_presence_test.dart +++ b/apps/desktop/test/coord_presence_test.dart @@ -7,6 +7,7 @@ import 'package:sync_relay_core/core.dart'; import 'package:sync_relay_server/server.dart'; import 'package:hive_ce/hive.dart'; import 'package:watch_together/src/session/session_coordinator.dart'; +import 'package:watch_together/src/sfx/sfx.dart'; import 'package:watch_together/src/state/session_state.dart'; import 'package:watch_together/src/sync/sync_client.dart'; @@ -214,7 +215,8 @@ void main() { MediaKit.ensureInitialized(); final server = SyncServer(const AppConfig(port: 0, secret: 'toast', name: 'toast')); await server.start(); - final coord = SessionCoordinator(); + final sounds = _RecordingSfx(); + final coord = SessionCoordinator(sfx: sounds); final friend = SyncClient(); try { await coord.connect(ConnectParams( @@ -227,6 +229,12 @@ void main() { final toasts = coord.toasts.peek(); expect(toasts.firstWhere((t) => t.text == 'Bob Jr joined').actor, 'Bob Jr'); expect(toasts.firstWhere((t) => t.text == 'Bob Jr: hi there').actor, 'Bob Jr'); + // Others' chat makes a sound, your own does not. + expect(sounds.kinds.where((k) => k == SfxKind.chat), hasLength(1)); + coord.sendChat('mine'); + await _poll(() => coord.toasts.peek().any((t) => t.text == 'Bob: mine'), 'own chat'); + expect(sounds.kinds.where((k) => k == SfxKind.chat), hasLength(1)); + expect(sounds.kinds, contains(SfxKind.join)); } finally { await friend.dispose(); await coord.dispose(); @@ -235,3 +243,9 @@ void main() { }, ); } + +class _RecordingSfx implements SfxService { + final kinds = []; + @override + Future play(SfxCue cue) async => kinds.add(cue.kind); +} diff --git a/apps/desktop/test/sfx_test.dart b/apps/desktop/test/sfx_test.dart new file mode 100644 index 0000000..3fd60c4 --- /dev/null +++ b/apps/desktop/test/sfx_test.dart @@ -0,0 +1,59 @@ +import 'dart:math' as math; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:watch_together/src/sfx/sfx.dart'; +import 'package:watch_together/src/sfx/sound_bank.dart'; +import 'package:watch_together/src/sfx/synth.dart'; + +double _pitch(List x, int from) { + final c = []; + for (var l = 0; l < 700; l++) { + var s = 0.0; + for (var i = from; i < from + 2048; i++) { + s += x[i] * x[i + l]; + } + c.add(s); + } + var best = 0.0, lag = 0.0; + for (var l = 30; l < 699; l++) { + if (c[l] > best && c[l] >= c[l - 1] && c[l] >= c[l + 1]) { + best = c[l]; + lag = l + (c[l - 1] - c[l + 1]) / (2 * (c[l - 1] - 2 * c[l] + c[l + 1])); + } + } + return sampleRate / lag; +} + +void main() { + test('every sound is described, quiet, short and clean', () { + for (final k in SfxKind.values) { + expect(soundInfo[k], isNotNull, reason: k.name); + final r = render(soundFor(k)); + final peak = r.fold(0, (m, v) => math.max(m, v.abs())); + expect(20 * math.log(peak) / math.ln10, lessThanOrEqualTo(-16.9), reason: '${k.name} peak'); + expect(r.length / sampleRate, lessThan(3), reason: '${k.name} length'); + expect(r.every((v) => v.isFinite), isTrue, reason: k.name); + expect(r.first.abs() + r.last.abs(), lessThan(1e-3), reason: '${k.name} clicks'); + } + }); + + test('the ready ladder climbs and stays in range', () { + expect(render(readySound(0)), render(readySound(1))); + expect(render(readySound(99)), render(readySound(6))); + }); + + test('koto strings are in tune', () { + for (final hz in [293.66, 587.33, 880.0, 1174.66]) { + final r = render(SoundSpec([Note(0, hz, 1.0)], room: 0, cutoff: 20000)); + final cents = 1200 * math.log(_pitch(r, 2000) / hz) / math.ln2; + expect(cents.abs(), lessThan(5), reason: '$hz'); + } + }); + + test('wav header and length', () { + final b = wav(render(soundFor(SfxKind.chat))); + expect(String.fromCharCodes(b.sublist(0, 4)), 'RIFF'); + expect(String.fromCharCodes(b.sublist(8, 12)), 'WAVE'); + expect(b.length, 44 + render(soundFor(SfxKind.chat)).length * 2); + }); +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 0314261..424073c 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -528,8 +528,7 @@ sideways until an end, then the page). of `NetworkInterface.list` (IPv4, no loopback or link-local, private ranges first) with the Host port, and a note that friends outside the LAN need a forwarded port or a reachable server). Empty strip space is the window - drag area; the window buttons sit at its end. Debug builds add a button - that opens the layout mock (`screens/demo/`). + drag area; the window buttons sit at its end. - **Avatars** (`avatar.dart`): `StatusAvatar` draws a ring outside the picture with a gap in the background colour (both scale with the size): green ready, amber buffering (with a spinner), grey not ready, dimmed @@ -590,13 +589,19 @@ sideways until an end, then the page). Jellyfin & integrations (linked servers, AnimeFlow's `/me` via `AnimeFlowClient.fromAccounts`, auto-advance), notifications & sounds (what pops up; no switches), updates, shortcuts, about. -- **Sounds** (`sfx/`): the coordinator plays an `SfxCue` per feed event - through `SfxService` (the app still uses `NoopSfxService`). The set is - synthesized, not sampled: `synth.dart` renders notes (sine/bell/pluck - voices, soft attacks, low-pass, short room tail) to quiet WAVs, - `sound_bank.dart` defines every sound in D major pentatonic, and - `SynthSfx` plays them on a small pool of audio-only mpv players. The - debug layout demo's Notifications & sounds page plays each one. +- **Sounds** (`sfx/`): the coordinator plays an `SfxCue` for ready, + unready, force-ready, join, leave, kick, swap and others' chat (feed + events), the idle warning, and connection lost / back (once per drop). + Nothing for what the video already says (play, pause, seeks, the end). + The set is synthesized, not sampled: `synth.dart` renders Japanese-house + voices (Karplus-Strong koto with bends, furin chime, rin bowl, bamboo, + hyoshigi clappers, a shoji slide of filtered noise) through a low-pass + and a short room to quiet WAVs; `sound_bank.dart` voices each event in + the Yo scale on D. `SynthSfx` renders a sound on first use into the temp + dir and plays it on a pool of three audio-only mpv players, at + `AppSettings.soundVolume` (square curve) unless switched off in + `soundsOff`. Settings → Notifications & sounds has the volume and a + preview and switch per sound. - **Player** (`session_player.dart`): `SessionPlayer` holds the one `Video` of the current player (a new `GlobalKey` per player generation). The shell keeps it in one layer of the body at one place in the element