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";
|
||||
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);
|
||||
|
||||
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 { SyncPlayActionIcon } from "./SyncPlayActionIcon";
|
||||
export { SyncPlayButton } from "./SyncPlayButton";
|
||||
export { SyncPlaySpinner } from "./SyncPlaySpinner";
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
/**
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user