// StemDeck mobile — vanilla port of the Claude Design prototype // (design/mobile/StemDeck-Mobile.dc.html). No framework, no build step, // to match the rest of static/. Library + Mixer are wired to the real API // (GET /api/jobs, /api/jobs/{id}, the Web Audio engine, mixdown export). // Extract is still mock pending the SSE/upload wiring (next step). import { fetchJobs, jobToCard } from "../js/shared/jobs.js"; import { createChunkedAudioEngine } from "../js/chunkedAudioEngine.js"; // Per-stem label + color, keyed by the backend stem name. Unknown names fall // back to a rotating palette so non-standard models still render sensibly. const STEM_META = { vocals: { label: "Vocals", color: "#f0506e" }, drums: { label: "Drums", color: "#f5862b" }, bass: { label: "Bass", color: "#f5c518" }, guitar: { label: "Guitar", color: "#3fcf6e" }, piano: { label: "Piano", color: "#9b6cf0" }, other: { label: "Other", color: "#4a9bf5" }, }; const FALLBACK_COLORS = ["#f0506e", "#f5862b", "#f5c518", "#3fcf6e", "#9b6cf0", "#4a9bf5", "#2bd4c4", "#c44ad0"]; function stemMeta(name, idx) { return STEM_META[name] || { label: name.charAt(0).toUpperCase() + name.slice(1), color: FALLBACK_COLORS[idx % FALLBACK_COLORS.length] }; } // Stem chips shown on the (still-mock) Extract screen. const EXTRACT_STEMS = Object.entries(STEM_META).map(([id, m]) => ({ id, name: m.label, color: m.color })); const DEFAULT_GRADIENT = "linear-gradient(150deg,#3a3a42,#202026)"; const FILTERS = ["All", "Favorites"]; // ─── Web Audio engine (reused from desktop: ../js/audioEngine.js) ─── let engine = null; let engineTrackId = null; let engineToken = 0; // guards against races when switching tracks mid-decode let engineReady = false; // true once the current engine has decodable stems let pendingPlay = false; // Shared AudioContext, created + resumed inside a user gesture (mobile/iOS // won't start audio otherwise). Once running it stays running, so later // programmatic play() calls work even outside a gesture. let audioCtx = null; function ensureAudioCtx() { const AC = window.AudioContext || window.webkitAudioContext; if (!AC) return null; if (!audioCtx) audioCtx = new AC(); if (audioCtx.state === "suspended") audioCtx.resume().catch(() => {}); return audioCtx; } const ICON = { chevron: '', dots: '', prev: '', next: '', play: (sz, fill) => ``, pause: (sz, fill) => ``, download: '', search: '', link: '', upload: '', check: '', scissors: '', tabLib: '', tabMix: '', tabExt: '', }; const state = { tab: "mixer", mixerView: "stems", playing: false, progress: 0, vols: {}, // per-stem gain 0..1, keyed by backend stem name muted: {}, solo: {}, speed: 1.0, selected: { vocals: true, drums: true, bass: true, guitar: true, piano: true, other: true }, quality: "High", filter: "All", // Library data from /api/jobs. libState: "loading" | "ready" | "empty" | "error". tracks: [], libState: "loading", swipedTrackId: null, // track whose swipe-to-delete is revealed current: null, // selected track card (with .detail once loaded), or null // Extract screen. extractUrl: "", extractFile: null, // File chosen for upload extractJob: null, // { id, title, status, progress, stage } while a job runs }; let extractES = null; // EventSource for the active extraction let extractPoll = null; // REST poll fallback timer const app = document.getElementById("app"); function fmt(sec) { if (!isFinite(sec) || sec < 0) sec = 0; const m = Math.floor(sec / 60); const s = Math.floor(sec % 60); return m + ":" + String(s).padStart(2, "0"); } function esc(s) { return String(s).replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]); } // Cover/thumbnail art. Uses the real YouTube/SoundCloud thumbnail when present // (layered over the gradient as a fallback if it fails to load); otherwise the // gradient + initial. The URL is constrained to a clean https URL so it can't // break out of the CSS url(). function safeThumb(url) { return typeof url === "string" && /^https:\/\/[^"'()\\\s]+$/.test(url) ? url : ""; } function artStyle(card) { const g = (card && card.gradient) || DEFAULT_GRADIENT; const t = card && safeThumb(card.thumb); return t ? `background-image:url('${t}'), ${g};background-size:cover;background-position:center;` : `background:${g};`; } function artLabel(card) { return card && safeThumb(card.thumb) ? "" : esc((card && card.initial) || "♪"); } // ─── Mixer / engine helpers ─── function lanes() { return state.current?.detail?.lanes || []; } function curDuration() { return state.current?.detail?.duration || 0; } function anySolo() { return Object.values(state.solo).some(Boolean); } function laneActive(name) { const muted = !!state.muted[name]; const soloed = !!state.solo[name]; return !(muted || (anySolo() && !soloed)); } function effectiveGain(name) { return laneActive(name) ? (state.vols[name] ?? 1) : 0; } function applyMix() { if (!engine) return; for (const l of lanes()) engine.setGain(l.name, effectiveGain(l.name)); } function cap(s) { return s ? s.charAt(0).toUpperCase() + s.slice(1) : s; } function drLabel(dr) { if (dr < 7) return "Compressed"; if (dr < 10) return "Moderate"; if (dr < 14) return "High"; return "Wide"; } function stabilityLabel(pct) { if (pct >= 90) return "Very stable"; if (pct >= 70) return "Stable"; if (pct >= 40) return "Variable"; return "Unstable"; } // Build the Analysis tab's stat cards + stem-presence rows from a job detail. function extractAnalysis(d, laneList) { const stats = []; if (d.key) stats.push({ k: "KEY", v: d.key, s: d.scale ? cap(d.scale) : "" }); if (d.bpm) stats.push({ k: "TEMPO", v: String(Math.round(d.bpm)), s: "BPM" }); if (d.lufs != null) stats.push({ k: "LUFS", v: Number(d.lufs).toFixed(1), s: d.peak_db != null ? `Peak ${Number(d.peak_db).toFixed(1)} dB` : "Integrated" }); if (d.duration) stats.push({ k: "DURATION", v: fmt(d.duration), s: "Full length" }); if (d.dynamic_range != null) stats.push({ k: "DYN RANGE", v: Number(d.dynamic_range).toFixed(1), s: drLabel(d.dynamic_range) }); if (d.tempo_stability != null) { const pct = d.tempo_stability > 1 ? d.tempo_stability : d.tempo_stability * 100; stats.push({ k: "STABILITY", v: Math.round(pct) + "%", s: stabilityLabel(pct) }); } const sp = d.stem_presence || {}; const presence = laneList .filter((l) => sp[l.name] != null) .map((l) => ({ name: l.label, color: l.color, val: Math.round(sp[l.name]) })); return { stats, presence }; } // Repaint the main waveform's played (yellow) vs. remaining bars as playback // advances. Only touches the DOM when the played-bar count actually changes // (≤ N times over the whole track), not every animation frame. let _lastPlayedBar = -1; function paintWaveProgress() { const bars = app.querySelectorAll(".wave-bars > i"); if (!bars.length) return; const played = Math.round(state.progress * bars.length); if (played === _lastPlayedBar) return; for (let i = 0; i < bars.length; i++) { bars[i].style.background = i <= played ? "#f5b417" : "#34343c"; } _lastPlayedBar = played; } function onEngineTime(t) { const dur = curDuration() || 1; state.progress = Math.max(0, Math.min(1, t / dur)); const head = app.querySelector(".playhead"); const cur = app.querySelector(".wave-times .cur"); if (head) head.style.left = state.progress * 100 + "%"; if (cur) cur.textContent = fmt(t); paintWaveProgress(); } // Load a track: fetch detail, build lanes, spin up the Web Audio engine. async function openTrack(card, { autoplay = false } = {}) { state.tab = "mixer"; state.current = { ...card, detail: null, loading: true, error: null }; state.playing = false; state.progress = 0; state.speed = 1.0; render(); if (engine) { engine.destroy(); engine = null; engineTrackId = null; } engineReady = false; const token = ++engineToken; pendingPlay = autoplay; let detail; try { const res = await fetch(`/api/jobs/${card.id}`, { cache: "no-store" }); if (!res.ok) throw new Error(`GET /api/jobs/${card.id} -> ${res.status}`); detail = await res.json(); } catch (e) { if (token !== engineToken) return; console.warn("[mobile] track detail failed:", e); state.current.loading = false; state.current.error = "Couldn't load this track."; render(); return; } if (token !== engineToken) return; // Use WAV stems with range-request chunking (chunkedAudioEngine.js): fetches // 10-second windows at a time, so the first audio starts after ~7 MB instead // of the full file, and peak RAM stays around 28 MB regardless of track length. const laneList = (detail.stems || []) .filter((s) => s && s.name !== "original" && s.url) .map((s, i) => ({ name: s.name, url: s.url, ...stemMeta(s.name, i) })); state.vols = {}; state.muted = {}; state.solo = {}; for (const l of laneList) state.vols[l.name] = 1; state.current.detail = { duration: detail.duration || 0, lanes: laneList, hasVideo: !!detail.has_video, analysis: extractAnalysis(detail, laneList), }; state.current.loading = false; state.current.stemCount = laneList.length; if (!laneList.length) { state.current.error = "This track has no playable stems yet."; render(); return; } const engineOpts = { onTime: onEngineTime, onEnded: () => { state.playing = false; render(); }, context: ensureAudioCtx(), }; engine = createChunkedAudioEngine(laneList.map((l) => ({ name: l.name, url: l.url })), engineOpts); engineTrackId = card.id; render(); const ok = await engine.ready; if (token !== engineToken) return; if (!ok) { state.current.error = "Couldn't load this track's audio."; pendingPlay = false; toast("Couldn't load this track's audio."); render(); return; } engineReady = true; applyMix(); render(); if (pendingPlay) { pendingPlay = false; engine.play(); state.playing = engine.isPlaying(); render(); } } function prevTrack() { if (!state.tracks.length || !state.current) return; const idx = state.tracks.findIndex((t) => t.id === state.current.id); const target = state.tracks[idx - 1]; if (target) openTrack(target, { autoplay: state.playing }); } function nextTrack() { if (!state.tracks.length || !state.current) return; const idx = state.tracks.findIndex((t) => t.id === state.current.id); const target = state.tracks[idx + 1]; if (target) openTrack(target, { autoplay: state.playing }); } function togglePlay() { if (!state.current) return; ensureAudioCtx(); // unlock audio within this gesture // Engine not loaded for this track yet → load then autoplay. if (!engine || engineTrackId !== state.current.id) { openTrack(state.current, { autoplay: true }); return; } // Decode still in flight → remember the intent and play once ready. if (!engineReady) { pendingPlay = true; return; } if (engine.isPlaying()) engine.pause(); else engine.play(); state.playing = engine.isPlaying(); render(); } function seekToFraction(frac) { if (!engine) return; engine.seek(Math.max(0, Math.min(1, frac)) * curDuration()); } function triggerDownload(url) { const a = document.createElement("a"); a.href = url; a.download = ""; document.body.appendChild(a); a.click(); a.remove(); } function exportMix(kind) { const id = state.current?.id; if (!id || !lanes().length) return; const names = []; const gains = []; for (const l of lanes()) { const g = effectiveGain(l.name); if (g > 0) { names.push(l.name); gains.push(g); } } if (!names.length) { toast("Every stem is muted — nothing to export."); return; } const q = new URLSearchParams({ stems: names.join(","), gains: gains.map((g) => g.toFixed(3)).join(",") }); const path = kind === "mp4" ? `video.mp4` : `mixdown.wav`; triggerDownload(`/api/jobs/${id}/${path}?${q}`); } let _toastTimer = null; function toast(msg) { let el = document.getElementById("m-toast"); if (!el) { el = document.createElement("div"); el.id = "m-toast"; el.className = "m-toast"; document.body.appendChild(el); } el.textContent = msg; el.classList.add("show"); clearTimeout(_toastTimer); _toastTimer = setTimeout(() => el.classList.remove("show"), 2400); } function mainWaveform() { const N = 56; const played = Math.round(state.progress * N); let bars = ""; for (let i = 0; i < N; i++) { let h = 24 + Math.abs(Math.sin(i * 0.7) + Math.sin(i * 0.21) * 0.7) * 46 + ((i * 37) % 11); h = Math.max(14, Math.min(98, h)); const bg = i <= played ? "#f5b417" : "#34343c"; bars += ``; } return bars; } function stemWave(idx, color, vol, eff) { const wn = 28; const wplayed = Math.round(state.progress * wn); let out = ""; for (let i = 0; i < wn; i++) { let wh = 16 + Math.abs(Math.sin(i * (0.55 + idx * 0.13)) + Math.sin(i * 0.33 + idx) * 0.6) * 62 + ((i * (7 + idx * 3)) % 9); wh = Math.max(7, Math.min(98, wh)) * (0.3 + 0.7 * vol); const wc = eff ? "#34343c" : i <= wplayed ? color : color + "3d"; out += ``; } return out; } function stemsBody() { const c = state.current; if (!c) return `
Pick a track from your Library to start mixing.
`; if (c.loading) return `
Loading stems…
`; if (c.error) return `
${esc(c.error)}
`; const ll = lanes(); if (!ll.length) return `
No stems available.
`; return `
${ll.map((l, idx) => { const vol = state.vols[l.name] ?? 1; const muted = !!state.muted[l.name]; const soloed = !!state.solo[l.name]; const eff = !laneActive(l.name); const pct = Math.round(vol * 100) + "%"; const mOn = muted ? `background:${l.color};color:#15100a;border-color:${l.color};` : ""; const sOn = soloed ? "background:#f5b417;color:#1a1206;border-color:#f5b417;" : ""; return `
${esc(l.label)}
${stemWave(idx, l.color, vol, eff)}
`; }).join("")}
`; } function analysisBody() { const a = state.current?.detail?.analysis; const hasVideo = state.current?.detail?.hasVideo; const statsHtml = a && a.stats.length ? `
${a.stats.map((x) => `
${x.k}
${esc(x.v)}
${esc(x.s)}
`).join("")}
` : `
Analysis not available for this track.
`; const presenceHtml = a && a.presence.length ? `
STEM PRESENCE
${a.presence.map((p) => `
${esc(p.name)}
${p.val}%
`).join("")}` : ""; const exportBtns = `${ hasVideo ? `` : "" }`; return `
${statsHtml}${presenceHtml}${exportBtns}
`; } function mixerScreen() { const c = state.current || { title: "No track selected", sub: "Pick one from your Library", initial: "♪", gradient: DEFAULT_GRADIENT, stemCount: 0 }; const sourceTag = c.sub || "—"; const stemTag = c.stemCount ? `${c.stemCount} stems` : ""; const dur = curDuration(); const body = state.mixerView === "stems" ? stemsBody() : analysisBody(); const ready = engineReady && engineTrackId === state.current?.id; const preparing = !!state.current && !state.current.loading && !state.current.error && !ready; const canPlay = ready; const curIdx = state.current ? state.tracks.findIndex((t) => t.id === state.current.id) : -1; const hasPrev = curIdx > 0; const hasNext = curIdx >= 0 && curIdx < state.tracks.length - 1; return `
NOW PLAYING
${artLabel(c)}
${esc(c.title)}
${esc(c.sub)}
${esc(sourceTag)}${stemTag ? `${stemTag}` : ""}
${mainWaveform()}
${fmt(state.progress * dur)}${fmt(dur)}
Speed ${state.speed % 1 === 0 ? state.speed.toFixed(1) : state.speed}x
${preparing ? '
Preparing audio…
' : ""}
${body}
`; } function libraryBody() { if (state.libState === "loading") { return `
Loading your library…
`; } if (state.libState === "error") { return `
Couldn't reach the server.
`; } if (state.libState === "empty") { return `
No tracks yet. Head to Extract to split your first song.
`; } return `
RECENT
${state.tracks.map((t) => `
${artLabel(t)}
${esc(t.title)}
${esc(t.sub)}
${esc(t.meta)}
`).join("")}`; } function libraryScreen() { return `
Library
JS
${FILTERS.map((f) => `${f}`).join("")}
${libraryBody()}
`; } function extractProgressCard() { const j = state.extractJob; if (!j) return ""; const pct = Math.round((j.progress || 0) * 100); const failed = j.status === "error"; const cancelled = j.status === "cancelled"; const done = j.status === "done"; const line = failed ? "Failed" : cancelled ? "Cancelled" : done ? "Done" : `${j.stage || j.status}…`; return `
IN PROGRESS
${esc(j.title || "Track")}
${esc(line)}
${done ? "100" : pct}%
`; } function extractScreen() { const fileName = state.extractFile?.name; return `
Extract stems
Paste a link or upload audio to split into stems.
${ICON.link}
STEMS TO EXTRACT
${EXTRACT_STEMS.map((s) => { const on = !!state.selected[s.id]; const onStyle = on ? `border-color:${s.color};background:${s.color}1c;` : ""; return ``; }).join("")}
${extractProgressCard()}
`; } // Submit an extraction (URL or file) and follow it to completion. async function startExtraction() { const stems = Object.keys(state.selected).filter((k) => state.selected[k]); if (!stems.length) { toast("Pick at least one stem."); return; } const file = state.extractFile; const url = (state.extractUrl || "").trim(); if (!file && !url) { toast("Paste a URL or choose a file first."); return; } let init, title; if (file) { const fd = new FormData(); fd.append("file", file); fd.append("stems", JSON.stringify(stems)); init = { method: "POST", body: fd }; title = file.name; } else { init = { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ url, stems }) }; title = url; } state.extractJob = { id: null, title, status: "queued", progress: 0, stage: "Queued" }; render(); let jobId; try { const res = await fetch("/api/jobs", init); const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(data.detail || res.statusText); jobId = data.job_id; } catch (e) { console.warn("[mobile] extraction submit failed:", e); state.extractJob = null; toast(`Couldn't start: ${e.message}`); render(); return; } state.extractJob.id = jobId; state.extractFile = null; state.extractUrl = ""; followExtraction(jobId); render(); } function _onExtractState(jobId, s) { if (!state.extractJob || state.extractJob.id !== jobId) return false; state.extractJob.status = s.status; state.extractJob.progress = s.progress || 0; state.extractJob.stage = s.stage || s.status; if (state.tab === "extract") render(); if (s.status === "done" || s.status === "error" || s.status === "cancelled") { if (s.status === "done") { toast("Stems ready!"); loadLibrary(); } else if (s.status === "error") toast("Extraction failed."); const finishedId = jobId; setTimeout(() => { if (state.extractJob && state.extractJob.id === finishedId) { state.extractJob = null; if (state.tab === "extract") render(); } }, 4000); return true; // terminal } return false; } // SSE with a REST-poll fallback (mirrors the desktop's resilience, simplified). function followExtraction(jobId) { if (extractES) { extractES.close(); extractES = null; } if (extractPoll) { clearInterval(extractPoll); extractPoll = null; } const startPolling = () => { if (extractPoll) return; extractPoll = setInterval(async () => { try { const r = await fetch(`/api/jobs/${jobId}`, { cache: "no-store" }); if (!r.ok) throw new Error(String(r.status)); if (_onExtractState(jobId, await r.json())) { clearInterval(extractPoll); extractPoll = null; } } catch { /* keep polling */ } }, 2500); }; try { const es = new EventSource(`/api/jobs/${jobId}/events`); extractES = es; es.onmessage = (ev) => { let s; try { s = JSON.parse(ev.data); } catch { return; } if (_onExtractState(jobId, s)) { es.close(); extractES = null; } }; es.onerror = () => { es.close(); extractES = null; startPolling(); }; } catch { startPolling(); } } function miniPlayer() { if (state.tab === "mixer" || !state.current) return ""; const c = state.current; return `
${artLabel(c)}
${esc(c.title)}
${esc(c.sub)}
`; } function tabBar() { const t = (tab, icon, label) => ``; return `
${t("extract", ICON.tabExt, "Extract")}${t("mixer", ICON.tabMix, "Mixer")}${t("library", ICON.tabLib, "Library")}
`; } function render() { let screen = mixerScreen(); if (state.tab === "library") screen = libraryScreen(); else if (state.tab === "extract") screen = extractScreen(); app.innerHTML = screen + miniPlayer() + tabBar(); _lastPlayedBar = -1; // bars were just rebuilt; force a repaint on next tick wireFaders(); wireSwipe(); } // Suppresses the synthetic click that follows a swipe gesture, so swiping a // row open doesn't immediately count as a tap. let suppressClick = false; // Swipe a library row left to reveal its Delete button (iOS-style). Wired // imperatively after each render. function wireSwipe() { app.querySelectorAll(".track-wrap .track").forEach((row) => { const id = row.dataset.id; let x0 = 0, y0 = 0, active = false, horizontal = false; const base = () => (state.swipedTrackId === id ? -84 : 0); row.addEventListener("pointerdown", (e) => { x0 = e.clientX; y0 = e.clientY; active = true; horizontal = false; row.style.transition = "none"; }); row.addEventListener("pointermove", (e) => { if (!active) return; const dx = e.clientX - x0; const dy = e.clientY - y0; if (!horizontal && Math.abs(dx) > 8 && Math.abs(dx) > Math.abs(dy)) horizontal = true; if (horizontal) { e.preventDefault(); const tx = Math.max(-84, Math.min(0, base() + dx)); row.style.transform = `translateX(${tx}px)`; } }); const end = (e) => { if (!active) return; active = false; if (!horizontal) { row.style.transition = ""; row.style.transform = ""; return; } suppressClick = true; setTimeout(() => { suppressClick = false; }, 60); const dx = e.clientX - x0; const open = dx < -40 ? true : dx > 40 ? false : state.swipedTrackId === id; // Animate to the snap position by toggling the class (no DOM rebuild, so // it transitions smoothly from wherever the finger let go). closeOtherSwipes(row.closest(".track-wrap")); row.style.transition = "transform 0.22s cubic-bezier(0.22,1,0.36,1)"; row.closest(".track-wrap").classList.toggle("swiped", open); row.style.transform = ""; state.swipedTrackId = open ? id : null; }; row.addEventListener("pointerup", end); row.addEventListener("pointercancel", end); }); } // Smoothly close any revealed row (optionally except `keep`), without a full // re-render so the close animates. function closeOtherSwipes(keep) { app.querySelectorAll(".track-wrap.swiped").forEach((w) => { if (w === keep) return; const r = w.querySelector(".track"); if (r) { r.style.transition = "transform 0.22s cubic-bezier(0.22,1,0.36,1)"; r.style.transform = ""; } w.classList.remove("swiped"); }); } function closeSwipe() { state.swipedTrackId = null; closeOtherSwipes(null); } async function deleteTrack(id) { state.swipedTrackId = null; try { await fetch(`/api/jobs/${id}`, { method: "DELETE" }); } catch (e) { console.warn("[mobile] delete failed:", e); } state.tracks = state.tracks.filter((t) => t.id !== id); if (!state.tracks.length) state.libState = "empty"; if (state.current?.id === id) { if (engine) { engine.destroy(); engine = null; engineTrackId = null; engineReady = false; } state.current = null; state.playing = false; } toast("Track deleted"); render(); } // Faders + waveform seek need live pointer drag, so they're wired imperatively // after each render (the rest of the UI uses click delegation below). function wireFaders() { app.querySelectorAll("[data-fader]").forEach((el) => { el.addEventListener("pointerdown", (e) => { e.preventDefault(); const id = el.dataset.id; const fill = el.querySelector(".fader-fill"); const knob = el.querySelector(".fader-knob"); const rect = el.getBoundingClientRect(); const set = (cx) => { let v = (cx - rect.left) / rect.width; v = Math.max(0, Math.min(1, v)); state.vols[id] = v; const pct = Math.round(v * 100) + "%"; fill.style.width = pct; knob.style.left = pct; if (engine && laneActive(id)) engine.setGain(id, v); }; set(e.clientX); el.setPointerCapture(e.pointerId); const mv = (ev) => set(ev.clientX); const up = () => { el.removeEventListener("pointermove", mv); el.removeEventListener("pointerup", up); }; el.addEventListener("pointermove", mv); el.addEventListener("pointerup", up); }); }); const speedSlider = app.querySelector("[data-speed]"); if (speedSlider) { speedSlider.addEventListener("input", () => { const rate = parseFloat(speedSlider.value); state.speed = rate; const valEl = speedSlider.parentElement?.querySelector(".speed-row-val"); if (valEl) valEl.textContent = `${rate % 1 === 0 ? rate.toFixed(1) : rate}x`; if (engine) engine.setPlaybackRate(rate); }); } const bars = app.querySelector("[data-seek]"); if (bars) { bars.addEventListener("pointerdown", (e) => { if (!engine || !curDuration()) return; e.preventDefault(); const rect = bars.getBoundingClientRect(); const head = bars.querySelector(".playhead"); const seek = (cx) => { const frac = Math.max(0, Math.min(1, (cx - rect.left) / rect.width)); if (head) head.style.left = frac * 100 + "%"; const cur = app.querySelector(".wave-times .cur"); if (cur) cur.textContent = fmt(frac * curDuration()); state.progress = frac; paintWaveProgress(); seekToFraction(frac); }; seek(e.clientX); bars.setPointerCapture(e.pointerId); const mv = (ev) => seek(ev.clientX); const up = () => { bars.removeEventListener("pointermove", mv); bars.removeEventListener("pointerup", up); }; bars.addEventListener("pointermove", mv); bars.addEventListener("pointerup", up); }); } } app.addEventListener("click", (e) => { if (suppressClick) { suppressClick = false; return; } const t = e.target.closest("[data-action]"); if (!t) return; ensureAudioCtx(); // any in-app tap unlocks audio on mobile const a = t.dataset.action; // A row is swiped open: any tap other than its Delete just closes it. if (state.swipedTrackId && a !== "delete") { closeSwipe(); return; } switch (a) { case "delete": deleteTrack(t.dataset.id); return; case "tab": state.tab = t.dataset.tab; break; case "mixview": state.mixerView = t.dataset.view; break; case "prev": prevTrack(); return; case "next": nextTrack(); return; case "play": case "play-mini": e.stopPropagation(); togglePlay(); return; case "mute": state.muted[t.dataset.id] = !state.muted[t.dataset.id]; applyMix(); break; case "solo": state.solo[t.dataset.id] = !state.solo[t.dataset.id]; applyMix(); break; case "chip": state.selected[t.dataset.id] = !state.selected[t.dataset.id]; break; case "qual": state.quality = t.dataset.q; break; case "filter": state.filter = t.dataset.filter; break; case "export": exportMix(t.dataset.kind || "wav"); return; case "split": startExtraction(); return; case "pick-file": document.getElementById("ext-file")?.click(); return; case "open": { const track = state.tracks.find((x) => x.id === t.dataset.id); if (track) openTrack(track); return; } case "reload": loadLibrary(); return; default: return; } render(); }); // URL field is uncontrolled-but-tracked: store keystrokes in state without // re-rendering (which would steal focus), so the value survives later renders. app.addEventListener("input", (e) => { if (e.target.id === "ext-url") state.extractUrl = e.target.value; }); app.addEventListener("change", (e) => { if (e.target.id === "ext-file") { state.extractFile = e.target.files?.[0] || null; render(); } }); // Load the real library from /api/jobs (newest first). On success, seed the // Mixer with the most recent track if nothing is selected yet. async function loadLibrary() { state.libState = "loading"; render(); try { const jobs = await fetchJobs(); state.tracks = jobs.map(jobToCard).sort((a, b) => b.createdAt - a.createdAt); state.libState = state.tracks.length ? "ready" : "empty"; } catch (e) { console.warn("[mobile] failed to load library:", e); state.libState = "error"; } render(); } render(); loadLibrary();