A music player that connects to your cloud/distributed storage. diffuse.sh
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391import * as TID from "@atcute/tid";import { html, nothing } from "lit-html";
import * as Output from "~/common/output.js";import { isAudioFile } from "~/components/input/common.js";import { SCHEME } from "~/components/input/local/constants.js";import { isSupported } from "~/components/input/local/common.js";import { effect } from "~/common/signal.js";import foundation from "~/common/foundation.js";
import { setup } from "~/facets/connect/common.js";
/** * @import {Track} from "~/definitions/types.d.ts" */
foundation.setup({ title: "Connect Local" });
////////////////////////////////////////////// SETUP////////////////////////////////////////////
const [inputConfigurator, outputOrchestrator, sourcesOrchestrator] = await Promise.all([ foundation.configurator.input(), foundation.orchestrator.output(), foundation.orchestrator.sources(), ]);
await Promise.all([ customElements.whenDefined(inputConfigurator.localName), customElements.whenDefined(outputOrchestrator.localName), customElements.whenDefined(sourcesOrchestrator.localName),]);
const localInput = /** @type {import("~/components/input/local/element.js").CLASS} */ (inputConfigurator .inputs?.()[SCHEME]);
////////////////////////////////////////////// UI////////////////////////////////////////////
const supported = isSupported();
const { setItems, setError } = setup({ title: "Local files", hasInput: false, hasOutput: false,
description: html` <p>Add local directories or files as audio input.</p> `,
rightContent: html` <label class="dropzone" id="local-dropzone"> <input id="local-dropzone-input" type="file" accept="audio/*" multiple hidden /> <i class="ph-bold ph-upload-simple"></i> <span>Drop or click to select files</span> </label>
<div id="local-ephemeral-row" class="button-row" style="margin-top: var(--space-md);" hidden> <button id="local-clear-ephemeral-btn" class="button--danger button--small" > <i class="ph-bold ph-trash"></i> Clear files </button> </div>
${supported ? html` <hr>
<div class="button-row"> <button id="local-add-dir-btn"> <i class="ph-fill ph-folder-open"></i> Add directory </button> <button id="local-add-files-btn"> <i class="ph-fill ph-music-notes"></i> Add files </button> </div> ` : nothing} `,
formFields: html``, onSubmit: async () => {},});
document .querySelector("#local-add-dir-btn") ?.addEventListener("click", () => addDirectory());
document .querySelector("#local-add-files-btn") ?.addEventListener("click", () => addFiles());
document .querySelector("#local-clear-ephemeral-btn") ?.addEventListener("click", () => clearEphemeral());
const dropzone = document.querySelector("#local-dropzone");const dropzoneInput = /** @type {HTMLInputElement | null} */ (document.querySelector( "#local-dropzone-input", ));
dropzoneInput?.addEventListener("change", async () => { const files = Array.from(dropzoneInput.files ?? []).filter((f) => f.type.startsWith("audio/") ); dropzoneInput.value = ""; if (files.length === 0) return; await cacheFiles(files);});
dropzone?.addEventListener("dragover", (e) => { e.preventDefault(); dropzone.classList.add("dropzone--active");});
dropzone?.addEventListener("dragleave", () => { dropzone.classList.remove("dropzone--active");});
dropzone?.addEventListener("drop", async (e) => { e.preventDefault(); dropzone.classList.remove("dropzone--active");
const dragEvent = /** @type {DragEvent} */ (e); // `dataTransfer.files` is reliably populated in every browser, but Firefox // (and older Safari) can return `null` from `getAsFile()` for files dragged // in from the OS. Read the direct files from `files` and only use `items` to // additionally expand dropped directories via the (non-standard) entries API. const directFiles = Array.from(dragEvent.dataTransfer?.files ?? []); const items = Array.from(dragEvent.dataTransfer?.items ?? []); const files = await collectFiles(items, directFiles); if (files.length === 0) return;
await cacheFiles(files);});
////////////////////////////////////////////// REACTIVE LIST////////////////////////////////////////////
const ephemeralRow = /** @type {HTMLElement | null} */ (document.querySelector( "#local-ephemeral-row", ));
effect(() => { const tracksCol = outputOrchestrator.tracks.collection(); const tracks = tracksCol.state === "loaded" ? tracksCol.data : []; const hasEphemeral = tracks.some((t) => t.uri.startsWith("ephemeral+cache://") );
if (ephemeralRow) ephemeralRow.hidden = !hasEphemeral;
const entries = localInput?.sources(tracks) ?? [];
setItems( entries.map(({ label, uri }) => ({ name: label, detail: "local", isInput: true, isOutput: false, isSelectedOutput: false, isDisabled: sourcesOrchestrator.isDisabled(uri), onRemove: () => removeEntry(uri), onToggleDisabled: () => sourcesOrchestrator.toggle(uri), })), );});////////////////////////////////////////////// ACTIONS////////////////////////////////////////////
/** @param {string} uri */async function removeEntry(uri) { setError(null); try { const tracks = await Output.data(outputOrchestrator.tracks); const detachedTracks = await inputConfigurator.detach({ fileUriOrScheme: uri, tracks, });
if (detachedTracks) await outputOrchestrator.tracks.save(detachedTracks); } catch (err) { setError(err instanceof Error ? err.message : "Failed to remove entry"); }}
async function clearEphemeral() { setError(null); try { const tracks = await Output.data(outputOrchestrator.tracks); const ephemeralUris = tracks .filter((t) => t.uri.startsWith("ephemeral+cache://")) .map((t) => t.uri);
await inputConfigurator.removeFromCache(ephemeralUris); await outputOrchestrator.tracks.save( tracks.filter((t) => !t.uri.startsWith("ephemeral+cache://")), ); } catch (err) { setError( err instanceof Error ? err.message : "Failed to clear cached files", ); }}
async function addDirectory() { setError(null); try { const uri = await localInput.addDirectory(); const now = new Date().toISOString(); const tracksCol = outputOrchestrator.tracks.collection(); const existingTracks = tracksCol.state === "loaded" ? tracksCol.data : [];
await outputOrchestrator.tracks.save([ ...existingTracks, { $type: "sh.diffuse.output.track", id: TID.now(), createdAt: now, updatedAt: now, kind: "placeholder", uri, }, ]); } catch (err) { if (err instanceof Error && err.name !== "AbortError") { setError(err.message); } }}
async function addFiles() { setError(null); try { const uris = await localInput.addFiles(); const now = new Date().toISOString(); const tracksCol = outputOrchestrator.tracks.collection(); const existingTracks = tracksCol.state === "loaded" ? tracksCol.data : []; await outputOrchestrator.tracks.save([ ...existingTracks, ...uris.map((uri) => { /** @type {Track} */ const track = { $type: "sh.diffuse.output.track", id: TID.now(), createdAt: now, updatedAt: now, kind: "placeholder", uri, };
return track; }), ]); } catch (err) { if (err instanceof Error && err.name !== "AbortError") { setError(err.message); } }}
/** * @param {File[]} files */async function cacheFiles(files) { setError(null); try { const uris = await Promise.all( files.map((file) => inputConfigurator.cacheBlob(file)), ); const now = new Date().toISOString(); const existingTracks = await Output.data(outputOrchestrator.tracks); const existingUris = new Set(existingTracks.map((t) => t.uri)); const newUris = uris.filter((uri) => !existingUris.has(uri)); await outputOrchestrator.tracks.save([ ...existingTracks, ...newUris.map((uri) => { /** @type {Track} */ const track = { $type: "sh.diffuse.output.track", id: TID.now(), createdAt: now, updatedAt: now, ephemeral: true, uri, }; return track; }), ]); } catch (err) { setError(err instanceof Error ? err.message : "Failed to cache files"); }}
/** * @param {DataTransferItem[]} items * @param {File[]} directFiles * @returns {Promise<File[]>} */async function collectFiles(items, directFiles) { // `isAudioFile` matches on the filename extension. This is deliberate: it // relies on the canonical `~/components/input/common.js` list and works even // when Firefox reports an empty `File.type` for OS-dragged files. const files = directFiles.filter((file) => isAudioFile(file.name));
await Promise.all( items.map(async (item) => { if (item.kind !== "file") return;
const entry = item.webkitGetAsEntry?.(); if (entry?.isDirectory) { const dirFiles = await readDirectoryEntry( /** @type {FileSystemDirectoryEntry} */ (entry), ); files.push(...dirFiles.filter((file) => isAudioFile(file.name))); } else { // `getAsFile()` can return `null` on Firefox for OS-dragged files, so // a `File` arriving here is best-effort; direct files already came via // `dataTransfer.files`. Only add when it's usable. const file = item.getAsFile(); if (file && isAudioFile(file.name)) files.push(file); } }), );
return files;}
/** * @param {FileSystemDirectoryEntry} dir * @returns {Promise<File[]>} */async function readDirectoryEntry(dir) { const reader = dir.createReader();
return new Promise((resolve, reject) => { /** @type {File[]} */ const files = [];
const readBatch = () => { reader.readEntries(async (entries) => { if (entries.length === 0) { resolve(files); return; }
await Promise.all( entries.map(async (entry) => { if (entry.isDirectory) { const nested = await readDirectoryEntry( /** @type {FileSystemDirectoryEntry} */ (entry), ); files.push(...nested); } else { const file = await new Promise( /** @param {(f: File) => void} res */ (res, rej) => /** @type {FileSystemFileEntry} */ (entry).file(res, rej), ); if (file.type.startsWith("audio/")) files.push(file); } }), );
readBatch(); }, reject); };
readBatch(); });}
////////////////////////////////////////////// 🚀////////////////////////////////////////////
foundation.ready();