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

+93
View File
@@ -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();
}