Protocol 13: Up next preloads, so switching to it is instant

Every app gets the first Up next item ready itself (own Jellyfin plan,
direct links, guests via /j when state.relayNext says the server's
login can pass it on) and opens it in a hidden, muted, paused second
player; the swap promotes that player instead of opening cold, so its
buffer counts at once. Reports go out on open and when the buffer
reaches the gate's need. SwitchTrace times each phase. Measured in the
e2e harness: 1.5-3 s before, 35-103 ms with a preload. Relayed items
still open cold for everyone but their source (ROADMAP).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
foxyzandClaude Opus 5.5 committed 2026-10-11 16:15:16 +03:00
1 parent 4275abe470
commit ddd7a21195
19 files changed
+1041 -142

No files matched your search

@@ -0,0 +1,327 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:media_kit/media_kit.dart';
import 'package:sync_relay_core/core.dart';
import 'package:sync_relay_server/server.dart';
import 'package:watch_together/src/jellyfin/accounts.dart';
import 'package:watch_together/src/jellyfin/session_media.dart';
import 'package:watch_together/src/jellyfin/track_prefs.dart';
import 'package:watch_together/src/playback/yt_dlp_app.dart';
import 'package:watch_together/src/queue/queue_controller.dart' show WarmState;
import 'package:watch_together/src/screens/home/picked_media.dart';
import 'package:watch_together/src/session/session_coordinator.dart';
import 'package:watch_together/src/session/switch_trace.dart';
// How long an Up next switch takes, end to end, over real mpv and a real
// sync server: two Jellyfin account holders and a guest (through the
// server's /j login) watch an episode, then ⏭ puts up the next episode,
// then a direct link. The fake Jellyfin and the link's host answer with a
// delay and a capped rate, like a server elsewhere. Prints each client's
// phases (SwitchTrace) and the wall time from ⏭ to the server playing.
//
// WT_SWITCH_PRELOAD=0 turns the preload off (the cold path, for comparison);
// WT_SWITCH_DELAY_MS and WT_SWITCH_RATE_KBS set the remote's delay and rate.
final _tmp = Platform.environment['WT_TEST_TMP'] ?? Directory.systemTemp.path;
const _sid = 'ab12ab12ab12ab12ab12ab12ab12ab12';
const _series = '5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e';
const _ep1 = 'e1e1e1e1e1e1e1e1e1e1e1e1e1e1e1e1';
const _ep2 = 'e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2';
/// Per API request, and before a stream's first byte (WT_SWITCH_DELAY_MS).
final _delay = Duration(milliseconds: int.tryParse(Platform.environment['WT_SWITCH_DELAY_MS'] ?? '') ?? 120);
/// Per stream connection, default 24 Mbit/s (WT_SWITCH_RATE_KBS).
final _bytesPerSec = (int.tryParse(Platform.environment['WT_SWITCH_RATE_KBS'] ?? '') ?? 3072) * 1024;
/// 60 s of 720p at about 4 Mbit/s with sound (headless mpv needs audio).
Future<String> _media() async {
final path = '$_tmp/queue_switch_60s.mkv';
if (await File(path).exists()) return path;
final r = await Process.run('ffmpeg', [
'-y', '-v', 'error',
'-f', 'lavfi', '-i', 'testsrc2=size=1280x720:rate=24',
'-f', 'lavfi', '-i', 'sine=frequency=440:sample_rate=48000',
'-t', '60', '-c:v', 'libx264', '-preset', 'ultrafast', '-b:v', '4M', '-g', '48', '-pix_fmt', 'yuv420p',
'-c:a', 'aac', path,
]);
if (r.exitCode != 0) fail('ffmpeg failed: ${r.stderr}');
return path;
}
/// Serves [file] with ranges, after [_delay], at most [_bytesPerSec].
Future<void> _serveFile(HttpRequest req, String file) async {
final f = File(file);
final size = await f.length();
final res = req.response;
res.headers.set('Accept-Ranges', 'bytes');
res.headers.contentType = ContentType('video', 'x-matroska');
var start = 0, end = size - 1;
final range = RegExp(r'bytes=(\d*)-(\d*)').firstMatch(req.headers.value('range') ?? '');
if (range != null) {
start = int.tryParse(range.group(1)!) ?? 0;
end = int.tryParse(range.group(2)!) ?? size - 1;
if (end >= size) end = size - 1;
res.statusCode = 206;
res.headers.set('Content-Range', 'bytes $start-$end/$size');
}
res.contentLength = end - start + 1;
await Future.delayed(_delay);
try {
final raf = await f.open();
try {
await raf.setPosition(start);
var at = start;
const chunk = 64 * 1024;
final clock = Stopwatch()..start();
var sent = 0;
while (at <= end) {
final n = (end - at + 1).clamp(0, chunk);
res.add(await raf.read(n));
await res.flush();
at += n;
sent += n;
final due = Duration(microseconds: sent * 1000000 ~/ _bytesPerSec);
if (due > clock.elapsed) await Future.delayed(due - clock.elapsed);
}
} finally {
await raf.close();
}
await res.close();
} catch (_) {}
}
class _FakeJellyfin {
final String file;
final tokens = {'ATOKEN': 'ua', 'CTOKEN': 'uc', 'HOSTTOKEN': 'uh'};
late HttpServer _server;
_FakeJellyfin(this.file);
String get base => 'http://127.0.0.1:${_server.port}';
Future<void> start() async {
_server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
_server.listen(_handle);
}
Future<void> stop() => _server.close(force: true);
Map<String, Object?> _item(String id) => {
'Id': id,
'Name': id == _ep1 ? 'Pilot' : 'Second',
'Type': 'Episode',
'ServerId': _sid,
'SeriesId': _series,
'SeriesName': 'Fake Show',
'ParentIndexNumber': 1,
'IndexNumber': id == _ep1 ? 1 : 2,
'RunTimeTicks': 60 * jellyfinTicksPerSecond,
};
Future<void> _handle(HttpRequest req) async {
final auth = req.headers.value('authorization') ?? '';
await req.drain<void>();
final res = req.response;
final path = req.uri.path;
if (RegExp(r'^/Videos/\w+/stream$').hasMatch(path)) return _serveFile(req, file);
await Future.delayed(_delay);
Future<void> json(Object v) async {
res.headers.contentType = ContentType.json;
res.write(jsonEncode(v));
await res.close();
}
if (path == '/System/Info/Public') return json({'Id': _sid, 'ServerName': 'Fake', 'Version': '10.11.11'});
final token = RegExp(r'Token="([^"]*)"').firstMatch(auth)?.group(1);
final user = tokens[token];
if (user == null) {
res.statusCode = 401;
return res.close();
}
final m = RegExp(r'^/Items/(\w+)(/PlaybackInfo)?$').firstMatch(path);
if (m != null && m.group(2) != null) {
return json({
'PlaySessionId': 'ps-$user-${m.group(1)}',
'MediaSources': [
{
'Id': m.group(1),
'SupportsDirectPlay': true,
'MediaStreams': [
{'Index': 0, 'Type': 'Video', 'Codec': 'h264'},
{'Index': 1, 'Type': 'Audio', 'Language': 'jpn', 'Codec': 'aac'},
],
}
],
});
}
if (m != null) return json(_item(m.group(1)!));
if (path == '/Shows/$_series/Episodes') {
return json({'Items': [_item(_ep1), _item(_ep2)], 'TotalRecordCount': 2});
}
if (path.startsWith('/Sessions/Playing')) {
res.statusCode = 204;
return res.close();
}
if (path.startsWith('/UserPlayedItems/') || path.startsWith('/UserItems/')) {
return json({'Played': false, 'PlaybackPositionTicks': 0});
}
res.statusCode = 404;
await res.close();
}
}
Future<void> _poll(FutureOr<bool> Function() cond, String what,
{Duration timeout = const Duration(seconds: 40), Duration every = const Duration(milliseconds: 200)}) async {
final deadline = DateTime.now().add(timeout);
while (DateTime.now().isBefore(deadline)) {
if (await cond()) return;
await Future.delayed(every);
}
fail('timed out waiting for $what');
}
Future<JellyfinAccounts> _account(String base, String token, String userId, String device) async {
final a = JellyfinAccounts(store: MemoryJellyfinStore(), deviceName: device);
await a.ensureLoaded(validate: false);
await a.link(
baseUrl: base,
server: const JellyfinServerInfo(id: _sid, name: 'Fake', version: '10.11.11'),
auth: JellyfinAuthResult(accessToken: token, user: JellyfinUser(id: userId, name: userId), serverId: _sid),
);
return a;
}
void main() {
test(
'Up next: ⏭ to a Jellyfin episode, then to a direct link, timed per client',
timeout: const Timeout(Duration(minutes: 4)),
() async {
MediaKit.ensureInitialized();
final preload = Platform.environment['WT_SWITCH_PRELOAD'] != '0';
final file = await _media();
final jelly = _FakeJellyfin(file);
await jelly.start();
final link = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
link.listen((req) => _serveFile(req, file));
final linkUrl = 'http://127.0.0.1:${link.port}/clip.mkv';
final server = SyncServer(AppConfig(
port: 0,
secret: 'q',
name: 'q',
jellyfin: JellyfinCredential(
baseUrl: jelly.base,
serverId: _sid,
token: 'HOSTTOKEN',
client: 'Watch Together',
device: 'Host',
deviceId: 'host-1',
version: '9.9.9'),
));
await server.start();
final ytDlp = AppYtDlp(YtDlp('$_tmp/queue_switch_ytdlp', enabled: false));
final accA = await _account(jelly.base, 'ATOKEN', 'ua', 'A-PC');
final accC = await _account(jelly.base, 'CTOKEN', 'uc', 'C-PC');
final accB = JellyfinAccounts(store: MemoryJellyfinStore());
await accB.ensureLoaded(validate: false);
SessionCoordinator coord(JellyfinAccounts acc) => SessionCoordinator(
jellyfin: acc, trackPrefs: TrackPrefs(store: MemoryJellyfinStore()), ytDlp: ytDlp)
..playback.preloadEnabled = preload;
final a = coord(accA), b = coord(accB), c = coord(accC);
final all = {'alice': a, 'guest': b, 'carol': c};
ConnectParams params(String name, [HelloMedia? media]) =>
ConnectParams(host: '127.0.0.1', port: server.boundPort, secret: 'q', name: name, media: media);
final report = <String>[];
Future<void> switchOnce(String what) async {
final before = a.serverState.peek()!.mediaId;
// Time to get ready (Up next's first item, on every client).
await Future.delayed(const Duration(seconds: 8));
if (preload) {
for (final x in all.values) {
expect(x.queue.warm.peek()?.state, WarmState.ready, reason: 'ready on every client before ⏭');
expect(x.playback.preloadUrl, isNotNull, reason: 'preloaded on every client');
}
}
final clock = Stopwatch()..start();
a.queue.skip();
await _poll(() {
final r = a.serverState.peek();
return r != null && r.mediaId > before && r.transport == 'playing';
}, '$what playing', every: const Duration(milliseconds: 10));
final total = clock.elapsedMilliseconds;
final id = a.serverState.peek()!.mediaId;
report.add('$what (${preload ? 'preload' : 'cold'}): ⏭ to server playing $total ms');
for (final e in all.entries) {
report.add(' ${e.key}: ${e.value.switchTrace.of(id)}');
}
await _poll(() => all.values.every((x) => x.playback.player.state.playing), 'all play $what');
// The promoted player is the session player: marks, tracks and
// the volume carried over, the clock moves.
for (final x in all.values) {
expect(x.playback.durationSecs, greaterThan(59));
expect(x.playback.player.state.volume, 0, reason: 'volume kept');
await _poll(() => x.playback.tracks.peek().isNotEmpty, 'tracks listed');
}
final at = a.playback.positionSecs;
await Future.delayed(const Duration(seconds: 1));
expect(a.playback.positionSecs, greaterThan(at + 0.5));
}
try {
await a.connect(params('alice', const HelloMedia(
name: 'Pilot', size: 0, duration: 0, jellyfin: JellyfinRef(serverId: _sid, itemId: _ep1, title: 'Pilot'))));
await b.connect(params('guest'));
await c.connect(params('carol'));
for (final x in all.values) {
await x.playback.player.setVolume(0);
}
await _poll(() => all.values.every((x) => x.playback.durationSecs > 59), 'all opened episode 1');
expect(await a.queue.add(pickForRef(accA, const JellyfinRef(serverId: _sid, itemId: _ep2, title: 'Second'))!),
isNull);
expect(await a.queue.add(LinkMedia(linkUrl, relay: LinkRelay.direct)), isNull);
await _poll(() => (a.serverState.peek()?.queue.length ?? 0) == 2, 'two queued');
for (final x in all.values) {
x.toggleReady();
}
await _poll(() => all.values.every((x) => x.playback.player.state.playing), 'all playing episode 1');
await switchOnce('episode 2');
expect(refOfStateMedia(a.serverState.peek()!.media)?.itemId, _ep2);
expect(b.jellyfinPlan.peek(), isA<RelayPlay>());
if (preload) {
for (final x in all.values) {
expect(x.switchTrace.of(x.serverState.peek()!.mediaId), contains(SwitchTrace.preloaded));
}
}
await switchOnce('direct link');
expect(stateMediaUrl(a.serverState.peek()!), linkUrl);
// ignore: avoid_print
print(report.join('\n'));
// Nothing queued: no preload. Queued and taken off again: dropped.
if (preload) {
await _poll(() => all.values.every((x) => x.playback.preloadUrl == null), 'nothing preloaded');
expect(await a.queue.add(LinkMedia(linkUrl, relay: LinkRelay.direct)), isNull);
await _poll(() => all.values.every((x) => x.playback.preloadUrl == linkUrl), 'preloaded again');
a.queue.remove(a.serverState.peek()!.queue.first.id);
await _poll(() => all.values.every((x) => x.playback.preloadUrl == null), 'dropped when taken off');
}
// Preloaded everywhere: every client starts from its preload.
if (preload) {
for (final x in all.values) {
expect(x.switchTrace.of(x.serverState.peek()!.mediaId), contains(SwitchTrace.preloaded));
}
}
} finally {
for (final x in all.values) {
await x.dispose();
}
await server.stop();
await jelly.stop();
await link.close(force: true);
}
},
);
}