Protocol 12: Up next (a shared queue) and where the video comes from

Up next: a server-side queue anyone edits by item id (add at end or
next, move, remove, play now, skip); each item says who added it and
how it plays. At the real end the first item swaps in after the end
card's countdown, put up by whoever can (its adder for through-me links
and files, someone with access for Jellyfin, else anyone); items that
can't play are skipped with the reason. The next item's link is
resolved ahead. Greyed "Continues with" episodes below. A chip with the
controls opens it as a popover (leaf_widgets, local path dependency for
now); "Add to queue" next to Put it up everywhere.

Delivery: the server measures the source's rate, the media's bitrate
and each member's read rate; the members card shows a source line
(who passes it on, through what, keeping up or not, source left) and
each member's route and buffer. A slow source is told once per media.
A failed server fetch is announced to everyone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
foxyzandClaude Opus 5.5 committed 2026-10-11 06:40:26 +03:00
1 parent 95cd668cfb
commit 6b10cae4e6
50 files changed
+4239 -157

No files matched your search

@@ -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<JellyfinItem> onSelect;
/// Double-click: put it up (a series: its next episode).
final ValueChanged<JellyfinItem> 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<void> cardMenu(BuildContext context, JellyfinItem item) async {
final queue = onAddToQueue;
if (queue == null || !isSelectable(item)) return;
final pick = await showAppMenu<int>(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<T> load<T>(String key, Future<T> Function(JellyfinClient c) fn) =>
accounts.cached(account.serverId, key, () => fn(client));
@@ -30,6 +30,8 @@ class JellyfinBrowser extends StatefulWidget {
final JellyfinSelection? selected;
final ValueChanged<JellyfinSelection> onSelect;
final ValueChanged<JellyfinSelection> 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<JellyfinBrowser> {
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,
+1
View File
@@ -186,6 +186,7 @@ class _MediaCardState extends State<MediaCard> {
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(
@@ -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<String?> Function(JellyfinSelection item) onPutUp;
/// Adds [item] to Up next (with next: right after what plays); null: no
/// server, no queue actions.
final Future<String?> 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<JellyfinDetailsPanel> {
});
}
Future<void> _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<void> _episodeMenu(BuildContext context, JellyfinItem e) async {
if (widget.onAddToQueue == null) return;
setState(() => _targetId = e.id);
final pick = await showAppMenu<int>(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<void> _change(Future<void> Function(JellyfinClient c) action) async {
final c = _client;
@@ -308,6 +352,16 @@ class _JellyfinDetailsPanelState extends State<JellyfinDetailsPanel> {
: 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<JellyfinDetailsPanel> {
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<JellyfinDetailsPanel> {
],
),
),
),
)),
);
}
}
@@ -14,6 +14,7 @@ class JellyfinPickerSource extends StatefulWidget {
final JellyfinSelection? selected;
final ValueChanged<JellyfinSelection> onSelect;
final ValueChanged<JellyfinSelection> 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<JellyfinPickerSource> {
selected: widget.selected,
onSelect: widget.onSelect,
onPutUp: widget.onPutUp,
onAddToQueue: widget.onAddToQueue,
onOpenIntegrations: widget.onOpenIntegrations,
start: widget.start,
libraryStrip: widget.libraryStrip,
@@ -375,6 +375,7 @@ class _EpisodeTileState extends State<EpisodeTile> {
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(