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