diff --git a/README.md b/README.md index 3c71424..02d728d 100644 --- a/README.md +++ b/README.md @@ -158,6 +158,32 @@ folder to Files, or fill Link). Put it up and the player opens. Not in a server, the buttons say Play and it plays for you alone ([Watch solo](#getting-started)). +### Up next (the queue) + +In a server (solo too), anything can wait its turn instead of going up +now: **Add to queue** on the Link page (in the way you picked), or +right-click a Jellyfin card or episode, a video in Files or a recent link +for **Add to queue** / **Play next**; the Jellyfin details panel has a +queue button (right-click it to play next). A link is checked first, like +Put it up. Everyone shares one Up next and anyone can change it; what +you added stays when you leave. + +In the player, **Up next · title** sits top right with the controls: ▶ +plays it now, the title opens the queue: drag rows to reorder, hover a +row to play it now (▶) or remove it (×), paste a link in the field or +drop a link or a video on it to add. When a video ends, a card shows +what is up next with a 10 s countdown (Cancel stops it on your screen) +and it plays; `>` or Shift+N plays it at once. Under the queue, +**Continues with** shows the next episodes of the Jellyfin series +playing (greyed: they play only once Up next is empty); **+** adds one, +**Clear** drops them for this episode. + +A link you pass on ("through me") or a file of yours can only be played +by your app: if you left when its turn comes, it is skipped ("Skipped …: +Foxyz left"). A Jellyfin item needs someone with an account on that +server. Up next is kept by the server while it runs; restarting the +server empties it. + ## Dedicated server For an always-on box (no display needed): the release tarball also contains @@ -302,7 +328,7 @@ Watching: - Friends without an account watch through the server: whoever put the item up passes it on automatically, with their own sign-in, which never leaves their computer (only the current item, only to people in the - session). They show "via host" in the member list (click the avatars). When the app hosts, + session). They show "via server" in the member list (click the avatars). When the app hosts, or a dedicated server has its own login (`--jellyfin `, see [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md), Jellyfin relay), the server fetches it itself instead. @@ -362,6 +388,18 @@ streams or DRM-protected video (use "Everyone fetches it" for live). Your upload has to beat the video's bitrate: a 25-minute 2 GB episode is about 11 Mbit/s. +The member list (click the avatars) shows where the video comes from on +top: who passes it on, or that the server fetches it, everyone plays the +link, or everyone streams it from Jellyfin, with how many watch through +the server and, for a relay, how fast it arrives against what the video +needs (the bar's mark; past it is keeping up). It turns amber when the +source is too slow or reconnecting, red when it left (the others play +out what the server already has), and whoever passes it on is told once +when their upload is too slow. Each member's line says how they get it +("via server" with their speed, or "direct") after a dot for their +buffer: green enough to play on, amber low, red empty. A red dot on one +member while the source keeps up is their own connection. + ## Video quality and shaders Each viewer can run mpv GLSL shaders on their own picture; nobody else is diff --git a/apps/desktop/lib/src/files/ui/files_page.dart b/apps/desktop/lib/src/files/ui/files_page.dart index 721340b..6d8098e 100644 --- a/apps/desktop/lib/src/files/ui/files_page.dart +++ b/apps/desktop/lib/src/files/ui/files_page.dart @@ -6,6 +6,7 @@ import 'package:flutter/material.dart'; import 'package:signals/signals_flutter.dart'; import '../../shell/motion.dart'; +import '../../widgets/app_menu.dart'; import '../folder_store.dart'; import '../name_parser.dart'; import '../scanner.dart'; @@ -54,6 +55,8 @@ class FilesPage extends StatefulWidget { final bool relayed; /// Plays [path] in the session; returns an error text, or null. final Future Function(String path) onPlay; + /// Adds [path] to Up next (right-click on a video); null: no server. + final Future Function(String path, {bool next})? onAddToQueue; final String? nowPlayingPath; /// Defaults to [thumbnailFor]. final ThumbnailLoader? thumbnails; @@ -70,6 +73,7 @@ class FilesPage extends StatefulWidget { this.solo = false, required this.relayed, required this.onPlay, + this.onAddToQueue, this.nowPlayingPath, this.thumbnails, this.scan, @@ -238,6 +242,35 @@ class _FilesPageState extends State { VoidCallback _tapOf(String path) => () => _play(path); + /// Right-click on a video ([context]: its tile): Put it up, Add to + /// queue, Play next. + Future _menu(BuildContext context, String path) async { + final add = widget.onAddToQueue; + if (add == null) return; + final pick = await showAppMenu(context, items: [ + AppMenuItem(value: 0, label: widget.solo ? 'Play' : 'Put it up'), + const AppMenuItem(value: 1, label: 'Add to queue'), + const AppMenuItem(value: 2, label: 'Play next'), + ]); + if (pick == null || !mounted) return; + if (pick == 0) return _play(path); + String? error; + try { + error = await add(path, next: pick == 2); + } catch (e) { + error = '$e'; + } + if (error != null) _showError(error); + } + + /// [child] with the right-click menu (when there is a server). + Widget _menuable(String path, Widget child) => widget.onAddToQueue == null + ? child + : KeyedSubtree( + key: ValueKey('menu:$path'), + child: Builder( + builder: (context) => GestureDetector(onSecondaryTap: () => _menu(context, path), child: child))); + @override Widget build(BuildContext context) => LayoutBuilder( builder: (context, box) { @@ -468,14 +501,16 @@ class _FilesPageState extends State { itemCount: recent.length, itemBuilder: (context, i) { final path = recent[i]; - return _RecentRow( - key: ValueKey(path), - path: path, - state: _stateOf(path), - thumbs: _thumbs, - onTap: _tapOf(path), - onRemove: () => _store.removeRecent(path), - ); + return _menuable( + path, + _RecentRow( + key: ValueKey(path), + path: path, + state: _stateOf(path), + thumbs: _thumbs, + onTap: _tapOf(path), + onRemove: () => _store.removeRecent(path), + )); }, ), ]; @@ -582,16 +617,18 @@ class _FilesPageState extends State { onTap: () => setState(() => _expanded.contains(id) ? _expanded.remove(id) : _expanded.add(id)), ); } - return FileRow( - key: ValueKey(v.path), - path: v.path, - title: v.parsed.label.isEmpty ? v.parsed.series : v.parsed.label, - subtitle: v.name, - size: formatSize(v.size), - state: _stateOf(v.path), - thumbs: _thumbs, - onTap: _tapOf(v.path), - ); + return _menuable( + v.path, + FileRow( + key: ValueKey(v.path), + path: v.path, + title: v.parsed.label.isEmpty ? v.parsed.series : v.parsed.label, + subtitle: v.name, + size: formatSize(v.size), + state: _stateOf(v.path), + thumbs: _thumbs, + onTap: _tapOf(v.path), + )); }, ), // Everything else (movies, clips) as a thumbnail grid. @@ -620,13 +657,15 @@ class _FilesPageState extends State { itemCount: loose.length, itemBuilder: (context, i) { final v = loose[i]; - return VideoCard( - key: ValueKey(v.path), - video: v, - state: _stateOf(v.path), - thumbs: _thumbs, - onTap: _tapOf(v.path), - ); + return _menuable( + v.path, + VideoCard( + key: ValueKey(v.path), + video: v, + state: _stateOf(v.path), + thumbs: _thumbs, + onTap: _tapOf(v.path), + )); }, ); }), diff --git a/apps/desktop/lib/src/jellyfin/browse_scope.dart b/apps/desktop/lib/src/jellyfin/browse_scope.dart index 88ae9db..ea05ef1 100644 --- a/apps/desktop/lib/src/jellyfin/browse_scope.dart +++ b/apps/desktop/lib/src/jellyfin/browse_scope.dart @@ -6,6 +6,7 @@ import 'package:sync_relay_core/core.dart'; import 'accounts.dart'; import 'format.dart'; +import '../widgets/app_menu.dart'; /// One level of the library browser's back stack. sealed class BrowseRoute { @@ -274,6 +275,9 @@ class BrowseScope extends InheritedWidget { final ValueChanged onSelect; /// Double-click: put it up (a series: its next episode). final ValueChanged onPutUp; + /// Adds it to Up next (with next: right after what plays); null: no + /// server, the cards have no menu. + final void Function(JellyfinItem item, {bool next})? onAddToQueue; /// Home shows the library chips itself (the Library tab has them in its /// header instead). final bool libraryStrip; @@ -287,6 +291,7 @@ class BrowseScope extends InheritedWidget { required this.selectedItemId, required this.onSelect, required this.onPutUp, + this.onAddToQueue, this.libraryStrip = true, required super.child, }); @@ -296,6 +301,26 @@ class BrowseScope extends InheritedWidget { void onOpen(BrowseRoute route) => controller.open(route); + /// Right-click on a playable card ([context]: the card): Put it up, Add + /// to queue, Play next. Nothing without a server. + Future cardMenu(BuildContext context, JellyfinItem item) async { + final queue = onAddToQueue; + if (queue == null || !isSelectable(item)) return; + final pick = await showAppMenu(context, items: const [ + AppMenuItem(value: 0, label: 'Put it up'), + AppMenuItem(value: 1, label: 'Add to queue'), + AppMenuItem(value: 2, label: 'Play next'), + ]); + switch (pick) { + case 0: + onPutUp(item); + case 1: + queue(item); + case 2: + queue(item, next: true); + } + } + /// Cached browse call on this server. Future load(String key, Future Function(JellyfinClient c) fn) => accounts.cached(account.serverId, key, () => fn(client)); diff --git a/apps/desktop/lib/src/jellyfin/browser.dart b/apps/desktop/lib/src/jellyfin/browser.dart index da78cff..0ad4d42 100644 --- a/apps/desktop/lib/src/jellyfin/browser.dart +++ b/apps/desktop/lib/src/jellyfin/browser.dart @@ -30,6 +30,8 @@ class JellyfinBrowser extends StatefulWidget { final JellyfinSelection? selected; final ValueChanged onSelect; final ValueChanged onPutUp; + /// Right-click on a card: Add to queue / Play next; null: no server. + final void Function(JellyfinSelection item, {bool next})? onAddToQueue; final VoidCallback? onOpenIntegrations; final Duration searchDebounce; /// Opens here instead of the remembered location (the series of the @@ -43,6 +45,7 @@ class JellyfinBrowser extends StatefulWidget { required this.accounts, required this.onSelect, required this.onPutUp, + this.onAddToQueue, this.controller, this.selected, this.onOpenIntegrations, @@ -221,6 +224,10 @@ class _JellyfinBrowserState extends State { selectedItemId: sel != null && sel.serverId == account.serverId ? sel.itemId : null, onSelect: (item) => widget.onSelect(JellyfinSelection(account.serverId, item.id, item)), onPutUp: (item) => widget.onPutUp(JellyfinSelection(account.serverId, item.id, item)), + onAddToQueue: widget.onAddToQueue == null + ? null + : (item, {next = false}) => + widget.onAddToQueue!(JellyfinSelection(account.serverId, item.id, item), next: next), libraryStrip: widget.libraryStrip, child: Focus( onKeyEvent: _onKey, diff --git a/apps/desktop/lib/src/jellyfin/cards.dart b/apps/desktop/lib/src/jellyfin/cards.dart index aedab39..9dca23f 100644 --- a/apps/desktop/lib/src/jellyfin/cards.dart +++ b/apps/desktop/lib/src/jellyfin/cards.dart @@ -186,6 +186,7 @@ class _MediaCardState extends State { focusColor: Colors.transparent, hoverColor: Colors.transparent, onTap: () => _tap(scope), + onSecondaryTap: () => scope.cardMenu(context, item), onHover: (h) => setState(() => _hover = h), onFocusChange: (f) => setState(() => _focus = f), child: Column( diff --git a/apps/desktop/lib/src/jellyfin/details_panel.dart b/apps/desktop/lib/src/jellyfin/details_panel.dart index a3cc625..cacfab2 100644 --- a/apps/desktop/lib/src/jellyfin/details_panel.dart +++ b/apps/desktop/lib/src/jellyfin/details_panel.dart @@ -45,12 +45,16 @@ class JellyfinDetailsPanel extends StatefulWidget { final bool solo; /// Puts [item] (playable) up; returns an error text, or null when sent. final Future Function(JellyfinSelection item) onPutUp; + /// Adds [item] to Up next (with next: right after what plays); null: no + /// server, no queue actions. + final Future Function(JellyfinSelection item, {bool next})? onAddToQueue; final VoidCallback onClose; const JellyfinDetailsPanel({ super.key, required this.controller, required this.selection, required this.onPutUp, + this.onAddToQueue, required this.onClose, this.solo = false, }); @@ -123,6 +127,46 @@ class _JellyfinDetailsPanelState extends State { }); } + Future _queue(JellyfinItem target, {bool next = false}) async { + final add = widget.onAddToQueue; + if (add == null || _busy) return; + setState(() { + _busy = true; + _error = null; + }); + String? error; + try { + error = await add(JellyfinSelection(_serverId, target.id, target), next: next); + } catch (e) { + error = '$e'; + } + if (!mounted) return; + setState(() { + _busy = false; + _error = error; + }); + } + + /// Right-click on an episode ([context]: its row): Put it up, Add to + /// queue, Play next. + Future _episodeMenu(BuildContext context, JellyfinItem e) async { + if (widget.onAddToQueue == null) return; + setState(() => _targetId = e.id); + final pick = await showAppMenu(context, items: [ + AppMenuItem(value: 0, label: widget.solo ? 'Play' : 'Put it up'), + const AppMenuItem(value: 1, label: 'Add to queue'), + const AppMenuItem(value: 2, label: 'Play next'), + ]); + switch (pick) { + case 0: + await _putUp(e); + case 1: + await _queue(e); + case 2: + await _queue(e, next: true); + } + } + /// Runs a watched-state change, then lets every row reload. Future _change(Future Function(JellyfinClient c) action) async { final c = _client; @@ -308,6 +352,16 @@ class _JellyfinDetailsPanelState extends State { : const Icon(Icons.play_arrow), label: Text(label, overflow: TextOverflow.ellipsis), ), + if (playable != null && widget.onAddToQueue != null) + GestureDetector( + onSecondaryTap: _busy ? null : () => _queue(playable, next: true), + child: IconButton( + key: const ValueKey('details-add-queue'), + onPressed: _busy ? null : () => _queue(playable), + tooltip: 'Add to queue (right-click: play next)', + icon: const Icon(Icons.playlist_add_rounded), + ), + ), if (playable != null) IconButton( key: const ValueKey('details-watched'), @@ -413,10 +467,12 @@ class _JellyfinDetailsPanelState extends State { return Material( color: isTarget ? scheme.primary.withValues(alpha: 0.10) : Colors.transparent, borderRadius: BorderRadius.circular(8), - child: InkWell( + // The row's own context: its menu opens at the row. + child: Builder(builder: (context) => InkWell( key: ValueKey('details-episode-${e.id}'), borderRadius: BorderRadius.circular(8), onTap: () => _tapEpisode(e), + onSecondaryTap: () => _episodeMenu(context, e), child: Padding( padding: const EdgeInsets.symmetric(vertical: 5, horizontal: 4), child: Row( @@ -461,7 +517,7 @@ class _JellyfinDetailsPanelState extends State { ], ), ), - ), + )), ); } } diff --git a/apps/desktop/lib/src/jellyfin/picker_source.dart b/apps/desktop/lib/src/jellyfin/picker_source.dart index fab4569..1167fe7 100644 --- a/apps/desktop/lib/src/jellyfin/picker_source.dart +++ b/apps/desktop/lib/src/jellyfin/picker_source.dart @@ -14,6 +14,7 @@ class JellyfinPickerSource extends StatefulWidget { final JellyfinSelection? selected; final ValueChanged onSelect; final ValueChanged onPutUp; + final void Function(JellyfinSelection item, {bool next})? onAddToQueue; final VoidCallback? onOpenIntegrations; final BrowseStart? start; final bool libraryStrip; @@ -25,6 +26,7 @@ class JellyfinPickerSource extends StatefulWidget { required this.accounts, required this.onSelect, required this.onPutUp, + this.onAddToQueue, this.controller, this.selected, this.onOpenIntegrations, @@ -61,6 +63,7 @@ class _JellyfinPickerSourceState extends State { selected: widget.selected, onSelect: widget.onSelect, onPutUp: widget.onPutUp, + onAddToQueue: widget.onAddToQueue, onOpenIntegrations: widget.onOpenIntegrations, start: widget.start, libraryStrip: widget.libraryStrip, diff --git a/apps/desktop/lib/src/jellyfin/series_page.dart b/apps/desktop/lib/src/jellyfin/series_page.dart index 49711e2..0794744 100644 --- a/apps/desktop/lib/src/jellyfin/series_page.dart +++ b/apps/desktop/lib/src/jellyfin/series_page.dart @@ -375,6 +375,7 @@ class _EpisodeTileState extends State { key: ValueKey('episode-${e.id}'), focusColor: Colors.transparent, onTap: () => _tap(scope), + onSecondaryTap: () => scope.cardMenu(context, widget.episode), onHover: (h) => setState(() => _hover = h), onFocusChange: (f) => setState(() => _focus = f), child: Padding( diff --git a/apps/desktop/lib/src/queue/queue_controller.dart b/apps/desktop/lib/src/queue/queue_controller.dart new file mode 100644 index 0000000..b63ef3a --- /dev/null +++ b/apps/desktop/lib/src/queue/queue_controller.dart @@ -0,0 +1,549 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:math'; + +import 'package:flutter/foundation.dart'; +import 'package:signals/signals_flutter.dart'; +import 'package:sync_relay_core/core.dart'; + +import '../files/name_parser.dart'; +import '../files/thumbnails.dart'; +import '../jellyfin/accounts.dart'; +import '../jellyfin/format.dart' show pickFor; +import '../jellyfin/session_media.dart'; +import '../screens/home/picked_media.dart'; +import '../session/media_art.dart'; +import '../session/session_coordinator.dart'; +import '../state/settings.dart'; +import '../sync/avatar_cache.dart'; +import 'queue_logic.dart'; + +// Up next on this client (see ARCHITECTURE.md "Up next"): the server's +// queue as signals, adding to it (probed, with a picture), the end-of-media +// countdown for its first item, putting an item up when this app is the +// one that can, and getting that item ready shortly before. + +/// The end-of-media card for the first Up next item. [secondsLeft] null: +/// no countdown here (auto-advance off, cancelled, or another app puts +/// it up), the card offers Play now only. +class UpNextCountdown { + final int mediaId; + final QueueItem item; + final int? secondsLeft; + const UpNextCountdown({required this.mediaId, required this.item, this.secondsLeft}); +} + +/// The first item's readiness, on the app that puts it up. +enum WarmState { idle, warming, ready, failed } + +class QueueController { + final SessionCoordinator coord; + QueueController(this.coord); + + /// Queued items' pictures by hash, from `/i/`. + late final PictureCache thumbs = PictureCache(coord.sync, + route: 'i', keep: RelayConstants.maxQueueItems + 8, live: (s) => [for (final q in s.queue) q.thumb]); + + /// Up next, in order (empty when nothing is queued or not in a server). + late final Computed> items = + computed(() => coord.serverState.value?.queue ?? const []); + + final countdown = signal(null); + + /// The first item's id and how ready it is here (only on the app that + /// puts it up; null elsewhere). + final warm = signal<({String id, WarmState state})?>(null); + + /// The natural continuation of what plays: the next episodes of the + /// Jellyfin series (not queued; shown greyed under Up next). + final continuation = signal>(const []); + + /// The mediaId whose continuation was cleared here. + final _hiddenFor = signal(-1); + + /// The queue popover is open: the chip stays up. + final popoverOpen = signal(false); + + // What adder-only items stand for, by their key (a link passed on from + // here, a file here). Never leaves this app. + final Map _local = {}; + final Map _performing = {}; + final Set _skipped = {}; + int _cancelledFor = -1; + Timer? _timer; + bool _attached = false; + String _continuationFor = ''; + final _random = Random.secure(); + + String get _me => coord.sync.memberId; + + void attach() { + if (_attached) return; + _attached = true; + coord.sync.states.listen(_onState); + coord.sync.feeds.listen(_onFeed); + effect(() { + final next = coord.nextEpisode.value; + final plan = coord.jellyfinPlan.value; + unawaited(_loadContinuation(next, plan)); + }); + } + + // ------------------------------------------------------------- adding + + /// Adds [pick] to Up next (at the end, or with [next] right after what + /// plays). A link is probed first and refused like Put it up when it + /// will not play. Returns an error text, or null when sent. + Future add(PickedMedia pick, {bool next = false}) async { + coord.markInput(); + if (coord.serverState.peek() == null) return 'not in a server'; + if (items.peek().length >= RelayConstants.maxQueueItems) { + return 'Up next is full (${RelayConstants.maxQueueItems} items)'; + } + final ({QueueItem item, Uint8List? thumb}) built; + try { + final b = await _build(pick); + if (b is String) return b; + built = b as ({QueueItem item, Uint8List? thumb}); + } catch (e) { + return "couldn't add it: $e"; + } + final item = built.item; + if (item.key.isNotEmpty) _local[item.key] = (pick: pick, at: DateTime.now()); + final thumb = built.thumb; + coord.sync.sendQueue(QueueIntent.add(item, next: next, thumb: thumb == null ? '' : base64Encode(thumb))); + coord.showToast('Added ${item.title} to Up next'); + return null; + } + + /// A continuation episode becomes an Up next item. + Future promote(EpisodeLink e) => add(e.pick); + + /// The item, or an error text. + Future _build(PickedMedia pick) async { + switch (pick) { + case JellyfinMedia(): + final accounts = coord.jellyfin; + if (accounts.account(pick.serverId) == null) return 'that Jellyfin server is not linked any more'; + if (accounts.statusOf(pick.serverId) == JellyfinAccountStatus.expired) { + return 'Jellyfin sign-in expired — reconnect it in Settings → Jellyfin & integrations'; + } + final client = accounts.clientFor(pick.serverId); + final thumb = client == null ? null : await _jellyfinThumb(client, pick.itemId); + return ( + item: QueueItem( + media: mediaForPick(pick), + how: QueueHow.jellyfin, + title: pick.title, + sub: pick.subtitle, + duration: (pick.runtime?.inMilliseconds ?? 0) / 1000, + ), + thumb: thumb, + ); + case FileMedia(:final path): + final file = File(path); + if (!await file.exists()) return 'file not found — check the path'; + final name = pick.label; + final parsed = parseReleaseName(name); + final shot = await thumbnailFor(path); + Uint8List? thumb; + if (shot != null) { + try { + final bytes = await File(shot.imagePath).readAsBytes(); + if (bytes.length <= RelayConstants.maxPosterBytes) thumb = bytes; + } catch (_) {} + } + final ep = [ + if (parsed.season != null) 'S${parsed.season}', + if (parsed.episode != null) 'E${parsed.episode}', + ].join(); + return ( + item: QueueItem( + media: HelloMedia(name: name, size: await file.length(), duration: 0), + how: QueueHow.file, + title: parsed.series.trim().isEmpty ? name : parsed.series.trim(), + sub: ep, + duration: (shot?.duration.inMilliseconds ?? 0) / 1000, + key: _newKey(), + ), + thumb: thumb, + ); + case LinkMedia(:final url, :final anyway): + // Alone, a link plays direct: there is nobody to pass it on to. + final relay = coord.solo ? LinkRelay.direct : pick.relay; + final linked = await mediaForLink(coord.jellyfin, url); + final ref = linked.jellyfin; + if (ref != null) { + return ( + item: QueueItem( + media: HelloMedia(name: linked.name, size: 0, duration: 0, jellyfin: ref), + how: QueueHow.jellyfin, + title: ref.title.isEmpty ? linked.name : ref.title, + sub: ref.subtitle ?? '', + duration: ref.runtimeSecs ?? 0), + thumb: null, + ); + } + LinkProbe? probe; + String? probeError; + try { + probe = await coord.ytDlp.probe(url).timeout(const Duration(seconds: 45)); + } catch (e) { + probeError = '$e'; + } + final problem = probe == null ? playErrorText(probeError ?? '') : probe.problem; + if (problem != null && !anyway) return problem; + if (relay == LinkRelay.me) { + if (probe == null) return problem ?? "couldn't reach the site"; + final relayProblem = probe.relayProblem; + if (relayProblem != null) return relayProblem; + } + if (relay == LinkRelay.server && !coord.serverHasPassword) return 'server fetch needs a server password'; + if (relay == LinkRelay.server && probe != null && probe.isPage && probe.info != null) { + final relayProblem = probe.relayProblem; + if (relayProblem != null) return relayProblem; + } + final uri = Uri.tryParse(url) ?? Uri(); + final info = probe?.info; + final title = probe?.title ?? linked.name; + final site = info == null + ? uri.host + : (info.uploader.isNotEmpty ? info.uploader : (info.extractor.isNotEmpty ? info.extractor : uri.host)); + final thumbUrl = info?.thumbnail == null ? null : Uri.tryParse(info!.thumbnail!); + final thumb = thumbUrl == null ? null : (await thumbnailArt(thumbUrl))?.poster; + final how = switch (relay) { + LinkRelay.direct => QueueHow.direct, + LinkRelay.server => QueueHow.server, + LinkRelay.me => QueueHow.through, + }; + return ( + item: QueueItem( + media: HelloMedia( + name: title, + size: 0, + duration: 0, + url: how == QueueHow.through ? null : url, + relay: how == QueueHow.server ? const MediaRelay(fetch: true) : null), + how: how, + title: title, + sub: site, + duration: info?.duration ?? 0, + key: how == QueueHow.through ? _newKey() : '', + ), + thumb: thumb, + ); + } + } + + String _newKey() => [for (var i = 0; i < 12; i++) _random.nextInt(256).toRadixString(16).padLeft(2, '0')].join(); + + /// A wide picture of a Jellyfin item: an episode's own still, else the + /// item's (or series') thumb or backdrop, else its poster. + Future _jellyfinThumb(JellyfinClient client, String itemId) async { + try { + final item = await client.item(itemId).timeout(const Duration(seconds: 10)); + final ref = (item.kind == JellyfinItemKind.episode ? item.ownImage(JellyfinImageType.primary) : null) ?? + item.image(JellyfinImageType.thumb) ?? + item.image(JellyfinImageType.backdrop) ?? + item.image(JellyfinImageType.primary); + if (ref == null) return null; + return await fetchImage( + client.imageUrl(ref, maxWidth: 480, quality: 80), client.authHeaders, RelayConstants.maxPosterBytes); + } catch (e) { + debugPrint('queue: no Jellyfin picture for $itemId: $e'); + return null; + } + } + + // ------------------------------------------------------------ editing + + void move(String id, int to) { + coord.markInput(); + coord.sync.sendQueue(QueueIntent.move(id, to)); + } + + void remove(String id) { + coord.markInput(); + coord.sync.sendQueue(QueueIntent.remove(id)); + } + + /// Plays [id] now: this app puts it up when it is the one that can, + /// else it asks the server, and the app that can puts it up. + void playNow(String id) { + coord.markInput(); + final r = coord.serverState.peek(); + final item = r?.queue.where((q) => q.id == id).firstOrNull; + if (r == null || item == null) return; + if (queuePerformer(item, r.members).id == _me) { + _perform(item); + } else { + coord.sync.sendQueue(QueueIntent.play(id)); + } + } + + /// Plays the first Up next item now. + void skip() { + final first = items.peek().firstOrNull; + if (first != null) playNow(first.id); + } + + /// Cancel on the end-of-media card: no countdown here for this media. + void cancelCountdown() { + final c = countdown.peek(); + _timer?.cancel(); + _timer = null; + if (c == null) return; + _cancelledFor = c.mediaId; + countdown.value = UpNextCountdown(mediaId: c.mediaId, item: c.item); + } + + /// "Clear": hides the continuation of what plays now (and its next + /// episode does not come up by itself). + void clearContinuation() { + final id = coord.serverState.peek()?.mediaId; + if (id != null) _hiddenFor.value = id; + if (coord.autoAdvance.peek() != null) coord.cancelAutoAdvance(); + } + + /// The continuation applies to [mediaId]: nothing is queued and it was + /// not cleared. + bool continuationShown(int mediaId) => + (coord.serverState.peek()?.queue.isEmpty ?? true) && _hiddenFor.peek() != mediaId; + + /// [continuation] unless cleared for the media playing. + late final Computed> shownContinuation = computed(() { + final id = coord.serverState.value?.mediaId; + return id != null && _hiddenFor.value == id ? const [] : continuation.value; + }); + + // --------------------------------------------------------- per state + + void _onState(StateBroadcast r) { + _prune(r); + final queue = r.queue; + final play = r.queuePlay; + if (play != null) { + final item = queue.where((q) => q.id == play).firstOrNull; + if (item != null) _bringUp(item, r); + } + final finished = r.transport == 'finished'; + if (queue.isNotEmpty && r.media == null) { + // Nothing up yet: the first item goes up at once. + _bringUp(queue.first, r); + } else if (finished && queue.isNotEmpty) { + _atEnd(r); + } else { + if (countdown.peek() != null) _stopCountdown(); + if (!finished) _cancelledFor = -1; + } + _warm(r); + } + + void _onFeed(FeedMessage f) { + if (f.action == 'queue-skip') { + coord.showToast('Skipped ${f.target ?? 'an item'}${f.reason.isEmpty ? '' : ': ${f.reason}'}'); + } + } + + /// Forgets what no longer matters: performing marks after a minute (a + /// swap that never landed may be tried again), local picks no longer + /// queued. + void _prune(StateBroadcast r) { + final now = DateTime.now(); + _performing.removeWhere((_, at) => now.difference(at) > const Duration(minutes: 1)); + final keys = {for (final q in r.queue) q.key}; + _local.removeWhere((k, v) => !keys.contains(k) && now.difference(v.at) > const Duration(minutes: 1)); + final ids = {for (final q in r.queue) q.id}; + _skipped.removeWhere((id) => !ids.contains(id)); + } + + /// [item] should go up now: by this app when it is the one that can; + /// nobody can: the lowest member takes it off with the reason. + void _bringUp(QueueItem item, StateBroadcast r) { + final perf = queuePerformer(item, r.members); + if (perf.id == null) { + final why = perf.why; + if (why != null && queueJanitor(r.members) == _me && _skipped.add(item.id)) { + coord.sync.sendQueue(QueueIntent.remove(item.id, why: why)); + } + return; + } + if (perf.id == _me) _perform(item); + } + + /// The media finished with Up next waiting: the card, and with + /// auto-advance on the countdown, for its first item. + void _atEnd(StateBroadcast r) { + // Up next goes before the next episode, even one already counting. + if (coord.autoAdvance.peek() != null) coord.cancelAutoAdvance(); + final head = r.queue.first; + final perf = queuePerformer(head, r.members); + if (perf.id == null && perf.why != null) { + // Taken off by [_bringUp]'s rule; the next state shows the next one. + _bringUp(head, r); + return; + } + final c = countdown.peek(); + if (c != null && c.mediaId == r.mediaId) { + if (c.item.id != head.id || c.item.thumb != head.thumb) { + countdown.value = UpNextCountdown(mediaId: c.mediaId, item: head, secondsLeft: c.secondsLeft); + } + return; + } + final auto = AppSettings.instance.autoAdvance.peek() && _cancelledFor != r.mediaId; + countdown.value = UpNextCountdown(mediaId: r.mediaId, item: head, secondsLeft: auto ? autoAdvanceSecs : null); + _timer?.cancel(); + _timer = auto ? Timer.periodic(const Duration(seconds: 1), (_) => _tick()) : null; + } + + void _tick() { + final c = countdown.peek(); + final r = coord.serverState.peek(); + final left = c?.secondsLeft; + if (c == null || left == null || r == null || r.mediaId != c.mediaId || r.transport != 'finished') { + _stopCountdown(); + return; + } + if (left > 1) { + countdown.value = UpNextCountdown(mediaId: c.mediaId, item: c.item, secondsLeft: left - 1); + return; + } + _timer?.cancel(); + _timer = null; + _cancelledFor = c.mediaId; + countdown.value = UpNextCountdown(mediaId: c.mediaId, item: c.item); + // Decided on the latest state: only the app that can puts it up. + final head = r.queue.firstOrNull; + if (head != null && queuePerformer(head, r.members).id == _me) _perform(head); + } + + void _stopCountdown() { + _timer?.cancel(); + _timer = null; + countdown.value = null; + } + + /// Puts [item] up from here (once per item while it is in flight). + /// When it cannot play after all, it is taken off with the reason. + void _perform(QueueItem item) { + if (_performing.containsKey(item.id)) return; + _performing[item.id] = DateTime.now(); + unawaited(() async { + String? error; + try { + error = await _swapTo(item); + } catch (e) { + error = '$e'; + } + if (error != null) { + debugPrint('queue: ${item.id} cannot go up: $error'); + coord.sync.sendQueue(QueueIntent.remove(item.id, why: "couldn't play it ($error)")); + } + }()); + } + + Future _swapTo(QueueItem item) async { + switch (item.how) { + case QueueHow.jellyfin: + final ref = item.media.jellyfin; + final pick = ref == null ? null : pickForRef(coord.jellyfin, ref); + if (pick == null) return 'no Jellyfin account here for it'; + coord.swapJellyfin(pick, queueId: item.id); + return null; + case QueueHow.direct: + final url = item.media.url; + if (url == null) return 'no link'; + // Each viewer opens it: one computer's probe does not decide. + return coord.swapLink(url, relay: LinkRelay.direct, anyway: true, queueId: item.id); + case QueueHow.server: + coord.swapQueued(HelloMedia(name: item.title, size: 0, duration: 0, relay: const MediaRelay(fetch: true)), + item.id, + art: thumbs.bytesFor(item.thumb)); + return null; + case QueueHow.through: + case QueueHow.file: + final local = _local[item.key]; + if (local == null) return "it isn't on this computer any more"; + // Only this app can pass it on, and a member sitting out has + // nothing open: back first. + if (coord.selfOut.peek()) coord.joinBack(); + return coord.swapPicked(local.pick, queueId: item.id); + } + } + + // ------------------------------------------------------------ warming + + /// Near the end of what plays (or once it is buffered to its end), the + /// app that will put the first item up resolves its link, so the swap + /// does not wait for yt-dlp. A Jellyfin item, a file or a link the + /// server fetches has nothing to get ready here. + void _warm(StateBroadcast r) { + final head = r.queue.firstOrNull; + if (head == null || queuePerformer(head, r.members).id != _me) { + if (warm.peek() != null) warm.value = null; + return; + } + final w = warm.peek(); + if (w == null || w.id != head.id) warm.value = (id: head.id, state: WarmState.idle); + if (warm.peek()!.state != WarmState.idle) return; + final duration = (r.media?['duration'] as num?)?.toDouble() ?? 0; + final position = coord.playback.positionSecs; + final due = r.media == null || + queueWarmDue( + transport: r.transport, + position: position, + duration: duration, + bufferedTo: position + coord.playback.bufferedSecsAhead); + if (!due) return; + final local = _local[head.key]?.pick; + final url = switch (head.how) { + QueueHow.direct => head.media.url, + QueueHow.through => local is LinkMedia ? local.url : null, + _ => null, + }; + if (url == null) { + warm.value = (id: head.id, state: WarmState.ready); + return; + } + warm.value = (id: head.id, state: WarmState.warming); + // The probe is kept a few minutes: the swap reads it from there. + unawaited(coord.ytDlp.probe(url).then((p) => p.problem == null, onError: (_) => false).then((ok) { + if (warm.peek()?.id != head.id) return; + warm.value = (id: head.id, state: ok ? WarmState.ready : WarmState.failed); + })); + } + + // -------------------------------------------------------- continuation + + /// The next few episodes after the one playing (own account only). + Future _loadContinuation(EpisodeLink? next, JellyfinPlan? plan) async { + if (next == null || plan is! OwnPlay) { + _continuationFor = ''; + if (continuation.peek().isNotEmpty) continuation.value = const []; + return; + } + final key = '${plan.account.serverId}/${next.item.id}'; + if (key == _continuationFor) return; + _continuationFor = key; + continuation.value = [next]; + final seriesId = next.item.seriesId; + if (seriesId == null) return; + try { + final list = await coord.jellyfin.cached( + plan.account.serverId, 'episodes:$seriesId', () => plan.client.episodes(seriesId)); + if (_continuationFor != key) return; + final more = [next]; + var at = next.item; + while (more.length < 4) { + final after = episodeAfter(list, at.id); + if (after == null) break; + more.add(EpisodeLink(after, pickFor(plan.account, after))); + at = after; + } + continuation.value = more; + } catch (e) { + debugPrint('queue: continuation of $seriesId: $e'); + } + } +} diff --git a/apps/desktop/lib/src/queue/queue_logic.dart b/apps/desktop/lib/src/queue/queue_logic.dart new file mode 100644 index 0000000..bb97d4e --- /dev/null +++ b/apps/desktop/lib/src/queue/queue_logic.dart @@ -0,0 +1,92 @@ +import 'package:sync_relay_core/core.dart'; + +import '../jellyfin/session_media.dart' show compareMemberIds; + +// Who puts an Up next item up, and when to get the first one ready. Pure: +// only what every client sees in `state` decides, so all clients agree +// without a wire message (like the Jellyfin auto-advance swapper). + +/// Who puts [item] up: [id] a member id; null with [why] when nobody here +/// can (the item is skipped), null without [why] when nobody is around +/// right now (nothing happens). +typedef QueuePerformer = ({String? id, String? why}); + +/// - A link passed on by its adder, or the adder's file: only the adder +/// (their app has the file, the cookies). Gone: skipped. +/// - Jellyfin: the adder when it plays that server with its own account, +/// else the lowest member id that does (`jf[serverId] == true`, not +/// stale, not sitting out). Nobody: skipped. +/// - A link everyone opens, or one the server fetches: the adder when +/// present (not stale or sitting out), else the lowest such member id. +QueuePerformer queuePerformer(QueueItem item, List members) { + MemberView? adder; + for (final m in members) { + if (m.id == item.by) adder = m; + } + bool active(MemberView m) => !m.stale && !m.out; + if (item.how.adderOnly) { + if (adder == null) return (id: null, why: '${_name(item)} left'); + return (id: adder.id, why: null); + } + final ref = item.media.jellyfin; + if (item.how == QueueHow.jellyfin && ref != null) { + bool can(MemberView m) => active(m) && m.jf[ref.serverId] == true; + if (adder != null && can(adder)) return (id: adder.id, why: null); + final id = autoAdvanceLowest(members.where(can)); + return id == null ? (id: null, why: 'nobody here has that Jellyfin server') : (id: id, why: null); + } + if (adder != null && active(adder)) return (id: adder.id, why: null); + return (id: autoAdvanceLowest(members.where(active)), why: null); +} + +String _name(QueueItem item) => item.byName.isEmpty ? 'who added it' : item.byName; + +/// The lowest member id of [members] ("m2" before "m10"), or null. +String? autoAdvanceLowest(Iterable members) { + final ids = [for (final m in members) m.id]..sort(compareMemberIds); + return ids.isEmpty ? null : ids.first; +} + +/// Who takes an item nobody can put up off the queue (`remove` with why): +/// the lowest member id that is not stale. +String? queueJanitor(List members) => autoAdvanceLowest(members.where((m) => !m.stale)); + +/// How [item] plays, in a few words for its row: "through Foxyz" (you: +/// "through you"), "server fetches", "everyone fetches", "Jellyfin", +/// "Foxyz's file". +String queueHowLabel(QueueItem item, {required String selfId}) { + final mine = item.by == selfId && selfId.isNotEmpty; + final who = mine ? 'you' : _name(item); + return switch (item.how) { + QueueHow.through => 'through $who', + QueueHow.file => mine ? 'your file' : "$who's file", + QueueHow.server => 'server fetches', + QueueHow.direct => 'everyone fetches', + QueueHow.jellyfin => 'Jellyfin', + }; +} + +/// Seconds before the end at which the first Up next item is got ready. +const queueWarmLeadSecs = 90.0; + +/// Whether to get the next item ready now: the current media is in its +/// last [queueWarmLeadSecs], is buffered to its end, or has finished. +bool queueWarmDue({ + required String transport, + required double position, + required double duration, + required double bufferedTo, +}) { + if (transport == 'finished') return true; + if (duration <= 0) return false; + return duration - position <= queueWarmLeadSecs || bufferedTo >= duration - 1; +} + +/// "1:23:45" / "23:45" for [secs]; empty when unknown. +String queueClock(double secs) { + if (secs <= 0) return ''; + final s = secs.round(); + final h = s ~/ 3600, m = (s % 3600) ~/ 60, r = s % 60; + String two(int v) => v.toString().padLeft(2, '0'); + return h > 0 ? '$h:${two(m)}:${two(r)}' : '$m:${two(r)}'; +} diff --git a/apps/desktop/lib/src/queue/queue_ui.dart b/apps/desktop/lib/src/queue/queue_ui.dart new file mode 100644 index 0000000..0d98ee6 --- /dev/null +++ b/apps/desktop/lib/src/queue/queue_ui.dart @@ -0,0 +1,1071 @@ +import 'dart:math' as math; +import 'dart:ui' show ImageFilter; + +import 'package:flutter/material.dart'; +import 'package:fox_utils/fox_utils.dart' show DesktopDropX, DropTarget; +import 'package:leaf_widgets/leaf_widgets.dart' show LeafPopover, LeafPopoverController; +import 'package:signals/signals_flutter.dart'; +import 'package:sync_relay_core/core.dart'; + +import '../files/scanner.dart' show FileKind, kindOf; +import '../jellyfin/images.dart'; +import '../jellyfin/session_media.dart' show EpisodeLink; +import '../screens/home/picked_media.dart'; +import '../session/session_coordinator.dart' show autoAdvanceSecs; +import '../shell/avatar.dart'; +import '../shell/handoff.dart' show handoffHolds; +import '../shell/motion.dart'; +import '../state/session_state.dart'; +import '../widgets/app_menu.dart' show AppMenuStyle; +import 'queue_controller.dart'; +import 'queue_logic.dart'; + +// Up next in the player: the chip top right (with the controls), the +// popover it opens (the queue, the continuation, a field and a drop zone +// to add), and the end-of-media card for the first item. + +/// Frosted dark glass like the app's menus. +class _Glass extends StatelessWidget { + final Widget child; + final double radius; + final Color color; + const _Glass({super.key, required this.child, this.radius = AppMenuStyle.radius, this.color = AppMenuStyle.glass}); + + @override + Widget build(BuildContext context) { + final r = BorderRadius.circular(radius); + return DecoratedBox( + decoration: BoxDecoration( + borderRadius: r, + boxShadow: const [BoxShadow(color: Color(0x66000000), blurRadius: 24, offset: Offset(0, 8))], + ), + child: ClipRRect( + borderRadius: r, + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 16, sigmaY: 16), + child: Material( + color: color, + shape: RoundedRectangleBorder( + borderRadius: r, side: const BorderSide(color: AppMenuStyle.border, width: 0.5)), + child: child, + ), + ), + ), + ); + } +} + +// ------------------------------------------------------------------ chip + +/// "Up next · " with ▶ (play it now), top right while the controls +/// are up; the title opens the queue. Without Up next, the continuation +/// ("Next · S1E8 …", ▶ plays it). Nothing when both are empty. +class UpNextChip extends StatefulWidget { + final QueueController queue; + /// Plays the continuation's first episode (the next episode). + final VoidCallback onPlayNextEpisode; + const UpNextChip({super.key, required this.queue, required this.onPlayNextEpisode}); + + @override + State<UpNextChip> createState() => _UpNextChipState(); +} + +class _UpNextChipState extends State<UpNextChip> { + final _popover = LeafPopoverController(); + bool _hoverTitle = false; + + QueueController get _q => widget.queue; + + @override + void dispose() { + if (_q.popoverOpen.peek()) { + _q.popoverOpen.value = false; + handoffHolds = math.max(0, handoffHolds - 1); + } + super.dispose(); + } + + void _toggle() { + if (_popover.isShowing) { + _popover.hide(); + return; + } + _q.coord.markInput(); + _q.popoverOpen.value = true; + handoffHolds++; + _popover.show(); + } + + void _closed() { + if (!_q.popoverOpen.peek()) return; + _q.popoverOpen.value = false; + handoffHolds = math.max(0, handoffHolds - 1); + } + + @override + Widget build(BuildContext context) { + return SignalBuilder(builder: (context) { + final items = _q.items.value; + final cont = _q.shownContinuation.value; + final open = _q.popoverOpen.value; + if (items.isEmpty && cont.isEmpty && !open) return const SizedBox.shrink(); + final queued = items.isNotEmpty; + final title = queued ? items.first.title : (cont.isNotEmpty ? cont.first.label : 'Nothing yet'); + final more = items.length - 1; + return _Glass( + radius: 999, + color: const Color(0xB3141418), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + LeafPopover( + controller: _popover, + popoverAnchor: Alignment.topRight, + childAnchor: Alignment.bottomRight, + spacing: 8, + onDismissed: _closed, + popoverBuilder: (context, c) => QueuePanel(queue: _q, onClose: c.hide), + child: MouseRegion( + cursor: SystemMouseCursors.click, + onEnter: (_) => setState(() => _hoverTitle = true), + onExit: (_) => setState(() => _hoverTitle = false), + child: GestureDetector( + key: const ValueKey('up-next-chip'), + behavior: HitTestBehavior.opaque, + onTap: _toggle, + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + height: 36, + padding: const EdgeInsets.only(left: 12, right: 10), + color: _hoverTitle || open ? const Color(0x14FFFFFF) : Colors.transparent, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(queued ? Icons.queue_play_next_rounded : Icons.playlist_play_rounded, + size: 17, color: Colors.white70), + const SizedBox(width: 8), + Text(queued ? 'Up next' : 'Next', + style: const TextStyle(color: Colors.white60, fontSize: 12.5)), + const Text(' · ', style: TextStyle(color: Colors.white38, fontSize: 12.5)), + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 240), + child: Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: queued ? Colors.white : Colors.white70, + fontSize: 13, + fontWeight: FontWeight.w500), + ), + ), + if (more > 0) ...[ + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1), + decoration: BoxDecoration( + color: const Color(0x24FFFFFF), borderRadius: BorderRadius.circular(999)), + child: Text('+$more', style: const TextStyle(color: Colors.white70, fontSize: 11)), + ), + ], + const SizedBox(width: 4), + AnimatedRotation( + duration: easeTime, + curve: ease, + turns: open ? 0.5 : 0, + child: const Icon(Icons.expand_more_rounded, size: 18, color: Colors.white54), + ), + ], + ), + ), + ), + ), + ), + if (queued || cont.isNotEmpty) ...[ + Container(width: 0.5, height: 20, color: Colors.white24), + _RoundIcon( + key: const ValueKey('up-next-play'), + icon: Icons.play_arrow_rounded, + tooltip: queued ? 'Play ${items.first.title} now' : 'Play ${cont.first.label} now', + onPressed: queued ? _q.skip : widget.onPlayNextEpisode, + ), + ], + ], + ), + ); + }); + } +} + +class _RoundIcon extends StatefulWidget { + final IconData icon; + final String tooltip; + final VoidCallback onPressed; + final double size; + final Color color; + const _RoundIcon( + {super.key, required this.icon, required this.tooltip, required this.onPressed, this.size = 20, this.color = Colors.white}); + + @override + State<_RoundIcon> createState() => _RoundIconState(); +} + +class _RoundIconState extends State<_RoundIcon> { + bool _hover = false; + + @override + Widget build(BuildContext context) => Tooltip( + message: widget.tooltip, + waitDuration: const Duration(milliseconds: 500), + child: MouseRegion( + cursor: SystemMouseCursors.click, + onEnter: (_) => setState(() => _hover = true), + onExit: (_) => setState(() => _hover = false), + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: widget.onPressed, + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + width: widget.size + 16, + height: widget.size + 16, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: _hover ? const Color(0x24FFFFFF) : Colors.transparent, + ), + child: Icon(widget.icon, size: widget.size, color: widget.color), + ), + ), + ), + ); +} + +// ----------------------------------------------------------------- panel + +/// The queue, under the chip: Up next (drag to reorder, ▶ / × on hover), +/// "Continues with" (greyed, + adds one), a field to paste a link, and +/// drops of links and video files. +class QueuePanel extends StatefulWidget { + final QueueController queue; + final VoidCallback onClose; + const QueuePanel({super.key, required this.queue, required this.onClose}); + + @override + State<QueuePanel> createState() => _QueuePanelState(); +} + +class _QueuePanelState extends State<QueuePanel> { + final _field = TextEditingController(); + final _focus = FocusNode(); + bool _busy = false; + String? _error; + bool _dragging = false; + // Reordered here before the server's state says so (no jump back while + // it travels). + List<String>? _order; + DateTime? _orderAt; + + QueueController get _q => widget.queue; + + @override + void initState() { + super.initState(); + // Typing and Esc land here, not on the player (its keys pause while a + // text field has focus). + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _focus.requestFocus(); + }); + } + + @override + void dispose() { + _field.dispose(); + _focus.dispose(); + super.dispose(); + } + + Future<void> _add(List<PickedMedia> picks) async { + if (picks.isEmpty || _busy) return; + setState(() { + _busy = true; + _error = null; + }); + String? error; + for (final p in picks) { + error = await _q.add(p) ?? error; + } + if (!mounted) return; + setState(() { + _busy = false; + _error = error; + if (error == null) _field.clear(); + }); + } + + void _submit() { + final text = _field.text.trim(); + if (text.isEmpty) return; + if (!RegExp(r'^https?://\S+$', caseSensitive: false).hasMatch(text)) { + setState(() => _error = 'Paste a link (http:// or https://)'); + return; + } + // Links go "through me" by default, like the Link page (alone: direct). + _add([LinkMedia(text, relay: LinkRelay.me)]); + } + + void _dropped(List<String> paths) { + final picks = <PickedMedia>[]; + for (final raw in paths) { + final p = raw.trim(); + if (RegExp(r'^https?://', caseSensitive: false).hasMatch(p)) { + picks.add(LinkMedia(p, relay: LinkRelay.me)); + } else if (kindOf(p) == FileKind.video) { + picks.add(FileMedia(p)); + } + } + if (picks.isEmpty) { + setState(() => _error = 'Drop a video file or a link'); + return; + } + _add(picks); + } + + List<QueueItem> _ordered(List<QueueItem> items) { + final order = _order; + if (order == null) return items; + final ids = [for (final q in items) q.id]; + final stale = _orderAt == null || DateTime.now().difference(_orderAt!) > const Duration(seconds: 3); + if (stale || ids.length != order.length || !ids.every(order.contains) || _sameOrder(ids, order)) { + _order = null; + return items; + } + final byId = {for (final q in items) q.id: q}; + return [for (final id in order) byId[id]!]; + } + + static bool _sameOrder(List<String> a, List<String> b) { + for (var i = 0; i < a.length; i++) { + if (a[i] != b[i]) return false; + } + return true; + } + + /// [to] counts without the moved item (`onReorderItem`). + void _reorder(List<QueueItem> shown, int from, int to) { + if (to == from) return; + final ids = [for (final q in shown) q.id]; + final id = ids.removeAt(from); + ids.insert(to, id); + setState(() { + _order = ids; + _orderAt = DateTime.now(); + }); + _q.move(id, to); + } + + @override + Widget build(BuildContext context) { + final height = MediaQuery.sizeOf(context).height; + return DropTarget( + onDragEntered: (_) => setState(() => _dragging = true), + onDragExited: (_) => setState(() => _dragging = false), + onDragDone: (d) { + setState(() => _dragging = false); + _dropped(DesktopDropX.pathsOf(d.files)); + }, + // Esc: the popover's own (it fades out). + child: Builder( + builder: (context) => SizedBox( + width: 424, + child: ConstrainedBox( + constraints: BoxConstraints(maxHeight: math.max(260, math.min(600, height - 140))), + child: _Glass( + child: AnimatedContainer( + duration: easeTime, + curve: ease, + foregroundDecoration: BoxDecoration( + borderRadius: BorderRadius.circular(AppMenuStyle.radius), + border: Border.all( + color: _dragging ? Theme.of(context).colorScheme.primary : Colors.transparent, width: 1.5), + ), + child: SignalBuilder(builder: (context) { + final items = _ordered(_q.items.value); + final cont = _q.shownContinuation.value; + final warm = _q.warm.value; + final r = _q.coord.serverState.value; + final selfId = _q.coord.sync.memberId; + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _header(items.length), + Flexible( + child: items.isEmpty && cont.isEmpty + ? _empty() + : ListView( + shrinkWrap: true, + padding: const EdgeInsets.fromLTRB(6, 0, 6, 6), + children: [ + if (items.isEmpty) + const Padding( + padding: EdgeInsets.fromLTRB(10, 2, 10, 8), + child: Text('Nothing added yet. What plays next is below.', + style: TextStyle(color: Colors.white54, fontSize: 12.5)), + ) + else + ReorderableListView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + buildDefaultDragHandles: false, + itemCount: items.length, + proxyDecorator: (child, _, anim) => AnimatedBuilder( + animation: anim, + builder: (context, child) => Material( + color: Colors.transparent, + elevation: 8 * Curves.easeOut.transform(anim.value), + shadowColor: Colors.black, + borderRadius: BorderRadius.circular(AppMenuStyle.rowRadius), + child: child, + ), + child: child, + ), + onReorderItem: (from, to) => _reorder(items, from, to), + itemBuilder: (context, i) { + final item = items[i]; + return _QueueRow( + key: ValueKey(item.id), + index: i, + item: item, + queue: _q, + members: r?.members ?? const [], + selfId: selfId, + warm: i == 0 && warm?.id == item.id ? warm!.state : null, + ); + }, + ), + if (cont.isNotEmpty) ..._continuation(cont, items.isEmpty), + ], + ), + ), + _footer(), + ], + ); + }), + ), + ), + ), + ), + ), + ); + } + + Widget _header(int count) => Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 8, 8), + child: Row( + children: [ + const Text('Up next', style: TextStyle(color: Colors.white, fontSize: 15, fontWeight: FontWeight.w600)), + if (count > 0) ...[ + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 1), + decoration: BoxDecoration(color: const Color(0x24FFFFFF), borderRadius: BorderRadius.circular(999)), + child: Text('$count', style: const TextStyle(color: Colors.white70, fontSize: 11.5)), + ), + ], + const Spacer(), + _RoundIcon(icon: Icons.close_rounded, size: 18, color: Colors.white60, tooltip: 'Close (Esc)', onPressed: widget.onClose), + ], + ), + ); + + Widget _empty() => const Padding( + padding: EdgeInsets.fromLTRB(20, 12, 20, 18), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.queue_play_next_rounded, size: 34, color: Colors.white30), + SizedBox(height: 10), + Text('Nothing up next', style: TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.w500)), + SizedBox(height: 4), + Text( + 'Paste a link below, drop a video or a link here, or pick "Add to queue" in the library. ' + 'It plays when this one ends.', + textAlign: TextAlign.center, + style: TextStyle(color: Colors.white54, fontSize: 12.5, height: 1.35), + ), + ], + ), + ); + + List<Widget> _continuation(List<EpisodeLink> cont, bool alone) => [ + Padding( + padding: EdgeInsets.fromLTRB(10, alone ? 0 : 10, 2, 2), + child: Row( + children: [ + const Text('CONTINUES WITH', + style: TextStyle(color: Colors.white38, fontSize: 10.5, letterSpacing: 0.8, fontWeight: FontWeight.w600)), + const Spacer(), + TextButton( + key: const ValueKey('queue-clear-continuation'), + style: TextButton.styleFrom(foregroundColor: Colors.white54, visualDensity: VisualDensity.compact), + onPressed: _q.clearContinuation, + child: const Text('Clear', style: TextStyle(fontSize: 12)), + ), + ], + ), + ), + for (final e in cont) _ContinuationRow(episode: e, queue: _q), + ]; + + Widget _footer() => Container( + decoration: const BoxDecoration(border: Border(top: BorderSide(color: AppMenuStyle.border, width: 0.5))), + padding: const EdgeInsets.fromLTRB(10, 8, 10, 10), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + key: const ValueKey('queue-add-field'), + controller: _field, + focusNode: _focus, + // Read-only while adding, not disabled: focus stays here, so + // Space and the arrows do not reach the player. + readOnly: _busy, + style: const TextStyle(fontSize: 13.5), + onSubmitted: (_) => _submit(), + decoration: InputDecoration( + isDense: true, + filled: true, + fillColor: const Color(0x14FFFFFF), + hintText: _dragging ? 'Drop to add' : 'Paste a link to add', + hintStyle: const TextStyle(color: Colors.white38, fontSize: 13.5), + prefixIcon: const Icon(Icons.add_link_rounded, size: 18, color: Colors.white54), + prefixIconConstraints: const BoxConstraints(minWidth: 38), + suffixIcon: _busy + ? const Padding( + padding: EdgeInsets.all(10), + child: SizedBox.square(dimension: 14, child: CircularProgressIndicator(strokeWidth: 2)), + ) + : null, + contentPadding: const EdgeInsets.symmetric(vertical: 10, horizontal: 8), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: BorderSide.none), + ), + ), + AnimatedSize( + duration: easeTime, + curve: ease, + alignment: Alignment.topCenter, + child: _error == null + ? const SizedBox(width: double.infinity) + : Padding( + padding: const EdgeInsets.fromLTRB(4, 6, 4, 0), + child: Text(_error!, style: TextStyle(color: Colors.orange.shade300, fontSize: 12)), + ), + ), + ], + ), + ); +} + +// ------------------------------------------------------------------ rows + +/// A picture of a queued item (by hash from the server), 16:9 and rounded; +/// an icon for how it plays until it arrives (or when it has none). +class QueueThumb extends StatelessWidget { + final QueueController queue; + final QueueItem item; + final double width; + const QueueThumb({super.key, required this.queue, required this.item, this.width = 72}); + + @override + Widget build(BuildContext context) { + return ClipRRect( + borderRadius: BorderRadius.circular(6), + child: SizedBox( + width: width, + height: width * 9 / 16, + child: ListenableBuilder( + listenable: queue.thumbs, + builder: (context, _) { + final bytes = queue.thumbs.bytesFor(item.thumb); + if (bytes == null) { + return ColoredBox( + color: const Color(0x1FFFFFFF), + child: Icon(howIcon(item.how), size: width / 4, color: Colors.white38), + ); + } + return Image.memory(bytes, fit: BoxFit.cover, gaplessPlayback: true, filterQuality: FilterQuality.medium); + }, + ), + ), + ); + } +} + +/// The icon for how an item plays (as on the Link page). +IconData howIcon(QueueHow how) => switch (how) { + QueueHow.through => Icons.cell_tower_rounded, + QueueHow.server => Icons.dns_rounded, + QueueHow.direct => Icons.groups_rounded, + QueueHow.jellyfin => Icons.video_library_rounded, + QueueHow.file => Icons.insert_drive_file_rounded, + }; + +/// Who added it: their picture (still here) or initial, and name. +AvatarInfo _adderAvatar(QueueItem item, List<MemberView> members) { + final coord = SessionState.instance.coordinator; + for (final m in members) { + if (m.id == item.by) return memberAvatarInfo(m, coord.serverState.peek(), coord); + } + final name = item.byName.isEmpty ? '?' : item.byName; + return AvatarInfo(name: name, color: colorForName(name)); +} + +/// "24:10 · added by ◉ Foxyz · ⌁ through Foxyz". +class _MetaLine extends StatelessWidget { + final QueueItem item; + final List<MemberView> members; + final String selfId; + final double fontSize; + /// Wraps onto a second line instead of cutting the end off. + final bool wrap; + const _MetaLine( + {required this.item, required this.members, required this.selfId, this.fontSize = 11.5, this.wrap = false}); + + @override + Widget build(BuildContext context) { + final style = TextStyle(color: Colors.white54, fontSize: fontSize); + final dur = queueClock(item.duration); + final mine = item.by == selfId && selfId.isNotEmpty; + final name = mine ? 'you' : (item.byName.isEmpty ? '?' : item.byName); + const dot = Text(' · ', style: TextStyle(color: Colors.white24, fontSize: 11)); + if (wrap) { + final how = queueHowLabel(item, selfId: selfId); + return Wrap( + crossAxisAlignment: WrapCrossAlignment.center, + runSpacing: 2, + children: [ + if (dur.isNotEmpty) ...[Text(dur, style: style), dot], + Text('added by ', style: style), + StatusAvatar(info: _adderAvatar(item, members), size: fontSize + 3, badges: false, ring: false), + const SizedBox(width: 4), + Text(name, style: style), + dot, + Row(mainAxisSize: MainAxisSize.min, children: [ + Icon(howIcon(item.how), size: fontSize + 1, color: Colors.white38), + const SizedBox(width: 3), + Text(how, style: style), + ]), + ], + ); + } + return Row( + children: [ + if (dur.isNotEmpty) ...[Text(dur, style: style), dot], + Text('added by ', style: style), + StatusAvatar(info: _adderAvatar(item, members), size: fontSize + 3, badges: false, ring: false), + const SizedBox(width: 4), + // Capped rather than flexible: two flexibles split the room in + // half and would cut the end off even when it fits. + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 96), + child: Text(name, maxLines: 1, overflow: TextOverflow.ellipsis, style: style), + ), + dot, + Icon(howIcon(item.how), size: fontSize + 1, color: Colors.white38), + const SizedBox(width: 3), + Flexible( + child: Text(queueHowLabel(item, selfId: selfId), maxLines: 1, overflow: TextOverflow.ellipsis, style: style), + ), + ], + ); + } +} + +class _QueueRow extends StatefulWidget { + final int index; + final QueueItem item; + final QueueController queue; + final List<MemberView> members; + final String selfId; + /// The first item's readiness when this app puts it up; null: no dot. + final WarmState? warm; + const _QueueRow({ + super.key, + required this.index, + required this.item, + required this.queue, + required this.members, + required this.selfId, + required this.warm, + }); + + @override + State<_QueueRow> createState() => _QueueRowState(); +} + +class _QueueRowState extends State<_QueueRow> { + bool _hover = false; + + @override + Widget build(BuildContext context) { + final item = widget.item; + final row = AnimatedContainer( + duration: const Duration(milliseconds: 120), + margin: const EdgeInsets.symmetric(vertical: 1), + padding: const EdgeInsets.fromLTRB(2, 6, 8, 6), + decoration: BoxDecoration( + color: _hover ? AppMenuStyle.hover : Colors.transparent, + borderRadius: BorderRadius.circular(AppMenuStyle.rowRadius), + ), + child: Row( + children: [ + ReorderableDragStartListener( + index: widget.index, + child: MouseRegion( + cursor: SystemMouseCursors.grab, + child: SizedBox( + width: 22, + height: 40, + child: AnimatedOpacity( + duration: const Duration(milliseconds: 120), + opacity: _hover ? 0.8 : 0.25, + child: const Icon(Icons.drag_indicator_rounded, size: 16, color: Colors.white), + ), + ), + ), + ), + QueueThumb(queue: widget.queue, item: item), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + Flexible( + child: Text(item.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: Colors.white, fontSize: 13.5, fontWeight: FontWeight.w500)), + ), + if (widget.warm != null) ...[const SizedBox(width: 6), _WarmDot(state: widget.warm!)], + ], + ), + if (item.sub.isNotEmpty) + Text(item.sub, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: Colors.white60, fontSize: 11.5)), + const SizedBox(height: 2), + _MetaLine(item: item, members: widget.members, selfId: widget.selfId), + ], + ), + ), + ], + ), + ); + final actions = Positioned( + right: 0, + top: 0, + bottom: 0, + child: AnimatedOpacity( + duration: const Duration(milliseconds: 120), + opacity: _hover ? 1 : 0, + child: IgnorePointer( + ignoring: !_hover, + child: Container( + padding: const EdgeInsets.only(left: 28, right: 6), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(AppMenuStyle.rowRadius), + gradient: const LinearGradient( + colors: [Color(0x00222226), Color(0xF0222226), Color(0xF0222226)], + stops: [0, 0.3, 1], + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + _RoundIcon( + key: ValueKey('queue-play-${item.id}'), + icon: Icons.play_arrow_rounded, + tooltip: 'Play now', + onPressed: () => widget.queue.playNow(item.id), + ), + _RoundIcon( + key: ValueKey('queue-remove-${item.id}'), + icon: Icons.close_rounded, + size: 18, + color: Colors.white70, + tooltip: 'Remove', + onPressed: () => widget.queue.remove(item.id), + ), + ], + ), + ), + ), + ), + ); + return MouseRegion( + onEnter: (_) => setState(() => _hover = true), + onExit: (_) => setState(() => _hover = false), + child: Stack(children: [row, actions]), + ); + } +} + +/// Hollow: not ready yet; pulsing: getting ready; filled: ready (red: +/// the link would not open). +class _WarmDot extends StatefulWidget { + final WarmState state; + const _WarmDot({required this.state}); + + @override + State<_WarmDot> createState() => _WarmDotState(); +} + +class _WarmDotState extends State<_WarmDot> with SingleTickerProviderStateMixin { + late final AnimationController _pulse = + AnimationController(vsync: this, duration: const Duration(milliseconds: 900)); + + @override + void initState() { + super.initState(); + _sync(); + } + + @override + void didUpdateWidget(_WarmDot old) { + super.didUpdateWidget(old); + _sync(); + } + + void _sync() { + if (widget.state == WarmState.warming) { + if (!_pulse.isAnimating) _pulse.repeat(reverse: true); + } else { + _pulse.stop(); + _pulse.value = 1; + } + } + + @override + void dispose() { + _pulse.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final green = Colors.greenAccent.shade400; + final (color, filled, tip) = switch (widget.state) { + WarmState.idle => (Colors.white54, false, 'Gets ready near the end of this one'), + WarmState.warming => (green, true, 'Getting it ready…'), + WarmState.ready => (green, true, 'Ready to start'), + WarmState.failed => (Colors.redAccent, true, "Couldn't get it ready; it is tried again when it comes up"), + }; + return Tooltip( + message: tip, + child: FadeTransition( + opacity: Tween(begin: 0.35, end: 1.0).animate(_pulse), + child: Container( + width: 8, + height: 8, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: filled ? color : Colors.transparent, + border: Border.all(color: color, width: 1.4), + ), + ), + ), + ); + } +} + +class _ContinuationRow extends StatefulWidget { + final EpisodeLink episode; + final QueueController queue; + const _ContinuationRow({required this.episode, required this.queue}); + + @override + State<_ContinuationRow> createState() => _ContinuationRowState(); +} + +class _ContinuationRowState extends State<_ContinuationRow> { + bool _hover = false; + + @override + Widget build(BuildContext context) { + final e = widget.episode; + final client = widget.queue.coord.jellyfin.clientFor(e.pick.serverId); + final image = e.item.ownImage(JellyfinImageType.primary) ?? e.item.image(JellyfinImageType.thumb); + final runtime = e.pick.runtime; + return MouseRegion( + onEnter: (_) => setState(() => _hover = true), + onExit: (_) => setState(() => _hover = false), + child: AnimatedOpacity( + duration: const Duration(milliseconds: 120), + opacity: _hover ? 0.9 : 0.55, + child: Container( + padding: const EdgeInsets.fromLTRB(24, 5, 6, 5), + decoration: BoxDecoration( + color: _hover ? AppMenuStyle.hover : Colors.transparent, + borderRadius: BorderRadius.circular(AppMenuStyle.rowRadius), + ), + child: Row( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(6), + child: SizedBox( + width: 72, + height: 40.5, + child: client == null + ? const ColoredBox(color: Color(0x1FFFFFFF)) + : JellyfinImage(client: client, image: image, icon: Icons.tv_rounded), + ), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text(e.label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: Colors.white, fontSize: 13)), + Text( + [if (runtime != null) queueClock(runtime.inSeconds.toDouble()), e.item.seriesName ?? ''] + .where((s) => s.isNotEmpty) + .join(' · '), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: Colors.white54, fontSize: 11.5), + ), + ], + ), + ), + _RoundIcon( + icon: Icons.add_rounded, + size: 18, + tooltip: 'Add to Up next', + onPressed: () => widget.queue.promote(e), + ), + ], + ), + ), + ), + ); + } +} + +// -------------------------------------------------------------- end card + +/// When the media ended with Up next waiting: its picture, title and who +/// added it, the countdown with Cancel, and Play now. +class UpNextCard extends StatelessWidget { + final QueueController queue; + final UpNextCountdown countdown; + const UpNextCard({super.key, required this.queue, required this.countdown}); + + @override + Widget build(BuildContext context) { + final c = countdown; + final item = c.item; + final left = c.secondsLeft; + final r = queue.coord.serverState.value; + final selfId = queue.coord.sync.memberId; + final waiting = left == null ? _waitingFor(item, r) : null; + return _Glass( + key: const ValueKey('up-next-card'), + child: SizedBox( + width: 400, + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + QueueThumb(queue: queue, item: item, width: 132), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(left == null ? 'Up next' : 'Up next in $left s', + style: const TextStyle(color: Colors.white60, fontSize: 12)), + const SizedBox(height: 2), + Text(item.title, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: Colors.white, fontSize: 15, fontWeight: FontWeight.w600, height: 1.25)), + if (item.sub.isNotEmpty) + Text(item.sub, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: Colors.white60, fontSize: 12)), + const SizedBox(height: 4), + _MetaLine( + item: item, members: r?.members ?? const [], selfId: selfId, fontSize: 11, wrap: true), + ], + ), + ), + ], + ), + if (left != null) ...[ + const SizedBox(height: 10), + ClipRRect( + borderRadius: BorderRadius.circular(2), + child: TweenAnimationBuilder<double>( + tween: Tween(end: left / autoAdvanceSecs), + duration: const Duration(seconds: 1), + builder: (context, v, _) => LinearProgressIndicator( + value: v, + minHeight: 3, + backgroundColor: const Color(0x1FFFFFFF), + ), + ), + ), + ], + if (waiting != null) ...[ + const SizedBox(height: 8), + Text(waiting, style: const TextStyle(color: Colors.white54, fontSize: 12)), + ], + const SizedBox(height: 10), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + if (left != null) + TextButton( + key: const ValueKey('up-next-cancel'), + onPressed: queue.cancelCountdown, + child: const Text('Cancel')), + const SizedBox(width: 8), + FilledButton.icon( + key: const ValueKey('up-next-play-now'), + onPressed: () => queue.playNow(item.id), + icon: const Icon(Icons.play_arrow_rounded, size: 18), + label: const Text('Play now'), + ), + ], + ), + ], + ), + ), + ), + ); + } + + /// Another app puts it up: say whose. + String? _waitingFor(QueueItem item, StateBroadcast? r) { + if (r == null) return null; + final id = queuePerformer(item, r.members).id; + if (id == null || id == queue.coord.sync.memberId) return null; + final name = r.members.where((m) => m.id == id).firstOrNull?.name; + return name == null ? null : '$name puts it up'; + } +} diff --git a/apps/desktop/lib/src/screens/home/picked_media.dart b/apps/desktop/lib/src/screens/home/picked_media.dart index 619f8ff..d85160e 100644 --- a/apps/desktop/lib/src/screens/home/picked_media.dart +++ b/apps/desktop/lib/src/screens/home/picked_media.dart @@ -122,3 +122,7 @@ final class JellyfinMedia extends PickedMedia { @override String toString() => 'JellyfinMedia($serverId/$itemId "$title")'; } + +/// Adds a pick to Up next (with [next]: right after what plays); returns +/// an error text, or null when sent. Null where there is no server. +typedef AddToQueue = Future<String?> Function(PickedMedia media, {bool next}); diff --git a/apps/desktop/lib/src/screens/session/jellyfin_widgets.dart b/apps/desktop/lib/src/screens/session/jellyfin_widgets.dart index 6565c17..787c87a 100644 --- a/apps/desktop/lib/src/screens/session/jellyfin_widgets.dart +++ b/apps/desktop/lib/src/screens/session/jellyfin_widgets.dart @@ -165,25 +165,3 @@ class JellyfinBlockedCard extends StatelessWidget { ); } } - -/// Small member-list badge: plays the Jellyfin item through the host. -class ViaHostBadge extends StatelessWidget { - const ViaHostBadge({super.key}); - - @override - Widget build(BuildContext context) { - return Tooltip( - message: 'No Jellyfin account here: watches through the host', - child: Container( - key: const ValueKey('via-host'), - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1), - decoration: BoxDecoration( - color: Colors.white10, - borderRadius: BorderRadius.circular(999), - border: Border.all(color: Colors.white24), - ), - child: const Text('via host', style: TextStyle(fontSize: 10, color: Colors.white70)), - ), - ); - } -} diff --git a/apps/desktop/lib/src/session/delivery.dart b/apps/desktop/lib/src/session/delivery.dart new file mode 100644 index 0000000..dccea83 --- /dev/null +++ b/apps/desktop/lib/src/session/delivery.dart @@ -0,0 +1,309 @@ +import 'package:sync_relay_core/core.dart'; + +import '../jellyfin/session_media.dart'; + +// Who fetches the current media and how it spreads, from the state alone: +// the members card's source line and each member's route and buffer. + +/// "4.1 MB/s", "850 kB/s". +String formatRate(int bytesPerSec) { + if (bytesPerSec >= 1000000) { + final mb = bytesPerSec / 1e6; + return '${mb.toStringAsFixed(mb >= 10 ? 0 : 1)} MB/s'; + } + return '${(bytesPerSec / 1000).round()} kB/s'; +} + +/// How the current media reaches the party. +enum DeliveryKind { + /// A link every app opens itself. + link, + /// The hosting app's file, read by everyone from its server. + hostFile, + /// A file the dedicated server was started with. + serverFile, + /// A member's machine passes it on through the server. + memberRelay, + /// The server reads the link itself. + serverFetch, + /// Jellyfin: own accounts, the server's login for the rest. + jellyfin, +} + +/// How a member gets the bytes. +enum DeliveryRoute { + /// It serves them (a relay source, the host's file). + source, + /// It reads them from the server. + server, + /// It fetches them itself (a link, its own Jellyfin account). + direct, +} + +enum BufferHealth { good, low, starving } + +/// The relay source's condition. +enum SourceState { + ok, + /// Measured slower than the media's bitrate. + slow, + /// Its channel is down (or the server has not read the link yet). + waiting, + /// It left the server. + left, +} + +/// Members' names by id, kept after they leave: "Foxyz left" needs the +/// name the state no longer has. +class MemberNames { + final _names = <String, String>{}; + + void observe(StateBroadcast r) { + if (_names.length > 256) _names.clear(); + for (final m in r.members) { + _names[m.id] = m.name; + } + } + + String? operator [](String id) => _names[id]; +} + +/// The member [m]'s route and buffer. +class MemberDelivery { + /// Null when unknown (a Jellyfin member that has not reported yet). + final DeliveryRoute? route; + final DeliveryKind kind; + /// Bytes per second the server sends it lately. + final int rate; + /// Null when it does not count: sitting out, reconnecting, cannot play. + final BufferHealth? health; + final double buffered; + /// The media is a Jellyfin item (direct = its own account). + final bool jellyfin; + const MemberDelivery({ + required this.route, + required this.kind, + this.jellyfin = false, + required this.rate, + required this.health, + required this.buffered, + }); + + /// "via server · 3.2 MB/s", "direct", "passes it on"; empty when unknown. + String get short => switch (route) { + DeliveryRoute.source => kind == DeliveryKind.hostFile ? 'hosts the file' : 'passes it on', + DeliveryRoute.server => rate > 0 ? 'via server · ${formatRate(rate)}' : 'via server', + DeliveryRoute.direct => jellyfin ? 'own Jellyfin account' : 'direct', + null => '', + }; + + /// [short] and the buffer: "via server · 3.2 MB/s · 45 s buffered". + String get detail => [ + if (short.isNotEmpty) short, + if (health != null) health == BufferHealth.starving ? 'buffer empty' : '${buffered.round()} s buffered', + ].join(' · '); +} + +/// The current media's path for everyone: what the members card's source +/// line says. +class Delivery { + final DeliveryKind kind; + /// The member serving it ([DeliveryKind.memberRelay], [DeliveryKind.hostFile]); empty otherwise. + final String sourceId; + final String sourceName; + final bool sourceIsSelf; + final SourceState state; + /// `media.relay.rate` / `need` (0 unknown). + final int rate, need; + /// Members (not sitting out, not the source) reading from the server, + /// and fetching it themselves. + final int viaServer, direct; + const Delivery({ + required this.kind, + this.sourceId = '', + this.sourceName = '', + this.sourceIsSelf = false, + this.state = SourceState.ok, + this.rate = 0, + this.need = 0, + this.viaServer = 0, + this.direct = 0, + }); + + bool get relayed => kind == DeliveryKind.memberRelay || kind == DeliveryKind.serverFetch; + + /// Rate against bitrate, both known: 1 = exactly what the media needs. + double? get headroom => rate > 0 && need > 0 ? rate / need : null; + + String get title { + final who = sourceIsSelf ? 'You' : sourceName; + switch (kind) { + case DeliveryKind.link: + return 'Everyone plays the link directly'; + case DeliveryKind.serverFile: + return 'The server has the file'; + case DeliveryKind.hostFile: + return sourceIsSelf ? 'You host the file' : '$who hosts the file'; + case DeliveryKind.jellyfin: + return viaServer == 0 ? 'Everyone streams it from Jellyfin' : 'From Jellyfin'; + case DeliveryKind.serverFetch: + return switch (state) { + SourceState.waiting => 'The server is getting the video…', + SourceState.slow => 'The server fetches it slower than it plays', + _ => 'The server fetches it', + }; + case DeliveryKind.memberRelay: + return switch (state) { + SourceState.left => '$sourceName left: playing what the server has', + SourceState.waiting => sourceIsSelf ? 'Reconnecting you to the server…' : 'Waiting for $who to reconnect', + SourceState.slow => sourceIsSelf ? 'Your upload is slower than the video' : "$who's upload is slower than the video", + SourceState.ok => sourceIsSelf ? 'You pass it on through the server' : '$who passes it on through the server', + }; + } + } + + /// "3 through the server · 4.1 MB/s, needs 2.3 MB/s"; empty when + /// there is nothing to add. + String get detail { + final parts = <String>[ + if (direct > 0 && (kind == DeliveryKind.jellyfin || kind == DeliveryKind.memberRelay)) + '$direct with their own account', + if (viaServer > 0 && kind != DeliveryKind.link) '$viaServer through the server', + if (rate > 0 && state != SourceState.left) need > 0 ? '${formatRate(rate)}, needs ${formatRate(need)}' : formatRate(rate), + ]; + return parts.join(' · '); + } + + /// The hover text: what the numbers mean. + String get explain => switch (kind) { + DeliveryKind.memberRelay || DeliveryKind.serverFetch => [ + if (rate > 0) + '${kind == DeliveryKind.serverFetch ? 'The server gets' : sourceIsSelf ? 'You send' : '$sourceName sends'} ' + '${formatRate(rate)} while the server asks for more.', + if (need > 0) 'The video averages ${formatRate(need)}.', + if (state == SourceState.slow) 'Slower than the video: the others will pause to buffer.', + if (rate == 0) 'The speed shows once bytes have flowed for a few seconds.', + ].join(' '), + DeliveryKind.hostFile || DeliveryKind.serverFile => 'Everyone else reads the file from the server.', + DeliveryKind.jellyfin => 'Members with an account on that Jellyfin stream it themselves; ' + 'the rest through the server.', + DeliveryKind.link => 'Each app opens the link itself; the server only keeps time.', + }; +} + +/// The current media's [Delivery]; null with nothing up or nobody else in +/// the server. [names] remembers a source that left. +Delivery? deliveryOf(StateBroadcast r, {required String selfId, MemberNames? names}) { + final media = r.media; + if (media == null || r.members.length < 2) return null; + final rel = media['relay']; + final relay = rel is Map ? MediaRelay.fromJson(Map<String, Object?>.from(rel)) : null; + final ref = refOfStateMedia(media); + final url = media['url']; + final kind = relay != null + ? (relay.fetch ? DeliveryKind.serverFetch : DeliveryKind.memberRelay) + : ref != null + ? DeliveryKind.jellyfin + : url is String && url.isNotEmpty + ? DeliveryKind.link + : r.members.any((m) => m.host) + ? DeliveryKind.hostFile + : DeliveryKind.serverFile; + final sourceId = switch (kind) { + DeliveryKind.memberRelay => relay!.source, + DeliveryKind.hostFile => r.members.firstWhere((m) => m.host).id, + _ => '', + }; + final source = r.members.where((m) => m.id == sourceId).firstOrNull; + var via = 0, direct = 0; + for (final m in r.members) { + if (m.out || m.id == sourceId) continue; + switch (routeOf(m, kind, sourceId, ref, r.relay)) { + case DeliveryRoute.server: + via++; + case DeliveryRoute.direct: + direct++; + default: + break; + } + } + // A Jellyfin item that everyone streams with their own account: the + // relay stands by, nothing goes through it. + if (kind == DeliveryKind.memberRelay && ref != null && via == 0 && source != null) { + return Delivery(kind: DeliveryKind.jellyfin, direct: direct); + } + final state = relay == null + ? SourceState.ok + : kind == DeliveryKind.memberRelay && source == null + ? SourceState.left + : !relay.live + ? SourceState.waiting + : relay.slow + ? SourceState.slow + : SourceState.ok; + return Delivery( + kind: kind, + sourceId: sourceId, + sourceName: source?.name ?? names?[sourceId] ?? 'The member passing it on', + sourceIsSelf: sourceId.isNotEmpty && sourceId == selfId, + state: state, + rate: relay?.rate ?? 0, + need: relay?.need ?? 0, + viaServer: via, + direct: direct, + ); +} + +/// How [m] gets media of [kind] served by [sourceId]. Jellyfin: by its +/// last `report.jf` for [ref]'s server ([jellyfinRelay]: the server can +/// relay it); null before it reported. +DeliveryRoute? routeOf(MemberView m, DeliveryKind kind, String sourceId, JellyfinRef? ref, bool jellyfinRelay) { + if (sourceId.isNotEmpty && m.id == sourceId) return DeliveryRoute.source; + switch (kind) { + case DeliveryKind.link: + return DeliveryRoute.direct; + case DeliveryKind.hostFile: + case DeliveryKind.serverFile: + case DeliveryKind.serverFetch: + return DeliveryRoute.server; + case DeliveryKind.memberRelay: + case DeliveryKind.jellyfin: + final own = ref == null ? null : m.jf[ref.serverId]; + if (own == true) return DeliveryRoute.direct; + if (kind == DeliveryKind.memberRelay) return DeliveryRoute.server; + return own == false && jellyfinRelay ? DeliveryRoute.server : null; + } +} + +/// [m]'s route and buffer for [d] (from [deliveryOf] on [r]). +MemberDelivery memberDeliveryOf(MemberView m, StateBroadcast r, Delivery d) { + final counts = !m.out && !m.stale && m.error.isEmpty; + final need = r.bufferNeed; + final ref = refOfStateMedia(r.media); + final health = !counts + ? null + : m.buffered >= need + ? BufferHealth.good + : r.transport == 'playing' && m.buffered < 1 + ? BufferHealth.starving + : BufferHealth.low; + return MemberDelivery( + route: m.out ? null : routeOf(m, d.kind, d.sourceId, ref, r.relay), + kind: d.kind, + jellyfin: ref != null, + rate: m.rate, + health: health, + buffered: m.buffered, + ); +} + +/// What the source is told once per media when it is too slow; null +/// while it keeps up (or this member is not the source). +String? slowSourceWarning(StateBroadcast r, String selfId) { + final rel = r.media?['relay']; + if (rel is! Map || selfId.isEmpty) return null; + final relay = MediaRelay.fromJson(Map<String, Object?>.from(rel)); + if (relay.source != selfId || !relay.slow) return null; + return 'Your upload (${formatRate(relay.rate)}) is slower than this video needs ' + '(${formatRate(relay.need)}): the others will pause to buffer now and then.'; +} diff --git a/apps/desktop/lib/src/session/session_coordinator.dart b/apps/desktop/lib/src/session/session_coordinator.dart index df2fc3a..008a676 100644 --- a/apps/desktop/lib/src/session/session_coordinator.dart +++ b/apps/desktop/lib/src/session/session_coordinator.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'dart:math'; +import 'dart:typed_data'; import 'package:flutter/widgets.dart'; import 'package:signals/signals_flutter.dart'; @@ -23,6 +24,8 @@ import '../sync/avatar_cache.dart'; import '../sync/state_diff.dart'; import '../sync/sync_client.dart'; import '../sync/user_seek.dart'; +import '../queue/queue_controller.dart'; +import 'delivery.dart'; import 'health.dart'; import 'idle.dart'; import 'media_art.dart'; @@ -168,6 +171,10 @@ class SessionCoordinator { /// The app's yt-dlp and link probes (tests pass their own). final AppYtDlp ytDlp; final SyncClient sync = SyncClient(); + /// Up next: the server's queue, adding to it, and putting items up. + late final QueueController queue = QueueController(this); + /// The server has a password (it fetches links only then). + bool get serverHasPassword => (_params?.secret ?? '').isNotEmpty; /// Member pictures by `avatarHash`, fetched from the server. late final AvatarCache avatars = AvatarCache(sync); /// The current media's poster and banner by `state.media.art` hash. @@ -282,7 +289,7 @@ class SessionCoordinator { // This client's last swap, until a state shows it landed (a newer // mediaId than [after] with this member as `swapBy`): its art goes up then. // [link]: the web link it came from (a relayed one has no `url`). - ({HelloMedia media, int after, String? link})? _pendingArt; + ({HelloMedia media, int after, String? link, Uint8List? art})? _pendingArt; AniListClient? _aniList; // What this member serves for media [$1]; survives a reconnect (the // state then looks new again). @@ -291,6 +298,10 @@ class SessionCoordinator { int _relaySourceMediaId = -1; /// This member serves the current media's bytes through the server. final relaying = signal<bool>(false); + /// Names by member id, kept after they leave (a relay source that left). + final memberNames = MemberNames(); + // The media this member was told its upload is too slow for. + int _slowWarnedFor = -1; /// What this client put up last in this connection (a solo session /// carries it over when it becomes a hosted server). PickedMedia? get lastPick => _lastPick; @@ -390,6 +401,17 @@ class SessionCoordinator { _memberErrors = now; } + /// Remembers names, and tells this member once per media when it + /// passes the video on slower than the video plays. + void _warnSlowSource(StateBroadcast r) { + memberNames.observe(r); + if (_slowWarnedFor == r.mediaId) return; + final warning = slowSourceWarning(r, sync.memberId); + if (warning == null) return; + _slowWarnedFor = r.mediaId; + _pushToast(warning); + } + bool _reloadingVideo = false; /// "Reload video" (button, Ctrl+R, status menu): fresh @@ -502,6 +524,7 @@ class SessionCoordinator { } void _subscribe() { + queue.attach(); _stateSub ??= sync.states.listen(_onState); _feedSub ??= sync.feeds.listen(_onFeed); _nudgeSub ??= sync.nudges.listen(_onNudge); @@ -576,8 +599,9 @@ class SessionCoordinator { jellyfinPlan.peek() is! OwnPlay; if (waiting) { final who = r.members.where((m) => m.id == relay.source).firstOrNull?.name; + final gone = memberNames[relay.source] ?? 'the member who put this up'; health.value = HealthStatus(HealthSeverity.warn, - who == null ? 'the member who put this up left' : 'waiting for $who to pass the video on'); + who == null ? '$gone left: playing what the server has' : 'waiting for $who to pass the video on'); _relayWaitShown = true; } else if (_relayWaitShown) { _relayWaitShown = false; @@ -652,6 +676,7 @@ class SessionCoordinator { _lastStateAt = DateTime.now().toUtc(); serverState.value = r; _toastMemberErrors(r); + _warnSlowSource(r); _checkPlayerError(r.mediaId); _maybeSendArt(r); _queuedState = r; @@ -919,6 +944,10 @@ class SessionCoordinator { ToastItem(id: _toastId, text: text, at: DateTime.now().toUtc(), actor: f.actor) ]; } + // The server gave up fetching what everyone waits for. + if (f.action == 'fetch-fail' && _awaitLiveFor != -1 && _awaitLiveFor == serverState.peek()?.mediaId) { + health.value = const HealthStatus(HealthSeverity.error, "the server couldn't fetch this video"); + } final selfName = selfMember.peek()?.name; // Sat out by someone else: say who (the feed names members by name). if (f.action == 'out' && f.target != null && f.target == selfName && f.actor != selfName) { @@ -1259,12 +1288,23 @@ class SessionCoordinator { sync.sendNudge(memberId); } - void _swap(HelloMedia media, {String? link}) { - // Putting something up means watching it: back first. + /// [queueId]: the Up next item this puts up (the server takes it off + /// the queue); [art]: a picture to send as its art (a queued item's). + void _swap(HelloMedia media, {String? link, String? queueId, Uint8List? art}) { + // Putting something up means watching it: back first. Not for Up next + // coming up by itself: whoever puts it up may be sitting out. final self = selfMember.peek(); - if (self != null && self.out) sync.sendOut(self.id, false); - _pendingArt = (media: media, after: serverState.peek()?.mediaId ?? 0, link: link ?? media.url); - sync.sendSwap(media); + if (self != null && self.out && queueId == null) sync.sendOut(self.id, false); + _pendingArt = (media: media, after: serverState.peek()?.mediaId ?? 0, link: link ?? media.url, art: art); + sync.sendSwap(media, queueId: queueId); + } + + /// An Up next item put up as queued, for one this app cannot build + /// itself (a server-fetched link: the server has the URL); [art] goes up + /// as its art. + void swapQueued(HelloMedia media, String queueId, {Uint8List? art}) { + markInput(); + _swap(media, queueId: queueId, art: art); } void _maybeSendArt(StateBroadcast r) { @@ -1272,17 +1312,19 @@ class SessionCoordinator { if (pending == null || r.mediaId <= pending.after) return; if (sync.memberId.isEmpty || r.media?['swapBy'] != sync.memberId) return; _pendingArt = null; - unawaited(_sendArt(r.mediaId, pending.media, pending.link)); + unawaited(_sendArt(r.mediaId, pending.media, pending.link, given: pending.art)); } /// Fetches the art of [media] (this client's swap, now [mediaId], from /// web [link] if any) and sends it; quietly nothing on any failure. - Future<void> _sendArt(int mediaId, HelloMedia media, String? link) async { + Future<void> _sendArt(int mediaId, HelloMedia media, String? link, {Uint8List? given}) async { try { final ref = media.jellyfin; final client = ref == null ? null : jellyfin.clientFor(ref.serverId); final ArtBytes? bytes; - if (ref != null && client != null) { + if (given != null) { + bytes = (poster: given, banner: given); + } else if (ref != null && client != null) { bytes = await jellyfinArt(client, ref.itemId); } else if (ref == null && (link ?? '').isNotEmpty) { // A web link is not a show: never guess. A page's thumbnail is @@ -1312,13 +1354,14 @@ class SessionCoordinator { /// probe's [LinkProbe.problem], or unreachable) is refused with the /// reason in plain words unless [anyway]. Returns an error text, or null /// when sent. - Future<String?> swapLink(String url, {LinkRelay relay = LinkRelay.direct, bool anyway = false}) async { + Future<String?> swapLink(String url, + {LinkRelay relay = LinkRelay.direct, bool anyway = false, String? queueId}) async { markInput(); _lastPick = LinkMedia(url, relay: relay, anyway: anyway); final media = await mediaForLink(jellyfin, url); if (media.jellyfin != null) { _pendingOrigin = _JellyfinOrigin(url); - _swap(media.copyWith(relay: const MediaRelay())); + _swap(media.copyWith(relay: const MediaRelay()), queueId: queueId); return null; } LinkProbe? probe; @@ -1334,7 +1377,7 @@ class SessionCoordinator { final uri = Uri.tryParse(url) ?? Uri(); final name = probe?.title ?? (probe != null || hasMediaExtension(uri) || uri.host.isEmpty ? media.name : uri.host); if (relay == LinkRelay.direct) { - _swap(HelloMedia(name: name, size: 0, duration: 0, url: url)); + _swap(HelloMedia(name: name, size: 0, duration: 0, url: url), queueId: queueId); return null; } if (relay == LinkRelay.me) { @@ -1366,7 +1409,7 @@ class SessionCoordinator { } _pendingOrigin = _LinkOrigin(url, probe, y); _swap(HelloMedia( - name: name, size: size, duration: 0, relay: MediaRelay(tracks: specs)), link: url); + name: name, size: size, duration: 0, relay: MediaRelay(tracks: specs)), link: url, queueId: queueId); return null; } // The server reads it with its own yt-dlp; only a page this app knows @@ -1376,7 +1419,8 @@ class SessionCoordinator { return probe.relayProblem; } _swap(HelloMedia( - name: name, size: max(0, probe?.size ?? 0), duration: 0, url: url, relay: const MediaRelay(fetch: true))); + name: name, size: max(0, probe?.size ?? 0), duration: 0, url: url, relay: const MediaRelay(fetch: true)), + queueId: queueId); return null; } @@ -1390,43 +1434,44 @@ class SessionCoordinator { /// A Jellyfin pick (browser, next / previous episode) goes up as a ref, /// relayed by this member for viewers without an account. - void swapJellyfin(JellyfinMedia media) { + void swapJellyfin(JellyfinMedia media, {String? queueId}) { markInput(); _lastPick = media; _pendingOrigin = const _JellyfinOrigin(null); - _swap(mediaForPick(media).copyWith(relay: const MediaRelay())); + _swap(mediaForPick(media).copyWith(relay: const MediaRelay()), queueId: queueId); } /// A file on this computer. The host's app serves it itself; anyone /// else's goes through the server's relay. Returns an error text, or null /// when sent. - Future<String?> swapFile(String path) async { + Future<String?> swapFile(String path, {String? queueId}) async { markInput(); _lastPick = FileMedia(path); final host = hostFile; if (host != null) { final file = await host(path); if (file == null) return 'file not found — check the path'; - _swap(HelloMedia(name: file.name, size: file.size, duration: 0)); + _swap(HelloMedia(name: file.name, size: file.size, duration: 0), queueId: queueId); return null; } final file = File(path); if (!await file.exists()) return 'file not found — check the path'; _pendingOrigin = _FileOrigin(path); _swap(HelloMedia( - name: file.uri.pathSegments.last, size: await file.length(), duration: 0, relay: const MediaRelay())); + name: file.uri.pathSegments.last, size: await file.length(), duration: 0, relay: const MediaRelay()), + queueId: queueId); return null; } - /// Any picker selection. - Future<String?> swapPicked(PickedMedia media) async { + /// Any picker selection; [queueId] when it is an Up next item. + Future<String?> swapPicked(PickedMedia media, {String? queueId}) async { switch (media) { case LinkMedia(:final url, :final relay, :final anyway): - return swapLink(url, relay: relay, anyway: anyway); + return swapLink(url, relay: relay, anyway: anyway, queueId: queueId); case JellyfinMedia(): - swapJellyfin(media); + swapJellyfin(media, queueId: queueId); case FileMedia(:final path): - return swapFile(path); + return swapFile(path, queueId: queueId); } return null; } @@ -1541,11 +1586,18 @@ class SessionCoordinator { return relayUrlFor(_wsHost, _wsPort, sync.token, ref.itemId); } + /// `report.jf`: every linked account that works (so a queued Jellyfin + /// item finds someone to put it up while other media plays), then how + /// the current item plays here. Map<String, bool>? _jfAccessFor(StateBroadcast r) { + final linked = { + for (final a in jellyfin.accounts) + if (jellyfin.statusOf(a.serverId) != JellyfinAccountStatus.expired) a.serverId: true, + }; final ref = _jfRef; final access = jellyfinPlan.peek()?.jfAccess; - if (ref == null || access == null || r.mediaId != _jfMediaId) return null; - return {ref.serverId: access}; + if (ref == null || access == null || r.mediaId != _jfMediaId) return linked.isEmpty ? null : linked; + return {...linked, ref.serverId: access}; } /// Resolves [ref] for this viewer and opens it: own account (direct @@ -1720,7 +1772,9 @@ class SessionCoordinator { } nextPromptVisible.value = false; final next = nextEpisode.peek(); + // Up next goes first; "Clear" in it hides the next episode here. if (next == null || + !queue.continuationShown(r.mediaId) || !AppSettings.instance.autoAdvance.peek() || _advanceDoneFor == r.mediaId || autoAdvance.peek()?.mediaId == r.mediaId) { diff --git a/apps/desktop/lib/src/shell/app_shell.dart b/apps/desktop/lib/src/shell/app_shell.dart index 075f750..ab49944 100644 --- a/apps/desktop/lib/src/shell/app_shell.dart +++ b/apps/desktop/lib/src/shell/app_shell.dart @@ -420,7 +420,7 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin // --------------------------------------------------------------- handoff /// Nothing (a dialog, a popover) is over the shell. - bool get _onTop => !_handoffOpen && (ModalRoute.of(context)?.isCurrent ?? true); + bool get _onTop => !_handoffOpen && handoffHolds == 0 && (ModalRoute.of(context)?.isCurrent ?? true); /// Ctrl+V outside a text field (a focused field pastes on its own). bool _onKey(KeyEvent e) { @@ -673,6 +673,8 @@ class AppShellState extends State<AppShell> with SingleTickerProviderStateMixin ), jellyfinStart: _libraryStart, onPutUp: _putUp, + // Up next needs a server (a solo session has one). + onAddToQueue: online ? coord.queue.add : null, onOpenIntegrations: () => _openSettings(SettingsSection.integrations), ), ShellTab.schedule => SchedulePage( diff --git a/apps/desktop/lib/src/shell/avatar.dart b/apps/desktop/lib/src/shell/avatar.dart index 0d8b08d..7ec56dc 100644 --- a/apps/desktop/lib/src/shell/avatar.dart +++ b/apps/desktop/lib/src/shell/avatar.dart @@ -5,6 +5,7 @@ import 'package:signals/signals_flutter.dart'; import 'package:sync_relay_core/core.dart'; import '../screens/home/profile.dart'; +import '../session/delivery.dart'; import '../session/session_coordinator.dart'; import '../state/session_state.dart'; import 'motion.dart'; @@ -22,6 +23,9 @@ class AvatarInfo { /// Why their player cannot open the media (`members[].error`): an error /// mark; empty when it can. final String error; + /// How it gets the media and its buffer ([MemberDelivery.detail]), for + /// the hover; empty when unknown. + final String delivery; const AvatarInfo({ this.id = '', required this.name, @@ -35,6 +39,7 @@ class AvatarInfo { this.self = false, this.out = false, this.error = '', + this.delivery = '', }); } @@ -103,6 +108,7 @@ AvatarInfo memberAvatarInfo(MemberView m, StateBroadcast? r, SessionCoordinator final self = m.id.isNotEmpty && m.id == coord.sync.memberId; final relay = r == null ? null : stateMediaRelay(r); final playing = r?.media != null; + final delivery = r == null ? null : deliveryOf(r, selfId: coord.sync.memberId, names: coord.memberNames); return AvatarInfo( id: m.id, name: m.name, @@ -118,6 +124,7 @@ AvatarInfo memberAvatarInfo(MemberView m, StateBroadcast? r, SessionCoordinator self: self, out: m.out, error: m.error, + delivery: delivery == null ? '' : memberDeliveryOf(m, r!, delivery).detail, ); } @@ -304,9 +311,12 @@ class _AvatarStackState extends State<AvatarStack> { left: i * step, top: 0, child: Tooltip( - message: visible[i].error.isEmpty - ? visible[i].name - : "${visible[i].name} can't play this: ${visible[i].error}", + message: [ + visible[i].error.isEmpty + ? visible[i].name + : "${visible[i].name} can't play this: ${visible[i].error}", + if (visible[i].delivery.isNotEmpty) visible[i].delivery, + ].join('\n'), child: StatusAvatar(info: visible[i], size: size), ), ), diff --git a/apps/desktop/lib/src/shell/chat_panel.dart b/apps/desktop/lib/src/shell/chat_panel.dart index 47f2827..7a0bc62 100644 --- a/apps/desktop/lib/src/shell/chat_panel.dart +++ b/apps/desktop/lib/src/shell/chat_panel.dart @@ -237,6 +237,15 @@ class ActivityRow extends StatelessWidget { return (Icons.bedtime, Colors.blueGrey, who == null || who.isEmpty ? 'is sitting out' : 'sat $who out'); case 'back': return (Icons.login, Colors.green, 'joined back'); + case 'queue-add': + return (Icons.playlist_add_rounded, scheme.primary, 'added ${f.target ?? ''} to Up next'); + case 'queue-remove': + return (Icons.playlist_remove_rounded, Colors.grey, 'removed ${f.target ?? ''} from Up next'); + case 'queue-skip': + final why = f.reason.isEmpty ? '' : ' · ${f.reason}'; + return (Icons.redo_rounded, Colors.orange, 'skipped ${f.target ?? ''}$why'); + case 'fetch-fail': + return (Icons.cloud_off, Colors.red, "couldn't fetch ${f.target ?? 'the video'}"); default: final target = f.target == null || f.target!.isEmpty ? '' : ' ${f.target}'; return (Icons.info_outline, Colors.grey, '${f.action}$target'); diff --git a/apps/desktop/lib/src/shell/delivery_line.dart b/apps/desktop/lib/src/shell/delivery_line.dart new file mode 100644 index 0000000..5efc3ce --- /dev/null +++ b/apps/desktop/lib/src/shell/delivery_line.dart @@ -0,0 +1,176 @@ +import 'package:flutter/material.dart'; + +import '../session/delivery.dart'; +import 'motion.dart'; + +// The members card's view of [Delivery]: one source line on top, a buffer +// dot on each member. Nothing of it sits on the picture. + +Color _tone(SourceState s, ColorScheme scheme) => switch (s) { + SourceState.ok => scheme.primary, + SourceState.slow || SourceState.waiting => Colors.amber, + SourceState.left => Colors.redAccent.shade200, + }; + +IconData _icon(Delivery d) => switch (d.state) { + SourceState.left => Icons.link_off_rounded, + SourceState.waiting => Icons.hourglass_top_rounded, + _ => switch (d.kind) { + DeliveryKind.memberRelay => Icons.cell_tower, + DeliveryKind.serverFetch => Icons.cloud_download_outlined, + DeliveryKind.hostFile => Icons.folder_shared_outlined, + DeliveryKind.serverFile => Icons.dns_outlined, + DeliveryKind.link => Icons.public, + DeliveryKind.jellyfin => Icons.video_library_outlined, + }, + }; + +/// Where the current media comes from and how it reaches the others: +/// "Foxyz passes it on through the server", "3 through the server · +/// 4.1 MB/s, needs 2.3 MB/s" and a bar of the source's speed against the +/// video's (past the mark: keeping up). Amber when slow or waiting, red +/// when the source left. +class SourceLine extends StatelessWidget { + final Delivery delivery; + const SourceLine({super.key, required this.delivery}); + + @override + Widget build(BuildContext context) { + final d = delivery; + final scheme = Theme.of(context).colorScheme; + final tone = _tone(d.state, scheme); + final detail = d.detail; + final headroom = d.state == SourceState.left ? null : d.headroom; + return Tooltip( + message: d.explain, + waitDuration: const Duration(milliseconds: 400), + child: Container( + key: const ValueKey('source-line'), + margin: const EdgeInsets.fromLTRB(12, 0, 12, 6), + padding: const EdgeInsets.fromLTRB(10, 8, 12, 8), + decoration: BoxDecoration( + color: d.state == SourceState.ok ? scheme.surfaceContainerHigh : tone.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(10), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(top: 1), + child: Icon(_icon(d), size: 18, color: tone), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(d.title, style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600)), + if (detail.isNotEmpty) ...[ + const SizedBox(height: 2), + Text(detail, style: TextStyle(fontSize: 11.5, color: scheme.onSurfaceVariant)), + ], + if (headroom != null) ...[ + const SizedBox(height: 6), + _HeadroomBar(headroom: headroom, slow: d.state == SourceState.slow), + ], + ], + ), + ), + ], + ), + ), + ); + } +} + +/// The source's speed on a scale to twice the video's bitrate; the mark +/// in the middle is what the video needs. +class _HeadroomBar extends StatelessWidget { + final double headroom; + final bool slow; + const _HeadroomBar({required this.headroom, required this.slow}); + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + final fill = (headroom / 2).clamp(0.04, 1.0); + final color = slow ? Colors.amber : Colors.greenAccent.shade400; + return SizedBox( + key: const ValueKey('headroom-bar'), + height: 4, + child: LayoutBuilder( + builder: (context, box) => Stack( + children: [ + Positioned.fill( + child: DecoratedBox( + decoration: BoxDecoration( + color: scheme.outlineVariant.withValues(alpha: 0.4), + borderRadius: BorderRadius.circular(2), + ), + ), + ), + AnimatedContainer( + duration: easeTime, + curve: ease, + width: box.maxWidth * fill, + decoration: BoxDecoration(color: color, borderRadius: BorderRadius.circular(2)), + ), + Positioned( + left: box.maxWidth / 2 - 0.75, + top: -1, + bottom: -1, + child: Container(width: 1.5, color: scheme.onSurface.withValues(alpha: 0.7)), + ), + ], + ), + ), + ); + } +} + +/// A member's buffer at a glance: green enough to play on, amber low, +/// red empty while playing. +Color healthColor(BufferHealth h) => switch (h) { + BufferHealth.good => Colors.greenAccent.shade400, + BufferHealth.low => Colors.amber, + BufferHealth.starving => Colors.redAccent.shade200, + }; + +/// The members card's subtitle: [status] ("ready"), then how the member +/// gets the media (the source's own pill says it serves it), after a dot +/// coloured by its buffer; the hover says how much it has buffered. +class DeliveryStatus extends StatelessWidget { + final String status; + final MemberDelivery? delivery; + const DeliveryStatus({super.key, required this.status, this.delivery}); + + @override + Widget build(BuildContext context) { + final d = delivery; + final route = d == null || d.route == DeliveryRoute.source ? '' : d.short; + final text = Text([status, if (route.isNotEmpty) route].join(' · '), + maxLines: 1, overflow: TextOverflow.ellipsis); + if (d == null) return text; + final health = d.health; + return Tooltip( + message: d.detail, + waitDuration: const Duration(milliseconds: 400), + child: Row( + children: [ + if (health != null) ...[ + AnimatedContainer( + key: const ValueKey('health-dot'), + duration: easeTime, + curve: ease, + width: 7, + height: 7, + decoration: BoxDecoration(color: healthColor(health), shape: BoxShape.circle), + ), + const SizedBox(width: 6), + ], + Flexible(child: text), + ], + ), + ); + } +} diff --git a/apps/desktop/lib/src/shell/handoff.dart b/apps/desktop/lib/src/shell/handoff.dart index df70cbc..5767c24 100644 --- a/apps/desktop/lib/src/shell/handoff.dart +++ b/apps/desktop/lib/src/shell/handoff.dart @@ -9,6 +9,11 @@ import '../files/ui/file_tiles.dart' show baseName; // field: what it is ([classifyHandoff], pure) and the small card that asks // how to open it ([HandoffCard]). The shell wires both. +/// Open overlays over the shell that take drops and pastes themselves +/// (the Up next popover, which is not a route): the shell's own handoff +/// stays off while any is open. +int handoffHolds = 0; + /// What was dropped or pasted. sealed class Handoff { const Handoff(); diff --git a/apps/desktop/lib/src/shell/library_link.dart b/apps/desktop/lib/src/shell/library_link.dart index b3ccb76..ec2bd2b 100644 --- a/apps/desktop/lib/src/shell/library_link.dart +++ b/apps/desktop/lib/src/shell/library_link.dart @@ -7,6 +7,7 @@ import 'package:sync_relay_core/core.dart' show LinkProbe, hasMediaExtension, pa import '../playback/yt_dlp_app.dart'; import '../screens/home/media_picker.dart'; +import '../widgets/app_menu.dart'; import '../state/connect_prefs.dart'; import 'motion.dart'; @@ -217,12 +218,15 @@ class LibraryLinkSource extends StatefulWidget { final bool solo; /// Puts the link up; returns an error text, or null when sent. final Future<String?> Function(LinkMedia media) onPutUp; + /// Adds the link to Up next in the chosen mode; null: no server. + final AddToQueue? onAddToQueue; final RecentLinks recents; final LinkProber probe; const LibraryLinkSource({ super.key, required this.controller, required this.onPutUp, + this.onAddToQueue, required this.recents, this.solo = false, this.probe = defaultLinkProber, @@ -328,6 +332,49 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> { }); } + /// "Add to queue" (or, from a recent card's menu, [url] and [next]): + /// the link in the chosen mode; the probe refuses what will not play. + Future<void> _queue({String? url, bool next = false}) async { + final add = widget.onAddToQueue; + final link = url ?? _c.link.text.trim(); + if (add == null || link.isEmpty || _busy) return; + setState(() { + _busy = true; + _error = null; + _canForce = false; + }); + String? error; + try { + error = await add(LinkMedia(link, relay: widget.solo ? LinkRelay.direct : _c.linkRelay), next: next); + } catch (e) { + error = '$e'; + } + if (!mounted) return; + setState(() { + _busy = false; + _error = error; + }); + } + + /// Right-click on a recent link: put it up or queue it. + Future<void> _recentMenu(BuildContext context, RecentLink l) async { + final pick = await showAppMenu<String>(context, items: [ + AppMenuItem(value: 'up', label: widget.solo ? 'Play' : 'Put it up'), + if (widget.onAddToQueue != null) ...const [ + AppMenuItem(value: 'queue', label: 'Add to queue'), + AppMenuItem(value: 'next', label: 'Play next'), + ], + ]); + if (pick == null || !mounted) return; + _c.link.text = l.url; + switch (pick) { + case 'up': + await _go(); + case 'queue' || 'next': + await _queue(url: l.url, next: pick == 'next'); + } + } + Widget _modeCard((LinkRelay, IconData, String, String) m, {bool compact = false}) => _ModeCard( key: ValueKey('link-mode-${m.$1.name}'), icon: m.$2, @@ -372,6 +419,22 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> { shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), ), ); + final queue = widget.onAddToQueue == null + ? null + : Tooltip( + message: 'Plays after what is on now (right-click a recent link for Play next)', + waitDuration: const Duration(milliseconds: 600), + child: OutlinedButton.icon( + key: const ValueKey('link-add-queue'), + onPressed: url.isEmpty || _busy ? null : () => _queue(), + icon: const Icon(Icons.playlist_add_rounded), + label: const Text('Add to queue'), + style: OutlinedButton.styleFrom( + minimumSize: const Size(0, 56), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + ), + ), + ); return SingleChildScrollView( key: const ValueKey('media-link'), @@ -408,8 +471,14 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> { field, const SizedBox(height: 10), button, + if (queue != null) ...[const SizedBox(height: 8), queue], ]) - : Row(children: [Expanded(child: field), const SizedBox(width: 12), button]), + : Row(children: [ + Expanded(child: field), + const SizedBox(width: 12), + button, + if (queue != null) ...[const SizedBox(width: 8), queue], + ]), ), if (solo || _error != null) ...[ const SizedBox(height: 8), @@ -510,7 +579,8 @@ class _LibraryLinkSourceState extends State<LibraryLinkSource> { ? const SizedBox.shrink() : Padding( padding: const EdgeInsets.only(top: 32), - child: _RecentGrid(recents: recents, onPick: (l) => _c.link.text = l.url), + child: _RecentGrid( + recents: recents, onPick: (l) => _c.link.text = l.url, onMenu: _recentMenu), ), ), ], @@ -655,7 +725,9 @@ class _ModeCard extends StatelessWidget { class _RecentGrid extends StatelessWidget { final List<RecentLink> recents; final ValueChanged<RecentLink> onPick; - const _RecentGrid({required this.recents, required this.onPick}); + /// Right-click: Put it up / Add to queue / Play next. + final void Function(BuildContext context, RecentLink link) onMenu; + const _RecentGrid({required this.recents, required this.onPick, required this.onMenu}); @override Widget build(BuildContext context) { @@ -680,9 +752,11 @@ class _RecentGrid extends StatelessWidget { child: Tooltip( message: l.url, waitDuration: const Duration(milliseconds: 600), - child: InkWell( + // The card's own context: the menu opens at the card. + child: Builder(builder: (context) => InkWell( key: ValueKey('recent-link-${l.url}'), onTap: () => onPick(l), + onSecondaryTap: () => onMenu(context, l), borderRadius: BorderRadius.circular(12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -709,7 +783,7 @@ class _RecentGrid extends StatelessWidget { ), ], ), - ), + )), ), ), ], diff --git a/apps/desktop/lib/src/shell/library_tab.dart b/apps/desktop/lib/src/shell/library_tab.dart index def619b..3339d44 100644 --- a/apps/desktop/lib/src/shell/library_tab.dart +++ b/apps/desktop/lib/src/shell/library_tab.dart @@ -78,6 +78,8 @@ class LibraryTab extends StatefulWidget { final BrowseStart? jellyfinStart; /// Puts the pick up; returns an error text, or null when sent. final Future<String?> Function(PickedMedia media) onPutUp; + /// Adds a pick to Up next; null without a server (no queue actions). + final AddToQueue? onAddToQueue; final VoidCallback? onPutUpDone; final VoidCallback? onOpenIntegrations; /// Defaults to [RecentLinks.instance]. @@ -97,6 +99,7 @@ class LibraryTab extends StatefulWidget { required this.solo, required this.isHost, required this.onPutUp, + this.onAddToQueue, this.onPutUpDone, this.jellyfinStart, this.onOpenIntegrations, @@ -164,6 +167,13 @@ class _LibraryTabState extends State<LibraryTab> { /// A Jellyfin card or episode: a series goes up as its next episode. Future<String?> _putUpJellyfin(JellyfinSelection sel) async { + final pick = await _jellyfinPick(sel); + return pick is JellyfinMedia ? _put(pick) : pick as String; + } + + /// What a Jellyfin card stands for (a series: its next episode), or an + /// error text. + Future<Object> _jellyfinPick(JellyfinSelection sel) async { final account = widget.accounts.account(sel.serverId); final client = widget.accounts.clientFor(sel.serverId); if (account == null || client == null) return 'that Jellyfin server is not linked any more'; @@ -175,7 +185,23 @@ class _LibraryTabState extends State<LibraryTab> { return e.message; } if (item == null) return 'nothing to play in there'; - return _put(pickFor(account, item)); + return pickFor(account, item); + } + + /// "Add to queue" on a Jellyfin card, episode or the details panel. + Future<String?> _queueJellyfin(JellyfinSelection sel, {bool next = false}) async { + final add = widget.onAddToQueue; + if (add == null) return null; + final pick = await _jellyfinPick(sel); + return pick is JellyfinMedia ? add(pick, next: next) : pick as String; + } + + /// A card's menu: errors as a snack bar. + Future<void> _cardQueue(JellyfinSelection sel, {bool next = false}) async { + final error = await _queueJellyfin(sel, next: next); + if (error != null && mounted) { + ScaffoldMessenger.maybeOf(context)?.showSnackBar(SnackBar(content: Text(error))); + } } /// Double-click on a card: put it up, errors as a snack bar. @@ -235,6 +261,7 @@ class _LibraryTabState extends State<LibraryTab> { selected: _c.selected, onSelect: _c.select, onPutUp: _doublePutUp, + onAddToQueue: widget.onAddToQueue == null ? null : _cardQueue, onOpenIntegrations: widget.onOpenIntegrations, start: widget.jellyfinStart, libraryStrip: false, @@ -251,6 +278,7 @@ class _LibraryTabState extends State<LibraryTab> { recents: widget.recentLinks ?? RecentLinks.instance, probe: widget.probe, onPutUp: _put, + onAddToQueue: widget.onAddToQueue, ), MediaKind.file => _files(context), }; @@ -282,6 +310,7 @@ class _LibraryTabState extends State<LibraryTab> { selection: shown, solo: widget.solo, onPutUp: _putUpJellyfin, + onAddToQueue: widget.onAddToQueue == null ? null : _queueJellyfin, onClose: () => _c.select(null), ), )); @@ -348,7 +377,13 @@ class _LibraryTabState extends State<LibraryTab> { if (b != null) { return b(context, solo: widget.solo, relayed: relayed, onPlay: play, nowPlayingPath: now); } - return FilesPage(solo: widget.solo, relayed: relayed, onPlay: play, nowPlayingPath: now); + final add = widget.onAddToQueue; + return FilesPage( + solo: widget.solo, + relayed: relayed, + onPlay: play, + onAddToQueue: add == null ? null : (path, {next = false}) => add(FileMedia(path), next: next), + nowPlayingPath: now); }); } } diff --git a/apps/desktop/lib/src/shell/party_strip.dart b/apps/desktop/lib/src/shell/party_strip.dart index f681132..a19784c 100644 --- a/apps/desktop/lib/src/shell/party_strip.dart +++ b/apps/desktop/lib/src/shell/party_strip.dart @@ -7,13 +7,14 @@ import 'package:sync_relay_core/core.dart'; import 'package:window_manager/window_manager.dart'; import '../screens/home/update_indicator.dart'; -import '../screens/session/jellyfin_widgets.dart' show ViaHostBadge; +import '../session/delivery.dart'; import '../state/session_state.dart'; import '../update/update_controller.dart'; import '../widgets/app_keys.dart'; import '../widgets/app_menu.dart'; import '../widgets/window_buttons.dart'; import 'avatar.dart'; +import 'delivery_line.dart'; import 'motion.dart'; /// The shell's tabs, in strip order. @@ -35,10 +36,6 @@ const stripAvatarSize = 42.0; /// so the tabs keep their room. const stripCompactBelow = 1100.0; -/// The member plays the current Jellyfin item through the host's relay. -bool viaHostOf(MemberView m, JellyfinRef? ref) => - ref != null && m.jf[ref.serverId] == false; - /// The 60 px strip over everything: your avatar (profile), the tabs, and /// the party: offline Join a friend / Host, solo a "Solo" chip, Invite /// friends and Stop, online the avatars (members card), server and @@ -483,6 +480,9 @@ class MembersCard extends StatelessWidget { final address = hosting ? null : '${session.lastForm.host}:${session.lastForm.port}'; + final delivery = r == null + ? null + : deliveryOf(r, selfId: coord.sync.memberId, names: coord.memberNames); return Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, @@ -494,11 +494,12 @@ class MembersCard extends StatelessWidget { style: const TextStyle(fontWeight: FontWeight.w600), ), ), + if (delivery != null) SourceLine(delivery: delivery), for (final m in r?.members ?? const <MemberView>[]) _MemberTile( member: m, info: memberAvatarInfo(m, r, coord), - viaHost: viaHostOf(m, coord.currentRef.value), + delivery: delivery == null ? null : memberDeliveryOf(m, r!, delivery), ), if (r == null) Padding( @@ -540,11 +541,12 @@ class MembersCard extends StatelessWidget { class _MemberTile extends StatelessWidget { final MemberView member; final AvatarInfo info; - final bool viaHost; + /// How it gets the media and its buffer; null with nothing up or alone. + final MemberDelivery? delivery; const _MemberTile({ required this.member, required this.info, - required this.viaHost, + this.delivery, }); @override @@ -575,10 +577,9 @@ class _MemberTile extends StatelessWidget { if (info.host) const Pill('host', Colors.amber), if (info.relaying) const Pill('passing the video on', Colors.lightBlueAccent), - if (viaHost) const ViaHostBadge(), ], ), - subtitle: Text(status), + subtitle: DeliveryStatus(status: status, delivery: delivery), trailing: AppMenuButton<String>( tooltip: 'More', onSelected: (v) { diff --git a/apps/desktop/lib/src/shell/session_player.dart b/apps/desktop/lib/src/shell/session_player.dart index 9af1d59..fffbe10 100644 --- a/apps/desktop/lib/src/shell/session_player.dart +++ b/apps/desktop/lib/src/shell/session_player.dart @@ -14,6 +14,7 @@ import '../controls/uosc/buttons.dart'; import '../controls/uosc/controls.dart'; import '../controls/uosc/timeline.dart'; import '../controls/volume.dart'; +import '../queue/queue_ui.dart'; import '../playback/media_marks.dart' show SkipKind, SkipOffer, formatClock; import '../screens/session/jellyfin_widgets.dart'; import '../screens/session/player_controls.dart'; @@ -200,6 +201,12 @@ class _SessionPlayerState extends State<SessionPlayer> { // mpv's playlist keys (< >) and Shift+P / Shift+N: Jellyfin episodes. final shift = HardwareKeyboard.instance.isShiftPressed; if (event.character == '>' || (shift && key == LogicalKeyboardKey.keyN)) { + // Up next first, then the next episode. + if (coord.queue.items.peek().isNotEmpty) { + _lastKeyAt = now; + coord.queue.skip(); + return true; + } if (coord.nextEpisode.peek() == null) return false; _lastKeyAt = now; coord.playNextEpisode(); @@ -287,6 +294,7 @@ class _SessionPlayerState extends State<SessionPlayer> { _status(), _progressLine(), _jellyfinOverlay(), + _upNextChip(), if (widget.fullscreen) ...[if (!SessionState.instance.solo.value) _pausedParty(), _unreadChip()], ] else _miniOverlay(), @@ -678,6 +686,8 @@ class _SessionPlayerState extends State<SessionPlayer> { final blocked = coord.jellyfinBlocked.value; final next = coord.nextEpisode.value; final countdown = coord.autoAdvance.value; + // Up next goes before the next episode. + final upNext = coord.queue.countdown.value; final finished = coord.serverState.value?.transport == 'finished'; final pill = coord.nextPromptVisible.value; final SkipOffer? skip = coord.skipOffer.value; @@ -692,7 +702,16 @@ class _SessionPlayerState extends State<SessionPlayer> { onRetry: coord.retryJellyfin, ), ), - if (next != null && (finished || countdown != null)) + if (upNext != null && finished) + Positioned( + right: 24, + bottom: 138, + child: UpNextCard(queue: coord.queue, countdown: upNext), + ) + // "Clear" in Up next hides the next episode for this media. + else if (next != null && + (finished || countdown != null) && + coord.queue.shownContinuation.value.isNotEmpty) Positioned( right: 24, bottom: 138, @@ -737,6 +756,24 @@ class _SessionPlayerState extends State<SessionPlayer> { ); } + /// "Up next · <title>" top right, with the controls (and while its + /// popover is open); under the party in fullscreen. + Widget _upNextChip() { + final queue = _coord.queue; + return Positioned( + right: 16, + top: widget.fullscreen ? _barHeight + 60 : _topBarHeight + 8, + child: SignalBuilder( + builder: (context) => PillGate( + key: const ValueKey('up-next-gate'), + kind: 'up-next', + controlsShown: queue.popoverOpen.value ? const _AlwaysOn() : _controlsShown, + child: UpNextChip(queue: queue, onPlayNextEpisode: _coord.playNextEpisode), + ), + ), + ); + } + /// Fullscreen hides the strip: while paused the party stays in a corner. Widget _pausedParty() => Positioned( right: 16, @@ -908,6 +945,17 @@ class _SessionPlayerState extends State<SessionPlayer> { } } +/// Always up (a pill whose popover is open). +class _AlwaysOn implements ValueListenable<bool> { + const _AlwaysOn(); + @override + bool get value => true; + @override + void addListener(VoidCallback listener) {} + @override + void removeListener(VoidCallback listener) {} +} + /// A floating pill's visibility: only while the controls are up (the /// mouse moved) or the mouse is on it. With [announce] it is also up for /// [firstShown] whenever [kind] changes (a new offer: Skip OP, Skip 1:30). diff --git a/apps/desktop/lib/src/sync/sync_client.dart b/apps/desktop/lib/src/sync/sync_client.dart index b640f2e..bfe87b6 100644 --- a/apps/desktop/lib/src/sync/sync_client.dart +++ b/apps/desktop/lib/src/sync/sync_client.dart @@ -208,8 +208,13 @@ class SyncClient { _send(PingMessage(clientMs: _clock().millisecondsSinceEpoch).toJson()); } - void sendSwap(HelloMedia media) { - _send(SwapIntent(media: media).toJson()); + void sendSwap(HelloMedia media, {String? queueId}) { + _send(SwapIntent(media: media, queueId: queueId).toJson()); + } + + /// One change to Up next (see [QueueIntent]). + void sendQueue(QueueIntent intent) { + _send(intent.toJson()); } void sendReport({ diff --git a/apps/desktop/pubspec.lock b/apps/desktop/pubspec.lock index 4ffb3be..2b90c66 100644 --- a/apps/desktop/pubspec.lock +++ b/apps/desktop/pubspec.lock @@ -342,6 +342,13 @@ packages: url: "https://pub.dev" source: hosted version: "4.12.0" + leaf_widgets: + dependency: "direct main" + description: + path: "../../../leaf_widgets" + relative: true + source: path + version: "0.0.1" leak_tracker: dependency: transitive description: @@ -777,14 +784,14 @@ packages: path: "../../packages/core" relative: true source: path - version: "1.1.1" + version: "1.1.2" sync_relay_server: dependency: "direct main" description: path: "../../packages/server" relative: true source: path - version: "1.1.1" + version: "1.1.2" synchronized: dependency: transitive description: diff --git a/apps/desktop/pubspec.yaml b/apps/desktop/pubspec.yaml index 3a29eaa..abd927c 100644 --- a/apps/desktop/pubspec.yaml +++ b/apps/desktop/pubspec.yaml @@ -30,6 +30,11 @@ dependencies: git: url: https://git.senkochan.moe/Foxyz/fox_utils.git ref: v0.1.9 + # LeafPopover (the Up next popover). A local experiment: it must become a + # git dependency (like fox_utils) before a CI release, which cannot see + # this path. + leaf_widgets: + path: ../../../leaf_widgets dev_dependencies: flutter_test: sdk: flutter diff --git a/apps/desktop/test/delivery_test.dart b/apps/desktop/test/delivery_test.dart new file mode 100644 index 0000000..75aff2e --- /dev/null +++ b/apps/desktop/test/delivery_test.dart @@ -0,0 +1,141 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sync_relay_core/core.dart'; +import 'package:watch_together/src/session/delivery.dart'; +import 'package:watch_together/src/shell/delivery_line.dart'; + +MemberView _m(String id, String name, {double buffered = 30, bool host = false, int rate = 0}) => + MemberView(id: id, name: name, ready: true, inSet: true, buffered: buffered, stale: false, host: host, rate: rate); + +StateBroadcast _state(Map<String, Object?>? media, List<MemberView> members, {String transport = 'playing'}) => + StateBroadcast( + eventId: 1, + transport: transport, + position: 10, + serverTime: DateTime.now().toUtc(), + mediaId: 2, + media: media, + members: members, + ); + +Map<String, Object?> _relayed(MediaRelay relay) => + {'name': 'x.mkv', 'size': 0, 'duration': 1000, 'relay': relay.toJson()}; + +void main() { + final foxyz = _m('m1', 'Foxyz'); + final bob = _m('m2', 'Bob', rate: 3200000); + final ann = _m('m3', 'Ann'); + + test('a member relay: who passes it on, how many read it, how fast', () { + final r = _state( + _relayed(const MediaRelay(source: 'm1', live: true, rate: 4100000, need: 2300000)), [foxyz, bob, ann]); + final d = deliveryOf(r, selfId: 'm2')!; + expect(d.kind, DeliveryKind.memberRelay); + expect(d.state, SourceState.ok); + expect(d.title, 'Foxyz passes it on through the server'); + expect(d.detail, '2 through the server · 4.1 MB/s, needs 2.3 MB/s'); + expect(d.headroom, closeTo(4.1 / 2.3, 0.01)); + expect(deliveryOf(r, selfId: 'm1')!.title, 'You pass it on through the server'); + final mb = memberDeliveryOf(bob, r, d); + expect(mb.route, DeliveryRoute.server); + expect(mb.detail, 'via server · 3.2 MB/s · 30 s buffered'); + expect(memberDeliveryOf(foxyz, r, d).route, DeliveryRoute.source); + }); + + test('slow source: amber for everyone, the warning for the source only', () { + final r = _state( + _relayed(const MediaRelay(source: 'm1', live: true, rate: 1200000, need: 2300000)), [foxyz, bob]); + expect(deliveryOf(r, selfId: 'm2')!.state, SourceState.slow); + expect(deliveryOf(r, selfId: 'm2')!.title, "Foxyz's upload is slower than the video"); + expect(slowSourceWarning(r, 'm1'), contains('Your upload (1.2 MB/s) is slower than this video needs (2.3 MB/s)')); + expect(slowSourceWarning(r, 'm2'), isNull); + // Bitrate unknown: rates without a comparison, no warning. + final unknown = _state(_relayed(const MediaRelay(source: 'm1', live: true, rate: 1200000)), [foxyz, bob]); + expect(deliveryOf(unknown, selfId: 'm2')!.state, SourceState.ok); + expect(deliveryOf(unknown, selfId: 'm2')!.detail, '1 through the server · 1.2 MB/s'); + expect(slowSourceWarning(unknown, 'm1'), isNull); + }); + + test('the source left: named from before, no rates', () { + final names = MemberNames()..observe(_state(null, [foxyz, bob])); + final r = _state(_relayed(const MediaRelay(source: 'm1', rate: 4100000, need: 2300000)), [bob, ann]); + final d = deliveryOf(r, selfId: 'm2', names: names)!; + expect(d.state, SourceState.left); + expect(d.title, 'Foxyz left: playing what the server has'); + expect(d.detail, '2 through the server'); + }); + + test('channel down: waiting', () { + final r = _state(_relayed(const MediaRelay(source: 'm1')), [foxyz, bob]); + expect(deliveryOf(r, selfId: 'm2')!.title, 'Waiting for Foxyz to reconnect'); + }); + + test('links are direct, the host file is the host\'s, solo shows nothing', () { + final link = _state({'name': 'v', 'url': 'https://example.com/v.mp4'}, [foxyz, bob]); + final d = deliveryOf(link, selfId: 'm2')!; + expect(d.title, 'Everyone plays the link directly'); + expect(d.detail, isEmpty); + expect(memberDeliveryOf(bob, link, d).short, 'direct'); + final file = _state({'name': 'f.mkv'}, [_m('m1', 'Foxyz', host: true), bob]); + expect(deliveryOf(file, selfId: 'm2')!.title, 'Foxyz hosts the file'); + expect(deliveryOf(_state({'name': 'f.mkv'}, [bob]), selfId: 'm2'), isNull); + expect(deliveryOf(_state(null, [foxyz, bob]), selfId: 'm2'), isNull); + }); + + test('Jellyfin: own accounts direct, the rest via server', () { + const ref = {'serverId': 'srv', 'itemId': '0123456789abcdef0123456789abcdef', 'title': 'Pilot'}; + MemberView jf(String id, String name, bool own) => + MemberView(id: id, name: name, ready: true, inSet: true, buffered: 30, stale: false, jf: {'srv': own}); + final all = [jf('m1', 'Foxyz', true), jf('m2', 'Bob', true)]; + final media = {'name': 'Pilot', 'jellyfin': ref, 'relay': const MediaRelay(source: 'm1', live: true).toJson()}; + final d = deliveryOf(_state(media, all), selfId: 'm2')!; + expect(d.title, 'Everyone streams it from Jellyfin', reason: 'the relay stands by'); + final mixed = _state(media, [...all, jf('m3', 'Guest', false)]); + final m = deliveryOf(mixed, selfId: 'm2')!; + expect(m.kind, DeliveryKind.memberRelay); + expect(m.detail, '1 with their own account · 1 through the server'); + expect(memberDeliveryOf(jf('m2', 'Bob', true), mixed, m).short, 'own Jellyfin account'); + }); + + test('buffer health: enough, low, empty while playing', () { + final r = _state({'name': 'v', 'url': 'https://e/v', 'duration': 1000}, [foxyz, bob]); + final d = deliveryOf(r, selfId: 'm2')!; + expect(memberDeliveryOf(_m('a', 'A', buffered: 30), r, d).health, BufferHealth.good); + expect(memberDeliveryOf(_m('a', 'A', buffered: 2), r, d).health, BufferHealth.low); + expect(memberDeliveryOf(_m('a', 'A', buffered: 0), r, d).health, BufferHealth.starving); + final held = _state(r.media, [foxyz, bob], transport: 'held'); + expect(memberDeliveryOf(_m('a', 'A', buffered: 0), held, d).health, BufferHealth.low); + }); + + test('rates read as MB/s or kB/s', () { + expect(formatRate(4100000), '4.1 MB/s'); + expect(formatRate(12000000), '12 MB/s'); + expect(formatRate(850000), '850 kB/s'); + }); + + testWidgets('the source line and a member\'s status fit the members card', (tester) async { + final r = _state( + _relayed(const MediaRelay(source: 'm1', live: true, rate: 1200000, need: 2300000)), [foxyz, bob, ann]); + final d = deliveryOf(r, selfId: 'm2')!; + await tester.pumpWidget(MaterialApp( + theme: ThemeData.dark(), + home: Scaffold( + body: Center( + child: SizedBox( + width: 340, + child: Column(mainAxisSize: MainAxisSize.min, children: [ + SourceLine(delivery: d), + DeliveryStatus(status: 'ready', delivery: memberDeliveryOf(bob, r, d)), + ]), + ), + ), + ), + )); + await tester.pumpAndSettle(); + expect(find.text("Foxyz's upload is slower than the video"), findsOneWidget); + expect(find.byKey(const ValueKey('headroom-bar')), findsOneWidget); + expect(find.text('ready · via server · 3.2 MB/s'), findsOneWidget); + expect(find.byKey(const ValueKey('health-dot')), findsOneWidget); + expect(tester.takeException(), isNull); + }); +} diff --git a/apps/desktop/test/jellyfin_session_test.dart b/apps/desktop/test/jellyfin_session_test.dart index e857a07..b54e78a 100644 --- a/apps/desktop/test/jellyfin_session_test.dart +++ b/apps/desktop/test/jellyfin_session_test.dart @@ -12,9 +12,9 @@ import 'package:watch_together/src/jellyfin/session_media.dart'; import 'package:watch_together/src/jellyfin/track_prefs.dart'; import 'package:watch_together/src/screens/home/media_picker.dart'; import 'package:watch_together/src/screens/session/jellyfin_widgets.dart'; +import 'package:watch_together/src/session/delivery.dart'; import 'package:watch_together/src/session/session_coordinator.dart' show AutoAdvance; import 'package:watch_together/src/shell/library_tab.dart' show putUpJellyfinMedia; -import 'package:watch_together/src/shell/party_strip.dart' show viaHostOf; import 'jellyfin_accounts_test.dart'; import 'jellyfin_browser_test.dart' show app, setSize, settle; @@ -341,11 +341,13 @@ void main() { expect(compareMemberIds('m9', 'm10'), lessThan(0)); }); - test('member badge: via host only for relay viewers of the current item', () { - expect(viaHostOf(member('m1', 'G', jf: {fixtureServerId: false}), episodeRef), isTrue); - expect(viaHostOf(member('m1', 'G', jf: {fixtureServerId: true}), episodeRef), isFalse); - expect(viaHostOf(member('m1', 'G'), episodeRef), isFalse); - expect(viaHostOf(member('m1', 'G', jf: {fixtureServerId: false}), null), isFalse); + test('member route: via server only for relay viewers of the current item', () { + DeliveryRoute? route(MemberView m, {bool relay = true}) => + routeOf(m, DeliveryKind.jellyfin, '', episodeRef, relay); + expect(route(member('m1', 'G', jf: {fixtureServerId: false})), DeliveryRoute.server); + expect(route(member('m1', 'G', jf: {fixtureServerId: false}), relay: false), isNull); + expect(route(member('m1', 'G', jf: {fixtureServerId: true})), DeliveryRoute.direct); + expect(route(member('m1', 'G')), isNull); }); }); diff --git a/apps/desktop/test/queue_logic_test.dart b/apps/desktop/test/queue_logic_test.dart new file mode 100644 index 0000000..eb2dc63 --- /dev/null +++ b/apps/desktop/test/queue_logic_test.dart @@ -0,0 +1,75 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:sync_relay_core/core.dart'; +import 'package:watch_together/src/queue/queue_logic.dart'; + +MemberView member(String id, + {bool stale = false, bool out = false, Map<String, bool> jf = const {}, String name = ''}) => + MemberView( + id: id, name: name.isEmpty ? id : name, ready: false, inSet: true, buffered: 0, stale: stale, out: out, jf: jf); + +QueueItem item(QueueHow how, {String by = 'm2', String byName = 'Bob'}) => QueueItem( + id: 'q1', + media: how == QueueHow.jellyfin + ? const HelloMedia(name: 'ep', size: 0, duration: 0, jellyfin: JellyfinRef(serverId: 'S', itemId: 'i')) + : const HelloMedia(name: 'x', size: 0, duration: 0, url: 'https://x/y'), + how: how, + title: 'x', + by: by, + byName: byName, + ); + +void main() { + group('queuePerformer', () { + test('a link passed on or a file: only its adder; gone means skipped', () { + for (final how in [QueueHow.through, QueueHow.file]) { + expect(queuePerformer(item(how), [member('m1'), member('m2', out: true)]).id, 'm2'); + final gone = queuePerformer(item(how), [member('m1'), member('m3')]); + expect(gone.id, isNull); + expect(gone.why, 'Bob left'); + } + }); + + test('Jellyfin: the adder with access, else the lowest member with access, else skipped', () { + final access = {'S': true}; + expect(queuePerformer(item(QueueHow.jellyfin), [member('m1', jf: access), member('m2', jf: access)]).id, 'm2'); + expect( + queuePerformer(item(QueueHow.jellyfin), [member('m10', jf: access), member('m3', jf: access), member('m2')]) + .id, + 'm3'); + expect(queuePerformer(item(QueueHow.jellyfin), [member('m3', jf: access, out: true), member('m2')]).why, + isNotNull); + }); + + test('a link everyone opens or the server fetches: the adder, else the lowest active member', () { + for (final how in [QueueHow.direct, QueueHow.server]) { + expect(queuePerformer(item(how), [member('m1'), member('m2')]).id, 'm2'); + expect(queuePerformer(item(how), [member('m4'), member('m2', stale: true), member('m3')]).id, 'm3'); + final nobody = queuePerformer(item(how), [member('m2', out: true)]); + expect((nobody.id, nobody.why), (null, null), reason: 'nobody around is not a skip'); + } + }); + }); + + test('the janitor is the lowest member that is not stale', () { + expect(queueJanitor([member('m9'), member('m1', stale: true), member('m2')]), 'm2'); + }); + + test('warming starts in the last 90 s, once buffered to the end, or at the end', () { + bool due(double pos, {double buffered = 0, String t = 'playing'}) => + queueWarmDue(transport: t, position: pos, duration: 1000, bufferedTo: pos + buffered); + expect(due(100), isFalse); + expect(due(911), isTrue); + expect(due(100, buffered: 900), isTrue); + expect(due(0, t: 'finished'), isTrue); + expect(queueWarmDue(transport: 'playing', position: 5, duration: 0, bufferedTo: 5), isFalse); + }); + + test('labels', () { + expect(queueHowLabel(item(QueueHow.through), selfId: 'm2'), 'through you'); + expect(queueHowLabel(item(QueueHow.through), selfId: 'm1'), 'through Bob'); + expect(queueHowLabel(item(QueueHow.file), selfId: 'm1'), "Bob's file"); + expect(queueClock(3725), '1:02:05'); + expect(queueClock(65), '1:05'); + expect(queueClock(0), ''); + }); +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index efc1335..5f07303 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -571,7 +571,28 @@ How bytes move: token after a resume); a different size (or piece count and roles) drops that track's buffer. `state.media.relay.live` follows the channel, and viewers show "waiting - for <name> to pass the video on" while it is down. + for <name> to pass the video on" while it is down, "<name> left: + playing what the server has" once the source left the server. +- Stats (`relay_stats.dart`, `ServerHub.sampleRates` on the 1 s + heartbeat; wire in [PROTOCOL.md](PROTOCOL.md)): the source's rate is + its bytes over the time a pull is in flight (`FeedClock`, one per media + shared by its tracks, run by each buffer's pull), so a full buffer with + an idle source does not read as a slow one; smoothed over about 4 s of + that time (`SourceRate`). Its first-byte wait counts, so it reads a bit + under the uplink on short pulls. The need is the tracks' sizes over the + reported duration (pieces: their bytes per second once a minute of + segments is in). Each member's rate is its `bytesServed` change over + 5 s (`ReadRates`), which follows what its player asks for, not what its + link could take. Two significant digits keep `state` from changing with + every byte. The app (`session/delivery.dart`) derives the rest from the + state: the kind (link, host's file, server's file, member relay, server + fetch, Jellyfin), each member's route (source; via server: relayed, + `/m`, or Jellyfin `jf` false; direct: links, Jellyfin `jf` true) and + buffer health (enough for `bufferNeed`, low, empty while playing). The + members card (`shell/delivery_line.dart`) shows it as a source line and + a dot per member; nothing goes on the picture. The source gets one toast + per media while `rate` < `need`. Not shown: a direct member's speed + (the server never sees its bytes). Measured (mpv 0.41 viewers, real server and source, a shaped link between source and server; `tool/relay_e2e.dart`, 2-minute 1080p test @@ -727,8 +748,8 @@ Client side (`lib/src/jellyfin/session_media.dart`, `progress.dart`, here for <server>, and nobody is passing it on" with a button that opens Integrations over the session; linking (or reconnecting) the account retries by itself. A 401 marks the account expired. The member - list shows "via host" for members whose `jf` is false for the current - item. A reconnect to the same item with `OwnPlay` keeps playing as is. + list shows "via server" for members whose `jf` is false for the current + item (see "Relay", Stats). A reconnect to the same item with `OwnPlay` keeps playing as is. - **Progress** (`JellyfinProgressReporter`, `OwnPlay` only): Playing once the file is open and mpv has picked its tracks; Progress every 10 s and at once on pause, resume, a seek (position more than 3 s from where playback @@ -758,7 +779,9 @@ Client side (`lib/src/jellyfin/session_media.dart`, `progress.dart`, `state`; a late joiner never saw that swap), so clients agree without a wire change. Cancel is local: it stops the countdown on that screen, and the swapper's Cancel stops the swap; a seek by anyone ends `finished` - and with it every countdown. + and with it every countdown. Only while Up next is empty and its + continuation was not cleared (see "Up next"); a queued item replaces + a countdown already running. - **Tracks**: the controls' Audio and Subtitles buttons list `PlaybackController.tracks`. A pick is applied, reported, and saved as a `TrackChoice` (language, title, codec, channels, forced/HI: from the Jellyfin streams @@ -771,6 +794,86 @@ Client side (`lib/src/jellyfin/session_media.dart`, `progress.dart`, Link goes through `mediaForLink`, a Jellyfin pick as a ref, a file through `ServerIsolate.setFile` (host) or the relay (`swapFile`). +## Up next + +The queue: what plays after the current media. The server holds the list +(`EngineQueue` in `packages/core/lib/src/queue.dart`, in memory, part of +`state`; wire in [PROTOCOL.md](PROTOCOL.md), "Queue"), any member edits +it by item id (ops never clash), items outlive their adder. The app side +is `lib/src/queue/`: `QueueController` (owned by the coordinator, fed by +every `state` and feed), the pure rules in `queue_logic.dart`, the UI in +`queue_ui.dart`. + +- **Adding** (`QueueController.add`, from "Add to queue" anywhere or the + popover): a link is probed like a swap (`AppYtDlp.probe`) and refused + when it will not play, in the chosen mode ("Through me" also needs + something a relay can read, "Server fetches it" a password on the + server; alone it is direct). The item carries the title, a second line + (uploader or site, the show and episode, a file's parsed episode), the + length when known, and a picture (a page's thumbnail, a Jellyfin + episode still or backdrop, a file's frame from `thumbnailFor`; JPEG, + under the poster cap) sent as `thumb`. A link passed on "through me" + and a file go up as a `key` only: the app keeps the URL or path in + memory under it and nothing else leaves it. The adder gets a toast + "Added … to Up next"; everyone else an activity-log line. +- **Who puts an item up** (`queuePerformer`, from `state` alone, so all + clients agree): a link passed on or a file, only its adder (gone: + skipped, "Bob left"); a Jellyfin item, its adder when it plays that + server with its own account, else the lowest member id that does + (`jf`, which lists every working account; nobody: skipped); a link + everyone opens or the server fetches, its adder when present, else the + lowest active member id; for these, stale or sitting out counts as + away (an adder who is stale is waited for until it is gone). A skip is + a `remove` with the reason from the lowest member id that is not stale + (`queueJanitor`); the feed's `queue-skip` toasts on every screen. +- **Advancing**: at `finished` with Up next waiting, every screen shows + `UpNextCard` (picture, title, who added it and how; with auto-advance on + a 10 s countdown with Cancel, else Play now only); at zero the app that + puts the first item up does, with a swap naming it (`swap.queueId`), so + the server takes it off in the same step and a second swap of it is + ignored. Cancel is per screen like the next-episode countdown. "Play + now" anywhere (a row, the card, the chip's ▶, `>` / Shift+N) swaps + directly from the app that can, else sends `play` and that app reacts + to `state.queuePlay`. With nothing up at all, the first item goes up as + soon as it is added. The swap goes through the usual paths + (`swapPicked`, `swapJellyfin`; a server-fetched item through + `swapQueued`, without the URL, its picture as the art), so relays, art + and refusals work as for any swap; when it fails the item is skipped + with the reason. A swap without `queueId` (a manual one) leaves the + queue alone. A member who puts its own link or file up while sitting + out comes back first; any other queued swap leaves the swapper out. +- **Getting ready** (`queueWarmDue`): in the last 90 s of the current + media, once it is buffered to its end, or at `finished`, the app that + will put the first item up resolves its link (`AppYtDlp.probe`, kept 10 + minutes, which the swap then reads), so the swap does not wait for + yt-dlp. A Jellyfin item, a file or a server-fetched link has nothing to + get ready there. Only the first item, only that app, nothing on the + server. Its row shows a dot: hollow waiting, pulsing getting ready, + filled ready, red failed (tried again when it comes up). +- **Continues with**: not queued, computed here like the next episode: + with an own-account Jellyfin episode playing, the next three episodes + of the series (`/Shows/{id}/Episodes`, cached), greyed under Up next, + "+" adds one. "Clear" hides them for the media playing and keeps its + next-episode countdown from starting. +- **UI**: `UpNextChip` top right under the top bar (under the party in + fullscreen), shown with the controls like the pills (`PillGate`) and + while its popover is open: "Up next · title", a +n count, ▶ plays it + now; without Up next the continuation ("Next · S1E8 …", ▶ = next + episode); nothing when both are empty. Its title opens the queue as a + `LeafPopover` (package `leaf_widgets`, an overlay rather than a route): + frosted glass like the app menus; rows with a 16:9 picture, title, second + line, "length · added by (avatar) name · how", a drag handle + (`ReorderableListView`, reordered here at once and sent as `move`), ▶ + and × floating in on hover; "Continues with"; a "Paste a link to add" + field (focused on open, so the player's keys and Space rest while it + is up; Esc closes) and a drop zone for links and video files. Being no + route, it holds the shell's own drop and paste handling off while open + (`handoffHolds`). "Add to queue" elsewhere: the Link page's button + next to Put it up (in the chosen mode), a right-click menu on recent + links, Jellyfin cards, episodes and details-panel episodes, and Files + videos (Put it up / Add to queue / Play next), and a button in the + Jellyfin details panel (right-click: play next). None of it offline. + ## Desktop UI One window, `AppShell` (`lib/src/shell/`), whatever the connection state @@ -802,8 +905,9 @@ from `AppMenuStyle.theme`; the uosc controls keep their own flat menus. dialog; no avatars or chat), online the party: `AvatarStack` (host first, then ready; spreads on hover; 5 shown, 3 under 1100 px), the server name and "ready n/m" (hidden under 1100 px), chat with an unread badge, and Leave - (asks first). The stack opens the members card (status, host / relay - pills, "via host", "sitting out"; per member force ready, Nudge and Sit + (asks first). The stack opens the members card (the source line, then + per member status, route and buffer dot (see "Relay", Stats), host / + relay pills, "sitting out"; per member force ready, Nudge and Sit out (anyone, the host too), your own row Sit out / Join back; "Copy invite address": the dialled address when joined; hosting, `inviteAddresses` of `NetworkInterface.list` (IPv4, no loopback or link-local, private diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index e67af1f..9b2e8d6 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -1,4 +1,4 @@ -# Protocol (proto = 11) +# Protocol (proto = 12) Wire format only. Behavior (gate, clock, timeouts) is in [ARCHITECTURE.md](ARCHITECTURE.md). Source of truth: @@ -30,7 +30,7 @@ with different `proto` refuse each other at `hello` (`proto mismatch`). ## Client to server ``` -{"t":"hello","proto":11,"secret":"","user":{"name":"","avatar":""}, +{"t":"hello","proto":12,"secret":"","user":{"name":"","avatar":""}, "media":{"name":"","size":0,"duration":0,"url":null},"hostKey":"...", "resume":"<token>"} {"t":"ready","ready":true,"source":"manual"} source: manual | auto @@ -39,6 +39,7 @@ with different `proto` refuse each other at `hello` (`proto mismatch`). {"t":"swap","media":{"name":"","size":0,"duration":0,"url":"..."}} {"t":"swap","media":{"name":"","size":0,"duration":0,"jellyfin":{<ref>}}} {"t":"swap","media":{"name":"","size":123,"duration":0,"url":"...","relay":{}}} +{"t":"swap","media":{...},"queueId":"q3"} puts up an Up next item {"t":"report","position":123.1,"buffered":6.2,"eventId":41, "duration":1440.5,"mediaId":2,"jf":{"<serverId>":true},"error":"Not allowed (403)"} {"t":"ping","c":1767225600000} @@ -47,6 +48,7 @@ with different `proto` refuse each other at `hello` (`proto mismatch`). {"t":"nudge","target":"<memberId>"} {"t":"profile","name":"...","avatar":"<base64>"} {"t":"art","mediaId":2,"poster":"<base64>","banner":"<base64>"} +{"t":"queue","op":"add"|"move"|"remove"|"play"|"skip",...} see "Queue" ``` Field notes: @@ -74,7 +76,10 @@ Field notes: - `report.jf` (optional): Jellyfin access per `ServerId`, `true` = this client plays the item with its own account, `false` = through the relay. Sticky: a report without `jf` keeps the last one. At most 8 entries, keys - up to 64 characters; others are dropped. + up to 64 characters; others are dropped. The reference client lists + every server it has a working account on as `true`, then the current + item's server as it plays it (so a queued Jellyfin item finds someone + to put it up while other media plays). - `report.error` (optional, omitted when empty): short plain text saying why this client's player cannot open the media `report.mediaId` (e.g. `Not allowed (403)`). The client sends it in every report while that @@ -127,8 +132,8 @@ Field notes: "serverTime":"<ISO-8601 UTC>","mediaId":1, "media":{name,size,duration,url?,jellyfin?,relay?,swapBy?,art?}|absent, "members":[{"id","name","avatarHash"?,"ready","inSet","buffered","stale","jf"?, - "host"?,"out"?,"error"?}], - "seekBy":"m1","seekSeq":3,"relay":true} + "host"?,"out"?,"error"?,"rate"?}], + "seekBy":"m1","seekSeq":3,"relay":true,"queue":[<item>]?,"queuePlay":"q3"?} {"t":"pong","c":1767225600000,"s":1767225600012} {"t":"feed","actor":"","action":"","target":"","reason":"","at":"<ISO-8601>"} {"t":"chat","text":"...","from":"<name>"} @@ -161,17 +166,23 @@ Field notes: server's `hostKey` (see `hello.hostKey`). `members[].out` (omitted when false): the member sits out (see `out`). `members[].error` (omitted when empty): the member's player cannot open the current media (see - `report.error`). + `report.error`). `members[].rate` (omitted when 0): bytes per second + the server sent this member over the last 5 s (`/m`, `/j` and `/r` + together), two significant digits, under 10 kB/s counts as 0. Members + that fetch the media themselves read nothing from the server. - `pong.c` echoes the ping's `c`; `pong.s` is the server's clock in epoch ms (the clock `serverTime` uses). With `t3` the local receive time, the offset is `s - (c + t3) / 2`, accurate to half the round trip. - `feed.action` values: `ready`, `unready`, `force-ready`, `force-unready`, `force-play`, `force-hold`, `out`, `back`, `join`, `leave`, `stale`, `swap`, - `seek`, `pause`, `resume`, `finished`, `chat`, `rename`, `avatar`. `target` + `seek`, `pause`, `resume`, `finished`, `chat`, `rename`, `avatar`, + `queue-add`, `queue-remove`, `queue-skip`, `fetch-fail` (proto 12: the + server could not fetch the current media, `target` its name). `target` and `reason` are optional; `reason` carries `auto`/`manual` for ready and `silent too long` or `no signal` for leave/stale. `rename`: `actor` is the new name, `target` the old one. `avatar`: the actor changed only the - picture. + picture. `queue-*`: `target` is the item's title, `reason` (skip) why it + could not play (see "Queue"). - Chat is delivered twice: a `chat` frame with the text and a textless `feed` with `action:"chat"`. Render the `chat` frame only; the reference client drops the textless feed entry. @@ -207,8 +218,9 @@ state media.art = {"poster":"<hash>","banner":"<hash>"} - `media.art` carries hashes like `avatarHash`; a key is omitted when that picture is absent, `art` when both are. The same picture as poster and banner (one hash) is a web video's thumbnail. -- `GET /i/<sid>/<token>/<hash>`: as `/a/` (see "Avatars"); 404 when the - current media has no such art. +- `GET /i/<sid>/<token>/<hash>`: as `/a/` (see "Avatars"); also the + pictures of queued items (`queue[].thumb`). 404 when neither the current + media nor a queued item has it. ## Jellyfin media @@ -257,6 +269,7 @@ swap media.relay = {} the swapper's machine serves the bytes media.relay = {"fetch":true} the server reads media.url itself state media.relay = {"source":"m2","live":true} or {"fetch":true,"live":true} media.relay = {"fetch":true,"live":true,"tracks":[{"kind":"video"},{"kind":"audio"}]} + media.relay = {"source":"m2","live":true,"rate":4100000,"need":2300000} ``` Tracks: a relayed media is one file, or tracks that viewers play as one @@ -286,6 +299,15 @@ anything else means one file. channel is connected, or the server could read the link). `tracks`: as the swap named them, or, with `fetch`, set once the server has read the link (together with `live`). +- `state.media.relay.rate` (omitted while unknown): bytes per second the + source (with `fetch`, the server's own fetch) delivers while the buffer + is waiting on it, smoothed over about 4 s of that time and present once + 2 s of it are measured; it holds while the buffer is full and the + source idles. `need` (omitted while unknown): the media's average bytes + per second, the tracks' sizes over `media.duration` (a pieces track: + its segments in so far, after a minute of them). Both two significant + digits, updated once a second. `rate` under `need` means the source + cannot keep up; clients tell the source once per media. - `welcome.mediaUrl` for relayed media without `jellyfin`: this member's `/r/` URL; absent for the source itself (it plays its own copy) and for a fetched link the server has not read yet (clients open it on `live`). @@ -360,6 +382,59 @@ is a fresh `hello` (with `resume` = the old token), after which the client re-sends its prior ready flag. If the old member was still there (unclean drop), `resume` takes it over and the token and media URL stay the same. -## Reserved, not implemented +## Queue (proto 12) -Server-held queue (`queue-add`, `queue-skip`) would auto-advance like a swap. +Up next: what plays after the current media, held by the server for +everyone, in memory (a restart loses it). Any member edits it; items stay +when their adder leaves. Behavior (who puts an item up, the countdown, +skipping) is in [ARCHITECTURE.md](ARCHITECTURE.md), "Up next". + +``` +client {"t":"queue","op":"add","at":"end"|"next","item":{<item>},"thumb":"<base64>"} + {"t":"queue","op":"move","id":"q3","to":0} + {"t":"queue","op":"remove","id":"q3","why":"Bob left"} + {"t":"queue","op":"play","id":"q3"} + {"t":"queue","op":"skip"} + {"t":"swap","media":{...},"queueId":"q3"} +state "queue":[<item>,...], "queuePlay":"q3" +<item> = {"id":"q3","media":{<swap media>},"how":"direct"|"server"|"through"|"file"|"jellyfin", + "title":"...","sub":"...","duration":213,"thumb":"<hash>", + "by":"m2","byName":"Bob","key":"<adder's handle>"} +``` + +- `add`: `item` without `id`, `by`, `byName`, `thumb` (the server sets + them: `id` `q<n>`, unique for the server's life; who sent it and their + name then). `at` `next` puts it first, else last. `thumb` (optional): + base64 picture, checked and capped like a poster (256 KiB decoded); + `item.thumb` is its hash, served at `/i/` (see "Media art") while the + item is queued. `title` and `sub` are cleaned like media names and cut + to 200 characters (an empty title becomes the media's name); `duration` + (seconds, omitted when unknown) is informational. At most 100 items; + over that the sender gets `err` `Up next is full (100 items)`. +- `how` and what `media` must carry: + - `direct`: an http(s) `url`; everyone opens the link. + - `server`: an http(s) `url` and `relay` `{"fetch":true}`, on a server + with a password (as for a fetch swap). The server keeps the URL to + itself: the broadcast `media` has none. + - `through` (a link the adder passes on) / `file` (the adder's file): + `name` and `size` only, and `key` (8 to 64 of `[A-Za-z0-9_-]`): the + adder's own handle for the link or path, which never leave its app. + A `url` sent along is dropped. + - `jellyfin`: a `jellyfin` ref, checked like a swap's (any `media` with + a ref is `jellyfin`). + Anything else: `err` `bad message: ...`. +- `move`: `id` to index `to` (clamped). `remove`: drops `id`; with `why` + (cut to 120 characters) it is a skip (feed `queue-skip` with the + reason; clients show it), else feed `queue-remove`. `add` gives feed + `queue-add`. Unknown ids are ignored. +- `play`: moves `id` first and sets `state.queuePlay` to it: a request for + the member who can put it up to do so now. `skip` is `play` of the + first item. `queuePlay` ends with any swap, when the item is removed, + or after 30 s. +- `swap.queueId`: the swap puts that item up and takes it off the queue + in the same step. A swap naming an item that is no longer queued is + ignored (someone put it up first). For a `server` item the swap may + leave out `url`: the server uses the one it kept. A swap without + `queueId` leaves the queue as it is. +- `state.queue` (omitted when empty) and `state.queuePlay` (omitted when + none) ride in every state, `welcome.state` included. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 778b9df..6bc5083 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -12,7 +12,8 @@ off by deleting it in the same commit as the code. Wire changes also edit ever be skipped. 2. **Files**: next episode from Files (by parsed episode, with the - countdown). Later: tracking for file series. + countdown, and under Up next's "Continues with"). Later: tracking for + file series. 3. **yt-dlp follow-ups** (see ARCHITECTURE "yt-dlp"): check that the bundled Windows libmpv has the ytdl hook (Lua) and plays `edl://` URLs @@ -36,7 +37,10 @@ off by deleting it in the same commit as the code. Wire changes also edit filter on the Schedule from the user's AniList lists. This is the Schedule's personal tracking; AnimeFlow has no per-user follow. -5. **Ops**: check shaders on Windows; the relay e2e "source drops +5. **Ops**: `leaf_widgets` (the Up next popover's `LeafPopover`) is a + local path dependency: make it a git dependency like `fox_utils` + before the next release (CI cannot see the path), or go back to the + app's popover route. Check shaders on Windows; the relay e2e "source drops uncleanly and resumes" fails about half the time under the full suite. 6. **Windows: first real use.** CI builds and publishes @@ -52,8 +56,6 @@ off by deleting it in the same commit as the code. Wire changes also edit over instead of the buffer running dry. - "Bob can't open this link — relay it?": offer the relay to whoever put a direct link up when a viewer's player fails to open it. - - Show who is relaying and how fast in the member list; warn the source - when its upload is below the media's bitrate. 8. **Jellyfin** (epic, below). @@ -77,19 +79,22 @@ off by deleting it in the same commit as the code. Wire changes also edit - HTTP: move the app's clients (Jellyfin, AniList, AnimeFlow, updater) to dio from `fox_utils` when touching them, for retries/interceptors. - TLS: document a reverse proxy in front of the server. - - Playlist: `queue-add`, `queue-skip`, auto-advance like a swap. + - Up next follow-ups (see ARCHITECTURE "Up next"): getting the first + item ready only resolves its link; fetching its first seconds early + through the relay would make relayed starts instant too. A member + whose reconnect lands as a new member loses its "through me" links + and files in the queue (skipped as gone); the key could follow the + member. A whole season or a pick list at once ("Add the rest of the + season"). - Sidecar subtitles. ## Epic: Jellyfin What is left (the rest is in [ARCHITECTURE.md](ARCHITECTURE.md), "Jellyfin in a session"): -- Queue: put several library items (a season, a pick list) up as a - playlist that advances like a swap (shares `queue-add` / `queue-skip` - with the backlog's Playlist item). -- Auto-advance cancel for everyone: today Cancel is per screen (the - designated swapper's Cancel stops the swap; a seek stops all). A shared - cancel needs a wire message. +- Auto-advance cancel for everyone (the next episode and Up next): today + Cancel is per screen (the designated swapper's Cancel stops the swap; a + seek stops all). A shared cancel needs a wire message. - Sidecar (external) subtitle streams of Jellyfin items: only embedded tracks are mapped to mpv today. diff --git a/packages/core/lib/core.dart b/packages/core/lib/core.dart index 847b478..61142a1 100644 --- a/packages/core/lib/core.dart +++ b/packages/core/lib/core.dart @@ -4,6 +4,7 @@ export 'src/logging.dart'; export 'src/clock_sync.dart'; export 'src/config.dart'; export 'src/protocol.dart'; +export 'src/queue.dart'; export 'src/relay.dart'; export 'src/relay_pieces.dart'; export 'src/link_probe.dart'; diff --git a/packages/core/lib/src/constants.dart b/packages/core/lib/src/constants.dart index 833e695..f5686c0 100644 --- a/packages/core/lib/src/constants.dart +++ b/packages/core/lib/src/constants.dart @@ -1,5 +1,5 @@ abstract class RelayConstants { - static const int protoVersion = 11; + static const int protoVersion = 12; // Relay (`/r/...`): a member's bytes through the server's buffer. static const int relaySliceBytes = 1 << 20; @@ -56,4 +56,12 @@ abstract class RelayConstants { /// minute; the server drops the rest silently. static const int nudgesPerSec = 3; static const int nudgesPerMin = 20; + + /// Up next (`queue`): at most this many items; a title or second line + /// is cut to [maxQueueTextLength] characters; an item's picture is + /// capped like a poster ([maxPosterBytes]). A play request nobody acts on + /// ends after [queuePlayTimeoutSecs]. + static const int maxQueueItems = 100; + static const int maxQueueTextLength = 200; + static const int queuePlayTimeoutSecs = 30; } diff --git a/packages/core/lib/src/protocol.dart b/packages/core/lib/src/protocol.dart index 5435ea6..8386c85 100644 --- a/packages/core/lib/src/protocol.dart +++ b/packages/core/lib/src/protocol.dart @@ -1,5 +1,6 @@ import 'constants.dart'; import 'errors.dart'; +import 'queue.dart'; /// Reads an optional JSON number as a finite double. JSON like 1e400 decodes to /// Infinity, which would later break jsonEncode on every broadcast. @@ -179,12 +180,22 @@ class RelayTrackSpec { /// [tracks]: the tracks of a media relayed as several played together, or /// as one segmented playlist; empty for one file. A member swap names /// them, the server fills them in for [fetch] once it has read the link. +/// [rate] and [need] are the server's measures, 0 while unknown: bytes per +/// second the source (or the server's own fetch) delivers while the +/// buffer asks for bytes, and the media's average bitrate. class MediaRelay { final String source; final bool fetch; final bool live; final List<RelayTrackSpec> tracks; - const MediaRelay({this.source = '', this.fetch = false, this.live = false, this.tracks = const []}); + final int rate; + final int need; + const MediaRelay( + {this.source = '', this.fetch = false, this.live = false, this.tracks = const [], this.rate = 0, this.need = 0}); + + /// The source is measured slower than the media plays (both known): + /// below 85 % of [need], so a rate close to it does not flicker. + bool get slow => rate > 0 && need > 0 && rate < need * 0.85; static const trackKinds = {'video', 'audio'}; static const maxTracks = 4; @@ -193,14 +204,21 @@ class MediaRelay { static bool validTracks(List<RelayTrackSpec> t) => t.length <= maxTracks && (t.length > 1 || (t.length == 1 && t.single.pieces)); - MediaRelay copyWith({bool? live, List<RelayTrackSpec>? tracks}) => - MediaRelay(source: source, fetch: fetch, live: live ?? this.live, tracks: tracks ?? this.tracks); + MediaRelay copyWith({bool? live, List<RelayTrackSpec>? tracks, int? rate, int? need}) => MediaRelay( + source: source, + fetch: fetch, + live: live ?? this.live, + tracks: tracks ?? this.tracks, + rate: rate ?? this.rate, + need: need ?? this.need); Map<String, Object?> toJson() => { if (source.isNotEmpty) 'source': source, if (fetch) 'fetch': true, if (live) 'live': true, if (validTracks(tracks)) 'tracks': [for (final t in tracks) t.toJson()], + if (rate > 0) 'rate': rate, + if (need > 0) 'need': need, }; /// Unknown kinds, a single byte track or too many make it one file. @@ -212,8 +230,13 @@ class MediaRelay { source: json['source'] is String ? json['source'] as String : '', fetch: json['fetch'] == true, live: json['live'] == true, - tracks: ok ? List.unmodifiable(specs.cast<RelayTrackSpec>()) : const []); + tracks: ok ? List.unmodifiable(specs.cast<RelayTrackSpec>()) : const [], + rate: _byteRate(json['rate']), + need: _byteRate(json['need'])); } + + /// A bytes-per-second field: a finite non-negative number, else 0. + static int _byteRate(Object? v) => v is num && v.isFinite && v > 0 ? v.round() : 0; } class HelloMessage { @@ -412,15 +435,23 @@ class SeekIntent { class SwapIntent { final HelloMedia media; - const SwapIntent({required this.media}); + /// The Up next item this swap puts up (`queue[].id`); the server takes + /// it out of the queue with the swap, and ignores the swap when it is no + /// longer queued (someone else put it up first). + final String? queueId; + const SwapIntent({required this.media, this.queueId}); - Map<String, Object?> toJson() => {'t': 'swap', 'media': media.toJson()}; + Map<String, Object?> toJson() => + {'t': 'swap', 'media': media.toJson(), if (queueId != null) 'queueId': queueId}; factory SwapIntent.fromJson(Map<String, Object?> json) { if (json['t'] != 'swap') throw const ProtocolException('not a swap intent'); final media = json['media']; if (media is! Map) throw const ProtocolException('swap missing media'); - return SwapIntent(media: HelloMedia.fromJson(Map<String, Object?>.from(media))); + final queueId = json['queueId']; + return SwapIntent( + media: HelloMedia.fromJson(Map<String, Object?>.from(media)), + queueId: queueId is String && queueId.isNotEmpty ? queueId : null); } } @@ -600,6 +631,9 @@ class MemberView { /// Why its player cannot open the current media (`report.error`); empty /// while it can. Errored: outside the ready gate. final String error; + /// Bytes per second the server sent this member lately (its file, the + /// Jellyfin relay or the relay); 0 when none. + final int rate; const MemberView({ required this.id, required this.name, @@ -612,6 +646,7 @@ class MemberView { this.host = false, this.out = false, this.error = '', + this.rate = 0, }); Map<String, Object?> toJson() => { @@ -626,6 +661,7 @@ class MemberView { if (host) 'host': true, if (out) 'out': true, if (error.isNotEmpty) 'error': error, + if (rate > 0) 'rate': rate, }; factory MemberView.fromJson(Map<String, Object?> json) => MemberView( @@ -640,6 +676,7 @@ class MemberView { host: json['host'] == true, out: json['out'] == true, error: memberErrorFromJson(json['error']), + rate: MediaRelay._byteRate(json['rate']), ); } @@ -674,6 +711,11 @@ class StateBroadcast { /// The server can relay the current Jellyfin media (`/j/...`) for viewers /// without their own access to it. final bool relay; + /// Up next, in order (see `QueueItem`); empty when nothing is queued. + final List<QueueItem> queue; + /// The queued item someone asked to play now, waiting for the member + /// who can put it up; null when none. + final String? queuePlay; const StateBroadcast({ required this.eventId, required this.transport, @@ -685,6 +727,8 @@ class StateBroadcast { this.seekBy, this.seekSeq, this.relay = false, + this.queue = const [], + this.queuePlay, }); Map<String, Object?> toJson() => { @@ -699,6 +743,8 @@ class StateBroadcast { if (seekBy != null) 'seekBy': seekBy, if (seekSeq != null) 'seekSeq': seekSeq, if (relay) 'relay': true, + if (queue.isNotEmpty) 'queue': [for (final q in queue) q.toJson()], + if (queuePlay != null) 'queuePlay': queuePlay, }; factory StateBroadcast.fromJson(Map<String, Object?> json) { @@ -721,6 +767,10 @@ class StateBroadcast { seekBy: json['seekBy'] as String?, seekSeq: json['seekSeq'] == null ? null : finiteInt(json['seekSeq'], 'seekSeq'), relay: (json['relay'] as bool?) ?? false, + queue: json['queue'] is List + ? (json['queue'] as List).map(QueueItem.fromJson).whereType<QueueItem>().toList() + : const [], + queuePlay: json['queuePlay'] is String ? json['queuePlay'] as String : null, ); } } diff --git a/packages/core/lib/src/queue.dart b/packages/core/lib/src/queue.dart new file mode 100644 index 0000000..235fbf3 --- /dev/null +++ b/packages/core/lib/src/queue.dart @@ -0,0 +1,336 @@ +import 'dart:typed_data'; + +import 'constants.dart'; +import 'errors.dart'; +import 'protocol.dart'; +import 'sync_engine.dart'; + +// Up next: what plays after the current media, held by the server for +// everyone (see PROTOCOL.md "Queue"). Wire types, and [EngineQueue], the +// server's list (pure, so the engine and tests share it). + +/// How a queued item plays, and so who can put it up when it comes up. +enum QueueHow { + /// A link everyone opens themselves: anyone puts it up. + direct, + /// A link the server fetches: anyone puts it up (the server keeps the URL). + server, + /// A link the adder's app reads and passes on: only the adder. + through, + /// A file on the adder's computer: only the adder. + file, + /// A Jellyfin item: someone with an account on that server. + jellyfin; + + static QueueHow? parse(Object? v) { + for (final h in values) { + if (h.name == v) return h; + } + return null; + } + + /// Only the adder's app has what it needs (its file, its cookies). + bool get adderOnly => this == through || this == file; +} + +/// One entry of Up next as everyone sees it. [media] is what the swap +/// puts up, without a URL for [QueueHow.server], [QueueHow.through] and +/// [QueueHow.file] (the server, or the adder's app, keeps it). [key]: the +/// adder's own handle for what it keeps (adder-only items). +class QueueItem { + final String id; + final HelloMedia media; + final QueueHow how; + final String title; + /// A second line: the site, uploader or show; may be empty. + final String sub; + /// Seconds, 0 when unknown. + final double duration; + /// Picture hash, served at `/i/...`; empty when none. + final String thumb; + /// Member id and name of who added it (the name as it was then). + final String by; + final String byName; + final String key; + const QueueItem({ + this.id = '', + required this.media, + required this.how, + required this.title, + this.sub = '', + this.duration = 0, + this.thumb = '', + this.by = '', + this.byName = '', + this.key = '', + }); + + QueueItem copyWith({String? id, HelloMedia? media, String? title, String? sub, double? duration, + String? thumb, String? by, String? byName, String? key}) => + QueueItem( + id: id ?? this.id, + media: media ?? this.media, + how: how, + title: title ?? this.title, + sub: sub ?? this.sub, + duration: duration ?? this.duration, + thumb: thumb ?? this.thumb, + by: by ?? this.by, + byName: byName ?? this.byName, + key: key ?? this.key, + ); + + Map<String, Object?> toJson() => { + if (id.isNotEmpty) 'id': id, + 'media': media.toJson(), + 'how': how.name, + 'title': title, + if (sub.isNotEmpty) 'sub': sub, + if (duration > 0) 'duration': duration, + if (thumb.isNotEmpty) 'thumb': thumb, + if (by.isNotEmpty) 'by': by, + if (byName.isNotEmpty) 'byName': byName, + if (key.isNotEmpty) 'key': key, + }; + + /// Null when [j] is not an item this side can read. + static QueueItem? fromJson(Object? j) { + if (j is! Map) return null; + final media = j['media']; + final how = QueueHow.parse(j['how']); + if (media is! Map || how == null) return null; + final d = j['duration']; + return QueueItem( + id: j['id'] is String ? j['id'] as String : '', + media: HelloMedia.fromJson(Map<String, Object?>.from(media)), + how: how, + title: j['title'] is String ? j['title'] as String : '', + sub: j['sub'] is String ? j['sub'] as String : '', + duration: d is num && d.isFinite && d > 0 ? d.toDouble() : 0, + thumb: j['thumb'] is String ? j['thumb'] as String : '', + by: j['by'] is String ? j['by'] as String : '', + byName: j['byName'] is String ? j['byName'] as String : '', + key: j['key'] is String ? j['key'] as String : '', + ); + } +} + +/// Client to server: one change to Up next. [op] `add` (with [item], +/// [next] = right after what plays instead of at the end, [thumb] base64 +/// picture), `move` ([id] to index [to]), `remove` ([id]; [why] set when it +/// is skipped because it cannot play), `play` ([id] now) or `skip` (the +/// first item now). +class QueueIntent { + final String op; + final QueueItem? item; + final bool next; + final String thumb; + final String id; + final int to; + final String why; + const QueueIntent._(this.op, + {this.item, this.next = false, this.thumb = '', this.id = '', this.to = 0, this.why = ''}); + + const QueueIntent.add(QueueItem item, {bool next = false, String thumb = ''}) + : this._('add', item: item, next: next, thumb: thumb); + const QueueIntent.move(String id, int to) : this._('move', id: id, to: to); + const QueueIntent.remove(String id, {String why = ''}) : this._('remove', id: id, why: why); + const QueueIntent.play(String id) : this._('play', id: id); + const QueueIntent.skip() : this._('skip'); + + static const ops = {'add', 'move', 'remove', 'play', 'skip'}; + + Map<String, Object?> toJson() => { + 't': 'queue', + 'op': op, + if (item != null) 'item': item!.toJson(), + if (op == 'add') 'at': next ? 'next' : 'end', + if (thumb.isNotEmpty) 'thumb': thumb, + if (id.isNotEmpty) 'id': id, + if (op == 'move') 'to': to, + if (why.isNotEmpty) 'why': why, + }; + + factory QueueIntent.fromJson(Map<String, Object?> json) { + if (json['t'] != 'queue') throw const ProtocolException('not a queue intent'); + final op = json['op']; + if (op is! String || !ops.contains(op)) throw const ProtocolException('unknown queue op'); + final item = QueueItem.fromJson(json['item']); + if (op == 'add' && item == null) throw const ProtocolException('queue add needs an item'); + return QueueIntent._(op, + item: item, + next: json['at'] == 'next', + thumb: json['thumb'] is String ? json['thumb'] as String : '', + id: json['id'] is String ? json['id'] as String : '', + to: finiteInt(json['to'], 'to'), + why: json['why'] is String ? _cut(json['why'] as String, 120) : ''); + } +} + +String _cut(String s, int max) { + final line = s.replaceAll(RegExp(r'\s+'), ' ').trim(); + return line.length > max ? line.substring(0, max) : line; +} + +/// Display text of a queued item: one line, at most +/// [RelayConstants.maxQueueTextLength] characters. +String cleanQueueText(String s) => _cut(s, RelayConstants.maxQueueTextLength); + +class _Entry { + QueueItem item; + /// A server-fetched link's URL: never broadcast. + final String? privateUrl; + final ({String hash, Uint8List bytes})? thumb; + _Entry(this.item, this.privateUrl, this.thumb); +} + +/// The server's Up next: items in order with server-assigned ids, and at +/// most one pending [play] request (an item someone asked to play now, +/// waiting for the member who can put it up). In memory only. +class EngineQueue { + final List<_Entry> _items = []; + int _nextId = 1; + String? play; + DateTime? _playAt; + + List<QueueItem> get items => [for (final e in _items) e.item]; + bool get isEmpty => _items.isEmpty; + int get length => _items.length; + + int _index(String id) => _items.indexWhere((e) => e.item.id == id); + bool has(String id) => _index(id) >= 0; + QueueItem? item(String id) { + final i = _index(id); + return i < 0 ? null : _items[i].item; + } + + /// The URL a server-fetched item keeps to itself. + String? privateUrlOf(String id) { + final i = _index(id); + return i < 0 ? null : _items[i].privateUrl; + } + + /// Adds [item] (already cleaned) with a fresh id, at the end or, with + /// [next], first. Null when full ([RelayConstants.maxQueueItems]). + QueueItem? add(QueueItem item, {bool next = false, String? privateUrl, Uint8List? thumb}) { + if (_items.length >= RelayConstants.maxQueueItems) return null; + final t = thumb == null ? null : ArtImage(thumb); + final added = item.copyWith(id: 'q${_nextId++}', thumb: t?.hash ?? ''); + final e = _Entry(added, privateUrl, t == null ? null : (hash: t.hash, bytes: t.bytes)); + if (next) { + _items.insert(0, e); + } else { + _items.add(e); + } + return added; + } + + /// Moves [id] to [to] (clamped). False when unknown or unchanged. + bool move(String id, int to) { + final i = _index(id); + if (i < 0) return false; + final target = to.clamp(0, _items.length - 1); + if (target == i) return false; + final e = _items.removeAt(i); + _items.insert(target, e); + return true; + } + + /// Removes [id]; returns it, or null when unknown. + QueueItem? remove(String id) { + final i = _index(id); + if (i < 0) return null; + final e = _items.removeAt(i); + if (play == id) _clearPlay(); + return e.item; + } + + /// Someone asked to play [id] now: it moves first and waits for the + /// member who can put it up ([play]). False when unknown. + bool requestPlay(String id, DateTime now) { + final i = _index(id); + if (i < 0) return false; + if (i > 0) _items.insert(0, _items.removeAt(i)); + play = id; + _playAt = now; + return true; + } + + /// A swap happened: [id] (when it came from Up next) leaves the list, + /// and any pending play request is over. + void swapped(String? id) { + if (id != null) { + final i = _index(id); + if (i >= 0) _items.removeAt(i); + } + _clearPlay(); + } + + /// A play request nobody acted on ends after + /// [RelayConstants.queuePlayTimeoutSecs]. Returns whether it changed. + bool tick(DateTime now) { + final at = _playAt; + if (play == null || at == null) return false; + if (now.difference(at).inSeconds < RelayConstants.queuePlayTimeoutSecs) return false; + _clearPlay(); + return true; + } + + void _clearPlay() { + play = null; + _playAt = null; + } + + /// Bytes of an item's picture by hash. + Uint8List? thumbFor(String hash) { + for (final e in _items) { + if (e.thumb?.hash == hash) return e.thumb!.bytes; + } + return null; + } +} + +/// The `queue` messages applied to the engine's [SyncEngine.queue], with +/// their feed lines (`queue-add`, `queue-remove`, `queue-skip`). Each +/// returns whether the queue changed; [applyQueueAdd] an error text for the +/// sender instead (null when added). +extension EngineQueueOps on SyncEngine { + /// [item] is already cleaned by the server (see `cleanQueueAdd`). + String? applyQueueAdd(String token, QueueItem item, + {bool next = false, String? privateUrl, Uint8List? thumb}) { + final member = members[token]; + if (member == null) return null; + final added = queue.add(item.copyWith(by: member.id, byName: member.name), + next: next, privateUrl: privateUrl, thumb: thumb); + if (added == null) return 'Up next is full (${RelayConstants.maxQueueItems} items)'; + feed.add(FeedMessage(actor: member.name, action: 'queue-add', target: added.title, at: now)); + return null; + } + + bool applyQueueMove(String token, String id, int to) => + members.containsKey(token) && queue.move(id, to); + + /// [why] set: skipped because it cannot play (`queue-skip`, with the + /// reason), else removed by hand (`queue-remove`). + bool applyQueueRemove(String token, String id, {String why = ''}) { + final member = members[token]; + if (member == null) return false; + final gone = queue.remove(id); + if (gone == null) return false; + feed.add(FeedMessage( + actor: member.name, + action: why.isEmpty ? 'queue-remove' : 'queue-skip', + target: gone.title, + reason: why, + at: now)); + return true; + } + + /// `play` ([id]) or `skip` (the first item, [id] empty). + bool applyQueuePlay(String token, String id) { + if (!members.containsKey(token)) return false; + final target = id.isEmpty ? (queue.isEmpty ? null : queue.items.first.id) : id; + if (target == null || queue.play == target) return false; + return queue.requestPlay(target, now); + } +} diff --git a/packages/core/lib/src/sync_engine.dart b/packages/core/lib/src/sync_engine.dart index afcfce7..d36480b 100644 --- a/packages/core/lib/src/sync_engine.dart +++ b/packages/core/lib/src/sync_engine.dart @@ -5,6 +5,7 @@ import 'package:crypto/crypto.dart'; import 'constants.dart'; import 'protocol.dart'; +import 'queue.dart'; typedef SyncClock = DateTime Function(); @@ -59,6 +60,8 @@ class EngineMember { /// Its player cannot open the current media (`report.error`): outside /// the ready gate until a report clears it or the media changes. String error = ''; + /// Bytes per second the server sent it lately (set by the server). + int rate = 0; DateTime lastSeen; EngineMember({ required this.id, @@ -101,6 +104,7 @@ class EngineMember { host: host, out: out, error: error, + rate: rate, ); } @@ -154,6 +158,9 @@ class SyncEngine { /// ride in `state.media.art`. Every swap clears it. ArtImage? poster; ArtImage? banner; + /// Up next ([EngineQueue]): in `state.queue`, edited by `queue` + /// messages, taken from by swaps that name an item. + final EngineQueue queue = EngineQueue(); int _nextId = 1; // Who sent the seek that produced [_seekEventId], for the echo check. String? _seekBy; @@ -211,6 +218,7 @@ class SyncEngine { /// Heartbeat: stale sweep, end of media. void tick() { sweepStale(); + queue.tick(now); evaluate(); } @@ -272,7 +280,7 @@ class SyncEngine { if (hash.isEmpty) return null; if (poster?.hash == hash) return poster!.bytes; if (banner?.hash == hash) return banner!.bytes; - return null; + return queue.thumbFor(hash); } /// `state.media.art`; null when there is none. @@ -397,9 +405,11 @@ class SyncEngine { } /// Returns false (no state change) for media with non-finite numbers. - bool applySwap(String actorToken, HelloMedia next) { + /// [queueId]: the Up next item this swap puts up; it leaves the queue. + bool applySwap(String actorToken, HelloMedia next, {String? queueId}) { if (!next.duration.isFinite || next.size < 0) return false; final actor = members[actorToken]; + queue.swapped(queueId); mediaId++; media = next; swapBy = actorToken; @@ -544,6 +554,8 @@ class SyncEngine { seekBy: bySeek ? _seekBy : null, seekSeq: bySeek ? _seekSeq : null, relay: relay, + queue: queue.items, + queuePlay: queue.play, ); } } diff --git a/packages/core/test/queue_test.dart b/packages/core/test/queue_test.dart new file mode 100644 index 0000000..cd84b89 --- /dev/null +++ b/packages/core/test/queue_test.dart @@ -0,0 +1,121 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:sync_relay_core/core.dart'; +import 'package:test/test.dart'; + +void main() { + late DateTime t; + late SyncEngine engine; + + setUp(() { + t = DateTime.utc(2026, 1, 1); + engine = SyncEngine(clock: () => t); + engine.applyJoin('a', 'alice', ''); + engine.applyJoin('b', 'bob', ''); + }); + + QueueItem link(String title) => QueueItem( + media: HelloMedia(name: title, size: 0, duration: 0, url: 'https://example.com/$title'), + how: QueueHow.direct, + title: title); + + List<String> titles() => [for (final q in engine.queue.items) q.title]; + String idOf(String title) => engine.queue.items.firstWhere((q) => q.title == title).id; + + test('add at the end or next, with who added it, a feed line and stable ids', () { + engine.applyQueueAdd('a', link('one')); + engine.applyQueueAdd('b', link('two')); + engine.applyQueueAdd('b', link('first'), next: true); + expect(titles(), ['first', 'one', 'two']); + final one = engine.queue.items[1]; + expect((one.by, one.byName), ('m1', 'alice')); + final ids = {for (final q in engine.queue.items) q.id}; + expect(ids.length, 3); + final feed = engine.feed.drain().where((f) => f.action == 'queue-add').toList(); + expect([for (final f in feed) '${f.actor}:${f.target}'], ['alice:one', 'bob:two', 'bob:first']); + }); + + test('move and remove by id; unknown ids change nothing', () { + for (final s in ['a1', 'a2', 'a3']) { + engine.applyQueueAdd('a', link(s)); + } + expect(engine.applyQueueMove('b', idOf('a3'), 0), isTrue); + expect(titles(), ['a3', 'a1', 'a2']); + expect(engine.applyQueueMove('b', idOf('a3'), 99), isTrue); + expect(titles(), ['a1', 'a2', 'a3']); + expect(engine.applyQueueMove('b', 'q99', 0), isFalse); + expect(engine.applyQueueRemove('b', idOf('a2')), isTrue); + expect(engine.applyQueueRemove('b', 'q99'), isFalse); + expect(titles(), ['a1', 'a3']); + final skip = engine.applyQueueRemove('a', idOf('a1'), why: 'bob left'); + expect(skip, isTrue); + final lines = engine.feed.drain().where((f) => f.action.startsWith('queue-r') || f.action == 'queue-skip'); + expect([for (final f in lines) '${f.action}:${f.target}:${f.reason}'], + ['queue-remove:a2:', 'queue-skip:a1:bob left']); + }); + + test('a swap naming an item takes it out; any swap ends a play request', () { + engine.applyQueueAdd('a', link('one')); + engine.applyQueueAdd('a', link('two')); + expect(engine.applyQueuePlay('b', idOf('two')), isTrue); + expect(titles(), ['two', 'one']); + expect(engine.broadcast().queuePlay, idOf('two')); + engine.applySwap('a', const HelloMedia(name: 'manual', size: 0, duration: 0, url: 'https://x/y')); + expect(engine.queue.play, isNull); + expect(titles(), ['two', 'one'], reason: 'a manual swap leaves the queue alone'); + engine.applySwap('a', const HelloMedia(name: 'two', size: 0, duration: 0, url: 'https://x/two'), + queueId: idOf('two')); + expect(titles(), ['one']); + }); + + test('skip asks for the first item; a play request times out', () { + expect(engine.applyQueuePlay('a', ''), isFalse, reason: 'nothing queued'); + engine.applyQueueAdd('a', link('one')); + expect(engine.applyQueuePlay('a', ''), isTrue); + expect(engine.queue.play, idOf('one')); + t = t.add(const Duration(seconds: RelayConstants.queuePlayTimeoutSecs + 1)); + engine.tick(); + expect(engine.queue.play, isNull); + }); + + test('full at the cap', () { + for (var i = 0; i < RelayConstants.maxQueueItems; i++) { + expect(engine.applyQueueAdd('a', link('$i')), isNull); + } + expect(engine.applyQueueAdd('a', link('over')), contains('full')); + expect(engine.queue.length, RelayConstants.maxQueueItems); + }); + + test('thumbnails are served by hash while queued', () { + final png = Uint8List.fromList([0x89, 0x50, 0x4E, 0x47, 1, 2, 3]); + engine.applyQueueAdd('a', link('one'), thumb: png); + final hash = engine.queue.items.single.thumb; + expect(hash, isNotEmpty); + expect(engine.artFor(hash), png); + engine.applyQueueRemove('a', engine.queue.items.single.id); + expect(engine.artFor(hash), isNull); + }); + + test('state round trip keeps the queue and the play request', () { + engine.applyQueueAdd('a', link('one')); + engine.applyQueueAdd('b', QueueItem( + media: const HelloMedia(name: 'f', size: 3, duration: 0), + how: QueueHow.file, + title: 'f', + sub: 'S1E2', + duration: 1440, + key: 'abcdefgh12')); + engine.applyQueuePlay('a', idOf('f')); + final back = StateBroadcast.fromJson( + Map<String, Object?>.from(jsonDecode(jsonEncode(engine.broadcast().toJson())) as Map)); + expect([for (final q in back.queue) q.title], ['f', 'one']); + final f = back.queue.first; + expect((f.how, f.sub, f.duration, f.key, f.byName), (QueueHow.file, 'S1E2', 1440.0, 'abcdefgh12', 'bob')); + expect(back.queuePlay, f.id); + final intent = QueueIntent.fromJson(Map<String, Object?>.from( + jsonDecode(jsonEncode(QueueIntent.add(link('x'), next: true).toJson())) as Map)); + expect((intent.op, intent.next, intent.item!.title), ('add', true, 'x')); + expect(() => QueueIntent.fromJson(const {'t': 'queue', 'op': 'nope'}), throwsA(isA<ProtocolException>())); + }); +} diff --git a/packages/server/lib/server.dart b/packages/server/lib/server.dart index 44c3640..40b476b 100644 --- a/packages/server/lib/server.dart +++ b/packages/server/lib/server.dart @@ -6,3 +6,4 @@ export 'src/sync_server.dart'; export 'src/jellyfin_relay.dart'; export 'src/relay_buffer.dart'; export 'src/relay_routes.dart'; +export 'src/relay_stats.dart'; diff --git a/packages/server/lib/src/piece_buffer.dart b/packages/server/lib/src/piece_buffer.dart index 6761ce6..2273763 100644 --- a/packages/server/lib/src/piece_buffer.dart +++ b/packages/server/lib/src/piece_buffer.dart @@ -5,6 +5,7 @@ import 'dart:typed_data'; import 'package:sync_relay_core/core.dart'; import 'relay_buffer.dart'; +import 'relay_stats.dart'; final _log = createLogger('relay'); @@ -67,7 +68,18 @@ class PieceBuffer implements TrackBuffer { final _waiters = <int, List<Completer<void>>>{}; final _aux = <int, int>{}; // init/key piece a request waits on -> since (us) final _viewers = <String, _Viewer>{}; - _PiecePull? _pull; + @override + FeedClock clock = FeedClock(); + _PiecePull? _pullNow; + _PiecePull? get _pull => _pullNow; + set _pull(_PiecePull? p) { + if ((_pullNow == null) != (p == null)) p == null ? clock.stop() : clock.start(); + _pullNow = p; + } + + /// The track's average bytes per second from the segments in so far; + /// null before a minute of it has arrived. + double? get bytesPerSec => _knownSecs >= 60 ? _knownBytes / _knownSecs : null; DateTime? _retryAt; var _disposed = false; Completer<void> _kick = Completer(); diff --git a/packages/server/lib/src/queue_ops.dart b/packages/server/lib/src/queue_ops.dart new file mode 100644 index 0000000..0d66224 --- /dev/null +++ b/packages/server/lib/src/queue_ops.dart @@ -0,0 +1,93 @@ +import 'package:relic/relic.dart'; +import 'package:sync_relay_core/core.dart'; + +import 'server_hub.dart'; + +// `queue` messages (Up next, see PROTOCOL.md "Queue"): what an added item +// may carry, and the ops applied to the engine's queue. + +final _key = RegExp(r'^[A-Za-z0-9_-]{8,64}$'); + +bool _httpUrl(String? url) { + final u = Uri.tryParse(url ?? ''); + return u != null && (u.isScheme('http') || u.isScheme('https')) && u.host.isNotEmpty; +} + +/// An added item as the server keeps it: names cleaned like a swap's, +/// how it plays checked against what it carries, a server-fetched link's +/// URL taken out ([privateUrl]: never broadcast). Throws +/// [ProtocolException] with the reason for the sender. +({QueueItem item, String? privateUrl}) cleanQueueAdd(QueueItem raw, {required bool serverHasPassword}) { + final media = raw.media; + var name = sanitizeMediaName(media.name); + final ref = media.jellyfin; + final QueueItem item; + String? privateUrl; + if (ref != null || raw.how == QueueHow.jellyfin) { + final clean = ref == null ? null : sanitizeJellyfinRef(ref); + if (clean == null) throw const ProtocolException('bad queue item'); + if (name.isEmpty) name = clean.title; + item = QueueItem( + media: HelloMedia(name: name, size: 0, duration: 0, jellyfin: clean), how: QueueHow.jellyfin, title: ''); + } else { + switch (raw.how) { + case QueueHow.direct: + if (!_httpUrl(media.url)) throw const ProtocolException('a queued link needs an http(s) url'); + item = QueueItem(media: HelloMedia(name: name, size: 0, duration: 0, url: media.url), how: raw.how, title: ''); + case QueueHow.server: + if (!_httpUrl(media.url)) throw const ProtocolException('relay fetch needs an http(s) url'); + if (!serverHasPassword) throw const ProtocolException('server fetch needs a server password'); + privateUrl = media.url; + item = QueueItem( + media: HelloMedia(name: name, size: 0, duration: 0, relay: const MediaRelay(fetch: true)), + how: raw.how, + title: ''); + case QueueHow.through: + case QueueHow.file: + if (!_key.hasMatch(raw.key)) throw const ProtocolException('a queued item of yours needs a key'); + item = QueueItem( + media: HelloMedia(name: name, size: media.size < 0 ? 0 : media.size, duration: 0), + how: raw.how, + title: '', + key: raw.key); + case QueueHow.jellyfin: + throw const ProtocolException('bad queue item'); + } + } + var title = cleanQueueText(sanitizeMediaName(raw.title)); + if (title.isEmpty) title = cleanQueueText(item.media.name.isEmpty ? 'media' : item.media.name); + return ( + item: item.copyWith( + title: title, sub: cleanQueueText(sanitizeMediaName(raw.sub)), duration: raw.duration), + privateUrl: privateUrl, + ); +} + +/// Applies one `queue` message from [token]; a refused add answers the +/// sender with `err` (the socket stays open). +void handleQueueIntent(ServerHub hub, String token, RelicWebSocket ws, Map<String, Object?> decoded) { + final intent = QueueIntent.fromJson(decoded); + final engine = hub.engine; + var changed = false; + switch (intent.op) { + case 'add': + final cleaned = cleanQueueAdd(intent.item!, serverHasPassword: hub.session.secret.isNotEmpty); + final thumb = intent.thumb.isEmpty ? null : decodePicture(intent.thumb, RelayConstants.maxPosterBytes); + final error = engine.applyQueueAdd(token, cleaned.item, + next: intent.next, privateUrl: cleaned.privateUrl, thumb: thumb); + if (error != null) { + wsErr(ws, error); + return; + } + changed = true; + case 'move': + changed = engine.applyQueueMove(token, intent.id, intent.to); + case 'remove': + changed = engine.applyQueueRemove(token, intent.id, why: intent.why); + case 'play': + changed = engine.applyQueuePlay(token, intent.id); + case 'skip': + changed = engine.applyQueuePlay(token, ''); + } + if (changed) hub.changed(); +} diff --git a/packages/server/lib/src/relay_buffer.dart b/packages/server/lib/src/relay_buffer.dart index 77b4827..47cc3bc 100644 --- a/packages/server/lib/src/relay_buffer.dart +++ b/packages/server/lib/src/relay_buffer.dart @@ -5,6 +5,8 @@ import 'dart:typed_data'; import 'package:sync_relay_core/core.dart'; +import 'relay_stats.dart'; + final _log = createLogger('relay'); /// One track's buffer on the server: a byte stream ([RelayBuffer]) or @@ -16,6 +18,9 @@ abstract interface class TrackBuffer { int get cachedBytes; int get bytesFromFeed; int get bytesToViewers; + /// Runs while this buffer has a pull in flight; the relay gives its + /// tracks one, for the source's rate. + abstract FeedClock clock; /// Waits for the feed's first connect, up to the wait limit. Future<bool> ready(); void detach(RelayFeed feed); @@ -162,7 +167,15 @@ class RelayBuffer implements TrackBuffer { final _waiters = <int, List<Completer<void>>>{}; final _blocked = <int, int>{}; // slice -> wait start (us) final _viewers = <String, _Viewer>{}; - _Pull? _pull; + @override + FeedClock clock = FeedClock(); + _Pull? _pullNow; + _Pull? get _pull => _pullNow; + set _pull(_Pull? p) { + if ((_pullNow == null) != (p == null)) p == null ? clock.stop() : clock.start(); + _pullNow = p; + } + var _reqSeq = 0; var _disposed = false; Completer<void> _kick = Completer(); diff --git a/packages/server/lib/src/relay_routes.dart b/packages/server/lib/src/relay_routes.dart index 941729d..962ab73 100644 --- a/packages/server/lib/src/relay_routes.dart +++ b/packages/server/lib/src/relay_routes.dart @@ -10,6 +10,7 @@ import 'package:web_socket/web_socket.dart'; import 'media_endpoint.dart'; import 'piece_buffer.dart'; import 'relay_buffer.dart'; +import 'relay_stats.dart'; import 'session.dart'; final _log = createLogger('relay'); @@ -56,9 +57,39 @@ class ActiveRelay { var _local = const <LocalRelayFeed>[]; RelicWebSocket? _channel; var _channelFeeds = const <ChannelRelayFeed>[]; + /// Shared by the tracks: time any of them waits on the source. + final clock = FeedClock(); + final _rate = SourceRate(); ActiveRelay._(this.mediaId, this.name, this.sourceToken, this.onLiveChanged); + /// Takes the bytes the source sent so far, for [rate]; the server calls + /// it once a second. + void sampleRate() => _rate.sample(buffers.fold(0, (a, b) => a + b.bytesFromFeed), clock.busy); + + /// Bytes per second the source delivers while the buffer asks for bytes + /// (`media.relay.rate`); 0 until measured. + int get rate => _rate.rate; + + /// The media's average bytes per second for a [duration] in seconds + /// (`media.relay.need`): byte tracks by size, pieces tracks from the + /// pieces in so far; 0 while any track is unknown. + int need(double duration) { + if (buffers.isEmpty) return 0; + var total = 0.0; + for (final b in buffers) { + if (b is RelayBuffer) { + if (b.size <= 0 || duration <= 0) return 0; + total += b.size / duration; + } else if (b is PieceBuffer) { + final r = b.bytesPerSec; + if (r == null) return 0; + total += r; + } + } + return roundRate(total); + } + int _nextId() => ++_ids; void _setTracks(List<RelayTrackSpec> tracks) { @@ -69,6 +100,9 @@ class ActiveRelay { for (final t in this.tracks) t.pieces ? PieceBuffer(nextId: _nextId, rate: _piecesRate(t.kind)) : RelayBuffer(nextId: _nextId), ]; + for (final b in buffers) { + b.clock = clock; + } if (!_known.isCompleted) _known.complete(); } diff --git a/packages/server/lib/src/relay_stats.dart b/packages/server/lib/src/relay_stats.dart new file mode 100644 index 0000000..fb0a1a9 --- /dev/null +++ b/packages/server/lib/src/relay_stats.dart @@ -0,0 +1,103 @@ +/// How fast bytes move through the server, coarse enough for `state` +/// (sent every second): the relay source's delivery rate and each +/// member's read rate. +library; + +/// [bytesPerSec] kept to two significant digits (under 10 kB/s: 0), so +/// the state does not change with every byte. +int roundRate(double bytesPerSec) { + if (!bytesPerSec.isFinite || bytesPerSec < 10000) return 0; + var unit = 1; + while (bytesPerSec / unit >= 100) { + unit *= 10; + } + return (bytesPerSec / unit).round() * unit; +} + +/// Time a relay's feed had at least one pull in flight. The tracks of a +/// media share one (they share the source's uplink), so its time counts +/// once however many tracks pull at the same moment. +class FeedClock { + final Stopwatch _watch; + var _open = 0; + FeedClock([Stopwatch? watch]) : _watch = watch ?? Stopwatch(); + + void start() { + if (_open++ == 0) _watch.start(); + } + + void stop() { + if (_open == 0) return; + if (--_open == 0) _watch.stop(); + } + + Duration get busy => _watch.elapsed; +} + +/// The source's delivery rate: bytes over the time a pull was in flight, +/// so a buffer that is full (the source idles) does not read as a slow +/// source. Smoothed over about [window] of busy time; null until +/// [settle] of busy time has been measured. +class SourceRate { + final Duration window; + final Duration settle; + SourceRate({this.window = const Duration(seconds: 4), this.settle = const Duration(seconds: 2)}); + + int _bytes = 0; + Duration _busy = Duration.zero; + Duration _measured = Duration.zero; + double? _rate; + + /// Takes the feed's totals now: all bytes so far, all busy time so far. + /// Gaps under a quarter second of busy time wait for the next sample + /// (a pull's first-byte wait would dominate them). + void sample(int bytes, Duration busy) { + final db = bytes - _bytes; + final dt = busy - _busy; + if (db < 0 || dt < Duration.zero) { + // Totals went back (a new feed): start over. + _bytes = bytes; + _busy = busy; + return; + } + if (dt < const Duration(milliseconds: 250)) return; + _bytes = bytes; + _busy = busy; + final secs = dt.inMicroseconds / 1e6; + final now = db / secs; + final r = _rate; + final w = (secs / (window.inMicroseconds / 1e6)).clamp(0.0, 1.0); + _rate = r == null ? now : r + (now - r) * w; + _measured += dt; + } + + /// Bytes per second, rounded ([roundRate]); 0 while not settled. + int get rate => _measured < settle || _rate == null ? 0 : roundRate(_rate!); +} + +/// Each member's read rate from the server: the change of its +/// `bytesServed` over the last [window]. +class ReadRates { + final Duration window; + ReadRates({this.window = const Duration(seconds: 5)}); + + final _seen = <String, List<(DateTime, int)>>{}; + + /// Records [served] totals by token at [now]; returns each token's rate + /// (rounded). Tokens not in [served] are forgotten. + Map<String, int> sample(DateTime now, Map<String, int> served) { + _seen.removeWhere((t, _) => !served.containsKey(t)); + final out = <String, int>{}; + for (final e in served.entries) { + final list = _seen.putIfAbsent(e.key, () => []); + list.add((now, e.value)); + while (list.length > 1 && now.difference(list[1].$1) >= window) { + list.removeAt(0); + } + final first = list.first; + final secs = now.difference(first.$1).inMicroseconds / 1e6; + out[e.key] = secs <= 0 ? 0 : roundRate((e.value - first.$2) / secs); + } + return out; + } +} diff --git a/packages/server/lib/src/server_hub.dart b/packages/server/lib/src/server_hub.dart index 31982b1..5026bb9 100644 --- a/packages/server/lib/src/server_hub.dart +++ b/packages/server/lib/src/server_hub.dart @@ -6,7 +6,9 @@ import 'package:relic/relic.dart'; import 'package:sync_relay_core/core.dart'; import 'package:web_socket/web_socket.dart'; +import 'queue_ops.dart'; import 'relay_routes.dart'; +import 'relay_stats.dart'; import 'session.dart'; final _hubLog = createLogger('server_hub'); @@ -80,6 +82,29 @@ class ServerHub { changed(); } + final _readRates = ReadRates(); + + /// Once a second: each member's read rate (`members[].rate`) and the + /// relay's source rate and bitrate (`media.relay.rate` / `need`). Goes + /// out with the next state. + void sampleRates() { + final tokens = session.tokens; + final rates = _readRates.sample(engine.now, { + for (final t in engine.members.keys) t: tokens[t]?.bytesServed ?? 0, + }); + for (final m in engine.members.values) { + m.rate = rates[m.token] ?? 0; + } + final r = relay, m = engine.media; + final was = m?.relay; + if (r == null || m == null || was == null || r.mediaId != engine.mediaId) return; + r.sampleRate(); + final rate = r.rate, need = r.need(m.duration); + if (rate != was.rate || need != was.need) { + engine.media = m.copyWith(relay: was.copyWith(rate: rate, need: need)); + } + } + bool isHostKey(String key) => hostKey.isNotEmpty && key == hostKey; /// The member a reconnecting client may take over with its old token: @@ -151,6 +176,13 @@ class ServerHub { broadcastFeed(); } + /// The server's own fetch of [name] failed: everyone hears it (the + /// swapper also gets the reason as an error), not just "getting it…". + void fetchFailed(String name) { + engine.feed.add(FeedMessage(actor: 'server', action: 'fetch-fail', target: name, at: engine.now)); + broadcastFeed(); + } + void changed() { broadcastState(); broadcastFeed(); @@ -314,7 +346,19 @@ void mountSyncRoute( hub.changed(); case 'swap': if (token == null) return; - final parsed = SwapIntent.fromJson(decoded).media; + final swap = SwapIntent.fromJson(decoded); + var parsed = swap.media; + final queueId = swap.queueId; + if (queueId != null) { + // Already put up by someone else: nothing to do. + if (!hub.engine.queue.has(queueId)) return; + // A server-fetched item's link never left the server. + final hidden = hub.engine.queue.privateUrlOf(queueId); + if (hidden != null && (parsed.url ?? '').isEmpty) { + parsed = HelloMedia( + name: parsed.name, size: parsed.size, duration: parsed.duration, url: hidden, relay: parsed.relay); + } + } if (parsed.jellyfin != null) { // By reference only: no URL (it could carry a token) is // stored or broadcast for Jellyfin media. @@ -330,7 +374,7 @@ void mountSyncRoute( duration: parsed.duration, jellyfin: ref, relay: relayReq == null ? null : hub.relayFor(token, const MediaRelay())); - if (media == null || !hub.engine.applySwap(token, media)) { + if (media == null || !hub.engine.applySwap(token, media, queueId: queueId)) { wsErr(ws, 'bad swap media'); return; } @@ -368,7 +412,7 @@ void mountSyncRoute( size: parsed.size, duration: parsed.duration, relay: hub.relayFor(token, relayReq)); - if (!hub.engine.applySwap(token, media)) { + if (!hub.engine.applySwap(token, media, queueId: queueId)) { wsErr(ws, 'bad swap media'); return; } @@ -379,7 +423,10 @@ void mountSyncRoute( ? ActiveRelay.fetch(hub.engine.mediaId, name, link!, ytDlp: hub.ytDlp, onLiveChanged: () => hub.relayLiveChanged(active), - onFail: (why) => wsErr(ws, hub.fetchFailText(why))) + onFail: (why) { + wsErr(ws, hub.fetchFailText(why)); + hub.fetchFailed(name); + }) : _memberRelay(hub, token, name, media.relay!.tracks); hub.setRelay(active); hub.changed(); @@ -399,7 +446,7 @@ void mountSyncRoute( wsErr(ws, 'swap needs a url or the hosted file'); return; } - if (!hub.engine.applySwap(token, intent.media)) { + if (!hub.engine.applySwap(token, intent.media, queueId: queueId)) { wsErr(ws, 'bad swap media'); return; } @@ -464,6 +511,9 @@ void mountSyncRoute( case 'nudge': if (token == null) return; hub.nudge(token, NudgeIntent.fromJson(decoded).target); + case 'queue': + if (token == null) return; + handleQueueIntent(hub, token, ws, decoded); } } on ProtocolException catch (e) { wsErr(ws, 'bad message: ${e.message}'); diff --git a/packages/server/lib/src/sync_server.dart b/packages/server/lib/src/sync_server.dart index 4276e18..417aabb 100644 --- a/packages/server/lib/src/sync_server.dart +++ b/packages/server/lib/src/sync_server.dart @@ -148,6 +148,7 @@ class SyncServer { hub.engine.tick(); hub.sweepClosedSockets(); hub.dropSilentSockets(); + hub.sampleRates(); hub.broadcastState(); hub.broadcastFeed(); }); diff --git a/packages/server/test/queue_test.dart b/packages/server/test/queue_test.dart new file mode 100644 index 0000000..495ccad --- /dev/null +++ b/packages/server/test/queue_test.dart @@ -0,0 +1,123 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:sync_relay_core/core.dart'; +import 'package:sync_relay_server/server.dart'; +import 'package:test/test.dart'; + +class _Peer { + final WebSocket ws; + final List<Map<String, Object?>> msgs = []; + _Peer(this.ws) { + ws.listen((m) => msgs.add(Map<String, Object?>.from(jsonDecode('$m') as Map))); + } + + static Future<_Peer> join(int port, String name) async { + final ws = await WebSocket.connect('ws://127.0.0.1:$port/sync'); + final p = _Peer(ws); + p.send(HelloMessage( + proto: RelayConstants.protoVersion, + secret: 's', + user: HelloUser(name: name), + media: const HelloMedia(name: '', size: 0, duration: 0)) + .toJson()); + await p.until((m) => m['t'] == 'welcome'); + return p; + } + + void send(Map<String, Object?> json) => ws.add(jsonEncode(json)); + + Future<Map<String, Object?>> until(bool Function(Map<String, Object?> m) test) async { + final end = DateTime.now().add(const Duration(seconds: 5)); + while (DateTime.now().isBefore(end)) { + for (final m in msgs) { + if (test(m)) { + msgs.remove(m); + return m; + } + } + await Future.delayed(const Duration(milliseconds: 20)); + } + fail('nothing matched in $msgs'); + } + + /// The next state whose queue passes [test]. + Future<StateBroadcast> state(bool Function(StateBroadcast s) test) async { + final m = await until((m) => m['t'] == 'state' && test(StateBroadcast.fromJson(m))); + return StateBroadcast.fromJson(m); + } +} + +void main() { + late SyncServer server; + + setUp(() async { + server = SyncServer(const AppConfig(port: 0, secret: 's', name: 't')); + await server.start(); + }); + + tearDown(() => server.stop()); + + test('queued items reach everyone; a server-fetched link stays on the server; a swap takes its item', () async { + final a = await _Peer.join(server.boundPort, 'alice'); + final b = await _Peer.join(server.boundPort, 'bob'); + a.send(const QueueIntent.add(QueueItem( + media: HelloMedia(name: 'https://example.com/v?token=secret', size: 0, duration: 0, + url: 'https://example.com/v?token=secret'), + how: QueueHow.server, + title: 'A video', + sub: 'Example')) + .toJson()); + a.send(const QueueIntent.add( + QueueItem( + media: HelloMedia(name: 'x', size: 0, duration: 0, url: 'https://example.com/x.mp4'), + how: QueueHow.direct, + title: 'Direct'), + next: true) + .toJson()); + final s = await b.state((s) => s.queue.length == 2); + expect([for (final q in s.queue) q.title], ['Direct', 'A video']); + final fetched = s.queue[1]; + expect(fetched.byName, 'alice'); + expect(jsonEncode(s.toJson()), isNot(contains('secret')), reason: 'the fetched URL is never broadcast'); + expect(fetched.media.relay?.fetch, isTrue); + + // Bob puts the fetched item up without knowing its URL; the server fills it in. + b.send(SwapIntent( + media: const HelloMedia(name: 'A video', size: 0, duration: 0, relay: MediaRelay(fetch: true)), + queueId: fetched.id) + .toJson()); + final after = await a.state((s) => s.media != null); + expect(after.media?['name'], 'A video'); + expect(after.queue.single.title, 'Direct'); + final mediaId = after.mediaId; + + // A second swap of the same item (a race) is ignored. + a.send(SwapIntent( + media: const HelloMedia(name: 'A video', size: 0, duration: 0, relay: MediaRelay(fetch: true)), + queueId: fetched.id) + .toJson()); + a.msgs.clear(); + final later = await a.state((s) => true); + expect(later.mediaId, mediaId); + }); + + test('a bad add answers the sender; adder-only items need a key', () async { + final a = await _Peer.join(server.boundPort, 'alice'); + a.send(const QueueIntent.add(QueueItem( + media: HelloMedia(name: 'f.mkv', size: 10, duration: 0), how: QueueHow.file, title: 'f')) + .toJson()); + final err = await a.until((m) => m['t'] == 'err'); + expect('${err['message']}', contains('key')); + a.send(const QueueIntent.add(QueueItem( + media: HelloMedia(name: 'f.mkv', size: 10, duration: 0, url: 'file:///home/me/f.mkv'), + how: QueueHow.file, + title: 'f', + key: 'k1234567890')) + .toJson()); + final s = await a.state((s) => s.queue.isNotEmpty); + expect(s.queue.single.media.url, isNull, reason: 'an adder-only item carries no path or link'); + expect(s.queue.single.key, 'k1234567890'); + }); +} diff --git a/packages/server/test/relay_stats_test.dart b/packages/server/test/relay_stats_test.dart new file mode 100644 index 0000000..8688816 --- /dev/null +++ b/packages/server/test/relay_stats_test.dart @@ -0,0 +1,118 @@ +import 'dart:typed_data'; + +import 'package:sync_relay_core/core.dart'; +import 'package:sync_relay_server/server.dart'; +import 'package:test/test.dart'; + +/// A feed that only records pulls; the test answers them. +class _Feed extends RelayFeed { + final pulls = <int>[]; + @override + void pull(int id, int start, int end) => pulls.add(id); + @override + void pullPiece(int id, int index) => pulls.add(id); + @override + void cancel(int id) {} +} + +void main() { + test('rates keep two significant digits; under 10 kB/s is 0', () { + expect(roundRate(3249999), 3200000); + expect(roundRate(87654), 88000); + expect(roundRate(9999), 0); + expect(roundRate(double.nan), 0); + }); + + test('the source rate counts busy time only, and settles first', () { + final r = SourceRate(); + r.sample(2000000, const Duration(seconds: 1)); + expect(r.rate, 0, reason: 'under 2 s of busy time'); + r.sample(4000000, const Duration(seconds: 2)); + expect(r.rate, 2000000); + // The buffer is full: no pull, no bytes, no busy time: the rate holds. + r.sample(4000000, const Duration(seconds: 2)); + expect(r.rate, 2000000); + // Slower now (0.5 MB/s): moves toward it over the window. + r.sample(5000000, const Duration(seconds: 4)); + expect(r.rate, inExclusiveRange(500000, 2000000)); + // A new feed (totals back to 0) starts over without a bogus sample. + final before = r.rate; + r.sample(0, Duration.zero); + expect(r.rate, before); + }); + + test('read rates: bytesServed over the last 5 s, forgotten tokens drop', () { + final rates = ReadRates(); + final t0 = DateTime.utc(2026); + expect(rates.sample(t0, {'a': 0, 'b': 0}), {'a': 0, 'b': 0}); + for (var i = 1; i <= 10; i++) { + final out = rates.sample(t0.add(Duration(seconds: i)), {'a': i * 2000000, 'b': 0}); + expect(out['a'], 2000000); + expect(out['b'], 0); + } + // a stops reading: its rate falls to 0 once the window has passed. + var out = <String, int>{}; + for (var i = 11; i <= 16; i++) { + out = rates.sample(t0.add(Duration(seconds: i)), {'a': 20000000}); + } + expect(out, {'a': 0}); + }); + + test('a buffer runs its clock while a pull is in flight', () async { + final clock = FeedClock(); + final buf = RelayBuffer()..clock = clock; + final feed = _Feed(); + buf.attach(feed, 3 * RelayBuffer.slice); + // A viewer asks: the buffer pulls. + final read = buf.read('v', 0, 9).toList(); + await Future<void>.delayed(Duration.zero); + expect(feed.pulls, isNotEmpty); + await Future<void>.delayed(const Duration(milliseconds: 30)); + final busy = clock.busy; + expect(busy, greaterThan(Duration.zero)); + final id = feed.pulls.first; + buf.feedBytes(id, Uint8List(3 * RelayBuffer.slice)); + buf.feedEnd(id); + await read; + final after = clock.busy; + await Future<void>.delayed(const Duration(milliseconds: 30)); + expect(clock.busy, after, reason: 'idle: the clock stopped'); + buf.dispose(); + }); + + test('the hub puts each member\'s read rate in the state', () { + var now = DateTime.utc(2026); + final hub = ServerHub(SessionMap(), 'pw', engine: SyncEngine(clock: () => now)); + final token = hub.session.issueToken('bob', ''); + hub.engine.applyJoin(token, 'bob', ''); + hub.sampleRates(); + hub.session.addBytes(token, 3000000); + now = now.add(const Duration(seconds: 1)); + hub.sampleRates(); + final json = hub.stateBroadcast().toJson(); + final members = (json['members'] as List).cast<Map<String, Object?>>(); + expect(members.single['rate'], 3000000); + expect(StateBroadcast.fromJson(json).members.single.rate, 3000000); + }); + + test('media.relay rate and need round-trip; slow only with both known', () { + const r = MediaRelay(source: 'm1', live: true, rate: 1200000, need: 2300000); + final back = MediaRelay.fromJson(r.toJson()); + expect(back.rate, 1200000); + expect(back.need, 2300000); + expect(back.slow, isTrue); + expect(const MediaRelay(rate: 1200000).slow, isFalse); + expect(const MediaRelay().toJson().containsKey('rate'), isFalse); + expect(MediaRelay.fromJson({'rate': double.infinity, 'need': -1}).rate, 0); + }); + + test('a relay\'s need: byte tracks by size over the duration', () { + final r = ActiveRelay.fromMember(1, 'x', 't', onLiveChanged: () {}); + expect(r.need(100), 0, reason: 'size unknown until the source connects'); + (r.buffers.single as RelayBuffer).attach(_Feed(), 230000000); + expect(r.need(100), 2300000); + expect(r.need(0), 0, reason: 'duration unknown'); + r.dispose(); + }); + +} diff --git a/packages/server/test/session_test.dart b/packages/server/test/session_test.dart index 382e8ab..33470c4 100644 --- a/packages/server/test/session_test.dart +++ b/packages/server/test/session_test.dart @@ -64,8 +64,8 @@ void main() { }); group('core welcome + proto', () { - test('proto 11: avatars by hash, profile intent', () { - expect(RelayConstants.protoVersion, 11); + test('proto 12: avatars by hash, profile intent', () { + expect(RelayConstants.protoVersion, 12); const v = MemberView(id: 'm1', name: 'a', ready: false, inSet: false, buffered: 0, stale: false); expect(v.toJson().containsKey('avatarHash'), isFalse); expect(v.toJson().containsKey('avatar'), isFalse);