mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-06-05 13:38:27 +01:00
Add PIP support for syncplay
This commit is contained in:
@@ -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);
|
||||||
|
|||||||
206
components/syncplay/SyncPlayActionIcon.tsx
Normal file
206
components/syncplay/SyncPlayActionIcon.tsx
Normal 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",
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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";
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
22
hooks/useKeepWebSocketAlive.ts
Normal file
22
hooks/useKeepWebSocketAlive.ts
Normal 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]);
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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") {
|
||||||
|
|||||||
@@ -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,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user