WIP: global player refactor pt. 1

This commit is contained in:
2026-02-09 23:19:17 -08:00
parent 9126e34f38
commit aea41df214
20 changed files with 1045 additions and 2740 deletions

View File

@@ -0,0 +1,326 @@
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
// Derived
currentTrack = $derived(
this.currentId
? (this.queue.find((t) => t.id === this.currentId) ?? null)
: null,
);
currentIndex = $derived(
this.currentId ? this.queue.findIndex((t) => t.id === this.currentId) : -1,
);
displayQueue = $derived(
this.isShuffled
? this.shuffledIndices
.map((i) => this.queue[i])
.filter((t) => t !== undefined)
: this.queue,
);
hasTrack(id: number) {
return this.queue.some((t) => t.id === 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;
}
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;
}
} catch (e) {
console.error("Failed to load player state", e);
}
}
save() {
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),
};
localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
}
// Actions
add(track: Track, playNow = false) {
const existingIdx = this.queue.findIndex((t) => t.id === track.id);
if (existingIdx !== -1) {
if (playNow) {
this.playId(track.id);
}
return;
}
// Add to end
this.queue.push(track);
if (this.isShuffled) {
this.shuffledIndices.push(this.queue.length - 1);
}
if (playNow) {
this.playId(track.id);
} else if (this.queue.length === 1 && !this.currentId) {
this.currentId = track.id;
}
}
playNext(track: Track) {
const existingIdx = this.queue.findIndex((t) => t.id === track.id);
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);
if (this.isShuffled) {
// Regenerate shuffle indices to be safe as queue shifted
this.reshuffle();
// Attempt to place new track next in shuffle order?
// The reshuffle logic handles "current first", rest random.
// Ideally we want playNext to be deterministic even in shuffle.
// Getting complex. Let's stick to: "Play Next" inserts after current in Queue.
// If Shuffle is on, we force it to be next in shuffledIndices too.
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[]) {
for (const track of tracks) {
this.add(track);
}
}
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.queue.findIndex((t) => t.id === id);
if (idx === -1) return;
const wasCurrent = this.currentId === id;
this.queue.splice(idx, 1);
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.currentId = null;
this.shuffledIndices = [];
this.history = [];
}
playId(id: number) {
if (this.hasTrack(id)) {
this.currentId = id;
this.addToHistory(id);
}
}
// 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 history has > 1 item, go back
if (this.history.length > 1) {
// Pop current
this.history.pop();
const prevId = this.history[this.history.length - 1];
if (this.hasTrack(prevId)) {
this.currentId = prevId;
} else {
// Track removed? fallback
this.history.pop(); // Remove invalid
this.prev(); // Recurse
}
} else {
// Restart current song?
// Handled by UI usually (calling audio.currentTime = 0),
// but here we just seek to 0 if we could.
// Store doesn't control audio element directly.
}
}
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();