music on atproto
plyr.fm
1import type { Track } from './types';
2import { API_URL } from './config';
3
4// global player state using Svelte 5 runes
5class PlayerState {
6 currentTrack = $state<Track | null>(null);
7 audioElement = $state<HTMLAudioElement | undefined>(undefined);
8 paused = $state(true);
9
10 currentTime = $state(0);
11 duration = $state(0);
12 volume = $state(0.7);
13 playCountedForTrack = $state<number | null>(null);
14
15 // synchronous guard to prevent duplicate play count requests
16 // (reactive state updates are batched, so we need this to block rapid-fire calls)
17 private _playCountPending: number | null = null;
18
19 playTrack(track: Track) {
20 if (this.currentTrack?.id === track.id) {
21 // toggle play/pause for same track
22 this.paused = !this.paused;
23 } else {
24 // switch tracks
25 this.currentTrack = track;
26 this.paused = false;
27 }
28 }
29
30 togglePlayPause() {
31 this.paused = !this.paused;
32 }
33
34 incrementPlayCount() {
35 if (!this.currentTrack || this.playCountedForTrack === this.currentTrack.id || !this.duration) {
36 return;
37 }
38
39 // synchronous check to prevent race condition from batched reactive updates
40 if (this._playCountPending === this.currentTrack.id) {
41 return;
42 }
43
44 // threshold: minimum of 30 seconds or 50% of track duration
45 const threshold = Math.min(30, this.duration * 0.5);
46
47 if (this.currentTime >= threshold) {
48 // set synchronous guard immediately (before async fetch)
49 this._playCountPending = this.currentTrack.id;
50 this.playCountedForTrack = this.currentTrack.id;
51
52 fetch(`${API_URL}/tracks/${this.currentTrack.id}/play`, {
53 method: 'POST',
54 credentials: 'include'
55 }).catch(err => {
56 console.error('failed to increment play count:', err);
57 });
58 }
59 }
60
61 resetPlayCount() {
62 this.playCountedForTrack = null;
63 this._playCountPending = null;
64 }
65
66 reset() {
67 this.currentTime = 0;
68 this.paused = true;
69 }
70}
71
72export const player = new PlayerState();