feat: web matrix timeline loop and seek
CI build oscope-of / build-check (push) Has been cancelled

Add 32-bar timeline header above the matrix grid in #tab-matrix.
- Tap a bar: sends /matrix/seek <bar>
- Drag across bars: sends /matrix/loop <min> <max>
- 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
This commit is contained in:
L'électron rare
2026-06-28 19:24:01 +02:00
parent 529d6c5cd9
commit f9da19f547
3 changed files with 137 additions and 0 deletions
+13
View File
@@ -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; }
+122
View File
@@ -143,6 +143,19 @@ ws.addEventListener("message", (ev) => {
const el = cellRefs[matPlayhead][vi];
if (el) el.classList.add("playing");
}
updateTimelineHead(bar);
return;
}
// /matrix/loop <start> <end> — 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"));
+2
View File
@@ -165,7 +165,9 @@
<button id="matrix-load" class="mat-action-btn">CHARGER</button>
<button id="matrix-refresh" class="mat-refresh-btn" title="Rafraichir la liste">&#8635;</button>
</div>
<button id="matrix-loop-full" class="mat-action-btn mat-loop-full-btn">BOUCLE COMPLETE</button>
<div class="matrix-scroll">
<div id="matrix-timeline" class="matrix-timeline"></div>
<div id="matrix-grid"></div>
</div>
</section>