From f9da19f547eb352f29a1fa2be05bc0155a6b8590 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=27=C3=A9lectron=20rare?= <108685187+electron-rare@users.noreply.github.com> Date: Sun, 28 Jun 2026 19:24:01 +0200 Subject: [PATCH] feat: web matrix timeline loop and seek MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add 32-bar timeline header above the matrix grid in #tab-matrix. - Tap a bar: sends /matrix/seek - Drag across bars: sends /matrix/loop - BOUCLE COMPLETE button: resets to /matrix/loop 0 31 - .in-loop highlight from /matrix/loop feedback - .tl-head moves with /matrix/playhead (O(32)) - Pointer Events API (pointerdown/move/up + setPointerCapture + elementFromPoint) — works on desktop mouse and iPad touch --- web_realart/public/control/control.css | 13 +++ web_realart/public/control/control.js | 122 +++++++++++++++++++++++++ web_realart/public/control/index.html | 2 + 3 files changed, 137 insertions(+) diff --git a/web_realart/public/control/control.css b/web_realart/public/control/control.css index 26f88fd..08c3b73 100644 --- a/web_realart/public/control/control.css +++ b/web_realart/public/control/control.css @@ -129,3 +129,16 @@ button.queued { animation: queued-blink 600ms ease-in-out infinite alternate; } .mat-action-btn { width: auto; padding: 8px 14px; font-size: 14px; flex-shrink: 0; } .mat-refresh-btn { width: auto; padding: 8px 12px; font-size: 16px; flex-shrink: 0; background: #1a2a1a; border-color: #2a4a2a; color: #4d9; } +.mat-loop-full-btn { width: auto; padding: 8px 14px; font-size: 13px; margin: 4px 0; + background: #1a1a2e; border-color: #335; color: #9af; } + +/* --- Matrix timeline header --- */ +.matrix-timeline { display: flex; gap: 1px; margin: 2px 0; touch-action: none; } +.tl-spacer { width: 54px; flex-shrink: 0; } +.tl-bar { width: 22px; min-width: 22px; height: 20px; flex-shrink: 0; + background: #1a1a1a; border: 1px solid #2a2a2a; border-radius: 2px; + cursor: pointer; font-size: 9px; color: #555; text-align: center; + line-height: 20px; user-select: none; -webkit-user-select: none; } +.tl-bar.in-loop { background: #1a2a1a; border-color: #2a5a2a; color: #4d9; } +.tl-bar.tl-head { background: #fa0; border-color: #fc0; color: #000; } +.tl-bar.tl-head.in-loop { background: #fa0; border-color: #fc0; color: #000; } diff --git a/web_realart/public/control/control.js b/web_realart/public/control/control.js index 81719a3..3800c45 100644 --- a/web_realart/public/control/control.js +++ b/web_realart/public/control/control.js @@ -143,6 +143,19 @@ ws.addEventListener("message", (ev) => { const el = cellRefs[matPlayhead][vi]; if (el) el.classList.add("playing"); } + updateTimelineHead(bar); + return; + } + + // /matrix/loop — loop region feedback + if (address === "/matrix/loop") { + const s = Math.round(Number(args[0])); + const e = Math.round(Number(args[1])); + if (Number.isFinite(s) && Number.isFinite(e) && s >= 0 && e < MATRIX_BARS) { + matLoopStart = Math.min(s, e); + matLoopEnd = Math.max(s, e); + updateTimelineLoop(); + } return; } @@ -211,6 +224,8 @@ let matGrid = Array.from({ length: 16 }, () => new Array(32).fill(0)); // cellRefs[bar][vi] for O(16) playhead column toggle const cellRefs = Array.from({ length: 32 }, () => new Array(16).fill(null)); let matPlayhead = -1; +let matLoopStart = 0; +let matLoopEnd = 31; function loadMatState() { try { @@ -291,6 +306,107 @@ function renderMatrix() { } } +// --- Matrix timeline: 32-bar loop region + playhead --- +function renderTimeline() { + const tl = document.getElementById("matrix-timeline"); + if (!tl) return; + tl.innerHTML = ""; + const spacer = document.createElement("span"); + spacer.className = "tl-spacer"; + tl.appendChild(spacer); + for (let bar = 0; bar < MATRIX_BARS; bar++) { + const cell = document.createElement("span"); + cell.className = "tl-bar"; + cell.dataset.bar = bar; + if (bar % 4 === 0) cell.textContent = String(bar + 1); + if (bar >= matLoopStart && bar <= matLoopEnd) cell.classList.add("in-loop"); + if (bar === matPlayhead) cell.classList.add("tl-head"); + tl.appendChild(cell); + } +} + +function updateTimelineLoop() { + const tl = document.getElementById("matrix-timeline"); + if (!tl) return; + tl.querySelectorAll(".tl-bar").forEach((cell) => { + const b = +cell.dataset.bar; + cell.classList.toggle("in-loop", b >= matLoopStart && b <= matLoopEnd); + }); +} + +function updateTimelineHead(bar) { + const tl = document.getElementById("matrix-timeline"); + if (!tl) return; + const prev = tl.querySelector(".tl-head"); + if (prev) prev.classList.remove("tl-head"); + const next = tl.querySelector(`.tl-bar[data-bar="${bar}"]`); + if (next) next.classList.add("tl-head"); +} + +function initTimelinePointer() { + const tl = document.getElementById("matrix-timeline"); + if (!tl) return; + let dragStart = -1; + let dragging = false; + + function barFromPoint(x, y) { + const el = document.elementFromPoint(x, y); + if (!el) return -1; + const barEl = el.classList && el.classList.contains("tl-bar") + ? el + : (el.closest ? el.closest(".tl-bar") : null); + if (!barEl) return -1; + const b = +barEl.dataset.bar; + return (Number.isFinite(b) && b >= 0 && b < MATRIX_BARS) ? b : -1; + } + + function previewLoop(start, end) { + const lo = Math.min(start, end); + const hi = Math.max(start, end); + tl.querySelectorAll(".tl-bar").forEach((cell) => { + const b = +cell.dataset.bar; + cell.classList.toggle("in-loop", b >= lo && b <= hi); + }); + } + + tl.addEventListener("pointerdown", (e) => { + const bar = barFromPoint(e.clientX, e.clientY); + if (bar < 0) return; + e.preventDefault(); + tl.setPointerCapture(e.pointerId); + dragStart = bar; + dragging = true; + }); + + tl.addEventListener("pointermove", (e) => { + if (!dragging) return; + const cur = barFromPoint(e.clientX, e.clientY); + if (cur >= 0) previewLoop(dragStart, cur); + }); + + tl.addEventListener("pointerup", (e) => { + if (!dragging) return; + dragging = false; + tl.releasePointerCapture(e.pointerId); + const end = barFromPoint(e.clientX, e.clientY); + const validEnd = end >= 0 ? end : dragStart; + if (validEnd === dragStart) { + send("/matrix/seek", dragStart); + } else { + const lo = Math.min(dragStart, validEnd); + const hi = Math.max(dragStart, validEnd); + send("/matrix/loop", lo, hi); + } + dragStart = -1; + }); + + tl.addEventListener("pointercancel", () => { + dragging = false; + dragStart = -1; + updateTimelineLoop(); + }); +} + // --- Matrix audio-reactive glow (trigger envelope; math in matrix_glow.js) --- const voiceLevel = new Array(MATRIX_VOICES.length).fill(0); let glowRaf = null; @@ -516,6 +632,8 @@ document.addEventListener("DOMContentLoaded", () => { renderRhyGrid(); renderMelSteps(); renderMatrix(); + renderTimeline(); + initTimelinePointer(); // Name input: rename selected preset and update button labels const melName = document.getElementById("mel-name"); @@ -619,6 +737,10 @@ document.addEventListener("DOMContentLoaded", () => { const sceneNext = document.getElementById("scene-next"); if (sceneNext) sceneNext.addEventListener("click", () => send("/scene/next")); + // Matrix loop full reset + const matLoopFull = document.getElementById("matrix-loop-full"); + if (matLoopFull) matLoopFull.addEventListener("click", () => send("/matrix/loop", 0, MATRIX_BARS - 1)); + // Matrix transport const matPlay = document.getElementById("matrix-play"); if (matPlay) matPlay.addEventListener("click", () => send("/matrix/play")); diff --git a/web_realart/public/control/index.html b/web_realart/public/control/index.html index 07dd577..f10856f 100644 --- a/web_realart/public/control/index.html +++ b/web_realart/public/control/index.html @@ -165,7 +165,9 @@ +
+