From ec82d98cc83b2456aaf299a05efdfce1d21a2c11 Mon Sep 17 00:00:00 2001 From: JenteJan Date: Sun, 7 Jun 2026 09:36:29 +0200 Subject: [PATCH] fix(syncplay): send full series queue and resume position on initiate When starting playback in a SyncPlay group, _playSyncPlay sent a single-item queue (PlayingQueue: [itemId], position 0, startPositionTicks 0). This broke playback for other participants, notably the official Jellyfin clients (e.g. the webOS TV app): - Episodes never started on other clients. The official client queues the whole series (with PlayingItemPosition pointing at the chosen episode); a lone single-episode queue is not started by those clients. Movies, being naturally single-item, worked - which made the bug look movie-vs-episode specific. - The resume position was dropped. Continue Watching restarted the group from 0:00 because startPositionTicks fell back to 0 whenever no explicit startPosition was passed. The local playback path resolves this via model.startDuration(); the SyncPlay path did not. Mirror the local playback path: build the queue via collectQueue (full series for an episode/series/season, empty for a movie), resolve Series/Season to their next-up episode, send the whole series with the correct playingItemPosition, and fall back to the resolved item's saved resume position. Also document the matching cross-client regression scenarios per the SyncPlay regression checklist (AGENTS.md rule 10). --- docs/syncplay-implementation.md | 16 +++++++++ .../item_base_model/play_item_helpers.dart | 36 +++++++++++++++++-- 2 files changed, 49 insertions(+), 3 deletions(-) diff --git a/docs/syncplay-implementation.md b/docs/syncplay-implementation.md index 5be621cfa..f56b6c0dc 100644 --- a/docs/syncplay-implementation.md +++ b/docs/syncplay-implementation.md @@ -1456,6 +1456,22 @@ AGENTS.md #10. direct-stream item. Confirm no group-level pause and no visible desync in the other client. +### Initiate playback (cross-client interop) + +- [ ] **Episode start propagates to official clients:** In a group with an + official Jellyfin client (e.g. webOS/Android TV) as a second + participant, start a TV episode from Fladder. Confirm the other + client actually begins playing the episode (was: `_playSyncPlay` sent + a single-item queue, which official clients do not start; movies, + being single-item by nature, masked the bug and worked). +- [ ] **Resume position on initiate:** Press "Continue Watching" on a + partially-watched item while in a group. Confirm every participant + resumes near the saved position, not from 0:00 (was: the SyncPlay + initiate path always sent startPositionTicks: 0). +- [ ] **Series/Season start:** Press play on a series or season (not a + specific episode) in a group. Confirm it resolves to the next-up + episode and all participants start there. + ### UI placement - [ ] **Side rail FAB count:** With a side navigation rail visible, each diff --git a/lib/util/item_base_model/play_item_helpers.dart b/lib/util/item_base_model/play_item_helpers.dart index acd8ea26b..a5fa276ac 100644 --- a/lib/util/item_base_model/play_item_helpers.dart +++ b/lib/util/item_base_model/play_item_helpers.dart @@ -6,7 +6,10 @@ import 'package:collection/collection.dart'; import 'package:fladder/models/book_model.dart'; import 'package:fladder/models/item_base_model.dart'; import 'package:fladder/models/items/channel_model.dart'; +import 'package:fladder/models/items/episode_model.dart'; import 'package:fladder/models/items/photos_model.dart'; +import 'package:fladder/models/items/season_model.dart'; +import 'package:fladder/models/items/series_model.dart'; import 'package:fladder/models/media_playback_model.dart'; import 'package:fladder/models/playback/playback_model.dart'; import 'package:fladder/models/playback/tv_playback_model.dart'; @@ -255,7 +258,34 @@ Future _playSyncPlay( WidgetRef ref, { Duration? startPosition, }) async { - final startPositionTicks = startPosition != null ? secondsToTicks(startPosition.inMilliseconds / 1000) : 0; + // Build the full play queue so episodes are sent to the group WITH their + // series context. The local playback path builds this via + // createPlaybackModel -> collectQueue; the SyncPlay path must match it. + // A lone single-episode queue is not started by the official Jellyfin + // clients (e.g. the webOS TV app) even though movies — naturally + // single-item — work fine. Movies return an empty seriesQueue and fall + // back to a single item. + final helper = ref.read(playbackModelHelper); + final List seriesQueue = await helper.collectQueue(itemModel); + + // Series/Season tiles resolve to their next-up episode (mirrors + // createPlaybackModel's firstItemToPlay switch). + ItemBaseModel target = itemModel; + if (itemModel is SeriesModel || itemModel is SeasonModel) { + final resolved = seriesQueue.whereType().toList().nextUp; + if (resolved != null) target = resolved; + } + + final List itemIds = seriesQueue.isNotEmpty ? seriesQueue.map((e) => e.id).toList() : [target.id]; + final int playingItemPosition = + seriesQueue.isNotEmpty ? seriesQueue.indexWhere((e) => e.id == target.id).clamp(0, itemIds.length - 1) : 0; + + // Fall back to the resolved item's saved resume position (mirrors the local + // path's `model.startDuration()`) so "Continue Watching" resumes mid-item + // for the whole group instead of restarting from 0. Previously, every play + // that didn't pass an explicit startPosition sent startPositionTicks: 0. + final effectiveStart = startPosition ?? target.userData.playBackPosition; + final startPositionTicks = secondsToTicks(effectiveStart.inMilliseconds / 1000); final notifier = ref.read(syncPlayProvider.notifier); final pending = notifier.awaitNextStartPlayback( @@ -266,8 +296,8 @@ Future _playSyncPlay( _showLoadingIndicator(context, itemModel, op, autoCloseOnComplete: true); final queueAccepted = await notifier.setNewQueue( - itemIds: [itemModel.id], - playingItemPosition: 0, + itemIds: itemIds, + playingItemPosition: playingItemPosition, startPositionTicks: startPositionTicks, );