1# state management 2 3## svelte 5 runes mode 4 5plyr.fm uses svelte 5 runes mode throughout the frontend. **all reactive state must use the `$state()` rune**. 6 7### component-local state 8 9**critical**: in svelte 5 runes mode, plain `let` variables are NOT reactive. you must explicitly opt into reactivity. 10 11```typescript 12// ❌ WRONG - no reactivity, UI won't update 13let loading = true; 14let tracks = []; 15let selectedId = null; 16 17// assignments won't trigger UI updates 18loading = false; // template still shows "loading..." 19tracks = newTracks; // template won't re-render 20``` 21 22```typescript 23// ✅ CORRECT - reactive state 24let loading = $state(true); 25let tracks = $state<Track[]>([]); 26let selectedId = $state<number | null>(null); 27 28// assignments trigger UI updates 29loading = false; // template updates immediately 30tracks = newTracks; // template re-renders with new data 31``` 32 33### when to use `$state()` 34 35use `$state()` for any variable that: 36- is used in the template (`{#if loading}`, `{#each tracks}`, etc.) 37- needs to trigger UI updates when changed 38- is bound to form inputs (`bind:value={title}`) 39- is checked in reactive blocks (`$effect(() => { ... })`) 40 41### overridable `$derived` for optimistic UI (Svelte 5.25+) 42 43as of Svelte 5.25, `$derived` values can be temporarily overridden by reassignment. this is the recommended pattern for optimistic UI where you want to: 441. sync with a prop value (derived behavior) 452. temporarily override for immediate feedback (state behavior) 463. auto-reset when the prop updates 47 48```typescript 49// ✅ RECOMMENDED for optimistic UI (Svelte 5.25+) 50let liked = $derived(initialLiked); 51 52async function toggleLike() { 53 const previous = liked; 54 liked = !liked; // optimistic update - works in 5.25+! 55 56 try { 57 await saveLike(liked); 58 } catch { 59 liked = previous; // revert on failure 60 } 61} 62``` 63 64this replaces the older pattern of `$state` + `$effect` to sync with props: 65 66```typescript 67// ❌ OLD pattern - still works but more verbose 68let liked = $state(initialLiked); 69 70$effect(() => { 71 liked = initialLiked; // sync with prop 72}); 73``` 74 75use plain `let` for: 76- constants that never change 77- variables only used in functions/callbacks (not template) 78- intermediate calculations that don't need reactivity 79 80### common mistakes 81 82**1. mixing reactive and non-reactive state** 83 84```typescript 85// ❌ creates confusing bugs - some state updates, some doesn't 86let loading = true; // non-reactive 87let tracks = $state<Track[]>([]); // reactive 88let selectedId = $state<number | null>(null); // reactive 89``` 90 91**2. forgetting `$state()` after copy-pasting** 92 93```typescript 94// ❌ copied from svelte 4 code 95let editing = false; 96let editValue = ''; 97 98// ✅ updated for svelte 5 99let editing = $state(false); 100let editValue = $state(''); 101``` 102 103**3. assuming reactivity from svelte 4 habits** 104 105svelte 4 made all component `let` variables reactive by default. svelte 5 requires explicit `$state()` opt-in for finer control and better performance. 106 107### debugging reactivity issues 108 109**symptom**: template shows stale data even though console.log shows variable updated 110 111```typescript 112async function loadData() { 113 loading = true; // variable updates... 114 const data = await fetch(...); 115 loading = false; // variable updates... 116 console.log('loading:', loading); // logs "false" 117} 118// but UI still shows "loading..." spinner 119``` 120 121**diagnosis**: missing `$state()` wrapper 122 123```typescript 124// check variable declaration 125let loading = true; // ❌ missing $state() 126 127// fix 128let loading = $state(true); // ✅ now reactive 129``` 130 131**verification**: after adding `$state()`, check: 1321. variable assignments trigger template updates 1332. no console errors about "Cannot access X before initialization" 1343. UI reflects current variable value 135 136### waiting for async conditions with `$effect` 137 138when you need to perform an action after some async condition is met (like audio being ready), **don't rely on event listeners** - they may not attach in time if the target element doesn't exist yet or the event fires before your listener is registered. 139 140**instead, use a reactive `$effect` that watches for the conditions to be met:** 141 142```typescript 143// ❌ WRONG - event listener may not attach in time 144onMount(() => { 145 queue.playNow(track); // triggers async loading in Player component 146 147 // player.audioElement might be undefined here! 148 // even if it exists, loadedmetadata may fire before this runs 149 player.audioElement?.addEventListener('loadedmetadata', () => { 150 player.audioElement.currentTime = seekTime; 151 }); 152}); 153``` 154 155```typescript 156// ✅ CORRECT - reactive effect waits for conditions 157let pendingSeekMs = $state<number | null>(null); 158 159onMount(() => { 160 pendingSeekMs = 11000; // store the pending action 161 queue.playNow(track); // trigger the async operation 162}); 163 164// effect runs whenever dependencies change, including when audio becomes ready 165$effect(() => { 166 if ( 167 pendingSeekMs !== null && 168 player.currentTrack?.id === track.id && 169 player.audioElement && 170 player.audioElement.readyState >= 1 171 ) { 172 player.audioElement.currentTime = pendingSeekMs / 1000; 173 pendingSeekMs = null; // clear after performing action 174 } 175}); 176``` 177 178**why this works:** 179- `$effect` re-runs whenever any of its dependencies change 180- when `player.audioElement` becomes available and ready, the effect fires 181- no race condition - the effect will catch the ready state even if it happened "in the past" 182- setting `pendingSeekMs = null` ensures the action only runs once 183 184**use this pattern when:** 185- waiting for DOM elements to exist 186- waiting for async operations to complete 187- coordinating between components that load independently 188 189## global state management 190 191### overview 192 193plyr.fm uses global state managers following the Svelte 5 runes pattern for cross-component reactive state. 194 195## managers 196 197### player (`frontend/src/lib/player.svelte.ts`) 198- manages audio playback state globally 199- tracks: current track, play/pause, volume, progress 200- persists across navigation 201- integrates with queue for track advancement 202- media session integration in `Player.svelte` (see below) 203 204### uploader (`frontend/src/lib/uploader.svelte.ts`) 205- manages file uploads in background 206- fire-and-forget pattern - returns immediately 207- shows toast notifications for progress/completion 208- triggers cache refresh on success 209- server-sent events for real-time progress 210 211### tracks cache (`frontend/src/lib/tracks.svelte.ts`) 212- caches track list globally in localStorage 213- provides instant navigation by serving cached data 214- cursor-based pagination with `fetchMore()` for infinite scroll 215- pagination state (`nextCursor`, `hasMore`) persisted alongside tracks 216- invalidates on new uploads (resets to first page) 217- includes like status for each track (when authenticated) 218- simple invalidation model - no time-based expiry 219 220### queue (`frontend/src/lib/queue.svelte.ts`) 221- manages playback queue with server sync 222- optimistic updates with 250ms debounce 223- handles shuffle and repeat modes 224- conflict resolution for multi-device scenarios 225- see [`queue.md`](./queue.md) for details 226 227### liked tracks (`frontend/src/lib/tracks.svelte.ts`) 228- like/unlike functions exported from tracks module 229- invalidates cache on like/unlike 230- fetch liked tracks via `/tracks/liked` endpoint 231- integrates with main tracks cache for like status 232 233### preferences 234- user preferences managed through `ProfileMenu.svelte` (mobile) and `SettingsMenu.svelte` (desktop) 235- theme selection: dark / light / system (follows OS preference) 236- accent color customization 237- auto-play next track setting 238- hidden tags for discovery feed filtering 239- persisted to backend via `/preferences/` API 240- localStorage fallback for offline access 241- no dedicated state file - integrated into settings components 242 243### toast (`frontend/src/lib/toast.svelte.ts`) 244- global notification system 245- types: success, error, info, warning 246- auto-dismiss with configurable duration 247- in-place updates for progress changes 248 249## pattern 250 251```typescript 252class GlobalState { 253 // reactive state using $state rune 254 data = $state<Type>(initialValue); 255 256 // methods that mutate state 257 updateData(newValue: Type) { 258 this.data = newValue; 259 } 260} 261 262export const globalState = new GlobalState(); 263``` 264 265## benefits 266 267- survives navigation (state persists across route changes) 268- single source of truth 269- reactive updates automatically propagate to components 270- no prop drilling 271 272## flow examples 273 274### upload flow 275 2761. user clicks upload on dedicated `/upload` page (linked from portal or ProfileMenu) 2772. `uploader.upload()` called - returns immediately 2783. user navigates to homepage 2794. homepage renders cached tracks instantly (no blocking) 2805. upload completes in background 2816. success toast appears with "view track" link 2827. cache refreshes, homepage updates with new track 283 284this avoids HTTP/1.1 connection pooling issues by using cached data instead of blocking on fresh fetches during long-running uploads. 285 286### like flow 287 2881. user clicks like button on track 2892. `POST /tracks/{id}/like` sent to backend 2903. ATProto record created on user's PDS 2914. database updated 2925. tracks cache invalidated 2936. UI reflects updated like status on next cache fetch 2947. if error occurs, error logged to console 295 296### queue flow 297 2981. user clicks "play now" on a track 2992. queue state updates locally (instant feedback) 3003. debounced PUT to `/queue/` (250ms) 3014. server hydrates and returns full queue 3025. client merges server state 3036. other devices receive updates via periodic polling 304 305### preferences flow 306 3071. user changes accent color in settings 3082. UI updates immediately 3093. `PATCH /preferences/` sent in background 3104. server updates and returns new preferences 3115. changes persist across sessions 3126. localStorage provides offline access 313 314## state persistence 315 316### server-side persistence 317 318- **queue**: stored in `queue_state` table, synced via LISTEN/NOTIFY 319- **liked tracks**: stored in `track_likes` table 320- **preferences**: stored in `user_preferences` table 321 322### client-side persistence 323 324- **player volume**: localStorage (`playerVolume`) 325- **queue position**: synced with server, cached locally 326- **preferences**: server-authoritative, localStorage fallback 327 328## benefits 329 330- **cross-device sync**: queue and likes work across devices 331- **offline resilience**: localStorage provides graceful degradation 332- **instant feedback**: optimistic updates keep UI responsive 333- **server authority**: conflicts resolved by server state 334- **efficient updates**: debouncing and batching reduce API calls 335 336## media session API 337 338the player component (`frontend/src/lib/components/player/Player.svelte`) integrates with the [Media Session API](https://developer.mozilla.org/en-US/docs/Web/API/Media_Session_API) to provide metadata and controls to external interfaces like: 339 340- **lock screen controls** (iOS/Android) 341- **CarPlay / Android Auto** 342- **bluetooth devices** 343- **macOS control center** 344 345### metadata 346 347when a track plays, we update `navigator.mediaSession.metadata` with: 348- title, artist, album 349- artwork (track image or artist avatar fallback) 350 351### action handlers 352 353set up in `onMount`, these respond to system media controls: 354- `play` / `pause` - toggle playback 355- `previoustrack` / `nexttrack` - navigate queue 356- `seekto` - jump to position 357- `seekbackward` / `seekforward` - skip 10 seconds 358 359### position state 360 361reactive effects keep `navigator.mediaSession.setPositionState()` synced with playback progress, enabling scrubbers on lock screens. 362 363### implementation notes 364 365- handlers are registered once in `onMount` 366- metadata updates reactively via `$effect` when `player.currentTrack` changes 367- playback state syncs reactively with `player.paused` 368- position updates on every `player.currentTime` / `player.duration` change 369- gracefully no-ops if `navigator.mediaSession` is unavailable