at main 15 kB view raw
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}