music on atproto
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### 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