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
+4 -4
View File
@@ -39,6 +39,7 @@ import type {
import { useApprovals } from '@/components/video-page/hooks/use-approvals';
import { useVideoAssets } from '@/components/video-page/hooks/use-video-assets';
import { resolvePublicBunnyCdnHostname } from '@/lib/bunny-cdn';
import { getSpeedOptionsForProvider } from '@/components/video-page/hooks/video-player-utils';
function formatTime(seconds: number): string {
const totalSeconds = Math.floor(seconds);
@@ -64,8 +65,6 @@ function formatBunnyQualityLabel(
return `Level ${index + 1}`;
}
const SPEED_OPTIONS = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2];
export type VideoPageMode = 'dashboard' | 'watch';
interface VideoPageContentProps {
@@ -275,6 +274,7 @@ export function VideoPageContent({
);
}, [video?.versions, activeVersionId]);
const activeProviderId = activeVersion?.providerId;
const speedOptions = getSpeedOptionsForProvider(activeProviderId);
const activeVersionDuration = activeVersion?.duration;
const bunnyCdnHostname = useMemo(() => resolvePublicBunnyCdnHostname(), []);
const embedUrl = useMemo(() => {
@@ -354,7 +354,7 @@ export function VideoPageContent({
playerRef,
formatTime,
formatBunnyQualityLabel,
speedOptions: SPEED_OPTIONS,
speedOptions,
scheduleWatchProgressSaveRef,
setViewingAnnotation,
});
@@ -819,7 +819,7 @@ export function VideoPageContent({
qualityOptions={qualityOptions}
handleQualityChange={handleQualityChange}
playbackSpeed={playbackSpeed}
speedOptions={SPEED_OPTIONS}
speedOptions={speedOptions}
handleSpeedChange={handleSpeedChange}
toggleFullscreen={toggleFullscreen}
showComments={showComments}
+10 -3
View File
@@ -21,6 +21,10 @@ import {
} from '@/components/ui/dropdown-menu';
import { resolvePublicBunnyCdnHostname } from '@/lib/bunny-cdn';
import { cn } from '@/lib/utils';
import {
NATIVE_SPEED_OPTIONS,
SILENT_ABOVE_SPEED,
} from '@/components/video-page/hooks/video-player-utils';
import type { BunnyPlaybackState, BunnyQualityOption } from '@/components/video-page/types';
interface BunnyPreviewPlayerProps {
@@ -35,8 +39,6 @@ export interface BunnyPreviewPlayerHandle {
toggleMute: () => void;
}
const SPEED_OPTIONS = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2];
function formatTime(value: number): string {
if (!Number.isFinite(value) || value < 0) return '0:00';
const total = Math.floor(value);
@@ -635,9 +637,14 @@ export const BunnyPreviewPlayer = forwardRef<BunnyPreviewPlayerHandle, BunnyPrev
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{SPEED_OPTIONS.map((speed) => (
{NATIVE_SPEED_OPTIONS.map((speed) => (
<DropdownMenuItem key={speed} onClick={() => handleSpeedChange(speed)}>
{speed}x {speed === playbackSpeed ? '(Current)' : ''}
{speed > SILENT_ABOVE_SPEED && (
<span className="ml-auto pl-2 text-[10px] text-muted-foreground">
no audio
</span>
)}
</DropdownMenuItem>
))}
</DropdownMenuContent>
@@ -95,6 +95,23 @@ export function timeFromClientX(
return percentage * duration;
}
// Speed ladders. YouTube is played through its iframe API, which silently
// ignores any rate outside the list `getAvailablePlaybackRates()` returns, so
// 2x is the ceiling there. Bunny and R2 are plain <video> elements, where the
// ceiling is the browser's: Chrome and Firefox both clamp `playbackRate` at 16,
// and setting more throws, so 16x is the top of the ladder.
export const YOUTUBE_SPEED_OPTIONS = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2];
export const NATIVE_SPEED_OPTIONS = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2, 2.5, 3, 4, 6, 8, 16];
// Past 4x the browsers stop pitch-correcting and drop the audio track entirely.
// The video still plays, so the fast rates are worth offering, but the picker
// says so rather than letting a silent 8x read as a broken file.
export const SILENT_ABOVE_SPEED = 4;
export function getSpeedOptionsForProvider(providerId: string | null | undefined): number[] {
return providerId === 'youtube' ? YOUTUBE_SPEED_OPTIONS : NATIVE_SPEED_OPTIONS;
}
/**
* Next or previous entry in the speed ladder, or `null` at either end. An
* unknown current speed behaves like index -1, so stepping up lands on the
+10 -1
View File
@@ -31,6 +31,7 @@ import {
type AnnotationCanvasHandle,
type AnnotationStroke,
} from '@/components/annotation-canvas';
import { SILENT_ABOVE_SPEED } from '@/components/video-page/hooks/video-player-utils';
import type { BunnyQualityOption, CommentMarker } from '@/components/video-page/types';
interface PlayerCoreProps {
@@ -453,9 +454,17 @@ export const PlayerCore = memo(function PlayerCore({
<DropdownMenuItem
key={speed}
onClick={() => handleSpeedChange(speed)}
className={cn(speed === playbackSpeed && 'font-bold text-primary')}
className={cn(
'flex items-center justify-between gap-2',
speed === playbackSpeed && 'font-bold text-primary'
)}
>
{speed}x
{speed > SILENT_ABOVE_SPEED && (
<span className="text-[10px] font-normal text-muted-foreground">
no audio
</span>
)}
</DropdownMenuItem>
))}
</DropdownMenuContent>