A music player that connects to your cloud/distributed storage. diffuse.sh
Something went wrong. Try again.
6.1 kB · 224 lines
JavaScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225import { BroadcastableDiffuseElement, defineElement, query } from "~/common/element.js";
/** * @import {ScrobbleElement} from "@specs/components/supplement/types.d.ts" * @import {Track} from "~/definitions/types.d.ts" */
////////////////////////////////////////////// ELEMENT////////////////////////////////////////////
/** * Connects the audio engine with the scrobble configurator. * * Calls `nowPlaying` when a track starts and `scrobble` once the user * has listened long enough per the last.fm rules: * - Track must be at least 30 seconds long. * - User must have listened to at least min(duration / 2, 4 minutes). */class ScrobbleAudioOrchestrator extends BroadcastableDiffuseElement { static NAME = "diffuse/orchestrator/scrobble-audio";
// LIFECYCLE
/** @override */ async connectedCallback() { if (this.hasAttribute("group")) { this.broadcast(this.identifier, {}); }
super.connectedCallback();
/** @type {import("~/components/engine/audio/element.js").CLASS} */ this.audio = query(this, "audio-engine-selector");
/** @type {ScrobbleElement} */ this.scrobble = query(this, "scrobble-selector");
await customElements.whenDefined(this.audio.localName); await customElements.whenDefined(this.scrobble.localName);
this.effect(() => this.#monitorAudio()); }
/** @override */ disconnectedCallback() { super.disconnectedCallback(); this.#stopTimer(); }
// TRACK STATE // Resets whenever the active (non-preload) audio item changes.
/** @type {string | null} */ #trackId = null;
/** @type {Track | null} */ #activeTrack = null;
/** @type {number | null} Date.now() when track first started, used as the scrobble timestamp. */ #startedAt = null;
/** Whether `nowPlaying` has been called for the current track. */ #nowPlayingSent = false;
/** Whether `scrobble` has been called for the current track. */ #scrobbled = false;
/** Whether the current track has ended (used to detect restarts, e.g. repeat). */ #hadEnded = false;
// TIMER STATE // Accumulates actual listening time (pauses don't count).
/** Accumulated listening time in ms before the last pause. */ #listenedMs = 0;
/** Date.now() when the timer was last resumed; null when paused. */ #timerResumedAt = /** @type {number | null} */ (null);
/** @type {ReturnType<typeof setInterval> | null} */ #intervalId = null;
// EFFECT
/** * Reacts to audio item changes and playback state. * Detects track changes, resets state, and starts/stops the listening timer. */ #monitorAudio() { if (!this.audio) return;
const active = this.audio.items().find((item) => !item.isPreload); const id = active?.id ?? null;
// Detect track change if (id !== this.#trackId) { this.#stopTimer();
this.#trackId = id; this.#activeTrack = active?.track ?? null; this.#startedAt = id ? Date.now() : null; this.#nowPlayingSent = false; this.#scrobbled = false; this.#listenedMs = 0; this.#hadEnded = false; }
if (!id) return;
const state = this.audio.state(id); const loadingState = state?.loadingState() ?? "loading"; const hasError = typeof loadingState === "object" && "error" in loadingState; const isPlaying = !hasError && (state?.isPlaying() ?? false); const hasEnded = state?.hasEnded() ?? false;
// Detect same-track restart (e.g. repeat): the track ended and now plays again. if (this.#hadEnded && !hasEnded && isPlaying) { this.#stopTimer(); this.#startedAt = Date.now(); this.#nowPlayingSent = false; this.#scrobbled = false; this.#listenedMs = 0; this.#hadEnded = false; }
if (hasEnded) this.#hadEnded = true;
if (isPlaying) { this.#startTimer();
if (!this.#nowPlayingSent) { this.#nowPlayingSent = true; this.#sendNowPlaying(id); } } else { this.#stopTimer(); } }
// TIMER
#startTimer() { if (this.#timerResumedAt !== null) return;
this.#timerResumedAt = Date.now(); this.#intervalId = setInterval(() => this.#checkScrobble(), 1_000); }
#stopTimer() { if (this.#timerResumedAt !== null) { this.#listenedMs += Date.now() - this.#timerResumedAt; this.#timerResumedAt = null; }
if (this.#intervalId !== null) { clearInterval(this.#intervalId); this.#intervalId = null; } }
#totalListenedMs() { return this.#listenedMs + (this.#timerResumedAt !== null ? Date.now() - this.#timerResumedAt : 0); }
// SCROBBLING
/** * @param {string} id */ async #sendNowPlaying(id) { if (!(await this.isLeader())) return; if (this.#trackId !== id || !this.#activeTrack) return;
try { await this.scrobble?.nowPlaying(this.#activeTrack); } catch (err) { console.warn("scrobble: nowPlaying failed", err); } }
async #checkScrobble() { if (this.#scrobbled) return;
const id = this.#trackId; if (!id || !this.#startedAt || !this.#activeTrack) return;
const durationSec = this.audio?.state(id)?.duration() ?? 0;
// Track must be at least 30 seconds if (durationSec < 30) return;
// Must have listened to at least half the track or 4 minutes const listenedSec = this.#totalListenedMs() / 1000; if (listenedSec < Math.min(durationSec / 2, 240)) return;
this.#scrobbled = true;
if (!(await this.isLeader())) return; if (this.#trackId !== id) return;
const track = this.#activeTrack; const startedAt = this.#startedAt;
try { await this.scrobble?.scrobble(track, startedAt, { duration: Math.round(durationSec * 1000) }); } catch (err) { console.warn("Scrobble failed", err); this.#scrobbled = false; } }}
export default ScrobbleAudioOrchestrator;
////////////////////////////////////////////// REGISTER////////////////////////////////////////////
export const CLASS = ScrobbleAudioOrchestrator;export const NAME = "do-scrobble-audio";
defineElement(NAME, ScrobbleAudioOrchestrator);