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

Configure Feed

Select the types of activity you want to include in your feed.

wip: rocksky scrobbling

+498 -1
+7 -1
src/_data/facets.json
··· 140 140 "title": "Scrobble", 141 141 "kind": "prelude", 142 142 "category": "Misc", 143 - "desc": "Enable scrobbling, keep track of what you're listening to. Adds support for these scrobblers: Last.fm" 143 + "desc": "Enable scrobbling, keep track of what you're listening to. Adds support for these scrobblers: Last.fm, Rocksky" 144 144 }, 145 145 { 146 146 "url": "facets/misc/scrobble/last.fm/index.html", 147 147 "title": "Scrobble / Last.fm", 148 148 "category": "Misc", 149 149 "desc": "Connect to Last.fm to setup the Last.fm scrobbler." 150 + }, 151 + { 152 + "url": "facets/misc/scrobble/rocksky/index.html", 153 + "title": "Scrobble / Rocksky (work in progress)", 154 + "category": "Misc", 155 + "desc": "Connect to Rocksky to setup the Rocksky scrobbler." 150 156 }, 151 157 { 152 158 "url": "facets/misc/split-view/index.html",
+1
src/_includes/layouts/diffuse.vto
··· 49 49 "@phosphor-icons/web/": "./vendor/@phosphor-icons/web/", 50 50 51 51 "@atcute/cbor": "./vendor/@atcute/cbor/index.js", 52 + "@atcute/oauth-browser-client": "./vendor/@atcute/oauth-browser-client/index.js", 52 53 "@atcute/tid": "./vendor/@atcute/tid/index.js", 53 54 "idb-keyval": "./vendor/idb-keyval/index.js", 54 55 "kmenu": "./vendor/kmenu-core/index.js",
+250
src/components/supplement/rocksky/element.js
··· 1 + import { md5 } from "@noble/hashes/legacy.js"; 2 + import { bytesToHex, utf8ToBytes } from "@noble/hashes/utils.js"; 3 + 4 + import { getSession } from "@atcute/oauth-browser-client"; 5 + 6 + import { BroadcastableDiffuseElement } from "~/common/element.js"; 7 + import { computed, signal } from "~/common/signal.js"; 8 + 9 + /** 10 + * @import {Track} from "~/definitions/types.d.ts" 11 + * @import {ScrobbleElement} from "../types.d.ts" 12 + */ 13 + 14 + //////////////////////////////////////////// 15 + // CONSTANTS 16 + //////////////////////////////////////////// 17 + 18 + const ROCKSKY_API_URL = "https://audioscrobbler.rocksky.app/2.0/"; 19 + const ATPROTO_DID_KEY = "diffuse/output/raw/atproto/did"; 20 + const STORAGE_KEY = "diffuse/supplement/rocksky/session"; 21 + 22 + const DEFAULT_API_KEY = "d21bdb464bd5e92c4dbbe814a5a9a8a4"; 23 + const DEFAULT_API_SECRET = "4a9d15e43ad1623ee7f9dc6b12d6ba08"; 24 + 25 + //////////////////////////////////////////// 26 + // ELEMENT 27 + //////////////////////////////////////////// 28 + 29 + /** 30 + * @implements {ScrobbleElement} 31 + */ 32 + class RockskyScrobbler extends BroadcastableDiffuseElement { 33 + static NAME = "diffuse/supplement/rocksky"; 34 + 35 + get #apiKey() { 36 + return this.getAttribute("api-key") ?? DEFAULT_API_KEY; 37 + } 38 + 39 + get #apiSecret() { 40 + return this.getAttribute("api-secret") ?? DEFAULT_API_SECRET; 41 + } 42 + 43 + // SIGNALS 44 + 45 + #handle = signal(/** @type {string | null} */ (null)); 46 + #sessionKey = signal(/** @type {string | null} */ (null)); 47 + #isAuthenticating = signal(false); 48 + 49 + // STATE 50 + 51 + handle = this.#handle.get; 52 + isAuthenticated = computed(() => this.#sessionKey.value !== null); 53 + isAuthenticating = this.#isAuthenticating.get; 54 + 55 + // LIFECYCLE 56 + 57 + /** @override */ 58 + connectedCallback() { 59 + // Broadcast if needed 60 + if (this.hasAttribute("group")) { 61 + const actions = this.broadcast(this.identifier, { 62 + nowPlaying: { strategy: "leaderOnly", fn: this.nowPlaying }, 63 + scrobble: { strategy: "leaderOnly", fn: this.scrobble }, 64 + 65 + setHandle: { strategy: "replicate", fn: this.#handle.set }, 66 + setSession: { strategy: "replicate", fn: this.#sessionKey.set }, 67 + }); 68 + 69 + if (actions) { 70 + this.nowPlaying = actions.nowPlaying; 71 + this.scrobble = actions.scrobble; 72 + 73 + this.#handle.set = actions.setHandle; 74 + this.#sessionKey.set = actions.setSession; 75 + } 76 + } 77 + 78 + super.connectedCallback(); 79 + 80 + this.#tryRestore(); 81 + } 82 + 83 + async #tryRestore() { 84 + await this.whenConnected(); 85 + 86 + const stored = localStorage.getItem(STORAGE_KEY); 87 + 88 + if (stored) { 89 + try { 90 + const { key, name: handle } = JSON.parse(stored); 91 + if (await this.isLeader()) { 92 + this.#sessionKey.set(key); 93 + this.#handle.set(handle); 94 + } else { 95 + this.#sessionKey.value = key; 96 + this.#handle.value = handle; 97 + } 98 + } catch { 99 + localStorage.removeItem(STORAGE_KEY); 100 + } 101 + } 102 + } 103 + 104 + // AUTH 105 + 106 + /** 107 + * Sign in to Rocksky using the existing AT Protocol session. 108 + * Exchanges the AT Protocol access token for a Rocksky audioscrobbler session key. 109 + */ 110 + async signIn() { 111 + const did = localStorage.getItem(ATPROTO_DID_KEY); 112 + if (!did) throw new Error("rocksky: no AT Protocol session found"); 113 + 114 + this.#isAuthenticating.set(true); 115 + try { 116 + const session = await getSession( 117 + /** @type {`did:${string}:${string}`} */ (did), 118 + ); 119 + const accessToken = session.token.access; 120 + 121 + const data = await this.#call("auth.getMobileSession", { 122 + username: did, 123 + password: accessToken, 124 + }); 125 + this.#setSession(data.session); 126 + } catch (err) { 127 + console.warn("rocksky: failed to authenticate", err); 128 + throw err; 129 + } finally { 130 + this.#isAuthenticating.set(false); 131 + } 132 + } 133 + 134 + /** 135 + * Clear the stored session. 136 + */ 137 + signOut() { 138 + this.#sessionKey.set(null); 139 + this.#handle.set(null); 140 + localStorage.removeItem(STORAGE_KEY); 141 + } 142 + 143 + /** @param {{ key: string, name: string }} session */ 144 + #setSession({ key, name: handle }) { 145 + this.#sessionKey.set(key); 146 + this.#handle.set(handle); 147 + localStorage.setItem(STORAGE_KEY, JSON.stringify({ key, name: handle })); 148 + } 149 + 150 + // SCROBBLE ACTIONS 151 + 152 + /** 153 + * @param {Track} track 154 + */ 155 + async nowPlaying(track) { 156 + const tags = track.tags ?? {}; 157 + /** @type {Record<string, string>} */ 158 + const params = {}; 159 + 160 + if (tags.title) params.track = tags.title; 161 + if (tags.artist) params.artist = tags.artist; 162 + if (tags.album) params.album = tags.album; 163 + if (tags.albumartist) params.albumArtist = tags.albumartist; 164 + if (tags.track?.no != null) params.trackNumber = String(tags.track.no); 165 + if (track.stats?.duration != null) { 166 + params.duration = String(Math.round(track.stats.duration / 1000)); 167 + } 168 + 169 + return this.#authenticatedCall("track.updateNowPlaying", params); 170 + } 171 + 172 + /** 173 + * @param {Track} track 174 + * @param {number} startedAt Unix timestamp in milliseconds 175 + */ 176 + async scrobble(track, startedAt) { 177 + const tags = track.tags ?? {}; 178 + /** @type {Record<string, string>} */ 179 + const params = { 180 + timestamp: String(Math.floor(startedAt / 1000)), 181 + }; 182 + 183 + if (tags.title) params.track = tags.title; 184 + if (tags.artist) params.artist = tags.artist; 185 + if (tags.album) params.album = tags.album; 186 + if (tags.albumartist) params.albumArtist = tags.albumartist; 187 + if (tags.track?.no != null) params.trackNumber = String(tags.track.no); 188 + if (track.stats?.duration != null) { 189 + params.duration = String(Math.round(track.stats.duration / 1000)); 190 + } 191 + 192 + return this.#authenticatedCall("track.scrobble", params); 193 + } 194 + 195 + // API 196 + 197 + /** 198 + * @param {Record<string, string>} params 199 + * @returns {string} MD5 hex digest 200 + */ 201 + #sign(params) { 202 + const str = Object.keys(params) 203 + .sort() 204 + .map((k) => k + params[k]) 205 + .join(""); 206 + return bytesToHex(md5(utf8ToBytes(str + this.#apiSecret))); 207 + } 208 + 209 + /** 210 + * @param {string} method 211 + * @param {Record<string, string>} [params] 212 + * @returns {Promise<any>} 213 + */ 214 + async #call(method, params = {}) { 215 + const allParams = { ...params, api_key: this.#apiKey, method }; 216 + const api_sig = this.#sign(allParams); 217 + const body = new URLSearchParams({ ...allParams, api_sig, format: "json" }); 218 + 219 + const response = await fetch(ROCKSKY_API_URL, { method: "POST", body }); 220 + const data = await response.json(); 221 + 222 + if (data.error) { 223 + throw new Error(`rocksky error ${data.error}: ${data.message}`); 224 + } 225 + 226 + return data; 227 + } 228 + 229 + /** 230 + * @param {string} method 231 + * @param {Record<string, string>} [params] 232 + * @returns {Promise<any>} 233 + */ 234 + async #authenticatedCall(method, params = {}) { 235 + const sk = this.#sessionKey.value; 236 + if (!sk) throw new Error("Not authenticated with Rocksky"); 237 + return this.#call(method, { ...params, sk }); 238 + } 239 + } 240 + 241 + export default RockskyScrobbler; 242 + 243 + //////////////////////////////////////////// 244 + // REGISTER 245 + //////////////////////////////////////////// 246 + 247 + export const CLASS = RockskyScrobbler; 248 + export const NAME = "ds-rocksky-scrobbler"; 249 + 250 + customElements.define(NAME, CLASS);
+8
src/facets/misc/scrobble/index.inline.js
··· 20 20 const lastFm = new LastFmScrobbler(); 21 21 lastFm.setAttribute("group", foundation.GROUP); 22 22 configurator.append(lastFm); 23 + 24 + const { default: RockskyScrobbler } = await import( 25 + "~/components/supplement/rocksky/element.js" 26 + ); 27 + 28 + const rocksky = new RockskyScrobbler(); 29 + rocksky.setAttribute("group", foundation.GROUP); 30 + configurator.append(rocksky); 23 31 }
+74
src/facets/misc/scrobble/rocksky/index.html
··· 1 + <style> 2 + @import "./vendor/@awesome.me/webawesome/styles/webawesome.css" layer(wa); 3 + 4 + @layer base, diffuse, wa; 5 + 6 + #container { 7 + display: flex; 8 + align-items: center; 9 + justify-content: center; 10 + min-height: 100dvh; 11 + margin: 0; 12 + } 13 + 14 + wa-card { 15 + width: min(360px, calc(100vw - 2rem)); 16 + } 17 + 18 + .card-header { 19 + display: flex; 20 + align-items: center; 21 + justify-content: space-between; 22 + } 23 + 24 + .card-body { 25 + display: flex; 26 + flex-direction: column; 27 + gap: var(--wa-space-m); 28 + } 29 + 30 + [hidden] { 31 + display: none !important; 32 + } 33 + 34 + p { 35 + margin: 0; 36 + } 37 + </style> 38 + 39 + <main class="wa-theme-default"> 40 + <wa-card> 41 + <div slot="header" class="card-header"> 42 + <strong>Rocksky</strong> 43 + </div> 44 + 45 + <div id="state-connect" class="card-body"> 46 + <div id="state-no-atproto" class="card-body"> 47 + <p>Sign in with your AT Protocol identity first, then connect to Rocksky.</p> 48 + <wa-input id="handle-input" label="Handle" placeholder="you.bsky.social"></wa-input> 49 + <wa-button id="atproto-sign-in-btn" variant="neutral" appearance="outlined"> 50 + <wa-icon slot="start" library="phosphor/bold" name="at"></wa-icon> 51 + Sign in with AT Protocol 52 + </wa-button> 53 + </div> 54 + 55 + <div id="state-has-atproto" class="card-body" hidden> 56 + <p>Connect your Rocksky account using your AT Protocol identity.</p> 57 + <wa-button id="sign-in-btn" variant="brand" appearance="filled"> 58 + <wa-icon slot="start" library="phosphor/bold" name="plugs"></wa-icon> 59 + Connect 60 + </wa-button> 61 + </div> 62 + </div> 63 + 64 + <div id="state-connected" class="card-body" hidden> 65 + <p id="handle-paragraph" hidden>Connected as <strong id="handle-text"></strong>.</p> 66 + <wa-button id="sign-out-btn" variant="neutral" appearance="outlined" hidden> 67 + <wa-icon slot="start" library="phosphor/bold" name="plugs-connecte"></wa-icon> 68 + Disconnect 69 + </wa-button> 70 + </div> 71 + </wa-card> 72 + </main> 73 + 74 + <script type="module" src="facets/misc/scrobble/rocksky/index.inline.js"></script>
+157
src/facets/misc/scrobble/rocksky/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/input/input.js"; 4 + import "@awesome.me/webawesome/dist/components/icon/icon.js"; 5 + 6 + import "~/common/webawesome/detect-dark.js"; 7 + import "~/common/webawesome/phosphor/bold.js"; 8 + 9 + import { login } from "~/components/output/raw/atproto/oauth.js"; 10 + import { finalizeAuthorization } from "@atcute/oauth-browser-client"; 11 + 12 + import foundation from "~/common/foundation.js"; 13 + import { effect, signal } from "~/common/signal.js"; 14 + 15 + /** 16 + * @import { default as WaInput } from "@awesome.me/webawesome/dist/components/input/input.js" 17 + */ 18 + 19 + //////////////////////////////////////////// 20 + // SETUP 21 + //////////////////////////////////////////// 22 + 23 + // Set doc title 24 + foundation.setup({ title: "Rocksky | Scrobble | Diffuse" }); 25 + 26 + const ATPROTO_DID_KEY = "diffuse/output/raw/atproto/did"; 27 + 28 + // Handle AT Protocol OAuth callback if returning from it. 29 + // The /oauth/callback page passes the #code fragment back to this page's URL. 30 + // We only finalize if the code is actually present — never attempt session 31 + // restoration, as its error path calls clearStoredSession() which would wipe 32 + // the main app's AT Protocol session from localStorage and IndexedDB. 33 + let freshAtprotoSession = null; 34 + const hashParams = new URLSearchParams(location.hash.slice(1)); 35 + if (hashParams.has("code")) { 36 + try { 37 + const result = await finalizeAuthorization(hashParams); 38 + history.replaceState(null, "", location.pathname + location.search); 39 + localStorage.setItem(ATPROTO_DID_KEY, result.session.info.sub); 40 + freshAtprotoSession = result.session; 41 + } catch (err) { 42 + console.warn("rocksky: failed to finalize AT Protocol auth", err); 43 + } 44 + } 45 + 46 + const configurator = await foundation.configurator.scrobbles(); 47 + 48 + /** @type {import("~/components/supplement/rocksky/element.js").CLASS | null} */ 49 + let rocksky = configurator.querySelector("ds-rocksky-scrobbler"); 50 + if (!rocksky) { 51 + const { default: RockskyScrobbler } = await import( 52 + "~/components/supplement/rocksky/element.js" 53 + ); 54 + 55 + rocksky = new RockskyScrobbler(); 56 + rocksky.setAttribute("group", foundation.GROUP); 57 + configurator.append(rocksky); 58 + } 59 + 60 + await customElements.whenDefined(rocksky.localName); 61 + 62 + // If AT Protocol was just authorized via OAuth, immediately connect to Rocksky 63 + if (freshAtprotoSession && !rocksky.isAuthenticated()) { 64 + rocksky.signIn().catch(() => {}); 65 + } 66 + 67 + //////////////////////////////////////////// 68 + // ELEMENTS 69 + //////////////////////////////////////////// 70 + 71 + const stateConnect = /** @type {HTMLElement} */ ( 72 + document.querySelector("#state-connect") 73 + ); 74 + 75 + const stateConnected = /** @type {HTMLElement} */ ( 76 + document.querySelector("#state-connected") 77 + ); 78 + 79 + const stateNoAtproto = /** @type {HTMLElement} */ ( 80 + document.querySelector("#state-no-atproto") 81 + ); 82 + 83 + const stateHasAtproto = /** @type {HTMLElement} */ ( 84 + document.querySelector("#state-has-atproto") 85 + ); 86 + 87 + const handleParagraph = /** @type {HTMLElement} */ ( 88 + document.querySelector("#handle-paragraph") 89 + ); 90 + 91 + const handleText = /** @type {HTMLElement} */ ( 92 + document.querySelector("#handle-text") 93 + ); 94 + 95 + const handleInput = /** @type {WaInput} */ ( 96 + document.querySelector("#handle-input") 97 + ); 98 + 99 + const atprotoSignInBtn = /** @type {HTMLElement} */ ( 100 + document.querySelector("#atproto-sign-in-btn") 101 + ); 102 + 103 + const signInBtn = /** @type {HTMLElement} */ ( 104 + document.querySelector("#sign-in-btn") 105 + ); 106 + 107 + const signOutBtn = /** @type {HTMLElement} */ ( 108 + document.querySelector("#sign-out-btn") 109 + ); 110 + 111 + //////////////////////////////////////////// 112 + // REACTIVE UI 113 + //////////////////////////////////////////// 114 + 115 + const $hasAtprotoSession = signal(!!localStorage.getItem(ATPROTO_DID_KEY)); 116 + 117 + effect(() => { 118 + const isAuthenticated = rocksky.isAuthenticated(); 119 + const isAuthenticating = rocksky.isAuthenticating(); 120 + const handle = rocksky.handle(); 121 + const hasAtproto = $hasAtprotoSession.value; 122 + 123 + stateConnect.hidden = isAuthenticated; 124 + stateConnected.hidden = !isAuthenticated; 125 + 126 + stateNoAtproto.hidden = hasAtproto; 127 + stateHasAtproto.hidden = !hasAtproto; 128 + 129 + handleParagraph.hidden = !handle; 130 + signOutBtn.hidden = !isAuthenticated; 131 + if (handle) handleText.textContent = handle; 132 + 133 + // @ts-ignore 134 + signInBtn.disabled = isAuthenticating; 135 + // @ts-ignore 136 + atprotoSignInBtn.disabled = isAuthenticating; 137 + }); 138 + 139 + //////////////////////////////////////////// 140 + // ACTIONS 141 + //////////////////////////////////////////// 142 + 143 + atprotoSignInBtn.onclick = async () => { 144 + const handle = handleInput.value?.trim(); 145 + if (!handle) return; 146 + await login(handle); 147 + }; 148 + 149 + signInBtn.onclick = () => rocksky.signIn().catch(() => {}); 150 + 151 + signOutBtn.onclick = () => rocksky.signOut(); 152 + 153 + //////////////////////////////////////////// 154 + // 🚀 155 + //////////////////////////////////////////// 156 + 157 + foundation.ready();
+1
src/vendor/@atcute/oauth-browser-client/index.js
··· 1 + export * from "@atcute/oauth-browser-client";