413 lines
11 KiB
TypeScript
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();
|