mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 17:46:06 +00:00
Merge pull request #62 from yusufipk/worktree-bunny-playback-speed
feat(player): raise the playback speed ceiling off YouTube's limit
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user