Monorepo for Aesthetic.Computer aesthetic.computer
4
fork

Configure Feed

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

merge adoring-galileo: stream fast-path no-CORS stations + chat commands

+513 -22
+110
system/netlify/functions/piece-commits.mjs
··· 1 + // Returns the most recent commit per piece in the disks directory. 2 + 3 + import fs from "fs"; 4 + import path from "path"; 5 + import { execFile } from "child_process"; 6 + import { promisify } from "util"; 7 + 8 + const execFileAsync = promisify(execFile); 9 + const DISKS_PATH = "system/public/aesthetic.computer/disks/"; 10 + const GIT_BRANCH = process.env.VERSION_GIT_BRANCH || "main"; 11 + const LOOK_BACK = 500; // commits to scan 12 + 13 + function getRepoRoot() { 14 + const candidates = [path.resolve(process.cwd(), ".."), process.cwd()]; 15 + return candidates.find((c) => fs.existsSync(path.join(c, ".git"))) || null; 16 + } 17 + 18 + async function git(args, repoRoot) { 19 + const { stdout } = await execFileAsync("git", ["-C", repoRoot, ...args], { 20 + timeout: 20000, 21 + maxBuffer: 16 * 1024 * 1024, 22 + }); 23 + return stdout; 24 + } 25 + 26 + async function getPreferredRemote(repoRoot) { 27 + if (process.env.VERSION_GIT_REMOTE) return process.env.VERSION_GIT_REMOTE; 28 + const remotes = (await git(["remote"], repoRoot)) 29 + .split("\n").map((r) => r.trim()).filter(Boolean); 30 + if (remotes.includes("tangled")) return "tangled"; 31 + if (remotes.includes("origin")) return "origin"; 32 + return remotes[0] || "origin"; 33 + } 34 + 35 + function parseCommits(raw) { 36 + const blocks = raw.split("<<COMMIT>>").map((b) => b.trim()).filter(Boolean); 37 + const result = {}; 38 + 39 + for (const block of blocks) { 40 + const lines = block.split("\n"); 41 + const header = lines[0]; 42 + const parts = header.split("|"); 43 + if (parts.length < 5) continue; 44 + const [sha, , author, , date, ...msgParts] = parts; 45 + const message = msgParts.join("|").trim(); 46 + 47 + for (let i = 1; i < lines.length; i++) { 48 + const line = lines[i].trim(); 49 + if (!line) continue; 50 + const cols = line.split("\t"); 51 + if (cols.length < 3) continue; 52 + const filePath = cols[2]; 53 + if (!filePath.startsWith(DISKS_PATH)) continue; 54 + 55 + const basename = path.basename(filePath); 56 + const name = basename.replace(/\.(mjs|lisp|l5|js)$/, ""); 57 + if (!name || name === basename) continue; // no recognized extension 58 + 59 + // Only keep the first (most recent) commit per piece 60 + if (!result[name]) { 61 + result[name] = { date, message, author, hash: sha.slice(0, 7) }; 62 + } 63 + } 64 + } 65 + 66 + return result; 67 + } 68 + 69 + export default async (request) => { 70 + const repoRoot = getRepoRoot(); 71 + if (!repoRoot) { 72 + return new Response(JSON.stringify({ commits: {} }), { 73 + status: 500, 74 + headers: { "Content-Type": "application/json" }, 75 + }); 76 + } 77 + 78 + try { 79 + const remote = await getPreferredRemote(repoRoot); 80 + 81 + const raw = await git( 82 + [ 83 + "log", 84 + `${remote}/${GIT_BRANCH}`, 85 + `--max-count=${LOOK_BACK}`, 86 + "--pretty=format:<<COMMIT>>%H|%P|%an|%ae|%cI|%s", 87 + "--numstat", 88 + "--", 89 + DISKS_PATH, 90 + ], 91 + repoRoot, 92 + ); 93 + 94 + const commits = parseCommits(raw); 95 + 96 + return new Response(JSON.stringify({ commits }), { 97 + headers: { 98 + "Content-Type": "application/json", 99 + "Cache-Control": "public, max-age=120", 100 + }, 101 + }); 102 + } catch (e) { 103 + return new Response(JSON.stringify({ error: e.message, commits: {} }), { 104 + status: 500, 105 + headers: { "Content-Type": "application/json" }, 106 + }); 107 + } 108 + }; 109 + 110 + export const config = { path: "/api/piece-commits" };
+47 -6
system/public/aesthetic.computer/bios.mjs
··· 16539 16539 // 🎵 Streaming Audio (Radio, etc.) 16540 16540 // Start playing a streaming audio URL 16541 16541 if (type === "stream:play") { 16542 - const { id, url, volume } = content; 16542 + const { id, url, volume, cors } = content; 16543 16543 16544 16544 // Stop any existing stream with this id 16545 16545 if (streamAudio[id]) { ··· 16554 16554 // buffered data. Matches the approach used by kpbj.fm's own navbar player. 16555 16555 const bustedUrl = url + (url.includes("?") ? "&" : "?") + "t=" + Date.now(); 16556 16556 16557 - // Try with CORS first so we can hook up the analyser (for r8dio/radio.co 16558 - // which sends Access-Control-Allow-Origin). If the server doesn't support 16559 - // CORS (e.g. vanilla Icecast like stream.kpbj.fm), the browser blocks the 16560 - // load — retry once without crossOrigin so the stream still plays (sans 16561 - // frequency analyser; the visualizer falls back to a synthetic waveform). 16557 + // Per-station CORS policy: 16558 + // cors === false → Icecast/no-CORS server (e.g. stream.kpbj.fm); skip 16559 + // crossOrigin entirely so we connect in one round-trip (no analyser). 16560 + // cors !== false → default; try crossOrigin="anonymous" so we can hook 16561 + // up the frequency analyser (e.g. radio.co supports CORS). If the 16562 + // load is blocked, fall back to a no-CORS retry automatically. 16563 + const corsAllowed = cors !== false; 16564 + 16565 + // Wire audio lifecycle events to rich telemetry so the UI can show 16566 + // "Connecting…" → "Buffering…" → "● LIVE" with real detail. 16567 + const attachTelemetry = (audio) => { 16568 + const emitState = (state, extra) => 16569 + send({ 16570 + type: "stream:state", 16571 + content: { id, state, ...(extra || {}) }, 16572 + }); 16573 + audio.addEventListener("loadstart", () => emitState("connecting")); 16574 + audio.addEventListener("progress", () => emitState("loading")); 16575 + audio.addEventListener("canplay", () => emitState("ready")); 16576 + audio.addEventListener("waiting", () => emitState("buffering")); 16577 + audio.addEventListener("playing", () => emitState("playing")); 16578 + audio.addEventListener("stalled", () => emitState("stalled")); 16579 + audio.addEventListener("error", () => { 16580 + const err = audio.error; 16581 + emitState("error", { 16582 + code: err?.code, 16583 + message: err?.message || "audio error", 16584 + }); 16585 + }); 16586 + }; 16587 + 16562 16588 const tryPlay = (withCors) => { 16563 16589 const audio = new Audio(); 16564 16590 if (withCors) audio.crossOrigin = "anonymous"; 16565 16591 audio.src = bustedUrl; 16566 16592 audio.volume = baseVolume * masterVolume; 16593 + 16594 + attachTelemetry(audio); 16567 16595 16568 16596 const entry = { audio, baseVolume }; 16569 16597 ··· 16583 16611 } 16584 16612 16585 16613 streamAudio[id] = entry; 16614 + send({ type: "stream:state", content: { id, state: "connecting" } }); 16586 16615 16587 16616 return audio.play().then(() => { 16588 16617 send({ type: "stream:playing", content: { id } }); 16589 16618 }); 16590 16619 }; 16620 + 16621 + if (!corsAllowed) { 16622 + // Skip the CORS probe entirely for known non-CORS servers. 16623 + tryPlay(false).catch((err) => { 16624 + console.warn("🎵 Stream play failed:", err); 16625 + send({ 16626 + type: "stream:error", 16627 + content: { id, error: err.message }, 16628 + }); 16629 + }); 16630 + return; 16631 + } 16591 16632 16592 16633 tryPlay(true).catch((err) => { 16593 16634 // CORS rejection surfaces as MediaError code 4 / NotSupportedError.
+102 -1
system/public/aesthetic.computer/disks/chat.mjs
··· 263 263 label: "r8Dio", 264 264 streamUrl: "https://s3.radio.co/s7cd1ffe2f/listen", 265 265 streamId: "chat-r8dio-stream", 266 + // radio.co sends Access-Control-Allow-Origin, so we can use crossOrigin 267 + // and get a real frequency analyser for the visualizer bars. 268 + cors: true, 266 269 metadataUrl: "https://public.radio.co/stations/s7cd1ffe2f/status", 267 270 parseTrack: (data) => data?.current_track?.title || "", 268 271 labelBg: [35, 25, 18], ··· 295 298 label: "KPBJ", 296 299 streamUrl: "https://stream.kpbj.fm/", 297 300 streamId: "chat-kpbj-stream", 301 + // KPBJ runs vanilla Icecast 2.4.4 without CORS headers — skip crossOrigin 302 + // so we connect in one round-trip. Visualizer uses the synthetic waveform 303 + // fallback since we can't hook up createMediaElementSource. 304 + cors: false, 298 305 metadataUrl: "https://www.kpbj.fm/api/stream/metadata", 299 306 parseTrack: (data) => { 300 307 const source = data?.icestats?.source; ··· 335 342 let r8dioPlaying = false; 336 343 let r8dioLoading = false; 337 344 let r8dioError = null; 345 + let r8dioState = "idle"; // idle | connecting | loading | buffering | ready | playing | stalled | error 338 346 let r8dioVolume = 0.5; 339 347 let r8dioTrack = ""; // Current track/program title 340 348 let r8dioLastMetadataFetch = 0; ··· 559 567 send({ type: "keyboard:text:replace", content: { text: "" } }); 560 568 console.log("⌨️🔴 [chat.mjs] sending keyboard:close - reason: message sent"); 561 569 send({ type: "keyboard:close" }); 570 + 571 + // 📻 Radio command words — type `bj`, `r8dio`, `radio`, or `radio off` 572 + // to start/stop the mini-player without reaching for the button. 573 + const radioCmd = parseRadioCommand(text); 574 + if (radioCmd) { 575 + applyRadioCommand(radioCmd, send); 576 + notice(radioCmd.toast || "", ["orange", 0]); 577 + return; 578 + } 562 579 563 580 // Pieces inheriting chat.mjs (e.g. aa.mjs) may pass a custom submit 564 581 // handler so they can route the typed text somewhere other than the ··· 3762 3779 r8dioPlaying = true; 3763 3780 r8dioLoading = false; 3764 3781 r8dioError = null; 3782 + r8dioState = "playing"; 3765 3783 } 3766 3784 3767 3785 if (type === "stream:paused") { 3768 3786 r8dioPlaying = false; 3787 + r8dioState = "idle"; 3769 3788 } 3770 3789 3771 3790 if (type === "stream:stopped") { 3772 3791 r8dioPlaying = false; 3773 3792 r8dioLoading = false; 3793 + r8dioState = "idle"; 3774 3794 } 3775 3795 3776 3796 if (type === "stream:error") { 3777 3797 r8dioPlaying = false; 3778 3798 r8dioLoading = false; 3779 3799 r8dioError = content.error; 3800 + r8dioState = "error"; 3801 + } 3802 + 3803 + // Rich lifecycle telemetry: connecting → loading → buffering → playing 3804 + if (type === "stream:state") { 3805 + r8dioState = content.state; 3806 + if (content.state === "playing") { 3807 + r8dioPlaying = true; 3808 + r8dioLoading = false; 3809 + r8dioError = null; 3810 + } else if (content.state === "error") { 3811 + r8dioError = content.message || "stream error"; 3812 + r8dioLoading = false; 3813 + } else if ( 3814 + content.state === "connecting" || 3815 + content.state === "loading" || 3816 + content.state === "buffering" || 3817 + content.state === "stalled" 3818 + ) { 3819 + // Keep the loading spinner going for any pre-play state. 3820 + r8dioLoading = true; 3821 + r8dioError = null; 3822 + } 3780 3823 } 3781 3824 3782 3825 if (type === "stream:frequencies-data") { ··· 3831 3874 } else { 3832 3875 r8dioLoading = true; 3833 3876 r8dioError = null; 3877 + r8dioState = "connecting"; 3834 3878 send({ 3835 3879 type: "stream:play", 3836 3880 content: { 3837 3881 id: cfg.streamId, 3838 3882 url: cfg.streamUrl, 3839 3883 volume: r8dioVolume, 3884 + cors: cfg.cors !== false, // default true; false for Icecast/no-CORS 3840 3885 }, 3841 3886 }); 3887 + } 3888 + } 3889 + 3890 + // 📻 Parse a chat input string into a radio command, or null if it's not one. 3891 + // `bj` → switch to KPBJ and play 3892 + // `r8dio` → switch to r8dio and play 3893 + // `radio` → toggle the active station 3894 + // `radio off` / `hush` / `mute radio` → pause 3895 + function parseRadioCommand(text) { 3896 + const t = (text || "").trim().toLowerCase(); 3897 + if (!t) return null; 3898 + if (t === "radio off" || t === "hush" || t === "mute radio" || t === "radio stop") { 3899 + return { action: "stop", toast: "RADIO OFF" }; 3900 + } 3901 + if (t === "radio") return { action: "toggle", toast: "RADIO" }; 3902 + if (RADIO_STATIONS[t]) { 3903 + return { action: "play", station: t, toast: RADIO_STATIONS[t].label.toUpperCase() }; 3904 + } 3905 + return null; 3906 + } 3907 + 3908 + // 📻 Apply a parsed radio command. 3909 + function applyRadioCommand(cmd, send) { 3910 + if (cmd.action === "stop") { 3911 + if (r8dioPlaying || r8dioLoading) { 3912 + send({ type: "stream:pause", content: { id: radioConfig().streamId } }); 3913 + } 3914 + return; 3915 + } 3916 + if (cmd.action === "toggle") { 3917 + toggleR8dioPlayback(send); 3918 + return; 3919 + } 3920 + if (cmd.action === "play" && cmd.station) { 3921 + // Switching stations? Pause the current one first so there's no overlap. 3922 + if (r8dioPlaying && activeRadioStation !== cmd.station) { 3923 + send({ type: "stream:pause", content: { id: radioConfig().streamId } }); 3924 + r8dioPlaying = false; 3925 + } 3926 + activeRadioStation = cmd.station; 3927 + if (!r8dioPlaying) toggleR8dioPlayback(send); 3842 3928 } 3843 3929 } 3844 3930 ··· 4957 5043 if (r8dioError) { 4958 5044 ink(255, 100, 100).write("err", { x: statusX, y: tickerY }, undefined, undefined, false, "MatrixChunky8"); 4959 5045 } else if (r8dioLoading) { 4960 - ink(...cfg.statusColor).write("...", { x: statusX, y: tickerY }, undefined, undefined, false, "MatrixChunky8"); 5046 + // Rich lifecycle labels so the user can see the stream actually making 5047 + // progress (connecting → buffering → ready), not just a static "...". 5048 + const dots = ellipsisTicker?.text?.(help?.repeat || 0) || ""; 5049 + let label; 5050 + switch (r8dioState) { 5051 + case "connecting": label = "connect" + dots; break; 5052 + case "loading": label = "load" + dots; break; 5053 + case "buffering": label = "buffer" + dots; break; 5054 + case "stalled": label = "stall" + dots; break; 5055 + case "ready": label = "ready" + dots; break; 5056 + default: label = "..." + dots; break; 5057 + } 5058 + // Truncate to fit 5059 + const maxLen = Math.max(1, Math.floor((statusEndX - statusX) / tickerCharWidth)); 5060 + if (label.length > maxLen) label = label.substring(0, maxLen); 5061 + ink(...cfg.statusColor).write(label, { x: statusX, y: tickerY }, undefined, undefined, false, "MatrixChunky8"); 4961 5062 } else if (r8dioPlaying) { 4962 5063 const maxLen = Math.floor((statusEndX - statusX) / tickerCharWidth); 4963 5064 const text = r8dioTrack
+15 -2
system/public/aesthetic.computer/disks/kpbj.mjs
··· 29 29 const CONFIG = { 30 30 streamUrl: "https://stream.kpbj.fm/", 31 31 streamId: "kpbj-stream", 32 + // Vanilla Icecast 2.4.4 — no Access-Control-Allow-Origin. Skip crossOrigin 33 + // so the stream connects in one round-trip (no analyser; visualizer uses 34 + // the synthetic waveform fallback). 35 + cors: false, 32 36 metadataUrl: "https://www.kpbj.fm/api/stream/metadata", 33 37 playoutNowUrl: "https://kpbj.fm/api/playout/now", 34 38 playoutFallbackUrl: "https://kpbj.fm/api/playout/fallback", ··· 345 349 const websiteColor = isWebsiteHovered ? [255, 220, 170] : THEME.qrLabel; 346 350 ink(...websiteColor).write(websiteText, { x: websiteLinkX, y: websiteLinkY }, undefined, undefined, false, "MatrixChunky8"); 347 351 348 - // 3. Status (● LIVE / Paused / Connecting...) 352 + // 3. Status (● LIVE / Paused / Connecting.../Buffering.../etc.) 349 353 let statusText, statusColor; 354 + const dots = ".".repeat(Math.floor(help.repeat / 20) % 4); 350 355 if (state.loadError) { 351 356 statusText = "Connection error"; 352 357 statusColor = THEME.statusError; 353 358 } else if (state.isLoading) { 354 - statusText = "Connecting" + ".".repeat(Math.floor(help.repeat / 20) % 4); 359 + // Use streamState for richer per-phase telemetry when available. 360 + switch (state.streamState) { 361 + case "connecting": statusText = "Connecting" + dots; break; 362 + case "loading": statusText = "Loading stream" + dots; break; 363 + case "buffering": statusText = "Buffering" + dots; break; 364 + case "stalled": statusText = "Reconnecting" + dots; break; 365 + case "ready": statusText = "Ready" + dots; break; 366 + default: statusText = "Connecting" + dots; break; 367 + } 355 368 statusColor = THEME.statusLoading; 356 369 } else if (state.isPlaying) { 357 370 statusText = "● LIVE";
+199
system/public/aesthetic.computer/disks/pieces.mjs
··· 1 + // pieces, 26.04.21 2 + // Most recently added / edited pieces. 3 + 4 + const { floor, max, min } = Math; 5 + const LIMIT = 50; 6 + const ROW_HEIGHT = 14; 7 + const LEFT_MARGIN = 6; 8 + const HEADER_HEIGHT = 16; 9 + const COMPACT_FONT = "MatrixChunky8"; 10 + const COMPACT_CHAR_W = 4; 11 + 12 + const pal = { 13 + dark: { 14 + bg: [16, 16, 24], 15 + headerBg: [24, 24, 36], 16 + name: [100, 220, 150], 17 + date: [80, 100, 140], 18 + msg: [80, 80, 100], 19 + highlight: [255, 255, 100], 20 + loading: [80, 100, 140], 21 + }, 22 + light: { 23 + bg: [240, 240, 245], 24 + headerBg: [230, 230, 238], 25 + name: [20, 120, 60], 26 + date: [120, 130, 160], 27 + msg: [130, 130, 150], 28 + highlight: [200, 160, 0], 29 + loading: [120, 130, 160], 30 + }, 31 + }; 32 + 33 + let items = []; // [{ name, date, message }] 34 + let scroll = 0; 35 + let anyDown = false; 36 + let itemButtons = []; 37 + let loaded = false; 38 + 39 + function formatDate(dateStr) { 40 + if (!dateStr) return ""; 41 + const d = new Date(dateStr); 42 + const now = new Date(); 43 + const diff = floor((now - d) / 86400000); 44 + if (diff === 0) return "today"; 45 + if (diff === 1) return "1d ago"; 46 + if (diff < 30) return `${diff}d ago`; 47 + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; 48 + } 49 + 50 + async function boot({ net }) { 51 + const [docsResult, commitsResult] = await Promise.all([ 52 + net.requestDocs().catch(() => null), 53 + fetch("/api/piece-commits").then((r) => r.json()).catch(() => ({ commits: {} })), 54 + ]); 55 + 56 + const commits = commitsResult?.commits || {}; 57 + const docs = docsResult?.pieces || {}; 58 + 59 + // Only include pieces that exist in docs (not hidden) or have a commit entry 60 + const names = new Set([ 61 + ...Object.keys(docs).filter((k) => !docs[k]?.hidden), 62 + ...Object.keys(commits), 63 + ]); 64 + 65 + items = Array.from(names) 66 + .map((name) => ({ 67 + name, 68 + date: commits[name]?.date ? new Date(commits[name].date) : null, 69 + message: commits[name]?.message || "", 70 + })) 71 + .filter((item) => item.date) // only show pieces with a known date 72 + .sort((a, b) => b.date - a.date) 73 + .slice(0, LIMIT); 74 + 75 + loaded = true; 76 + buildButtons(); 77 + } 78 + 79 + function buildButtons() { 80 + itemButtons = items.map((item, i) => ({ 81 + name: item.name, 82 + y: HEADER_HEIGHT + 4 + i * ROW_HEIGHT, 83 + down: false, 84 + })); 85 + } 86 + 87 + function paint({ wipe, ink, screen, dark, paintCount }) { 88 + const c = dark ? pal.dark : pal.light; 89 + wipe(c.bg); 90 + 91 + if (!loaded) { 92 + if (paintCount > 8n) ink(c.loading).write("Loading...", { center: "xy" }); 93 + return; 94 + } 95 + 96 + if (items.length === 0) { 97 + ink(c.loading).write("No recent pieces.", { center: "xy" }); 98 + return; 99 + } 100 + 101 + const contentTop = HEADER_HEIGHT + 4; 102 + const viewH = screen.height - contentTop; 103 + 104 + // Rows 105 + items.forEach((item, i) => { 106 + const y = contentTop + i * ROW_HEIGHT + scroll; 107 + if (y < contentTop - ROW_HEIGHT || y > screen.height) return; 108 + 109 + const btn = itemButtons[i]; 110 + if (btn?.down) ink(c.highlight, 50).box(0, y, screen.width, ROW_HEIGHT); 111 + 112 + // Name 113 + ink(btn?.down ? c.highlight : c.name).write(item.name, { x: LEFT_MARGIN, y: y + 1 }); 114 + 115 + // Date (right-aligned, compact font) 116 + const dateStr = formatDate(item.date); 117 + const dateW = dateStr.length * COMPACT_CHAR_W; 118 + ink(c.date).write(dateStr, { x: screen.width - dateW - 4, y: y + 2 }, undefined, undefined, false, COMPACT_FONT); 119 + 120 + // Commit message (between name and date, truncated) 121 + if (item.message) { 122 + const nameW = item.name.length * 6 + LEFT_MARGIN + 8; 123 + const maxW = screen.width - nameW - dateW - 12; 124 + const maxChars = floor(maxW / COMPACT_CHAR_W); 125 + if (maxChars > 4) { 126 + const msg = item.message.length > maxChars 127 + ? item.message.slice(0, maxChars - 2) + ".." 128 + : item.message; 129 + ink(c.msg).write(msg, { x: nameW, y: y + 2 }, undefined, undefined, false, COMPACT_FONT); 130 + } 131 + } 132 + }); 133 + 134 + // Scrollbar 135 + const totalH = items.length * ROW_HEIGHT; 136 + if (totalH > viewH) { 137 + const thumbH = max(8, (viewH / totalH) * viewH); 138 + const thumbY = contentTop + (-scroll / totalH) * viewH; 139 + ink(dark ? [60, 60, 80] : [180, 180, 200]).box(screen.width - 3, contentTop, 2, viewH); 140 + ink(dark ? [100, 120, 180] : [120, 130, 170]).box(screen.width - 3, thumbY, 2, thumbH); 141 + } 142 + 143 + // Header (painted last to mask scroll) 144 + ink(c.headerBg).box(0, 0, screen.width, HEADER_HEIGHT); 145 + const countStr = `${items.length} recent`; 146 + ink(c.date).write(countStr, { x: LEFT_MARGIN, y: 3 }, undefined, undefined, false, COMPACT_FONT); 147 + } 148 + 149 + function act({ event: e, screen, hud, piece, jump, needsPaint, beep }) { 150 + const contentTop = HEADER_HEIGHT + 4; 151 + const viewH = screen.height - contentTop; 152 + const totalH = items.length * ROW_HEIGHT; 153 + 154 + if (e.is("scroll")) { 155 + scroll = max(-(totalH - viewH), min(0, scroll - e.y)); 156 + needsPaint(); 157 + return; 158 + } 159 + 160 + if (!anyDown && e.is("draw:1")) { 161 + scroll = max(-(totalH - viewH), min(0, scroll + e.delta.y)); 162 + needsPaint(); 163 + } 164 + 165 + itemButtons.forEach((btn) => { 166 + const y = btn.y + scroll; 167 + const inBounds = e.x >= 0 && e.x <= screen.width && e.y >= y && e.y <= y + ROW_HEIGHT; 168 + 169 + if (e.is("touch") && inBounds) { 170 + btn.down = true; 171 + anyDown = true; 172 + hud.label(btn.name, "white"); 173 + needsPaint(); 174 + } 175 + 176 + if (e.is("lift") && btn.down) { 177 + btn.down = false; 178 + anyDown = false; 179 + beep?.(); 180 + jump(btn.name); 181 + } 182 + }); 183 + 184 + if (e.is("lift")) { 185 + anyDown = false; 186 + itemButtons.forEach((b) => (b.down = false)); 187 + hud.label(piece); 188 + needsPaint(); 189 + } 190 + } 191 + 192 + function meta() { 193 + return { 194 + title: "Pieces", 195 + desc: "Most recently added or edited pieces.", 196 + }; 197 + } 198 + 199 + export { boot, paint, act, meta };
+6 -10
system/public/aesthetic.computer/lib/disk.mjs
··· 15194 15194 }; 15195 15195 15196 15196 // DEBUG: Add hitbox visualization overlay 15197 - if (globalThis.debugHudHitbox && currentHUDButton) { 15197 + // TEMP: default-on fill visualization to inspect tap area height. Toggle off via `toggleHudHitboxDebug()`. 15198 + if (globalThis.debugHudHitbox !== false && currentHUDButton) { 15198 15199 const hitboxWidth = currentHUDButton.box.w; 15199 15200 const hitboxHeight = currentHUDButton.box.h; 15200 - const blinkFrame = Number($api.paintCount || 0n); 15201 - const blinkOn = (blinkFrame % 30) < 15; 15202 - const outerAlpha = blinkOn ? 200 : 80; 15203 - const innerAlpha = blinkOn ? 120 : 40; 15204 15201 15205 15202 const hitboxOverlay = $api.painting(hitboxWidth, hitboxHeight, ($) => { 15206 15203 $.unpan(); 15207 15204 $.unmask(); // Ensure hitbox overlay renders without piece mask 15208 - // Draw a blinking green border to show the hitbox 15209 - $.ink(0, 255, 0, outerAlpha).box(0, 0, hitboxWidth, hitboxHeight, "outline"); 15210 - if (hitboxWidth > 2 && hitboxHeight > 2) { 15211 - $.ink(0, 255, 0, innerAlpha).box(1, 1, hitboxWidth - 2, hitboxHeight - 2, "outline"); 15212 - } 15205 + // Solid translucent magenta fill so the tap area is clearly visible 15206 + $.ink(255, 0, 180, 90).box(0, 0, hitboxWidth, hitboxHeight); 15207 + // Bright outline on top for precise edge inspection 15208 + $.ink(0, 255, 0, 220).box(0, 0, hitboxWidth, hitboxHeight, "outline"); 15213 15209 }); 15214 15210 15215 15211 sendData.hitboxDebug = {
+34 -3
system/public/aesthetic.computer/lib/radio.mjs
··· 14 14 // Config 15 15 streamUrl: config.streamUrl, 16 16 streamId: config.streamId, 17 + // cors: false → skip crossOrigin on the <audio> element (for Icecast 18 + // servers without Access-Control-Allow-Origin, e.g. stream.kpbj.fm). The 19 + // visualizer falls back to a synthetic waveform instead of the analyser. 20 + cors: config.cors !== false, 17 21 metadataUrl: config.metadataUrl || null, 18 22 qrUrl: config.qrUrl, 19 23 qrLabel: config.qrLabel || config.qrUrl.replace("https://", ""), ··· 24 28 isPlaying: false, 25 29 isLoading: false, 26 30 loadError: null, 31 + streamState: "idle", // idle | connecting | loading | buffering | ready | playing | stalled | error 27 32 volume: 0.5, 28 33 29 34 // Visualization ··· 156 161 id: state.streamId, 157 162 url: state.streamUrl, 158 163 volume: state.volume, 164 + cors: state.cors, 159 165 }, 160 166 }); 161 167 } ··· 194 200 state.isPlaying = true; 195 201 state.isLoading = false; 196 202 state.loadError = null; 203 + state.streamState = "playing"; 197 204 } 198 - 205 + 199 206 if (type === "stream:paused" && content.id === state.streamId) { 200 207 state.isPlaying = false; 208 + state.streamState = "idle"; 201 209 } 202 - 210 + 203 211 if (type === "stream:stopped" && content.id === state.streamId) { 204 212 state.isPlaying = false; 205 213 state.isLoading = false; 214 + state.streamState = "idle"; 206 215 } 207 - 216 + 208 217 if (type === "stream:error" && content.id === state.streamId) { 209 218 state.isPlaying = false; 210 219 state.isLoading = false; 211 220 state.loadError = content.error; 221 + state.streamState = "error"; 222 + } 223 + 224 + // Lifecycle telemetry (connecting → loading → buffering → ready → playing). 225 + if (type === "stream:state" && content.id === state.streamId) { 226 + state.streamState = content.state; 227 + if (content.state === "playing") { 228 + state.isPlaying = true; 229 + state.isLoading = false; 230 + state.loadError = null; 231 + } else if (content.state === "error") { 232 + state.loadError = content.message || "stream error"; 233 + state.isLoading = false; 234 + } else if ( 235 + content.state === "connecting" || 236 + content.state === "loading" || 237 + content.state === "buffering" || 238 + content.state === "stalled" 239 + ) { 240 + state.isLoading = true; 241 + state.loadError = null; 242 + } 212 243 } 213 244 214 245 if (type === "stream:frequencies-data" && content.id === state.streamId) {