Files
streamyfin/providers/SyncPlay/SyncPlayProvider.tsx
2026-06-05 23:13:52 +10:00

562 lines
19 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 { usePathname } from "expo-router";
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 { getDownloadedItemById } from "@/providers/Downloads";
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);
// Keep a live ref of the current route pathname so the
// navigateToPlayer helper (wired up once inside the manager-lifecycle
// effect) can read the *current* page without stale-closure issues.
const pathname = usePathname();
const pathnameRef = useRef(pathname);
useEffect(() => {
pathnameRef.current = pathname;
}, [pathname]);
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();
/*
* Both localPlay (new queue / joining a group) and
* localSetCurrentPlaylistItem (queue advance to next episode)
* end up navigating to the same screen with the same params;
* jellyfin-web treats them as distinct because one calls full
* playbackManager.play() and the other does a cheap item swap,
* but on RN both have to re-mount direct-player either way.
*/
const navigateToPlayer = (
itemId: string,
startPositionTicks: number,
withJoinToast: boolean,
) => {
if (isNavigatingToPlayerRef.current) {
console.debug("SyncPlay: already navigating to player");
return;
}
isNavigatingToPlayerRef.current = true;
if (withJoinToast) {
toast(i18n.t("syncplay.joining_playback"));
}
// Opportunistic local playback: if we have a downloaded copy of
// the target item, use it instead of streaming. Matters most when
// the group advances to an episode you've downloaded — the local
// file starts instantly and survives spotty wifi. SyncPlay's
// position/pause/seek commands keep flowing normally; only the
// source changes.
const isDownloaded = !!getDownloadedItemById(itemId);
const queryParams = new URLSearchParams({
itemId,
playbackPosition: String(startPositionTicks),
syncPlay: "true",
...(isDownloaded && { offline: "true" }),
}).toString();
// Use `replace` when we're already on the player screen so queue
// advances don't stack a second player on the nav stack; `push`
// otherwise so the user can back out to where they came from.
const onPlayerScreen =
pathnameRef.current?.startsWith("/player/direct-player") ?? false;
if (onPlayerScreen) {
router.replace(`/player/direct-player?${queryParams}`);
} else {
router.push(`/player/direct-player?${queryParams}`);
}
setTimeout(() => {
isNavigatingToPlayerRef.current = false;
}, 2000);
};
// 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;
}
navigateToPlayer(itemId, options.startPositionTicks ?? 0, true);
});
// 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;
}
navigateToPlayer(itemId, queueCore.getStartPositionTicks(), false);
});
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;
}