mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-05-31 19:18:26 +01:00
feat(sync-play): squash feature/sync-play
This commit is contained in:
292
providers/SyncPlay/QueueCore.ts
Normal file
292
providers/SyncPlay/QueueCore.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user