(card, { autoplay = false } = {})
| 205 | |
| 206 | // Load a track: fetch detail, build lanes, spin up the Web Audio engine. |
| 207 | async function openTrack(card, { autoplay = false } = {}) { |
| 208 | state.tab = "mixer"; |
| 209 | state.current = { ...card, detail: null, loading: true, error: null }; |
| 210 | state.playing = false; |
| 211 | state.progress = 0; |
| 212 | state.speed = 1.0; |
| 213 | render(); |
| 214 | |
| 215 | if (engine) { engine.destroy(); engine = null; engineTrackId = null; } |
| 216 | engineReady = false; |
| 217 | const token = ++engineToken; |
| 218 | pendingPlay = autoplay; |
| 219 | |
| 220 | let detail; |
| 221 | try { |
| 222 | const res = await fetch(`/api/jobs/${card.id}`, { cache: "no-store" }); |
| 223 | if (!res.ok) throw new Error(`GET /api/jobs/${card.id} -> ${res.status}`); |
| 224 | detail = await res.json(); |
| 225 | } catch (e) { |
| 226 | if (token !== engineToken) return; |
| 227 | console.warn("[mobile] track detail failed:", e); |
| 228 | state.current.loading = false; |
| 229 | state.current.error = "Couldn't load this track."; |
| 230 | render(); |
| 231 | return; |
| 232 | } |
| 233 | if (token !== engineToken) return; |
| 234 | |
| 235 | // Use WAV stems with range-request chunking (chunkedAudioEngine.js): fetches |
| 236 | // 10-second windows at a time, so the first audio starts after ~7 MB instead |
| 237 | // of the full file, and peak RAM stays around 28 MB regardless of track length. |
| 238 | const laneList = (detail.stems || []) |
| 239 | .filter((s) => s && s.name !== "original" && s.url) |
| 240 | .map((s, i) => ({ name: s.name, url: s.url, ...stemMeta(s.name, i) })); |
| 241 | |
| 242 | state.vols = {}; |
| 243 | state.muted = {}; |
| 244 | state.solo = {}; |
| 245 | for (const l of laneList) state.vols[l.name] = 1; |
| 246 | |
| 247 | state.current.detail = { |
| 248 | duration: detail.duration || 0, |
| 249 | lanes: laneList, |
| 250 | hasVideo: !!detail.has_video, |
| 251 | analysis: extractAnalysis(detail, laneList), |
| 252 | }; |
| 253 | state.current.loading = false; |
| 254 | state.current.stemCount = laneList.length; |
| 255 | |
| 256 | if (!laneList.length) { |
| 257 | state.current.error = "This track has no playable stems yet."; |
| 258 | render(); |
| 259 | return; |
| 260 | } |
| 261 | |
| 262 | const engineOpts = { |
| 263 | onTime: onEngineTime, |
| 264 | onEnded: () => { state.playing = false; render(); }, |
no test coverage detected