From 6873219c7a2c0fb7bcc0264a4a2fa2cdb4ce334f Mon Sep 17 00:00:00 2001 From: efiten Date: Thu, 21 May 2026 07:00:14 +0200 Subject: [PATCH] =?UTF-8?q?feat(live):=20slow-mo=20playback=20=E2=80=94=20?= =?UTF-8?q?sub-1x=20VCR=20speeds=20(closes=20#771=20M1)=20(#922)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends VCR speed cycle to `[0.25, 0.5, 1, 2, 4, 8]` so users can watch live paths in slow motion. ## Changes - `vcrSpeedCycle()`: speed array extended to include `¼x` and `½x`; saves preference to `localStorage('live-vcr-speed')` - `speedLabel()`: new helper returning `¼x` / `½x` for sub-1x, used in the speed button - `drawAnimatedLine`: step interval scales with speed (`33 / VCR.speed`) - `drawMatrixLine`: `DURATION_MS` scales with speed (`1100 / VCR.speed`) - Speed preference restored from localStorage on page load ## Tests 3 new unit tests; 72 pass, 0 regressions. Closes #771 (M1 of 3) --------- Co-authored-by: Claude Sonnet 4.6 --- public/live.js | 23 +++++++++++++++++------ test-live.js | 29 ++++++++++++++++++++++++++--- 2 files changed, 43 insertions(+), 9 deletions(-) diff --git a/public/live.js b/public/live.js index c7104977..ae015b52 100644 --- a/public/live.js +++ b/public/live.js @@ -96,6 +96,8 @@ } return m; } + const _savedSpeed = parseFloat(localStorage.getItem('live-vcr-speed')); + const _initialSpeed = [0.25, 0.5, 1, 2, 4, 8].includes(_savedSpeed) ? _savedSpeed : 1; let rainCanvas = null, rainCtx = null, rainDrops = [], rainRAF = null; const propagationBuffer = new Map(); // hash -> {timer, packets[]} let _onResize = null; @@ -579,10 +581,17 @@ }); } + function speedLabel(s) { + if (s === 0.25) return '¼x'; + if (s === 0.5) return '½x'; + return s + 'x'; + } + function vcrSpeedCycle() { - const speeds = [1, 2, 4, 8]; + const speeds = [0.25, 0.5, 1, 2, 4, 8]; const idx = speeds.indexOf(VCR.speed); VCR.speed = speeds[(idx + 1) % speeds.length]; + localStorage.setItem('live-vcr-speed', VCR.speed); updateVCRUI(); // If replaying, restart with new speed if (VCR.mode === 'REPLAY' && VCR.replayTimer) { @@ -718,7 +727,7 @@ if (pauseBtn) { pauseBtn.textContent = '⏸'; pauseBtn.setAttribute('aria-label', 'Pause'); } if (missedEl) missedEl.classList.add('hidden'); } - if (speedBtn) { speedBtn.textContent = VCR.speed + 'x'; speedBtn.setAttribute('aria-label', 'Speed ' + VCR.speed + 'x'); } + if (speedBtn) { speedBtn.textContent = speedLabel(VCR.speed); speedBtn.setAttribute('aria-label', 'Speed ' + speedLabel(VCR.speed)); } updateVCRLcd(); } @@ -2461,6 +2470,7 @@ window._liveFormatLiveTimestampHtml = formatLiveTimestampHtml; window._liveResolveHopPositions = resolveHopPositions; window._liveVcrSpeedCycle = vcrSpeedCycle; + window._liveSpeedLabel = speedLabel; window._liveVcrPause = vcrPause; window._liveVcrResumeLive = vcrResumeLive; window._liveVcrSetMode = vcrSetMode; @@ -3129,7 +3139,7 @@ const matrixGreen = '#00ff41'; const TRAIL_LEN = Math.min(6, bytes.length); - const DURATION_MS = 1100; // total hop duration + const DURATION_MS = 1100 / VCR.speed; const CHAR_INTERVAL = 0.06; // spawn a char every 6% of progress const charMarkers = []; let nextCharAt = CHAR_INTERVAL; @@ -3261,8 +3271,9 @@ return; } const elapsed = now - lastStep; - if (elapsed >= 33) { - const ticks = Math.min(Math.floor(elapsed / 33), 4); + const stepMs = 33 / VCR.speed; + if (elapsed >= stepMs) { + const ticks = Math.min(Math.floor(elapsed / stepMs), 4); lastStep = now; for (let t = 0; t < ticks && step < steps; t++) { step++; @@ -3605,7 +3616,7 @@ packetCount = 0; activeAnims = 0; nodeActivity = {}; pktTimestamps = []; feedDedup.clear(); - VCR.buffer = []; VCR.playhead = -1; VCR.mode = 'LIVE'; VCR.missedCount = 0; VCR.speed = 1; VCR.replayGen = 0; + VCR.buffer = []; VCR.playhead = -1; VCR.mode = 'LIVE'; VCR.missedCount = 0; VCR.speed = _initialSpeed; VCR.replayGen = 0; } let _themeRefreshHandler = null; diff --git a/test-live.js b/test-live.js index 0de5add2..46322791 100644 --- a/test-live.js +++ b/test-live.js @@ -402,9 +402,13 @@ console.log('\n=== live.js: VCR state machine ==='); assert.strictEqual(VCR().mode, 'PAUSED', 'mode should stay PAUSED after second call'); }); - test('vcrSpeedCycle cycles through 1,2,4,8', () => { + test('vcrSpeedCycle cycles through 0.25, 0.5, 1, 2, 4, 8 and wraps', () => { vcrSetMode('LIVE'); - VCR().speed = 1; + VCR().speed = 0.25; + vcrSpeedCycle(); + assert.strictEqual(VCR().speed, 0.5); + vcrSpeedCycle(); + assert.strictEqual(VCR().speed, 1); vcrSpeedCycle(); assert.strictEqual(VCR().speed, 2); vcrSpeedCycle(); @@ -412,7 +416,26 @@ console.log('\n=== live.js: VCR state machine ==='); vcrSpeedCycle(); assert.strictEqual(VCR().speed, 8); vcrSpeedCycle(); - assert.strictEqual(VCR().speed, 1); // wraps around + assert.strictEqual(VCR().speed, 0.25); // wraps around + }); + + test('vcrSpeedCycle saves speed to localStorage', () => { + VCR().speed = 1; + vcrSpeedCycle(); + assert.strictEqual(ctx.localStorage.getItem('live-vcr-speed'), '2'); + vcrSpeedCycle(); + assert.strictEqual(ctx.localStorage.getItem('live-vcr-speed'), '4'); + }); + + const speedLabel = ctx.window._liveSpeedLabel; + assert.ok(speedLabel, '_liveSpeedLabel must be exposed'); + + test('speedLabel returns fraction strings for sub-1x speeds', () => { + assert.strictEqual(speedLabel(0.25), '¼x'); + assert.strictEqual(speedLabel(0.5), '½x'); + assert.strictEqual(speedLabel(1), '1x'); + assert.strictEqual(speedLabel(2), '2x'); + assert.strictEqual(speedLabel(8), '8x'); }); const vcrResumeLive = ctx.window._liveVcrResumeLive;