Files
amqtrain/src/lib/player/store.svelte.ts

413 lines
11 KiB
TypeScript

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<Track[]>([]);
currentId = $state<number | null>(null);
history = $state<number[]>([]);
shuffledIndices = $state<number[]>([]);
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<typeof setTimeout> | null = null;
// O(1) index: track.id → index in queue (maintained imperatively)
private idToIndex = $state(new Map<number, number>());
/** Rebuild the full index from the queue array. */
private rebuildIndex() {
const map = new Map<number, number>();
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<import("./persist").PersistedState>) {
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();