Monorepo for Aesthetic.Computer aesthetic.computer
4
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: simplify DJ — one big turntable, live mouse scratching

Complete rewrite: single track, one giant record filling the screen.
- Touch/drag anywhere to scratch (drag down=forward, up=rewind)
- Big spinning platter with grooves, needle, label
- N/P: next/prev track, Space: play/pause, R: rescan
- Auto-advance to next track when current ends
- USB hot-plug with TTS
- Theme-aware colors

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

+181 -486
+181 -486
fedac/native/pieces/dj.mjs
··· 1 - // dj.mjs — DJ deck piece for AC Native 2 - // Two decks with turntable interface, crossfader, auto-scan, hot-plug USB, TTS. 3 - // Audio continues when jumping to notepat or other pieces. 1 + // dj.mjs — Turntable piece for AC Native 2 + // One big record, one track, live scratching with mouse/touchpad. 3 + // Auto-scans USB for audio files. Simple, visual, interactive. 4 4 5 - const MUSIC_DIR = "/media"; 6 - const FALLBACK_DIRS = ["/mnt/samples", "/mnt", "/media"]; 7 5 const AUDIO_EXTS = new Set(["mp3", "wav", "flac", "ogg", "aac", "m4a", "opus", "wma"]); 6 + let T = __theme.update(); 8 7 9 8 // 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 dragging = -1; // which deck is being mouse-dragged (-1=none) 17 - let dragStartY = 0; // mouse Y at drag start 18 - let dragStartPos = 0; // deck position at drag start 19 - let crossfader = 0.5; 9 + let files = []; // discovered audio [{path, name}] 10 + let trackIdx = 0; // current track index 20 11 let mounted = false; 12 + let dragging = false; 13 + let dragLastY = 0; 14 + let angle = 0; // visual rotation angle 21 15 let message = ""; 22 16 let messageFrame = 0; 23 17 let frame = 0; 24 18 let lastUsbCheck = 0; 25 19 let usbConnected = false; 26 - let T = __theme.update(); 27 - let view = "decks"; // "decks" | "browser" | "queue" 28 - let browserPath = ""; 29 - let browserFiles = []; 30 - let browserIdx = 0; 31 - let browserScroll = 0; 20 + let spinSpeed = 0; // current spin velocity (auto-decays) 32 21 33 - function isAudioFile(name) { 22 + function isAudio(name) { 34 23 const dot = name.lastIndexOf("."); 35 24 return dot >= 0 && AUDIO_EXTS.has(name.slice(dot + 1).toLowerCase()); 36 25 } 37 26 38 - // Recursively scan a directory for audio files 39 27 function scanDir(system, path, results, depth) { 40 - if (depth > 5) return; // safety limit 28 + if (depth > 4) return; 41 29 const listing = system?.listDir?.(path); 42 30 if (!listing) return; 43 31 for (const f of listing) { 44 32 const full = path + "/" + f.name; 45 - if (f.isDir && !f.name.startsWith(".")) { 46 - scanDir(system, full, results, depth + 1); 47 - } else if (isAudioFile(f.name)) { 48 - results.push({ path: full, name: f.name, size: f.size || 0 }); 49 - } 33 + if (f.isDir && !f.name.startsWith(".")) scanDir(system, full, results, depth + 1); 34 + else if (isAudio(f.name)) results.push({ path: full, name: f.name }); 50 35 } 51 36 } 52 37 53 - function autoScan(system, tts) { 38 + function scan(system, tts) { 54 39 files = []; 55 - // Try music dir first, then fallbacks 56 - const dirs = mounted ? [MUSIC_DIR, ...FALLBACK_DIRS] : FALLBACK_DIRS; 57 - for (const dir of dirs) { 58 - scanDir(system, dir, files, 0); 59 - } 60 - // Sort by name 40 + const dirs = ["/media", "/mnt/samples", "/mnt"]; 41 + for (const d of dirs) scanDir(system, d, files, 0); 61 42 files.sort((a, b) => a.name.localeCompare(b.name)); 62 - 63 - // Build queue from all files 64 - queue = files.map((_, i) => i); 65 - queueIdx = 0; 66 - 67 - if (tts) { 68 - if (files.length > 0) { 69 - tts.speak(`Found ${files.length} tracks`); 70 - } else { 71 - tts.speak("No tracks found"); 72 - } 73 - } 74 - showMsg(files.length > 0 ? `${files.length} tracks found` : "No tracks"); 43 + if (tts) tts.speak(files.length > 0 ? `${files.length} tracks` : "no tracks"); 44 + msg(files.length > 0 ? `${files.length} tracks` : "no tracks found"); 75 45 } 76 46 77 - function showMsg(text) { 78 - message = text; 79 - messageFrame = frame; 80 - } 47 + function msg(t) { message = t; messageFrame = frame; } 81 48 82 - function fmt(secs) { 83 - if (!secs || secs < 0) return "0:00"; 84 - const m = Math.floor(secs / 60); 85 - const s = Math.floor(secs % 60); 86 - return `${m}:${s < 10 ? "0" : ""}${s}`; 49 + function fmt(s) { 50 + if (!s || s < 0) return "0:00"; 51 + return `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, "0")}`; 87 52 } 88 53 89 - // Load next track from queue into deck 90 - function loadNext(sound, deck, tts) { 54 + function loadTrack(sound, tts) { 91 55 if (files.length === 0) return; 92 - if (queueIdx >= queue.length) queueIdx = 0; // loop 93 - const f = files[queue[queueIdx]]; 94 - if (f) { 95 - const ok = sound?.deck?.load(deck, f.path); 96 - if (ok) { 97 - showMsg(`${deck ? "B" : "A"}: ${f.name}`); 98 - if (tts) tts.speak(`Deck ${deck ? "B" : "A"}, ${f.name.replace(/\.[^.]+$/, "")}`); 99 - queueIdx++; 100 - } 101 - } 102 - } 103 - 104 - // Check USB hot-plug 105 - function checkUsb(system, tts) { 106 - const nowMounted = system?.mountMusic?.() || false; 107 - if (nowMounted && !usbConnected) { 108 - // USB plugged in 109 - usbConnected = true; 110 - mounted = true; 111 - if (tts) tts.speak("USB connected, scanning tracks"); 112 - autoScan(system, tts); 113 - } else if (!nowMounted && usbConnected) { 114 - // USB removed 115 - usbConnected = false; 116 - if (tts) tts.speak("USB removed"); 117 - showMsg("USB removed"); 56 + if (trackIdx >= files.length) trackIdx = 0; 57 + const f = files[trackIdx]; 58 + const ok = sound?.deck?.load(0, f.path); 59 + if (ok) { 60 + msg(f.name.replace(/\.[^.]+$/, "")); 61 + if (tts) tts.speak(f.name.replace(/\.[^.]+$/, "")); 62 + sound.deck.play(0); 63 + spinSpeed = 1; 64 + } else { 65 + msg("failed: " + f.name); 118 66 } 119 67 } 120 68 121 69 function boot({ system, sound, tts }) { 122 70 mounted = system?.mountMusic?.() || false; 123 71 usbConnected = mounted; 124 - autoScan(system, tts); 125 - 126 - // If decks already playing (returned from another piece), don't reset 127 - const decks = sound?.deck?.decks; 128 - if (decks?.[0]?.loaded || decks?.[1]?.loaded) { 129 - showMsg("Decks resumed"); 130 - if (tts) tts.speak("Decks resumed"); 72 + scan(system, null); 73 + const d = sound?.deck?.decks?.[0]; 74 + if (d?.loaded) { 75 + msg("resumed"); 76 + if (d.playing) spinSpeed = 1; 131 77 } else if (files.length > 0) { 132 - // Auto-load first two tracks 133 - loadNext(sound, 0, tts); 134 - loadNext(sound, 1, null); // silent for deck B 78 + loadTrack(sound, tts); 135 79 } 136 80 } 137 81 138 - // Platter geometry (computed in paint, used in act) 139 - let platterA = { cx: 0, cy: 0, r: 0 }; 140 - let platterB = { cx: 0, cy: 0, r: 0 }; 141 - 142 82 function act({ event: e, sound, system, tts, screen }) { 143 83 const dk = sound?.deck; 144 - const decks = dk?.decks || [{}, {}]; 84 + const d = dk?.decks?.[0]; 145 85 const w = screen?.width || 320; 146 86 const h = screen?.height || 240; 147 - const P = 4; 148 - const deckW = Math.floor((w - P * 3) / 2); 149 87 150 - // --- Mouse scratch interaction --- 88 + // --- Mouse/touchpad scratch --- 151 89 if (e.is("touch")) { 152 - const mx = e.x, my = e.y; 153 - // Check if touching a platter 154 - for (let i = 0; i < 2; i++) { 155 - const pl = i === 0 ? platterA : platterB; 156 - const dx = mx - pl.cx, dy = my - pl.cy; 157 - if (dx * dx + dy * dy <= pl.r * pl.r) { 158 - dragging = i; 159 - dragStartY = my; 160 - dragStartPos = decks[i]?.position || 0; 161 - if (decks[i]?.playing) dk?.pause(i); 162 - return; 163 - } 164 - } 90 + dragging = true; 91 + dragLastY = e.y; 92 + spinSpeed = 0; 93 + if (d?.playing) dk.pause(0); 165 94 return; 166 95 } 167 - 168 - if (e.is("draw") && dragging >= 0) { 169 - const d = decks[dragging]; 96 + if (e.is("draw") && dragging) { 170 97 if (d?.loaded) { 171 - const dy = e.y - dragStartY; 172 - // Drag down = forward, drag up = rewind. 100px = 2 seconds 173 - const seekTo = Math.max(0, Math.min(d.duration, dragStartPos + dy * 0.02)); 174 - dk?.seek(dragging, seekTo); 175 - scratchPos[dragging] += (e.y - dragStartY) * 0.5; 176 - dragStartY = e.y; 177 - dragStartPos = seekTo; 98 + const dy = e.y - dragLastY; 99 + const seekAmt = dy * 0.03; // drag sensitivity 100 + const pos = Math.max(0, Math.min(d.duration, d.position + seekAmt)); 101 + dk.seek(0, pos); 102 + angle += dy * 0.02; 103 + dragLastY = e.y; 178 104 } 179 105 return; 180 106 } 181 - 182 - if (e.is("lift") && dragging >= 0) { 183 - dragging = -1; 107 + if (e.is("lift")) { 108 + dragging = false; 184 109 return; 185 110 } 186 111 187 112 if (!e.is("keyboard:down")) return; 188 113 189 - // --- Global --- 190 114 if (e.is("keyboard:down:escape")) { system?.jump?.("prompt"); return; } 191 115 192 - // Tab: switch active deck 193 - if (e.is("keyboard:down:tab")) { 194 - activeDeck = activeDeck === 0 ? 1 : 0; 195 - sound?.synth?.({ type: "sine", tone: activeDeck ? 880 : 660, duration: 0.04, volume: 0.06 }); 196 - return; 197 - } 198 - 199 - // V: switch view (decks / browser / queue) 200 - if (e.is("keyboard:down:v")) { 201 - view = view === "decks" ? "browser" : view === "browser" ? "queue" : "decks"; 202 - return; 203 - } 204 - 205 - // R: rescan tracks 206 - if (e.is("keyboard:down:r")) { 207 - autoScan(system, tts); 208 - return; 209 - } 210 - 211 - // --- Deck controls --- 212 - // Space: play/pause active deck 116 + // Space: play/pause 213 117 if (e.is("keyboard:down:space")) { 214 - const d = decks[activeDeck]; 215 118 if (d?.loaded) { 216 - if (d.playing) { dk.pause(activeDeck); showMsg(`${activeDeck ? "B" : "A"} paused`); } 217 - else { dk.play(activeDeck); showMsg(`${activeDeck ? "B" : "A"} playing`); } 119 + if (d.playing) { dk.pause(0); spinSpeed = 0; msg("paused"); } 120 + else { dk.play(0); spinSpeed = 1; msg("playing"); } 218 121 } 219 122 return; 220 123 } 221 124 222 - // Q/W: play/pause deck A/B directly 223 - if (e.is("keyboard:down:q")) { 224 - const d = decks[0]; 225 - if (d?.loaded) { if (d.playing) dk.pause(0); else dk.play(0); } 125 + // N: next track 126 + if (e.is("keyboard:down:n")) { 127 + trackIdx++; 128 + loadTrack(sound, tts); 226 129 return; 227 130 } 228 - if (e.is("keyboard:down:w")) { 229 - const d = decks[1]; 230 - if (d?.loaded) { if (d.playing) dk.pause(1); else dk.play(1); } 231 - return; 232 - } 233 - 234 - // N: load next track into active deck 235 - if (e.is("keyboard:down:n")) { 236 - loadNext(sound, activeDeck, tts); 131 + // P: prev track 132 + if (e.is("keyboard:down:p")) { 133 + trackIdx = Math.max(0, trackIdx - 1); 134 + loadTrack(sound, tts); 237 135 return; 238 136 } 239 137 240 - // S: toggle scratch mode for active deck 241 - if (e.is("keyboard:down:s")) { 242 - scratching[activeDeck] = !scratching[activeDeck]; 243 - showMsg(`Scratch ${scratching[activeDeck] ? "ON" : "OFF"} (${activeDeck ? "B" : "A"})`); 138 + // R: rescan 139 + if (e.is("keyboard:down:r")) { 140 + mounted = system?.mountMusic?.() || false; 141 + scan(system, tts); 142 + if (files.length > 0) { trackIdx = 0; loadTrack(sound, tts); } 244 143 return; 245 144 } 246 145 247 - // --- Scratch / Seek --- 146 + // Arrow keys: seek 248 147 if (e.is("keyboard:down:arrowleft")) { 249 - const d = decks[activeDeck]; 250 - if (d?.loaded) { 251 - if (scratching[activeDeck]) { 252 - // Scratch: small backward jumps 253 - dk.seek(activeDeck, Math.max(0, d.position - 0.1)); 254 - scratchPos[activeDeck] -= 5; 255 - } else { 256 - dk.seek(activeDeck, Math.max(0, d.position - 5)); 257 - } 258 - } 148 + if (d?.loaded) dk.seek(0, Math.max(0, d.position - 5)); 259 149 return; 260 150 } 261 151 if (e.is("keyboard:down:arrowright")) { 262 - const d = decks[activeDeck]; 263 - if (d?.loaded) { 264 - if (scratching[activeDeck]) { 265 - dk.seek(activeDeck, Math.min(d.duration, d.position + 0.1)); 266 - scratchPos[activeDeck] += 5; 267 - } else { 268 - dk.seek(activeDeck, Math.min(d.duration, d.position + 5)); 269 - } 270 - } 152 + if (d?.loaded) dk.seek(0, Math.min(d.duration, d.position + 5)); 271 153 return; 272 154 } 273 155 274 - // --- Crossfader --- 275 - if (e.is("keyboard:down:[")) { 276 - crossfader = Math.max(0, crossfader - 0.05); 277 - dk?.setCrossfader(crossfader); 278 - return; 279 - } 280 - if (e.is("keyboard:down:]")) { 281 - crossfader = Math.min(1, crossfader + 0.05); 282 - dk?.setCrossfader(crossfader); 283 - return; 284 - } 156 + // Volume 157 + if (e.is("keyboard:down:-")) { if (d) dk.setVolume(0, Math.max(0, d.volume - 0.05)); return; } 158 + if (e.is("keyboard:down:=")) { if (d) dk.setVolume(0, Math.min(1, d.volume + 0.05)); return; } 285 159 286 - // --- Speed / Pitch --- 287 - if (e.is("keyboard:down:z")) { 288 - const d = decks[activeDeck]; 289 - if (d?.loaded) dk.setSpeed(activeDeck, Math.max(0.5, d.speed - 0.05)); 290 - return; 291 - } 292 - if (e.is("keyboard:down:x")) { 293 - const d = decks[activeDeck]; 294 - if (d?.loaded) dk.setSpeed(activeDeck, Math.min(2.0, d.speed + 0.05)); 295 - return; 296 - } 297 - 298 - // --- Volume --- 299 - if (e.is("keyboard:down:-")) { 300 - const d = decks[activeDeck]; 301 - if (d) dk.setVolume(activeDeck, Math.max(0, d.volume - 0.05)); 302 - return; 303 - } 304 - if (e.is("keyboard:down:=")) { 305 - const d = decks[activeDeck]; 306 - if (d) dk.setVolume(activeDeck, Math.min(1, d.volume + 0.05)); 307 - return; 308 - } 309 - 310 - // --- Browser view navigation --- 311 - if (view === "browser") { 312 - if (e.is("keyboard:down:arrowdown")) { 313 - if (browserFiles.length > 0) browserIdx = Math.min(browserIdx + 1, browserFiles.length - 1); 314 - return; 315 - } 316 - if (e.is("keyboard:down:arrowup")) { 317 - if (browserFiles.length > 0) browserIdx = Math.max(browserIdx - 1, 0); 318 - return; 319 - } 320 - if (e.is("keyboard:down:enter")) { 321 - const sel = browserFiles[browserIdx]; 322 - if (sel?.isDir) { 323 - browserPath = browserPath + "/" + sel.name; 324 - browseCurrent(system); 325 - } else if (sel) { 326 - const ok = dk?.load(activeDeck, browserPath + "/" + sel.name); 327 - if (ok) showMsg(`Loaded: ${sel.name}`); 328 - } 329 - return; 330 - } 331 - if (e.is("keyboard:down:backspace")) { 332 - browserPath = browserPath.replace(/\/[^/]*$/, "") || "/"; 333 - browseCurrent(system); 334 - return; 335 - } 336 - } 337 - 338 - // --- Queue view --- 339 - if (view === "queue") { 340 - if (e.is("keyboard:down:arrowdown")) { 341 - if (queue.length > 0) queueIdx = Math.min(queueIdx + 1, queue.length - 1); 342 - return; 343 - } 344 - if (e.is("keyboard:down:arrowup")) { 345 - if (queue.length > 0) queueIdx = Math.max(queueIdx - 1, 0); 346 - return; 347 - } 348 - } 349 - } 350 - 351 - function browseCurrent(system) { 352 - const listing = system?.listDir?.(browserPath); 353 - browserFiles = (listing || []) 354 - .filter(f => f.isDir || isAudioFile(f.name)) 355 - .sort((a, b) => { 356 - if (a.isDir !== b.isDir) return a.isDir ? -1 : 1; 357 - return a.name.localeCompare(b.name); 358 - }); 359 - browserIdx = 0; 360 - browserScroll = 0; 160 + // Speed 161 + if (e.is("keyboard:down:z")) { if (d?.loaded) dk.setSpeed(0, Math.max(0.5, d.speed - 0.05)); return; } 162 + if (e.is("keyboard:down:x")) { if (d?.loaded) dk.setSpeed(0, Math.min(2.0, d.speed + 0.05)); return; } 361 163 } 362 164 363 165 function paint({ wipe, ink, box, line, write, circle, screen, sound }) { 364 166 frame++; 365 167 T = __theme.update(); 366 168 const w = screen.width, h = screen.height; 367 - const P = 4; // padding 368 169 const F = "font_1"; 369 170 const CW = 6; 370 171 wipe(T.bg[0], T.bg[1], T.bg[2]); 371 172 372 - const dk = sound?.deck; 373 - const decks = dk?.decks || [{}, {}]; 374 - const cf = crossfader; 173 + const d = sound?.deck?.decks?.[0] || {}; 375 174 376 - // --- Turntable decks --- 377 - const deckW = Math.floor((w - P * 3) / 2); 378 - const deckH = Math.min(120, Math.floor(h * 0.35)); 175 + // --- Big turntable --- 176 + const cx = Math.floor(w / 2); 177 + const cy = Math.floor(h / 2) - 10; 178 + const r = Math.min(cx - 8, cy - 16); 379 179 380 - const drawDeck = (d, idx, x0) => { 381 - const isActive = idx === activeDeck; 382 - const label = idx === 0 ? "A" : "B"; 180 + // Rotate angle 181 + if (d.playing && !dragging) { 182 + angle += (d.speed || 1) * 0.05; 183 + spinSpeed = d.speed || 1; 184 + } 383 185 384 - // Border 385 - const dim = T.fgMute || 60; 386 - ink(isActive ? dim : dim - 30, isActive ? dim + 5 : dim - 28, isActive ? dim + 20 : dim - 20); 387 - box(x0 - 1, P - 1, deckW + 2, deckH + 2); 388 - ink(T.bg[0] + 4, T.bg[1] + 4, T.bg[2] + 8); 389 - box(x0, P, deckW, deckH); 186 + // Platter 187 + const fg = T.fg || 200; 188 + const dim = T.fgMute || 60; 189 + ink(T.bg[0] + 8, T.bg[1] + 8, T.bg[2] + 12); 190 + circle(cx, cy, r, true); 390 191 391 - // Label 392 - const fg = T.fg || 220; 393 - ink(isActive ? fg : dim, isActive ? fg : dim - 20, isActive ? fg - 120 : dim - 10); 394 - write(label, { x: x0 + 2, y: P + 2, size: 1, font: "matrix" }); 192 + // Grooves 193 + for (let g = Math.floor(r * 0.3); g < r; g += 2) { 194 + const brightness = dragging ? 10 : 6; 195 + ink(T.bg[0] + brightness, T.bg[1] + brightness, T.bg[2] + brightness + 4); 196 + circle(cx, cy, g, false); 197 + } 395 198 396 - if (!d.loaded) { 397 - ink(dim, dim, dim + 10); 398 - write("empty", { x: x0 + 14, y: P + 3, size: 1, font: F }); 399 - write("N=load next", { x: x0 + 4, y: P + deckH - 12, size: 1, font: F }); 400 - return; 401 - } 199 + // Rim 200 + ink(dragging ? 120 : dim, dragging ? 120 : dim, dragging ? 140 : dim + 15); 201 + circle(cx, cy, r, false); 402 202 403 - // Turntable platter (large interactive circle) 404 - const r = Math.min(Math.floor(deckW * 0.3), Math.floor(deckH * 0.35)); 405 - const cx = x0 + deckW - r - 6; 406 - const cy = P + r + 6; 407 - // Store for mouse hit testing 408 - if (idx === 0) { platterA.cx = cx; platterA.cy = cy; platterA.r = r; } 409 - else { platterB.cx = cx; platterB.cy = cy; platterB.r = r; } 410 - const isDragging = dragging === idx; 411 - // Platter background 412 - ink(isDragging ? 35 : 20, isDragging ? 35 : 20, isDragging ? 50 : 35); 413 - circle(cx, cy, r, true); 414 - // Groove rings 415 - for (let gr = Math.floor(r * 0.4); gr < r; gr += 3) { 416 - ink(isDragging ? 45 : 28, isDragging ? 45 : 28, isDragging ? 60 : 42); 417 - circle(cx, cy, gr, false); 418 - } 419 - // Rim 420 - ink(isActive ? 80 : 45, isActive ? 80 : 45, isActive ? 100 : 60); 421 - circle(cx, cy, r, false); 422 - // Label dot (center) 423 - ink(isDragging ? 255 : 60, isDragging ? 100 : 60, isDragging ? 60 : 80); 424 - circle(cx, cy, Math.max(3, Math.floor(r * 0.15)), true); 203 + // Label (center area) 204 + const labelR = Math.floor(r * 0.25); 205 + ink(dragging ? 60 : 30, dragging ? 40 : 25, dragging ? 30 : 20); 206 + circle(cx, cy, labelR, true); 207 + ink(dragging ? 100 : 50, dragging ? 80 : 40, dragging ? 60 : 35); 208 + circle(cx, cy, labelR, false); 425 209 426 - // Spinning needle (rotates with position + scratch) 427 - const angle = ((d.position || 0) + scratchPos[idx] * 0.01) * 3; 428 - const nx = cx + Math.cos(angle) * (r - 4); 429 - const ny = cy + Math.sin(angle) * (r - 4); 430 - ink(d.playing ? 100 : 60, d.playing ? 255 : 120, d.playing ? 100 : 60); 431 - circle(Math.floor(nx), Math.floor(ny), Math.max(2, Math.floor(r * 0.08)), true); 432 - // Line from center to needle 433 - ink(50, 50, 70); 434 - line(cx, cy, Math.floor(nx), Math.floor(ny)); 210 + // Center dot 211 + ink(fg, fg - 30, fg - 60); 212 + circle(cx, cy, 3, true); 435 213 436 - // Drag hint 437 - if (isActive && !isDragging) { 438 - ink(dim, dim, dim + 10); 439 - write("drag", { x: cx - 12, y: cy + r + 3, size: 1, font: F }); 440 - } 441 - if (isDragging) { 442 - ink(255, 100, 60); 443 - write("SCRATCH", { x: cx - 20, y: cy + r + 3, size: 1, font: F }); 444 - } 214 + // Spinning needle line 215 + const needleLen = r - 6; 216 + const nx = cx + Math.cos(angle) * needleLen; 217 + const ny = cy + Math.sin(angle) * needleLen; 218 + ink(d.playing ? 80 : 40, d.playing ? 200 : 80, d.playing ? 80 : 40); 219 + line(cx, cy, Math.floor(nx), Math.floor(ny)); 220 + // Needle tip 221 + ink(d.playing ? 120 : 60, d.playing ? 255 : 120, d.playing ? 120 : 60); 222 + circle(Math.floor(nx), Math.floor(ny), Math.max(2, Math.floor(r * 0.04)), true); 445 223 446 - // Title 447 - const maxChars = Math.floor((deckW - 65) / CW); 448 - ink(fg, fg, fg + 20); 449 - const title = (d.title || "?").replace(/\.[^.]+$/, ""); 450 - write(title.slice(0, maxChars), { x: x0 + 2, y: P + 14, size: 1, font: F }); 224 + // --- Track info --- 225 + const title = d.loaded ? (d.title || "?").replace(/\.[^.]+$/, "") : "no track"; 226 + const maxC = Math.floor(w / CW) - 2; 451 227 452 - // Play state 453 - ink(d.playing ? 60 : 180, d.playing ? 220 : 180, d.playing ? 60 : 60); 454 - write(d.playing ? "PLAY" : "STOP", { x: x0 + 2, y: P + 26, size: 1, font: F }); 228 + // Title at top 229 + ink(fg, fg, fg + 10); 230 + write(title.slice(0, maxC), { x: 4, y: 3, size: 1, font: F }); 455 231 456 - // Progress bar 457 - const barY = P + 38; 458 - const barW = deckW - 4; 459 - const progress = d.duration > 0 ? d.position / d.duration : 0; 460 - ink(25, 25, 40); 461 - box(x0 + 2, barY, barW, 4); 462 - ink(d.playing ? 60 : 40, d.playing ? 180 : 100, d.playing ? 60 : 40); 463 - box(x0 + 2, barY, Math.max(1, Math.floor(barW * progress)), 4); 232 + // Progress bar 233 + const barY = h - 22; 234 + const barW = w - 8; 235 + const progress = d.duration > 0 ? d.position / d.duration : 0; 236 + ink(T.bg[0] + 15, T.bg[1] + 15, T.bg[2] + 20); 237 + box(4, barY, barW, 4); 238 + ink(d.playing ? 60 : 40, d.playing ? 180 : 100, d.playing ? 60 : 40); 239 + box(4, barY, Math.max(1, Math.floor(barW * progress)), 4); 464 240 465 - // Time 466 - ink(140, 140, 160); 467 - write(`${fmt(d.position)}/${fmt(d.duration)}`, { x: x0 + 2, y: barY + 7, size: 1, font: F }); 468 - 469 - // Speed 470 - ink(100, 100, 120); 241 + // Time + speed 242 + ink(dim + 20, dim + 20, dim + 30); 243 + write(`${fmt(d.position)} / ${fmt(d.duration)}`, { x: 4, y: barY + 7, size: 1, font: F }); 244 + if (d.loaded) { 471 245 const spd = `${(d.speed || 1).toFixed(2)}x`; 472 - write(spd, { x: x0 + deckW - spd.length * CW - 4, y: barY + 7, size: 1, font: F }); 473 - 474 - // Volume bar 475 - const volY = barY + 20; 476 - ink(40, 40, 60); 477 - box(x0 + 2, volY, barW, 3); 478 - ink(100, 100, 200); 479 - box(x0 + 2, volY, Math.floor(barW * (d.volume || 1)), 3); 480 - ink(80, 80, 100); 481 - write("vol", { x: x0 + 2, y: volY + 5, size: 1, font: F }); 482 - }; 483 - 484 - drawDeck(decks[0], 0, P); 485 - drawDeck(decks[1], 1, P * 2 + deckW); 486 - 487 - // --- Crossfader --- 488 - const cfY = P + deckH + 6; 489 - const cfW = w - P * 2; 490 - ink(20, 20, 35); 491 - box(P, cfY, cfW, 5); 492 - const cfPos = Math.floor(cfW * cf); 493 - ink(255, 200, 60); 494 - box(P + cfPos - 3, cfY - 2, 7, 9); 495 - ink(80, 80, 100); 496 - write("A", { x: P, y: cfY + 7, size: 1, font: F }); 497 - write("[ ]", { x: Math.floor(w / 2) - 9, y: cfY + 7, size: 1, font: F }); 498 - write("B", { x: w - P - CW, y: cfY + 7, size: 1, font: F }); 499 - 500 - // --- Lower section (view-dependent) --- 501 - const lowerY = cfY + 22; 502 - ink(25, 25, 40); 503 - line(0, lowerY - 2, w, lowerY - 2); 504 - 505 - if (view === "decks") { 506 - // Track queue preview 507 - ink(100, 100, 140); 508 - write("QUEUE", { x: P, y: lowerY, size: 1, font: F }); 509 - ink(60, 60, 80); 510 - write(`${files.length} tracks`, { x: P + 42, y: lowerY, size: 1, font: F }); 511 - 512 - const rowH = 11; 513 - const maxRows = Math.floor((h - lowerY - 24) / rowH); 514 - for (let i = 0; i < maxRows && queueIdx + i < queue.length; i++) { 515 - const fi = files[queue[queueIdx + i]]; 516 - if (!fi) continue; 517 - const y = lowerY + 12 + i * rowH; 518 - const isCur = i === 0; 519 - if (isCur) { 520 - ink(20, 25, 35); 521 - box(0, y - 1, w, rowH); 522 - } 523 - ink(isCur ? 255 : 120, isCur ? 255 : 120, isCur ? 200 : 140); 524 - write(fi.name.replace(/\.[^.]+$/, "").slice(0, Math.floor(w / CW) - 2), 525 - { x: P, y, size: 1, font: F }); 526 - } 527 - } else if (view === "browser") { 528 - ink(100, 100, 140); 529 - write("BROWSE: " + (browserPath || "/"), { x: P, y: lowerY, size: 1, font: F }); 530 - 531 - const rowH = 11; 532 - const maxRows = Math.floor((h - lowerY - 24) / rowH); 533 - if (browserIdx < browserScroll) browserScroll = browserIdx; 534 - if (browserIdx >= browserScroll + maxRows) browserScroll = browserIdx - maxRows + 1; 535 - 536 - for (let i = 0; i < maxRows && i + browserScroll < browserFiles.length; i++) { 537 - const fi = browserFiles[i + browserScroll]; 538 - const y = lowerY + 12 + i * rowH; 539 - const isSel = (i + browserScroll) === browserIdx; 540 - if (isSel) { ink(20, 25, 35); box(0, y - 1, w, rowH); } 541 - if (fi.isDir) { 542 - ink(isSel ? 255 : 120, isSel ? 200 : 100, isSel ? 80 : 60); 543 - write(`> ${fi.name}/`, { x: P, y, size: 1, font: F }); 544 - } else { 545 - ink(isSel ? 255 : 160, isSel ? 255 : 160, isSel ? 255 : 180); 546 - write(fi.name.slice(0, Math.floor(w / CW) - 2), { x: P + CW, y, size: 1, font: F }); 547 - } 548 - } 549 - } else if (view === "queue") { 550 - ink(100, 100, 140); 551 - write(`QUEUE (${queue.length} tracks)`, { x: P, y: lowerY, size: 1, font: F }); 246 + write(spd, { x: w - spd.length * CW - 4, y: barY + 7, size: 1, font: F }); 247 + } 552 248 553 - const rowH = 11; 554 - const maxRows = Math.floor((h - lowerY - 24) / rowH); 555 - for (let i = 0; i < maxRows && i < queue.length; i++) { 556 - const fi = files[queue[i]]; 557 - if (!fi) continue; 558 - const y = lowerY + 12 + i * rowH; 559 - const isCur = i === queueIdx; 560 - if (isCur) { ink(20, 25, 35); box(0, y - 1, w, rowH); } 561 - ink(isCur ? 255 : 120, isCur ? 200 : 120, isCur ? 80 : 140); 562 - write(`${i + 1}. ${fi.name.replace(/\.[^.]+$/, "").slice(0, Math.floor(w / CW) - 6)}`, 563 - { x: P, y, size: 1, font: F }); 564 - } 249 + // Track counter 250 + if (files.length > 0) { 251 + ink(dim, dim, dim + 10); 252 + const tc = `${trackIdx + 1}/${files.length}`; 253 + write(tc, { x: w - tc.length * CW - 4, y: 3, size: 1, font: F }); 565 254 } 566 255 567 - // --- Status bar --- 568 - const sY = h - 11; 569 - ink(12, 12, 22); 570 - box(0, sY - 1, w, 12); 571 - ink(100, 100, 120); 572 - const dk_l = activeDeck === 0 ? "A" : "B"; 573 - const help = `${dk_l} Spc:play N:next S:scr V:view [:xf Tab:deck Esc:exit`; 574 - write(help.slice(0, Math.floor(w / CW)), { x: P, y: sY, size: 1, font: F }); 256 + // Status bar 257 + const sY = h - 10; 258 + ink(dim, dim, dim + 10); 259 + write("Spc:play N/P:track R:scan Esc:exit", { x: 4, y: sY, size: 1, font: F }); 575 260 576 - // USB indicator 577 - ink(usbConnected ? 60 : 40, usbConnected ? 200 : 40, usbConnected ? 60 : 40); 578 - write(usbConnected ? "USB" : "---", { x: w - 22, y: sY, size: 1, font: F }); 261 + // Drag state 262 + if (dragging) { 263 + ink(255, 100, 60); 264 + write("SCRATCH", { x: cx - 21, y: cy - 5, size: 1, font: F }); 265 + } 579 266 580 - // --- Message toast --- 267 + // Message toast 581 268 if (message && frame - messageFrame < 120) { 582 269 const fade = Math.max(0, 255 - Math.floor((frame - messageFrame) * 2.5)); 583 270 ink(255, 220, 60, fade); 584 - write(message, { x: P, y: cfY - 10, size: 1, font: F }); 271 + write(message.slice(0, maxC), { x: 4, y: 14, size: 1, font: F }); 585 272 } 273 + 274 + // USB indicator 275 + ink(usbConnected ? 60 : dim, usbConnected ? 180 : dim, usbConnected ? 60 : dim); 276 + write(usbConnected ? "USB" : "---", { x: w - 22, y: sY, size: 1, font: F }); 586 277 } 587 278 588 279 function sim({ system, tts, sound }) { 589 - // USB hot-plug check every 2 seconds 590 - if (frame - lastUsbCheck > 120) { 280 + // USB hot-plug check every 3 seconds 281 + if (frame - lastUsbCheck > 180) { 591 282 lastUsbCheck = frame; 592 - checkUsb(system, tts); 593 - } 594 - 595 - // Auto-advance: when a deck finishes, load next from queue 596 - const decks = sound?.deck?.decks || [{}, {}]; 597 - for (let i = 0; i < 2; i++) { 598 - const d = decks[i]; 599 - if (d?.loaded && !d.playing && d.position >= d.duration - 0.1 && d.duration > 0) { 600 - loadNext(sound, i, tts); 601 - sound?.deck?.play(i); 283 + const nowMounted = system?.mountMusic?.() || false; 284 + if (nowMounted && !usbConnected) { 285 + usbConnected = true; mounted = true; 286 + if (tts) tts.speak("USB connected"); 287 + scan(system, tts); 288 + if (files.length > 0) { trackIdx = 0; loadTrack(sound, tts); } 289 + } else if (!nowMounted && usbConnected) { 290 + usbConnected = false; 291 + if (tts) tts.speak("USB removed"); 292 + msg("USB removed"); 602 293 } 603 294 } 295 + // Auto-advance when track ends 296 + const d = sound?.deck?.decks?.[0]; 297 + if (d?.loaded && !d.playing && d.position >= d.duration - 0.1 && d.duration > 0 && !dragging) { 298 + trackIdx++; 299 + loadTrack(sound, tts); 300 + } 604 301 } 605 302 606 - function leave() { 607 - // Audio keeps playing — intentional! 608 - } 303 + function leave() {} 609 304 610 305 export { boot, act, paint, sim, leave };