feat(live): slow-mo playback — sub-1x VCR speeds (closes #771 M1) (#922)

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 <noreply@anthropic.com>
This commit is contained in:
efiten
2026-05-21 07:00:14 +02:00
committed by GitHub
parent 38eb7103b3
commit 6873219c7a
2 changed files with 43 additions and 9 deletions
+17 -6
View File
@@ -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;
+26 -3
View File
@@ -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;