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

+309
View File
@@ -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.';
}
@@ -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) {