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:
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', () {
|
||||
|
||||
Reference in new issue
Block a user