A music player that connects to your cloud/distributed storage.

feat: scrobbling

+839 -9
+1
deno.jsonc
··· 24 "@js-temporal/polyfill": "npm:@js-temporal/polyfill@^0.5.1", 25 "@mary/ds-queue": "jsr:@mary/ds-queue@^0.1.3", 26 "@noble/ciphers": "npm:@noble/ciphers@^2.1.1", 27 "@okikio/transferables": "jsr:@okikio/transferables@^1.0.2", 28 "@orama/orama": "npm:@orama/orama@^3.1.18", 29 "@phosphor-icons/web": "npm:@phosphor-icons/web@^2.1.2",
··· 24 "@js-temporal/polyfill": "npm:@js-temporal/polyfill@^0.5.1", 25 "@mary/ds-queue": "jsr:@mary/ds-queue@^0.1.3", 26 "@noble/ciphers": "npm:@noble/ciphers@^2.1.1", 27 + "@noble/hashes": "npm:@noble/hashes@^2.0.1", 28 "@okikio/transferables": "jsr:@okikio/transferables@^1.0.2", 29 "@orama/orama": "npm:@orama/orama@^3.1.18", 30 "@phosphor-icons/web": "npm:@phosphor-icons/web@^2.1.2",
+5
src/_data/facets.yaml
··· 10 featured: true 11 desc: > 12 Automatically put tracks into the queue. 13 - url: "facets/tools/export-import/index.html" 14 title: "Tools / Export & Import" 15 category: Data
··· 10 featured: true 11 desc: > 12 Automatically put tracks into the queue. 13 + - url: "facets/scrobble/last.fm/index.html" 14 + title: "Scrobble / Last.fm" 15 + category: Data 16 + desc: > 17 + Enable Last.fm scrobbling. 18 - url: "facets/tools/export-import/index.html" 19 title: "Tools / Export & Import" 20 category: Data
-7
src/_includes/layouts/diffuse.vto
··· 28 <meta name="msapplication-TileColor" content="#8a90a9" /> 29 <meta name="theme-color" content="#8a90a9" /> 30 31 - <!-- Preload items so they're ready before first paint (prevents flash during view transitions) --> 32 - <link rel="preload" as="font" type="font/woff2" crossorigin href="fonts/InterVariable.woff2" /> 33 - <link rel="preload" as="font" type="font/woff2" crossorigin href="fonts/InterVariable-Italic.woff2" /> 34 - <link rel="preload" as="font" type="font/woff2" crossorigin href="fonts/CommitMonoVariable.woff2" /> 35 - <link rel="preload" as="font" type="font/woff2" crossorigin href="vendor/@phosphor-icons/bold/Phosphor-Bold.woff2" /> 36 - <link rel="preload" as="font" type="font/woff2" crossorigin href="vendor/@phosphor-icons/fill/Phosphor-Fill.woff2" /> 37 - 38 <!-- Styles --> 39 {{ for url of styles }} 40 <link rel="stylesheet" href="{{ url }}" />
··· 28 <meta name="msapplication-TileColor" content="#8a90a9" /> 29 <meta name="theme-color" content="#8a90a9" /> 30 31 <!-- Styles --> 32 {{ for url of styles }} 33 <link rel="stylesheet" href="{{ url }}" />
+39
src/common/facets/foundation.js
··· 13 import ScopedTracksOrchestrator from "~/components/orchestrator/scoped-tracks/element.js"; 14 import FavouritesOrchestrator from "~/components/orchestrator/favourites/element.js"; 15 import MediaSessionOrchestrator from "~/components/orchestrator/media-session/element.js"; 16 import SourcesOrchestrator from "~/components/orchestrator/sources/element.js"; 17 18 /** 19 * @import { DiffuseElement } from "@toko/diffuse/common/element.js"; ··· 29 GROUP, 30 31 features: { 32 fillQueueAutomatically, 33 playAudioFromQueue, 34 processInputs, ··· 36 }, 37 38 // Elements 39 engine: { 40 audio, 41 queue, ··· 51 queueAudio, 52 processTracks, 53 scopedTracks, 54 sources, 55 }, 56 processor: { ··· 64 65 // 📦️ 66 67 function fillQueueAutomatically() { 68 return { 69 engine: { ··· 122 } 123 124 // 🥡 125 126 // Engines 127 function audio() { ··· 285 sto.setAttribute("search-processor-selector", s.selector); 286 287 return findExistingOrAdd(sto); 288 } 289 290 function sources() {
··· 13 import ScopedTracksOrchestrator from "~/components/orchestrator/scoped-tracks/element.js"; 14 import FavouritesOrchestrator from "~/components/orchestrator/favourites/element.js"; 15 import MediaSessionOrchestrator from "~/components/orchestrator/media-session/element.js"; 16 + import ScrobbleAudioOrchestrator from "~/components/orchestrator/scrobble-audio/element.js"; 17 import SourcesOrchestrator from "~/components/orchestrator/sources/element.js"; 18 + import ScrobbleConfigurator from "~/components/configurator/scrobbles/element.js"; 19 20 /** 21 * @import { DiffuseElement } from "@toko/diffuse/common/element.js"; ··· 31 GROUP, 32 33 features: { 34 + audioScrobbling, 35 fillQueueAutomatically, 36 playAudioFromQueue, 37 processInputs, ··· 39 }, 40 41 // Elements 42 + configurator: { 43 + scrobbles, 44 + }, 45 engine: { 46 audio, 47 queue, ··· 57 queueAudio, 58 processTracks, 59 scopedTracks, 60 + scrobbleAudio, 61 sources, 62 }, 63 processor: { ··· 71 72 // 📦️ 73 74 + function audioScrobbling() { 75 + return { 76 + configurator: { 77 + scrobbles: scrobbles(), 78 + }, 79 + orchestrator: { 80 + scrobbleAudio: scrobbleAudio(), 81 + }, 82 + }; 83 + } 84 + 85 function fillQueueAutomatically() { 86 return { 87 engine: { ··· 140 } 141 142 // 🥡 143 + 144 + // Configurators 145 + function scrobbles() { 146 + const sc = new ScrobbleConfigurator(); 147 + sc.setAttribute("group", GROUP); 148 + sc.setAttribute("id", "scrobbles"); 149 + 150 + return findExistingOrAdd(sc); 151 + } 152 153 // Engines 154 function audio() { ··· 312 sto.setAttribute("search-processor-selector", s.selector); 313 314 return findExistingOrAdd(sto); 315 + } 316 + 317 + function scrobbleAudio() { 318 + const a = audio(); 319 + const sc = scrobbles(); 320 + 321 + const sao = new ScrobbleAudioOrchestrator(); 322 + sao.setAttribute("group", GROUP); 323 + sao.setAttribute("audio-engine-selector", a.selector); 324 + sao.setAttribute("scrobbles-selector", sc.selector); 325 + 326 + return findExistingOrAdd(sao); 327 } 328 329 function sources() {
+72
src/components/configurator/scrobbles/element.js
···
··· 1 + import { DiffuseElement } from "~/common/element.js"; 2 + 3 + /** 4 + * @import {Track} from "~/definitions/types.d.ts" 5 + * @import {ScrobbleActions, ScrobbleElement} from "~/components/supplement/types.d.ts" 6 + */ 7 + 8 + //////////////////////////////////////////// 9 + // ELEMENT 10 + //////////////////////////////////////////// 11 + 12 + /** 13 + * @implements {ScrobbleActions} 14 + */ 15 + class ScrobbleConfigurator extends DiffuseElement { 16 + static NAME = "diffuse/configurator/scrobbles"; 17 + 18 + // SCROBBLE ACTIONS 19 + 20 + /** 21 + * @param {Track} track 22 + */ 23 + async nowPlaying(track) { 24 + return await Promise.all( 25 + this.#activeScrobblers().map((s) => s.nowPlaying(track)), 26 + ); 27 + } 28 + 29 + /** 30 + * @param {Track} track 31 + * @param {number} startedAt Unix timestamp in milliseconds 32 + */ 33 + async scrobble(track, startedAt) { 34 + return await Promise.all( 35 + this.#activeScrobblers().map((s) => s.scrobble(track, startedAt)), 36 + ); 37 + } 38 + 39 + // MISC 40 + 41 + /** 42 + * All child scrobble elements, regardless of authentication state. 43 + * 44 + * @returns {ScrobbleElement[]} 45 + */ 46 + scrobblers() { 47 + return Array.from(this.root().children).flatMap((el) => { 48 + if (!("isAuthenticated" in el && "nowPlaying" in el)) return []; 49 + return [/** @type {ScrobbleElement} */ (/** @type {unknown} */ (el))]; 50 + }); 51 + } 52 + 53 + /** 54 + * Child scrobble elements that are currently authenticated. 55 + * 56 + * @returns {ScrobbleElement[]} 57 + */ 58 + #activeScrobblers() { 59 + return this.scrobblers().filter((s) => s.isAuthenticated()); 60 + } 61 + } 62 + 63 + export default ScrobbleConfigurator; 64 + 65 + //////////////////////////////////////////// 66 + // REGISTER 67 + //////////////////////////////////////////// 68 + 69 + export const CLASS = ScrobbleConfigurator; 70 + export const NAME = "dc-scrobbles"; 71 + 72 + customElements.define(NAME, ScrobbleConfigurator);
+1
src/components/engine/audio/element.js
··· 288 isPreload: a.isPreload, 289 mimeType: a.mimeType, 290 progress: a.progress, 291 url, 292 }; 293 });
··· 288 isPreload: a.isPreload, 289 mimeType: a.mimeType, 290 progress: a.progress, 291 + track: a.track, 292 url, 293 }; 294 });
+2
src/components/engine/audio/types.d.ts
··· 1 import type { Signal, SignalReader } from "~/common/signal.d.ts"; 2 3 export type Actions = { 4 adjustVolume: (_: { audioId?: string; volume: number }) => void; ··· 35 * Initial progress 36 */ 37 progress?: number; 38 }; 39 40 export type AudioState = {
··· 1 import type { Signal, SignalReader } from "~/common/signal.d.ts"; 2 + import type { Track } from "~/definitions/types.d.ts"; 3 4 export type Actions = { 5 adjustVolume: (_: { audioId?: string; volume: number }) => void; ··· 36 * Initial progress 37 */ 38 progress?: number; 39 + track: Track; 40 }; 41 42 export type AudioState = {
+8 -2
src/components/orchestrator/queue-audio/element.js
··· 101 // TODO: Take URL expiration timestamp into account 102 // TODO: Add support for seeking streams 103 // (requires a lot of code, decoding audio frames, etc.) 104 - const activeAudio = activeItem && resolvedUri 105 - ? [{ id: activeItem.id, isPreload: false, ...resolvedUri }] 106 : []; 107 108 audio.supply({ ··· 131 id: nextItem.id, 132 isPreload: true, 133 url: nextUrl, 134 }], 135 }); 136 }, 30_000);
··· 101 // TODO: Take URL expiration timestamp into account 102 // TODO: Add support for seeking streams 103 // (requires a lot of code, decoding audio frames, etc.) 104 + const activeAudio = activeTrack && resolvedUri 105 + ? [{ 106 + id: activeTrack.id, 107 + isPreload: false, 108 + track: activeTrack, 109 + ...resolvedUri, 110 + }] 111 : []; 112 113 audio.supply({ ··· 136 id: nextItem.id, 137 isPreload: true, 138 url: nextUrl, 139 + track: nextTrack, 140 }], 141 }); 142 }, 30_000);
+204
src/components/orchestrator/scrobble-audio/element.js
···
··· 1 + import { BroadcastableDiffuseElement, 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 + */ 20 + class 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.scrobbles = query(this, "scrobbles-selector"); 38 + 39 + await customElements.whenDefined(this.audio.localName); 40 + await customElements.whenDefined(this.scrobbles.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 + // TIMER STATE 70 + // Accumulates actual listening time (pauses don't count). 71 + 72 + /** Accumulated listening time in ms before the last pause. */ 73 + #listenedMs = 0; 74 + 75 + /** Date.now() when the timer was last resumed; null when paused. */ 76 + #timerResumedAt = /** @type {number | null} */ (null); 77 + 78 + /** @type {number | null} */ 79 + #intervalId = null; 80 + 81 + // EFFECT 82 + 83 + /** 84 + * Reacts to audio item changes and playback state. 85 + * Detects track changes, resets state, and starts/stops the listening timer. 86 + */ 87 + #monitorAudio() { 88 + if (!this.audio) return; 89 + 90 + const active = this.audio.items().find((item) => !item.isPreload); 91 + const id = active?.id ?? null; 92 + 93 + // Detect track change 94 + if (id !== this.#trackId) { 95 + this.#stopTimer(); 96 + 97 + this.#trackId = id; 98 + this.#activeTrack = active?.track ?? null; 99 + this.#startedAt = id ? Date.now() : null; 100 + this.#nowPlayingSent = false; 101 + this.#scrobbled = false; 102 + this.#listenedMs = 0; 103 + } 104 + 105 + if (!id) return; 106 + 107 + const isPlaying = this.audio.state(id)?.isPlaying() ?? false; 108 + 109 + if (isPlaying) { 110 + this.#startTimer(); 111 + 112 + if (!this.#nowPlayingSent) { 113 + this.#nowPlayingSent = true; 114 + this.#sendNowPlaying(id); 115 + } 116 + } else { 117 + this.#stopTimer(); 118 + } 119 + } 120 + 121 + // TIMER 122 + 123 + #startTimer() { 124 + if (this.#timerResumedAt !== null) return; 125 + 126 + this.#timerResumedAt = Date.now(); 127 + this.#intervalId = setInterval(() => this.#checkScrobble(), 1_000); 128 + } 129 + 130 + #stopTimer() { 131 + if (this.#timerResumedAt !== null) { 132 + this.#listenedMs += Date.now() - this.#timerResumedAt; 133 + this.#timerResumedAt = null; 134 + } 135 + 136 + if (this.#intervalId !== null) { 137 + clearInterval(this.#intervalId); 138 + this.#intervalId = null; 139 + } 140 + } 141 + 142 + #totalListenedMs() { 143 + return this.#listenedMs + 144 + (this.#timerResumedAt !== null ? Date.now() - this.#timerResumedAt : 0); 145 + } 146 + 147 + // SCROBBLING 148 + 149 + /** 150 + * @param {string} id 151 + */ 152 + async #sendNowPlaying(id) { 153 + if (!(await this.isLeader())) return; 154 + if (this.#trackId !== id || !this.#activeTrack) return; 155 + 156 + try { 157 + await this.scrobbles?.nowPlaying(this.#activeTrack); 158 + } catch (err) { 159 + console.warn("scrobble: nowPlaying failed", err); 160 + } 161 + } 162 + 163 + async #checkScrobble() { 164 + if (this.#scrobbled) return; 165 + 166 + const id = this.#trackId; 167 + if (!id || !this.#startedAt || !this.#activeTrack) return; 168 + 169 + const durationSec = this.audio?.state(id)?.duration() ?? 0; 170 + 171 + // last.fm: track must be at least 30 seconds 172 + if (durationSec < 30) return; 173 + 174 + // last.fm: must have listened to min(half the track, 4 minutes) 175 + const listenedSec = this.#totalListenedMs() / 1000; 176 + if (listenedSec < Math.min(durationSec / 2, 240)) return; 177 + 178 + this.#scrobbled = true; 179 + 180 + if (!(await this.isLeader())) return; 181 + if (this.#trackId !== id) return; 182 + 183 + const track = this.#activeTrack; 184 + const startedAt = this.#startedAt; 185 + 186 + try { 187 + await this.scrobbles?.scrobble(track, startedAt); 188 + } catch (err) { 189 + console.warn("scrobble: scrobble failed", err); 190 + this.#scrobbled = false; 191 + } 192 + } 193 + } 194 + 195 + export default ScrobbleAudioOrchestrator; 196 + 197 + //////////////////////////////////////////// 198 + // REGISTER 199 + //////////////////////////////////////////// 200 + 201 + export const CLASS = ScrobbleAudioOrchestrator; 202 + export const NAME = "do-scrobble-audio"; 203 + 204 + customElements.define(NAME, ScrobbleAudioOrchestrator);
+246
src/components/supplement/last.fm/element.js
···
··· 1 + import { md5 } from "@noble/hashes/legacy.js"; 2 + import { bytesToHex, utf8ToBytes } from "@noble/hashes/utils.js"; 3 + 4 + import { DiffuseElement } from "~/common/element.js"; 5 + import { computed, signal } from "~/common/signal.js"; 6 + 7 + /** 8 + * @import {Track} from "~/definitions/types.d.ts" 9 + * @import {ScrobbleElement} from "../types.d.ts" 10 + */ 11 + 12 + //////////////////////////////////////////// 13 + // CONSTANTS 14 + //////////////////////////////////////////// 15 + 16 + const LASTFM_API_URL = "https://ws.audioscrobbler.com/2.0/"; 17 + const LASTFM_AUTH_URL = "https://www.last.fm/api/auth/"; 18 + const STORAGE_KEY = "diffuse/supplement/last.fm/session"; 19 + const PENDING_TOKEN_KEY = "diffuse/supplement/last.fm/pending-token"; 20 + 21 + const DEFAULT_API_KEY = "4f0fe85b67baef8bb7d008a8754a95e5"; 22 + const DEFAULT_API_SECRET = "0cec3ca0f58e04a5082f1131aba1e0d3"; 23 + 24 + //////////////////////////////////////////// 25 + // ELEMENT 26 + //////////////////////////////////////////// 27 + 28 + /** 29 + * @implements {ScrobbleElement} 30 + */ 31 + class LastFmSupplement extends DiffuseElement { 32 + static NAME = "diffuse/supplement/last.fm"; 33 + 34 + get #apiKey() { 35 + return this.getAttribute("api-key") ?? DEFAULT_API_KEY; 36 + } 37 + 38 + get #apiSecret() { 39 + return this.getAttribute("api-secret") ?? DEFAULT_API_SECRET; 40 + } 41 + 42 + // SIGNALS 43 + 44 + #sessionKey = signal(/** @type {string | null} */ (null)); 45 + #handle = signal(/** @type {string | null} */ (null)); 46 + 47 + // STATE 48 + 49 + isAuthenticated = computed(() => this.#sessionKey.value !== null); 50 + handle = this.#handle.get; 51 + 52 + // LIFECYCLE 53 + 54 + /** @override */ 55 + connectedCallback() { 56 + super.connectedCallback(); 57 + this.#tryRestore(); 58 + } 59 + 60 + async #tryRestore() { 61 + await this.whenConnected(); 62 + 63 + // Check for a pending token in sessionStorage (returning from auth redirect) 64 + const pendingToken = sessionStorage.getItem(PENDING_TOKEN_KEY); 65 + 66 + if (pendingToken) { 67 + sessionStorage.removeItem(PENDING_TOKEN_KEY); 68 + 69 + try { 70 + const session = await this.#getSession(pendingToken); 71 + this.#setSession(session); 72 + } catch (err) { 73 + console.warn("last.fm: failed to exchange token for session", err); 74 + } 75 + 76 + return; 77 + } 78 + 79 + // Restore an existing session from localStorage 80 + const stored = localStorage.getItem(STORAGE_KEY); 81 + 82 + if (stored) { 83 + try { 84 + const { key, name: handle } = JSON.parse(stored); 85 + this.#sessionKey.value = key; 86 + this.#handle.value = handle; 87 + } catch { 88 + localStorage.removeItem(STORAGE_KEY); 89 + } 90 + } 91 + } 92 + 93 + // AUTH 94 + 95 + /** 96 + * Initiate the last.fm auth flow. 97 + * Requests a token and redirects the browser to the authorization page. 98 + */ 99 + async signIn() { 100 + const token = await this.#getToken(); 101 + 102 + sessionStorage.setItem(PENDING_TOKEN_KEY, token); 103 + 104 + const callbackUrl = location.origin + location.pathname + location.search; 105 + const authUrl = new URL(LASTFM_AUTH_URL); 106 + authUrl.searchParams.set("api_key", this.#apiKey); 107 + authUrl.searchParams.set("token", token); 108 + authUrl.searchParams.set("cb", callbackUrl); 109 + 110 + location.assign(authUrl.toString()); 111 + } 112 + 113 + /** 114 + * Clear the stored session. 115 + */ 116 + signOut() { 117 + this.#sessionKey.value = null; 118 + this.#handle.value = null; 119 + localStorage.removeItem(STORAGE_KEY); 120 + } 121 + 122 + /** @param {{ key: string, name: string }} session */ 123 + #setSession({ key, name: handle }) { 124 + this.#sessionKey.value = key; 125 + this.#handle.value = handle; 126 + localStorage.setItem(STORAGE_KEY, JSON.stringify({ key, name: handle })); 127 + } 128 + 129 + // SCROBBLE ACTIONS 130 + 131 + /** 132 + * @param {Track} track 133 + */ 134 + async nowPlaying(track) { 135 + const tags = track.tags ?? {}; 136 + /** @type {Record<string, string>} */ 137 + const params = {}; 138 + 139 + if (tags.title) params.track = tags.title; 140 + if (tags.artist) params.artist = tags.artist; 141 + if (tags.album) params.album = tags.album; 142 + if (tags.albumartist) params.albumArtist = tags.albumartist; 143 + if (tags.track?.no != null) params.trackNumber = String(tags.track.no); 144 + if (track.stats?.duration != null) { 145 + params.duration = String(Math.round(track.stats.duration / 1000)); 146 + } 147 + 148 + return this.#authenticatedCall("track.updateNowPlaying", params); 149 + } 150 + 151 + /** 152 + * @param {Track} track 153 + * @param {number} startedAt Unix timestamp in milliseconds 154 + */ 155 + async scrobble(track, startedAt) { 156 + const tags = track.tags ?? {}; 157 + /** @type {Record<string, string>} */ 158 + const params = { 159 + timestamp: String(Math.floor(startedAt / 1000)), 160 + }; 161 + 162 + if (tags.title) params.track = tags.title; 163 + if (tags.artist) params.artist = tags.artist; 164 + if (tags.album) params.album = tags.album; 165 + if (tags.albumartist) params.albumArtist = tags.albumartist; 166 + if (tags.track?.no != null) params.trackNumber = String(tags.track.no); 167 + if (track.stats?.duration != null) { 168 + params.duration = String(Math.round(track.stats.duration / 1000)); 169 + } 170 + 171 + return this.#authenticatedCall("track.scrobble", params); 172 + } 173 + 174 + // API 175 + 176 + /** 177 + * Sign a set of API parameters (excluding `format` and `callback`). 178 + * 179 + * @param {Record<string, string>} params 180 + * @returns {string} MD5 hex digest 181 + */ 182 + #sign(params) { 183 + const str = Object.keys(params) 184 + .sort() 185 + .map((k) => k + params[k]) 186 + .join(""); 187 + return bytesToHex(md5(utf8ToBytes(str + this.#apiSecret))); 188 + } 189 + 190 + /** 191 + * @param {string} method 192 + * @param {Record<string, string>} [params] 193 + * @returns {Promise<any>} 194 + */ 195 + async #call(method, params = {}) { 196 + const allParams = { ...params, api_key: this.#apiKey, method }; 197 + const api_sig = this.#sign(allParams); 198 + const body = new URLSearchParams({ ...allParams, api_sig, format: "json" }); 199 + 200 + const response = await fetch(LASTFM_API_URL, { method: "POST", body }); 201 + const data = await response.json(); 202 + 203 + if (data.error) { 204 + throw new Error(`last.fm error ${data.error}: ${data.message}`); 205 + } 206 + 207 + return data; 208 + } 209 + 210 + /** 211 + * @param {string} method 212 + * @param {Record<string, string>} [params] 213 + * @returns {Promise<any>} 214 + */ 215 + async #authenticatedCall(method, params = {}) { 216 + const sk = this.#sessionKey.value; 217 + if (!sk) throw new Error("Not authenticated with last.fm"); 218 + return this.#call(method, { ...params, sk }); 219 + } 220 + 221 + /** @returns {Promise<string>} */ 222 + async #getToken() { 223 + const data = await this.#call("auth.getToken"); 224 + return data.token; 225 + } 226 + 227 + /** 228 + * @param {string} token 229 + * @returns {Promise<{ key: string, name: string }>} 230 + */ 231 + async #getSession(token) { 232 + const data = await this.#call("auth.getSession", { token }); 233 + return data.session; 234 + } 235 + } 236 + 237 + export default LastFmSupplement; 238 + 239 + //////////////////////////////////////////// 240 + // REGISTER 241 + //////////////////////////////////////////// 242 + 243 + export const CLASS = LastFmSupplement; 244 + export const NAME = "ds-lastfm"; 245 + 246 + customElements.define(NAME, CLASS);
+18
src/components/supplement/types.d.ts
···
··· 1 + import type { DiffuseElement } from "~/common/element.js"; 2 + import type { SignalReader } from "~/common/signal.d.ts"; 3 + import type { Track } from "~/definitions/types.d.ts"; 4 + 5 + export type ScrobbleElement = DiffuseElement & ScrobbleActions & { 6 + isAuthenticated: SignalReader<boolean>; 7 + handle: SignalReader<string | null>; 8 + }; 9 + 10 + export type ScrobbleActions = { 11 + nowPlaying(track: Track): Promise<unknown>; 12 + 13 + /** 14 + * @param {Track} track 15 + * @param {number} startedAt Unix timestamp in milliseconds 16 + */ 17 + scrobble(track: Track, startedAt: number): Promise<unknown>; 18 + };
+93
src/facets/scrobble/last.fm/index.html
···
··· 1 + <link rel="stylesheet" href="vendor/@awesome.me/webawesome/styles/themes/default.css" /> 2 + 3 + <main> 4 + <wa-card> 5 + <div slot="header" class="card-header"> 6 + <strong>Last.fm</strong> 7 + <!--<wa-button id="settings-btn" appearance="plain" size="small" aria-label="API credentials"> 8 + <wa-icon name="gear"></wa-icon> 9 + </wa-button>--> 10 + </div> 11 + 12 + <div id="state-connect" class="card-body"> 13 + <p>Connect your Last.fm account to start scrobbling.</p> 14 + <wa-button id="sign-in-btn" variant="brand" appearance="filled"> 15 + <wa-icon slot="prefix" name="plug"></wa-icon> 16 + Connect 17 + </wa-button> 18 + </div> 19 + 20 + <div id="state-connected" class="card-body" hidden> 21 + <p id="handle-paragraph" hidden>Connected as <strong id="handle-text"></strong>.</p> 22 + <wa-button id="sign-out-btn" variant="neutral" appearance="outlined" hidden> 23 + <wa-icon slot="prefix" name="plug-slash"></wa-icon> 24 + Disconnect 25 + </wa-button> 26 + </div> 27 + </wa-card> 28 + </main> 29 + 30 + <wa-drawer id="credentials-drawer" label="API Credentials" placement="end"> 31 + <div class="drawer-body"> 32 + <wa-input id="api-key-input" label="API Key" placeholder="Default"></wa-input> 33 + <wa-input 34 + id="api-secret-input" 35 + label="API Secret" 36 + type="password" 37 + placeholder="Default" 38 + ></wa-input> 39 + </div> 40 + <div slot="footer" class="drawer-footer"> 41 + <wa-button id="save-creds-btn" variant="brand" appearance="filled">Save</wa-button> 42 + <wa-button id="reset-creds-btn" variant="neutral" appearance="outlined" 43 + >Reset to defaults</wa-button 44 + > 45 + </div> 46 + </wa-drawer> 47 + 48 + <style> 49 + body { 50 + display: flex; 51 + align-items: center; 52 + justify-content: center; 53 + min-height: 100dvh; 54 + margin: 0; 55 + } 56 + 57 + wa-card { 58 + width: min(360px, calc(100vw - 2rem)); 59 + } 60 + 61 + .card-header { 62 + display: flex; 63 + align-items: center; 64 + justify-content: space-between; 65 + } 66 + 67 + .card-body { 68 + display: flex; 69 + flex-direction: column; 70 + gap: var(--wa-space-m); 71 + } 72 + 73 + .drawer-body { 74 + display: flex; 75 + flex-direction: column; 76 + gap: var(--wa-space-m); 77 + } 78 + 79 + .drawer-footer { 80 + display: flex; 81 + gap: var(--wa-space-s); 82 + } 83 + 84 + [hidden] { 85 + display: none !important; 86 + } 87 + 88 + p { 89 + margin: 0; 90 + } 91 + </style> 92 + 93 + <script type="module" src="./index.inline.js"></script>
+150
src/facets/scrobble/last.fm/index.inline.js
···
··· 1 + import "@awesome.me/webawesome/dist/components/card/card.js"; 2 + import "@awesome.me/webawesome/dist/components/button/button.js"; 3 + import "@awesome.me/webawesome/dist/components/drawer/drawer.js"; 4 + import "@awesome.me/webawesome/dist/components/input/input.js"; 5 + import "@awesome.me/webawesome/dist/components/icon/icon.js"; 6 + 7 + import "~/common/webawesome/detect-dark.js"; 8 + 9 + import LastFmSupplement from "~/components/supplement/last.fm/element.js"; 10 + import { effect } from "~/common/signal.js"; 11 + 12 + /** 13 + * @import { default as WaDrawer } from "@awesome.me/webawesome/dist/components/drawer/drawer.js" 14 + * @import { default as WaInput } from "@awesome.me/webawesome/dist/components/input/input.js" 15 + */ 16 + 17 + //////////////////////////////////////////// 18 + // SETUP 19 + //////////////////////////////////////////// 20 + 21 + const CREDS_KEY = "diffuse/supplement/last.fm/credentials"; 22 + 23 + /** @returns {{ apiKey: string, apiSecret: string } | null} */ 24 + function loadCredentials() { 25 + try { 26 + return JSON.parse(localStorage.getItem(CREDS_KEY) ?? "null"); 27 + } catch { 28 + return null; 29 + } 30 + } 31 + 32 + // Find existing or create new ds-lastfm element 33 + let lastFm = /** @type {LastFmSupplement | null} */ ( 34 + document.body.querySelector("ds-lastfm") 35 + ); 36 + 37 + if (!lastFm) { 38 + lastFm = new LastFmSupplement(); 39 + const creds = loadCredentials(); 40 + if (creds) { 41 + lastFm.setAttribute("api-key", creds.apiKey); 42 + lastFm.setAttribute("api-secret", creds.apiSecret); 43 + } 44 + document.body.append(lastFm); 45 + } 46 + 47 + await customElements.whenDefined(lastFm.localName); 48 + 49 + //////////////////////////////////////////// 50 + // ELEMENTS 51 + //////////////////////////////////////////// 52 + 53 + const stateConnect = /** @type {HTMLElement} */ ( 54 + document.querySelector("#state-connect") 55 + ); 56 + const stateConnected = /** @type {HTMLElement} */ ( 57 + document.querySelector("#state-connected") 58 + ); 59 + const handleParagraph = /** @type {HTMLElement} */ ( 60 + document.querySelector("#handle-paragraph") 61 + ); 62 + const handleText = /** @type {HTMLElement} */ ( 63 + document.querySelector("#handle-text") 64 + ); 65 + 66 + const settingsBtn = /** @type {HTMLElement} */ ( 67 + document.querySelector("#settings-btn") 68 + ); 69 + 70 + const signInBtn = /** @type {HTMLElement} */ ( 71 + document.querySelector("#sign-in-btn") 72 + ); 73 + 74 + const signOutBtn = /** @type {HTMLElement} */ ( 75 + document.querySelector("#sign-out-btn") 76 + ); 77 + 78 + const credentialsDrawer = /** @type {WaDrawer} */ ( 79 + document.querySelector("#credentials-drawer") 80 + ); 81 + 82 + const apiKeyInput = /** @type {WaInput} */ ( 83 + document.querySelector("#api-key-input") 84 + ); 85 + 86 + const apiSecretInput = /** @type {WaInput} */ ( 87 + document.querySelector("#api-secret-input") 88 + ); 89 + 90 + const saveCredsBtn = /** @type {HTMLElement} */ ( 91 + document.querySelector("#save-creds-btn") 92 + ); 93 + 94 + const resetCredsBtn = /** @type {HTMLElement} */ ( 95 + document.querySelector("#reset-creds-btn") 96 + ); 97 + 98 + // Pre-fill drawer inputs with stored credentials 99 + const existingCreds = loadCredentials(); 100 + if (existingCreds) { 101 + apiKeyInput.value = existingCreds.apiKey; 102 + apiSecretInput.value = existingCreds.apiSecret; 103 + } 104 + 105 + //////////////////////////////////////////// 106 + // REACTIVE UI 107 + //////////////////////////////////////////// 108 + 109 + effect(() => { 110 + const isAuthenticated = lastFm.isAuthenticated(); 111 + const handle = lastFm.handle(); 112 + 113 + stateConnect.hidden = isAuthenticated; 114 + stateConnected.hidden = !isAuthenticated; 115 + 116 + handleParagraph.hidden = !handle; 117 + signOutBtn.hidden = !isAuthenticated; 118 + if (handle) handleText.textContent = handle; 119 + }); 120 + 121 + //////////////////////////////////////////// 122 + // ACTIONS 123 + //////////////////////////////////////////// 124 + 125 + settingsBtn?.addEventListener("click", (e) => { 126 + e.stopPropagation(); 127 + credentialsDrawer.open = true; 128 + }); 129 + 130 + signInBtn.onclick = () => lastFm.signIn(); 131 + 132 + signOutBtn.onclick = () => lastFm.signOut(); 133 + 134 + saveCredsBtn.onclick = () => { 135 + const apiKey = apiKeyInput.value?.trim(); 136 + const apiSecret = apiSecretInput.value?.trim(); 137 + if (!apiKey || !apiSecret) return; 138 + 139 + localStorage.setItem(CREDS_KEY, JSON.stringify({ apiKey, apiSecret })); 140 + lastFm.setAttribute("api-key", apiKey); 141 + lastFm.setAttribute("api-secret", apiSecret); 142 + }; 143 + 144 + resetCredsBtn.onclick = () => { 145 + localStorage.removeItem(CREDS_KEY); 146 + lastFm.removeAttribute("api-key"); 147 + lastFm.removeAttribute("api-secret"); 148 + apiKeyInput.value = ""; 149 + apiSecretInput.value = ""; 150 + };