music on atproto
plyr.fm
1import { browser } from '$app/environment';
2import type { QueueResponse, QueueState, Track } from './types';
3import { API_URL } from './config';
4import { APP_BROADCAST_PREFIX } from './branding';
5import { auth } from './auth.svelte';
6
7const SYNC_DEBOUNCE_MS = 250;
8
9// global queue state using Svelte 5 runes
10class Queue {
11 tracks = $state<Track[]>([]);
12 currentIndex = $state(0);
13 shuffle = $state(false);
14 originalOrder = $state<Track[]>([]);
15 autoAdvance = $state(true);
16
17 revision = $state<number | null>(null);
18 etag = $state<string | null>(null);
19 syncInProgress = $state(false);
20 lastUpdateWasLocal = $state(false);
21
22 initialized = false;
23 hydrating = false;
24
25 syncTimer: number | null = null;
26 pendingSync = false;
27 channel: BroadcastChannel | null = null;
28 tabId: string | null = null;
29
30 get currentTrack(): Track | null {
31 if (this.tracks.length === 0) return null;
32 return this.tracks[this.currentIndex] ?? null;
33 }
34
35 get hasNext(): boolean {
36 return this.currentIndex < this.tracks.length - 1;
37 }
38
39 get hasPrevious(): boolean {
40 return this.currentIndex > 0;
41 }
42
43 get upNext(): Track[] {
44 if (this.tracks.length === 0) return [];
45 return this.tracks.slice(this.currentIndex + 1);
46 }
47
48 get upNextEntries(): { track: Track; index: number }[] {
49 if (this.tracks.length === 0) return [];
50 return this.tracks
51 .map((track, index) => ({ track, index }))
52 .filter(({ index }) => index > this.currentIndex);
53 }
54
55 getCurrentTrack(): Track | null {
56 if (this.tracks.length === 0) return null;
57 return this.tracks[this.currentIndex] ?? null;
58 }
59
60 getUpNextEntries(): { track: Track; index: number }[] {
61 if (this.tracks.length === 0) return [];
62 return this.tracks
63 .map((track, index) => ({ track, index }))
64 .filter(({ index }) => index > this.currentIndex);
65 }
66
67 setAutoAdvance(value: boolean) {
68 this.autoAdvance = value;
69 if (browser) {
70 localStorage.setItem('autoAdvance', value ? '1' : '0');
71 }
72 }
73
74 async initialize() {
75 if (!browser || this.initialized) return;
76 this.initialized = true;
77
78 const storedTabId = sessionStorage.getItem('queue_tab_id');
79 if (storedTabId) {
80 this.tabId = storedTabId;
81 } else {
82 this.tabId = this.createTabId();
83 sessionStorage.setItem('queue_tab_id', this.tabId);
84 }
85
86 const savedAutoAdvance = localStorage.getItem('autoAdvance');
87 if (savedAutoAdvance !== null) {
88 this.autoAdvance = savedAutoAdvance !== '0';
89 }
90
91 // set up cross-tab synchronization
92 this.channel = new BroadcastChannel(`${APP_BROADCAST_PREFIX}-queue`);
93 this.channel.onmessage = (event) => {
94 if (event.data.type === 'queue-updated') {
95 // ignore our own broadcasts (we already have this revision)
96 if (event.data.sourceTabId && event.data.sourceTabId === this.tabId) {
97 return;
98 }
99
100 if (event.data.revision === this.revision) {
101 return;
102 }
103
104 // another tab updated the queue, refetch to stay in sync
105 this.lastUpdateWasLocal = false;
106 void this.fetchQueue(true);
107 }
108 };
109
110 // only fetch from server if authenticated
111 if (this.isAuthenticated()) {
112 await this.fetchQueue();
113 }
114
115 document.addEventListener('visibilitychange', this.handleVisibilityChange);
116 window.addEventListener('beforeunload', this.handleBeforeUnload);
117 }
118
119 handleVisibilityChange = () => {
120 if (document.visibilityState === 'hidden') {
121 void this.flushSync();
122 }
123 };
124
125 handleBeforeUnload = () => {
126 void this.flushSync();
127 this.channel?.close();
128 };
129
130 async flushSync() {
131 if (this.syncTimer) {
132 window.clearTimeout(this.syncTimer);
133 this.syncTimer = null;
134 await this.pushQueue();
135 return;
136 }
137
138 if (this.pendingSync && !this.syncInProgress) {
139 await this.pushQueue();
140 }
141 }
142
143 private isAuthenticated(): boolean {
144 if (!browser) return false;
145 return auth.isAuthenticated;
146 }
147
148 async fetchQueue(force = false) {
149 if (!browser) return;
150 if (!this.isAuthenticated()) return; // skip if not authenticated
151
152 // while we have unsent or in-flight local changes, skip non-forced fetches
153 if (
154 !force &&
155 (this.syncInProgress || this.syncTimer !== null || this.pendingSync)
156 ) {
157 return;
158 }
159
160 try {
161 this.hydrating = true;
162
163 const headers: HeadersInit = {};
164
165 if (this.etag && !force) {
166 headers['If-None-Match'] = this.etag;
167 }
168
169 const response = await fetch(`${API_URL}/queue/`, {
170 headers,
171 credentials: 'include'
172 });
173
174 if (response.status === 304) {
175 return;
176 }
177
178 if (!response.ok) {
179 throw new Error(`failed to fetch queue: ${response.statusText}`);
180 }
181
182 const data: QueueResponse = await response.json();
183 const newEtag = response.headers.get('etag');
184
185 if (this.revision !== null && data.revision < this.revision) {
186 return;
187 }
188
189 this.revision = data.revision;
190 this.etag = newEtag;
191
192 this.lastUpdateWasLocal = false;
193 this.applySnapshot(data);
194 } catch (error) {
195 console.error('failed to fetch queue:', error);
196 } finally {
197 this.hydrating = false;
198 }
199 }
200
201 applySnapshot(snapshot: QueueResponse) {
202 const { state, tracks } = snapshot;
203 const trackIds = state.track_ids ?? [];
204 const serverTracks = tracks ?? [];
205
206 // build track lookup by file_id from server tracks (deduplicated)
207 const trackByFileId = new Map<string, Track>();
208 for (const track of serverTracks) {
209 if (track) {
210 trackByFileId.set(track.file_id, track);
211 }
212 }
213
214 // build ordered tracks array, using track metadata for each file_id
215 const orderedTracks: Track[] = [];
216 for (const fileId of trackIds) {
217 const track = trackByFileId.get(fileId);
218 if (track) {
219 // always use a copy to ensure each queue position is independent
220 orderedTracks.push({ ...track });
221 }
222 }
223
224 if (orderedTracks.length > 0 || trackIds.length === 0) {
225 this.tracks = orderedTracks;
226 }
227
228 // build original order array
229 const originalIds =
230 state.original_order_ids && state.original_order_ids.length > 0
231 ? state.original_order_ids
232 : trackIds;
233
234 const originalTracks: Track[] = [];
235 for (const fileId of originalIds) {
236 const track = trackByFileId.get(fileId);
237 if (track) {
238 // always use a copy to ensure independence
239 originalTracks.push({ ...track });
240 }
241 }
242
243 if (originalTracks.length > 0 || originalIds.length === 0) {
244 this.originalOrder = originalTracks.length ? originalTracks : [...orderedTracks];
245 }
246
247 this.shuffle = state.shuffle;
248
249 // sync autoAdvance from server
250 if (state.auto_advance !== undefined) {
251 this.autoAdvance = state.auto_advance;
252 }
253
254 this.currentIndex = this.resolveCurrentIndex(
255 state.current_track_id,
256 state.current_index,
257 this.tracks
258 );
259 }
260
261 resolveCurrentIndex(currentTrackId: string | null, index: number, tracks: Track[]): number {
262 if (tracks.length === 0) return 0;
263
264 const indexInRange = Number.isInteger(index) && index >= 0 && index < tracks.length;
265
266 // trust the explicit index first – the server always sends the correct slot
267 if (indexInRange) {
268 return index;
269 }
270
271 if (currentTrackId) {
272 const match = tracks.findIndex((track) => track.file_id === currentTrackId);
273 if (match !== -1) return match;
274 }
275
276 return 0;
277 }
278
279 clampIndex(index: number): number {
280 if (this.tracks.length === 0) return 0;
281 if (index < 0) return 0;
282 if (index >= this.tracks.length) return this.tracks.length - 1;
283 return index;
284 }
285
286 schedulePush() {
287 if (!browser) return;
288
289 if (this.syncTimer !== null) {
290 window.clearTimeout(this.syncTimer);
291 }
292
293 this.syncTimer = window.setTimeout(() => {
294 this.syncTimer = null;
295 void this.pushQueue();
296 }, SYNC_DEBOUNCE_MS);
297 }
298
299 async pushQueue(): Promise<boolean> {
300 if (!browser) return false;
301 if (!this.isAuthenticated()) return false; // skip if not authenticated
302
303 if (this.syncInProgress) {
304 this.pendingSync = true;
305 return false;
306 }
307
308 if (this.syncTimer !== null) {
309 window.clearTimeout(this.syncTimer);
310 this.syncTimer = null;
311 }
312
313 this.syncInProgress = true;
314 this.pendingSync = false;
315
316 try {
317 const state: QueueState = {
318 track_ids: this.tracks.map((t) => t.file_id),
319 current_index: this.currentIndex,
320 current_track_id: this.currentTrack?.file_id ?? null,
321 shuffle: this.shuffle,
322 original_order_ids: this.originalOrder.map((t) => t.file_id),
323 auto_advance: this.autoAdvance
324 };
325
326 const headers: HeadersInit = {
327 'Content-Type': 'application/json'
328 };
329
330 if (this.revision !== null) {
331 headers['If-Match'] = `"${this.revision}"`;
332 }
333
334 const response = await fetch(`${API_URL}/queue/`, {
335 credentials: 'include',
336 method: 'PUT',
337 headers,
338 body: JSON.stringify({ state })
339 });
340
341 if (response.status === 401) {
342 // session expired or invalid, stop trying to sync
343 return false;
344 }
345
346 if (response.status === 409) {
347 console.warn('queue conflict detected, fetching latest state');
348 await this.fetchQueue(true);
349 return false;
350 }
351
352 if (!response.ok) {
353 throw new Error(`failed to push queue: ${response.statusText}`);
354 }
355
356 const data: QueueResponse = await response.json();
357 const newEtag = response.headers.get('etag');
358
359 if (this.revision !== null && data.revision < this.revision) {
360 return true;
361 }
362
363 this.revision = data.revision;
364 this.etag = newEtag;
365
366 this.applySnapshot(data);
367
368 // notify other tabs about the queue update
369 const sourceTabId = this.tabId ?? this.createTabId();
370 this.tabId = sourceTabId;
371 try {
372 sessionStorage.setItem('queue_tab_id', sourceTabId);
373 } catch (error) {
374 console.warn('failed to persist queue tab id', error);
375 }
376 this.channel?.postMessage({ type: 'queue-updated', revision: data.revision, sourceTabId });
377
378 return true;
379 } catch (error) {
380 console.error('failed to push queue:', error);
381 return false;
382 } finally {
383 this.syncInProgress = false;
384
385 if (this.pendingSync) {
386 this.pendingSync = false;
387 void this.pushQueue();
388 }
389 }
390 }
391
392 addTracks(tracks: Track[], playNow = false) {
393 if (tracks.length === 0) return;
394
395 this.lastUpdateWasLocal = true;
396 this.tracks = [...this.tracks, ...tracks];
397 this.originalOrder = [...this.originalOrder, ...tracks];
398
399 if (playNow) {
400 this.currentIndex = this.tracks.length - tracks.length;
401 }
402
403 this.schedulePush();
404 }
405
406 setQueue(tracks: Track[], startIndex = 0) {
407 if (tracks.length === 0) {
408 this.clear();
409 return;
410 }
411
412 this.lastUpdateWasLocal = true;
413 this.tracks = [...tracks];
414 this.originalOrder = [...tracks];
415 this.currentIndex = this.clampIndex(startIndex);
416 this.schedulePush();
417 }
418
419 playNow(track: Track, autoPlay = true) {
420 this.lastUpdateWasLocal = autoPlay;
421 const upNext = this.tracks.slice(this.currentIndex + 1);
422 this.tracks = [track, ...upNext];
423 this.originalOrder = [...this.tracks];
424 this.currentIndex = 0;
425 this.schedulePush();
426 }
427
428 clear() {
429 this.lastUpdateWasLocal = true;
430 this.tracks = [];
431 this.originalOrder = [];
432 this.currentIndex = 0;
433 this.schedulePush();
434 }
435
436 goTo(index: number) {
437 if (index < 0 || index >= this.tracks.length) return;
438 this.lastUpdateWasLocal = true;
439 this.currentIndex = index;
440 this.schedulePush();
441 }
442
443 next() {
444 if (this.tracks.length === 0) return;
445
446 if (this.currentIndex < this.tracks.length - 1) {
447 this.lastUpdateWasLocal = true;
448 this.currentIndex += 1;
449 this.schedulePush();
450 }
451 }
452
453 previous(forceSkip = false) {
454 if (this.tracks.length === 0) return;
455
456 if (this.currentIndex > 0 || forceSkip) {
457 this.lastUpdateWasLocal = true;
458 if (this.currentIndex > 0) {
459 this.currentIndex -= 1;
460 }
461 this.schedulePush();
462 return true;
463 }
464 return false;
465 }
466
467 toggleShuffle() {
468 // shuffle is an action, not a mode - shuffle upcoming tracks every time
469 if (this.tracks.length <= 1) {
470 return;
471 }
472
473 this.lastUpdateWasLocal = true;
474
475 // keep current track, shuffle everything after it
476 const current = this.tracks[this.currentIndex];
477 const before = this.tracks.slice(0, this.currentIndex);
478 const after = this.tracks.slice(this.currentIndex + 1);
479
480 // if only one track in up next, nothing to shuffle
481 if (after.length <= 1) {
482 return;
483 }
484
485 // fisher-yates shuffle, ensuring we get a DIFFERENT permutation
486 let shuffled: typeof after;
487 let attempts = 0;
488 const maxAttempts = 10;
489
490 do {
491 shuffled = [...after];
492 for (let i = shuffled.length - 1; i > 0; i--) {
493 const j = Math.floor(Math.random() * (i + 1));
494 [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
495 }
496 attempts++;
497 } while (
498 attempts < maxAttempts &&
499 shuffled.every((track, i) => track.file_id === after[i].file_id)
500 );
501
502 // rebuild queue: everything before current + current + shuffled upcoming
503 this.tracks = [...before, current, ...shuffled];
504
505 // current index stays the same (it's in the same position)
506 // no need to update currentIndex
507
508 this.schedulePush();
509 }
510
511 moveTrack(fromIndex: number, toIndex: number) {
512 if (fromIndex === toIndex) return;
513 if (fromIndex < 0 || fromIndex >= this.tracks.length) return;
514 if (toIndex < 0 || toIndex >= this.tracks.length) return;
515
516 this.lastUpdateWasLocal = true;
517 const updated = [...this.tracks];
518 const [moved] = updated.splice(fromIndex, 1);
519 updated.splice(toIndex, 0, moved);
520
521 if (fromIndex === this.currentIndex) {
522 this.currentIndex = toIndex;
523 } else if (fromIndex < this.currentIndex && toIndex >= this.currentIndex) {
524 this.currentIndex -= 1;
525 } else if (fromIndex > this.currentIndex && toIndex <= this.currentIndex) {
526 this.currentIndex += 1;
527 }
528
529 this.tracks = updated;
530
531 if (!this.shuffle) {
532 this.originalOrder = [...updated];
533 }
534
535 this.schedulePush();
536 }
537
538 removeTrack(index: number) {
539 if (index < 0 || index >= this.tracks.length) return;
540 if (index === this.currentIndex) return;
541
542 this.lastUpdateWasLocal = true;
543 const updated = [...this.tracks];
544 const [removed] = updated.splice(index, 1);
545
546 this.tracks = updated;
547 this.originalOrder = this.originalOrder.filter((track) => track.file_id !== removed.file_id);
548
549 if (updated.length === 0) {
550 this.currentIndex = 0;
551 this.schedulePush();
552 return;
553 }
554
555 if (index < this.currentIndex) {
556 this.currentIndex -= 1;
557 } else if (index === this.currentIndex) {
558 this.currentIndex = this.clampIndex(this.currentIndex);
559 }
560
561 this.schedulePush();
562 }
563
564 clearUpNext() {
565 if (this.tracks.length === 0) return;
566
567 this.lastUpdateWasLocal = true;
568
569 // keep only the current track
570 const currentTrack = this.tracks[this.currentIndex];
571 if (!currentTrack) return;
572
573 this.tracks = [currentTrack];
574 this.originalOrder = [currentTrack];
575 this.currentIndex = 0;
576
577 this.schedulePush();
578 }
579
580 private createTabId(): string {
581 if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) {
582 return crypto.randomUUID();
583 }
584
585 return `${Date.now()}-${Math.random().toString(16).slice(2)}`;
586 }
587}
588
589export const queue = new Queue();
590
591if (browser) {
592 void queue.initialize();
593}