b1acc5b5b3
* Add German, Portuguese, and Indonesian translations; fix i18n coverage gaps Extends the existing English/Polish/Japanese/Simplified Chinese i18n system to seven languages total. Also fixes several pre-existing i18n coverage gaps found while auditing: the recent-tracks list, search placeholder, and trash empty-state were hardcoding English text instead of using the translation system; a presence-panel legend lacked data-i18n attributes; upload/job/ playlist error toasts were untranslated; and library list content did not refresh on a live language switch. Widened the settings dropdown to fit the longest new language name and the device-select to stop truncating longer translated values. Bumps the Unraid template pin to 0.13.0. * Remove unused plural import in job.js Flagged in PR review: job.js imports plural from i18n.js but never calls it, only t(). * Native-speaker QA pass on all translations Fixes real mistranslations (German "schleifen" for loop, "Skala" for musical scale, Indonesian countdown/count-in mixup, Chinese Alpha badge), grammar bugs (Polish aria-labels requiring an unavailable grammatical case, singular/plural adjective agreement in playlist skip messages), inconsistent terminology within each language, and a stray three-dot ellipsis instead of the single character used everywhere else. Converts playlist.skip.* from t() to plural() with proper singular/plural forms across all seven languages, since Portuguese and Polish adjectives don't inflect correctly as flat strings. --------- Co-authored-by: Thales <>
634 lines
25 KiB
JavaScript
634 lines
25 KiB
JavaScript
import {
|
|
playBtn, loopBtn, multitrack, totalDuration, loopEnabled, loopStart, loopEnd,
|
|
setLoopStart, setLoopEnd, selectedStems, saveSelectedStems, stemSelectionReady,
|
|
currentJobId, vocalSplitMode, vocalSplitModeReady, setVocalSplitMode,
|
|
} from "./state.js";
|
|
import { STEM_NAMES, syncStemNamesFromAPI } from "./constants.js";
|
|
import { renderEmptyShell, buildStripStems, downloadCurrentMix, downloadCurrentVideo, downloadAllStemsZip, downloadRegionMix, drawFooterPlaceholder } from "./player.js";
|
|
import { wireJobForm, showError } from "./job.js";
|
|
import { wireTransportButtons } from "./transport.js";
|
|
import { wireBeatGridUi } from "./beatgridUi.js";
|
|
import { togglePlayPause, updateLoopRegionVisual, toggleMetronome } from "./transport.js";
|
|
import { wireStemListControls, wireMixerToolbar } from "./mixer.js";
|
|
import { initCatalog, collectDiagnostics } from "./catalog.js";
|
|
import { initNotifications, notifyFailure, dismissFailuresByJobId } from "./notifications.js";
|
|
import { runStoreMigrationIfNeeded } from "./utils.js";
|
|
import { initI18n, applyTranslations, t, plural, onLanguageChange } from "./i18n.js";
|
|
|
|
// ─── Stem choice toggles on the import page ───
|
|
//
|
|
// Filter-chip semantics (Spotify-style). The natural mental model when
|
|
// a user sees all 6 stems lit up is "everything is extracted"; when
|
|
// they then click ONE chip, they expect "now only this one". A plain
|
|
// toggle inverts the clicked chip and leaves the others on, which
|
|
// reads as "I just deselected the one I wanted" -- exactly the user
|
|
// confusion that prompted this fix.
|
|
//
|
|
// Algorithm:
|
|
// - "All selected" is the implicit default (no filter applied).
|
|
// - First click on a chip while in default state switches to
|
|
// "only this stem" (clears all others).
|
|
// - Subsequent clicks on inactive chips ADD them to the filter.
|
|
// - Clicks on the only-selected chip clear it; if that empties the
|
|
// selection, we revert to "all selected" (wraparound).
|
|
//
|
|
// Persisted across reloads so the next song honors the user's last
|
|
// chosen subset, but a 0-selection state is normalized to all 6.
|
|
function refreshStemChoiceVisuals() {
|
|
for (const btn of document.querySelectorAll(".stem-choice[data-stem]")) {
|
|
btn.setAttribute(
|
|
"aria-pressed",
|
|
String(selectedStems.has(btn.dataset.stem)),
|
|
);
|
|
}
|
|
}
|
|
|
|
// ─── Vocals: All / Lead + Backing toggle (on-demand split, #275) ───
|
|
//
|
|
// Only meaningful while Vocals is actually selected above -- hidden
|
|
// otherwise so it can't imply a choice that has nothing to act on.
|
|
|
|
function refreshVocalModeToggleVisibility() {
|
|
document.getElementById("vocalModeToggle")?.classList.toggle("hidden", !selectedStems.has("vocals"));
|
|
}
|
|
|
|
function wireVocalModeToggle() {
|
|
const wrap = document.getElementById("vocalModeToggle");
|
|
if (!wrap) return;
|
|
for (const btn of wrap.querySelectorAll(".vocal-mode-btn")) {
|
|
btn.addEventListener("click", () => {
|
|
setVocalSplitMode(btn.dataset.mode);
|
|
for (const b of wrap.querySelectorAll(".vocal-mode-btn")) {
|
|
b.setAttribute("aria-pressed", String(b.dataset.mode === btn.dataset.mode));
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
function handleStemChoiceClick(stem) {
|
|
const allSelected = selectedStems.size === STEM_NAMES.length;
|
|
if (allSelected) {
|
|
// Default state -> switch to "only this stem".
|
|
selectedStems.clear();
|
|
selectedStems.add(stem);
|
|
} else if (selectedStems.has(stem)) {
|
|
selectedStems.delete(stem);
|
|
if (selectedStems.size === 0) {
|
|
// Empty out wraps back to "all" so the user is never stuck.
|
|
for (const n of STEM_NAMES) selectedStems.add(n);
|
|
}
|
|
} else {
|
|
selectedStems.add(stem);
|
|
}
|
|
saveSelectedStems();
|
|
refreshStemChoiceVisuals();
|
|
refreshVocalModeToggleVisibility();
|
|
buildStripStems();
|
|
}
|
|
|
|
function wireStemChoiceButtons() {
|
|
refreshStemChoiceVisuals();
|
|
refreshVocalModeToggleVisibility();
|
|
for (const btn of document.querySelectorAll(".stem-choice[data-stem]")) {
|
|
btn.addEventListener("click", () => handleStemChoiceClick(btn.dataset.stem));
|
|
}
|
|
}
|
|
|
|
function wireAllButton() {
|
|
const allBtn = document.getElementById("stemAllBtn");
|
|
if (!allBtn) return;
|
|
|
|
function syncAllBtn() {
|
|
allBtn.setAttribute("aria-pressed", String(selectedStems.size === STEM_NAMES.length));
|
|
}
|
|
|
|
allBtn.addEventListener("click", () => {
|
|
const allSelected = selectedStems.size === STEM_NAMES.length;
|
|
if (allSelected) {
|
|
selectedStems.clear();
|
|
} else {
|
|
for (const n of STEM_NAMES) selectedStems.add(n);
|
|
}
|
|
saveSelectedStems();
|
|
refreshStemChoiceVisuals();
|
|
refreshVocalModeToggleVisibility();
|
|
buildStripStems();
|
|
syncAllBtn();
|
|
});
|
|
|
|
/* Keep All in sync when individual stems are toggled */
|
|
for (const btn of document.querySelectorAll(".stem-choice[data-stem]")) {
|
|
btn.addEventListener("click", syncAllBtn);
|
|
}
|
|
|
|
syncAllBtn();
|
|
}
|
|
|
|
// ─── Wire everything up ───
|
|
|
|
// Applied as early as possible in module execution, ahead of every other
|
|
// top-level call below, to minimize the flash of English before the DOM
|
|
// gets its real language (unavoidable without server-side rendering, since
|
|
// static/index.html always ships pre-rendered in English).
|
|
const i18nReady = initI18n().then(() => applyTranslations(document));
|
|
|
|
syncStemNamesFromAPI().then(() => buildStripStems());
|
|
wireJobForm();
|
|
wireTransportButtons();
|
|
wireBeatGridUi();
|
|
wireFooterControls();
|
|
requestAnimationFrame(drawFooterPlaceholder);
|
|
wireStemListControls();
|
|
wireMixerToolbar();
|
|
wireStemChoiceButtons();
|
|
wireAllButton();
|
|
wireVocalModeToggle();
|
|
wireFileDrop();
|
|
wireAppShellControls();
|
|
|
|
(async () => {
|
|
await i18nReady;
|
|
// Waits for the language to be resolved: the empty shell's stem labels
|
|
// (STEM_DISPLAY) are a one-time textContent snapshot, not a live binding,
|
|
// so rendering it before i18n is ready would freeze it in English
|
|
// regardless of the stored language preference.
|
|
renderEmptyShell();
|
|
await runStoreMigrationIfNeeded();
|
|
await stemSelectionReady;
|
|
refreshStemChoiceVisuals();
|
|
refreshVocalModeToggleVisibility();
|
|
await vocalSplitModeReady;
|
|
for (const b of document.querySelectorAll(".vocal-mode-btn")) {
|
|
b.setAttribute("aria-pressed", String(b.dataset.mode === vocalSplitMode));
|
|
}
|
|
// Before initCatalog: it runs the update check, which can itself notify.
|
|
// collectDiagnostics is injected rather than imported by notifications.js,
|
|
// which would make the two modules import each other.
|
|
await initNotifications({ diagnostics: collectDiagnostics });
|
|
await initCatalog();
|
|
})().catch(console.error);
|
|
|
|
// ─── Footer: speed dropdown, export dropdown, scrub seek ───
|
|
|
|
function wireFooterControls() {
|
|
// ── Export split-button dropdown ──
|
|
// The full button toggles the export menu. Export actions live inside
|
|
// the dropdown so the hit target is predictable.
|
|
// The menu offers Mix / All Stems / Current Region, with a WAV/MP3 toggle in
|
|
// the header. All exports reuse the backend-served download helpers.
|
|
const exportBtn = document.getElementById("t-export-btn");
|
|
const exportPanel = document.getElementById("t-export-panel");
|
|
const exportLabel = document.getElementById("t-export-label");
|
|
const fmtWav = document.getElementById("t-fmt-wav");
|
|
const fmtMp3 = document.getElementById("t-fmt-mp3");
|
|
const fmtFlac = document.getElementById("t-fmt-flac");
|
|
const fmtOgg = document.getElementById("t-fmt-ogg");
|
|
const fmtMp4 = document.getElementById("t-fmt-mp4");
|
|
const exportWrap = document.getElementById("footer-export-wrap");
|
|
const itemMix = document.getElementById("t-export-mix");
|
|
const itemStems = document.getElementById("t-export-stems");
|
|
const itemRegion = document.getElementById("t-export-region");
|
|
const mixDescEl = itemMix?.querySelector(".chip-item-desc");
|
|
// Only the rows actually visible in the current format mode (MP4 hides the
|
|
// audio-only Stems/Region rows).
|
|
const actionItems = () =>
|
|
[itemMix, itemStems, itemRegion].filter((it) => it && it.offsetParent !== null);
|
|
|
|
// MP4 is a format choice, shown only for jobs with a preserved video track.
|
|
// It applies to the mix only — stems/region are audio-only.
|
|
const videoAvailable = () => !!exportWrap?.classList.contains("has-video");
|
|
|
|
let format = "wav";
|
|
let busy = false;
|
|
// True from the click until the transfer starts or the dialog is cancelled.
|
|
let picking = false;
|
|
|
|
const panelOpen = () => exportPanel && !exportPanel.classList.contains("hidden");
|
|
function openPanel() {
|
|
closeAllChipPanels();
|
|
// A previous (video) job may have left MP4 selected; revert if unavailable now.
|
|
if (format === "mp4" && !videoAvailable()) setFormat("wav");
|
|
exportPanel?.classList.remove("hidden");
|
|
exportBtn?.setAttribute("aria-expanded", "true");
|
|
}
|
|
function closePanel() {
|
|
exportPanel?.classList.add("hidden");
|
|
exportBtn?.setAttribute("aria-expanded", "false");
|
|
}
|
|
|
|
function setFormat(f) {
|
|
format = f;
|
|
for (const [btn, val] of [[fmtWav, "wav"], [fmtMp3, "mp3"], [fmtFlac, "flac"], [fmtOgg, "ogg"], [fmtMp4, "mp4"]]) {
|
|
btn?.classList.toggle("active", f === val);
|
|
btn?.setAttribute("aria-checked", String(f === val));
|
|
}
|
|
applyFormatState();
|
|
}
|
|
fmtWav?.addEventListener("click", (e) => { e.stopPropagation(); setFormat("wav"); });
|
|
fmtMp3?.addEventListener("click", (e) => { e.stopPropagation(); setFormat("mp3"); });
|
|
fmtFlac?.addEventListener("click", (e) => { e.stopPropagation(); setFormat("flac"); });
|
|
fmtOgg?.addEventListener("click", (e) => { e.stopPropagation(); setFormat("ogg"); });
|
|
fmtMp4?.addEventListener("click", (e) => { e.stopPropagation(); setFormat("mp4"); });
|
|
|
|
// MP4 exports the mix muxed with the source video. Stems and region have no
|
|
// video equivalent, so they're hidden (via .fmt-mp4) while MP4 is selected,
|
|
// leaving just "Export Mix".
|
|
function applyFormatState() {
|
|
const video = format === "mp4";
|
|
exportPanel?.classList.toggle("fmt-mp4", video);
|
|
if (mixDescEl) {
|
|
mixDescEl.textContent = video ? t("export.mixDescVideo") : t("export.mixDesc");
|
|
}
|
|
if (!video) updateLoopRegionVisual(); // restores the region item's disabled state
|
|
}
|
|
// mixDescEl's text depends on `format`, not just the current language, so a
|
|
// language switch needs to re-derive it rather than rely solely on the
|
|
// generic data-i18n pass (which would otherwise reset it to the non-video
|
|
// wording even while MP4 is selected).
|
|
onLanguageChange(applyFormatState);
|
|
|
|
function resetBusy() {
|
|
busy = false;
|
|
exportBtn?.classList.remove("is-busy");
|
|
if (exportLabel) exportLabel.textContent = t("export.mix");
|
|
// Clear every row, not just the ones enterBusy could see: it disables via
|
|
// actionItems(), which filters on visibility, and it closes the panel, so
|
|
// by the time this runs every row is hidden and a visibility-filtered clear
|
|
// would clear nothing at all -- leaving the menu dead for the rest of the
|
|
// session (#335).
|
|
for (const it of [itemMix, itemStems, itemRegion]) it?.removeAttribute("aria-disabled");
|
|
applyFormatState(); // re-derives the region row's genuine disabled state
|
|
}
|
|
|
|
// How long to hold the indeterminate state when the host reports nothing back.
|
|
const EXPORT_FLASH_MS = 1200;
|
|
// Safety net for the promise path: a pending invoke that somehow never settles
|
|
// must not leave the menu disabled for the rest of the session (#335).
|
|
const EXPORT_BUSY_MAX_MS = 15 * 60 * 1000;
|
|
// Guards against a stale timer from a finished export resetting a later one.
|
|
let busyToken = 0;
|
|
|
|
// Show the busy state. Called when bytes actually start moving, which on
|
|
// desktop is after the user has picked a destination -- not on click, or the
|
|
// label would claim to be exporting for as long as the save dialog sat open
|
|
// (#338).
|
|
function enterBusy() {
|
|
busy = true;
|
|
exportBtn?.classList.add("is-busy");
|
|
if (exportLabel) exportLabel.textContent = t("export.mixing");
|
|
actionItems().forEach((it) => it?.setAttribute("aria-disabled", "true"));
|
|
closePanel();
|
|
}
|
|
// Same reasoning as applyFormatState's listener above: exportLabel's text
|
|
// depends on `busy`, so re-derive it after a language switch instead of
|
|
// leaving it on whatever the generic data-i18n pass reset it to.
|
|
onLanguageChange(() => {
|
|
if (exportLabel) exportLabel.textContent = t(busy ? "export.mixing" : "export.mix");
|
|
});
|
|
|
|
// `pending` is whatever the download helper returned: a promise on desktop,
|
|
// resolving once the file is written, or `true` in a browser, where an
|
|
// <a download> is fire-and-forget and there is nothing to wait on. Only the
|
|
// guess needs a fixed duration.
|
|
//
|
|
// `picking` covers the gap between the click and the transfer: the dialog is
|
|
// app-modal so the menu is unreachable anyway, but the flag keeps a second
|
|
// export from being queued behind it without lying about the label.
|
|
// jobId is snapshotted by the caller at click time, not read live here:
|
|
// settlement can take up to EXPORT_BUSY_MAX_MS, by which point the user may
|
|
// have opened a different track, and currentJobId would then point at the
|
|
// wrong one (#401).
|
|
function settleBusy(pending, jobId) {
|
|
const token = ++busyToken;
|
|
const finish = () => {
|
|
picking = false;
|
|
if (token === busyToken) resetBusy();
|
|
};
|
|
|
|
if (!pending || typeof pending.then !== "function") {
|
|
window.setTimeout(finish, EXPORT_FLASH_MS);
|
|
return;
|
|
}
|
|
const backstop = window.setTimeout(finish, EXPORT_BUSY_MAX_MS);
|
|
pending
|
|
.then((ok) => {
|
|
// ok === false means the save dialog was cancelled, not a real
|
|
// export — nothing was resolved, so leave any failure notification
|
|
// in place rather than clearing it on a no-op.
|
|
if (jobId && ok !== false) dismissFailuresByJobId(jobId, "export");
|
|
})
|
|
.catch((err) => {
|
|
// A cancelled dialog resolves false without ever entering the busy
|
|
// state, so anything here is a real failure.
|
|
const message = typeof err === "string" && err ? err : t("export.failed");
|
|
showError(message, null, { retry: false });
|
|
notifyFailure({
|
|
kind: "export",
|
|
message,
|
|
detail: err instanceof Error ? String(err.message) : null,
|
|
context: { stage: `Exporting ${format}`, jobId },
|
|
});
|
|
})
|
|
.finally(() => {
|
|
window.clearTimeout(backstop);
|
|
finish();
|
|
});
|
|
}
|
|
|
|
// Kick off an export: hand the helper a callback that flips the UI into its
|
|
// busy state, then wait on the result.
|
|
function runExport(start, emptyMessage) {
|
|
if (busy || picking) return;
|
|
picking = true;
|
|
const jobId = currentJobId; // snapshot now -- see settleBusy's comment
|
|
const pending = start(enterBusy);
|
|
if (!pending) {
|
|
picking = false;
|
|
showError(emptyMessage, null, { retry: false });
|
|
return;
|
|
}
|
|
settleBusy(pending, jobId);
|
|
}
|
|
|
|
exportBtn?.addEventListener("click", (e) => {
|
|
e.stopPropagation();
|
|
if (busy || picking) return;
|
|
panelOpen() ? closePanel() : openPanel();
|
|
});
|
|
|
|
// Export Mix: MP4 produces the video; any other format an audio mix.
|
|
itemMix?.addEventListener("click", (e) => {
|
|
e.stopPropagation();
|
|
runExport(
|
|
(onStart) => (format === "mp4" ? downloadCurrentVideo(onStart) : downloadCurrentMix(format, onStart)),
|
|
t("export.allMuted"),
|
|
);
|
|
});
|
|
|
|
itemRegion?.addEventListener("click", (e) => {
|
|
e.stopPropagation();
|
|
if (itemRegion.getAttribute("aria-disabled") === "true") return;
|
|
runExport((onStart) => downloadRegionMix(format, onStart), t("export.allMuted"));
|
|
});
|
|
|
|
// All Stems = a single backend-built ZIP, named after the song. Audio-only,
|
|
// so it's disabled (and inert) while MP4 is the selected format.
|
|
itemStems?.addEventListener("click", (e) => {
|
|
e.stopPropagation();
|
|
if (itemStems.getAttribute("aria-disabled") === "true") return;
|
|
runExport((onStart) => downloadAllStemsZip(format, onStart), t("export.noStems"));
|
|
});
|
|
|
|
// Keyboard: ↓ opens/moves into the menu, ↑/↓ cycle rows, Esc closes + restores focus.
|
|
exportBtn?.addEventListener("keydown", (e) => {
|
|
if (e.key === "ArrowDown") {
|
|
e.preventDefault();
|
|
if (!panelOpen()) openPanel();
|
|
actionItems().find((it) => it?.getAttribute("aria-disabled") !== "true")?.focus();
|
|
}
|
|
});
|
|
exportPanel?.addEventListener("keydown", (e) => {
|
|
const focusable = actionItems().filter((it) => it && it.getAttribute("aria-disabled") !== "true");
|
|
const idx = focusable.indexOf(document.activeElement);
|
|
if (e.key === "Escape") { closePanel(); exportBtn?.focus(); }
|
|
else if (e.key === "ArrowDown") { e.preventDefault(); focusable[(idx + 1) % focusable.length]?.focus(); }
|
|
else if (e.key === "ArrowUp") { e.preventDefault(); focusable[(idx - 1 + focusable.length) % focusable.length]?.focus(); }
|
|
});
|
|
|
|
// ── Scrub bar seek ──
|
|
const scrub = document.getElementById("footer-scrub");
|
|
if (scrub) {
|
|
function seekToX(clientX) {
|
|
if (!multitrack || !totalDuration) return;
|
|
const rect = scrub.getBoundingClientRect();
|
|
const frac = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
|
|
multitrack.setTime(frac * totalDuration);
|
|
}
|
|
let _scrubbing = false;
|
|
scrub.addEventListener("mousedown", (e) => {
|
|
_scrubbing = true;
|
|
seekToX(e.clientX);
|
|
});
|
|
document.addEventListener("mousemove", (e) => { if (_scrubbing) seekToX(e.clientX); });
|
|
document.addEventListener("mouseup", () => { _scrubbing = false; });
|
|
}
|
|
|
|
// ── Close panels on outside click ──
|
|
// Inside the menu is not "away": ticking an option must not dismiss it. The
|
|
// export panel carries two checkboxes (click track, count-in) that a user
|
|
// may well want both of, and without this the first tick closed the menu and
|
|
// the second needed it reopened. Rows that *should* close the menu do it
|
|
// themselves -- the export actions via enterBusy() -> closePanel().
|
|
exportPanel?.addEventListener("click", (e) => e.stopPropagation());
|
|
document.addEventListener("click", closeAllChipPanels);
|
|
}
|
|
|
|
function closeAllChipPanels() {
|
|
document.querySelectorAll(".footer-chip-panel:not(.hidden)").forEach((p) => {
|
|
p.classList.add("hidden");
|
|
p.previousElementSibling?.setAttribute("aria-expanded", "false");
|
|
});
|
|
}
|
|
|
|
// ─── File drop on URL input ───
|
|
|
|
function wireFileDrop() {
|
|
const urlWrap = document.querySelector(".url-wrap");
|
|
const urlInput = document.getElementById("url");
|
|
const fileInput = document.getElementById("fileInput");
|
|
const filePill = document.getElementById("filePill");
|
|
const fileName = document.getElementById("fileName");
|
|
const fileSize = document.getElementById("fileSize");
|
|
const fileClear = document.getElementById("fileClear");
|
|
if (!urlWrap || !urlInput || !fileInput || !filePill) return;
|
|
|
|
function formatBytes(n) {
|
|
return n < 1024 * 1024 ? `${(n / 1024).toFixed(0)} KB` : `${(n / 1024 / 1024).toFixed(1)} MB`;
|
|
}
|
|
|
|
const MAX_UPLOAD_BYTES = 400 * 1024 * 1024; // must match server _MAX_UPLOAD_BYTES
|
|
|
|
const AUDIO_EXTS = [".mp3", ".wav", ".flac", ".mp4", ".m4a", ".ogg", ".opus"];
|
|
const isAudioFile = (file) => AUDIO_EXTS.some((ext) => file.name.toLowerCase().endsWith(ext));
|
|
|
|
function applyFiles(fileList) {
|
|
const all = [...(fileList || [])];
|
|
if (!all.length) return;
|
|
|
|
// Filter here rather than letting the server reject each one: dropping a
|
|
// folder, or a folder of mixed content, would otherwise mean one 422 per
|
|
// stray file. Only complain if nothing usable came through.
|
|
const audio = all.filter(isAudioFile);
|
|
if (!audio.length) {
|
|
showError(t("upload.unsupportedFormat"));
|
|
return;
|
|
}
|
|
const files = audio.filter((f) => f.size <= MAX_UPLOAD_BYTES);
|
|
const oversized = audio.length - files.length;
|
|
if (!files.length) {
|
|
showError(t("upload.fileTooLarge", { size: formatBytes(audio[0].size), max: formatBytes(MAX_UPLOAD_BYTES) }));
|
|
return;
|
|
}
|
|
|
|
const skipped = all.length - files.length;
|
|
if (fileName) {
|
|
fileName.textContent =
|
|
files.length === 1 ? files[0].name : `${files.length} files`;
|
|
}
|
|
if (fileSize) {
|
|
const bytes = files.reduce((sum, f) => sum + f.size, 0);
|
|
fileSize.textContent = formatBytes(bytes);
|
|
}
|
|
filePill.classList.remove("hidden");
|
|
urlWrap.classList.add("has-file");
|
|
// Cache the File objects directly on the element so job.js can always
|
|
// retrieve them even after the browser clears fileInput.files following
|
|
// a fetch() submission (known WKWebView / Chromium behaviour). _file stays
|
|
// as the first one so any older single-file reader keeps working.
|
|
fileInput._files = files;
|
|
fileInput._file = files[0];
|
|
const dt = new DataTransfer();
|
|
for (const f of files) dt.items.add(f);
|
|
fileInput.files = dt.files;
|
|
urlInput.value = "";
|
|
urlInput.removeAttribute("required");
|
|
|
|
if (skipped > 0) {
|
|
const reason = t(oversized > 0 ? "upload.reasonTooLarge" : "upload.reasonNotAudio");
|
|
showError(plural("upload.skippedFiles", skipped, { reason }), null, {
|
|
retry: false,
|
|
});
|
|
}
|
|
}
|
|
|
|
function clearFile() {
|
|
filePill.classList.add("hidden");
|
|
urlWrap.classList.remove("has-file");
|
|
fileInput._file = null;
|
|
fileInput._files = null;
|
|
fileInput.value = "";
|
|
urlInput.setAttribute("required", "");
|
|
}
|
|
|
|
fileClear?.addEventListener("click", clearFile);
|
|
// Exposed on the element, same convention as _file above, so job.js can drop
|
|
// the selection once the upload has been handed to the server. Without it the
|
|
// chip stays armed and the (now immediately re-enabled) Process button will
|
|
// happily import the same file twice.
|
|
fileInput._clear = clearFile;
|
|
|
|
urlWrap.addEventListener("dragover", (e) => {
|
|
if (!e.dataTransfer.types.includes("Files")) return;
|
|
e.preventDefault();
|
|
e.dataTransfer.dropEffect = "copy";
|
|
urlWrap.classList.add("drag-over");
|
|
});
|
|
urlWrap.addEventListener("dragleave", (e) => {
|
|
if (!urlWrap.contains(e.relatedTarget)) urlWrap.classList.remove("drag-over");
|
|
});
|
|
urlWrap.addEventListener("drop", (e) => {
|
|
e.preventDefault();
|
|
urlWrap.classList.remove("drag-over");
|
|
applyFiles(e.dataTransfer.files);
|
|
});
|
|
|
|
fileInput.addEventListener("change", () => {
|
|
applyFiles(fileInput.files);
|
|
});
|
|
}
|
|
|
|
// ─── App shell controls ───
|
|
|
|
function wireAppShellControls() {
|
|
document.getElementById("appMenuBtn")?.addEventListener("click", (e) => {
|
|
e.stopPropagation();
|
|
const app = document.querySelector(".app");
|
|
// In trash view: switch back to library.
|
|
if (document.querySelector(".sidebar.trash-view, .sidebar.favorites-view")) {
|
|
document.querySelector(".rail-library")?.click();
|
|
}
|
|
// If collapsed: open. Never collapse from the library button.
|
|
if (app?.classList.contains("cat-collapsed")) {
|
|
document.getElementById("catalogToggle")?.click();
|
|
}
|
|
});
|
|
|
|
}
|
|
|
|
// ─── Keyboard shortcuts ───
|
|
|
|
document.addEventListener("keydown", (e) => {
|
|
if (!multitrack) return;
|
|
if (e.target instanceof HTMLInputElement) return;
|
|
if (e.code === "Space") {
|
|
e.preventDefault();
|
|
togglePlayPause();
|
|
} else if (e.code === "BracketLeft") {
|
|
e.preventDefault();
|
|
multitrack.setTime(Math.max(0, multitrack.getCurrentTime() - 5));
|
|
} else if (e.code === "BracketRight") {
|
|
e.preventDefault();
|
|
multitrack.setTime(
|
|
Math.min(multitrack.getDuration(), multitrack.getCurrentTime() + 5),
|
|
);
|
|
} else if (e.code === "KeyL") {
|
|
e.preventDefault();
|
|
loopBtn.click();
|
|
} else if (e.code === "KeyK") {
|
|
e.preventDefault();
|
|
toggleMetronome();
|
|
} else if (e.code === "KeyI" && loopEnabled && multitrack) {
|
|
e.preventDefault();
|
|
setLoopStart(Math.min(multitrack.getCurrentTime(), loopEnd - 0.5));
|
|
updateLoopRegionVisual();
|
|
} else if (e.code === "KeyO" && loopEnabled && multitrack) {
|
|
e.preventDefault();
|
|
setLoopEnd(Math.max(multitrack.getCurrentTime(), loopStart + 0.5));
|
|
updateLoopRegionVisual();
|
|
}
|
|
});
|
|
|
|
// ─── External links ───
|
|
|
|
document.addEventListener("click", (e) => {
|
|
const dl = e.target.closest("a.lane-dl");
|
|
if (dl?.href) {
|
|
const invoke = window.__TAURI__?.core?.invoke;
|
|
// A download attribute is meaningless to the OS handler, so open_url used to
|
|
// hand the stem to a browser or media player instead of saving it. Save it
|
|
// like every other export, which also honours the song-prefixed name (#336).
|
|
// Placeholder rows for absent stems keep href="#" and carry no name.
|
|
if (invoke && dl.download && !dl.getAttribute("href").endsWith("#")) {
|
|
e.preventDefault();
|
|
invoke("save_audio_file", { url: dl.href, filename: dl.download });
|
|
return;
|
|
}
|
|
if (invoke) {
|
|
e.preventDefault();
|
|
invoke("open_url", { url: dl.href });
|
|
}
|
|
return;
|
|
}
|
|
const anchor = e.target.closest('a[target="_blank"]');
|
|
if (anchor?.href) {
|
|
const openUrl = window.__TAURI__?.core?.invoke;
|
|
if (openUrl) {
|
|
e.preventDefault();
|
|
openUrl("open_url", { url: anchor.href });
|
|
}
|
|
}
|
|
});
|
|
|
|
// ─── Global error logging ───
|
|
|
|
window.addEventListener("error", (e) => {
|
|
console.error("[app:error]", e.message, "\n", e.filename, ":", e.lineno, "\n", e.error?.stack ?? "");
|
|
});
|
|
window.addEventListener("unhandledrejection", (e) => {
|
|
console.error("[app:unhandledrejection]", e.reason?.message ?? e.reason, "\n", e.reason?.stack ?? "");
|
|
});
|
|
|
|
// ─── Bootstrap ───
|
|
|
|
buildStripStems();
|