mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 09:36:08 +00:00
639 lines
22 KiB
TypeScript
639 lines
22 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 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;
|
|
}
|
|
|
|
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);
|
|
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">
|
|
{SPEED_OPTIONS.map((speed) => (
|
|
<DropdownMenuItem key={speed} onClick={() => handleSpeedChange(speed)}>
|
|
{speed}x {speed === playbackSpeed ? '(Current)' : ''}
|
|
</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>
|
|
);
|
|
});
|