Merge remote-tracking branch 'origin/main' into worktree-welcome-screen-controls

# Conflicts:
#	simulator/e2e/tests/a11y.spec.ts
#	simulator/static/app.js
This commit is contained in:
BenStullsBets
2026-06-30 12:55:42 -07:00
6 changed files with 37 additions and 116 deletions
+14 -42
View File
@@ -1126,60 +1126,33 @@ let devStatsTimer = null;
// --- Playback speed + reduced motion -----------------------------------------
// "Reduce motion" is no longer a visible toggle — its accessibility behavior is
// derived LIVE from the OS `prefers-reduced-motion` setting (no persisted
// override). The visible control is the Playback Speed slider, which drives ONLY
// the resting altitude loop video (#vid-loop); the morph/transition video (#vid)
// is scrub-driven and is never speed-controlled. Below 0x the loop plays in
// reverse via a manual rAF loop (native negative playbackRate is unreliable —
// Chrome ignores it). When the OS asks for reduced motion the loop stays frozen
// and the slider is disabled, so a vestibular/photosensitive visitor never gets
// motion they did not ask for.
const SPEED_EPS = 0.02; // |speed| below this = freeze (treat as 0)
// override). The visible control is the Playback Speed slider (0x4x forward),
// which drives ONLY the resting altitude loop video (#vid-loop); the
// morph/transition video (#vid) is scrub-driven and is never speed-controlled.
// 0x freezes the loop on its current frame. (Reverse playback was dropped: base
// loop clips aren't all-intra, so native/manual reverse seeking jitters.) When
// the OS asks for reduced motion the loop stays frozen and the slider is
// disabled, so a vestibular/photosensitive visitor never gets motion they did
// not ask for.
const SPEED_EPS = 0.02; // speed below this = freeze (treat as 0)
let playSpeed = 1;
let revRaf = 0; // rAF handle for the manual reverse loop
let revLast = 0; // last rAF timestamp (ms) for dt
const reduceMq = (window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)")) || null;
function isReduced() { return !!(reduceMq && reduceMq.matches); }
function stopReverse() {
if (revRaf) { cancelAnimationFrame(revRaf); revRaf = 0; }
}
// One reverse-loop frame: step #vid-loop's currentTime backward via the pure
// HEFScrub.reverseStep math, then schedule the next. dt is capped so a stalled
// tab can't make a giant jump.
function reverseTick(now) {
if (!revRaf) return;
const dt = revLast ? Math.min((now - revLast) / 1000, 0.1) : 0;
revLast = now;
const dur = loopVid.duration;
if (Number.isFinite(dur) && dur > 0 && dt > 0) {
const r = HEFScrub.reverseStep(loopVid.currentTime, Math.abs(playSpeed), dt, loopStartFrame(), dur);
try { loopVid.currentTime = r.next; } catch (_) { /* not seekable yet */ }
}
revRaf = requestAnimationFrame(reverseTick);
}
function startReverse() {
if (revRaf) return;
loopVid.pause(); // the rAF owns the element while reversing
revLast = 0;
revRaf = requestAnimationFrame(reverseTick);
}
// Apply the chosen slider speed to the resting loop video. When reduced motion
// holds or video output is off, the freeze/play decision belongs to
// syncReducedMotion / playLoop — here we only make sure no reverse loop is left
// running and the forward rate is sane for when it resumes.
// syncReducedMotion / playLoop — here we only keep the forward rate sane for
// when it resumes.
function applyPlaySpeed() {
stopReverse();
if (isReduced() || !videoEverOn || !$("visual").checked) {
loopVid.playbackRate = Math.max(0.0625, Math.abs(playSpeed) || 1);
loopVid.playbackRate = Math.max(0.0625, playSpeed || 1);
return;
}
if (playSpeed > SPEED_EPS) {
loopVid.playbackRate = playSpeed;
if (loopVid.paused) loopVid.play().catch(() => {});
} else if (playSpeed < -SPEED_EPS) {
startReverse();
} else {
loopVid.pause(); // ~0x → freeze on the current frame
loopVid.pause(); // 0x → freeze on the current frame
}
}
// Both the welcome screen's slider and the panel's drive the same playSpeed; each
@@ -1190,7 +1163,7 @@ function syncSpeedSliders() {
for (const id of SPEED_SLIDERS) { const s = $(id); if (s && s.value !== String(playSpeed)) s.value = String(playSpeed); }
}
function updateSpeedLabel() {
const txt = playSpeed.toFixed(2).replace("-", "") + "×";
const txt = playSpeed.toFixed(2) + "×";
for (const id of SPEED_VALS) { const el = $(id); if (el) el.textContent = txt; }
}
function initPlaySpeed() {
@@ -1219,7 +1192,6 @@ function syncReducedMotion() {
const reduced = isReduced();
for (const id of SPEED_SLIDERS) { const s = $(id); if (s) s.disabled = reduced; }
if (reduced) {
stopReverse();
if (autoRaf) { cancelAnimationFrame(autoRaf); autoRaf = 0; }
vid.pause();
loopVid.pause();
+5 -8
View File
@@ -22,7 +22,7 @@
<select id="welcome-lang" aria-label="Language"></select>
</label>
<label class="audio-level play-speed"><span data-i18n="speed.label">Speed</span>
<input type="range" id="welcome-play-speed" min="-2" max="2" step="any" value="1"
<input type="range" id="welcome-play-speed" min="0" max="4" step="any" value="1"
list="play-speed-ticks" aria-describedby="welcome-play-speed-val" />
<span id="welcome-play-speed-val">1.00×</span>
</label>
@@ -72,17 +72,14 @@
<span id="audio-level-val">0</span>/10
</label>
<label class="audio-level play-speed"><span data-i18n="speed.label">Speed</span>
<input type="range" id="play-speed" min="-2" max="2" step="any" value="1"
<input type="range" id="play-speed" min="0" max="4" step="any" value="1"
list="play-speed-ticks" aria-describedby="play-speed-val" />
<span id="play-speed-val">1.00×</span>
</label>
<datalist id="play-speed-ticks">
<option value="-2"></option><option value="-1.75"></option><option value="-1.5"></option>
<option value="-1.25"></option><option value="-1"></option><option value="-0.75"></option>
<option value="-0.5"></option><option value="-0.25"></option><option value="0"></option>
<option value="0.25"></option><option value="0.5"></option><option value="0.75"></option>
<option value="1"></option><option value="1.25"></option><option value="1.5"></option>
<option value="1.75"></option><option value="2"></option>
<option value="0"></option><option value="0.5"></option><option value="1"></option>
<option value="1.5"></option><option value="2"></option><option value="2.5"></option>
<option value="3"></option><option value="3.5"></option><option value="4"></option>
</datalist>
<label class="lang-pick">🌐
<select id="lang-select" aria-label="Language"></select>
+1 -18
View File
@@ -41,23 +41,6 @@
return dir < 0 ? 0 : loopTailS;
}
// One frame of REVERSE loop playback for the resting altitude video. Native
// negative playbackRate is unreliable (Chrome ignores it), so app.js drives
// currentTime by hand; this is the pure math. The visible loop region is
// [loopStart, duration] (same frames the forward loop shows). Step back by
// absSpeed*dt; if it crosses below loopStart, wrap around to the tail
// (duration), carrying the overshoot modulo the region length so a big dt
// can't escape the region. Returns the next currentTime and whether a
// bottom-boundary wrap happened (so the caller can re-arm its loop state).
function reverseStep(t, absSpeed, dt, loopStart, duration) {
const L = duration - loopStart;
if (!(L > 0)) return { next: duration, wrapped: false };
const next = t - Math.abs(absSpeed) * dt;
if (next >= loopStart) return { next, wrapped: false };
const overshoot = (loopStart - next) % L;
return { next: duration - overshoot, wrapped: true };
}
// Integers strictly crossed moving prevPos -> pos, in travel order. Landing
// exactly on an integer counts as crossing it (it commits that altitude).
function integerCrossings(prevPos, pos) {
@@ -70,5 +53,5 @@
return out;
}
return { clamp01, wrapIndex, segmentOf, accumToPos, fracToTime, crossfadeGains, loopLandFrame, reverseStep, integerCrossings };
return { clamp01, wrapIndex, segmentOf, accumToPos, fracToTime, crossfadeGains, loopLandFrame, integerCrossings };
});