/** * Copyright (c) Double Symmetry GmbH * Commercial use requires a license. See https://rntp.dev/pricing */ import type { ResolvedMediaItem } from './engines/AudioEngine'; /** * Pure queue state for the web backend. Ports the iOS `QueueManager` * behavioral contract (see ios/tests/QueueManagerTests.swift). * * `items` is the canonical queue. When shuffle is enabled, `playOrder` is a * permutation of canonical indices and navigation walks it; the canonical * queue and `currentIndex` (canonical) are unaffected by shuffle state. */ export class QueueManager { items: ResolvedMediaItem[] = []; currentIndex = -1; private playOrder: number[] | null = null; get current(): ResolvedMediaItem | null { return this.currentIndex >= 0 ? (this.items[this.currentIndex] ?? null) : null; } add(newItems: ResolvedMediaItem[]): void { const start = this.items.length; this.items.push(...newItems); if (this.playOrder) { for (let i = start; i < this.items.length; i++) this.playOrder.push(i); } } insert(index: number, newItems: ResolvedMediaItem[]): void { if (index < 0 || index > this.items.length) { throw new Error( `insert index ${index} out of bounds (0..${this.items.length})` ); } this.items.splice(index, 0, ...newItems); const shift = newItems.length; if (this.playOrder) { this.playOrder = this.playOrder.map((i) => (i >= index ? i + shift : i)); for (let i = index; i < index + shift; i++) this.playOrder.push(i); } if (this.currentIndex >= index) this.currentIndex += shift; } /** Returns true if the removed item was the current item. */ remove(index: number): boolean { if (index < 0 || index >= this.items.length) { throw new Error(`remove index ${index} out of bounds`); } const wasCurrent = index === this.currentIndex; this.items.splice(index, 1); if (this.playOrder) { this.playOrder = this.playOrder .filter((i) => i !== index) .map((i) => (i > index ? i - 1 : i)); } if (this.items.length === 0) { this.currentIndex = -1; } else if (index < this.currentIndex) { this.currentIndex -= 1; } else if (wasCurrent && this.currentIndex >= this.items.length) { this.currentIndex = this.items.length - 1; // removed current at end: clamp } // removed current mid-queue: index unchanged, now points at next item return wasCurrent; } /** Removes [fromIndex, toIndex). Returns true if the current item was removed. */ removeRange(fromIndex: number, toIndex: number): boolean { if (fromIndex < 0 || toIndex > this.items.length || fromIndex >= toIndex) { throw new Error(`removeRange [${fromIndex}, ${toIndex}) out of bounds`); } let wasCurrent = false; for (let i = toIndex - 1; i >= fromIndex; i--) { wasCurrent = this.remove(i) || wasCurrent; } return wasCurrent; } replace(index: number, item: ResolvedMediaItem): void { if (index < 0 || index >= this.items.length) { throw new Error(`replace index ${index} out of bounds`); } this.items[index] = item; } move(fromIndex: number, toIndex: number): void { if ( fromIndex < 0 || fromIndex >= this.items.length || toIndex < 0 || toIndex >= this.items.length ) { throw new Error(`move ${fromIndex} -> ${toIndex} out of bounds`); } const [item] = this.items.splice(fromIndex, 1); this.items.splice(toIndex, 0, item!); // Track the canonical index of the current item through the move. if (this.currentIndex === fromIndex) { this.currentIndex = toIndex; } else if (fromIndex < this.currentIndex && toIndex >= this.currentIndex) { this.currentIndex -= 1; } else if (fromIndex > this.currentIndex && toIndex <= this.currentIndex) { this.currentIndex += 1; } // A move invalidates a stale shuffle order's index mapping; rebuild it // around the (unchanged) current item. if (this.playOrder) this.rebuildPlayOrder(); } clear(): void { this.items = []; this.currentIndex = -1; this.playOrder = null; } jump(index: number): void { if (index < 0 || index >= this.items.length) { throw new Error(`jump index ${index} out of bounds`); } this.currentIndex = index; } next(wrap: boolean): boolean { return this.step(1, wrap); } previous(wrap: boolean): boolean { return this.step(-1, wrap); } setShuffleEnabled(enabled: boolean): void { if (enabled === this.isShuffleEnabled()) return; if (enabled) { this.rebuildPlayOrder(); } else { this.playOrder = null; } } isShuffleEnabled(): boolean { return this.playOrder !== null; } /** Fisher–Yates over all indices, current item pinned first. */ private rebuildPlayOrder(): void { const rest = this.items .map((_, i) => i) .filter((i) => i !== this.currentIndex); for (let i = rest.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [rest[i], rest[j]] = [rest[j]!, rest[i]!]; } this.playOrder = this.currentIndex >= 0 ? [this.currentIndex, ...rest] : rest; } private step(direction: 1 | -1, wrap: boolean): boolean { if (this.items.length === 0 || this.currentIndex < 0) return false; if (this.playOrder) { const pos = this.playOrder.indexOf(this.currentIndex); let nextPos = pos + direction; if (nextPos < 0 || nextPos >= this.playOrder.length) { if (!wrap) return false; nextPos = (nextPos + this.playOrder.length) % this.playOrder.length; } this.currentIndex = this.playOrder[nextPos]!; return true; } let nextIndex = this.currentIndex + direction; if (nextIndex < 0 || nextIndex >= this.items.length) { if (!wrap) return false; nextIndex = (nextIndex + this.items.length) % this.items.length; } this.currentIndex = nextIndex; return true; } }