Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:32:09 +08:00

940 lines
38 KiB
JavaScript

// 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: '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 9l6 6 6-6"/></svg>',
dots: '<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><circle cx="5" cy="12" r="1.6"/><circle cx="12" cy="12" r="1.6"/><circle cx="19" cy="12" r="1.6"/></svg>',
prev: '<svg width="32" height="32" viewBox="0 0 24 24" fill="currentColor"><path d="M7 6v12M19 6l-9 6 9 6z"/></svg>',
next: '<svg width="32" height="32" viewBox="0 0 24 24" fill="currentColor"><path d="M17 6v12M5 6l9 6-9 6z"/></svg>',
play: (sz, fill) => `<svg width="${sz}" height="${sz}" viewBox="0 0 24 24" fill="${fill}"><path d="M8 5.5v13l11-6.5z"/></svg>`,
pause: (sz, fill) => `<svg width="${sz}" height="${sz}" viewBox="0 0 24 24" fill="${fill}"><rect x="6" y="5" width="4" height="14" rx="1.3"/><rect x="14" y="5" width="4" height="14" rx="1.3"/></svg>`,
download: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v12"/><path d="M8 11l4 4 4-4"/><path d="M4 21h16"/></svg>',
search: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#65656d" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4-4"/></svg>',
link: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#65656d" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7 0l3-3a5 5 0 0 0-7-7l-1 1"/><path d="M14 11a5 5 0 0 0-7 0l-3 3a5 5 0 0 0 7 7l1-1"/></svg>',
upload: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 16V4"/><path d="M8 8l4-4 4 4"/><path d="M4 16v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2"/></svg>',
check: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg>',
scissors: '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.3" stroke-linecap="round" stroke-linejoin="round"><circle cx="6" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M20 4L8.12 15.88M14.47 14.48L20 20M8.12 8.12L12 12"/></svg>',
tabLib: '<svg width="23" height="23" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="3" width="7" height="7" rx="1.5"/><rect x="3" y="14" width="7" height="7" rx="1.5"/><rect x="14" y="14" width="7" height="7" rx="1.5"/></svg>',
tabMix: '<svg width="23" height="23" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M4 8h16M4 16h16"/><circle cx="9" cy="8" r="2.4" fill="currentColor" stroke="none"/><circle cx="15" cy="16" r="2.4" fill="currentColor" stroke="none"/></svg>',
tabExt: '<svg width="23" height="23" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="6" cy="6" r="2.6"/><circle cx="6" cy="18" r="2.6"/><path d="M20 4L8.5 15.5M14 14.5L20 20M8.5 8.5L11.5 11.5"/></svg>',
};
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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[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 += `<i style="height:${h}%;background:${bg}"></i>`;
}
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 += `<i style="height:${wh}%;background:${wc}"></i>`;
}
return out;
}
function stemsBody() {
const c = state.current;
if (!c) return `<div class="lib-note">Pick a track from your Library to start mixing.</div>`;
if (c.loading) return `<div class="lib-note">Loading stems…</div>`;
if (c.error) return `<div class="lib-note">${esc(c.error)}</div>`;
const ll = lanes();
if (!ll.length) return `<div class="lib-note">No stems available.</div>`;
return `<div class="stems-grid">${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 `<div class="stem">
<div class="stem-top">
<div class="stem-dot" style="background:${l.color};box-shadow:0 0 8px ${l.color}88;opacity:${eff ? 0.4 : 1}"></div>
<div class="stem-name" style="color:${eff ? "#6a6a72" : l.color}">${esc(l.label)}</div>
<button class="ms-btn" style="${mOn}" data-action="mute" data-id="${l.name}">M</button>
<button class="ms-btn" style="${sOn}" data-action="solo" data-id="${l.name}">S</button>
</div>
<div class="stem-wave">${stemWave(idx, l.color, vol, eff)}</div>
<div class="fader" data-fader data-id="${l.name}">
<div class="fader-track"></div>
<div class="fader-fill" style="width:${pct};background:${eff ? "#46464e" : l.color}"></div>
<div class="fader-knob" style="left:${pct};background:${eff ? "#8a8a90" : "#fff"}"></div>
</div>
</div>`;
}).join("")}</div>`;
}
function analysisBody() {
const a = state.current?.detail?.analysis;
const hasVideo = state.current?.detail?.hasVideo;
const statsHtml = a && a.stats.length
? `<div class="stats">${a.stats.map((x) => `<div class="stat"><div class="stat-k">${x.k}</div><div class="stat-v">${esc(x.v)}</div><div class="stat-s">${esc(x.s)}</div></div>`).join("")}</div>`
: `<div class="lib-note" style="margin-top:8px">Analysis not available for this track.</div>`;
const presenceHtml = a && a.presence.length
? `<div class="eyebrow">STEM PRESENCE</div>${a.presence.map((p) => `<div class="presence-row"><div class="presence-name">${esc(p.name)}</div><div class="presence-bar"><i style="width:${p.val}%;background:${p.color}"></i></div><span class="presence-val">${p.val}%</span></div>`).join("")}`
: "";
const exportBtns = `<button class="cta sm" data-action="export" data-kind="wav">${ICON.download}Export mix</button>${
hasVideo ? `<button class="cta sm" data-action="export" data-kind="mp4" style="margin-top:10px">${ICON.download}Export MP4 (with video)</button>` : ""
}`;
return `<div class="pad" style="padding-top:16px">${statsHtml}${presenceHtml}${exportBtns}</div>`;
}
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 `<div class="screen scrl">
<div class="pad">
<div class="mx-head">
<button class="icon-btn" data-action="tab" data-tab="library">${ICON.chevron}</button>
<span class="now-playing">NOW PLAYING</span>
<button class="icon-btn">${ICON.dots}</button>
</div>
<div class="cover-wrap">
<div class="cover" style="${artStyle(c)}"><span>${artLabel(c)}</span></div>
<div class="track-title">${esc(c.title)}</div>
<div class="track-sub">${esc(c.sub)}</div>
<div class="tags"><span class="tag">${esc(sourceTag)}</span>${stemTag ? `<span class="tag">${stemTag}</span>` : ""}</div>
</div>
<div class="wave">
<div class="wave-bars" data-seek>${mainWaveform()}<div class="playhead" style="left:${state.progress * 100}%"></div></div>
<div class="wave-times"><span class="cur">${fmt(state.progress * dur)}</span><span class="dur">${fmt(dur)}</span></div>
</div>
<div class="transport">
<button class="t-step" data-action="prev" ${hasPrev ? "" : "disabled"}>${ICON.prev}</button>
<button class="t-play" data-action="play" data-playing="${state.playing}" ${canPlay ? "" : "disabled style=opacity:.45"}>${state.playing ? ICON.pause(26, "#1a1206") : ICON.play(28, "#1a1206")}</button>
<button class="t-step" data-action="next" ${hasNext ? "" : "disabled"}>${ICON.next}</button>
</div>
<div class="speed-row">
<span class="speed-row-label">Speed</span>
<input type="range" class="speed-slider" data-speed min="0" max="2" step="0.25" value="${state.speed}">
<span class="speed-row-val">${state.speed % 1 === 0 ? state.speed.toFixed(1) : state.speed}x</span>
</div>
${preparing ? '<div class="mx-prep">Preparing audio…</div>' : ""}
<div class="segmented">
<button class="${state.mixerView === "stems" ? "on" : ""}" data-action="mixview" data-view="stems">Stems</button>
<button class="${state.mixerView === "analysis" ? "on" : ""}" data-action="mixview" data-view="analysis">Analysis</button>
</div>
</div>
${body}
</div>`;
}
function libraryBody() {
if (state.libState === "loading") {
return `<div class="lib-note">Loading your library…</div>`;
}
if (state.libState === "error") {
return `<div class="lib-note">Couldn't reach the server. <button class="lib-retry" data-action="reload">Retry</button></div>`;
}
if (state.libState === "empty") {
return `<div class="lib-note">No tracks yet. Head to <b>Extract</b> to split your first song.</div>`;
}
return `<div class="eyebrow">RECENT</div>
${state.tracks.map((t) => `<div class="track-wrap${state.swipedTrackId === t.id ? " swiped" : ""}">
<button class="track-delete" data-action="delete" data-id="${esc(t.id)}">Delete</button>
<div class="track" data-action="open" data-id="${esc(t.id)}">
<div class="track-art" style="${artStyle(t)}">${artLabel(t)}</div>
<div class="track-info"><div class="t">${esc(t.title)}</div><div class="s">${esc(t.sub)}</div><div class="m">${esc(t.meta)}</div></div>
<div class="track-dot ${t.status}"></div>
<button class="track-load" data-action="open" data-id="${esc(t.id)}">Load</button>
</div>
</div>`).join("")}`;
}
function libraryScreen() {
return `<div class="screen scrl">
<div class="pad">
<div class="lib-head">
<span class="h1">Library</span>
<div class="avatar">JS</div>
</div>
<div class="search">${ICON.search}<span>Search your library</span></div>
<div class="filters scrl">${FILTERS.map((f) => `<span class="filter ${f === state.filter ? "on" : ""}" data-action="filter" data-filter="${f}">${f}</span>`).join("")}</div>
${libraryBody()}
</div>
</div>`;
}
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 `<div class="eyebrow">IN PROGRESS</div>
<div class="progress-card">
<div class="progress-top">
<div class="progress-art"></div>
<div style="flex:1;min-width:0">
<div style="font-size:14px;font-weight:600;color:#eaeaec;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${esc(j.title || "Track")}</div>
<div style="font-size:12px;color:${failed ? "#f0506e" : "#7a7a82"};margin-top:1px">${esc(line)}</div>
</div>
<span class="progress-pct">${done ? "100" : pct}%</span>
</div>
<div class="progress-bar"><i style="width:${done ? 100 : pct}%"></i></div>
</div>`;
}
function extractScreen() {
const fileName = state.extractFile?.name;
return `<div class="screen scrl">
<div class="pad">
<div class="h1">Extract stems</div>
<div class="sub">Paste a link or upload audio to split into stems.</div>
<div class="paste">${ICON.link}<input id="ext-url" class="ext-input" type="url" inputmode="url" autocomplete="off" autocapitalize="off" spellcheck="false" placeholder="Paste YouTube or audio URL" value="${esc(state.extractUrl || "")}"></div>
<button class="upload" data-action="pick-file">${ICON.upload}${fileName ? esc(fileName) : "Upload audio file"}</button>
<input id="ext-file" type="file" accept="audio/*,video/mp4,.mp3,.wav,.flac,.m4a,.ogg" style="display:none">
<div class="eyebrow">STEMS TO EXTRACT</div>
<div class="chips">${EXTRACT_STEMS.map((s) => {
const on = !!state.selected[s.id];
const onStyle = on ? `border-color:${s.color};background:${s.color}1c;` : "";
return `<button class="chip-btn ${on ? "on" : ""}" style="${onStyle}" data-action="chip" data-id="${s.id}"><div class="dot" style="background:${s.color}"></div><span class="nm">${s.name}</span>${on ? ICON.check : ""}</button>`;
}).join("")}</div>
<button class="cta" style="margin-top:22px" data-action="split">${ICON.scissors}Split stems</button>
${extractProgressCard()}
</div>
</div>`;
}
// 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 `<div class="mini" data-action="tab" data-tab="mixer">
<div class="mini-art" style="${artStyle(c)}">${artLabel(c)}</div>
<div class="mini-info"><div class="t">${esc(c.title)}</div><div class="s">${esc(c.sub)}</div></div>
<button class="mini-play" data-action="play-mini">${state.playing ? ICON.pause(17, "#1a1206") : ICON.play(18, "#1a1206")}</button>
</div>`;
}
function tabBar() {
const t = (tab, icon, label) => `<button class="tab ${state.tab === tab ? "on" : ""}" data-action="tab" data-tab="${tab}">${icon}${label}</button>`;
return `<div class="tabbar">${t("extract", ICON.tabExt, "Extract")}${t("mixer", ICON.tabMix, "Mixer")}${t("library", ICON.tabLib, "Library")}</div>`;
}
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();