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

+1
View File
@@ -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';
+9 -1
View File
@@ -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;
}
+57 -7
View File
@@ -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,
);
}
}
+336
View File
@@ -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);
}
}
+14 -2
View File
@@ -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,
);
}
}
+121
View File
@@ -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>()));
});
}