A music player that connects to your cloud/distributed storage.
at v4 221 lines 5.9 kB view raw
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);