audio streaming app
plyr.fm
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## global state management
137
138### overview
139
140plyr.fm uses global state managers following the Svelte 5 runes pattern for cross-component reactive state.
141
142## managers
143
144### player (`frontend/src/lib/player.svelte.ts`)
145- manages audio playback state globally
146- tracks: current track, play/pause, volume, progress
147- persists across navigation
148- integrates with queue for track advancement
149- media session integration in `Player.svelte` (see below)
150
151### uploader (`frontend/src/lib/uploader.svelte.ts`)
152- manages file uploads in background
153- fire-and-forget pattern - returns immediately
154- shows toast notifications for progress/completion
155- triggers cache refresh on success
156- server-sent events for real-time progress
157
158### tracks cache (`frontend/src/lib/tracks.svelte.ts`)
159- caches track list globally in localStorage
160- provides instant navigation by serving cached data
161- cursor-based pagination with `fetchMore()` for infinite scroll
162- pagination state (`nextCursor`, `hasMore`) persisted alongside tracks
163- invalidates on new uploads (resets to first page)
164- includes like status for each track (when authenticated)
165- simple invalidation model - no time-based expiry
166
167### queue (`frontend/src/lib/queue.svelte.ts`)
168- manages playback queue with server sync
169- optimistic updates with 250ms debounce
170- handles shuffle and repeat modes
171- conflict resolution for multi-device scenarios
172- see [`queue.md`](./queue.md) for details
173
174### liked tracks (`frontend/src/lib/tracks.svelte.ts`)
175- like/unlike functions exported from tracks module
176- invalidates cache on like/unlike
177- fetch liked tracks via `/tracks/liked` endpoint
178- integrates with main tracks cache for like status
179
180### preferences
181- user preferences managed through `ProfileMenu.svelte` (mobile) and `SettingsMenu.svelte` (desktop)
182- theme selection: dark / light / system (follows OS preference)
183- accent color customization
184- auto-play next track setting
185- hidden tags for discovery feed filtering
186- persisted to backend via `/preferences/` API
187- localStorage fallback for offline access
188- no dedicated state file - integrated into settings components
189
190### toast (`frontend/src/lib/toast.svelte.ts`)
191- global notification system
192- types: success, error, info, warning
193- auto-dismiss with configurable duration
194- in-place updates for progress changes
195
196## pattern
197
198```typescript
199class GlobalState {
200 // reactive state using $state rune
201 data = $state<Type>(initialValue);
202
203 // methods that mutate state
204 updateData(newValue: Type) {
205 this.data = newValue;
206 }
207}
208
209export const globalState = new GlobalState();
210```
211
212## benefits
213
214- survives navigation (state persists across route changes)
215- single source of truth
216- reactive updates automatically propagate to components
217- no prop drilling
218
219## flow examples
220
221### upload flow
222
2231. user clicks upload on dedicated `/upload` page (linked from portal or ProfileMenu)
2242. `uploader.upload()` called - returns immediately
2253. user navigates to homepage
2264. homepage renders cached tracks instantly (no blocking)
2275. upload completes in background
2286. success toast appears with "view track" link
2297. cache refreshes, homepage updates with new track
230
231this avoids HTTP/1.1 connection pooling issues by using cached data instead of blocking on fresh fetches during long-running uploads.
232
233### like flow
234
2351. user clicks like button on track
2362. `POST /tracks/{id}/like` sent to backend
2373. ATProto record created on user's PDS
2384. database updated
2395. tracks cache invalidated
2406. UI reflects updated like status on next cache fetch
2417. if error occurs, error logged to console
242
243### queue flow
244
2451. user clicks "play now" on a track
2462. queue state updates locally (instant feedback)
2473. debounced PUT to `/queue/` (250ms)
2484. server hydrates and returns full queue
2495. client merges server state
2506. other devices receive updates via periodic polling
251
252### preferences flow
253
2541. user changes accent color in settings
2552. UI updates immediately
2563. `PATCH /preferences/` sent in background
2574. server updates and returns new preferences
2585. changes persist across sessions
2596. localStorage provides offline access
260
261## state persistence
262
263### server-side persistence
264
265- **queue**: stored in `queue_state` table, synced via LISTEN/NOTIFY
266- **liked tracks**: stored in `track_likes` table
267- **preferences**: stored in `user_preferences` table
268
269### client-side persistence
270
271- **player volume**: localStorage (`playerVolume`)
272- **queue position**: synced with server, cached locally
273- **preferences**: server-authoritative, localStorage fallback
274
275## benefits
276
277- **cross-device sync**: queue and likes work across devices
278- **offline resilience**: localStorage provides graceful degradation
279- **instant feedback**: optimistic updates keep UI responsive
280- **server authority**: conflicts resolved by server state
281- **efficient updates**: debouncing and batching reduce API calls
282
283## media session API
284
285the 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:
286
287- **lock screen controls** (iOS/Android)
288- **CarPlay / Android Auto**
289- **bluetooth devices**
290- **macOS control center**
291
292### metadata
293
294when a track plays, we update `navigator.mediaSession.metadata` with:
295- title, artist, album
296- artwork (track image or artist avatar fallback)
297
298### action handlers
299
300set up in `onMount`, these respond to system media controls:
301- `play` / `pause` - toggle playback
302- `previoustrack` / `nexttrack` - navigate queue
303- `seekto` - jump to position
304- `seekbackward` / `seekforward` - skip 10 seconds
305
306### position state
307
308reactive effects keep `navigator.mediaSession.setPositionState()` synced with playback progress, enabling scrubbers on lock screens.
309
310### implementation notes
311
312- handlers are registered once in `onMount`
313- metadata updates reactively via `$effect` when `player.currentTrack` changes
314- playback state syncs reactively with `player.paused`
315- position updates on every `player.currentTime` / `player.duration` change
316- gracefully no-ops if `navigator.mediaSession` is unavailable