Jellyfin end to end: per-viewer playback, progress, episodes, swap browser

- Host/swap send refs only; pasted Jellyfin links become refs; the host
  hands the relay its own app-obtained credential.
- Each viewer resolves with their own account (token in a header) or
  via the relay, else a clear blocked card with Link account.
- Per-account progress reporting (Playing/Progress/Stopped), 401 marks
  the account expired.
- Previous/next episode in the controls (< > / Shift+P/N), floating
  Next episode pill near the end, auto-advance countdown (one
  designated swapper), track carry-over per series.
- Swap panel uses the shared MediaPicker; browser location remembered
  per server and opens on the current series.
- Fix: states are applied one at a time (a stale pause could land
  after a newer play).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
foxyzandClaude Opus 5.5 committed 2026-10-07 02:38:24 +03:00
1 parent 773a04369e
commit ae4edc29e8
33 files changed
+3541 -175

No files matched your search

@@ -426,6 +426,16 @@ class JellyfinClient {
await episodes(seriesId, withMediaStreams: withMediaStreams), episode.id);
}
/// The episodes before and after [episode] in its series (one
/// `/Shows/{id}/Episodes` call; see [episodeBefore] / [episodeAfter]).
Future<({JellyfinItem? previous, JellyfinItem? next})> adjacentEpisodes(
JellyfinItem episode) async {
final seriesId = episode.seriesId;
if (seriesId == null) return (previous: null, next: null);
final list = await episodes(seriesId);
return (previous: episodeBefore(list, episode.id), next: episodeAfter(list, episode.id));
}
// -------------------------------------------------------------- images
/// Image URL for caching and display. Images need no token on Jellyfin,
@@ -594,3 +604,20 @@ JellyfinItem? episodeAfter(List<JellyfinItem> episodes, String episodeId) {
}
return null;
}
/// The predecessor of [episodeId] in [episodes], the mirror of
/// [episodeAfter]: crossing seasons, skipping other copies of the same
/// episode. Null when it is first or not in the list.
JellyfinItem? episodeBefore(List<JellyfinItem> episodes, String episodeId) {
final at = episodes.indexWhere((e) => e.id == episodeId);
if (at < 0) return null;
final cur = episodes[at];
for (var i = at - 1; i >= 0; i--) {
final e = episodes[i];
final sameEpisode = cur.indexNumber != null &&
e.indexNumber == cur.indexNumber &&
e.parentIndexNumber == cur.parentIndexNumber;
if (!sameEpisode) return e;
}
return null;
}
@@ -235,6 +235,22 @@ void main() {
expect(episodeAfter(eps, eps.last.id), isNull);
expect(episodeAfter(eps, 'nope'), isNull);
});
test('adjacentEpisodes: previous mirrors next across seasons and copies', () async {
http.on('GET', '/Shows/550eff23b37ec61082c988678ccc214c/Episodes', fixture: 'episodes');
final eps = await client.episodes('550eff23b37ec61082c988678ccc214c');
final s3e2 = eps.firstWhere((e) => e.episodeLabel == 'S3E2');
final around = await client.adjacentEpisodes(s3e2);
// S3E1 exists twice: the predecessor is the copy right before S3E2.
expect(around.previous!.episodeLabel, 'S3E1');
expect(around.next!.episodeLabel, 'S3E3');
final s3e1 = eps.lastWhere((e) => e.episodeLabel == 'S3E1');
expect(episodeBefore(eps, s3e1.id)!.episodeLabel, 'S2E12');
expect(episodeBefore(eps, eps.first.id), isNull);
expect(episodeBefore(eps, 'nope'), isNull);
expect((await client.adjacentEpisodes(const JellyfinItem(id: 'x', name: 'm', type: 'Movie'))).next,
isNull);
});
});
group('images', () {
+19 -2
View File
@@ -14,7 +14,11 @@ import 'sync_server.dart';
/// `{'t':'error','message':m}`, and forwards log records as
/// `{'t':'log','level','logger','message'[,'error']}`. Sending
/// `{'t':'stop'}` to the `stop` port stops the server; the returned future
/// completes once it is down. Without [events] a start failure throws.
/// completes once it is down. `{'t':'file','path':p,'reply':SendPort}` makes
/// a file on this machine the hosted file (the hosting app's in-session file
/// swap; a following `swap` with its name and size puts it up) and answers
/// `{'ok':true,'name':n,'size':s}` or `{'ok':false}`. Without [events] a
/// start failure throws.
Future<void> serve(Map<String, Object?> config, {SendPort? events}) async {
StreamSubscription<pkg_logging.LogRecord>? logs;
if (events != null) {
@@ -39,7 +43,20 @@ Future<void> serve(Map<String, Object?> config, {SendPort? events}) async {
final control = ReceivePort('serve control');
events?.send({'t': 'ready', 'port': server.boundPort, 'stop': control.sendPort});
await for (final msg in control) {
if (msg is Map && msg['t'] == 'stop') break;
if (msg is! Map) continue;
if (msg['t'] == 'stop') break;
if (msg['t'] == 'file' && msg['path'] is String && msg['reply'] is SendPort) {
final reply = msg['reply'] as SendPort;
try {
final ok = await server.setLocalFile(msg['path'] as String);
final media = server.session.media;
reply.send(ok && media != null
? {'ok': true, 'name': media.name, 'size': media.size}
: {'ok': false});
} catch (_) {
reply.send({'ok': false});
}
}
}
control.close();
await server.stop();
@@ -51,6 +51,31 @@ void main() {
errors.close();
});
test('serve: the file control message makes a local file the hosted one', () async {
final events = ReceivePort();
final exit = ReceivePort();
final dir = Directory.systemTemp.createTempSync('serve_file_');
final file = File('${dir.path}/ep 2.mkv')..writeAsBytesSync(List.filled(1000, 1));
await Isolate.spawn(_run, (const AppConfig(port: 0, secret: '', name: 'f').toMap(), events.sendPort),
onExit: exit.sendPort);
final ready = await events.firstWhere((m) => m is Map && m['t'] == 'ready') as Map;
final stop = ready['stop'] as SendPort;
Future<Object?> ask(String path) async {
final reply = ReceivePort();
stop.send({'t': 'file', 'path': path, 'reply': reply.sendPort});
final r = await reply.first.timeout(const Duration(seconds: 5));
reply.close();
return r;
}
expect(await ask(file.path), {'ok': true, 'name': 'ep 2.mkv', 'size': 1000});
expect(await ask('${dir.path}/missing.mkv'), {'ok': false});
stop.send({'t': 'stop'});
await exit.first.timeout(const Duration(seconds: 10));
events.close();
dir.deleteSync(recursive: true);
});
test('serve reports a bind failure as an error event', () async {
final blocker = await ServerSocket.bind(InternetAddress.anyIPv4, 0);
final events = ReceivePort();