WIP: global player refactor pt. 1
This commit is contained in:
326
src/lib/player/store.svelte.ts
Normal file
326
src/lib/player/store.svelte.ts
Normal 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();
|
||||
Reference in New Issue
Block a user