mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 17:46:06 +00:00
feat(video-player): add Bunny original-source fallback and quality option, plus approval/download UI tweaks
This commit is contained in:
+40
-2
@@ -896,6 +896,7 @@ function BunnyPanel({
|
|||||||
let isPlaying = false;
|
let isPlaying = false;
|
||||||
let destroyed = false;
|
let destroyed = false;
|
||||||
let retryAttempt = 0;
|
let retryAttempt = 0;
|
||||||
|
let sourceMode: 'hls' | 'original' = 'hls';
|
||||||
let retryTimer: ReturnType<typeof setTimeout> | null = null;
|
let retryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
const clearRetryTimer = () => {
|
const clearRetryTimer = () => {
|
||||||
@@ -956,6 +957,7 @@ function BunnyPanel({
|
|||||||
videoEl.removeEventListener('pause', onPause);
|
videoEl.removeEventListener('pause', onPause);
|
||||||
videoEl.removeEventListener('ended', onEnded);
|
videoEl.removeEventListener('ended', onEnded);
|
||||||
videoEl.removeEventListener('loadedmetadata', onLoadedMetadata);
|
videoEl.removeEventListener('loadedmetadata', onLoadedMetadata);
|
||||||
|
videoEl.removeEventListener('error', onError);
|
||||||
if (hlsRef.current) {
|
if (hlsRef.current) {
|
||||||
try { hlsRef.current.destroy(); } catch { /* ignore */ }
|
try { hlsRef.current.destroy(); } catch { /* ignore */ }
|
||||||
hlsRef.current = null;
|
hlsRef.current = null;
|
||||||
@@ -978,18 +980,45 @@ function BunnyPanel({
|
|||||||
const onPlay = () => { isPlaying = true; };
|
const onPlay = () => { isPlaying = true; };
|
||||||
const onPause = () => { isPlaying = false; };
|
const onPause = () => { isPlaying = false; };
|
||||||
const onEnded = () => { isPlaying = false; };
|
const onEnded = () => { isPlaying = false; };
|
||||||
|
const hlsUrl = `https://${BUNNY_PULL_ZONE_HOSTNAME}/${version.videoId}/playlist.m3u8`;
|
||||||
|
const originalUrl = `https://${BUNNY_PULL_ZONE_HOSTNAME}/${version.videoId}/original`;
|
||||||
|
const activateOriginalFallback = (): void => {
|
||||||
|
sourceMode = 'original';
|
||||||
|
clearRetryTimer();
|
||||||
|
if (hlsRef.current) {
|
||||||
|
try { hlsRef.current.destroy(); } catch { /* ignore */ }
|
||||||
|
hlsRef.current = null;
|
||||||
|
}
|
||||||
|
videoEl.src = getRetryUrl(originalUrl);
|
||||||
|
videoEl.load();
|
||||||
|
};
|
||||||
|
const onError = () => {
|
||||||
|
if (destroyed) return;
|
||||||
|
if (videoEl.readyState >= HTMLMediaElement.HAVE_METADATA) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (sourceMode === 'hls') {
|
||||||
|
activateOriginalFallback();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
scheduleRetry(() => {
|
||||||
|
videoEl.src = getRetryUrl(originalUrl);
|
||||||
|
videoEl.load();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
videoEl.addEventListener('loadedmetadata', onLoadedMetadata);
|
videoEl.addEventListener('loadedmetadata', onLoadedMetadata);
|
||||||
videoEl.addEventListener('timeupdate', onTimeUpdate);
|
videoEl.addEventListener('timeupdate', onTimeUpdate);
|
||||||
videoEl.addEventListener('play', onPlay);
|
videoEl.addEventListener('play', onPlay);
|
||||||
videoEl.addEventListener('pause', onPause);
|
videoEl.addEventListener('pause', onPause);
|
||||||
videoEl.addEventListener('ended', onEnded);
|
videoEl.addEventListener('ended', onEnded);
|
||||||
|
videoEl.addEventListener('error', onError);
|
||||||
const hlsUrl = `https://${BUNNY_PULL_ZONE_HOSTNAME}/${version.videoId}/playlist.m3u8`;
|
|
||||||
if (videoEl.canPlayType('application/vnd.apple.mpegurl')) {
|
if (videoEl.canPlayType('application/vnd.apple.mpegurl')) {
|
||||||
|
sourceMode = 'hls';
|
||||||
videoEl.src = hlsUrl;
|
videoEl.src = hlsUrl;
|
||||||
videoEl.load();
|
videoEl.load();
|
||||||
} else if (Hls.isSupported()) {
|
} else if (Hls.isSupported()) {
|
||||||
|
sourceMode = 'hls';
|
||||||
const hls = new Hls();
|
const hls = new Hls();
|
||||||
hlsRef.current = hls;
|
hlsRef.current = hls;
|
||||||
hls.attachMedia(videoEl);
|
hls.attachMedia(videoEl);
|
||||||
@@ -1023,7 +1052,16 @@ function BunnyPanel({
|
|||||||
&& videoEl.readyState < HTMLMediaElement.HAVE_METADATA;
|
&& videoEl.readyState < HTMLMediaElement.HAVE_METADATA;
|
||||||
|
|
||||||
if (isLikelyProcessing || isNetworkPreMetadataProcessing || isUnknownPreMetadataProcessing) {
|
if (isLikelyProcessing || isNetworkPreMetadataProcessing || isUnknownPreMetadataProcessing) {
|
||||||
|
if (sourceMode === 'hls') {
|
||||||
|
activateOriginalFallback();
|
||||||
|
return;
|
||||||
|
}
|
||||||
scheduleRetry(() => {
|
scheduleRetry(() => {
|
||||||
|
if (sourceMode === 'original') {
|
||||||
|
videoEl.src = getRetryUrl(originalUrl);
|
||||||
|
videoEl.load();
|
||||||
|
return;
|
||||||
|
}
|
||||||
const retryUrl = getRetryUrl(hlsUrl);
|
const retryUrl = getRetryUrl(hlsUrl);
|
||||||
try {
|
try {
|
||||||
hls.stopLoad();
|
hls.stopLoad();
|
||||||
|
|||||||
@@ -381,6 +381,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
|||||||
}, [videoDuration, activeVersion?.duration]);
|
}, [videoDuration, activeVersion?.duration]);
|
||||||
|
|
||||||
const selectedQualityLabel = useMemo(() => {
|
const selectedQualityLabel = useMemo(() => {
|
||||||
|
if (selectedQualityLevel === -2) return 'Original';
|
||||||
if (selectedQualityLevel === -1) return 'Auto';
|
if (selectedQualityLevel === -1) return 'Auto';
|
||||||
return qualityOptions.find((option) => option.level === selectedQualityLevel)?.label ?? 'Auto';
|
return qualityOptions.find((option) => option.level === selectedQualityLevel)?.label ?? 'Auto';
|
||||||
}, [qualityOptions, selectedQualityLevel]);
|
}, [qualityOptions, selectedQualityLevel]);
|
||||||
@@ -514,7 +515,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
|||||||
? `/projects/${propProjectId}`
|
? `/projects/${propProjectId}`
|
||||||
: (video?.projectId ? `/projects/${video.projectId}` : '/');
|
: (video?.projectId ? `/projects/${video.projectId}` : '/');
|
||||||
const isBunnyVersion = activeVersion?.providerId === 'bunny';
|
const isBunnyVersion = activeVersion?.providerId === 'bunny';
|
||||||
const showBunnyProcessingOverlay = isBunnyVersion && bunnyPlaybackState === 'processing';
|
const showBunnyProcessingOverlay = isBunnyVersion && bunnyPlaybackState === 'processing' && !isReady;
|
||||||
const showBunnyErrorOverlay = isBunnyVersion && bunnyPlaybackState === 'error';
|
const showBunnyErrorOverlay = isBunnyVersion && bunnyPlaybackState === 'error';
|
||||||
|
|
||||||
const confirmGuestName = useCallback(() => {
|
const confirmGuestName = useCallback(() => {
|
||||||
@@ -916,6 +917,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
|||||||
requests={approvalRequests}
|
requests={approvalRequests}
|
||||||
currentUserId={currentUserId}
|
currentUserId={currentUserId}
|
||||||
canRequestApproval={canRequestApproval}
|
canRequestApproval={canRequestApproval}
|
||||||
|
onOpenApprovalRequest={handleOpenApprovalRequestDialog}
|
||||||
isLoadingRequests={isLoadingApprovals}
|
isLoadingRequests={isLoadingApprovals}
|
||||||
isSubmittingDecision={isSubmittingApprovalDecision}
|
isSubmittingDecision={isSubmittingApprovalDecision}
|
||||||
isCancelingRequest={isCancelingApprovalRequest}
|
isCancelingRequest={isCancelingApprovalRequest}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ interface ApprovalRequestsPanelProps {
|
|||||||
requests: ApprovalRequest[];
|
requests: ApprovalRequest[];
|
||||||
currentUserId: string | null;
|
currentUserId: string | null;
|
||||||
canRequestApproval: boolean;
|
canRequestApproval: boolean;
|
||||||
|
onOpenApprovalRequest: () => void;
|
||||||
isLoadingRequests: boolean;
|
isLoadingRequests: boolean;
|
||||||
isSubmittingDecision: boolean;
|
isSubmittingDecision: boolean;
|
||||||
isCancelingRequest: boolean;
|
isCancelingRequest: boolean;
|
||||||
@@ -58,6 +59,7 @@ export function ApprovalRequestsPanel({
|
|||||||
requests,
|
requests,
|
||||||
currentUserId,
|
currentUserId,
|
||||||
canRequestApproval,
|
canRequestApproval,
|
||||||
|
onOpenApprovalRequest,
|
||||||
isLoadingRequests,
|
isLoadingRequests,
|
||||||
isSubmittingDecision,
|
isSubmittingDecision,
|
||||||
isCancelingRequest,
|
isCancelingRequest,
|
||||||
@@ -102,9 +104,19 @@ export function ApprovalRequestsPanel({
|
|||||||
<div className="px-4 pb-4 space-y-3 overflow-y-auto">
|
<div className="px-4 pb-4 space-y-3 overflow-y-auto">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<p className="text-xs text-muted-foreground">{requests.length} request(s)</p>
|
<p className="text-xs text-muted-foreground">{requests.length} request(s)</p>
|
||||||
<Button size="sm" variant="ghost" onClick={onRefresh} disabled={isLoadingRequests}>
|
<div className="flex items-center gap-2">
|
||||||
{isLoadingRequests ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCcw className="h-4 w-4" />}
|
<Button
|
||||||
</Button>
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={onOpenApprovalRequest}
|
||||||
|
disabled={!canRequestApproval || !!pendingRequest}
|
||||||
|
>
|
||||||
|
Request Approval
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="ghost" onClick={onRefresh} disabled={isLoadingRequests}>
|
||||||
|
{isLoadingRequests ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCcw className="h-4 w-4" />}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error ? (
|
{error ? (
|
||||||
|
|||||||
@@ -55,6 +55,10 @@ export const BunnyPreviewPlayer = forwardRef<BunnyPreviewPlayerHandle, BunnyPrev
|
|||||||
if (!providerVideoId) return null;
|
if (!providerVideoId) return null;
|
||||||
return `https://${resolveBunnyCdnHostname()}/${providerVideoId}/playlist.m3u8`;
|
return `https://${resolveBunnyCdnHostname()}/${providerVideoId}/playlist.m3u8`;
|
||||||
}, [providerVideoId]);
|
}, [providerVideoId]);
|
||||||
|
const originalUrl = useMemo(() => {
|
||||||
|
if (!providerVideoId) return null;
|
||||||
|
return `https://${resolveBunnyCdnHostname()}/${providerVideoId}/original`;
|
||||||
|
}, [providerVideoId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const video = videoRef.current;
|
const video = videoRef.current;
|
||||||
@@ -62,6 +66,7 @@ export const BunnyPreviewPlayer = forwardRef<BunnyPreviewPlayerHandle, BunnyPrev
|
|||||||
|
|
||||||
let destroyed = false;
|
let destroyed = false;
|
||||||
let usingHlsJs = false;
|
let usingHlsJs = false;
|
||||||
|
let sourceMode: 'hls' | 'original' = 'hls';
|
||||||
|
|
||||||
const clearRetry = () => {
|
const clearRetry = () => {
|
||||||
if (retryTimerRef.current) {
|
if (retryTimerRef.current) {
|
||||||
@@ -77,10 +82,22 @@ export const BunnyPreviewPlayer = forwardRef<BunnyPreviewPlayerHandle, BunnyPrev
|
|||||||
}, 3000);
|
}, 3000);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getRetryUrl = () => {
|
const getRetryUrl = (baseUrl: string) => {
|
||||||
retryAttemptRef.current += 1;
|
retryAttemptRef.current += 1;
|
||||||
const separator = playlistUrl.includes('?') ? '&' : '?';
|
const separator = baseUrl.includes('?') ? '&' : '?';
|
||||||
return `${playlistUrl}${separator}retry=${Date.now()}-${retryAttemptRef.current}`;
|
return `${baseUrl}${separator}retry=${Date.now()}-${retryAttemptRef.current}`;
|
||||||
|
};
|
||||||
|
const loadOriginal = (): boolean => {
|
||||||
|
if (!originalUrl) return false;
|
||||||
|
sourceMode = 'original';
|
||||||
|
usingHlsJs = false;
|
||||||
|
if (hlsRef.current) {
|
||||||
|
hlsRef.current.destroy();
|
||||||
|
hlsRef.current = null;
|
||||||
|
}
|
||||||
|
video.src = getRetryUrl(originalUrl);
|
||||||
|
video.load();
|
||||||
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
const onLoadedMetadata = () => {
|
const onLoadedMetadata = () => {
|
||||||
@@ -101,12 +118,18 @@ export const BunnyPreviewPlayer = forwardRef<BunnyPreviewPlayerHandle, BunnyPrev
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setLoadError(false);
|
setLoadError(false);
|
||||||
|
if (sourceMode === 'hls' && loadOriginal()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
scheduleRetry(() => {
|
scheduleRetry(() => {
|
||||||
if (usingHlsJs && hlsRef.current) {
|
if (sourceMode === 'original' && originalUrl) {
|
||||||
hlsRef.current.loadSource(getRetryUrl());
|
video.src = getRetryUrl(originalUrl);
|
||||||
|
video.load();
|
||||||
|
} else if (usingHlsJs && hlsRef.current) {
|
||||||
|
hlsRef.current.loadSource(getRetryUrl(playlistUrl));
|
||||||
hlsRef.current.startLoad(-1);
|
hlsRef.current.startLoad(-1);
|
||||||
} else {
|
} else {
|
||||||
video.src = getRetryUrl();
|
video.src = getRetryUrl(playlistUrl);
|
||||||
video.load();
|
video.load();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -124,6 +147,7 @@ export const BunnyPreviewPlayer = forwardRef<BunnyPreviewPlayerHandle, BunnyPrev
|
|||||||
const hls = new Hls();
|
const hls = new Hls();
|
||||||
hlsRef.current = hls;
|
hlsRef.current = hls;
|
||||||
usingHlsJs = true;
|
usingHlsJs = true;
|
||||||
|
sourceMode = 'hls';
|
||||||
hls.attachMedia(video);
|
hls.attachMedia(video);
|
||||||
hls.on(Hls.Events.MEDIA_ATTACHED, () => {
|
hls.on(Hls.Events.MEDIA_ATTACHED, () => {
|
||||||
if (!destroyed) hls.loadSource(playlistUrl);
|
if (!destroyed) hls.loadSource(playlistUrl);
|
||||||
@@ -131,10 +155,12 @@ export const BunnyPreviewPlayer = forwardRef<BunnyPreviewPlayerHandle, BunnyPrev
|
|||||||
hls.on(Hls.Events.ERROR, (_event, data) => {
|
hls.on(Hls.Events.ERROR, (_event, data) => {
|
||||||
if (destroyed) return;
|
if (destroyed) return;
|
||||||
if (data.fatal && video.readyState < HTMLMediaElement.HAVE_METADATA) {
|
if (data.fatal && video.readyState < HTMLMediaElement.HAVE_METADATA) {
|
||||||
scheduleRetry(() => hls.loadSource(getRetryUrl()));
|
if (loadOriginal()) return;
|
||||||
|
scheduleRetry(() => hls.loadSource(getRetryUrl(playlistUrl)));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} else if (canPlayNativeHls) {
|
} else if (canPlayNativeHls) {
|
||||||
|
sourceMode = 'hls';
|
||||||
video.src = playlistUrl;
|
video.src = playlistUrl;
|
||||||
video.load();
|
video.load();
|
||||||
} else {
|
} else {
|
||||||
@@ -164,7 +190,7 @@ export const BunnyPreviewPlayer = forwardRef<BunnyPreviewPlayerHandle, BunnyPrev
|
|||||||
setDuration(0);
|
setDuration(0);
|
||||||
setIsReady(false);
|
setIsReady(false);
|
||||||
};
|
};
|
||||||
}, [playlistUrl]);
|
}, [playlistUrl, originalUrl]);
|
||||||
|
|
||||||
const seekTo = (event: React.MouseEvent<HTMLDivElement>) => {
|
const seekTo = (event: React.MouseEvent<HTMLDivElement>) => {
|
||||||
const video = videoRef.current;
|
const video = videoRef.current;
|
||||||
@@ -177,17 +203,17 @@ export const BunnyPreviewPlayer = forwardRef<BunnyPreviewPlayerHandle, BunnyPrev
|
|||||||
|
|
||||||
const togglePlayPause = useCallback(() => {
|
const togglePlayPause = useCallback(() => {
|
||||||
const video = videoRef.current;
|
const video = videoRef.current;
|
||||||
if (!video || !isReady || isProcessing) return;
|
if (!video || !isReady) return;
|
||||||
if (video.paused) void video.play();
|
if (video.paused) void video.play();
|
||||||
else video.pause();
|
else video.pause();
|
||||||
}, [isProcessing, isReady]);
|
}, [isReady]);
|
||||||
|
|
||||||
const seekBy = useCallback((seconds: number) => {
|
const seekBy = useCallback((seconds: number) => {
|
||||||
const video = videoRef.current;
|
const video = videoRef.current;
|
||||||
if (!video || !isReady || isProcessing || !duration) return;
|
if (!video || !isReady || !duration) return;
|
||||||
video.currentTime = Math.min(duration, Math.max(0, (video.currentTime || 0) + seconds));
|
video.currentTime = Math.min(duration, Math.max(0, (video.currentTime || 0) + seconds));
|
||||||
setCurrentTime(video.currentTime);
|
setCurrentTime(video.currentTime);
|
||||||
}, [duration, isProcessing, isReady]);
|
}, [duration, isReady]);
|
||||||
|
|
||||||
const toggleMute = useCallback(() => {
|
const toggleMute = useCallback(() => {
|
||||||
const video = videoRef.current;
|
const video = videoRef.current;
|
||||||
@@ -208,11 +234,11 @@ export const BunnyPreviewPlayer = forwardRef<BunnyPreviewPlayerHandle, BunnyPrev
|
|||||||
<div className="relative flex-1 min-h-0 flex items-center justify-center bg-black" onClick={togglePlayPause}>
|
<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" />
|
<video ref={videoRef} className="w-full h-full object-contain bg-black" playsInline preload="metadata" />
|
||||||
|
|
||||||
{(isProcessing || (!isReady && !loadError)) && (
|
{(!isReady && !loadError) && (
|
||||||
<div className="absolute inset-0 bg-black/65 flex items-center justify-center">
|
<div className="absolute inset-0 bg-black/65 flex items-center justify-center">
|
||||||
<div className="flex items-center gap-2 text-white text-sm">
|
<div className="flex items-center gap-2 text-white text-sm">
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
Processing...
|
{isProcessing ? 'Processing...' : 'Loading...'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -230,7 +256,7 @@ export const BunnyPreviewPlayer = forwardRef<BunnyPreviewPlayerHandle, BunnyPrev
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-7 w-7 text-white hover:text-white"
|
className="h-7 w-7 text-white hover:text-white"
|
||||||
disabled={!isReady || isProcessing}
|
disabled={!isReady}
|
||||||
onClick={togglePlayPause}
|
onClick={togglePlayPause}
|
||||||
>
|
>
|
||||||
{isPlaying ? <Pause className="h-3.5 w-3.5" /> : <Play className="h-3.5 w-3.5" />}
|
{isPlaying ? <Pause className="h-3.5 w-3.5" /> : <Play className="h-3.5 w-3.5" />}
|
||||||
@@ -251,7 +277,7 @@ export const BunnyPreviewPlayer = forwardRef<BunnyPreviewPlayerHandle, BunnyPrev
|
|||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'relative h-6 rounded bg-white/10 select-none',
|
'relative h-6 rounded bg-white/10 select-none',
|
||||||
isReady && !isProcessing ? 'cursor-pointer' : 'cursor-not-allowed opacity-70'
|
isReady ? 'cursor-pointer' : 'cursor-not-allowed opacity-70'
|
||||||
)}
|
)}
|
||||||
onClick={seekTo}
|
onClick={seekTo}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -64,6 +64,9 @@ export function useVideoPlayer({
|
|||||||
const [playbackSpeed, setPlaybackSpeed] = useState(1);
|
const [playbackSpeed, setPlaybackSpeed] = useState(1);
|
||||||
const [qualityOptions, setQualityOptions] = useState<BunnyQualityOption[]>([]);
|
const [qualityOptions, setQualityOptions] = useState<BunnyQualityOption[]>([]);
|
||||||
const [selectedQualityLevel, setSelectedQualityLevel] = useState<number>(-1);
|
const [selectedQualityLevel, setSelectedQualityLevel] = useState<number>(-1);
|
||||||
|
const [bunnySourcePreference, setBunnySourcePreference] = useState<'auto' | 'original'>('auto');
|
||||||
|
const pendingHlsQualityRef = useRef<number | null>(null);
|
||||||
|
const previousVersionKeyRef = useRef<string | null>(null);
|
||||||
const [isBunnyPortraitSource, setIsBunnyPortraitSource] = useState(false);
|
const [isBunnyPortraitSource, setIsBunnyPortraitSource] = useState(false);
|
||||||
const [bunnyPortraitFrameWidth, setBunnyPortraitFrameWidth] = useState<number>(0);
|
const [bunnyPortraitFrameWidth, setBunnyPortraitFrameWidth] = useState<number>(0);
|
||||||
const [cursorIdle, setCursorIdle] = useState(false);
|
const [cursorIdle, setCursorIdle] = useState(false);
|
||||||
@@ -145,6 +148,10 @@ export function useVideoPlayer({
|
|||||||
if (isYoutube && !isApiLoaded) return;
|
if (isYoutube && !isApiLoaded) return;
|
||||||
if (!isYoutube && !isBunny) return;
|
if (!isYoutube && !isBunny) return;
|
||||||
|
|
||||||
|
const currentVersionKey = `${activeProviderId ?? 'none'}:${activeVersionId ?? 'none'}`;
|
||||||
|
const versionChanged = previousVersionKeyRef.current !== currentVersionKey;
|
||||||
|
previousVersionKeyRef.current = currentVersionKey;
|
||||||
|
|
||||||
setIsReady(false);
|
setIsReady(false);
|
||||||
setBunnyPlaybackState('none');
|
setBunnyPlaybackState('none');
|
||||||
setCurrentTime(0);
|
setCurrentTime(0);
|
||||||
@@ -152,8 +159,8 @@ export function useVideoPlayer({
|
|||||||
setIsPlaying(false);
|
setIsPlaying(false);
|
||||||
setIsMuted(false);
|
setIsMuted(false);
|
||||||
setPlaybackSpeed(1);
|
setPlaybackSpeed(1);
|
||||||
setQualityOptions([]);
|
setQualityOptions((prev) => (versionChanged ? [] : prev));
|
||||||
setSelectedQualityLevel(-1);
|
setSelectedQualityLevel(bunnySourcePreference === 'original' ? -2 : -1);
|
||||||
setIsBunnyPortraitSource(false);
|
setIsBunnyPortraitSource(false);
|
||||||
|
|
||||||
if (playerRef.current) {
|
if (playerRef.current) {
|
||||||
@@ -204,11 +211,16 @@ export function useVideoPlayer({
|
|||||||
const videoEl = videoRef.current;
|
const videoEl = videoRef.current;
|
||||||
if (!videoEl) return;
|
if (!videoEl) return;
|
||||||
|
|
||||||
|
const bunnyOriginalUrl = embedUrl.includes('/playlist.m3u8')
|
||||||
|
? embedUrl.replace('/playlist.m3u8', '/original')
|
||||||
|
: '';
|
||||||
|
|
||||||
let cachedDuration = 0;
|
let cachedDuration = 0;
|
||||||
let destroyed = false;
|
let destroyed = false;
|
||||||
let retryAttempt = 0;
|
let retryAttempt = 0;
|
||||||
let usingHlsJs = false;
|
let usingHlsJs = false;
|
||||||
let hlsInstance: Hls | null = null;
|
let hlsInstance: Hls | null = null;
|
||||||
|
let sourceMode: 'hls' | 'original' = bunnySourcePreference === 'original' ? 'original' : 'hls';
|
||||||
const clearRetryTimer = () => {
|
const clearRetryTimer = () => {
|
||||||
if (bunnyRetryTimerRef.current) {
|
if (bunnyRetryTimerRef.current) {
|
||||||
clearTimeout(bunnyRetryTimerRef.current);
|
clearTimeout(bunnyRetryTimerRef.current);
|
||||||
@@ -223,18 +235,23 @@ export function useVideoPlayer({
|
|||||||
}
|
}
|
||||||
}, 3000);
|
}, 3000);
|
||||||
};
|
};
|
||||||
const getRetryUrl = () => {
|
const getRetryUrl = (baseUrl: string) => {
|
||||||
retryAttempt += 1;
|
retryAttempt += 1;
|
||||||
const separator = embedUrl.includes('?') ? '&' : '?';
|
const separator = baseUrl.includes('?') ? '&' : '?';
|
||||||
return `${embedUrl}${separator}retry=${Date.now()}-${retryAttempt}`;
|
return `${baseUrl}${separator}retry=${Date.now()}-${retryAttempt}`;
|
||||||
};
|
};
|
||||||
const retryNativeLoad = () => {
|
const retryNativeLoad = () => {
|
||||||
videoEl.src = getRetryUrl();
|
videoEl.src = getRetryUrl(embedUrl);
|
||||||
|
videoEl.load();
|
||||||
|
};
|
||||||
|
const retryOriginalLoad = () => {
|
||||||
|
if (!bunnyOriginalUrl) return;
|
||||||
|
videoEl.src = getRetryUrl(bunnyOriginalUrl);
|
||||||
videoEl.load();
|
videoEl.load();
|
||||||
};
|
};
|
||||||
const retryHlsLoad = () => {
|
const retryHlsLoad = () => {
|
||||||
if (destroyed || !hlsInstance) return;
|
if (destroyed || !hlsInstance) return;
|
||||||
const retryUrl = getRetryUrl();
|
const retryUrl = getRetryUrl(embedUrl);
|
||||||
try {
|
try {
|
||||||
hlsInstance.stopLoad();
|
hlsInstance.stopLoad();
|
||||||
} catch {
|
} catch {
|
||||||
@@ -243,6 +260,21 @@ export function useVideoPlayer({
|
|||||||
hlsInstance.loadSource(retryUrl);
|
hlsInstance.loadSource(retryUrl);
|
||||||
hlsInstance.startLoad(-1);
|
hlsInstance.startLoad(-1);
|
||||||
};
|
};
|
||||||
|
const activateOriginalFallback = (): boolean => {
|
||||||
|
if (!bunnyOriginalUrl) return false;
|
||||||
|
sourceMode = 'original';
|
||||||
|
usingHlsJs = false;
|
||||||
|
clearRetryTimer();
|
||||||
|
if (hlsRef.current) {
|
||||||
|
try { hlsRef.current.destroy(); } catch { /* ignore */ }
|
||||||
|
hlsRef.current = null;
|
||||||
|
}
|
||||||
|
hlsInstance = null;
|
||||||
|
setBunnyPlaybackState('processing');
|
||||||
|
setIsReady(false);
|
||||||
|
retryOriginalLoad();
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
const syncDuration = () => {
|
const syncDuration = () => {
|
||||||
if (Number.isFinite(videoEl.duration) && videoEl.duration > 0) {
|
if (Number.isFinite(videoEl.duration) && videoEl.duration > 0) {
|
||||||
@@ -265,7 +297,7 @@ export function useVideoPlayer({
|
|||||||
const onLoadedMetadata = () => {
|
const onLoadedMetadata = () => {
|
||||||
if (destroyed) return;
|
if (destroyed) return;
|
||||||
clearRetryTimer();
|
clearRetryTimer();
|
||||||
setBunnyPlaybackState('none');
|
setBunnyPlaybackState(sourceMode === 'original' ? 'processing' : 'none');
|
||||||
if (videoEl.videoWidth > 0 && videoEl.videoHeight > 0) {
|
if (videoEl.videoWidth > 0 && videoEl.videoHeight > 0) {
|
||||||
setIsBunnyPortraitSource(videoEl.videoHeight > videoEl.videoWidth);
|
setIsBunnyPortraitSource(videoEl.videoHeight > videoEl.videoWidth);
|
||||||
}
|
}
|
||||||
@@ -275,7 +307,9 @@ export function useVideoPlayer({
|
|||||||
|
|
||||||
const onPlay = () => {
|
const onPlay = () => {
|
||||||
setIsPlaying(true);
|
setIsPlaying(true);
|
||||||
setBunnyPlaybackState('none');
|
if (sourceMode !== 'original') {
|
||||||
|
setBunnyPlaybackState('none');
|
||||||
|
}
|
||||||
syncDuration();
|
syncDuration();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -305,9 +339,16 @@ export function useVideoPlayer({
|
|||||||
setBunnyPlaybackState('error');
|
setBunnyPlaybackState('error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (sourceMode === 'hls') {
|
||||||
|
if (activateOriginalFallback()) return;
|
||||||
|
setIsReady(false);
|
||||||
|
setBunnyPlaybackState('processing');
|
||||||
|
scheduleRetry(retryNativeLoad);
|
||||||
|
return;
|
||||||
|
}
|
||||||
setIsReady(false);
|
setIsReady(false);
|
||||||
setBunnyPlaybackState('processing');
|
setBunnyPlaybackState('processing');
|
||||||
scheduleRetry(retryNativeLoad);
|
scheduleRetry(retryOriginalLoad);
|
||||||
};
|
};
|
||||||
|
|
||||||
videoEl.addEventListener('loadedmetadata', onLoadedMetadata);
|
videoEl.addEventListener('loadedmetadata', onLoadedMetadata);
|
||||||
@@ -322,13 +363,36 @@ export function useVideoPlayer({
|
|||||||
level: index,
|
level: index,
|
||||||
label: formatBunnyQualityLabel(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);
|
setSelectedQualityLevel(-1);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (videoEl.canPlayType('application/vnd.apple.mpegurl')) {
|
if (sourceMode === 'original' && bunnyOriginalUrl) {
|
||||||
|
retryOriginalLoad();
|
||||||
|
} else if (videoEl.canPlayType('application/vnd.apple.mpegurl')) {
|
||||||
|
sourceMode = 'hls';
|
||||||
videoEl.src = embedUrl;
|
videoEl.src = embedUrl;
|
||||||
videoEl.load();
|
videoEl.load();
|
||||||
} else if (Hls.isSupported()) {
|
} else if (Hls.isSupported()) {
|
||||||
|
sourceMode = 'hls';
|
||||||
usingHlsJs = true;
|
usingHlsJs = true;
|
||||||
const hls = new Hls();
|
const hls = new Hls();
|
||||||
hlsInstance = hls;
|
hlsInstance = hls;
|
||||||
@@ -371,6 +435,9 @@ export function useVideoPlayer({
|
|||||||
&& !data.type
|
&& !data.type
|
||||||
&& videoEl.readyState < HTMLMediaElement.HAVE_METADATA;
|
&& videoEl.readyState < HTMLMediaElement.HAVE_METADATA;
|
||||||
if (isLikelyProcessing || isNetworkPreMetadataProcessing || isUnknownPreMetadataProcessing) {
|
if (isLikelyProcessing || isNetworkPreMetadataProcessing || isUnknownPreMetadataProcessing) {
|
||||||
|
if (activateOriginalFallback()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
setIsReady(false);
|
setIsReady(false);
|
||||||
setBunnyPlaybackState('processing');
|
setBunnyPlaybackState('processing');
|
||||||
scheduleRetry(retryHlsLoad);
|
scheduleRetry(retryHlsLoad);
|
||||||
@@ -465,7 +532,7 @@ export function useVideoPlayer({
|
|||||||
bunnyRetryTimerRef.current = null;
|
bunnyRetryTimerRef.current = null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [activeProviderId, activeVersionId, embedUrl, isApiLoaded, canInitializePlayer, formatBunnyQualityLabel, hlsRef, iframeRef, playerRef, scheduleWatchProgressSaveRef, videoRef]);
|
}, [activeProviderId, activeVersionId, embedUrl, isApiLoaded, canInitializePlayer, formatBunnyQualityLabel, bunnySourcePreference, hlsRef, iframeRef, playerRef, scheduleWatchProgressSaveRef, videoRef]);
|
||||||
|
|
||||||
const toggleFullscreen = useCallback(() => {
|
const toggleFullscreen = useCallback(() => {
|
||||||
if (!document.fullscreenElement) {
|
if (!document.fullscreenElement) {
|
||||||
@@ -529,24 +596,6 @@ export function useVideoPlayer({
|
|||||||
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) {
|
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const isBunnyBlocked = activeVersion?.providerId === 'bunny' && bunnyPlaybackState !== 'none';
|
|
||||||
const isPlaybackControlKey = [
|
|
||||||
'Space',
|
|
||||||
'KeyK',
|
|
||||||
'ArrowLeft',
|
|
||||||
'ArrowRight',
|
|
||||||
'ArrowUp',
|
|
||||||
'ArrowDown',
|
|
||||||
'Comma',
|
|
||||||
'Period',
|
|
||||||
'KeyM',
|
|
||||||
'KeyJ',
|
|
||||||
'KeyL',
|
|
||||||
].includes(e.code);
|
|
||||||
if (isBunnyBlocked && isPlaybackControlKey) {
|
|
||||||
e.preventDefault();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (e.code) {
|
switch (e.code) {
|
||||||
case 'Space':
|
case 'Space':
|
||||||
@@ -660,17 +709,16 @@ export function useVideoPlayer({
|
|||||||
|
|
||||||
window.addEventListener('keydown', handleKeyDown);
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
}, [activeVersion?.providerId, bunnyPlaybackState, isPlaying, currentTime, duration, isMuted, playbackSpeed, speedOptions, toggleFullscreen, playerRef]);
|
}, [isPlaying, currentTime, duration, isMuted, playbackSpeed, speedOptions, toggleFullscreen, playerRef]);
|
||||||
|
|
||||||
const handlePlayPause = useCallback(() => {
|
const handlePlayPause = useCallback(() => {
|
||||||
if (activeVersion?.providerId === 'bunny' && bunnyPlaybackState !== 'none') return;
|
|
||||||
if (!playerRef.current) return;
|
if (!playerRef.current) return;
|
||||||
if (isPlaying) {
|
if (isPlaying) {
|
||||||
playerRef.current.pauseVideo();
|
playerRef.current.pauseVideo();
|
||||||
} else {
|
} else {
|
||||||
playerRef.current.playVideo();
|
playerRef.current.playVideo();
|
||||||
}
|
}
|
||||||
}, [activeVersion?.providerId, bunnyPlaybackState, isPlaying, playerRef]);
|
}, [isPlaying, playerRef]);
|
||||||
|
|
||||||
const handleSeekToTimestamp = useCallback((timestamp: number, annotation?: string | null) => {
|
const handleSeekToTimestamp = useCallback((timestamp: number, annotation?: string | null) => {
|
||||||
setCurrentTime(timestamp);
|
setCurrentTime(timestamp);
|
||||||
@@ -728,8 +776,21 @@ export function useVideoPlayer({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleQualityChange = useCallback((level: number) => {
|
const handleQualityChange = useCallback((level: number) => {
|
||||||
|
if (level === -2) {
|
||||||
|
pendingHlsQualityRef.current = null;
|
||||||
|
setBunnySourcePreference('original');
|
||||||
|
setSelectedQualityLevel(-2);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingHlsQualityRef.current = level;
|
||||||
|
setBunnySourcePreference('auto');
|
||||||
|
|
||||||
const hls = hlsRef.current;
|
const hls = hlsRef.current;
|
||||||
if (!hls) return;
|
if (!hls) {
|
||||||
|
setSelectedQualityLevel(level === -1 ? -1 : level);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (level === -1) {
|
if (level === -1) {
|
||||||
hls.currentLevel = -1;
|
hls.currentLevel = -1;
|
||||||
|
|||||||
@@ -376,6 +376,12 @@ export const PlayerCore = memo(function PlayerCore({
|
|||||||
>
|
>
|
||||||
Auto
|
Auto
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => handleQualityChange(-2)}
|
||||||
|
className={cn(selectedQualityLevel === -2 && 'font-bold text-primary')}
|
||||||
|
>
|
||||||
|
Original
|
||||||
|
</DropdownMenuItem>
|
||||||
{qualityOptions.length > 0 && <DropdownMenuSeparator />}
|
{qualityOptions.length > 0 && <DropdownMenuSeparator />}
|
||||||
{qualityOptions.map((option) => (
|
{qualityOptions.map((option) => (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
} from '@/components/ui/dropdown-menu';
|
} from '@/components/ui/dropdown-menu';
|
||||||
import { Separator } from '@/components/ui/separator';
|
import { Separator } from '@/components/ui/separator';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { DownloadMenuItems } from '@/components/video-page/download-controls';
|
import { DownloadControls } from '@/components/video-page/download-controls';
|
||||||
import { VersionDeleteDialog } from '@/components/video-page/version-delete-dialog';
|
import { VersionDeleteDialog } from '@/components/video-page/version-delete-dialog';
|
||||||
import { VersionActionsDialog } from '@/components/video-page/version-actions-dialog';
|
import { VersionActionsDialog } from '@/components/video-page/version-actions-dialog';
|
||||||
import type { BunnyDownloadPreference, DownloadTarget, Version } from '@/components/video-page/types';
|
import type { BunnyDownloadPreference, DownloadTarget, Version } from '@/components/video-page/types';
|
||||||
@@ -205,6 +205,16 @@ export const VideoPageHeader = memo(function VideoPageHeader({
|
|||||||
) : null}
|
) : null}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
<div className="hidden sm:block">
|
||||||
|
<DownloadControls
|
||||||
|
activeVersion={activeVersion}
|
||||||
|
videoCanDownload={videoCanDownload}
|
||||||
|
isDownloading={isDownloadingVideo}
|
||||||
|
activeDownloadTarget={activeDownloadTarget}
|
||||||
|
onDownload={onDownload}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{versions.length >= 2 && (
|
{versions.length >= 2 && (
|
||||||
<Button variant="outline" size="sm" onClick={onOpenCompare} className="hidden sm:inline-flex">
|
<Button variant="outline" size="sm" onClick={onOpenCompare} className="hidden sm:inline-flex">
|
||||||
<GitCompareArrows className="h-4 w-4 mr-1" />
|
<GitCompareArrows className="h-4 w-4 mr-1" />
|
||||||
@@ -265,14 +275,6 @@ export const VideoPageHeader = memo(function VideoPageHeader({
|
|||||||
<ShieldCheck className="h-4 w-4 mr-2" />
|
<ShieldCheck className="h-4 w-4 mr-2" />
|
||||||
Request Approval
|
Request Approval
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DownloadMenuItems
|
|
||||||
activeVersion={activeVersion}
|
|
||||||
videoCanDownload={videoCanDownload}
|
|
||||||
isDownloading={isDownloadingVideo}
|
|
||||||
activeDownloadTarget={activeDownloadTarget}
|
|
||||||
onDownload={onDownload}
|
|
||||||
/>
|
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user