A music player that connects to your cloud/distributed storage.
1import { BroadcastableDiffuseElement, defineElement, query } from "~/common/element.js";
2
3/**
4 * @import {ScrobbleElement} from "~/components/supplement/types.d.ts"
5 * @import {Track} from "~/definitions/types.d.ts"
6 */
7
8////////////////////////////////////////////
9// ELEMENT
10////////////////////////////////////////////
11
12/**
13 * Connects the audio engine with the scrobble configurator.
14 *
15 * Calls `nowPlaying` when a track starts and `scrobble` once the user
16 * has listened long enough per the last.fm rules:
17 * - Track must be at least 30 seconds long.
18 * - User must have listened to at least min(duration / 2, 4 minutes).
19 */
20class ScrobbleAudioOrchestrator extends BroadcastableDiffuseElement {
21 static NAME = "diffuse/orchestrator/scrobble-audio";
22
23 // LIFECYCLE
24
25 /** @override */
26 async connectedCallback() {
27 if (this.hasAttribute("group")) {
28 this.broadcast(this.identifier, {});
29 }
30
31 super.connectedCallback();
32
33 /** @type {import("~/components/engine/audio/element.js").CLASS} */
34 this.audio = query(this, "audio-engine-selector");
35
36 /** @type {ScrobbleElement} */
37 this.scrobble = query(this, "scrobble-selector");
38
39 await customElements.whenDefined(this.audio.localName);
40 await customElements.whenDefined(this.scrobble.localName);
41
42 this.effect(() => this.#monitorAudio());
43 }
44
45 /** @override */
46 disconnectedCallback() {
47 super.disconnectedCallback();
48 this.#stopTimer();
49 }
50
51 // TRACK STATE
52 // Resets whenever the active (non-preload) audio item changes.
53
54 /** @type {string | null} */
55 #trackId = null;
56
57 /** @type {Track | null} */
58 #activeTrack = null;
59
60 /** @type {number | null} Date.now() when track first started, used as the scrobble timestamp. */
61 #startedAt = null;
62
63 /** Whether `nowPlaying` has been called for the current track. */
64 #nowPlayingSent = false;
65
66 /** Whether `scrobble` has been called for the current track. */
67 #scrobbled = false;
68
69 /** Whether the current track has ended (used to detect restarts, e.g. repeat). */
70 #hadEnded = false;
71
72 // TIMER STATE
73 // Accumulates actual listening time (pauses don't count).
74
75 /** Accumulated listening time in ms before the last pause. */
76 #listenedMs = 0;
77
78 /** Date.now() when the timer was last resumed; null when paused. */
79 #timerResumedAt = /** @type {number | null} */ (null);
80
81 /** @type {number | null} */
82 #intervalId = null;
83
84 // EFFECT
85
86 /**
87 * Reacts to audio item changes and playback state.
88 * Detects track changes, resets state, and starts/stops the listening timer.
89 */
90 #monitorAudio() {
91 if (!this.audio) return;
92
93 const active = this.audio.items().find((item) => !item.isPreload);
94 const id = active?.id ?? null;
95
96 // Detect track change
97 if (id !== this.#trackId) {
98 this.#stopTimer();
99
100 this.#trackId = id;
101 this.#activeTrack = active?.track ?? null;
102 this.#startedAt = id ? Date.now() : null;
103 this.#nowPlayingSent = false;
104 this.#scrobbled = false;
105 this.#listenedMs = 0;
106 this.#hadEnded = false;
107 }
108
109 if (!id) return;
110
111 const isPlaying = this.audio.state(id)?.isPlaying() ?? false;
112 const hasEnded = this.audio.state(id)?.hasEnded() ?? false;
113
114 // Detect same-track restart (e.g. repeat): the track ended and now plays again.
115 if (this.#hadEnded && !hasEnded && isPlaying) {
116 this.#stopTimer();
117 this.#startedAt = Date.now();
118 this.#nowPlayingSent = false;
119 this.#scrobbled = false;
120 this.#listenedMs = 0;
121 this.#hadEnded = false;
122 }
123
124 if (hasEnded) this.#hadEnded = true;
125
126 if (isPlaying) {
127 this.#startTimer();
128
129 if (!this.#nowPlayingSent) {
130 this.#nowPlayingSent = true;
131 this.#sendNowPlaying(id);
132 }
133 } else {
134 this.#stopTimer();
135 }
136 }
137
138 // TIMER
139
140 #startTimer() {
141 if (this.#timerResumedAt !== null) return;
142
143 this.#timerResumedAt = Date.now();
144 this.#intervalId = setInterval(() => this.#checkScrobble(), 1_000);
145 }
146
147 #stopTimer() {
148 if (this.#timerResumedAt !== null) {
149 this.#listenedMs += Date.now() - this.#timerResumedAt;
150 this.#timerResumedAt = null;
151 }
152
153 if (this.#intervalId !== null) {
154 clearInterval(this.#intervalId);
155 this.#intervalId = null;
156 }
157 }
158
159 #totalListenedMs() {
160 return this.#listenedMs +
161 (this.#timerResumedAt !== null ? Date.now() - this.#timerResumedAt : 0);
162 }
163
164 // SCROBBLING
165
166 /**
167 * @param {string} id
168 */
169 async #sendNowPlaying(id) {
170 if (!(await this.isLeader())) return;
171 if (this.#trackId !== id || !this.#activeTrack) return;
172
173 try {
174 await this.scrobble?.nowPlaying(this.#activeTrack);
175 } catch (err) {
176 console.warn("scrobble: nowPlaying failed", err);
177 }
178 }
179
180 async #checkScrobble() {
181 if (this.#scrobbled) return;
182
183 const id = this.#trackId;
184 if (!id || !this.#startedAt || !this.#activeTrack) return;
185
186 const durationSec = this.audio?.state(id)?.duration() ?? 0;
187
188 // Track must be at least 30 seconds
189 if (durationSec < 30) return;
190
191 // Must have listened to at least half the track or 4 minutes
192 const listenedSec = this.#totalListenedMs() / 1000;
193 if (listenedSec < Math.min(durationSec / 2, 240)) return;
194
195 this.#scrobbled = true;
196
197 if (!(await this.isLeader())) return;
198 if (this.#trackId !== id) return;
199
200 const track = this.#activeTrack;
201 const startedAt = this.#startedAt;
202
203 try {
204 await this.scrobble?.scrobble(track, startedAt);
205 } catch (err) {
206 console.warn("Scrobble failed", err);
207 this.#scrobbled = false;
208 }
209 }
210}
211
212export default ScrobbleAudioOrchestrator;
213
214////////////////////////////////////////////
215// REGISTER
216////////////////////////////////////////////
217
218export const CLASS = ScrobbleAudioOrchestrator;
219export const NAME = "do-scrobble-audio";
220
221defineElement(NAME, ScrobbleAudioOrchestrator);