chore: import upstream snapshot with attribution
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
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
This commit is contained in:
@@ -0,0 +1,443 @@
|
||||
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();
|
||||
Reference in New Issue
Block a user