mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-06-01 03:28:27 +01:00
feat(sync-play): squash feature/sync-play
This commit is contained in:
594
providers/SyncPlay/SyncPlayProvider.tsx
Normal file
594
providers/SyncPlay/SyncPlayProvider.tsx
Normal file
@@ -0,0 +1,594 @@
|
||||
/**
|
||||
* SyncPlayProvider
|
||||
*
|
||||
* React context provider for SyncPlay functionality.
|
||||
* Manages the SyncPlay manager and exposes hooks for components.
|
||||
*/
|
||||
|
||||
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 i18n from "@/i18n";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { SyncPlayController } from "./Controller";
|
||||
import { ticksToMs } from "./Helper";
|
||||
import { SyncPlayManager } from "./Manager";
|
||||
import { PlaybackCore } from "./PlaybackCore";
|
||||
import { QueueCore } from "./QueueCore";
|
||||
import type {
|
||||
GroupInfoDto,
|
||||
PlayerControls,
|
||||
PlayQueueUpdate,
|
||||
SendCommand,
|
||||
SyncPlayOsdAction,
|
||||
SyncPlayStats,
|
||||
} from "./types";
|
||||
import { useSyncPlayWebSocket } from "./useSyncPlayWebSocket";
|
||||
|
||||
// ============================================================================
|
||||
// Context Types
|
||||
// ============================================================================
|
||||
|
||||
interface SyncPlayContextValue {
|
||||
// State
|
||||
isEnabled: boolean;
|
||||
isReady: boolean;
|
||||
groupInfo: GroupInfoDto | null;
|
||||
canJoinGroups: boolean;
|
||||
canCreateGroups: boolean;
|
||||
|
||||
// Group management
|
||||
joinGroup: (groupId: string) => Promise<void>;
|
||||
createGroup: (groupName?: string) => Promise<void>;
|
||||
leaveGroup: () => Promise<void>;
|
||||
getGroups: () => Promise<GroupInfoDto[]>;
|
||||
|
||||
// Playback control delegation
|
||||
controller: SyncPlayController | null;
|
||||
|
||||
// Player integration
|
||||
setPlayerControls: (controls: PlayerControls | null) => void;
|
||||
notifyReady: () => void;
|
||||
notifyBuffering: () => void;
|
||||
|
||||
// Stats
|
||||
getStats: () => SyncPlayStats;
|
||||
|
||||
// OSD state
|
||||
osdAction: SyncPlayOsdAction | null;
|
||||
isSyncing: boolean;
|
||||
syncMethod: string;
|
||||
/** In-flight Unpause/Pause request, before the server has echoed back. */
|
||||
pendingPlaybackCommand: "Unpause" | "Pause" | null;
|
||||
}
|
||||
|
||||
const SyncPlayContext = createContext<SyncPlayContextValue | null>(null);
|
||||
|
||||
// ============================================================================
|
||||
// Provider Component
|
||||
// ============================================================================
|
||||
|
||||
interface SyncPlayProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function SyncPlayProvider({ children }: SyncPlayProviderProps) {
|
||||
const api = useAtomValue(apiAtom);
|
||||
const user = useAtomValue(userAtom);
|
||||
const router = useAppRouter();
|
||||
|
||||
// Core modules - use state for manager so WebSocket hook re-runs when ready
|
||||
const [manager, setManager] = useState<SyncPlayManager | null>(null);
|
||||
const playbackCoreRef = useRef<PlaybackCore | null>(null);
|
||||
const queueCoreRef = useRef<QueueCore | null>(null);
|
||||
const controllerRef = useRef<SyncPlayController | null>(null);
|
||||
|
||||
// Track if we're already on the player page to avoid duplicate navigations
|
||||
const isNavigatingToPlayerRef = useRef(false);
|
||||
|
||||
// State
|
||||
const [isEnabled, setIsEnabled] = useState(false);
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
const [groupInfo, setGroupInfoDto] = useState<GroupInfoDto | null>(null);
|
||||
const [osdAction, setOsdAction] = useState<SyncPlayOsdAction | null>(null);
|
||||
const [isSyncing, setIsSyncing] = useState(false);
|
||||
const [syncMethod, setSyncMethod] = useState("None");
|
||||
const [pendingPlaybackCommand, setPendingPlaybackCommand] = useState<
|
||||
"Unpause" | "Pause" | null
|
||||
>(null);
|
||||
|
||||
// Permission checks
|
||||
const canJoinGroups = useMemo(() => {
|
||||
const access = user?.Policy?.SyncPlayAccess;
|
||||
return access !== "None" && access !== undefined;
|
||||
}, [user?.Policy?.SyncPlayAccess]);
|
||||
|
||||
const canCreateGroups = useMemo(() => {
|
||||
return user?.Policy?.SyncPlayAccess === "CreateAndJoinGroups";
|
||||
}, [user?.Policy?.SyncPlayAccess]);
|
||||
|
||||
// Initialize manager
|
||||
useEffect(() => {
|
||||
if (!api) return;
|
||||
|
||||
// Create manager and cores
|
||||
const manager = new SyncPlayManager(api);
|
||||
const playbackCore = new PlaybackCore(api, manager.getTimeSyncCore());
|
||||
const queueCore = new QueueCore();
|
||||
const controller = new SyncPlayController(api, manager, queueCore);
|
||||
|
||||
setManager(manager);
|
||||
playbackCoreRef.current = playbackCore;
|
||||
queueCoreRef.current = queueCore;
|
||||
controllerRef.current = controller;
|
||||
|
||||
// Wire up manager callbacks
|
||||
manager.setPlaybackCommandHandler((command: SendCommand) => {
|
||||
playbackCore.applyCommand(command);
|
||||
});
|
||||
|
||||
manager.setQueueUpdateHandler((update: PlayQueueUpdate) => {
|
||||
queueCore.updatePlayQueue(update);
|
||||
});
|
||||
|
||||
manager.setPlaylistItemIdGetter(() => {
|
||||
return queueCore.getCurrentPlaylistItemId();
|
||||
});
|
||||
|
||||
// When SyncPlay is disabled, flush PlaybackCore's scheduled commands and
|
||||
// cached state so we don't carry ghost commands into the next group.
|
||||
manager.setDisableHandler(() => {
|
||||
playbackCore.reset();
|
||||
});
|
||||
|
||||
// Also clear the cached PlayQueue snapshot on disable. If we don't, then
|
||||
// when the user later re-joins the same group, the server's first
|
||||
// PlayQueue echo (which can carry the same LastUpdate as the snapshot we
|
||||
// saw last session) gets dropped by QueueCore's stale-update guard, and
|
||||
// the receiver never auto-navigates to the group's content.
|
||||
manager.setQueueClearHandler(() => {
|
||||
queueCore.clear();
|
||||
});
|
||||
|
||||
// Wire up playback core callbacks
|
||||
playbackCore.setPlaylistItemIdGetter(() => {
|
||||
return queueCore.getCurrentPlaylistItemId();
|
||||
});
|
||||
|
||||
playbackCore.setOsdHandler((action) => {
|
||||
setOsdAction(action);
|
||||
// Clear after display
|
||||
setTimeout(() => setOsdAction(null), 1500);
|
||||
});
|
||||
|
||||
// Wire up queue core
|
||||
queueCore.setTicksEstimator((ticks, when) => {
|
||||
return playbackCore.estimateCurrentTicks(ticks, when);
|
||||
});
|
||||
|
||||
// Navigate to player when group starts playing new content
|
||||
queueCore.setStartPlaybackHandler(async () => {
|
||||
const itemId = queueCore.getCurrentItemId();
|
||||
const startPositionTicks = queueCore.getStartPositionTicks();
|
||||
|
||||
if (!itemId) {
|
||||
console.warn("SyncPlay: new playlist but no current item ID");
|
||||
return;
|
||||
}
|
||||
|
||||
// Avoid duplicate navigations
|
||||
if (isNavigatingToPlayerRef.current) {
|
||||
console.debug("SyncPlay: already navigating to player");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("SyncPlay: navigating to player for item", itemId);
|
||||
isNavigatingToPlayerRef.current = true;
|
||||
|
||||
// Mirror jellyfin-web's `QueueCore.startPlayback` ordering:
|
||||
// 1. followGroupPlayback (IgnoreWait:false) — tell server we follow
|
||||
// 2. scheduleReadyRequestOnPlaybackStart — arm initial pause
|
||||
// 3. playerWrapper.localPlay (== our router navigation) — start loading
|
||||
// The arm-then-navigate order matters: scheduling must happen BEFORE
|
||||
// navigation so the flag is set when the player attaches and fires
|
||||
// its first `notifyReady`. Otherwise we race the player and the
|
||||
// initial SyncPlayReady reports `IsPlaying:true`, defeating the
|
||||
// server's "hold the group until everyone is parked" semantics.
|
||||
await manager.followGroupPlayback();
|
||||
playbackCore.scheduleReadyRequestOnPlaybackStart();
|
||||
|
||||
// Show toast notification
|
||||
toast(i18n.t("syncplay.joining_playback"));
|
||||
|
||||
// Navigate to the player with the item. Use `replace` so repeated
|
||||
// queue updates don't stack player screens on the history.
|
||||
const queryParams = new URLSearchParams({
|
||||
itemId: itemId,
|
||||
playbackPosition: startPositionTicks.toString(),
|
||||
syncPlay: "true", // Mark this as a SyncPlay-initiated playback
|
||||
}).toString();
|
||||
|
||||
router.push(`/player/direct-player?${queryParams}` as any);
|
||||
|
||||
// Reset navigation flag after a short delay
|
||||
setTimeout(() => {
|
||||
isNavigatingToPlayerRef.current = false;
|
||||
}, 2000);
|
||||
});
|
||||
|
||||
// Also handle item changes (next/previous in playlist)
|
||||
queueCore.on("item-change", () => {
|
||||
const newItemId = queueCore.getCurrentItemId();
|
||||
const startPositionTicks = queueCore.getStartPositionTicks();
|
||||
|
||||
if (!newItemId) {
|
||||
console.warn("SyncPlay: item change but no current item ID");
|
||||
return;
|
||||
}
|
||||
|
||||
// Avoid duplicate navigations
|
||||
if (isNavigatingToPlayerRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("SyncPlay: item changed, navigating to", newItemId);
|
||||
isNavigatingToPlayerRef.current = true;
|
||||
|
||||
// Same pause-before-ready dance as NewPlaylist — the new item's
|
||||
// player needs to park at the start position and report
|
||||
// IsPlaying:false so the server holds the group until everyone is
|
||||
// ready for the next Unpause. Mirrors jellyfin-web's
|
||||
// `QueueCore.setCurrentPlaylistItem`.
|
||||
playbackCore.scheduleReadyRequestOnPlaybackStart();
|
||||
|
||||
const queryParams = new URLSearchParams({
|
||||
itemId: newItemId,
|
||||
playbackPosition: startPositionTicks.toString(),
|
||||
syncPlay: "true",
|
||||
}).toString();
|
||||
|
||||
router.push(`/player/direct-player?${queryParams}`);
|
||||
|
||||
setTimeout(() => {
|
||||
isNavigatingToPlayerRef.current = false;
|
||||
}, 2000);
|
||||
});
|
||||
|
||||
// Handle seek events from other devices - pause first, then seek (like Jellyfin-web)
|
||||
queueCore.on("seek", (...args: unknown[]) => {
|
||||
const positionTicks = args[0] as number;
|
||||
const positionMs = ticksToMs(positionTicks);
|
||||
console.log(
|
||||
"SyncPlay: seek event received, pausing then seeking to",
|
||||
positionMs,
|
||||
"ms",
|
||||
);
|
||||
const playerControls = manager.getPlayerControls();
|
||||
if (playerControls) {
|
||||
playerControls.pause();
|
||||
playerControls.seekTo(positionMs);
|
||||
}
|
||||
});
|
||||
|
||||
// Subscribe to manager events
|
||||
manager.on("enabled", (...args: unknown[]) => {
|
||||
const enabled = args[0] as boolean;
|
||||
setIsEnabled(enabled);
|
||||
if (!enabled) {
|
||||
setIsReady(false);
|
||||
setGroupInfoDto(null);
|
||||
}
|
||||
});
|
||||
|
||||
manager.on("syncing", (...args: unknown[]) => {
|
||||
const syncing = args[0] as boolean;
|
||||
const method = args[1] as string;
|
||||
setIsSyncing(syncing);
|
||||
setSyncMethod(method);
|
||||
});
|
||||
|
||||
// Keep React-side groupInfo in sync with Manager mutations. Without this,
|
||||
// CenterControls' `groupInfo.State === 'Waiting'` check is stale because
|
||||
// Manager mutates the existing object reference rather than emitting a
|
||||
// fresh one.
|
||||
manager.on("group-info-change", (...args: unknown[]) => {
|
||||
setGroupInfoDto(args[0] as GroupInfoDto);
|
||||
});
|
||||
|
||||
// Expose pending Unpause/Pause to consumers (e.g. CenterControls renders
|
||||
// a spinner instead of the play/pause button while a request is in
|
||||
// flight — mirrors jellyfin-web's "schedule-play" indicator).
|
||||
manager.on("pending-playback-change", (...args: unknown[]) => {
|
||||
setPendingPlaybackCommand(args[0] as "Unpause" | "Pause" | null);
|
||||
});
|
||||
|
||||
// When entering Waiting state, report ready through PlaybackCore
|
||||
manager.on("waiting-for-ready", () => {
|
||||
console.log(
|
||||
"SyncPlay: waiting-for-ready event, calling PlaybackCore.onReady()",
|
||||
);
|
||||
playbackCore.onReady();
|
||||
});
|
||||
|
||||
// Handle seek from StateUpdate (when SyncPlayCommand doesn't include seek)
|
||||
manager.on("seek-from-state-update", (...args: unknown[]) => {
|
||||
const positionTicks = args[0] as number;
|
||||
const positionMs = ticksToMs(positionTicks);
|
||||
console.log(
|
||||
"SyncPlay: seek from StateUpdate, seeking to",
|
||||
positionMs,
|
||||
"ms",
|
||||
);
|
||||
const playerControls = manager.getPlayerControls();
|
||||
if (playerControls) {
|
||||
playerControls.pause();
|
||||
playerControls.seekTo(positionMs);
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize
|
||||
manager.init();
|
||||
|
||||
return () => {
|
||||
manager.destroy();
|
||||
playbackCore.destroy();
|
||||
queueCore.destroy();
|
||||
setManager(null);
|
||||
playbackCoreRef.current = null;
|
||||
queueCoreRef.current = null;
|
||||
controllerRef.current = null;
|
||||
};
|
||||
}, [api]);
|
||||
|
||||
// Update group info when enabled
|
||||
useEffect(() => {
|
||||
if (isEnabled && manager) {
|
||||
setGroupInfoDto(manager.getGroupInfo());
|
||||
setIsReady(manager.isSyncPlayReady());
|
||||
}
|
||||
}, [isEnabled, manager]);
|
||||
|
||||
// Connect to WebSocket messages - manager is now state so hook re-runs when ready
|
||||
useSyncPlayWebSocket(manager);
|
||||
|
||||
// ============================================================================
|
||||
// Group Management
|
||||
// ============================================================================
|
||||
|
||||
const getGroups = useCallback(async (): Promise<GroupInfoDto[]> => {
|
||||
if (!api) return [];
|
||||
|
||||
try {
|
||||
const syncPlayApi = getSyncPlayApi(api);
|
||||
const response = await syncPlayApi.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 {
|
||||
const syncPlayApi = getSyncPlayApi(api);
|
||||
await syncPlayApi.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 {
|
||||
const syncPlayApi = getSyncPlayApi(api);
|
||||
await syncPlayApi.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 {
|
||||
const syncPlayApi = getSyncPlayApi(api);
|
||||
await syncPlayApi.syncPlayLeaveGroup();
|
||||
} catch (error) {
|
||||
console.error("SyncPlay: failed to leave group", error);
|
||||
throw error;
|
||||
}
|
||||
}, [api]);
|
||||
|
||||
// Re-join the SyncPlay group when the app returns from background.
|
||||
//
|
||||
// Backgrounding tears down our WebSocket (see WebSocketProvider) and the
|
||||
// server may drop us from the group after its inactivity timeout. Even
|
||||
// when it doesn't, we likely missed any commands/state-updates broadcast
|
||||
// while we were suspended. Re-issuing the join is idempotent on the
|
||||
// server and gets us a fresh GroupJoined snapshot.
|
||||
const lastGroupIdRef = useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
lastGroupIdRef.current = groupInfo?.GroupId ?? null;
|
||||
}, [groupInfo?.GroupId]);
|
||||
|
||||
const appStateRef = useRef<AppStateStatus>(AppState.currentState);
|
||||
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;
|
||||
|
||||
// Give the WebSocket a moment to reconnect (handled by
|
||||
// WebSocketProvider on the same 'active' transition) so the
|
||||
// server's GroupJoined broadcast actually reaches us.
|
||||
setTimeout(() => {
|
||||
console.log(`SyncPlay: app foregrounded, 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 Integration
|
||||
// ============================================================================
|
||||
|
||||
const setPlayerControls = useCallback(
|
||||
(controls: PlayerControls | null) => {
|
||||
manager?.setPlayerControls(controls);
|
||||
playbackCoreRef.current?.setPlayerControls(controls);
|
||||
},
|
||||
[manager],
|
||||
);
|
||||
|
||||
const notifyReady = useCallback(() => {
|
||||
console.log("SyncPlay: notifyReady called");
|
||||
playbackCoreRef.current?.onReady();
|
||||
}, []);
|
||||
|
||||
const notifyBuffering = useCallback(() => {
|
||||
console.log("SyncPlay: notifyBuffering called");
|
||||
playbackCoreRef.current?.onBuffering();
|
||||
}, []);
|
||||
|
||||
// ============================================================================
|
||||
// Stats
|
||||
// ============================================================================
|
||||
|
||||
const getStats = useCallback((): SyncPlayStats => {
|
||||
return (
|
||||
manager?.getStats() ?? {
|
||||
timeSyncDevice: "None",
|
||||
timeSyncOffset: "0.00",
|
||||
playbackDiff: "0.00",
|
||||
syncMethod: "None",
|
||||
}
|
||||
);
|
||||
}, [manager]);
|
||||
|
||||
// ============================================================================
|
||||
// Context Value
|
||||
// ============================================================================
|
||||
|
||||
const contextValue: SyncPlayContextValue = useMemo(
|
||||
() => ({
|
||||
isEnabled,
|
||||
isReady,
|
||||
groupInfo,
|
||||
canJoinGroups,
|
||||
canCreateGroups,
|
||||
joinGroup,
|
||||
createGroup,
|
||||
leaveGroup,
|
||||
getGroups,
|
||||
controller: controllerRef.current,
|
||||
setPlayerControls,
|
||||
notifyReady,
|
||||
notifyBuffering,
|
||||
getStats,
|
||||
osdAction,
|
||||
isSyncing,
|
||||
syncMethod,
|
||||
pendingPlaybackCommand,
|
||||
}),
|
||||
[
|
||||
isEnabled,
|
||||
isReady,
|
||||
groupInfo,
|
||||
canJoinGroups,
|
||||
canCreateGroups,
|
||||
joinGroup,
|
||||
createGroup,
|
||||
leaveGroup,
|
||||
getGroups,
|
||||
setPlayerControls,
|
||||
notifyReady,
|
||||
notifyBuffering,
|
||||
getStats,
|
||||
osdAction,
|
||||
isSyncing,
|
||||
syncMethod,
|
||||
pendingPlaybackCommand,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<SyncPlayContext.Provider value={contextValue}>
|
||||
{children}
|
||||
</SyncPlayContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Hooks
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Hook to access SyncPlay state and actions
|
||||
*/
|
||||
export function useSyncPlay(): SyncPlayContextValue {
|
||||
const context = useContext(SyncPlayContext);
|
||||
if (!context) {
|
||||
throw new Error("useSyncPlay must be used within a SyncPlayProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to access the SyncPlay controller
|
||||
*/
|
||||
export function useSyncPlayController(): SyncPlayController | null {
|
||||
const { controller } = useSyncPlay();
|
||||
return controller;
|
||||
}
|
||||
Reference in New Issue
Block a user