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

@@ -1,232 +1,33 @@
import { z } from "zod";
import { browser } from "$app/environment";
import type { Track } from "./types";
/**
* Persistence for the global player.
*
* Persisted:
* - queue
* - currentIndex
* - shuffleEnabled
* - wrapEnabled
* - shuffle traversal bookkeeping (order/history/cursor)
* - volume
* - uiOpen
*
* Not persisted by design:
* - currentTime / playback position
* - isPlaying (we always restore paused)
*/
const STORAGE_KEY = "amqtrain:player:v2";
const STORAGE_KEY = "amqtrain:player:v1";
const STORAGE_VERSION = 1;
const TrackSchema = z
.object({
id: z.number().int().nonnegative(),
src: z.string().min(1),
title: z.string().default(""),
artist: z.string().default(""),
album: z.string().default(""),
animeName: z.string().optional(),
type: z.number().optional(),
number: z.number().optional(),
fileName: z.string().nullable().optional(),
})
.strict();
const PersistedSnapshotSchema = z
.object({
version: z.literal(STORAGE_VERSION),
queue: z.array(TrackSchema).default([]),
currentIndex: z.number().int().nullable().default(null),
shuffleEnabled: z.boolean().default(false),
wrapEnabled: z.boolean().default(false),
/**
* Shuffle traversal:
* - order: upcoming indices into `queue` in the order they will be visited
* - history: visited indices into `queue` in visit order
* - cursor: index into `history` pointing at the current item
*/
order: z.array(z.number().int().nonnegative()).default([]),
history: z.array(z.number().int().nonnegative()).default([]),
cursor: z.number().int().default(0),
volume: z.number().min(0).max(1).default(1),
uiOpen: z.boolean().default(false),
})
.strict();
export type PersistedSnapshot = z.infer<typeof PersistedSnapshotSchema>;
export type PersistablePlayerState = {
export type PersistedState = {
queue: Track[];
currentIndex: number | null;
shuffleEnabled: boolean;
wrapEnabled: boolean;
order: number[];
history: number[];
cursor: number;
currentId: number | null;
volume: number;
uiOpen: boolean;
isMuted: boolean;
minimized: boolean;
};
export function loadPersistedPlayerState(): PersistablePlayerState | null {
export function loadState(): PersistedState | null {
if (!browser) return null;
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return null;
const parsed = PersistedSnapshotSchema.safeParse(JSON.parse(raw));
if (!parsed.success) return null;
return sanitizePersistedState(parsed.data);
} catch {
return JSON.parse(raw);
} catch (e) {
console.error("Failed to load player state", e);
return null;
}
}
export function savePersistedPlayerState(state: PersistablePlayerState): void {
if (!browser) return;
const snapshot: PersistedSnapshot = {
version: STORAGE_VERSION,
queue: state.queue,
currentIndex: state.currentIndex,
shuffleEnabled: state.shuffleEnabled,
wrapEnabled: state.wrapEnabled,
order: state.order,
history: state.history,
cursor: state.cursor,
volume: clamp01(state.volume),
uiOpen: state.uiOpen,
};
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(snapshot));
} catch {
// Ignore quota/security errors; persistence is a best-effort feature.
}
}
/**
* Throttled saver (simple debounce). Call this from reactive effects.
*/
export function createPersistScheduler(delayMs = 250) {
let timeout: ReturnType<typeof setTimeout> | null = null;
return (state: PersistablePlayerState) => {
if (!browser) return;
if (timeout) clearTimeout(timeout);
timeout = setTimeout(() => {
timeout = null;
savePersistedPlayerState(state);
}, delayMs);
};
}
export function clearPersistedPlayerState(): void {
export function saveState(state: PersistedState) {
if (!browser) return;
try {
localStorage.removeItem(STORAGE_KEY);
} catch {
// ignore
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
} catch (e) {
console.error("Failed to save player state", e);
}
}
function sanitizePersistedState(
snapshot: PersistedSnapshot,
): PersistablePlayerState {
const queue = dedupeById(snapshot.queue);
const maxIndex = queue.length - 1;
const currentIndex =
snapshot.currentIndex == null
? null
: snapshot.currentIndex >= 0 && snapshot.currentIndex <= maxIndex
? snapshot.currentIndex
: null;
const order = filterValidIndices(snapshot.order, queue.length);
const history = filterValidIndices(snapshot.history, queue.length);
// cursor points into history; if history is empty, cursor should be 0
const cursor =
history.length === 0
? 0
: Math.max(0, Math.min(snapshot.cursor, history.length - 1));
// If we have a currentIndex but history doesn't reflect it, try to repair:
// put currentIndex at end and point cursor there.
let repairedHistory = history;
let repairedCursor = cursor;
if (currentIndex != null) {
if (history.length === 0 || history[cursor] !== currentIndex) {
repairedHistory = [...history, currentIndex];
repairedCursor = repairedHistory.length - 1;
}
}
// Ensure order doesn't contain items already visited up through cursor.
const visitedSet =
repairedHistory.length === 0
? new Set<number>()
: new Set(repairedHistory.slice(0, repairedCursor + 1));
const repairedOrder = order.filter((i) => !visitedSet.has(i));
return {
queue,
currentIndex,
shuffleEnabled: snapshot.shuffleEnabled,
wrapEnabled: snapshot.wrapEnabled,
order: repairedOrder,
history: repairedHistory,
cursor: repairedCursor,
volume: clamp01(snapshot.volume),
uiOpen: snapshot.uiOpen,
};
}
function filterValidIndices(indices: number[], length: number) {
const out: number[] = [];
for (const i of indices) {
if (Number.isInteger(i) && i >= 0 && i < length) out.push(i);
}
return out;
}
function dedupeById(tracks: Track[]) {
const seen = new Set<number>();
const out: Track[] = [];
for (const t of tracks) {
const id = Number(t.id);
if (!Number.isFinite(id)) continue;
if (seen.has(id)) continue;
seen.add(id);
out.push(t);
}
return out;
}
function clamp01(n: number) {
if (!Number.isFinite(n)) return 1;
return Math.max(0, Math.min(1, n));
}