feat(sync-play): squash feature/sync-play

This commit is contained in:
Alex Kim
2026-05-31 19:03:03 +10:00
parent ed7928b4d3
commit d06daef933
25 changed files with 4889 additions and 136 deletions

View File

@@ -0,0 +1,292 @@
/**
* 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;
}
}