Monorepo for Aesthetic.Computer aesthetic.computer

feat: full DJ interface — turntables, crossfader, auto-scan, hot-plug

- Turntable platter visualization with spinning indicator per deck
- Scratch mode (S key) with fine-grained seek
- Crossfader ([ / ] keys) with visual slider
- Auto-scan: recursively finds all audio files on USB/media
- Queue system with auto-advance to next track
- USB hot-plug detection with TTS announcements
- Three views: decks, file browser, queue (V to switch)
- N key loads next queued track into active deck
- Per-deck volume, speed, play/pause controls

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

+394 -227
+394 -227
fedac/native/pieces/dj.mjs
··· 1 1 // dj.mjs — DJ deck piece for AC Native 2 - // Two decks with crossfader, file browser, and persistent audio playback. 2 + // Two decks with turntable interface, crossfader, auto-scan, hot-plug USB, TTS. 3 3 // Audio continues when jumping to notepat or other pieces. 4 4 5 5 const MUSIC_DIR = "/media"; 6 - const FALLBACK_DIR = "/mnt/samples"; 7 - let files = []; // current directory listing [{name, isDir, size}] 8 - let currentPath = ""; // current browsing path 9 - let selectedIdx = 0; 10 - let scrollOffset = 0; 11 - let activeDeck = 0; // 0 = A, 1 = B 6 + const FALLBACK_DIRS = ["/mnt/samples", "/mnt", "/media"]; 7 + const AUDIO_EXTS = new Set(["mp3", "wav", "flac", "ogg", "aac", "m4a", "opus", "wma"]); 8 + 9 + // State 10 + let files = []; // all discovered audio files [{path, name, size}] 11 + let queue = []; // upcoming tracks (indices into files[]) 12 + let queueIdx = 0; 13 + let activeDeck = 0; // 0 = A, 1 = B 14 + let scratching = [false, false]; // per-deck scratch mode 15 + let scratchPos = [0, 0]; // scratch position (virtual "angle") 16 + let crossfader = 0.5; 12 17 let mounted = false; 13 18 let message = ""; 14 19 let messageFrame = 0; 15 20 let frame = 0; 16 - 17 - // Audio file extensions 18 - const AUDIO_EXTS = new Set(["mp3", "wav", "flac", "ogg", "aac", "m4a", "opus", "wma"]); 21 + let lastUsbCheck = 0; 22 + let usbConnected = false; 23 + let view = "decks"; // "decks" | "browser" | "queue" 24 + let browserPath = ""; 25 + let browserFiles = []; 26 + let browserIdx = 0; 27 + let browserScroll = 0; 19 28 20 29 function isAudioFile(name) { 21 30 const dot = name.lastIndexOf("."); 22 - if (dot < 0) return false; 23 - return AUDIO_EXTS.has(name.slice(dot + 1).toLowerCase()); 31 + return dot >= 0 && AUDIO_EXTS.has(name.slice(dot + 1).toLowerCase()); 24 32 } 25 33 26 - function browseDir(system, path) { 34 + // Recursively scan a directory for audio files 35 + function scanDir(system, path, results, depth) { 36 + if (depth > 5) return; // safety limit 27 37 const listing = system?.listDir?.(path); 28 - if (!listing) { 29 - files = []; 30 - return; 38 + if (!listing) return; 39 + for (const f of listing) { 40 + const full = path + "/" + f.name; 41 + if (f.isDir && !f.name.startsWith(".")) { 42 + scanDir(system, full, results, depth + 1); 43 + } else if (isAudioFile(f.name)) { 44 + results.push({ path: full, name: f.name, size: f.size || 0 }); 45 + } 31 46 } 32 - // Sort: directories first, then audio files, alphabetically 33 - files = listing 34 - .filter(f => f.isDir || isAudioFile(f.name)) 35 - .sort((a, b) => { 36 - if (a.isDir !== b.isDir) return a.isDir ? -1 : 1; 37 - return a.name.localeCompare(b.name); 38 - }); 39 - currentPath = path; 40 - selectedIdx = 0; 41 - scrollOffset = 0; 47 + } 48 + 49 + function autoScan(system, tts) { 50 + files = []; 51 + // Try music dir first, then fallbacks 52 + const dirs = mounted ? [MUSIC_DIR, ...FALLBACK_DIRS] : FALLBACK_DIRS; 53 + for (const dir of dirs) { 54 + scanDir(system, dir, files, 0); 55 + } 56 + // Sort by name 57 + files.sort((a, b) => a.name.localeCompare(b.name)); 58 + 59 + // Build queue from all files 60 + queue = files.map((_, i) => i); 61 + queueIdx = 0; 62 + 63 + if (tts) { 64 + if (files.length > 0) { 65 + tts.speak(`Found ${files.length} tracks`); 66 + } else { 67 + tts.speak("No tracks found"); 68 + } 69 + } 70 + showMsg(files.length > 0 ? `${files.length} tracks found` : "No tracks"); 42 71 } 43 72 44 73 function showMsg(text) { ··· 46 75 messageFrame = frame; 47 76 } 48 77 49 - function formatTime(secs) { 78 + function fmt(secs) { 50 79 if (!secs || secs < 0) return "0:00"; 51 80 const m = Math.floor(secs / 60); 52 81 const s = Math.floor(secs % 60); 53 82 return `${m}:${s < 10 ? "0" : ""}${s}`; 54 83 } 55 84 56 - function formatSize(bytes) { 57 - if (bytes < 1024) return bytes + "B"; 58 - if (bytes < 1048576) return (bytes / 1024).toFixed(0) + "K"; 59 - return (bytes / 1048576).toFixed(1) + "M"; 85 + // Load next track from queue into deck 86 + function loadNext(sound, deck, tts) { 87 + if (files.length === 0) return; 88 + if (queueIdx >= queue.length) queueIdx = 0; // loop 89 + const f = files[queue[queueIdx]]; 90 + if (f) { 91 + const ok = sound?.deck?.load(deck, f.path); 92 + if (ok) { 93 + showMsg(`${deck ? "B" : "A"}: ${f.name}`); 94 + if (tts) tts.speak(`Deck ${deck ? "B" : "A"}, ${f.name.replace(/\.[^.]+$/, "")}`); 95 + queueIdx++; 96 + } 97 + } 60 98 } 61 99 62 - function boot({ system, sound }) { 63 - // Try to mount music USB 64 - mounted = system?.mountMusic?.() || false; 65 - 66 - // Browse music dir or fallback 67 - if (mounted) { 68 - browseDir(system, MUSIC_DIR); 69 - showMsg("Music USB mounted"); 70 - } else { 71 - browseDir(system, FALLBACK_DIR); 72 - showMsg("No USB found — browsing samples"); 100 + // Check USB hot-plug 101 + function checkUsb(system, tts) { 102 + const nowMounted = system?.mountMusic?.() || false; 103 + if (nowMounted && !usbConnected) { 104 + // USB plugged in 105 + usbConnected = true; 106 + mounted = true; 107 + if (tts) tts.speak("USB connected, scanning tracks"); 108 + autoScan(system, tts); 109 + } else if (!nowMounted && usbConnected) { 110 + // USB removed 111 + usbConnected = false; 112 + if (tts) tts.speak("USB removed"); 113 + showMsg("USB removed"); 73 114 } 115 + } 116 + 117 + function boot({ system, sound, tts }) { 118 + mounted = system?.mountMusic?.() || false; 119 + usbConnected = mounted; 120 + autoScan(system, tts); 74 121 75 122 // If decks already playing (returned from another piece), don't reset 76 123 const decks = sound?.deck?.decks; 77 124 if (decks?.[0]?.loaded || decks?.[1]?.loaded) { 78 125 showMsg("Decks resumed"); 126 + if (tts) tts.speak("Decks resumed"); 127 + } else if (files.length > 0) { 128 + // Auto-load first two tracks 129 + loadNext(sound, 0, tts); 130 + loadNext(sound, 1, null); // silent for deck B 79 131 } 80 132 } 81 133 82 - function act({ event: e, sound, system }) { 134 + function act({ event: e, sound, system, tts }) { 83 135 if (!e.is("keyboard:down")) return; 136 + const dk = sound?.deck; 137 + const decks = dk?.decks || [{}, {}]; 84 138 85 - // Exit (audio persists!) 86 - if (e.is("keyboard:down:escape")) { 87 - system?.jump?.("prompt"); 88 - return; 89 - } 139 + // --- Global --- 140 + if (e.is("keyboard:down:escape")) { system?.jump?.("prompt"); return; } 90 141 91 - // Deck selection 142 + // Tab: switch active deck 92 143 if (e.is("keyboard:down:tab")) { 93 144 activeDeck = activeDeck === 0 ? 1 : 0; 94 145 sound?.synth?.({ type: "sine", tone: activeDeck ? 880 : 660, duration: 0.04, volume: 0.06 }); 95 146 return; 96 147 } 97 148 98 - // Play/pause 149 + // V: switch view (decks / browser / queue) 150 + if (e.is("keyboard:down:v")) { 151 + view = view === "decks" ? "browser" : view === "browser" ? "queue" : "decks"; 152 + return; 153 + } 154 + 155 + // R: rescan tracks 156 + if (e.is("keyboard:down:r")) { 157 + autoScan(system, tts); 158 + return; 159 + } 160 + 161 + // --- Deck controls --- 162 + // Space: play/pause active deck 99 163 if (e.is("keyboard:down:space")) { 100 - const d = sound?.deck?.decks?.[activeDeck]; 164 + const d = decks[activeDeck]; 101 165 if (d?.loaded) { 102 - if (d.playing) { 103 - sound.deck.pause(activeDeck); 104 - showMsg(`Deck ${activeDeck ? "B" : "A"} paused`); 105 - } else { 106 - sound.deck.play(activeDeck); 107 - showMsg(`Deck ${activeDeck ? "B" : "A"} playing`); 108 - } 166 + if (d.playing) { dk.pause(activeDeck); showMsg(`${activeDeck ? "B" : "A"} paused`); } 167 + else { dk.play(activeDeck); showMsg(`${activeDeck ? "B" : "A"} playing`); } 109 168 } 110 169 return; 111 170 } 112 171 113 - // Load file into active deck 114 - if (e.is("keyboard:down:enter") || e.is("keyboard:down:return")) { 115 - if (files.length === 0) return; 116 - const sel = files[selectedIdx]; 117 - if (!sel) return; 118 - const fullPath = currentPath + "/" + sel.name; 119 - if (sel.isDir) { 120 - browseDir(system, fullPath); 121 - sound?.synth?.({ type: "sine", tone: 550, duration: 0.03, volume: 0.06 }); 122 - } else { 123 - const ok = sound?.deck?.load(activeDeck, fullPath); 124 - if (ok) { 125 - showMsg(`Loaded -> Deck ${activeDeck ? "B" : "A"}: ${sel.name}`); 126 - sound?.synth?.({ type: "sine", tone: 880, duration: 0.06, volume: 0.08 }); 127 - } else { 128 - showMsg(`Failed to load: ${sel.name}`); 129 - sound?.synth?.({ type: "square", tone: 200, duration: 0.1, volume: 0.08 }); 130 - } 131 - } 172 + // Q/W: play/pause deck A/B directly 173 + if (e.is("keyboard:down:q")) { 174 + const d = decks[0]; 175 + if (d?.loaded) { if (d.playing) dk.pause(0); else dk.play(0); } 132 176 return; 133 177 } 134 - 135 - // Navigate file browser 136 - if (e.is("keyboard:down:arrowdown")) { 137 - if (files.length > 0) selectedIdx = Math.min(selectedIdx + 1, files.length - 1); 138 - sound?.synth?.({ type: "sine", tone: 440, duration: 0.02, volume: 0.04 }); 178 + if (e.is("keyboard:down:w")) { 179 + const d = decks[1]; 180 + if (d?.loaded) { if (d.playing) dk.pause(1); else dk.play(1); } 139 181 return; 140 182 } 141 - if (e.is("keyboard:down:arrowup")) { 142 - if (files.length > 0) selectedIdx = Math.max(selectedIdx - 1, 0); 143 - sound?.synth?.({ type: "sine", tone: 480, duration: 0.02, volume: 0.04 }); 183 + 184 + // N: load next track into active deck 185 + if (e.is("keyboard:down:n")) { 186 + loadNext(sound, activeDeck, tts); 144 187 return; 145 188 } 146 189 147 - // Go up directory 148 - if (e.is("keyboard:down:backspace")) { 149 - const parent = currentPath.replace(/\/[^/]*$/, "") || "/"; 150 - if (parent !== currentPath) { 151 - browseDir(system, parent); 152 - sound?.synth?.({ type: "sine", tone: 330, duration: 0.04, volume: 0.06 }); 153 - } 190 + // S: toggle scratch mode for active deck 191 + if (e.is("keyboard:down:s")) { 192 + scratching[activeDeck] = !scratching[activeDeck]; 193 + showMsg(`Scratch ${scratching[activeDeck] ? "ON" : "OFF"} (${activeDeck ? "B" : "A"})`); 154 194 return; 155 195 } 156 196 157 - // Seek 197 + // --- Scratch / Seek --- 158 198 if (e.is("keyboard:down:arrowleft")) { 159 - const d = sound?.deck?.decks?.[activeDeck]; 160 - if (d?.loaded) sound.deck.seek(activeDeck, Math.max(0, d.position - 5)); 199 + const d = decks[activeDeck]; 200 + if (d?.loaded) { 201 + if (scratching[activeDeck]) { 202 + // Scratch: small backward jumps 203 + dk.seek(activeDeck, Math.max(0, d.position - 0.1)); 204 + scratchPos[activeDeck] -= 5; 205 + } else { 206 + dk.seek(activeDeck, Math.max(0, d.position - 5)); 207 + } 208 + } 161 209 return; 162 210 } 163 211 if (e.is("keyboard:down:arrowright")) { 164 - const d = sound?.deck?.decks?.[activeDeck]; 165 - if (d?.loaded) sound.deck.seek(activeDeck, Math.min(d.duration, d.position + 5)); 212 + const d = decks[activeDeck]; 213 + if (d?.loaded) { 214 + if (scratching[activeDeck]) { 215 + dk.seek(activeDeck, Math.min(d.duration, d.position + 0.1)); 216 + scratchPos[activeDeck] += 5; 217 + } else { 218 + dk.seek(activeDeck, Math.min(d.duration, d.position + 5)); 219 + } 220 + } 166 221 return; 167 222 } 168 223 169 - // Crossfader 224 + // --- Crossfader --- 170 225 if (e.is("keyboard:down:[")) { 171 - const cf = Math.max(0, (sound?.deck?.crossfaderValue || 0.5) - 0.05); 172 - sound?.deck?.setCrossfader(cf); 226 + crossfader = Math.max(0, crossfader - 0.05); 227 + dk?.setCrossfader(crossfader); 173 228 return; 174 229 } 175 230 if (e.is("keyboard:down:]")) { 176 - const cf = Math.min(1, (sound?.deck?.crossfaderValue || 0.5) + 0.05); 177 - sound?.deck?.setCrossfader(cf); 231 + crossfader = Math.min(1, crossfader + 0.05); 232 + dk?.setCrossfader(crossfader); 178 233 return; 179 234 } 180 235 181 - // Volume 182 - if (e.is("keyboard:down:-")) { 183 - const d = sound?.deck?.decks?.[activeDeck]; 184 - if (d) sound.deck.setVolume(activeDeck, Math.max(0, d.volume - 0.05)); 236 + // --- Speed / Pitch --- 237 + if (e.is("keyboard:down:z")) { 238 + const d = decks[activeDeck]; 239 + if (d?.loaded) dk.setSpeed(activeDeck, Math.max(0.5, d.speed - 0.05)); 185 240 return; 186 241 } 187 - if (e.is("keyboard:down:=")) { 188 - const d = sound?.deck?.decks?.[activeDeck]; 189 - if (d) sound.deck.setVolume(activeDeck, Math.min(1, d.volume + 0.05)); 242 + if (e.is("keyboard:down:x")) { 243 + const d = decks[activeDeck]; 244 + if (d?.loaded) dk.setSpeed(activeDeck, Math.min(2.0, d.speed + 0.05)); 190 245 return; 191 246 } 192 247 193 - // Speed 194 - if (e.is("keyboard:down:z")) { 195 - const d = sound?.deck?.decks?.[activeDeck]; 196 - if (d?.loaded) sound.deck.setSpeed(activeDeck, Math.max(0.5, d.speed - 0.05)); 248 + // --- Volume --- 249 + if (e.is("keyboard:down:-")) { 250 + const d = decks[activeDeck]; 251 + if (d) dk.setVolume(activeDeck, Math.max(0, d.volume - 0.05)); 197 252 return; 198 253 } 199 - if (e.is("keyboard:down:x")) { 200 - const d = sound?.deck?.decks?.[activeDeck]; 201 - if (d?.loaded) sound.deck.setSpeed(activeDeck, Math.min(2.0, d.speed + 0.05)); 254 + if (e.is("keyboard:down:=")) { 255 + const d = decks[activeDeck]; 256 + if (d) dk.setVolume(activeDeck, Math.min(1, d.volume + 0.05)); 202 257 return; 203 258 } 204 259 205 - // Quick play: Q = deck A, W = deck B 206 - if (e.is("keyboard:down:q")) { 207 - const d = sound?.deck?.decks?.[0]; 208 - if (d?.loaded) { if (d.playing) sound.deck.pause(0); else sound.deck.play(0); } 209 - return; 260 + // --- Browser view navigation --- 261 + if (view === "browser") { 262 + if (e.is("keyboard:down:arrowdown")) { 263 + if (browserFiles.length > 0) browserIdx = Math.min(browserIdx + 1, browserFiles.length - 1); 264 + return; 265 + } 266 + if (e.is("keyboard:down:arrowup")) { 267 + if (browserFiles.length > 0) browserIdx = Math.max(browserIdx - 1, 0); 268 + return; 269 + } 270 + if (e.is("keyboard:down:enter")) { 271 + const sel = browserFiles[browserIdx]; 272 + if (sel?.isDir) { 273 + browserPath = browserPath + "/" + sel.name; 274 + browseCurrent(system); 275 + } else if (sel) { 276 + const ok = dk?.load(activeDeck, browserPath + "/" + sel.name); 277 + if (ok) showMsg(`Loaded: ${sel.name}`); 278 + } 279 + return; 280 + } 281 + if (e.is("keyboard:down:backspace")) { 282 + browserPath = browserPath.replace(/\/[^/]*$/, "") || "/"; 283 + browseCurrent(system); 284 + return; 285 + } 210 286 } 211 - if (e.is("keyboard:down:w")) { 212 - const d = sound?.deck?.decks?.[1]; 213 - if (d?.loaded) { if (d.playing) sound.deck.pause(1); else sound.deck.play(1); } 214 - return; 287 + 288 + // --- Queue view --- 289 + if (view === "queue") { 290 + if (e.is("keyboard:down:arrowdown")) { 291 + if (queue.length > 0) queueIdx = Math.min(queueIdx + 1, queue.length - 1); 292 + return; 293 + } 294 + if (e.is("keyboard:down:arrowup")) { 295 + if (queue.length > 0) queueIdx = Math.max(queueIdx - 1, 0); 296 + return; 297 + } 215 298 } 216 299 } 217 300 218 - function paint({ wipe, ink, box, line, write, screen, sound }) { 301 + function browseCurrent(system) { 302 + const listing = system?.listDir?.(browserPath); 303 + browserFiles = (listing || []) 304 + .filter(f => f.isDir || isAudioFile(f.name)) 305 + .sort((a, b) => { 306 + if (a.isDir !== b.isDir) return a.isDir ? -1 : 1; 307 + return a.name.localeCompare(b.name); 308 + }); 309 + browserIdx = 0; 310 + browserScroll = 0; 311 + } 312 + 313 + function paint({ wipe, ink, box, line, write, circle, screen, sound }) { 219 314 frame++; 220 315 const w = screen.width, h = screen.height; 221 - const pad = 4; 316 + const P = 4; // padding 222 317 const F = "font_1"; 223 - const CW = 6; // font_1 char width 224 - wipe(8, 8, 12); 318 + const CW = 6; 319 + wipe(8, 8, 14); 225 320 226 - const decks = sound?.deck?.decks || [{}, {}]; 227 - const cf = sound?.deck?.crossfaderValue ?? 0.5; 228 - const deckW = Math.floor((w - pad * 3) / 2); 321 + const dk = sound?.deck; 322 + const decks = dk?.decks || [{}, {}]; 323 + const cf = crossfader; 229 324 230 - // --- Deck A (top-left) --- 231 - const drawDeck = (dk, d, x0) => { 232 - const isActive = d === activeDeck; 233 - const label = d === 0 ? "A" : "B"; 234 - const maxChars = Math.floor((deckW - 4) / CW); 325 + // --- Turntable decks --- 326 + const deckW = Math.floor((w - P * 3) / 2); 327 + const deckH = Math.min(120, Math.floor(h * 0.35)); 328 + 329 + const drawDeck = (d, idx, x0) => { 330 + const isActive = idx === activeDeck; 331 + const label = idx === 0 ? "A" : "B"; 332 + 333 + // Border 334 + ink(isActive ? 40 : 20, isActive ? 45 : 22, isActive ? 60 : 30); 335 + box(x0 - 1, P - 1, deckW + 2, deckH + 2); 336 + ink(12, 12, 20); 337 + box(x0, P, deckW, deckH); 235 338 236 - // Header: label + status 237 - ink(isActive ? 255 : 100, isActive ? 255 : 80, isActive ? 80 : 60); 238 - write(label, { x: x0, y: pad, size: 1, font: "matrix" }); 339 + // Label 340 + ink(isActive ? 255 : 80, isActive ? 255 : 60, isActive ? 100 : 50); 341 + write(label, { x: x0 + 2, y: P + 2, size: 1, font: "matrix" }); 239 342 240 - if (!dk.loaded) { 241 - ink(60, 60, 80); 242 - write("--", { x: x0 + 12, y: pad, size: 1, font: F }); 343 + if (!d.loaded) { 344 + ink(50, 50, 70); 345 + write("empty", { x: x0 + 14, y: P + 3, size: 1, font: F }); 346 + write("N=load next", { x: x0 + 4, y: P + deckH - 12, size: 1, font: F }); 243 347 return; 244 348 } 245 349 246 - // Playing indicator 247 - if (dk.playing) { 248 - ink(60, 220, 60); 249 - write(">", { x: x0 + 12, y: pad, size: 1, font: F }); 250 - } else { 251 - ink(180, 180, 60); 252 - write("=", { x: x0 + 12, y: pad, size: 1, font: F }); 350 + // Turntable platter (circle) 351 + const cx = x0 + deckW - 30; 352 + const cy = P + 30; 353 + const r = 20; 354 + ink(25, 25, 40); 355 + circle(cx, cy, r, true); 356 + ink(isActive ? 60 : 35, isActive ? 60 : 35, isActive ? 80 : 50); 357 + circle(cx, cy, r, false); 358 + 359 + // Spinning indicator (rotates with position) 360 + const angle = (d.position || 0) * 3; 361 + const nx = cx + Math.cos(angle) * (r - 4); 362 + const ny = cy + Math.sin(angle) * (r - 4); 363 + ink(d.playing ? 100 : 60, d.playing ? 255 : 120, d.playing ? 100 : 60); 364 + circle(Math.floor(nx), Math.floor(ny), 2, true); 365 + 366 + // Scratch indicator 367 + if (scratching[idx]) { 368 + ink(255, 80, 80); 369 + write("SCR", { x: cx - 9, y: cy + r + 3, size: 1, font: F }); 253 370 } 254 371 255 - // Title (truncated) 372 + // Title 373 + const maxChars = Math.floor((deckW - 65) / CW); 256 374 ink(220, 220, 240); 257 - write((dk.title || "?").slice(0, maxChars - 3), { x: x0 + 20, y: pad, size: 1, font: F }); 375 + const title = (d.title || "?").replace(/\.[^.]+$/, ""); 376 + write(title.slice(0, maxChars), { x: x0 + 2, y: P + 14, size: 1, font: F }); 377 + 378 + // Play state 379 + ink(d.playing ? 60 : 180, d.playing ? 220 : 180, d.playing ? 60 : 60); 380 + write(d.playing ? "PLAY" : "STOP", { x: x0 + 2, y: P + 26, size: 1, font: F }); 258 381 259 382 // Progress bar 260 - const barY = pad + 12; 383 + const barY = P + 38; 261 384 const barW = deckW - 4; 262 - const progress = dk.duration > 0 ? dk.position / dk.duration : 0; 263 - ink(30, 30, 45); 264 - box(x0, barY, barW, 4); 265 - ink(dk.playing ? 60 : 40, dk.playing ? 180 : 100, dk.playing ? 60 : 40); 266 - box(x0, barY, Math.max(1, Math.floor(barW * progress)), 4); 385 + const progress = d.duration > 0 ? d.position / d.duration : 0; 386 + ink(25, 25, 40); 387 + box(x0 + 2, barY, barW, 4); 388 + ink(d.playing ? 60 : 40, d.playing ? 180 : 100, d.playing ? 60 : 40); 389 + box(x0 + 2, barY, Math.max(1, Math.floor(barW * progress)), 4); 267 390 268 - // Time + speed 391 + // Time 269 392 ink(140, 140, 160); 270 - const timeTxt = `${formatTime(dk.position)}/${formatTime(dk.duration)}`; 271 - write(timeTxt, { x: x0, y: barY + 6, size: 1, font: F }); 393 + write(`${fmt(d.position)}/${fmt(d.duration)}`, { x: x0 + 2, y: barY + 7, size: 1, font: F }); 394 + 395 + // Speed 272 396 ink(100, 100, 120); 273 - const spdTxt = `${dk.speed?.toFixed(2) || "1.00"}x`; 274 - write(spdTxt, { x: x0 + deckW - spdTxt.length * CW - 4, y: barY + 6, size: 1, font: F }); 397 + const spd = `${(d.speed || 1).toFixed(2)}x`; 398 + write(spd, { x: x0 + deckW - spd.length * CW - 4, y: barY + 7, size: 1, font: F }); 399 + 400 + // Volume bar 401 + const volY = barY + 20; 402 + ink(40, 40, 60); 403 + box(x0 + 2, volY, barW, 3); 404 + ink(100, 100, 200); 405 + box(x0 + 2, volY, Math.floor(barW * (d.volume || 1)), 3); 406 + ink(80, 80, 100); 407 + write("vol", { x: x0 + 2, y: volY + 5, size: 1, font: F }); 275 408 }; 276 409 277 - drawDeck(decks[0], 0, pad); 278 - drawDeck(decks[1], 1, pad * 2 + deckW); 410 + drawDeck(decks[0], 0, P); 411 + drawDeck(decks[1], 1, P * 2 + deckW); 279 412 280 - // --- Crossfader (horizontal bar below decks) --- 281 - const cfY = pad + 28; 282 - const cfW = w - pad * 2; 283 - ink(25, 25, 40); 284 - box(pad, cfY, cfW, 3); 413 + // --- Crossfader --- 414 + const cfY = P + deckH + 6; 415 + const cfW = w - P * 2; 416 + ink(20, 20, 35); 417 + box(P, cfY, cfW, 5); 285 418 const cfPos = Math.floor(cfW * cf); 286 419 ink(255, 200, 60); 287 - box(pad + cfPos - 2, cfY - 1, 5, 5); 288 - ink(50, 50, 70); 289 - write("A", { x: pad, y: cfY + 5, size: 1, font: F }); 290 - write("B", { x: w - pad - CW, y: cfY + 5, size: 1, font: F }); 420 + box(P + cfPos - 3, cfY - 2, 7, 9); 421 + ink(80, 80, 100); 422 + write("A", { x: P, y: cfY + 7, size: 1, font: F }); 423 + write("[ ]", { x: Math.floor(w / 2) - 9, y: cfY + 7, size: 1, font: F }); 424 + write("B", { x: w - P - CW, y: cfY + 7, size: 1, font: F }); 291 425 292 - // --- Divider --- 293 - const divY = cfY + 14; 294 - ink(30, 30, 50); 295 - line(0, divY, w, divY); 426 + // --- Lower section (view-dependent) --- 427 + const lowerY = cfY + 22; 428 + ink(25, 25, 40); 429 + line(0, lowerY - 2, w, lowerY - 2); 296 430 297 - // --- File browser --- 298 - const rowH = 12; 299 - const headerY = divY + 2; 300 - ink(100, 100, 140); 301 - const pathMax = Math.floor(w / CW) - 2; 302 - const pathStr = currentPath.length > pathMax 303 - ? "..." + currentPath.slice(-pathMax + 3) : currentPath; 304 - write(pathStr, { x: pad, y: headerY, size: 1, font: F }); 305 - 306 - // Scroll count 307 - if (files.length > 0) { 431 + if (view === "decks") { 432 + // Track queue preview 433 + ink(100, 100, 140); 434 + write("QUEUE", { x: P, y: lowerY, size: 1, font: F }); 308 435 ink(60, 60, 80); 309 - const si = `${selectedIdx + 1}/${files.length}`; 310 - write(si, { x: w - si.length * CW - pad, y: headerY, size: 1, font: F }); 311 - } 436 + write(`${files.length} tracks`, { x: P + 42, y: lowerY, size: 1, font: F }); 312 437 313 - const listY = headerY + 12; 314 - const maxVisible = Math.floor((h - listY - 14) / rowH); 438 + const rowH = 11; 439 + const maxRows = Math.floor((h - lowerY - 24) / rowH); 440 + for (let i = 0; i < maxRows && queueIdx + i < queue.length; i++) { 441 + const fi = files[queue[queueIdx + i]]; 442 + if (!fi) continue; 443 + const y = lowerY + 12 + i * rowH; 444 + const isCur = i === 0; 445 + if (isCur) { 446 + ink(20, 25, 35); 447 + box(0, y - 1, w, rowH); 448 + } 449 + ink(isCur ? 255 : 120, isCur ? 255 : 120, isCur ? 200 : 140); 450 + write(fi.name.replace(/\.[^.]+$/, "").slice(0, Math.floor(w / CW) - 2), 451 + { x: P, y, size: 1, font: F }); 452 + } 453 + } else if (view === "browser") { 454 + ink(100, 100, 140); 455 + write("BROWSE: " + (browserPath || "/"), { x: P, y: lowerY, size: 1, font: F }); 315 456 316 - if (selectedIdx < scrollOffset) scrollOffset = selectedIdx; 317 - if (selectedIdx >= scrollOffset + maxVisible) scrollOffset = selectedIdx - maxVisible + 1; 457 + const rowH = 11; 458 + const maxRows = Math.floor((h - lowerY - 24) / rowH); 459 + if (browserIdx < browserScroll) browserScroll = browserIdx; 460 + if (browserIdx >= browserScroll + maxRows) browserScroll = browserIdx - maxRows + 1; 318 461 319 - if (files.length === 0) { 320 - ink(80, 80, 100); 321 - write("(empty)", { x: pad, y: listY, size: 1, font: F }); 322 - } 323 - 324 - for (let i = 0; i < maxVisible && i + scrollOffset < files.length; i++) { 325 - const fi = files[i + scrollOffset]; 326 - const y = listY + i * rowH; 327 - const isSel = (i + scrollOffset) === selectedIdx; 328 - 329 - if (isSel) { 330 - ink(25, 30, 45); 331 - box(0, y - 1, w, rowH); 462 + for (let i = 0; i < maxRows && i + browserScroll < browserFiles.length; i++) { 463 + const fi = browserFiles[i + browserScroll]; 464 + const y = lowerY + 12 + i * rowH; 465 + const isSel = (i + browserScroll) === browserIdx; 466 + if (isSel) { ink(20, 25, 35); box(0, y - 1, w, rowH); } 467 + if (fi.isDir) { 468 + ink(isSel ? 255 : 120, isSel ? 200 : 100, isSel ? 80 : 60); 469 + write(`> ${fi.name}/`, { x: P, y, size: 1, font: F }); 470 + } else { 471 + ink(isSel ? 255 : 160, isSel ? 255 : 160, isSel ? 255 : 180); 472 + write(fi.name.slice(0, Math.floor(w / CW) - 2), { x: P + CW, y, size: 1, font: F }); 473 + } 332 474 } 475 + } else if (view === "queue") { 476 + ink(100, 100, 140); 477 + write(`QUEUE (${queue.length} tracks)`, { x: P, y: lowerY, size: 1, font: F }); 333 478 334 - const nameMax = Math.floor(w / CW) - 6; 335 - if (fi.isDir) { 336 - ink(isSel ? 255 : 120, isSel ? 200 : 120, isSel ? 80 : 80); 337 - write(`> ${fi.name.slice(0, nameMax)}/`, { x: pad, y: y, size: 1, font: F }); 338 - } else { 339 - ink(isSel ? 255 : 160, isSel ? 255 : 160, isSel ? 255 : 180); 340 - write(fi.name.slice(0, nameMax), { x: pad + CW, y: y, size: 1, font: F }); 341 - ink(80, 80, 100); 342 - const sz = formatSize(fi.size); 343 - write(sz, { x: w - sz.length * CW - pad, y: y, size: 1, font: F }); 479 + const rowH = 11; 480 + const maxRows = Math.floor((h - lowerY - 24) / rowH); 481 + for (let i = 0; i < maxRows && i < queue.length; i++) { 482 + const fi = files[queue[i]]; 483 + if (!fi) continue; 484 + const y = lowerY + 12 + i * rowH; 485 + const isCur = i === queueIdx; 486 + if (isCur) { ink(20, 25, 35); box(0, y - 1, w, rowH); } 487 + ink(isCur ? 255 : 120, isCur ? 200 : 120, isCur ? 80 : 140); 488 + write(`${i + 1}. ${fi.name.replace(/\.[^.]+$/, "").slice(0, Math.floor(w / CW) - 6)}`, 489 + { x: P, y, size: 1, font: F }); 344 490 } 345 491 } 346 492 347 493 // --- Status bar --- 348 494 const sY = h - 11; 349 - ink(15, 15, 25); 495 + ink(12, 12, 22); 350 496 box(0, sY - 1, w, 12); 351 - ink(120, 120, 140); 352 - const dl = activeDeck === 0 ? "A" : "B"; 353 - write(`${dl} Spc:play Tab:deck Esc:exit`, { x: pad, y: sY, size: 1, font: F }); 497 + ink(100, 100, 120); 498 + const dk_l = activeDeck === 0 ? "A" : "B"; 499 + const help = `${dk_l} Spc:play N:next S:scr V:view [:xf Tab:deck Esc:exit`; 500 + write(help.slice(0, Math.floor(w / CW)), { x: P, y: sY, size: 1, font: F }); 501 + 502 + // USB indicator 503 + ink(usbConnected ? 60 : 40, usbConnected ? 200 : 40, usbConnected ? 60 : 40); 504 + write(usbConnected ? "USB" : "---", { x: w - 22, y: sY, size: 1, font: F }); 354 505 355 - // --- Message --- 506 + // --- Message toast --- 356 507 if (message && frame - messageFrame < 120) { 357 508 const fade = Math.max(0, 255 - Math.floor((frame - messageFrame) * 2.5)); 358 509 ink(255, 220, 60, fade); 359 - write(message, { x: pad, y: divY - 10, size: 1, font: F }); 510 + write(message, { x: P, y: cfY - 10, size: 1, font: F }); 360 511 } 361 512 } 362 513 363 - function sim() {} 514 + function sim({ system, tts, sound }) { 515 + // USB hot-plug check every 2 seconds 516 + if (frame - lastUsbCheck > 120) { 517 + lastUsbCheck = frame; 518 + checkUsb(system, tts); 519 + } 520 + 521 + // Auto-advance: when a deck finishes, load next from queue 522 + const decks = sound?.deck?.decks || [{}, {}]; 523 + for (let i = 0; i < 2; i++) { 524 + const d = decks[i]; 525 + if (d?.loaded && !d.playing && d.position >= d.duration - 0.1 && d.duration > 0) { 526 + loadNext(sound, i, tts); 527 + sound?.deck?.play(i); 528 + } 529 + } 530 + } 364 531 365 532 function leave() { 366 - // Audio keeps playing — this is intentional! 533 + // Audio keeps playing — intentional! 367 534 } 368 535 369 536 export { boot, act, paint, sim, leave };