diff --git a/app/(auth)/player/direct-player.tsx b/app/(auth)/player/direct-player.tsx index e9591e98c..b709b90ae 100644 --- a/app/(auth)/player/direct-player.tsx +++ b/app/(auth)/player/direct-player.tsx @@ -31,6 +31,7 @@ import { } from "@/components/video-player/controls/utils/playback-speed-settings"; import useRouter from "@/hooks/useAppRouter"; import { useHaptic } from "@/hooks/useHaptic"; +import { useKeepWebSocketAlive } from "@/hooks/useKeepWebSocketAlive"; import { useOrientation } from "@/hooks/useOrientation"; import { usePlaybackManager } from "@/hooks/usePlaybackManager"; import usePlaybackSpeed from "@/hooks/usePlaybackSpeed"; @@ -78,6 +79,11 @@ export default function DirectPlayerPage() { const [isPlaybackStopped, setIsPlaybackStopped] = useState(false); const [showControls, _setShowControls] = useState(true); const [isPipMode, setIsPipMode] = useState(false); + + // Keep the global WebSocket open while in PiP so SyncPlay commands + // (and any other server pushes) keep flowing while iOS treats the + // app as backgrounded. See `WebSocketProvider.acquireKeepAlive`. + useKeepWebSocketAlive(isPipMode); const [aspectRatio] = useState<"default" | "16:9" | "4:3" | "1:1" | "21:9">( "default", ); @@ -488,28 +494,6 @@ export default function DirectPlayerPage() { notifyBuffering(!isLocallyReady); }, [isSyncPlayEnabled, isVideoLoaded, isBuffering, notifyBuffering]); - // SyncPlay: Pause playback when group is waiting - useEffect(() => { - if (!isSyncPlayEnabled) { - return; - } - - const groupState = syncPlay.groupInfo?.State; - const isLocalReady = isVideoLoaded && !isBuffering; - const isWaitingForGroup = groupState === "Waiting"; - - // Pause playback when waiting for group - if (isLocalReady && isWaitingForGroup && isPlaying) { - videoRef.current?.pause(); - } - }, [ - isSyncPlayEnabled, - syncPlay.groupInfo?.State, - isVideoLoaded, - isBuffering, - isPlaying, - ]); - const togglePlay = async () => { lightHapticFeedback(); @@ -947,6 +931,41 @@ export default function DirectPlayerPage() { [], ); + // PiP playback controls. When SyncPlay is active, the native side + // is told to *delegate* these via `syncPlayDelegated`, so the OS + // play/pause/skip buttons emit these events instead of poking MPV + // directly. We route them through the SyncPlay controller so the + // server broadcasts a command to every group member (including us). + const _onPipPlayRequest = useCallback(() => { + if (isSyncPlayEnabled && syncPlayController) { + console.log("SyncPlay: PiP play → controller.playPause()"); + syncPlayController.playPause(); + } + }, [isSyncPlayEnabled, syncPlayController]); + + const _onPipPauseRequest = useCallback(() => { + if (isSyncPlayEnabled && syncPlayController) { + console.log("SyncPlay: PiP pause → controller.playPause()"); + syncPlayController.playPause(); + } + }, [isSyncPlayEnabled, syncPlayController]); + + const _onPipSkipRequest = useCallback( + (e: { + nativeEvent: { targetSeconds: number; intervalSeconds: number }; + }) => { + if (!isSyncPlayEnabled || !syncPlayController) return; + const { targetSeconds } = e.nativeEvent; + // SyncPlay seek takes ticks (1 s = 10_000_000 ticks). + const ticks = Math.max(0, Math.round(targetSeconds * 10_000_000)); + console.log( + `SyncPlay: PiP skip → controller.seek(${targetSeconds}s = ${ticks} ticks)`, + ); + syncPlayController.seek(ticks); + }, + [isSyncPlayEnabled, syncPlayController], + ); + const [isMounted, setIsMounted] = useState(false); // Add useEffect to handle mounting @@ -1377,6 +1396,10 @@ export default function DirectPlayerPage() { onProgress={onProgress} onPlaybackStateChange={onPlaybackStateChanged} onPictureInPictureChange={_onPictureInPictureChange} + syncPlayDelegated={isSyncPlayEnabled} + onPipPlayRequest={_onPipPlayRequest} + onPipPauseRequest={_onPipPauseRequest} + onPipSkipRequest={_onPipSkipRequest} onLoad={() => setIsVideoLoaded(true)} onError={(e: { nativeEvent: MpvOnErrorEventPayload }) => { console.error("Video Error:", e.nativeEvent); diff --git a/components/syncplay/SyncPlayActionIcon.tsx b/components/syncplay/SyncPlayActionIcon.tsx new file mode 100644 index 000000000..c2f2166c6 --- /dev/null +++ b/components/syncplay/SyncPlayActionIcon.tsx @@ -0,0 +1,206 @@ +/** + * SyncPlayActionIcon + * + * In-button SyncPlay status indicator — drops into the player's + * play/pause button slot and replaces the normal play/pause/loader + * graphic while SyncPlay is mid-transition. Mirrors jellyfin-web's + * `#syncPlayIcon` element (see `showIcon()` in + * `jellyfin-web/src/controllers/playback/video/index.js`). + * + * Icon vocabulary (matched 1:1 with jellyfin-web's `showIcon` switch): + * + * action primary secondary pulse spin + * --------------- ------------- ----------------- ---------- ---- + * schedule-play sync play (centered) infinite yes + * unpause play-circle — one-shot no + * pause pause-circle — one-shot no + * seek refresh — infinite no + * buffering clock — infinite no + * wait-pause clock pause (shifted) infinite no + * wait-unpause clock play (shifted) infinite no + * + * Material → Ionicons mapping used here: + * sync → sync, schedule → time-outline, update → refresh-outline, + * play_arrow → play, pause → pause, + * play_circle_outline → play-circle-outline, + * pause_circle_outline → pause-circle-outline. + * + * When no SyncPlay action is active the component renders `fallback` + * so callers can keep the normal play/pause/loader graphic. + */ + +import { Ionicons } from "@expo/vector-icons"; +import { type ReactNode, useEffect } from "react"; +import { StyleSheet, View } from "react-native"; +import Animated, { + cancelAnimation, + Easing, + useAnimatedStyle, + useSharedValue, + withRepeat, + withSequence, + withTiming, +} from "react-native-reanimated"; +import type { SyncPlayOsdAction } from "@/providers/SyncPlay"; +import { useSyncPlay } from "@/providers/SyncPlay"; + +// SyncPlay cyan (matches jellyfin-web's `.syncPlayIconCircle` color) +const SYNC_PLAY_COLOR = "#00a4dc"; + +type IoniconName = keyof typeof Ionicons.glyphMap; + +type SecondaryPosition = "centered" | "shifted"; + +interface SecondaryIcon { + icon: IoniconName; + position: SecondaryPosition; +} + +interface OsdConfig { + /** Primary icon — fills the available size. */ + icon: IoniconName; + /** Optional smaller overlay (~42% size). */ + secondary?: SecondaryIcon; + /** Wrapper-level scale animation. */ + pulse: "infinite" | "oneshot"; + /** Rotate the primary icon continuously (secondary stays still). */ + spin?: boolean; +} + +const CONFIG: Record = { + "schedule-play": { + icon: "sync", + secondary: { icon: "play", position: "centered" }, + pulse: "infinite", + spin: true, + }, + unpause: { icon: "play-circle-outline", pulse: "oneshot" }, + pause: { icon: "pause-circle-outline", pulse: "oneshot" }, + seek: { icon: "refresh-outline", pulse: "infinite" }, + buffering: { icon: "time-outline", pulse: "infinite" }, + "wait-pause": { + icon: "time-outline", + secondary: { icon: "pause", position: "shifted" }, + pulse: "infinite", + }, + "wait-unpause": { + icon: "time-outline", + secondary: { icon: "play", position: "shifted" }, + pulse: "infinite", + }, +}; + +interface SyncPlayActionIconProps { + size: number; + color?: string; + /** Rendered when no SyncPlay action is active. */ + fallback?: ReactNode; +} + +export function SyncPlayActionIcon({ + size, + color = SYNC_PLAY_COLOR, + fallback = null, +}: SyncPlayActionIconProps) { + const { osdAction } = useSyncPlay(); + + const rotation = useSharedValue(0); + const scale = useSharedValue(1); + + useEffect(() => { + cancelAnimation(rotation); + cancelAnimation(scale); + rotation.value = 0; + scale.value = 1; + + if (!osdAction) return; + + const config = CONFIG[osdAction]; + + if (config.spin) { + rotation.value = withRepeat( + withTiming(360, { duration: 1200, easing: Easing.linear }), + -1, + false, + ); + } + + if (config.pulse === "infinite") { + scale.value = withRepeat( + withSequence( + withTiming(1.1, { + duration: 700, + easing: Easing.inOut(Easing.quad), + }), + withTiming(0.95, { + duration: 700, + easing: Easing.inOut(Easing.quad), + }), + ), + -1, + true, + ); + } else { + // one-shot: single scale flash; the provider clears the action + // ~1500ms later (transient OSD) so the icon then unmounts. + scale.value = withSequence( + withTiming(1.2, { duration: 220, easing: Easing.out(Easing.quad) }), + withTiming(1, { duration: 220, easing: Easing.inOut(Easing.quad) }), + ); + } + }, [osdAction, rotation, scale]); + + const pulseStyle = useAnimatedStyle(() => ({ + transform: [{ scale: scale.value }], + })); + + const spinStyle = useAnimatedStyle(() => ({ + transform: [{ rotate: `${rotation.value}deg` }], + })); + + if (!osdAction) return <>{fallback}; + + const config = CONFIG[osdAction]; + const secondarySize = Math.round(size * 0.42); + + // centered: geometric middle of the primary (e.g. play arrow inside + // the spinning `sync` ring for schedule-play). + // shifted: bottom-right corner (e.g. play/pause badge on the clock + // for wait-unpause / wait-pause). + const secondaryPosStyle = + config.secondary?.position === "centered" + ? { + top: (size - secondarySize) / 2, + left: (size - secondarySize) / 2, + } + : { bottom: 0, right: 0 }; + + return ( + + + + + + + {config.secondary && ( + + + + )} + + + ); +} + +const styles = StyleSheet.create({ + secondary: { + position: "absolute", + }, +}); diff --git a/components/syncplay/index.ts b/components/syncplay/index.ts index 5ea42ee15..fdadf3da9 100644 --- a/components/syncplay/index.ts +++ b/components/syncplay/index.ts @@ -3,5 +3,6 @@ */ export { GroupSelectionMenu } from "./GroupSelectionMenu"; +export { SyncPlayActionIcon } from "./SyncPlayActionIcon"; export { SyncPlayButton } from "./SyncPlayButton"; export { SyncPlaySpinner } from "./SyncPlaySpinner"; diff --git a/components/video-player/controls/CenterControls.tsx b/components/video-player/controls/CenterControls.tsx index 3d33ec9e7..38b70be93 100644 --- a/components/video-player/controls/CenterControls.tsx +++ b/components/video-player/controls/CenterControls.tsx @@ -3,17 +3,13 @@ import type { FC } from "react"; import { Platform, TouchableOpacity, View } from "react-native"; import { Text } from "@/components/common/Text"; import { Loader } from "@/components/Loader"; -import { SyncPlaySpinner } from "@/components/syncplay/SyncPlaySpinner"; +import { SyncPlayActionIcon } from "@/components/syncplay/SyncPlayActionIcon"; import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets"; -import { useSyncPlay } from "@/providers/SyncPlay/SyncPlayProvider"; import { useSettings } from "@/utils/atoms/settings"; import AudioSlider from "./AudioSlider"; import BrightnessSlider from "./BrightnessSlider"; import { ICON_SIZES } from "./constants"; -// SyncPlay cyan color (matches Jellyfin-web) -const SYNC_PLAY_COLOR = "#00a4dc"; - interface CenterControlsProps { showControls: boolean; isPlaying: boolean; @@ -49,18 +45,6 @@ export const CenterControls: FC = ({ const { settings } = useSettings(); const insets = useControlsSafeAreaInsets(); - // SyncPlay state from global provider - const { - isEnabled: isSyncPlayEnabled, - groupInfo, - pendingPlaybackCommand, - } = useSyncPlay(); - const isSyncPlayWaiting = isSyncPlayEnabled && groupInfo?.State === "Waiting"; - // Show the rotating SyncPlay icon ("schedule-play" in jellyfin-web) while a - // play/pause request is in flight to the server. - const isSyncPlayScheduling = - isSyncPlayEnabled && pendingPlaybackCommand !== null; - return ( = ({ - {isSyncPlayScheduling ? ( - // SyncPlay command in flight - rotating spinner ("schedule-play") - - ) : isSyncPlayWaiting ? ( - // SyncPlay waiting indicator - clock icon, still pressable to toggle - - ) : !isBuffering ? ( - - ) : ( - - )} + + ) : ( + + ) + } + /> diff --git a/hooks/useKeepWebSocketAlive.ts b/hooks/useKeepWebSocketAlive.ts new file mode 100644 index 000000000..435472c36 --- /dev/null +++ b/hooks/useKeepWebSocketAlive.ts @@ -0,0 +1,22 @@ +import { useEffect } from "react"; +import { useWebSocketContext } from "@/providers/WebSocketProvider"; + +/** + * While `active` is true, hold a keep-alive token on the global + * WebSocket so it is NOT closed when the app moves to + * background/inactive. Releases automatically when `active` flips + * false or the component unmounts. + * + * Used by the video player while in Picture-in-Picture so SyncPlay + * commands (and any other server pushes) keep flowing while the OS + * thinks the app is backgrounded. + */ +export function useKeepWebSocketAlive(active: boolean): void { + const { acquireKeepAlive } = useWebSocketContext(); + + useEffect(() => { + if (!active) return; + const release = acquireKeepAlive(); + return release; + }, [active, acquireKeepAlive]); +} diff --git a/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MpvPlayerModule.kt b/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MpvPlayerModule.kt index 2d1cfddd5..4d8d13cb6 100644 --- a/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MpvPlayerModule.kt +++ b/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MpvPlayerModule.kt @@ -50,6 +50,13 @@ class MpvPlayerModule : Module() { // No-op on Android - media session integration would require MediaSessionCompat } + // When true, PiP play/pause/skip controls emit JS events + // instead of driving MPV directly, so the host app can route + // through SyncPlay (server -> group broadcast -> all clients). + Prop("syncPlayDelegated") { view: MpvPlayerView, delegated: Boolean -> + view.syncPlayDelegated = delegated + } + // Async function to play video AsyncFunction("play") { view: MpvPlayerView -> view.play() @@ -198,7 +205,7 @@ class MpvPlayerModule : Module() { } // Defines events that the view can send to JavaScript - Events("onLoad", "onPlaybackStateChange", "onProgress", "onError", "onTracksReady", "onPictureInPictureChange") + Events("onLoad", "onPlaybackStateChange", "onProgress", "onError", "onTracksReady", "onPictureInPictureChange", "onPipPlayRequest", "onPipPauseRequest", "onPipSkipRequest") } } } diff --git a/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MpvPlayerView.kt b/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MpvPlayerView.kt index 4df7fe0b3..a198dcbc7 100644 --- a/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MpvPlayerView.kt +++ b/modules/mpv-player/android/src/main/java/expo/modules/mpvplayer/MpvPlayerView.kt @@ -47,6 +47,16 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context val onError by EventDispatcher() val onTracksReady by EventDispatcher() val onPictureInPictureChange by EventDispatcher() + // SyncPlay: when `syncPlayDelegated == true`, PiP playback controls + // (play / pause / skip) emit these events instead of driving MPV + // directly, so JS can route the action through the SyncPlay + // controller (server -> group broadcast -> all clients). Default + // behavior (non-SyncPlay) is unchanged. + val onPipPlayRequest by EventDispatcher() + val onPipPauseRequest by EventDispatcher() + val onPipSkipRequest by EventDispatcher() + + var syncPlayDelegated: Boolean = false private var textureView: TextureView private var renderer: MPVLayerRenderer? = null @@ -85,14 +95,32 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context pipController?.setPlayerView(textureView) pipController?.delegate = object : PiPController.Delegate { override fun onPlay() { + if (syncPlayDelegated) { + onPipPlayRequest(mapOf()) + return + } play() } override fun onPause() { + if (syncPlayDelegated) { + onPipPauseRequest(mapOf()) + return + } pause() } override fun onSeekBy(seconds: Double) { + if (syncPlayDelegated) { + val target = (cachedPosition + seconds).coerceAtLeast(0.0) + onPipSkipRequest( + mapOf( + "targetSeconds" to target, + "intervalSeconds" to seconds + ) + ) + return + } seekBy(seconds) } diff --git a/modules/mpv-player/ios/MpvPlayerModule.swift b/modules/mpv-player/ios/MpvPlayerModule.swift index 768916869..a818f9a28 100644 --- a/modules/mpv-player/ios/MpvPlayerModule.swift +++ b/modules/mpv-player/ios/MpvPlayerModule.swift @@ -65,6 +65,13 @@ public class MpvPlayerModule: Module { } } + // When true, PiP play/pause/skip controls emit JS events instead + // of driving MPV directly, so the host app can route through + // SyncPlay (server -> group broadcast -> all clients). + Prop("syncPlayDelegated") { (view: MpvPlayerView, delegated: Bool) in + view.syncPlayDelegated = delegated + } + // Async function to play video AsyncFunction("play") { (view: MpvPlayerView) in view.play() @@ -213,7 +220,7 @@ public class MpvPlayerModule: Module { } // Defines events that the view can send to JavaScript - Events("onLoad", "onPlaybackStateChange", "onProgress", "onError", "onTracksReady", "onPictureInPictureChange") + Events("onLoad", "onPlaybackStateChange", "onProgress", "onError", "onTracksReady", "onPictureInPictureChange", "onPipPlayRequest", "onPipPauseRequest", "onPipSkipRequest") } } } diff --git a/modules/mpv-player/ios/MpvPlayerView.swift b/modules/mpv-player/ios/MpvPlayerView.swift index dce3abb9c..d3c294b02 100644 --- a/modules/mpv-player/ios/MpvPlayerView.swift +++ b/modules/mpv-player/ios/MpvPlayerView.swift @@ -62,6 +62,16 @@ class MpvPlayerView: ExpoView { let onError = EventDispatcher() let onTracksReady = EventDispatcher() let onPictureInPictureChange = EventDispatcher() + // SyncPlay: when `syncPlayDelegated == true`, PiP playback controls + // (play / pause / skip) emit these events instead of driving MPV + // directly, so JS can route the action through the SyncPlay + // controller (server → group broadcast → all clients). Default + // behavior (non-SyncPlay) is unchanged. + let onPipPlayRequest = EventDispatcher() + let onPipPauseRequest = EventDispatcher() + let onPipSkipRequest = EventDispatcher() + + var syncPlayDelegated: Bool = false private var currentURL: URL? private var cachedPosition: Double = 0 @@ -77,7 +87,6 @@ class MpvPlayerView: ExpoView { super.init(appContext: appContext) setupNotifications() setupView() - // Note: Decoder reset is handled automatically via KVO in MPVLayerRenderer } private func setupView() { @@ -672,6 +681,12 @@ extension MpvPlayerView: PiPControllerDelegate { func pipControllerPlay(_ controller: PiPController) { print("PiP play requested") + if syncPlayDelegated { + // Let JS route through SyncPlay. We deliberately do NOT touch + // MPV here; the WS command coming back will drive playback. + onPipPlayRequest([:]) + return + } intendedPlayState = true renderer?.play() pipController?.setPlaybackRate(1.0) @@ -679,6 +694,10 @@ extension MpvPlayerView: PiPControllerDelegate { func pipControllerPause(_ controller: PiPController) { print("PiP pause requested") + if syncPlayDelegated { + onPipPauseRequest([:]) + return + } intendedPlayState = false renderer?.pausePlayback() pipController?.setPlaybackRate(0.0) @@ -688,6 +707,16 @@ extension MpvPlayerView: PiPControllerDelegate { let seconds = CMTimeGetSeconds(interval) print("PiP skip by interval: \(seconds)") let target = max(0, cachedPosition + seconds) + if syncPlayDelegated { + // `targetSeconds` lets JS convert to ticks and call + // syncPlayController.seek(). `intervalSeconds` is also sent + // for telemetry / debug. + onPipSkipRequest([ + "targetSeconds": target, + "intervalSeconds": seconds + ]) + return + } seekTo(position: target) } diff --git a/modules/mpv-player/src/MpvPlayer.types.ts b/modules/mpv-player/src/MpvPlayer.types.ts index b6bd04711..00bfbbf60 100644 --- a/modules/mpv-player/src/MpvPlayer.types.ts +++ b/modules/mpv-player/src/MpvPlayer.types.ts @@ -29,6 +29,21 @@ export type OnPictureInPictureChangePayload = { isActive: boolean; }; +/** + * Emitted when the user taps a PiP playback control while the view + * was rendered with `syncPlayDelegated`. The host app should route + * the action through the SyncPlay controller instead of acting + * locally. + */ +export type OnPipPlayRequestPayload = Record; +export type OnPipPauseRequestPayload = Record; +export type OnPipSkipRequestPayload = { + /** Absolute target position the user wants to seek to, in seconds. */ + targetSeconds: number; + /** Skip interval requested by the OS (signed seconds). Debug only. */ + intervalSeconds: number; +}; + export type NowPlayingMetadata = { title?: string; artist?: string; @@ -84,6 +99,18 @@ export type MpvPlayerViewProps = { onPictureInPictureChange?: (event: { nativeEvent: OnPictureInPictureChangePayload; }) => void; + /** + * When true, PiP play/pause/skip controls emit the corresponding + * `onPipPlayRequest` / `onPipPauseRequest` / `onPipSkipRequest` + * events instead of driving MPV directly. Used to route PiP control + * actions through SyncPlay. + */ + syncPlayDelegated?: boolean; + onPipPlayRequest?: (event: { nativeEvent: OnPipPlayRequestPayload }) => void; + onPipPauseRequest?: (event: { + nativeEvent: OnPipPauseRequestPayload; + }) => void; + onPipSkipRequest?: (event: { nativeEvent: OnPipSkipRequestPayload }) => void; }; export interface MpvPlayerViewRef { diff --git a/providers/SyncPlay/Manager.ts b/providers/SyncPlay/Manager.ts index 1a1f8aaf4..33ebb183e 100644 --- a/providers/SyncPlay/Manager.ts +++ b/providers/SyncPlay/Manager.ts @@ -198,14 +198,16 @@ export class SyncPlayManager extends EventEmitter { const stateData = update.Data as { State?: string; PreviousState?: string; + Reason?: string; }; const newState = stateData.State ?? "Idle"; const previousState = stateData.PreviousState ?? "Idle"; + const reason = stateData.Reason; if (this.groupInfo) { this.groupInfo.State = newState as GroupInfoDto["State"]; this.emit("group-update", this.groupInfo); } - this.emit("group-state-change", newState, previousState); + this.emit("group-state-change", newState, previousState, reason); // Server signals "Playing" or "Paused" → clear any in-flight // optimistic tap state. if (newState === "Playing" || newState === "Paused") { diff --git a/providers/SyncPlay/SyncPlayProvider.tsx b/providers/SyncPlay/SyncPlayProvider.tsx index 89413be29..4d5ea8e1d 100644 --- a/providers/SyncPlay/SyncPlayProvider.tsx +++ b/providers/SyncPlay/SyncPlayProvider.tsx @@ -31,13 +31,14 @@ import { 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 { ticksToMs } from "./constants"; import { SyncPlayManager } from "./Manager"; import { useSyncPlayWebSocket } from "./transport/useSyncPlayWebSocket"; -import type { GroupInfoDto, PlayerControls } from "./types"; +import type { GroupInfoDto, PlayerControls, SyncPlayOsdAction } from "./types"; interface SyncPlayContextValue { isEnabled: boolean; @@ -58,6 +59,11 @@ interface SyncPlayContextValue { 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); @@ -70,6 +76,7 @@ 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); @@ -80,6 +87,64 @@ export function SyncPlayProvider({ children }: SyncPlayProviderProps) { "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; @@ -177,24 +242,55 @@ export function SyncPlayProvider({ children }: SyncPlayProviderProps) { setPendingPlaybackCommand(args[0] as "Unpause" | "Pause" | null); }); - // group-state-change → on "Waiting", park the player at the last - // broadcast position so it's ready to resume cleanly. + // 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") { - 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`, - ); - } } + + // 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[]) => { @@ -287,7 +383,19 @@ export function SyncPlayProvider({ children }: SyncPlayProviderProps) { 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; @@ -304,9 +412,21 @@ export function SyncPlayProvider({ children }: SyncPlayProviderProps) { 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, rejoining group ${groupId}`); + console.log( + `SyncPlay: app foregrounded after WS drop, rejoining group ${groupId}`, + ); getSyncPlayApi(api) .syncPlayJoinGroup({ joinGroupRequestDto: { GroupId: groupId } }) .catch((error) => { @@ -370,6 +490,7 @@ export function SyncPlayProvider({ children }: SyncPlayProviderProps) { notifyBuffering, notifyPlaybackStart, pendingPlaybackCommand, + osdAction, }), [ isEnabled, @@ -386,6 +507,7 @@ export function SyncPlayProvider({ children }: SyncPlayProviderProps) { notifyBuffering, notifyPlaybackStart, pendingPlaybackCommand, + osdAction, ], ); diff --git a/providers/SyncPlay/cores/PlaybackCore.ts b/providers/SyncPlay/cores/PlaybackCore.ts index 3a2d2c83d..58e3e5401 100644 --- a/providers/SyncPlay/cores/PlaybackCore.ts +++ b/providers/SyncPlay/cores/PlaybackCore.ts @@ -136,11 +136,18 @@ export class PlaybackCore extends EventEmitter { this.lastCommand && (( this.lastCommand as unknown as { EmittedAt: Date } - ).EmittedAt.getTime() >= + ).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()) ) { + // NOTE: strict `>` (not `>=`) on EmittedAt — Jellyfin's server timestamps + // commands at sub-ms precision but JS `Date` truncates to ms, so two + // commands emitted within the same millisecond would otherwise be + // rejected as "outdated" and silently dropped. This produced an + // unbreakable pause/unpause loop where every fresh command was + // discarded. Matches jellyfin-web's check in + // `web/src/plugins/syncPlay/core/Manager.js`. console.debug( "SyncPlay applyCommand: dropping outdated command", command, diff --git a/providers/SyncPlay/types.ts b/providers/SyncPlay/types.ts index d46952bfb..7cc8ffd8d 100644 --- a/providers/SyncPlay/types.ts +++ b/providers/SyncPlay/types.ts @@ -47,11 +47,19 @@ export interface PlayerControls { /** OSD action types — drive optional player-overlay feedback. */ export type SyncPlayOsdAction = + /** transient — 1.5s pulse, the unpause command fired locally */ | "unpause" + /** transient — 1.5s pulse, the pause command fired locally */ | "pause" + /** transient — 1.5s pulse, a seek command applied locally */ | "seek" + /** persistent — group is about to play (Waiting+Unpause / pending Unpause) */ + | "schedule-play" + /** persistent — another client is buffering (Waiting+Buffer) */ | "buffering" + /** persistent — group transitioning to pause (Waiting+Pause) */ | "wait-pause" + /** persistent — group transitioning to unpause; sibling of schedule-play */ | "wait-unpause"; /** diff --git a/providers/WebSocketProvider.tsx b/providers/WebSocketProvider.tsx index ed9db7549..0fa0b677f 100644 --- a/providers/WebSocketProvider.tsx +++ b/providers/WebSocketProvider.tsx @@ -44,6 +44,15 @@ interface WebSocketContextType { lastMessage: WebSocketMessage | null; sendMessage: (message: any) => void; clearLastMessage: () => void; + /** + * Acquire a keep-alive token. While at least one token is held the + * WebSocket will NOT be closed on AppState background/inactive. Used + * by the video player while in Picture-in-Picture so SyncPlay (and + * any other server-pushed events) keep flowing. Returns a release + * function — call it (or rely on the React effect cleanup) when the + * keep-alive is no longer needed. + */ + acquireKeepAlive: () => () => void; } const WebSocketContext = createContext(null); @@ -63,6 +72,21 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => { const libraryChangeDebounceRef = useRef | null>( null, ); + // Ref-counted keep-alive: while > 0 we skip the AppState→background + // close so the socket survives PiP / brief OS suspensions. iOS keeps + // the audio session (and therefore networking) alive while PiP is + // active, so the WS can continue to receive SyncPlay commands. + const keepAliveCountRef = useRef(0); + + const acquireKeepAlive = useCallback((): (() => void) => { + keepAliveCountRef.current += 1; + let released = false; + return () => { + if (released) return; + released = true; + keepAliveCountRef.current = Math.max(0, keepAliveCountRef.current - 1); + }; + }, []); const connectWebSocket = useCallback(() => { if (!deviceId || !api?.accessToken || !isNetworkConnected) { @@ -235,9 +259,20 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => { useEffect(() => { const handleAppStateChange = (state: AppStateStatus) => { if (state === "background" || state === "inactive") { + if (keepAliveCountRef.current > 0) { + console.log( + `App backgrounded but WS keep-alive held (${keepAliveCountRef.current}); leaving socket open`, + ); + return; + } console.log("App moving to background, closing WebSocket..."); ws?.close(); } else if (state === "active") { + // Only reconnect if we actually lost the socket (we may have + // skipped the close above because of a keep-alive token). + if (ws?.readyState === WebSocket.OPEN) { + return; + } console.log("App coming to foreground, reconnecting WebSocket..."); connectWebSocket(); } @@ -267,7 +302,14 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => { }, []); return ( {children}