mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-06-05 13:38:27 +01:00
Improve code quality
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<Animated.View style={[styles.container, containerStyle]}>
|
||||
<View style={styles.content}>
|
||||
{/* Pulsing icon container */}
|
||||
<Animated.View style={[styles.iconContainer, pulseStyle]}>
|
||||
<View style={styles.iconCircle}>
|
||||
<Ionicons name='people' size={28} color='white' />
|
||||
</View>
|
||||
</Animated.View>
|
||||
|
||||
{/* Message */}
|
||||
<Text style={styles.message}>{displayMessage}</Text>
|
||||
|
||||
{/* SyncPlay badge */}
|
||||
<View style={styles.badge}>
|
||||
<Ionicons name='sync' size={12} color='white' />
|
||||
<Text style={styles.badgeText}>SyncPlay</Text>
|
||||
</View>
|
||||
</View>
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -4,7 +4,4 @@
|
||||
|
||||
export { GroupSelectionMenu } from "./GroupSelectionMenu";
|
||||
export { SyncPlayButton } from "./SyncPlayButton";
|
||||
export {
|
||||
SyncPlayIndicator,
|
||||
useSyncPlayIndicatorState,
|
||||
} from "./SyncPlayIndicator";
|
||||
export { SyncPlaySpinner } from "./SyncPlaySpinner";
|
||||
|
||||
@@ -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<void> {
|
||||
// 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<void> {
|
||||
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<void> {
|
||||
/** 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<void> {
|
||||
console.log(`SyncPlay Controller: seekMs to ${positionMs}ms`);
|
||||
await this.seek(msToTicks(positionMs));
|
||||
}
|
||||
|
||||
/**
|
||||
* Request stop
|
||||
*/
|
||||
async stop(): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
/** 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<void> {
|
||||
/** 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<typeof getSyncPlayApi>["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<void> {
|
||||
/** 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<typeof getSyncPlayApi>["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<void> {
|
||||
/** 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<void> {
|
||||
await this.queue(itemIds, "QueueNext");
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove items from playlist
|
||||
*/
|
||||
async removeFromPlaylist(playlistItemIds: string[]): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
const currentMode = this.queueCore.getShuffleMode();
|
||||
const newMode: GroupShuffleMode =
|
||||
currentMode === "Sorted" ? "Shuffle" : "Sorted";
|
||||
await this.setShuffleMode(newMode);
|
||||
}
|
||||
}
|
||||
|
||||
export default Controller;
|
||||
|
||||
93
providers/SyncPlay/EventEmitter.ts
Normal file
93
providers/SyncPlay/EventEmitter.ts
Normal file
@@ -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<string, Set<(...args: unknown[]) => 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<unknown[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let timer: ReturnType<typeof setTimeout> | 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);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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<T>(
|
||||
eventEmitter: {
|
||||
addEventListener: (event: string, handler: (data: T) => void) => void;
|
||||
removeEventListener: (event: string, handler: (data: T) => void) => void;
|
||||
},
|
||||
eventType: string,
|
||||
timeout?: number,
|
||||
rejectEvents?: string[],
|
||||
): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let timeoutId: ReturnType<typeof setTimeout> | 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<T>(
|
||||
promise: Promise<T>,
|
||||
timeout: number,
|
||||
): Promise<T> {
|
||||
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<string, Set<(...args: unknown[]) => 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<unknown[]> {
|
||||
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<string, unknown>;
|
||||
}
|
||||
|
||||
/** 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<string, unknown>,
|
||||
): Promise<BaseItemDto[]> {
|
||||
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<string, unknown>,
|
||||
): Promise<BaseItemDto[]> {
|
||||
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<BaseItemDto[]> {
|
||||
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<BaseItemDto[]> {
|
||||
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;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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<typeof setTimeout> | null = null;
|
||||
private syncTimeout: ReturnType<typeof setTimeout> | 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<void> | 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<typeof setTimeout> | 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<SyncPlaySettings>): 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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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<void> {
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
@@ -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<void>;
|
||||
createGroup: (groupName?: string) => Promise<void>;
|
||||
leaveGroup: () => Promise<void>;
|
||||
getGroups: () => Promise<GroupInfoDto[]>;
|
||||
|
||||
// Playback control delegation
|
||||
controller: SyncPlayController | null;
|
||||
|
||||
// Player integration
|
||||
setPlayerControls: (controls: PlayerControls | null) => void;
|
||||
notifyReady: () => void;
|
||||
notifyBuffering: () => void;
|
||||
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<SyncPlayContextValue | null>(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<SyncPlayManager | null>(null);
|
||||
const playbackCoreRef = useRef<PlaybackCore | null>(null);
|
||||
const queueCoreRef = useRef<QueueCore | null>(null);
|
||||
const controllerRef = useRef<SyncPlayController | null>(null);
|
||||
|
||||
// Track if we're already on the player page to avoid duplicate navigations
|
||||
const isNavigatingToPlayerRef = useRef(false);
|
||||
|
||||
// State
|
||||
const [isEnabled, setIsEnabled] = useState(false);
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
const [groupInfo, setGroupInfoDto] = useState<GroupInfoDto | null>(null);
|
||||
const [osdAction, setOsdAction] = useState<SyncPlayOsdAction | null>(null);
|
||||
const [isSyncing, setIsSyncing] = useState(false);
|
||||
const [syncMethod, setSyncMethod] = useState("None");
|
||||
const [groupInfo, setGroupInfo] = useState<GroupInfoDto | null>(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<GroupInfoDto[]> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<string | null>(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;
|
||||
}
|
||||
|
||||
@@ -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<typeof setTimeout> | 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<TimeSyncMeasurement> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
23
providers/SyncPlay/constants.ts
Normal file
23
providers/SyncPlay/constants.ts
Normal file
@@ -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);
|
||||
}
|
||||
371
providers/SyncPlay/cores/PlaybackCore.ts
Normal file
371
providers/SyncPlay/cores/PlaybackCore.ts
Normal file
@@ -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<typeof setTimeout> | 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;
|
||||
332
providers/SyncPlay/cores/QueueCore.ts
Normal file
332
providers/SyncPlay/cores/QueueCore.ts
Normal file
@@ -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<void> {
|
||||
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;
|
||||
220
providers/SyncPlay/cores/TimeSync.ts
Normal file
220
providers/SyncPlay/cores/TimeSync.ts
Normal file
@@ -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<typeof setTimeout> | 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;
|
||||
@@ -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";
|
||||
|
||||
58
providers/SyncPlay/player/PendingPlaybackTracker.ts
Normal file
58
providers/SyncPlay/player/PendingPlaybackTracker.ts
Normal file
@@ -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<typeof setTimeout> | 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
87
providers/SyncPlay/player/PlayerWrapper.ts
Normal file
87
providers/SyncPlay/player/PlayerWrapper.ts
Normal file
@@ -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<void> {
|
||||
this.localPlayHandler?.(options);
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
localSetCurrentPlaylistItem(playlistItemId: string | null): void {
|
||||
this.setCurrentItemHandler?.(playlistItemId);
|
||||
}
|
||||
}
|
||||
64
providers/SyncPlay/player/bufferingDebouncer.ts
Normal file
64
providers/SyncPlay/player/bufferingDebouncer.ts
Normal file
@@ -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<void>,
|
||||
) {
|
||||
let lastSent: boolean | null = null;
|
||||
let inflight: Promise<void> | null = null;
|
||||
let pendingTimeout: ReturnType<typeof setTimeout> | 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;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
58
providers/SyncPlay/player/reconcileToGroupOnAttach.ts
Normal file
58
providers/SyncPlay/player/reconcileToGroupOnAttach.ts
Normal file
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
183
providers/SyncPlay/transport/queueTranslation.ts
Normal file
183
providers/SyncPlay/transport/queueTranslation.ts
Normal file
@@ -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<string, unknown>;
|
||||
}
|
||||
|
||||
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<string, unknown>,
|
||||
): Promise<BaseItemDto[]> {
|
||||
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<string, unknown>,
|
||||
): Promise<BaseItemDto[]> {
|
||||
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<BaseItemDto[]> {
|
||||
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<BaseItemDto[]> {
|
||||
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<string, unknown>),
|
||||
} as Parameters<ReturnType<typeof getTvShowsApi>["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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user