mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 17:46:06 +00:00
feat: harden Bunny upload flow, migrate Bunny playback to hls.js, and add Bunny storage admin stats
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import {
|
||||
@@ -21,8 +21,6 @@ import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { VideoCard } from '@/components/video-card';
|
||||
|
||||
type SortOrder = 'desc' | 'asc';
|
||||
|
||||
interface SerializedVideo {
|
||||
id: string;
|
||||
title: string;
|
||||
@@ -57,13 +55,17 @@ export function ProjectContentClient({
|
||||
videos,
|
||||
canEdit,
|
||||
isOwner,
|
||||
workspaceRole,
|
||||
totalPages,
|
||||
currentPage
|
||||
}: ProjectContentClientProps) {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const sortOrder = searchParams.get('sort') || 'desc';
|
||||
const [localVideos, setLocalVideos] = useState<SerializedVideo[]>(videos);
|
||||
|
||||
useEffect(() => {
|
||||
setLocalVideos(videos);
|
||||
}, [videos]);
|
||||
|
||||
const createQueryString = useCallback(
|
||||
(name: string, value: string) => {
|
||||
@@ -79,12 +81,16 @@ export function ProjectContentClient({
|
||||
[searchParams]
|
||||
);
|
||||
|
||||
const sortedVideos = [...videos].sort((a, b) => {
|
||||
const sortedVideos = [...localVideos].sort((a, b) => {
|
||||
const dateA = new Date(a.updatedAt).getTime();
|
||||
const dateB = new Date(b.updatedAt).getTime();
|
||||
return sortOrder === 'desc' ? dateB - dateA : dateA - dateB;
|
||||
});
|
||||
|
||||
const handleVideoDeleted = useCallback((videoId: string) => {
|
||||
setLocalVideos((prev) => prev.filter((video) => video.id !== videoId));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Project Header */}
|
||||
@@ -171,10 +177,10 @@ export function ProjectContentClient({
|
||||
</div>
|
||||
|
||||
{/* Videos Grid */}
|
||||
{videos.length > 0 ? (
|
||||
{localVideos.length > 0 ? (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{sortedVideos.map((video) => (
|
||||
<VideoCard key={video.id} video={video} projectId={projectId} />
|
||||
<VideoCard key={video.id} video={video} projectId={projectId} onDeleted={handleVideoDeleted} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import Hls from 'hls.js';
|
||||
import Link from 'next/link';
|
||||
import { useParams, useSearchParams } from 'next/navigation';
|
||||
import {
|
||||
@@ -98,6 +99,8 @@ const isSafeUrl = (url: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
const BUNNY_PULL_ZONE_HOSTNAME = 'vz-965f4f4a-fc1.b-cdn.net';
|
||||
|
||||
export default function CompareVersionsPage() {
|
||||
const params = useParams();
|
||||
const searchParams = useSearchParams();
|
||||
@@ -117,6 +120,8 @@ export default function CompareVersionsPage() {
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [cursorIdle, setCursorIdle] = useState(false);
|
||||
const cursorIdleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const timelineRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Map of versionId -> YT.Player or Custom Adapter
|
||||
@@ -307,6 +312,57 @@ export default function CompareVersionsPage() {
|
||||
handleSeek(currentTime);
|
||||
}, [isDragging, currentTime, handleSeek]);
|
||||
|
||||
const handleVideoMouseMove = useCallback(() => {
|
||||
setCursorIdle(false);
|
||||
if (cursorIdleTimerRef.current) {
|
||||
clearTimeout(cursorIdleTimerRef.current);
|
||||
}
|
||||
|
||||
if (isPlaying) {
|
||||
cursorIdleTimerRef.current = setTimeout(() => {
|
||||
setCursorIdle(true);
|
||||
}, 1000);
|
||||
}
|
||||
}, [isPlaying]);
|
||||
|
||||
const handleVideoMouseLeave = useCallback(() => {
|
||||
if (cursorIdleTimerRef.current) {
|
||||
clearTimeout(cursorIdleTimerRef.current);
|
||||
}
|
||||
setCursorIdle(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (cursorIdleTimerRef.current) {
|
||||
clearTimeout(cursorIdleTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (cursorIdleTimerRef.current) {
|
||||
clearTimeout(cursorIdleTimerRef.current);
|
||||
cursorIdleTimerRef.current = null;
|
||||
}
|
||||
|
||||
if (!isPlaying) {
|
||||
setCursorIdle(false);
|
||||
return;
|
||||
}
|
||||
|
||||
cursorIdleTimerRef.current = setTimeout(() => {
|
||||
setCursorIdle(true);
|
||||
}, 1000);
|
||||
|
||||
return () => {
|
||||
if (cursorIdleTimerRef.current) {
|
||||
clearTimeout(cursorIdleTimerRef.current);
|
||||
cursorIdleTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [isPlaying]);
|
||||
|
||||
// Keyboard shortcuts (matching video page)
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
@@ -563,9 +619,12 @@ export default function CompareVersionsPage() {
|
||||
<div
|
||||
className={cn(
|
||||
'bg-black flex items-center justify-center relative cursor-pointer group',
|
||||
cursorIdle && isPlaying && 'cursor-none',
|
||||
isCommentsOpen ? 'h-[55%]' : 'flex-1'
|
||||
)}
|
||||
onClick={handlePlayPause}
|
||||
onMouseMove={handleVideoMouseMove}
|
||||
onMouseLeave={handleVideoMouseLeave}
|
||||
>
|
||||
{version.providerId === 'youtube' ? (
|
||||
<YouTubePanel
|
||||
@@ -595,7 +654,7 @@ export default function CompareVersionsPage() {
|
||||
<div
|
||||
className={cn(
|
||||
'absolute inset-0 flex items-center justify-center bg-black/20 transition-opacity duration-300 pointer-events-none',
|
||||
isPlaying ? 'opacity-0 group-hover:opacity-100' : 'opacity-100'
|
||||
isPlaying ? (cursorIdle ? 'opacity-0' : 'opacity-0 group-hover:opacity-100') : 'opacity-100'
|
||||
)}
|
||||
>
|
||||
<div className="w-14 h-14 rounded-full bg-black/60 flex items-center justify-center">
|
||||
@@ -798,7 +857,7 @@ function YouTubePanel({
|
||||
return <div ref={containerRef} className="w-full h-full pointer-events-none" />;
|
||||
}
|
||||
|
||||
// Isolated Bunny Steam player component per panel filtering Player.js to YouTube wrapper interface
|
||||
// Isolated Bunny Stream player component per panel mapped to the shared adapter interface
|
||||
function BunnyPanel({
|
||||
version,
|
||||
onRegister,
|
||||
@@ -808,86 +867,218 @@ function BunnyPanel({
|
||||
onRegister: (versionId: string, player: YT.Player | PlayerAdapter) => void;
|
||||
onUnregister: (versionId: string) => void;
|
||||
}) {
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const hlsRef = useRef<Hls | null>(null);
|
||||
const [portraitFrameWidth, setPortraitFrameWidth] = useState<number>(0);
|
||||
const [isPortraitSource, setIsPortraitSource] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!iframeRef.current) return;
|
||||
const playerjs = require('player.js');
|
||||
const player = new playerjs.Player(iframeRef.current);
|
||||
const panelEl = panelRef.current;
|
||||
if (!panelEl || typeof ResizeObserver === 'undefined') return;
|
||||
|
||||
const updateFrameWidth = () => {
|
||||
const panelWidth = panelEl.clientWidth;
|
||||
const panelHeight = panelEl.clientHeight;
|
||||
if (panelWidth <= 0 || panelHeight <= 0) return;
|
||||
setPortraitFrameWidth(Math.min(panelWidth, panelHeight * (9 / 16)));
|
||||
};
|
||||
|
||||
updateFrameWidth();
|
||||
const observer = new ResizeObserver(updateFrameWidth);
|
||||
observer.observe(panelEl);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const videoEl = videoRef.current;
|
||||
if (!videoEl) return;
|
||||
|
||||
let cachedTime = 0;
|
||||
let cachedDuration = 0;
|
||||
let isPlaying = false;
|
||||
let isMuted = false;
|
||||
let destroyed = false;
|
||||
let retryAttempt = 0;
|
||||
let retryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
player.on('ready', () => {
|
||||
player.getDuration((d: number) => { cachedDuration = d; });
|
||||
|
||||
const adapter = {
|
||||
playVideo: () => player.play(),
|
||||
pauseVideo: () => player.pause(),
|
||||
seekTo: (time: number) => { cachedTime = time; player.setCurrentTime(time); },
|
||||
mute: () => { isMuted = true; player.mute(); },
|
||||
unMute: () => { isMuted = false; player.unmute(); },
|
||||
isMuted: () => isMuted,
|
||||
getCurrentTime: () => cachedTime,
|
||||
getDuration: () => cachedDuration,
|
||||
getPlayerState: () => isPlaying ? window.YT?.PlayerState?.PLAYING : window.YT?.PlayerState?.PAUSED,
|
||||
setPlaybackRate: (rate: number) => {
|
||||
try {
|
||||
if (player && typeof player.setPlaybackRate === 'function') {
|
||||
player.setPlaybackRate(rate);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to set playback rate', e);
|
||||
}
|
||||
},
|
||||
destroy: () => {
|
||||
try {
|
||||
player.off('ready');
|
||||
player.off('timeupdate');
|
||||
player.off('play');
|
||||
player.off('pause');
|
||||
player.off('ended');
|
||||
} catch { }
|
||||
const clearRetryTimer = () => {
|
||||
if (!retryTimer) return;
|
||||
clearTimeout(retryTimer);
|
||||
retryTimer = null;
|
||||
};
|
||||
const getRetryUrl = (baseUrl: string) => {
|
||||
retryAttempt += 1;
|
||||
const separator = baseUrl.includes('?') ? '&' : '?';
|
||||
return `${baseUrl}${separator}retry=${Date.now()}-${retryAttempt}`;
|
||||
};
|
||||
const scheduleRetry = (retryFn: () => void) => {
|
||||
clearRetryTimer();
|
||||
retryTimer = setTimeout(() => {
|
||||
if (!destroyed) {
|
||||
retryFn();
|
||||
}
|
||||
};
|
||||
}, 3000);
|
||||
};
|
||||
|
||||
onRegister(version.id, adapter);
|
||||
});
|
||||
const adapter: PlayerAdapter = {
|
||||
playVideo: () => {
|
||||
videoEl.play().catch((err) => console.error('Error playing Bunny panel video:', err));
|
||||
},
|
||||
pauseVideo: () => videoEl.pause(),
|
||||
seekTo: (time: number) => {
|
||||
cachedTime = time;
|
||||
videoEl.currentTime = time;
|
||||
},
|
||||
mute: () => {
|
||||
videoEl.muted = true;
|
||||
},
|
||||
unMute: () => {
|
||||
videoEl.muted = false;
|
||||
},
|
||||
isMuted: () => videoEl.muted,
|
||||
getCurrentTime: () => videoEl.currentTime || cachedTime,
|
||||
getDuration: () => {
|
||||
if (Number.isFinite(videoEl.duration) && videoEl.duration > 0) {
|
||||
cachedDuration = videoEl.duration;
|
||||
}
|
||||
return cachedDuration;
|
||||
},
|
||||
getPlayerState: () => (
|
||||
isPlaying
|
||||
? (window.YT?.PlayerState?.PLAYING ?? 1)
|
||||
: (window.YT?.PlayerState?.PAUSED ?? 2)
|
||||
),
|
||||
setPlaybackRate: (rate: number) => {
|
||||
videoEl.playbackRate = rate;
|
||||
},
|
||||
destroy: () => {
|
||||
destroyed = true;
|
||||
clearRetryTimer();
|
||||
videoEl.removeEventListener('timeupdate', onTimeUpdate);
|
||||
videoEl.removeEventListener('play', onPlay);
|
||||
videoEl.removeEventListener('pause', onPause);
|
||||
videoEl.removeEventListener('ended', onEnded);
|
||||
videoEl.removeEventListener('loadedmetadata', onLoadedMetadata);
|
||||
if (hlsRef.current) {
|
||||
try { hlsRef.current.destroy(); } catch { /* ignore */ }
|
||||
hlsRef.current = null;
|
||||
}
|
||||
videoEl.removeAttribute('src');
|
||||
videoEl.load();
|
||||
},
|
||||
};
|
||||
|
||||
player.on('timeupdate', (data: { seconds: number }) => { cachedTime = data.seconds; });
|
||||
player.on('play', () => { isPlaying = true; });
|
||||
player.on('pause', () => { isPlaying = false; });
|
||||
player.on('ended', () => { isPlaying = false; });
|
||||
const onLoadedMetadata = () => {
|
||||
clearRetryTimer();
|
||||
if (Number.isFinite(videoEl.duration) && videoEl.duration > 0) {
|
||||
cachedDuration = videoEl.duration;
|
||||
}
|
||||
if (videoEl.videoWidth > 0 && videoEl.videoHeight > 0) {
|
||||
setIsPortraitSource(videoEl.videoHeight > videoEl.videoWidth);
|
||||
}
|
||||
};
|
||||
const onTimeUpdate = () => { cachedTime = videoEl.currentTime || 0; };
|
||||
const onPlay = () => { isPlaying = true; };
|
||||
const onPause = () => { isPlaying = false; };
|
||||
const onEnded = () => { isPlaying = false; };
|
||||
|
||||
videoEl.addEventListener('loadedmetadata', onLoadedMetadata);
|
||||
videoEl.addEventListener('timeupdate', onTimeUpdate);
|
||||
videoEl.addEventListener('play', onPlay);
|
||||
videoEl.addEventListener('pause', onPause);
|
||||
videoEl.addEventListener('ended', onEnded);
|
||||
|
||||
const hlsUrl = `https://${BUNNY_PULL_ZONE_HOSTNAME}/${version.videoId}/playlist.m3u8`;
|
||||
if (videoEl.canPlayType('application/vnd.apple.mpegurl')) {
|
||||
videoEl.src = hlsUrl;
|
||||
videoEl.load();
|
||||
} else if (Hls.isSupported()) {
|
||||
const hls = new Hls();
|
||||
hlsRef.current = hls;
|
||||
hls.attachMedia(videoEl);
|
||||
hls.on(Hls.Events.MEDIA_ATTACHED, () => {
|
||||
if (!destroyed) {
|
||||
hls.loadSource(hlsUrl);
|
||||
}
|
||||
});
|
||||
hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
||||
if (destroyed) return;
|
||||
clearRetryTimer();
|
||||
});
|
||||
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) {
|
||||
scheduleRetry(() => {
|
||||
const retryUrl = getRetryUrl(hlsUrl);
|
||||
try {
|
||||
hls.stopLoad();
|
||||
} catch {
|
||||
// ignore stop-load failures; loadSource is the important part
|
||||
}
|
||||
hls.loadSource(retryUrl);
|
||||
hls.startLoad(-1);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.fatal) {
|
||||
console.error('Fatal HLS error in compare panel:', data);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
console.error('HLS is not supported in this browser.');
|
||||
}
|
||||
|
||||
onRegister(version.id, adapter);
|
||||
|
||||
return () => {
|
||||
onUnregister(version.id);
|
||||
try {
|
||||
player.off('ready');
|
||||
player.off('timeupdate');
|
||||
player.off('play');
|
||||
player.off('pause');
|
||||
player.off('ended');
|
||||
} catch { }
|
||||
adapter.destroy();
|
||||
};
|
||||
}, [version.id, onRegister, onUnregister]);
|
||||
|
||||
const src = version.originalUrl.replace('/play/', '/embed/');
|
||||
const embedSrc = `${src}${src.includes('?') ? '&' : '?'}autoplay=false&controls=false`;
|
||||
}, [version.id, version.videoId, onRegister, onUnregister]);
|
||||
|
||||
return (
|
||||
<div className="relative w-full h-full group">
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
src={embedSrc}
|
||||
width="100%"
|
||||
height="100%"
|
||||
className="w-full h-full pointer-events-none border-0"
|
||||
style={{ pointerEvents: 'none' }}
|
||||
allow="accelerometer; autoplay; encrypted-media; gyroscope;"
|
||||
allowFullScreen
|
||||
/>
|
||||
<div ref={panelRef} className="relative w-full h-full group flex items-center justify-center bg-black">
|
||||
<div
|
||||
className={cn(
|
||||
'relative flex items-center justify-center bg-black',
|
||||
isPortraitSource ? 'h-full overflow-hidden' : 'w-full h-full'
|
||||
)}
|
||||
style={isPortraitSource && portraitFrameWidth > 0 ? { width: `${portraitFrameWidth}px` } : undefined}
|
||||
>
|
||||
<video
|
||||
ref={videoRef}
|
||||
className="w-full h-full object-contain pointer-events-none border-0 bg-black"
|
||||
style={{
|
||||
pointerEvents: 'none',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'contain',
|
||||
objectPosition: 'center',
|
||||
backgroundColor: 'black',
|
||||
}}
|
||||
preload="metadata"
|
||||
playsInline
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useRouter, useParams } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
@@ -10,7 +10,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { parseVideoUrl, fetchVideoMetadata, getThumbnailUrl, type VideoSource } from '@/lib/video-providers';
|
||||
import * as tus from 'tus-js-client';
|
||||
|
||||
@@ -32,12 +32,100 @@ export default function NewVideoPage() {
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const [uploadProgress, setUploadProgress] = useState(0);
|
||||
const [uploadStatus, setUploadStatus] = useState('');
|
||||
const [pendingBunnyVideoId, setPendingBunnyVideoId] = useState<string | null>(null);
|
||||
const [pendingBunnyUploadToken, setPendingBunnyUploadToken] = useState<string | null>(null);
|
||||
const pendingBunnyVideoIdRef = useRef<string | null>(null);
|
||||
const pendingBunnyUploadTokenRef = useRef<string | null>(null);
|
||||
const activeTusUploadRef = useRef<tus.Upload | null>(null);
|
||||
|
||||
const [submitError, setSubmitError] = useState('');
|
||||
const [formData, setFormData] = useState({
|
||||
title: '',
|
||||
description: '',
|
||||
});
|
||||
const isUploadingFile = isLoading && uploadMode === 'file';
|
||||
const leaveWarningMessage = 'A video upload is in progress. Leaving this page will interrupt it. Do you want to leave?';
|
||||
|
||||
useEffect(() => {
|
||||
pendingBunnyVideoIdRef.current = pendingBunnyVideoId;
|
||||
}, [pendingBunnyVideoId]);
|
||||
|
||||
useEffect(() => {
|
||||
pendingBunnyUploadTokenRef.current = pendingBunnyUploadToken;
|
||||
}, [pendingBunnyUploadToken]);
|
||||
|
||||
const cleanupPendingBunnyVideo = useCallback(async (videoId: string, uploadToken: string, keepalive = false) => {
|
||||
try {
|
||||
await fetch(`/api/projects/${projectId}/videos/bunny-init`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ videoId, uploadToken }),
|
||||
keepalive,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to cleanup pending Bunny upload:', error);
|
||||
} finally {
|
||||
if (pendingBunnyVideoIdRef.current === videoId) {
|
||||
pendingBunnyVideoIdRef.current = null;
|
||||
setPendingBunnyVideoId(null);
|
||||
}
|
||||
if (pendingBunnyUploadTokenRef.current === uploadToken) {
|
||||
pendingBunnyUploadTokenRef.current = null;
|
||||
setPendingBunnyUploadToken(null);
|
||||
}
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
const abortAndCleanupPendingUpload = useCallback((keepalive = false) => {
|
||||
const pendingVideoId = pendingBunnyVideoIdRef.current;
|
||||
const pendingUploadToken = pendingBunnyUploadTokenRef.current;
|
||||
if (!pendingVideoId || !pendingUploadToken) return;
|
||||
|
||||
if (activeTusUploadRef.current) {
|
||||
try {
|
||||
activeTusUploadRef.current.abort(true);
|
||||
} catch {
|
||||
// Ignore abort failures; we'll still attempt cleanup.
|
||||
} finally {
|
||||
activeTusUploadRef.current = null;
|
||||
}
|
||||
}
|
||||
|
||||
void cleanupPendingBunnyVideo(pendingVideoId, pendingUploadToken, keepalive);
|
||||
}, [cleanupPendingBunnyVideo]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isUploadingFile) return;
|
||||
|
||||
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
|
||||
event.preventDefault();
|
||||
event.returnValue = '';
|
||||
};
|
||||
|
||||
const handlePageHide = () => {
|
||||
abortAndCleanupPendingUpload(true);
|
||||
};
|
||||
|
||||
const handlePopState = () => {
|
||||
const shouldLeave = window.confirm(leaveWarningMessage);
|
||||
if (!shouldLeave) {
|
||||
window.history.pushState(null, '', window.location.href);
|
||||
return;
|
||||
}
|
||||
abortAndCleanupPendingUpload(true);
|
||||
};
|
||||
|
||||
window.history.pushState(null, '', window.location.href);
|
||||
window.addEventListener('beforeunload', handleBeforeUnload);
|
||||
window.addEventListener('pagehide', handlePageHide);
|
||||
window.addEventListener('popstate', handlePopState);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('beforeunload', handleBeforeUnload);
|
||||
window.removeEventListener('pagehide', handlePageHide);
|
||||
window.removeEventListener('popstate', handlePopState);
|
||||
};
|
||||
}, [abortAndCleanupPendingUpload, isUploadingFile]);
|
||||
|
||||
// Auto-fetch metadata when a valid video source is detected
|
||||
useEffect(() => {
|
||||
@@ -102,7 +190,9 @@ export default function NewVideoPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const uploadToBunny = async (file: File): Promise<{ videoId: string; libraryId: string; providerId: string; url: string }> => {
|
||||
const uploadToBunny = async (
|
||||
file: File
|
||||
): Promise<{ videoId: string; libraryId: string; providerId: string; url: string; uploadToken: string }> => {
|
||||
// 1. Initialize Bunny Stream upload (creates video & gets signature)
|
||||
setUploadStatus('Initializing upload...');
|
||||
const initRes = await fetch(`/api/projects/${projectId}/videos/bunny-init`, {
|
||||
@@ -116,7 +206,11 @@ export default function NewVideoPage() {
|
||||
throw new Error(data.error || 'Failed to initialize upload');
|
||||
}
|
||||
|
||||
const { data: { videoId, libraryId, signature, expirationTime } } = await initRes.json();
|
||||
const { data: { videoId, libraryId, signature, expirationTime, uploadToken } } = await initRes.json();
|
||||
setPendingBunnyVideoId(videoId);
|
||||
setPendingBunnyUploadToken(uploadToken);
|
||||
pendingBunnyVideoIdRef.current = videoId;
|
||||
pendingBunnyUploadTokenRef.current = uploadToken;
|
||||
|
||||
// 2. Upload via TUS
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -135,6 +229,7 @@ export default function NewVideoPage() {
|
||||
title: formData.title || file.name,
|
||||
},
|
||||
onError: (error) => {
|
||||
activeTusUploadRef.current = null;
|
||||
reject(new Error('Upload failed: ' + error.message));
|
||||
},
|
||||
onProgress: (bytesUploaded, bytesTotal) => {
|
||||
@@ -143,15 +238,18 @@ export default function NewVideoPage() {
|
||||
setUploadStatus(`Uploading... ${percentage}%`);
|
||||
},
|
||||
onSuccess: () => {
|
||||
activeTusUploadRef.current = null;
|
||||
setUploadStatus('Processing video...');
|
||||
resolve({
|
||||
videoId,
|
||||
libraryId,
|
||||
providerId: 'bunny',
|
||||
url: `https://iframe.mediadelivery.net/embed/${libraryId}/${videoId}`
|
||||
url: `https://iframe.mediadelivery.net/embed/${libraryId}/${videoId}`,
|
||||
uploadToken,
|
||||
});
|
||||
},
|
||||
});
|
||||
activeTusUploadRef.current = upload;
|
||||
upload.start();
|
||||
});
|
||||
};
|
||||
@@ -165,8 +263,10 @@ export default function NewVideoPage() {
|
||||
setUploadProgress(0);
|
||||
|
||||
try {
|
||||
let uploadedBunnyVideoId: string | null = null;
|
||||
let uploadedBunnyUploadToken: string | null = null;
|
||||
let finalTitle = formData.title.trim();
|
||||
let finalDescription = formData.description.trim() || null;
|
||||
const finalDescription = formData.description.trim() || null;
|
||||
let finalVideoUrl = '';
|
||||
let finalProviderId = '';
|
||||
let finalVideoId = '';
|
||||
@@ -195,6 +295,8 @@ export default function NewVideoPage() {
|
||||
|
||||
// Handle TUS Upload
|
||||
const bunnyData = await uploadToBunny(selectedFile);
|
||||
uploadedBunnyVideoId = bunnyData.videoId;
|
||||
uploadedBunnyUploadToken = bunnyData.uploadToken;
|
||||
|
||||
finalVideoUrl = bunnyData.url;
|
||||
finalProviderId = bunnyData.providerId;
|
||||
@@ -216,20 +318,32 @@ export default function NewVideoPage() {
|
||||
videoId: finalVideoId,
|
||||
thumbnailUrl: finalThumbnailUrl,
|
||||
duration: finalDuration,
|
||||
uploadToken: uploadedBunnyUploadToken,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
setSubmitError(data.error || 'Failed to add video');
|
||||
if (uploadedBunnyVideoId && uploadedBunnyUploadToken) {
|
||||
await cleanupPendingBunnyVideo(uploadedBunnyVideoId, uploadedBunnyUploadToken);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
pendingBunnyVideoIdRef.current = null;
|
||||
pendingBunnyUploadTokenRef.current = null;
|
||||
setPendingBunnyVideoId(null);
|
||||
setPendingBunnyUploadToken(null);
|
||||
router.push(`/projects/${projectId}`);
|
||||
} catch (error: any) {
|
||||
} catch (error: unknown) {
|
||||
console.error('Failed to add video:', error);
|
||||
setSubmitError(error.message || 'An unexpected error occurred');
|
||||
setSubmitError(error instanceof Error ? error.message : 'An unexpected error occurred');
|
||||
if (pendingBunnyVideoIdRef.current && pendingBunnyUploadTokenRef.current) {
|
||||
await cleanupPendingBunnyVideo(pendingBunnyVideoIdRef.current, pendingBunnyUploadTokenRef.current);
|
||||
}
|
||||
} finally {
|
||||
activeTusUploadRef.current = null;
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
@@ -242,6 +356,15 @@ export default function NewVideoPage() {
|
||||
<Link
|
||||
href={`/projects/${projectId}`}
|
||||
className="inline-flex items-center text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
onClick={(event) => {
|
||||
if (!isUploadingFile) return;
|
||||
const shouldLeave = window.confirm(leaveWarningMessage);
|
||||
if (!shouldLeave) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
abortAndCleanupPendingUpload(true);
|
||||
}}
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4 mr-1" />
|
||||
Back to Project
|
||||
@@ -256,10 +379,10 @@ export default function NewVideoPage() {
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Tabs value={uploadMode} onValueChange={(v) => setUploadMode(v as 'url' | 'file')} className="mb-6">
|
||||
<Tabs value={uploadMode} onValueChange={(v) => !isLoading && setUploadMode(v as 'url' | 'file')} className="mb-6">
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="url">Paste URL</TabsTrigger>
|
||||
<TabsTrigger value="file">Direct Upload</TabsTrigger>
|
||||
<TabsTrigger value="url" disabled={isLoading}>Paste URL</TabsTrigger>
|
||||
<TabsTrigger value="file" disabled={isLoading}>Direct Upload</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
@@ -385,6 +508,11 @@ export default function NewVideoPage() {
|
||||
<div className="bg-primary h-2 rounded-full transition-all" style={{ width: `${uploadProgress}%` }}></div>
|
||||
</div>
|
||||
)}
|
||||
{isUploadingFile && (
|
||||
<p className="text-xs text-amber-500">
|
||||
Do not close, refresh, or navigate away while the upload is in progress.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user