mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 17:46:06 +00:00
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.
705 lines
24 KiB
TypeScript
705 lines
24 KiB
TypeScript
'use client';
|
|
/* eslint-disable react-hooks/set-state-in-effect */
|
|
|
|
import {
|
|
forwardRef,
|
|
useCallback,
|
|
useEffect,
|
|
useImperativeHandle,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
} from 'react';
|
|
import Hls, { type Level } from 'hls.js';
|
|
import { ChevronDown, Loader2, Pause, Play, Volume2, VolumeX } from 'lucide-react';
|
|
import { Button } from '@/components/ui/button';
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuTrigger,
|
|
} 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 {
|
|
providerVideoId: string | null;
|
|
isProcessing: boolean;
|
|
onReadyToPlay?: () => void;
|
|
}
|
|
|
|
export interface BunnyPreviewPlayerHandle {
|
|
togglePlayPause: () => void;
|
|
seekBy: (seconds: number) => void;
|
|
toggleMute: () => void;
|
|
}
|
|
|
|
function formatTime(value: number): string {
|
|
if (!Number.isFinite(value) || value < 0) return '0:00';
|
|
const total = Math.floor(value);
|
|
const minutes = Math.floor(total / 60);
|
|
const seconds = total % 60;
|
|
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
|
}
|
|
|
|
function formatBunnyQualityLabel(
|
|
level: { height?: number; bitrate?: number },
|
|
index: number
|
|
): string {
|
|
if (typeof level.height === 'number' && level.height > 0) {
|
|
return `${level.height}p`;
|
|
}
|
|
if (typeof level.bitrate === 'number' && level.bitrate > 0) {
|
|
return `${Math.round(level.bitrate / 1000)} kbps`;
|
|
}
|
|
return `Level ${index + 1}`;
|
|
}
|
|
|
|
export const BunnyPreviewPlayer = forwardRef<BunnyPreviewPlayerHandle, BunnyPreviewPlayerProps>(
|
|
function BunnyPreviewPlayer({ providerVideoId, isProcessing, onReadyToPlay }, ref) {
|
|
const videoRef = useRef<HTMLVideoElement | null>(null);
|
|
const hlsRef = useRef<Hls | null>(null);
|
|
const retryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
const pendingHlsQualityRef = useRef<number | null>(null);
|
|
const onReadyToPlayRef = useRef(onReadyToPlay);
|
|
const hasNotifiedReadyRef = useRef(false);
|
|
const playbackSpeedRef = useRef(1);
|
|
const sourceSwitchResumeRef = useRef<{ time: number; wasPlaying: boolean } | null>(null);
|
|
const previousProviderVideoIdRef = useRef<string | null>(null);
|
|
|
|
const [isReady, setIsReady] = useState(false);
|
|
const [isPlaying, setIsPlaying] = useState(false);
|
|
const [isMuted, setIsMuted] = useState(false);
|
|
const [currentTime, setCurrentTime] = useState(0);
|
|
const [duration, setDuration] = useState(0);
|
|
const [playbackSpeed, setPlaybackSpeed] = useState(1);
|
|
const [qualityOptions, setQualityOptions] = useState<BunnyQualityOption[]>([]);
|
|
const [selectedQualityLevel, setSelectedQualityLevel] = useState<number>(-1);
|
|
const [bunnySourcePreference, setBunnySourcePreference] = useState<'auto' | 'original'>('auto');
|
|
const [bunnyPlaybackState, setBunnyPlaybackState] = useState<BunnyPlaybackState>('none');
|
|
const bunnyCdnHostname = useMemo(() => resolvePublicBunnyCdnHostname(), []);
|
|
|
|
const playlistUrl = useMemo(() => {
|
|
if (!providerVideoId || !bunnyCdnHostname) return null;
|
|
return `https://${bunnyCdnHostname}/${providerVideoId}/playlist.m3u8`;
|
|
}, [bunnyCdnHostname, providerVideoId]);
|
|
const originalUrl = useMemo(() => {
|
|
if (!providerVideoId || !bunnyCdnHostname) return null;
|
|
return `https://${bunnyCdnHostname}/${providerVideoId}/original`;
|
|
}, [bunnyCdnHostname, providerVideoId]);
|
|
|
|
useEffect(() => {
|
|
onReadyToPlayRef.current = onReadyToPlay;
|
|
}, [onReadyToPlay]);
|
|
|
|
useEffect(() => {
|
|
hasNotifiedReadyRef.current = false;
|
|
}, [providerVideoId]);
|
|
|
|
const notifyReadyToPlay = useCallback(() => {
|
|
if (hasNotifiedReadyRef.current) return;
|
|
hasNotifiedReadyRef.current = true;
|
|
onReadyToPlayRef.current?.();
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
playbackSpeedRef.current = playbackSpeed;
|
|
if (videoRef.current) {
|
|
videoRef.current.playbackRate = playbackSpeed;
|
|
}
|
|
}, [playbackSpeed]);
|
|
|
|
useEffect(() => {
|
|
const videoEl = videoRef.current;
|
|
const sourceKey = providerVideoId ?? null;
|
|
const sourceChanged = previousProviderVideoIdRef.current !== sourceKey;
|
|
previousProviderVideoIdRef.current = sourceKey;
|
|
|
|
if (!videoEl || !playlistUrl) {
|
|
setIsReady(false);
|
|
setIsPlaying(false);
|
|
setIsMuted(false);
|
|
setCurrentTime(0);
|
|
setDuration(0);
|
|
setQualityOptions((prev) => (sourceChanged ? [] : prev));
|
|
setSelectedQualityLevel(-1);
|
|
setBunnyPlaybackState('error');
|
|
return;
|
|
}
|
|
|
|
let cachedDuration = 0;
|
|
let destroyed = false;
|
|
let retryAttempt = 0;
|
|
let usingHlsJs = false;
|
|
let hlsInstance: Hls | null = null;
|
|
let sourceMode: 'hls' | 'original' =
|
|
bunnySourcePreference === 'original' ? 'original' : 'hls';
|
|
let attemptedAutoplay = false;
|
|
|
|
setIsReady(false);
|
|
setIsPlaying(false);
|
|
setCurrentTime(0);
|
|
setDuration(0);
|
|
setIsMuted(videoEl.muted);
|
|
setSelectedQualityLevel(bunnySourcePreference === 'original' ? -2 : -1);
|
|
setQualityOptions((prev) => (sourceChanged ? [] : prev));
|
|
setBunnyPlaybackState('none');
|
|
|
|
const clearRetryTimer = () => {
|
|
if (retryTimerRef.current) {
|
|
clearTimeout(retryTimerRef.current);
|
|
retryTimerRef.current = null;
|
|
}
|
|
};
|
|
|
|
const scheduleRetry = (retryFn: () => void) => {
|
|
clearRetryTimer();
|
|
retryTimerRef.current = setTimeout(() => {
|
|
if (!destroyed) {
|
|
retryFn();
|
|
}
|
|
}, 3000);
|
|
};
|
|
|
|
const getRetryUrl = (baseUrl: string) => {
|
|
retryAttempt += 1;
|
|
const separator = baseUrl.includes('?') ? '&' : '?';
|
|
return `${baseUrl}${separator}retry=${Date.now()}-${retryAttempt}`;
|
|
};
|
|
|
|
const retryNativeLoad = () => {
|
|
videoEl.src = getRetryUrl(playlistUrl);
|
|
videoEl.load();
|
|
};
|
|
|
|
const retryOriginalLoad = () => {
|
|
if (!originalUrl) return;
|
|
videoEl.src = getRetryUrl(originalUrl);
|
|
videoEl.load();
|
|
};
|
|
|
|
const retryHlsLoad = () => {
|
|
if (destroyed || !hlsInstance) return;
|
|
const retryUrl = getRetryUrl(playlistUrl);
|
|
try {
|
|
hlsInstance.stopLoad();
|
|
} catch {
|
|
// ignore stop-load failures and continue with a fresh loadSource
|
|
}
|
|
hlsInstance.loadSource(retryUrl);
|
|
hlsInstance.startLoad(-1);
|
|
};
|
|
|
|
const activateOriginalFallback = (): boolean => {
|
|
if (!originalUrl) return false;
|
|
sourceMode = 'original';
|
|
usingHlsJs = false;
|
|
clearRetryTimer();
|
|
if (hlsRef.current) {
|
|
try {
|
|
hlsRef.current.destroy();
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
hlsRef.current = null;
|
|
}
|
|
hlsInstance = null;
|
|
setSelectedQualityLevel(-2);
|
|
setBunnyPlaybackState('processing');
|
|
setIsReady(false);
|
|
retryOriginalLoad();
|
|
return true;
|
|
};
|
|
|
|
const syncDuration = () => {
|
|
if (Number.isFinite(videoEl.duration) && videoEl.duration > 0) {
|
|
cachedDuration = videoEl.duration;
|
|
setDuration(videoEl.duration);
|
|
}
|
|
};
|
|
|
|
const attemptAutoplay = () => {
|
|
if (attemptedAutoplay) return;
|
|
attemptedAutoplay = true;
|
|
videoEl
|
|
.play()
|
|
.then(() => {
|
|
notifyReadyToPlay();
|
|
})
|
|
.catch(() => {
|
|
// Autoplay can fail due to browser policy. User can still start playback manually.
|
|
});
|
|
};
|
|
|
|
const onLoadedMetadata = () => {
|
|
if (destroyed) return;
|
|
clearRetryTimer();
|
|
videoEl.playbackRate = playbackSpeedRef.current;
|
|
if (sourceMode === 'original') {
|
|
setSelectedQualityLevel(-2);
|
|
}
|
|
setBunnyPlaybackState(sourceMode === 'original' ? 'processing' : 'none');
|
|
setIsReady(true);
|
|
const resumeState = sourceSwitchResumeRef.current;
|
|
if (resumeState) {
|
|
const knownDuration =
|
|
Number.isFinite(videoEl.duration) && videoEl.duration > 0
|
|
? videoEl.duration
|
|
: cachedDuration;
|
|
const targetTime =
|
|
knownDuration > 0
|
|
? Math.min(Math.max(0, resumeState.time), Math.max(0, knownDuration - 0.01))
|
|
: Math.max(0, resumeState.time);
|
|
videoEl.currentTime = targetTime;
|
|
setCurrentTime(targetTime);
|
|
sourceSwitchResumeRef.current = null;
|
|
if (resumeState.wasPlaying) {
|
|
videoEl.play().catch(() => {
|
|
// Ignore policy and transient resume-play errors in preview modal.
|
|
});
|
|
}
|
|
}
|
|
syncDuration();
|
|
attemptAutoplay();
|
|
};
|
|
|
|
const onCanPlay = () => {
|
|
if (destroyed) return;
|
|
notifyReadyToPlay();
|
|
};
|
|
|
|
const onPlay = () => {
|
|
if (destroyed) return;
|
|
setIsPlaying(true);
|
|
if (sourceMode !== 'original') {
|
|
setBunnyPlaybackState('none');
|
|
}
|
|
syncDuration();
|
|
notifyReadyToPlay();
|
|
};
|
|
|
|
const onPause = () => {
|
|
if (destroyed) return;
|
|
setIsPlaying(false);
|
|
};
|
|
|
|
const onEnded = () => {
|
|
if (destroyed) return;
|
|
setIsPlaying(false);
|
|
};
|
|
|
|
const onTimeUpdate = () => {
|
|
if (destroyed) return;
|
|
setCurrentTime(videoEl.currentTime || 0);
|
|
if (
|
|
Number.isFinite(videoEl.duration) &&
|
|
videoEl.duration > 0 &&
|
|
videoEl.duration !== cachedDuration
|
|
) {
|
|
cachedDuration = videoEl.duration;
|
|
setDuration(videoEl.duration);
|
|
}
|
|
};
|
|
|
|
const onVideoError = () => {
|
|
if (destroyed) return;
|
|
if (usingHlsJs) return;
|
|
if (videoEl.readyState >= HTMLMediaElement.HAVE_METADATA) {
|
|
setBunnyPlaybackState('error');
|
|
return;
|
|
}
|
|
if (sourceMode === 'hls') {
|
|
if (activateOriginalFallback()) return;
|
|
setIsReady(false);
|
|
setBunnyPlaybackState('processing');
|
|
scheduleRetry(retryNativeLoad);
|
|
return;
|
|
}
|
|
setIsReady(false);
|
|
setBunnyPlaybackState('processing');
|
|
scheduleRetry(retryOriginalLoad);
|
|
};
|
|
|
|
const configureHlsLevels = (levels: Level[]) => {
|
|
setQualityOptions(
|
|
levels.map((level, index) => ({
|
|
level: index,
|
|
label: formatBunnyQualityLabel(level, index),
|
|
}))
|
|
);
|
|
const pendingQuality = pendingHlsQualityRef.current;
|
|
pendingHlsQualityRef.current = null;
|
|
|
|
if (pendingQuality === null || pendingQuality === -1) {
|
|
if (hlsInstance) {
|
|
hlsInstance.currentLevel = -1;
|
|
hlsInstance.nextLevel = -1;
|
|
}
|
|
setSelectedQualityLevel(-1);
|
|
return;
|
|
}
|
|
|
|
if (pendingQuality >= 0 && pendingQuality < levels.length && hlsInstance) {
|
|
hlsInstance.currentLevel = pendingQuality;
|
|
hlsInstance.nextLevel = pendingQuality;
|
|
setSelectedQualityLevel(pendingQuality);
|
|
return;
|
|
}
|
|
|
|
setSelectedQualityLevel(-1);
|
|
};
|
|
|
|
videoEl.addEventListener('loadedmetadata', onLoadedMetadata);
|
|
videoEl.addEventListener('canplay', onCanPlay);
|
|
videoEl.addEventListener('play', onPlay);
|
|
videoEl.addEventListener('pause', onPause);
|
|
videoEl.addEventListener('ended', onEnded);
|
|
videoEl.addEventListener('timeupdate', onTimeUpdate);
|
|
videoEl.addEventListener('error', onVideoError);
|
|
|
|
if (sourceMode === 'original' && originalUrl) {
|
|
retryOriginalLoad();
|
|
} else if (videoEl.canPlayType('application/vnd.apple.mpegurl')) {
|
|
sourceMode = 'hls';
|
|
videoEl.src = playlistUrl;
|
|
videoEl.load();
|
|
} else if (Hls.isSupported()) {
|
|
sourceMode = 'hls';
|
|
usingHlsJs = true;
|
|
const hls = new Hls();
|
|
hlsInstance = hls;
|
|
hlsRef.current = hls;
|
|
hls.attachMedia(videoEl);
|
|
|
|
hls.on(Hls.Events.MEDIA_ATTACHED, () => {
|
|
if (!destroyed) {
|
|
hls.loadSource(playlistUrl);
|
|
}
|
|
});
|
|
|
|
hls.on(Hls.Events.MANIFEST_PARSED, (_, data) => {
|
|
if (destroyed) return;
|
|
clearRetryTimer();
|
|
setBunnyPlaybackState('none');
|
|
configureHlsLevels(data.levels);
|
|
setIsReady(true);
|
|
syncDuration();
|
|
attemptAutoplay();
|
|
});
|
|
|
|
hls.on(Hls.Events.ERROR, (_, data) => {
|
|
if (destroyed) return;
|
|
const responseCode = (data as { response?: { code?: number } }).response?.code;
|
|
const isManifestLoadFailure =
|
|
data.details === Hls.ErrorDetails.MANIFEST_LOAD_ERROR ||
|
|
data.details === Hls.ErrorDetails.MANIFEST_LOAD_TIMEOUT;
|
|
const hasProcessingLikeStatus =
|
|
responseCode === undefined ||
|
|
responseCode === 0 ||
|
|
responseCode === 403 ||
|
|
responseCode === 404 ||
|
|
responseCode === 423 ||
|
|
responseCode === 429 ||
|
|
responseCode === 503;
|
|
const isLikelyProcessing = isManifestLoadFailure && hasProcessingLikeStatus;
|
|
const isNetworkPreMetadataProcessing =
|
|
data.type === Hls.ErrorTypes.NETWORK_ERROR &&
|
|
hasProcessingLikeStatus &&
|
|
videoEl.readyState < HTMLMediaElement.HAVE_METADATA;
|
|
const isUnknownPreMetadataProcessing =
|
|
!data.details && !data.type && videoEl.readyState < HTMLMediaElement.HAVE_METADATA;
|
|
|
|
if (
|
|
isLikelyProcessing ||
|
|
isNetworkPreMetadataProcessing ||
|
|
isUnknownPreMetadataProcessing
|
|
) {
|
|
if (activateOriginalFallback()) {
|
|
return;
|
|
}
|
|
setIsReady(false);
|
|
setBunnyPlaybackState('processing');
|
|
scheduleRetry(retryHlsLoad);
|
|
return;
|
|
}
|
|
|
|
if (data.fatal) {
|
|
setBunnyPlaybackState('error');
|
|
console.error('Fatal Bunny preview HLS error:', data);
|
|
}
|
|
});
|
|
} else {
|
|
setBunnyPlaybackState('error');
|
|
console.error('HLS is not supported in this browser.');
|
|
}
|
|
|
|
return () => {
|
|
destroyed = true;
|
|
clearRetryTimer();
|
|
videoEl.removeEventListener('loadedmetadata', onLoadedMetadata);
|
|
videoEl.removeEventListener('canplay', onCanPlay);
|
|
videoEl.removeEventListener('play', onPlay);
|
|
videoEl.removeEventListener('pause', onPause);
|
|
videoEl.removeEventListener('ended', onEnded);
|
|
videoEl.removeEventListener('timeupdate', onTimeUpdate);
|
|
videoEl.removeEventListener('error', onVideoError);
|
|
if (hlsRef.current) {
|
|
try {
|
|
hlsRef.current.destroy();
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
hlsRef.current = null;
|
|
}
|
|
videoEl.removeAttribute('src');
|
|
videoEl.load();
|
|
};
|
|
}, [notifyReadyToPlay, originalUrl, playlistUrl, bunnySourcePreference, providerVideoId]);
|
|
|
|
const seekTo = (event: React.MouseEvent<HTMLDivElement>) => {
|
|
const video = videoRef.current;
|
|
if (!video || !duration) return;
|
|
const rect = event.currentTarget.getBoundingClientRect();
|
|
const ratio = Math.min(1, Math.max(0, (event.clientX - rect.left) / rect.width));
|
|
video.currentTime = ratio * duration;
|
|
setCurrentTime(video.currentTime);
|
|
};
|
|
|
|
const togglePlayPause = useCallback(() => {
|
|
const video = videoRef.current;
|
|
if (!video || !isReady) return;
|
|
if (video.paused) void video.play();
|
|
else video.pause();
|
|
}, [isReady]);
|
|
|
|
const seekBy = useCallback(
|
|
(seconds: number) => {
|
|
const video = videoRef.current;
|
|
if (!video || !isReady || !duration) return;
|
|
video.currentTime = Math.min(duration, Math.max(0, (video.currentTime || 0) + seconds));
|
|
setCurrentTime(video.currentTime);
|
|
},
|
|
[duration, isReady]
|
|
);
|
|
|
|
const toggleMute = useCallback(() => {
|
|
const video = videoRef.current;
|
|
if (!video) return;
|
|
const nextMuted = !video.muted;
|
|
video.muted = nextMuted;
|
|
setIsMuted(nextMuted);
|
|
}, []);
|
|
|
|
const handleSpeedChange = useCallback((speed: number) => {
|
|
setPlaybackSpeed(speed);
|
|
if (videoRef.current) {
|
|
videoRef.current.playbackRate = speed;
|
|
}
|
|
}, []);
|
|
|
|
const handleQualityChange = useCallback(
|
|
(level: number) => {
|
|
const shouldCaptureSourceSwitch =
|
|
(level === -2 && bunnySourcePreference !== 'original') ||
|
|
(level !== -2 && bunnySourcePreference === 'original');
|
|
if (shouldCaptureSourceSwitch) {
|
|
const current = videoRef.current?.currentTime ?? 0;
|
|
sourceSwitchResumeRef.current = {
|
|
time: Number.isFinite(current) ? Math.max(0, current) : 0,
|
|
wasPlaying: !!videoRef.current && !videoRef.current.paused,
|
|
};
|
|
}
|
|
|
|
if (level === -2) {
|
|
pendingHlsQualityRef.current = null;
|
|
setBunnySourcePreference('original');
|
|
setSelectedQualityLevel(-2);
|
|
return;
|
|
}
|
|
|
|
pendingHlsQualityRef.current = level;
|
|
setBunnySourcePreference('auto');
|
|
|
|
const hls = hlsRef.current;
|
|
if (!hls) {
|
|
setSelectedQualityLevel(level === -1 ? -1 : level);
|
|
return;
|
|
}
|
|
|
|
if (level === -1) {
|
|
hls.currentLevel = -1;
|
|
hls.nextLevel = -1;
|
|
setSelectedQualityLevel(-1);
|
|
return;
|
|
}
|
|
|
|
hls.currentLevel = level;
|
|
hls.nextLevel = level;
|
|
setSelectedQualityLevel(level);
|
|
},
|
|
[bunnySourcePreference]
|
|
);
|
|
|
|
useImperativeHandle(
|
|
ref,
|
|
() => ({
|
|
togglePlayPause,
|
|
seekBy,
|
|
toggleMute,
|
|
}),
|
|
[seekBy, toggleMute, togglePlayPause]
|
|
);
|
|
|
|
const showProcessingOverlay = bunnyPlaybackState !== 'error' && !isReady;
|
|
const showErrorOverlay = bunnyPlaybackState === 'error';
|
|
const loadingLabel =
|
|
isProcessing || bunnyPlaybackState === 'processing' ? 'Processing...' : 'Loading...';
|
|
|
|
const selectedQualityLabel = useMemo(() => {
|
|
if (selectedQualityLevel === -2) return 'Original';
|
|
if (selectedQualityLevel === -1) return 'Auto';
|
|
return (
|
|
qualityOptions.find((option) => option.level === selectedQualityLevel)?.label ?? 'Auto'
|
|
);
|
|
}, [qualityOptions, selectedQualityLevel]);
|
|
|
|
return (
|
|
<div className="w-full h-full rounded-md border overflow-hidden bg-black flex flex-col">
|
|
<div
|
|
className="relative flex-1 min-h-0 flex items-center justify-center bg-black"
|
|
onClick={togglePlayPause}
|
|
>
|
|
<video
|
|
ref={videoRef}
|
|
className="w-full h-full object-contain bg-black"
|
|
playsInline
|
|
preload="metadata"
|
|
/>
|
|
|
|
{showProcessingOverlay && (
|
|
<div className="absolute inset-0 bg-black/65 flex items-center justify-center">
|
|
<div className="flex items-center gap-2 text-white text-sm">
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
{loadingLabel}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{showErrorOverlay && (
|
|
<div className="absolute inset-0 bg-black/65 flex items-center justify-center">
|
|
<p className="text-xs text-white/85">
|
|
Unable to load Bunny preview. Please try again in a moment.
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="shrink-0 border-t border-white/10 bg-black/70 px-2 py-1.5">
|
|
<div className="flex items-center gap-1.5 mb-1.5">
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-7 w-7 text-white hover:text-white"
|
|
disabled={!isReady}
|
|
onClick={togglePlayPause}
|
|
>
|
|
{isPlaying ? <Pause className="h-3.5 w-3.5" /> : <Play className="h-3.5 w-3.5" />}
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-7 w-7 text-white hover:text-white"
|
|
disabled={!isReady}
|
|
onClick={toggleMute}
|
|
>
|
|
{isMuted ? <VolumeX className="h-3.5 w-3.5" /> : <Volume2 className="h-3.5 w-3.5" />}
|
|
</Button>
|
|
<span className="text-[11px] text-white/80 tabular-nums ml-1">
|
|
{formatTime(currentTime)} / {formatTime(duration)}
|
|
</span>
|
|
|
|
<div className="ml-auto flex items-center gap-1">
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-7 px-2 text-[11px] text-white hover:text-white"
|
|
disabled={!isReady}
|
|
>
|
|
{playbackSpeed}x
|
|
<ChevronDown className="h-3 w-3 ml-1" />
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end">
|
|
{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>
|
|
</DropdownMenu>
|
|
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-7 px-2 text-[11px] text-white hover:text-white"
|
|
disabled={!isReady}
|
|
>
|
|
{selectedQualityLabel}
|
|
<ChevronDown className="h-3 w-3 ml-1" />
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end">
|
|
<DropdownMenuItem onClick={() => handleQualityChange(-1)}>
|
|
Auto {selectedQualityLevel === -1 ? '(Current)' : ''}
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem onClick={() => handleQualityChange(-2)}>
|
|
Original {selectedQualityLevel === -2 ? '(Current)' : ''}
|
|
</DropdownMenuItem>
|
|
{qualityOptions.map((option) => (
|
|
<DropdownMenuItem
|
|
key={option.level}
|
|
onClick={() => handleQualityChange(option.level)}
|
|
>
|
|
{option.label} {option.level === selectedQualityLevel ? '(Current)' : ''}
|
|
</DropdownMenuItem>
|
|
))}
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</div>
|
|
</div>
|
|
<div
|
|
className={cn(
|
|
'relative h-6 rounded bg-white/10 select-none',
|
|
isReady ? 'cursor-pointer' : 'cursor-not-allowed opacity-70'
|
|
)}
|
|
onClick={seekTo}
|
|
>
|
|
<div
|
|
className="absolute left-0 top-0 h-full rounded bg-cyan-500/40 pointer-events-none"
|
|
style={{ width: `${duration > 0 ? (currentTime / duration) * 100 : 0}%` }}
|
|
/>
|
|
<div
|
|
className="absolute top-0 h-full w-1 rounded bg-cyan-400 pointer-events-none"
|
|
style={{ left: `calc(${duration > 0 ? (currentTime / duration) * 100 : 0}% - 2px)` }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
);
|