feat(player): raise the playback speed ceiling off YouTube's limit

A single speed ladder fed both players, so the 2x cap that YouTube's
iframe API enforces also applied to Bunny and R2, which are plain <video>
elements the browser will play far faster. Pick the ladder per provider:
YouTube keeps 0.25x-2x, the native ones go up to 16x, where Chrome and
Firefox clamp playbackRate. The picker labels everything past 4x as
"no audio", since that is where the browsers stop pitch-correcting and
drop the audio track.
This commit is contained in:
2026-08-20 10:50:42 +03:00
parent 4d9d35164f
commit 7b7b1d21b2
5 changed files with 73 additions and 8 deletions
+32
View File
@@ -6,11 +6,15 @@ import {
getFrameStepLabel,
getFrameStepSeconds,
getPlayheadPercent,
getSpeedOptionsForProvider,
isTypingTarget,
normalizeFrameRate,
NATIVE_SPEED_OPTIONS,
resolvePlayerShortcut,
resolveSkipAmount,
SILENT_ABOVE_SPEED,
timeFromClientX,
YOUTUBE_SPEED_OPTIONS,
} from '@/components/video-page/hooks/video-player-utils';
const SPEEDS = [0.25, 0.5, 1, 1.5, 2];
@@ -215,6 +219,34 @@ describe('getAdjacentPlaybackSpeed', () => {
});
});
describe('getSpeedOptionsForProvider', () => {
it('caps YouTube at 2x, since its iframe API ignores anything faster', () => {
expect(getSpeedOptionsForProvider('youtube')).toBe(YOUTUBE_SPEED_OPTIONS);
expect(Math.max(...YOUTUBE_SPEED_OPTIONS)).toBe(2);
});
it('lets the <video> providers run up to the browser ceiling', () => {
for (const provider of ['bunny', 'r2', undefined, null]) {
expect(getSpeedOptionsForProvider(provider)).toBe(NATIVE_SPEED_OPTIONS);
}
expect(NATIVE_SPEED_OPTIONS).toContain(3);
// 16 is where Chrome and Firefox clamp `playbackRate`; anything past it throws.
expect(Math.max(...NATIVE_SPEED_OPTIONS)).toBe(16);
});
it('marks the rates the browser plays without audio', () => {
expect(SILENT_ABOVE_SPEED).toBe(4);
expect(NATIVE_SPEED_OPTIONS.filter((speed) => speed > SILENT_ABOVE_SPEED)).toEqual([6, 8, 16]);
expect(YOUTUBE_SPEED_OPTIONS.every((speed) => speed <= SILENT_ABOVE_SPEED)).toBe(true);
});
it('keeps both ladders ascending so the arrow-key stepping stays monotonic', () => {
for (const ladder of [YOUTUBE_SPEED_OPTIONS, NATIVE_SPEED_OPTIONS]) {
expect([...ladder].sort((a, b) => a - b)).toEqual(ladder);
}
});
});
describe('resolvePlayerShortcut', () => {
it('maps the play/pause, seek, mute and fullscreen keys', () => {
expect(resolvePlayerShortcut({ code: 'Space' })).toBe('toggle-play');