1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798 import { defineStore } from 'pinia'
import { watch } from 'vue'
import { useLocalStorage } from '@vueuse/core'
import { throttle } from 'lodash-es'
import { type Track, ReplayGainMode } from '../types'
import { shuffle, shuffled, trackListEquals, formatArtists, sleep, isMobile } from '../utils'
import { AudioController } from '../audioController'
import { useMainStore } from './main'
import { useRadioStore } from './radio'
// ---------------------------------------------------------------------------
// Module-level singletons
// ---------------------------------------------------------------------------
// These are created once and shared across the whole app lifetime.
// Placing them outside the store avoids re-creation on hot-reload.
/** Browser MediaSession API (undefined on unsupported browsers). */
const mediaSession: MediaSession | undefined = navigator.mediaSession
/** Singleton Web Audio controller – owns the AudioContext and pipeline. */
const audio = new AudioController()
// MediaSession requires a non-zero playback rate; 1 = normal speed
const mediaSessionProgressRate = 1
// ---------------------------------------------------------------------------
// Pinia store
// ---------------------------------------------------------------------------
export const usePlayerStore = defineStore('player', {
// ── State ─────────────────────────────────────────────────────────────────
state: () => ({
/** Ordered list of tracks in the current playback queue. */
queue: [] as Track[],
/** Index of the currently playing track within queue (-1 = nothing loaded). */
queueIndex: -1,
/** Duration of the current track in seconds (from audio metadata). */
duration: 0.0,
/** Current playback position in seconds, updated on every timeupdate event. */
currentTime: 0.0,
/** Active ReplayGain normalisation mode. */
replayGainMode: useLocalStorage<ReplayGainMode>('player.replayGainMode', ReplayGainMode.None),
/** Whether the queue loops back to the beginning when it reaches the end. */
repeat: useLocalStorage<boolean>('player.repeat', false),
/** Whether the queue was shuffled when loaded. */
shuffle: useLocalStorage<boolean>('player.shuffle', false),
/** Master volume (0–1). */
volume: useLocalStorage<number>('player.volume', 1.0),
/** True while the audio element is actively playing. */
isPlaying: false,
/** True once the current track has been scrobbled (sent to the API). */
scrobbled: false,
/** True while skipping next/back. */
inTransition: false,
/**
* True when the user explicitly paused.
* Used by the mobile auto-resume logic to avoid resuming after an
* OS-level interruption if the user had intentionally paused.
*/
wasPaused: true
}),
// ── Getters ───────────────────────────────────────────────────────────────
getters: {
/** Currently active track, or null when the queue is empty / not started. */
track(): Track | null {
return this.queue[this.queueIndex] ?? null
},
/** The track that will play after the current one (wraps around). */
nextTrack(): Track | null {
return this.queue[(this.queueIndex + 1) % this.queue.length] ?? null
},
/** Shorthand for the current track's ID (null when nothing is loaded). */
trackId(): string | null {
return this.track?.id ?? null
},
/**
* Current playback position clamped to [0, duration].
* Returns 0 when duration is unknown.
*/
progress(): number {
if (this.duration > 0) {
return Math.min(this.currentTime, this.duration)
}
return 0
},
/** True when there is at least one more track after the current one. */
hasNext(): boolean {
return !!this.queue && (this.queueIndex < this.queue.length - 1)
},
/** True when the current track is not the first in the queue. */
hasPrevious(): boolean {
return this.queueIndex > 0
},
},
// ── Actions ───────────────────────────────────────────────────────────────
actions: {
/**
* Replace the queue with `tracks` and start playing from the first track.
* Shuffle is disabled so the order matches what was passed in.
*/
async playNow(tracks: Track[]) {
this.setShuffle(false)
await this.playTrackList(tracks, 0)
},
/**
* Replace the queue with a shuffled version of `tracks` and start playing.
* A random starting track is chosen by playTrackList when index is omitted.
*/
async shuffleNow(tracks: Track[]) {
this.setShuffle(true)
await this.playTrackList(tracks)
},
/**
* Jump to a specific queue position without replacing the queue.
* Pre-loads the next track's URL into the audio buffer.
*/
async playTrackListIndex(index: number) {
this.setQueueIndex(index)
if (!this.track)
return
await audio.loadTrack({
track: this.track,
nextTrack: this.nextTrack,
fade: true,
})
},
/**
* Replace the queue (unless it already contains the same tracks) and
* start playback from the given index.
*
* When shuffle is enabled the track list is shuffled in-place so the
* chosen starting track ends up at index 0.
*/
async playTrackList(tracks: Track[], index?: number) {
if (index == null)
// Pick a random start position when shuffling, otherwise start at 0
index = this.shuffle ? Math.floor(Math.random() * tracks.length) : 0
if (this.shuffle) {
tracks = [...tracks]
shuffle(tracks, index) // Moves the chosen track to position 0
index = 0
}
// Avoid resetting the queue if the contents haven't changed
if (!trackListEquals(this.queue || [], tracks)) {
this.setQueue(tracks)
}
this.setQueueIndex(index)
if (!this.track)
return
await audio.loadTrack({
track: this.track,
nextTrack: this.nextTrack,
fade: true,
})
},
/** Resume playback and update the MediaSession position state. */
async play() {
this.wasPaused = false
await audio.play()
},
/** Pause playback and update the MediaSession position state. */
async pause() {
this.wasPaused = true
await audio.pause()
},
async stop() {
this.wasPaused = true
await audio.stop()
this.setMediaSessionPosition(0, 0)
this.setMediaSessionState('none')
},
/** Toggle between play and pause. */
async playPause() {
if (this.isPlaying) {
return this.pause()
} else {
return this.play()
}
},
/**
* Advance to the next track.
*
* @param fade - Whether to cross-fade into the next track.
*
* If there is no next track and repeat is off, processQueueEnd() is called
* which may hand off to the radio store for auto-continuation.
*/
async next(fade = true) {
if (this.hasNext || this.repeat) {
this.inTransition = true
this.setQueueIndex(this.queueIndex + 1)
if (this.track) {
await audio.loadTrack({
track: this.track,
nextTrack: this.nextTrack,
fade,
})
this.inTransition = false
sleep(200).then(() => {
this.setMediaSessionPosition()
this.setMediaSessionState()
})
}
} else {
await this.processQueueEnd()
}
},
/**
* Go back to the previous track.
*
* If the current track has been playing for more than 3 seconds, restart
* it instead of jumping to the previous one.
*/
async back() {
if (this.currentTime > 3) {
await this.seek(0)
} else {
this.inTransition = true
this.setQueueIndex(this.queueIndex - 1)
if (!this.track)
return
await audio.loadTrack({
track: this.track,
nextTrack: this.nextTrack,
fade: true,
})
this.inTransition = false
sleep(200).then(() => {
this.setMediaSessionPosition()
this.setMediaSessionState()
})
}
},
/** Seek to an absolute position in seconds. */
async seek(position: number) {
audio.seek(position).then(
() => sleep(200).then(
() => {
this.setMediaSessionPosition()
this.setMediaSessionState()
}
)
)
},
/**
* Restore the play queue from the server (saved on the previous session).
* The track is loaded in a paused state and seeked to the saved position.
*/
async loadQueue() {
const { tracks, currentTrack, currentTrackPosition } = await this.subsonicApi.getPlayQueue()
if (!tracks)
return
this.setQueue(tracks)
this.setQueueIndex(currentTrack)
if (!this.track)
return
audio.loadTrack({
track: this.track,
nextTrack: this.nextTrack,
fade: true,
paused: true,
}).then(() => this.seek(currentTrackPosition))
},
async saveQueue() {
if (!navigator.onLine || !this.queue || !this.track)
return
this.subsonicApi.savePlayQueue(
this.queue,
this.track,
Math.trunc(this.currentTime)
).catch(
err => console.info('savePlayQueue aborted:', err)
)
},
/**
* Restart the queue from index 0 without changing the track list.
* Used when radio continuation fails and there is no fallback.
*/
async resetQueue() {
if (!this.queue.length || !this.track?.url) {
this.setQueueIndex(-1)
return
}
this.setQueueIndex(0)
if (!this.track)
return
await audio.loadTrack({
track: this.track,
nextTrack: this.nextTrack,
fade: true,
paused: true,
})
},
/**
* Remove all tracks from the queue except the currently playing one.
* If there is only one track, stop the audio and clear the queue entirely.
*/
async clearQueue() {
if (!this.queue.length)
return
const currentTrack = this.queue[this.queueIndex] ?? null
if (this.queue.length > 1 && currentTrack !== null) {
this.setQueue([ currentTrack ])
this.setQueueIndex(0)
} else {
this.setQueue([])
this.setQueueIndex(-1)
await this.stop()
}
},
/**
* Push the current playback position to the MediaSession API so that
* lock-screen / notification controls show an accurate scrubber.
*
* All parameters are optional; current store values are used as fallbacks.
*/
async setMediaSessionPosition(_duration?: number, _position?: number) {
if (!navigator.mediaSession)
return
_duration ??= this.duration
_position ??= this.currentTime
navigator.mediaSession.setPositionState({
duration: _duration,
playbackRate: mediaSessionProgressRate,
position: _position,
})
},
setMediaSessionState(_state?: MediaSessionPlaybackState) {
if (!navigator.mediaSession)
return
if (!_state)
_state = this.isPlaying ? 'playing' : 'paused'
mediaSession!.playbackState = _state
},
/**
* Append tracks to the end of the queue.
* Deduplicates the trivial case of adding the same single track twice.
* In shuffle mode the tracks are randomised before appending.
*/
addToQueue(tracks: Track[]) {
const lastTrack = this.queue && this.queue.length > 0 ? this.queue[this.queue.length - 1] : null
// Only dedup the trivial "same track added twice in a row" case
if (tracks.length === 1 && tracks[0]?.id === lastTrack?.id)
return
this.queue?.push(...this.shuffle ? shuffled(tracks) : tracks)
},
/**
* Insert tracks immediately after the current queue position so they play
* next. Deduplicates if the same single track is already queued next.
*/
setNextInQueue(tracks: Track[]) {
if (tracks.length === 1 && tracks[0]?.id === this.nextTrack?.id)
return
this.queue?.splice(this.queueIndex + 1, 0, ...(this.shuffle ? shuffled(tracks) : tracks))
},
/**
* Remove a track at the given queue index.
* Adjusts queueIndex to keep the current track pointer correct when a
* track before the current one is removed.
*/
removeFromQueue(index: number) {
this.queue?.splice(index, 1)
if (index < this.queueIndex)
this.queueIndex--
},
/**
* Re-shuffle the entire queue, moving the current track to index 0 so
* playback is uninterrupted.
*/
shuffleQueue() {
if (!this.queue.length)
return
this.queue = shuffled(this.queue, this.queueIndex)
this.queueIndex = 0
},
/**
* Cycle through ReplayGain modes (None → Track → Album → None …).
*/
toggleReplayGain() {
const mode = (this.replayGainMode + 1) % ReplayGainMode._Length
audio.setReplayGainMode(mode)
this.replayGainMode = mode
},
/** Toggle queue repeat. */
toggleRepeat() {
this.repeat = !this.repeat
},
/** Toggle shuffle mode. */
toggleShuffle() {
this.shuffle = !this.shuffle
},
/** Set master volume, apply to audio controller. */
setVolume(volume: number) {
audio.setVolume(volume)
this.volume = volume
},
/** Set the shuffle flag. */
setShuffle(toggle: boolean) {
this.shuffle = toggle
},
/** Replace the queue and reset the index to -1. */
setQueue(queue: Track[]) {
this.queue = queue
this.queueIndex = -1
},
/**
* Called when the queue has no more tracks.
* Hands off to the radio store to continue playback from the last track.
* Falls back to resetQueue() if radio continuation is unavailable.
*/
async processQueueEnd() {
if (!this.track?.url)
return
this.inTransition = true
try {
const radioStore = useRadioStore()
await radioStore.continueFromTrack(this.track)
} catch {
this.resetQueue()
}
this.inTransition = false
},
/**
* Update queueIndex and refresh track-level metadata (duration, MediaSession).
*
* Handles edge cases:
* - Empty queue → index set to -1
* - Index past end with repeat on → wraps to 0
* - Index past end with repeat off → stays at last track (no wrap)
*/
setQueueIndex(index: number) {
if (!this.queue || this.queue.length === 0) {
this.queueIndex = -1
this.duration = 0
this.setMediaSessionState('paused')
return
}
index = Math.max(0, index) // Guard against negative indices
if (index >= this.queue.length) {
if (this.repeat) {
index = 0 // Loop back to start
} else {
// Stay on the last track; do not advance
this.queueIndex = this.queue.length - 1
return
}
}
this.queueIndex = index
if (!this.track)
return
// Reset scrobble flag so the new track can be scrobbled
this.scrobbled = false
// Initialize this.duration & this.currentTime
this.duration = this.track.duration
this.currentTime = 0
// Update lock-screen / notification metadata
if (mediaSession) {
const artwork: MediaImage[] = [];
if (this.track.image)
// Provide artwork at multiple resolutions for different OS contexts
artwork.push(
{ src: this.track.image, sizes: '96x96', type: 'image/png' },
{ src: this.track.image, sizes: '128x128', type: 'image/png' },
{ src: this.track.image, sizes: '192x192', type: 'image/png' },
{ src: this.track.image, sizes: '256x256', type: 'image/png' },
{ src: this.track.image, sizes: '384x384', type: 'image/png' },
{ src: this.track.image, sizes: '512x512', type: 'image/png' },
)
navigator.mediaSession.metadata = new MediaMetadata({
title: this.track.title || '',
artist: formatArtists(this.track.artists) || '',
album: this.track.album || '',
artwork,
})
}
},
},
})
// ---------------------------------------------------------------------------
// setupAudio
// ---------------------------------------------------------------------------
// Called once at app startup to wire the AudioController events to the store
// and register MediaSession action handlers.
export function setupAudio(
playerStore: ReturnType<typeof usePlayerStore>,
mainStore: ReturnType<typeof useMainStore>
) {
playerStore.setMediaSessionState('none')
// ---------------------------------------------------------------------------
// Mobile auto-resume
// ---------------------------------------------------------------------------
// On iOS/Android the AudioContext is suspended when the browser tab goes to
// the background. When the tab becomes visible again we poll until the audio
// resumes by itself or we force a reload from the server queue.
let resumeToken = false
function autoResume() {
// Only attempt auto-resume on mobile, when the user didn't pause manually,
// and when the page is currently visible
if (!isMobile() || playerStore.wasPaused || resumeToken) return
resumeToken = true
const interval = setInterval(async () => {
if (playerStore.isPlaying) {
// Audio resumed on its own – cancel the polling loop
clearInterval(interval)
resumeToken = false
return
}
try {
// Re-load from the server queue and re-start playback
await playerStore.loadQueue()
await playerStore.play()
} catch {}
}, 2000)
}
/**
*
* Watch the playback position to handle two concerns:
* 1. Auto-skip: When fewer than 250 ms remain and there is a next track,
* advance early so there is no audible gap between tracks.
*
* 2. Scrobbling: Once the user has listened to more than 50% of a track,
* report a play to the server (Last.fm-style scrobble).
*/
audio.ontimeupdate = (time: number) => {
if (playerStore.inTransition) return
playerStore.currentTime = time
const track = playerStore.track
const isPlaying = playerStore.isPlaying
// Ignore the first 20 s to avoid false triggers on seek or slow loads
if (!track || !isPlaying || time < 20) return
// ── Auto-skip ──────────────────────────────────────────────────────
const duration = playerStore.duration
const remaining = duration - time
if (remaining < 0.25 && playerStore.hasNext) {
playerStore.next(false) // No fade – the gapless buffer handles the transition
return
}
// ── Scrobble ───────────────────────────────────────────────────────
const progress = duration ? time / duration : 0
if (!playerStore.scrobbled && progress > 0.5 && track && isPlaying) {
playerStore.scrobbled = true
playerStore.subsonicApi.scrobble(track.id)
}
}
audio.ondurationchange = (duration: number) => {
playerStore.duration = duration
playerStore.setMediaSessionPosition()
}
// ---------------------------------------------------------------------------
// Page lifecycle
// ---------------------------------------------------------------------------
/** On unload: pause, then persist the queue position to the server. */
window.addEventListener('beforeunload', () => {
playerStore.stop()
playerStore.saveQueue()
})
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden')
playerStore.saveQueue()
})
document.addEventListener('resume', autoResume)
// ---------------------------------------------------------------------------
// Playback event handlers
// ---------------------------------------------------------------------------
/** When a track finishes naturally, advance to the next one or end the queue. */
audio.onended = async () => {
const { hasNext, repeat } = playerStore
if (hasNext || repeat) {
await playerStore.next(true)
} else {
await playerStore.processQueueEnd()
}
}
const onHandler = state => {
const isPlaying = state === 'playing'
playerStore.isPlaying = isPlaying
playerStore.setMediaSessionPosition()
playerStore.setMediaSessionState(state)
if (!isPlaying) autoResume() // Kick off polling if this was an OS-level pause
}
audio.onplay = () => onHandler('playing')
audio.onpause = () => onHandler('paused')
audio.onsuspend = () => onHandler('paused')
/**
* Fatal errors only (ABORTED / SRC_NOT_SUPPORTED).
* Transient network/decode errors are retried internally by AudioController;
* this fires only once all retries are exhausted via onfailed below,
* or immediately for errors that are never worth retrying.
*/
audio.onerror = (error: any) => {
console.warn('[Audio] Fatal error', error)
mainStore.setError(error)
// Skip the broken track rather than looping forever on it.
if (playerStore.hasNext) {
void playerStore.next(true)
} else if (playerStore.hasPrevious) {
void playerStore.back()
} else {
void playerStore.resetQueue()
}
}
/** Fired by AudioController after every retry delay. */
audio.onretrying = (attempt: number, max: number) => console.info(`[Audio] Network error – retrying (${attempt}/${max})…`)
/** All retries exhausted without a successful load */
audio.onfailed = () => console.warn('[Audio] Retries exhausted, waiting for network')
/** Stall watchdog armed – nothing to do in the store beyond logging. */
audio.onstalled = () => console.info('[Audio] Playback stalled, watchdog armed')
/**
* When the browser goes back online, immediately retry if we're supposed
* to be playing (the user didn't deliberately pause).
*/
window.addEventListener('online', () => {
if (!playerStore.wasPaused) {
console.info('[Audio] Network restored – retrying current track')
audio.retryCurrentTrack()
}
})
// ---------------------------------------------------------------------------
// Initialise audio controller with persisted settings
// ---------------------------------------------------------------------------
audio.setReplayGainMode(playerStore.replayGainMode)
audio.setVolume(playerStore.volume)
// Restore the track that was playing when the page was last open (paused)
if (playerStore.track) {
audio.loadTrack({
track: playerStore.track,
nextTrack: playerStore.nextTrack,
paused: true
})
}
// ---------------------------------------------------------------------------
// MediaSession action handlers
// ---------------------------------------------------------------------------
// These allow OS media controls (lock screen, headphone buttons, etc.)
// to control playback.
if (mediaSession) {
mediaSession.setActionHandler('play', () => playerStore.play())
mediaSession.setActionHandler('pause', () => playerStore.pause())
mediaSession.setActionHandler('nexttrack', () => playerStore.next(true))
mediaSession.setActionHandler('previoustrack', () => playerStore.back())
mediaSession.setActionHandler('stop', () => playerStore.pause())
mediaSession.setActionHandler('seekto', async (details) => {
// fastSeek is a hint that the browser is still scrubbing; skip those
if (details.fastSeek || details.seekTime === undefined)
return
playerStore.seek(
Math.min(details.seekTime, playerStore.duration)
)
})
mediaSession.setActionHandler('seekforward', (details) => {
const offset = details.seekOffset || 10
const position = Math.min(playerStore.currentTime + offset, playerStore.duration)
playerStore.seek(position)
})
mediaSession.setActionHandler('seekbackward', (details) => {
const offset = details.seekOffset || 10
const position = Math.max(playerStore.currentTime - offset, 0)
playerStore.seek(position)
})
}
// ---------------------------------------------------------------------------
// Periodic queue persistence
// ---------------------------------------------------------------------------
// Save the play queue to the server every 10 s so the position can be
// restored on the next page load or on another device.
setInterval(() => playerStore.saveQueue(), 10000)
}