Skip to content

Comments

feat: Jellyfin SyncPlay support#735

Open
irican-f wants to merge 21 commits intoDonutWare:developfrom
irican-f:syncplay
Open

feat: Jellyfin SyncPlay support#735
irican-f wants to merge 21 commits intoDonutWare:developfrom
irican-f:syncplay

Conversation

@irican-f
Copy link

@irican-f irican-f commented Feb 3, 2026

Pull Request Description

Adds Jellyfin SyncPlay support so users can watch media together in sync across devices.

  • Backend: WebSocket connection to Jellyfin for real-time commands; REST API for group create/join/leave, pause/unpause, seek, buffering/ready; NTP-like time sync for command scheduling; duplicate command handling and buffering-aware playback.
  • UI: SyncPlay FAB and badge in the app shell; bottom sheet to create/join/leave groups and see participants; command indicator (syncing pause/play/seek); native Android command overlay when using the native player.
  • Lifecycle: Reconnect WebSocket and rejoin group on app resume (mobile); Web skipped for background behavior.
  • L10n: English and French for all SyncPlay strings.
  • Docs: docs/syncplay-implementation.md documents the protocol and architecture.

Issue Being Fixed

Feature request: SyncPlay support for watching together with other Jellyfin clients.

Screenshots / Recordings

  • Screenshot of SyncPlay FAB and group sheet
  • Screenshot of command indicator (e.g. "Syncing pause...") during sync
  • (Optional) Short recording: join group from two devices, pause/unpause/seek in sync
image image image image image image image
fladder_syncplay_demo_beta_compressed.mp4

Checklist

  • If a new package was added, did you ensure it works for all supported platforms? Is the package well maintained
    (Added: web_socket_channel ^3.0.3 — used for SyncPlay WebSocket. pub.dev; cross-platform.)
  • Check that any changes are related to the issue at hand.

irican-f and others added 17 commits January 10, 2026 01:22
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.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Probably better to move these callbacks to the ExoPlayer and listen to state changes from the player itself.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will recalculate whenever the state changes. Probably fine for a small composable but lets change this to

Suggested change
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)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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(
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's put this in pigeon.

key: const Key("Search"),
onPressed: () => context.router.navigate(LibrarySearchRoute()),
child: const Icon(IconsaxPlusLinear.search_normal_1),
);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Extension please.


String _getProcessingText(BuildContext context, String? command) {
return switch (command) {
'Pause' => context.localized.syncPlaySyncingPause,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would be cleaner to lift this state out of the widget and put it in a provider.

pubspec.yaml Outdated
flutter_native_splash: ^2.4.7
macos_window_utils: ^1.9.0

web_socket_channel: ^3.0.3
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's move it to "# Network and HTTP" group.

Copy link
Collaborator

@PartyDonut PartyDonut left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

@PartyDonut
Copy link
Collaborator

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.

@PartyDonut PartyDonut added the feature New feature or request label Feb 3, 2026
Filip Iricanin and others added 4 commits February 11, 2026 16:16
# 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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants