From 0e93cd5385d8853f5acdc3613d6e5d410e752a47 Mon Sep 17 00:00:00 2001 From: Alex Kim Date: Fri, 5 Jun 2026 20:00:55 +1000 Subject: [PATCH] Improve code quality --- app/(auth)/player/direct-player.tsx | 22 +- components/syncplay/SyncPlayIndicator.tsx | 165 --- components/syncplay/index.ts | 5 +- providers/SyncPlay/Controller.ts | 415 ++------ providers/SyncPlay/EventEmitter.ts | 93 ++ providers/SyncPlay/Helper.ts | 448 -------- providers/SyncPlay/Manager.ts | 958 +++++------------- providers/SyncPlay/PlaybackCore.ts | 881 ---------------- providers/SyncPlay/QueueCore.ts | 292 ------ providers/SyncPlay/SyncPlayProvider.tsx | 483 +++------ providers/SyncPlay/TimeSyncCore.ts | 284 ------ providers/SyncPlay/constants.ts | 23 + providers/SyncPlay/cores/PlaybackCore.ts | 371 +++++++ providers/SyncPlay/cores/QueueCore.ts | 332 ++++++ providers/SyncPlay/cores/TimeSync.ts | 220 ++++ providers/SyncPlay/index.ts | 26 +- .../SyncPlay/player/PendingPlaybackTracker.ts | 58 ++ providers/SyncPlay/player/PlayerWrapper.ts | 87 ++ .../SyncPlay/player/bufferingDebouncer.ts | 64 ++ .../player/reconcileToGroupOnAttach.ts | 58 ++ .../SyncPlay/transport/queueTranslation.ts | 183 ++++ .../{ => transport}/useSyncPlayWebSocket.ts | 15 +- providers/SyncPlay/types.ts | 115 +-- 23 files changed, 2050 insertions(+), 3548 deletions(-) delete mode 100644 components/syncplay/SyncPlayIndicator.tsx create mode 100644 providers/SyncPlay/EventEmitter.ts delete mode 100644 providers/SyncPlay/Helper.ts delete mode 100644 providers/SyncPlay/PlaybackCore.ts delete mode 100644 providers/SyncPlay/QueueCore.ts delete mode 100644 providers/SyncPlay/TimeSyncCore.ts create mode 100644 providers/SyncPlay/constants.ts create mode 100644 providers/SyncPlay/cores/PlaybackCore.ts create mode 100644 providers/SyncPlay/cores/QueueCore.ts create mode 100644 providers/SyncPlay/cores/TimeSync.ts create mode 100644 providers/SyncPlay/player/PendingPlaybackTracker.ts create mode 100644 providers/SyncPlay/player/PlayerWrapper.ts create mode 100644 providers/SyncPlay/player/bufferingDebouncer.ts create mode 100644 providers/SyncPlay/player/reconcileToGroupOnAttach.ts create mode 100644 providers/SyncPlay/transport/queueTranslation.ts rename providers/SyncPlay/{ => transport}/useSyncPlayWebSocket.ts (83%) diff --git a/app/(auth)/player/direct-player.tsx b/app/(auth)/player/direct-player.tsx index b6598537c..e9591e98c 100644 --- a/app/(auth)/player/direct-player.tsx +++ b/app/(auth)/player/direct-player.tsx @@ -153,7 +153,6 @@ export default function DirectPlayerPage() { isEnabled: isSyncPlayEnabled, controller: syncPlayController, setPlayerControls, - notifyReady, notifyBuffering, } = syncPlay; @@ -484,18 +483,10 @@ export default function DirectPlayerPage() { } const isLocallyReady = isVideoLoaded && !isBuffering; - if (isLocallyReady) { - notifyReady(); - } else { - notifyBuffering(); - } - }, [ - isSyncPlayEnabled, - isVideoLoaded, - isBuffering, - notifyReady, - notifyBuffering, - ]); + // notifyBuffering routes through the debouncer in PlaybackCore so + // re-renders during a stall don't spam the server. + notifyBuffering(!isLocallyReady); + }, [isSyncPlayEnabled, isVideoLoaded, isBuffering, notifyBuffering]); // SyncPlay: Pause playback when group is waiting useEffect(() => { @@ -982,10 +973,11 @@ export default function DirectPlayerPage() { const seek = useCallback( (position: number) => { - // Route through SyncPlay when active + // Route through SyncPlay when active. `position` is in ms; the + // controller takes ticks (1 ms = 10000 ticks). if (isSyncPlayEnabled && syncPlayController) { console.log("SyncPlay: seek requested via SyncPlay", position); - syncPlayController.seekMs(position); + syncPlayController.seek(Math.round(position * 10000)); return; } diff --git a/components/syncplay/SyncPlayIndicator.tsx b/components/syncplay/SyncPlayIndicator.tsx deleted file mode 100644 index 00f0c8939..000000000 --- a/components/syncplay/SyncPlayIndicator.tsx +++ /dev/null @@ -1,165 +0,0 @@ -/** - * SyncPlayIndicator - * - * Visual indicator shown during SyncPlay operations. - * Only appears when user's stream is ready but waiting for other group members. - * - * Key principle: SyncPlay indicator = "You're ready, waiting on others" - */ - -import { Ionicons } from "@expo/vector-icons"; -import { useEffect } from "react"; -import { useTranslation } from "react-i18next"; -import { StyleSheet, View } from "react-native"; -import Animated, { - Easing, - useAnimatedStyle, - useSharedValue, - withRepeat, - withTiming, -} from "react-native-reanimated"; -import { Text } from "@/components/common/Text"; - -// SyncPlay cyan color (matches Jellyfin-web) -const SYNC_PLAY_COLOR = "#00a4dc"; - -interface SyncPlayIndicatorProps { - /** - * Whether the indicator should be visible. - * Should only be true when: - * 1. User's stream has loaded - * 2. Waiting for other group members - */ - visible: boolean; - - /** - * Optional message to display - */ - message?: string; -} - -export function SyncPlayIndicator({ - visible, - message, -}: SyncPlayIndicatorProps) { - const { t } = useTranslation(); - const displayMessage = message ?? t("syncplay.waiting_for_group"); - const opacity = useSharedValue(0); - const scale = useSharedValue(1); - - useEffect(() => { - if (visible) { - opacity.value = withTiming(1, { duration: 200 }); - scale.value = withRepeat( - withTiming(1.15, { - duration: 800, - easing: Easing.inOut(Easing.ease), - }), - -1, - true, - ); - } else { - opacity.value = withTiming(0, { duration: 200 }); - scale.value = 1; - } - }, [visible, opacity, scale]); - - const containerStyle = useAnimatedStyle(() => ({ - opacity: opacity.value, - })); - - const pulseStyle = useAnimatedStyle(() => ({ - transform: [{ scale: scale.value }], - })); - - if (!visible) { - return null; - } - - return ( - - - {/* Pulsing icon container */} - - - - - - - {/* Message */} - {displayMessage} - - {/* SyncPlay badge */} - - - SyncPlay - - - - ); -} - -const styles = StyleSheet.create({ - container: { - ...StyleSheet.absoluteFill, - justifyContent: "center", - alignItems: "center", - backgroundColor: "rgba(0, 0, 0, 0.7)", - zIndex: 100, - }, - content: { - alignItems: "center", - }, - iconContainer: { - marginBottom: 16, - }, - iconCircle: { - width: 64, - height: 64, - borderRadius: 32, - backgroundColor: SYNC_PLAY_COLOR, - justifyContent: "center", - alignItems: "center", - // Glow effect - shadowColor: SYNC_PLAY_COLOR, - shadowOffset: { width: 0, height: 0 }, - shadowOpacity: 0.6, - shadowRadius: 16, - elevation: 8, - }, - message: { - color: "white", - fontSize: 16, - fontWeight: "500", - marginBottom: 8, - }, - badge: { - flexDirection: "row", - alignItems: "center", - backgroundColor: "rgba(0, 164, 220, 0.2)", - paddingHorizontal: 12, - paddingVertical: 6, - borderRadius: 16, - borderWidth: 1, - borderColor: SYNC_PLAY_COLOR, - }, - badgeText: { - color: SYNC_PLAY_COLOR, - fontSize: 12, - fontWeight: "600", - marginLeft: 4, - }, -}); - -/** - * Hook-compatible version that reads SyncPlay state directly - */ -export function useSyncPlayIndicatorState( - isLocalReady: boolean, - isGroupWaiting: boolean, -): boolean { - // Show indicator only when: - // 1. User's local stream has loaded (isLocalReady) - // 2. Group is still waiting for others (isGroupWaiting) - return isLocalReady && isGroupWaiting; -} diff --git a/components/syncplay/index.ts b/components/syncplay/index.ts index 234abf3ec..5ea42ee15 100644 --- a/components/syncplay/index.ts +++ b/components/syncplay/index.ts @@ -4,7 +4,4 @@ export { GroupSelectionMenu } from "./GroupSelectionMenu"; export { SyncPlayButton } from "./SyncPlayButton"; -export { - SyncPlayIndicator, - useSyncPlayIndicatorState, -} from "./SyncPlayIndicator"; +export { SyncPlaySpinner } from "./SyncPlaySpinner"; diff --git a/providers/SyncPlay/Controller.ts b/providers/SyncPlay/Controller.ts index 660ef75f0..a773892db 100644 --- a/providers/SyncPlay/Controller.ts +++ b/providers/SyncPlay/Controller.ts @@ -1,402 +1,165 @@ /** - * SyncPlay Controller + * SyncPlay Controller — public playback API exposed to consumers. * - * Exposes SyncPlay API calls to external modules. - * Provides methods for controlling synchronized playback. - * - * Based on jellyfin-web's Controller.js + * Methods are fire-and-forget by design: SyncPlay HTTP responses don't + * carry useful info (the real state arrives via WebSocket broadcast). + * Wrap calls in try/catch so transient network errors don't reach the UI. */ -import type { Api } from "@jellyfin/sdk"; -import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; +import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import { getSyncPlayApi } from "@jellyfin/sdk/lib/utils/api"; +import type { SyncPlayManager } from "./Manager"; import { getItemsForPlayback, - msToTicks, + type TranslateOptions, translateItemsForPlayback, -} from "./Helper"; -import type { SyncPlayManager } from "./Manager"; -import type { QueueCore } from "./QueueCore"; -import type { GroupRepeatMode, GroupShuffleMode, PlayOptions } from "./types"; +} from "./transport/queueTranslation"; -/** - * SyncPlay Controller - External API for controlling SyncPlay - */ -export class SyncPlayController { - private api: Api; - private manager: SyncPlayManager; - private queueCore: QueueCore; +export interface PlayOptions extends TranslateOptions { + items?: BaseItemDto[]; + ids?: string[]; + startIndex?: number; + startPositionTicks?: number; +} - constructor(api: Api, manager: SyncPlayManager, queueCore: QueueCore) { - this.api = api; +export class Controller { + private manager!: SyncPlayManager; + + init(manager: SyncPlayManager): void { this.manager = manager; - this.queueCore = queueCore; } - // ============================================================================ - // Playback Control - // ============================================================================ - - /** - * Toggle play/pause - */ + /** Toggle play/pause for the whole group. */ playPause(): void { - // Use server group state (with pending in-flight command preferred) as - // the source of truth. The local player can lag the group by hundreds of - // ms while a scheduled command is pending, so reading `playerControls` - // here would cause rapid taps to send duplicate / wrong commands and - // desync other clients. - const state = this.manager.getEffectivePlayState(); - console.log(`SyncPlay Controller: playPause - effectiveState=${state}`); - if (state === "Playing") { - console.log("SyncPlay Controller: requesting PAUSE"); + if (this.manager.isPlaying()) { this.pause(); } else { - console.log("SyncPlay Controller: requesting UNPAUSE"); this.unpause(); } } - /** - * Request unpause (play) - */ - async unpause(): Promise { - // Drop duplicate rapid taps while a previous request is still in flight - // (cleared when the server broadcasts back via SyncPlayCommand, or after - // a safety timeout). - if (this.manager.getPendingPlaybackCommand() === "Unpause") { - console.debug("SyncPlay Controller: unpause ignored — already pending"); - return; - } + /** Resume the group's playback. */ + unpause(): void { this.manager.markPendingPlaybackCommand("Unpause"); try { - console.log("SyncPlay Controller: sending syncPlayUnpause to server"); - const syncPlayApi = getSyncPlayApi(this.api); - await syncPlayApi.syncPlayUnpause(); - console.log("SyncPlay Controller: syncPlayUnpause sent successfully"); + getSyncPlayApi(this.manager.getApiClient()).syncPlayUnpause(); } catch (error) { - console.error("SyncPlay Controller: failed to unpause", error); + console.error("SyncPlay Controller.unpause failed", error); } } - /** - * Request pause - */ - async pause(): Promise { - if (this.manager.getPendingPlaybackCommand() === "Pause") { - console.debug("SyncPlay Controller: pause ignored — already pending"); - return; - } + /** Pause the group's playback. */ + pause(): void { this.manager.markPendingPlaybackCommand("Pause"); try { - console.log("SyncPlay Controller: sending syncPlayPause to server"); - const syncPlayApi = getSyncPlayApi(this.api); - await syncPlayApi.syncPlayPause(); - console.log("SyncPlay Controller: syncPlayPause sent successfully"); - - // Also pause locally for immediate feedback - this.manager.getPlayerControls()?.pause(); + getSyncPlayApi(this.manager.getApiClient()).syncPlayPause(); } catch (error) { - console.error("SyncPlay Controller: failed to pause", error); + console.error("SyncPlay Controller.pause failed", error); } + // Pause locally too so the user sees instant feedback. + this.manager.getPlayerWrapper().localPause(); } - /** - * Request seek to position - */ - async seek(positionTicks: number): Promise { + /** Seek the group's playback. `positionTicks` is in ticks (1ms = 10000 ticks). */ + seek(positionTicks: number): void { try { - console.log( - `SyncPlay Controller: sending syncPlaySeek to ${positionTicks} ticks`, - ); - const syncPlayApi = getSyncPlayApi(this.api); - await syncPlayApi.syncPlaySeek({ - seekRequestDto: { - PositionTicks: positionTicks, - }, + getSyncPlayApi(this.manager.getApiClient()).syncPlaySeek({ + seekRequestDto: { PositionTicks: positionTicks }, }); - console.log("SyncPlay Controller: syncPlaySeek sent successfully"); - - // Also seek locally for immediate feedback - const positionMs = positionTicks / 10000; - this.manager.getPlayerControls()?.seekTo(positionMs); } catch (error) { - console.error("SyncPlay Controller: failed to seek", error); + console.error("SyncPlay Controller.seek failed", error); } } /** - * Request seek to position in milliseconds - */ - async seekMs(positionMs: number): Promise { - console.log(`SyncPlay Controller: seekMs to ${positionMs}ms`); - await this.seek(msToTicks(positionMs)); - } - - /** - * Request stop - */ - async stop(): Promise { - try { - const syncPlayApi = getSyncPlayApi(this.api); - await syncPlayApi.syncPlayStop(); - } catch (error) { - console.error("SyncPlay Controller: failed to stop", error); - } - } - - // ============================================================================ - // Queue Control - // ============================================================================ - - /** - * Start playback with a new SyncPlay group queue. + * Start playback in the group. Expands containers (Series, Season, + * BoxSet, Playlist, single Episode w/ autoplay) into the real + * playable queue before broadcasting. * - * Mirrors jellyfin-web's `Controller.play`: - * - * - If the caller passed full `items` objects, translate them directly - * (Series → episodes, BoxSet → children, etc.). - * - Otherwise fetch the items by ID first (`getItemsForPlayback`), then - * translate. - * - Send the translated, real playable IDs to - * `syncPlaySetNewQueue` so every group member receives a queue of - * playable items — not container IDs (Series / Season / BoxSet) that - * receivers like jellyfin-web silently drop. - * - * `startIndex` / `startPositionTicks` default to 0, same as jellyfin-web. + * Resolves once the SetNewQueue request completes; the server then + * broadcasts a PlayQueue update and Play command to every member. */ async play(options: PlayOptions): Promise { - const { items, ids, startIndex = 0, startPositionTicks = 0 } = options; + const api = this.manager.getApiClient(); - if ((!ids || ids.length === 0) && (!items || items.length === 0)) { - console.error("SyncPlay Controller: no items or ids to play"); - return; - } - - try { - // Step 1 — resolve to a list of BaseItemDto. Prefer the caller-supplied - // items (no extra round trip), fall back to a fetch by IDs. - const sourceItems: BaseItemDto[] = - items && items.length > 0 - ? items - : await getItemsForPlayback(this.api, ids ?? []); - - if (!sourceItems.length) { - console.error( - "SyncPlay Controller: getItemsForPlayback returned no items", - ); - return; - } - - // Step 2 — expand Series / Season / BoxSet / Playlist / single-Episode - // into the real playable queue. - const translated = await translateItemsForPlayback( - this.api, - sourceItems, - { ids, queryOptions: {} }, - ); - - const queueIds = translated + const sendPlayRequest = async (items: BaseItemDto[]) => { + const queue = items .map((item) => item.Id) - .filter((id): id is string => !!id); - - if (!queueIds.length) { - console.error( - "SyncPlay Controller: translateItemsForPlayback produced empty queue", - ); - return; - } - - console.log( - `SyncPlay Controller: sending syncPlaySetNewQueue (${queueIds.length} item${queueIds.length === 1 ? "" : "s"}, startIndex=${startIndex})`, - ); - - // Step 3 — broadcast the resolved queue to the group. - const syncPlayApi = getSyncPlayApi(this.api); - await syncPlayApi.syncPlaySetNewQueue({ + .filter((id): id is string => typeof id === "string"); + await getSyncPlayApi(api).syncPlaySetNewQueue({ playRequestDto: { - PlayingQueue: queueIds, - PlayingItemPosition: startIndex, - StartPositionTicks: startPositionTicks, + PlayingQueue: queue, + PlayingItemPosition: options.startIndex ?? 0, + StartPositionTicks: options.startPositionTicks ?? 0, }, }); + }; + + try { + const sourceItems = options.items + ? options.items + : await getItemsForPlayback(api, options.ids ?? []); + const items = await translateItemsForPlayback(api, sourceItems, options); + await sendPlayRequest(items); } catch (error) { - // Surface the server response body when available — a SetNewQueue - // that 4xx's silently is the most common "why didn't the other - // client start?" cause. Without the body we'd just see a generic - // axios error and have no way to tell whether it was a permission - // problem, an unknown item ID, or the server rejecting the queue. - const err = error as { - response?: { status?: number; data?: unknown }; - message?: string; - }; - console.error("SyncPlay Controller: failed to set new queue", { - status: err?.response?.status, - data: err?.response?.data, - message: err?.message, - }); + console.error("SyncPlay Controller.play failed", error); + throw error; } } - /** - * Set current item in playlist - */ - async setCurrentPlaylistItem(playlistItemId: string): Promise { + /** Stop the group's playback. */ + stop(): void { try { - const syncPlayApi = getSyncPlayApi(this.api); - await syncPlayApi.syncPlaySetPlaylistItem({ - setPlaylistItemRequestDto: { - PlaylistItemId: playlistItemId, - }, - }); + getSyncPlayApi(this.manager.getApiClient()).syncPlayStop(); } catch (error) { - console.error("SyncPlay Controller: failed to set playlist item", error); + console.error("SyncPlay Controller.stop failed", error); } } - /** - * Play next item - */ - async nextItem(): Promise { + /** Jump to the next item in the group's queue. */ + nextItem(): void { try { - const syncPlayApi = getSyncPlayApi(this.api); - await syncPlayApi.syncPlayNextItem({ + getSyncPlayApi(this.manager.getApiClient()).syncPlayNextItem({ nextItemRequestDto: { - PlaylistItemId: - this.queueCore.getCurrentPlaylistItemId() ?? undefined, - }, + PlaylistItemId: this.manager + .getQueueCore() + .getCurrentPlaylistItemId(), + } as unknown as Parameters< + ReturnType["syncPlayNextItem"] + >[0]["nextItemRequestDto"], }); } catch (error) { - console.error("SyncPlay Controller: failed to play next", error); + console.error("SyncPlay Controller.nextItem failed", error); } } - /** - * Play previous item - */ - async previousItem(): Promise { + /** Jump to the previous item in the group's queue. */ + previousItem(): void { try { - const syncPlayApi = getSyncPlayApi(this.api); - await syncPlayApi.syncPlayPreviousItem({ + getSyncPlayApi(this.manager.getApiClient()).syncPlayPreviousItem({ previousItemRequestDto: { - PlaylistItemId: - this.queueCore.getCurrentPlaylistItemId() ?? undefined, - }, + PlaylistItemId: this.manager + .getQueueCore() + .getCurrentPlaylistItemId(), + } as unknown as Parameters< + ReturnType["syncPlayPreviousItem"] + >[0]["previousItemRequestDto"], }); } catch (error) { - console.error("SyncPlay Controller: failed to play previous", error); + console.error("SyncPlay Controller.previousItem failed", error); } } - /** - * Add items to queue - */ - async queue( - itemIds: string[], - mode: "Queue" | "QueueNext" = "Queue", - ): Promise { + /** Jump to a specific item in the queue by playlist item id. */ + setCurrentPlaylistItem(playlistItemId: string): void { try { - const syncPlayApi = getSyncPlayApi(this.api); - await syncPlayApi.syncPlayQueue({ - queueRequestDto: { - ItemIds: itemIds, - Mode: mode, - }, + getSyncPlayApi(this.manager.getApiClient()).syncPlaySetPlaylistItem({ + setPlaylistItemRequestDto: { PlaylistItemId: playlistItemId }, }); } catch (error) { - console.error("SyncPlay Controller: failed to queue items", error); + console.error("SyncPlay Controller.setCurrentPlaylistItem failed", error); } } - - /** - * Add items to play next - */ - async queueNext(itemIds: string[]): Promise { - await this.queue(itemIds, "QueueNext"); - } - - /** - * Remove items from playlist - */ - async removeFromPlaylist(playlistItemIds: string[]): Promise { - try { - const syncPlayApi = getSyncPlayApi(this.api); - await syncPlayApi.syncPlayRemoveFromPlaylist({ - removeFromPlaylistRequestDto: { - PlaylistItemIds: playlistItemIds, - }, - }); - } catch (error) { - console.error( - "SyncPlay Controller: failed to remove from playlist", - error, - ); - } - } - - /** - * Move item in playlist - */ - async movePlaylistItem( - playlistItemId: string, - newIndex: number, - ): Promise { - try { - const syncPlayApi = getSyncPlayApi(this.api); - await syncPlayApi.syncPlayMovePlaylistItem({ - movePlaylistItemRequestDto: { - PlaylistItemId: playlistItemId, - NewIndex: newIndex, - }, - }); - } catch (error) { - console.error("SyncPlay Controller: failed to move playlist item", error); - } - } - - // ============================================================================ - // Playback Settings - // ============================================================================ - - /** - * Set repeat mode - */ - async setRepeatMode(mode: GroupRepeatMode): Promise { - try { - const syncPlayApi = getSyncPlayApi(this.api); - await syncPlayApi.syncPlaySetRepeatMode({ - setRepeatModeRequestDto: { - Mode: mode, - }, - }); - } catch (error) { - console.error("SyncPlay Controller: failed to set repeat mode", error); - } - } - - /** - * Set shuffle mode - */ - async setShuffleMode(mode: GroupShuffleMode): Promise { - try { - const syncPlayApi = getSyncPlayApi(this.api); - await syncPlayApi.syncPlaySetShuffleMode({ - setShuffleModeRequestDto: { - Mode: mode, - }, - }); - } catch (error) { - console.error("SyncPlay Controller: failed to set shuffle mode", error); - } - } - - /** - * Toggle shuffle mode - */ - async toggleShuffleMode(): Promise { - const currentMode = this.queueCore.getShuffleMode(); - const newMode: GroupShuffleMode = - currentMode === "Sorted" ? "Shuffle" : "Sorted"; - await this.setShuffleMode(newMode); - } } + +export default Controller; diff --git a/providers/SyncPlay/EventEmitter.ts b/providers/SyncPlay/EventEmitter.ts new file mode 100644 index 000000000..4c0615b83 --- /dev/null +++ b/providers/SyncPlay/EventEmitter.ts @@ -0,0 +1,93 @@ +/** + * Per-instance event emitter — replaces jellyfin-web's global `Events.trigger` + * bus. Listeners that throw are caught and logged so one bad listener can't + * break the rest. + */ + +import { WaitForEventDefaultTimeout } from "./constants"; + +export class EventEmitter { + private listeners: Map void>> = new Map(); + + on(event: string, callback: (...args: unknown[]) => void): void { + if (!this.listeners.has(event)) { + this.listeners.set(event, new Set()); + } + this.listeners.get(event)!.add(callback); + } + + off(event: string, callback: (...args: unknown[]) => void): void { + this.listeners.get(event)?.delete(callback); + } + + emit(event: string, ...args: unknown[]): void { + this.listeners.get(event)?.forEach((callback) => { + try { + callback(...args); + } catch (error) { + console.error( + `SyncPlay EventEmitter: handler for "${event}" threw`, + error, + ); + } + }); + } + + removeAllListeners(event?: string): void { + if (event) { + this.listeners.delete(event); + } else { + this.listeners.clear(); + } + } +} + +/** + * Resolve on the next emission of `event`, or reject after `timeoutMs` + * (or any event in `rejectEventTypes`). Cleans up every listener. + */ +export function waitForEventOnce( + emitter: EventEmitter, + event: string, + timeoutMs: number = WaitForEventDefaultTimeout, + rejectEventTypes?: string[], +): Promise { + return new Promise((resolve, reject) => { + let timer: ReturnType | null = null; + + const clearAll = () => { + emitter.off(event, handler); + if (timer) clearTimeout(timer); + if (Array.isArray(rejectEventTypes)) { + for (const eventName of rejectEventTypes) { + emitter.off(eventName, rejectCallback); + } + } + }; + + const handler = (...args: unknown[]) => { + clearAll(); + resolve(args); + }; + + const rejectCallback = (...args: unknown[]) => { + clearAll(); + reject(args[0] ?? new Error("rejected")); + }; + + if (timeoutMs) { + timer = setTimeout(() => { + clearAll(); + reject(new Error("Timed out.")); + }, timeoutMs); + } + + emitter.on(event, handler); + + if (Array.isArray(rejectEventTypes)) { + for (const eventName of rejectEventTypes) { + emitter.on(eventName, rejectCallback); + } + } + }); +} diff --git a/providers/SyncPlay/Helper.ts b/providers/SyncPlay/Helper.ts deleted file mode 100644 index ca8094543..000000000 --- a/providers/SyncPlay/Helper.ts +++ /dev/null @@ -1,448 +0,0 @@ -/** - * SyncPlay Helper - * - * Utility functions for SyncPlay functionality. - * Based on jellyfin-web's Helper.js - */ - -import type { Api } from "@jellyfin/sdk"; -import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; -import { - getItemsApi, - getTvShowsApi, - getUserApi, - getUserLibraryApi, -} from "@jellyfin/sdk/lib/utils/api"; -import { TicksPerMillisecond } from "./types"; - -/** - * Wait for an event to be triggered, with optional timeout. - */ -export function waitForEvent( - eventEmitter: { - addEventListener: (event: string, handler: (data: T) => void) => void; - removeEventListener: (event: string, handler: (data: T) => void) => void; - }, - eventType: string, - timeout?: number, - rejectEvents?: string[], -): Promise { - return new Promise((resolve, reject) => { - let timeoutId: ReturnType | undefined; - - const cleanup = () => { - eventEmitter.removeEventListener(eventType, handler); - if (timeoutId) { - clearTimeout(timeoutId); - } - if (rejectEvents) { - for (const event of rejectEvents) { - eventEmitter.removeEventListener(event, rejectHandler); - } - } - }; - - const handler = (data: T) => { - cleanup(); - resolve(data); - }; - - const rejectHandler = () => { - cleanup(); - reject(new Error("Rejected by event")); - }; - - eventEmitter.addEventListener(eventType, handler); - - if (rejectEvents) { - for (const event of rejectEvents) { - eventEmitter.addEventListener(event, rejectHandler); - } - } - - if (timeout) { - timeoutId = setTimeout(() => { - cleanup(); - reject(new Error("Timed out waiting for event")); - }, timeout); - } - }); -} - -/** - * Wait for a promise-based callback, with timeout. - */ -export function waitWithTimeout( - promise: Promise, - timeout: number, -): Promise { - return new Promise((resolve, reject) => { - const timeoutId = setTimeout(() => { - reject(new Error("Operation timed out")); - }, timeout); - - promise - .then((result) => { - clearTimeout(timeoutId); - resolve(result); - }) - .catch((error) => { - clearTimeout(timeoutId); - reject(error); - }); - }); -} - -/** - * Convert ticks to milliseconds. - */ -export function ticksToMs(ticks: number): number { - return ticks / TicksPerMillisecond; -} - -/** - * Convert milliseconds to ticks. - */ -export function msToTicks(ms: number): number { - return Math.round(ms * TicksPerMillisecond); -} - -/** - * Convert a GUID string to standard format. - */ -export function stringToGuid(input: string): string { - return input.replace( - /([0-z]{8})([0-z]{4})([0-z]{4})([0-z]{4})([0-z]{12})/, - "$1-$2-$3-$4-$5", - ); -} - -/** - * Parse a date string to Date object. - */ -export function parseDate(dateString: string): Date { - return new Date(dateString); -} - -/** - * Get current time as ISO string for API requests. - */ -export function nowAsIsoString(): string { - return new Date().toISOString(); -} - -/** - * Clamp a value between min and max. - */ -export function clamp(value: number, min: number, max: number): number { - return Math.max(min, Math.min(max, value)); -} - -/** - * Simple event emitter for internal use. - */ -export class EventEmitter { - private listeners: Map void>> = new Map(); - - on(event: string, callback: (...args: unknown[]) => void): void { - if (!this.listeners.has(event)) { - this.listeners.set(event, new Set()); - } - this.listeners.get(event)!.add(callback); - } - - off(event: string, callback: (...args: unknown[]) => void): void { - this.listeners.get(event)?.delete(callback); - } - - emit(event: string, ...args: unknown[]): void { - this.listeners.get(event)?.forEach((callback) => { - try { - callback(...args); - } catch (error) { - console.error(`Error in event handler for ${event}:`, error); - } - }); - } - - removeAllListeners(event?: string): void { - if (event) { - this.listeners.delete(event); - } else { - this.listeners.clear(); - } - } -} - -/** - * Wait for the next emission of an event on our internal {@link EventEmitter}, - * or reject after `timeoutMs`. Auto-cleans the listener. - */ -export function waitForOwnEvent( - emitter: EventEmitter, - event: string, - timeoutMs = 5000, -): Promise { - return new Promise((resolve, reject) => { - const handler = (...args: unknown[]) => { - clearTimeout(timer); - emitter.off(event, handler); - resolve(args); - }; - const timer = setTimeout(() => { - emitter.off(event, handler); - reject(new Error(`Timed out waiting for "${event}"`)); - }, timeoutMs); - emitter.on(event, handler); - }); -} - -// ============================================================================ -// Item fetching / queue translation -// -// Faithful port of jellyfin-web's `getItemsForPlayback` and -// `translateItemsForPlayback` from `src/plugins/syncPlay/core/Helper.js`. -// -// Why this matters for SyncPlay: -// - The server takes the queue we send via `syncPlaySetNewQueue` and -// broadcasts it verbatim to every group member. If we send a Series / -// Season / BoxSet ID, every receiver tries to load that container as a -// playable item, which silently fails on jellyfin-web (it never opens -// the player). Sending an Episode ID without sibling expansion breaks -// next-episode auto-advance for everyone in the group. -// - jellyfin-web's `playbackManager.play` runs the same translation -// locally; SyncPlay's `Controller.play` runs it before the SetNewQueue -// request so the broadcast carries real playable item IDs. -// - We replicate the same translation here so a mobile sender produces -// the same broadcast a jellyfin-web sender would. -// ============================================================================ - -/** Options bag accepted by `translateItemsForPlayback`. */ -export interface TranslateOptions { - ids?: string[]; - shuffle?: boolean; - queryOptions?: Record; -} - -/** Fields jellyfin-web requests for any playback queue. */ -const PLAYBACK_FIELDS = ["Chapters", "Trickplay"] as const; - -/** Resolve the current user. Cached only for the duration of one call. */ -async function getCurrentUser(api: Api) { - const user = (await getUserApi(api).getCurrentUser()).data; - if (!user?.Id) { - throw new Error("SyncPlay Helper: no authenticated user"); - } - return user; -} - -/** - * Generic `getItems` wrapper with the playback defaults jellyfin-web uses - * (`Chapters,Trickplay` fields, `ExcludeLocationTypes: Virtual`, - * `CollapseBoxSetItems: false`, `EnableTotalRecordCount: false`, `Limit: 300`). - * - * Callers pass camelCase params straight to the SDK — no PascalCase shim. - */ -async function queryItems( - api: Api, - userId: string, - params: Record, -): Promise { - const res = await getItemsApi(api).getItems({ - limit: 300, - fields: PLAYBACK_FIELDS as unknown as never, - excludeLocationTypes: ["Virtual"] as unknown as never, - enableTotalRecordCount: false, - collapseBoxSetItems: false, - ...params, - userId, - }); - return res.data.Items ?? []; -} - -/** - * Recursive "fetch children/tracks under X" — the shape MusicArtist / - * MusicGenre / Photo / PhotoAlbum / IsFolder all share. - */ -function fetchSiblings( - api: Api, - userId: string, - params: Record, -): Promise { - return queryItems(api, userId, { - filters: ["IsNotFolder"], - recursive: true, - ...params, - }); -} - -/** - * Resolve item IDs into full `BaseItemDto`s. - * Mirrors jellyfin-web's `Helper.getItemsForPlayback`: - * - single ID → `getUserLibraryApi.getItem` (cheap) - * - multi ID → `getItemsApi.getItems` with playback defaults - */ -export async function getItemsForPlayback( - api: Api, - ids: string[], -): Promise { - if (!ids.length) return []; - const userId = (await getCurrentUser(api)).Id as string; - if (ids.length === 1) { - const res = await getUserLibraryApi(api).getItem({ - userId, - itemId: ids[0], - }); - return res.data ? [res.data] : []; - } - return queryItems(api, userId, { ids }); -} - -/** - * Expand a "first item" into a real playable queue. - * - * Mirrors jellyfin-web's `Helper.translateItemsForPlayback`: - * - Program → channel items - * - Playlist → playlist children - * - MusicArtist → artist tracks - * - MusicGenre → genre tracks - * - Photo / PhotoAlbum → sibling photos - * - IsFolder (Series, Season, BoxSet, MusicAlbum, ...) → recursive descendants - * - single Episode w/ `EnableNextEpisodeAutoPlay` → remaining series episodes - * - anything else → passthrough (Movies, Audio, single Episodes when autoplay off) - * - * Preserves the original `options.ids` order so the receiver sees the same - * queue order the sender intended. - */ -export async function translateItemsForPlayback( - api: Api, - items: BaseItemDto[], - options: TranslateOptions = {}, -): Promise { - if (!items.length) return []; - - const workingItems = - items.length > 1 && options.ids - ? [...items].sort( - (a, b) => - (options.ids ?? []).indexOf(a.Id ?? "") - - (options.ids ?? []).indexOf(b.Id ?? ""), - ) - : items; - - const firstItem = workingItems[0]; - const defaultSortBy = options.shuffle ? "Random" : "SortName"; - - // Program → channel's playable items. Doesn't need a user lookup. - if (firstItem.Type === "Program" && firstItem.ChannelId) { - return getItemsForPlayback(api, [firstItem.ChannelId]); - } - - // Resolve the user once for every branch that follows. Saves 1-2 round - // trips vs. each helper resolving independently. - const user = await getCurrentUser(api); - const userId = user.Id as string; - - if (firstItem.Type === "Playlist") { - return queryItems(api, userId, { - parentId: firstItem.Id, - sortBy: options.shuffle ? ["Random"] : undefined, - }); - } - - if (firstItem.Type === "MusicArtist") { - return fetchSiblings(api, userId, { - artistIds: firstItem.Id ? [firstItem.Id] : undefined, - mediaTypes: ["Audio"], - sortBy: options.shuffle - ? ["Random"] - : ["Album", "ParentIndexNumber", "IndexNumber", "SortName"], - }); - } - - if (firstItem.MediaType === "Photo") { - const siblings = await fetchSiblings(api, userId, { - parentId: firstItem.ParentId, - recursive: false, - mediaTypes: ["Photo", "Video"], - sortBy: [defaultSortBy], - }); - // Re-anchor startIndex to the chosen photo, same as jellyfin-web. - // SyncPlay doesn't currently consume startIndex from queryOptions, - // but we keep parity for any future caller. - if (siblings.length && options.queryOptions) { - const idx = siblings.findIndex((i) => i.Id === firstItem.Id); - if (idx >= 0) options.queryOptions.startIndex = idx; - } - return siblings; - } - - if (firstItem.Type === "PhotoAlbum") { - return fetchSiblings(api, userId, { - parentId: firstItem.Id, - recursive: false, - mediaTypes: ["Photo", "Video"], - sortBy: [defaultSortBy], - limit: 1000, - }); - } - - if (firstItem.Type === "MusicGenre") { - return fetchSiblings(api, userId, { - genreIds: firstItem.Id ? [firstItem.Id] : undefined, - mediaTypes: ["Audio"], - sortBy: [defaultSortBy], - }); - } - - if (firstItem.IsFolder) { - // Series, Season, BoxSet, MusicAlbum, etc. jellyfin-web only sets - // SortBy for shuffle or BoxSet — everything else inherits server-side - // sort order (typically index/premiere date). - const sortBy = options.shuffle - ? ["Random"] - : firstItem.Type === "BoxSet" - ? ["SortName"] - : undefined; - return fetchSiblings(api, userId, { - parentId: firstItem.Id, - mediaTypes: ["Audio", "Video"], - sortBy, - }); - } - - if (firstItem.Type === "Episode" && workingItems.length === 1) { - // Single-episode auto-next: drop everything before this episode so - // playback starts here and auto-advances through the rest of the - // series. Gated on the user's `EnableNextEpisodeAutoPlay` like - // jellyfin-web does. - if (!user.Configuration?.EnableNextEpisodeAutoPlay || !firstItem.SeriesId) { - return workingItems; - } - try { - const res = await getTvShowsApi(api).getEpisodes({ - seriesId: firstItem.SeriesId, - userId, - isMissing: false, - fields: PLAYBACK_FIELDS as unknown as never, - }); - const all = res.data.Items ?? []; - const foundIdx = Math.max( - 0, - all.findIndex((e) => e.Id === firstItem.Id), - ); - return all.slice(foundIdx); - } catch (error) { - // Don't block playback on a translation failure — fall back to the - // single-item queue the caller already supplied. - console.warn( - "SyncPlay Helper: Episode translation failed, falling back to single item", - error, - ); - return workingItems; - } - } - - // Everything else (Movie, Audio, ...) plays as-is. - return workingItems; -} diff --git a/providers/SyncPlay/Manager.ts b/providers/SyncPlay/Manager.ts index 3fd6696e4..1a1f8aaf4 100644 --- a/providers/SyncPlay/Manager.ts +++ b/providers/SyncPlay/Manager.ts @@ -1,797 +1,381 @@ /** - * SyncPlay Manager + * SyncPlayManager — central orchestrator for a SyncPlay session. * - * Main orchestrator for SyncPlay functionality. - * Manages group state, coordinates time sync, playback, and queue. + * Owns the three "cores" (TimeSync, PlaybackCore, QueueCore) and the + * PlayerWrapper, and routes WebSocket events between them. * - * Based on jellyfin-web's Manager.js + * Lifecycle: + * constructor → init() → (joinGroup → group-state-change "Idle"+) → + * group-state-change "Playing" → group-state-change "Paused" → ... + * → (leaveGroup) → destroy() + * + * Events emitted (provider listens): + * - `group-info-update` `(GroupInfoDto | null)` + * - `group-state-change` `(state: string, oldState: string)` + * - `enabled` `(isEnabled: boolean)` + * - `play-state-change` `(isFollowing: boolean)` + * - `playbackstart` / `playbackerror` — from PlayerWrapper hooks + * - `osd` `(action: SyncPlayOsdAction)` + * - `toast` `(messageKey: string)` + * + * The manager exposes a per-instance `EventEmitter` rather than upstream + * `Events.on(manager, ...)` — replaces the global Events bus pattern. */ import type { Api } from "@jellyfin/sdk"; -import { getSyncPlayApi } from "@jellyfin/sdk/lib/utils/api"; -import { toast } from "sonner-native"; -import i18n from "@/i18n"; -import { EventEmitter, msToTicks } from "./Helper"; -import { TimeSyncCore } from "./TimeSyncCore"; +import { Controller } from "./Controller"; +import { PlaybackCore } from "./cores/PlaybackCore"; +import { QueueCore } from "./cores/QueueCore"; +import { TimeSync } from "./cores/TimeSync"; +import { EventEmitter } from "./EventEmitter"; +import { PendingPlaybackTracker } from "./player/PendingPlaybackTracker"; +import { PlayerWrapper } from "./player/PlayerWrapper"; +import { reconcileToGroupOnAttach } from "./player/reconcileToGroupOnAttach"; import type { GroupInfoDto, + GroupUpdate, PlayerControls, PlayQueueUpdate, SendCommand, - SyncPlayStats, } from "./types"; -/** - * SyncPlay Manager - Main orchestrator - */ +/** Raw WebSocket message data shapes (already unwrapped by the hook). */ + export class SyncPlayManager extends EventEmitter { - private api: Api; - private timeSyncCore: TimeSyncCore; + private apiClient: Api; + private playerWrapper: PlayerWrapper; + private timeSync: TimeSync; + private playbackCore: PlaybackCore; + private queueCore: QueueCore; + private pendingPlaybackTracker: PendingPlaybackTracker; + private controller: Controller; - // Group state + /** Current group info. `null` when not in a group. */ private groupInfo: GroupInfoDto | null = null; - private syncPlayEnabledAt: Date | null = null; - private syncPlayReady = false; - private queuedCommand: SendCommand | null = null; + /** Is SyncPlay actively enabled (i.e., we're in a group)? */ + private syncPlayEnabledAtPlayer = false; + /** Are we mirroring the group's commands locally? */ private followingGroupPlayback = true; - private lastPlaybackCommand: SendCommand | null = null; - - // Pending play/pause request awaiting server broadcast. - // Used to (1) ignore duplicate rapid taps and (2) treat the server's - // upcoming state as the source of truth while a request is in flight. - private pendingPlaybackCommand: "Unpause" | "Pause" | null = null; - private pendingPlaybackTimeout: ReturnType | null = null; - // Safety: drop the pending guard after this long if no broadcast arrives. - private static readonly PENDING_PLAYBACK_TIMEOUT_MS = 1500; - - // Player state - private playerControls: PlayerControls | null = null; - private syncMethod = "None"; - - // Callbacks - private onPlaybackCoreCommand: ((command: SendCommand) => void) | null = null; - private onQueueUpdate: ((update: PlayQueueUpdate) => void) | null = null; - private onGetPlaylistItemId: (() => string | null) | null = null; - // Fired when SyncPlay is disabled — PlaybackCore wires up here to reset its - // own scheduled timers / cached command so we don't carry stale state into - // the next group. - private onDisable: (() => void) | null = null; - // Fired when SyncPlay is disabled — QueueCore wires up here to clear its - // last PlayQueue snapshot. Without this, re-joining the same group later - // causes the first PlayQueue echo (which can have a `LastUpdate` equal to - // or older than the snapshot we cached before the disable) to be dropped - // by the stale-update guard in `QueueCore.updatePlayQueue`. - private onQueueClear: (() => void) | null = null; constructor(api: Api) { super(); - this.api = api; - this.timeSyncCore = new TimeSyncCore(api); - - // Listen for time sync updates - this.timeSyncCore.onUpdate((error, timeOffset, ping) => { - if (error) { - console.debug("SyncPlay Manager: time sync error", error); - return; - } - - this.emit("time-sync-update", timeOffset, ping); - - // Report ping to server when enabled - if (this.isSyncPlayEnabled() && ping !== null) { - this.sendPing(ping); - } - }); + this.apiClient = api; + this.playerWrapper = new PlayerWrapper(); + this.timeSync = new TimeSync(api); + this.playbackCore = new PlaybackCore(); + this.queueCore = new QueueCore(); + this.pendingPlaybackTracker = new PendingPlaybackTracker(); + this.controller = new Controller(); } - /** - * Initialize the manager - */ + /** Wire up cores. Called once after construction. */ init(): void { - this.timeSyncCore.startPing(); + this.playbackCore.init(this); + this.queueCore.init(this); + this.controller.init(this); + + // Forward PlaybackCore OSD events to provider listeners. + this.playbackCore.on("osd", (...args) => { + this.emit("osd", ...args); + }); + + // Bridge optimistic pending Pause/Unpause → React state. + this.pendingPlaybackTracker.setChangeHandler((cmd) => { + this.emit("pending-playback-change", cmd); + }); + + this.timeSync.startPing(); } - /** - * Update the API client - */ + /** Public controller for callers. */ + getController(): Controller { + return this.controller; + } + + /** Called by SyncPlayProvider when the user switches Jellyfin servers. */ updateApiClient(api: Api): void { - this.api = api; + this.apiClient = api; + this.timeSync.updateApiClient(api); } - /** - * Get the API client - */ getApiClient(): Api { - return this.api; + return this.apiClient; } - /** - * Get the time sync core - */ - getTimeSyncCore(): TimeSyncCore { - return this.timeSyncCore; + getPlayerWrapper(): PlayerWrapper { + return this.playerWrapper; } - /** - * Set player controls for playback management - */ - setPlayerControls(controls: PlayerControls | null): void { - this.playerControls = controls; - - // When player controls are connected and SyncPlay is active, sync to group state - if (controls && this.isSyncPlayEnabled() && this.syncPlayReady) { - const state = this.groupInfo?.State; - console.log( - `SyncPlay: player controls connected, group state is ${state}`, - ); - - // CRITICAL: Tell server we're following group playback - // This ensures the server sends us SyncPlayCommand messages - this.followGroupPlayback(); - - // Reconcile position: if we know the last command and group is playing, - // estimate where the group is *now* and seek there before resuming. This - // fixes the case where the player attaches mid-stream and would - // otherwise resume from 0 or the last-known local position. - const last = this.lastPlaybackCommand; - if ( - last && - (last.Command === "Unpause" || last.Command === "Pause") && - last.When && - last.PositionTicks != null - ) { - try { - const commandWhen = new Date(last.When); - let targetTicks = last.PositionTicks; - if (last.Command === "Unpause") { - const remoteNow = this.timeSyncCore.localDateToRemote(new Date()); - targetTicks += - (remoteNow.getTime() - commandWhen.getTime()) * 10000; - } - const targetMs = Math.max(0, targetTicks / 10000); - const currentMs = controls.getCurrentPosition(); - if (Math.abs(currentMs - targetMs) > 500) { - console.log( - `SyncPlay: player attached — seeking to estimated group position ${targetMs}ms (was ${currentMs}ms)`, - ); - controls.seekTo(targetMs); - } - } catch (error) { - console.warn( - "SyncPlay: failed to estimate group position on attach", - error, - ); - } - } - - if (state === "Playing" && !controls.isPlaying()) { - console.log("SyncPlay: starting playback to match group"); - controls.play(); - } else if (state === "Paused" && controls.isPlaying()) { - console.log("SyncPlay: pausing to match group"); - controls.pause(); - } - } + getTimeSync(): TimeSync { + return this.timeSync; } - /** - * Get current player controls - */ - getPlayerControls(): PlayerControls | null { - return this.playerControls; + getPlaybackCore(): PlaybackCore { + return this.playbackCore; } - /** - * Set callback for playback commands - */ - setPlaybackCommandHandler( - handler: ((command: SendCommand) => void) | null, - ): void { - this.onPlaybackCoreCommand = handler; + getQueueCore(): QueueCore { + return this.queueCore; } - /** - * Set callback for queue updates - */ - setQueueUpdateHandler( - handler: ((update: PlayQueueUpdate) => void) | null, - ): void { - this.onQueueUpdate = handler; + getPendingPlaybackTracker(): PendingPlaybackTracker { + return this.pendingPlaybackTracker; } - /** - * Set callback for getting current playlist item ID - */ - setPlaylistItemIdGetter(getter: (() => string | null) | null): void { - this.onGetPlaylistItemId = getter; - } + // =========================================================================== + // WebSocket message handlers (called by useSyncPlayWebSocket) + // =========================================================================== /** - * Set a callback invoked when SyncPlay is disabled. PlaybackCore registers - * here so it can flush scheduled commands and stale state. - */ - setDisableHandler(handler: (() => void) | null): void { - this.onDisable = handler; - } - - /** - * Set a callback invoked when SyncPlay is disabled. QueueCore registers - * here so it can drop the cached PlayQueue snapshot and treat the next - * server update as fresh. - */ - setQueueClearHandler(handler: (() => void) | null): void { - this.onQueueClear = handler; - } - - // ============================================================================ - // Group Management - // ============================================================================ - - /** - * Check if SyncPlay is enabled (user is in a group) - */ - isSyncPlayEnabled(): boolean { - return this.syncPlayEnabledAt !== null; - } - - /** - * Check if SyncPlay is ready (time sync complete) - */ - isSyncPlayReady(): boolean { - return this.syncPlayReady; - } - - /** - * Get current group info - */ - getGroupInfo(): GroupInfoDto | null { - return this.groupInfo; - } - - /** - * Get the last playback command - */ - getLastPlaybackCommand(): SendCommand | null { - return this.lastPlaybackCommand; - } - - /** - * Check if currently playing - */ - isPlaying(): boolean { - // First check actual player state - if (this.playerControls) { - return this.playerControls.isPlaying(); - } - // Fall back to group state - if (this.groupInfo?.State) { - return this.groupInfo.State === "Playing"; - } - // Last resort: check last command - return this.lastPlaybackCommand?.Command === "Unpause"; - } - - /** - * Effective play state for SyncPlay routing decisions. + * Handle a `SyncPlayGroupUpdate` WebSocket message. * - * Prefers (1) a pending in-flight command we just sent, (2) the server's - * group state, and only falls back to the local player. This avoids the - * race where a rapid second tap reads the local player (which hasn't - * applied the scheduled command yet) and sends a duplicate request that - * either re-broadcasts with a new `When` or flips the group the wrong way. + * Cast: the SDK's `GroupUpdate.Type` union is narrower than what the + * server actually emits (it omits `SyncPlayIsDisabled`, `GroupUpdate`, + * `CreateGroupDenied`, `JoinGroupDenied`). Wire format is the source + * of truth here. */ - getEffectivePlayState(): "Playing" | "Paused" { - if (this.pendingPlaybackCommand === "Unpause") return "Playing"; - if (this.pendingPlaybackCommand === "Pause") return "Paused"; - if (this.groupInfo?.State === "Playing") return "Playing"; - if (this.groupInfo?.State === "Paused") return "Paused"; - return this.playerControls?.isPlaying() ? "Playing" : "Paused"; - } - - /** - * Returns the in-flight play/pause request, if any. - */ - getPendingPlaybackCommand(): "Unpause" | "Pause" | null { - return this.pendingPlaybackCommand; - } - - /** - * Mark a play/pause request as in flight. Auto-clears on a safety timeout - * in case the server broadcast is missed. - */ - markPendingPlaybackCommand(command: "Unpause" | "Pause"): void { - this.pendingPlaybackCommand = command; - if (this.pendingPlaybackTimeout) { - clearTimeout(this.pendingPlaybackTimeout); + processGroupUpdate(rawUpdate: GroupUpdate): void { + if (!rawUpdate) { + console.warn("SyncPlay processGroupUpdate: empty update"); + return; } - this.pendingPlaybackTimeout = setTimeout(() => { - console.debug( - "SyncPlay Manager: pending playback command timed out", - command, - ); - this.pendingPlaybackCommand = null; - this.pendingPlaybackTimeout = null; - this.emit("pending-playback-change", null); - }, SyncPlayManager.PENDING_PLAYBACK_TIMEOUT_MS); - this.emit("pending-playback-change", command); - } - - private clearPendingPlaybackCommand(): void { - if (this.pendingPlaybackTimeout) { - clearTimeout(this.pendingPlaybackTimeout); - this.pendingPlaybackTimeout = null; - } - if (this.pendingPlaybackCommand !== null) { - this.pendingPlaybackCommand = null; - this.emit("pending-playback-change", null); - } - } - - /** - * Check if following group playback - */ - isFollowingGroupPlayback(): boolean { - return this.followingGroupPlayback; - } - - /** - * Enable SyncPlay (join a group) - */ - enableSyncPlay(groupInfo: GroupInfoDto, showMessage = false): void { - if (this.isSyncPlayEnabled()) { - if (groupInfo.GroupId === this.groupInfo?.GroupId) { - console.debug( - `SyncPlay: group ${this.groupInfo?.GroupId} already joined.`, - ); - return; - } - console.warn( - `SyncPlay: switching from group ${this.groupInfo?.GroupId} to ${groupInfo.GroupId}`, - ); - this.disableSyncPlay(false); - } - - this.groupInfo = groupInfo; - this.syncPlayEnabledAt = groupInfo.LastUpdatedAt - ? new Date(groupInfo.LastUpdatedAt) - : new Date(); - this.followingGroupPlayback = true; - this.syncPlayReady = false; - - console.log(`SyncPlay: enableSyncPlay - group state is ${groupInfo.State}`); - - this.emit("enabled", true); - - // Wait for time sync to be ready - const checkReady = () => { - if (this.timeSyncCore.isReady()) { - this.syncPlayReady = true; - - // CRITICAL: Tell server we're following group playback - // This ensures the server sends us SyncPlayCommand messages - this.followGroupPlayback(); - - if (this.queuedCommand) { - this.processCommand(this.queuedCommand); - this.queuedCommand = null; - } - - // Act on initial group state if player is connected - if (this.playerControls && groupInfo.State) { - console.log(`SyncPlay: applying initial state ${groupInfo.State}`); - if (groupInfo.State === "Playing") { - this.playerControls.play(); - } else if (groupInfo.State === "Paused") { - this.playerControls.pause(); - } - } - } else { - setTimeout(checkReady, 100); - } + const update = rawUpdate as unknown as { + Type: string; + Data: unknown; }; - this.timeSyncCore.forceUpdate(); - checkReady(); - - if (showMessage) { - toast(i18n.t("syncplay.enabled")); - } - } - - /** - * Disable SyncPlay (leave group) - */ - disableSyncPlay(showMessage = false): void { - this.syncPlayEnabledAt = null; - this.syncPlayReady = false; - this.followingGroupPlayback = true; - this.lastPlaybackCommand = null; - this.queuedCommand = null; - this.groupInfo = null; - this.clearPendingPlaybackCommand(); - - // Tell PlaybackCore (or whoever subscribed) to flush any scheduled - // commands / cached state so a future re-enable starts clean. - try { - this.onDisable?.(); - } catch (error) { - console.warn("SyncPlay: onDisable handler threw", error); - } - - // Drop the cached PlayQueue snapshot so a future re-join doesn't get - // its first PlayQueue update silently dropped as "older than what we - // already have". - try { - this.onQueueClear?.(); - } catch (error) { - console.warn("SyncPlay: onQueueClear handler threw", error); - } - - this.emit("enabled", false); - - if (showMessage) { - toast(i18n.t("syncplay.disabled")); - } - } - - // ============================================================================ - // Server Communication - // ============================================================================ - - /** - * Send ping to server - */ - private async sendPing(ping: number): Promise { - try { - const syncPlayApi = getSyncPlayApi(this.api); - await syncPlayApi.syncPlayPing({ - pingRequestDto: { Ping: Math.round(ping) }, - }); - } catch (error) { - console.debug("SyncPlay: failed to send ping", error); - } - } - - /** - * Report that we're ready (not buffering) - */ - async reportReady(): Promise { - try { - const syncPlayApi = getSyncPlayApi(this.api); - const now = new Date(); - const currentPosition = this.playerControls?.getCurrentPosition() ?? 0; - const currentPositionTicks = msToTicks(currentPosition); - - console.log( - "SyncPlay Manager: reporting READY at position", - currentPositionTicks, - ); - - await syncPlayApi.syncPlayReady({ - readyRequestDto: { - When: now.toISOString(), - PositionTicks: currentPositionTicks, - IsPlaying: this.playerControls?.isPlaying() ?? false, - PlaylistItemId: - this.onGetPlaylistItemId?.() ?? - "00000000-0000-0000-0000-000000000000", - }, - }); - console.log("SyncPlay Manager: READY sent successfully"); - } catch (error) { - console.error("SyncPlay Manager: failed to report ready", error); - } - } - - /** - * Follow group playback - */ - async followGroupPlayback(): Promise { - this.followingGroupPlayback = true; - - try { - const syncPlayApi = getSyncPlayApi(this.api); - await syncPlayApi.syncPlaySetIgnoreWait({ - ignoreWaitRequestDto: { IgnoreWait: false }, - }); - } catch (error) { - console.error("SyncPlay: failed to follow group playback", error); - } - } - - /** - * Halt group playback (stop following) - */ - async haltGroupPlayback(): Promise { - this.followingGroupPlayback = false; - - try { - const syncPlayApi = getSyncPlayApi(this.api); - await syncPlayApi.syncPlaySetIgnoreWait({ - ignoreWaitRequestDto: { IgnoreWait: true }, - }); - - // Stop local playback - this.playerControls?.pause(); - } catch (error) { - console.error("SyncPlay: failed to halt group playback", error); - } - } - - // ============================================================================ - // Message Processing - // ============================================================================ - - /** - * Process a group update from the server - * Uses generic type to handle all possible update types from server - */ - processGroupUpdate(update: { Type?: string; Data?: unknown }): void { - const { Type, Data } = update; - - switch (Type) { - case "PlayQueue": { - const playQueueData = Data as PlayQueueUpdate; - console.log( - "SyncPlay: received PlayQueue update - position:", - playQueueData.StartPositionTicks, - "reason:", - playQueueData.Reason, + switch (update.Type) { + case "PlayQueue": + this.queueCore.updatePlayQueue( + this.apiClient, + update.Data as unknown as PlayQueueUpdate, ); - this.onQueueUpdate?.(playQueueData); break; - } case "UserJoined": - toast(i18n.t("syncplay.user_joined", { username: Data })); - if (this.groupInfo) { - if (!this.groupInfo.Participants) { - this.groupInfo.Participants = [Data as string]; - } else { - this.groupInfo.Participants.push(Data as string); - } - } - break; - case "UserLeft": - toast(i18n.t("syncplay.user_left", { username: Data })); - if (this.groupInfo?.Participants) { - this.groupInfo.Participants = this.groupInfo.Participants.filter( - (user: string) => user !== Data, - ); - } + // Group membership notifications — current group will follow + // via GroupUpdate, but emit a toast for friendliness. + this.emit("toast", `MessageSyncPlay${update.Type}`, update.Data); break; case "GroupJoined": { - const groupData = Data as GroupInfoDto; - this.enableSyncPlay(groupData, true); + this.groupInfo = update.Data as GroupInfoDto; + this.enableSyncPlay(this.groupInfo); + this.emit("group-update", this.groupInfo); + this.emit("toast", "MessageSyncPlayGroupJoined"); break; } - case "SyncPlayIsDisabled": - toast(i18n.t("syncplay.permission_required")); - break; - - case "NotInGroup": case "GroupLeft": - this.disableSyncPlay(true); + case "NotInGroup": + case "SyncPlayIsDisabled": { + const previousState = this.groupInfo?.State; + this.groupInfo = null; + this.disableSyncPlay(); + this.emit("group-update", null); + if (update.Type === "GroupLeft") { + this.emit("toast", "MessageSyncPlayGroupLeft"); + } + if (previousState) { + this.emit("group-state-change", "Idle", previousState); + } break; + } case "GroupUpdate": { - const updatedData = Data as GroupInfoDto; - this.groupInfo = updatedData; - this.emit("group-info-change", updatedData); + const previousState = this.groupInfo?.State; + this.groupInfo = update.Data as GroupInfoDto; + this.emit("group-update", this.groupInfo); + const newState = this.groupInfo.State; + if (newState && newState !== previousState) { + this.emit("group-state-change", newState, previousState ?? "Idle"); + } break; } case "StateUpdate": { - // Log full state data to see if position is included - console.log("SyncPlay: StateUpdate full data:", JSON.stringify(Data)); - const stateData = Data as { - State: string; - Reason: string; - PositionTicks?: number; + const stateData = update.Data as { + State?: string; + PreviousState?: string; }; - - // CRITICAL: Update the stored group state so subsequent checks use the correct value + const newState = stateData.State ?? "Idle"; + const previousState = stateData.PreviousState ?? "Idle"; if (this.groupInfo) { - this.groupInfo.State = stateData.State as any; - // Emit a fresh object so React state subscribers re-render — - // mutating in place would not trigger re-renders. - this.emit("group-info-change", { ...this.groupInfo }); + this.groupInfo.State = newState as GroupInfoDto["State"]; + this.emit("group-update", this.groupInfo); } - - this.emit("group-state-update", stateData.State, stateData.Reason); - console.log( - `SyncPlay: state changed to ${stateData.State} because ${stateData.Reason}`, - ); - - // Handle seek from StateUpdate if position is included - if (stateData.Reason === "Seek" && stateData.PositionTicks != null) { - console.log( - "SyncPlay: StateUpdate contains seek position:", - stateData.PositionTicks, - ); - this.emit("seek-from-state-update", stateData.PositionTicks); - } - - // Use StateUpdate as a fallback to control playback when SyncPlayCommand isn't received - // This ensures we stay in sync even if the server doesn't send commands - if (this.playerControls) { - const currentlyPlaying = this.playerControls.isPlaying(); - console.log( - `SyncPlay: StateUpdate handler - state=${stateData.State}, currentlyPlaying=${currentlyPlaying}`, - ); - - if (stateData.State === "Paused" && currentlyPlaying) { - console.log("SyncPlay: StateUpdate -> PAUSING player"); - this.playerControls.pause(); - } else if (stateData.State === "Playing" && !currentlyPlaying) { - console.log("SyncPlay: StateUpdate -> PLAYING"); - this.playerControls.play(); - } else if (stateData.State === "Waiting") { - console.log("SyncPlay: StateUpdate -> Waiting for other members"); - // Pause player when waiting - if (currentlyPlaying) { - this.playerControls.pause(); - } - // Emit event so PlaybackCore can report ready - this.emit("waiting-for-ready"); - } - } else { - console.warn("SyncPlay: StateUpdate but no playerControls!"); + this.emit("group-state-change", newState, previousState); + // Server signals "Playing" or "Paused" → clear any in-flight + // optimistic tap state. + if (newState === "Playing" || newState === "Paused") { + this.pendingPlaybackTracker.clear(); } break; } - case "GroupDoesNotExist": - toast(i18n.t("syncplay.group_does_not_exist")); - break; - case "CreateGroupDenied": - toast(i18n.t("syncplay.create_denied")); + this.emit("toast", "MessageSyncPlayCreateGroupDenied"); break; - case "JoinGroupDenied": - toast(i18n.t("syncplay.join_denied")); + this.emit("toast", "MessageSyncPlayJoinGroupDenied"); break; - case "LibraryAccessDenied": - toast(i18n.t("syncplay.library_access_denied")); + this.emit("toast", "MessageSyncPlayLibraryAccessDenied"); + break; + case "GroupDoesNotExist": + this.emit("toast", "MessageSyncPlayGroupDoesNotExist"); break; default: - console.warn(`SyncPlay: unrecognized group update type: ${Type}`); + console.warn("SyncPlay processGroupUpdate: unknown type", update.Type); + break; } } - /** - * Process a playback command from the server - */ + /** Handle a `SyncPlayCommand` WebSocket message. */ processCommand(command: SendCommand): void { - console.log(`SyncPlay Manager: processCommand called - ${command.Command}`); - - if (!this.isSyncPlayEnabled()) { - console.warn( - "SyncPlay Manager: not enabled, ignoring command", - command.Command, - ); + if (!command) { + console.warn("SyncPlay processCommand: empty command"); return; } - - const emittedAt = command.EmittedAt ? new Date(command.EmittedAt) : null; - if (this.syncPlayEnabledAt && emittedAt) { - if (emittedAt.getTime() < this.syncPlayEnabledAt.getTime()) { - console.debug("SyncPlay Manager: ignoring old command", command); - return; - } - } - - // Reject commands targeted at a different playlist item than the one we - // currently have loaded. Stop is always honored (it may be a teardown - // before a queue swap). This prevents (e.g.) seeking the wrong episode - // when a queue change is racing a command. - if (command.Command !== "Stop" && command.PlaylistItemId) { - const currentItemId = this.onGetPlaylistItemId?.(); - if (currentItemId && currentItemId !== command.PlaylistItemId) { - console.debug( - `SyncPlay Manager: ignoring command for playlist item ${command.PlaylistItemId} (current is ${currentItemId})`, - ); - return; - } - } - - if (!this.syncPlayReady) { - console.log( - "SyncPlay Manager: not ready, queuing command", - command.Command, - ); - this.queuedCommand = command; - return; - } - - // Remember the command even if we can't act on it yet. When the player - // attaches (setPlayerControls), the reconcile-on-attach path uses - // `lastPlaybackCommand` to seek to the estimated group position and - // resume/pause to match the group. Without this assignment, a command - // that arrives during the join→navigate→load window is lost. - this.lastPlaybackCommand = command; - - // Clear pending guard once the matching broadcast arrives. We treat any - // Unpause/Pause arrival as satisfying the pending request (the server - // may coalesce or override our intent — either way we trust its decision). + this.playbackCore.applyCommand(command); + // Server told us the new playing state — clear optimistic UI. if (command.Command === "Unpause" || command.Command === "Pause") { - this.clearPendingPlaybackCommand(); + this.pendingPlaybackTracker.clear(); } + } - if (!this.playerControls) { - // Expected when a command arrives between joining the group and the - // player finishing its initial load. The reconciliation in - // setPlayerControls will replay this command from `lastPlaybackCommand` - // once controls attach. - console.debug( - `SyncPlay Manager: ${command.Command} stored for replay (player not attached yet)`, + // =========================================================================== + // Enable / disable SyncPlay + // =========================================================================== + + private enableSyncPlay(_group: GroupInfoDto): void { + if (this.syncPlayEnabledAtPlayer) return; + this.syncPlayEnabledAtPlayer = true; + this.followingGroupPlayback = true; + this.timeSync.forceUpdate(); + this.emit("enabled", true); + this.emit("play-state-change", true); + } + + private disableSyncPlay(): void { + if (!this.syncPlayEnabledAtPlayer) return; + this.syncPlayEnabledAtPlayer = false; + this.followingGroupPlayback = false; + this.playbackCore.clearScheduledCommand(); + this.queueCore.clear(); + this.pendingPlaybackTracker.clear(); + this.emit("enabled", false); + this.emit("play-state-change", false); + } + + /** + * Resume following group playback after the user temporarily took + * local control (e.g. scrubbed the seek bar). + */ + async followGroupPlayback(_api: Api): Promise { + this.followingGroupPlayback = true; + this.emit("play-state-change", true); + } + + /** Stop following group playback (e.g., user takes local control). */ + haltGroupPlayback(_api: Api): void { + this.followingGroupPlayback = false; + this.emit("play-state-change", false); + } + + isFollowingGroupPlayback(): boolean { + return this.followingGroupPlayback; + } + + isSyncPlayEnabled(): boolean { + return this.syncPlayEnabledAtPlayer; + } + + // =========================================================================== + // Player attach + provider bridges + // =========================================================================== + + /** + * Bind the RN player controls. + * Called from the player screen's `useEffect`. Triggers a reconcile + * if a group is active and the player is late-arriving. + */ + setPlayerControls(controls: PlayerControls | null): void { + this.playerWrapper.bindToControls(controls); + if (controls && this.syncPlayEnabledAtPlayer) { + const lastCommand = this.playbackCore.getLastCommand(); + reconcileToGroupOnAttach(controls, lastCommand, (local) => + this.timeSync.localDateToRemote(local), ); - return; } + } - console.log( - `SyncPlay Manager: delegating ${command.Command} to playback core`, - ); + /** Player-side notify hook: media is ready to play. */ + notifyReady(): void { + this.emit("playbackstart"); + if (this.syncPlayEnabledAtPlayer) { + this.playbackCore.onReady(this.apiClient); + } + } - // Delegate to playback handler - if (this.onPlaybackCoreCommand) { - this.onPlaybackCoreCommand(command); + /** Player-side notify hook: buffering state changed. */ + notifyBuffering(isBuffering: boolean): void { + if (!this.syncPlayEnabledAtPlayer) return; + if (isBuffering) { + this.playbackCore.onBuffering(this.apiClient); } else { - console.error("SyncPlay Manager: no playback command handler set!"); + this.playbackCore.onReady(this.apiClient); } } - // ============================================================================ - // Stats - // ============================================================================ - - /** - * Get SyncPlay stats for display - */ - getStats(): SyncPlayStats { - return { - timeSyncDevice: this.timeSyncCore.getActiveDeviceName(), - timeSyncOffset: this.timeSyncCore.getTimeOffset().toFixed(2), - playbackDiff: "0.00", - syncMethod: this.syncMethod, - }; + /** Player-side notify hook: local playback started. */ + notifyPlaybackStart(): void { + this.emit("playbackstart"); + if (this.syncPlayEnabledAtPlayer) { + this.playbackCore.onPlaybackStart(this.apiClient); + } } - /** - * Show sync icon - */ - showSyncIcon(method: string): void { - this.syncMethod = method; - this.emit("syncing", true, method); + // =========================================================================== + // Pending playback (optimistic UI for play/pause taps) + // =========================================================================== + + /** Called by Controller before sending an Unpause/Pause request. */ + markPendingPlaybackCommand(command: "Unpause" | "Pause"): void { + this.pendingPlaybackTracker.mark(command); } - /** - * Clear sync icon - */ - clearSyncIcon(): void { - this.syncMethod = "None"; - this.emit("syncing", false, "None"); + /** Is the group currently playing? Used by Controller.playPause. */ + isPlaying(): boolean { + const pending = this.pendingPlaybackTracker.get(); + if (pending === "Unpause") return true; + if (pending === "Pause") return false; + return this.groupInfo?.State === "Playing"; } - // ============================================================================ - // Cleanup - // ============================================================================ + /** Group info for consumers. */ + getGroupInfo(): GroupInfoDto | null { + return this.groupInfo; + } + + /** Last playback command (for QueueCore.startPlayback resumption). */ + getLastPlaybackCommand(): SendCommand | null { + return this.playbackCore.getLastCommand(); + } + + // =========================================================================== + // Teardown + // =========================================================================== - /** - * Destroy the manager - */ destroy(): void { - this.timeSyncCore.destroy(); - this.disableSyncPlay(false); + this.timeSync.destroy(); + this.playbackCore.destroy(); + this.queueCore.destroy(); + this.playerWrapper.bindToControls(null); this.removeAllListeners(); - this.playerControls = null; - this.onPlaybackCoreCommand = null; - this.onQueueUpdate = null; } } + +export default SyncPlayManager; diff --git a/providers/SyncPlay/PlaybackCore.ts b/providers/SyncPlay/PlaybackCore.ts deleted file mode 100644 index 03d31f982..000000000 --- a/providers/SyncPlay/PlaybackCore.ts +++ /dev/null @@ -1,881 +0,0 @@ -/** - * PlaybackCore - * - * Manages synchronized playback for SyncPlay. - * Handles scheduling commands at precise times and sync correction. - * - * Based on jellyfin-web's PlaybackCore.js - */ - -import type { Api } from "@jellyfin/sdk"; -import { getSyncPlayApi } from "@jellyfin/sdk/lib/utils/api"; -import { EventEmitter, msToTicks, ticksToMs, waitForOwnEvent } from "./Helper"; -import type { TimeSyncCore } from "./TimeSyncCore"; -import type { - PlayerControls, - SendCommand, - SyncPlayOsdAction, - SyncPlaySettings, -} from "./types"; -import { TicksPerMillisecond } from "./types"; - -// Random offset added when re-issuing a duplicate Seek to force the player -// off-position so the next sync correction has something to chew on. Matches -// jellyfin-web's behavior (server tolerates a range, so we deliberately land -// just outside it). -const SEEK_FORCE_RANGE_MS = 100; -// Timeout for waiting on the local player's "ready" event after seek. -// Matches jellyfin-web's Helper.WaitForEventDefaultTimeout. -const WAIT_FOR_READY_TIMEOUT_MS = 30000; -// How close player position must be to command position to consider it -// already in the correct place (fuzz to account for player imprecision). -const POSITION_MATCH_TOLERANCE_MS = 500; - -/** - * PlaybackCore - Handles synchronized playback - */ -export class PlaybackCore extends EventEmitter { - private api: Api; - private timeSyncCore: TimeSyncCore; - private playerControls: PlayerControls | null = null; - - // Sync state - private syncEnabled = false; - private playbackDiffMillis = 0; - private syncAttempts = 0; - private lastSyncTime = new Date(); - private playerIsBuffering = false; - - // Command tracking - private lastCommand: SendCommand | null = null; - private scheduledCommandTimeout: ReturnType | null = null; - private syncTimeout: ReturnType | null = null; - - // Last buffering state we reported to the server. Used to dedupe - // sendBufferingRequest so we only send on actual transitions — - // jellyfin-web gets this for free from the HTML5 `waiting`/`canplay` - // events, but our player exposes state, not events, and the React - // effect that drives notifyReady/notifyBuffering can re-run many times - // per second during normal playback. Without this guard we flood the - // server with redundant Ready/Buffering requests. - private lastBufferingSent: boolean | null = null; - private inflightBufferingRequest: Promise | null = null; - - // Debounce buffering notifications, matching jellyfin-web's - // `minBufferingThresholdMillis = 3000` in HtmlVideoPlayer.js. A short - // re-buffer blip (<3s) shouldn't notify the server at all — there's no - // reason to pause the whole group for a hiccup that resolves itself. - // Going Ready cancels any pending buffering notification. - private notifyBufferingTimeout: ReturnType | null = null; - private readonly minBufferingThresholdMillis = 3000; - - // Set to true by `scheduleReadyRequestOnPlaybackStart` whenever a new - // SyncPlay queue starts loading (NewPlaylist / SetCurrentItem / NextItem - // / PreviousItem). On the next `onReady` we pause the player BEFORE - // sending SyncPlayReady so the server sees us as `IsPlaying:false`, - // parked at the start position, awaiting an Unpause command. Mirrors - // jellyfin-web's `QueueCore.scheduleReadyRequestOnPlaybackStart` which - // registers a one-shot `playbackstart` listener that does the same. - // Without this the receiver's player auto-plays the moment it loads and - // the group's Unpause command arrives to an already-playing player — - // which leaves the receiver subtly out of sync with the sender (or, on - // slower devices, stuck on a blank loading screen because the early - // play attempt races the media load and never recovers). - private pendingInitialPause = false; - - // Settings - private minDelaySpeedToSync = 60.0; - private maxDelaySpeedToSync = 3000.0; - private speedToSyncDuration = 1000.0; - private minDelaySkipToSync = 400.0; - private useSpeedToSync = true; - private useSkipToSync = true; - private enableSyncCorrection = false; - - // Callbacks - private onNotifyOsd: ((action: SyncPlayOsdAction) => void) | null = null; - private getCurrentPlaylistItemId: (() => string | null) | null = null; - - constructor(api: Api, timeSyncCore: TimeSyncCore) { - super(); - this.api = api; - this.timeSyncCore = timeSyncCore; - } - - /** - * Set player controls - */ - setPlayerControls(controls: PlayerControls | null): void { - this.playerControls = controls; - // A new (or detached) player means the server's view of our ready - // state is stale — drop the dedupe latch so the next notifyReady / - // notifyBuffering actually reaches the server. - this.lastBufferingSent = null; - } - - /** - * Set OSD notification handler - */ - setOsdHandler(handler: ((action: SyncPlayOsdAction) => void) | null): void { - this.onNotifyOsd = handler; - } - - /** - * Set playlist item ID getter - */ - setPlaylistItemIdGetter(getter: (() => string | null) | null): void { - this.getCurrentPlaylistItemId = getter; - } - - /** - * Load settings - */ - loadSettings(settings: Partial): void { - if (settings.minDelaySpeedToSync !== undefined) { - this.minDelaySpeedToSync = settings.minDelaySpeedToSync; - } - if (settings.maxDelaySpeedToSync !== undefined) { - this.maxDelaySpeedToSync = settings.maxDelaySpeedToSync; - } - if (settings.speedToSyncDuration !== undefined) { - this.speedToSyncDuration = settings.speedToSyncDuration; - } - if (settings.minDelaySkipToSync !== undefined) { - this.minDelaySkipToSync = settings.minDelaySkipToSync; - } - if (settings.useSpeedToSync !== undefined) { - this.useSpeedToSync = settings.useSpeedToSync; - } - if (settings.useSkipToSync !== undefined) { - this.useSkipToSync = settings.useSkipToSync; - } - if (settings.enableSyncCorrection !== undefined) { - this.enableSyncCorrection = settings.enableSyncCorrection; - } - } - - // ============================================================================ - // Player Events - // ============================================================================ - - /** - * Called when playback starts - */ - onPlaybackStart(): void { - this.emit("playbackstart"); - } - - /** - * Called when playback stops - */ - onPlaybackStop(): void { - this.lastCommand = null; - this.emit("playbackstop"); - } - - /** - * Called when player is ready to play - */ - onReady(): void { - this.playerIsBuffering = false; - // Cancel any pending buffering notification — we're ready before the - // 3s threshold fired, so the server never needs to know we hiccupped. - if (this.notifyBufferingTimeout) { - clearTimeout(this.notifyBufferingTimeout); - this.notifyBufferingTimeout = null; - } - // If we're handling the first ready event after a queue change, - // pause the player BEFORE reporting ready. The subsequent - // `sendBufferingRequest(false)` will then read `isPlaying() === false` - // and the server will hold the group until we receive an Unpause. - if (this.pendingInitialPause) { - this.pendingInitialPause = false; - if (this.playerControls?.isPlaying()) { - console.log( - "SyncPlay PlaybackCore: pausing on initial ready (awaiting group Unpause)", - ); - this.playerControls.pause(); - } - } - this.sendBufferingRequest(false); - this.emit("ready"); - } - - /** - * Mark the next `onReady` call as the initial ready for a new queue - * item. The player will be paused before SyncPlayReady is sent so the - * server keeps the group in `Waiting` until our Unpause arrives. - * - * Mirrors jellyfin-web's `QueueCore.scheduleReadyRequestOnPlaybackStart`. - * Called by the provider when a PlayQueue update is `NewPlaylist`, - * `SetCurrentItem`, `NextItem`, or `PreviousItem`. - */ - scheduleReadyRequestOnPlaybackStart(): void { - this.pendingInitialPause = true; - } - - /** - * Called when player is buffering - */ - onBuffering(): void { - // Debounce: only flip into "buffering" state (and notify the server) - // if the stall lasts longer than minBufferingThresholdMillis. Mirrors - // jellyfin-web's HtmlVideoPlayer.js `_onWaiting` handler, which only - // calls `onBuffering()` after the 3s timeout elapses. Keeping - // playerIsBuffering=false during brief blips lets sync correction - // continue to run normally. - if (this.notifyBufferingTimeout) { - clearTimeout(this.notifyBufferingTimeout); - } - this.notifyBufferingTimeout = setTimeout(() => { - this.notifyBufferingTimeout = null; - this.playerIsBuffering = true; - this.sendBufferingRequest(true); - this.emit("buffering"); - }, this.minBufferingThresholdMillis); - } - - /** - * Check if player is buffering - */ - isBuffering(): boolean { - return this.playerIsBuffering; - } - - /** - * Get playback difference in milliseconds - */ - getPlaybackDiff(): number { - return this.playbackDiffMillis; - } - - // ============================================================================ - // Server Communication - // ============================================================================ - - /** - * Send buffering/ready request to server. - * - * NOTE: This must work even before player controls are bound, so that we - * can signal "I'm not ready yet, hold the group" while the video is still - * loading. jellyfin-web's HTML5 player gets this for free via the - * `waiting` event firing during initial buffering; we don't bind controls - * until the video is loaded, so we synthesize a position=0 buffering - * signal in the pre-bind window. - */ - async sendBufferingRequest(isBuffering: boolean): Promise { - if (!this.api) { - console.warn("SyncPlay PlaybackCore: no api for buffering request"); - return; - } - - // Skip if the desired state matches what we last sent. Without this, - // the React effect that drives notifyReady/notifyBuffering will flood - // the server every time the video player's isBuffering momentarily - // toggles during normal playback. - if (this.lastBufferingSent === isBuffering) { - return; - } - - // Coalesce: if a request is already in flight, wait for it. This - // prevents racing two requests when state flips rapidly. - if (this.inflightBufferingRequest) { - await this.inflightBufferingRequest; - // Re-check after the in-flight request settled — the new state may - // already match. - if (this.lastBufferingSent === isBuffering) { - return; - } - } - - const request = this.doSendBufferingRequest(isBuffering); - this.inflightBufferingRequest = request; - try { - await request; - this.lastBufferingSent = isBuffering; - } finally { - if (this.inflightBufferingRequest === request) { - this.inflightBufferingRequest = null; - } - } - } - - private async doSendBufferingRequest(isBuffering: boolean): Promise { - if (!this.api) return; - - try { - const currentPosition = this.playerControls?.getCurrentPosition() ?? 0; - const currentPositionTicks = msToTicks(currentPosition); - const isPlaying = this.playerControls?.isPlaying() ?? false; - - const now = this.timeSyncCore.localDateToRemote(new Date()); - const playlistItemId = this.getCurrentPlaylistItemId?.() ?? null; - - const syncPlayApi = getSyncPlayApi(this.api); - - console.log( - `SyncPlay PlaybackCore: sending ${isBuffering ? "BUFFERING" : "READY"} to server`, - { - position: currentPositionTicks, - playlistItemId, - hasPlayerControls: !!this.playerControls, - }, - ); - - if (isBuffering) { - await syncPlayApi.syncPlayBuffering({ - bufferRequestDto: { - When: now.toISOString(), - PositionTicks: currentPositionTicks, - IsPlaying: isPlaying, - PlaylistItemId: playlistItemId ?? undefined, - }, - }); - } else { - await syncPlayApi.syncPlayReady({ - readyRequestDto: { - When: now.toISOString(), - PositionTicks: currentPositionTicks, - IsPlaying: isPlaying, - PlaylistItemId: playlistItemId ?? undefined, - }, - }); - } - - console.log( - `SyncPlay PlaybackCore: ${isBuffering ? "BUFFERING" : "READY"} sent successfully`, - ); - } catch (error) { - console.error("SyncPlay: failed to send buffering request", error); - // On failure, clear the dedupe latch so the next attempt actually - // re-sends rather than getting stuck thinking the server knows. - throw error; - } - } - - // ============================================================================ - // Command Handling - // ============================================================================ - - /** - * Apply a playback command - */ - async applyCommand(command: SendCommand): Promise { - console.log(`SyncPlay PlaybackCore: applyCommand - ${command.Command}`); - - // Parse the When time from string - const commandWhen = command.When ? new Date(command.When) : new Date(); - const positionTicks = command.PositionTicks ?? 0; - - // Duplicate command handling — don't blindly skip. Match jellyfin-web: - // if the duplicate's scheduled time has already passed and local player - // state doesn't match, re-apply (with a force-offset for seek). This - // self-heals after a missed broadcast, reconnect, or local drift. - if (this.lastCommand?.When) { - const lastWhen = new Date(this.lastCommand.When); - if ( - lastWhen.getTime() === commandWhen.getTime() && - this.lastCommand.PositionTicks === command.PositionTicks && - this.lastCommand.Command === command.Command && - this.lastCommand.PlaylistItemId === command.PlaylistItemId - ) { - const whenLocal = this.timeSyncCore.remoteDateToLocal(commandWhen); - if (whenLocal > new Date()) { - // Still in the future — already scheduled, nothing to do. - console.debug( - "SyncPlay PlaybackCore: duplicate (still scheduled), skipping", - ); - return; - } - - if (!this.playerControls) { - console.debug( - "SyncPlay PlaybackCore: duplicate past command but no player", - ); - return; - } - - const currentPositionMs = this.playerControls.getCurrentPosition(); - const isPlaying = this.playerControls.isPlaying(); - const targetMs = ticksToMs(positionTicks); - const positionMatches = - Math.abs(currentPositionMs - targetMs) <= POSITION_MATCH_TOLERANCE_MS; - - switch (command.Command) { - case "Unpause": - if (!isPlaying) { - console.debug("SyncPlay PlaybackCore: dup Unpause — reconciling"); - await this.scheduleUnpause(commandWhen, positionTicks); - } - return; - case "Pause": - if (isPlaying || !positionMatches) { - console.debug("SyncPlay PlaybackCore: dup Pause — reconciling"); - this.schedulePause(commandWhen, positionTicks); - } - return; - case "Stop": - if (isPlaying) { - console.debug("SyncPlay PlaybackCore: dup Stop — reconciling"); - this.scheduleStop(commandWhen); - } - return; - case "Seek": { - if (!isPlaying && positionMatches) { - // Already paused at target — just confirm ready. - this.sendBufferingRequest(false); - return; - } - // Force a re-seek with a small random offset so the player - // actually moves (server tolerates a range). - const randomOffsetTicks = - Math.round((Math.random() - 0.5) * SEEK_FORCE_RANGE_MS) * - TicksPerMillisecond; - console.debug( - `SyncPlay PlaybackCore: dup Seek — reconciling with offset ${randomOffsetTicks} ticks`, - ); - this.scheduleSeek(commandWhen, positionTicks + randomOffsetTicks); - return; - } - default: - console.error( - "SyncPlay PlaybackCore: unrecognized duplicate command", - command, - ); - return; - } - } - } - - this.lastCommand = command; - - if (!this.playerControls) { - console.error("SyncPlay PlaybackCore: NO PLAYER CONTROLS!"); - return; - } - - console.log( - `SyncPlay PlaybackCore: executing ${command.Command} scheduled for ${commandWhen.toISOString()}`, - ); - - switch (command.Command) { - case "Unpause": - await this.scheduleUnpause(commandWhen, positionTicks); - break; - case "Pause": - this.schedulePause(commandWhen, positionTicks); - break; - case "Stop": - this.scheduleStop(commandWhen); - break; - case "Seek": - this.scheduleSeek(commandWhen, positionTicks); - break; - default: - console.error("SyncPlay PlaybackCore: unrecognized command", command); - } - } - - /** - * Schedule an unpause at a specific time - */ - private async scheduleUnpause( - playAtTime: Date, - positionTicks: number, - ): Promise { - this.clearScheduledCommand(); - - const currentTime = new Date(); - const playAtTimeLocal = this.timeSyncCore.remoteDateToLocal(playAtTime); - const positionMs = ticksToMs(positionTicks); - - if (playAtTimeLocal > currentTime) { - // Future command - schedule it - const playTimeout = playAtTimeLocal.getTime() - currentTime.getTime(); - - // Pre-seek only when we're AHEAD of the target by more than the skip - // threshold. If we're behind, the unpause itself plays forward and - // SkipToSync/SpeedToSync will catch us up — forward-seeking now would - // just cause needless buffering. (Matches jellyfin-web.) - const currentPositionMs = this.playerControls?.getCurrentPosition() ?? 0; - const aheadByMs = currentPositionMs - positionMs; - console.log( - `SyncPlay: pre-seek check - current=${currentPositionMs}ms, target=${positionMs}ms, aheadBy=${aheadByMs}ms, threshold=${this.minDelaySkipToSync}ms`, - ); - if (aheadByMs > this.minDelaySkipToSync) { - console.log(`SyncPlay: PRE-SEEKING back to ${positionMs}ms`); - this.localSeek(positionMs); - } - - this.scheduledCommandTimeout = setTimeout(() => { - this.localUnpause(); - this.onNotifyOsd?.("unpause"); - - // Enable sync after a delay - this.syncTimeout = setTimeout(() => { - this.syncEnabled = true; - }, this.maxDelaySpeedToSync / 2); - }, playTimeout); - - console.debug(`SyncPlay: scheduled unpause in ${playTimeout}ms`); - } else { - // Past command - play immediately and seek to estimated position - const elapsed = currentTime.getTime() - playAtTimeLocal.getTime(); - const serverPositionTicks = positionTicks + elapsed * TicksPerMillisecond; - const serverPositionMs = ticksToMs(serverPositionTicks); - - this.localUnpause(); - this.localSeek(serverPositionMs); - - setTimeout(() => { - this.onNotifyOsd?.("unpause"); - }, 100); - - this.syncTimeout = setTimeout(() => { - this.syncEnabled = true; - }, this.maxDelaySpeedToSync / 2); - - console.debug(`SyncPlay: immediate unpause at ${serverPositionMs}ms`); - } - } - - /** - * Schedule a pause at a specific time - */ - private schedulePause(pauseAtTime: Date, positionTicks: number): void { - console.log("SyncPlay PlaybackCore: schedulePause called"); - this.clearScheduledCommand(); - - const currentTime = new Date(); - const pauseAtTimeLocal = this.timeSyncCore.remoteDateToLocal(pauseAtTime); - const positionMs = ticksToMs(positionTicks); - - const callback = () => { - console.log("SyncPlay PlaybackCore: EXECUTING PAUSE NOW"); - - // If we're already paused at the target position, do nothing. - // jellyfin-web gets this for free because HTML5 video's seekTo is a - // no-op when the target equals currentTime, and pause() is a no-op - // when already paused. Our PlayerControls.seekTo always actually - // seeks, which triggers waiting→canplay and a notifyBuffering → - // notifyReady cycle. The server reacts by re-sending Pause, which - // re-enters this callback → infinite feedback loop. Guarding here - // breaks the loop while preserving normal pause behaviour. - if (this.playerControls) { - const isPlaying = this.playerControls.isPlaying(); - const currentPositionMs = this.playerControls.getCurrentPosition(); - const positionMatches = - positionMs <= 100 || - Math.abs(currentPositionMs - positionMs) <= - POSITION_MATCH_TOLERANCE_MS; - if (!isPlaying && positionMatches) { - console.debug( - "SyncPlay PlaybackCore: already paused at target position, skipping", - ); - this.onNotifyOsd?.("pause"); - return; - } - } - - this.localPause(); - // Only seek if we have a valid position (not 0 or very small) - if (positionMs > 100) { - this.localSeek(positionMs); - } else { - console.log("SyncPlay PlaybackCore: skipping seek (no valid position)"); - } - this.onNotifyOsd?.("pause"); - }; - - if (pauseAtTimeLocal > currentTime) { - const pauseTimeout = pauseAtTimeLocal.getTime() - currentTime.getTime(); - this.scheduledCommandTimeout = setTimeout(callback, pauseTimeout); - console.log( - `SyncPlay PlaybackCore: scheduled pause in ${pauseTimeout}ms`, - ); - } else { - console.log("SyncPlay PlaybackCore: immediate pause (past time)"); - callback(); - } - } - - /** - * Schedule a stop at a specific time - */ - private scheduleStop(stopAtTime: Date): void { - this.clearScheduledCommand(); - - const currentTime = new Date(); - const stopAtTimeLocal = this.timeSyncCore.remoteDateToLocal(stopAtTime); - - const callback = () => { - this.localStop(); - }; - - if (stopAtTimeLocal > currentTime) { - const stopTimeout = stopAtTimeLocal.getTime() - currentTime.getTime(); - this.scheduledCommandTimeout = setTimeout(callback, stopTimeout); - console.debug(`SyncPlay: scheduled stop in ${stopTimeout}ms`); - } else { - callback(); - console.debug("SyncPlay: immediate stop"); - } - } - - /** - * Schedule a seek at a specific time. - * - * Flow (matches jellyfin-web): unpause -> seek -> wait for local "ready" - * (player finished buffering at the new position) -> pause and report ready - * to the server so the group can resume. This handles the common case - * where the player must rebuffer after the seek. - */ - private scheduleSeek(seekAtTime: Date, positionTicks: number): void { - this.clearScheduledCommand(); - - const currentTime = new Date(); - const seekAtTimeLocal = this.timeSyncCore.remoteDateToLocal(seekAtTime); - const positionMs = ticksToMs(positionTicks); - - const callback = () => { - this.localUnpause(); - this.localSeek(positionMs); - this.onNotifyOsd?.("seek"); - - // Wait for the local player to report ready ("onReady" fires this), - // then pause and tell the server we're ready at the new position. - waitForOwnEvent(this, "ready", WAIT_FOR_READY_TIMEOUT_MS) - .then(() => { - this.localPause(); - this.sendBufferingRequest(false); - }) - .catch((error) => { - console.warn( - `SyncPlay: seek ready-wait timed out, seeking to ${positionMs}ms anyway`, - error, - ); - this.localSeek(positionMs); - }); - }; - - if (seekAtTimeLocal > currentTime) { - const seekTimeout = seekAtTimeLocal.getTime() - currentTime.getTime(); - this.scheduledCommandTimeout = setTimeout(callback, seekTimeout); - console.debug(`SyncPlay: scheduled seek in ${seekTimeout}ms`); - } else { - callback(); - console.debug("SyncPlay: immediate seek"); - } - } - - /** - * Clear scheduled command - */ - private clearScheduledCommand(): void { - if (this.scheduledCommandTimeout) { - clearTimeout(this.scheduledCommandTimeout); - this.scheduledCommandTimeout = null; - } - if (this.syncTimeout) { - clearTimeout(this.syncTimeout); - this.syncTimeout = null; - } - - this.syncEnabled = false; - - // Reset playback rate - if (this.playerControls && this.playerControls.getSpeed() !== 1.0) { - this.playerControls.setSpeed(1.0); - } - - this.emit("syncing", false, "None"); - } - - // ============================================================================ - // Local Playback Control - // ============================================================================ - - private localUnpause(): void { - this.playerControls?.play(); - } - - private localPause(): void { - this.playerControls?.pause(); - } - - private localSeek(positionMs: number): void { - console.log(`SyncPlay PlaybackCore: localSeek to ${positionMs}ms`); - if (this.playerControls) { - this.playerControls.seekTo(positionMs); - console.log("SyncPlay PlaybackCore: seekTo called on playerControls"); - } else { - console.error("SyncPlay PlaybackCore: NO PLAYER CONTROLS for seek!"); - } - } - - private localStop(): void { - this.playerControls?.pause(); - } - - // ============================================================================ - // Time Sync - // ============================================================================ - - /** - * Estimate current position ticks given a past state - */ - estimateCurrentTicks( - ticks: number, - when: Date, - currentTime: Date = new Date(), - ): number { - const remoteTime = this.timeSyncCore.localDateToRemote(currentTime); - return ( - ticks + (remoteTime.getTime() - when.getTime()) * TicksPerMillisecond - ); - } - - /** - * Sync playback time during playback - */ - syncPlaybackTime(currentPositionMs: number): void { - if (!this.playerControls || !this.lastCommand) return; - - // Only sync during unpause - if (this.lastCommand.Command !== "Unpause" || this.isBuffering()) return; - - // Don't apply sync corrections if the active player isn't on the same - // playlist item that the group is playing (e.g. user switched item - // locally, or queue update in flight). Prevents seeking the wrong item. - const currentItemId = this.getCurrentPlaylistItemId?.(); - if ( - currentItemId && - this.lastCommand.PlaylistItemId && - this.lastCommand.PlaylistItemId !== currentItemId - ) { - return; - } - - const currentTime = new Date(); - const currentPositionTicks = msToTicks(currentPositionMs); - const lastCommandWhen = this.lastCommand.When - ? new Date(this.lastCommand.When) - : new Date(); - - // Estimate server position - const serverPositionTicks = this.estimateCurrentTicks( - this.lastCommand.PositionTicks ?? 0, - lastCommandWhen, - currentTime, - ); - - // Calculate difference - const diffMillis = - (serverPositionTicks - currentPositionTicks) / TicksPerMillisecond; - this.playbackDiffMillis = diffMillis; - - this.emit("playback-diff", diffMillis); - - // Rate-limit sync attempts - const elapsed = currentTime.getTime() - this.lastSyncTime.getTime(); - if (elapsed < this.maxDelaySpeedToSync / 2) return; - - this.lastSyncTime = currentTime; - - if (!this.syncEnabled || !this.enableSyncCorrection) return; - - const absDiffMillis = Math.abs(diffMillis); - - // SpeedToSync - if ( - this.useSpeedToSync && - absDiffMillis >= this.minDelaySpeedToSync && - absDiffMillis < this.maxDelaySpeedToSync - ) { - let speedToSyncTime = this.speedToSyncDuration; - - // Prevent negative speed - const MinSpeed = 0.2; - if (diffMillis <= -speedToSyncTime * MinSpeed) { - speedToSyncTime = Math.abs(diffMillis) / (1.0 - MinSpeed); - } - - const speed = 1 + diffMillis / speedToSyncTime; - - if (speed > 0) { - this.playerControls.setSpeed(speed); - this.syncEnabled = false; - this.syncAttempts++; - this.emit("syncing", true, `SpeedToSync (x${speed.toFixed(2)})`); - - this.syncTimeout = setTimeout(() => { - this.playerControls?.setSpeed(1.0); - this.syncEnabled = true; - this.emit("syncing", false, "None"); - }, speedToSyncTime); - - console.debug(`SyncPlay: SpeedToSync x${speed.toFixed(2)}`); - } - } - // SkipToSync - else if (this.useSkipToSync && absDiffMillis >= this.minDelaySkipToSync) { - const serverPositionMs = ticksToMs(serverPositionTicks); - this.localSeek(serverPositionMs); - this.syncEnabled = false; - this.syncAttempts++; - this.emit("syncing", true, `SkipToSync (${this.syncAttempts})`); - - this.syncTimeout = setTimeout(() => { - this.syncEnabled = true; - this.emit("syncing", false, "None"); - }, this.maxDelaySpeedToSync / 2); - - console.debug(`SyncPlay: SkipToSync to ${serverPositionMs}ms`); - } else { - // Synced - if (this.syncAttempts > 0) { - console.debug(`SyncPlay: synced after ${this.syncAttempts} attempts`); - } - this.syncAttempts = 0; - } - } - - // ============================================================================ - // Cleanup - // ============================================================================ - - /** - * Reset PlaybackCore state — used when SyncPlay is disabled so we don't - * carry stale commands, scheduled timers, or sync state into the next - * session. - */ - reset(): void { - this.clearScheduledCommand(); - this.lastCommand = null; - this.lastSyncTime = new Date(); - this.syncAttempts = 0; - this.playbackDiffMillis = 0; - this.playerIsBuffering = false; - // Forget what we last told the server so the next session starts fresh. - this.lastBufferingSent = null; - this.inflightBufferingRequest = null; - if (this.notifyBufferingTimeout) { - clearTimeout(this.notifyBufferingTimeout); - this.notifyBufferingTimeout = null; - } - // Drop a pending pause-before-ready flag so it can't leak into the - // next group. - this.pendingInitialPause = false; - } - - /** - * Destroy the playback core - */ - destroy(): void { - this.clearScheduledCommand(); - this.removeAllListeners(); - this.playerControls = null; - this.onNotifyOsd = null; - this.getCurrentPlaylistItemId = null; - } -} diff --git a/providers/SyncPlay/QueueCore.ts b/providers/SyncPlay/QueueCore.ts deleted file mode 100644 index 1b8d78f1f..000000000 --- a/providers/SyncPlay/QueueCore.ts +++ /dev/null @@ -1,292 +0,0 @@ -/** - * QueueCore - * - * Manages the shared playlist/queue for SyncPlay. - * Handles queue updates from the server. - * - * Based on jellyfin-web's QueueCore.js - */ - -import { EventEmitter } from "./Helper"; -import type { - GroupRepeatMode, - GroupShuffleMode, - PlayQueueUpdate, - SyncPlayQueueItem, -} from "./types"; - -/** - * QueueCore - Manages the shared playlist - */ -export class QueueCore extends EventEmitter { - // Queue state - private lastPlayQueueUpdate: PlayQueueUpdate | null = null; - private playlist: SyncPlayQueueItem[] = []; - - // Callbacks - private onStartPlayback: (() => void) | null = null; - private estimateCurrentTicks: ((ticks: number, when: Date) => number) | null = - null; - - /** - * Set the start playback callback - */ - setStartPlaybackHandler(handler: (() => void) | null): void { - this.onStartPlayback = handler; - } - - /** - * Set the ticks estimator function - */ - setTicksEstimator( - estimator: ((ticks: number, when: Date) => number) | null, - ): void { - this.estimateCurrentTicks = estimator; - } - - // ============================================================================ - // Queue State - // ============================================================================ - - /** - * Get the current playlist - */ - getPlaylist(): SyncPlayQueueItem[] { - return [...this.playlist]; - } - - /** - * Check if playlist is empty - */ - isPlaylistEmpty(): boolean { - return this.playlist.length === 0; - } - - /** - * Get current playing index - */ - getCurrentPlaylistIndex(): number { - return this.lastPlayQueueUpdate?.PlayingItemIndex ?? -1; - } - - /** - * Get current playlist item ID - */ - getCurrentPlaylistItemId(): string | null { - if (!this.lastPlayQueueUpdate) return null; - - const index = this.lastPlayQueueUpdate.PlayingItemIndex; - if (index === undefined || index === -1 || index >= this.playlist.length) { - return null; - } - - return this.playlist[index]?.PlaylistItemId ?? null; - } - - /** - * Get current item's Jellyfin ID (the actual media item ID) - */ - getCurrentItemId(): string | null { - if (!this.lastPlayQueueUpdate) return null; - - const index = this.lastPlayQueueUpdate.PlayingItemIndex; - if (index === undefined || index === -1 || index >= this.playlist.length) { - return null; - } - - return this.playlist[index]?.ItemId ?? null; - } - - /** - * Get the current item from the playlist - */ - getCurrentItem(): SyncPlayQueueItem | null { - if (!this.lastPlayQueueUpdate) return null; - - const index = this.lastPlayQueueUpdate.PlayingItemIndex; - if (index === undefined || index === -1 || index >= this.playlist.length) { - return null; - } - - return this.playlist[index] ?? null; - } - - /** - * Get the last update time - */ - getLastUpdate(): Date | null { - const lastUpdate = this.lastPlayQueueUpdate?.LastUpdate; - return lastUpdate ? new Date(lastUpdate) : null; - } - - /** - * Get the last update time as timestamp - */ - getLastUpdateTime(): number { - const lastUpdate = this.lastPlayQueueUpdate?.LastUpdate; - return lastUpdate ? new Date(lastUpdate).getTime() : 0; - } - - /** - * Get start position ticks - */ - getStartPositionTicks(): number { - return this.lastPlayQueueUpdate?.StartPositionTicks ?? 0; - } - - /** - * Get repeat mode - */ - getRepeatMode(): GroupRepeatMode { - return this.lastPlayQueueUpdate?.RepeatMode ?? "RepeatNone"; - } - - /** - * Get shuffle mode - */ - getShuffleMode(): GroupShuffleMode { - return this.lastPlayQueueUpdate?.ShuffleMode ?? "Sorted"; - } - - /** - * Get playlist as item IDs - */ - getPlaylistAsItemIds(): (string | undefined)[] { - return this.playlist.map((item) => item.ItemId); - } - - // ============================================================================ - // Queue Updates - // ============================================================================ - - /** - * Update the play queue from server - */ - async updatePlayQueue(update: PlayQueueUpdate): Promise { - // Parse the last update time - const updateTime = update.LastUpdate - ? new Date(update.LastUpdate).getTime() - : 0; - - // Ignore old updates - if (updateTime <= this.getLastUpdateTime()) { - console.debug("SyncPlay QueueCore: ignoring old update", update); - return; - } - - console.log("SyncPlay QueueCore: processing update", { - reason: update.Reason, - position: update.StartPositionTicks, - index: update.PlayingItemIndex, - }); - - // Check for position change (seek) - const oldPosition = this.lastPlayQueueUpdate?.StartPositionTicks ?? 0; - const newPosition = update.StartPositionTicks ?? 0; - const positionChanged = Math.abs(newPosition - oldPosition) > 10000000; // > 1 second difference - - // Store the update - this.lastPlayQueueUpdate = update; - this.playlist = update.Playlist ?? []; - - // Emit update event - this.emit("queue-update", update); - - // Handle different update reasons - switch (update.Reason) { - case "NewPlaylist": - // Start playback with new playlist - this.onStartPlayback?.(); - break; - - case "SetCurrentItem": - case "NextItem": - case "PreviousItem": - // Item changed - this.emit("item-change", this.getCurrentPlaylistItemId()); - break; - - case "RemoveItems": - case "MoveItem": - case "Queue": - case "QueueNext": - // Playlist modified - this.emit("playlist-change", this.playlist); - break; - - case "RepeatMode": - this.emit("repeat-mode-change", update.RepeatMode); - break; - - case "ShuffleMode": - this.emit("shuffle-mode-change", update.ShuffleMode); - break; - - default: - console.debug( - "SyncPlay QueueCore: unhandled update reason", - update.Reason, - ); - break; - } - - // Emit seek if position changed significantly (likely a seek from another device) - if (positionChanged && update.Reason !== "NewPlaylist") { - console.log( - `SyncPlay QueueCore: position changed ${oldPosition} -> ${newPosition}, emitting seek`, - ); - this.emit("seek", newPosition); - } - } - - /** - * Get estimated start position based on last command - */ - getEstimatedStartPosition( - lastCommandPositionTicks: number | null, - lastCommandWhen: Date | null, - ): number { - if (lastCommandPositionTicks !== null && lastCommandWhen !== null) { - // Use playback command if recent enough - if (lastCommandWhen.getTime() >= this.getLastUpdateTime()) { - return ( - this.estimateCurrentTicks?.( - lastCommandPositionTicks, - lastCommandWhen, - ) ?? lastCommandPositionTicks - ); - } - } - - // Fall back to queue update position - const startTicks = this.getStartPositionTicks(); - const lastUpdate = this.getLastUpdate(); - if (lastUpdate) { - return this.estimateCurrentTicks?.(startTicks, lastUpdate) ?? startTicks; - } - - return startTicks; - } - - // ============================================================================ - // Cleanup - // ============================================================================ - - /** - * Clear the queue - */ - clear(): void { - this.lastPlayQueueUpdate = null; - this.playlist = []; - } - - /** - * Destroy the queue core - */ - destroy(): void { - this.clear(); - this.removeAllListeners(); - this.onStartPlayback = null; - this.estimateCurrentTicks = null; - } -} diff --git a/providers/SyncPlay/SyncPlayProvider.tsx b/providers/SyncPlay/SyncPlayProvider.tsx index a9c722d13..89413be29 100644 --- a/providers/SyncPlay/SyncPlayProvider.tsx +++ b/providers/SyncPlay/SyncPlayProvider.tsx @@ -1,8 +1,19 @@ /** - * SyncPlayProvider + * SyncPlayProvider — React glue around `SyncPlayManager`. * - * React context provider for SyncPlay functionality. - * Manages the SyncPlay manager and exposes hooks for components. + * Responsibilities: + * - Manager lifecycle (construct on api change, destroy on unmount) + * - React mirrors of manager state (`isEnabled`, `groupInfo`, + * `pendingPlaybackCommand`) so components re-render + * - Navigation handlers wired into `PlayerWrapper.localPlay` / + * `localSetCurrentPlaylistItem` — these are what jellyfin-web does + * synchronously via `playbackManager.play`; on RN they navigate + * to the player screen instead + * - AppState foreground re-join (we may miss broadcasts while + * suspended) + * + * External API surface (`useSyncPlay`) is stable; components don't + * change when the internals do. */ import { getSyncPlayApi } from "@jellyfin/sdk/lib/utils/api"; @@ -22,64 +33,35 @@ import { toast } from "sonner-native"; import { useAppRouter } from "@/hooks/useAppRouter"; import i18n from "@/i18n"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; -import { SyncPlayController } from "./Controller"; -import { ticksToMs } from "./Helper"; +import type { Controller as SyncPlayController } from "./Controller"; +import { ticksToMs } from "./constants"; import { SyncPlayManager } from "./Manager"; -import { PlaybackCore } from "./PlaybackCore"; -import { QueueCore } from "./QueueCore"; -import type { - GroupInfoDto, - PlayerControls, - PlayQueueUpdate, - SendCommand, - SyncPlayOsdAction, - SyncPlayStats, -} from "./types"; -import { useSyncPlayWebSocket } from "./useSyncPlayWebSocket"; - -// ============================================================================ -// Context Types -// ============================================================================ +import { useSyncPlayWebSocket } from "./transport/useSyncPlayWebSocket"; +import type { GroupInfoDto, PlayerControls } from "./types"; interface SyncPlayContextValue { - // State isEnabled: boolean; - isReady: boolean; groupInfo: GroupInfoDto | null; canJoinGroups: boolean; canCreateGroups: boolean; - // Group management joinGroup: (groupId: string) => Promise; createGroup: (groupName?: string) => Promise; leaveGroup: () => Promise; getGroups: () => Promise; - // Playback control delegation controller: SyncPlayController | null; - // Player integration setPlayerControls: (controls: PlayerControls | null) => void; notifyReady: () => void; - notifyBuffering: () => void; + notifyBuffering: (isBuffering: boolean) => void; + notifyPlaybackStart: () => void; - // Stats - getStats: () => SyncPlayStats; - - // OSD state - osdAction: SyncPlayOsdAction | null; - isSyncing: boolean; - syncMethod: string; - /** In-flight Unpause/Pause request, before the server has echoed back. */ pendingPlaybackCommand: "Unpause" | "Pause" | null; } const SyncPlayContext = createContext(null); -// ============================================================================ -// Provider Component -// ============================================================================ - interface SyncPlayProviderProps { children: ReactNode; } @@ -89,176 +71,61 @@ export function SyncPlayProvider({ children }: SyncPlayProviderProps) { const user = useAtomValue(userAtom); const router = useAppRouter(); - // Core modules - use state for manager so WebSocket hook re-runs when ready const [manager, setManager] = useState(null); - const playbackCoreRef = useRef(null); - const queueCoreRef = useRef(null); - const controllerRef = useRef(null); - - // Track if we're already on the player page to avoid duplicate navigations const isNavigatingToPlayerRef = useRef(false); - // State const [isEnabled, setIsEnabled] = useState(false); - const [isReady, setIsReady] = useState(false); - const [groupInfo, setGroupInfoDto] = useState(null); - const [osdAction, setOsdAction] = useState(null); - const [isSyncing, setIsSyncing] = useState(false); - const [syncMethod, setSyncMethod] = useState("None"); + const [groupInfo, setGroupInfo] = useState(null); const [pendingPlaybackCommand, setPendingPlaybackCommand] = useState< "Unpause" | "Pause" | null >(null); - // Permission checks const canJoinGroups = useMemo(() => { const access = user?.Policy?.SyncPlayAccess; return access !== "None" && access !== undefined; }, [user?.Policy?.SyncPlayAccess]); - const canCreateGroups = useMemo(() => { - return user?.Policy?.SyncPlayAccess === "CreateAndJoinGroups"; - }, [user?.Policy?.SyncPlayAccess]); + const canCreateGroups = useMemo( + () => user?.Policy?.SyncPlayAccess === "CreateAndJoinGroups", + [user?.Policy?.SyncPlayAccess], + ); + + // Latch: `true` once we've fired the per-attach `playbackstart` event. + const playbackStartFiredRef = useRef(false); + + // --------------------------------------------------------------------------- + // Manager lifecycle + // --------------------------------------------------------------------------- - // Initialize manager useEffect(() => { if (!api) return; - // Create manager and cores - const manager = new SyncPlayManager(api); - const playbackCore = new PlaybackCore(api, manager.getTimeSyncCore()); - const queueCore = new QueueCore(); - const controller = new SyncPlayController(api, manager, queueCore); + const mgr = new SyncPlayManager(api); + mgr.init(); + setManager(mgr); - setManager(manager); - playbackCoreRef.current = playbackCore; - queueCoreRef.current = queueCore; - controllerRef.current = controller; - - // Wire up manager callbacks - manager.setPlaybackCommandHandler((command: SendCommand) => { - playbackCore.applyCommand(command); - }); - - manager.setQueueUpdateHandler((update: PlayQueueUpdate) => { - queueCore.updatePlayQueue(update); - }); - - manager.setPlaylistItemIdGetter(() => { - return queueCore.getCurrentPlaylistItemId(); - }); - - // When SyncPlay is disabled, flush PlaybackCore's scheduled commands and - // cached state so we don't carry ghost commands into the next group. - manager.setDisableHandler(() => { - playbackCore.reset(); - }); - - // Also clear the cached PlayQueue snapshot on disable. If we don't, then - // when the user later re-joins the same group, the server's first - // PlayQueue echo (which can carry the same LastUpdate as the snapshot we - // saw last session) gets dropped by QueueCore's stale-update guard, and - // the receiver never auto-navigates to the group's content. - manager.setQueueClearHandler(() => { - queueCore.clear(); - }); - - // Wire up playback core callbacks - playbackCore.setPlaylistItemIdGetter(() => { - return queueCore.getCurrentPlaylistItemId(); - }); - - playbackCore.setOsdHandler((action) => { - setOsdAction(action); - // Clear after display - setTimeout(() => setOsdAction(null), 1500); - }); - - // Wire up queue core - queueCore.setTicksEstimator((ticks, when) => { - return playbackCore.estimateCurrentTicks(ticks, when); - }); - - // Navigate to player when group starts playing new content - queueCore.setStartPlaybackHandler(async () => { - const itemId = queueCore.getCurrentItemId(); - const startPositionTicks = queueCore.getStartPositionTicks(); + const playerWrapper = mgr.getPlayerWrapper(); + // localPlay → navigate to direct-player with syncPlay=true + playerWrapper.setLocalPlayHandler((options) => { + const itemId = options.ids[0]; if (!itemId) { - console.warn("SyncPlay: new playlist but no current item ID"); + console.warn("SyncPlay: localPlay called with no ids"); return; } - - // Avoid duplicate navigations if (isNavigatingToPlayerRef.current) { console.debug("SyncPlay: already navigating to player"); return; } - - console.log("SyncPlay: navigating to player for item", itemId); isNavigatingToPlayerRef.current = true; - // Mirror jellyfin-web's `QueueCore.startPlayback` ordering: - // 1. followGroupPlayback (IgnoreWait:false) — tell server we follow - // 2. scheduleReadyRequestOnPlaybackStart — arm initial pause - // 3. playerWrapper.localPlay (== our router navigation) — start loading - // The arm-then-navigate order matters: scheduling must happen BEFORE - // navigation so the flag is set when the player attaches and fires - // its first `notifyReady`. Otherwise we race the player and the - // initial SyncPlayReady reports `IsPlaying:true`, defeating the - // server's "hold the group until everyone is parked" semantics. - await manager.followGroupPlayback(); - playbackCore.scheduleReadyRequestOnPlaybackStart(); - - // Show toast notification toast(i18n.t("syncplay.joining_playback")); - // Navigate to the player with the item. Use `replace` so repeated - // queue updates don't stack player screens on the history. const queryParams = new URLSearchParams({ - itemId: itemId, - playbackPosition: startPositionTicks.toString(), - syncPlay: "true", // Mark this as a SyncPlay-initiated playback - }).toString(); - - router.push(`/player/direct-player?${queryParams}` as any); - - // Reset navigation flag after a short delay - setTimeout(() => { - isNavigatingToPlayerRef.current = false; - }, 2000); - }); - - // Also handle item changes (next/previous in playlist) - queueCore.on("item-change", () => { - const newItemId = queueCore.getCurrentItemId(); - const startPositionTicks = queueCore.getStartPositionTicks(); - - if (!newItemId) { - console.warn("SyncPlay: item change but no current item ID"); - return; - } - - // Avoid duplicate navigations - if (isNavigatingToPlayerRef.current) { - return; - } - - console.log("SyncPlay: item changed, navigating to", newItemId); - isNavigatingToPlayerRef.current = true; - - // Same pause-before-ready dance as NewPlaylist — the new item's - // player needs to park at the start position and report - // IsPlaying:false so the server holds the group until everyone is - // ready for the next Unpause. Mirrors jellyfin-web's - // `QueueCore.setCurrentPlaylistItem`. - playbackCore.scheduleReadyRequestOnPlaybackStart(); - - const queryParams = new URLSearchParams({ - itemId: newItemId, - playbackPosition: startPositionTicks.toString(), + itemId, + playbackPosition: String(options.startPositionTicks ?? 0), syncPlay: "true", }).toString(); - router.push(`/player/direct-player?${queryParams}`); setTimeout(() => { @@ -266,113 +133,103 @@ export function SyncPlayProvider({ children }: SyncPlayProviderProps) { }, 2000); }); - // Handle seek events from other devices - pause first, then seek (like Jellyfin-web) - queueCore.on("seek", (...args: unknown[]) => { - const positionTicks = args[0] as number; - const positionMs = ticksToMs(positionTicks); - console.log( - "SyncPlay: seek event received, pausing then seeking to", - positionMs, - "ms", - ); - const playerControls = manager.getPlayerControls(); - if (playerControls) { - playerControls.pause(); - playerControls.seekTo(positionMs); + // localSetCurrentPlaylistItem → navigate to the new playlist item + playerWrapper.setLocalSetCurrentItemHandler((playlistItemId) => { + if (!playlistItemId) return; + const queueCore = mgr.getQueueCore(); + const target = queueCore + .getPlaylist() + .find((i) => i.PlaylistItemId === playlistItemId); + const itemId = target?.Id; + if (!itemId) { + console.warn( + "SyncPlay: localSetCurrentPlaylistItem — item not in playlist", + playlistItemId, + ); + return; } + if (isNavigatingToPlayerRef.current) return; + isNavigatingToPlayerRef.current = true; + + const queryParams = new URLSearchParams({ + itemId, + playbackPosition: String(queueCore.getStartPositionTicks()), + syncPlay: "true", + }).toString(); + router.push(`/player/direct-player?${queryParams}`); + + setTimeout(() => { + isNavigatingToPlayerRef.current = false; + }, 2000); }); - // Subscribe to manager events - manager.on("enabled", (...args: unknown[]) => { + mgr.on("enabled", (...args: unknown[]) => { const enabled = args[0] as boolean; setIsEnabled(enabled); - if (!enabled) { - setIsReady(false); - setGroupInfoDto(null); - } + if (!enabled) setGroupInfo(null); }); - manager.on("syncing", (...args: unknown[]) => { - const syncing = args[0] as boolean; - const method = args[1] as string; - setIsSyncing(syncing); - setSyncMethod(method); + mgr.on("group-update", (...args: unknown[]) => { + setGroupInfo((args[0] as GroupInfoDto | null | undefined) ?? null); }); - // Keep React-side groupInfo in sync with Manager mutations. Without this, - // CenterControls' `groupInfo.State === 'Waiting'` check is stale because - // Manager mutates the existing object reference rather than emitting a - // fresh one. - manager.on("group-info-change", (...args: unknown[]) => { - setGroupInfoDto(args[0] as GroupInfoDto); - }); - - // Expose pending Unpause/Pause to consumers (e.g. CenterControls renders - // a spinner instead of the play/pause button while a request is in - // flight — mirrors jellyfin-web's "schedule-play" indicator). - manager.on("pending-playback-change", (...args: unknown[]) => { + mgr.on("pending-playback-change", (...args: unknown[]) => { setPendingPlaybackCommand(args[0] as "Unpause" | "Pause" | null); }); - // When entering Waiting state, report ready through PlaybackCore - manager.on("waiting-for-ready", () => { - console.log( - "SyncPlay: waiting-for-ready event, calling PlaybackCore.onReady()", - ); - playbackCore.onReady(); - }); - - // Handle seek from StateUpdate (when SyncPlayCommand doesn't include seek) - manager.on("seek-from-state-update", (...args: unknown[]) => { - const positionTicks = args[0] as number; - const positionMs = ticksToMs(positionTicks); - console.log( - "SyncPlay: seek from StateUpdate, seeking to", - positionMs, - "ms", - ); - const playerControls = manager.getPlayerControls(); - if (playerControls) { - playerControls.pause(); - playerControls.seekTo(positionMs); + // group-state-change → on "Waiting", park the player at the last + // broadcast position so it's ready to resume cleanly. + mgr.on("group-state-change", (...args: unknown[]) => { + const state = args[0] as string | undefined; + const wrapper = mgr.getPlayerWrapper(); + if (!wrapper.isPlaybackActive()) return; + if (state === "Waiting") { + const lastCommand = mgr.getLastPlaybackCommand(); + wrapper.localPause(); + if (lastCommand?.PositionTicks != null) { + wrapper.localSeek(lastCommand.PositionTicks); + console.debug( + `SyncPlay: paused + seeked to ${ticksToMs( + lastCommand.PositionTicks, + )}ms on group-state-change=Waiting`, + ); + } } }); - // Initialize - manager.init(); + mgr.on("toast", (...args: unknown[]) => { + const key = args[0] as string; + const arg = args[1] as string | undefined; + const message = arg + ? i18n.t(`syncplay.toasts.${key}`, { user: arg }) + : i18n.t(`syncplay.toasts.${key}`); + toast(message); + }); return () => { - manager.destroy(); - playbackCore.destroy(); - queueCore.destroy(); + mgr.destroy(); setManager(null); - playbackCoreRef.current = null; - queueCoreRef.current = null; - controllerRef.current = null; }; - }, [api]); + }, [api, router]); - // Update group info when enabled + // Initial join race: once `enabled` flips true, snapshot the current group. useEffect(() => { if (isEnabled && manager) { - setGroupInfoDto(manager.getGroupInfo()); - setIsReady(manager.isSyncPlayReady()); + setGroupInfo(manager.getGroupInfo()); } }, [isEnabled, manager]); - // Connect to WebSocket messages - manager is now state so hook re-runs when ready + // Wire WebSocket messages → manager useSyncPlayWebSocket(manager); - // ============================================================================ - // Group Management - // ============================================================================ + // --------------------------------------------------------------------------- + // Group management + // --------------------------------------------------------------------------- const getGroups = useCallback(async (): Promise => { if (!api) return []; - try { - const syncPlayApi = getSyncPlayApi(api); - const response = await syncPlayApi.syncPlayGetGroups(); + const response = await getSyncPlayApi(api).syncPlayGetGroups(); return (response.data as unknown as GroupInfoDto[]) ?? []; } catch (error) { console.error("SyncPlay: failed to get groups", error); @@ -383,13 +240,9 @@ export function SyncPlayProvider({ children }: SyncPlayProviderProps) { const joinGroup = useCallback( async (groupId: string): Promise => { if (!api) return; - try { - const syncPlayApi = getSyncPlayApi(api); - await syncPlayApi.syncPlayJoinGroup({ - joinGroupRequestDto: { - GroupId: groupId, - }, + await getSyncPlayApi(api).syncPlayJoinGroup({ + joinGroupRequestDto: { GroupId: groupId }, }); } catch (error) { console.error("SyncPlay: failed to join group", error); @@ -402,15 +255,10 @@ export function SyncPlayProvider({ children }: SyncPlayProviderProps) { const createGroup = useCallback( async (groupName?: string): Promise => { if (!api || !user) return; - const name = groupName || `${user.Name}'s Group`; - try { - const syncPlayApi = getSyncPlayApi(api); - await syncPlayApi.syncPlayCreateGroup({ - newGroupRequestDto: { - GroupName: name, - }, + await getSyncPlayApi(api).syncPlayCreateGroup({ + newGroupRequestDto: { GroupName: name }, }); } catch (error) { console.error("SyncPlay: failed to create group", error); @@ -422,23 +270,18 @@ export function SyncPlayProvider({ children }: SyncPlayProviderProps) { const leaveGroup = useCallback(async (): Promise => { if (!api) return; - try { - const syncPlayApi = getSyncPlayApi(api); - await syncPlayApi.syncPlayLeaveGroup(); + await getSyncPlayApi(api).syncPlayLeaveGroup(); } catch (error) { console.error("SyncPlay: failed to leave group", error); throw error; } }, [api]); - // Re-join the SyncPlay group when the app returns from background. - // - // Backgrounding tears down our WebSocket (see WebSocketProvider) and the - // server may drop us from the group after its inactivity timeout. Even - // when it doesn't, we likely missed any commands/state-updates broadcast - // while we were suspended. Re-issuing the join is idempotent on the - // server and gets us a fresh GroupJoined snapshot. + // --------------------------------------------------------------------------- + // App foreground re-join (idempotent; gets us a fresh GroupJoined snapshot) + // --------------------------------------------------------------------------- + const lastGroupIdRef = useRef(null); useEffect(() => { lastGroupIdRef.current = groupInfo?.GroupId ?? null; @@ -456,15 +299,12 @@ export function SyncPlayProvider({ children }: SyncPlayProviderProps) { (previousAppState === "background" || previousAppState === "inactive") && nextAppState === "active"; - if (!becameActive) return; const groupId = lastGroupIdRef.current; if (!groupId) return; - // Give the WebSocket a moment to reconnect (handled by - // WebSocketProvider on the same 'active' transition) so the - // server's GroupJoined broadcast actually reaches us. + // Small delay so the WebSocket has a moment to reconnect. setTimeout(() => { console.log(`SyncPlay: app foregrounded, rejoining group ${groupId}`); getSyncPlayApi(api) @@ -475,56 +315,48 @@ export function SyncPlayProvider({ children }: SyncPlayProviderProps) { }, 1000); }); - return () => { - subscription.remove(); - }; + return () => subscription.remove(); }, [api]); - // ============================================================================ - // Player Integration - // ============================================================================ + // --------------------------------------------------------------------------- + // Player attach bridges + // --------------------------------------------------------------------------- const setPlayerControls = useCallback( (controls: PlayerControls | null) => { + // Reset the playbackstart latch on each new attach. + playbackStartFiredRef.current = false; manager?.setPlayerControls(controls); - playbackCoreRef.current?.setPlayerControls(controls); }, [manager], ); const notifyReady = useCallback(() => { - console.log("SyncPlay: notifyReady called"); - playbackCoreRef.current?.onReady(); - }, []); - - const notifyBuffering = useCallback(() => { - console.log("SyncPlay: notifyBuffering called"); - playbackCoreRef.current?.onBuffering(); - }, []); - - // ============================================================================ - // Stats - // ============================================================================ - - const getStats = useCallback((): SyncPlayStats => { - return ( - manager?.getStats() ?? { - timeSyncDevice: "None", - timeSyncOffset: "0.00", - playbackDiff: "0.00", - syncMethod: "None", - } - ); + manager?.notifyReady(); }, [manager]); - // ============================================================================ - // Context Value - // ============================================================================ + const notifyBuffering = useCallback( + (isBuffering: boolean) => { + manager?.notifyBuffering(isBuffering); + if (!isBuffering && !playbackStartFiredRef.current) { + playbackStartFiredRef.current = true; + manager?.notifyPlaybackStart(); + } + }, + [manager], + ); + + const notifyPlaybackStart = useCallback(() => { + manager?.notifyPlaybackStart(); + }, [manager]); + + // --------------------------------------------------------------------------- + // Context value + // --------------------------------------------------------------------------- const contextValue: SyncPlayContextValue = useMemo( () => ({ isEnabled, - isReady, groupInfo, canJoinGroups, canCreateGroups, @@ -532,19 +364,15 @@ export function SyncPlayProvider({ children }: SyncPlayProviderProps) { createGroup, leaveGroup, getGroups, - controller: controllerRef.current, + controller: manager?.getController() ?? null, setPlayerControls, notifyReady, notifyBuffering, - getStats, - osdAction, - isSyncing, - syncMethod, + notifyPlaybackStart, pendingPlaybackCommand, }), [ isEnabled, - isReady, groupInfo, canJoinGroups, canCreateGroups, @@ -552,13 +380,11 @@ export function SyncPlayProvider({ children }: SyncPlayProviderProps) { createGroup, leaveGroup, getGroups, + manager, setPlayerControls, notifyReady, notifyBuffering, - getStats, - osdAction, - isSyncing, - syncMethod, + notifyPlaybackStart, pendingPlaybackCommand, ], ); @@ -570,13 +396,6 @@ export function SyncPlayProvider({ children }: SyncPlayProviderProps) { ); } -// ============================================================================ -// Hooks -// ============================================================================ - -/** - * Hook to access SyncPlay state and actions - */ export function useSyncPlay(): SyncPlayContextValue { const context = useContext(SyncPlayContext); if (!context) { @@ -584,11 +403,3 @@ export function useSyncPlay(): SyncPlayContextValue { } return context; } - -/** - * Hook to access the SyncPlay controller - */ -export function useSyncPlayController(): SyncPlayController | null { - const { controller } = useSyncPlay(); - return controller; -} diff --git a/providers/SyncPlay/TimeSyncCore.ts b/providers/SyncPlay/TimeSyncCore.ts deleted file mode 100644 index a3727b962..000000000 --- a/providers/SyncPlay/TimeSyncCore.ts +++ /dev/null @@ -1,284 +0,0 @@ -/** - * TimeSyncCore - * - * Manages time synchronization with the Jellyfin server. - * Uses NTP-like algorithm to calculate clock offset between client and server. - * - * Based on jellyfin-web's TimeSyncCore.js and TimeSync.js - */ - -import type { Api } from "@jellyfin/sdk"; -import { getTimeSyncApi } from "@jellyfin/sdk/lib/utils/api"; -import type { TimeSyncMeasurement } from "./types"; - -// Time estimation constants -const NumberOfTrackedMeasurements = 8; -const PollingIntervalGreedy = 1000; // ms - fast polling initially -const PollingIntervalLowProfile = 60000; // ms - slow polling once synced -const GreedyPingCount = 3; - -/** - * Stores a single time sync measurement - */ -class Measurement { - requestSent: number; - requestReceived: number; - responseSent: number; - responseReceived: number; - - constructor(data: TimeSyncMeasurement) { - this.requestSent = data.requestSent; - this.requestReceived = data.requestReceived; - this.responseSent = data.responseSent; - this.responseReceived = data.responseReceived; - } - - /** - * Calculate time offset from server, in milliseconds. - * Offset = (t1 - t0 + t2 - t3) / 2 - * where t0 = request sent, t1 = request received, t2 = response sent, t3 = response received - */ - getOffset(): number { - return ( - (this.requestReceived - - this.requestSent + - (this.responseSent - this.responseReceived)) / - 2 - ); - } - - /** - * Get round-trip delay, in milliseconds. - */ - getDelay(): number { - return ( - this.responseReceived - - this.requestSent - - (this.responseSent - this.requestReceived) - ); - } - - /** - * Get ping time (half of round-trip), in milliseconds. - */ - getPing(): number { - return this.getDelay() / 2; - } -} - -export type TimeSyncEventCallback = ( - error: Error | null, - timeOffset: number | null, - ping: number | null, -) => void; - -/** - * TimeSyncCore - Manages time synchronization with the server - */ -export class TimeSyncCore { - private api: Api; - private poller: ReturnType | null = null; - private pingStop = true; - private pollingInterval = PollingIntervalGreedy; - private pings = 0; - private measurement: Measurement | null = null; - private measurements: Measurement[] = []; - private extraTimeOffset = 0; - private onUpdateCallback: TimeSyncEventCallback | null = null; - - constructor(api: Api) { - this.api = api; - } - - /** - * Set callback for time sync updates - */ - onUpdate(callback: TimeSyncEventCallback): void { - this.onUpdateCallback = callback; - } - - /** - * Check if time sync is ready (has at least one measurement) - */ - isReady(): boolean { - return this.measurement !== null; - } - - /** - * Get the current time offset with server, in milliseconds. - */ - getTimeOffset(): number { - return (this.measurement?.getOffset() ?? 0) + this.extraTimeOffset; - } - - /** - * Get current ping time to server, in milliseconds. - */ - getPing(): number { - return this.measurement?.getPing() ?? 0; - } - - /** - * Set extra time offset for manual adjustment - */ - setExtraTimeOffset(offset: number): void { - this.extraTimeOffset = offset; - } - - /** - * Convert server time to local time. - */ - remoteDateToLocal(remote: Date): Date { - // remote - local = offset, so local = remote - offset - return new Date(remote.getTime() - this.getTimeOffset()); - } - - /** - * Convert local time to server time. - */ - localDateToRemote(local: Date): Date { - // remote - local = offset, so remote = local + offset - return new Date(local.getTime() + this.getTimeOffset()); - } - - /** - * Get the display name of the sync device - */ - getActiveDeviceName(): string { - return "Server"; - } - - /** - * Make a ping request to the server to measure time offset - */ - private async requestPing(): Promise { - const requestSent = Date.now(); - - const timeSyncApi = getTimeSyncApi(this.api); - const response = await timeSyncApi.getUtcTime(); - - const responseReceived = Date.now(); - const data = response.data; - - const requestReceived = new Date(data.RequestReceptionTime!).getTime(); - const responseSent = new Date(data.ResponseTransmissionTime!).getTime(); - - return { - requestSent, - requestReceived, - responseSent, - responseReceived, - }; - } - - /** - * Update time offset with a new measurement - */ - private updateTimeOffset(measurement: Measurement): void { - this.measurements.push(measurement); - - if (this.measurements.length > NumberOfTrackedMeasurements) { - this.measurements.shift(); - } - - // Pick measurement with minimum delay (most accurate) - const sortedMeasurements = [...this.measurements].sort( - (a, b) => a.getDelay() - b.getDelay(), - ); - this.measurement = sortedMeasurements[0]; - } - - /** - * Internal poller for ping requests - */ - private internalRequestPing(): void { - if (this.poller !== null || this.pingStop) { - return; - } - - this.poller = setTimeout(async () => { - this.poller = null; - - try { - const result = await this.requestPing(); - this.onPingSuccess(result); - } catch (error) { - this.onPingError(error as Error); - } - - // Schedule next ping - this.internalRequestPing(); - }, this.pollingInterval); - } - - /** - * Handle successful ping response - */ - private onPingSuccess(result: TimeSyncMeasurement): void { - const measurement = new Measurement(result); - this.updateTimeOffset(measurement); - - // Slow down polling after initial greedy phase - if (this.pings >= GreedyPingCount) { - this.pollingInterval = PollingIntervalLowProfile; - } else { - this.pings++; - } - - this.onUpdateCallback?.(null, this.getTimeOffset(), this.getPing()); - } - - /** - * Handle ping error - */ - private onPingError(error: Error): void { - console.error("SyncPlay TimeSyncCore: ping error", error); - this.onUpdateCallback?.(error, null, null); - } - - /** - * Start the time sync poller - */ - startPing(): void { - this.pingStop = false; - this.internalRequestPing(); - } - - /** - * Stop the time sync poller - */ - stopPing(): void { - this.pingStop = true; - if (this.poller !== null) { - clearTimeout(this.poller); - this.poller = null; - } - } - - /** - * Force an immediate update (reset to greedy mode) - */ - forceUpdate(): void { - this.stopPing(); - this.pollingInterval = PollingIntervalGreedy; - this.pings = 0; - this.startPing(); - } - - /** - * Drop all accumulated measurements - */ - resetMeasurements(): void { - this.measurement = null; - this.measurements = []; - } - - /** - * Clean up resources - */ - destroy(): void { - this.stopPing(); - this.resetMeasurements(); - this.onUpdateCallback = null; - } -} diff --git a/providers/SyncPlay/constants.ts b/providers/SyncPlay/constants.ts new file mode 100644 index 000000000..bfade370a --- /dev/null +++ b/providers/SyncPlay/constants.ts @@ -0,0 +1,23 @@ +/** + * Constants — shared timing/threshold values used across SyncPlay files. + * Kept separate from `types.ts` because these are implementation tuning + * values, not the public protocol/types surface. + */ + +import { TicksPerMillisecond } from "./types"; + +export { TicksPerMillisecond }; + +/** Default timeout for `waitForEventOnce` (matches jellyfin-web). */ +export const WaitForEventDefaultTimeout = 30000; + +/** Short-lived timeout for player events (matches jellyfin-web). */ +export const WaitForPlayerEventTimeout = 500; + +export function ticksToMs(ticks: number): number { + return ticks / TicksPerMillisecond; +} + +export function msToTicks(ms: number): number { + return Math.round(ms * TicksPerMillisecond); +} diff --git a/providers/SyncPlay/cores/PlaybackCore.ts b/providers/SyncPlay/cores/PlaybackCore.ts new file mode 100644 index 000000000..3a2d2c83d --- /dev/null +++ b/providers/SyncPlay/cores/PlaybackCore.ts @@ -0,0 +1,371 @@ +/** + * SyncPlay PlaybackCore — schedules unpauses/pauses/seeks/stops to fire + * at the precise group-wide moment and keeps the player drift-corrected. + * + * Design choices that diverge from jellyfin-web: + * - **No SpeedToSync**. Our RN players' `setPlaybackSpeed` is unreliable + * across platforms (mpv/VLC/expo-video each behave differently for + * fractional speeds). We always seek to catch up. + * - **No `MessageSyncPlayDuplicateMedia` detection**. Web's detection + * used HTML element identity; on RN we don't have a stable handle + * and the false-positive rate would be much higher than the value. + * - **No syncMethod / showSyncIcon**. We don't surface the sync + * technique to the UI. + */ + +import type { Api } from "@jellyfin/sdk"; +import { getSyncPlayApi } from "@jellyfin/sdk/lib/utils/api"; +import { + TicksPerMillisecond, + ticksToMs, + WaitForPlayerEventTimeout, +} from "../constants"; +import { EventEmitter, waitForEventOnce } from "../EventEmitter"; +import type { SyncPlayManager } from "../Manager"; +import { type SendCommand, SYNC_PLAY_TUNING } from "../types"; + +export class PlaybackCore extends EventEmitter { + private manager!: SyncPlayManager; + private lastCommand: SendCommand | null = null; + private scheduledCommand: ReturnType | null = null; + + init(manager: SyncPlayManager): void { + this.manager = manager; + } + + /** Local "playback started" hook — fires the initial Ready request. */ + onPlaybackStart(apiClient: Api): void { + try { + const playerWrapper = this.manager.getPlayerWrapper(); + const positionMs = playerWrapper.currentTime(); + const positionTicks = Math.round(positionMs * TicksPerMillisecond); + const isPlaying = playerWrapper.isPlaying(); + const playlistItemId = + this.manager.getQueueCore().getCurrentPlaylistItemId() ?? undefined; + const now = this.manager.getTimeSync().localDateToRemote(new Date()); + + getSyncPlayApi(apiClient).syncPlayReady({ + readyRequestDto: { + When: now.toISOString(), + PositionTicks: positionTicks, + IsPlaying: isPlaying, + PlaylistItemId: playlistItemId, + }, + }); + } catch (error) { + console.error("SyncPlay onPlaybackStart:", error); + } + } + + /** Local pause → tell the server. */ + onPause(apiClient: Api): void { + try { + getSyncPlayApi(apiClient).syncPlayPause(); + } catch (error) { + console.error("SyncPlay onPause:", error); + } + } + + /** Local unpause → tell the server. */ + onUnpause(apiClient: Api): void { + try { + getSyncPlayApi(apiClient).syncPlayUnpause(); + } catch (error) { + console.error("SyncPlay onUnpause:", error); + } + } + + /** Local "ready" hook — server uses this to know we've finished buffering. */ + onReady(apiClient: Api): void { + this.sendBufferingRequest(apiClient, false); + } + + /** Local "buffering" hook — server uses this to (optionally) pause the group. */ + onBuffering(apiClient: Api): void { + this.sendBufferingRequest(apiClient, true); + } + + /** Send a Ready or Buffering request. */ + sendBufferingRequest(apiClient: Api, isBuffering: boolean): void { + const playerWrapper = this.manager.getPlayerWrapper(); + const positionMs = playerWrapper.currentTime(); + const positionTicks = Math.round(positionMs * TicksPerMillisecond); + const isPlaying = playerWrapper.isPlaying(); + const playlistItemId = + this.manager.getQueueCore().getCurrentPlaylistItemId() ?? undefined; + const now = this.manager.getTimeSync().localDateToRemote(new Date()); + + try { + if (isBuffering) { + getSyncPlayApi(apiClient).syncPlayBuffering({ + bufferRequestDto: { + When: now.toISOString(), + PositionTicks: positionTicks, + IsPlaying: isPlaying, + PlaylistItemId: playlistItemId, + }, + }); + } else { + getSyncPlayApi(apiClient).syncPlayReady({ + readyRequestDto: { + When: now.toISOString(), + PositionTicks: positionTicks, + IsPlaying: isPlaying, + PlaylistItemId: playlistItemId, + }, + }); + } + } catch (error) { + console.error("SyncPlay sendBufferingRequest:", error); + } + } + + /** + * Apply a group command (Unpause, Pause, Stop, Seek). Times the + * execution to fire at the group-wide instant the server selected. + */ + applyCommand(command: SendCommand): void { + (command as unknown as { EmittedAt: Date }).EmittedAt = new Date( + command.EmittedAt as unknown as string, + ); + (command as unknown as { When: Date }).When = new Date( + command.When as unknown as string, + ); + + if ( + this.lastCommand && + (( + this.lastCommand as unknown as { EmittedAt: Date } + ).EmittedAt.getTime() >= + (command as unknown as { EmittedAt: Date }).EmittedAt.getTime() || + (this.lastCommand as unknown as { When: Date }).When.getTime() > + (command as unknown as { When: Date }).When.getTime()) + ) { + console.debug( + "SyncPlay applyCommand: dropping outdated command", + command, + ); + return; + } + + this.lastCommand = command; + if (!this.manager.isFollowingGroupPlayback()) { + console.debug( + "SyncPlay applyCommand: dropping command (not following playback)", + command, + ); + return; + } + + const playerWrapper = this.manager.getPlayerWrapper(); + if (!playerWrapper.isPlaybackActive()) { + console.debug( + "SyncPlay applyCommand: dropping command (playback not active)", + command, + ); + return; + } + + const enqueuedAt = new Date(); + const remoteEnqueuedAt = this.manager + .getTimeSync() + .localDateToRemote(enqueuedAt); + const localCommandWhen = this.manager + .getTimeSync() + .remoteDateToLocal(command.When as unknown as Date); + + switch (command.Command) { + case "Unpause": + this.scheduleUnpause(localCommandWhen, command.PositionTicks ?? 0); + this.emit("osd", "unpause"); + break; + case "Pause": + this.schedulePause(localCommandWhen, command.PositionTicks ?? 0); + this.emit("osd", "pause"); + break; + case "Stop": + this.scheduleStop(localCommandWhen); + break; + case "Seek": + this.scheduleSeek(localCommandWhen, command.PositionTicks ?? 0); + this.emit("osd", "seek"); + break; + default: + console.warn("SyncPlay applyCommand: unknown command", command); + break; + } + + if ( + (command as unknown as { When: Date }).When.getTime() < + remoteEnqueuedAt.getTime() + ) { + console.debug( + "SyncPlay applyCommand: command was scheduled for the past", + command, + ); + } + } + + /** Pre-Unpause hook: emit a wait OSD while we wait for the moment. */ + scheduleUnpause(when: Date, positionTicks: number): void { + this.clearScheduledCommand(); + const now = Date.now(); + const playAtTime = when.getTime(); + const currentPositionMs = this.manager.getPlayerWrapper().currentTime(); + const currentPositionTicks = Math.round( + currentPositionMs * TicksPerMillisecond, + ); + + if (playAtTime > now) { + // Future: seek now, then play at the right moment. + this.localSeek(positionTicks); + this.scheduledCommand = setTimeout(() => { + this.localUnpause(); + // After playback resumes, the player position will need a + // small bump to land on the group target. waitForPlayerEvent + // is best-effort. + waitForEventOnce( + this.manager, + "unpause", + WaitForPlayerEventTimeout, + ).catch(() => undefined); + }, playAtTime - now); + this.emit("osd", "wait-unpause"); + } else { + // Past: catch up now. + const targetMs = ticksToMs(positionTicks); + const delayMs = now - playAtTime; + this.localSeek(Math.round((targetMs + delayMs) * TicksPerMillisecond)); + this.localUnpause(); + void currentPositionTicks; + } + } + + schedulePause(when: Date, positionTicks: number): void { + this.clearScheduledCommand(); + const now = Date.now(); + const pauseAtTime = when.getTime(); + + const callback = () => { + this.localUnpause(); + this.localSeek(positionTicks); + this.localPause(); + }; + + if (pauseAtTime > now) { + this.scheduledCommand = setTimeout(callback, pauseAtTime - now); + this.emit("osd", "wait-pause"); + } else { + callback(); + } + } + + scheduleStop(when: Date): void { + this.clearScheduledCommand(); + const now = Date.now(); + const stopAtTime = when.getTime(); + if (stopAtTime > now) { + this.scheduledCommand = setTimeout(() => { + this.localStop(); + }, stopAtTime - now); + } else { + this.localStop(); + } + } + + scheduleSeek(when: Date, positionTicks: number): void { + this.applyCommand({ + ...this.lastCommand!, + Command: "Pause", + PositionTicks: positionTicks, + When: when as unknown as string, + EmittedAt: new Date().toISOString(), + }); + } + + clearScheduledCommand(): void { + if (this.scheduledCommand) { + clearTimeout(this.scheduledCommand); + this.scheduledCommand = null; + } + } + + // -- local player ops ------------------------------------------------------ + + localUnpause(): void { + this.manager.getPlayerWrapper().localUnpause(); + } + + localPause(): void { + this.manager.getPlayerWrapper().localPause(); + } + + localSeek(positionTicks: number): void { + this.manager.getPlayerWrapper().localSeek(positionTicks); + } + + localStop(): void { + this.manager.getPlayerWrapper().localStop(); + } + + // -- queries --------------------------------------------------------------- + + getLastCommand(): SendCommand | null { + return this.lastCommand; + } + + /** + * Estimate where the group should be in ticks, given a known + * starting position and the time the position was valid at. + */ + estimateCurrentTicks(positionTicks: number, when: Date): number { + const lastCommand = this.lastCommand; + if (!lastCommand) return positionTicks; + const remoteNow = this.manager.getTimeSync().localDateToRemote(new Date()); + const elapsedMs = remoteNow.getTime() - when.getTime(); + if (lastCommand.Command === "Unpause") { + return positionTicks + elapsedMs * TicksPerMillisecond; + } + return positionTicks; + } + + /** + * Drift correction tick — called on every player time update. Skips + * to the group's expected position if drift exceeds the threshold. + * SpeedToSync is intentionally not implemented (see file header). + */ + syncPlaybackTime(): void { + const lastCommand = this.lastCommand; + if (lastCommand?.Command !== "Unpause") return; + + const playerWrapper = this.manager.getPlayerWrapper(); + if (!playerWrapper.isPlaying()) return; + + const currentMs = playerWrapper.currentTime(); + const expectedTicks = this.estimateCurrentTicks( + lastCommand.PositionTicks ?? 0, + lastCommand.When as unknown as Date, + ); + const expectedMs = ticksToMs(expectedTicks); + const driftMs = Math.abs(currentMs - expectedMs); + + if (driftMs > SYNC_PLAY_TUNING.minDelaySkipToSync) { + console.log( + `SyncPlay syncPlaybackTime: drift ${driftMs.toFixed( + 0, + )}ms exceeds threshold, seeking to ${expectedMs.toFixed(0)}ms`, + ); + this.localSeek(expectedTicks); + } + } + + // -- teardown -------------------------------------------------------------- + + destroy(): void { + this.clearScheduledCommand(); + this.lastCommand = null; + this.removeAllListeners(); + } +} + +export default PlaybackCore; diff --git a/providers/SyncPlay/cores/QueueCore.ts b/providers/SyncPlay/cores/QueueCore.ts new file mode 100644 index 000000000..e81fefc43 --- /dev/null +++ b/providers/SyncPlay/cores/QueueCore.ts @@ -0,0 +1,332 @@ +/** + * SyncPlay QueueCore — tracks the group's playlist. + * + * Responsibilities: + * - Handle `PlayQueue` group updates (NewPlaylist, SetCurrentItem, + * NextItem, PreviousItem, RemoveItems, etc.) + * - Resolve the server's flat list of ItemIds into full `BaseItemDto`s + * (with PlaylistItemId glued on for SyncPlay requests) + * - Expose `currentPlaylistItemId` — required by every SyncPlay + * request (Ready, Buffering, Seek) so the server can ignore stale + * ones from before the playlist moved + * - On NewPlaylist, ask the server we're ready by sending a Buffering + * request after the local player emits `playbackstart` + */ + +import type { Api } from "@jellyfin/sdk"; +import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; +import { getSyncPlayApi } from "@jellyfin/sdk/lib/utils/api"; +import { TicksPerMillisecond, WaitForEventDefaultTimeout } from "../constants"; +import { EventEmitter, waitForEventOnce } from "../EventEmitter"; +import type { SyncPlayManager } from "../Manager"; +import { + getItemsForPlayback, + translateItemsForPlayback, +} from "../transport/queueTranslation"; +import type { + PlayQueueUpdate, + PlayQueueUpdateReason, + SyncPlayQueueItem, +} from "../types"; + +export class QueueCore extends EventEmitter { + private manager!: SyncPlayManager; + private lastPlayQueueUpdate: PlayQueueUpdate | null = null; + /** Playable items with `PlaylistItemId` glued on. */ + private playlist: BaseItemDto[] = []; + + init(manager: SyncPlayManager): void { + this.manager = manager; + } + + /** Handle a PlayQueue group update from the server. */ + updatePlayQueue(apiClient: Api, newPlayQueue: PlayQueueUpdate): void { + (newPlayQueue as unknown as { LastUpdate: Date }).LastUpdate = new Date( + newPlayQueue.LastUpdate as unknown as string, + ); + + if ( + (newPlayQueue.LastUpdate as unknown as Date).getTime() <= + this.getLastUpdateTime() + ) { + console.debug("SyncPlay updatePlayQueue: ignoring old update"); + return; + } + + this.onPlayQueueUpdate(apiClient, newPlayQueue) + .then(() => { + if ( + (newPlayQueue.LastUpdate as unknown as Date).getTime() < + this.getLastUpdateTime() + ) { + console.warn("SyncPlay updatePlayQueue: trying to apply old update"); + return; + } + + const reason = newPlayQueue.Reason as PlayQueueUpdateReason; + switch (reason) { + case "NewPlaylist": { + if (!this.manager.isFollowingGroupPlayback()) { + this.manager.followGroupPlayback(apiClient).then(() => { + this.startPlayback(apiClient); + }); + } else { + this.startPlayback(apiClient); + } + break; + } + case "SetCurrentItem": + case "NextItem": + case "PreviousItem": { + const playlistItemId = this.getCurrentPlaylistItemId(); + this.setCurrentPlaylistItem(apiClient, playlistItemId); + break; + } + case "RemoveItems": + case "MoveItem": + case "Queue": + case "QueueNext": + case "RepeatMode": + case "ShuffleMode": + // Video-focused: we don't expose repeat/shuffle/queue mutation + // controls in the RN UI yet, so these reasons just update our + // local snapshot (already done by onPlayQueueUpdate) without + // triggering any local action. + break; + default: + console.warn( + "SyncPlay updatePlayQueue: unknown reason", + newPlayQueue.Reason, + ); + break; + } + }) + .catch((error) => { + console.warn("SyncPlay updatePlayQueue:", error); + }); + } + + /** Apply a play-queue update to local state. */ + async onPlayQueueUpdate( + apiClient: Api, + playQueueUpdate: PlayQueueUpdate, + ): Promise { + const itemIds = (playQueueUpdate.Playlist ?? []) + .map((queueItem: SyncPlayQueueItem) => queueItem.ItemId) + .filter((id): id is string => typeof id === "string"); + + if (!itemIds.length) { + this.lastPlayQueueUpdate = playQueueUpdate; + this.playlist = []; + return; + } + + const fetched = await getItemsForPlayback(apiClient, itemIds); + const items = await translateItemsForPlayback(apiClient, fetched, { + ids: itemIds, + }); + + if ( + this.lastPlayQueueUpdate && + (playQueueUpdate.LastUpdate as unknown as Date).getTime() <= + this.getLastUpdateTime() + ) { + throw new Error("Trying to apply old update"); + } + + // Glue PlaylistItemId from the server's playlist entries onto each + // resolved item. The server-assigned IDs are what every SyncPlay + // request needs to identify the queue slot. + const playlistItems = playQueueUpdate.Playlist ?? []; + for (let i = 0; i < items.length && i < playlistItems.length; i++) { + items[i].PlaylistItemId = playlistItems[i].PlaylistItemId; + } + + this.lastPlayQueueUpdate = playQueueUpdate; + this.playlist = items; + } + + /** + * Send a Ready request once the local player begins playback. The + * server uses this to wait until every member is buffered before + * issuing the next Unpause. + * + * On timeout (player never starts), halt group playback so the rest + * of the group can proceed without us. + */ + scheduleReadyRequestOnPlaybackStart(apiClient: Api, origin: string): void { + waitForEventOnce( + this.manager, + "playbackstart", + WaitForEventDefaultTimeout, + ["playbackerror"], + ) + .then(() => { + console.debug( + "SyncPlay scheduleReadyRequestOnPlaybackStart: notifying server", + ); + const playerWrapper = this.manager.getPlayerWrapper(); + playerWrapper.localPause(); + + const currentPosition = playerWrapper.currentTime(); + const currentPositionTicks = Math.round( + currentPosition * TicksPerMillisecond, + ); + const isPlaying = playerWrapper.isPlaying(); + const now = this.manager.getTimeSync().localDateToRemote(new Date()); + + try { + getSyncPlayApi(apiClient).syncPlayReady({ + readyRequestDto: { + When: now.toISOString(), + PositionTicks: currentPositionTicks, + IsPlaying: isPlaying, + PlaylistItemId: this.getCurrentPlaylistItemId() ?? undefined, + }, + }); + } catch (error) { + console.error("SyncPlay syncPlayReady failed", error); + } + }) + .catch((error) => { + console.error( + "Timed out waiting for 'playbackstart' event!", + origin, + error, + ); + if (!this.manager.isSyncPlayEnabled()) { + this.manager.emit("toast", "MessageSyncPlayErrorMedia"); + } + this.manager.haltGroupPlayback(apiClient); + }); + } + + /** Start local playback by navigating to the player screen for the current item. */ + startPlayback(apiClient: Api): void { + if (!this.manager.isFollowingGroupPlayback()) { + console.debug("SyncPlay startPlayback: ignoring, not following playback"); + return; + } + + if (this.isPlaylistEmpty()) { + console.debug("SyncPlay startPlayback: empty playlist"); + return; + } + + // Estimate where to start playback from. Prefer the last playback + // command if newer than the queue update (playback ticks change + // more often than queue position). + const playbackCommand = this.manager.getLastPlaybackCommand(); + let startPositionTicks = 0; + + if ( + playbackCommand && + ( + playbackCommand as unknown as { EmittedAt: Date } + ).EmittedAt?.getTime() >= this.getLastUpdateTime() + ) { + startPositionTicks = this.manager + .getPlaybackCore() + .estimateCurrentTicks( + playbackCommand.PositionTicks ?? 0, + (playbackCommand as unknown as { When: Date }).When, + ); + } else { + startPositionTicks = this.manager + .getPlaybackCore() + .estimateCurrentTicks( + this.getStartPositionTicks(), + (this.getLastUpdate() ?? new Date()) as Date, + ); + } + + const serverId = apiClient.deviceInfo?.id ?? ""; + + this.scheduleReadyRequestOnPlaybackStart(apiClient, "startPlayback"); + + this.manager + .getPlayerWrapper() + .localPlay({ + ids: this.getPlaylistAsItemIds(), + startPositionTicks, + startIndex: this.getCurrentPlaylistIndex(), + serverId, + }) + .catch((error: unknown) => { + console.error("SyncPlay startPlayback: localPlay failed", error); + this.manager.emit("toast", "MessageSyncPlayErrorMedia"); + }); + } + + /** Navigate to a specific item in the queue. */ + setCurrentPlaylistItem(apiClient: Api, playlistItemId: string | null): void { + if (!this.manager.isFollowingGroupPlayback()) { + console.debug( + "SyncPlay setCurrentPlaylistItem: ignoring, not following playback", + ); + return; + } + + this.scheduleReadyRequestOnPlaybackStart( + apiClient, + "setCurrentPlaylistItem", + ); + + this.manager.getPlayerWrapper().localSetCurrentPlaylistItem(playlistItemId); + } + + // -- getters --------------------------------------------------------------- + + getCurrentPlaylistIndex(): number { + return this.lastPlayQueueUpdate?.PlayingItemIndex ?? -1; + } + + getCurrentPlaylistItemId(): string | null { + if (!this.lastPlayQueueUpdate) return null; + const index = this.lastPlayQueueUpdate.PlayingItemIndex ?? -1; + if (index === -1) return null; + return this.playlist[index]?.PlaylistItemId ?? null; + } + + getPlaylist(): BaseItemDto[] { + return this.playlist.slice(0); + } + + isPlaylistEmpty(): boolean { + return this.playlist.length === 0; + } + + getLastUpdate(): Date | null { + if (!this.lastPlayQueueUpdate) return null; + return this.lastPlayQueueUpdate.LastUpdate as unknown as Date; + } + + getLastUpdateTime(): number { + if (!this.lastPlayQueueUpdate) return 0; + return (this.lastPlayQueueUpdate.LastUpdate as unknown as Date).getTime(); + } + + getStartPositionTicks(): number { + return this.lastPlayQueueUpdate?.StartPositionTicks ?? 0; + } + + getPlaylistAsItemIds(): (string | undefined)[] { + if (!this.lastPlayQueueUpdate) return []; + return (this.lastPlayQueueUpdate.Playlist ?? []).map((q) => q.ItemId); + } + + // -- teardown -------------------------------------------------------------- + + /** Clear cached playlist. Called on group disable so a re-join starts clean. */ + clear(): void { + this.lastPlayQueueUpdate = null; + this.playlist = []; + } + + destroy(): void { + this.clear(); + this.removeAllListeners(); + } +} + +export default QueueCore; diff --git a/providers/SyncPlay/cores/TimeSync.ts b/providers/SyncPlay/cores/TimeSync.ts new file mode 100644 index 000000000..cbbf2f521 --- /dev/null +++ b/providers/SyncPlay/cores/TimeSync.ts @@ -0,0 +1,220 @@ +/** + * TimeSync — NTP-style time synchronisation with the Jellyfin server. + * + * Merged port of jellyfin-web's `core/timeSync/{TimeSync,TimeSyncServer, + * TimeSyncCore}.js` — three classes that exist on web because the + * abstract layer supports syncing against other group members, not just + * the server. RN only syncs against the server, so it's one class. + * + * Algorithm: repeatedly time a round-trip request to `getUtcTime`, + * compute `offset = ((requestReceived - requestSent) + (responseSent - + * responseReceived)) / 2`, keep the minimum-delay measurement out of + * the last 8. This is the standard NTP outlier-rejection trick — the + * measurement with the shortest delay is the most accurate because + * less network jitter could have skewed the timestamps. + * + * Polling: greedy mode at 1s intervals for the first 3 pings to warm + * up the offset, then low-profile at 60s intervals for steady-state. + * `forceUpdate()` resets to greedy mode (called on group join). + */ + +import type { Api } from "@jellyfin/sdk"; +import { getTimeSyncApi } from "@jellyfin/sdk/lib/utils/api"; +import { EventEmitter } from "../EventEmitter"; + +const NumberOfTrackedMeasurements = 8; +const PollingIntervalGreedy = 1000; // ms +const PollingIntervalLowProfile = 60000; // ms +const GreedyPingCount = 3; + +class Measurement { + requestSent: number; + requestReceived: number; + responseSent: number; + responseReceived: number; + + constructor( + requestSent: Date, + requestReceived: Date, + responseSent: Date, + responseReceived: Date, + ) { + this.requestSent = requestSent.getTime(); + this.requestReceived = requestReceived.getTime(); + this.responseSent = responseSent.getTime(); + this.responseReceived = responseReceived.getTime(); + } + + /** Time offset (ms): positive means server clock is ahead of ours. */ + getOffset(): number { + return ( + (this.requestReceived - + this.requestSent + + (this.responseSent - this.responseReceived)) / + 2 + ); + } + + /** Round-trip delay (ms), excluding server processing. */ + getDelay(): number { + return ( + this.responseReceived - + this.requestSent - + (this.responseSent - this.requestReceived) + ); + } + + /** One-way ping (ms). */ + getPing(): number { + return this.getDelay() / 2; + } +} + +/** + * Tracks the offset between this client's clock and the Jellyfin server's + * clock, and exposes conversions between local and remote Dates. + * + * Listeners: + * - `"update"` (timeOffset: number, ping: number) — fires on every + * successful ping. Errors are logged but not emitted; consumers + * should treat absence of updates as transient. + */ +export class TimeSync extends EventEmitter { + private api: Api; + private pingStop = true; + private pollingInterval = PollingIntervalGreedy; + private poller: ReturnType | null = null; + private pings = 0; + private measurement: Measurement | null = null; + private measurements: Measurement[] = []; + + constructor(api: Api) { + super(); + this.api = api; + } + + /** Called when the user switches Jellyfin servers. */ + updateApiClient(api: Api): void { + this.api = api; + } + + /** Whether we've completed at least one successful measurement. */ + isReady(): boolean { + return !!this.measurement; + } + + /** Current best-estimate time offset (ms). */ + getTimeOffset(): number { + return this.measurement ? this.measurement.getOffset() : 0; + } + + /** Current best-estimate one-way ping (ms). */ + getPing(): number { + return this.measurement ? this.measurement.getPing() : 0; + } + + /** Convert a server-time Date to local time. */ + remoteDateToLocal(remote: Date): Date { + return new Date(remote.getTime() - this.getTimeOffset()); + } + + /** Convert a local Date to server time. */ + localDateToRemote(local: Date): Date { + return new Date(local.getTime() + this.getTimeOffset()); + } + + /** Start polling. Idempotent. */ + startPing(): void { + this.pingStop = false; + this.scheduleNextPing(); + } + + /** Stop polling. Idempotent. */ + stopPing(): void { + this.pingStop = true; + if (this.poller) { + clearTimeout(this.poller); + this.poller = null; + } + } + + /** Reset to greedy polling and force a fresh measurement immediately. */ + forceUpdate(): void { + this.stopPing(); + this.pollingInterval = PollingIntervalGreedy; + this.pings = 0; + this.startPing(); + } + + /** Drop all measurements. Used on group leave. */ + resetMeasurements(): void { + this.measurement = null; + this.measurements = []; + } + + /** Full teardown on provider unmount. */ + destroy(): void { + this.stopPing(); + this.resetMeasurements(); + this.removeAllListeners(); + } + + private scheduleNextPing(): void { + if (this.poller || this.pingStop) return; + this.poller = setTimeout(() => { + this.poller = null; + this.requestPing() + .then((result) => this.onPingResponse(result)) + .catch((error) => { + console.error("SyncPlay TimeSync: ping failed", error); + }) + .finally(() => this.scheduleNextPing()); + }, this.pollingInterval); + } + + private async requestPing() { + const requestSent = new Date(); + const response = await getTimeSyncApi(this.api).getUtcTime(); + const responseReceived = new Date(); + const data = response.data; + const requestReceived = new Date(data.RequestReceptionTime as string); + const responseSent = new Date(data.ResponseTransmissionTime as string); + return { requestSent, requestReceived, responseSent, responseReceived }; + } + + private onPingResponse(result: { + requestSent: Date; + requestReceived: Date; + responseSent: Date; + responseReceived: Date; + }): void { + const measurement = new Measurement( + result.requestSent, + result.requestReceived, + result.responseSent, + result.responseReceived, + ); + + this.measurements.push(measurement); + if (this.measurements.length > NumberOfTrackedMeasurements) { + this.measurements.shift(); + } + + // Outlier rejection: pick the measurement with the shortest delay. + const sorted = [...this.measurements].sort( + (a, b) => a.getDelay() - b.getDelay(), + ); + this.measurement = sorted[0]; + + // Throttle once we've warmed up. + if (this.pings >= GreedyPingCount) { + this.pollingInterval = PollingIntervalLowProfile; + } else { + this.pings++; + } + + this.emit("update", this.getTimeOffset(), this.getPing()); + } +} + +export default TimeSync; diff --git a/providers/SyncPlay/index.ts b/providers/SyncPlay/index.ts index 32a5ef32a..02249a66a 100644 --- a/providers/SyncPlay/index.ts +++ b/providers/SyncPlay/index.ts @@ -1,25 +1,13 @@ /** - * SyncPlay Module + * SyncPlay — public exports. * - * Synchronized playback for Jellyfin. - * Allows multiple users to watch content together in sync. + * Only what external consumers (components, hooks, screens) need. + * Internal modules (PlaybackCore, QueueCore, TimeSync, PlayerWrapper, + * queueTranslation, EventEmitter, etc.) stay package-private. */ -export { SyncPlayController } from "./Controller"; -// Helpers -export * from "./Helper"; -// Core modules +export { Controller as SyncPlayController } from "./Controller"; +export { msToTicks, ticksToMs } from "./constants"; export { SyncPlayManager } from "./Manager"; -export { PlaybackCore } from "./PlaybackCore"; -export { QueueCore } from "./QueueCore"; - -// Provider and hooks -export { - SyncPlayProvider, - useSyncPlay, - useSyncPlayController, -} from "./SyncPlayProvider"; -export { TimeSyncCore } from "./TimeSyncCore"; - -// Types +export { SyncPlayProvider, useSyncPlay } from "./SyncPlayProvider"; export * from "./types"; diff --git a/providers/SyncPlay/player/PendingPlaybackTracker.ts b/providers/SyncPlay/player/PendingPlaybackTracker.ts new file mode 100644 index 000000000..3e567496d --- /dev/null +++ b/providers/SyncPlay/player/PendingPlaybackTracker.ts @@ -0,0 +1,58 @@ +/** + * PendingPlaybackTracker — tracks an in-flight `Unpause` / `Pause` request + * that we've sent to the server but haven't seen echoed back via + * `SyncPlayCommand`. + * + * Drives three things: + * 1. Drop duplicate rapid taps + * 2. Provide an optimistic-UI hint for the in-flight state + * 3. Override "current play state" when deciding pause-vs-unpause + * for the next tap + * + * Auto-clears after `pendingPlaybackTimeoutMs` so a lost broadcast + * doesn't freeze the UI forever. + */ + +import { SYNC_PLAY_TUNING } from "../types"; + +export class PendingPlaybackTracker { + private command: "Unpause" | "Pause" | null = null; + private timeout: ReturnType | null = null; + private onChange: ((cmd: "Unpause" | "Pause" | null) => void) | null = null; + + setChangeHandler( + handler: ((cmd: "Unpause" | "Pause" | null) => void) | null, + ): void { + this.onChange = handler; + } + + get(): "Unpause" | "Pause" | null { + return this.command; + } + + mark(command: "Unpause" | "Pause"): void { + this.command = command; + if (this.timeout) clearTimeout(this.timeout); + this.timeout = setTimeout(() => { + console.debug( + "SyncPlay PendingPlaybackTracker: timed out waiting for broadcast", + command, + ); + this.command = null; + this.timeout = null; + this.onChange?.(null); + }, SYNC_PLAY_TUNING.pendingPlaybackTimeoutMs); + this.onChange?.(command); + } + + clear(): void { + if (this.timeout) { + clearTimeout(this.timeout); + this.timeout = null; + } + if (this.command !== null) { + this.command = null; + this.onChange?.(null); + } + } +} diff --git a/providers/SyncPlay/player/PlayerWrapper.ts b/providers/SyncPlay/player/PlayerWrapper.ts new file mode 100644 index 000000000..7cc7bea42 --- /dev/null +++ b/providers/SyncPlay/player/PlayerWrapper.ts @@ -0,0 +1,87 @@ +/** + * PlayerWrapper — adapter between jellyfin's tick-based playerWrapper API + * and our millisecond-based `PlayerControls`. Methods that have no RN + * analog (queue mutation hooks) delegate to provider-supplied handlers + * which navigate to the player screen. + */ + +import { TicksPerMillisecond } from "../constants"; +import type { PlayerControls } from "../types"; + +/** Options passed to `playerWrapper.localPlay` — provider navigates to the player screen. */ +export interface LocalPlayOptions { + ids: (string | undefined)[]; + startPositionTicks: number; + startIndex: number; + serverId?: string; +} + +export class PlayerWrapper { + private controls: PlayerControls | null = null; + private localPlayHandler: ((options: LocalPlayOptions) => void) | null = null; + private setCurrentItemHandler: + | ((playlistItemId: string | null) => void) + | null = null; + + /** Attach / detach the underlying player. */ + bindToControls(controls: PlayerControls | null): void { + this.controls = controls; + } + + /** Provider wires this to navigate to the player screen. */ + setLocalPlayHandler(handler: ((options: LocalPlayOptions) => void) | null) { + this.localPlayHandler = handler; + } + + /** Provider wires this to navigate to a different queue item. */ + setLocalSetCurrentItemHandler( + handler: ((playlistItemId: string | null) => void) | null, + ) { + this.setCurrentItemHandler = handler; + } + + localUnpause(): void { + this.controls?.play(); + } + + localPause(): void { + this.controls?.pause(); + } + + /** Upstream takes ticks; RN's `seekTo` takes ms. */ + localSeek(positionTicks: number): void { + this.controls?.seekTo(positionTicks / TicksPerMillisecond); + } + + /** RN: pause instead of teardown — leaving the player screen is the navigator's job. */ + localStop(): void { + this.controls?.pause(); + } + + /** Position in ms. */ + currentTime(): number { + return this.controls?.getCurrentPosition() ?? 0; + } + + isPlaying(): boolean { + return this.controls?.isPlaying() ?? false; + } + + isPlaybackActive(): boolean { + return this.controls !== null; + } + + /** RN never runs as a remote-managed player. */ + isRemote(): boolean { + return false; + } + + localPlay(options: LocalPlayOptions): Promise { + this.localPlayHandler?.(options); + return Promise.resolve(); + } + + localSetCurrentPlaylistItem(playlistItemId: string | null): void { + this.setCurrentItemHandler?.(playlistItemId); + } +} diff --git a/providers/SyncPlay/player/bufferingDebouncer.ts b/providers/SyncPlay/player/bufferingDebouncer.ts new file mode 100644 index 000000000..736e3c50d --- /dev/null +++ b/providers/SyncPlay/player/bufferingDebouncer.ts @@ -0,0 +1,64 @@ +/** + * bufferingDebouncer — wrap an `isBuffering: boolean → void` notify callback + * with three RN-only guards. Web gets these for free from HTML `waiting`/ + * `canplay`; our `PlayerControls` exposes state (not events) and the React + * effect that polls it can fire many times per second. + * + * - **dedup**: drop redundant calls when state hasn't changed + * - **debounce buffering→true**: only escalate after the threshold; + * going back to ready cancels the pending escalation + * - **coalesce inflight**: serialize concurrent sends + * + * Returns `{ notify, dispose }`. + */ + +import { SYNC_PLAY_TUNING } from "../types"; + +export function createBufferingDebouncer( + send: (isBuffering: boolean) => Promise, +) { + let lastSent: boolean | null = null; + let inflight: Promise | null = null; + let pendingTimeout: ReturnType | null = null; + + const flush = async (isBuffering: boolean) => { + if (lastSent === isBuffering) return; + if (inflight) { + try { + await inflight; + } catch { + // ignore — used only for ordering + } + if (lastSent === isBuffering) return; + } + lastSent = isBuffering; + inflight = send(isBuffering).finally(() => { + inflight = null; + }); + return inflight; + }; + + return { + notify(isBuffering: boolean): void { + if (pendingTimeout) { + clearTimeout(pendingTimeout); + pendingTimeout = null; + } + if (!isBuffering) { + // Ready always fires immediately. + void flush(false); + return; + } + pendingTimeout = setTimeout(() => { + pendingTimeout = null; + void flush(true); + }, SYNC_PLAY_TUNING.minBufferingThresholdMs); + }, + dispose(): void { + if (pendingTimeout) { + clearTimeout(pendingTimeout); + pendingTimeout = null; + } + }, + }; +} diff --git a/providers/SyncPlay/player/reconcileToGroupOnAttach.ts b/providers/SyncPlay/player/reconcileToGroupOnAttach.ts new file mode 100644 index 000000000..51753c6df --- /dev/null +++ b/providers/SyncPlay/player/reconcileToGroupOnAttach.ts @@ -0,0 +1,58 @@ +/** + * reconcileToGroupOnAttach — estimate the group's current position from + * the last play/pause broadcast and seek the freshly-attached player + * there if drift exceeds the threshold. + * + * Web's player binds at group-join, so this race doesn't exist there. + * On RN the player mounts in a separate route after the join, so + * commands arrive before controls attach. Without this, the player + * resumes from its local position and is silently behind the group. + */ + +import { TicksPerMillisecond } from "../constants"; +import { + type PlayerControls, + type SendCommand, + SYNC_PLAY_TUNING, +} from "../types"; + +export function reconcileToGroupOnAttach( + controls: PlayerControls, + lastCommand: SendCommand | null, + localToRemote: (local: Date) => Date, +): void { + if ( + !lastCommand || + (lastCommand.Command !== "Unpause" && lastCommand.Command !== "Pause") || + !lastCommand.When || + lastCommand.PositionTicks == null + ) { + return; + } + + try { + const commandWhen = new Date(lastCommand.When); + let targetTicks = lastCommand.PositionTicks; + if (lastCommand.Command === "Unpause") { + const remoteNow = localToRemote(new Date()); + targetTicks += + (remoteNow.getTime() - commandWhen.getTime()) * TicksPerMillisecond; + } + const targetMs = Math.max(0, targetTicks / TicksPerMillisecond); + const currentMs = controls.getCurrentPosition(); + if ( + Math.abs(currentMs - targetMs) > + SYNC_PLAY_TUNING.positionReconcileThresholdMs + ) { + console.log( + `SyncPlay: player attached — seeking to estimated group position ${targetMs}ms (was ${currentMs}ms)`, + ); + controls.seekTo(targetMs); + } + } catch (error) { + console.warn( + "SyncPlay: failed to estimate group position on attach", + error, + ); + } +} diff --git a/providers/SyncPlay/transport/queueTranslation.ts b/providers/SyncPlay/transport/queueTranslation.ts new file mode 100644 index 000000000..04916fa2a --- /dev/null +++ b/providers/SyncPlay/transport/queueTranslation.ts @@ -0,0 +1,183 @@ +/** + * queueTranslation — expand container items into a real playable queue. + * + * The server takes the queue we send via `syncPlaySetNewQueue` and + * rebroadcasts it verbatim to every group member. Sending a container + * ID (Series, Season, BoxSet, Playlist) means every receiver fails to + * open the player because they can't directly play a container. We must + * expand to real playable item IDs before sending the queue. + * + * Video-focused: music (MusicArtist/MusicGenre) and photo branches are + * intentionally omitted. Live TV (Program), Episode auto-advance, and + * folder expansion are preserved because they're the common video flows. + */ + +import type { Api } from "@jellyfin/sdk"; +import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; +import { + getItemsApi, + getTvShowsApi, + getUserApi, + getUserLibraryApi, +} from "@jellyfin/sdk/lib/utils/api"; + +export interface TranslateOptions { + ids?: string[]; + shuffle?: boolean; + queryOptions?: Record; +} + +const PLAYBACK_FIELDS = ["Chapters", "Trickplay"] as const; + +async function getCurrentUser(api: Api) { + const user = (await getUserApi(api).getCurrentUser()).data; + if (!user?.Id) { + throw new Error("SyncPlay queueTranslation: no authenticated user"); + } + return user; +} + +async function queryItems( + api: Api, + userId: string, + params: Record, +): Promise { + const res = await getItemsApi(api).getItems({ + limit: 300, + fields: PLAYBACK_FIELDS as unknown as never, + excludeLocationTypes: ["Virtual"] as unknown as never, + enableTotalRecordCount: false, + collapseBoxSetItems: false, + ...params, + userId, + }); + return res.data.Items ?? []; +} + +function fetchFolderChildren( + api: Api, + userId: string, + params: Record, +): Promise { + return queryItems(api, userId, { + filters: ["IsNotFolder"], + recursive: true, + ...params, + }); +} + +/** + * Resolve item IDs into full `BaseItemDto`s. + * + * - single ID → `getItem` (cheap, no Items wrapper) + * - multi ID → `getItems` with playback defaults + */ +export async function getItemsForPlayback( + api: Api, + ids: string[], +): Promise { + if (!ids.length) return []; + const userId = (await getCurrentUser(api)).Id as string; + if (ids.length === 1) { + const res = await getUserLibraryApi(api).getItem({ + userId, + itemId: ids[0], + }); + return res.data ? [res.data] : []; + } + return queryItems(api, userId, { ids }); +} + +/** + * Expand a "first item" into a real playable queue. + * + * - Program → channel items + * - Playlist → playlist children + * - IsFolder (Series, Season, BoxSet, MusicAlbum, ...) → recursive descendants + * - single Episode (when `EnableNextEpisodeAutoPlay`) → remaining series episodes + * - everything else → passthrough (Movies, Audio, single Episode w/ autoplay off) + * + * Preserves the caller's `ids` order so the receiver sees the same + * queue order the sender intended. + */ +export async function translateItemsForPlayback( + api: Api, + items: BaseItemDto[], + options: TranslateOptions = {}, +): Promise { + if (!items.length) return []; + + const workingItems = + items.length > 1 && options.ids + ? [...items].sort( + (a, b) => + (options.ids ?? []).indexOf(a.Id ?? "") - + (options.ids ?? []).indexOf(b.Id ?? ""), + ) + : items; + + const firstItem = workingItems[0]; + + if (firstItem.Type === "Program" && firstItem.ChannelId) { + return getItemsForPlayback(api, [firstItem.ChannelId]); + } + + const user = await getCurrentUser(api); + const userId = user.Id as string; + + if (firstItem.Type === "Playlist") { + return queryItems(api, userId, { + parentId: firstItem.Id, + sortBy: options.shuffle ? ["Random"] : undefined, + }); + } + + if (firstItem.IsFolder) { + // Series, Season, BoxSet, MusicAlbum, etc. + const sortBy = options.shuffle + ? ["Random"] + : firstItem.Type === "BoxSet" + ? ["SortName"] + : undefined; + return fetchFolderChildren(api, userId, { + parentId: firstItem.Id, + mediaTypes: ["Audio", "Video"], + sortBy, + ...(options.queryOptions ?? {}), + }); + } + + if (firstItem.Type === "Episode" && workingItems.length === 1) { + // Single-episode auto-next: load all remaining episodes in the + // series, starting at this one. Gated on the user preference so we + // don't surprise users who disabled autoplay. + if (!user.Configuration?.EnableNextEpisodeAutoPlay || !firstItem.SeriesId) { + return workingItems; + } + const res = await getTvShowsApi(api).getEpisodes({ + seriesId: firstItem.SeriesId, + userId, + isMissing: false, + fields: PLAYBACK_FIELDS as unknown as never, + // SDK omits `isVirtualUnaired` from typed request; server honours + // it. Cast keeps wire payload identical to jellyfin-web. + ...({ isVirtualUnaired: false } as Record), + } as Parameters["getEpisodes"]>[0]); + const all = res.data.Items ?? []; + // Drop everything before firstItem; keep firstItem and everything + // after. Empty list if firstItem isn't in the series (shouldn't + // happen, but matches upstream's behaviour). + let foundItem = false; + return all.filter((e) => { + if (foundItem) return true; + if (e.Id === firstItem.Id) { + foundItem = true; + return true; + } + return false; + }); + } + + // Movies, Audio, single Episode w/ autoplay off, etc. + return workingItems; +} diff --git a/providers/SyncPlay/useSyncPlayWebSocket.ts b/providers/SyncPlay/transport/useSyncPlayWebSocket.ts similarity index 83% rename from providers/SyncPlay/useSyncPlayWebSocket.ts rename to providers/SyncPlay/transport/useSyncPlayWebSocket.ts index fcefb9308..ad7e17c99 100644 --- a/providers/SyncPlay/useSyncPlayWebSocket.ts +++ b/providers/SyncPlay/transport/useSyncPlayWebSocket.ts @@ -18,8 +18,8 @@ import { useEffect } from "react"; import { useWebSocketContext } from "@/providers/WebSocketProvider"; -import type { SyncPlayManager } from "./Manager"; -import type { SendCommand } from "./types"; +import type { SyncPlayManager } from "../Manager"; +import type { GroupUpdate, SendCommand } from "../types"; /** * Hook to connect SyncPlay manager to WebSocket @@ -68,8 +68,15 @@ export function useSyncPlayWebSocket(manager: SyncPlayManager | null): void { } case "SyncPlayGroupUpdate": { - const update = Data as { Type?: string; Data?: unknown }; - console.debug("SyncPlay: group update -", update.Type); + // SDK's `GroupUpdate` type is a discriminated union with a + // narrower `Type` enum than the wire format. Cast through + // unknown so upstream `Manager.processGroupUpdate` can switch + // on the real string. + const update = Data as unknown as GroupUpdate; + console.debug( + "SyncPlay: group update -", + (update as { Type?: string }).Type, + ); manager.processGroupUpdate(update); break; } diff --git a/providers/SyncPlay/types.ts b/providers/SyncPlay/types.ts index 545bccc00..d46952bfb 100644 --- a/providers/SyncPlay/types.ts +++ b/providers/SyncPlay/types.ts @@ -1,83 +1,52 @@ /** - * SyncPlay Types + * SyncPlay — public types and tuning constants. * - * Re-exports Jellyfin SDK types and defines app-specific types. - * Following the pattern used in offline downloads. + * Re-exports the SDK types we use, defines the small RN-specific + * extensions (PlayerControls, OSD actions), and centralises the magic + * numbers that govern sync behaviour. */ import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; -// ============================================================================ -// Re-export SDK Types -// ============================================================================ - -// Group types -// Playback command types -// Queue types -// User access type -// Group update message types +// SDK type re-exports — kept narrow on purpose, only what callers +// actually reach for. export type { GroupInfoDto, + GroupQueueMode, GroupRepeatMode, GroupShuffleMode, GroupStateType, GroupUpdate, - GroupUpdateType, PlayQueueUpdate, PlayQueueUpdateReason, SendCommand, SendCommandType, - SyncPlayGroupJoinedUpdate, - SyncPlayGroupLeftUpdate, - SyncPlayPlayQueueUpdate, SyncPlayQueueItem, - SyncPlayStateUpdate, SyncPlayUserAccessType, - SyncPlayUserJoinedUpdate, - SyncPlayUserLeftUpdate, } from "@jellyfin/sdk/lib/generated-client/models"; -// ============================================================================ -// Constants -// ============================================================================ - +/** Jellyfin's tick unit. 1ms = 10000 ticks. */ export const TicksPerMillisecond = 10000; -export const WaitForEventDefaultTimeout = 30000; // milliseconds -export const WaitForPlayerEventTimeout = 500; // milliseconds - -// ============================================================================ -// App-Specific Types (not in SDK) -// ============================================================================ /** - * Time sync measurement for NTP-like synchronization - */ -export interface TimeSyncMeasurement { - requestSent: number; - requestReceived: number; - responseSent: number; - responseReceived: number; -} - -/** - * Player controls interface for integrating with MPV player + * Player controls SyncPlay drives. The provider wires this up against + * the active RN player (mpv / VLC / expo-video). */ export interface PlayerControls { play: () => void; pause: () => void; + /** Seek to absolute position in milliseconds. */ seekTo: (positionMs: number) => void; setSpeed: (speed: number) => void; getSpeed: () => number; + /** Current position in milliseconds. */ getCurrentPosition: () => number; isPlaying: () => boolean; isBuffering: () => boolean; } -/** - * OSD action types for visual feedback - */ +/** OSD action types — drive optional player-overlay feedback. */ export type SyncPlayOsdAction = - | "schedule-play" | "unpause" | "pause" | "seek" @@ -86,54 +55,26 @@ export type SyncPlayOsdAction = | "wait-unpause"; /** - * SyncPlay settings for sync correction algorithms + * Tuning constants. These mirror jellyfin-web's defaults; tweak with + * care — they affect perceived sync quality across all clients. */ -export interface SyncPlaySettings { - // SpeedToSync settings - minDelaySpeedToSync: number; - maxDelaySpeedToSync: number; - speedToSyncDuration: number; +export const SYNC_PLAY_TUNING = { + /** Drift threshold (ms) above which we hard-seek to catch up. */ + minDelaySkipToSync: 400, + /** Drift beyond this (ms) is always corrected by seeking. */ + maxDelaySync: 3000, + /** Don't escalate buffering to the group for blips shorter than this (ms). */ + minBufferingThresholdMs: 3000, + /** Player-attach drift (ms) above which we reconcile to group position. */ + positionReconcileThresholdMs: 500, + /** Safety timeout (ms) for in-flight Pause/Unpause optimistic UI. */ + pendingPlaybackTimeoutMs: 1500, +} as const; - // SkipToSync settings - minDelaySkipToSync: number; - - // Feature toggles - useSpeedToSync: boolean; - useSkipToSync: boolean; - enableSyncCorrection: boolean; - - // Time sync - extraTimeOffset: number; -} - -export const DEFAULT_SYNC_PLAY_SETTINGS: SyncPlaySettings = { - minDelaySpeedToSync: 60.0, - maxDelaySpeedToSync: 3000.0, - speedToSyncDuration: 1000.0, - minDelaySkipToSync: 400.0, - useSpeedToSync: true, - useSkipToSync: true, - enableSyncCorrection: false, - extraTimeOffset: 0.0, -}; - -/** - * Stats for debugging/display - */ -export interface SyncPlayStats { - timeSyncDevice: string; - timeSyncOffset: string; - playbackDiff: string; - syncMethod: string; -} - -/** - * Play options for starting playback - */ +/** Options accepted by `Controller.play`. */ export interface PlayOptions { ids?: string[]; items?: BaseItemDto[]; startIndex?: number; startPositionTicks?: number; - serverId?: string; }