diff --git a/app/(auth)/player/direct-player.tsx b/app/(auth)/player/direct-player.tsx
index b6598537c..e9591e98c 100644
--- a/app/(auth)/player/direct-player.tsx
+++ b/app/(auth)/player/direct-player.tsx
@@ -153,7 +153,6 @@ export default function DirectPlayerPage() {
isEnabled: isSyncPlayEnabled,
controller: syncPlayController,
setPlayerControls,
- notifyReady,
notifyBuffering,
} = syncPlay;
@@ -484,18 +483,10 @@ export default function DirectPlayerPage() {
}
const isLocallyReady = isVideoLoaded && !isBuffering;
- if (isLocallyReady) {
- notifyReady();
- } else {
- notifyBuffering();
- }
- }, [
- isSyncPlayEnabled,
- isVideoLoaded,
- isBuffering,
- notifyReady,
- notifyBuffering,
- ]);
+ // notifyBuffering routes through the debouncer in PlaybackCore so
+ // re-renders during a stall don't spam the server.
+ notifyBuffering(!isLocallyReady);
+ }, [isSyncPlayEnabled, isVideoLoaded, isBuffering, notifyBuffering]);
// SyncPlay: Pause playback when group is waiting
useEffect(() => {
@@ -982,10 +973,11 @@ export default function DirectPlayerPage() {
const seek = useCallback(
(position: number) => {
- // Route through SyncPlay when active
+ // Route through SyncPlay when active. `position` is in ms; the
+ // controller takes ticks (1 ms = 10000 ticks).
if (isSyncPlayEnabled && syncPlayController) {
console.log("SyncPlay: seek requested via SyncPlay", position);
- syncPlayController.seekMs(position);
+ syncPlayController.seek(Math.round(position * 10000));
return;
}
diff --git a/components/syncplay/SyncPlayIndicator.tsx b/components/syncplay/SyncPlayIndicator.tsx
deleted file mode 100644
index 00f0c8939..000000000
--- a/components/syncplay/SyncPlayIndicator.tsx
+++ /dev/null
@@ -1,165 +0,0 @@
-/**
- * SyncPlayIndicator
- *
- * Visual indicator shown during SyncPlay operations.
- * Only appears when user's stream is ready but waiting for other group members.
- *
- * Key principle: SyncPlay indicator = "You're ready, waiting on others"
- */
-
-import { Ionicons } from "@expo/vector-icons";
-import { useEffect } from "react";
-import { useTranslation } from "react-i18next";
-import { StyleSheet, View } from "react-native";
-import Animated, {
- Easing,
- useAnimatedStyle,
- useSharedValue,
- withRepeat,
- withTiming,
-} from "react-native-reanimated";
-import { Text } from "@/components/common/Text";
-
-// SyncPlay cyan color (matches Jellyfin-web)
-const SYNC_PLAY_COLOR = "#00a4dc";
-
-interface SyncPlayIndicatorProps {
- /**
- * Whether the indicator should be visible.
- * Should only be true when:
- * 1. User's stream has loaded
- * 2. Waiting for other group members
- */
- visible: boolean;
-
- /**
- * Optional message to display
- */
- message?: string;
-}
-
-export function SyncPlayIndicator({
- visible,
- message,
-}: SyncPlayIndicatorProps) {
- const { t } = useTranslation();
- const displayMessage = message ?? t("syncplay.waiting_for_group");
- const opacity = useSharedValue(0);
- const scale = useSharedValue(1);
-
- useEffect(() => {
- if (visible) {
- opacity.value = withTiming(1, { duration: 200 });
- scale.value = withRepeat(
- withTiming(1.15, {
- duration: 800,
- easing: Easing.inOut(Easing.ease),
- }),
- -1,
- true,
- );
- } else {
- opacity.value = withTiming(0, { duration: 200 });
- scale.value = 1;
- }
- }, [visible, opacity, scale]);
-
- const containerStyle = useAnimatedStyle(() => ({
- opacity: opacity.value,
- }));
-
- const pulseStyle = useAnimatedStyle(() => ({
- transform: [{ scale: scale.value }],
- }));
-
- if (!visible) {
- return null;
- }
-
- return (
-
-
- {/* Pulsing icon container */}
-
-
-
-
-
-
- {/* Message */}
- {displayMessage}
-
- {/* SyncPlay badge */}
-
-
- SyncPlay
-
-
-
- );
-}
-
-const styles = StyleSheet.create({
- container: {
- ...StyleSheet.absoluteFill,
- justifyContent: "center",
- alignItems: "center",
- backgroundColor: "rgba(0, 0, 0, 0.7)",
- zIndex: 100,
- },
- content: {
- alignItems: "center",
- },
- iconContainer: {
- marginBottom: 16,
- },
- iconCircle: {
- width: 64,
- height: 64,
- borderRadius: 32,
- backgroundColor: SYNC_PLAY_COLOR,
- justifyContent: "center",
- alignItems: "center",
- // Glow effect
- shadowColor: SYNC_PLAY_COLOR,
- shadowOffset: { width: 0, height: 0 },
- shadowOpacity: 0.6,
- shadowRadius: 16,
- elevation: 8,
- },
- message: {
- color: "white",
- fontSize: 16,
- fontWeight: "500",
- marginBottom: 8,
- },
- badge: {
- flexDirection: "row",
- alignItems: "center",
- backgroundColor: "rgba(0, 164, 220, 0.2)",
- paddingHorizontal: 12,
- paddingVertical: 6,
- borderRadius: 16,
- borderWidth: 1,
- borderColor: SYNC_PLAY_COLOR,
- },
- badgeText: {
- color: SYNC_PLAY_COLOR,
- fontSize: 12,
- fontWeight: "600",
- marginLeft: 4,
- },
-});
-
-/**
- * Hook-compatible version that reads SyncPlay state directly
- */
-export function useSyncPlayIndicatorState(
- isLocalReady: boolean,
- isGroupWaiting: boolean,
-): boolean {
- // Show indicator only when:
- // 1. User's local stream has loaded (isLocalReady)
- // 2. Group is still waiting for others (isGroupWaiting)
- return isLocalReady && isGroupWaiting;
-}
diff --git a/components/syncplay/index.ts b/components/syncplay/index.ts
index 234abf3ec..5ea42ee15 100644
--- a/components/syncplay/index.ts
+++ b/components/syncplay/index.ts
@@ -4,7 +4,4 @@
export { GroupSelectionMenu } from "./GroupSelectionMenu";
export { SyncPlayButton } from "./SyncPlayButton";
-export {
- SyncPlayIndicator,
- useSyncPlayIndicatorState,
-} from "./SyncPlayIndicator";
+export { SyncPlaySpinner } from "./SyncPlaySpinner";
diff --git a/providers/SyncPlay/Controller.ts b/providers/SyncPlay/Controller.ts
index 660ef75f0..a773892db 100644
--- a/providers/SyncPlay/Controller.ts
+++ b/providers/SyncPlay/Controller.ts
@@ -1,402 +1,165 @@
/**
- * SyncPlay Controller
+ * SyncPlay Controller — public playback API exposed to consumers.
*
- * Exposes SyncPlay API calls to external modules.
- * Provides methods for controlling synchronized playback.
- *
- * Based on jellyfin-web's Controller.js
+ * Methods are fire-and-forget by design: SyncPlay HTTP responses don't
+ * carry useful info (the real state arrives via WebSocket broadcast).
+ * Wrap calls in try/catch so transient network errors don't reach the UI.
*/
-import type { Api } from "@jellyfin/sdk";
-import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
+import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { getSyncPlayApi } from "@jellyfin/sdk/lib/utils/api";
+import type { SyncPlayManager } from "./Manager";
import {
getItemsForPlayback,
- msToTicks,
+ type TranslateOptions,
translateItemsForPlayback,
-} from "./Helper";
-import type { SyncPlayManager } from "./Manager";
-import type { QueueCore } from "./QueueCore";
-import type { GroupRepeatMode, GroupShuffleMode, PlayOptions } from "./types";
+} from "./transport/queueTranslation";
-/**
- * SyncPlay Controller - External API for controlling SyncPlay
- */
-export class SyncPlayController {
- private api: Api;
- private manager: SyncPlayManager;
- private queueCore: QueueCore;
+export interface PlayOptions extends TranslateOptions {
+ items?: BaseItemDto[];
+ ids?: string[];
+ startIndex?: number;
+ startPositionTicks?: number;
+}
- constructor(api: Api, manager: SyncPlayManager, queueCore: QueueCore) {
- this.api = api;
+export class Controller {
+ private manager!: SyncPlayManager;
+
+ init(manager: SyncPlayManager): void {
this.manager = manager;
- this.queueCore = queueCore;
}
- // ============================================================================
- // Playback Control
- // ============================================================================
-
- /**
- * Toggle play/pause
- */
+ /** Toggle play/pause for the whole group. */
playPause(): void {
- // Use server group state (with pending in-flight command preferred) as
- // the source of truth. The local player can lag the group by hundreds of
- // ms while a scheduled command is pending, so reading `playerControls`
- // here would cause rapid taps to send duplicate / wrong commands and
- // desync other clients.
- const state = this.manager.getEffectivePlayState();
- console.log(`SyncPlay Controller: playPause - effectiveState=${state}`);
- if (state === "Playing") {
- console.log("SyncPlay Controller: requesting PAUSE");
+ if (this.manager.isPlaying()) {
this.pause();
} else {
- console.log("SyncPlay Controller: requesting UNPAUSE");
this.unpause();
}
}
- /**
- * Request unpause (play)
- */
- async unpause(): Promise {
- // Drop duplicate rapid taps while a previous request is still in flight
- // (cleared when the server broadcasts back via SyncPlayCommand, or after
- // a safety timeout).
- if (this.manager.getPendingPlaybackCommand() === "Unpause") {
- console.debug("SyncPlay Controller: unpause ignored — already pending");
- return;
- }
+ /** Resume the group's playback. */
+ unpause(): void {
this.manager.markPendingPlaybackCommand("Unpause");
try {
- console.log("SyncPlay Controller: sending syncPlayUnpause to server");
- const syncPlayApi = getSyncPlayApi(this.api);
- await syncPlayApi.syncPlayUnpause();
- console.log("SyncPlay Controller: syncPlayUnpause sent successfully");
+ getSyncPlayApi(this.manager.getApiClient()).syncPlayUnpause();
} catch (error) {
- console.error("SyncPlay Controller: failed to unpause", error);
+ console.error("SyncPlay Controller.unpause failed", error);
}
}
- /**
- * Request pause
- */
- async pause(): Promise {
- if (this.manager.getPendingPlaybackCommand() === "Pause") {
- console.debug("SyncPlay Controller: pause ignored — already pending");
- return;
- }
+ /** Pause the group's playback. */
+ pause(): void {
this.manager.markPendingPlaybackCommand("Pause");
try {
- console.log("SyncPlay Controller: sending syncPlayPause to server");
- const syncPlayApi = getSyncPlayApi(this.api);
- await syncPlayApi.syncPlayPause();
- console.log("SyncPlay Controller: syncPlayPause sent successfully");
-
- // Also pause locally for immediate feedback
- this.manager.getPlayerControls()?.pause();
+ getSyncPlayApi(this.manager.getApiClient()).syncPlayPause();
} catch (error) {
- console.error("SyncPlay Controller: failed to pause", error);
+ console.error("SyncPlay Controller.pause failed", error);
}
+ // Pause locally too so the user sees instant feedback.
+ this.manager.getPlayerWrapper().localPause();
}
- /**
- * Request seek to position
- */
- async seek(positionTicks: number): Promise {
+ /** Seek the group's playback. `positionTicks` is in ticks (1ms = 10000 ticks). */
+ seek(positionTicks: number): void {
try {
- console.log(
- `SyncPlay Controller: sending syncPlaySeek to ${positionTicks} ticks`,
- );
- const syncPlayApi = getSyncPlayApi(this.api);
- await syncPlayApi.syncPlaySeek({
- seekRequestDto: {
- PositionTicks: positionTicks,
- },
+ getSyncPlayApi(this.manager.getApiClient()).syncPlaySeek({
+ seekRequestDto: { PositionTicks: positionTicks },
});
- console.log("SyncPlay Controller: syncPlaySeek sent successfully");
-
- // Also seek locally for immediate feedback
- const positionMs = positionTicks / 10000;
- this.manager.getPlayerControls()?.seekTo(positionMs);
} catch (error) {
- console.error("SyncPlay Controller: failed to seek", error);
+ console.error("SyncPlay Controller.seek failed", error);
}
}
/**
- * Request seek to position in milliseconds
- */
- async seekMs(positionMs: number): Promise {
- console.log(`SyncPlay Controller: seekMs to ${positionMs}ms`);
- await this.seek(msToTicks(positionMs));
- }
-
- /**
- * Request stop
- */
- async stop(): Promise {
- try {
- const syncPlayApi = getSyncPlayApi(this.api);
- await syncPlayApi.syncPlayStop();
- } catch (error) {
- console.error("SyncPlay Controller: failed to stop", error);
- }
- }
-
- // ============================================================================
- // Queue Control
- // ============================================================================
-
- /**
- * Start playback with a new SyncPlay group queue.
+ * Start playback in the group. Expands containers (Series, Season,
+ * BoxSet, Playlist, single Episode w/ autoplay) into the real
+ * playable queue before broadcasting.
*
- * Mirrors jellyfin-web's `Controller.play`:
- *
- * - If the caller passed full `items` objects, translate them directly
- * (Series → episodes, BoxSet → children, etc.).
- * - Otherwise fetch the items by ID first (`getItemsForPlayback`), then
- * translate.
- * - Send the translated, real playable IDs to
- * `syncPlaySetNewQueue` so every group member receives a queue of
- * playable items — not container IDs (Series / Season / BoxSet) that
- * receivers like jellyfin-web silently drop.
- *
- * `startIndex` / `startPositionTicks` default to 0, same as jellyfin-web.
+ * Resolves once the SetNewQueue request completes; the server then
+ * broadcasts a PlayQueue update and Play command to every member.
*/
async play(options: PlayOptions): Promise {
- const { items, ids, startIndex = 0, startPositionTicks = 0 } = options;
+ const api = this.manager.getApiClient();
- if ((!ids || ids.length === 0) && (!items || items.length === 0)) {
- console.error("SyncPlay Controller: no items or ids to play");
- return;
- }
-
- try {
- // Step 1 — resolve to a list of BaseItemDto. Prefer the caller-supplied
- // items (no extra round trip), fall back to a fetch by IDs.
- const sourceItems: BaseItemDto[] =
- items && items.length > 0
- ? items
- : await getItemsForPlayback(this.api, ids ?? []);
-
- if (!sourceItems.length) {
- console.error(
- "SyncPlay Controller: getItemsForPlayback returned no items",
- );
- return;
- }
-
- // Step 2 — expand Series / Season / BoxSet / Playlist / single-Episode
- // into the real playable queue.
- const translated = await translateItemsForPlayback(
- this.api,
- sourceItems,
- { ids, queryOptions: {} },
- );
-
- const queueIds = translated
+ const sendPlayRequest = async (items: BaseItemDto[]) => {
+ const queue = items
.map((item) => item.Id)
- .filter((id): id is string => !!id);
-
- if (!queueIds.length) {
- console.error(
- "SyncPlay Controller: translateItemsForPlayback produced empty queue",
- );
- return;
- }
-
- console.log(
- `SyncPlay Controller: sending syncPlaySetNewQueue (${queueIds.length} item${queueIds.length === 1 ? "" : "s"}, startIndex=${startIndex})`,
- );
-
- // Step 3 — broadcast the resolved queue to the group.
- const syncPlayApi = getSyncPlayApi(this.api);
- await syncPlayApi.syncPlaySetNewQueue({
+ .filter((id): id is string => typeof id === "string");
+ await getSyncPlayApi(api).syncPlaySetNewQueue({
playRequestDto: {
- PlayingQueue: queueIds,
- PlayingItemPosition: startIndex,
- StartPositionTicks: startPositionTicks,
+ PlayingQueue: queue,
+ PlayingItemPosition: options.startIndex ?? 0,
+ StartPositionTicks: options.startPositionTicks ?? 0,
},
});
+ };
+
+ try {
+ const sourceItems = options.items
+ ? options.items
+ : await getItemsForPlayback(api, options.ids ?? []);
+ const items = await translateItemsForPlayback(api, sourceItems, options);
+ await sendPlayRequest(items);
} catch (error) {
- // Surface the server response body when available — a SetNewQueue
- // that 4xx's silently is the most common "why didn't the other
- // client start?" cause. Without the body we'd just see a generic
- // axios error and have no way to tell whether it was a permission
- // problem, an unknown item ID, or the server rejecting the queue.
- const err = error as {
- response?: { status?: number; data?: unknown };
- message?: string;
- };
- console.error("SyncPlay Controller: failed to set new queue", {
- status: err?.response?.status,
- data: err?.response?.data,
- message: err?.message,
- });
+ console.error("SyncPlay Controller.play failed", error);
+ throw error;
}
}
- /**
- * Set current item in playlist
- */
- async setCurrentPlaylistItem(playlistItemId: string): Promise {
+ /** Stop the group's playback. */
+ stop(): void {
try {
- const syncPlayApi = getSyncPlayApi(this.api);
- await syncPlayApi.syncPlaySetPlaylistItem({
- setPlaylistItemRequestDto: {
- PlaylistItemId: playlistItemId,
- },
- });
+ getSyncPlayApi(this.manager.getApiClient()).syncPlayStop();
} catch (error) {
- console.error("SyncPlay Controller: failed to set playlist item", error);
+ console.error("SyncPlay Controller.stop failed", error);
}
}
- /**
- * Play next item
- */
- async nextItem(): Promise {
+ /** Jump to the next item in the group's queue. */
+ nextItem(): void {
try {
- const syncPlayApi = getSyncPlayApi(this.api);
- await syncPlayApi.syncPlayNextItem({
+ getSyncPlayApi(this.manager.getApiClient()).syncPlayNextItem({
nextItemRequestDto: {
- PlaylistItemId:
- this.queueCore.getCurrentPlaylistItemId() ?? undefined,
- },
+ PlaylistItemId: this.manager
+ .getQueueCore()
+ .getCurrentPlaylistItemId(),
+ } as unknown as Parameters<
+ ReturnType["syncPlayNextItem"]
+ >[0]["nextItemRequestDto"],
});
} catch (error) {
- console.error("SyncPlay Controller: failed to play next", error);
+ console.error("SyncPlay Controller.nextItem failed", error);
}
}
- /**
- * Play previous item
- */
- async previousItem(): Promise {
+ /** Jump to the previous item in the group's queue. */
+ previousItem(): void {
try {
- const syncPlayApi = getSyncPlayApi(this.api);
- await syncPlayApi.syncPlayPreviousItem({
+ getSyncPlayApi(this.manager.getApiClient()).syncPlayPreviousItem({
previousItemRequestDto: {
- PlaylistItemId:
- this.queueCore.getCurrentPlaylistItemId() ?? undefined,
- },
+ PlaylistItemId: this.manager
+ .getQueueCore()
+ .getCurrentPlaylistItemId(),
+ } as unknown as Parameters<
+ ReturnType["syncPlayPreviousItem"]
+ >[0]["previousItemRequestDto"],
});
} catch (error) {
- console.error("SyncPlay Controller: failed to play previous", error);
+ console.error("SyncPlay Controller.previousItem failed", error);
}
}
- /**
- * Add items to queue
- */
- async queue(
- itemIds: string[],
- mode: "Queue" | "QueueNext" = "Queue",
- ): Promise {
+ /** Jump to a specific item in the queue by playlist item id. */
+ setCurrentPlaylistItem(playlistItemId: string): void {
try {
- const syncPlayApi = getSyncPlayApi(this.api);
- await syncPlayApi.syncPlayQueue({
- queueRequestDto: {
- ItemIds: itemIds,
- Mode: mode,
- },
+ getSyncPlayApi(this.manager.getApiClient()).syncPlaySetPlaylistItem({
+ setPlaylistItemRequestDto: { PlaylistItemId: playlistItemId },
});
} catch (error) {
- console.error("SyncPlay Controller: failed to queue items", error);
+ console.error("SyncPlay Controller.setCurrentPlaylistItem failed", error);
}
}
-
- /**
- * Add items to play next
- */
- async queueNext(itemIds: string[]): Promise {
- await this.queue(itemIds, "QueueNext");
- }
-
- /**
- * Remove items from playlist
- */
- async removeFromPlaylist(playlistItemIds: string[]): Promise {
- try {
- const syncPlayApi = getSyncPlayApi(this.api);
- await syncPlayApi.syncPlayRemoveFromPlaylist({
- removeFromPlaylistRequestDto: {
- PlaylistItemIds: playlistItemIds,
- },
- });
- } catch (error) {
- console.error(
- "SyncPlay Controller: failed to remove from playlist",
- error,
- );
- }
- }
-
- /**
- * Move item in playlist
- */
- async movePlaylistItem(
- playlistItemId: string,
- newIndex: number,
- ): Promise {
- try {
- const syncPlayApi = getSyncPlayApi(this.api);
- await syncPlayApi.syncPlayMovePlaylistItem({
- movePlaylistItemRequestDto: {
- PlaylistItemId: playlistItemId,
- NewIndex: newIndex,
- },
- });
- } catch (error) {
- console.error("SyncPlay Controller: failed to move playlist item", error);
- }
- }
-
- // ============================================================================
- // Playback Settings
- // ============================================================================
-
- /**
- * Set repeat mode
- */
- async setRepeatMode(mode: GroupRepeatMode): Promise {
- try {
- const syncPlayApi = getSyncPlayApi(this.api);
- await syncPlayApi.syncPlaySetRepeatMode({
- setRepeatModeRequestDto: {
- Mode: mode,
- },
- });
- } catch (error) {
- console.error("SyncPlay Controller: failed to set repeat mode", error);
- }
- }
-
- /**
- * Set shuffle mode
- */
- async setShuffleMode(mode: GroupShuffleMode): Promise {
- try {
- const syncPlayApi = getSyncPlayApi(this.api);
- await syncPlayApi.syncPlaySetShuffleMode({
- setShuffleModeRequestDto: {
- Mode: mode,
- },
- });
- } catch (error) {
- console.error("SyncPlay Controller: failed to set shuffle mode", error);
- }
- }
-
- /**
- * Toggle shuffle mode
- */
- async toggleShuffleMode(): Promise {
- const currentMode = this.queueCore.getShuffleMode();
- const newMode: GroupShuffleMode =
- currentMode === "Sorted" ? "Shuffle" : "Sorted";
- await this.setShuffleMode(newMode);
- }
}
+
+export default Controller;
diff --git a/providers/SyncPlay/EventEmitter.ts b/providers/SyncPlay/EventEmitter.ts
new file mode 100644
index 000000000..4c0615b83
--- /dev/null
+++ b/providers/SyncPlay/EventEmitter.ts
@@ -0,0 +1,93 @@
+/**
+ * Per-instance event emitter — replaces jellyfin-web's global `Events.trigger`
+ * bus. Listeners that throw are caught and logged so one bad listener can't
+ * break the rest.
+ */
+
+import { WaitForEventDefaultTimeout } from "./constants";
+
+export class EventEmitter {
+ private listeners: Map void>> = new Map();
+
+ on(event: string, callback: (...args: unknown[]) => void): void {
+ if (!this.listeners.has(event)) {
+ this.listeners.set(event, new Set());
+ }
+ this.listeners.get(event)!.add(callback);
+ }
+
+ off(event: string, callback: (...args: unknown[]) => void): void {
+ this.listeners.get(event)?.delete(callback);
+ }
+
+ emit(event: string, ...args: unknown[]): void {
+ this.listeners.get(event)?.forEach((callback) => {
+ try {
+ callback(...args);
+ } catch (error) {
+ console.error(
+ `SyncPlay EventEmitter: handler for "${event}" threw`,
+ error,
+ );
+ }
+ });
+ }
+
+ removeAllListeners(event?: string): void {
+ if (event) {
+ this.listeners.delete(event);
+ } else {
+ this.listeners.clear();
+ }
+ }
+}
+
+/**
+ * Resolve on the next emission of `event`, or reject after `timeoutMs`
+ * (or any event in `rejectEventTypes`). Cleans up every listener.
+ */
+export function waitForEventOnce(
+ emitter: EventEmitter,
+ event: string,
+ timeoutMs: number = WaitForEventDefaultTimeout,
+ rejectEventTypes?: string[],
+): Promise {
+ return new Promise((resolve, reject) => {
+ let timer: ReturnType | null = null;
+
+ const clearAll = () => {
+ emitter.off(event, handler);
+ if (timer) clearTimeout(timer);
+ if (Array.isArray(rejectEventTypes)) {
+ for (const eventName of rejectEventTypes) {
+ emitter.off(eventName, rejectCallback);
+ }
+ }
+ };
+
+ const handler = (...args: unknown[]) => {
+ clearAll();
+ resolve(args);
+ };
+
+ const rejectCallback = (...args: unknown[]) => {
+ clearAll();
+ reject(args[0] ?? new Error("rejected"));
+ };
+
+ if (timeoutMs) {
+ timer = setTimeout(() => {
+ clearAll();
+ reject(new Error("Timed out."));
+ }, timeoutMs);
+ }
+
+ emitter.on(event, handler);
+
+ if (Array.isArray(rejectEventTypes)) {
+ for (const eventName of rejectEventTypes) {
+ emitter.on(eventName, rejectCallback);
+ }
+ }
+ });
+}
diff --git a/providers/SyncPlay/Helper.ts b/providers/SyncPlay/Helper.ts
deleted file mode 100644
index ca8094543..000000000
--- a/providers/SyncPlay/Helper.ts
+++ /dev/null
@@ -1,448 +0,0 @@
-/**
- * SyncPlay Helper
- *
- * Utility functions for SyncPlay functionality.
- * Based on jellyfin-web's Helper.js
- */
-
-import type { Api } from "@jellyfin/sdk";
-import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
-import {
- getItemsApi,
- getTvShowsApi,
- getUserApi,
- getUserLibraryApi,
-} from "@jellyfin/sdk/lib/utils/api";
-import { TicksPerMillisecond } from "./types";
-
-/**
- * Wait for an event to be triggered, with optional timeout.
- */
-export function waitForEvent(
- eventEmitter: {
- addEventListener: (event: string, handler: (data: T) => void) => void;
- removeEventListener: (event: string, handler: (data: T) => void) => void;
- },
- eventType: string,
- timeout?: number,
- rejectEvents?: string[],
-): Promise {
- return new Promise((resolve, reject) => {
- let timeoutId: ReturnType | undefined;
-
- const cleanup = () => {
- eventEmitter.removeEventListener(eventType, handler);
- if (timeoutId) {
- clearTimeout(timeoutId);
- }
- if (rejectEvents) {
- for (const event of rejectEvents) {
- eventEmitter.removeEventListener(event, rejectHandler);
- }
- }
- };
-
- const handler = (data: T) => {
- cleanup();
- resolve(data);
- };
-
- const rejectHandler = () => {
- cleanup();
- reject(new Error("Rejected by event"));
- };
-
- eventEmitter.addEventListener(eventType, handler);
-
- if (rejectEvents) {
- for (const event of rejectEvents) {
- eventEmitter.addEventListener(event, rejectHandler);
- }
- }
-
- if (timeout) {
- timeoutId = setTimeout(() => {
- cleanup();
- reject(new Error("Timed out waiting for event"));
- }, timeout);
- }
- });
-}
-
-/**
- * Wait for a promise-based callback, with timeout.
- */
-export function waitWithTimeout(
- promise: Promise,
- timeout: number,
-): Promise {
- return new Promise((resolve, reject) => {
- const timeoutId = setTimeout(() => {
- reject(new Error("Operation timed out"));
- }, timeout);
-
- promise
- .then((result) => {
- clearTimeout(timeoutId);
- resolve(result);
- })
- .catch((error) => {
- clearTimeout(timeoutId);
- reject(error);
- });
- });
-}
-
-/**
- * Convert ticks to milliseconds.
- */
-export function ticksToMs(ticks: number): number {
- return ticks / TicksPerMillisecond;
-}
-
-/**
- * Convert milliseconds to ticks.
- */
-export function msToTicks(ms: number): number {
- return Math.round(ms * TicksPerMillisecond);
-}
-
-/**
- * Convert a GUID string to standard format.
- */
-export function stringToGuid(input: string): string {
- return input.replace(
- /([0-z]{8})([0-z]{4})([0-z]{4})([0-z]{4})([0-z]{12})/,
- "$1-$2-$3-$4-$5",
- );
-}
-
-/**
- * Parse a date string to Date object.
- */
-export function parseDate(dateString: string): Date {
- return new Date(dateString);
-}
-
-/**
- * Get current time as ISO string for API requests.
- */
-export function nowAsIsoString(): string {
- return new Date().toISOString();
-}
-
-/**
- * Clamp a value between min and max.
- */
-export function clamp(value: number, min: number, max: number): number {
- return Math.max(min, Math.min(max, value));
-}
-
-/**
- * Simple event emitter for internal use.
- */
-export class EventEmitter {
- private listeners: Map void>> = new Map();
-
- on(event: string, callback: (...args: unknown[]) => void): void {
- if (!this.listeners.has(event)) {
- this.listeners.set(event, new Set());
- }
- this.listeners.get(event)!.add(callback);
- }
-
- off(event: string, callback: (...args: unknown[]) => void): void {
- this.listeners.get(event)?.delete(callback);
- }
-
- emit(event: string, ...args: unknown[]): void {
- this.listeners.get(event)?.forEach((callback) => {
- try {
- callback(...args);
- } catch (error) {
- console.error(`Error in event handler for ${event}:`, error);
- }
- });
- }
-
- removeAllListeners(event?: string): void {
- if (event) {
- this.listeners.delete(event);
- } else {
- this.listeners.clear();
- }
- }
-}
-
-/**
- * Wait for the next emission of an event on our internal {@link EventEmitter},
- * or reject after `timeoutMs`. Auto-cleans the listener.
- */
-export function waitForOwnEvent(
- emitter: EventEmitter,
- event: string,
- timeoutMs = 5000,
-): Promise {
- return new Promise((resolve, reject) => {
- const handler = (...args: unknown[]) => {
- clearTimeout(timer);
- emitter.off(event, handler);
- resolve(args);
- };
- const timer = setTimeout(() => {
- emitter.off(event, handler);
- reject(new Error(`Timed out waiting for "${event}"`));
- }, timeoutMs);
- emitter.on(event, handler);
- });
-}
-
-// ============================================================================
-// Item fetching / queue translation
-//
-// Faithful port of jellyfin-web's `getItemsForPlayback` and
-// `translateItemsForPlayback` from `src/plugins/syncPlay/core/Helper.js`.
-//
-// Why this matters for SyncPlay:
-// - The server takes the queue we send via `syncPlaySetNewQueue` and
-// broadcasts it verbatim to every group member. If we send a Series /
-// Season / BoxSet ID, every receiver tries to load that container as a
-// playable item, which silently fails on jellyfin-web (it never opens
-// the player). Sending an Episode ID without sibling expansion breaks
-// next-episode auto-advance for everyone in the group.
-// - jellyfin-web's `playbackManager.play` runs the same translation
-// locally; SyncPlay's `Controller.play` runs it before the SetNewQueue
-// request so the broadcast carries real playable item IDs.
-// - We replicate the same translation here so a mobile sender produces
-// the same broadcast a jellyfin-web sender would.
-// ============================================================================
-
-/** Options bag accepted by `translateItemsForPlayback`. */
-export interface TranslateOptions {
- ids?: string[];
- shuffle?: boolean;
- queryOptions?: Record;
-}
-
-/** Fields jellyfin-web requests for any playback queue. */
-const PLAYBACK_FIELDS = ["Chapters", "Trickplay"] as const;
-
-/** Resolve the current user. Cached only for the duration of one call. */
-async function getCurrentUser(api: Api) {
- const user = (await getUserApi(api).getCurrentUser()).data;
- if (!user?.Id) {
- throw new Error("SyncPlay Helper: no authenticated user");
- }
- return user;
-}
-
-/**
- * Generic `getItems` wrapper with the playback defaults jellyfin-web uses
- * (`Chapters,Trickplay` fields, `ExcludeLocationTypes: Virtual`,
- * `CollapseBoxSetItems: false`, `EnableTotalRecordCount: false`, `Limit: 300`).
- *
- * Callers pass camelCase params straight to the SDK — no PascalCase shim.
- */
-async function queryItems(
- api: Api,
- userId: string,
- params: Record,
-): Promise {
- const res = await getItemsApi(api).getItems({
- limit: 300,
- fields: PLAYBACK_FIELDS as unknown as never,
- excludeLocationTypes: ["Virtual"] as unknown as never,
- enableTotalRecordCount: false,
- collapseBoxSetItems: false,
- ...params,
- userId,
- });
- return res.data.Items ?? [];
-}
-
-/**
- * Recursive "fetch children/tracks under X" — the shape MusicArtist /
- * MusicGenre / Photo / PhotoAlbum / IsFolder all share.
- */
-function fetchSiblings(
- api: Api,
- userId: string,
- params: Record,
-): Promise {
- return queryItems(api, userId, {
- filters: ["IsNotFolder"],
- recursive: true,
- ...params,
- });
-}
-
-/**
- * Resolve item IDs into full `BaseItemDto`s.
- * Mirrors jellyfin-web's `Helper.getItemsForPlayback`:
- * - single ID → `getUserLibraryApi.getItem` (cheap)
- * - multi ID → `getItemsApi.getItems` with playback defaults
- */
-export async function getItemsForPlayback(
- api: Api,
- ids: string[],
-): Promise {
- if (!ids.length) return [];
- const userId = (await getCurrentUser(api)).Id as string;
- if (ids.length === 1) {
- const res = await getUserLibraryApi(api).getItem({
- userId,
- itemId: ids[0],
- });
- return res.data ? [res.data] : [];
- }
- return queryItems(api, userId, { ids });
-}
-
-/**
- * Expand a "first item" into a real playable queue.
- *
- * Mirrors jellyfin-web's `Helper.translateItemsForPlayback`:
- * - Program → channel items
- * - Playlist → playlist children
- * - MusicArtist → artist tracks
- * - MusicGenre → genre tracks
- * - Photo / PhotoAlbum → sibling photos
- * - IsFolder (Series, Season, BoxSet, MusicAlbum, ...) → recursive descendants
- * - single Episode w/ `EnableNextEpisodeAutoPlay` → remaining series episodes
- * - anything else → passthrough (Movies, Audio, single Episodes when autoplay off)
- *
- * Preserves the original `options.ids` order so the receiver sees the same
- * queue order the sender intended.
- */
-export async function translateItemsForPlayback(
- api: Api,
- items: BaseItemDto[],
- options: TranslateOptions = {},
-): Promise {
- if (!items.length) return [];
-
- const workingItems =
- items.length > 1 && options.ids
- ? [...items].sort(
- (a, b) =>
- (options.ids ?? []).indexOf(a.Id ?? "") -
- (options.ids ?? []).indexOf(b.Id ?? ""),
- )
- : items;
-
- const firstItem = workingItems[0];
- const defaultSortBy = options.shuffle ? "Random" : "SortName";
-
- // Program → channel's playable items. Doesn't need a user lookup.
- if (firstItem.Type === "Program" && firstItem.ChannelId) {
- return getItemsForPlayback(api, [firstItem.ChannelId]);
- }
-
- // Resolve the user once for every branch that follows. Saves 1-2 round
- // trips vs. each helper resolving independently.
- const user = await getCurrentUser(api);
- const userId = user.Id as string;
-
- if (firstItem.Type === "Playlist") {
- return queryItems(api, userId, {
- parentId: firstItem.Id,
- sortBy: options.shuffle ? ["Random"] : undefined,
- });
- }
-
- if (firstItem.Type === "MusicArtist") {
- return fetchSiblings(api, userId, {
- artistIds: firstItem.Id ? [firstItem.Id] : undefined,
- mediaTypes: ["Audio"],
- sortBy: options.shuffle
- ? ["Random"]
- : ["Album", "ParentIndexNumber", "IndexNumber", "SortName"],
- });
- }
-
- if (firstItem.MediaType === "Photo") {
- const siblings = await fetchSiblings(api, userId, {
- parentId: firstItem.ParentId,
- recursive: false,
- mediaTypes: ["Photo", "Video"],
- sortBy: [defaultSortBy],
- });
- // Re-anchor startIndex to the chosen photo, same as jellyfin-web.
- // SyncPlay doesn't currently consume startIndex from queryOptions,
- // but we keep parity for any future caller.
- if (siblings.length && options.queryOptions) {
- const idx = siblings.findIndex((i) => i.Id === firstItem.Id);
- if (idx >= 0) options.queryOptions.startIndex = idx;
- }
- return siblings;
- }
-
- if (firstItem.Type === "PhotoAlbum") {
- return fetchSiblings(api, userId, {
- parentId: firstItem.Id,
- recursive: false,
- mediaTypes: ["Photo", "Video"],
- sortBy: [defaultSortBy],
- limit: 1000,
- });
- }
-
- if (firstItem.Type === "MusicGenre") {
- return fetchSiblings(api, userId, {
- genreIds: firstItem.Id ? [firstItem.Id] : undefined,
- mediaTypes: ["Audio"],
- sortBy: [defaultSortBy],
- });
- }
-
- if (firstItem.IsFolder) {
- // Series, Season, BoxSet, MusicAlbum, etc. jellyfin-web only sets
- // SortBy for shuffle or BoxSet — everything else inherits server-side
- // sort order (typically index/premiere date).
- const sortBy = options.shuffle
- ? ["Random"]
- : firstItem.Type === "BoxSet"
- ? ["SortName"]
- : undefined;
- return fetchSiblings(api, userId, {
- parentId: firstItem.Id,
- mediaTypes: ["Audio", "Video"],
- sortBy,
- });
- }
-
- if (firstItem.Type === "Episode" && workingItems.length === 1) {
- // Single-episode auto-next: drop everything before this episode so
- // playback starts here and auto-advances through the rest of the
- // series. Gated on the user's `EnableNextEpisodeAutoPlay` like
- // jellyfin-web does.
- if (!user.Configuration?.EnableNextEpisodeAutoPlay || !firstItem.SeriesId) {
- return workingItems;
- }
- try {
- const res = await getTvShowsApi(api).getEpisodes({
- seriesId: firstItem.SeriesId,
- userId,
- isMissing: false,
- fields: PLAYBACK_FIELDS as unknown as never,
- });
- const all = res.data.Items ?? [];
- const foundIdx = Math.max(
- 0,
- all.findIndex((e) => e.Id === firstItem.Id),
- );
- return all.slice(foundIdx);
- } catch (error) {
- // Don't block playback on a translation failure — fall back to the
- // single-item queue the caller already supplied.
- console.warn(
- "SyncPlay Helper: Episode translation failed, falling back to single item",
- error,
- );
- return workingItems;
- }
- }
-
- // Everything else (Movie, Audio, ...) plays as-is.
- return workingItems;
-}
diff --git a/providers/SyncPlay/Manager.ts b/providers/SyncPlay/Manager.ts
index 3fd6696e4..1a1f8aaf4 100644
--- a/providers/SyncPlay/Manager.ts
+++ b/providers/SyncPlay/Manager.ts
@@ -1,797 +1,381 @@
/**
- * SyncPlay Manager
+ * SyncPlayManager — central orchestrator for a SyncPlay session.
*
- * Main orchestrator for SyncPlay functionality.
- * Manages group state, coordinates time sync, playback, and queue.
+ * Owns the three "cores" (TimeSync, PlaybackCore, QueueCore) and the
+ * PlayerWrapper, and routes WebSocket events between them.
*
- * Based on jellyfin-web's Manager.js
+ * Lifecycle:
+ * constructor → init() → (joinGroup → group-state-change "Idle"+) →
+ * group-state-change "Playing" → group-state-change "Paused" → ...
+ * → (leaveGroup) → destroy()
+ *
+ * Events emitted (provider listens):
+ * - `group-info-update` `(GroupInfoDto | null)`
+ * - `group-state-change` `(state: string, oldState: string)`
+ * - `enabled` `(isEnabled: boolean)`
+ * - `play-state-change` `(isFollowing: boolean)`
+ * - `playbackstart` / `playbackerror` — from PlayerWrapper hooks
+ * - `osd` `(action: SyncPlayOsdAction)`
+ * - `toast` `(messageKey: string)`
+ *
+ * The manager exposes a per-instance `EventEmitter` rather than upstream
+ * `Events.on(manager, ...)` — replaces the global Events bus pattern.
*/
import type { Api } from "@jellyfin/sdk";
-import { getSyncPlayApi } from "@jellyfin/sdk/lib/utils/api";
-import { toast } from "sonner-native";
-import i18n from "@/i18n";
-import { EventEmitter, msToTicks } from "./Helper";
-import { TimeSyncCore } from "./TimeSyncCore";
+import { Controller } from "./Controller";
+import { PlaybackCore } from "./cores/PlaybackCore";
+import { QueueCore } from "./cores/QueueCore";
+import { TimeSync } from "./cores/TimeSync";
+import { EventEmitter } from "./EventEmitter";
+import { PendingPlaybackTracker } from "./player/PendingPlaybackTracker";
+import { PlayerWrapper } from "./player/PlayerWrapper";
+import { reconcileToGroupOnAttach } from "./player/reconcileToGroupOnAttach";
import type {
GroupInfoDto,
+ GroupUpdate,
PlayerControls,
PlayQueueUpdate,
SendCommand,
- SyncPlayStats,
} from "./types";
-/**
- * SyncPlay Manager - Main orchestrator
- */
+/** Raw WebSocket message data shapes (already unwrapped by the hook). */
+
export class SyncPlayManager extends EventEmitter {
- private api: Api;
- private timeSyncCore: TimeSyncCore;
+ private apiClient: Api;
+ private playerWrapper: PlayerWrapper;
+ private timeSync: TimeSync;
+ private playbackCore: PlaybackCore;
+ private queueCore: QueueCore;
+ private pendingPlaybackTracker: PendingPlaybackTracker;
+ private controller: Controller;
- // Group state
+ /** Current group info. `null` when not in a group. */
private groupInfo: GroupInfoDto | null = null;
- private syncPlayEnabledAt: Date | null = null;
- private syncPlayReady = false;
- private queuedCommand: SendCommand | null = null;
+ /** Is SyncPlay actively enabled (i.e., we're in a group)? */
+ private syncPlayEnabledAtPlayer = false;
+ /** Are we mirroring the group's commands locally? */
private followingGroupPlayback = true;
- private lastPlaybackCommand: SendCommand | null = null;
-
- // Pending play/pause request awaiting server broadcast.
- // Used to (1) ignore duplicate rapid taps and (2) treat the server's
- // upcoming state as the source of truth while a request is in flight.
- private pendingPlaybackCommand: "Unpause" | "Pause" | null = null;
- private pendingPlaybackTimeout: ReturnType | null = null;
- // Safety: drop the pending guard after this long if no broadcast arrives.
- private static readonly PENDING_PLAYBACK_TIMEOUT_MS = 1500;
-
- // Player state
- private playerControls: PlayerControls | null = null;
- private syncMethod = "None";
-
- // Callbacks
- private onPlaybackCoreCommand: ((command: SendCommand) => void) | null = null;
- private onQueueUpdate: ((update: PlayQueueUpdate) => void) | null = null;
- private onGetPlaylistItemId: (() => string | null) | null = null;
- // Fired when SyncPlay is disabled — PlaybackCore wires up here to reset its
- // own scheduled timers / cached command so we don't carry stale state into
- // the next group.
- private onDisable: (() => void) | null = null;
- // Fired when SyncPlay is disabled — QueueCore wires up here to clear its
- // last PlayQueue snapshot. Without this, re-joining the same group later
- // causes the first PlayQueue echo (which can have a `LastUpdate` equal to
- // or older than the snapshot we cached before the disable) to be dropped
- // by the stale-update guard in `QueueCore.updatePlayQueue`.
- private onQueueClear: (() => void) | null = null;
constructor(api: Api) {
super();
- this.api = api;
- this.timeSyncCore = new TimeSyncCore(api);
-
- // Listen for time sync updates
- this.timeSyncCore.onUpdate((error, timeOffset, ping) => {
- if (error) {
- console.debug("SyncPlay Manager: time sync error", error);
- return;
- }
-
- this.emit("time-sync-update", timeOffset, ping);
-
- // Report ping to server when enabled
- if (this.isSyncPlayEnabled() && ping !== null) {
- this.sendPing(ping);
- }
- });
+ this.apiClient = api;
+ this.playerWrapper = new PlayerWrapper();
+ this.timeSync = new TimeSync(api);
+ this.playbackCore = new PlaybackCore();
+ this.queueCore = new QueueCore();
+ this.pendingPlaybackTracker = new PendingPlaybackTracker();
+ this.controller = new Controller();
}
- /**
- * Initialize the manager
- */
+ /** Wire up cores. Called once after construction. */
init(): void {
- this.timeSyncCore.startPing();
+ this.playbackCore.init(this);
+ this.queueCore.init(this);
+ this.controller.init(this);
+
+ // Forward PlaybackCore OSD events to provider listeners.
+ this.playbackCore.on("osd", (...args) => {
+ this.emit("osd", ...args);
+ });
+
+ // Bridge optimistic pending Pause/Unpause → React state.
+ this.pendingPlaybackTracker.setChangeHandler((cmd) => {
+ this.emit("pending-playback-change", cmd);
+ });
+
+ this.timeSync.startPing();
}
- /**
- * Update the API client
- */
+ /** Public controller for callers. */
+ getController(): Controller {
+ return this.controller;
+ }
+
+ /** Called by SyncPlayProvider when the user switches Jellyfin servers. */
updateApiClient(api: Api): void {
- this.api = api;
+ this.apiClient = api;
+ this.timeSync.updateApiClient(api);
}
- /**
- * Get the API client
- */
getApiClient(): Api {
- return this.api;
+ return this.apiClient;
}
- /**
- * Get the time sync core
- */
- getTimeSyncCore(): TimeSyncCore {
- return this.timeSyncCore;
+ getPlayerWrapper(): PlayerWrapper {
+ return this.playerWrapper;
}
- /**
- * Set player controls for playback management
- */
- setPlayerControls(controls: PlayerControls | null): void {
- this.playerControls = controls;
-
- // When player controls are connected and SyncPlay is active, sync to group state
- if (controls && this.isSyncPlayEnabled() && this.syncPlayReady) {
- const state = this.groupInfo?.State;
- console.log(
- `SyncPlay: player controls connected, group state is ${state}`,
- );
-
- // CRITICAL: Tell server we're following group playback
- // This ensures the server sends us SyncPlayCommand messages
- this.followGroupPlayback();
-
- // Reconcile position: if we know the last command and group is playing,
- // estimate where the group is *now* and seek there before resuming. This
- // fixes the case where the player attaches mid-stream and would
- // otherwise resume from 0 or the last-known local position.
- const last = this.lastPlaybackCommand;
- if (
- last &&
- (last.Command === "Unpause" || last.Command === "Pause") &&
- last.When &&
- last.PositionTicks != null
- ) {
- try {
- const commandWhen = new Date(last.When);
- let targetTicks = last.PositionTicks;
- if (last.Command === "Unpause") {
- const remoteNow = this.timeSyncCore.localDateToRemote(new Date());
- targetTicks +=
- (remoteNow.getTime() - commandWhen.getTime()) * 10000;
- }
- const targetMs = Math.max(0, targetTicks / 10000);
- const currentMs = controls.getCurrentPosition();
- if (Math.abs(currentMs - targetMs) > 500) {
- console.log(
- `SyncPlay: player attached — seeking to estimated group position ${targetMs}ms (was ${currentMs}ms)`,
- );
- controls.seekTo(targetMs);
- }
- } catch (error) {
- console.warn(
- "SyncPlay: failed to estimate group position on attach",
- error,
- );
- }
- }
-
- if (state === "Playing" && !controls.isPlaying()) {
- console.log("SyncPlay: starting playback to match group");
- controls.play();
- } else if (state === "Paused" && controls.isPlaying()) {
- console.log("SyncPlay: pausing to match group");
- controls.pause();
- }
- }
+ getTimeSync(): TimeSync {
+ return this.timeSync;
}
- /**
- * Get current player controls
- */
- getPlayerControls(): PlayerControls | null {
- return this.playerControls;
+ getPlaybackCore(): PlaybackCore {
+ return this.playbackCore;
}
- /**
- * Set callback for playback commands
- */
- setPlaybackCommandHandler(
- handler: ((command: SendCommand) => void) | null,
- ): void {
- this.onPlaybackCoreCommand = handler;
+ getQueueCore(): QueueCore {
+ return this.queueCore;
}
- /**
- * Set callback for queue updates
- */
- setQueueUpdateHandler(
- handler: ((update: PlayQueueUpdate) => void) | null,
- ): void {
- this.onQueueUpdate = handler;
+ getPendingPlaybackTracker(): PendingPlaybackTracker {
+ return this.pendingPlaybackTracker;
}
- /**
- * Set callback for getting current playlist item ID
- */
- setPlaylistItemIdGetter(getter: (() => string | null) | null): void {
- this.onGetPlaylistItemId = getter;
- }
+ // ===========================================================================
+ // WebSocket message handlers (called by useSyncPlayWebSocket)
+ // ===========================================================================
/**
- * Set a callback invoked when SyncPlay is disabled. PlaybackCore registers
- * here so it can flush scheduled commands and stale state.
- */
- setDisableHandler(handler: (() => void) | null): void {
- this.onDisable = handler;
- }
-
- /**
- * Set a callback invoked when SyncPlay is disabled. QueueCore registers
- * here so it can drop the cached PlayQueue snapshot and treat the next
- * server update as fresh.
- */
- setQueueClearHandler(handler: (() => void) | null): void {
- this.onQueueClear = handler;
- }
-
- // ============================================================================
- // Group Management
- // ============================================================================
-
- /**
- * Check if SyncPlay is enabled (user is in a group)
- */
- isSyncPlayEnabled(): boolean {
- return this.syncPlayEnabledAt !== null;
- }
-
- /**
- * Check if SyncPlay is ready (time sync complete)
- */
- isSyncPlayReady(): boolean {
- return this.syncPlayReady;
- }
-
- /**
- * Get current group info
- */
- getGroupInfo(): GroupInfoDto | null {
- return this.groupInfo;
- }
-
- /**
- * Get the last playback command
- */
- getLastPlaybackCommand(): SendCommand | null {
- return this.lastPlaybackCommand;
- }
-
- /**
- * Check if currently playing
- */
- isPlaying(): boolean {
- // First check actual player state
- if (this.playerControls) {
- return this.playerControls.isPlaying();
- }
- // Fall back to group state
- if (this.groupInfo?.State) {
- return this.groupInfo.State === "Playing";
- }
- // Last resort: check last command
- return this.lastPlaybackCommand?.Command === "Unpause";
- }
-
- /**
- * Effective play state for SyncPlay routing decisions.
+ * Handle a `SyncPlayGroupUpdate` WebSocket message.
*
- * Prefers (1) a pending in-flight command we just sent, (2) the server's
- * group state, and only falls back to the local player. This avoids the
- * race where a rapid second tap reads the local player (which hasn't
- * applied the scheduled command yet) and sends a duplicate request that
- * either re-broadcasts with a new `When` or flips the group the wrong way.
+ * Cast: the SDK's `GroupUpdate.Type` union is narrower than what the
+ * server actually emits (it omits `SyncPlayIsDisabled`, `GroupUpdate`,
+ * `CreateGroupDenied`, `JoinGroupDenied`). Wire format is the source
+ * of truth here.
*/
- getEffectivePlayState(): "Playing" | "Paused" {
- if (this.pendingPlaybackCommand === "Unpause") return "Playing";
- if (this.pendingPlaybackCommand === "Pause") return "Paused";
- if (this.groupInfo?.State === "Playing") return "Playing";
- if (this.groupInfo?.State === "Paused") return "Paused";
- return this.playerControls?.isPlaying() ? "Playing" : "Paused";
- }
-
- /**
- * Returns the in-flight play/pause request, if any.
- */
- getPendingPlaybackCommand(): "Unpause" | "Pause" | null {
- return this.pendingPlaybackCommand;
- }
-
- /**
- * Mark a play/pause request as in flight. Auto-clears on a safety timeout
- * in case the server broadcast is missed.
- */
- markPendingPlaybackCommand(command: "Unpause" | "Pause"): void {
- this.pendingPlaybackCommand = command;
- if (this.pendingPlaybackTimeout) {
- clearTimeout(this.pendingPlaybackTimeout);
+ processGroupUpdate(rawUpdate: GroupUpdate): void {
+ if (!rawUpdate) {
+ console.warn("SyncPlay processGroupUpdate: empty update");
+ return;
}
- this.pendingPlaybackTimeout = setTimeout(() => {
- console.debug(
- "SyncPlay Manager: pending playback command timed out",
- command,
- );
- this.pendingPlaybackCommand = null;
- this.pendingPlaybackTimeout = null;
- this.emit("pending-playback-change", null);
- }, SyncPlayManager.PENDING_PLAYBACK_TIMEOUT_MS);
- this.emit("pending-playback-change", command);
- }
-
- private clearPendingPlaybackCommand(): void {
- if (this.pendingPlaybackTimeout) {
- clearTimeout(this.pendingPlaybackTimeout);
- this.pendingPlaybackTimeout = null;
- }
- if (this.pendingPlaybackCommand !== null) {
- this.pendingPlaybackCommand = null;
- this.emit("pending-playback-change", null);
- }
- }
-
- /**
- * Check if following group playback
- */
- isFollowingGroupPlayback(): boolean {
- return this.followingGroupPlayback;
- }
-
- /**
- * Enable SyncPlay (join a group)
- */
- enableSyncPlay(groupInfo: GroupInfoDto, showMessage = false): void {
- if (this.isSyncPlayEnabled()) {
- if (groupInfo.GroupId === this.groupInfo?.GroupId) {
- console.debug(
- `SyncPlay: group ${this.groupInfo?.GroupId} already joined.`,
- );
- return;
- }
- console.warn(
- `SyncPlay: switching from group ${this.groupInfo?.GroupId} to ${groupInfo.GroupId}`,
- );
- this.disableSyncPlay(false);
- }
-
- this.groupInfo = groupInfo;
- this.syncPlayEnabledAt = groupInfo.LastUpdatedAt
- ? new Date(groupInfo.LastUpdatedAt)
- : new Date();
- this.followingGroupPlayback = true;
- this.syncPlayReady = false;
-
- console.log(`SyncPlay: enableSyncPlay - group state is ${groupInfo.State}`);
-
- this.emit("enabled", true);
-
- // Wait for time sync to be ready
- const checkReady = () => {
- if (this.timeSyncCore.isReady()) {
- this.syncPlayReady = true;
-
- // CRITICAL: Tell server we're following group playback
- // This ensures the server sends us SyncPlayCommand messages
- this.followGroupPlayback();
-
- if (this.queuedCommand) {
- this.processCommand(this.queuedCommand);
- this.queuedCommand = null;
- }
-
- // Act on initial group state if player is connected
- if (this.playerControls && groupInfo.State) {
- console.log(`SyncPlay: applying initial state ${groupInfo.State}`);
- if (groupInfo.State === "Playing") {
- this.playerControls.play();
- } else if (groupInfo.State === "Paused") {
- this.playerControls.pause();
- }
- }
- } else {
- setTimeout(checkReady, 100);
- }
+ const update = rawUpdate as unknown as {
+ Type: string;
+ Data: unknown;
};
- this.timeSyncCore.forceUpdate();
- checkReady();
-
- if (showMessage) {
- toast(i18n.t("syncplay.enabled"));
- }
- }
-
- /**
- * Disable SyncPlay (leave group)
- */
- disableSyncPlay(showMessage = false): void {
- this.syncPlayEnabledAt = null;
- this.syncPlayReady = false;
- this.followingGroupPlayback = true;
- this.lastPlaybackCommand = null;
- this.queuedCommand = null;
- this.groupInfo = null;
- this.clearPendingPlaybackCommand();
-
- // Tell PlaybackCore (or whoever subscribed) to flush any scheduled
- // commands / cached state so a future re-enable starts clean.
- try {
- this.onDisable?.();
- } catch (error) {
- console.warn("SyncPlay: onDisable handler threw", error);
- }
-
- // Drop the cached PlayQueue snapshot so a future re-join doesn't get
- // its first PlayQueue update silently dropped as "older than what we
- // already have".
- try {
- this.onQueueClear?.();
- } catch (error) {
- console.warn("SyncPlay: onQueueClear handler threw", error);
- }
-
- this.emit("enabled", false);
-
- if (showMessage) {
- toast(i18n.t("syncplay.disabled"));
- }
- }
-
- // ============================================================================
- // Server Communication
- // ============================================================================
-
- /**
- * Send ping to server
- */
- private async sendPing(ping: number): Promise {
- try {
- const syncPlayApi = getSyncPlayApi(this.api);
- await syncPlayApi.syncPlayPing({
- pingRequestDto: { Ping: Math.round(ping) },
- });
- } catch (error) {
- console.debug("SyncPlay: failed to send ping", error);
- }
- }
-
- /**
- * Report that we're ready (not buffering)
- */
- async reportReady(): Promise {
- try {
- const syncPlayApi = getSyncPlayApi(this.api);
- const now = new Date();
- const currentPosition = this.playerControls?.getCurrentPosition() ?? 0;
- const currentPositionTicks = msToTicks(currentPosition);
-
- console.log(
- "SyncPlay Manager: reporting READY at position",
- currentPositionTicks,
- );
-
- await syncPlayApi.syncPlayReady({
- readyRequestDto: {
- When: now.toISOString(),
- PositionTicks: currentPositionTicks,
- IsPlaying: this.playerControls?.isPlaying() ?? false,
- PlaylistItemId:
- this.onGetPlaylistItemId?.() ??
- "00000000-0000-0000-0000-000000000000",
- },
- });
- console.log("SyncPlay Manager: READY sent successfully");
- } catch (error) {
- console.error("SyncPlay Manager: failed to report ready", error);
- }
- }
-
- /**
- * Follow group playback
- */
- async followGroupPlayback(): Promise {
- this.followingGroupPlayback = true;
-
- try {
- const syncPlayApi = getSyncPlayApi(this.api);
- await syncPlayApi.syncPlaySetIgnoreWait({
- ignoreWaitRequestDto: { IgnoreWait: false },
- });
- } catch (error) {
- console.error("SyncPlay: failed to follow group playback", error);
- }
- }
-
- /**
- * Halt group playback (stop following)
- */
- async haltGroupPlayback(): Promise {
- this.followingGroupPlayback = false;
-
- try {
- const syncPlayApi = getSyncPlayApi(this.api);
- await syncPlayApi.syncPlaySetIgnoreWait({
- ignoreWaitRequestDto: { IgnoreWait: true },
- });
-
- // Stop local playback
- this.playerControls?.pause();
- } catch (error) {
- console.error("SyncPlay: failed to halt group playback", error);
- }
- }
-
- // ============================================================================
- // Message Processing
- // ============================================================================
-
- /**
- * Process a group update from the server
- * Uses generic type to handle all possible update types from server
- */
- processGroupUpdate(update: { Type?: string; Data?: unknown }): void {
- const { Type, Data } = update;
-
- switch (Type) {
- case "PlayQueue": {
- const playQueueData = Data as PlayQueueUpdate;
- console.log(
- "SyncPlay: received PlayQueue update - position:",
- playQueueData.StartPositionTicks,
- "reason:",
- playQueueData.Reason,
+ switch (update.Type) {
+ case "PlayQueue":
+ this.queueCore.updatePlayQueue(
+ this.apiClient,
+ update.Data as unknown as PlayQueueUpdate,
);
- this.onQueueUpdate?.(playQueueData);
break;
- }
case "UserJoined":
- toast(i18n.t("syncplay.user_joined", { username: Data }));
- if (this.groupInfo) {
- if (!this.groupInfo.Participants) {
- this.groupInfo.Participants = [Data as string];
- } else {
- this.groupInfo.Participants.push(Data as string);
- }
- }
- break;
-
case "UserLeft":
- toast(i18n.t("syncplay.user_left", { username: Data }));
- if (this.groupInfo?.Participants) {
- this.groupInfo.Participants = this.groupInfo.Participants.filter(
- (user: string) => user !== Data,
- );
- }
+ // Group membership notifications — current group will follow
+ // via GroupUpdate, but emit a toast for friendliness.
+ this.emit("toast", `MessageSyncPlay${update.Type}`, update.Data);
break;
case "GroupJoined": {
- const groupData = Data as GroupInfoDto;
- this.enableSyncPlay(groupData, true);
+ this.groupInfo = update.Data as GroupInfoDto;
+ this.enableSyncPlay(this.groupInfo);
+ this.emit("group-update", this.groupInfo);
+ this.emit("toast", "MessageSyncPlayGroupJoined");
break;
}
- case "SyncPlayIsDisabled":
- toast(i18n.t("syncplay.permission_required"));
- break;
-
- case "NotInGroup":
case "GroupLeft":
- this.disableSyncPlay(true);
+ case "NotInGroup":
+ case "SyncPlayIsDisabled": {
+ const previousState = this.groupInfo?.State;
+ this.groupInfo = null;
+ this.disableSyncPlay();
+ this.emit("group-update", null);
+ if (update.Type === "GroupLeft") {
+ this.emit("toast", "MessageSyncPlayGroupLeft");
+ }
+ if (previousState) {
+ this.emit("group-state-change", "Idle", previousState);
+ }
break;
+ }
case "GroupUpdate": {
- const updatedData = Data as GroupInfoDto;
- this.groupInfo = updatedData;
- this.emit("group-info-change", updatedData);
+ const previousState = this.groupInfo?.State;
+ this.groupInfo = update.Data as GroupInfoDto;
+ this.emit("group-update", this.groupInfo);
+ const newState = this.groupInfo.State;
+ if (newState && newState !== previousState) {
+ this.emit("group-state-change", newState, previousState ?? "Idle");
+ }
break;
}
case "StateUpdate": {
- // Log full state data to see if position is included
- console.log("SyncPlay: StateUpdate full data:", JSON.stringify(Data));
- const stateData = Data as {
- State: string;
- Reason: string;
- PositionTicks?: number;
+ const stateData = update.Data as {
+ State?: string;
+ PreviousState?: string;
};
-
- // CRITICAL: Update the stored group state so subsequent checks use the correct value
+ const newState = stateData.State ?? "Idle";
+ const previousState = stateData.PreviousState ?? "Idle";
if (this.groupInfo) {
- this.groupInfo.State = stateData.State as any;
- // Emit a fresh object so React state subscribers re-render —
- // mutating in place would not trigger re-renders.
- this.emit("group-info-change", { ...this.groupInfo });
+ this.groupInfo.State = newState as GroupInfoDto["State"];
+ this.emit("group-update", this.groupInfo);
}
-
- this.emit("group-state-update", stateData.State, stateData.Reason);
- console.log(
- `SyncPlay: state changed to ${stateData.State} because ${stateData.Reason}`,
- );
-
- // Handle seek from StateUpdate if position is included
- if (stateData.Reason === "Seek" && stateData.PositionTicks != null) {
- console.log(
- "SyncPlay: StateUpdate contains seek position:",
- stateData.PositionTicks,
- );
- this.emit("seek-from-state-update", stateData.PositionTicks);
- }
-
- // Use StateUpdate as a fallback to control playback when SyncPlayCommand isn't received
- // This ensures we stay in sync even if the server doesn't send commands
- if (this.playerControls) {
- const currentlyPlaying = this.playerControls.isPlaying();
- console.log(
- `SyncPlay: StateUpdate handler - state=${stateData.State}, currentlyPlaying=${currentlyPlaying}`,
- );
-
- if (stateData.State === "Paused" && currentlyPlaying) {
- console.log("SyncPlay: StateUpdate -> PAUSING player");
- this.playerControls.pause();
- } else if (stateData.State === "Playing" && !currentlyPlaying) {
- console.log("SyncPlay: StateUpdate -> PLAYING");
- this.playerControls.play();
- } else if (stateData.State === "Waiting") {
- console.log("SyncPlay: StateUpdate -> Waiting for other members");
- // Pause player when waiting
- if (currentlyPlaying) {
- this.playerControls.pause();
- }
- // Emit event so PlaybackCore can report ready
- this.emit("waiting-for-ready");
- }
- } else {
- console.warn("SyncPlay: StateUpdate but no playerControls!");
+ this.emit("group-state-change", newState, previousState);
+ // Server signals "Playing" or "Paused" → clear any in-flight
+ // optimistic tap state.
+ if (newState === "Playing" || newState === "Paused") {
+ this.pendingPlaybackTracker.clear();
}
break;
}
- case "GroupDoesNotExist":
- toast(i18n.t("syncplay.group_does_not_exist"));
- break;
-
case "CreateGroupDenied":
- toast(i18n.t("syncplay.create_denied"));
+ this.emit("toast", "MessageSyncPlayCreateGroupDenied");
break;
-
case "JoinGroupDenied":
- toast(i18n.t("syncplay.join_denied"));
+ this.emit("toast", "MessageSyncPlayJoinGroupDenied");
break;
-
case "LibraryAccessDenied":
- toast(i18n.t("syncplay.library_access_denied"));
+ this.emit("toast", "MessageSyncPlayLibraryAccessDenied");
+ break;
+ case "GroupDoesNotExist":
+ this.emit("toast", "MessageSyncPlayGroupDoesNotExist");
break;
default:
- console.warn(`SyncPlay: unrecognized group update type: ${Type}`);
+ console.warn("SyncPlay processGroupUpdate: unknown type", update.Type);
+ break;
}
}
- /**
- * Process a playback command from the server
- */
+ /** Handle a `SyncPlayCommand` WebSocket message. */
processCommand(command: SendCommand): void {
- console.log(`SyncPlay Manager: processCommand called - ${command.Command}`);
-
- if (!this.isSyncPlayEnabled()) {
- console.warn(
- "SyncPlay Manager: not enabled, ignoring command",
- command.Command,
- );
+ if (!command) {
+ console.warn("SyncPlay processCommand: empty command");
return;
}
-
- const emittedAt = command.EmittedAt ? new Date(command.EmittedAt) : null;
- if (this.syncPlayEnabledAt && emittedAt) {
- if (emittedAt.getTime() < this.syncPlayEnabledAt.getTime()) {
- console.debug("SyncPlay Manager: ignoring old command", command);
- return;
- }
- }
-
- // Reject commands targeted at a different playlist item than the one we
- // currently have loaded. Stop is always honored (it may be a teardown
- // before a queue swap). This prevents (e.g.) seeking the wrong episode
- // when a queue change is racing a command.
- if (command.Command !== "Stop" && command.PlaylistItemId) {
- const currentItemId = this.onGetPlaylistItemId?.();
- if (currentItemId && currentItemId !== command.PlaylistItemId) {
- console.debug(
- `SyncPlay Manager: ignoring command for playlist item ${command.PlaylistItemId} (current is ${currentItemId})`,
- );
- return;
- }
- }
-
- if (!this.syncPlayReady) {
- console.log(
- "SyncPlay Manager: not ready, queuing command",
- command.Command,
- );
- this.queuedCommand = command;
- return;
- }
-
- // Remember the command even if we can't act on it yet. When the player
- // attaches (setPlayerControls), the reconcile-on-attach path uses
- // `lastPlaybackCommand` to seek to the estimated group position and
- // resume/pause to match the group. Without this assignment, a command
- // that arrives during the join→navigate→load window is lost.
- this.lastPlaybackCommand = command;
-
- // Clear pending guard once the matching broadcast arrives. We treat any
- // Unpause/Pause arrival as satisfying the pending request (the server
- // may coalesce or override our intent — either way we trust its decision).
+ this.playbackCore.applyCommand(command);
+ // Server told us the new playing state — clear optimistic UI.
if (command.Command === "Unpause" || command.Command === "Pause") {
- this.clearPendingPlaybackCommand();
+ this.pendingPlaybackTracker.clear();
}
+ }
- if (!this.playerControls) {
- // Expected when a command arrives between joining the group and the
- // player finishing its initial load. The reconciliation in
- // setPlayerControls will replay this command from `lastPlaybackCommand`
- // once controls attach.
- console.debug(
- `SyncPlay Manager: ${command.Command} stored for replay (player not attached yet)`,
+ // ===========================================================================
+ // Enable / disable SyncPlay
+ // ===========================================================================
+
+ private enableSyncPlay(_group: GroupInfoDto): void {
+ if (this.syncPlayEnabledAtPlayer) return;
+ this.syncPlayEnabledAtPlayer = true;
+ this.followingGroupPlayback = true;
+ this.timeSync.forceUpdate();
+ this.emit("enabled", true);
+ this.emit("play-state-change", true);
+ }
+
+ private disableSyncPlay(): void {
+ if (!this.syncPlayEnabledAtPlayer) return;
+ this.syncPlayEnabledAtPlayer = false;
+ this.followingGroupPlayback = false;
+ this.playbackCore.clearScheduledCommand();
+ this.queueCore.clear();
+ this.pendingPlaybackTracker.clear();
+ this.emit("enabled", false);
+ this.emit("play-state-change", false);
+ }
+
+ /**
+ * Resume following group playback after the user temporarily took
+ * local control (e.g. scrubbed the seek bar).
+ */
+ async followGroupPlayback(_api: Api): Promise {
+ this.followingGroupPlayback = true;
+ this.emit("play-state-change", true);
+ }
+
+ /** Stop following group playback (e.g., user takes local control). */
+ haltGroupPlayback(_api: Api): void {
+ this.followingGroupPlayback = false;
+ this.emit("play-state-change", false);
+ }
+
+ isFollowingGroupPlayback(): boolean {
+ return this.followingGroupPlayback;
+ }
+
+ isSyncPlayEnabled(): boolean {
+ return this.syncPlayEnabledAtPlayer;
+ }
+
+ // ===========================================================================
+ // Player attach + provider bridges
+ // ===========================================================================
+
+ /**
+ * Bind the RN player controls.
+ * Called from the player screen's `useEffect`. Triggers a reconcile
+ * if a group is active and the player is late-arriving.
+ */
+ setPlayerControls(controls: PlayerControls | null): void {
+ this.playerWrapper.bindToControls(controls);
+ if (controls && this.syncPlayEnabledAtPlayer) {
+ const lastCommand = this.playbackCore.getLastCommand();
+ reconcileToGroupOnAttach(controls, lastCommand, (local) =>
+ this.timeSync.localDateToRemote(local),
);
- return;
}
+ }
- console.log(
- `SyncPlay Manager: delegating ${command.Command} to playback core`,
- );
+ /** Player-side notify hook: media is ready to play. */
+ notifyReady(): void {
+ this.emit("playbackstart");
+ if (this.syncPlayEnabledAtPlayer) {
+ this.playbackCore.onReady(this.apiClient);
+ }
+ }
- // Delegate to playback handler
- if (this.onPlaybackCoreCommand) {
- this.onPlaybackCoreCommand(command);
+ /** Player-side notify hook: buffering state changed. */
+ notifyBuffering(isBuffering: boolean): void {
+ if (!this.syncPlayEnabledAtPlayer) return;
+ if (isBuffering) {
+ this.playbackCore.onBuffering(this.apiClient);
} else {
- console.error("SyncPlay Manager: no playback command handler set!");
+ this.playbackCore.onReady(this.apiClient);
}
}
- // ============================================================================
- // Stats
- // ============================================================================
-
- /**
- * Get SyncPlay stats for display
- */
- getStats(): SyncPlayStats {
- return {
- timeSyncDevice: this.timeSyncCore.getActiveDeviceName(),
- timeSyncOffset: this.timeSyncCore.getTimeOffset().toFixed(2),
- playbackDiff: "0.00",
- syncMethod: this.syncMethod,
- };
+ /** Player-side notify hook: local playback started. */
+ notifyPlaybackStart(): void {
+ this.emit("playbackstart");
+ if (this.syncPlayEnabledAtPlayer) {
+ this.playbackCore.onPlaybackStart(this.apiClient);
+ }
}
- /**
- * Show sync icon
- */
- showSyncIcon(method: string): void {
- this.syncMethod = method;
- this.emit("syncing", true, method);
+ // ===========================================================================
+ // Pending playback (optimistic UI for play/pause taps)
+ // ===========================================================================
+
+ /** Called by Controller before sending an Unpause/Pause request. */
+ markPendingPlaybackCommand(command: "Unpause" | "Pause"): void {
+ this.pendingPlaybackTracker.mark(command);
}
- /**
- * Clear sync icon
- */
- clearSyncIcon(): void {
- this.syncMethod = "None";
- this.emit("syncing", false, "None");
+ /** Is the group currently playing? Used by Controller.playPause. */
+ isPlaying(): boolean {
+ const pending = this.pendingPlaybackTracker.get();
+ if (pending === "Unpause") return true;
+ if (pending === "Pause") return false;
+ return this.groupInfo?.State === "Playing";
}
- // ============================================================================
- // Cleanup
- // ============================================================================
+ /** Group info for consumers. */
+ getGroupInfo(): GroupInfoDto | null {
+ return this.groupInfo;
+ }
+
+ /** Last playback command (for QueueCore.startPlayback resumption). */
+ getLastPlaybackCommand(): SendCommand | null {
+ return this.playbackCore.getLastCommand();
+ }
+
+ // ===========================================================================
+ // Teardown
+ // ===========================================================================
- /**
- * Destroy the manager
- */
destroy(): void {
- this.timeSyncCore.destroy();
- this.disableSyncPlay(false);
+ this.timeSync.destroy();
+ this.playbackCore.destroy();
+ this.queueCore.destroy();
+ this.playerWrapper.bindToControls(null);
this.removeAllListeners();
- this.playerControls = null;
- this.onPlaybackCoreCommand = null;
- this.onQueueUpdate = null;
}
}
+
+export default SyncPlayManager;
diff --git a/providers/SyncPlay/PlaybackCore.ts b/providers/SyncPlay/PlaybackCore.ts
deleted file mode 100644
index 03d31f982..000000000
--- a/providers/SyncPlay/PlaybackCore.ts
+++ /dev/null
@@ -1,881 +0,0 @@
-/**
- * PlaybackCore
- *
- * Manages synchronized playback for SyncPlay.
- * Handles scheduling commands at precise times and sync correction.
- *
- * Based on jellyfin-web's PlaybackCore.js
- */
-
-import type { Api } from "@jellyfin/sdk";
-import { getSyncPlayApi } from "@jellyfin/sdk/lib/utils/api";
-import { EventEmitter, msToTicks, ticksToMs, waitForOwnEvent } from "./Helper";
-import type { TimeSyncCore } from "./TimeSyncCore";
-import type {
- PlayerControls,
- SendCommand,
- SyncPlayOsdAction,
- SyncPlaySettings,
-} from "./types";
-import { TicksPerMillisecond } from "./types";
-
-// Random offset added when re-issuing a duplicate Seek to force the player
-// off-position so the next sync correction has something to chew on. Matches
-// jellyfin-web's behavior (server tolerates a range, so we deliberately land
-// just outside it).
-const SEEK_FORCE_RANGE_MS = 100;
-// Timeout for waiting on the local player's "ready" event after seek.
-// Matches jellyfin-web's Helper.WaitForEventDefaultTimeout.
-const WAIT_FOR_READY_TIMEOUT_MS = 30000;
-// How close player position must be to command position to consider it
-// already in the correct place (fuzz to account for player imprecision).
-const POSITION_MATCH_TOLERANCE_MS = 500;
-
-/**
- * PlaybackCore - Handles synchronized playback
- */
-export class PlaybackCore extends EventEmitter {
- private api: Api;
- private timeSyncCore: TimeSyncCore;
- private playerControls: PlayerControls | null = null;
-
- // Sync state
- private syncEnabled = false;
- private playbackDiffMillis = 0;
- private syncAttempts = 0;
- private lastSyncTime = new Date();
- private playerIsBuffering = false;
-
- // Command tracking
- private lastCommand: SendCommand | null = null;
- private scheduledCommandTimeout: ReturnType | null = null;
- private syncTimeout: ReturnType | null = null;
-
- // Last buffering state we reported to the server. Used to dedupe
- // sendBufferingRequest so we only send on actual transitions —
- // jellyfin-web gets this for free from the HTML5 `waiting`/`canplay`
- // events, but our player exposes state, not events, and the React
- // effect that drives notifyReady/notifyBuffering can re-run many times
- // per second during normal playback. Without this guard we flood the
- // server with redundant Ready/Buffering requests.
- private lastBufferingSent: boolean | null = null;
- private inflightBufferingRequest: Promise | null = null;
-
- // Debounce buffering notifications, matching jellyfin-web's
- // `minBufferingThresholdMillis = 3000` in HtmlVideoPlayer.js. A short
- // re-buffer blip (<3s) shouldn't notify the server at all — there's no
- // reason to pause the whole group for a hiccup that resolves itself.
- // Going Ready cancels any pending buffering notification.
- private notifyBufferingTimeout: ReturnType | null = null;
- private readonly minBufferingThresholdMillis = 3000;
-
- // Set to true by `scheduleReadyRequestOnPlaybackStart` whenever a new
- // SyncPlay queue starts loading (NewPlaylist / SetCurrentItem / NextItem
- // / PreviousItem). On the next `onReady` we pause the player BEFORE
- // sending SyncPlayReady so the server sees us as `IsPlaying:false`,
- // parked at the start position, awaiting an Unpause command. Mirrors
- // jellyfin-web's `QueueCore.scheduleReadyRequestOnPlaybackStart` which
- // registers a one-shot `playbackstart` listener that does the same.
- // Without this the receiver's player auto-plays the moment it loads and
- // the group's Unpause command arrives to an already-playing player —
- // which leaves the receiver subtly out of sync with the sender (or, on
- // slower devices, stuck on a blank loading screen because the early
- // play attempt races the media load and never recovers).
- private pendingInitialPause = false;
-
- // Settings
- private minDelaySpeedToSync = 60.0;
- private maxDelaySpeedToSync = 3000.0;
- private speedToSyncDuration = 1000.0;
- private minDelaySkipToSync = 400.0;
- private useSpeedToSync = true;
- private useSkipToSync = true;
- private enableSyncCorrection = false;
-
- // Callbacks
- private onNotifyOsd: ((action: SyncPlayOsdAction) => void) | null = null;
- private getCurrentPlaylistItemId: (() => string | null) | null = null;
-
- constructor(api: Api, timeSyncCore: TimeSyncCore) {
- super();
- this.api = api;
- this.timeSyncCore = timeSyncCore;
- }
-
- /**
- * Set player controls
- */
- setPlayerControls(controls: PlayerControls | null): void {
- this.playerControls = controls;
- // A new (or detached) player means the server's view of our ready
- // state is stale — drop the dedupe latch so the next notifyReady /
- // notifyBuffering actually reaches the server.
- this.lastBufferingSent = null;
- }
-
- /**
- * Set OSD notification handler
- */
- setOsdHandler(handler: ((action: SyncPlayOsdAction) => void) | null): void {
- this.onNotifyOsd = handler;
- }
-
- /**
- * Set playlist item ID getter
- */
- setPlaylistItemIdGetter(getter: (() => string | null) | null): void {
- this.getCurrentPlaylistItemId = getter;
- }
-
- /**
- * Load settings
- */
- loadSettings(settings: Partial): void {
- if (settings.minDelaySpeedToSync !== undefined) {
- this.minDelaySpeedToSync = settings.minDelaySpeedToSync;
- }
- if (settings.maxDelaySpeedToSync !== undefined) {
- this.maxDelaySpeedToSync = settings.maxDelaySpeedToSync;
- }
- if (settings.speedToSyncDuration !== undefined) {
- this.speedToSyncDuration = settings.speedToSyncDuration;
- }
- if (settings.minDelaySkipToSync !== undefined) {
- this.minDelaySkipToSync = settings.minDelaySkipToSync;
- }
- if (settings.useSpeedToSync !== undefined) {
- this.useSpeedToSync = settings.useSpeedToSync;
- }
- if (settings.useSkipToSync !== undefined) {
- this.useSkipToSync = settings.useSkipToSync;
- }
- if (settings.enableSyncCorrection !== undefined) {
- this.enableSyncCorrection = settings.enableSyncCorrection;
- }
- }
-
- // ============================================================================
- // Player Events
- // ============================================================================
-
- /**
- * Called when playback starts
- */
- onPlaybackStart(): void {
- this.emit("playbackstart");
- }
-
- /**
- * Called when playback stops
- */
- onPlaybackStop(): void {
- this.lastCommand = null;
- this.emit("playbackstop");
- }
-
- /**
- * Called when player is ready to play
- */
- onReady(): void {
- this.playerIsBuffering = false;
- // Cancel any pending buffering notification — we're ready before the
- // 3s threshold fired, so the server never needs to know we hiccupped.
- if (this.notifyBufferingTimeout) {
- clearTimeout(this.notifyBufferingTimeout);
- this.notifyBufferingTimeout = null;
- }
- // If we're handling the first ready event after a queue change,
- // pause the player BEFORE reporting ready. The subsequent
- // `sendBufferingRequest(false)` will then read `isPlaying() === false`
- // and the server will hold the group until we receive an Unpause.
- if (this.pendingInitialPause) {
- this.pendingInitialPause = false;
- if (this.playerControls?.isPlaying()) {
- console.log(
- "SyncPlay PlaybackCore: pausing on initial ready (awaiting group Unpause)",
- );
- this.playerControls.pause();
- }
- }
- this.sendBufferingRequest(false);
- this.emit("ready");
- }
-
- /**
- * Mark the next `onReady` call as the initial ready for a new queue
- * item. The player will be paused before SyncPlayReady is sent so the
- * server keeps the group in `Waiting` until our Unpause arrives.
- *
- * Mirrors jellyfin-web's `QueueCore.scheduleReadyRequestOnPlaybackStart`.
- * Called by the provider when a PlayQueue update is `NewPlaylist`,
- * `SetCurrentItem`, `NextItem`, or `PreviousItem`.
- */
- scheduleReadyRequestOnPlaybackStart(): void {
- this.pendingInitialPause = true;
- }
-
- /**
- * Called when player is buffering
- */
- onBuffering(): void {
- // Debounce: only flip into "buffering" state (and notify the server)
- // if the stall lasts longer than minBufferingThresholdMillis. Mirrors
- // jellyfin-web's HtmlVideoPlayer.js `_onWaiting` handler, which only
- // calls `onBuffering()` after the 3s timeout elapses. Keeping
- // playerIsBuffering=false during brief blips lets sync correction
- // continue to run normally.
- if (this.notifyBufferingTimeout) {
- clearTimeout(this.notifyBufferingTimeout);
- }
- this.notifyBufferingTimeout = setTimeout(() => {
- this.notifyBufferingTimeout = null;
- this.playerIsBuffering = true;
- this.sendBufferingRequest(true);
- this.emit("buffering");
- }, this.minBufferingThresholdMillis);
- }
-
- /**
- * Check if player is buffering
- */
- isBuffering(): boolean {
- return this.playerIsBuffering;
- }
-
- /**
- * Get playback difference in milliseconds
- */
- getPlaybackDiff(): number {
- return this.playbackDiffMillis;
- }
-
- // ============================================================================
- // Server Communication
- // ============================================================================
-
- /**
- * Send buffering/ready request to server.
- *
- * NOTE: This must work even before player controls are bound, so that we
- * can signal "I'm not ready yet, hold the group" while the video is still
- * loading. jellyfin-web's HTML5 player gets this for free via the
- * `waiting` event firing during initial buffering; we don't bind controls
- * until the video is loaded, so we synthesize a position=0 buffering
- * signal in the pre-bind window.
- */
- async sendBufferingRequest(isBuffering: boolean): Promise {
- if (!this.api) {
- console.warn("SyncPlay PlaybackCore: no api for buffering request");
- return;
- }
-
- // Skip if the desired state matches what we last sent. Without this,
- // the React effect that drives notifyReady/notifyBuffering will flood
- // the server every time the video player's isBuffering momentarily
- // toggles during normal playback.
- if (this.lastBufferingSent === isBuffering) {
- return;
- }
-
- // Coalesce: if a request is already in flight, wait for it. This
- // prevents racing two requests when state flips rapidly.
- if (this.inflightBufferingRequest) {
- await this.inflightBufferingRequest;
- // Re-check after the in-flight request settled — the new state may
- // already match.
- if (this.lastBufferingSent === isBuffering) {
- return;
- }
- }
-
- const request = this.doSendBufferingRequest(isBuffering);
- this.inflightBufferingRequest = request;
- try {
- await request;
- this.lastBufferingSent = isBuffering;
- } finally {
- if (this.inflightBufferingRequest === request) {
- this.inflightBufferingRequest = null;
- }
- }
- }
-
- private async doSendBufferingRequest(isBuffering: boolean): Promise {
- if (!this.api) return;
-
- try {
- const currentPosition = this.playerControls?.getCurrentPosition() ?? 0;
- const currentPositionTicks = msToTicks(currentPosition);
- const isPlaying = this.playerControls?.isPlaying() ?? false;
-
- const now = this.timeSyncCore.localDateToRemote(new Date());
- const playlistItemId = this.getCurrentPlaylistItemId?.() ?? null;
-
- const syncPlayApi = getSyncPlayApi(this.api);
-
- console.log(
- `SyncPlay PlaybackCore: sending ${isBuffering ? "BUFFERING" : "READY"} to server`,
- {
- position: currentPositionTicks,
- playlistItemId,
- hasPlayerControls: !!this.playerControls,
- },
- );
-
- if (isBuffering) {
- await syncPlayApi.syncPlayBuffering({
- bufferRequestDto: {
- When: now.toISOString(),
- PositionTicks: currentPositionTicks,
- IsPlaying: isPlaying,
- PlaylistItemId: playlistItemId ?? undefined,
- },
- });
- } else {
- await syncPlayApi.syncPlayReady({
- readyRequestDto: {
- When: now.toISOString(),
- PositionTicks: currentPositionTicks,
- IsPlaying: isPlaying,
- PlaylistItemId: playlistItemId ?? undefined,
- },
- });
- }
-
- console.log(
- `SyncPlay PlaybackCore: ${isBuffering ? "BUFFERING" : "READY"} sent successfully`,
- );
- } catch (error) {
- console.error("SyncPlay: failed to send buffering request", error);
- // On failure, clear the dedupe latch so the next attempt actually
- // re-sends rather than getting stuck thinking the server knows.
- throw error;
- }
- }
-
- // ============================================================================
- // Command Handling
- // ============================================================================
-
- /**
- * Apply a playback command
- */
- async applyCommand(command: SendCommand): Promise {
- console.log(`SyncPlay PlaybackCore: applyCommand - ${command.Command}`);
-
- // Parse the When time from string
- const commandWhen = command.When ? new Date(command.When) : new Date();
- const positionTicks = command.PositionTicks ?? 0;
-
- // Duplicate command handling — don't blindly skip. Match jellyfin-web:
- // if the duplicate's scheduled time has already passed and local player
- // state doesn't match, re-apply (with a force-offset for seek). This
- // self-heals after a missed broadcast, reconnect, or local drift.
- if (this.lastCommand?.When) {
- const lastWhen = new Date(this.lastCommand.When);
- if (
- lastWhen.getTime() === commandWhen.getTime() &&
- this.lastCommand.PositionTicks === command.PositionTicks &&
- this.lastCommand.Command === command.Command &&
- this.lastCommand.PlaylistItemId === command.PlaylistItemId
- ) {
- const whenLocal = this.timeSyncCore.remoteDateToLocal(commandWhen);
- if (whenLocal > new Date()) {
- // Still in the future — already scheduled, nothing to do.
- console.debug(
- "SyncPlay PlaybackCore: duplicate (still scheduled), skipping",
- );
- return;
- }
-
- if (!this.playerControls) {
- console.debug(
- "SyncPlay PlaybackCore: duplicate past command but no player",
- );
- return;
- }
-
- const currentPositionMs = this.playerControls.getCurrentPosition();
- const isPlaying = this.playerControls.isPlaying();
- const targetMs = ticksToMs(positionTicks);
- const positionMatches =
- Math.abs(currentPositionMs - targetMs) <= POSITION_MATCH_TOLERANCE_MS;
-
- switch (command.Command) {
- case "Unpause":
- if (!isPlaying) {
- console.debug("SyncPlay PlaybackCore: dup Unpause — reconciling");
- await this.scheduleUnpause(commandWhen, positionTicks);
- }
- return;
- case "Pause":
- if (isPlaying || !positionMatches) {
- console.debug("SyncPlay PlaybackCore: dup Pause — reconciling");
- this.schedulePause(commandWhen, positionTicks);
- }
- return;
- case "Stop":
- if (isPlaying) {
- console.debug("SyncPlay PlaybackCore: dup Stop — reconciling");
- this.scheduleStop(commandWhen);
- }
- return;
- case "Seek": {
- if (!isPlaying && positionMatches) {
- // Already paused at target — just confirm ready.
- this.sendBufferingRequest(false);
- return;
- }
- // Force a re-seek with a small random offset so the player
- // actually moves (server tolerates a range).
- const randomOffsetTicks =
- Math.round((Math.random() - 0.5) * SEEK_FORCE_RANGE_MS) *
- TicksPerMillisecond;
- console.debug(
- `SyncPlay PlaybackCore: dup Seek — reconciling with offset ${randomOffsetTicks} ticks`,
- );
- this.scheduleSeek(commandWhen, positionTicks + randomOffsetTicks);
- return;
- }
- default:
- console.error(
- "SyncPlay PlaybackCore: unrecognized duplicate command",
- command,
- );
- return;
- }
- }
- }
-
- this.lastCommand = command;
-
- if (!this.playerControls) {
- console.error("SyncPlay PlaybackCore: NO PLAYER CONTROLS!");
- return;
- }
-
- console.log(
- `SyncPlay PlaybackCore: executing ${command.Command} scheduled for ${commandWhen.toISOString()}`,
- );
-
- switch (command.Command) {
- case "Unpause":
- await this.scheduleUnpause(commandWhen, positionTicks);
- break;
- case "Pause":
- this.schedulePause(commandWhen, positionTicks);
- break;
- case "Stop":
- this.scheduleStop(commandWhen);
- break;
- case "Seek":
- this.scheduleSeek(commandWhen, positionTicks);
- break;
- default:
- console.error("SyncPlay PlaybackCore: unrecognized command", command);
- }
- }
-
- /**
- * Schedule an unpause at a specific time
- */
- private async scheduleUnpause(
- playAtTime: Date,
- positionTicks: number,
- ): Promise {
- this.clearScheduledCommand();
-
- const currentTime = new Date();
- const playAtTimeLocal = this.timeSyncCore.remoteDateToLocal(playAtTime);
- const positionMs = ticksToMs(positionTicks);
-
- if (playAtTimeLocal > currentTime) {
- // Future command - schedule it
- const playTimeout = playAtTimeLocal.getTime() - currentTime.getTime();
-
- // Pre-seek only when we're AHEAD of the target by more than the skip
- // threshold. If we're behind, the unpause itself plays forward and
- // SkipToSync/SpeedToSync will catch us up — forward-seeking now would
- // just cause needless buffering. (Matches jellyfin-web.)
- const currentPositionMs = this.playerControls?.getCurrentPosition() ?? 0;
- const aheadByMs = currentPositionMs - positionMs;
- console.log(
- `SyncPlay: pre-seek check - current=${currentPositionMs}ms, target=${positionMs}ms, aheadBy=${aheadByMs}ms, threshold=${this.minDelaySkipToSync}ms`,
- );
- if (aheadByMs > this.minDelaySkipToSync) {
- console.log(`SyncPlay: PRE-SEEKING back to ${positionMs}ms`);
- this.localSeek(positionMs);
- }
-
- this.scheduledCommandTimeout = setTimeout(() => {
- this.localUnpause();
- this.onNotifyOsd?.("unpause");
-
- // Enable sync after a delay
- this.syncTimeout = setTimeout(() => {
- this.syncEnabled = true;
- }, this.maxDelaySpeedToSync / 2);
- }, playTimeout);
-
- console.debug(`SyncPlay: scheduled unpause in ${playTimeout}ms`);
- } else {
- // Past command - play immediately and seek to estimated position
- const elapsed = currentTime.getTime() - playAtTimeLocal.getTime();
- const serverPositionTicks = positionTicks + elapsed * TicksPerMillisecond;
- const serverPositionMs = ticksToMs(serverPositionTicks);
-
- this.localUnpause();
- this.localSeek(serverPositionMs);
-
- setTimeout(() => {
- this.onNotifyOsd?.("unpause");
- }, 100);
-
- this.syncTimeout = setTimeout(() => {
- this.syncEnabled = true;
- }, this.maxDelaySpeedToSync / 2);
-
- console.debug(`SyncPlay: immediate unpause at ${serverPositionMs}ms`);
- }
- }
-
- /**
- * Schedule a pause at a specific time
- */
- private schedulePause(pauseAtTime: Date, positionTicks: number): void {
- console.log("SyncPlay PlaybackCore: schedulePause called");
- this.clearScheduledCommand();
-
- const currentTime = new Date();
- const pauseAtTimeLocal = this.timeSyncCore.remoteDateToLocal(pauseAtTime);
- const positionMs = ticksToMs(positionTicks);
-
- const callback = () => {
- console.log("SyncPlay PlaybackCore: EXECUTING PAUSE NOW");
-
- // If we're already paused at the target position, do nothing.
- // jellyfin-web gets this for free because HTML5 video's seekTo is a
- // no-op when the target equals currentTime, and pause() is a no-op
- // when already paused. Our PlayerControls.seekTo always actually
- // seeks, which triggers waiting→canplay and a notifyBuffering →
- // notifyReady cycle. The server reacts by re-sending Pause, which
- // re-enters this callback → infinite feedback loop. Guarding here
- // breaks the loop while preserving normal pause behaviour.
- if (this.playerControls) {
- const isPlaying = this.playerControls.isPlaying();
- const currentPositionMs = this.playerControls.getCurrentPosition();
- const positionMatches =
- positionMs <= 100 ||
- Math.abs(currentPositionMs - positionMs) <=
- POSITION_MATCH_TOLERANCE_MS;
- if (!isPlaying && positionMatches) {
- console.debug(
- "SyncPlay PlaybackCore: already paused at target position, skipping",
- );
- this.onNotifyOsd?.("pause");
- return;
- }
- }
-
- this.localPause();
- // Only seek if we have a valid position (not 0 or very small)
- if (positionMs > 100) {
- this.localSeek(positionMs);
- } else {
- console.log("SyncPlay PlaybackCore: skipping seek (no valid position)");
- }
- this.onNotifyOsd?.("pause");
- };
-
- if (pauseAtTimeLocal > currentTime) {
- const pauseTimeout = pauseAtTimeLocal.getTime() - currentTime.getTime();
- this.scheduledCommandTimeout = setTimeout(callback, pauseTimeout);
- console.log(
- `SyncPlay PlaybackCore: scheduled pause in ${pauseTimeout}ms`,
- );
- } else {
- console.log("SyncPlay PlaybackCore: immediate pause (past time)");
- callback();
- }
- }
-
- /**
- * Schedule a stop at a specific time
- */
- private scheduleStop(stopAtTime: Date): void {
- this.clearScheduledCommand();
-
- const currentTime = new Date();
- const stopAtTimeLocal = this.timeSyncCore.remoteDateToLocal(stopAtTime);
-
- const callback = () => {
- this.localStop();
- };
-
- if (stopAtTimeLocal > currentTime) {
- const stopTimeout = stopAtTimeLocal.getTime() - currentTime.getTime();
- this.scheduledCommandTimeout = setTimeout(callback, stopTimeout);
- console.debug(`SyncPlay: scheduled stop in ${stopTimeout}ms`);
- } else {
- callback();
- console.debug("SyncPlay: immediate stop");
- }
- }
-
- /**
- * Schedule a seek at a specific time.
- *
- * Flow (matches jellyfin-web): unpause -> seek -> wait for local "ready"
- * (player finished buffering at the new position) -> pause and report ready
- * to the server so the group can resume. This handles the common case
- * where the player must rebuffer after the seek.
- */
- private scheduleSeek(seekAtTime: Date, positionTicks: number): void {
- this.clearScheduledCommand();
-
- const currentTime = new Date();
- const seekAtTimeLocal = this.timeSyncCore.remoteDateToLocal(seekAtTime);
- const positionMs = ticksToMs(positionTicks);
-
- const callback = () => {
- this.localUnpause();
- this.localSeek(positionMs);
- this.onNotifyOsd?.("seek");
-
- // Wait for the local player to report ready ("onReady" fires this),
- // then pause and tell the server we're ready at the new position.
- waitForOwnEvent(this, "ready", WAIT_FOR_READY_TIMEOUT_MS)
- .then(() => {
- this.localPause();
- this.sendBufferingRequest(false);
- })
- .catch((error) => {
- console.warn(
- `SyncPlay: seek ready-wait timed out, seeking to ${positionMs}ms anyway`,
- error,
- );
- this.localSeek(positionMs);
- });
- };
-
- if (seekAtTimeLocal > currentTime) {
- const seekTimeout = seekAtTimeLocal.getTime() - currentTime.getTime();
- this.scheduledCommandTimeout = setTimeout(callback, seekTimeout);
- console.debug(`SyncPlay: scheduled seek in ${seekTimeout}ms`);
- } else {
- callback();
- console.debug("SyncPlay: immediate seek");
- }
- }
-
- /**
- * Clear scheduled command
- */
- private clearScheduledCommand(): void {
- if (this.scheduledCommandTimeout) {
- clearTimeout(this.scheduledCommandTimeout);
- this.scheduledCommandTimeout = null;
- }
- if (this.syncTimeout) {
- clearTimeout(this.syncTimeout);
- this.syncTimeout = null;
- }
-
- this.syncEnabled = false;
-
- // Reset playback rate
- if (this.playerControls && this.playerControls.getSpeed() !== 1.0) {
- this.playerControls.setSpeed(1.0);
- }
-
- this.emit("syncing", false, "None");
- }
-
- // ============================================================================
- // Local Playback Control
- // ============================================================================
-
- private localUnpause(): void {
- this.playerControls?.play();
- }
-
- private localPause(): void {
- this.playerControls?.pause();
- }
-
- private localSeek(positionMs: number): void {
- console.log(`SyncPlay PlaybackCore: localSeek to ${positionMs}ms`);
- if (this.playerControls) {
- this.playerControls.seekTo(positionMs);
- console.log("SyncPlay PlaybackCore: seekTo called on playerControls");
- } else {
- console.error("SyncPlay PlaybackCore: NO PLAYER CONTROLS for seek!");
- }
- }
-
- private localStop(): void {
- this.playerControls?.pause();
- }
-
- // ============================================================================
- // Time Sync
- // ============================================================================
-
- /**
- * Estimate current position ticks given a past state
- */
- estimateCurrentTicks(
- ticks: number,
- when: Date,
- currentTime: Date = new Date(),
- ): number {
- const remoteTime = this.timeSyncCore.localDateToRemote(currentTime);
- return (
- ticks + (remoteTime.getTime() - when.getTime()) * TicksPerMillisecond
- );
- }
-
- /**
- * Sync playback time during playback
- */
- syncPlaybackTime(currentPositionMs: number): void {
- if (!this.playerControls || !this.lastCommand) return;
-
- // Only sync during unpause
- if (this.lastCommand.Command !== "Unpause" || this.isBuffering()) return;
-
- // Don't apply sync corrections if the active player isn't on the same
- // playlist item that the group is playing (e.g. user switched item
- // locally, or queue update in flight). Prevents seeking the wrong item.
- const currentItemId = this.getCurrentPlaylistItemId?.();
- if (
- currentItemId &&
- this.lastCommand.PlaylistItemId &&
- this.lastCommand.PlaylistItemId !== currentItemId
- ) {
- return;
- }
-
- const currentTime = new Date();
- const currentPositionTicks = msToTicks(currentPositionMs);
- const lastCommandWhen = this.lastCommand.When
- ? new Date(this.lastCommand.When)
- : new Date();
-
- // Estimate server position
- const serverPositionTicks = this.estimateCurrentTicks(
- this.lastCommand.PositionTicks ?? 0,
- lastCommandWhen,
- currentTime,
- );
-
- // Calculate difference
- const diffMillis =
- (serverPositionTicks - currentPositionTicks) / TicksPerMillisecond;
- this.playbackDiffMillis = diffMillis;
-
- this.emit("playback-diff", diffMillis);
-
- // Rate-limit sync attempts
- const elapsed = currentTime.getTime() - this.lastSyncTime.getTime();
- if (elapsed < this.maxDelaySpeedToSync / 2) return;
-
- this.lastSyncTime = currentTime;
-
- if (!this.syncEnabled || !this.enableSyncCorrection) return;
-
- const absDiffMillis = Math.abs(diffMillis);
-
- // SpeedToSync
- if (
- this.useSpeedToSync &&
- absDiffMillis >= this.minDelaySpeedToSync &&
- absDiffMillis < this.maxDelaySpeedToSync
- ) {
- let speedToSyncTime = this.speedToSyncDuration;
-
- // Prevent negative speed
- const MinSpeed = 0.2;
- if (diffMillis <= -speedToSyncTime * MinSpeed) {
- speedToSyncTime = Math.abs(diffMillis) / (1.0 - MinSpeed);
- }
-
- const speed = 1 + diffMillis / speedToSyncTime;
-
- if (speed > 0) {
- this.playerControls.setSpeed(speed);
- this.syncEnabled = false;
- this.syncAttempts++;
- this.emit("syncing", true, `SpeedToSync (x${speed.toFixed(2)})`);
-
- this.syncTimeout = setTimeout(() => {
- this.playerControls?.setSpeed(1.0);
- this.syncEnabled = true;
- this.emit("syncing", false, "None");
- }, speedToSyncTime);
-
- console.debug(`SyncPlay: SpeedToSync x${speed.toFixed(2)}`);
- }
- }
- // SkipToSync
- else if (this.useSkipToSync && absDiffMillis >= this.minDelaySkipToSync) {
- const serverPositionMs = ticksToMs(serverPositionTicks);
- this.localSeek(serverPositionMs);
- this.syncEnabled = false;
- this.syncAttempts++;
- this.emit("syncing", true, `SkipToSync (${this.syncAttempts})`);
-
- this.syncTimeout = setTimeout(() => {
- this.syncEnabled = true;
- this.emit("syncing", false, "None");
- }, this.maxDelaySpeedToSync / 2);
-
- console.debug(`SyncPlay: SkipToSync to ${serverPositionMs}ms`);
- } else {
- // Synced
- if (this.syncAttempts > 0) {
- console.debug(`SyncPlay: synced after ${this.syncAttempts} attempts`);
- }
- this.syncAttempts = 0;
- }
- }
-
- // ============================================================================
- // Cleanup
- // ============================================================================
-
- /**
- * Reset PlaybackCore state — used when SyncPlay is disabled so we don't
- * carry stale commands, scheduled timers, or sync state into the next
- * session.
- */
- reset(): void {
- this.clearScheduledCommand();
- this.lastCommand = null;
- this.lastSyncTime = new Date();
- this.syncAttempts = 0;
- this.playbackDiffMillis = 0;
- this.playerIsBuffering = false;
- // Forget what we last told the server so the next session starts fresh.
- this.lastBufferingSent = null;
- this.inflightBufferingRequest = null;
- if (this.notifyBufferingTimeout) {
- clearTimeout(this.notifyBufferingTimeout);
- this.notifyBufferingTimeout = null;
- }
- // Drop a pending pause-before-ready flag so it can't leak into the
- // next group.
- this.pendingInitialPause = false;
- }
-
- /**
- * Destroy the playback core
- */
- destroy(): void {
- this.clearScheduledCommand();
- this.removeAllListeners();
- this.playerControls = null;
- this.onNotifyOsd = null;
- this.getCurrentPlaylistItemId = null;
- }
-}
diff --git a/providers/SyncPlay/QueueCore.ts b/providers/SyncPlay/QueueCore.ts
deleted file mode 100644
index 1b8d78f1f..000000000
--- a/providers/SyncPlay/QueueCore.ts
+++ /dev/null
@@ -1,292 +0,0 @@
-/**
- * QueueCore
- *
- * Manages the shared playlist/queue for SyncPlay.
- * Handles queue updates from the server.
- *
- * Based on jellyfin-web's QueueCore.js
- */
-
-import { EventEmitter } from "./Helper";
-import type {
- GroupRepeatMode,
- GroupShuffleMode,
- PlayQueueUpdate,
- SyncPlayQueueItem,
-} from "./types";
-
-/**
- * QueueCore - Manages the shared playlist
- */
-export class QueueCore extends EventEmitter {
- // Queue state
- private lastPlayQueueUpdate: PlayQueueUpdate | null = null;
- private playlist: SyncPlayQueueItem[] = [];
-
- // Callbacks
- private onStartPlayback: (() => void) | null = null;
- private estimateCurrentTicks: ((ticks: number, when: Date) => number) | null =
- null;
-
- /**
- * Set the start playback callback
- */
- setStartPlaybackHandler(handler: (() => void) | null): void {
- this.onStartPlayback = handler;
- }
-
- /**
- * Set the ticks estimator function
- */
- setTicksEstimator(
- estimator: ((ticks: number, when: Date) => number) | null,
- ): void {
- this.estimateCurrentTicks = estimator;
- }
-
- // ============================================================================
- // Queue State
- // ============================================================================
-
- /**
- * Get the current playlist
- */
- getPlaylist(): SyncPlayQueueItem[] {
- return [...this.playlist];
- }
-
- /**
- * Check if playlist is empty
- */
- isPlaylistEmpty(): boolean {
- return this.playlist.length === 0;
- }
-
- /**
- * Get current playing index
- */
- getCurrentPlaylistIndex(): number {
- return this.lastPlayQueueUpdate?.PlayingItemIndex ?? -1;
- }
-
- /**
- * Get current playlist item ID
- */
- getCurrentPlaylistItemId(): string | null {
- if (!this.lastPlayQueueUpdate) return null;
-
- const index = this.lastPlayQueueUpdate.PlayingItemIndex;
- if (index === undefined || index === -1 || index >= this.playlist.length) {
- return null;
- }
-
- return this.playlist[index]?.PlaylistItemId ?? null;
- }
-
- /**
- * Get current item's Jellyfin ID (the actual media item ID)
- */
- getCurrentItemId(): string | null {
- if (!this.lastPlayQueueUpdate) return null;
-
- const index = this.lastPlayQueueUpdate.PlayingItemIndex;
- if (index === undefined || index === -1 || index >= this.playlist.length) {
- return null;
- }
-
- return this.playlist[index]?.ItemId ?? null;
- }
-
- /**
- * Get the current item from the playlist
- */
- getCurrentItem(): SyncPlayQueueItem | null {
- if (!this.lastPlayQueueUpdate) return null;
-
- const index = this.lastPlayQueueUpdate.PlayingItemIndex;
- if (index === undefined || index === -1 || index >= this.playlist.length) {
- return null;
- }
-
- return this.playlist[index] ?? null;
- }
-
- /**
- * Get the last update time
- */
- getLastUpdate(): Date | null {
- const lastUpdate = this.lastPlayQueueUpdate?.LastUpdate;
- return lastUpdate ? new Date(lastUpdate) : null;
- }
-
- /**
- * Get the last update time as timestamp
- */
- getLastUpdateTime(): number {
- const lastUpdate = this.lastPlayQueueUpdate?.LastUpdate;
- return lastUpdate ? new Date(lastUpdate).getTime() : 0;
- }
-
- /**
- * Get start position ticks
- */
- getStartPositionTicks(): number {
- return this.lastPlayQueueUpdate?.StartPositionTicks ?? 0;
- }
-
- /**
- * Get repeat mode
- */
- getRepeatMode(): GroupRepeatMode {
- return this.lastPlayQueueUpdate?.RepeatMode ?? "RepeatNone";
- }
-
- /**
- * Get shuffle mode
- */
- getShuffleMode(): GroupShuffleMode {
- return this.lastPlayQueueUpdate?.ShuffleMode ?? "Sorted";
- }
-
- /**
- * Get playlist as item IDs
- */
- getPlaylistAsItemIds(): (string | undefined)[] {
- return this.playlist.map((item) => item.ItemId);
- }
-
- // ============================================================================
- // Queue Updates
- // ============================================================================
-
- /**
- * Update the play queue from server
- */
- async updatePlayQueue(update: PlayQueueUpdate): Promise {
- // Parse the last update time
- const updateTime = update.LastUpdate
- ? new Date(update.LastUpdate).getTime()
- : 0;
-
- // Ignore old updates
- if (updateTime <= this.getLastUpdateTime()) {
- console.debug("SyncPlay QueueCore: ignoring old update", update);
- return;
- }
-
- console.log("SyncPlay QueueCore: processing update", {
- reason: update.Reason,
- position: update.StartPositionTicks,
- index: update.PlayingItemIndex,
- });
-
- // Check for position change (seek)
- const oldPosition = this.lastPlayQueueUpdate?.StartPositionTicks ?? 0;
- const newPosition = update.StartPositionTicks ?? 0;
- const positionChanged = Math.abs(newPosition - oldPosition) > 10000000; // > 1 second difference
-
- // Store the update
- this.lastPlayQueueUpdate = update;
- this.playlist = update.Playlist ?? [];
-
- // Emit update event
- this.emit("queue-update", update);
-
- // Handle different update reasons
- switch (update.Reason) {
- case "NewPlaylist":
- // Start playback with new playlist
- this.onStartPlayback?.();
- break;
-
- case "SetCurrentItem":
- case "NextItem":
- case "PreviousItem":
- // Item changed
- this.emit("item-change", this.getCurrentPlaylistItemId());
- break;
-
- case "RemoveItems":
- case "MoveItem":
- case "Queue":
- case "QueueNext":
- // Playlist modified
- this.emit("playlist-change", this.playlist);
- break;
-
- case "RepeatMode":
- this.emit("repeat-mode-change", update.RepeatMode);
- break;
-
- case "ShuffleMode":
- this.emit("shuffle-mode-change", update.ShuffleMode);
- break;
-
- default:
- console.debug(
- "SyncPlay QueueCore: unhandled update reason",
- update.Reason,
- );
- break;
- }
-
- // Emit seek if position changed significantly (likely a seek from another device)
- if (positionChanged && update.Reason !== "NewPlaylist") {
- console.log(
- `SyncPlay QueueCore: position changed ${oldPosition} -> ${newPosition}, emitting seek`,
- );
- this.emit("seek", newPosition);
- }
- }
-
- /**
- * Get estimated start position based on last command
- */
- getEstimatedStartPosition(
- lastCommandPositionTicks: number | null,
- lastCommandWhen: Date | null,
- ): number {
- if (lastCommandPositionTicks !== null && lastCommandWhen !== null) {
- // Use playback command if recent enough
- if (lastCommandWhen.getTime() >= this.getLastUpdateTime()) {
- return (
- this.estimateCurrentTicks?.(
- lastCommandPositionTicks,
- lastCommandWhen,
- ) ?? lastCommandPositionTicks
- );
- }
- }
-
- // Fall back to queue update position
- const startTicks = this.getStartPositionTicks();
- const lastUpdate = this.getLastUpdate();
- if (lastUpdate) {
- return this.estimateCurrentTicks?.(startTicks, lastUpdate) ?? startTicks;
- }
-
- return startTicks;
- }
-
- // ============================================================================
- // Cleanup
- // ============================================================================
-
- /**
- * Clear the queue
- */
- clear(): void {
- this.lastPlayQueueUpdate = null;
- this.playlist = [];
- }
-
- /**
- * Destroy the queue core
- */
- destroy(): void {
- this.clear();
- this.removeAllListeners();
- this.onStartPlayback = null;
- this.estimateCurrentTicks = null;
- }
-}
diff --git a/providers/SyncPlay/SyncPlayProvider.tsx b/providers/SyncPlay/SyncPlayProvider.tsx
index a9c722d13..89413be29 100644
--- a/providers/SyncPlay/SyncPlayProvider.tsx
+++ b/providers/SyncPlay/SyncPlayProvider.tsx
@@ -1,8 +1,19 @@
/**
- * SyncPlayProvider
+ * SyncPlayProvider — React glue around `SyncPlayManager`.
*
- * React context provider for SyncPlay functionality.
- * Manages the SyncPlay manager and exposes hooks for components.
+ * 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";
@@ -22,64 +33,35 @@ 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 type { Controller as SyncPlayController } from "./Controller";
+import { ticksToMs } from "./constants";
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
-// ============================================================================
+import { useSyncPlayWebSocket } from "./transport/useSyncPlayWebSocket";
+import type { GroupInfoDto, PlayerControls } from "./types";
interface SyncPlayContextValue {
- // State
isEnabled: boolean;
- isReady: boolean;
groupInfo: GroupInfoDto | null;
canJoinGroups: boolean;
canCreateGroups: boolean;
- // Group management
joinGroup: (groupId: string) => Promise;
createGroup: (groupName?: string) => Promise;
leaveGroup: () => Promise;
getGroups: () => Promise;
- // Playback control delegation
controller: SyncPlayController | null;
- // Player integration
setPlayerControls: (controls: PlayerControls | null) => void;
notifyReady: () => void;
- notifyBuffering: () => void;
+ notifyBuffering: (isBuffering: boolean) => void;
+ notifyPlaybackStart: () => 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(null);
-// ============================================================================
-// Provider Component
-// ============================================================================
-
interface SyncPlayProviderProps {
children: ReactNode;
}
@@ -89,176 +71,61 @@ export function SyncPlayProvider({ children }: SyncPlayProviderProps) {
const user = useAtomValue(userAtom);
const router = useAppRouter();
- // Core modules - use state for manager so WebSocket hook re-runs when ready
const [manager, setManager] = useState(null);
- const playbackCoreRef = useRef(null);
- const queueCoreRef = useRef(null);
- const controllerRef = useRef(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(null);
- const [osdAction, setOsdAction] = useState(null);
- const [isSyncing, setIsSyncing] = useState(false);
- const [syncMethod, setSyncMethod] = useState("None");
+ const [groupInfo, setGroupInfo] = useState(null);
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]);
+ 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
+ // ---------------------------------------------------------------------------
- // 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);
+ const mgr = new SyncPlayManager(api);
+ mgr.init();
+ setManager(mgr);
- 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();
+ 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: new playlist but no current item ID");
+ console.warn("SyncPlay: localPlay called with no ids");
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(),
+ itemId,
+ playbackPosition: String(options.startPositionTicks ?? 0),
syncPlay: "true",
}).toString();
-
router.push(`/player/direct-player?${queryParams}`);
setTimeout(() => {
@@ -266,113 +133,103 @@ export function SyncPlayProvider({ children }: SyncPlayProviderProps) {
}, 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);
+ // 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);
});
- // Subscribe to manager events
- manager.on("enabled", (...args: unknown[]) => {
+ mgr.on("enabled", (...args: unknown[]) => {
const enabled = args[0] as boolean;
setIsEnabled(enabled);
- if (!enabled) {
- setIsReady(false);
- setGroupInfoDto(null);
- }
+ if (!enabled) setGroupInfo(null);
});
- manager.on("syncing", (...args: unknown[]) => {
- const syncing = args[0] as boolean;
- const method = args[1] as string;
- setIsSyncing(syncing);
- setSyncMethod(method);
+ mgr.on("group-update", (...args: unknown[]) => {
+ setGroupInfo((args[0] as GroupInfoDto | null | undefined) ?? null);
});
- // 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[]) => {
+ mgr.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);
+ // group-state-change → on "Waiting", park the player at the last
+ // broadcast position so it's ready to resume cleanly.
+ mgr.on("group-state-change", (...args: unknown[]) => {
+ const state = args[0] 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`,
+ );
+ }
}
});
- // Initialize
- manager.init();
+ 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 () => {
- manager.destroy();
- playbackCore.destroy();
- queueCore.destroy();
+ mgr.destroy();
setManager(null);
- playbackCoreRef.current = null;
- queueCoreRef.current = null;
- controllerRef.current = null;
};
- }, [api]);
+ }, [api, router]);
- // Update group info when enabled
+ // Initial join race: once `enabled` flips true, snapshot the current group.
useEffect(() => {
if (isEnabled && manager) {
- setGroupInfoDto(manager.getGroupInfo());
- setIsReady(manager.isSyncPlayReady());
+ setGroupInfo(manager.getGroupInfo());
}
}, [isEnabled, manager]);
- // Connect to WebSocket messages - manager is now state so hook re-runs when ready
+ // Wire WebSocket messages → manager
useSyncPlayWebSocket(manager);
- // ============================================================================
- // Group Management
- // ============================================================================
+ // ---------------------------------------------------------------------------
+ // Group management
+ // ---------------------------------------------------------------------------
const getGroups = useCallback(async (): Promise => {
if (!api) return [];
-
try {
- const syncPlayApi = getSyncPlayApi(api);
- const response = await syncPlayApi.syncPlayGetGroups();
+ const response = await getSyncPlayApi(api).syncPlayGetGroups();
return (response.data as unknown as GroupInfoDto[]) ?? [];
} catch (error) {
console.error("SyncPlay: failed to get groups", error);
@@ -383,13 +240,9 @@ export function SyncPlayProvider({ children }: SyncPlayProviderProps) {
const joinGroup = useCallback(
async (groupId: string): Promise => {
if (!api) return;
-
try {
- const syncPlayApi = getSyncPlayApi(api);
- await syncPlayApi.syncPlayJoinGroup({
- joinGroupRequestDto: {
- GroupId: groupId,
- },
+ await getSyncPlayApi(api).syncPlayJoinGroup({
+ joinGroupRequestDto: { GroupId: groupId },
});
} catch (error) {
console.error("SyncPlay: failed to join group", error);
@@ -402,15 +255,10 @@ export function SyncPlayProvider({ children }: SyncPlayProviderProps) {
const createGroup = useCallback(
async (groupName?: string): Promise => {
if (!api || !user) return;
-
const name = groupName || `${user.Name}'s Group`;
-
try {
- const syncPlayApi = getSyncPlayApi(api);
- await syncPlayApi.syncPlayCreateGroup({
- newGroupRequestDto: {
- GroupName: name,
- },
+ await getSyncPlayApi(api).syncPlayCreateGroup({
+ newGroupRequestDto: { GroupName: name },
});
} catch (error) {
console.error("SyncPlay: failed to create group", error);
@@ -422,23 +270,18 @@ export function SyncPlayProvider({ children }: SyncPlayProviderProps) {
const leaveGroup = useCallback(async (): Promise => {
if (!api) return;
-
try {
- const syncPlayApi = getSyncPlayApi(api);
- await syncPlayApi.syncPlayLeaveGroup();
+ await getSyncPlayApi(api).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.
+ // ---------------------------------------------------------------------------
+ // App foreground re-join (idempotent; gets us a fresh GroupJoined snapshot)
+ // ---------------------------------------------------------------------------
+
const lastGroupIdRef = useRef(null);
useEffect(() => {
lastGroupIdRef.current = groupInfo?.GroupId ?? null;
@@ -456,15 +299,12 @@ export function SyncPlayProvider({ children }: SyncPlayProviderProps) {
(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.
+ // Small delay so the WebSocket has a moment to reconnect.
setTimeout(() => {
console.log(`SyncPlay: app foregrounded, rejoining group ${groupId}`);
getSyncPlayApi(api)
@@ -475,56 +315,48 @@ export function SyncPlayProvider({ children }: SyncPlayProviderProps) {
}, 1000);
});
- return () => {
- subscription.remove();
- };
+ return () => subscription.remove();
}, [api]);
- // ============================================================================
- // Player Integration
- // ============================================================================
+ // ---------------------------------------------------------------------------
+ // Player attach bridges
+ // ---------------------------------------------------------------------------
const setPlayerControls = useCallback(
(controls: PlayerControls | null) => {
+ // Reset the playbackstart latch on each new attach.
+ playbackStartFiredRef.current = false;
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?.notifyReady();
}, [manager]);
- // ============================================================================
- // Context Value
- // ============================================================================
+ 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,
- isReady,
groupInfo,
canJoinGroups,
canCreateGroups,
@@ -532,19 +364,15 @@ export function SyncPlayProvider({ children }: SyncPlayProviderProps) {
createGroup,
leaveGroup,
getGroups,
- controller: controllerRef.current,
+ controller: manager?.getController() ?? null,
setPlayerControls,
notifyReady,
notifyBuffering,
- getStats,
- osdAction,
- isSyncing,
- syncMethod,
+ notifyPlaybackStart,
pendingPlaybackCommand,
}),
[
isEnabled,
- isReady,
groupInfo,
canJoinGroups,
canCreateGroups,
@@ -552,13 +380,11 @@ export function SyncPlayProvider({ children }: SyncPlayProviderProps) {
createGroup,
leaveGroup,
getGroups,
+ manager,
setPlayerControls,
notifyReady,
notifyBuffering,
- getStats,
- osdAction,
- isSyncing,
- syncMethod,
+ notifyPlaybackStart,
pendingPlaybackCommand,
],
);
@@ -570,13 +396,6 @@ export function SyncPlayProvider({ children }: SyncPlayProviderProps) {
);
}
-// ============================================================================
-// Hooks
-// ============================================================================
-
-/**
- * Hook to access SyncPlay state and actions
- */
export function useSyncPlay(): SyncPlayContextValue {
const context = useContext(SyncPlayContext);
if (!context) {
@@ -584,11 +403,3 @@ export function useSyncPlay(): SyncPlayContextValue {
}
return context;
}
-
-/**
- * Hook to access the SyncPlay controller
- */
-export function useSyncPlayController(): SyncPlayController | null {
- const { controller } = useSyncPlay();
- return controller;
-}
diff --git a/providers/SyncPlay/TimeSyncCore.ts b/providers/SyncPlay/TimeSyncCore.ts
deleted file mode 100644
index a3727b962..000000000
--- a/providers/SyncPlay/TimeSyncCore.ts
+++ /dev/null
@@ -1,284 +0,0 @@
-/**
- * TimeSyncCore
- *
- * Manages time synchronization with the Jellyfin server.
- * Uses NTP-like algorithm to calculate clock offset between client and server.
- *
- * Based on jellyfin-web's TimeSyncCore.js and TimeSync.js
- */
-
-import type { Api } from "@jellyfin/sdk";
-import { getTimeSyncApi } from "@jellyfin/sdk/lib/utils/api";
-import type { TimeSyncMeasurement } from "./types";
-
-// Time estimation constants
-const NumberOfTrackedMeasurements = 8;
-const PollingIntervalGreedy = 1000; // ms - fast polling initially
-const PollingIntervalLowProfile = 60000; // ms - slow polling once synced
-const GreedyPingCount = 3;
-
-/**
- * Stores a single time sync measurement
- */
-class Measurement {
- requestSent: number;
- requestReceived: number;
- responseSent: number;
- responseReceived: number;
-
- constructor(data: TimeSyncMeasurement) {
- this.requestSent = data.requestSent;
- this.requestReceived = data.requestReceived;
- this.responseSent = data.responseSent;
- this.responseReceived = data.responseReceived;
- }
-
- /**
- * Calculate time offset from server, in milliseconds.
- * Offset = (t1 - t0 + t2 - t3) / 2
- * where t0 = request sent, t1 = request received, t2 = response sent, t3 = response received
- */
- getOffset(): number {
- return (
- (this.requestReceived -
- this.requestSent +
- (this.responseSent - this.responseReceived)) /
- 2
- );
- }
-
- /**
- * Get round-trip delay, in milliseconds.
- */
- getDelay(): number {
- return (
- this.responseReceived -
- this.requestSent -
- (this.responseSent - this.requestReceived)
- );
- }
-
- /**
- * Get ping time (half of round-trip), in milliseconds.
- */
- getPing(): number {
- return this.getDelay() / 2;
- }
-}
-
-export type TimeSyncEventCallback = (
- error: Error | null,
- timeOffset: number | null,
- ping: number | null,
-) => void;
-
-/**
- * TimeSyncCore - Manages time synchronization with the server
- */
-export class TimeSyncCore {
- private api: Api;
- private poller: ReturnType | null = null;
- private pingStop = true;
- private pollingInterval = PollingIntervalGreedy;
- private pings = 0;
- private measurement: Measurement | null = null;
- private measurements: Measurement[] = [];
- private extraTimeOffset = 0;
- private onUpdateCallback: TimeSyncEventCallback | null = null;
-
- constructor(api: Api) {
- this.api = api;
- }
-
- /**
- * Set callback for time sync updates
- */
- onUpdate(callback: TimeSyncEventCallback): void {
- this.onUpdateCallback = callback;
- }
-
- /**
- * Check if time sync is ready (has at least one measurement)
- */
- isReady(): boolean {
- return this.measurement !== null;
- }
-
- /**
- * Get the current time offset with server, in milliseconds.
- */
- getTimeOffset(): number {
- return (this.measurement?.getOffset() ?? 0) + this.extraTimeOffset;
- }
-
- /**
- * Get current ping time to server, in milliseconds.
- */
- getPing(): number {
- return this.measurement?.getPing() ?? 0;
- }
-
- /**
- * Set extra time offset for manual adjustment
- */
- setExtraTimeOffset(offset: number): void {
- this.extraTimeOffset = offset;
- }
-
- /**
- * Convert server time to local time.
- */
- remoteDateToLocal(remote: Date): Date {
- // remote - local = offset, so local = remote - offset
- return new Date(remote.getTime() - this.getTimeOffset());
- }
-
- /**
- * Convert local time to server time.
- */
- localDateToRemote(local: Date): Date {
- // remote - local = offset, so remote = local + offset
- return new Date(local.getTime() + this.getTimeOffset());
- }
-
- /**
- * Get the display name of the sync device
- */
- getActiveDeviceName(): string {
- return "Server";
- }
-
- /**
- * Make a ping request to the server to measure time offset
- */
- private async requestPing(): Promise {
- const requestSent = Date.now();
-
- const timeSyncApi = getTimeSyncApi(this.api);
- const response = await timeSyncApi.getUtcTime();
-
- const responseReceived = Date.now();
- const data = response.data;
-
- const requestReceived = new Date(data.RequestReceptionTime!).getTime();
- const responseSent = new Date(data.ResponseTransmissionTime!).getTime();
-
- return {
- requestSent,
- requestReceived,
- responseSent,
- responseReceived,
- };
- }
-
- /**
- * Update time offset with a new measurement
- */
- private updateTimeOffset(measurement: Measurement): void {
- this.measurements.push(measurement);
-
- if (this.measurements.length > NumberOfTrackedMeasurements) {
- this.measurements.shift();
- }
-
- // Pick measurement with minimum delay (most accurate)
- const sortedMeasurements = [...this.measurements].sort(
- (a, b) => a.getDelay() - b.getDelay(),
- );
- this.measurement = sortedMeasurements[0];
- }
-
- /**
- * Internal poller for ping requests
- */
- private internalRequestPing(): void {
- if (this.poller !== null || this.pingStop) {
- return;
- }
-
- this.poller = setTimeout(async () => {
- this.poller = null;
-
- try {
- const result = await this.requestPing();
- this.onPingSuccess(result);
- } catch (error) {
- this.onPingError(error as Error);
- }
-
- // Schedule next ping
- this.internalRequestPing();
- }, this.pollingInterval);
- }
-
- /**
- * Handle successful ping response
- */
- private onPingSuccess(result: TimeSyncMeasurement): void {
- const measurement = new Measurement(result);
- this.updateTimeOffset(measurement);
-
- // Slow down polling after initial greedy phase
- if (this.pings >= GreedyPingCount) {
- this.pollingInterval = PollingIntervalLowProfile;
- } else {
- this.pings++;
- }
-
- this.onUpdateCallback?.(null, this.getTimeOffset(), this.getPing());
- }
-
- /**
- * Handle ping error
- */
- private onPingError(error: Error): void {
- console.error("SyncPlay TimeSyncCore: ping error", error);
- this.onUpdateCallback?.(error, null, null);
- }
-
- /**
- * Start the time sync poller
- */
- startPing(): void {
- this.pingStop = false;
- this.internalRequestPing();
- }
-
- /**
- * Stop the time sync poller
- */
- stopPing(): void {
- this.pingStop = true;
- if (this.poller !== null) {
- clearTimeout(this.poller);
- this.poller = null;
- }
- }
-
- /**
- * Force an immediate update (reset to greedy mode)
- */
- forceUpdate(): void {
- this.stopPing();
- this.pollingInterval = PollingIntervalGreedy;
- this.pings = 0;
- this.startPing();
- }
-
- /**
- * Drop all accumulated measurements
- */
- resetMeasurements(): void {
- this.measurement = null;
- this.measurements = [];
- }
-
- /**
- * Clean up resources
- */
- destroy(): void {
- this.stopPing();
- this.resetMeasurements();
- this.onUpdateCallback = null;
- }
-}
diff --git a/providers/SyncPlay/constants.ts b/providers/SyncPlay/constants.ts
new file mode 100644
index 000000000..bfade370a
--- /dev/null
+++ b/providers/SyncPlay/constants.ts
@@ -0,0 +1,23 @@
+/**
+ * Constants — shared timing/threshold values used across SyncPlay files.
+ * Kept separate from `types.ts` because these are implementation tuning
+ * values, not the public protocol/types surface.
+ */
+
+import { TicksPerMillisecond } from "./types";
+
+export { TicksPerMillisecond };
+
+/** Default timeout for `waitForEventOnce` (matches jellyfin-web). */
+export const WaitForEventDefaultTimeout = 30000;
+
+/** Short-lived timeout for player events (matches jellyfin-web). */
+export const WaitForPlayerEventTimeout = 500;
+
+export function ticksToMs(ticks: number): number {
+ return ticks / TicksPerMillisecond;
+}
+
+export function msToTicks(ms: number): number {
+ return Math.round(ms * TicksPerMillisecond);
+}
diff --git a/providers/SyncPlay/cores/PlaybackCore.ts b/providers/SyncPlay/cores/PlaybackCore.ts
new file mode 100644
index 000000000..3a2d2c83d
--- /dev/null
+++ b/providers/SyncPlay/cores/PlaybackCore.ts
@@ -0,0 +1,371 @@
+/**
+ * SyncPlay PlaybackCore — schedules unpauses/pauses/seeks/stops to fire
+ * at the precise group-wide moment and keeps the player drift-corrected.
+ *
+ * Design choices that diverge from jellyfin-web:
+ * - **No SpeedToSync**. Our RN players' `setPlaybackSpeed` is unreliable
+ * across platforms (mpv/VLC/expo-video each behave differently for
+ * fractional speeds). We always seek to catch up.
+ * - **No `MessageSyncPlayDuplicateMedia` detection**. Web's detection
+ * used HTML element identity; on RN we don't have a stable handle
+ * and the false-positive rate would be much higher than the value.
+ * - **No syncMethod / showSyncIcon**. We don't surface the sync
+ * technique to the UI.
+ */
+
+import type { Api } from "@jellyfin/sdk";
+import { getSyncPlayApi } from "@jellyfin/sdk/lib/utils/api";
+import {
+ TicksPerMillisecond,
+ ticksToMs,
+ WaitForPlayerEventTimeout,
+} from "../constants";
+import { EventEmitter, waitForEventOnce } from "../EventEmitter";
+import type { SyncPlayManager } from "../Manager";
+import { type SendCommand, SYNC_PLAY_TUNING } from "../types";
+
+export class PlaybackCore extends EventEmitter {
+ private manager!: SyncPlayManager;
+ private lastCommand: SendCommand | null = null;
+ private scheduledCommand: ReturnType | null = null;
+
+ init(manager: SyncPlayManager): void {
+ this.manager = manager;
+ }
+
+ /** Local "playback started" hook — fires the initial Ready request. */
+ onPlaybackStart(apiClient: Api): void {
+ try {
+ const playerWrapper = this.manager.getPlayerWrapper();
+ const positionMs = playerWrapper.currentTime();
+ const positionTicks = Math.round(positionMs * TicksPerMillisecond);
+ const isPlaying = playerWrapper.isPlaying();
+ const playlistItemId =
+ this.manager.getQueueCore().getCurrentPlaylistItemId() ?? undefined;
+ const now = this.manager.getTimeSync().localDateToRemote(new Date());
+
+ getSyncPlayApi(apiClient).syncPlayReady({
+ readyRequestDto: {
+ When: now.toISOString(),
+ PositionTicks: positionTicks,
+ IsPlaying: isPlaying,
+ PlaylistItemId: playlistItemId,
+ },
+ });
+ } catch (error) {
+ console.error("SyncPlay onPlaybackStart:", error);
+ }
+ }
+
+ /** Local pause → tell the server. */
+ onPause(apiClient: Api): void {
+ try {
+ getSyncPlayApi(apiClient).syncPlayPause();
+ } catch (error) {
+ console.error("SyncPlay onPause:", error);
+ }
+ }
+
+ /** Local unpause → tell the server. */
+ onUnpause(apiClient: Api): void {
+ try {
+ getSyncPlayApi(apiClient).syncPlayUnpause();
+ } catch (error) {
+ console.error("SyncPlay onUnpause:", error);
+ }
+ }
+
+ /** Local "ready" hook — server uses this to know we've finished buffering. */
+ onReady(apiClient: Api): void {
+ this.sendBufferingRequest(apiClient, false);
+ }
+
+ /** Local "buffering" hook — server uses this to (optionally) pause the group. */
+ onBuffering(apiClient: Api): void {
+ this.sendBufferingRequest(apiClient, true);
+ }
+
+ /** Send a Ready or Buffering request. */
+ sendBufferingRequest(apiClient: Api, isBuffering: boolean): void {
+ const playerWrapper = this.manager.getPlayerWrapper();
+ const positionMs = playerWrapper.currentTime();
+ const positionTicks = Math.round(positionMs * TicksPerMillisecond);
+ const isPlaying = playerWrapper.isPlaying();
+ const playlistItemId =
+ this.manager.getQueueCore().getCurrentPlaylistItemId() ?? undefined;
+ const now = this.manager.getTimeSync().localDateToRemote(new Date());
+
+ try {
+ if (isBuffering) {
+ getSyncPlayApi(apiClient).syncPlayBuffering({
+ bufferRequestDto: {
+ When: now.toISOString(),
+ PositionTicks: positionTicks,
+ IsPlaying: isPlaying,
+ PlaylistItemId: playlistItemId,
+ },
+ });
+ } else {
+ getSyncPlayApi(apiClient).syncPlayReady({
+ readyRequestDto: {
+ When: now.toISOString(),
+ PositionTicks: positionTicks,
+ IsPlaying: isPlaying,
+ PlaylistItemId: playlistItemId,
+ },
+ });
+ }
+ } catch (error) {
+ console.error("SyncPlay sendBufferingRequest:", error);
+ }
+ }
+
+ /**
+ * Apply a group command (Unpause, Pause, Stop, Seek). Times the
+ * execution to fire at the group-wide instant the server selected.
+ */
+ applyCommand(command: SendCommand): void {
+ (command as unknown as { EmittedAt: Date }).EmittedAt = new Date(
+ command.EmittedAt as unknown as string,
+ );
+ (command as unknown as { When: Date }).When = new Date(
+ command.When as unknown as string,
+ );
+
+ if (
+ this.lastCommand &&
+ ((
+ this.lastCommand as unknown as { EmittedAt: Date }
+ ).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())
+ ) {
+ console.debug(
+ "SyncPlay applyCommand: dropping outdated command",
+ command,
+ );
+ return;
+ }
+
+ this.lastCommand = command;
+ if (!this.manager.isFollowingGroupPlayback()) {
+ console.debug(
+ "SyncPlay applyCommand: dropping command (not following playback)",
+ command,
+ );
+ return;
+ }
+
+ const playerWrapper = this.manager.getPlayerWrapper();
+ if (!playerWrapper.isPlaybackActive()) {
+ console.debug(
+ "SyncPlay applyCommand: dropping command (playback not active)",
+ command,
+ );
+ return;
+ }
+
+ const enqueuedAt = new Date();
+ const remoteEnqueuedAt = this.manager
+ .getTimeSync()
+ .localDateToRemote(enqueuedAt);
+ const localCommandWhen = this.manager
+ .getTimeSync()
+ .remoteDateToLocal(command.When as unknown as Date);
+
+ switch (command.Command) {
+ case "Unpause":
+ this.scheduleUnpause(localCommandWhen, command.PositionTicks ?? 0);
+ this.emit("osd", "unpause");
+ break;
+ case "Pause":
+ this.schedulePause(localCommandWhen, command.PositionTicks ?? 0);
+ this.emit("osd", "pause");
+ break;
+ case "Stop":
+ this.scheduleStop(localCommandWhen);
+ break;
+ case "Seek":
+ this.scheduleSeek(localCommandWhen, command.PositionTicks ?? 0);
+ this.emit("osd", "seek");
+ break;
+ default:
+ console.warn("SyncPlay applyCommand: unknown command", command);
+ break;
+ }
+
+ if (
+ (command as unknown as { When: Date }).When.getTime() <
+ remoteEnqueuedAt.getTime()
+ ) {
+ console.debug(
+ "SyncPlay applyCommand: command was scheduled for the past",
+ command,
+ );
+ }
+ }
+
+ /** Pre-Unpause hook: emit a wait OSD while we wait for the moment. */
+ scheduleUnpause(when: Date, positionTicks: number): void {
+ this.clearScheduledCommand();
+ const now = Date.now();
+ const playAtTime = when.getTime();
+ const currentPositionMs = this.manager.getPlayerWrapper().currentTime();
+ const currentPositionTicks = Math.round(
+ currentPositionMs * TicksPerMillisecond,
+ );
+
+ if (playAtTime > now) {
+ // Future: seek now, then play at the right moment.
+ this.localSeek(positionTicks);
+ this.scheduledCommand = setTimeout(() => {
+ this.localUnpause();
+ // After playback resumes, the player position will need a
+ // small bump to land on the group target. waitForPlayerEvent
+ // is best-effort.
+ waitForEventOnce(
+ this.manager,
+ "unpause",
+ WaitForPlayerEventTimeout,
+ ).catch(() => undefined);
+ }, playAtTime - now);
+ this.emit("osd", "wait-unpause");
+ } else {
+ // Past: catch up now.
+ const targetMs = ticksToMs(positionTicks);
+ const delayMs = now - playAtTime;
+ this.localSeek(Math.round((targetMs + delayMs) * TicksPerMillisecond));
+ this.localUnpause();
+ void currentPositionTicks;
+ }
+ }
+
+ schedulePause(when: Date, positionTicks: number): void {
+ this.clearScheduledCommand();
+ const now = Date.now();
+ const pauseAtTime = when.getTime();
+
+ const callback = () => {
+ this.localUnpause();
+ this.localSeek(positionTicks);
+ this.localPause();
+ };
+
+ if (pauseAtTime > now) {
+ this.scheduledCommand = setTimeout(callback, pauseAtTime - now);
+ this.emit("osd", "wait-pause");
+ } else {
+ callback();
+ }
+ }
+
+ scheduleStop(when: Date): void {
+ this.clearScheduledCommand();
+ const now = Date.now();
+ const stopAtTime = when.getTime();
+ if (stopAtTime > now) {
+ this.scheduledCommand = setTimeout(() => {
+ this.localStop();
+ }, stopAtTime - now);
+ } else {
+ this.localStop();
+ }
+ }
+
+ scheduleSeek(when: Date, positionTicks: number): void {
+ this.applyCommand({
+ ...this.lastCommand!,
+ Command: "Pause",
+ PositionTicks: positionTicks,
+ When: when as unknown as string,
+ EmittedAt: new Date().toISOString(),
+ });
+ }
+
+ clearScheduledCommand(): void {
+ if (this.scheduledCommand) {
+ clearTimeout(this.scheduledCommand);
+ this.scheduledCommand = null;
+ }
+ }
+
+ // -- local player ops ------------------------------------------------------
+
+ localUnpause(): void {
+ this.manager.getPlayerWrapper().localUnpause();
+ }
+
+ localPause(): void {
+ this.manager.getPlayerWrapper().localPause();
+ }
+
+ localSeek(positionTicks: number): void {
+ this.manager.getPlayerWrapper().localSeek(positionTicks);
+ }
+
+ localStop(): void {
+ this.manager.getPlayerWrapper().localStop();
+ }
+
+ // -- queries ---------------------------------------------------------------
+
+ getLastCommand(): SendCommand | null {
+ return this.lastCommand;
+ }
+
+ /**
+ * Estimate where the group should be in ticks, given a known
+ * starting position and the time the position was valid at.
+ */
+ estimateCurrentTicks(positionTicks: number, when: Date): number {
+ const lastCommand = this.lastCommand;
+ if (!lastCommand) return positionTicks;
+ const remoteNow = this.manager.getTimeSync().localDateToRemote(new Date());
+ const elapsedMs = remoteNow.getTime() - when.getTime();
+ if (lastCommand.Command === "Unpause") {
+ return positionTicks + elapsedMs * TicksPerMillisecond;
+ }
+ return positionTicks;
+ }
+
+ /**
+ * Drift correction tick — called on every player time update. Skips
+ * to the group's expected position if drift exceeds the threshold.
+ * SpeedToSync is intentionally not implemented (see file header).
+ */
+ syncPlaybackTime(): void {
+ const lastCommand = this.lastCommand;
+ if (lastCommand?.Command !== "Unpause") return;
+
+ const playerWrapper = this.manager.getPlayerWrapper();
+ if (!playerWrapper.isPlaying()) return;
+
+ const currentMs = playerWrapper.currentTime();
+ const expectedTicks = this.estimateCurrentTicks(
+ lastCommand.PositionTicks ?? 0,
+ lastCommand.When as unknown as Date,
+ );
+ const expectedMs = ticksToMs(expectedTicks);
+ const driftMs = Math.abs(currentMs - expectedMs);
+
+ if (driftMs > SYNC_PLAY_TUNING.minDelaySkipToSync) {
+ console.log(
+ `SyncPlay syncPlaybackTime: drift ${driftMs.toFixed(
+ 0,
+ )}ms exceeds threshold, seeking to ${expectedMs.toFixed(0)}ms`,
+ );
+ this.localSeek(expectedTicks);
+ }
+ }
+
+ // -- teardown --------------------------------------------------------------
+
+ destroy(): void {
+ this.clearScheduledCommand();
+ this.lastCommand = null;
+ this.removeAllListeners();
+ }
+}
+
+export default PlaybackCore;
diff --git a/providers/SyncPlay/cores/QueueCore.ts b/providers/SyncPlay/cores/QueueCore.ts
new file mode 100644
index 000000000..e81fefc43
--- /dev/null
+++ b/providers/SyncPlay/cores/QueueCore.ts
@@ -0,0 +1,332 @@
+/**
+ * SyncPlay QueueCore — tracks the group's playlist.
+ *
+ * Responsibilities:
+ * - Handle `PlayQueue` group updates (NewPlaylist, SetCurrentItem,
+ * NextItem, PreviousItem, RemoveItems, etc.)
+ * - Resolve the server's flat list of ItemIds into full `BaseItemDto`s
+ * (with PlaylistItemId glued on for SyncPlay requests)
+ * - Expose `currentPlaylistItemId` — required by every SyncPlay
+ * request (Ready, Buffering, Seek) so the server can ignore stale
+ * ones from before the playlist moved
+ * - On NewPlaylist, ask the server we're ready by sending a Buffering
+ * request after the local player emits `playbackstart`
+ */
+
+import type { Api } from "@jellyfin/sdk";
+import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
+import { getSyncPlayApi } from "@jellyfin/sdk/lib/utils/api";
+import { TicksPerMillisecond, WaitForEventDefaultTimeout } from "../constants";
+import { EventEmitter, waitForEventOnce } from "../EventEmitter";
+import type { SyncPlayManager } from "../Manager";
+import {
+ getItemsForPlayback,
+ translateItemsForPlayback,
+} from "../transport/queueTranslation";
+import type {
+ PlayQueueUpdate,
+ PlayQueueUpdateReason,
+ SyncPlayQueueItem,
+} from "../types";
+
+export class QueueCore extends EventEmitter {
+ private manager!: SyncPlayManager;
+ private lastPlayQueueUpdate: PlayQueueUpdate | null = null;
+ /** Playable items with `PlaylistItemId` glued on. */
+ private playlist: BaseItemDto[] = [];
+
+ init(manager: SyncPlayManager): void {
+ this.manager = manager;
+ }
+
+ /** Handle a PlayQueue group update from the server. */
+ updatePlayQueue(apiClient: Api, newPlayQueue: PlayQueueUpdate): void {
+ (newPlayQueue as unknown as { LastUpdate: Date }).LastUpdate = new Date(
+ newPlayQueue.LastUpdate as unknown as string,
+ );
+
+ if (
+ (newPlayQueue.LastUpdate as unknown as Date).getTime() <=
+ this.getLastUpdateTime()
+ ) {
+ console.debug("SyncPlay updatePlayQueue: ignoring old update");
+ return;
+ }
+
+ this.onPlayQueueUpdate(apiClient, newPlayQueue)
+ .then(() => {
+ if (
+ (newPlayQueue.LastUpdate as unknown as Date).getTime() <
+ this.getLastUpdateTime()
+ ) {
+ console.warn("SyncPlay updatePlayQueue: trying to apply old update");
+ return;
+ }
+
+ const reason = newPlayQueue.Reason as PlayQueueUpdateReason;
+ switch (reason) {
+ case "NewPlaylist": {
+ if (!this.manager.isFollowingGroupPlayback()) {
+ this.manager.followGroupPlayback(apiClient).then(() => {
+ this.startPlayback(apiClient);
+ });
+ } else {
+ this.startPlayback(apiClient);
+ }
+ break;
+ }
+ case "SetCurrentItem":
+ case "NextItem":
+ case "PreviousItem": {
+ const playlistItemId = this.getCurrentPlaylistItemId();
+ this.setCurrentPlaylistItem(apiClient, playlistItemId);
+ break;
+ }
+ case "RemoveItems":
+ case "MoveItem":
+ case "Queue":
+ case "QueueNext":
+ case "RepeatMode":
+ case "ShuffleMode":
+ // Video-focused: we don't expose repeat/shuffle/queue mutation
+ // controls in the RN UI yet, so these reasons just update our
+ // local snapshot (already done by onPlayQueueUpdate) without
+ // triggering any local action.
+ break;
+ default:
+ console.warn(
+ "SyncPlay updatePlayQueue: unknown reason",
+ newPlayQueue.Reason,
+ );
+ break;
+ }
+ })
+ .catch((error) => {
+ console.warn("SyncPlay updatePlayQueue:", error);
+ });
+ }
+
+ /** Apply a play-queue update to local state. */
+ async onPlayQueueUpdate(
+ apiClient: Api,
+ playQueueUpdate: PlayQueueUpdate,
+ ): Promise {
+ const itemIds = (playQueueUpdate.Playlist ?? [])
+ .map((queueItem: SyncPlayQueueItem) => queueItem.ItemId)
+ .filter((id): id is string => typeof id === "string");
+
+ if (!itemIds.length) {
+ this.lastPlayQueueUpdate = playQueueUpdate;
+ this.playlist = [];
+ return;
+ }
+
+ const fetched = await getItemsForPlayback(apiClient, itemIds);
+ const items = await translateItemsForPlayback(apiClient, fetched, {
+ ids: itemIds,
+ });
+
+ if (
+ this.lastPlayQueueUpdate &&
+ (playQueueUpdate.LastUpdate as unknown as Date).getTime() <=
+ this.getLastUpdateTime()
+ ) {
+ throw new Error("Trying to apply old update");
+ }
+
+ // Glue PlaylistItemId from the server's playlist entries onto each
+ // resolved item. The server-assigned IDs are what every SyncPlay
+ // request needs to identify the queue slot.
+ const playlistItems = playQueueUpdate.Playlist ?? [];
+ for (let i = 0; i < items.length && i < playlistItems.length; i++) {
+ items[i].PlaylistItemId = playlistItems[i].PlaylistItemId;
+ }
+
+ this.lastPlayQueueUpdate = playQueueUpdate;
+ this.playlist = items;
+ }
+
+ /**
+ * Send a Ready request once the local player begins playback. The
+ * server uses this to wait until every member is buffered before
+ * issuing the next Unpause.
+ *
+ * On timeout (player never starts), halt group playback so the rest
+ * of the group can proceed without us.
+ */
+ scheduleReadyRequestOnPlaybackStart(apiClient: Api, origin: string): void {
+ waitForEventOnce(
+ this.manager,
+ "playbackstart",
+ WaitForEventDefaultTimeout,
+ ["playbackerror"],
+ )
+ .then(() => {
+ console.debug(
+ "SyncPlay scheduleReadyRequestOnPlaybackStart: notifying server",
+ );
+ const playerWrapper = this.manager.getPlayerWrapper();
+ playerWrapper.localPause();
+
+ const currentPosition = playerWrapper.currentTime();
+ const currentPositionTicks = Math.round(
+ currentPosition * TicksPerMillisecond,
+ );
+ const isPlaying = playerWrapper.isPlaying();
+ const now = this.manager.getTimeSync().localDateToRemote(new Date());
+
+ try {
+ getSyncPlayApi(apiClient).syncPlayReady({
+ readyRequestDto: {
+ When: now.toISOString(),
+ PositionTicks: currentPositionTicks,
+ IsPlaying: isPlaying,
+ PlaylistItemId: this.getCurrentPlaylistItemId() ?? undefined,
+ },
+ });
+ } catch (error) {
+ console.error("SyncPlay syncPlayReady failed", error);
+ }
+ })
+ .catch((error) => {
+ console.error(
+ "Timed out waiting for 'playbackstart' event!",
+ origin,
+ error,
+ );
+ if (!this.manager.isSyncPlayEnabled()) {
+ this.manager.emit("toast", "MessageSyncPlayErrorMedia");
+ }
+ this.manager.haltGroupPlayback(apiClient);
+ });
+ }
+
+ /** Start local playback by navigating to the player screen for the current item. */
+ startPlayback(apiClient: Api): void {
+ if (!this.manager.isFollowingGroupPlayback()) {
+ console.debug("SyncPlay startPlayback: ignoring, not following playback");
+ return;
+ }
+
+ if (this.isPlaylistEmpty()) {
+ console.debug("SyncPlay startPlayback: empty playlist");
+ return;
+ }
+
+ // Estimate where to start playback from. Prefer the last playback
+ // command if newer than the queue update (playback ticks change
+ // more often than queue position).
+ const playbackCommand = this.manager.getLastPlaybackCommand();
+ let startPositionTicks = 0;
+
+ if (
+ playbackCommand &&
+ (
+ playbackCommand as unknown as { EmittedAt: Date }
+ ).EmittedAt?.getTime() >= this.getLastUpdateTime()
+ ) {
+ startPositionTicks = this.manager
+ .getPlaybackCore()
+ .estimateCurrentTicks(
+ playbackCommand.PositionTicks ?? 0,
+ (playbackCommand as unknown as { When: Date }).When,
+ );
+ } else {
+ startPositionTicks = this.manager
+ .getPlaybackCore()
+ .estimateCurrentTicks(
+ this.getStartPositionTicks(),
+ (this.getLastUpdate() ?? new Date()) as Date,
+ );
+ }
+
+ const serverId = apiClient.deviceInfo?.id ?? "";
+
+ this.scheduleReadyRequestOnPlaybackStart(apiClient, "startPlayback");
+
+ this.manager
+ .getPlayerWrapper()
+ .localPlay({
+ ids: this.getPlaylistAsItemIds(),
+ startPositionTicks,
+ startIndex: this.getCurrentPlaylistIndex(),
+ serverId,
+ })
+ .catch((error: unknown) => {
+ console.error("SyncPlay startPlayback: localPlay failed", error);
+ this.manager.emit("toast", "MessageSyncPlayErrorMedia");
+ });
+ }
+
+ /** Navigate to a specific item in the queue. */
+ setCurrentPlaylistItem(apiClient: Api, playlistItemId: string | null): void {
+ if (!this.manager.isFollowingGroupPlayback()) {
+ console.debug(
+ "SyncPlay setCurrentPlaylistItem: ignoring, not following playback",
+ );
+ return;
+ }
+
+ this.scheduleReadyRequestOnPlaybackStart(
+ apiClient,
+ "setCurrentPlaylistItem",
+ );
+
+ this.manager.getPlayerWrapper().localSetCurrentPlaylistItem(playlistItemId);
+ }
+
+ // -- getters ---------------------------------------------------------------
+
+ getCurrentPlaylistIndex(): number {
+ return this.lastPlayQueueUpdate?.PlayingItemIndex ?? -1;
+ }
+
+ getCurrentPlaylistItemId(): string | null {
+ if (!this.lastPlayQueueUpdate) return null;
+ const index = this.lastPlayQueueUpdate.PlayingItemIndex ?? -1;
+ if (index === -1) return null;
+ return this.playlist[index]?.PlaylistItemId ?? null;
+ }
+
+ getPlaylist(): BaseItemDto[] {
+ return this.playlist.slice(0);
+ }
+
+ isPlaylistEmpty(): boolean {
+ return this.playlist.length === 0;
+ }
+
+ getLastUpdate(): Date | null {
+ if (!this.lastPlayQueueUpdate) return null;
+ return this.lastPlayQueueUpdate.LastUpdate as unknown as Date;
+ }
+
+ getLastUpdateTime(): number {
+ if (!this.lastPlayQueueUpdate) return 0;
+ return (this.lastPlayQueueUpdate.LastUpdate as unknown as Date).getTime();
+ }
+
+ getStartPositionTicks(): number {
+ return this.lastPlayQueueUpdate?.StartPositionTicks ?? 0;
+ }
+
+ getPlaylistAsItemIds(): (string | undefined)[] {
+ if (!this.lastPlayQueueUpdate) return [];
+ return (this.lastPlayQueueUpdate.Playlist ?? []).map((q) => q.ItemId);
+ }
+
+ // -- teardown --------------------------------------------------------------
+
+ /** Clear cached playlist. Called on group disable so a re-join starts clean. */
+ clear(): void {
+ this.lastPlayQueueUpdate = null;
+ this.playlist = [];
+ }
+
+ destroy(): void {
+ this.clear();
+ this.removeAllListeners();
+ }
+}
+
+export default QueueCore;
diff --git a/providers/SyncPlay/cores/TimeSync.ts b/providers/SyncPlay/cores/TimeSync.ts
new file mode 100644
index 000000000..cbbf2f521
--- /dev/null
+++ b/providers/SyncPlay/cores/TimeSync.ts
@@ -0,0 +1,220 @@
+/**
+ * TimeSync — NTP-style time synchronisation with the Jellyfin server.
+ *
+ * Merged port of jellyfin-web's `core/timeSync/{TimeSync,TimeSyncServer,
+ * TimeSyncCore}.js` — three classes that exist on web because the
+ * abstract layer supports syncing against other group members, not just
+ * the server. RN only syncs against the server, so it's one class.
+ *
+ * Algorithm: repeatedly time a round-trip request to `getUtcTime`,
+ * compute `offset = ((requestReceived - requestSent) + (responseSent -
+ * responseReceived)) / 2`, keep the minimum-delay measurement out of
+ * the last 8. This is the standard NTP outlier-rejection trick — the
+ * measurement with the shortest delay is the most accurate because
+ * less network jitter could have skewed the timestamps.
+ *
+ * Polling: greedy mode at 1s intervals for the first 3 pings to warm
+ * up the offset, then low-profile at 60s intervals for steady-state.
+ * `forceUpdate()` resets to greedy mode (called on group join).
+ */
+
+import type { Api } from "@jellyfin/sdk";
+import { getTimeSyncApi } from "@jellyfin/sdk/lib/utils/api";
+import { EventEmitter } from "../EventEmitter";
+
+const NumberOfTrackedMeasurements = 8;
+const PollingIntervalGreedy = 1000; // ms
+const PollingIntervalLowProfile = 60000; // ms
+const GreedyPingCount = 3;
+
+class Measurement {
+ requestSent: number;
+ requestReceived: number;
+ responseSent: number;
+ responseReceived: number;
+
+ constructor(
+ requestSent: Date,
+ requestReceived: Date,
+ responseSent: Date,
+ responseReceived: Date,
+ ) {
+ this.requestSent = requestSent.getTime();
+ this.requestReceived = requestReceived.getTime();
+ this.responseSent = responseSent.getTime();
+ this.responseReceived = responseReceived.getTime();
+ }
+
+ /** Time offset (ms): positive means server clock is ahead of ours. */
+ getOffset(): number {
+ return (
+ (this.requestReceived -
+ this.requestSent +
+ (this.responseSent - this.responseReceived)) /
+ 2
+ );
+ }
+
+ /** Round-trip delay (ms), excluding server processing. */
+ getDelay(): number {
+ return (
+ this.responseReceived -
+ this.requestSent -
+ (this.responseSent - this.requestReceived)
+ );
+ }
+
+ /** One-way ping (ms). */
+ getPing(): number {
+ return this.getDelay() / 2;
+ }
+}
+
+/**
+ * Tracks the offset between this client's clock and the Jellyfin server's
+ * clock, and exposes conversions between local and remote Dates.
+ *
+ * Listeners:
+ * - `"update"` (timeOffset: number, ping: number) — fires on every
+ * successful ping. Errors are logged but not emitted; consumers
+ * should treat absence of updates as transient.
+ */
+export class TimeSync extends EventEmitter {
+ private api: Api;
+ private pingStop = true;
+ private pollingInterval = PollingIntervalGreedy;
+ private poller: ReturnType | null = null;
+ private pings = 0;
+ private measurement: Measurement | null = null;
+ private measurements: Measurement[] = [];
+
+ constructor(api: Api) {
+ super();
+ this.api = api;
+ }
+
+ /** Called when the user switches Jellyfin servers. */
+ updateApiClient(api: Api): void {
+ this.api = api;
+ }
+
+ /** Whether we've completed at least one successful measurement. */
+ isReady(): boolean {
+ return !!this.measurement;
+ }
+
+ /** Current best-estimate time offset (ms). */
+ getTimeOffset(): number {
+ return this.measurement ? this.measurement.getOffset() : 0;
+ }
+
+ /** Current best-estimate one-way ping (ms). */
+ getPing(): number {
+ return this.measurement ? this.measurement.getPing() : 0;
+ }
+
+ /** Convert a server-time Date to local time. */
+ remoteDateToLocal(remote: Date): Date {
+ return new Date(remote.getTime() - this.getTimeOffset());
+ }
+
+ /** Convert a local Date to server time. */
+ localDateToRemote(local: Date): Date {
+ return new Date(local.getTime() + this.getTimeOffset());
+ }
+
+ /** Start polling. Idempotent. */
+ startPing(): void {
+ this.pingStop = false;
+ this.scheduleNextPing();
+ }
+
+ /** Stop polling. Idempotent. */
+ stopPing(): void {
+ this.pingStop = true;
+ if (this.poller) {
+ clearTimeout(this.poller);
+ this.poller = null;
+ }
+ }
+
+ /** Reset to greedy polling and force a fresh measurement immediately. */
+ forceUpdate(): void {
+ this.stopPing();
+ this.pollingInterval = PollingIntervalGreedy;
+ this.pings = 0;
+ this.startPing();
+ }
+
+ /** Drop all measurements. Used on group leave. */
+ resetMeasurements(): void {
+ this.measurement = null;
+ this.measurements = [];
+ }
+
+ /** Full teardown on provider unmount. */
+ destroy(): void {
+ this.stopPing();
+ this.resetMeasurements();
+ this.removeAllListeners();
+ }
+
+ private scheduleNextPing(): void {
+ if (this.poller || this.pingStop) return;
+ this.poller = setTimeout(() => {
+ this.poller = null;
+ this.requestPing()
+ .then((result) => this.onPingResponse(result))
+ .catch((error) => {
+ console.error("SyncPlay TimeSync: ping failed", error);
+ })
+ .finally(() => this.scheduleNextPing());
+ }, this.pollingInterval);
+ }
+
+ private async requestPing() {
+ const requestSent = new Date();
+ const response = await getTimeSyncApi(this.api).getUtcTime();
+ const responseReceived = new Date();
+ const data = response.data;
+ const requestReceived = new Date(data.RequestReceptionTime as string);
+ const responseSent = new Date(data.ResponseTransmissionTime as string);
+ return { requestSent, requestReceived, responseSent, responseReceived };
+ }
+
+ private onPingResponse(result: {
+ requestSent: Date;
+ requestReceived: Date;
+ responseSent: Date;
+ responseReceived: Date;
+ }): void {
+ const measurement = new Measurement(
+ result.requestSent,
+ result.requestReceived,
+ result.responseSent,
+ result.responseReceived,
+ );
+
+ this.measurements.push(measurement);
+ if (this.measurements.length > NumberOfTrackedMeasurements) {
+ this.measurements.shift();
+ }
+
+ // Outlier rejection: pick the measurement with the shortest delay.
+ const sorted = [...this.measurements].sort(
+ (a, b) => a.getDelay() - b.getDelay(),
+ );
+ this.measurement = sorted[0];
+
+ // Throttle once we've warmed up.
+ if (this.pings >= GreedyPingCount) {
+ this.pollingInterval = PollingIntervalLowProfile;
+ } else {
+ this.pings++;
+ }
+
+ this.emit("update", this.getTimeOffset(), this.getPing());
+ }
+}
+
+export default TimeSync;
diff --git a/providers/SyncPlay/index.ts b/providers/SyncPlay/index.ts
index 32a5ef32a..02249a66a 100644
--- a/providers/SyncPlay/index.ts
+++ b/providers/SyncPlay/index.ts
@@ -1,25 +1,13 @@
/**
- * SyncPlay Module
+ * SyncPlay — public exports.
*
- * Synchronized playback for Jellyfin.
- * Allows multiple users to watch content together in sync.
+ * Only what external consumers (components, hooks, screens) need.
+ * Internal modules (PlaybackCore, QueueCore, TimeSync, PlayerWrapper,
+ * queueTranslation, EventEmitter, etc.) stay package-private.
*/
-export { SyncPlayController } from "./Controller";
-// Helpers
-export * from "./Helper";
-// Core modules
+export { Controller as SyncPlayController } from "./Controller";
+export { msToTicks, ticksToMs } from "./constants";
export { SyncPlayManager } from "./Manager";
-export { PlaybackCore } from "./PlaybackCore";
-export { QueueCore } from "./QueueCore";
-
-// Provider and hooks
-export {
- SyncPlayProvider,
- useSyncPlay,
- useSyncPlayController,
-} from "./SyncPlayProvider";
-export { TimeSyncCore } from "./TimeSyncCore";
-
-// Types
+export { SyncPlayProvider, useSyncPlay } from "./SyncPlayProvider";
export * from "./types";
diff --git a/providers/SyncPlay/player/PendingPlaybackTracker.ts b/providers/SyncPlay/player/PendingPlaybackTracker.ts
new file mode 100644
index 000000000..3e567496d
--- /dev/null
+++ b/providers/SyncPlay/player/PendingPlaybackTracker.ts
@@ -0,0 +1,58 @@
+/**
+ * PendingPlaybackTracker — tracks an in-flight `Unpause` / `Pause` request
+ * that we've sent to the server but haven't seen echoed back via
+ * `SyncPlayCommand`.
+ *
+ * Drives three things:
+ * 1. Drop duplicate rapid taps
+ * 2. Provide an optimistic-UI hint for the in-flight state
+ * 3. Override "current play state" when deciding pause-vs-unpause
+ * for the next tap
+ *
+ * Auto-clears after `pendingPlaybackTimeoutMs` so a lost broadcast
+ * doesn't freeze the UI forever.
+ */
+
+import { SYNC_PLAY_TUNING } from "../types";
+
+export class PendingPlaybackTracker {
+ private command: "Unpause" | "Pause" | null = null;
+ private timeout: ReturnType | null = null;
+ private onChange: ((cmd: "Unpause" | "Pause" | null) => void) | null = null;
+
+ setChangeHandler(
+ handler: ((cmd: "Unpause" | "Pause" | null) => void) | null,
+ ): void {
+ this.onChange = handler;
+ }
+
+ get(): "Unpause" | "Pause" | null {
+ return this.command;
+ }
+
+ mark(command: "Unpause" | "Pause"): void {
+ this.command = command;
+ if (this.timeout) clearTimeout(this.timeout);
+ this.timeout = setTimeout(() => {
+ console.debug(
+ "SyncPlay PendingPlaybackTracker: timed out waiting for broadcast",
+ command,
+ );
+ this.command = null;
+ this.timeout = null;
+ this.onChange?.(null);
+ }, SYNC_PLAY_TUNING.pendingPlaybackTimeoutMs);
+ this.onChange?.(command);
+ }
+
+ clear(): void {
+ if (this.timeout) {
+ clearTimeout(this.timeout);
+ this.timeout = null;
+ }
+ if (this.command !== null) {
+ this.command = null;
+ this.onChange?.(null);
+ }
+ }
+}
diff --git a/providers/SyncPlay/player/PlayerWrapper.ts b/providers/SyncPlay/player/PlayerWrapper.ts
new file mode 100644
index 000000000..7cc7bea42
--- /dev/null
+++ b/providers/SyncPlay/player/PlayerWrapper.ts
@@ -0,0 +1,87 @@
+/**
+ * PlayerWrapper — adapter between jellyfin's tick-based playerWrapper API
+ * and our millisecond-based `PlayerControls`. Methods that have no RN
+ * analog (queue mutation hooks) delegate to provider-supplied handlers
+ * which navigate to the player screen.
+ */
+
+import { TicksPerMillisecond } from "../constants";
+import type { PlayerControls } from "../types";
+
+/** Options passed to `playerWrapper.localPlay` — provider navigates to the player screen. */
+export interface LocalPlayOptions {
+ ids: (string | undefined)[];
+ startPositionTicks: number;
+ startIndex: number;
+ serverId?: string;
+}
+
+export class PlayerWrapper {
+ private controls: PlayerControls | null = null;
+ private localPlayHandler: ((options: LocalPlayOptions) => void) | null = null;
+ private setCurrentItemHandler:
+ | ((playlistItemId: string | null) => void)
+ | null = null;
+
+ /** Attach / detach the underlying player. */
+ bindToControls(controls: PlayerControls | null): void {
+ this.controls = controls;
+ }
+
+ /** Provider wires this to navigate to the player screen. */
+ setLocalPlayHandler(handler: ((options: LocalPlayOptions) => void) | null) {
+ this.localPlayHandler = handler;
+ }
+
+ /** Provider wires this to navigate to a different queue item. */
+ setLocalSetCurrentItemHandler(
+ handler: ((playlistItemId: string | null) => void) | null,
+ ) {
+ this.setCurrentItemHandler = handler;
+ }
+
+ localUnpause(): void {
+ this.controls?.play();
+ }
+
+ localPause(): void {
+ this.controls?.pause();
+ }
+
+ /** Upstream takes ticks; RN's `seekTo` takes ms. */
+ localSeek(positionTicks: number): void {
+ this.controls?.seekTo(positionTicks / TicksPerMillisecond);
+ }
+
+ /** RN: pause instead of teardown — leaving the player screen is the navigator's job. */
+ localStop(): void {
+ this.controls?.pause();
+ }
+
+ /** Position in ms. */
+ currentTime(): number {
+ return this.controls?.getCurrentPosition() ?? 0;
+ }
+
+ isPlaying(): boolean {
+ return this.controls?.isPlaying() ?? false;
+ }
+
+ isPlaybackActive(): boolean {
+ return this.controls !== null;
+ }
+
+ /** RN never runs as a remote-managed player. */
+ isRemote(): boolean {
+ return false;
+ }
+
+ localPlay(options: LocalPlayOptions): Promise {
+ this.localPlayHandler?.(options);
+ return Promise.resolve();
+ }
+
+ localSetCurrentPlaylistItem(playlistItemId: string | null): void {
+ this.setCurrentItemHandler?.(playlistItemId);
+ }
+}
diff --git a/providers/SyncPlay/player/bufferingDebouncer.ts b/providers/SyncPlay/player/bufferingDebouncer.ts
new file mode 100644
index 000000000..736e3c50d
--- /dev/null
+++ b/providers/SyncPlay/player/bufferingDebouncer.ts
@@ -0,0 +1,64 @@
+/**
+ * bufferingDebouncer — wrap an `isBuffering: boolean → void` notify callback
+ * with three RN-only guards. Web gets these for free from HTML `waiting`/
+ * `canplay`; our `PlayerControls` exposes state (not events) and the React
+ * effect that polls it can fire many times per second.
+ *
+ * - **dedup**: drop redundant calls when state hasn't changed
+ * - **debounce buffering→true**: only escalate after the threshold;
+ * going back to ready cancels the pending escalation
+ * - **coalesce inflight**: serialize concurrent sends
+ *
+ * Returns `{ notify, dispose }`.
+ */
+
+import { SYNC_PLAY_TUNING } from "../types";
+
+export function createBufferingDebouncer(
+ send: (isBuffering: boolean) => Promise,
+) {
+ let lastSent: boolean | null = null;
+ let inflight: Promise | null = null;
+ let pendingTimeout: ReturnType | null = null;
+
+ const flush = async (isBuffering: boolean) => {
+ if (lastSent === isBuffering) return;
+ if (inflight) {
+ try {
+ await inflight;
+ } catch {
+ // ignore — used only for ordering
+ }
+ if (lastSent === isBuffering) return;
+ }
+ lastSent = isBuffering;
+ inflight = send(isBuffering).finally(() => {
+ inflight = null;
+ });
+ return inflight;
+ };
+
+ return {
+ notify(isBuffering: boolean): void {
+ if (pendingTimeout) {
+ clearTimeout(pendingTimeout);
+ pendingTimeout = null;
+ }
+ if (!isBuffering) {
+ // Ready always fires immediately.
+ void flush(false);
+ return;
+ }
+ pendingTimeout = setTimeout(() => {
+ pendingTimeout = null;
+ void flush(true);
+ }, SYNC_PLAY_TUNING.minBufferingThresholdMs);
+ },
+ dispose(): void {
+ if (pendingTimeout) {
+ clearTimeout(pendingTimeout);
+ pendingTimeout = null;
+ }
+ },
+ };
+}
diff --git a/providers/SyncPlay/player/reconcileToGroupOnAttach.ts b/providers/SyncPlay/player/reconcileToGroupOnAttach.ts
new file mode 100644
index 000000000..51753c6df
--- /dev/null
+++ b/providers/SyncPlay/player/reconcileToGroupOnAttach.ts
@@ -0,0 +1,58 @@
+/**
+ * reconcileToGroupOnAttach — estimate the group's current position from
+ * the last play/pause broadcast and seek the freshly-attached player
+ * there if drift exceeds the threshold.
+ *
+ * Web's player binds at group-join, so this race doesn't exist there.
+ * On RN the player mounts in a separate route after the join, so
+ * commands arrive before controls attach. Without this, the player
+ * resumes from its local position and is silently behind the group.
+ */
+
+import { TicksPerMillisecond } from "../constants";
+import {
+ type PlayerControls,
+ type SendCommand,
+ SYNC_PLAY_TUNING,
+} from "../types";
+
+export function reconcileToGroupOnAttach(
+ controls: PlayerControls,
+ lastCommand: SendCommand | null,
+ localToRemote: (local: Date) => Date,
+): void {
+ if (
+ !lastCommand ||
+ (lastCommand.Command !== "Unpause" && lastCommand.Command !== "Pause") ||
+ !lastCommand.When ||
+ lastCommand.PositionTicks == null
+ ) {
+ return;
+ }
+
+ try {
+ const commandWhen = new Date(lastCommand.When);
+ let targetTicks = lastCommand.PositionTicks;
+ if (lastCommand.Command === "Unpause") {
+ const remoteNow = localToRemote(new Date());
+ targetTicks +=
+ (remoteNow.getTime() - commandWhen.getTime()) * TicksPerMillisecond;
+ }
+ const targetMs = Math.max(0, targetTicks / TicksPerMillisecond);
+ const currentMs = controls.getCurrentPosition();
+ if (
+ Math.abs(currentMs - targetMs) >
+ SYNC_PLAY_TUNING.positionReconcileThresholdMs
+ ) {
+ console.log(
+ `SyncPlay: player attached — seeking to estimated group position ${targetMs}ms (was ${currentMs}ms)`,
+ );
+ controls.seekTo(targetMs);
+ }
+ } catch (error) {
+ console.warn(
+ "SyncPlay: failed to estimate group position on attach",
+ error,
+ );
+ }
+}
diff --git a/providers/SyncPlay/transport/queueTranslation.ts b/providers/SyncPlay/transport/queueTranslation.ts
new file mode 100644
index 000000000..04916fa2a
--- /dev/null
+++ b/providers/SyncPlay/transport/queueTranslation.ts
@@ -0,0 +1,183 @@
+/**
+ * queueTranslation — expand container items into a real playable queue.
+ *
+ * The server takes the queue we send via `syncPlaySetNewQueue` and
+ * rebroadcasts it verbatim to every group member. Sending a container
+ * ID (Series, Season, BoxSet, Playlist) means every receiver fails to
+ * open the player because they can't directly play a container. We must
+ * expand to real playable item IDs before sending the queue.
+ *
+ * Video-focused: music (MusicArtist/MusicGenre) and photo branches are
+ * intentionally omitted. Live TV (Program), Episode auto-advance, and
+ * folder expansion are preserved because they're the common video flows.
+ */
+
+import type { Api } from "@jellyfin/sdk";
+import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
+import {
+ getItemsApi,
+ getTvShowsApi,
+ getUserApi,
+ getUserLibraryApi,
+} from "@jellyfin/sdk/lib/utils/api";
+
+export interface TranslateOptions {
+ ids?: string[];
+ shuffle?: boolean;
+ queryOptions?: Record;
+}
+
+const PLAYBACK_FIELDS = ["Chapters", "Trickplay"] as const;
+
+async function getCurrentUser(api: Api) {
+ const user = (await getUserApi(api).getCurrentUser()).data;
+ if (!user?.Id) {
+ throw new Error("SyncPlay queueTranslation: no authenticated user");
+ }
+ return user;
+}
+
+async function queryItems(
+ api: Api,
+ userId: string,
+ params: Record,
+): Promise {
+ const res = await getItemsApi(api).getItems({
+ limit: 300,
+ fields: PLAYBACK_FIELDS as unknown as never,
+ excludeLocationTypes: ["Virtual"] as unknown as never,
+ enableTotalRecordCount: false,
+ collapseBoxSetItems: false,
+ ...params,
+ userId,
+ });
+ return res.data.Items ?? [];
+}
+
+function fetchFolderChildren(
+ api: Api,
+ userId: string,
+ params: Record,
+): Promise {
+ return queryItems(api, userId, {
+ filters: ["IsNotFolder"],
+ recursive: true,
+ ...params,
+ });
+}
+
+/**
+ * Resolve item IDs into full `BaseItemDto`s.
+ *
+ * - single ID → `getItem` (cheap, no Items wrapper)
+ * - multi ID → `getItems` with playback defaults
+ */
+export async function getItemsForPlayback(
+ api: Api,
+ ids: string[],
+): Promise {
+ if (!ids.length) return [];
+ const userId = (await getCurrentUser(api)).Id as string;
+ if (ids.length === 1) {
+ const res = await getUserLibraryApi(api).getItem({
+ userId,
+ itemId: ids[0],
+ });
+ return res.data ? [res.data] : [];
+ }
+ return queryItems(api, userId, { ids });
+}
+
+/**
+ * Expand a "first item" into a real playable queue.
+ *
+ * - Program → channel items
+ * - Playlist → playlist children
+ * - IsFolder (Series, Season, BoxSet, MusicAlbum, ...) → recursive descendants
+ * - single Episode (when `EnableNextEpisodeAutoPlay`) → remaining series episodes
+ * - everything else → passthrough (Movies, Audio, single Episode w/ autoplay off)
+ *
+ * Preserves the caller's `ids` order so the receiver sees the same
+ * queue order the sender intended.
+ */
+export async function translateItemsForPlayback(
+ api: Api,
+ items: BaseItemDto[],
+ options: TranslateOptions = {},
+): Promise {
+ if (!items.length) return [];
+
+ const workingItems =
+ items.length > 1 && options.ids
+ ? [...items].sort(
+ (a, b) =>
+ (options.ids ?? []).indexOf(a.Id ?? "") -
+ (options.ids ?? []).indexOf(b.Id ?? ""),
+ )
+ : items;
+
+ const firstItem = workingItems[0];
+
+ if (firstItem.Type === "Program" && firstItem.ChannelId) {
+ return getItemsForPlayback(api, [firstItem.ChannelId]);
+ }
+
+ const user = await getCurrentUser(api);
+ const userId = user.Id as string;
+
+ if (firstItem.Type === "Playlist") {
+ return queryItems(api, userId, {
+ parentId: firstItem.Id,
+ sortBy: options.shuffle ? ["Random"] : undefined,
+ });
+ }
+
+ if (firstItem.IsFolder) {
+ // Series, Season, BoxSet, MusicAlbum, etc.
+ const sortBy = options.shuffle
+ ? ["Random"]
+ : firstItem.Type === "BoxSet"
+ ? ["SortName"]
+ : undefined;
+ return fetchFolderChildren(api, userId, {
+ parentId: firstItem.Id,
+ mediaTypes: ["Audio", "Video"],
+ sortBy,
+ ...(options.queryOptions ?? {}),
+ });
+ }
+
+ if (firstItem.Type === "Episode" && workingItems.length === 1) {
+ // Single-episode auto-next: load all remaining episodes in the
+ // series, starting at this one. Gated on the user preference so we
+ // don't surprise users who disabled autoplay.
+ if (!user.Configuration?.EnableNextEpisodeAutoPlay || !firstItem.SeriesId) {
+ return workingItems;
+ }
+ const res = await getTvShowsApi(api).getEpisodes({
+ seriesId: firstItem.SeriesId,
+ userId,
+ isMissing: false,
+ fields: PLAYBACK_FIELDS as unknown as never,
+ // SDK omits `isVirtualUnaired` from typed request; server honours
+ // it. Cast keeps wire payload identical to jellyfin-web.
+ ...({ isVirtualUnaired: false } as Record),
+ } as Parameters["getEpisodes"]>[0]);
+ const all = res.data.Items ?? [];
+ // Drop everything before firstItem; keep firstItem and everything
+ // after. Empty list if firstItem isn't in the series (shouldn't
+ // happen, but matches upstream's behaviour).
+ let foundItem = false;
+ return all.filter((e) => {
+ if (foundItem) return true;
+ if (e.Id === firstItem.Id) {
+ foundItem = true;
+ return true;
+ }
+ return false;
+ });
+ }
+
+ // Movies, Audio, single Episode w/ autoplay off, etc.
+ return workingItems;
+}
diff --git a/providers/SyncPlay/useSyncPlayWebSocket.ts b/providers/SyncPlay/transport/useSyncPlayWebSocket.ts
similarity index 83%
rename from providers/SyncPlay/useSyncPlayWebSocket.ts
rename to providers/SyncPlay/transport/useSyncPlayWebSocket.ts
index fcefb9308..ad7e17c99 100644
--- a/providers/SyncPlay/useSyncPlayWebSocket.ts
+++ b/providers/SyncPlay/transport/useSyncPlayWebSocket.ts
@@ -18,8 +18,8 @@
import { useEffect } from "react";
import { useWebSocketContext } from "@/providers/WebSocketProvider";
-import type { SyncPlayManager } from "./Manager";
-import type { SendCommand } from "./types";
+import type { SyncPlayManager } from "../Manager";
+import type { GroupUpdate, SendCommand } from "../types";
/**
* Hook to connect SyncPlay manager to WebSocket
@@ -68,8 +68,15 @@ export function useSyncPlayWebSocket(manager: SyncPlayManager | null): void {
}
case "SyncPlayGroupUpdate": {
- const update = Data as { Type?: string; Data?: unknown };
- console.debug("SyncPlay: group update -", update.Type);
+ // SDK's `GroupUpdate` type is a discriminated union with a
+ // narrower `Type` enum than the wire format. Cast through
+ // unknown so upstream `Manager.processGroupUpdate` can switch
+ // on the real string.
+ const update = Data as unknown as GroupUpdate;
+ console.debug(
+ "SyncPlay: group update -",
+ (update as { Type?: string }).Type,
+ );
manager.processGroupUpdate(update);
break;
}
diff --git a/providers/SyncPlay/types.ts b/providers/SyncPlay/types.ts
index 545bccc00..d46952bfb 100644
--- a/providers/SyncPlay/types.ts
+++ b/providers/SyncPlay/types.ts
@@ -1,83 +1,52 @@
/**
- * SyncPlay Types
+ * SyncPlay — public types and tuning constants.
*
- * Re-exports Jellyfin SDK types and defines app-specific types.
- * Following the pattern used in offline downloads.
+ * Re-exports the SDK types we use, defines the small RN-specific
+ * extensions (PlayerControls, OSD actions), and centralises the magic
+ * numbers that govern sync behaviour.
*/
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
-// ============================================================================
-// Re-export SDK Types
-// ============================================================================
-
-// Group types
-// Playback command types
-// Queue types
-// User access type
-// Group update message types
+// SDK type re-exports — kept narrow on purpose, only what callers
+// actually reach for.
export type {
GroupInfoDto,
+ GroupQueueMode,
GroupRepeatMode,
GroupShuffleMode,
GroupStateType,
GroupUpdate,
- GroupUpdateType,
PlayQueueUpdate,
PlayQueueUpdateReason,
SendCommand,
SendCommandType,
- SyncPlayGroupJoinedUpdate,
- SyncPlayGroupLeftUpdate,
- SyncPlayPlayQueueUpdate,
SyncPlayQueueItem,
- SyncPlayStateUpdate,
SyncPlayUserAccessType,
- SyncPlayUserJoinedUpdate,
- SyncPlayUserLeftUpdate,
} from "@jellyfin/sdk/lib/generated-client/models";
-// ============================================================================
-// Constants
-// ============================================================================
-
+/** Jellyfin's tick unit. 1ms = 10000 ticks. */
export const TicksPerMillisecond = 10000;
-export const WaitForEventDefaultTimeout = 30000; // milliseconds
-export const WaitForPlayerEventTimeout = 500; // milliseconds
-
-// ============================================================================
-// App-Specific Types (not in SDK)
-// ============================================================================
/**
- * Time sync measurement for NTP-like synchronization
- */
-export interface TimeSyncMeasurement {
- requestSent: number;
- requestReceived: number;
- responseSent: number;
- responseReceived: number;
-}
-
-/**
- * Player controls interface for integrating with MPV player
+ * Player controls SyncPlay drives. The provider wires this up against
+ * the active RN player (mpv / VLC / expo-video).
*/
export interface PlayerControls {
play: () => void;
pause: () => void;
+ /** Seek to absolute position in milliseconds. */
seekTo: (positionMs: number) => void;
setSpeed: (speed: number) => void;
getSpeed: () => number;
+ /** Current position in milliseconds. */
getCurrentPosition: () => number;
isPlaying: () => boolean;
isBuffering: () => boolean;
}
-/**
- * OSD action types for visual feedback
- */
+/** OSD action types — drive optional player-overlay feedback. */
export type SyncPlayOsdAction =
- | "schedule-play"
| "unpause"
| "pause"
| "seek"
@@ -86,54 +55,26 @@ export type SyncPlayOsdAction =
| "wait-unpause";
/**
- * SyncPlay settings for sync correction algorithms
+ * Tuning constants. These mirror jellyfin-web's defaults; tweak with
+ * care — they affect perceived sync quality across all clients.
*/
-export interface SyncPlaySettings {
- // SpeedToSync settings
- minDelaySpeedToSync: number;
- maxDelaySpeedToSync: number;
- speedToSyncDuration: number;
+export const SYNC_PLAY_TUNING = {
+ /** Drift threshold (ms) above which we hard-seek to catch up. */
+ minDelaySkipToSync: 400,
+ /** Drift beyond this (ms) is always corrected by seeking. */
+ maxDelaySync: 3000,
+ /** Don't escalate buffering to the group for blips shorter than this (ms). */
+ minBufferingThresholdMs: 3000,
+ /** Player-attach drift (ms) above which we reconcile to group position. */
+ positionReconcileThresholdMs: 500,
+ /** Safety timeout (ms) for in-flight Pause/Unpause optimistic UI. */
+ pendingPlaybackTimeoutMs: 1500,
+} as const;
- // SkipToSync settings
- minDelaySkipToSync: number;
-
- // Feature toggles
- useSpeedToSync: boolean;
- useSkipToSync: boolean;
- enableSyncCorrection: boolean;
-
- // Time sync
- extraTimeOffset: number;
-}
-
-export const DEFAULT_SYNC_PLAY_SETTINGS: SyncPlaySettings = {
- minDelaySpeedToSync: 60.0,
- maxDelaySpeedToSync: 3000.0,
- speedToSyncDuration: 1000.0,
- minDelaySkipToSync: 400.0,
- useSpeedToSync: true,
- useSkipToSync: true,
- enableSyncCorrection: false,
- extraTimeOffset: 0.0,
-};
-
-/**
- * Stats for debugging/display
- */
-export interface SyncPlayStats {
- timeSyncDevice: string;
- timeSyncOffset: string;
- playbackDiff: string;
- syncMethod: string;
-}
-
-/**
- * Play options for starting playback
- */
+/** Options accepted by `Controller.play`. */
export interface PlayOptions {
ids?: string[];
items?: BaseItemDto[];
startIndex?: number;
startPositionTicks?: number;
- serverId?: string;
}