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