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

Configure Feed

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

feat: add local input

+615 -5
+2 -1
deno.jsonc
··· 65 65 "@dotenv-run/esbuild": "npm:@dotenv-run/esbuild@^1.5.1", 66 66 "@std/fs": "jsr:@std/fs@^1.0.19", 67 67 "@std/path": "jsr:@std/path@^1.1.2", 68 + "@types/wicg-file-system-access": "npm:@types/wicg-file-system-access@^2023.10.7", 68 69 "esbuild-plugins-node-modules-polyfill": "npm:esbuild-plugins-node-modules-polyfill@^1.8.1", 69 70 "esbuild-plugin-wasm": "npm:esbuild-plugin-wasm@^1.1.0", 70 71 "lume/": "https://cdn.jsdelivr.net/gh/lumeland/lume@3.1.4/", ··· 192 193 "compilerOptions": { 193 194 "checkJs": true, 194 195 "lib": ["deno.ns", "dom", "esnext"], 195 - "types": ["lume/types.ts"], 196 + "types": ["lume/types.ts", "@types/wicg-file-system-access"], 196 197 "jsx": "react-jsx", 197 198 "jsxImportSource": "lume", 198 199 },
+168
src/components/input/local/common.js
··· 1 + import * as IDB from "idb-keyval"; 2 + import * as URI from "fast-uri"; 3 + 4 + import { isAudioFile } from "@components/input/common.js"; 5 + import { safeDecodeURIComponent } from "@common/utils.js"; 6 + import { IDB_HANDLES, SCHEME } from "./constants.js"; 7 + 8 + /** 9 + * @import { Track } from "@definitions/types.d.ts" 10 + */ 11 + 12 + //////////////////////////////////////////// 13 + // 🛠️ 14 + //////////////////////////////////////////// 15 + 16 + /** 17 + * @param {string} uriString 18 + * @returns {{ tid: string; path: string } | undefined} 19 + */ 20 + export function parseURI(uriString) { 21 + try { 22 + const url = new URL(uriString); 23 + if (url.protocol !== `${SCHEME}:`) return undefined; 24 + if (!url.host) return undefined; 25 + 26 + return { 27 + tid: url.host, 28 + path: safeDecodeURIComponent(url.pathname), 29 + }; 30 + } catch { 31 + return undefined; 32 + } 33 + } 34 + 35 + /** 36 + * @param {string} tid 37 + * @param {string} [path] 38 + */ 39 + export function buildURI(tid, path = "/") { 40 + return URI.serialize({ 41 + scheme: SCHEME, 42 + host: tid, 43 + path, 44 + }); 45 + } 46 + 47 + /** 48 + * @param {Track[]} tracks 49 + * @returns {Record<string, { tid: string; tracks: Track[] }>} 50 + */ 51 + export function groupTracksByTid(tracks) { 52 + /** @type {Record<string, { tid: string; tracks: Track[] }>} */ 53 + const acc = {}; 54 + 55 + tracks.forEach((track) => { 56 + const parsed = parseURI(track.uri); 57 + if (!parsed) return; 58 + 59 + const { tid } = parsed; 60 + if (acc[tid]) { 61 + acc[tid].tracks.push(track); 62 + } else { 63 + acc[tid] = { tid, tracks: [track] }; 64 + } 65 + }); 66 + 67 + return acc; 68 + } 69 + 70 + /** 71 + * @param {string[]} uris 72 + * @returns {Record<string, { tid: string; uris: string[] }>} 73 + */ 74 + export function groupUrisByTid(uris) { 75 + /** @type {Record<string, { tid: string; uris: string[] }>} */ 76 + const acc = {}; 77 + 78 + uris.forEach((uri) => { 79 + const parsed = parseURI(uri); 80 + if (!parsed) return; 81 + 82 + const { tid } = parsed; 83 + if (acc[tid]) { 84 + acc[tid].uris.push(uri); 85 + } else { 86 + acc[tid] = { tid, uris: [uri] }; 87 + } 88 + }); 89 + 90 + return acc; 91 + } 92 + 93 + /** 94 + * @param {Track[]} tracks 95 + * @returns {Record<string, string>} 96 + */ 97 + export function tidsFromTracks(tracks) { 98 + /** @type {Record<string, string>} */ 99 + const acc = {}; 100 + 101 + tracks.forEach((track) => { 102 + const parsed = parseURI(track.uri); 103 + if (!parsed) return; 104 + acc[parsed.tid] = parsed.tid; 105 + }); 106 + 107 + return acc; 108 + } 109 + 110 + /** 111 + * @returns {Promise<Record<string, FileSystemHandle>>} 112 + */ 113 + export async function loadHandles() { 114 + const i = await IDB.get(IDB_HANDLES); 115 + return i ?? {}; 116 + } 117 + 118 + /** 119 + * @param {Record<string, FileSystemHandle>} handles 120 + */ 121 + export async function saveHandles(handles) { 122 + await IDB.set(IDB_HANDLES, handles); 123 + } 124 + 125 + /** 126 + * @param {FileSystemHandle} handle 127 + * @param {string} path 128 + * @returns {Promise<FileSystemFileHandle>} 129 + */ 130 + export async function getHandleFile(handle, path) { 131 + if (handle.kind === "file") { 132 + return /** @type {FileSystemFileHandle} */ (handle); 133 + } 134 + 135 + const parts = path.replace(/^\//, "").split("/").filter(Boolean); 136 + let current = /** @type {FileSystemDirectoryHandle} */ (handle); 137 + 138 + for (const part of parts.slice(0, -1)) { 139 + current = await current.getDirectoryHandle(part); 140 + } 141 + 142 + return current.getFileHandle(/** @type {string} */ (parts.at(-1))); 143 + } 144 + 145 + /** 146 + * @param {FileSystemDirectoryHandle} dirHandle 147 + * @param {string} [basePath] 148 + * @returns {Promise<string[]>} 149 + */ 150 + export async function enumerateAudioFiles(dirHandle, basePath = "/") { 151 + const results = []; 152 + 153 + for await (const [name, handle] of /** @type {any} */ (dirHandle).entries()) { 154 + const entryPath = basePath + name; 155 + 156 + if (handle.kind === "directory") { 157 + const sub = await enumerateAudioFiles( 158 + /** @type {FileSystemDirectoryHandle} */ (handle), 159 + entryPath + "/", 160 + ); 161 + results.push(...sub); 162 + } else if (isAudioFile(name)) { 163 + results.push(entryPath); 164 + } 165 + } 166 + 167 + return results; 168 + }
+2
src/components/input/local/constants.js
··· 1 + export const SCHEME = "local"; 2 + export const IDB_HANDLES = "local-input/handles";
+120
src/components/input/local/element.js
··· 1 + import * as TID from "@atcute/tid"; 2 + import { DiffuseElement } from "@common/element.js"; 3 + import { SCHEME } from "./constants.js"; 4 + import { 5 + buildURI, 6 + loadHandles, 7 + saveHandles, 8 + tidsFromTracks, 9 + } from "./common.js"; 10 + 11 + /** 12 + * @import {InputActions, InputSchemeProvider} from "@components/input/types.d.ts" 13 + * @import {ProxiedActions} from "@common/worker.d.ts" 14 + * @import {Track} from "@definitions/types.d.ts" 15 + */ 16 + 17 + //////////////////////////////////////////// 18 + // ELEMENT 19 + //////////////////////////////////////////// 20 + 21 + /** 22 + * @implements {ProxiedActions<InputActions>} 23 + * @implements {InputSchemeProvider} 24 + */ 25 + class LocalInput extends DiffuseElement { 26 + static NAME = "diffuse/input/local"; 27 + static WORKER_URL = "components/input/local/worker.js"; 28 + 29 + SCHEME = SCHEME; 30 + 31 + /** @type {Map<string, string>} tid → handle name */ 32 + #names = new Map(); 33 + 34 + constructor() { 35 + super(); 36 + 37 + /** @type {ProxiedActions<InputActions>} */ 38 + this.proxy = this.workerProxy(); 39 + 40 + this.consult = this.proxy.consult; 41 + this.detach = this.proxy.detach; 42 + this.groupConsult = this.proxy.groupConsult; 43 + this.list = this.proxy.list; 44 + this.resolve = this.proxy.resolve; 45 + } 46 + 47 + // LIFECYCLE 48 + 49 + /** @override */ 50 + async connectedCallback() { 51 + super.connectedCallback(); 52 + const handles = await loadHandles(); 53 + for (const [tid, handle] of Object.entries(handles)) { 54 + this.#names.set(tid, handle.name); 55 + } 56 + } 57 + 58 + // 🛠️ 59 + 60 + /** 61 + * Prompts the user to pick a directory. 62 + * Stores handle in IDB. 63 + * Returns the URI for the track placeholder. 64 + */ 65 + async addDirectory() { 66 + const dirHandle = await /** @type {any} */ (globalThis).showDirectoryPicker( 67 + { mode: "read" }, 68 + ); 69 + 70 + const tid = TID.now(); 71 + const handles = await loadHandles(); 72 + 73 + handles[tid] = dirHandle; 74 + await saveHandles(handles); 75 + this.#names.set(tid, dirHandle.name); 76 + 77 + return buildURI(tid); 78 + } 79 + 80 + /** 81 + * Prompts the user to pick one or more files. 82 + * Stores handles in IDB. 83 + * Returns the URIs for the track placeholders. 84 + */ 85 + async addFiles() { 86 + const fileHandles = await /** @type {any} */ (globalThis) 87 + .showOpenFilePicker({ multiple: true }); 88 + const handles = await loadHandles(); 89 + const uris = []; 90 + 91 + for (const fileHandle of fileHandles) { 92 + const tid = TID.now(); 93 + handles[tid] = fileHandle; 94 + this.#names.set(tid, fileHandle.name); 95 + uris.push(buildURI(tid)); 96 + } 97 + 98 + await saveHandles(handles); 99 + return uris; 100 + } 101 + 102 + /** @param {Track[]} tracks */ 103 + sources(tracks) { 104 + return Object.values(tidsFromTracks(tracks)).map((tid) => ({ 105 + label: this.#names.get(tid) ?? tid, 106 + uri: buildURI(tid), 107 + })); 108 + } 109 + } 110 + 111 + export default LocalInput; 112 + 113 + //////////////////////////////////////////// 114 + // REGISTER 115 + //////////////////////////////////////////// 116 + 117 + export const CLASS = LocalInput; 118 + export const NAME = "di-local"; 119 + 120 + customElements.define(NAME, CLASS);
+248
src/components/input/local/worker.js
··· 1 + import * as TID from "@atcute/tid"; 2 + import { ostiary, rpc } from "@common/worker.js"; 3 + import { groupKey } from "@components/input/common.js"; 4 + import { 5 + buildURI, 6 + enumerateAudioFiles, 7 + getHandleFile, 8 + groupTracksByTid, 9 + groupUrisByTid, 10 + loadHandles, 11 + parseURI, 12 + saveHandles, 13 + } from "./common.js"; 14 + import { SCHEME } from "./constants.js"; 15 + 16 + /** 17 + * @import { InputActions as Actions, ConsultGrouping } from "@components/input/types.d.ts"; 18 + * @import { Track } from "@definitions/types.d.ts" 19 + */ 20 + 21 + //////////////////////////////////////////// 22 + // ACTIONS 23 + //////////////////////////////////////////// 24 + 25 + /** 26 + * @type {Actions['consult']} 27 + */ 28 + export async function consult(fileUriOrScheme) { 29 + if (typeof FileSystemHandle === "undefined") { 30 + return { supported: false, reason: "No browser support" }; 31 + } 32 + 33 + if (!fileUriOrScheme.includes(":")) { 34 + return { supported: true, consult: "undetermined" }; 35 + } 36 + 37 + const parsed = parseURI(fileUriOrScheme); 38 + if (!parsed) return { supported: false, reason: "Unknown handle" }; 39 + 40 + const handles = await loadHandles(); 41 + const handle = handles[parsed.tid]; 42 + 43 + if (!handle) return { supported: false, reason: "Unknown handle" }; 44 + 45 + const permission = await /** @type {any} */ (handle).queryPermission({ 46 + mode: "read", 47 + }); 48 + 49 + return { supported: true, consult: permission === "granted" }; 50 + } 51 + 52 + /** 53 + * @type {Actions['detach']} 54 + */ 55 + export async function detach({ fileUriOrScheme, tracks }) { 56 + if (!fileUriOrScheme.includes("://")) { 57 + if (fileUriOrScheme === SCHEME) return []; 58 + return tracks; 59 + } 60 + 61 + const parsed = parseURI(fileUriOrScheme); 62 + if (!parsed) return tracks; 63 + 64 + const { tid } = parsed; 65 + const groups = groupTracksByTid(tracks); 66 + delete groups[tid]; 67 + 68 + const handles = await loadHandles(); 69 + delete handles[tid]; 70 + await saveHandles(handles); 71 + 72 + return Object.values(groups).map((g) => g.tracks).flat(1); 73 + } 74 + 75 + /** 76 + * @type {Actions['groupConsult']} 77 + */ 78 + export async function groupConsult(uris) { 79 + const groups = groupUrisByTid(uris); 80 + const handles = await loadHandles(); 81 + 82 + const promises = Object.entries(groups).flatMap(async ([tid, { uris }]) => { 83 + const handle = handles[tid]; 84 + if (!handle) return []; 85 + 86 + const available = 87 + (await /** @type {any} */ (handle).queryPermission({ mode: "read" })) === 88 + "granted"; 89 + 90 + /** @type {ConsultGrouping} */ 91 + const grouping = available ? { available, scheme: SCHEME, uris } : { 92 + available: false, 93 + reason: "Permission not granted", 94 + scheme: SCHEME, 95 + uris, 96 + }; 97 + 98 + return [{ key: groupKey(SCHEME, tid), grouping }]; 99 + }); 100 + 101 + const results = (await Promise.all(promises)).flat(1); 102 + return Object.fromEntries(results.map((e) => [e.key, e.grouping])); 103 + } 104 + 105 + /** 106 + * @type {Actions['list']} 107 + */ 108 + export async function list(cachedTracks = []) { 109 + const handles = await loadHandles(); 110 + const now = new Date().toISOString(); 111 + 112 + console.log(cachedTracks) 113 + 114 + /** @type {Record<string, Track>} */ 115 + const cacheByUri = {}; 116 + 117 + cachedTracks.forEach((t) => { 118 + cacheByUri[t.uri] = t; 119 + }); 120 + 121 + const trackGroups = groupTracksByTid(cachedTracks); 122 + 123 + const allTids = new Set([ 124 + ...Object.keys(handles), 125 + ...Object.keys(trackGroups), 126 + ]); 127 + 128 + console.log(allTids); 129 + 130 + const promises = [...allTids].map(async (tid) => { 131 + const handle = handles[tid]; 132 + if (!handle) return trackGroups[tid]?.tracks ?? /** @type {Track[]} */ ([]); 133 + 134 + const perm = await /** @type {any} */ (handle).queryPermission({ 135 + mode: "read", 136 + }); 137 + 138 + console.log(tid, perm, handle.kind); 139 + 140 + if (perm !== "granted") { 141 + const cached = trackGroups[tid]?.tracks[0]; 142 + 143 + /** @type {Track} */ 144 + const placeholder = { 145 + $type: "sh.diffuse.output.track", 146 + id: cached?.id ?? TID.now(), 147 + createdAt: cached?.createdAt ?? now, 148 + updatedAt: now, 149 + kind: "placeholder", 150 + uri: buildURI(tid), 151 + }; 152 + 153 + return [placeholder]; 154 + } 155 + 156 + if (handle.kind === "file") { 157 + const uri = buildURI(tid); 158 + const cached = cacheByUri[uri]; 159 + 160 + /** @type {Track} */ 161 + const track = { 162 + $type: "sh.diffuse.output.track", 163 + id: cached?.id ?? TID.now(), 164 + createdAt: cached?.createdAt ?? now, 165 + updatedAt: cached?.updatedAt ?? now, 166 + stats: cached?.stats, 167 + tags: cached?.tags, 168 + uri, 169 + }; 170 + 171 + return [track]; 172 + } 173 + 174 + const paths = await enumerateAudioFiles( 175 + /** @type {FileSystemDirectoryHandle} */ (handle), 176 + ); 177 + 178 + if (!paths.length) { 179 + /** @type {Track} */ 180 + const placeholder = { 181 + $type: "sh.diffuse.output.track", 182 + id: TID.now(), 183 + createdAt: now, 184 + updatedAt: now, 185 + kind: "placeholder", 186 + uri: buildURI(tid), 187 + }; 188 + 189 + return [placeholder]; 190 + } 191 + 192 + return paths.map((path) => { 193 + const uri = buildURI(tid, path); 194 + const cached = cacheByUri[uri]; 195 + 196 + /** @type {Track} */ 197 + const track = { 198 + $type: "sh.diffuse.output.track", 199 + id: cached?.id ?? TID.now(), 200 + createdAt: cached?.createdAt ?? now, 201 + updatedAt: cached?.updatedAt ?? now, 202 + stats: cached?.stats, 203 + tags: cached?.tags, 204 + uri, 205 + }; 206 + 207 + return track; 208 + }); 209 + }); 210 + 211 + const tracks = (await Promise.all(promises)).flat(1); 212 + return tracks; 213 + } 214 + 215 + /** 216 + * @type {Actions['resolve']} 217 + */ 218 + export async function resolve({ uri }) { 219 + const parsed = parseURI(uri); 220 + if (!parsed) return undefined; 221 + 222 + const handles = await loadHandles(); 223 + const handle = handles[parsed.tid]; 224 + const path = parsed.path.replace(/^\//, ""); 225 + 226 + if (!handle) return undefined; 227 + if (handle.kind === "directory" && path === "") return undefined; 228 + 229 + const fileHandle = await getHandleFile(handle, path); 230 + const file = await fileHandle.getFile(); 231 + 232 + const url = URL.createObjectURL(file); 233 + return { url, expiresAt: Infinity }; 234 + } 235 + 236 + //////////////////////////////////////////// 237 + // ⚡️ 238 + //////////////////////////////////////////// 239 + 240 + ostiary((context) => { 241 + rpc(context, { 242 + consult, 243 + detach, 244 + groupConsult, 245 + list, 246 + resolve, 247 + }); 248 + });
+2
src/components/orchestrator/input/element.js
··· 2 2 3 3 import "@components/configurator/input/element.js"; 4 4 import "@components/input/https/element.js"; 5 + import "@components/input/local/element.js"; 5 6 import "@components/input/opensubsonic/element.js"; 6 7 import "@components/input/s3/element.js"; 7 8 ··· 62 63 return html` 63 64 <dc-input> 64 65 <di-https></di-https> 66 + <di-local></di-local> 65 67 <di-opensubsonic></di-opensubsonic> 66 68 <di-s3></di-s3> 67 69 </dc-input>
+8 -4
src/index.vto
··· 40 40 Signals that could influence the scope of a set of tracks. 41 41 42 42 input: 43 - - url: "components/input/opensubsonic/element.js" 44 - title: "Opensubsonic" 45 - desc: > 46 - Add any (open)subsonic server. 47 43 - url: "components/input/https/element.js" 48 44 title: "HTTPS" 49 45 desc: > ··· 52 48 desc: > 53 49 Generate tracks based on HTTPS servers that provide JSON (directory) listings. 54 50 todo: true 51 + - url: "components/input/local/element.js" 52 + title: "Local" 53 + desc: > 54 + Audio files or directories from your local device, using the browser's File System Access API. 55 + - url: "components/input/opensubsonic/element.js" 56 + title: "Opensubsonic" 57 + desc: > 58 + Add any (open)subsonic server. 55 59 - url: "components/input/s3/element.js" 56 60 title: "S3" 57 61 desc: >
+65
src/themes/webamp/configurators/input/element.js
··· 12 12 import { buildURI as buildS3URI } from "@components/input/s3/common.js"; 13 13 14 14 import { SCHEME as HTTPS_SCHEME } from "@components/input/https/constants.js"; 15 + import { SCHEME as LOCAL_SCHEME } from "@components/input/local/constants.js"; 15 16 import { SCHEME as OPENSUBSONIC_SCHEME } from "@components/input/opensubsonic/constants.js"; 16 17 import { SCHEME as S3_SCHEME } from "@components/input/s3/constants.js"; 17 18 ··· 194 195 if (button) button.disabled = false; 195 196 }; 196 197 198 + #addLocalDirectory = async () => { 199 + const localInput = /** @type {any} */ (this.$input.value)?.input 200 + ?.inputs?.()[LOCAL_SCHEME]; 201 + if (!localInput) return; 202 + 203 + const uri = await localInput.addDirectory(); 204 + await this.addSource(uri); 205 + }; 206 + 207 + #addLocalFiles = async () => { 208 + const localInput = /** @type {any} */ (this.$input.value)?.input 209 + ?.inputs?.()[LOCAL_SCHEME]; 210 + if (!localInput) return; 211 + 212 + const uris = await localInput.addFiles(); 213 + for (const uri of uris) { 214 + await this.addSource(uri); 215 + } 216 + }; 217 + 197 218 /** 198 219 * @param {Event} event 199 220 */ ··· 299 320 <style> 300 321 @import "./themes/webamp/98-vars.css"; 301 322 323 + .button-row { 324 + display: inline-flex; 325 + gap: var(--grouped-button-spacing); 326 + } 327 + 302 328 #tabbed { 303 329 display: flex; 304 330 flex-direction: column; ··· 370 396 <span>HTTPS</span> 371 397 </label> 372 398 </li> 399 + <li role="tab" aria-selected="${this.$tab.value === "local"}"> 400 + <label @click="${() => this.$tab.value = "local"}"> 401 + <span>Local</span> 402 + </label> 403 + </li> 373 404 <li role="tab" aria-selected="${this.$tab.value === "opensubsonic"}"> 374 405 <label @click="${() => this.$tab.value = "opensubsonic"}"> 375 406 <span>OpenSubsonic</span> ··· 398 429 return this.#renderOverviewTab(html); 399 430 case "https": 400 431 return this.#renderHttpsTab(html); 432 + case "local": 433 + return this.#renderLocalTab(html); 401 434 case "opensubsonic": 402 435 return this.#renderOpenSubsonicTab(html); 403 436 case "s3": ··· 469 502 <button type="submit" id="https-submit">Add URL</button> 470 503 </p> 471 504 </form> 505 + </div> 506 + `; 507 + } 508 + 509 + /** 510 + * @param {RenderArg["html"]} html 511 + */ 512 + #renderLocalTab(html) { 513 + const sources = this.$sourcesOrchestrator.value?.sources(); 514 + 515 + return html` 516 + <div class="window-body"> 517 + <fieldset> 518 + ${this.#renderList( 519 + html, 520 + sources?.[LOCAL_SCHEME] ?? [], 521 + "Added directories & files", 522 + )} 523 + 524 + <p> 525 + <button disabled role="delete" @click="${this.#deleteSelected}"> 526 + Delete selected 527 + </button> 528 + </p> 529 + </fieldset> 530 + 531 + <fieldset> 532 + <p class="button-row"> 533 + <button @click="${this.#addLocalDirectory}">Add directory</button> 534 + <button @click="${this.#addLocalFiles}">Add files</button> 535 + </p> 536 + </fieldset> 472 537 </div> 473 538 `; 474 539 }