diff --git a/system/public/aesthetic.computer/bios.mjs b/system/public/aesthetic.computer/bios.mjs index b88920c215..75e88bf987 100644 --- a/system/public/aesthetic.computer/bios.mjs +++ b/system/public/aesthetic.computer/bios.mjs @@ -11737,6 +11737,12 @@ async function boot(parsed, bpm = 60, resolution, debug) { send({ type: "qr:toggle-fullscreen" }); } + // 📦 Update-ready badge tap → full reload (new deployment available). + if (content.label === "update-corner") { + console.log("🔘 Update badge tapped — reloading"); + window.location.reload(); + } + if (content.label === "copy") { try { await navigator.clipboard.writeText(content.message); @@ -17419,7 +17425,7 @@ async function boot(parsed, bpm = 60, resolution, debug) { if (!o || !o.img) { // During reframes, if overlay data is missing but we have a cached version, use it // EXCEPT for tapeProgressBar, merryProgressBar, durationProgressBar, durationTimecode, qrOverlay and bumperOverlay which should never use cached versions - if (content.reframe && window.framePersistentOverlayCache[name] && name !== "tapeProgressBar" && name !== "merryProgressBar" && name !== "demoplayCard" && name !== "durationProgressBar" && name !== "durationTimecode" && name !== "qrOverlay" && name !== "qrCornerLabel" && name !== "qrFullscreenLabel" && name !== "authorOverlay" && name !== "bumperOverlay") { + if (content.reframe && window.framePersistentOverlayCache[name] && name !== "tapeProgressBar" && name !== "merryProgressBar" && name !== "demoplayCard" && name !== "durationProgressBar" && name !== "durationTimecode" && name !== "qrOverlay" && name !== "qrCornerLabel" && name !== "qrFullscreenLabel" && name !== "authorOverlay" && name !== "bumperOverlay" && name !== "updateBadge") { paintOverlays[name] = window.framePersistentOverlayCache[name]; return; } @@ -17504,6 +17510,13 @@ async function boot(parsed, bpm = 60, resolution, debug) { currentKey += `_${performance.now()}`; // Force unique key every time } + // 📦 Update badge pulses every frame. + if (name === "updateBadge") { + overlayCache.lastKey = null; + delete window.framePersistentOverlayCache[name]; + currentKey += `_${performance.now()}`; + } + // Only rebuild if overlay actually changed // Force rebuild every frame for tape progress bar, merry progress bar, duration progress bar, duration timecode and QR overlay (no caching) if ( @@ -17702,6 +17715,7 @@ async function boot(parsed, bpm = 60, resolution, debug) { buildOverlay("qrCornerLabel", content.qrCornerLabel); buildOverlay("qrFullscreenLabel", content.qrFullscreenLabel); buildOverlay("authorOverlay", content.authorOverlay); // 👤 Author attribution for KidLisp pieces + buildOverlay("updateBadge", content.updateBadge); // 📦 Update-ready ↑ badge (top-right) buildOverlay("merryProgressBar", content.merryProgressBar); // 🎄 Merry pipeline progress bar buildOverlay("demoplayCard", content.demoplayCard); // 🎬 Demoplay text card overlay buildOverlay("tapeProgressBar", content.tapeProgressBar); @@ -17931,6 +17945,7 @@ async function boot(parsed, bpm = 60, resolution, debug) { if (paintOverlays["qrCornerLabel"]) paintOverlays["qrCornerLabel"](); if (paintOverlays["qrFullscreenLabel"]) paintOverlays["qrFullscreenLabel"](); if (paintOverlays["authorOverlay"]) paintOverlays["authorOverlay"](); + if (paintOverlays["updateBadge"]) paintOverlays["updateBadge"](); // 📦 Update-ready badge (top-right) if (paintOverlays["tapeProgressBar"] && !window.currentRecordingOptions?.cleanMode) paintOverlays["tapeProgressBar"](); if (paintOverlays["durationProgressBar"]) paintOverlays["durationProgressBar"](); if (paintOverlays["hitboxDebug"]) paintOverlays["hitboxDebug"](); // Debug overlay (green hitbox) @@ -18020,6 +18035,7 @@ async function boot(parsed, bpm = 60, resolution, debug) { if (paintOverlays["qrCornerLabel"]) paintOverlays["qrCornerLabel"](); if (paintOverlays["qrFullscreenLabel"]) paintOverlays["qrFullscreenLabel"](); if (paintOverlays["authorOverlay"]) paintOverlays["authorOverlay"](); + if (paintOverlays["updateBadge"]) paintOverlays["updateBadge"](); // 📦 Update-ready badge if (paintOverlays["tapeProgressBar"] && !window.currentRecordingOptions?.cleanMode) paintOverlays["tapeProgressBar"](); if (paintOverlays["durationProgressBar"]) paintOverlays["durationProgressBar"](); if (paintOverlays["hitboxDebug"]) paintOverlays["hitboxDebug"](); @@ -18129,6 +18145,12 @@ async function boot(parsed, bpm = 60, resolution, debug) { paintOverlays["authorOverlay"](); } + // 📦 Update-ready badge (top-right) — drawn after corner overlays so + // it sits on top of everything else. + if (!skipImmediateOverlays && paintOverlays["updateBadge"]) { + paintOverlays["updateBadge"](); + } + // Paint hitbox debug overlay immediately (green, shows button hitbox) if (!skipImmediateOverlays && paintOverlays["hitboxDebug"]) { paintOverlays["hitboxDebug"](); diff --git a/system/public/aesthetic.computer/disks/prompt.mjs b/system/public/aesthetic.computer/disks/prompt.mjs index ca5a8e5c74..af173997e1 100644 --- a/system/public/aesthetic.computer/disks/prompt.mjs +++ b/system/public/aesthetic.computer/disks/prompt.mjs @@ -337,8 +337,7 @@ let previousKidlispMode = false; // Track previous KidLisp mode state for sound let versionInfo = null; // { deployed, latest, status, behindBy } - git commit status let versionCommit = null; // Current commit hash for the commit button let recentCommits = []; // Recent commits for uniticker display: [{hash, message, author, date}] -let updateAvailable = false; // True when a new deployment is detected via long-poll -let versionPollController = null; // AbortController for long-poll requests +let updateAvailable = false; // Mirrors $commonApi.system.update.ready (synced in sim) // Multilingual "Prompt" translations cycling const promptTranslations = [ @@ -759,86 +758,9 @@ async function boot({ console.warn("💁 Could not get handle count."); }); - // Fetch git commit/version status with long-poll for deploy detection - const fetchVersion = async () => { - try { - const res = await fetch("/api/version"); - if (!res.ok) throw new Error(`HTTP ${res.status}`); - versionInfo = await res.json(); - // Local/dev: never show "(N behind)" — the .commit-ref on a dev - // checkout can be stale and the user isn't deploying anything. - if (debug) { - versionInfo = { - ...versionInfo, - deployed: versionInfo.deployed === "unknown" ? "dev" : versionInfo.deployed, - status: "local", - behindBy: 0, - }; - } - // Store recent commits from the version API - recentCommits = versionInfo.recentCommits || []; - needsPaint(); - } catch (e) { - console.warn("📦 Could not fetch version info:", e); - } - }; - fetchVersion(); - - // Long-poll loop: detect new deployments within ~5 seconds - const startVersionPoll = async () => { - if (debug) { - // Local dev: just poll every 60 seconds - setInterval(fetchVersion, 60 * 1000); - return; - } - const poll = async () => { - while (true) { - try { - if (!versionInfo?.deployed) { - await new Promise(r => setTimeout(r, 5000)); - continue; - } - versionPollController = new AbortController(); - const res = await fetch( - `/api/version?current=${versionInfo.deployed}`, - { signal: versionPollController.signal } - ); - if (!res.ok) throw new Error(`HTTP ${res.status}`); - const data = await res.json(); - if (data.changed === false) { - // Same version — loop again immediately (server already waited ~4s) - continue; - } - // New deployment detected! Update version info - updateAvailable = true; - versionInfo = data; - recentCommits = data.recentCommits || []; - needsPaint(); - console.log("📦 New deployment detected:", data.deployed); - // Tell the active service worker (via the main thread, since we - // run inside a Web Worker and don't have direct access to - // navigator.serviceWorker) to evict its module cache. The - // user's NEXT navigation/reload then pulls fresh bios.mjs + - // disk.mjs instead of stale-while-revalidating for up to an - // hour. We don't auto-reload here — that would interrupt the - // user mid-task — but the cache eviction makes a manual - // reload propagate the new build immediately. - try { - send({ type: "sw:clear-cache" }); - } catch (e) { - console.warn("📦 Could not request SW cache clear:", e); - } - break; // Stop polling — update is available - } catch (e) { - if (e.name === "AbortError") break; - console.warn("📦 Version poll error:", e); - await new Promise(r => setTimeout(r, 10000)); // Back off on error - } - } - }; - poll(); - }; - startVersionPoll(); + // 📦 Version state now lives globally in disk.mjs (`$commonApi.system.update`) + // so the corner ↑ badge appears in any piece. We sync into our local vars in + // sim() so the existing commit-button + uniticker code keeps working. // Clear handle colors cache on each boot so edits are picked up immediately. handleColorsCache.clear(); @@ -7267,6 +7189,24 @@ function paint($) { // 🧮 Sim function sim($) { + // 📦 Mirror the worker-global update poll into our local refs so the + // existing commit-button + uniticker code keeps working unchanged. + const u = $.system?.update; + if (u) { + if (u.versionInfo && versionInfo !== u.versionInfo) { + versionInfo = u.versionInfo; + $.needsPaint(); + } + if (u.recentCommits && recentCommits !== u.recentCommits) { + recentCommits = u.recentCommits; + $.needsPaint(); + } + if (u.ready && !updateAvailable) { + updateAvailable = true; + $.needsPaint(); + } + } + // 🎄 Handle pending autorun commands (from URL-based merry, etc.) if (pendingAutorun) { const { text } = pendingAutorun; @@ -8304,7 +8244,6 @@ function meta() { // 👋 Leave function leave() { motdController?.abort(); // Abort any motd update. - versionPollController?.abort(); // Stop version long-poll. } export const nohud = true; diff --git a/system/public/aesthetic.computer/lib/disk.mjs b/system/public/aesthetic.computer/lib/disk.mjs index 55628499de..4593af8021 100644 --- a/system/public/aesthetic.computer/lib/disk.mjs +++ b/system/public/aesthetic.computer/lib/disk.mjs @@ -1905,6 +1905,17 @@ let currentPath, qrOverlayCache = new Map(), // Cache for QR overlays to prevent regeneration every frame hudLabelCache = null; // Cache for HUD label to prevent regeneration every frame +// 📦 Global update state — polled once per worker lifetime so a fresh build is +// visible from any piece (not just `prompt`, which used to own polling and +// aborted it on leave). Exposed via `$commonApi.system.update`. +let globalUpdateReady = false; +let globalVersionInfo = null; // { deployed, latest, status, behindBy, recentCommits } +let globalRecentCommits = []; +let updatePollStarted = false; +let updatePollController = null; +let updateBadgeBoxScreen = null; // {x, y, w, h} of the rendered badge for hitbox bookkeeping +let updateBadgeHitboxRegistered = false; + // 📱 LAN Dev mode identity (set by session server in dev mode) let devIdentity = null; // { name, host, hostIp, mode, connectionId } @@ -3590,6 +3601,15 @@ const $commonApi = { }, system: { // prompt: { input: undefined }, Gets set in `prompt_boot`. + // 📦 Global update poll state — `ready` flips true when /api/version + // long-poll detects a new deployment. Pieces can read this to render + // their own update UI; the corner ↑ badge in disk.mjs is automatic. + update: { + get ready() { return globalUpdateReady; }, + get versionInfo() { return globalVersionInfo; }, + get recentCommits() { return globalRecentCommits; }, + reload: () => send({ type: "window:reload" }), + }, world: { // Populated in `world_boot` of `world.mjs`. teleported: false, @@ -9907,6 +9927,74 @@ if (_workerReadyTime - _importStart > 50) { console.log(`📦 [DISK] Ready: ${(_workerReadyTime - _importStart).toFixed(0)}ms`); } +// 📦 Long-poll `/api/version` to detect new deployments. Runs once for the +// life of the worker so the green ↑ badge can appear in any piece. +async function startGlobalVersionPoll() { + if (updatePollStarted) return; + updatePollStarted = true; + + const fetchVersion = async () => { + try { + const res = await fetch("/api/version"); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const data = await res.json(); + globalVersionInfo = debug + ? { + ...data, + deployed: data.deployed === "unknown" ? "dev" : data.deployed, + status: "local", + behindBy: 0, + } + : data; + globalRecentCommits = data.recentCommits || []; + try { $commonApi.needsPaint?.(); } catch {} + } catch (e) { + console.warn("📦 Could not fetch version info:", e); + } + }; + + await fetchVersion(); + + if (debug) { + // Local dev: poll once a minute instead of long-polling. + setInterval(fetchVersion, 60 * 1000); + return; + } + + // Long-poll loop: detect new deployments within ~5 seconds. + while (true) { + try { + if (!globalVersionInfo?.deployed) { + await new Promise((r) => setTimeout(r, 5000)); + continue; + } + updatePollController = new AbortController(); + const res = await fetch( + `/api/version?current=${globalVersionInfo.deployed}`, + { signal: updatePollController.signal }, + ); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const data = await res.json(); + if (data.changed === false) continue; // Server already waited ~4s + globalUpdateReady = true; + globalVersionInfo = data; + globalRecentCommits = data.recentCommits || []; + console.log("📦 New deployment detected:", data.deployed); + try { + send({ type: "sw:clear-cache" }); + } catch (e) { + console.warn("📦 Could not request SW cache clear:", e); + } + try { $commonApi.needsPaint?.(); } catch {} + break; // Stop polling — update is available + } catch (e) { + if (e.name === "AbortError") break; + console.warn("📦 Version poll error:", e); + await new Promise((r) => setTimeout(r, 10000)); + } + } +} + // ***Bootstrap*** // Start by responding to a load message, then change // the message response to makeFrame. @@ -10003,6 +10091,16 @@ async function makeFrame({ data: { type, content } }) { ROOT_PIECE = content.rootPiece; if (content.bootId && typeof self !== "undefined") self.acBOOT_ID = content.bootId; + // 📦 Kick off global version polling (skip preview/icon/pack/objkt where + // the user can't reload anyway). + if ( + !content.previewOrIcon && + !content.objktMode && + !content.packMode + ) { + startGlobalVersionPoll(); + } + if (content.debugHud) { globalThis.debugHudHitbox = true; console.log("🔍 HUD Hitbox Debug: ON (init-from-bios)"); @@ -16145,6 +16243,74 @@ async function makeFrame({ data: { type, content } }) { } } + // 📦 Top-right update-ready badge — appears in any piece when + // /api/version detects a new deployment. Tap to reload. + if ( + globalUpdateReady && + !PREVIEW_OR_ICON && + !DEVICE_MODE && + !hudAnimationState.qrFullscreen + ) { + const badgeSize = 13; + const margin = 4; + const startX = screen.width - badgeSize - margin; + const startY = margin; + + // Pulse for attention. + const tBadge = Number($api.paintCount || 0n); + const pulse = 0.5 + 0.5 * Math.sin(tBadge * 0.15); + const fillBoost = Math.round(40 * pulse); + const outlineBoost = Math.round(60 * pulse); + + const badge = $api.painting(badgeSize, badgeSize, ($) => { + $.unpan(); + $.unmask(); + // Filled square w/ pulsing green. + $.ink(20 + fillBoost, 80 + fillBoost, 30, 230).box( + 0, 0, badgeSize, badgeSize, + ); + $.ink(60 + outlineBoost, 180 + outlineBoost, 70, 255).box( + 0, 0, badgeSize, badgeSize, "outline", + ); + // Up arrow ↑ in pixels. + const cx = Math.floor(badgeSize / 2); + $.ink(255, 255, 255, 255); + $.box(cx, 3, 1, 1); // tip + $.box(cx - 1, 4, 3, 1); // wing 1 + $.box(cx - 2, 5, 5, 1); // wing 2 + $.box(cx, 6, 1, 4); // shaft + }); + + sendData.updateBadge = { + x: startX, + y: startY, + opacity: 1, + img: (({ width, height, pixels }) => ({ width, height, pixels }))( + badge, + ), + }; + + const boxKey = `${startX},${startY},${badgeSize}`; + if (!updateBadgeBoxScreen || updateBadgeBoxScreen.key !== boxKey) { + send({ + type: "button:hitbox:add", + content: { + label: "update-corner", + box: { x: startX, y: startY, w: badgeSize, h: badgeSize }, + message: "update-corner-tap", + }, + }); + updateBadgeBoxScreen = { + x: startX, y: startY, w: badgeSize, h: badgeSize, key: boxKey, + }; + updateBadgeHitboxRegistered = true; + } + } else if (updateBadgeHitboxRegistered) { + send({ type: "button:hitbox:remove", content: "update-corner" }); + updateBadgeHitboxRegistered = false; + updateBadgeBoxScreen = null; + } + let transferredPixels; // Check to see if we have a dirtyBox to render from. diff --git a/system/public/aesthetic.computer/lib/radio.mjs b/system/public/aesthetic.computer/lib/radio.mjs index 76e41ab750..9eb39ae310 100644 --- a/system/public/aesthetic.computer/lib/radio.mjs +++ b/system/public/aesthetic.computer/lib/radio.mjs @@ -83,7 +83,9 @@ export function updateBars(state, { lerp }) { const dataIndex = Math.floor((i / BAR_COUNT) * state.waveformData.length); const sample = state.waveformData[dataIndex] || 128; state.bars[i].targetHeight = Math.abs(sample - 128) / 128; - } else if (state.isPlaying) { + } else if (state.isPlaying && state.cors === false) { + // Only synthesize when CORS prevents the real analyser from working + // (e.g. kpbj). CORS-enabled streams (r8dio) wait for real data instead. const wave = Math.sin(state.animPhase + i * 0.3) * 0.3 + 0.4; const noise = Math.random() * 0.2; state.bars[i].targetHeight = wave + noise; @@ -297,8 +299,10 @@ export function calcLayout(screen, theme, qrCells = null) { const subtitleY = titleY + (isSmall ? 14 : 20); // More space between title and subtitle // QR code position - bottom right corner, fully inside screen + // Reserve room below the QR for its label (MatrixChunky8 ~6-8px tall + gap). + const qrLabelReserve = isSmall ? 10 : 14; const qrX = w - qrSize - qrPadding; - const qrY = h - qrSize - qrPadding; + const qrY = h - qrSize - qrLabelReserve; // Button sizing - responsive const btnSize = isSmall ? 24 : 32;