feat: Jellyfin SyncPlay support#735
Conversation
This commit introduces a comprehensive implementation of Jellyfin SyncPlay, enabling synchronized media playback between multiple users. The integration includes real-time state synchronization, group management, and low-latency command execution.
Key features and architectural components:
- **WebSocket Infrastructure**: Dedicated `WebSocketManager` handling persistent connections, automatic reconnection with exponential backoff, and keep-alive messaging.
- **Time Synchronization**: NTP-like clock synchronization via `TimeSyncService` to calculate server/client offsets, ensuring precise command execution across different network latencies.
- **SyncPlay Controller**: A central state machine managing group lifecycle (create, join, leave), command scheduling with future execution timers, and late-command estimation.
- **Player Integration**: Intercepted user actions (play, pause, seek) in the video player to route requests through the SyncPlay API when active, ensuring all participants stay in sync.
- **UI Components**:
- **Dashboard FAB**: New "SyncPlay" action button on the main dashboard to access group management.
- **Group Management Sheet**: Bottom sheet for listing active sessions, creating new groups, and joining existing ones.
- **Status Indicators**: Added `SyncPlayBadge` and indicators within the video player controls to show the current group state (Playing, Paused, Waiting).
- **Riverpod State Management**: Comprehensive providers for session state, group metadata, and player synchronization status.
Technical details:
- Implemented tick-based time conversion (10,000,000 ticks per second) for compatibility with Jellyfin's internal timing.
- Added duplicate command detection to prevent redundant player operations.
- Enhanced navigation scaffold to support custom FAB widgets per destination.
This commit improves the SyncPlay implementation by ensuring proper synchronization between group members and the media player. Key changes include: - **SyncPlay Controller**: Added detailed logging for debugging and updated playback trigger logic to handle `NewPlaylist` and `SetCurrentItem` events even when the item ID hasn't changed. - **Playback Routing**: Integrated SyncPlay into the global playback helpers. Playing an item or playlist now automatically sets the SyncPlay queue if a group is active. - **Player Controls**: Updated the video progress bar and player provider to route user play, pause, and seek actions through the SyncPlay controller when active. - **UI Adjustments**: Updated dashboard FABs to use a `Column` layout in dual-pane mode and improved the visual feedback of the playback information card in the video player. - **Reliability**: Modified `userPlay` to report readiness to the SyncPlay server immediately after requesting an unpause to ensure consistent state broadcasting.
This commit introduces automatic reconnection and group rejoining for SyncPlay when the application resumes from the background. Key changes: - Added `forceReconnect` to `WebSocketManager` to immediately reset and reconnect the socket. - Introduced `_SyncPlayLifecycleObserver` to monitor `AppLifecycleState` changes. - Updated `SyncPlayController` to track connection state and group IDs, enabling automatic re-sync and group re-joining upon app resume. - Improved TV navigation by adding `autofocus` to group creation and list items in the SyncPlay group sheet. - Updated generated route files and provider hashes.
This change updates the `showModalBottomSheet` calls for the `SyncPlayGroupSheet` in both `syncplay_fab.dart` and `dashboard_fabs.dart` to use a transparent background.
This commit introduces UI feedback to inform users when SyncPlay commands (Pause, Unpause, Seek, Stop) are being processed and synchronized with the group. Key changes: - Created `SyncPlayCommandIndicator`, a centered overlay that displays the current command and a syncing status during playback. - Updated `SyncPlayState` and its controller to track and manage command processing states. - Enhanced `SyncPlayBadge` and compact indicators to show a loading state while commands are in flight. - Integrated the new indicator into the video player controls. - Minor cleanup of generated route arguments for `HomeScreen`.
This commit refactors the SyncPlay implementation by splitting the monolithic controller into specialized handlers and moving data models to a dedicated directory. The changes include: - **Refactored Controller**: Extracted command and message handling logic from `SyncPlayController` into `SyncPlayCommandHandler` and `SyncPlayMessageHandler`. - **Model Reorganization**: Moved SyncPlay models and generated files from `lib/providers/syncplay/` to `lib/models/syncplay/`. - **New Command Handler**: Manages execution, scheduling, and duplicate detection of playback commands (Play, Pause, Seek, Stop) using server-synchronized time. - **New Message Handler**: Processes WebSocket group updates, including user joins/leaves, state changes, and play queue synchronization. - **Utility Improvements**: Added `syncplay_utils.dart` for shared UI actions and created a central `syncplay.dart` library export file. - **Cleaned Up Imports**: Updated references across the codebase to reflect the new model locations and helper functions.
This commit adds comprehensive localization support for the SyncPlay feature in both English and French. It also cleans up the project by removing the implementation plan document. Key changes: - Added localized strings for group management (create, join, leave), playback states (playing, pausing, seeking), and participant notifications. - Updated SyncPlay UI components (`SyncPlayBadge`, `SyncPlayGroupSheet`, `SyncPlayCommandIndicator`, and FABs) to use the new localized strings. - Enhanced `SyncPlayMessageHandler` and `SyncPlayController` to support context-aware notifications for user join/leave events.
This change updates the `SideNavigationBar` to consistently display the `SyncPlayFab` alongside the primary action button. Previously, the `SyncPlayFab` was only included via custom FABs (like on the dashboard); it is now integrated into the default layout for all destinations within the side navigation bar.
This commit bridges the native Android video player with the SyncPlay system by routing user interactions (play, pause, seek) through Flutter. This ensures that actions performed on the native player are properly synchronized across SyncPlay group members.
Key changes:
- **Pigeon API Update**: Added `onUserPlay`, `onUserPause`, and `onUserSeek` to `VideoPlayerControlsCallback` to allow the native Android layer to communicate user actions back to Flutter.
- **Native Android UI integration**: Updated `ProgressBar`, `SkipOverlay`, and `VideoPlayerControls` composables to invoke these new Flutter callbacks instead of calling the player directly when SyncPlay is active.
- **SyncPlay Logic Improvements**:
- Introduced a cooldown period (`_syncPlayCooldown`) after receiving a SyncPlay command to prevent feedback loops and accidental double-reporting of buffering states.
- Enhanced `SyncPlayCommandHandler` to improve playback consistency: seeks now only trigger if the difference is >1 second, and the "Seek" command now reports "ready" to the server after completion.
- Updated `VideoPlayerProvider` to maintain playback state (resuming if previously playing) after a seek operation for better consistency.
- **Group Management**: Added checks in `SyncPlayController` to automatically leave an existing group before joining a new one and verified WebSocket connectivity before join attempts.
- **Navigation**: Improved SyncPlay playback initiation by using the shared `openPlayer` logic, ensuring compatibility with both native (Android TV) and Flutter-based players.
…ging - Implement `onUserPlay`, `onUserPause`, and `onUserSeek` in `MediaControlWrapper` to handle native player events via the `videoPlayerProvider`. - Add enhanced logging to `WebSocketManager`, including sanitized URI connection logs and incoming message type tracking. - Update generated `syncplay_provider.g.dart` following logic changes. - Update Android build problem report with new environment paths and updated line references.
- Add device ID logging during `SyncPlayController` initialization. - Enhance WebSocket message logging to include full message contents while filtering out "KeepAlive" noise. - Include raw data in error logs when WebSocket message parsing fails. - Remove unused `video_player.dart` import in `syncplay_controller.dart`.
# Conflicts: # lib/providers/items/movies_details_provider.g.dart
- **SyncPlay Logic Improvements**:
- Add `onSeekRequested` callback to `SyncPlayCommandHandler` to notify the player immediately when a remote seek occurs, allowing for faster buffering reports.
- Implement a confirmation mechanism in `SyncPlayController` using a `Completer` to wait for the `GroupJoined` WebSocket message before confirming a successful join.
- Enhance `SyncPlayMessageHandler` to handle `NotInGroup` messages and provide failure callbacks to the controller.
- Ensure `reportReady` is called after successful reload/seek operations in SyncPlay mode to coordinate group unpausing.
- **Video Player Enhancements**:
- Introduce a `reloading` state in `VideoPlayerProvider` to manage playback transitions during transcoding or audio track changes.
- Update `isBuffering` logic to consider the reloading state, ensuring SyncPlay groups stay synchronized while one member is fetching new playback info.
- Explicitly report buffering to SyncPlay before stopping or loading new playback items.
- Prevent automatic playback after loading an item if SyncPlay is active, deferring control to the synchronization system.
- **Refactoring & Maintenance**:
- Improve position tracking during reloads by prioritizing the current SyncPlay position when active.
- Clean up imports and formatting in `playback_model.dart` and `syncplay_controller.dart`.
- Update generated `movies_details_provider.g.dart`.
- **SyncPlay Logic**:
- Update `_isDuplicateCommand` in `SyncPlayCommandHandler` to ensure "Unpause" commands are never ignored if the player is currently paused, preventing stuck playback.
- Add `onSeekRequested` callback to signal the provider to report buffering immediately when an external seek occurs.
- Modify `userPlay` to request an unpause and rely on the buffering listener to report "Ready" instead of reporting it immediately.
- Ensure `reportReady` is called with the correct `isPlaying` state during buffering transitions and media reloads to synchronize group playback more accurately.
- **Platform Support**:
- Disable `_SyncPlayLifecycleObserver` and skip forced reconnection logic on Web to maintain WebSocket stability when the browser tab is in the background.
- **UI & UX**:
- Add localized snackbar notifications in `SyncPlayMessageHandler` when users join or leave a group.
- **Code Quality**:
- Refactor imports and apply consistent formatting across SyncPlay handler and provider files.
- Improve logging for group join/fail events.
# Conflicts: # android/app/src/main/kotlin/nl/jknaapen/fladder/api/VideoPlayerHelper.g.kt # android/app/src/main/kotlin/nl/jknaapen/fladder/composables/controls/ProgressBar.kt # android/app/src/main/kotlin/nl/jknaapen/fladder/composables/controls/VideoPlayerControls.kt # android/build/reports/problems/problems-report.html # lib/models/playback/playback_model.dart # lib/providers/video_player_provider.dart # lib/screens/video_player/components/video_progress_bar.dart # lib/src/video_player_helper.g.dart # lib/util/item_base_model/play_item_helpers.dart # lib/wrappers/media_control_wrapper.dart # pigeons/video_player.dart # pubspec.yaml
- **Native Android Integration**:
- Created `SyncPlayCommandOverlay` composable to display real-time SyncPlay action status (Pause, Unpause, Seek, Stop, Syncing) on the native player layer.
- Updated `VideoPlayerControls` to include the new overlay.
- Added `setSyncPlayCommandState` to the Pigeon-defined `VideoPlayerApi` to bridge state from Flutter to native Kotlin.
- **Flutter SyncPlay Logic**:
- Enhanced `VideoPlayerProvider` to listen for SyncPlay state changes and forward processing status and command types to the player wrapper.
- Added `updateSyncPlayCommandState` to `MediaControlWrapper` to communicate with the native player implementation.
- Integrated `SyncPlayCommandIndicator` and `SyncPlayBadge` into `TvPlayerControls`.
- **Internationalization**:
- Added new Pigeon-mapped translation strings for SyncPlay status messages (e.g., "Pausing...", "Seeking...", "Syncing with group").
- Updated `LocalizationHelper` and generated translation files to support these new keys.
- **General Improvements**:
- Added `FladderItemType.tvchannel` to the library filter model.
- Updated generated provider files and performed minor code formatting in `VideoProgressBar`.
- Implement the `Translate` wrapper in `SyncPlayCommandOverlay` to handle asynchronous localization for command labels (Pause, Unpause, Seek, Stop, and Syncing). - Update the "Syncing with group" status text to use the new translation callback mechanism. - Remove the static `getCommandLabel` helper in favor of inline localized callbacks.
There was a problem hiding this comment.
Probably better to move these callbacks to the ExoPlayer and listen to state changes from the player itself.
There was a problem hiding this comment.
Same here I'm not even sure we need to use these callbacks.
The player already reports back it's state to flutter we could probably use that for reporting seek/pause/play states. That way we don't rely on any additional kotlin implementation.
| modifier: Modifier = Modifier | ||
| ) { | ||
| val syncPlayState by VideoPlayerObject.syncPlayCommandState.collectAsState() | ||
| val visible = syncPlayState.processing && syncPlayState.commandType != null |
There was a problem hiding this comment.
This will recalculate whenever the state changes. Probably fine for a small composable but lets change this to
| val visible = syncPlayState.processing && syncPlayState.commandType != null | |
| val visible by remember(syncPlayState) { | |
| derivedStateOf { | |
| syncPlayState.processing && syncPlayState.commandType != null | |
| } | |
| } |
| Translate( | ||
| callback = { cb -> | ||
| when (syncPlayState.commandType) { | ||
| "Pause" -> Localized.syncPlayCommandPausing(cb) |
There was a problem hiding this comment.
Create a enum for this. Pigeon supports enum's that way both flutter/kotlin are in sync and we don't rely on Strings.
| } | ||
|
|
||
| // SyncPlay command state for overlay | ||
| data class SyncPlayCommandState( |
There was a problem hiding this comment.
Let's put this in pigeon.
| key: const Key("Search"), | ||
| onPressed: () => context.router.navigate(LibrarySearchRoute()), | ||
| child: const Icon(IconsaxPlusLinear.search_normal_1), | ||
| ); |
There was a problem hiding this comment.
Not sure about the position of this widget. Let's remove it from the dashboard for now.
We should not be using multiple fabs together in a single navigation rail.
We'll have to find a better spot.
| final isProcessing = ref.watch(syncPlayProvider.select((s) => s.isProcessingCommand)); | ||
| final processingCommand = ref.watch(syncPlayProvider.select((s) => s.processingCommandType)); | ||
|
|
||
| final (icon, color) = switch (groupState) { |
|
|
||
| String _getProcessingText(BuildContext context, String? command) { | ||
| return switch (command) { | ||
| 'Pause' => context.localized.syncPlaySyncingPause, |
There was a problem hiding this comment.
This is re-used quite a lot just adding a reminder to replace this with the enum and extension method.
| _loadGroups(); | ||
| } | ||
|
|
||
| Future<void> _loadGroups() async { |
There was a problem hiding this comment.
Would be cleaner to lift this state out of the widget and put it in a provider.
| flutter_native_splash: ^2.4.7 | ||
| macos_window_utils: ^1.9.0 | ||
|
|
||
| web_socket_channel: ^3.0.3 |
There was a problem hiding this comment.
Let's move it to "# Network and HTTP" group.
There was a problem hiding this comment.
First of thanks for implementing this, pretty big PR. But something a lot of people where requesting 👍🏼.
Works pretty well for the most part, some notes/quirks though. These are some initial findings will have to go over it after some changes.
About the UI itself. I left some comments about UI choices. However I will probably go over it myself to make some changes to bring it more in line with Fladder as it currently is.
UX:
We should show a loading indicator when any of the users press a play button. Now it has to load for a bit before the playback starts because Fladder is still synchronising.
When a player “stops” playback should all other participants return to the previous screen as well?
Architecture:
Currently most of the calls inside of the.UI go to videoplayerprovider but it now either calls the original player.pause or a new syncprovider.pause.
Like mentioned in the comments it would be better to listen to the players state stream and adjust everything based on that.
Bugs:
Playback stops working when syncplay becomes out of sync. Leaving/creating a group does nothing to change this state.
Fladder starts playback and finishes loading the video but it remains in a “paused” state as if it’s awaiting the syncplay to synchronize.
Sometimes “play” commands seem to not propagate to other users
|
Also mentioned in some comments. But there is a lot of re-formatting making it difficult to review the changes. Please re-format all files using the .vscode/settings.json. The biggest issue being the 120 line length currently not being used in your formatter. |
# Conflicts: # lib/providers/items/movies_details_provider.g.dart # lib/providers/library_screen_provider.g.dart
This commit refines the SyncPlay implementation by introducing a more robust way to distinguish between user-initiated actions and server-commanded playback changes. It bridges the gap between the native Android player and the Flutter-based SyncPlay controller by tagging playback state updates with their source.
Key changes:
- **Playback State Inference**:
- Added `PlaybackChangeSource` (none, user, syncplay) to the Pigeon API and `PlaybackState` model.
- Updated native `ExoPlayer` and `VideoPlayerObject` to track and report whether a state change was triggered by the native UI or a SyncPlay command.
- Enhanced `VideoPlayerNotifier` to automatically trigger SyncPlay actions (`userPlay`, `userPause`, `userSeek`) when it detects `PlaybackChangeSource.user` from the native state stream.
- **SyncPlay Logic Improvements**:
- Introduced `SyncPlayGroups` provider and `SyncPlayGroupsState` (using Freezed) to manage group listing and UI loading states.
- Updated `SyncPlayController` and `SyncPlayMessageHandler` to handle "Waiting" and "Playing" states more accurately, ensuring the player recovers if an "Unpause" command is missed.
- Improved group lifecycle management: clearing processing states and canceling pending commands when leaving or being kicked from a group to prevent playback from becoming "stuck."
- **UI & Extensions**:
- Extracted SyncPlay UI logic into `SyncPlayGroupStateExtension` and `SyncPlayCommandLabelExtension` for cleaner, localized badge and indicator rendering.
- Refactored `SyncPlayGroupSheet` to use the new `syncPlayGroupsProvider` for better state separation.
- Integrated the new `SyncPlayCommandIndicator` and badge logic into the video player overlays.
- **Maintenance**:
- Updated `web_socket_channel` dependency location and generated files (`syncplay_provider.g.dart`, `VideoPlayerHelper.g.kt`).
- Standardized formatting and imports across several provider and model files.
# Conflicts: # android/app/src/main/kotlin/nl/jknaapen/fladder/api/VideoPlayerHelper.g.kt # android/app/src/main/kotlin/nl/jknaapen/fladder/messengers/VideoPlayerImplementation.kt # lib/l10n/app_fr.arb # lib/main.dart # lib/models/account_model.freezed.dart # lib/models/account_model.g.dart # lib/models/settings/client_settings_model.freezed.dart # lib/models/settings/client_settings_model.g.dart # lib/models/settings/video_player_settings.g.dart # lib/providers/video_player_provider.dart # lib/routes/auto_router.gr.dart # lib/screens/login/login_screen.dart # lib/screens/video_player/video_player_controls.dart # lib/seerr/seerr_models.g.dart # lib/src/video_player_helper.g.dart # lib/util/application_info.freezed.dart # lib/util/item_base_model/play_item_helpers.dart # lib/widgets/navigation_scaffold/components/side_navigation_bar.dart # pigeons/video_player.dart
|
Hi! What's the status of this PR? Are the fixes discussed above still needed, or has everything been fixed? I'm curious because I'm really looking forward to this feature. Thanks for such a great app! |
|
Hi ! |
|
I you need someone to test it out I can help. |
# Conflicts: # lib/main.dart # lib/models/seerr/seerr_item_models.dart # lib/models/settings/arguments_model.freezed.dart # lib/models/settings/client_settings_model.g.dart # lib/models/syncing/sync_item.freezed.dart # lib/providers/seerr_service_provider.dart # lib/routes/auto_router.gr.dart # lib/screens/login/login_code_dialog.dart
…onization - Implement a playback drift correction system introducing `SpeedToSync` (playback rate adjustment) and `SkipToSync` (seeking) strategies to maintain alignment with the group. - Add `SyncCorrectionConfig` and `SyncCorrectionState` models to define thresholds and track synchronization attempts. - Update `SyncPlayController` to calculate drift against estimated server time and manage correction timers and cooldowns. - Enhance `SyncPlayCommandHandler` to reorder the "Unpause" sequence (seek before play) for smoother alignment and add guards for correction logic. - Optimize `VideoPlayerProvider` and `PlaybackModel` to distinguish between local track switches and group-initiated reloads, preventing unnecessary group-wide pauses during local adjustments. - Update `SyncPlayCommandIndicator` and `SyncPlayBadge` UI components to provide visual feedback when synchronization strategies are active. - Refactor `SyncPlayProvider` to expose new states for correction runtime and strategy monitoring. - Clean up code formatting and update boilerplate across various generated files, including OpenAPI swagger clients, Freezed models, and Mappable classes. - Ensure `routerProvider` is correctly initialized in `BaseAppWrapper`.
- Introduce `SyncPlayCommand` and `SyncPlayStateReason` enums to replace raw string processing and centralize command logic. - Implement "local-only" operation mode to suppress group-wide pausing during local audio or subtitle track reloads. - Add re-entrancy protection and debouncing for `_startPlayback` and `setNewQueue` to prevent crashes during concurrent group actions. - Introduce a "Resume group playback" feature in the group sheet to allow re-joining active sessions from outside the player. - Improve UI feedback with a specialized loader UX during SyncPlay initialization and standardized snackbar notifications for group events. - Refactor `VideoPlayerProvider` to perform immediate local seeks for better UI responsiveness while awaiting server synchronization. - Update `SyncPlayCommandHandler` to support late command execution, improved duplicate detection for unpausing, and position estimation. - Harden video player lifecycle management with `mounted` guards and microtasks to prevent "modify provider during build" errors. - Fix a crash on Flutter Web by guarding the Windows-specific SMTC platform check. - Add comprehensive unit tests for `SyncPlayCommandHandler` and sync correction strategy selection.
Clear _lastSetNewQueueAt, _currentlyStartingPlaylistItemId, in-flight completers, and the join-group completer when leaving or being kicked. Previously the 1s setNewQueue debounce leaked across rejoin, silently swallowing the first play request after re-entering a group.
Check _state.isInGroup at each await boundary inside _startPlayback so that a leaveGroup() during init/fetch/load no longer re-opens the player route on top of media that no longer belongs to any group.
…tartPlayback failure Wrap loadPlaybackItem's media-kit calls in try/catch and always reset SyncPlay's player-buffering flag on the way out. Mirror that in _startPlayback's finally block so an aborted/failed start no longer leaves the group in Waiting (Buffer) indefinitely.
Replace the manual reportBuffering on _onGroupJoined with rejoinPlayback when the group has an active item. Matches jellyfin-web behavior and prevents the group from being stranded in Waiting because a fresh joiner had not yet clicked the Resume Playback button.
When the group enters Waiting with reason=Buffer, mirror the group state locally before reporting ready. Previously the local video kept playing while the group was paused, then snapped when the broadcast Unpause command arrived. Matches the protocol described in docs/syncplay-implementation.md and AGENTS.md rule 10.
Surface the existing /SyncPlay/NextItem and /SyncPlay/PreviousItem endpoints on the controller and provider. Both pass the current playlistItemId so the server can reject stale requests. Used by the upcoming next-episode migration.
…for episode advance loadNewVideo now detects whether the target item is the next or previous queue entry and calls the lightweight /SyncPlay/NextItem or /SyncPlay/PreviousItem endpoints accordingly. Falls back to setNewQueue only for non-adjacent jumps. Matches jellyfin-web behavior, eliminates the 1-second silent debounce on rapid Next clicks, and preserves the group's existing queue context across episode changes.
…em reasons Whitelist NextItem and PreviousItem reasons explicitly in _handlePlayQueue so the local player loads the new item even if the group is currently paused or the previous item id matches some edge case.
…load Reuse syncPlayStartPlaybackInProgressProvider in the existing command indicator so the user gets visible feedback during the API round-trip plus media reload triggered by Next/Previous Episode (player controls, auto-advance timer, OS media controls)
…witch shouldReload now extrapolates the group's current playhead from the last Unpause command's timestamp instead of reading the frozen positionTicks from SyncPlayState. Eliminates the SkipToSync jump that followed every audio/subtitle switch on transcoded media.
Hoist the SyncPlay entry point out of the action-button column so each rail destination shows at most one FAB. AGENTS.md rule 4 explicitly prohibits stacked FABs in a navigation rail. SyncPlay remains accessible from the dashboard FAB and the SyncPlayBadge. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add a manual-verification checklist covering the lifecycle, stuck-state recovery, next-episode flow, track switching, and UI placement bugs fixed in this branch. Future PRs touching SyncPlay should re-run the list before merge.
Drive-by lint fix on a pre-existing analyzer info that would fail CI's --fatal-infos check.
Navigator.push returns a Future that completes when the route is popped, so awaiting openPlayer kept _startPlayback's try block alive for the entire time the player was visible. That left startPlaybackInProgress permanently true while playing, which made the "Switching item…" overlay stick on top of the player. Push the player route fire-and-forget so the finally block runs immediately after scheduling and resets startPlaybackInProgress as soon as the load phase finishes.
Close button on the player now calls stop() and nulls the playback model whether or not SyncPlay is active. Previously the SyncPlay branch only called pause() and left the playback model alive, which kept the floating mini-player visible and let a server-broadcast Unpause resume local playback in the background — making the close button feel like a minimize button. User stays in the SyncPlay group; they can re-attach via the sheet's "Resume Playback" button if they want to rejoin the watch session.
stop() reports the session to the server via POST /Sessions/Playing/Stopped which can take 2+ seconds. Awaiting it kept the player route on screen (black background + live controls) for the duration, and the post-await ref.read calls hit the now-disposed widget state with "Cannot use ref after the widget was disposed". Pop the route first, fire-and-forget the stop. Matches the original non-SyncPlay branch behaviour.
Click on Next/Previous Video now starts the local media swap immediately instead of waiting for the server's NextItem → PlayQueue round-trip. Previously the user-perceived switch took: click → server round-trip (200-1500ms) → API fetches (300-800ms) → createPlaybackModel (1000-1500ms) → media reload (500-2000ms) = 2-5s with the "Switching item…" overlay covering the old episode the whole time. New flow: click → fire requestNextItem AND start local load in parallel. The local load finishes ~1-2s after click; the server's PlayQueue broadcast arrives mid-load and is dedup'd by _startPlayback because the item is already loaded (or in flight via the same dedup keys). Implementation: - SyncPlayController.runOptimisticPlayback(item, position) — sets startPlaybackInProgress + currentlyStartingPlaylistItemId so the existing dedup machinery covers the eventual PlayQueue-driven _startPlayback call. Loads with waitForSyncPlayCommand: false so the local switch doesn't pause the rest of the group. - _startPlayback now has a top-of-method check that skips when the local player already has the requested item, plus a post-wait recheck for the in-flight optimistic case. - loadNewVideo's adjacent-item branches fire requestNext/PreviousItem AND runOptimisticPlayback both unawaited so the click returns immediately. Non-adjacent jumps still go through setNewQueue and wait for the broadcast (server may resolve to a different item).
Extend the optimistic preload pattern from next/previous-episode to the two setNewQueue call sites: - _playSyncPlay (initial play from library): kick runOptimisticPlayback after the queue is accepted. The local load runs in parallel with the server's PlayQueue broadcast; runOptimisticPlayback also pushes the player route on success so the user sees the new media as soon as it's ready (instead of after the dialog closes and _startPlayback finishes its full load). - loadNewVideo non-adjacent fallback (arbitrary library item picked while in a SyncPlay group): same pattern. With a single-item queue the server will play exactly the item we send, so optimistic is safe. Also: _startPlayback's dedup-skip-because-already-loaded branch now pushes the player route as a fallback if the optimistic flow couldn't (no navigator context at the time, race, etc.).
Two earlier changes are reverted because they didn't behave correctly in practice: 1. The migration to NextItem/PreviousItem (60aef441) — the server's PlayQueue broadcast wasn't reliably triggering _startPlayback for these reasons in real sessions, so the local player never switched. 2. The optimistic-local-load pattern that was layered on top — the user explicitly wants the SyncPlay flow to drive the load (only load when the new playlist arrives from the server), so loading locally before the broadcast is incorrect. loadNewVideo now sends setNewQueue([newItem.id]) for every episode change, matching the proven flow used by _playSyncPlay for initial play. The server broadcasts PlayQueue/NewPlaylist, _handlePlayQueue triggers _startPlayback, and the "Switching item…" overlay covers the wait. Removes: - runOptimisticPlayback method on SyncPlayController + provider exposure - ItemBaseModel import on the controller and provider - "skip if local item already loaded" dedup at the top of _startPlayback - "match by itemId or playlistItemId" dedup variant - runOptimisticPlayback calls in _playSyncPlay and loadNewVideo - Unused dart:async import in play_item_helpers.dart
Seek on a playing player no longer leaves the group stuck paused. The Seek command handler paused the local player as part of the seek protocol, so playbackState.playing was false when the seek-induced buffering ended. The buffering listener in video_player_provider then fired Ready(isPlaying: playbackState.playing) — i.e. isPlaying:false — which overrode the command handler's earlier Ready(isPlaying:true) and the server kept the group in Waiting/Paused without broadcasting the Unpause that would resume the player. Two coordinated changes: - The Seek case in _executeCommand now waits for the seek-induced buffering to clear (new _waitUntilNotBuffering helper, 10s timeout) and then unconditionally calls onReportReady. The old "skip Ready if buffering" gate is gone — buffering during a seek is the rule, not the exception. - updateBuffering in video_player_provider now also short-circuits its own reportReady/reportBuffering while syncPlayProvider's isProcessingCommand is true. The command handler owns the Buffering/Ready exchange during command execution; the listener resumes its normal duty as soon as isProcessingCommand flips back to false.
… load Initial play via SetNewQueue no longer requires one user to press play to start the group. Root cause: loadPlaybackItem auto-played (loadVideo with play=true plus state.play() after) and relied on the buffering listener to send Ready when buffering=false. media-kit on web doesn't reliably emit playing=true synchronously with buffering=false, so the listener almost always raced the actual play and sent Ready(isPlaying:false). Server saw all clients report isPlaying:false and never broadcast Unpause — playback only resumed when a user manually pressed play. Three coordinated changes: - loadPlaybackItem now passes play=false to state.loadVideo and skips the trailing state.play() when waitForSyncPlayCommand is true. The protocol's Unpause command (broadcast after all clients report Ready) is what drives playback for the group; auto-playing here raced the protocol. - After loadPlaybackItem finishes the load, it explicitly calls reportReady(isPlaying: true). This is the single authoritative Ready the server sees for the load — server broadcasts Unpause, command handler's onPlay calls state.play(), playback starts. - A new private flag `_isLoadingForSyncPlay` suppresses the buffering listener's auto-Ready/Buffering reports during this window so the listener can't fire a stale Ready before the explicit one.
Audio/subtitle track changes are local operations that should leave the rest of the group untouched and the local player should resume on the new track. Two coordinated fixes: 1. Suppress the /Sessions/Playing/Stopped POST during in-route reloads. media_control_wrapper.stop() fires playbackStopped after a 1-second delay, which Jellyfin treats as a session-lifecycle event and broadcasts to the SyncPlay group — pausing other clients (and ourselves via T5's "pause locally on Buffer" handler). _startPlayback already nulls playBackModel before init+stop for the same reason. loadPlaybackItem now does the same thing when SyncPlay is active: state.stop() exits early on null model, media-kit's open() in loadVideo replaces the media in place — no explicit stop is needed for an in-route reload. 2. Make _ensureLocalTrackSwitchAutoplay actually retry. Previously it polled 8 × 250ms but only called play() once on the first not-buffering-not-playing iteration. media-kit on web sometimes drops the first one or two play() calls after a track change / transcode reload (media still settling). Now we re-issue play() on every iteration the player is neither playing nor buffering, exiting the moment playing=true is observed. Loop budget extended to 12 × 250ms so we have a couple of extra retries on slow loads.
Closing the player and clicking Resume Playback (without leaving the group) used to reset every other client's playback to where the rejoiner had been when they closed — visible as "everyone restarted near the beginning" from the perspective of users who kept playing. Two coordinated changes: 1. Use the live extrapolated group position when rejoining instead of `_state.positionTicks`. The state field only updates on server-broadcast state changes (Pause/Unpause/Seek/Buffering), not during continuous playback, so it can be many minutes stale. `estimateCurrentGroupPositionTicks()` extrapolates from the last Unpause command's `When` timestamp, giving the live position. The server then issues a normal Unpause at the live position with `When = now + small_latency` rather than scheduling a catch-up Unpause far in the future at the stale position. 2. Allow `reportBuffering` / `reportReady` to override the position reported to the server. The local player is at 0 (just got reset by loadPlaybackItem) when these reports fire, but the rejoiner intends to load at the live group position — sending 0 there makes the server use 0 as the group's position, and was the second source of the rewind. `loadPlaybackItem` now passes the `startPosition` ticks explicitly to both reports.
Single-line `test(...)` declaration that fits within the project's line-length-120 limit; CI formatter would have flagged it.
Android-TV (leanback / NativePlayer / ExoPlayer): * Wait for ExoPlayer's STATE_BUFFERING to clear after Unpause before clearing isProcessingCommand. Without this the player-state listener leaked a stale Buffering report once the 500ms cooldown expired, which self-amplified into a 1-Hz buffer/resume loop in any group containing a TV. * Same wait on Pause-with-seek where the correction seek puts ExoPlayer through STATE_BUFFERING. * Wire NativePlayer.setSpeed (was an empty stub) through the already-existing setPlaybackSpeed Pigeon path. SyncPlay drift correction's hasPlaybackRate gate now returns true on TV, so the strategy selector picks SpeedToSync (a brief rate nudge, no buffering) instead of falling back to SkipToSync — which on ExoPlayer triggers a real seek-buffer cycle on every post-Unpause drift > 400ms. Seek path: * Cap the seek-case _waitUntilNotBuffering at 2s instead of the default 10s. libMPV (phone/web) keeps paused-for-cache true conservatively while the player is paused; on phone this used to leave the "Syncing..." overlay and buffering spinner up for the full 10s timeout after a SyncPlay seek. Pressing play used to skip the wait because libMPV emits buffering=false the moment it transitions to playing — capping at 2s achieves the same effect automatically. Post-disconnect resilience: * Drop replayed commands more than 30s late. ColorOS / aggressive Android battery savers can drop the SyncPlay WebSocket during brief window-focus loss; on reconnect the server replays its queued backlog with stale `When` timestamps. Executing them would chase positions far past EOF and start a buffer oscillation. The current StateUpdate messages from the server resync us correctly. * Restrict _estimateCurrentTicks to Unpause commands only. Pause/Seek/Stop are static targets — the original PositionTicks is the authoritative value regardless of how late the command arrives. Without this, even a 5s-late Pause was extrapolating position by elapsed delay. * Auto-rejoin the last-known group on a transparent WebSocket reconnect. The existing AppLifecycleState observer doesn't fire on brief window-focus loss (only on app-level pause), so ColorOS-induced socket drops left users evicted from the group on the server but unaware locally. _attemptSilentRejoin runs on every disconnected→connected transition where _lastGroupId is set, sharing a _sendJoinRequest helper with joinGroup. The server is the source of truth across the gap. * Skip the rejoinPlayback() reload in _onGroupJoined when the local player is already showing the joining item. Reloading was tearing the player to ticks=0 and the resulting reportReady(positionTicks: 0) inside loadPlaybackItem was broadcast by the server as the new group position, resetting every other client to the start. * Defer _stopLocalPlayback to a microtask. It used to read videoPlayerProvider and update playBackModel.notifier synchronously while a Riverpod state-update chain (originating in the WebSocket message handler) was still on the stack, triggering CircularDependencyError on every server-initiated group leave. Race fix: * _lastGroupId is now stamped only after join confirmation, not before the awaited API call. Otherwise a WS bounce mid-join would trip _handleConnectionState's silent-rejoin path, which would create a second join completer and race the original. Tests: * New SyncPlayCommandHandler tests cover the Unpause and Pause-with-seek defer-state-clear contracts via a sentinel-detection pattern.
…ssues - connect() is now idempotent: reuse the existing WebSocketManager instead of spawning a second socket. Stops the leaked-socket / misdetected-reconnect path that fired an unwanted silent rejoin on every group-sheet open (extra Join -> server re-broadcasts UserJoined). - _handleUserJoined ignores an already-present participant, defending against Jellyfin's re-join broadcast (UserJoined with no matching UserLeft) that stacked ghost/duplicate users. - _sendJoinRequest reconciles against authoritative state on timeout (12s) instead of hard-failing, and a guarded _completeJoinRequest helper removes the shared-completer race / double-complete crash. Fixes the false 'Failed to join group'. - WebSocket URL strips trailing slash(es) so a serverUrl like 'https://host/' no longer yields '//socket' (proxy 404s). - Group sheet shows participant names instead of 'X participants'.
- _onGroupJoined now stamps _lastGroupId from the authoritative server frame instead of the awaited joinGroup bool. A slow socket could make _sendJoinRequest reconcile/return before GroupJoined landed, leaving _lastGroupId null and silently breaking the WS-reconnect silent-rejoin invariant (AGENTS.md DonutWare#10 recovery scenario) even though we were in the group. Removes the now-redundant stamping + race comment in joinGroup. - Active-group participants Text is bounded (maxLines: 2, ellipsis) so a long name list cannot overflow the sheet header, matching the list tile's overflow handling.
Pull Request Description
Adds Jellyfin SyncPlay support so users can watch media together in sync across devices.
docs/syncplay-implementation.mddocuments the protocol and architecture.Issue Being Fixed
Feature request: SyncPlay support for watching together with other Jellyfin clients.
Screenshots / Recordings
fladder_syncplay_demo_beta_compressed.mp4
Checklist
(Added:
web_socket_channel^3.0.3 — used for SyncPlay WebSocket. pub.dev; cross-platform.)