/** * SyncPlayProvider — React glue around `SyncPlayManager`. * * 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"; import { useAtomValue } from "jotai"; import { createContext, type ReactNode, useCallback, useContext, useEffect, useMemo, useRef, useState, } from "react"; import { AppState, type AppStateStatus } from "react-native"; import { toast } from "sonner-native"; import { useAppRouter } from "@/hooks/useAppRouter"; import { useKeepWebSocketAlive } from "@/hooks/useKeepWebSocketAlive"; import i18n from "@/i18n"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { useWebSocketContext } from "@/providers/WebSocketProvider"; import type { Controller as SyncPlayController } from "./Controller"; import { SyncPlayManager } from "./Manager"; import { useSyncPlayWebSocket } from "./transport/useSyncPlayWebSocket"; import type { GroupInfoDto, PlayerControls, SyncPlayOsdAction } from "./types"; interface SyncPlayContextValue { isEnabled: boolean; groupInfo: GroupInfoDto | null; canJoinGroups: boolean; canCreateGroups: boolean; joinGroup: (groupId: string) => Promise; createGroup: (groupName?: string) => Promise; leaveGroup: () => Promise; getGroups: () => Promise; controller: SyncPlayController | null; setPlayerControls: (controls: PlayerControls | null) => void; notifyReady: () => void; notifyBuffering: (isBuffering: boolean) => void; notifyPlaybackStart: () => void; pendingPlaybackCommand: "Unpause" | "Pause" | null; /** * Current SyncPlay OSD overlay state. Drives the animated icon over the * video that mirrors jellyfin-web's `#syncPlayIcon`. `null` means hidden. */ osdAction: SyncPlayOsdAction | null; } const SyncPlayContext = createContext(null); interface SyncPlayProviderProps { children: ReactNode; } export function SyncPlayProvider({ children }: SyncPlayProviderProps) { const api = useAtomValue(apiAtom); const user = useAtomValue(userAtom); const router = useAppRouter(); const { isConnected: isWsConnected } = useWebSocketContext(); const [manager, setManager] = useState(null); const isNavigatingToPlayerRef = useRef(false); const [isEnabled, setIsEnabled] = useState(false); const [groupInfo, setGroupInfo] = useState(null); const [pendingPlaybackCommand, setPendingPlaybackCommand] = useState< "Unpause" | "Pause" | null >(null); // While in a SyncPlay group, hold a keep-alive token on the global // WebSocket so backgrounding the app does NOT cleanly close the // socket. A clean close is interpreted by the Jellyfin server as // leaving the group and is broadcast to every other member as // " has left the group". Keeping the socket open across a // short suspend lets us stay in the group while quickly switching // apps; if the OS eventually tears the TCP connection down anyway, // the app-foreground rejoin effect below will pull us back in. useKeepWebSocketAlive(isEnabled); const [osdAction, setOsdAction] = useState(null); const osdTimeoutRef = useRef | null>(null); /** * Set the OSD overlay action. * * `transient` mirrors jellyfin-web's `iconVisibilityTime = 1500` for the * Unpause / Pause / Seek command-confirmation flashes. Persistent actions * (schedule-play, buffering, wait-*) stay until cleared by a state * transition or a subsequent call with `null`. */ const showOsd = useCallback( (action: SyncPlayOsdAction | null, transient = false) => { if (osdTimeoutRef.current) { clearTimeout(osdTimeoutRef.current); osdTimeoutRef.current = null; } setOsdAction(action); if (transient && action !== null) { osdTimeoutRef.current = setTimeout(() => { osdTimeoutRef.current = null; setOsdAction((cur) => (cur === action ? null : cur)); }, 1500); } }, [], ); // Pending play/pause tap → optimistic schedule-play overlay (unless another // overlay reason has already taken precedence). useEffect(() => { if (pendingPlaybackCommand) { setOsdAction((cur) => cur ?? "schedule-play"); } else { setOsdAction((cur) => (cur === "schedule-play" ? null : cur)); } }, [pendingPlaybackCommand]); // Clear the OSD auto-expire timeout on unmount. useEffect(() => { return () => { if (osdTimeoutRef.current) { clearTimeout(osdTimeoutRef.current); osdTimeoutRef.current = null; } }; }, []); const canJoinGroups = useMemo(() => { const access = user?.Policy?.SyncPlayAccess; return access !== "None" && access !== undefined; }, [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 // --------------------------------------------------------------------------- useEffect(() => { if (!api) return; const mgr = new SyncPlayManager(api); mgr.init(); setManager(mgr); 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: localPlay called with no ids"); return; } if (isNavigatingToPlayerRef.current) { console.debug("SyncPlay: already navigating to player"); return; } isNavigatingToPlayerRef.current = true; toast(i18n.t("syncplay.joining_playback")); const queryParams = new URLSearchParams({ itemId, playbackPosition: String(options.startPositionTicks ?? 0), syncPlay: "true", }).toString(); router.push(`/player/direct-player?${queryParams}`); setTimeout(() => { isNavigatingToPlayerRef.current = false; }, 2000); }); // 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); }); mgr.on("enabled", (...args: unknown[]) => { const enabled = args[0] as boolean; setIsEnabled(enabled); if (!enabled) setGroupInfo(null); }); mgr.on("group-update", (...args: unknown[]) => { setGroupInfo((args[0] as GroupInfoDto | null | undefined) ?? null); }); mgr.on("pending-playback-change", (...args: unknown[]) => { setPendingPlaybackCommand(args[0] as "Unpause" | "Pause" | null); }); // group-state-change → on "Waiting", pause locally so we don't drift // ahead of the group while the server is reconciling buffering/seek // state. Position resync is *only* done from the explicit Pause / // Unpause / Seek SendCommands that follow (`PlaybackCore.applyCommand` // → `scheduleUnpause` etc.) — those commands carry the canonical // `PositionTicks` for the action's `When`. The old code here also // called `wrapper.localSeek(lastCommand.PositionTicks)`, but // `lastCommand` is the *previous* Pause/Unpause and can be many // seconds stale, so it rewound the user every time someone else // buffered. Don't put a seek back here. mgr.on("group-state-change", (...args: unknown[]) => { const state = args[0] as string | undefined; const reason = args[2] as string | undefined; const wrapper = mgr.getPlayerWrapper(); if (!wrapper.isPlaybackActive()) return; if (state === "Waiting") { wrapper.localPause(); } // Drive the persistent OSD overlay from (state, reason). // Mirrors jellyfin-web's `group-state-update` → `showIcon` mapping. if (state === "Waiting") { if (reason === "Buffer") showOsd("buffering"); else if (reason === "Unpause") showOsd("wait-unpause"); else if (reason === "Pause") showOsd("wait-pause"); else if (reason === "Seek") showOsd("seek"); } else if (state === "Playing" || state === "Paused") { // Stable state — clear any persistent overlay; transient flashes // come from the `osd` event below and self-expire. setOsdAction((cur) => { if ( cur === "schedule-play" || cur === "buffering" || cur === "wait-pause" || cur === "wait-unpause" ) { return null; } return cur; }); } }); // PlaybackCore-emitted OSD events. Transient ones auto-expire in 1.5s. mgr.on("osd", (...args: unknown[]) => { const action = args[0] as SyncPlayOsdAction; const transient = action === "unpause" || action === "pause" || action === "seek"; showOsd(action, transient); }); 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 () => { mgr.destroy(); setManager(null); }; }, [api, router]); // Initial join race: once `enabled` flips true, snapshot the current group. useEffect(() => { if (isEnabled && manager) { setGroupInfo(manager.getGroupInfo()); } }, [isEnabled, manager]); // Wire WebSocket messages → manager useSyncPlayWebSocket(manager); // --------------------------------------------------------------------------- // Group management // --------------------------------------------------------------------------- const getGroups = useCallback(async (): Promise => { if (!api) return []; try { const response = await getSyncPlayApi(api).syncPlayGetGroups(); return (response.data as unknown as GroupInfoDto[]) ?? []; } catch (error) { console.error("SyncPlay: failed to get groups", error); return []; } }, [api]); const joinGroup = useCallback( async (groupId: string): Promise => { if (!api) return; try { await getSyncPlayApi(api).syncPlayJoinGroup({ joinGroupRequestDto: { GroupId: groupId }, }); } catch (error) { console.error("SyncPlay: failed to join group", error); throw error; } }, [api], ); const createGroup = useCallback( async (groupName?: string): Promise => { if (!api || !user) return; const name = groupName || `${user.Name}'s Group`; try { await getSyncPlayApi(api).syncPlayCreateGroup({ newGroupRequestDto: { GroupName: name }, }); } catch (error) { console.error("SyncPlay: failed to create group", error); throw error; } }, [api, user], ); const leaveGroup = useCallback(async (): Promise => { if (!api) return; try { await getSyncPlayApi(api).syncPlayLeaveGroup(); } catch (error) { console.error("SyncPlay: failed to leave group", error); throw error; } }, [api]); // --------------------------------------------------------------------------- // App foreground re-join (idempotent; gets us a fresh GroupJoined snapshot) // --------------------------------------------------------------------------- const lastGroupIdRef = useRef(null); useEffect(() => { lastGroupIdRef.current = groupInfo?.GroupId ?? null; }, [groupInfo?.GroupId]); // Track whether the WebSocket got torn down while the app was // backgrounded. If it survived (keep-alive worked), the server // still has us in the group and we must NOT call JoinGroup again — // doing so would trigger a redundant "X joined the group" broadcast // to every other member every time we briefly leave the app. const wsClosedWhileBackgroundedRef = useRef(false); const appStateRef = useRef(AppState.currentState); useEffect(() => { if (!isWsConnected && appStateRef.current !== "active") { wsClosedWhileBackgroundedRef.current = true; } }, [isWsConnected]); useEffect(() => { if (!api) return; const subscription = AppState.addEventListener("change", (nextAppState) => { const previousAppState = appStateRef.current; appStateRef.current = nextAppState; const becameActive = (previousAppState === "background" || previousAppState === "inactive") && nextAppState === "active"; if (!becameActive) return; const groupId = lastGroupIdRef.current; if (!groupId) return; // Happy path: keep-alive held the socket open across the // suspend. Server still considers us a member — nothing to do. if (!wsClosedWhileBackgroundedRef.current) { console.log( "SyncPlay: app foregrounded with WS still alive, skipping rejoin", ); return; } wsClosedWhileBackgroundedRef.current = false; // Small delay so the WebSocket has a moment to reconnect. setTimeout(() => { console.log( `SyncPlay: app foregrounded after WS drop, rejoining group ${groupId}`, ); getSyncPlayApi(api) .syncPlayJoinGroup({ joinGroupRequestDto: { GroupId: groupId } }) .catch((error) => { console.error("SyncPlay: failed to rejoin group", error); }); }, 1000); }); return () => subscription.remove(); }, [api]); // --------------------------------------------------------------------------- // Player attach bridges // --------------------------------------------------------------------------- const setPlayerControls = useCallback( (controls: PlayerControls | null) => { // Reset the playbackstart latch on each new attach. playbackStartFiredRef.current = false; manager?.setPlayerControls(controls); }, [manager], ); const notifyReady = useCallback(() => { manager?.notifyReady(); }, [manager]); 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, groupInfo, canJoinGroups, canCreateGroups, joinGroup, createGroup, leaveGroup, getGroups, controller: manager?.getController() ?? null, setPlayerControls, notifyReady, notifyBuffering, notifyPlaybackStart, pendingPlaybackCommand, osdAction, }), [ isEnabled, groupInfo, canJoinGroups, canCreateGroups, joinGroup, createGroup, leaveGroup, getGroups, manager, setPlayerControls, notifyReady, notifyBuffering, notifyPlaybackStart, pendingPlaybackCommand, osdAction, ], ); return ( {children} ); } export function useSyncPlay(): SyncPlayContextValue { const context = useContext(SyncPlayContext); if (!context) { throw new Error("useSyncPlay must be used within a SyncPlayProvider"); } return context; }