diff --git a/ac-electron/main.js b/ac-electron/main.js index 15a2add04..79752821e 100644 --- a/ac-electron/main.js +++ b/ac-electron/main.js @@ -1734,6 +1734,29 @@ function openPreviewWindow(rawUrl) { preload: getAppPath('renderer/preview-preload.js'), // click-drag to move }, }); + // ac://close (prompt's `-` command) closes THIS pane; externals go to + // the system browser; anything else is denied — a preview never spawns + // sibling windows via window.open. + win.webContents.setWindowOpenHandler(({ url: openUrl }) => { + if (openUrl.startsWith('ac://close')) { + win.close(); + return { action: 'deny' }; + } + try { + const u = new URL(openUrl); + if (!u.hostname.includes('aesthetic.computer') && + !u.hostname.includes('localhost') && !u.hostname.includes('127.0.0.1')) { + shell.openExternal(openUrl); + } + } catch {} + return { action: 'deny' }; + }); + win.webContents.on('will-navigate', (event, navUrl) => { + if (navUrl.startsWith('ac://close')) { + event.preventDefault(); + win.close(); + } + }); win.loadURL(url); const windowId = windowIdCounter++; windows.set(windowId, { window: win, mode: 'preview' }); @@ -1760,6 +1783,12 @@ ipcMain.on('preview-drag-move', (event, { sx, sy }) => { }); ipcMain.on('preview-drag-end', (event) => { previewDragOrigin.delete(event.sender.id); }); +// Preview close (prompt's `-` command via preview-preload's acElectron API). +ipcMain.on('preview-close', (event) => { + const win = BrowserWindow.fromWebContents(event.sender); + if (win && !win.isDestroyed()) win.close(); +}); + // Open a standalone Notepat window — compact window dedicated to the /notepat piece let notepatWindow = null; function openNotepatWindow() { diff --git a/ac-electron/renderer/preview-preload.js b/ac-electron/renderer/preview-preload.js index 00dab7894..c95521947 100644 --- a/ac-electron/renderer/preview-preload.js +++ b/ac-electron/renderer/preview-preload.js @@ -5,7 +5,16 @@ // the window itself; a plain tap still passes through as before. // // Wired only into `--preview` windows (see openPreviewWindow in main.js). -const { ipcRenderer } = require('electron'); +const { ipcRenderer, contextBridge } = require('electron'); + +// Give preview panes the same close API the main window has, so the +// prompt's `-` command closes THIS pane instead of falling back to a +// window.open("ac://close") that spawns a stray window. +try { + contextBridge.exposeInMainWorld('acElectron', { + closeWindow: () => ipcRenderer.send('preview-close'), + }); +} catch {} const THRESHOLD = 4; // px of travel before it counts as a drag let down = false, dragging = false, startX = 0, startY = 0; diff --git a/system/public/aesthetic.computer/disks/video.mjs b/system/public/aesthetic.computer/disks/video.mjs index f8f28eb21..7bc3fdd00 100644 --- a/system/public/aesthetic.computer/disks/video.mjs +++ b/system/public/aesthetic.computer/disks/video.mjs @@ -962,9 +962,10 @@ function paint({ sustained || tapDipTime >= 0; const liveRate = scrubDriven ? scrubSpeed : playing ? 1 : 0; - // Top-right is display-only — no pointer targets live up here. + // Fixed-width readout — every glyph keeps its position as values + // change, so `frame` OCR (and eyes) can anchor on it. ink(255, 255, 0).write( - `${liveRate.toFixed(2)}x`, + `${liveRate.toFixed(2).padStart(6)}x`, { x: screen.width - 6, y: 6, right: true }, undefined, undefined, @@ -995,13 +996,18 @@ function paint({ const errMs = Math.round(phaseErr * durMs); const locked = Math.abs(errMs) < 60; // Measured in bars too — 64ths are the finest musical slice worth - // naming; inside half a 64th the tape is simply "on grid". + // naming; inside half a 64th the tape is simply "on grid". Both + // fields are fixed-width so the label never shifts as values move. const barMs = BEAT_SEC * 4 * 1000; const n64 = Math.round(errMs / (barMs / 64)); - const musical = - n64 === 0 ? "on grid" : `${n64 > 0 ? "+" : "-"}${Math.abs(n64)}/64 bar`; + const msStr = `${errMs >= 0 ? "+" : "-"}${String(Math.min(9999, Math.abs(errMs))).padStart(4)}ms`; + const musical = ( + n64 === 0 + ? "on grid" + : `${n64 > 0 ? "+" : "-"}${String(Math.min(99, Math.abs(n64))).padStart(2)}/64` + ).padStart(7); ink(locked ? [0, 255, 120] : [255, 170, 0]).write( - `sync ${errMs >= 0 ? "+" : ""}${errMs}ms ${musical}`, + `sync ${msStr} ${musical}`, { x: screen.width - 6, y: 18, right: true }, undefined, undefined, @@ -1083,6 +1089,16 @@ function paint({ volBtn.box.y = vy - 6; volBtn.box.w = vw + 12; volBtn.box.h = vh + 12; + // Hovering shows the true hit box — you can see what you can grab. + if (volBtn.over || volBtn.down) { + ink(volBtn.down ? [255, 255, 255] : [200, 220, 255]).box( + volBtn.box.x, + volBtn.box.y, + volBtn.box.w, + volBtn.box.h, + "outline", + ); + } // Wedge silhouette (dim) + filled portion up to the level. for (let i = 0; i < vw; i += 2) { const colH = Math.max(1, Math.round(vh * (i / vw))); @@ -1128,70 +1144,65 @@ function paint({ } } - // ⌨️🖲️ Deck keys legend, bottom-right — every row is also a button: - // tap ←/→ to beat-jump, hold the chop rows, tap reset. Keyboard and - // touch drive the exact same moves. - const keyRows = [ - { key: "jumpL", label: "<-", extra: "-> beat jump" }, - { key: "chop4", label: "hold ^ 1/4 chop" }, - { key: "chop8", label: "hold v 1/8 chop" }, - { key: "reset", label: "space reset" }, + // 🖲️ Deck buttons, arena-style (the arena.mjs mobile-controls idiom): + // color-coded fill + outline with distinct idle / hover / pressed + // states. Tap ‹ › to beat-jump, HOLD the chop pads, tap reset — + // keyboard drives the exact same moves. + const DECK = [ + [{ key: "jumpL", label: "<" }, { key: "jumpR", label: ">" }], + [{ key: "chop4", label: "1/4 chop" }], + [{ key: "chop8", label: "1/8 chop" }], + [{ key: "reset", label: "reset" }], ]; if (!legendBtns) legendBtns = {}; - const rowH = 10; - keyRows.forEach((row, i) => { - const y = screen.height - 10 - (keyRows.length - i) * rowH; - const full = row.extra ? `${row.label} ${row.extra}` : row.label; - const w = 78; // Generous hit box for the whole row - if (row.key === "jumpL") { - // Row 1 splits: left half jumps back, right half jumps forward. - for (const [k, x0] of [["jumpL", screen.width - 6 - w], ["jumpR", screen.width - 6 - w / 2]]) { - if (!legendBtns[k]) { - legendBtns[k] = new ui.Button(x0, y - 1, w / 2, rowH); - legendBtns[k].noRolloverActivation = true; - } - legendBtns[k].box.x = x0; - legendBtns[k].box.y = y - 1; - legendBtns[k].box.w = w / 2; - legendBtns[k].box.h = rowH; + const bw = 64; // Cluster width + const bh = 14; // Button height + const bgap = 2; + const bx0 = screen.width - 6 - bw; + let by = screen.height - 12 - DECK.length * (bh + bgap); + for (const row of DECK) { + const cw = Math.floor((bw - (row.length - 1) * bgap) / row.length); + row.forEach((cell, ci) => { + const bx = bx0 + ci * (cw + bgap); + if (!legendBtns[cell.key]) { + legendBtns[cell.key] = new ui.Button(bx, by, cw, bh); + legendBtns[cell.key].noRolloverActivation = true; } - } else { - if (!legendBtns[row.key]) { - legendBtns[row.key] = new ui.Button(screen.width - 6 - w, y - 1, w, rowH); - legendBtns[row.key].noRolloverActivation = true; - } - legendBtns[row.key].box.x = screen.width - 6 - w; - legendBtns[row.key].box.y = y - 1; - legendBtns[row.key].box.w = w; - legendBtns[row.key].box.h = rowH; - } - const active = - row.key === "jumpL" - ? legendBtns.jumpL?.down || legendBtns.jumpR?.down - : legendBtns[row.key]?.down; - // Button chrome — these are controls, not a ghosted legend. - ink(active ? [40, 90, 40, 220] : [0, 0, 0, 170]).box( - screen.width - 6 - w, - y - 1, - w, - rowH, - ); - ink(255, 255, 255, active ? 255 : 120).box( - screen.width - 6 - w, - y - 1, - w, - rowH, - "outline", - ); - ink(active ? [0, 255, 120] : [255, 255, 255]).write( - full, - { x: screen.width - 8, y, right: true }, - undefined, - undefined, - false, - "MatrixChunky8", - ); - }); + const b = legendBtns[cell.key]; + b.box.x = bx; + b.box.y = by; + b.box.w = cw; + b.box.h = bh; + const pressed = b.down; + const hover = b.over && !pressed; + const bg = pressed + ? [100, 140, 180, 220] + : hover + ? [80, 100, 125, 190] + : [60, 75, 95, 150]; + const border = pressed + ? [255, 255, 255] + : hover + ? [200, 220, 255] + : [110, 130, 160]; + const txt = pressed + ? [255, 255, 255] + : hover + ? [230, 240, 255] + : [180, 200, 230]; + ink(...bg).box(bx, by, cw, bh); + ink(...border).box(bx, by, cw, bh, "outline"); + ink(...txt).write( + cell.label, + { x: bx + (row.length > 1 ? Math.floor(cw / 2) - 2 : 5), y: by + 4 }, + undefined, + undefined, + false, + "MatrixChunky8", + ); + }); + by += bh + bgap; + } } // Scrub overlay (STAMPLE-style speed-based) diff --git a/toolchain/tapes/scrub-jam.mjs b/toolchain/tapes/scrub-jam.mjs index a3d7a8eec..16038e514 100644 --- a/toolchain/tapes/scrub-jam.mjs +++ b/toolchain/tapes/scrub-jam.mjs @@ -120,9 +120,12 @@ class Performer { let fadeTimer = null; window.__jamCursorMove = (x, y, down) => { window.__jamCursor = { x, y, down }; - el.style.transform = `translate(${x}px,${y}px)`; + // Down is unmistakable: the crosshair grows and glows. + el.style.transform = `translate(${x}px,${y}px) scale(${down ? 1.6 : 1})`; el.style.opacity = "1"; // Appear while in use... - el.style.filter = down ? "brightness(1.6)" : "none"; + el.style.filter = down + ? `brightness(1.5) drop-shadow(0 0 5px ${color})` + : "none"; clearTimeout(fadeTimer); fadeTimer = setTimeout(() => { el.style.opacity = "0"; // ...fade once the hand goes quiet. @@ -135,12 +138,26 @@ class Performer { } // Mirror a pointer state onto the virtual cursor (fire-and-forget). + // Self-healing: a page reload wipes the overlay — if the move lands on + // a bare window, reinstall the cursor so it never silently vanishes. cur(x, y, down = this.curDown) { this.curDown = down; this.curX = x; this.curY = y; this.page - .evaluate((a) => window.__jamCursorMove?.(a.x, a.y, a.down), { x, y, down }) + .evaluate( + (a) => + window.__jamCursorMove ? (window.__jamCursorMove(a.x, a.y, a.down), true) : false, + { x, y, down }, + ) + .then((ok) => { + if (!ok && !this.reinstalling) { + this.reinstalling = true; + this.installCursor() + .then(() => (this.reinstalling = false)) + .catch(() => (this.reinstalling = false)); + } + }) .catch(() => {}); }