import { browser } from "$app/environment"; import type { Track } from "./types"; const STORAGE_KEY = "amqtrain:player:v2"; export type PlayerState = { queue: Track[]; currentId: number | null; history: number[]; // List of track IDs shuffledIndices: number[]; // List of indices into queue (maintained for shuffle order) isShuffled: boolean; repeatMode: "off" | "all" | "one"; volume: number; isMuted: boolean; }; class PlayerStore { // State queue = $state([]); currentId = $state(null); history = $state([]); shuffledIndices = $state([]); isShuffled = $state(false); repeatMode = $state<"off" | "all" | "one">("off"); volume = $state(1); isMuted = $state(false); uiOpen = $state(false); // Mobile UI state // Debounce timer for save() private _saveTimer: ReturnType | null = null; // O(1) index: track.id → index in queue (maintained imperatively) private idToIndex = $state(new Map()); /** Rebuild the full index from the queue array. */ private rebuildIndex() { const map = new Map(); for (let i = 0; i < this.queue.length; i++) { map.set(this.queue[i].id, i); } this.idToIndex = map; } // Derived currentTrack = $derived.by(() => { if (this.currentId == null) return null; const idx = this.idToIndex.get(this.currentId); return idx !== undefined ? this.queue[idx] : null; }); currentIndex = $derived.by(() => { if (this.currentId == null) return -1; return this.idToIndex.get(this.currentId) ?? -1; }); displayQueue = $derived( this.isShuffled ? this.shuffledIndices.map((i) => this.queue[i]) : this.queue, ); hasTrack(id: number) { return this.idToIndex.has(id); } constructor() { if (browser) { this.load(); // Auto-save on changes $effect.root(() => { $effect(() => { this.save(); }); }); } } init(state: Partial) { if (state.queue) this.queue = state.queue; if (state.currentId) this.currentId = state.currentId; if (state.volume !== undefined) this.volume = state.volume; if (state.isMuted !== undefined) this.isMuted = state.isMuted; if (state.minimized !== undefined) this.uiOpen = !state.minimized; this.rebuildIndex(); } load() { try { const stored = localStorage.getItem(STORAGE_KEY); if (stored) { const data = JSON.parse(stored); this.queue = data.queue || []; this.currentId = data.currentId ?? null; this.history = data.history || []; this.shuffledIndices = data.shuffledIndices || []; this.isShuffled = data.isShuffled || false; this.repeatMode = data.repeatMode || "off"; this.volume = data.volume ?? 1; this.isMuted = data.isMuted || false; this.rebuildIndex(); } } catch (e) { console.error("Failed to load player state", e); } } save() { // Read snapshots synchronously so $effect tracks reactive deps const data: PlayerState = { queue: $state.snapshot(this.queue), currentId: $state.snapshot(this.currentId), history: $state.snapshot(this.history), shuffledIndices: $state.snapshot(this.shuffledIndices), isShuffled: $state.snapshot(this.isShuffled), repeatMode: $state.snapshot(this.repeatMode), volume: $state.snapshot(this.volume), isMuted: $state.snapshot(this.isMuted), }; // Debounce only the serialization + write if (this._saveTimer) clearTimeout(this._saveTimer); this._saveTimer = setTimeout(() => { localStorage.setItem(STORAGE_KEY, JSON.stringify(data)); }, 300); } // Actions add(track: Track, playNow = false) { const exists = this.hasTrack(track.id); if (exists) { if (playNow) { this.playNext(track); this.playId(track.id); } return; } if (playNow) { this.playNext(track); this.playId(track.id); } else { // Add to end this.queue.push(track); this.idToIndex.set(track.id, this.queue.length - 1); if (this.isShuffled) { this.shuffledIndices.push(this.queue.length - 1); } if (this.queue.length === 1 && !this.currentId) { this.currentId = track.id; } } } playNext(track: Track) { const existingIdx = this.idToIndex.get(track.id) ?? -1; const targetTrack = track; if (existingIdx !== -1) { // Move approach: remove then insert this.remove(track.id); } // Insert after current // If playing: insert at currentIndex + 1 // If empty: insert at 0 const currentIdx = this.currentIndex; const insertIdx = currentIdx === -1 ? 0 : currentIdx + 1; this.queue.splice(insertIdx, 0, targetTrack); // Rebuild index — splice shifts everything after insertIdx this.rebuildIndex(); if (this.isShuffled) { // Shift indices that are >= insertIdx because we inserted a new item this.shuffledIndices = this.shuffledIndices.map((i) => i >= insertIdx ? i + 1 : i, ); const newIdx = insertIdx; // Find where current is in shuffledIndices const currentShufflePos = this.shuffledIndices.indexOf(currentIdx); // Insert newIdx after it if (currentShufflePos !== -1) { this.shuffledIndices.splice(currentShufflePos + 1, 0, newIdx); } else { this.shuffledIndices.unshift(newIdx); } } } addAll(tracks: Track[]) { // Batch: collect new tracks, push all at once const newTracks: Track[] = []; for (const track of tracks) { // Check existence inline to avoid O(n) per-track via add() if (!this.hasTrack(track.id)) { newTracks.push(track); } } if (newTracks.length === 0) return; const startIdx = this.queue.length; this.queue.push(...newTracks); // Only index the newly added tracks for (let i = 0; i < newTracks.length; i++) { this.idToIndex.set(newTracks[i].id, startIdx + i); } if (this.isShuffled) { const newIndices = newTracks.map((_, i) => startIdx + i); this.shuffledIndices.push(...newIndices); } if (startIdx === 0 && !this.currentId) { this.currentId = newTracks[0].id; } } playAllNext(tracks: Track[]) { // Reverse iterate to maintain order when inserting after current for (let i = tracks.length - 1; i >= 0; i--) { this.playNext(tracks[i]); } } remove(id: number) { const idx = this.idToIndex.get(id); if (idx === undefined) return; const wasCurrent = this.currentId === id; this.queue.splice(idx, 1); // Rebuild index — splice shifts everything after idx this.rebuildIndex(); if (wasCurrent) { this.currentId = null; // Or auto-advance? this.next(); } // Fix shuffle indices // All indices > idx must be decremented // The index `idx` itself must be removed this.shuffledIndices = this.shuffledIndices .filter((i) => i !== idx) .map((i) => (i > idx ? i - 1 : i)); } clearQueue() { this.queue = []; this.idToIndex = new Map(); this.currentId = null; this.shuffledIndices = []; this.history = []; } playId(id: number) { if (this.hasTrack(id)) { this.currentId = id; this.addToHistory(id); } } move(fromIdx: number, toIdx: number) { if (fromIdx === toIdx) return; if (this.isShuffled) { const indices = [...this.shuffledIndices]; if (fromIdx < 0 || fromIdx >= indices.length) return; if (toIdx < 0 || toIdx >= indices.length) return; const [item] = indices.splice(fromIdx, 1); indices.splice(toIdx, 0, item); this.shuffledIndices = indices; } else { const q = [...this.queue]; if (fromIdx < 0 || fromIdx >= q.length) return; if (toIdx < 0 || toIdx >= q.length) return; const [item] = q.splice(fromIdx, 1); q.splice(toIdx, 0, item); this.queue = q; this.rebuildIndex(); } } // Playback Controls next() { if (this.queue.length === 0) return; let nextIdxInQueue: number | null = null; const currentIdx = this.currentIndex; if (this.isShuffled) { const currentShufflePos = this.shuffledIndices.indexOf(currentIdx); if ( currentShufflePos !== -1 && currentShufflePos < this.shuffledIndices.length - 1 ) { nextIdxInQueue = this.shuffledIndices[currentShufflePos + 1]; } else if (this.repeatMode === "all" && this.shuffledIndices.length > 0) { nextIdxInQueue = this.shuffledIndices[0]; } } else { if (currentIdx !== -1 && currentIdx < this.queue.length - 1) { nextIdxInQueue = currentIdx + 1; } else if (this.repeatMode === "all" && this.queue.length > 0) { nextIdxInQueue = 0; } } if (nextIdxInQueue !== null) { const nextId = this.queue[nextIdxInQueue]?.id; if (nextId) { this.currentId = nextId; this.addToHistory(nextId); } } } prev() { if (this.queue.length === 0) return; let prevIdxInQueue: number | null = null; const currentIdx = this.currentIndex; if (this.isShuffled) { const currentShufflePos = this.shuffledIndices.indexOf(currentIdx); if (currentShufflePos > 0) { prevIdxInQueue = this.shuffledIndices[currentShufflePos - 1]; } else if (this.repeatMode === "all" && this.shuffledIndices.length > 0) { // Wrap to end? Or just stop. // For now let's stop at start if not wrapping. // If repeat all, wrap to end? prevIdxInQueue = this.shuffledIndices[this.shuffledIndices.length - 1]; } } else { if (currentIdx > 0) { prevIdxInQueue = currentIdx - 1; } else if (this.repeatMode === "all" && this.queue.length > 0) { prevIdxInQueue = this.queue.length - 1; } } if (prevIdxInQueue !== null) { this.playId(this.queue[prevIdxInQueue]?.id); } else { // At start of queue. // Just seek to 0? Store doesn't control audio. // If we can't go back, we do nothing (UI likely handles the seek-to-0 comparison). } } addToHistory(id: number) { const last = this.history[this.history.length - 1]; if (last !== id) { this.history.push(id); } } toggleShuffle() { this.isShuffled = !this.isShuffled; if (this.isShuffled) { this.reshuffle(); } } reshuffle() { // Create indices 0..N-1 const indices = Array.from({ length: this.queue.length }, (_, i) => i); // Fisher-Yates for (let i = indices.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [indices[i], indices[j]] = [indices[j], indices[i]]; } // Keep current first if (this.currentId) { const currentIdx = this.currentIndex; const without = indices.filter((i) => i !== currentIdx); this.shuffledIndices = [currentIdx, ...without]; } else { this.shuffledIndices = indices; } } toggleRepeat() { if (this.repeatMode === "off") this.repeatMode = "all"; else if (this.repeatMode === "all") this.repeatMode = "one"; else this.repeatMode = "off"; } setVolume(v: number) { this.volume = Math.max(0, Math.min(1, v)); } toggleMute() { this.isMuted = !this.isMuted; } setUiOpen(open: boolean) { this.uiOpen = open; } } export const player = new PlayerStore();