From ccf3a6bc1fa9785f43577c5db78436ba5e514994 Mon Sep 17 00:00:00 2001 From: Yuri Tatishchev Date: Fri, 6 Feb 2026 01:48:22 -0800 Subject: [PATCH] global player pt. 4 more persistance sheanigans --- src/lib/components/GlobalPlayer.svelte | 33 +++++++---- src/lib/player/player.svelte.ts | 78 +++++++++++++++++++++++--- 2 files changed, 91 insertions(+), 20 deletions(-) diff --git a/src/lib/components/GlobalPlayer.svelte b/src/lib/components/GlobalPlayer.svelte index 9e30ae6..2d14e59 100644 --- a/src/lib/components/GlobalPlayer.svelte +++ b/src/lib/components/GlobalPlayer.svelte @@ -8,7 +8,6 @@ import { browser } from "$app/environment"; import { onDestroy, onMount } from "svelte"; import { - getSnapshot, jumpToTrack, next, nowPlayingLabel, @@ -17,9 +16,11 @@ schedulePersistNow, setUiOpen, setVolume, + subscribe, toggleShuffle, toggleUiOpen, toggleWrap, + type PlayerSnapshot, } from "$lib/player/player.svelte"; import { createMediaSessionBindings } from "$lib/player/media-session"; @@ -32,15 +33,22 @@ // local UI derived from viewport; not persisted let isMobile = $state(false); - let snap = $state(getSnapshot()); + // IMPORTANT: Do not read `window` during SSR. Start with a safe snapshot and let + // the player module + subscription populate the real values on mount. + let snap = $state({ + queue: [], + currentIndex: null, + currentTrack: null, + shuffleEnabled: false, + wrapEnabled: false, + order: [], + history: [], + cursor: 0, + volume: 1, + uiOpen: false, + }); - // Keep `snap` fresh. (This is a simple polling effect; if you later want a - // more "store-like" subscription API, we can refactor player module exports.) - let raf: number | null = null; - function tickSnapshot() { - snap = getSnapshot(); - raf = requestAnimationFrame(tickSnapshot); - } + let unsubscribe: (() => void) | null = null; function updateIsMobile() { if (!browser) return; @@ -169,7 +177,10 @@ window.addEventListener("resize", updateIsMobile); } - raf = requestAnimationFrame(tickSnapshot); + // Subscribe to player changes instead of polling + unsubscribe = subscribe((s) => { + snap = s; + }); media.setPlaybackState("paused"); }); @@ -178,7 +189,7 @@ if (browser) { window.removeEventListener("resize", updateIsMobile); } - if (raf) cancelAnimationFrame(raf); + if (unsubscribe) unsubscribe(); media.destroy(); }); diff --git a/src/lib/player/player.svelte.ts b/src/lib/player/player.svelte.ts index 3b76484..2047bfd 100644 --- a/src/lib/player/player.svelte.ts +++ b/src/lib/player/player.svelte.ts @@ -19,6 +19,9 @@ import type { Track } from "./types"; * binds to the state here and calls actions. * * Canonical dedupe id: Track.id === annSongId (number). + * + * NOTE: Do NOT use module-level `$effect` here — consumers (e.g. GlobalPlayer) + * should subscribe via `subscribe()` and drive any side-effects (like persistence). */ export type InsertMode = "play" | "playNext" | "add"; @@ -44,6 +47,9 @@ export type PlayerSnapshot = { uiOpen: boolean; }; +export type PlayerSubscriber = (snapshot: PlayerSnapshot) => void; +export type Unsubscribe = () => void; + const DEFAULT_VOLUME = 1; function isMobileLike() { @@ -154,12 +160,49 @@ const snapshot = $derived({ uiOpen, }); +/** --- Lightweight subscription API (to avoid polling) --- */ +const subscribers = new Set(); + +function notifySubscribers() { + // Read `snapshot` here so every subscriber sees a consistent value. + const s = snapshot; + for (const fn of subscribers) fn(s); +} + +/** + * Subscribe to player snapshot changes. + * + * - Calls `fn` immediately with the current snapshot + * - Returns an `unsubscribe` function + */ +export function subscribe(fn: PlayerSubscriber): Unsubscribe { + subscribers.add(fn); + fn(snapshot); + + return () => { + subscribers.delete(fn); + }; +} + +/** + * Notify subscribers at the end of the current microtask. + * This coalesces multiple mutations within the same tick into one update. + */ +let notifyQueued = false; +function queueNotify() { + if (notifyQueued) return; + notifyQueued = true; + queueMicrotask(() => { + notifyQueued = false; + notifySubscribers(); + }); +} + /** * Persistence * - * NOTE: Module-level `$effect` is not allowed (it becomes an "orphaned effect"). - * Instead, the GlobalPlayer component (or any single always-mounted component) - * should call `schedulePersistNow()` inside its own `$effect`. + * Persistence must be driven from a component (e.g. GlobalPlayer) via `subscribe()` + * to avoid orphaned module-level effects. */ const schedulePersist = createPersistScheduler(250); @@ -328,9 +371,11 @@ export function next(): void { const idx = computeNextIndex(); if (idx == null) { currentIndex = null; + queueNotify(); return; } applyCurrentIndex(idx); + queueNotify(); } export function prev(currentTimeSeconds = 0): void { @@ -339,6 +384,7 @@ export function prev(currentTimeSeconds = 0): void { // If idx === currentIndex, we interpret that as "restart track" applyCurrentIndex(idx); + queueNotify(); } /** Jump to an existing queued track by id (does not reorder). */ @@ -346,6 +392,7 @@ export function jumpToTrack(id: number): void { const i = indexOfTrack(id); if (i === -1) return; applyCurrentIndex(i); + queueNotify(); } /** --- Queue mutation primitives (keep traversal state consistent) --- */ @@ -459,12 +506,15 @@ export function clearQueue(): void { order = []; history = []; cursor = 0; + + queueNotify(); } export function removeTrack(id: number): void { const i = indexOfTrack(id); if (i === -1) return; removeAt(i); + queueNotify(); } /** @@ -507,6 +557,7 @@ export function insertTrack(track: Track, mode: InsertMode): void { } // For play: skipping to inserted is effectively current track already. + queueNotify(); return; } @@ -523,6 +574,7 @@ export function insertTrack(track: Track, mode: InsertMode): void { if (existingIndex !== -1) return; insertNewAt(queue.length, track); // No traversal tweaks required + queueNotify(); return; } @@ -565,17 +617,20 @@ export function insertTrack(track: Track, mode: InsertMode): void { if (mode === "play") { // Skip current -> go to that "next" next(); + } else { + queueNotify(); } } export function insertTracks(tracks: Track[], mode: InsertMode): void { - const incoming = dedupeTracks(tracks).filter((t) => t?.src); + const incoming = dedupeTracks(tracks).filter((t) => t && t.src); if (incoming.length === 0) return; if (mode === "add") { // Append in order for (const t of incoming) insertTrack(t, "add"); + queueNotify(); return; } @@ -593,11 +648,6 @@ export function insertTracks(tracks: Track[], mode: InsertMode): void { let insertPos = base + 1; for (const t of incoming) { - // If it already exists, moving it will affect indices; recompute on each iteration. - // We use insertTrack which handles move + dedupe, but for multiple we want stable sequential order. - // We'll emulate by: - // - ensuring track is at insertPos - // - then increment insertPos const existing = indexOfTrack(t.id); if (existing === -1) { insertNewAt(insertPos, t); @@ -629,6 +679,8 @@ export function insertTracks(tracks: Track[], mode: InsertMode): void { if (mode === "play") { next(); + } else { + queueNotify(); } } @@ -636,18 +688,22 @@ export function insertTracks(tracks: Track[], mode: InsertMode): void { export function setVolume(v: number): void { volume = clamp01(v); + queueNotify(); } export function setUiOpen(open: boolean): void { uiOpen = !!open; + queueNotify(); } export function toggleUiOpen(): void { uiOpen = !uiOpen; + queueNotify(); } export function toggleWrap(): void { wrapEnabled = !wrapEnabled; + queueNotify(); } export function enableShuffle(enable: boolean): void { @@ -660,6 +716,7 @@ export function enableShuffle(enable: boolean): void { order = []; history = []; cursor = 0; + queueNotify(); return; } @@ -673,6 +730,7 @@ export function enableShuffle(enable: boolean): void { } rebuildShuffleOrderPreservingPast(); + queueNotify(); } export function toggleShuffle(): void { @@ -700,6 +758,8 @@ export function setQueue( cursor = 0; if (shuffleEnabled) rebuildShuffleOrderPreservingPast(); + + queueNotify(); } /** --- Convenience wrappers that match UI wording --- */