diff --git a/src/lib/components/GlobalPlayer.svelte b/src/lib/components/GlobalPlayer.svelte
new file mode 100644
index 0000000..b2baa36
--- /dev/null
+++ b/src/lib/components/GlobalPlayer.svelte
@@ -0,0 +1,475 @@
+
+
+
+
+{#if isMobile}
+
+
+
+
+
+
+
{nowPlayingLabel()}
+
+ {formatTime(currentTime)} / {formatTime(duration)}
+
+
+
+
+
+
+
+
+
+
+ {#if snap.uiOpen}
+
+
+
+
+
+
+
+
+
+
+
Queue ({snap.queue.length})
+
+ {#if snap.queue.length === 0}
+
Queue is empty.
+ {:else}
+
+ {#each snap.queue as t, i (t.id)}
+ -
+
+
+
+
+ {/each}
+
+ {/if}
+
+
+
+ {/if}
+
+{:else}
+
+
+
+
+
+{/if}
+
+
+
diff --git a/src/lib/components/SongEntry.svelte b/src/lib/components/SongEntry.svelte
index 659e16c..e6c977f 100644
--- a/src/lib/components/SongEntry.svelte
+++ b/src/lib/components/SongEntry.svelte
@@ -1,33 +1,33 @@
-
-
- {displayTypeNumber}
{animeName}
@@ -81,18 +69,57 @@
— {artistDisplay}
- {#if showPlayer && fileName}
-
-
-
- {/if}
+ Remove
+
+
+
Queued
+ {/if}
+
+ {#if !track}
+
No audio file
+ {/if}
+
diff --git a/src/lib/player/media-session.ts b/src/lib/player/media-session.ts
new file mode 100644
index 0000000..7a78ebb
--- /dev/null
+++ b/src/lib/player/media-session.ts
@@ -0,0 +1,189 @@
+import { browser } from "$app/environment";
+import type { Track } from "./types";
+
+export type MediaSessionHandlers = {
+ play: () => void;
+ pause: () => void;
+ next: () => void;
+ prev: () => void;
+ seekTo?: (timeSeconds: number) => void;
+ seekBy?: (deltaSeconds: number) => void;
+};
+
+export type MediaSessionBindings = {
+ /**
+ * Call whenever the current track changes.
+ */
+ setTrack: (track: Track | null) => void;
+
+ /**
+ * Call on play/pause changes if you want to keep Media Session "active" state
+ * aligned with your app (handlers still work regardless).
+ */
+ setPlaybackState: (state: "none" | "paused" | "playing") => void;
+
+ /**
+ * Call reasonably often (e.g. on `timeupdate`, `loadedmetadata`, `ratechange`)
+ * to keep lockscreen / OS UI in sync.
+ */
+ updatePositionState: (args: {
+ duration: number;
+ position: number;
+ playbackRate?: number;
+ }) => void;
+
+ /**
+ * Unregisters handlers. Optional — layout-scoped players typically never unmount.
+ */
+ destroy: () => void;
+};
+
+function canUseMediaSession() {
+ return (
+ browser &&
+ typeof navigator !== "undefined" &&
+ "mediaSession" in navigator &&
+ typeof (navigator as Navigator).mediaSession !== "undefined"
+ );
+}
+
+function canUseMetadata() {
+ return typeof MediaMetadata !== "undefined";
+}
+
+export function createMediaSessionBindings(
+ handlers: MediaSessionHandlers,
+): MediaSessionBindings {
+ const mediaSession = canUseMediaSession()
+ ? (navigator as Navigator).mediaSession
+ : null;
+
+ const setActionHandler = (
+ action: MediaSessionAction,
+ handler: MediaSessionActionHandler | null,
+ ) => {
+ if (!mediaSession) return;
+ try {
+ mediaSession.setActionHandler(action, handler);
+ } catch {
+ // Some browsers throw for unsupported actions; ignore.
+ }
+ };
+
+ const safeNumber = (n: number) => (Number.isFinite(n) ? n : 0);
+
+ const setTrack = (track: Track | null) => {
+ if (!mediaSession) return;
+ if (!canUseMetadata()) return;
+
+ if (!track) {
+ // Keep it simple: clear metadata.
+ mediaSession.metadata = null;
+ return;
+ }
+
+ mediaSession.metadata = new MediaMetadata({
+ title: track.title,
+ artist: track.artist,
+ album: track.album,
+ // You can add artwork later if/when you have it:
+ // artwork: [{ src: "/some.png", sizes: "512x512", type: "image/png" }]
+ });
+ };
+
+ const setPlaybackState = (state: "none" | "paused" | "playing") => {
+ if (!mediaSession) return;
+ try {
+ mediaSession.playbackState = state;
+ } catch {
+ // Some browsers may not implement playbackState; ignore.
+ }
+ };
+
+ const updatePositionState = (args: {
+ duration: number;
+ position: number;
+ playbackRate?: number;
+ }) => {
+ if (!mediaSession) return;
+
+ const anySession = mediaSession as unknown as {
+ setPositionState?: (state: {
+ duration: number;
+ playbackRate?: number;
+ position: number;
+ }) => void;
+ };
+
+ if (typeof anySession.setPositionState !== "function") return;
+
+ const duration = Math.max(0, safeNumber(args.duration));
+ const position = Math.max(0, safeNumber(args.position));
+ const playbackRate = args.playbackRate ?? 1;
+
+ try {
+ anySession.setPositionState({
+ duration,
+ position: Math.min(position, duration || position),
+ playbackRate,
+ });
+ } catch {
+ // iOS Safari and some Chromium variants can throw on invalid values.
+ }
+ };
+
+ const installHandlers = () => {
+ if (!mediaSession) return;
+
+ setActionHandler("play", () => handlers.play());
+ setActionHandler("pause", () => handlers.pause());
+ setActionHandler("previoustrack", () => handlers.prev());
+ setActionHandler("nexttrack", () => handlers.next());
+
+ // Seeking (optional)
+ setActionHandler("seekto", (details) => {
+ if (!handlers.seekTo) return;
+ const d = details as MediaSessionActionDetails & { seekTime?: number };
+ if (typeof d.seekTime !== "number") return;
+ handlers.seekTo(d.seekTime);
+ });
+
+ setActionHandler("seekbackward", (details) => {
+ const d = details as MediaSessionActionDetails & { seekOffset?: number };
+ const offset = typeof d.seekOffset === "number" ? d.seekOffset : 10;
+ if (handlers.seekBy) handlers.seekBy(-offset);
+ else if (handlers.seekTo) handlers.seekTo(0); // fallback-ish
+ });
+
+ setActionHandler("seekforward", (details) => {
+ const d = details as MediaSessionActionDetails & { seekOffset?: number };
+ const offset = typeof d.seekOffset === "number" ? d.seekOffset : 10;
+ if (handlers.seekBy) handlers.seekBy(offset);
+ });
+
+ // Stop isn't as universally supported; map to pause.
+ setActionHandler("stop", () => handlers.pause());
+ };
+
+ const destroy = () => {
+ if (!mediaSession) return;
+ // Clear handlers we set.
+ setActionHandler("play", null);
+ setActionHandler("pause", null);
+ setActionHandler("previoustrack", null);
+ setActionHandler("nexttrack", null);
+ setActionHandler("seekto", null);
+ setActionHandler("seekbackward", null);
+ setActionHandler("seekforward", null);
+ setActionHandler("stop", null);
+ };
+
+ installHandlers();
+
+ return {
+ setTrack,
+ setPlaybackState,
+ updatePositionState,
+ destroy,
+ };
+}
diff --git a/src/lib/player/persist.ts b/src/lib/player/persist.ts
new file mode 100644
index 0000000..cef859e
--- /dev/null
+++ b/src/lib/player/persist.ts
@@ -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;
+
+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 | 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()
+ : 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();
+ 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));
+}
diff --git a/src/lib/player/player.svelte.ts b/src/lib/player/player.svelte.ts
new file mode 100644
index 0000000..d34e0fb
--- /dev/null
+++ b/src/lib/player/player.svelte.ts
@@ -0,0 +1,730 @@
+import { browser } from "$app/environment";
+import {
+ createPersistScheduler,
+ loadPersistedPlayerState,
+ type PersistablePlayerState,
+} from "./persist";
+import {
+ buildInitialShuffleOrder,
+ injectNextIntoShuffleOrder,
+ reindexAfterMoveOrRemove,
+} from "./shuffle";
+import type { Track } from "./types";
+
+/**
+ * Global audio player state + queue/shuffle actions (Svelte 5 runes).
+ *
+ * This module is intended to be imported from UI components and pages.
+ * The actual