mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-05-31 19:18:26 +01:00
882 lines
30 KiB
TypeScript
882 lines
30 KiB
TypeScript
/**
|
|
* 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;
|
|
}
|
|
}
|