8f10353f0c
CI / lint (push) Has been cancelled
CI / js-syntax (push) Successful in 10m24s
CI / deps-audit (push) Has been cancelled
CI / test (push) Failing after 24m55s
CI / sast-bandit (push) Failing after 11m13s
CI / trivy (push) Failing after 9m32s
Docker Publish / build-and-push (push) Failing after 34m3s
444 lines
16 KiB
JavaScript
444 lines
16 KiB
JavaScript
import {
|
|
playBtn, loopBtn, multitrack, totalDuration, loopEnabled, loopStart, loopEnd,
|
|
setLoopStart, setLoopEnd, selectedStems, saveSelectedStems, stemSelectionReady,
|
|
} 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 { togglePlayPause, updateLoopRegionVisual } from "./transport.js";
|
|
import { wireStemListControls, wireMixerToolbar } from "./mixer.js";
|
|
import { initCatalog } from "./catalog.js";
|
|
import { runStoreMigrationIfNeeded } from "./utils.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)),
|
|
);
|
|
}
|
|
}
|
|
|
|
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();
|
|
buildStripStems();
|
|
}
|
|
|
|
function wireStemChoiceButtons() {
|
|
refreshStemChoiceVisuals();
|
|
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();
|
|
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 ───
|
|
|
|
syncStemNamesFromAPI().then(() => buildStripStems());
|
|
wireJobForm();
|
|
wireTransportButtons();
|
|
wireFooterControls();
|
|
requestAnimationFrame(drawFooterPlaceholder);
|
|
wireStemListControls();
|
|
wireMixerToolbar();
|
|
wireStemChoiceButtons();
|
|
wireAllButton();
|
|
wireFileDrop();
|
|
wireAppShellControls();
|
|
|
|
(async () => {
|
|
await runStoreMigrationIfNeeded();
|
|
await stemSelectionReady;
|
|
refreshStemChoiceVisuals();
|
|
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 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;
|
|
|
|
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"], [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"); });
|
|
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 ? "Export mix with the original video" : "Export the mixed audio";
|
|
}
|
|
if (!video) updateLoopRegionVisual(); // restores the region item's disabled state
|
|
}
|
|
|
|
function resetBusy() {
|
|
busy = false;
|
|
exportBtn?.classList.remove("is-busy");
|
|
if (exportLabel) exportLabel.textContent = "Export Mix";
|
|
itemMix?.removeAttribute("aria-disabled");
|
|
applyFormatState(); // restores stems/region per the active format
|
|
}
|
|
|
|
// Single-file (mix/region) downloads give no JS-observable byte progress, so
|
|
// show a brief indeterminate "Exporting…" state, then reset.
|
|
function flashBusy() {
|
|
busy = true;
|
|
exportBtn?.classList.add("is-busy");
|
|
if (exportLabel) exportLabel.textContent = "Exporting…";
|
|
actionItems().forEach((it) => it?.setAttribute("aria-disabled", "true"));
|
|
closePanel();
|
|
window.setTimeout(resetBusy, 1200);
|
|
}
|
|
|
|
exportBtn?.addEventListener("click", (e) => {
|
|
e.stopPropagation();
|
|
if (busy) return;
|
|
panelOpen() ? closePanel() : openPanel();
|
|
});
|
|
|
|
// Export Mix: MP4 produces the video; any other format an audio mix.
|
|
itemMix?.addEventListener("click", (e) => {
|
|
e.stopPropagation();
|
|
if (busy) return;
|
|
const ok = format === "mp4" ? downloadCurrentVideo() : downloadCurrentMix(format);
|
|
if (!ok) { showError("All stems are muted - nothing to export."); return; }
|
|
flashBusy();
|
|
});
|
|
|
|
itemRegion?.addEventListener("click", (e) => {
|
|
e.stopPropagation();
|
|
if (busy || itemRegion.getAttribute("aria-disabled") === "true") return;
|
|
const ok = downloadRegionMix(format);
|
|
if (!ok) { showError("All stems are muted - nothing to export."); return; }
|
|
flashBusy();
|
|
});
|
|
|
|
// 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 (busy || itemStems.getAttribute("aria-disabled") === "true") return;
|
|
downloadAllStemsZip(format);
|
|
flashBusy();
|
|
});
|
|
|
|
// 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 ──
|
|
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
|
|
|
|
function applyFile(file) {
|
|
if (!file) return;
|
|
const lower = file.name.toLowerCase();
|
|
if (!lower.endsWith(".mp3") && !lower.endsWith(".wav") && !lower.endsWith(".flac") &&
|
|
!lower.endsWith(".mp4") && !lower.endsWith(".m4a")) {
|
|
showError("Only MP3, WAV, FLAC, MP4, and M4A files are supported.");
|
|
return;
|
|
}
|
|
if (file.size > MAX_UPLOAD_BYTES) {
|
|
showError(`File is too large (${formatBytes(file.size)}). Maximum is 400 MB.`);
|
|
return;
|
|
}
|
|
if (fileName) fileName.textContent = file.name;
|
|
if (fileSize) fileSize.textContent = formatBytes(file.size);
|
|
filePill.classList.remove("hidden");
|
|
urlWrap.classList.add("has-file");
|
|
// Cache the File object directly on the element so job.js can always
|
|
// retrieve it even after the browser clears fileInput.files following
|
|
// a fetch() submission (known WKWebView / Chromium behaviour).
|
|
fileInput._file = file;
|
|
const dt = new DataTransfer();
|
|
dt.items.add(file);
|
|
fileInput.files = dt.files;
|
|
urlInput.value = "";
|
|
urlInput.removeAttribute("required");
|
|
}
|
|
|
|
function clearFile() {
|
|
filePill.classList.add("hidden");
|
|
urlWrap.classList.remove("has-file");
|
|
fileInput._file = null;
|
|
fileInput.value = "";
|
|
urlInput.setAttribute("required", "");
|
|
}
|
|
|
|
fileClear?.addEventListener("click", 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");
|
|
const file = e.dataTransfer.files[0];
|
|
if (file) applyFile(file);
|
|
});
|
|
|
|
fileInput.addEventListener("change", () => {
|
|
if (fileInput.files[0]) applyFile(fileInput.files[0]);
|
|
});
|
|
}
|
|
|
|
// ─── 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 === "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 openUrl = window.__TAURI__?.core?.invoke;
|
|
if (openUrl) {
|
|
e.preventDefault();
|
|
openUrl("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();
|
|
renderEmptyShell();
|