Add PIP support for syncplay

This commit is contained in:
Alex Kim
2026-06-05 21:42:06 +10:00
parent 0e93cd5385
commit ab42e8a576
15 changed files with 588 additions and 78 deletions

View File

@@ -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);

View File

@@ -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<SyncPlayOsdAction, OsdConfig> = {
"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 (
<Animated.View style={pulseStyle}>
<View style={{ width: size, height: size }}>
<Animated.View style={[StyleSheet.absoluteFill, spinStyle]}>
<Ionicons name={config.icon} size={size} color={color} />
</Animated.View>
{config.secondary && (
<View
pointerEvents='none'
style={[styles.secondary, secondaryPosStyle]}
>
<Ionicons
name={config.secondary.icon}
size={secondarySize}
color={color}
/>
</View>
)}
</View>
</Animated.View>
);
}
const styles = StyleSheet.create({
secondary: {
position: "absolute",
},
});

View File

@@ -3,5 +3,6 @@
*/
export { GroupSelectionMenu } from "./GroupSelectionMenu";
export { SyncPlayActionIcon } from "./SyncPlayActionIcon";
export { SyncPlayButton } from "./SyncPlayButton";
export { SyncPlaySpinner } from "./SyncPlaySpinner";

View File

@@ -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<CenterControlsProps> = ({
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 (
<View
style={{
@@ -138,25 +122,20 @@ export const CenterControls: FC<CenterControlsProps> = ({
<View style={Platform.isTV ? { flex: 1, alignItems: "center" } : {}}>
<TouchableOpacity onPress={togglePlay}>
{isSyncPlayScheduling ? (
// SyncPlay command in flight - rotating spinner ("schedule-play")
<SyncPlaySpinner size={ICON_SIZES.CENTER} />
) : isSyncPlayWaiting ? (
// SyncPlay waiting indicator - clock icon, still pressable to toggle
<Ionicons
name='time'
size={ICON_SIZES.CENTER}
color={SYNC_PLAY_COLOR}
/>
) : !isBuffering ? (
<Ionicons
name={isPlaying ? "pause" : "play"}
size={ICON_SIZES.CENTER}
color='white'
/>
) : (
<Loader size={"large"} />
)}
<SyncPlayActionIcon
size={ICON_SIZES.CENTER}
fallback={
!isBuffering ? (
<Ionicons
name={isPlaying ? "pause" : "play"}
size={ICON_SIZES.CENTER}
color='white'
/>
) : (
<Loader size={"large"} />
)
}
/>
</TouchableOpacity>
</View>

View File

@@ -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]);
}

View File

@@ -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")
}
}
}

View File

@@ -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<String, Any>())
return
}
play()
}
override fun onPause() {
if (syncPlayDelegated) {
onPipPauseRequest(mapOf<String, Any>())
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)
}

View File

@@ -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")
}
}
}

View File

@@ -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)
}

View File

@@ -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<string, never>;
export type OnPipPauseRequestPayload = Record<string, never>;
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 {

View File

@@ -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") {

View File

@@ -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<SyncPlayContextValue | null>(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<SyncPlayManager | null>(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
// "<user> 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<SyncPlayOsdAction | null>(null);
const osdTimeoutRef = useRef<ReturnType<typeof setTimeout> | 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<AppStateStatus>(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,
],
);

View File

@@ -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,

View File

@@ -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";
/**

View File

@@ -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<WebSocketContextType | null>(null);
@@ -63,6 +72,21 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => {
const libraryChangeDebounceRef = useRef<ReturnType<typeof setTimeout> | 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 (
<WebSocketContext.Provider
value={{ ws, isConnected, lastMessage, sendMessage, clearLastMessage }}
value={{
ws,
isConnected,
lastMessage,
sendMessage,
clearLastMessage,
acquireKeepAlive,
}}
>
{children}
</WebSocketContext.Provider>