global player pt. 1
This commit is contained in:
232
src/lib/player/persist.ts
Normal file
232
src/lib/player/persist.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
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: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 = {
|
||||
queue: Track[];
|
||||
currentIndex: number | null;
|
||||
|
||||
shuffleEnabled: boolean;
|
||||
wrapEnabled: boolean;
|
||||
|
||||
order: number[];
|
||||
history: number[];
|
||||
cursor: number;
|
||||
|
||||
volume: number;
|
||||
uiOpen: boolean;
|
||||
};
|
||||
|
||||
export function loadPersistedPlayerState(): PersistablePlayerState | 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 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 {
|
||||
if (!browser) return;
|
||||
try {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
Reference in New Issue
Block a user