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"; } from "@/components/video-player/controls/utils/playback-speed-settings";
import useRouter from "@/hooks/useAppRouter"; import useRouter from "@/hooks/useAppRouter";
import { useHaptic } from "@/hooks/useHaptic"; import { useHaptic } from "@/hooks/useHaptic";
import { useKeepWebSocketAlive } from "@/hooks/useKeepWebSocketAlive";
import { useOrientation } from "@/hooks/useOrientation"; import { useOrientation } from "@/hooks/useOrientation";
import { usePlaybackManager } from "@/hooks/usePlaybackManager"; import { usePlaybackManager } from "@/hooks/usePlaybackManager";
import usePlaybackSpeed from "@/hooks/usePlaybackSpeed"; import usePlaybackSpeed from "@/hooks/usePlaybackSpeed";
@@ -78,6 +79,11 @@ export default function DirectPlayerPage() {
const [isPlaybackStopped, setIsPlaybackStopped] = useState(false); const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
const [showControls, _setShowControls] = useState(true); const [showControls, _setShowControls] = useState(true);
const [isPipMode, setIsPipMode] = useState(false); 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">( const [aspectRatio] = useState<"default" | "16:9" | "4:3" | "1:1" | "21:9">(
"default", "default",
); );
@@ -488,28 +494,6 @@ export default function DirectPlayerPage() {
notifyBuffering(!isLocallyReady); notifyBuffering(!isLocallyReady);
}, [isSyncPlayEnabled, isVideoLoaded, isBuffering, notifyBuffering]); }, [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 () => { const togglePlay = async () => {
lightHapticFeedback(); 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); const [isMounted, setIsMounted] = useState(false);
// Add useEffect to handle mounting // Add useEffect to handle mounting
@@ -1377,6 +1396,10 @@ export default function DirectPlayerPage() {
onProgress={onProgress} onProgress={onProgress}
onPlaybackStateChange={onPlaybackStateChanged} onPlaybackStateChange={onPlaybackStateChanged}
onPictureInPictureChange={_onPictureInPictureChange} onPictureInPictureChange={_onPictureInPictureChange}
syncPlayDelegated={isSyncPlayEnabled}
onPipPlayRequest={_onPipPlayRequest}
onPipPauseRequest={_onPipPauseRequest}
onPipSkipRequest={_onPipSkipRequest}
onLoad={() => setIsVideoLoaded(true)} onLoad={() => setIsVideoLoaded(true)}
onError={(e: { nativeEvent: MpvOnErrorEventPayload }) => { onError={(e: { nativeEvent: MpvOnErrorEventPayload }) => {
console.error("Video Error:", e.nativeEvent); 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 { GroupSelectionMenu } from "./GroupSelectionMenu";
export { SyncPlayActionIcon } from "./SyncPlayActionIcon";
export { SyncPlayButton } from "./SyncPlayButton"; export { SyncPlayButton } from "./SyncPlayButton";
export { SyncPlaySpinner } from "./SyncPlaySpinner"; export { SyncPlaySpinner } from "./SyncPlaySpinner";

View File

@@ -3,17 +3,13 @@ import type { FC } from "react";
import { Platform, TouchableOpacity, View } from "react-native"; import { Platform, TouchableOpacity, View } from "react-native";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { Loader } from "@/components/Loader"; import { Loader } from "@/components/Loader";
import { SyncPlaySpinner } from "@/components/syncplay/SyncPlaySpinner"; import { SyncPlayActionIcon } from "@/components/syncplay/SyncPlayActionIcon";
import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets"; import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
import { useSyncPlay } from "@/providers/SyncPlay/SyncPlayProvider";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import AudioSlider from "./AudioSlider"; import AudioSlider from "./AudioSlider";
import BrightnessSlider from "./BrightnessSlider"; import BrightnessSlider from "./BrightnessSlider";
import { ICON_SIZES } from "./constants"; import { ICON_SIZES } from "./constants";
// SyncPlay cyan color (matches Jellyfin-web)
const SYNC_PLAY_COLOR = "#00a4dc";
interface CenterControlsProps { interface CenterControlsProps {
showControls: boolean; showControls: boolean;
isPlaying: boolean; isPlaying: boolean;
@@ -49,18 +45,6 @@ export const CenterControls: FC<CenterControlsProps> = ({
const { settings } = useSettings(); const { settings } = useSettings();
const insets = useControlsSafeAreaInsets(); 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 ( return (
<View <View
style={{ style={{
@@ -138,25 +122,20 @@ export const CenterControls: FC<CenterControlsProps> = ({
<View style={Platform.isTV ? { flex: 1, alignItems: "center" } : {}}> <View style={Platform.isTV ? { flex: 1, alignItems: "center" } : {}}>
<TouchableOpacity onPress={togglePlay}> <TouchableOpacity onPress={togglePlay}>
{isSyncPlayScheduling ? ( <SyncPlayActionIcon
// SyncPlay command in flight - rotating spinner ("schedule-play") size={ICON_SIZES.CENTER}
<SyncPlaySpinner size={ICON_SIZES.CENTER} /> fallback={
) : isSyncPlayWaiting ? ( !isBuffering ? (
// SyncPlay waiting indicator - clock icon, still pressable to toggle <Ionicons
<Ionicons name={isPlaying ? "pause" : "play"}
name='time' size={ICON_SIZES.CENTER}
size={ICON_SIZES.CENTER} color='white'
color={SYNC_PLAY_COLOR} />
/> ) : (
) : !isBuffering ? ( <Loader size={"large"} />
<Ionicons )
name={isPlaying ? "pause" : "play"} }
size={ICON_SIZES.CENTER} />
color='white'
/>
) : (
<Loader size={"large"} />
)}
</TouchableOpacity> </TouchableOpacity>
</View> </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 // 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 // Async function to play video
AsyncFunction("play") { view: MpvPlayerView -> AsyncFunction("play") { view: MpvPlayerView ->
view.play() view.play()
@@ -198,7 +205,7 @@ class MpvPlayerModule : Module() {
} }
// Defines events that the view can send to JavaScript // 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 onError by EventDispatcher()
val onTracksReady by EventDispatcher() val onTracksReady by EventDispatcher()
val onPictureInPictureChange 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 textureView: TextureView
private var renderer: MPVLayerRenderer? = null private var renderer: MPVLayerRenderer? = null
@@ -85,14 +95,32 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
pipController?.setPlayerView(textureView) pipController?.setPlayerView(textureView)
pipController?.delegate = object : PiPController.Delegate { pipController?.delegate = object : PiPController.Delegate {
override fun onPlay() { override fun onPlay() {
if (syncPlayDelegated) {
onPipPlayRequest(mapOf<String, Any>())
return
}
play() play()
} }
override fun onPause() { override fun onPause() {
if (syncPlayDelegated) {
onPipPauseRequest(mapOf<String, Any>())
return
}
pause() pause()
} }
override fun onSeekBy(seconds: Double) { 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) 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 // Async function to play video
AsyncFunction("play") { (view: MpvPlayerView) in AsyncFunction("play") { (view: MpvPlayerView) in
view.play() view.play()
@@ -213,7 +220,7 @@ public class MpvPlayerModule: Module {
} }
// Defines events that the view can send to JavaScript // 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 onError = EventDispatcher()
let onTracksReady = EventDispatcher() let onTracksReady = EventDispatcher()
let onPictureInPictureChange = 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 currentURL: URL?
private var cachedPosition: Double = 0 private var cachedPosition: Double = 0
@@ -77,7 +87,6 @@ class MpvPlayerView: ExpoView {
super.init(appContext: appContext) super.init(appContext: appContext)
setupNotifications() setupNotifications()
setupView() setupView()
// Note: Decoder reset is handled automatically via KVO in MPVLayerRenderer
} }
private func setupView() { private func setupView() {
@@ -672,6 +681,12 @@ extension MpvPlayerView: PiPControllerDelegate {
func pipControllerPlay(_ controller: PiPController) { func pipControllerPlay(_ controller: PiPController) {
print("PiP play requested") 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 intendedPlayState = true
renderer?.play() renderer?.play()
pipController?.setPlaybackRate(1.0) pipController?.setPlaybackRate(1.0)
@@ -679,6 +694,10 @@ extension MpvPlayerView: PiPControllerDelegate {
func pipControllerPause(_ controller: PiPController) { func pipControllerPause(_ controller: PiPController) {
print("PiP pause requested") print("PiP pause requested")
if syncPlayDelegated {
onPipPauseRequest([:])
return
}
intendedPlayState = false intendedPlayState = false
renderer?.pausePlayback() renderer?.pausePlayback()
pipController?.setPlaybackRate(0.0) pipController?.setPlaybackRate(0.0)
@@ -688,6 +707,16 @@ extension MpvPlayerView: PiPControllerDelegate {
let seconds = CMTimeGetSeconds(interval) let seconds = CMTimeGetSeconds(interval)
print("PiP skip by interval: \(seconds)") print("PiP skip by interval: \(seconds)")
let target = max(0, cachedPosition + 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) seekTo(position: target)
} }

View File

@@ -29,6 +29,21 @@ export type OnPictureInPictureChangePayload = {
isActive: boolean; 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 = { export type NowPlayingMetadata = {
title?: string; title?: string;
artist?: string; artist?: string;
@@ -84,6 +99,18 @@ export type MpvPlayerViewProps = {
onPictureInPictureChange?: (event: { onPictureInPictureChange?: (event: {
nativeEvent: OnPictureInPictureChangePayload; nativeEvent: OnPictureInPictureChangePayload;
}) => void; }) => 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 { export interface MpvPlayerViewRef {

View File

@@ -198,14 +198,16 @@ export class SyncPlayManager extends EventEmitter {
const stateData = update.Data as { const stateData = update.Data as {
State?: string; State?: string;
PreviousState?: string; PreviousState?: string;
Reason?: string;
}; };
const newState = stateData.State ?? "Idle"; const newState = stateData.State ?? "Idle";
const previousState = stateData.PreviousState ?? "Idle"; const previousState = stateData.PreviousState ?? "Idle";
const reason = stateData.Reason;
if (this.groupInfo) { if (this.groupInfo) {
this.groupInfo.State = newState as GroupInfoDto["State"]; this.groupInfo.State = newState as GroupInfoDto["State"];
this.emit("group-update", this.groupInfo); 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 // Server signals "Playing" or "Paused" → clear any in-flight
// optimistic tap state. // optimistic tap state.
if (newState === "Playing" || newState === "Paused") { if (newState === "Playing" || newState === "Paused") {

View File

@@ -31,13 +31,14 @@ import {
import { AppState, type AppStateStatus } from "react-native"; import { AppState, type AppStateStatus } from "react-native";
import { toast } from "sonner-native"; import { toast } from "sonner-native";
import { useAppRouter } from "@/hooks/useAppRouter"; import { useAppRouter } from "@/hooks/useAppRouter";
import { useKeepWebSocketAlive } from "@/hooks/useKeepWebSocketAlive";
import i18n from "@/i18n"; import i18n from "@/i18n";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useWebSocketContext } from "@/providers/WebSocketProvider";
import type { Controller as SyncPlayController } from "./Controller"; import type { Controller as SyncPlayController } from "./Controller";
import { ticksToMs } from "./constants";
import { SyncPlayManager } from "./Manager"; import { SyncPlayManager } from "./Manager";
import { useSyncPlayWebSocket } from "./transport/useSyncPlayWebSocket"; import { useSyncPlayWebSocket } from "./transport/useSyncPlayWebSocket";
import type { GroupInfoDto, PlayerControls } from "./types"; import type { GroupInfoDto, PlayerControls, SyncPlayOsdAction } from "./types";
interface SyncPlayContextValue { interface SyncPlayContextValue {
isEnabled: boolean; isEnabled: boolean;
@@ -58,6 +59,11 @@ interface SyncPlayContextValue {
notifyPlaybackStart: () => void; notifyPlaybackStart: () => void;
pendingPlaybackCommand: "Unpause" | "Pause" | null; 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); const SyncPlayContext = createContext<SyncPlayContextValue | null>(null);
@@ -70,6 +76,7 @@ export function SyncPlayProvider({ children }: SyncPlayProviderProps) {
const api = useAtomValue(apiAtom); const api = useAtomValue(apiAtom);
const user = useAtomValue(userAtom); const user = useAtomValue(userAtom);
const router = useAppRouter(); const router = useAppRouter();
const { isConnected: isWsConnected } = useWebSocketContext();
const [manager, setManager] = useState<SyncPlayManager | null>(null); const [manager, setManager] = useState<SyncPlayManager | null>(null);
const isNavigatingToPlayerRef = useRef(false); const isNavigatingToPlayerRef = useRef(false);
@@ -80,6 +87,64 @@ export function SyncPlayProvider({ children }: SyncPlayProviderProps) {
"Unpause" | "Pause" | null "Unpause" | "Pause" | null
>(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 canJoinGroups = useMemo(() => {
const access = user?.Policy?.SyncPlayAccess; const access = user?.Policy?.SyncPlayAccess;
return access !== "None" && access !== undefined; return access !== "None" && access !== undefined;
@@ -177,24 +242,55 @@ export function SyncPlayProvider({ children }: SyncPlayProviderProps) {
setPendingPlaybackCommand(args[0] as "Unpause" | "Pause" | null); setPendingPlaybackCommand(args[0] as "Unpause" | "Pause" | null);
}); });
// group-state-change → on "Waiting", park the player at the last // group-state-change → on "Waiting", pause locally so we don't drift
// broadcast position so it's ready to resume cleanly. // 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[]) => { mgr.on("group-state-change", (...args: unknown[]) => {
const state = args[0] as string | undefined; const state = args[0] as string | undefined;
const reason = args[2] as string | undefined;
const wrapper = mgr.getPlayerWrapper(); const wrapper = mgr.getPlayerWrapper();
if (!wrapper.isPlaybackActive()) return; if (!wrapper.isPlaybackActive()) return;
if (state === "Waiting") { if (state === "Waiting") {
const lastCommand = mgr.getLastPlaybackCommand();
wrapper.localPause(); 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[]) => { mgr.on("toast", (...args: unknown[]) => {
@@ -287,7 +383,19 @@ export function SyncPlayProvider({ children }: SyncPlayProviderProps) {
lastGroupIdRef.current = groupInfo?.GroupId ?? null; lastGroupIdRef.current = groupInfo?.GroupId ?? null;
}, [groupInfo?.GroupId]); }, [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); const appStateRef = useRef<AppStateStatus>(AppState.currentState);
useEffect(() => {
if (!isWsConnected && appStateRef.current !== "active") {
wsClosedWhileBackgroundedRef.current = true;
}
}, [isWsConnected]);
useEffect(() => { useEffect(() => {
if (!api) return; if (!api) return;
@@ -304,9 +412,21 @@ export function SyncPlayProvider({ children }: SyncPlayProviderProps) {
const groupId = lastGroupIdRef.current; const groupId = lastGroupIdRef.current;
if (!groupId) return; 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. // Small delay so the WebSocket has a moment to reconnect.
setTimeout(() => { setTimeout(() => {
console.log(`SyncPlay: app foregrounded, rejoining group ${groupId}`); console.log(
`SyncPlay: app foregrounded after WS drop, rejoining group ${groupId}`,
);
getSyncPlayApi(api) getSyncPlayApi(api)
.syncPlayJoinGroup({ joinGroupRequestDto: { GroupId: groupId } }) .syncPlayJoinGroup({ joinGroupRequestDto: { GroupId: groupId } })
.catch((error) => { .catch((error) => {
@@ -370,6 +490,7 @@ export function SyncPlayProvider({ children }: SyncPlayProviderProps) {
notifyBuffering, notifyBuffering,
notifyPlaybackStart, notifyPlaybackStart,
pendingPlaybackCommand, pendingPlaybackCommand,
osdAction,
}), }),
[ [
isEnabled, isEnabled,
@@ -386,6 +507,7 @@ export function SyncPlayProvider({ children }: SyncPlayProviderProps) {
notifyBuffering, notifyBuffering,
notifyPlaybackStart, notifyPlaybackStart,
pendingPlaybackCommand, pendingPlaybackCommand,
osdAction,
], ],
); );

View File

@@ -136,11 +136,18 @@ export class PlaybackCore extends EventEmitter {
this.lastCommand && this.lastCommand &&
(( ((
this.lastCommand as unknown as { EmittedAt: Date } this.lastCommand as unknown as { EmittedAt: Date }
).EmittedAt.getTime() >= ).EmittedAt.getTime() >
(command as unknown as { EmittedAt: Date }).EmittedAt.getTime() || (command as unknown as { EmittedAt: Date }).EmittedAt.getTime() ||
(this.lastCommand as unknown as { When: Date }).When.getTime() > (this.lastCommand as unknown as { When: Date }).When.getTime() >
(command 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( console.debug(
"SyncPlay applyCommand: dropping outdated command", "SyncPlay applyCommand: dropping outdated command",
command, command,

View File

@@ -47,11 +47,19 @@ export interface PlayerControls {
/** OSD action types — drive optional player-overlay feedback. */ /** OSD action types — drive optional player-overlay feedback. */
export type SyncPlayOsdAction = export type SyncPlayOsdAction =
/** transient — 1.5s pulse, the unpause command fired locally */
| "unpause" | "unpause"
/** transient — 1.5s pulse, the pause command fired locally */
| "pause" | "pause"
/** transient — 1.5s pulse, a seek command applied locally */
| "seek" | "seek"
/** persistent — group is about to play (Waiting+Unpause / pending Unpause) */
| "schedule-play"
/** persistent — another client is buffering (Waiting+Buffer) */
| "buffering" | "buffering"
/** persistent — group transitioning to pause (Waiting+Pause) */
| "wait-pause" | "wait-pause"
/** persistent — group transitioning to unpause; sibling of schedule-play */
| "wait-unpause"; | "wait-unpause";
/** /**

View File

@@ -44,6 +44,15 @@ interface WebSocketContextType {
lastMessage: WebSocketMessage | null; lastMessage: WebSocketMessage | null;
sendMessage: (message: any) => void; sendMessage: (message: any) => void;
clearLastMessage: () => 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); const WebSocketContext = createContext<WebSocketContextType | null>(null);
@@ -63,6 +72,21 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => {
const libraryChangeDebounceRef = useRef<ReturnType<typeof setTimeout> | null>( const libraryChangeDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(
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(() => { const connectWebSocket = useCallback(() => {
if (!deviceId || !api?.accessToken || !isNetworkConnected) { if (!deviceId || !api?.accessToken || !isNetworkConnected) {
@@ -235,9 +259,20 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => {
useEffect(() => { useEffect(() => {
const handleAppStateChange = (state: AppStateStatus) => { const handleAppStateChange = (state: AppStateStatus) => {
if (state === "background" || state === "inactive") { 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..."); console.log("App moving to background, closing WebSocket...");
ws?.close(); ws?.close();
} else if (state === "active") { } 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..."); console.log("App coming to foreground, reconnecting WebSocket...");
connectWebSocket(); connectWebSocket();
} }
@@ -267,7 +302,14 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => {
}, []); }, []);
return ( return (
<WebSocketContext.Provider <WebSocketContext.Provider
value={{ ws, isConnected, lastMessage, sendMessage, clearLastMessage }} value={{
ws,
isConnected,
lastMessage,
sendMessage,
clearLastMessage,
acquireKeepAlive,
}}
> >
{children} {children}
</WebSocketContext.Provider> </WebSocketContext.Provider>