import * 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`
Add local directories or files as audio input.
`,
rightContent: html`
${supported
? html`
`
: 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}
*/
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}
*/
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();