mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-06-05 21:48:31 +01:00
528 lines
17 KiB
TypeScript
528 lines
17 KiB
TypeScript
/**
|
|
* SyncPlayProvider — React glue around `SyncPlayManager`.
|
|
*
|
|
* Responsibilities:
|
|
* - Manager lifecycle (construct on api change, destroy on unmount)
|
|
* - React mirrors of manager state (`isEnabled`, `groupInfo`,
|
|
* `pendingPlaybackCommand`) so components re-render
|
|
* - Navigation handlers wired into `PlayerWrapper.localPlay` /
|
|
* `localSetCurrentPlaylistItem` — these are what jellyfin-web does
|
|
* synchronously via `playbackManager.play`; on RN they navigate
|
|
* to the player screen instead
|
|
* - AppState foreground re-join (we may miss broadcasts while
|
|
* suspended)
|
|
*
|
|
* External API surface (`useSyncPlay`) is stable; components don't
|
|
* change when the internals do.
|
|
*/
|
|
|
|
import { getSyncPlayApi } from "@jellyfin/sdk/lib/utils/api";
|
|
import { useAtomValue } from "jotai";
|
|
import {
|
|
createContext,
|
|
type ReactNode,
|
|
useCallback,
|
|
useContext,
|
|
useEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
} from "react";
|
|
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 { SyncPlayManager } from "./Manager";
|
|
import { useSyncPlayWebSocket } from "./transport/useSyncPlayWebSocket";
|
|
import type { GroupInfoDto, PlayerControls, SyncPlayOsdAction } from "./types";
|
|
|
|
interface SyncPlayContextValue {
|
|
isEnabled: boolean;
|
|
groupInfo: GroupInfoDto | null;
|
|
canJoinGroups: boolean;
|
|
canCreateGroups: boolean;
|
|
|
|
joinGroup: (groupId: string) => Promise<void>;
|
|
createGroup: (groupName?: string) => Promise<void>;
|
|
leaveGroup: () => Promise<void>;
|
|
getGroups: () => Promise<GroupInfoDto[]>;
|
|
|
|
controller: SyncPlayController | null;
|
|
|
|
setPlayerControls: (controls: PlayerControls | null) => void;
|
|
notifyReady: () => void;
|
|
notifyBuffering: (isBuffering: boolean) => void;
|
|
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);
|
|
|
|
interface SyncPlayProviderProps {
|
|
children: ReactNode;
|
|
}
|
|
|
|
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);
|
|
|
|
const [isEnabled, setIsEnabled] = useState(false);
|
|
const [groupInfo, setGroupInfo] = useState<GroupInfoDto | null>(null);
|
|
const [pendingPlaybackCommand, setPendingPlaybackCommand] = useState<
|
|
"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;
|
|
}, [user?.Policy?.SyncPlayAccess]);
|
|
|
|
const canCreateGroups = useMemo(
|
|
() => user?.Policy?.SyncPlayAccess === "CreateAndJoinGroups",
|
|
[user?.Policy?.SyncPlayAccess],
|
|
);
|
|
|
|
// Latch: `true` once we've fired the per-attach `playbackstart` event.
|
|
const playbackStartFiredRef = useRef(false);
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Manager lifecycle
|
|
// ---------------------------------------------------------------------------
|
|
|
|
useEffect(() => {
|
|
if (!api) return;
|
|
|
|
const mgr = new SyncPlayManager(api);
|
|
mgr.init();
|
|
setManager(mgr);
|
|
|
|
const playerWrapper = mgr.getPlayerWrapper();
|
|
|
|
// localPlay → navigate to direct-player with syncPlay=true
|
|
playerWrapper.setLocalPlayHandler((options) => {
|
|
const itemId = options.ids[0];
|
|
if (!itemId) {
|
|
console.warn("SyncPlay: localPlay called with no ids");
|
|
return;
|
|
}
|
|
if (isNavigatingToPlayerRef.current) {
|
|
console.debug("SyncPlay: already navigating to player");
|
|
return;
|
|
}
|
|
isNavigatingToPlayerRef.current = true;
|
|
|
|
toast(i18n.t("syncplay.joining_playback"));
|
|
|
|
const queryParams = new URLSearchParams({
|
|
itemId,
|
|
playbackPosition: String(options.startPositionTicks ?? 0),
|
|
syncPlay: "true",
|
|
}).toString();
|
|
router.push(`/player/direct-player?${queryParams}`);
|
|
|
|
setTimeout(() => {
|
|
isNavigatingToPlayerRef.current = false;
|
|
}, 2000);
|
|
});
|
|
|
|
// localSetCurrentPlaylistItem → navigate to the new playlist item
|
|
playerWrapper.setLocalSetCurrentItemHandler((playlistItemId) => {
|
|
if (!playlistItemId) return;
|
|
const queueCore = mgr.getQueueCore();
|
|
const target = queueCore
|
|
.getPlaylist()
|
|
.find((i) => i.PlaylistItemId === playlistItemId);
|
|
const itemId = target?.Id;
|
|
if (!itemId) {
|
|
console.warn(
|
|
"SyncPlay: localSetCurrentPlaylistItem — item not in playlist",
|
|
playlistItemId,
|
|
);
|
|
return;
|
|
}
|
|
if (isNavigatingToPlayerRef.current) return;
|
|
isNavigatingToPlayerRef.current = true;
|
|
|
|
const queryParams = new URLSearchParams({
|
|
itemId,
|
|
playbackPosition: String(queueCore.getStartPositionTicks()),
|
|
syncPlay: "true",
|
|
}).toString();
|
|
router.push(`/player/direct-player?${queryParams}`);
|
|
|
|
setTimeout(() => {
|
|
isNavigatingToPlayerRef.current = false;
|
|
}, 2000);
|
|
});
|
|
|
|
mgr.on("enabled", (...args: unknown[]) => {
|
|
const enabled = args[0] as boolean;
|
|
setIsEnabled(enabled);
|
|
if (!enabled) setGroupInfo(null);
|
|
});
|
|
|
|
mgr.on("group-update", (...args: unknown[]) => {
|
|
setGroupInfo((args[0] as GroupInfoDto | null | undefined) ?? null);
|
|
});
|
|
|
|
mgr.on("pending-playback-change", (...args: unknown[]) => {
|
|
setPendingPlaybackCommand(args[0] as "Unpause" | "Pause" | null);
|
|
});
|
|
|
|
// 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") {
|
|
wrapper.localPause();
|
|
}
|
|
|
|
// 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[]) => {
|
|
const key = args[0] as string;
|
|
const arg = args[1] as string | undefined;
|
|
const message = arg
|
|
? i18n.t(`syncplay.toasts.${key}`, { user: arg })
|
|
: i18n.t(`syncplay.toasts.${key}`);
|
|
toast(message);
|
|
});
|
|
|
|
return () => {
|
|
mgr.destroy();
|
|
setManager(null);
|
|
};
|
|
}, [api, router]);
|
|
|
|
// Initial join race: once `enabled` flips true, snapshot the current group.
|
|
useEffect(() => {
|
|
if (isEnabled && manager) {
|
|
setGroupInfo(manager.getGroupInfo());
|
|
}
|
|
}, [isEnabled, manager]);
|
|
|
|
// Wire WebSocket messages → manager
|
|
useSyncPlayWebSocket(manager);
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Group management
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const getGroups = useCallback(async (): Promise<GroupInfoDto[]> => {
|
|
if (!api) return [];
|
|
try {
|
|
const response = await getSyncPlayApi(api).syncPlayGetGroups();
|
|
return (response.data as unknown as GroupInfoDto[]) ?? [];
|
|
} catch (error) {
|
|
console.error("SyncPlay: failed to get groups", error);
|
|
return [];
|
|
}
|
|
}, [api]);
|
|
|
|
const joinGroup = useCallback(
|
|
async (groupId: string): Promise<void> => {
|
|
if (!api) return;
|
|
try {
|
|
await getSyncPlayApi(api).syncPlayJoinGroup({
|
|
joinGroupRequestDto: { GroupId: groupId },
|
|
});
|
|
} catch (error) {
|
|
console.error("SyncPlay: failed to join group", error);
|
|
throw error;
|
|
}
|
|
},
|
|
[api],
|
|
);
|
|
|
|
const createGroup = useCallback(
|
|
async (groupName?: string): Promise<void> => {
|
|
if (!api || !user) return;
|
|
const name = groupName || `${user.Name}'s Group`;
|
|
try {
|
|
await getSyncPlayApi(api).syncPlayCreateGroup({
|
|
newGroupRequestDto: { GroupName: name },
|
|
});
|
|
} catch (error) {
|
|
console.error("SyncPlay: failed to create group", error);
|
|
throw error;
|
|
}
|
|
},
|
|
[api, user],
|
|
);
|
|
|
|
const leaveGroup = useCallback(async (): Promise<void> => {
|
|
if (!api) return;
|
|
try {
|
|
await getSyncPlayApi(api).syncPlayLeaveGroup();
|
|
} catch (error) {
|
|
console.error("SyncPlay: failed to leave group", error);
|
|
throw error;
|
|
}
|
|
}, [api]);
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// App foreground re-join (idempotent; gets us a fresh GroupJoined snapshot)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const lastGroupIdRef = useRef<string | null>(null);
|
|
useEffect(() => {
|
|
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;
|
|
|
|
const subscription = AppState.addEventListener("change", (nextAppState) => {
|
|
const previousAppState = appStateRef.current;
|
|
appStateRef.current = nextAppState;
|
|
|
|
const becameActive =
|
|
(previousAppState === "background" ||
|
|
previousAppState === "inactive") &&
|
|
nextAppState === "active";
|
|
if (!becameActive) return;
|
|
|
|
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 after WS drop, rejoining group ${groupId}`,
|
|
);
|
|
getSyncPlayApi(api)
|
|
.syncPlayJoinGroup({ joinGroupRequestDto: { GroupId: groupId } })
|
|
.catch((error) => {
|
|
console.error("SyncPlay: failed to rejoin group", error);
|
|
});
|
|
}, 1000);
|
|
});
|
|
|
|
return () => subscription.remove();
|
|
}, [api]);
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Player attach bridges
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const setPlayerControls = useCallback(
|
|
(controls: PlayerControls | null) => {
|
|
// Reset the playbackstart latch on each new attach.
|
|
playbackStartFiredRef.current = false;
|
|
manager?.setPlayerControls(controls);
|
|
},
|
|
[manager],
|
|
);
|
|
|
|
const notifyReady = useCallback(() => {
|
|
manager?.notifyReady();
|
|
}, [manager]);
|
|
|
|
const notifyBuffering = useCallback(
|
|
(isBuffering: boolean) => {
|
|
manager?.notifyBuffering(isBuffering);
|
|
if (!isBuffering && !playbackStartFiredRef.current) {
|
|
playbackStartFiredRef.current = true;
|
|
manager?.notifyPlaybackStart();
|
|
}
|
|
},
|
|
[manager],
|
|
);
|
|
|
|
const notifyPlaybackStart = useCallback(() => {
|
|
manager?.notifyPlaybackStart();
|
|
}, [manager]);
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Context value
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const contextValue: SyncPlayContextValue = useMemo(
|
|
() => ({
|
|
isEnabled,
|
|
groupInfo,
|
|
canJoinGroups,
|
|
canCreateGroups,
|
|
joinGroup,
|
|
createGroup,
|
|
leaveGroup,
|
|
getGroups,
|
|
controller: manager?.getController() ?? null,
|
|
setPlayerControls,
|
|
notifyReady,
|
|
notifyBuffering,
|
|
notifyPlaybackStart,
|
|
pendingPlaybackCommand,
|
|
osdAction,
|
|
}),
|
|
[
|
|
isEnabled,
|
|
groupInfo,
|
|
canJoinGroups,
|
|
canCreateGroups,
|
|
joinGroup,
|
|
createGroup,
|
|
leaveGroup,
|
|
getGroups,
|
|
manager,
|
|
setPlayerControls,
|
|
notifyReady,
|
|
notifyBuffering,
|
|
notifyPlaybackStart,
|
|
pendingPlaybackCommand,
|
|
osdAction,
|
|
],
|
|
);
|
|
|
|
return (
|
|
<SyncPlayContext.Provider value={contextValue}>
|
|
{children}
|
|
</SyncPlayContext.Provider>
|
|
);
|
|
}
|
|
|
|
export function useSyncPlay(): SyncPlayContextValue {
|
|
const context = useContext(SyncPlayContext);
|
|
if (!context) {
|
|
throw new Error("useSyncPlay must be used within a SyncPlayProvider");
|
|
}
|
|
return context;
|
|
}
|