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.

feat: add dropbox input support

+746 -5
+7 -1
src/_data/facets.json
··· 62 62 "desc": "Use your AT Protocol identity for user-data storage." 63 63 }, 64 64 { 65 + "url": "facets/connect/dropbox/index.html", 66 + "title": "Connect / Dropbox", 67 + "category": "Data", 68 + "desc": "Add Dropbox as an audio source." 69 + }, 70 + { 65 71 "url": "facets/connect/https/index.html", 66 72 "title": "Connect / HTTPS", 67 73 "category": "Data", ··· 105 111 "kind": "prelude", 106 112 "category": "Data", 107 113 "tags": ["base"], 108 - "desc": "The default setup for audio input sources. Adds support for: HTTPS, Icecast, the local filesystem, OpenSubsonic, and S3-compatible storage." 114 + "desc": "The default setup for audio input sources. Adds support for: Dropbox, HTTPS, Icecast, the local filesystem, OpenSubsonic, and S3-compatible storage." 109 115 }, 110 116 { 111 117 "url": "facets/data/metadata-bundle/index.html",
+226
src/components/input/dropbox/common.js
··· 1 + import * as URI from "fast-uri"; 2 + import QS from "query-string"; 3 + 4 + import { cachedConsult, isAudioFile } from "~/components/input/common.js"; 5 + import { safeDecodeURIComponent } from "~/common/utils.js"; 6 + import { SCHEME } from "./constants.js"; 7 + 8 + /** 9 + * @import { Track } from "~/definitions/types.d.ts" 10 + */ 11 + 12 + /** 13 + * @typedef {{ accessToken: string; directoryPath: string }} Account 14 + */ 15 + 16 + //////////////////////////////////////////// 17 + // URI 18 + //////////////////////////////////////////// 19 + 20 + /** 21 + * @param {Account} account 22 + * @param {string} [filePath] 23 + */ 24 + export function buildURI(account, filePath) { 25 + return URI.serialize({ 26 + scheme: SCHEME, 27 + userinfo: encodeURIComponent(account.accessToken), 28 + host: "dropbox.com", 29 + path: filePath || "/", 30 + query: QS.stringify({ dir: account.directoryPath || "/" }), 31 + }); 32 + } 33 + 34 + /** 35 + * @param {string} uriString 36 + * @returns {{ accessToken: string; path: string; directoryPath: string } | undefined} 37 + */ 38 + export function parseURI(uriString) { 39 + const uri = URI.parse(uriString); 40 + if (uri.scheme !== SCHEME) return undefined; 41 + if (!uri.userinfo) return undefined; 42 + 43 + const accessToken = decodeURIComponent(uri.userinfo); 44 + const path = safeDecodeURIComponent(uri.path || "/"); 45 + const qs = QS.parse(uri.query || ""); 46 + const directoryPath = typeof qs.dir === "string" ? safeDecodeURIComponent(qs.dir) : "/"; 47 + 48 + return { accessToken, path, directoryPath }; 49 + } 50 + 51 + //////////////////////////////////////////// 52 + // ACCOUNT HELPERS 53 + //////////////////////////////////////////// 54 + 55 + /** 56 + * @param {Account} account 57 + */ 58 + export function accountId(account) { 59 + return `${account.accessToken}:${account.directoryPath}`; 60 + } 61 + 62 + /** 63 + * @param {Track[]} tracks 64 + * @returns {Record<string, Account>} 65 + */ 66 + export function accountsFromTracks(tracks) { 67 + /** @type {Record<string, Account>} */ 68 + const acc = {}; 69 + 70 + tracks.forEach((track) => { 71 + const parsed = parseURI(track.uri); 72 + if (!parsed) return; 73 + 74 + const id = accountId(parsed); 75 + if (acc[id]) return; 76 + 77 + acc[id] = { accessToken: parsed.accessToken, directoryPath: parsed.directoryPath }; 78 + }); 79 + 80 + return acc; 81 + } 82 + 83 + /** 84 + * @param {Track[]} tracks 85 + * @returns {Record<string, { account: Account; tracks: Track[] }>} 86 + */ 87 + export function groupTracksByAccount(tracks) { 88 + /** @type {Record<string, { account: Account; tracks: Track[] }>} */ 89 + const acc = {}; 90 + 91 + tracks.forEach((track) => { 92 + const parsed = parseURI(track.uri); 93 + if (!parsed) return; 94 + 95 + const id = accountId(parsed); 96 + 97 + if (acc[id]) { 98 + acc[id].tracks.push(track); 99 + } else { 100 + acc[id] = { 101 + account: { accessToken: parsed.accessToken, directoryPath: parsed.directoryPath }, 102 + tracks: [track], 103 + }; 104 + } 105 + }); 106 + 107 + return acc; 108 + } 109 + 110 + /** 111 + * @param {string[]} uris 112 + * @returns {Record<string, { account: Account; uris: string[] }>} 113 + */ 114 + export function groupUrisByAccount(uris) { 115 + /** @type {Record<string, { account: Account; uris: string[] }>} */ 116 + const acc = {}; 117 + 118 + uris.forEach((uri) => { 119 + const parsed = parseURI(uri); 120 + if (!parsed) return; 121 + 122 + const id = accountId(parsed); 123 + 124 + if (acc[id]) { 125 + acc[id].uris.push(uri); 126 + } else { 127 + acc[id] = { 128 + account: { accessToken: parsed.accessToken, directoryPath: parsed.directoryPath }, 129 + uris: [uri], 130 + }; 131 + } 132 + }); 133 + 134 + return acc; 135 + } 136 + 137 + //////////////////////////////////////////// 138 + // DROPBOX API 139 + //////////////////////////////////////////// 140 + 141 + /** 142 + * @param {string} accessToken 143 + * @param {string} directoryPath 144 + * @returns {Promise<Array<{ name: string; path_lower: string }> | null>} 145 + */ 146 + export async function listFiles(accessToken, directoryPath) { 147 + const apiPath = directoryPath === "/" ? "" : directoryPath; 148 + const headers = { 149 + "Authorization": `Bearer ${accessToken}`, 150 + "Content-Type": "application/json", 151 + }; 152 + 153 + /** @type {Array<{ name: string; path_lower: string }>} */ 154 + const entries = []; 155 + let cursor = /** @type {string | null} */ (null); 156 + let hasMore = true; 157 + 158 + while (hasMore) { 159 + const url = cursor 160 + ? "https://api.dropboxapi.com/2/files/list_folder/continue" 161 + : "https://api.dropboxapi.com/2/files/list_folder"; 162 + 163 + const body = cursor 164 + ? JSON.stringify({ cursor }) 165 + : JSON.stringify({ path: apiPath, recursive: true, limit: 2000 }); 166 + 167 + const resp = await fetch(url, { method: "POST", headers, body }); 168 + if (!resp.ok) return null; 169 + 170 + /** @type {{ entries: Array<{ ".tag": string; name: string; path_lower: string }>; has_more: boolean; cursor: string }} */ 171 + const data = await resp.json(); 172 + 173 + for (const entry of data.entries) { 174 + if (entry[".tag"] === "file" && isAudioFile(entry.name)) { 175 + entries.push({ name: entry.name, path_lower: entry.path_lower }); 176 + } 177 + } 178 + 179 + hasMore = data.has_more; 180 + cursor = data.cursor; 181 + } 182 + 183 + return entries; 184 + } 185 + 186 + /** 187 + * @param {string} accessToken 188 + * @param {string} filePath 189 + * @returns {Promise<string | null>} 190 + */ 191 + export async function getTemporaryLink(accessToken, filePath) { 192 + const resp = await fetch( 193 + "https://api.dropboxapi.com/2/files/get_temporary_link", 194 + { 195 + method: "POST", 196 + headers: { 197 + "Authorization": `Bearer ${accessToken}`, 198 + "Content-Type": "application/json", 199 + }, 200 + body: JSON.stringify({ path: filePath }), 201 + }, 202 + ); 203 + 204 + if (!resp.ok) return null; 205 + 206 + /** @type {{ link: string }} */ 207 + const data = await resp.json(); 208 + return data.link ?? null; 209 + } 210 + 211 + /** 212 + * @param {string} accessToken 213 + * @returns {Promise<boolean>} 214 + */ 215 + export async function checkAccess(accessToken) { 216 + const resp = await fetch( 217 + "https://api.dropboxapi.com/2/users/get_current_account", 218 + { 219 + method: "POST", 220 + headers: { "Authorization": `Bearer ${accessToken}` }, 221 + }, 222 + ); 223 + return resp.ok; 224 + } 225 + 226 + export const checkAccessCached = cachedConsult(checkAccess, (token) => token);
+2
src/components/input/dropbox/constants.js
··· 1 + export const SCHEME = "dropbox"; 2 + export const APP_KEY = "kwsydtrzban41zr";
+59
src/components/input/dropbox/element.js
··· 1 + import { defineElement, DiffuseElement } from "~/common/element.js"; 2 + import { SCHEME } from "./constants.js"; 3 + import { accountsFromTracks, buildURI } from "./common.js"; 4 + 5 + /** 6 + * @import {InputActions, InputSchemeProvider} from "~/components/input/types.d.ts" 7 + * @import {ProxiedActions} from "~/common/worker.d.ts" 8 + * @import {Track} from "~/definitions/types.d.ts" 9 + */ 10 + 11 + //////////////////////////////////////////// 12 + // ELEMENT 13 + //////////////////////////////////////////// 14 + 15 + /** 16 + * @implements {ProxiedActions<InputActions>} 17 + * @implements {InputSchemeProvider} 18 + */ 19 + class DropboxInput extends DiffuseElement { 20 + static NAME = "diffuse/input/dropbox"; 21 + static WORKER_URL = "components/input/dropbox/worker.js"; 22 + 23 + SCHEME = SCHEME; 24 + 25 + constructor() { 26 + super(); 27 + 28 + /** @type {ProxiedActions<InputActions>} */ 29 + this.proxy = this.workerProxy(); 30 + 31 + this.artwork = this.proxy.artwork; 32 + this.consult = this.proxy.consult; 33 + this.detach = this.proxy.detach; 34 + this.groupConsult = this.proxy.groupConsult; 35 + this.list = this.proxy.list; 36 + this.resolve = this.proxy.resolve; 37 + } 38 + 39 + // 🛠️ 40 + 41 + /** @param {Track[]} tracks */ 42 + sources(tracks) { 43 + return Object.values(accountsFromTracks(tracks)).map((account) => ({ 44 + label: `Dropbox (${account.directoryPath})`, 45 + uri: buildURI(account), 46 + })); 47 + } 48 + } 49 + 50 + export default DropboxInput; 51 + 52 + //////////////////////////////////////////// 53 + // REGISTER 54 + //////////////////////////////////////////// 55 + 56 + export const CLASS = DropboxInput; 57 + export const NAME = "di-dropbox"; 58 + 59 + defineElement(NAME, CLASS);
+198
src/components/input/dropbox/worker.js
··· 1 + import * as TID from "@atcute/tid"; 2 + import { ostiary, rpc } from "~/common/worker.js"; 3 + import { 4 + detach as detachUtil, 5 + groupKey, 6 + } from "~/components/input/common.js"; 7 + import { 8 + accountId, 9 + accountsFromTracks, 10 + buildURI, 11 + checkAccessCached, 12 + getTemporaryLink, 13 + groupTracksByAccount, 14 + groupUrisByAccount, 15 + listFiles, 16 + parseURI, 17 + } from "./common.js"; 18 + import { SCHEME } from "./constants.js"; 19 + 20 + /** 21 + * @import { InputActions as Actions, ConsultGrouping } from "~/components/input/types.d.ts"; 22 + * @import { Track } from "~/definitions/types.d.ts" 23 + */ 24 + 25 + //////////////////////////////////////////// 26 + // ACTIONS 27 + //////////////////////////////////////////// 28 + 29 + /** 30 + * @type {Actions['artwork']} 31 + */ 32 + export async function artwork(_uri) { 33 + return null; 34 + } 35 + 36 + /** 37 + * @type {Actions['consult']} 38 + */ 39 + export async function consult(fileUriOrScheme) { 40 + if (!fileUriOrScheme.includes(":")) { 41 + return { supported: true, consult: "undetermined" }; 42 + } 43 + 44 + const parsed = parseURI(fileUriOrScheme); 45 + if (!parsed) return { supported: true, consult: "undetermined" }; 46 + 47 + const accessible = await checkAccessCached(parsed.accessToken); 48 + return { supported: true, consult: accessible }; 49 + } 50 + 51 + /** 52 + * @type {Actions['detach']} 53 + */ 54 + export async function detach(args) { 55 + return detachUtil({ 56 + ...args, 57 + inputScheme: SCHEME, 58 + handleFileUri: ({ fileURI, tracks }) => { 59 + const result = parseURI(fileURI); 60 + if (!result) return tracks; 61 + 62 + const id = accountId(result); 63 + const groups = groupTracksByAccount(tracks); 64 + delete groups[id]; 65 + 66 + return Object.values(groups).map((g) => g.tracks).flat(1); 67 + }, 68 + }); 69 + } 70 + 71 + /** 72 + * @type {Actions['groupConsult']} 73 + */ 74 + export async function groupConsult(uris) { 75 + const groups = groupUrisByAccount(uris); 76 + 77 + const promises = Object.entries(groups).map( 78 + async ([id, { account, uris }]) => { 79 + const available = await checkAccessCached(account.accessToken); 80 + 81 + /** @type {ConsultGrouping} */ 82 + const grouping = available 83 + ? { available, scheme: SCHEME, uris } 84 + : { available, reason: "Dropbox access denied", scheme: SCHEME, uris }; 85 + 86 + return { key: groupKey(SCHEME, id), grouping }; 87 + }, 88 + ); 89 + 90 + const entries = (await Promise.all(promises)).map((e) => [e.key, e.grouping]); 91 + return Object.fromEntries(entries); 92 + } 93 + 94 + /** 95 + * @type {Actions['list']} 96 + */ 97 + export async function list(cachedTracks = []) { 98 + const accounts = accountsFromTracks(cachedTracks); 99 + 100 + /** @type {Record<string, Record<string, Track>>} */ 101 + const cache = {}; 102 + 103 + cachedTracks.forEach((t) => { 104 + const parsed = parseURI(t.uri); 105 + if (!parsed || t.kind === "placeholder") return; 106 + 107 + const id = accountId(parsed); 108 + if (!cache[id]) cache[id] = {}; 109 + cache[id][parsed.path] = t; 110 + }); 111 + 112 + const promises = Object.values(accounts).map(async (account) => { 113 + const id = accountId(account); 114 + const files = await listFiles(account.accessToken, account.directoryPath); 115 + 116 + if (!files) { 117 + const existing = cachedTracks.find((t) => { 118 + const p = parseURI(t.uri); 119 + return p && accountId(p) === id && t.kind === "placeholder"; 120 + }); 121 + 122 + const now = new Date().toISOString(); 123 + 124 + return [/** @type {Track} */ ({ 125 + $type: "sh.diffuse.output.track", 126 + id: existing?.id ?? TID.now(), 127 + createdAt: existing?.createdAt ?? now, 128 + updatedAt: now, 129 + kind: "placeholder", 130 + uri: buildURI(account), 131 + })]; 132 + } 133 + 134 + if (!files.length) { 135 + const now = new Date().toISOString(); 136 + 137 + return [/** @type {Track} */ ({ 138 + $type: "sh.diffuse.output.track", 139 + id: TID.now(), 140 + createdAt: now, 141 + updatedAt: now, 142 + kind: "placeholder", 143 + uri: buildURI(account), 144 + })]; 145 + } 146 + 147 + return files.map((file) => { 148 + const uri = buildURI(account, file.path_lower); 149 + const cached = cache[id]?.[file.path_lower]; 150 + const now = new Date().toISOString(); 151 + 152 + /** @type {Track} */ 153 + const track = { 154 + $type: "sh.diffuse.output.track", 155 + id: cached?.id ?? TID.now(), 156 + createdAt: cached?.createdAt ?? now, 157 + updatedAt: cached?.updatedAt ?? now, 158 + stats: cached?.stats, 159 + tags: cached?.tags, 160 + uri, 161 + }; 162 + 163 + return track; 164 + }); 165 + }); 166 + 167 + return (await Promise.all(promises)).flat(1); 168 + } 169 + 170 + /** 171 + * @type {Actions['resolve']} 172 + */ 173 + export async function resolve({ uri }) { 174 + const parsed = parseURI(uri); 175 + if (!parsed || parsed.path === "/") return undefined; 176 + 177 + const link = await getTemporaryLink(parsed.accessToken, parsed.path); 178 + if (!link) return undefined; 179 + 180 + // Dropbox temporary links expire after 4 hours 181 + const expiresAt = Math.round(Date.now() / 1000) + 4 * 60 * 60; 182 + return { url: link, expiresAt }; 183 + } 184 + 185 + //////////////////////////////////////////// 186 + // ⚡️ 187 + //////////////////////////////////////////// 188 + 189 + ostiary((context) => { 190 + rpc(context, { 191 + artwork, 192 + consult, 193 + detach, 194 + groupConsult, 195 + list, 196 + resolve, 197 + }); 198 + });
+8 -2
src/components/metadata/common.js
··· 40 40 41 41 // FUCKAROUND: Not sure of the downsides of this 42 42 httpClient.getHeadInfo = async () => { 43 - const info = await getHeadInfo.call(httpClient); 44 - return { ...info, acceptPartialRequests: true }; 43 + try { 44 + const info = await getHeadInfo.call(httpClient); 45 + return { ...info, acceptPartialRequests: true }; 46 + } catch { 47 + // Some servers (e.g. Dropbox temporary links) don't return Content-Length. 48 + // Fall back to downloading the full file without range requests. 49 + return { size: undefined, acceptPartialRequests: false }; 50 + } 45 51 }; 46 52 47 53 /** @type {any} */
+4
src/elements.vto
··· 55 55 Signals that could influence the scope of a set of tracks. 56 56 57 57 input: 58 + - url: "components/input/dropbox/element.js" 59 + title: "Dropbox" 60 + desc: > 61 + Audio files from Dropbox, using the Dropbox v2 HTTP API. 58 62 - url: "components/input/https/element.js" 59 63 title: "HTTPS" 60 64 desc: >
+12
src/facets/connect/dropbox/index.html
··· 1 + <style> 2 + @import "./styles/base.css"; 3 + @import "./vendor/@phosphor-icons/web/bold/style.css"; 4 + @import "./vendor/@phosphor-icons/web/fill/style.css"; 5 + @import "./facets/connect/common.css"; 6 + 7 + @layer base, diffuse; 8 + </style> 9 + 10 + <main></main> 11 + 12 + <script type="module" src="facets/connect/dropbox/index.inline.js"></script>
+202
src/facets/connect/dropbox/index.inline.js
··· 1 + import * as TID from "@atcute/tid"; 2 + import { html, nothing } from "lit-html"; 3 + 4 + import * as Output from "~/common/output.js"; 5 + import { SCHEME } from "~/components/input/dropbox/constants.js"; 6 + import { accountId, buildURI, parseURI } from "~/components/input/dropbox/common.js"; 7 + import { APP_KEY } from "~/components/input/dropbox/constants.js"; 8 + import { effect } from "~/common/signal.js"; 9 + import foundation from "~/common/foundation.js"; 10 + 11 + import { setup } from "~/facets/connect/common.js"; 12 + 13 + foundation.setup({ title: "Connect Dropbox | Diffuse" }); 14 + 15 + //////////////////////////////////////////// 16 + // SETUP 17 + //////////////////////////////////////////// 18 + 19 + const [inputConfigurator, outputOrchestrator, sourcesOrchestrator] = 20 + await Promise.all([ 21 + foundation.configurator.input(), 22 + foundation.orchestrator.output(), 23 + foundation.orchestrator.sources(), 24 + ]); 25 + 26 + await Promise.all([ 27 + customElements.whenDefined(inputConfigurator.localName), 28 + customElements.whenDefined(outputOrchestrator.localName), 29 + customElements.whenDefined(sourcesOrchestrator.localName), 30 + ]); 31 + 32 + //////////////////////////////////////////// 33 + // OAUTH TOKEN FROM HASH 34 + //////////////////////////////////////////// 35 + 36 + const hashParams = new URLSearchParams(location.hash.slice(1)); 37 + let currentToken = hashParams.get("access_token"); 38 + 39 + if (currentToken) { 40 + history.replaceState({}, "", location.pathname + location.search); 41 + } 42 + 43 + //////////////////////////////////////////// 44 + // UI 45 + //////////////////////////////////////////// 46 + 47 + const { setItems, setError } = setup({ 48 + title: "Dropbox", 49 + hasInput: false, 50 + hasOutput: false, 51 + 52 + description: html` 53 + <p> 54 + Add your Dropbox as an audio source. Authorize with Dropbox, then 55 + optionally specify which directory to scan for music. 56 + </p> 57 + `, 58 + 59 + rightContent: html` 60 + <div id="dropbox-auth-section" class="button-row"> 61 + <button id="dropbox-auth-btn"> 62 + <i class="ph-fill ph-cloud-arrow-up"></i> 63 + Authorize with Dropbox 64 + </button> 65 + </div> 66 + 67 + <div id="dropbox-add-section" hidden> 68 + <label> 69 + Directory path 70 + <input id="dropbox-dir" placeholder="/"> 71 + </label> 72 + <p class="caption">Leave empty to scan your entire Dropbox.</p> 73 + <div class="button-row"> 74 + <button id="dropbox-add-btn"> 75 + <i class="ph-fill ph-music-notes"></i> 76 + Add audio input 77 + </button> 78 + </div> 79 + </div> 80 + `, 81 + 82 + formFields: html``, 83 + onSubmit: async () => {}, 84 + }); 85 + 86 + const authSection = /** @type {HTMLElement} */ (document.querySelector("#dropbox-auth-section")); 87 + const addSection = /** @type {HTMLElement} */ (document.querySelector("#dropbox-add-section")); 88 + const dirInput = /** @type {HTMLInputElement} */ (document.querySelector("#dropbox-dir")); 89 + 90 + if (currentToken) { 91 + authSection.hidden = true; 92 + addSection.hidden = false; 93 + } 94 + 95 + //////////////////////////////////////////// 96 + // REACTIVE LIST 97 + //////////////////////////////////////////// 98 + 99 + effect(() => { 100 + const inputSources = sourcesOrchestrator.sources()[SCHEME] ?? []; 101 + 102 + /** @type {Map<string, { label: string; uri: string }>} */ 103 + const allSources = new Map(); 104 + 105 + for (const source of inputSources) { 106 + const parsed = parseURI(source.uri); 107 + if (!parsed) continue; 108 + 109 + const id = accountId(parsed); 110 + if (!allSources.has(id)) { 111 + allSources.set(id, { label: source.label, uri: source.uri }); 112 + } 113 + } 114 + 115 + setItems( 116 + [...allSources.values()].map(({ label, uri }) => ({ 117 + name: label, 118 + detail: "Dropbox", 119 + isInput: true, 120 + isOutput: false, 121 + isSelectedOutput: false, 122 + isDisabled: sourcesOrchestrator.isDisabled(uri), 123 + onRemove: () => removeSource(uri), 124 + onToggleDisabled: () => sourcesOrchestrator.toggle(uri), 125 + })), 126 + ); 127 + }); 128 + 129 + //////////////////////////////////////////// 130 + // ACTIONS 131 + //////////////////////////////////////////// 132 + 133 + document.querySelector("#dropbox-auth-btn")?.addEventListener("click", () => { 134 + sessionStorage.setItem("oauth/callback/redirect_path", location.pathname + location.search); 135 + 136 + const params = new URLSearchParams({ 137 + response_type: "token", 138 + client_id: APP_KEY, 139 + redirect_uri: location.origin + "/oauth/callback/", 140 + }); 141 + 142 + location.assign(`https://www.dropbox.com/oauth2/authorize?${params}`); 143 + }); 144 + 145 + document.querySelector("#dropbox-add-btn")?.addEventListener("click", async () => { 146 + if (!currentToken) return; 147 + 148 + setError(null); 149 + try { 150 + const rawDir = dirInput?.value?.trim() || "/"; 151 + const directoryPath = rawDir.startsWith("/") ? rawDir : "/" + rawDir; 152 + 153 + const account = { accessToken: currentToken, directoryPath }; 154 + const uri = buildURI(account); 155 + const now = new Date().toISOString(); 156 + 157 + const tracksCol = outputOrchestrator.tracks.collection(); 158 + const existingTracks = tracksCol.state === "loaded" ? tracksCol.data : []; 159 + 160 + await outputOrchestrator.tracks.save([ 161 + ...existingTracks, 162 + { 163 + $type: "sh.diffuse.output.track", 164 + id: TID.now(), 165 + createdAt: now, 166 + updatedAt: now, 167 + kind: "placeholder", 168 + uri, 169 + }, 170 + ]); 171 + 172 + // Reset UI after adding 173 + if (dirInput) dirInput.value = ""; 174 + currentToken = null; 175 + authSection.hidden = false; 176 + addSection.hidden = true; 177 + } catch (err) { 178 + setError(err instanceof Error ? err.message : "Failed to add Dropbox source"); 179 + } 180 + }); 181 + 182 + /** @param {string} uri */ 183 + async function removeSource(uri) { 184 + setError(null); 185 + try { 186 + const tracks = await Output.data(outputOrchestrator.tracks); 187 + const detachedTracks = await inputConfigurator.detach({ 188 + fileUriOrScheme: uri, 189 + tracks, 190 + }); 191 + 192 + if (detachedTracks) await outputOrchestrator.tracks.save(detachedTracks); 193 + } catch (err) { 194 + setError(err instanceof Error ? err.message : "Failed to remove source"); 195 + } 196 + } 197 + 198 + //////////////////////////////////////////// 199 + // 🚀 200 + //////////////////////////////////////////// 201 + 202 + foundation.ready();
+1
src/facets/connect/index.inline.js
··· 11 11 /** @type {Record<string, string>} */ 12 12 const icons = { 13 13 "facets/connect/atproto/index.html": "at", 14 + "facets/connect/dropbox/index.html": "cloud", 14 15 "facets/connect/https/index.html": "globe", 15 16 "facets/connect/icecast/index.html": "radio", 16 17 "facets/connect/local/index.html": "folder-open",
+13
src/facets/data/input-bundle/index.inline.js
··· 1 1 import foundation from "~/common/foundation.js"; 2 2 import { effect } from "~/common/signal.js"; 3 3 4 + import { NAME as DROPBOX_NAME } from "~/components/input/dropbox/element.js"; 4 5 import { NAME as EPHEMERAL_CACHE_NAME } from "~/components/input/ephemeral-cache/element.js"; 5 6 import { NAME as HTTPS_NAME } from "~/components/input/https/element.js"; 6 7 import { NAME as ICECAST_NAME } from "~/components/input/icecast/element.js"; ··· 20 21 const input = foundation.signals.configurator.input(); 21 22 if (!input) return; 22 23 24 + dropbox(input); 23 25 ephemeralCache(input); 24 26 https(input); 25 27 icecast(input); ··· 27 29 opensubsonic(input); 28 30 s3(input); 29 31 }); 32 + 33 + //////////////////////////////////////////// 34 + // DROPBOX 35 + //////////////////////////////////////////// 36 + 37 + /** 38 + * @param {InputConfigurator} input 39 + */ 40 + export function dropbox(input) { 41 + input.append(document.createElement(DROPBOX_NAME)); 42 + } 30 43 31 44 //////////////////////////////////////////// 32 45 // EPHEMERAL CACHE
+2
src/facets/data/sources/index.inline.js
··· 4 4 import foundation from "~/common/foundation.js"; 5 5 import { effect } from "~/common/signal.js"; 6 6 7 + import { SCHEME as SCHEME_DROPBOX } from "~/components/input/dropbox/constants.js"; 7 8 import { SCHEME as SCHEME_EPHEMERAL_CACHE } from "~/components/input/ephemeral-cache/constants.js"; 8 9 import { SCHEME as SCHEME_HTTPS } from "~/components/input/https/constants.js"; 9 10 import { SCHEME as SCHEME_ICECAST } from "~/components/input/icecast/constants.js"; ··· 13 14 14 15 /** @type {Record<string, string>} */ 15 16 const SCHEME_NAMES = { 17 + [SCHEME_DROPBOX]: "Dropbox", 16 18 [SCHEME_EPHEMERAL_CACHE]: "Browser storage", 17 19 [SCHEME_HTTPS]: "HTTPS", 18 20 [SCHEME_ICECAST]: "Icecast",
+12 -2
src/themes/blur/browser/element.js
··· 23 23 * @import {ArtworkElement} from "~/components/artwork/types.d.ts" 24 24 */ 25 25 26 + /** 27 + * @param {Track} track 28 + */ 29 + function trackTitle(track) { 30 + if (track.tags?.title) return track.tags.title; 31 + const path = track.uri.split("?")[0]; 32 + const filename = path.split("/").filter(Boolean).at(-1); 33 + return filename ? decodeURIComponent(filename) : track.uri; 34 + } 35 + 26 36 const MAX_ART_CONCURRENT = 8; 27 37 28 38 const TRACK_ROW_HEIGHT = 40; ··· 780 790 </button> 781 791 </div> 782 792 <div class="col-title"> 783 - <span class="track-title">${track.tags?.title}</span> 793 + <span class="track-title">${trackTitle(track)}</span> 784 794 </div> 785 795 <div class="col-artist"> 786 796 <span>${track.tags?.artist}</span> ··· 1012 1022 </button> 1013 1023 </div> 1014 1024 <div class="col-title"> 1015 - <span class="track-title">${t.tags?.title}</span> 1025 + <span class="track-title">${trackTitle(t)}</span> 1016 1026 </div> 1017 1027 <div class="col-artist"> 1018 1028 <span>${t.tags?.artist}</span>