mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 09:36:08 +00:00
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:
@@ -39,6 +39,7 @@ import type {
|
|||||||
import { useApprovals } from '@/components/video-page/hooks/use-approvals';
|
import { useApprovals } from '@/components/video-page/hooks/use-approvals';
|
||||||
import { useVideoAssets } from '@/components/video-page/hooks/use-video-assets';
|
import { useVideoAssets } from '@/components/video-page/hooks/use-video-assets';
|
||||||
import { resolvePublicBunnyCdnHostname } from '@/lib/bunny-cdn';
|
import { resolvePublicBunnyCdnHostname } from '@/lib/bunny-cdn';
|
||||||
|
import { getSpeedOptionsForProvider } from '@/components/video-page/hooks/video-player-utils';
|
||||||
|
|
||||||
function formatTime(seconds: number): string {
|
function formatTime(seconds: number): string {
|
||||||
const totalSeconds = Math.floor(seconds);
|
const totalSeconds = Math.floor(seconds);
|
||||||
@@ -64,8 +65,6 @@ function formatBunnyQualityLabel(
|
|||||||
return `Level ${index + 1}`;
|
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';
|
export type VideoPageMode = 'dashboard' | 'watch';
|
||||||
|
|
||||||
interface VideoPageContentProps {
|
interface VideoPageContentProps {
|
||||||
@@ -275,6 +274,7 @@ export function VideoPageContent({
|
|||||||
);
|
);
|
||||||
}, [video?.versions, activeVersionId]);
|
}, [video?.versions, activeVersionId]);
|
||||||
const activeProviderId = activeVersion?.providerId;
|
const activeProviderId = activeVersion?.providerId;
|
||||||
|
const speedOptions = getSpeedOptionsForProvider(activeProviderId);
|
||||||
const activeVersionDuration = activeVersion?.duration;
|
const activeVersionDuration = activeVersion?.duration;
|
||||||
const bunnyCdnHostname = useMemo(() => resolvePublicBunnyCdnHostname(), []);
|
const bunnyCdnHostname = useMemo(() => resolvePublicBunnyCdnHostname(), []);
|
||||||
const embedUrl = useMemo(() => {
|
const embedUrl = useMemo(() => {
|
||||||
@@ -354,7 +354,7 @@ export function VideoPageContent({
|
|||||||
playerRef,
|
playerRef,
|
||||||
formatTime,
|
formatTime,
|
||||||
formatBunnyQualityLabel,
|
formatBunnyQualityLabel,
|
||||||
speedOptions: SPEED_OPTIONS,
|
speedOptions,
|
||||||
scheduleWatchProgressSaveRef,
|
scheduleWatchProgressSaveRef,
|
||||||
setViewingAnnotation,
|
setViewingAnnotation,
|
||||||
});
|
});
|
||||||
@@ -819,7 +819,7 @@ export function VideoPageContent({
|
|||||||
qualityOptions={qualityOptions}
|
qualityOptions={qualityOptions}
|
||||||
handleQualityChange={handleQualityChange}
|
handleQualityChange={handleQualityChange}
|
||||||
playbackSpeed={playbackSpeed}
|
playbackSpeed={playbackSpeed}
|
||||||
speedOptions={SPEED_OPTIONS}
|
speedOptions={speedOptions}
|
||||||
handleSpeedChange={handleSpeedChange}
|
handleSpeedChange={handleSpeedChange}
|
||||||
toggleFullscreen={toggleFullscreen}
|
toggleFullscreen={toggleFullscreen}
|
||||||
showComments={showComments}
|
showComments={showComments}
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ import {
|
|||||||
} from '@/components/ui/dropdown-menu';
|
} from '@/components/ui/dropdown-menu';
|
||||||
import { resolvePublicBunnyCdnHostname } from '@/lib/bunny-cdn';
|
import { resolvePublicBunnyCdnHostname } from '@/lib/bunny-cdn';
|
||||||
import { cn } from '@/lib/utils';
|
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';
|
import type { BunnyPlaybackState, BunnyQualityOption } from '@/components/video-page/types';
|
||||||
|
|
||||||
interface BunnyPreviewPlayerProps {
|
interface BunnyPreviewPlayerProps {
|
||||||
@@ -35,8 +39,6 @@ export interface BunnyPreviewPlayerHandle {
|
|||||||
toggleMute: () => void;
|
toggleMute: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SPEED_OPTIONS = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2];
|
|
||||||
|
|
||||||
function formatTime(value: number): string {
|
function formatTime(value: number): string {
|
||||||
if (!Number.isFinite(value) || value < 0) return '0:00';
|
if (!Number.isFinite(value) || value < 0) return '0:00';
|
||||||
const total = Math.floor(value);
|
const total = Math.floor(value);
|
||||||
@@ -635,9 +637,14 @@ export const BunnyPreviewPlayer = forwardRef<BunnyPreviewPlayerHandle, BunnyPrev
|
|||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
{SPEED_OPTIONS.map((speed) => (
|
{NATIVE_SPEED_OPTIONS.map((speed) => (
|
||||||
<DropdownMenuItem key={speed} onClick={() => handleSpeedChange(speed)}>
|
<DropdownMenuItem key={speed} onClick={() => handleSpeedChange(speed)}>
|
||||||
{speed}x {speed === playbackSpeed ? '(Current)' : ''}
|
{speed}x {speed === playbackSpeed ? '(Current)' : ''}
|
||||||
|
{speed > SILENT_ABOVE_SPEED && (
|
||||||
|
<span className="ml-auto pl-2 text-[10px] text-muted-foreground">
|
||||||
|
no audio
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
))}
|
))}
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
|
|||||||
@@ -95,6 +95,23 @@ export function timeFromClientX(
|
|||||||
return percentage * duration;
|
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
|
* 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
|
* unknown current speed behaves like index -1, so stepping up lands on the
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import {
|
|||||||
type AnnotationCanvasHandle,
|
type AnnotationCanvasHandle,
|
||||||
type AnnotationStroke,
|
type AnnotationStroke,
|
||||||
} from '@/components/annotation-canvas';
|
} 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';
|
import type { BunnyQualityOption, CommentMarker } from '@/components/video-page/types';
|
||||||
|
|
||||||
interface PlayerCoreProps {
|
interface PlayerCoreProps {
|
||||||
@@ -453,9 +454,17 @@ export const PlayerCore = memo(function PlayerCore({
|
|||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
key={speed}
|
key={speed}
|
||||||
onClick={() => handleSpeedChange(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}x
|
||||||
|
{speed > SILENT_ABOVE_SPEED && (
|
||||||
|
<span className="text-[10px] font-normal text-muted-foreground">
|
||||||
|
no audio
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
))}
|
))}
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
|
|||||||
@@ -6,11 +6,15 @@ import {
|
|||||||
getFrameStepLabel,
|
getFrameStepLabel,
|
||||||
getFrameStepSeconds,
|
getFrameStepSeconds,
|
||||||
getPlayheadPercent,
|
getPlayheadPercent,
|
||||||
|
getSpeedOptionsForProvider,
|
||||||
isTypingTarget,
|
isTypingTarget,
|
||||||
normalizeFrameRate,
|
normalizeFrameRate,
|
||||||
|
NATIVE_SPEED_OPTIONS,
|
||||||
resolvePlayerShortcut,
|
resolvePlayerShortcut,
|
||||||
resolveSkipAmount,
|
resolveSkipAmount,
|
||||||
|
SILENT_ABOVE_SPEED,
|
||||||
timeFromClientX,
|
timeFromClientX,
|
||||||
|
YOUTUBE_SPEED_OPTIONS,
|
||||||
} from '@/components/video-page/hooks/video-player-utils';
|
} from '@/components/video-page/hooks/video-player-utils';
|
||||||
|
|
||||||
const SPEEDS = [0.25, 0.5, 1, 1.5, 2];
|
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', () => {
|
describe('resolvePlayerShortcut', () => {
|
||||||
it('maps the play/pause, seek, mute and fullscreen keys', () => {
|
it('maps the play/pause, seek, mute and fullscreen keys', () => {
|
||||||
expect(resolvePlayerShortcut({ code: 'Space' })).toBe('toggle-play');
|
expect(resolvePlayerShortcut({ code: 'Space' })).toBe('toggle-play');
|
||||||
|
|||||||
Reference in New Issue
Block a user