From ecbfe4246b857ae0226936eb17b9c9c35bc4496f Mon Sep 17 00:00:00 2001 From: you Date: Thu, 19 Mar 2026 06:58:42 +0000 Subject: [PATCH] Fix VCR scrubber: pure visual drag + DB fetch on release Root cause: scrubbing moved playhead to closest buffer entry (recent WS packets only), then replay tick() recalculated position relative to current time, snapping it back. Fixed by splitting into two phases: 1. scrubVisual: only moves the DOM playhead element during drag 2. scrubCommit: on release, fetches packets from DB around target timestamp, merges into buffer, seeks to closest entry, starts replay. No more rubber-banding. --- public/live.js | 86 ++++++++++++++++++++++++++++---------------------- 1 file changed, 48 insertions(+), 38 deletions(-) diff --git a/public/live.js b/public/live.js index 5853f01a..d3a4c86a 100644 --- a/public/live.js +++ b/public/live.js @@ -576,67 +576,77 @@ // Drag scrubbing on timeline VCR.dragging = false; - function scrubToX(clientX, isFinal) { + VCR.dragPct = 0; + + function scrubVisual(clientX) { const rect = timelineEl.getBoundingClientRect(); - const pct = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width)); - const now = Date.now(); - const targetTs = now - VCR.timelineScope + pct * VCR.timelineScope; - - // Always move playhead visually during drag + VCR.dragPct = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width)); const playheadEl = document.getElementById('vcrPlayhead'); - if (playheadEl) { - playheadEl.style.left = (pct * rect.width) + 'px'; - } - - // If buffer is empty or target is before buffer start, fetch from DB on release - if (VCR.buffer.length === 0 || targetTs < VCR.buffer[0].ts - 5000) { - if (isFinal) vcrRewind(now - targetTs); - return; - } - - let closest = 0; - let minDist = Infinity; - VCR.buffer.forEach((entry, i) => { - const dist = Math.abs(entry.ts - targetTs); - if (dist < minDist) { minDist = dist; closest = i; } - }); - - stopReplay(); - VCR.playhead = closest; - vcrSetMode('REPLAY'); - updateVCRUI(); + if (playheadEl) playheadEl.style.left = (VCR.dragPct * rect.width) + 'px'; } + + function scrubCommit() { + const now = Date.now(); + const targetTs = now - VCR.timelineScope + VCR.dragPct * VCR.timelineScope; + + // Always fetch from DB for the target time window and replay from there + stopReplay(); + vcrSetMode('REPLAY'); + const fetchFrom = new Date(targetTs - 30000).toISOString(); // 30s before target + fetch(`/api/packets?limit=200&grouped=false&since=${encodeURIComponent(fetchFrom)}`) + .then(r => r.json()) + .then(data => { + const pkts = (data.packets || []).reverse(); // oldest first + const existingIds = new Set(VCR.buffer.map(b => b.pkt.id).filter(Boolean)); + const newEntries = pkts.filter(p => !existingIds.has(p.id)).map(p => ({ + ts: new Date(p.timestamp || p.created_at).getTime(), + pkt: dbPacketToLive(p) + })); + if (newEntries.length) { + VCR.buffer = [...newEntries, ...VCR.buffer].sort((a,b) => a.ts - b.ts); + } + // Find closest entry to target timestamp + let closest = 0, minDist = Infinity; + VCR.buffer.forEach((entry, i) => { + const dist = Math.abs(entry.ts - targetTs); + if (dist < minDist) { minDist = dist; closest = i; } + }); + VCR.playhead = closest; + startReplay(); + }) + .catch(() => {}); + } + timelineEl.addEventListener('mousedown', (e) => { VCR.dragging = true; - scrubToX(e.clientX, false); + stopReplay(); + scrubVisual(e.clientX); e.preventDefault(); }); document.addEventListener('mousemove', (e) => { if (!VCR.dragging) return; - scrubToX(e.clientX, false); + scrubVisual(e.clientX); }); - document.addEventListener('mouseup', (e) => { + document.addEventListener('mouseup', () => { if (!VCR.dragging) return; VCR.dragging = false; - scrubToX(e.clientX, true); - if (VCR.mode === 'REPLAY') startReplay(); + scrubCommit(); }); // Touch support timelineEl.addEventListener('touchstart', (e) => { VCR.dragging = true; - scrubToX(e.touches[0].clientX, false); + stopReplay(); + scrubVisual(e.touches[0].clientX); e.preventDefault(); }, { passive: false }); timelineEl.addEventListener('touchmove', (e) => { if (!VCR.dragging) return; - scrubToX(e.touches[0].clientX, false); + scrubVisual(e.touches[0].clientX); }); - timelineEl.addEventListener('touchend', (e) => { + timelineEl.addEventListener('touchend', () => { if (!VCR.dragging) return; - const lastTouch = e.changedTouches[0]; VCR.dragging = false; - scrubToX(lastTouch.clientX, true); - if (VCR.mode === 'REPLAY') startReplay(); + scrubCommit(); }); // Fetch historical timestamps for timeline, then start refresh