import { form, urlInput, submitBtn, errorEl, jobBox, jobTitleEl, jobStageEl, jobDetailEl, jobCancelBtn, progressEl, titleEl, bpmChip, keyChip, eventSource, setEventSource, setCurrentJobId, currentJobId, selectedStems, } from "./state.js"; import { destroyPlayer, wireUpAudio, setWaveformLoading, updateFooterTrack } from "./player.js"; import { stagePhrases } from "./phrases.js"; import { addTrackToLibrary, setCurrentTrack, updateTrackStatus, applyStemPresenceCards } from "./catalog.js"; import { initSections } from "./sections.js"; // Playful stage label rotation (Claude-Code-style flair). The backend // emits truthful stage strings; we surface them in the small #job-detail // line so progress is debuggable, while #job-stage rotates whimsy. const ROTATION_MS = 2500; let phraseTimerId = null; let lastStatus = null; let jobPollTimerId = null; const renderedJobs = new Set(); const jobSources = new Map(); const TERMINAL_STATUSES = new Set(["done", "error", "cancelled"]); function setSubmitProcessing(processing) { submitBtn.disabled = processing; submitBtn.classList.toggle("loading", processing); document.querySelector(".strip-sq-process")?.classList.toggle("loading", processing); const label = submitBtn.querySelector("span"); if (label) label.textContent = processing ? "Processing" : "Process"; } function pickPhrase(status) { const pool = stagePhrases[status] || stagePhrases.default; return pool[Math.floor(Math.random() * pool.length)]; } function setOverlayPhrase(text) { const el = document.getElementById("waveLoadingPhrase"); if (el) el.textContent = text; } function startPhraseRotation(status) { stopPhraseRotation(); const phrase = pickPhrase(status); jobStageEl.textContent = phrase; setOverlayPhrase(phrase); phraseTimerId = setInterval(() => { const p = pickPhrase(status); jobStageEl.textContent = p; setOverlayPhrase(p); }, ROTATION_MS); } function stopPhraseRotation() { if (phraseTimerId) { clearInterval(phraseTimerId); phraseTimerId = null; } jobStageEl.textContent = ""; } function stopJobPolling() { if (jobPollTimerId) { clearInterval(jobPollTimerId); jobPollTimerId = null; } } export function showError(message) { errorEl.textContent = ""; const msg = document.createElement("div"); msg.className = "error-msg"; msg.textContent = message; const retry = document.createElement("button"); retry.className = "retry-btn"; retry.type = "button"; retry.textContent = "Try again"; retry.addEventListener("click", () => { errorEl.classList.add("hidden"); urlInput.focus(); urlInput.select(); }); errorEl.append(msg, retry); errorEl.classList.remove("hidden"); } export function reset() { if (eventSource) { eventSource.close(); setEventSource(null); } stopJobPolling(); stopPhraseRotation(); lastStatus = null; destroyPlayer(); errorEl.classList.add("hidden"); errorEl.textContent = ""; jobBox.classList.add("hidden"); jobCancelBtn.classList.add("hidden"); jobTitleEl.textContent = ""; jobStageEl.textContent = ""; jobDetailEl.textContent = ""; progressEl.value = 0; setSubmitProcessing(false); setCurrentJobId(null); } function applyState(state) { if (state.job_id) { addTrackToLibrary({ id: state.job_id, title: state.title || urlInput.value || "Processing track", channel: state.status === "done" ? "Extracted" : "Processing", thumb: state.thumbnail, stems: state.selected_stems || state.stems?.map((stem) => stem.name) || [...selectedStems], selectedStems: state.selected_stems || [...selectedStems], audioStems: state.stems || [], status: state.status, duration: state.duration, bpm: state.bpm, key: state.key, scale: state.scale, keyConfidence: state.key_confidence, lufs: state.lufs, peakDb: state.peak_db, stemPresence: state.stem_presence, sourceUrl: jobSources.get(state.job_id) || urlInput.value, createdAt: state.created_at, }); setCurrentTrack(state.job_id); } if (state.title) { jobTitleEl.textContent = state.title; titleEl.textContent = state.title; } if (state.bpm) bpmChip.textContent = `${state.bpm} BPM`; if (state.key) keyChip.textContent = state.key; if (state.title || state.bpm || state.key || state.thumbnail) { updateFooterTrack({ title: state.title, thumbnail: state.thumbnail, key: state.key, bpm: state.bpm, stemCount: state.stems ? state.stems.filter((s) => s.name !== "original").length : null, }); } const summaryKey = document.getElementById("summary-key"); const summaryBpm = document.getElementById("summary-bpm"); const summaryScale = document.getElementById("summary-scale"); const summaryScaleName = document.getElementById("summary-scale-name"); const summaryConfidence = document.getElementById("summary-confidence"); const summaryConfidenceLabel = document.getElementById("summary-confidence-label"); const summaryLufs = document.getElementById("summary-lufs"); const summaryPeak = document.getElementById("summary-peak"); const summaryDuration = document.getElementById("summary-duration"); if (summaryKey && state.key) summaryKey.textContent = state.key; if (summaryBpm && state.bpm) summaryBpm.textContent = String(state.bpm); if (summaryScale && state.scale) summaryScale.textContent = state.scale; if (summaryScaleName && state.scale) summaryScaleName.textContent = state.scale; if (summaryLufs && state.lufs != null) summaryLufs.textContent = state.lufs.toFixed(1); if (summaryPeak && state.peak_db != null) summaryPeak.textContent = `Peak ${state.peak_db.toFixed(1)} dB`; if (summaryDuration && state.duration) { const m = Math.floor(state.duration / 60); const s = Math.floor(state.duration % 60).toString().padStart(2, "0"); summaryDuration.textContent = `${m.toString().padStart(2, "0")}:${s}`; } if (summaryConfidence && state.key_confidence != null) { const confidence = Math.max(0, Math.min(100, Number(state.key_confidence))); const confSpan = document.createElement("span"); confSpan.textContent = `${confidence}%`; summaryConfidence.textContent = ""; summaryConfidence.appendChild(confSpan); summaryConfidence.style.setProperty("--confidence-pct", confidence); summaryConfidence.classList.remove("hidden"); summaryConfidenceLabel?.classList.remove("hidden"); } const summaryDr = document.getElementById("summary-dr"); const summaryDrLabel = document.getElementById("summary-dr-label"); const summaryStability = document.getElementById("summary-stability"); const summaryStabilityLabel = document.getElementById("summary-stability-label"); if (summaryDr && state.dynamic_range != null) summaryDr.textContent = String(state.dynamic_range); if (summaryDrLabel && state.dynamic_range != null) { const dr = state.dynamic_range; summaryDrLabel.textContent = dr < 7 ? "Compressed" : dr < 10 ? "Moderate" : dr < 14 ? "High" : "Wide"; } if (summaryStability && state.tempo_stability != null) { summaryStability.textContent = `${state.tempo_stability}%`; summaryStability.className = "meta-card-value" + (state.tempo_stability >= 80 ? " stability-high" : ""); } if (summaryStabilityLabel && state.tempo_stability != null) { const s = state.tempo_stability; summaryStabilityLabel.textContent = s >= 90 ? "Very Stable" : s >= 70 ? "Stable" : s >= 50 ? "Moderate" : "Variable"; } if (state.stem_presence != null) { applyStemPresenceCards(state.stem_presence); } // Stage label is owned by the phrase-rotation timer below; we don't // overwrite it from each SSE tick. The truthful backend stage goes // to the small detail line instead. jobDetailEl.textContent = state.stage || ""; progressEl.value = Math.round((state.progress || 0) * 100); // Cancel button is visible exactly while the job is in a non-terminal state. const terminal = TERMINAL_STATUSES.has(state.status); jobCancelBtn.classList.toggle("hidden", terminal); if (state.status !== lastStatus) { if (terminal) stopPhraseRotation(); else startPhraseRotation(state.status); lastStatus = state.status; } if (state.status === "error") { stopJobPolling(); updateTrackStatus(state.job_id, "error"); setWaveformLoading(false); showError(state.error || "Unknown error"); setSubmitProcessing(false); } else if (state.status === "cancelled") { stopJobPolling(); updateTrackStatus(state.job_id, "cancelled"); setWaveformLoading(false); jobBox.classList.add("hidden"); setSubmitProcessing(false); } else if (state.status === "done") { stopJobPolling(); updateTrackStatus(state.job_id, "done"); jobBox.classList.add("hidden"); if (!renderedJobs.has(state.job_id)) { renderedJobs.add(state.job_id); wireUpAudio( state.job_id, state.stems || [], state.duration || 0, state.thumbnail, state.mix_url ?? null, state.title || "", null, state.has_video ?? false, ); initSections(state.job_id, state.sections, state.duration || 0); } setSubmitProcessing(false); } } async function probeJob(jobId) { const r = await fetch(`/api/jobs/${jobId}`); if (!r.ok) { if (r.status === 404) throw new Error("Job no longer exists on the server"); throw new Error(`Job probe failed: ${r.status}`); } const s = await r.json(); applyState(s); return s; } function startJobPolling(jobId) { stopJobPolling(); const tick = async () => { try { const s = await probeJob(jobId); if (TERMINAL_STATUSES.has(s.status)) stopJobPolling(); } catch (err) { console.warn("[job] REST fallback failed:", err); } }; tick(); jobPollTimerId = setInterval(tick, 1000); } // Connect (or reconnect) to the SSE stream for a job. On unexpected // disconnect we probe /api/jobs/{id} to decide: if the job is already // terminal, accept its final state; otherwise reconnect with backoff. // Falls back to REST polling only after SSE exhausts its retry budget. function connectEvents(jobId) { let attempt = 0; let stopped = false; const open = () => { const es = new EventSource(`/api/jobs/${jobId}/events`); setEventSource(es); es.onmessage = (ev) => { attempt = 0; // any successful frame resets backoff let s; try { s = JSON.parse(ev.data); } catch { return; } // Defer by one tick so synchronous user event handlers (clicks, // input events) always complete before SSE state is applied. setTimeout(() => { applyState(s); if (TERMINAL_STATUSES.has(s.status)) { stopped = true; es.close(); setEventSource(null); } }, 0); }; es.onerror = async () => { if (stopped) return; es.close(); setEventSource(null); // Probe REST once before declaring failure -- handles dev-server // reloads and brief network blips where the job is actually fine. try { const s = await probeJob(jobId); if (TERMINAL_STATUSES.has(s.status)) { stopped = true; return; } } catch (err) { if (err.message === "Job no longer exists on the server") { stopped = true; showError(err.message); setSubmitProcessing(false); return; } // Network down -- fall through to backoff. } attempt += 1; if (attempt > 6) { // SSE gave up — activate REST polling as the fallback. startJobPolling(jobId); return; } // 0.5s, 1s, 2s, 4s, 8s, 16s const delay = 500 * Math.pow(2, attempt - 1); setTimeout(() => { if (!stopped) open(); }, delay); }; }; open(); } async function cancelCurrentJob() { const id = currentJobId; if (!id) return; jobCancelBtn.disabled = true; jobCancelBtn.textContent = "Cancelling…"; try { await fetch(`/api/jobs/${id}/cancel`, { method: "POST" }); // The next SSE frame (or the REST probe in connectEvents) will // surface the cancelled state and hide the button via applyState. } catch { /* SSE will reflect the result regardless */ } finally { jobCancelBtn.disabled = false; jobCancelBtn.textContent = "Cancel"; } } function sanitizeFilename(name) { // Strip extension, collapse whitespace, cap at 120 chars — mirrors the // backend _sanitize_title() so title and sourceUrl match on both sides. return name .replace(/\.[^.]+$/, "") .replace(/\s+/g, " ") .trim() .slice(0, 120); } // Programmatic URL import — re-uses the full studio/SSE pipeline (same as the // import form's URL path). Used by the library "Sync again" auto-restore to // re-download + re-separate a track whose backend audio was swept. Takes over // the studio like a normal import. Returns the new job id, or null on failure. export async function importFromUrl(url, { title, stems } = {}) { if (!url || url.startsWith("local:")) return null; // local files can't auto-restore reset(); setSubmitProcessing(true); setWaveformLoading(true, ""); const stemSel = stems?.length ? stems : [...selectedStems]; let jobId; try { const res = await fetch("/api/jobs", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ url, stems: stemSel }), }); const data = await res.json(); if (!res.ok) throw new Error(data.detail || res.statusText); jobId = data.job_id; } catch (err) { showError(`Failed to restore track: ${err.message}`); setSubmitProcessing(false); return null; } setCurrentJobId(jobId); jobSources.set(jobId, url); // Merges into the existing library entry by sourceUrl (replaceTrackId), // preserving its folder placement; status updates as SSE frames arrive. addTrackToLibrary({ id: jobId, title: title || url || "Processing track", channel: "Processing", thumb: "", stems: stemSel, selectedStems: stemSel, audioStems: [], status: "processing", bpm: null, key: null, scale: null, keyConfidence: null, lufs: null, peakDb: null, sourceUrl: url, }); setCurrentTrack(jobId); jobBox.classList.add("hidden"); jobCancelBtn.classList.add("hidden"); startPhraseRotation("queued"); lastStatus = "queued"; connectEvents(jobId); return jobId; } export function wireJobForm() { jobCancelBtn.addEventListener("click", cancelCurrentJob); form.addEventListener("submit", async (e) => { e.preventDefault(); reset(); setSubmitProcessing(true); const fileInput = document.getElementById("fileInput"); // Prefer _file cache: browsers (WKWebView, Chromium) silently clear // fileInput.files after a fetch() submission, breaking re-submits. const file = fileInput?._file ?? fileInput?.files?.[0] ?? null; const sanitized = file ? sanitizeFilename(file.name) : null; const sourceUrl = file ? `local:${sanitized}` : urlInput.value; const displayTitle = sanitized ?? (urlInput.value || "Processing track"); const postUrlText = document.getElementById("post-url-text"); if (postUrlText) postUrlText.textContent = displayTitle; // Show overlay immediately for both paths. File uploads show "Uploading…" // in the overlay phrase until the fetch completes and SSE takes over. setWaveformLoading(true, file ? "Uploading…" : ""); if (file) { lastStatus = "queued"; } let fetchInit; if (file) { const fd = new FormData(); fd.append("file", file); fd.append("stems", JSON.stringify([...selectedStems])); fetchInit = { method: "POST", body: fd }; } else { fetchInit = { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ url: urlInput.value, // Backend uses this to decide whether to ffmpeg-amix a // "selected stems" track (mix.wav) at the end of the pipeline. stems: [...selectedStems], }), }; } let jobId; try { const res = await fetch("/api/jobs", fetchInit); const data = await res.json(); if (!res.ok) throw new Error(data.detail || res.statusText); jobId = data.job_id; } catch (err) { if (file) jobBox.classList.add("hidden"); showError(`Failed to start job: ${err.message}`); setSubmitProcessing(false); return; } setCurrentJobId(jobId); jobSources.set(jobId, sourceUrl); addTrackToLibrary({ id: jobId, title: displayTitle, channel: "Processing", thumb: "", stems: [...selectedStems], selectedStems: [...selectedStems], audioStems: [], status: "processing", bpm: null, key: null, scale: null, keyConfidence: null, lufs: null, peakDb: null, sourceUrl, }); setCurrentTrack(jobId); // Both paths: keep job box hidden, overlay drives the UI. // Start phrase rotation now that the job exists on the server. jobBox.classList.add("hidden"); jobCancelBtn.classList.add("hidden"); startPhraseRotation("queued"); lastStatus = "queued"; connectEvents(jobId); }); }