mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-05-31 19:18:26 +01:00
798 lines
24 KiB
TypeScript
798 lines
24 KiB
TypeScript
/**
|
|
* SyncPlay Manager
|
|
*
|
|
* Main orchestrator for SyncPlay functionality.
|
|
* Manages group state, coordinates time sync, playback, and queue.
|
|
*
|
|
* Based on jellyfin-web's Manager.js
|
|
*/
|
|
|
|
import type { Api } from "@jellyfin/sdk";
|
|
import { getSyncPlayApi } from "@jellyfin/sdk/lib/utils/api";
|
|
import { toast } from "sonner-native";
|
|
import i18n from "@/i18n";
|
|
import { EventEmitter, msToTicks } from "./Helper";
|
|
import { TimeSyncCore } from "./TimeSyncCore";
|
|
import type {
|
|
GroupInfoDto,
|
|
PlayerControls,
|
|
PlayQueueUpdate,
|
|
SendCommand,
|
|
SyncPlayStats,
|
|
} from "./types";
|
|
|
|
/**
|
|
* SyncPlay Manager - Main orchestrator
|
|
*/
|
|
export class SyncPlayManager extends EventEmitter {
|
|
private api: Api;
|
|
private timeSyncCore: TimeSyncCore;
|
|
|
|
// Group state
|
|
private groupInfo: GroupInfoDto | null = null;
|
|
private syncPlayEnabledAt: Date | null = null;
|
|
private syncPlayReady = false;
|
|
private queuedCommand: SendCommand | null = null;
|
|
private followingGroupPlayback = true;
|
|
private lastPlaybackCommand: SendCommand | null = null;
|
|
|
|
// Pending play/pause request awaiting server broadcast.
|
|
// Used to (1) ignore duplicate rapid taps and (2) treat the server's
|
|
// upcoming state as the source of truth while a request is in flight.
|
|
private pendingPlaybackCommand: "Unpause" | "Pause" | null = null;
|
|
private pendingPlaybackTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
// Safety: drop the pending guard after this long if no broadcast arrives.
|
|
private static readonly PENDING_PLAYBACK_TIMEOUT_MS = 1500;
|
|
|
|
// Player state
|
|
private playerControls: PlayerControls | null = null;
|
|
private syncMethod = "None";
|
|
|
|
// Callbacks
|
|
private onPlaybackCoreCommand: ((command: SendCommand) => void) | null = null;
|
|
private onQueueUpdate: ((update: PlayQueueUpdate) => void) | null = null;
|
|
private onGetPlaylistItemId: (() => string | null) | null = null;
|
|
// Fired when SyncPlay is disabled — PlaybackCore wires up here to reset its
|
|
// own scheduled timers / cached command so we don't carry stale state into
|
|
// the next group.
|
|
private onDisable: (() => void) | null = null;
|
|
// Fired when SyncPlay is disabled — QueueCore wires up here to clear its
|
|
// last PlayQueue snapshot. Without this, re-joining the same group later
|
|
// causes the first PlayQueue echo (which can have a `LastUpdate` equal to
|
|
// or older than the snapshot we cached before the disable) to be dropped
|
|
// by the stale-update guard in `QueueCore.updatePlayQueue`.
|
|
private onQueueClear: (() => void) | null = null;
|
|
|
|
constructor(api: Api) {
|
|
super();
|
|
this.api = api;
|
|
this.timeSyncCore = new TimeSyncCore(api);
|
|
|
|
// Listen for time sync updates
|
|
this.timeSyncCore.onUpdate((error, timeOffset, ping) => {
|
|
if (error) {
|
|
console.debug("SyncPlay Manager: time sync error", error);
|
|
return;
|
|
}
|
|
|
|
this.emit("time-sync-update", timeOffset, ping);
|
|
|
|
// Report ping to server when enabled
|
|
if (this.isSyncPlayEnabled() && ping !== null) {
|
|
this.sendPing(ping);
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Initialize the manager
|
|
*/
|
|
init(): void {
|
|
this.timeSyncCore.startPing();
|
|
}
|
|
|
|
/**
|
|
* Update the API client
|
|
*/
|
|
updateApiClient(api: Api): void {
|
|
this.api = api;
|
|
}
|
|
|
|
/**
|
|
* Get the API client
|
|
*/
|
|
getApiClient(): Api {
|
|
return this.api;
|
|
}
|
|
|
|
/**
|
|
* Get the time sync core
|
|
*/
|
|
getTimeSyncCore(): TimeSyncCore {
|
|
return this.timeSyncCore;
|
|
}
|
|
|
|
/**
|
|
* Set player controls for playback management
|
|
*/
|
|
setPlayerControls(controls: PlayerControls | null): void {
|
|
this.playerControls = controls;
|
|
|
|
// When player controls are connected and SyncPlay is active, sync to group state
|
|
if (controls && this.isSyncPlayEnabled() && this.syncPlayReady) {
|
|
const state = this.groupInfo?.State;
|
|
console.log(
|
|
`SyncPlay: player controls connected, group state is ${state}`,
|
|
);
|
|
|
|
// CRITICAL: Tell server we're following group playback
|
|
// This ensures the server sends us SyncPlayCommand messages
|
|
this.followGroupPlayback();
|
|
|
|
// Reconcile position: if we know the last command and group is playing,
|
|
// estimate where the group is *now* and seek there before resuming. This
|
|
// fixes the case where the player attaches mid-stream and would
|
|
// otherwise resume from 0 or the last-known local position.
|
|
const last = this.lastPlaybackCommand;
|
|
if (
|
|
last &&
|
|
(last.Command === "Unpause" || last.Command === "Pause") &&
|
|
last.When &&
|
|
last.PositionTicks != null
|
|
) {
|
|
try {
|
|
const commandWhen = new Date(last.When);
|
|
let targetTicks = last.PositionTicks;
|
|
if (last.Command === "Unpause") {
|
|
const remoteNow = this.timeSyncCore.localDateToRemote(new Date());
|
|
targetTicks +=
|
|
(remoteNow.getTime() - commandWhen.getTime()) * 10000;
|
|
}
|
|
const targetMs = Math.max(0, targetTicks / 10000);
|
|
const currentMs = controls.getCurrentPosition();
|
|
if (Math.abs(currentMs - targetMs) > 500) {
|
|
console.log(
|
|
`SyncPlay: player attached — seeking to estimated group position ${targetMs}ms (was ${currentMs}ms)`,
|
|
);
|
|
controls.seekTo(targetMs);
|
|
}
|
|
} catch (error) {
|
|
console.warn(
|
|
"SyncPlay: failed to estimate group position on attach",
|
|
error,
|
|
);
|
|
}
|
|
}
|
|
|
|
if (state === "Playing" && !controls.isPlaying()) {
|
|
console.log("SyncPlay: starting playback to match group");
|
|
controls.play();
|
|
} else if (state === "Paused" && controls.isPlaying()) {
|
|
console.log("SyncPlay: pausing to match group");
|
|
controls.pause();
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get current player controls
|
|
*/
|
|
getPlayerControls(): PlayerControls | null {
|
|
return this.playerControls;
|
|
}
|
|
|
|
/**
|
|
* Set callback for playback commands
|
|
*/
|
|
setPlaybackCommandHandler(
|
|
handler: ((command: SendCommand) => void) | null,
|
|
): void {
|
|
this.onPlaybackCoreCommand = handler;
|
|
}
|
|
|
|
/**
|
|
* Set callback for queue updates
|
|
*/
|
|
setQueueUpdateHandler(
|
|
handler: ((update: PlayQueueUpdate) => void) | null,
|
|
): void {
|
|
this.onQueueUpdate = handler;
|
|
}
|
|
|
|
/**
|
|
* Set callback for getting current playlist item ID
|
|
*/
|
|
setPlaylistItemIdGetter(getter: (() => string | null) | null): void {
|
|
this.onGetPlaylistItemId = getter;
|
|
}
|
|
|
|
/**
|
|
* Set a callback invoked when SyncPlay is disabled. PlaybackCore registers
|
|
* here so it can flush scheduled commands and stale state.
|
|
*/
|
|
setDisableHandler(handler: (() => void) | null): void {
|
|
this.onDisable = handler;
|
|
}
|
|
|
|
/**
|
|
* Set a callback invoked when SyncPlay is disabled. QueueCore registers
|
|
* here so it can drop the cached PlayQueue snapshot and treat the next
|
|
* server update as fresh.
|
|
*/
|
|
setQueueClearHandler(handler: (() => void) | null): void {
|
|
this.onQueueClear = handler;
|
|
}
|
|
|
|
// ============================================================================
|
|
// Group Management
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Check if SyncPlay is enabled (user is in a group)
|
|
*/
|
|
isSyncPlayEnabled(): boolean {
|
|
return this.syncPlayEnabledAt !== null;
|
|
}
|
|
|
|
/**
|
|
* Check if SyncPlay is ready (time sync complete)
|
|
*/
|
|
isSyncPlayReady(): boolean {
|
|
return this.syncPlayReady;
|
|
}
|
|
|
|
/**
|
|
* Get current group info
|
|
*/
|
|
getGroupInfo(): GroupInfoDto | null {
|
|
return this.groupInfo;
|
|
}
|
|
|
|
/**
|
|
* Get the last playback command
|
|
*/
|
|
getLastPlaybackCommand(): SendCommand | null {
|
|
return this.lastPlaybackCommand;
|
|
}
|
|
|
|
/**
|
|
* Check if currently playing
|
|
*/
|
|
isPlaying(): boolean {
|
|
// First check actual player state
|
|
if (this.playerControls) {
|
|
return this.playerControls.isPlaying();
|
|
}
|
|
// Fall back to group state
|
|
if (this.groupInfo?.State) {
|
|
return this.groupInfo.State === "Playing";
|
|
}
|
|
// Last resort: check last command
|
|
return this.lastPlaybackCommand?.Command === "Unpause";
|
|
}
|
|
|
|
/**
|
|
* Effective play state for SyncPlay routing decisions.
|
|
*
|
|
* Prefers (1) a pending in-flight command we just sent, (2) the server's
|
|
* group state, and only falls back to the local player. This avoids the
|
|
* race where a rapid second tap reads the local player (which hasn't
|
|
* applied the scheduled command yet) and sends a duplicate request that
|
|
* either re-broadcasts with a new `When` or flips the group the wrong way.
|
|
*/
|
|
getEffectivePlayState(): "Playing" | "Paused" {
|
|
if (this.pendingPlaybackCommand === "Unpause") return "Playing";
|
|
if (this.pendingPlaybackCommand === "Pause") return "Paused";
|
|
if (this.groupInfo?.State === "Playing") return "Playing";
|
|
if (this.groupInfo?.State === "Paused") return "Paused";
|
|
return this.playerControls?.isPlaying() ? "Playing" : "Paused";
|
|
}
|
|
|
|
/**
|
|
* Returns the in-flight play/pause request, if any.
|
|
*/
|
|
getPendingPlaybackCommand(): "Unpause" | "Pause" | null {
|
|
return this.pendingPlaybackCommand;
|
|
}
|
|
|
|
/**
|
|
* Mark a play/pause request as in flight. Auto-clears on a safety timeout
|
|
* in case the server broadcast is missed.
|
|
*/
|
|
markPendingPlaybackCommand(command: "Unpause" | "Pause"): void {
|
|
this.pendingPlaybackCommand = command;
|
|
if (this.pendingPlaybackTimeout) {
|
|
clearTimeout(this.pendingPlaybackTimeout);
|
|
}
|
|
this.pendingPlaybackTimeout = setTimeout(() => {
|
|
console.debug(
|
|
"SyncPlay Manager: pending playback command timed out",
|
|
command,
|
|
);
|
|
this.pendingPlaybackCommand = null;
|
|
this.pendingPlaybackTimeout = null;
|
|
this.emit("pending-playback-change", null);
|
|
}, SyncPlayManager.PENDING_PLAYBACK_TIMEOUT_MS);
|
|
this.emit("pending-playback-change", command);
|
|
}
|
|
|
|
private clearPendingPlaybackCommand(): void {
|
|
if (this.pendingPlaybackTimeout) {
|
|
clearTimeout(this.pendingPlaybackTimeout);
|
|
this.pendingPlaybackTimeout = null;
|
|
}
|
|
if (this.pendingPlaybackCommand !== null) {
|
|
this.pendingPlaybackCommand = null;
|
|
this.emit("pending-playback-change", null);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if following group playback
|
|
*/
|
|
isFollowingGroupPlayback(): boolean {
|
|
return this.followingGroupPlayback;
|
|
}
|
|
|
|
/**
|
|
* Enable SyncPlay (join a group)
|
|
*/
|
|
enableSyncPlay(groupInfo: GroupInfoDto, showMessage = false): void {
|
|
if (this.isSyncPlayEnabled()) {
|
|
if (groupInfo.GroupId === this.groupInfo?.GroupId) {
|
|
console.debug(
|
|
`SyncPlay: group ${this.groupInfo?.GroupId} already joined.`,
|
|
);
|
|
return;
|
|
}
|
|
console.warn(
|
|
`SyncPlay: switching from group ${this.groupInfo?.GroupId} to ${groupInfo.GroupId}`,
|
|
);
|
|
this.disableSyncPlay(false);
|
|
}
|
|
|
|
this.groupInfo = groupInfo;
|
|
this.syncPlayEnabledAt = groupInfo.LastUpdatedAt
|
|
? new Date(groupInfo.LastUpdatedAt)
|
|
: new Date();
|
|
this.followingGroupPlayback = true;
|
|
this.syncPlayReady = false;
|
|
|
|
console.log(`SyncPlay: enableSyncPlay - group state is ${groupInfo.State}`);
|
|
|
|
this.emit("enabled", true);
|
|
|
|
// Wait for time sync to be ready
|
|
const checkReady = () => {
|
|
if (this.timeSyncCore.isReady()) {
|
|
this.syncPlayReady = true;
|
|
|
|
// CRITICAL: Tell server we're following group playback
|
|
// This ensures the server sends us SyncPlayCommand messages
|
|
this.followGroupPlayback();
|
|
|
|
if (this.queuedCommand) {
|
|
this.processCommand(this.queuedCommand);
|
|
this.queuedCommand = null;
|
|
}
|
|
|
|
// Act on initial group state if player is connected
|
|
if (this.playerControls && groupInfo.State) {
|
|
console.log(`SyncPlay: applying initial state ${groupInfo.State}`);
|
|
if (groupInfo.State === "Playing") {
|
|
this.playerControls.play();
|
|
} else if (groupInfo.State === "Paused") {
|
|
this.playerControls.pause();
|
|
}
|
|
}
|
|
} else {
|
|
setTimeout(checkReady, 100);
|
|
}
|
|
};
|
|
|
|
this.timeSyncCore.forceUpdate();
|
|
checkReady();
|
|
|
|
if (showMessage) {
|
|
toast(i18n.t("syncplay.enabled"));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Disable SyncPlay (leave group)
|
|
*/
|
|
disableSyncPlay(showMessage = false): void {
|
|
this.syncPlayEnabledAt = null;
|
|
this.syncPlayReady = false;
|
|
this.followingGroupPlayback = true;
|
|
this.lastPlaybackCommand = null;
|
|
this.queuedCommand = null;
|
|
this.groupInfo = null;
|
|
this.clearPendingPlaybackCommand();
|
|
|
|
// Tell PlaybackCore (or whoever subscribed) to flush any scheduled
|
|
// commands / cached state so a future re-enable starts clean.
|
|
try {
|
|
this.onDisable?.();
|
|
} catch (error) {
|
|
console.warn("SyncPlay: onDisable handler threw", error);
|
|
}
|
|
|
|
// Drop the cached PlayQueue snapshot so a future re-join doesn't get
|
|
// its first PlayQueue update silently dropped as "older than what we
|
|
// already have".
|
|
try {
|
|
this.onQueueClear?.();
|
|
} catch (error) {
|
|
console.warn("SyncPlay: onQueueClear handler threw", error);
|
|
}
|
|
|
|
this.emit("enabled", false);
|
|
|
|
if (showMessage) {
|
|
toast(i18n.t("syncplay.disabled"));
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Server Communication
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Send ping to server
|
|
*/
|
|
private async sendPing(ping: number): Promise<void> {
|
|
try {
|
|
const syncPlayApi = getSyncPlayApi(this.api);
|
|
await syncPlayApi.syncPlayPing({
|
|
pingRequestDto: { Ping: Math.round(ping) },
|
|
});
|
|
} catch (error) {
|
|
console.debug("SyncPlay: failed to send ping", error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Report that we're ready (not buffering)
|
|
*/
|
|
async reportReady(): Promise<void> {
|
|
try {
|
|
const syncPlayApi = getSyncPlayApi(this.api);
|
|
const now = new Date();
|
|
const currentPosition = this.playerControls?.getCurrentPosition() ?? 0;
|
|
const currentPositionTicks = msToTicks(currentPosition);
|
|
|
|
console.log(
|
|
"SyncPlay Manager: reporting READY at position",
|
|
currentPositionTicks,
|
|
);
|
|
|
|
await syncPlayApi.syncPlayReady({
|
|
readyRequestDto: {
|
|
When: now.toISOString(),
|
|
PositionTicks: currentPositionTicks,
|
|
IsPlaying: this.playerControls?.isPlaying() ?? false,
|
|
PlaylistItemId:
|
|
this.onGetPlaylistItemId?.() ??
|
|
"00000000-0000-0000-0000-000000000000",
|
|
},
|
|
});
|
|
console.log("SyncPlay Manager: READY sent successfully");
|
|
} catch (error) {
|
|
console.error("SyncPlay Manager: failed to report ready", error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Follow group playback
|
|
*/
|
|
async followGroupPlayback(): Promise<void> {
|
|
this.followingGroupPlayback = true;
|
|
|
|
try {
|
|
const syncPlayApi = getSyncPlayApi(this.api);
|
|
await syncPlayApi.syncPlaySetIgnoreWait({
|
|
ignoreWaitRequestDto: { IgnoreWait: false },
|
|
});
|
|
} catch (error) {
|
|
console.error("SyncPlay: failed to follow group playback", error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Halt group playback (stop following)
|
|
*/
|
|
async haltGroupPlayback(): Promise<void> {
|
|
this.followingGroupPlayback = false;
|
|
|
|
try {
|
|
const syncPlayApi = getSyncPlayApi(this.api);
|
|
await syncPlayApi.syncPlaySetIgnoreWait({
|
|
ignoreWaitRequestDto: { IgnoreWait: true },
|
|
});
|
|
|
|
// Stop local playback
|
|
this.playerControls?.pause();
|
|
} catch (error) {
|
|
console.error("SyncPlay: failed to halt group playback", error);
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Message Processing
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Process a group update from the server
|
|
* Uses generic type to handle all possible update types from server
|
|
*/
|
|
processGroupUpdate(update: { Type?: string; Data?: unknown }): void {
|
|
const { Type, Data } = update;
|
|
|
|
switch (Type) {
|
|
case "PlayQueue": {
|
|
const playQueueData = Data as PlayQueueUpdate;
|
|
console.log(
|
|
"SyncPlay: received PlayQueue update - position:",
|
|
playQueueData.StartPositionTicks,
|
|
"reason:",
|
|
playQueueData.Reason,
|
|
);
|
|
this.onQueueUpdate?.(playQueueData);
|
|
break;
|
|
}
|
|
|
|
case "UserJoined":
|
|
toast(i18n.t("syncplay.user_joined", { username: Data }));
|
|
if (this.groupInfo) {
|
|
if (!this.groupInfo.Participants) {
|
|
this.groupInfo.Participants = [Data as string];
|
|
} else {
|
|
this.groupInfo.Participants.push(Data as string);
|
|
}
|
|
}
|
|
break;
|
|
|
|
case "UserLeft":
|
|
toast(i18n.t("syncplay.user_left", { username: Data }));
|
|
if (this.groupInfo?.Participants) {
|
|
this.groupInfo.Participants = this.groupInfo.Participants.filter(
|
|
(user: string) => user !== Data,
|
|
);
|
|
}
|
|
break;
|
|
|
|
case "GroupJoined": {
|
|
const groupData = Data as GroupInfoDto;
|
|
this.enableSyncPlay(groupData, true);
|
|
break;
|
|
}
|
|
|
|
case "SyncPlayIsDisabled":
|
|
toast(i18n.t("syncplay.permission_required"));
|
|
break;
|
|
|
|
case "NotInGroup":
|
|
case "GroupLeft":
|
|
this.disableSyncPlay(true);
|
|
break;
|
|
|
|
case "GroupUpdate": {
|
|
const updatedData = Data as GroupInfoDto;
|
|
this.groupInfo = updatedData;
|
|
this.emit("group-info-change", updatedData);
|
|
break;
|
|
}
|
|
|
|
case "StateUpdate": {
|
|
// Log full state data to see if position is included
|
|
console.log("SyncPlay: StateUpdate full data:", JSON.stringify(Data));
|
|
const stateData = Data as {
|
|
State: string;
|
|
Reason: string;
|
|
PositionTicks?: number;
|
|
};
|
|
|
|
// CRITICAL: Update the stored group state so subsequent checks use the correct value
|
|
if (this.groupInfo) {
|
|
this.groupInfo.State = stateData.State as any;
|
|
// Emit a fresh object so React state subscribers re-render —
|
|
// mutating in place would not trigger re-renders.
|
|
this.emit("group-info-change", { ...this.groupInfo });
|
|
}
|
|
|
|
this.emit("group-state-update", stateData.State, stateData.Reason);
|
|
console.log(
|
|
`SyncPlay: state changed to ${stateData.State} because ${stateData.Reason}`,
|
|
);
|
|
|
|
// Handle seek from StateUpdate if position is included
|
|
if (stateData.Reason === "Seek" && stateData.PositionTicks != null) {
|
|
console.log(
|
|
"SyncPlay: StateUpdate contains seek position:",
|
|
stateData.PositionTicks,
|
|
);
|
|
this.emit("seek-from-state-update", stateData.PositionTicks);
|
|
}
|
|
|
|
// Use StateUpdate as a fallback to control playback when SyncPlayCommand isn't received
|
|
// This ensures we stay in sync even if the server doesn't send commands
|
|
if (this.playerControls) {
|
|
const currentlyPlaying = this.playerControls.isPlaying();
|
|
console.log(
|
|
`SyncPlay: StateUpdate handler - state=${stateData.State}, currentlyPlaying=${currentlyPlaying}`,
|
|
);
|
|
|
|
if (stateData.State === "Paused" && currentlyPlaying) {
|
|
console.log("SyncPlay: StateUpdate -> PAUSING player");
|
|
this.playerControls.pause();
|
|
} else if (stateData.State === "Playing" && !currentlyPlaying) {
|
|
console.log("SyncPlay: StateUpdate -> PLAYING");
|
|
this.playerControls.play();
|
|
} else if (stateData.State === "Waiting") {
|
|
console.log("SyncPlay: StateUpdate -> Waiting for other members");
|
|
// Pause player when waiting
|
|
if (currentlyPlaying) {
|
|
this.playerControls.pause();
|
|
}
|
|
// Emit event so PlaybackCore can report ready
|
|
this.emit("waiting-for-ready");
|
|
}
|
|
} else {
|
|
console.warn("SyncPlay: StateUpdate but no playerControls!");
|
|
}
|
|
break;
|
|
}
|
|
|
|
case "GroupDoesNotExist":
|
|
toast(i18n.t("syncplay.group_does_not_exist"));
|
|
break;
|
|
|
|
case "CreateGroupDenied":
|
|
toast(i18n.t("syncplay.create_denied"));
|
|
break;
|
|
|
|
case "JoinGroupDenied":
|
|
toast(i18n.t("syncplay.join_denied"));
|
|
break;
|
|
|
|
case "LibraryAccessDenied":
|
|
toast(i18n.t("syncplay.library_access_denied"));
|
|
break;
|
|
|
|
default:
|
|
console.warn(`SyncPlay: unrecognized group update type: ${Type}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Process a playback command from the server
|
|
*/
|
|
processCommand(command: SendCommand): void {
|
|
console.log(`SyncPlay Manager: processCommand called - ${command.Command}`);
|
|
|
|
if (!this.isSyncPlayEnabled()) {
|
|
console.warn(
|
|
"SyncPlay Manager: not enabled, ignoring command",
|
|
command.Command,
|
|
);
|
|
return;
|
|
}
|
|
|
|
const emittedAt = command.EmittedAt ? new Date(command.EmittedAt) : null;
|
|
if (this.syncPlayEnabledAt && emittedAt) {
|
|
if (emittedAt.getTime() < this.syncPlayEnabledAt.getTime()) {
|
|
console.debug("SyncPlay Manager: ignoring old command", command);
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Reject commands targeted at a different playlist item than the one we
|
|
// currently have loaded. Stop is always honored (it may be a teardown
|
|
// before a queue swap). This prevents (e.g.) seeking the wrong episode
|
|
// when a queue change is racing a command.
|
|
if (command.Command !== "Stop" && command.PlaylistItemId) {
|
|
const currentItemId = this.onGetPlaylistItemId?.();
|
|
if (currentItemId && currentItemId !== command.PlaylistItemId) {
|
|
console.debug(
|
|
`SyncPlay Manager: ignoring command for playlist item ${command.PlaylistItemId} (current is ${currentItemId})`,
|
|
);
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (!this.syncPlayReady) {
|
|
console.log(
|
|
"SyncPlay Manager: not ready, queuing command",
|
|
command.Command,
|
|
);
|
|
this.queuedCommand = command;
|
|
return;
|
|
}
|
|
|
|
// Remember the command even if we can't act on it yet. When the player
|
|
// attaches (setPlayerControls), the reconcile-on-attach path uses
|
|
// `lastPlaybackCommand` to seek to the estimated group position and
|
|
// resume/pause to match the group. Without this assignment, a command
|
|
// that arrives during the join→navigate→load window is lost.
|
|
this.lastPlaybackCommand = command;
|
|
|
|
// Clear pending guard once the matching broadcast arrives. We treat any
|
|
// Unpause/Pause arrival as satisfying the pending request (the server
|
|
// may coalesce or override our intent — either way we trust its decision).
|
|
if (command.Command === "Unpause" || command.Command === "Pause") {
|
|
this.clearPendingPlaybackCommand();
|
|
}
|
|
|
|
if (!this.playerControls) {
|
|
// Expected when a command arrives between joining the group and the
|
|
// player finishing its initial load. The reconciliation in
|
|
// setPlayerControls will replay this command from `lastPlaybackCommand`
|
|
// once controls attach.
|
|
console.debug(
|
|
`SyncPlay Manager: ${command.Command} stored for replay (player not attached yet)`,
|
|
);
|
|
return;
|
|
}
|
|
|
|
console.log(
|
|
`SyncPlay Manager: delegating ${command.Command} to playback core`,
|
|
);
|
|
|
|
// Delegate to playback handler
|
|
if (this.onPlaybackCoreCommand) {
|
|
this.onPlaybackCoreCommand(command);
|
|
} else {
|
|
console.error("SyncPlay Manager: no playback command handler set!");
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Stats
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Get SyncPlay stats for display
|
|
*/
|
|
getStats(): SyncPlayStats {
|
|
return {
|
|
timeSyncDevice: this.timeSyncCore.getActiveDeviceName(),
|
|
timeSyncOffset: this.timeSyncCore.getTimeOffset().toFixed(2),
|
|
playbackDiff: "0.00",
|
|
syncMethod: this.syncMethod,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Show sync icon
|
|
*/
|
|
showSyncIcon(method: string): void {
|
|
this.syncMethod = method;
|
|
this.emit("syncing", true, method);
|
|
}
|
|
|
|
/**
|
|
* Clear sync icon
|
|
*/
|
|
clearSyncIcon(): void {
|
|
this.syncMethod = "None";
|
|
this.emit("syncing", false, "None");
|
|
}
|
|
|
|
// ============================================================================
|
|
// Cleanup
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Destroy the manager
|
|
*/
|
|
destroy(): void {
|
|
this.timeSyncCore.destroy();
|
|
this.disableSyncPlay(false);
|
|
this.removeAllListeners();
|
|
this.playerControls = null;
|
|
this.onPlaybackCoreCommand = null;
|
|
this.onQueueUpdate = null;
|
|
}
|
|
}
|