mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 17:46:06 +00:00
feat: Implement direct video file uploads via Bunny.net and TUS protocol, adding a new API route and UI for file selection.
This commit is contained in:
@@ -43,6 +43,20 @@ interface Version {
|
|||||||
_count: { comments: number };
|
_count: { comments: number };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface PlayerAdapter {
|
||||||
|
playVideo: () => void;
|
||||||
|
pauseVideo: () => void;
|
||||||
|
seekTo: (time: number, allowSeekAhead?: boolean) => void;
|
||||||
|
mute: () => void;
|
||||||
|
unMute: () => void;
|
||||||
|
isMuted: () => boolean;
|
||||||
|
getCurrentTime: () => number;
|
||||||
|
getDuration: () => number;
|
||||||
|
getPlayerState: () => number;
|
||||||
|
setPlaybackRate?: (rate: number) => void;
|
||||||
|
destroy: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
interface Comment {
|
interface Comment {
|
||||||
id: string;
|
id: string;
|
||||||
content: string | null;
|
content: string | null;
|
||||||
@@ -96,8 +110,8 @@ export default function CompareVersionsPage() {
|
|||||||
const [isDragging, setIsDragging] = useState(false);
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
const timelineRef = useRef<HTMLDivElement>(null);
|
const timelineRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// Map of versionId -> YT.Player
|
// Map of versionId -> YT.Player or Custom Adapter
|
||||||
const playersRef = useRef<Map<string, YT.Player>>(new Map());
|
const playersRef = useRef<Map<string, YT.Player | PlayerAdapter>>(new Map());
|
||||||
const rafRef = useRef<number | null>(null);
|
const rafRef = useRef<number | null>(null);
|
||||||
|
|
||||||
// Comments state per panel
|
// Comments state per panel
|
||||||
@@ -219,7 +233,7 @@ export default function CompareVersionsPage() {
|
|||||||
}, [isDragging]);
|
}, [isDragging]);
|
||||||
|
|
||||||
// Register/unregister players
|
// Register/unregister players
|
||||||
const registerPlayer = useCallback((versionId: string, player: YT.Player) => {
|
const registerPlayer = useCallback((versionId: string, player: YT.Player | PlayerAdapter) => {
|
||||||
playersRef.current.set(versionId, player);
|
playersRef.current.set(versionId, player);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -319,7 +333,7 @@ export default function CompareVersionsPage() {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
players.forEach((p) => {
|
players.forEach((p) => {
|
||||||
try {
|
try {
|
||||||
if (p.isMuted()) { p.unMute(); } else { p.mute(); }
|
if (p.isMuted?.()) { p.unMute?.(); } else { p.mute?.(); }
|
||||||
} catch { /* */ }
|
} catch { /* */ }
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
@@ -552,6 +566,13 @@ export default function CompareVersionsPage() {
|
|||||||
onRegister={registerPlayer}
|
onRegister={registerPlayer}
|
||||||
onUnregister={unregisterPlayer}
|
onUnregister={unregisterPlayer}
|
||||||
/>
|
/>
|
||||||
|
) : version.providerId === 'bunny' ? (
|
||||||
|
<BunnyPanel
|
||||||
|
key={versionId}
|
||||||
|
version={version}
|
||||||
|
onRegister={registerPlayer}
|
||||||
|
onUnregister={unregisterPlayer}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<iframe
|
<iframe
|
||||||
src={version.originalUrl}
|
src={version.originalUrl}
|
||||||
@@ -767,3 +788,97 @@ function YouTubePanel({
|
|||||||
|
|
||||||
return <div ref={containerRef} className="w-full h-full pointer-events-none" />;
|
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
|
||||||
|
function BunnyPanel({
|
||||||
|
version,
|
||||||
|
onRegister,
|
||||||
|
onUnregister,
|
||||||
|
}: {
|
||||||
|
version: Version;
|
||||||
|
onRegister: (versionId: string, player: YT.Player | PlayerAdapter) => void;
|
||||||
|
onUnregister: (versionId: string) => void;
|
||||||
|
}) {
|
||||||
|
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!iframeRef.current) return;
|
||||||
|
const playerjs = require('player.js');
|
||||||
|
const player = new playerjs.Player(iframeRef.current);
|
||||||
|
|
||||||
|
let cachedTime = 0;
|
||||||
|
let cachedDuration = 0;
|
||||||
|
let isPlaying = false;
|
||||||
|
let isMuted = false;
|
||||||
|
|
||||||
|
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 { }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onRegister(version.id, adapter);
|
||||||
|
});
|
||||||
|
|
||||||
|
player.on('timeupdate', (data: { seconds: number }) => { cachedTime = data.seconds; });
|
||||||
|
player.on('play', () => { isPlaying = true; });
|
||||||
|
player.on('pause', () => { isPlaying = false; });
|
||||||
|
player.on('ended', () => { isPlaying = false; });
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
onUnregister(version.id);
|
||||||
|
try {
|
||||||
|
player.off('ready');
|
||||||
|
player.off('timeupdate');
|
||||||
|
player.off('play');
|
||||||
|
player.off('pause');
|
||||||
|
player.off('ended');
|
||||||
|
} catch { }
|
||||||
|
};
|
||||||
|
}, [version.id, onRegister, onUnregister]);
|
||||||
|
|
||||||
|
const src = version.originalUrl.replace('/play/', '/embed/');
|
||||||
|
const embedSrc = `${src}${src.includes('?') ? '&' : '?'}autoplay=false&controls=false`;
|
||||||
|
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,16 +1,18 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import { useRouter, useParams } from 'next/navigation';
|
import { useRouter, useParams } from 'next/navigation';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { ArrowLeft, Loader2, Link as LinkIcon, AlertCircle, CheckCircle2 } from 'lucide-react';
|
import { ArrowLeft, Loader2, Link as LinkIcon, AlertCircle, CheckCircle2, UploadCloud, FileVideo } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
import { parseVideoUrl, fetchVideoMetadata, getThumbnailUrl, type VideoSource } from '@/lib/video-providers';
|
import { parseVideoUrl, fetchVideoMetadata, getThumbnailUrl, type VideoSource } from '@/lib/video-providers';
|
||||||
|
import * as tus from 'tus-js-client';
|
||||||
|
|
||||||
export default function NewVideoPage() {
|
export default function NewVideoPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -19,9 +21,18 @@ export default function NewVideoPage() {
|
|||||||
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [isFetchingMeta, setIsFetchingMeta] = useState(false);
|
const [isFetchingMeta, setIsFetchingMeta] = useState(false);
|
||||||
|
|
||||||
|
// URL Mode State
|
||||||
const [videoUrl, setVideoUrl] = useState('');
|
const [videoUrl, setVideoUrl] = useState('');
|
||||||
const [videoSource, setVideoSource] = useState<VideoSource | null>(null);
|
const [videoSource, setVideoSource] = useState<VideoSource | null>(null);
|
||||||
const [urlError, setUrlError] = useState('');
|
const [urlError, setUrlError] = useState('');
|
||||||
|
|
||||||
|
// Upload Mode State
|
||||||
|
const [uploadMode, setUploadMode] = useState<'url' | 'file'>('url');
|
||||||
|
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||||
|
const [uploadProgress, setUploadProgress] = useState(0);
|
||||||
|
const [uploadStatus, setUploadStatus] = useState('');
|
||||||
|
|
||||||
const [submitError, setSubmitError] = useState('');
|
const [submitError, setSubmitError] = useState('');
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
title: '',
|
title: '',
|
||||||
@@ -74,32 +85,137 @@ export default function NewVideoPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (file) {
|
||||||
|
if (!file.type.startsWith('video/')) {
|
||||||
|
setSubmitError('Please select a valid video file.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSelectedFile(file);
|
||||||
|
setSubmitError('');
|
||||||
|
if (!formData.title) {
|
||||||
|
// Strip extension from filename for default title
|
||||||
|
const nameWithoutExt = file.name.replace(/\.[^/.]+$/, '');
|
||||||
|
setFormData((prev) => ({ ...prev, title: nameWithoutExt }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const uploadToBunny = async (file: File): Promise<{ videoId: string; libraryId: string; providerId: string; url: string }> => {
|
||||||
|
// 1. Initialize Bunny Stream upload (creates video & gets signature)
|
||||||
|
setUploadStatus('Initializing upload...');
|
||||||
|
const initRes = await fetch(`/api/projects/${projectId}/videos/bunny-init`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ title: formData.title || file.name })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!initRes.ok) {
|
||||||
|
const data = await initRes.json();
|
||||||
|
throw new Error(data.error || 'Failed to initialize upload');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: { videoId, libraryId, signature, expirationTime } } = await initRes.json();
|
||||||
|
|
||||||
|
// 2. Upload via TUS
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
setUploadStatus('Uploading video...');
|
||||||
|
const upload = new tus.Upload(file, {
|
||||||
|
endpoint: 'https://video.bunnycdn.com/tusupload',
|
||||||
|
retryDelays: [0, 3000, 5000, 10000, 20000],
|
||||||
|
headers: {
|
||||||
|
AuthorizationSignature: signature,
|
||||||
|
AuthorizationExpire: expirationTime.toString(),
|
||||||
|
VideoId: videoId,
|
||||||
|
LibraryId: libraryId,
|
||||||
|
},
|
||||||
|
metadata: {
|
||||||
|
filetype: file.type,
|
||||||
|
title: formData.title || file.name,
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
reject(new Error('Upload failed: ' + error.message));
|
||||||
|
},
|
||||||
|
onProgress: (bytesUploaded, bytesTotal) => {
|
||||||
|
const percentage = ((bytesUploaded / bytesTotal) * 100).toFixed(1);
|
||||||
|
setUploadProgress(Number(percentage));
|
||||||
|
setUploadStatus(`Uploading... ${percentage}%`);
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
setUploadStatus('Processing video...');
|
||||||
|
resolve({
|
||||||
|
videoId,
|
||||||
|
libraryId,
|
||||||
|
providerId: 'bunny',
|
||||||
|
url: `https://iframe.mediadelivery.net/embed/${libraryId}/${videoId}`
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
upload.start();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
if (!videoSource) {
|
|
||||||
setUrlError('Please enter a valid video URL');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setSubmitError('');
|
setSubmitError('');
|
||||||
|
setUploadStatus('');
|
||||||
|
setUploadProgress(0);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const thumbnailUrl = getThumbnailUrl(videoSource, 'large');
|
let finalTitle = formData.title.trim();
|
||||||
const title = formData.title.trim() || videoSource.metadata?.title || 'Untitled Video';
|
let finalDescription = formData.description.trim() || null;
|
||||||
|
let finalVideoUrl = '';
|
||||||
|
let finalProviderId = '';
|
||||||
|
let finalVideoId = '';
|
||||||
|
let finalThumbnailUrl: string | null = null;
|
||||||
|
let finalDuration: number | null = null;
|
||||||
|
|
||||||
|
if (uploadMode === 'url') {
|
||||||
|
if (!videoSource) {
|
||||||
|
setUrlError('Please enter a valid video URL');
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
finalTitle = finalTitle || videoSource.metadata?.title || 'Untitled Video';
|
||||||
|
finalVideoUrl = videoSource.originalUrl;
|
||||||
|
finalProviderId = videoSource.providerId;
|
||||||
|
finalVideoId = videoSource.videoId;
|
||||||
|
finalThumbnailUrl = getThumbnailUrl(videoSource, 'large');
|
||||||
|
finalDuration = videoSource.metadata?.duration || null;
|
||||||
|
} else {
|
||||||
|
if (!selectedFile) {
|
||||||
|
setSubmitError('Please select a video file to upload');
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
finalTitle = finalTitle || selectedFile.name;
|
||||||
|
|
||||||
|
// Handle TUS Upload
|
||||||
|
const bunnyData = await uploadToBunny(selectedFile);
|
||||||
|
|
||||||
|
finalVideoUrl = bunnyData.url;
|
||||||
|
finalProviderId = bunnyData.providerId;
|
||||||
|
finalVideoId = bunnyData.videoId;
|
||||||
|
// Bunny will generate thumbnails automatically after processing.
|
||||||
|
// We'll just provide the standard CDN thumbnail URL format as fallback.
|
||||||
|
finalThumbnailUrl = `https://vz-thumbnail.b-cdn.net/${bunnyData.videoId}/thumbnail.jpg`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Final POST to our database
|
||||||
const response = await fetch(`/api/projects/${projectId}/videos`, {
|
const response = await fetch(`/api/projects/${projectId}/videos`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
title,
|
title: finalTitle,
|
||||||
description: formData.description.trim() || null,
|
description: finalDescription,
|
||||||
videoUrl: videoSource.originalUrl,
|
videoUrl: finalVideoUrl,
|
||||||
providerId: videoSource.providerId,
|
providerId: finalProviderId,
|
||||||
videoId: videoSource.videoId,
|
videoId: finalVideoId,
|
||||||
thumbnailUrl,
|
thumbnailUrl: finalThumbnailUrl,
|
||||||
duration: videoSource.metadata?.duration || null,
|
duration: finalDuration,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -110,9 +226,9 @@ export default function NewVideoPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
router.push(`/projects/${projectId}`);
|
router.push(`/projects/${projectId}`);
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
console.error('Failed to add video:', error);
|
console.error('Failed to add video:', error);
|
||||||
setSubmitError('An unexpected error occurred');
|
setSubmitError(error.message || 'An unexpected error occurred');
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
@@ -136,12 +252,20 @@ export default function NewVideoPage() {
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Add Video</CardTitle>
|
<CardTitle>Add Video</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Paste a video link to add it to your project. Currently supports YouTube and Vimeo.
|
Paste a video link or upload a file directly to add it to your project. Currently supports YouTube.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
|
<Tabs value={uploadMode} onValueChange={(v) => 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>
|
||||||
|
</TabsList>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-6">
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
{/* Video URL Input */}
|
|
||||||
|
{uploadMode === 'url' ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="url">Video URL</Label>
|
<Label htmlFor="url">Video URL</Label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
@@ -172,9 +296,38 @@ export default function NewVideoPage() {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="file">Video File</Label>
|
||||||
|
<div className="flex items-center justify-center w-full">
|
||||||
|
<label htmlFor="file" className={`flex flex-col items-center justify-center w-full h-40 border-2 border-dashed rounded-lg cursor-pointer bg-muted/30 hover:bg-muted/50 transition-colors ${selectedFile ? 'border-primary' : 'border-border'}`}>
|
||||||
|
<div className="flex flex-col items-center justify-center pt-5 pb-6">
|
||||||
|
{selectedFile ? (
|
||||||
|
<>
|
||||||
|
<FileVideo className="w-10 h-10 mb-3 text-primary" />
|
||||||
|
<p className="mb-2 text-sm text-foreground font-medium">{selectedFile.name}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{(selectedFile.size / (1024 * 1024)).toFixed(2)} MB
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<UploadCloud className="w-10 h-10 mb-3 text-muted-foreground" />
|
||||||
|
<p className="mb-2 text-sm text-muted-foreground">
|
||||||
|
<span className="font-semibold">Click to upload</span> or drag and drop
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">MP4, WebM, or OGG</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<input id="file" type="file" accept="video/*" className="hidden" onChange={handleFileChange} disabled={isLoading} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Video Preview */}
|
{/* Video Preview (Only for URL mode) */}
|
||||||
{thumbnailUrl && videoSource && (
|
{uploadMode === 'url' && thumbnailUrl && videoSource && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Preview</Label>
|
<Label>Preview</Label>
|
||||||
<div className="relative aspect-video rounded-lg overflow-hidden bg-muted">
|
<div className="relative aspect-video rounded-lg overflow-hidden bg-muted">
|
||||||
@@ -224,12 +377,23 @@ export default function NewVideoPage() {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{uploadStatus && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-sm text-muted-foreground">{uploadStatus}</p>
|
||||||
|
{uploadProgress > 0 && uploadProgress < 100 && (
|
||||||
|
<div className="w-full bg-secondary rounded-full h-2">
|
||||||
|
<div className="bg-primary h-2 rounded-full transition-all" style={{ width: `${uploadProgress}%` }}></div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-3">
|
<div className="flex flex-wrap gap-3">
|
||||||
<Button type="submit" disabled={isLoading || !videoSource}>
|
<Button type="submit" disabled={isLoading || (uploadMode === 'url' && !videoSource) || (uploadMode === 'file' && !selectedFile)}>
|
||||||
{isLoading && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
|
{isLoading && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
|
||||||
Add Video
|
Add Video
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="button" variant="outline" onClick={() => router.back()}>
|
<Button type="button" variant="outline" onClick={() => router.back()} disabled={isLoading}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { NextRequest } from 'next/server';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { auth } from '@/lib/auth';
|
||||||
|
import { ProjectMemberRole, WorkspaceMemberRole } from '@prisma/client';
|
||||||
|
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
|
||||||
|
import crypto from 'crypto';
|
||||||
|
|
||||||
|
type RouteParams = { params: Promise<{ projectId: string }> };
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||||
|
try {
|
||||||
|
const session = await auth();
|
||||||
|
const { projectId } = await params;
|
||||||
|
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return apiErrors.unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check project access (must be owner, project admin, or workspace admin)
|
||||||
|
const project = await db.project.findUnique({
|
||||||
|
where: { id: projectId },
|
||||||
|
include: {
|
||||||
|
members: { where: { userId: session.user.id } },
|
||||||
|
workspace: {
|
||||||
|
include: {
|
||||||
|
members: { where: { userId: session.user.id } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!project) {
|
||||||
|
return apiErrors.notFound('Project');
|
||||||
|
}
|
||||||
|
|
||||||
|
const isOwner = project.ownerId === session.user.id;
|
||||||
|
const membership = project.members[0];
|
||||||
|
const workspaceMembership = project.workspace.members[0];
|
||||||
|
const canEdit = isOwner ||
|
||||||
|
membership?.role === ProjectMemberRole.ADMIN ||
|
||||||
|
workspaceMembership?.role === WorkspaceMemberRole.ADMIN;
|
||||||
|
|
||||||
|
if (!canEdit) {
|
||||||
|
return apiErrors.forbidden('Access denied');
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const { title } = body;
|
||||||
|
|
||||||
|
if (!title) {
|
||||||
|
return apiErrors.badRequest('Title is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiKey = process.env.BUNNY_STREAM_API_KEY;
|
||||||
|
const libraryId = process.env.BUNNY_STREAM_LIBRARY_ID || process.env.NEXT_PUBLIC_BUNNY_STREAM_LIBRARY_ID;
|
||||||
|
|
||||||
|
if (!apiKey || !libraryId) {
|
||||||
|
return apiErrors.internalError('Bunny Stream is not configured correctly');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Create video object in Bunny Stream
|
||||||
|
const bunnyRes = await fetch(`https://video.bunnycdn.com/library/${libraryId}/videos`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'AccessKey': apiKey,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Accept': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ title })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!bunnyRes.ok) {
|
||||||
|
console.error('Failed to create Bunny Stream video', await bunnyRes.text());
|
||||||
|
return apiErrors.internalError('Failed to initialize video upload with provider');
|
||||||
|
}
|
||||||
|
|
||||||
|
const bunnyVideo = await bunnyRes.json();
|
||||||
|
const videoId = bunnyVideo.guid;
|
||||||
|
|
||||||
|
// 2. Generate TUS upload signature
|
||||||
|
const expirationTime = Math.floor(Date.now() / 1000) + 3600; // 1 hour validity
|
||||||
|
|
||||||
|
// SHA256(library_id + api_key + expiration_time + video_id)
|
||||||
|
const hash = crypto.createHash('sha256');
|
||||||
|
hash.update(libraryId + apiKey + expirationTime + videoId);
|
||||||
|
const signature = hash.digest('hex');
|
||||||
|
|
||||||
|
const response = successResponse({
|
||||||
|
videoId,
|
||||||
|
libraryId,
|
||||||
|
signature,
|
||||||
|
expirationTime
|
||||||
|
});
|
||||||
|
|
||||||
|
return withCacheControl(response, 'private, no-store');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error initializing Bunny upload:', error);
|
||||||
|
return apiErrors.internalError('Failed to initialize upload');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,6 +22,7 @@
|
|||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"nodemailer": "^8.0.1",
|
"nodemailer": "^8.0.1",
|
||||||
"pg": "^8.18.0",
|
"pg": "^8.18.0",
|
||||||
|
"player.js": "^0.1.0",
|
||||||
"prisma": "^7.3.0",
|
"prisma": "^7.3.0",
|
||||||
"radix-ui": "^1.4.3",
|
"radix-ui": "^1.4.3",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
@@ -29,6 +30,7 @@
|
|||||||
"react-window": "^2.2.7",
|
"react-window": "^2.2.7",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
|
"tus-js-client": "^4.3.1",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"zod": "^4.3.6",
|
"zod": "^4.3.6",
|
||||||
},
|
},
|
||||||
@@ -833,6 +835,8 @@
|
|||||||
|
|
||||||
"browserslist": ["[email protected]", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="],
|
"browserslist": ["[email protected]", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="],
|
||||||
|
|
||||||
|
"buffer-from": ["[email protected]", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
|
||||||
|
|
||||||
"bundle-name": ["[email protected]", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="],
|
"bundle-name": ["[email protected]", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="],
|
||||||
|
|
||||||
"bytes": ["[email protected]", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="],
|
"bytes": ["[email protected]", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="],
|
||||||
@@ -877,6 +881,8 @@
|
|||||||
|
|
||||||
"color-name": ["[email protected]", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
|
"color-name": ["[email protected]", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
|
||||||
|
|
||||||
|
"combine-errors": ["[email protected]", "", { "dependencies": { "custom-error-instance": "2.1.1", "lodash.uniqby": "4.5.0" } }, "sha512-C8ikRNRMygCwaTx+Ek3Yr+OuZzgZjduCOfSQBjbM8V3MfgcjSTeto/GXP6PAwKvJz/v15b7GHZvx5rOlczFw/Q=="],
|
||||||
|
|
||||||
"commander": ["[email protected]", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="],
|
"commander": ["[email protected]", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="],
|
||||||
|
|
||||||
"concat-map": ["[email protected]", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="],
|
"concat-map": ["[email protected]", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="],
|
||||||
@@ -905,6 +911,8 @@
|
|||||||
|
|
||||||
"csstype": ["[email protected]", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
"csstype": ["[email protected]", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
||||||
|
|
||||||
|
"custom-error-instance": ["[email protected]", "", {}, "sha512-p6JFxJc3M4OTD2li2qaHkDCw9SfMw82Ldr6OC9Je1aXiGfhx2W8p3GaoeaGrPJTUN9NirTM/KTxHWMUdR1rsUg=="],
|
||||||
|
|
||||||
"damerau-levenshtein": ["[email protected]", "", {}, "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA=="],
|
"damerau-levenshtein": ["[email protected]", "", {}, "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA=="],
|
||||||
|
|
||||||
"data-uri-to-buffer": ["[email protected]", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="],
|
"data-uri-to-buffer": ["[email protected]", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="],
|
||||||
@@ -1257,7 +1265,7 @@
|
|||||||
|
|
||||||
"is-shared-array-buffer": ["[email protected]", "", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A=="],
|
"is-shared-array-buffer": ["[email protected]", "", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A=="],
|
||||||
|
|
||||||
"is-stream": ["is-stream@4.0.1", "", {}, "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A=="],
|
"is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="],
|
||||||
|
|
||||||
"is-string": ["[email protected]", "", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA=="],
|
"is-string": ["[email protected]", "", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA=="],
|
||||||
|
|
||||||
@@ -1285,6 +1293,8 @@
|
|||||||
|
|
||||||
"jose": ["[email protected]", "", {}, "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ=="],
|
"jose": ["[email protected]", "", {}, "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ=="],
|
||||||
|
|
||||||
|
"js-base64": ["[email protected]", "", {}, "sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow=="],
|
||||||
|
|
||||||
"js-tokens": ["[email protected]", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
|
"js-tokens": ["[email protected]", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
|
||||||
|
|
||||||
"js-yaml": ["[email protected]", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
|
"js-yaml": ["[email protected]", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
|
||||||
@@ -1349,8 +1359,24 @@
|
|||||||
|
|
||||||
"lodash": ["[email protected]", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="],
|
"lodash": ["[email protected]", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="],
|
||||||
|
|
||||||
|
"lodash._baseiteratee": ["[email protected]", "", { "dependencies": { "lodash._stringtopath": "~4.8.0" } }, "sha512-nqB9M+wITz0BX/Q2xg6fQ8mLkyfF7MU7eE+MNBNjTHFKeKaZAPEzEg+E8LWxKWf1DQVflNEn9N49yAuqKh2mWQ=="],
|
||||||
|
|
||||||
|
"lodash._basetostring": ["[email protected]", "", {}, "sha512-SwcRIbyxnN6CFEEK4K1y+zuApvWdpQdBHM/swxP962s8HIxPO3alBH5t3m/dl+f4CMUug6sJb7Pww8d13/9WSw=="],
|
||||||
|
|
||||||
|
"lodash._baseuniq": ["[email protected]", "", { "dependencies": { "lodash._createset": "~4.0.0", "lodash._root": "~3.0.0" } }, "sha512-Ja1YevpHZctlI5beLA7oc5KNDhGcPixFhcqSiORHNsp/1QTv7amAXzw+gu4YOvErqVlMVyIJGgtzeepCnnur0A=="],
|
||||||
|
|
||||||
|
"lodash._createset": ["[email protected]", "", {}, "sha512-GTkC6YMprrJZCYU3zcqZj+jkXkrXzq3IPBcF/fIPpNEAB4hZEtXU8zp/RwKOvZl43NUmwDbyRk3+ZTbeRdEBXA=="],
|
||||||
|
|
||||||
|
"lodash._root": ["[email protected]", "", {}, "sha512-O0pWuFSK6x4EXhM1dhZ8gchNtG7JMqBtrHdoUFUWXD7dJnNSUze1GuyQr5sOs0aCvgGeI3o/OJW8f4ca7FDxmQ=="],
|
||||||
|
|
||||||
|
"lodash._stringtopath": ["[email protected]", "", { "dependencies": { "lodash._basetostring": "~4.12.0" } }, "sha512-SXL66C731p0xPDC5LZg4wI5H+dJo/EO4KTqOMwLYCH3+FmmfAKJEZCm6ohGpI+T1xwsDsJCfL4OnhorllvlTPQ=="],
|
||||||
|
|
||||||
"lodash.merge": ["[email protected]", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="],
|
"lodash.merge": ["[email protected]", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="],
|
||||||
|
|
||||||
|
"lodash.throttle": ["[email protected]", "", {}, "sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ=="],
|
||||||
|
|
||||||
|
"lodash.uniqby": ["[email protected]", "", { "dependencies": { "lodash._baseiteratee": "~4.7.0", "lodash._baseuniq": "~4.6.0" } }, "sha512-IRt7cfTtHy6f1aRVA5n7kT8rgN3N1nH6MOWLcHfpWG2SH19E3JksLK38MktLxZDhlAjCP9jpIXkOnRXlu6oByQ=="],
|
||||||
|
|
||||||
"log-symbols": ["[email protected]", "", { "dependencies": { "chalk": "^5.3.0", "is-unicode-supported": "^1.3.0" } }, "sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw=="],
|
"log-symbols": ["[email protected]", "", { "dependencies": { "chalk": "^5.3.0", "is-unicode-supported": "^1.3.0" } }, "sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw=="],
|
||||||
|
|
||||||
"long": ["[email protected]", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="],
|
"long": ["[email protected]", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="],
|
||||||
@@ -1517,6 +1543,8 @@
|
|||||||
|
|
||||||
"pkg-types": ["[email protected]", "", { "dependencies": { "confbox": "^0.2.2", "exsolve": "^1.0.7", "pathe": "^2.0.3" } }, "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig=="],
|
"pkg-types": ["[email protected]", "", { "dependencies": { "confbox": "^0.2.2", "exsolve": "^1.0.7", "pathe": "^2.0.3" } }, "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig=="],
|
||||||
|
|
||||||
|
"player.js": ["[email protected]", "", {}, "sha512-pzWPiqw5b4kQCYhXyoLOZWidWcSySVJTqciDOtoH/MzY97piKnUme494dfczCAC7FLkMSPMH2HfhNJU9D/lVFw=="],
|
||||||
|
|
||||||
"possible-typed-array-names": ["[email protected]", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="],
|
"possible-typed-array-names": ["[email protected]", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="],
|
||||||
|
|
||||||
"postcss": ["[email protected]", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
|
"postcss": ["[email protected]", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
|
||||||
@@ -1559,6 +1587,8 @@
|
|||||||
|
|
||||||
"qs": ["[email protected]", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ=="],
|
"qs": ["[email protected]", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ=="],
|
||||||
|
|
||||||
|
"querystringify": ["[email protected]", "", {}, "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ=="],
|
||||||
|
|
||||||
"queue-microtask": ["[email protected]", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
|
"queue-microtask": ["[email protected]", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
|
||||||
|
|
||||||
"radix-ui": ["[email protected]", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-accessible-icon": "1.1.7", "@radix-ui/react-accordion": "1.2.12", "@radix-ui/react-alert-dialog": "1.1.15", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-aspect-ratio": "1.1.7", "@radix-ui/react-avatar": "1.1.10", "@radix-ui/react-checkbox": "1.3.3", "@radix-ui/react-collapsible": "1.1.12", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-context-menu": "2.2.16", "@radix-ui/react-dialog": "1.1.15", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-dropdown-menu": "2.1.16", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-form": "0.1.8", "@radix-ui/react-hover-card": "1.1.15", "@radix-ui/react-label": "2.1.7", "@radix-ui/react-menu": "2.1.16", "@radix-ui/react-menubar": "1.1.16", "@radix-ui/react-navigation-menu": "1.2.14", "@radix-ui/react-one-time-password-field": "0.1.8", "@radix-ui/react-password-toggle-field": "0.1.3", "@radix-ui/react-popover": "1.1.15", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-progress": "1.1.7", "@radix-ui/react-radio-group": "1.3.8", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-scroll-area": "1.2.10", "@radix-ui/react-select": "2.2.6", "@radix-ui/react-separator": "1.1.7", "@radix-ui/react-slider": "1.3.6", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-switch": "1.2.6", "@radix-ui/react-tabs": "1.1.13", "@radix-ui/react-toast": "1.2.15", "@radix-ui/react-toggle": "1.1.10", "@radix-ui/react-toggle-group": "1.1.11", "@radix-ui/react-toolbar": "1.1.11", "@radix-ui/react-tooltip": "1.2.8", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-escape-keydown": "1.1.1", "@radix-ui/react-use-is-hydrated": "0.1.0", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-aWizCQiyeAenIdUbqEpXgRA1ya65P13NKn/W8rWkcN0OPkRDxdBVLWnIEDsS2RpwCK2nobI7oMUSmexzTDyAmA=="],
|
"radix-ui": ["[email protected]", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-accessible-icon": "1.1.7", "@radix-ui/react-accordion": "1.2.12", "@radix-ui/react-alert-dialog": "1.1.15", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-aspect-ratio": "1.1.7", "@radix-ui/react-avatar": "1.1.10", "@radix-ui/react-checkbox": "1.3.3", "@radix-ui/react-collapsible": "1.1.12", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-context-menu": "2.2.16", "@radix-ui/react-dialog": "1.1.15", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-dropdown-menu": "2.1.16", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-form": "0.1.8", "@radix-ui/react-hover-card": "1.1.15", "@radix-ui/react-label": "2.1.7", "@radix-ui/react-menu": "2.1.16", "@radix-ui/react-menubar": "1.1.16", "@radix-ui/react-navigation-menu": "1.2.14", "@radix-ui/react-one-time-password-field": "0.1.8", "@radix-ui/react-password-toggle-field": "0.1.3", "@radix-ui/react-popover": "1.1.15", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-progress": "1.1.7", "@radix-ui/react-radio-group": "1.3.8", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-scroll-area": "1.2.10", "@radix-ui/react-select": "2.2.6", "@radix-ui/react-separator": "1.1.7", "@radix-ui/react-slider": "1.3.6", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-switch": "1.2.6", "@radix-ui/react-tabs": "1.1.13", "@radix-ui/react-toast": "1.2.15", "@radix-ui/react-toggle": "1.1.10", "@radix-ui/react-toggle-group": "1.1.11", "@radix-ui/react-toolbar": "1.1.11", "@radix-ui/react-tooltip": "1.2.8", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-escape-keydown": "1.1.1", "@radix-ui/react-use-is-hydrated": "0.1.0", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-aWizCQiyeAenIdUbqEpXgRA1ya65P13NKn/W8rWkcN0OPkRDxdBVLWnIEDsS2RpwCK2nobI7oMUSmexzTDyAmA=="],
|
||||||
@@ -1599,6 +1629,8 @@
|
|||||||
|
|
||||||
"require-from-string": ["[email protected]", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
|
"require-from-string": ["[email protected]", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
|
||||||
|
|
||||||
|
"requires-port": ["[email protected]", "", {}, "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ=="],
|
||||||
|
|
||||||
"reselect": ["[email protected]", "", {}, "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w=="],
|
"reselect": ["[email protected]", "", {}, "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w=="],
|
||||||
|
|
||||||
"resolve": ["[email protected]", "", { "dependencies": { "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ=="],
|
"resolve": ["[email protected]", "", { "dependencies": { "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ=="],
|
||||||
@@ -1755,6 +1787,8 @@
|
|||||||
|
|
||||||
"tslib": ["[email protected]", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
"tslib": ["[email protected]", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||||
|
|
||||||
|
"tus-js-client": ["[email protected]", "", { "dependencies": { "buffer-from": "^1.1.2", "combine-errors": "^3.0.3", "is-stream": "^2.0.0", "js-base64": "^3.7.2", "lodash.throttle": "^4.1.1", "proper-lockfile": "^4.1.2", "url-parse": "^1.5.7" } }, "sha512-ZLeYmjrkaU1fUsKbIi8JML52uAocjEZtBx4DKjRrqzrZa0O4MYwT6db+oqePlspV+FxXJAyFBc/L5gwUi2OFsg=="],
|
||||||
|
|
||||||
"tw-animate-css": ["[email protected]", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="],
|
"tw-animate-css": ["[email protected]", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="],
|
||||||
|
|
||||||
"type-check": ["[email protected]", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="],
|
"type-check": ["[email protected]", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="],
|
||||||
@@ -1793,6 +1827,8 @@
|
|||||||
|
|
||||||
"uri-js": ["[email protected]", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
|
"uri-js": ["[email protected]", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
|
||||||
|
|
||||||
|
"url-parse": ["[email protected]", "", { "dependencies": { "querystringify": "^2.1.1", "requires-port": "^1.0.0" } }, "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ=="],
|
||||||
|
|
||||||
"use-callback-ref": ["[email protected]", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="],
|
"use-callback-ref": ["[email protected]", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="],
|
||||||
|
|
||||||
"use-sidecar": ["[email protected]", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="],
|
"use-sidecar": ["[email protected]", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="],
|
||||||
@@ -1919,10 +1955,14 @@
|
|||||||
|
|
||||||
"eslint-plugin-react/resolve": ["[email protected]", "", { "dependencies": { "is-core-module": "^2.13.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA=="],
|
"eslint-plugin-react/resolve": ["[email protected]", "", { "dependencies": { "is-core-module": "^2.13.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA=="],
|
||||||
|
|
||||||
|
"execa/is-stream": ["[email protected]", "", {}, "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A=="],
|
||||||
|
|
||||||
"express/cookie": ["[email protected]", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
|
"express/cookie": ["[email protected]", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
|
||||||
|
|
||||||
"fast-glob/glob-parent": ["[email protected]", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
|
"fast-glob/glob-parent": ["[email protected]", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
|
||||||
|
|
||||||
|
"get-stream/is-stream": ["[email protected]", "", {}, "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A=="],
|
||||||
|
|
||||||
"is-bun-module/semver": ["[email protected]", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
|
"is-bun-module/semver": ["[email protected]", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
|
||||||
|
|
||||||
"log-symbols/chalk": ["[email protected]", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
|
"log-symbols/chalk": ["[email protected]", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
|
||||||
@@ -1975,8 +2015,6 @@
|
|||||||
|
|
||||||
"@dotenvx/dotenvx/execa/human-signals": ["[email protected]", "", {}, "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw=="],
|
"@dotenvx/dotenvx/execa/human-signals": ["[email protected]", "", {}, "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw=="],
|
||||||
|
|
||||||
"@dotenvx/dotenvx/execa/is-stream": ["[email protected]", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="],
|
|
||||||
|
|
||||||
"@dotenvx/dotenvx/execa/npm-run-path": ["[email protected]", "", { "dependencies": { "path-key": "^3.0.0" } }, "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw=="],
|
"@dotenvx/dotenvx/execa/npm-run-path": ["[email protected]", "", { "dependencies": { "path-key": "^3.0.0" } }, "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw=="],
|
||||||
|
|
||||||
"@dotenvx/dotenvx/execa/signal-exit": ["[email protected]", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="],
|
"@dotenvx/dotenvx/execa/signal-exit": ["[email protected]", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="],
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
import { Tabs as TabsPrimitive } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Tabs({
|
||||||
|
className,
|
||||||
|
orientation = "horizontal",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<TabsPrimitive.Root
|
||||||
|
data-slot="tabs"
|
||||||
|
data-orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
"gap-2 group/tabs flex data-horizontal:flex-col",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const tabsListVariants = cva(
|
||||||
|
"rounded-none p-[3px] group-data-horizontal/tabs:h-8 data-[variant=line]:rounded-none group/tabs-list text-muted-foreground inline-flex w-fit items-center justify-center group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-muted",
|
||||||
|
line: "gap-1 bg-transparent",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function TabsList({
|
||||||
|
className,
|
||||||
|
variant = "default",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TabsPrimitive.List> &
|
||||||
|
VariantProps<typeof tabsListVariants>) {
|
||||||
|
return (
|
||||||
|
<TabsPrimitive.List
|
||||||
|
data-slot="tabs-list"
|
||||||
|
data-variant={variant}
|
||||||
|
className={cn(tabsListVariants({ variant }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TabsTrigger({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
||||||
|
return (
|
||||||
|
<TabsPrimitive.Trigger
|
||||||
|
data-slot="tabs-trigger"
|
||||||
|
className={cn(
|
||||||
|
"gap-1.5 rounded-none border border-transparent px-1.5 py-0.5 text-xs font-medium group-data-vertical/tabs:py-[calc(--spacing(1.25))] [&_svg:not([class*='size-'])]:size-4 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring text-foreground/60 hover:text-foreground dark:text-muted-foreground dark:hover:text-foreground relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center whitespace-nowrap transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||||
|
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
|
||||||
|
"data-active:bg-background dark:data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 data-active:text-foreground",
|
||||||
|
"after:bg-foreground after:absolute after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TabsContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<TabsPrimitive.Content
|
||||||
|
data-slot="tabs-content"
|
||||||
|
className={cn("text-xs/relaxed flex-1 outline-none", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
|
||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import Image from 'next/image';
|
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import {
|
import {
|
||||||
Play,
|
Play,
|
||||||
@@ -60,6 +59,8 @@ interface VideoCardProps {
|
|||||||
|
|
||||||
export function VideoCard({ video, projectId }: VideoCardProps) {
|
export function VideoCard({ video, projectId }: VideoCardProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const [imgError, setImgError] = useState(false);
|
||||||
|
const [retryKey, setRetryKey] = useState(0);
|
||||||
|
|
||||||
// Edit dialog
|
// Edit dialog
|
||||||
const [showEditDialog, setShowEditDialog] = useState(false);
|
const [showEditDialog, setShowEditDialog] = useState(false);
|
||||||
@@ -181,18 +182,33 @@ export function VideoCard({ video, projectId }: VideoCardProps) {
|
|||||||
<Link href={`/projects/${projectId}/videos/${video.id}`}>
|
<Link href={`/projects/${projectId}/videos/${video.id}`}>
|
||||||
{/* Thumbnail */}
|
{/* Thumbnail */}
|
||||||
<div className="relative aspect-video bg-muted overflow-hidden">
|
<div className="relative aspect-video bg-muted overflow-hidden">
|
||||||
<Image
|
{imgError ? (
|
||||||
src={video.thumbnailUrl}
|
<div className="absolute inset-0 flex flex-col items-center justify-center bg-muted/80">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground mb-2" />
|
||||||
|
<span className="text-xs text-muted-foreground font-medium">Processing...</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img
|
||||||
|
src={`${video.thumbnailUrl?.replace('vz-thumbnail.b-cdn.net', 'vz-965f4f4a-fc1.b-cdn.net')}${retryKey ? `?t=${retryKey}` : ''}`}
|
||||||
alt={video.title}
|
alt={video.title}
|
||||||
fill
|
className="absolute inset-0 w-full h-full object-cover transition-transform group-hover:scale-105"
|
||||||
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
|
onError={(e) => {
|
||||||
className="object-cover transition-transform group-hover:scale-105"
|
console.error('Thumbnail failed to load. Tried:', (e.target as HTMLImageElement).currentSrc);
|
||||||
placeholder="blur"
|
setImgError(true);
|
||||||
blurDataURL="data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAMCAgMCAgMDAwMEAwMEBQgFBQQEBQoHBwYIDAoMCwsKCwsNDhIQDQ4RDgsLEBYQERMUFRUVDA8XGBYUGBIUFRT/2wBDAQMEBAUEBQkFBQkUDQsNFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBT/wAARCAAIAAoDASIAAhEBAxEB/8QAFgABAQEAAAAAAAAAAAAAAAAAAAUH/8QAIhAAAQMDBQADAAAAAAAAAAAAAQIDBAAFEQYSITFBE1FR/8QAFQEBAQAAAAAAAAAAAAAAAAAAAAX/xAAYEQADAQEAAAAAAAAAAAAAAAAAAQIhMf/aAAwDAQACEQMRAD8Adu3bgZt8NqM2y6sNJCQTjJ+dKz/9k="
|
// Check again after 10 seconds in case Bunny is still processing
|
||||||
|
setTimeout(() => {
|
||||||
|
setRetryKey(Date.now());
|
||||||
|
setImgError(false);
|
||||||
|
}, 10000);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
{!imgError && (
|
||||||
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
||||||
<Play className="h-12 w-12 text-white" fill="white" />
|
<Play className="h-12 w-12 text-white" fill="white" />
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
<Badge className="absolute bottom-2 right-2 bg-black/70">{video.duration}</Badge>
|
<Badge className="absolute bottom-2 right-2 bg-black/70">{video.duration}</Badge>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -78,6 +78,9 @@ import { cn } from '@/lib/utils';
|
|||||||
import { parseVideoUrl, getThumbnailUrl, fetchVideoMetadata, type VideoSource } from '@/lib/video-providers';
|
import { parseVideoUrl, getThumbnailUrl, fetchVideoMetadata, type VideoSource } from '@/lib/video-providers';
|
||||||
import { AnnotationCanvas, type AnnotationStroke, type AnnotationCanvasHandle } from '@/components/annotation-canvas';
|
import { AnnotationCanvas, type AnnotationStroke, type AnnotationCanvasHandle } from '@/components/annotation-canvas';
|
||||||
import { Linkify } from '@/components/linkify';
|
import { Linkify } from '@/components/linkify';
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
|
import * as tus from 'tus-js-client';
|
||||||
|
import { UploadCloud, FileVideo } from 'lucide-react';
|
||||||
|
|
||||||
interface Version {
|
interface Version {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -99,6 +102,21 @@ interface CommentTag {
|
|||||||
color: string;
|
color: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface PlayerAdapter {
|
||||||
|
playVideo: () => void;
|
||||||
|
pauseVideo: () => void;
|
||||||
|
seekTo: (time: number, allowSeekAhead?: boolean) => void;
|
||||||
|
mute: () => void;
|
||||||
|
unMute: () => void;
|
||||||
|
isMuted: () => boolean;
|
||||||
|
getCurrentTime: () => number;
|
||||||
|
getDuration: () => number;
|
||||||
|
getPlayerState: () => number;
|
||||||
|
setPlaybackRate: (rate: number) => void;
|
||||||
|
destroy: () => void;
|
||||||
|
off?: (event: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
interface Comment {
|
interface Comment {
|
||||||
id: string;
|
id: string;
|
||||||
content: string | null;
|
content: string | null;
|
||||||
@@ -167,7 +185,7 @@ interface VideoPageContentProps {
|
|||||||
|
|
||||||
export function VideoPageContent({ mode, videoId, projectId: propProjectId }: VideoPageContentProps) {
|
export function VideoPageContent({ mode, videoId, projectId: propProjectId }: VideoPageContentProps) {
|
||||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||||
const playerRef = useRef<YT.Player | null>(null);
|
const playerRef = useRef<YT.Player | PlayerAdapter | null>(null);
|
||||||
const timelineRef = useRef<HTMLDivElement>(null);
|
const timelineRef = useRef<HTMLDivElement>(null);
|
||||||
const videoContainerRef = useRef<HTMLDivElement>(null);
|
const videoContainerRef = useRef<HTMLDivElement>(null);
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
@@ -278,6 +296,11 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
|||||||
const [newVersionUrlError, setNewVersionUrlError] = useState('');
|
const [newVersionUrlError, setNewVersionUrlError] = useState('');
|
||||||
const [isCreatingVersion, setIsCreatingVersion] = useState(false);
|
const [isCreatingVersion, setIsCreatingVersion] = useState(false);
|
||||||
|
|
||||||
|
const [newVersionMode, setNewVersionMode] = useState<'url' | 'file'>('url');
|
||||||
|
const [newVersionFile, setNewVersionFile] = useState<File | null>(null);
|
||||||
|
const [newVersionUploadProgress, setNewVersionUploadProgress] = useState(0);
|
||||||
|
const [newVersionUploadStatus, setNewVersionUploadStatus] = useState('');
|
||||||
|
|
||||||
const [availableTags, setAvailableTags] = useState<CommentTag[]>([]);
|
const [availableTags, setAvailableTags] = useState<CommentTag[]>([]);
|
||||||
const [selectedTagId, setSelectedTagId] = useState<string | null>(null);
|
const [selectedTagId, setSelectedTagId] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -389,6 +412,11 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
|||||||
if (activeVersion.providerId === 'youtube') {
|
if (activeVersion.providerId === 'youtube') {
|
||||||
return `https://www.youtube.com/embed/${activeVersion.videoId}?enablejsapi=1&rel=0&modestbranding=1&controls=0&showinfo=0&iv_load_policy=3&disablekb=1`;
|
return `https://www.youtube.com/embed/${activeVersion.videoId}?enablejsapi=1&rel=0&modestbranding=1&controls=0&showinfo=0&iv_load_policy=3&disablekb=1`;
|
||||||
}
|
}
|
||||||
|
if (activeVersion.providerId === 'bunny') {
|
||||||
|
// Force /embed/ endpoint to ensure player.js integration works correctly and hide native controls
|
||||||
|
const url = activeVersion.originalUrl.replace('/play/', '/embed/');
|
||||||
|
return `${url}${url.includes('?') ? '&' : '?'}autoplay=false&controls=false`;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const url = new URL(activeVersion.originalUrl);
|
const url = new URL(activeVersion.originalUrl);
|
||||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
||||||
@@ -441,8 +469,12 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
|||||||
}, [isApiLoaded]);
|
}, [isApiLoaded]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!activeVersion || activeVersion.providerId !== 'youtube') return;
|
if (!activeVersion) return;
|
||||||
if (!isApiLoaded) return;
|
const isYoutube = activeVersion.providerId === 'youtube';
|
||||||
|
const isBunny = activeVersion.providerId === 'bunny';
|
||||||
|
|
||||||
|
if (isYoutube && !isApiLoaded) return;
|
||||||
|
if (!isYoutube && !isBunny) return;
|
||||||
|
|
||||||
setIsReady(false);
|
setIsReady(false);
|
||||||
setCurrentTime(0);
|
setCurrentTime(0);
|
||||||
@@ -451,12 +483,23 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
|||||||
setPlaybackSpeed(1);
|
setPlaybackSpeed(1);
|
||||||
|
|
||||||
if (playerRef.current) {
|
if (playerRef.current) {
|
||||||
|
if (isYoutube) {
|
||||||
try { playerRef.current.destroy(); } catch { /* ignore */ }
|
try { playerRef.current.destroy(); } catch { /* ignore */ }
|
||||||
|
} else if (isBunny && 'off' in playerRef.current) {
|
||||||
|
try {
|
||||||
|
(playerRef.current as PlayerAdapter).off?.('ready');
|
||||||
|
(playerRef.current as PlayerAdapter).off?.('play');
|
||||||
|
(playerRef.current as PlayerAdapter).off?.('pause');
|
||||||
|
(playerRef.current as PlayerAdapter).off?.('timeupdate');
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
playerRef.current = null;
|
playerRef.current = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const initPlayer = () => {
|
const initPlayer = () => {
|
||||||
if (!iframeRef.current) return;
|
if (!iframeRef.current) return;
|
||||||
|
|
||||||
|
if (isYoutube) {
|
||||||
playerRef.current = new YT.Player(iframeRef.current, {
|
playerRef.current = new YT.Player(iframeRef.current, {
|
||||||
events: {
|
events: {
|
||||||
onReady: (event: YT.PlayerEvent) => {
|
onReady: (event: YT.PlayerEvent) => {
|
||||||
@@ -467,9 +510,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
|||||||
onStateChange: (event: YT.OnStateChangeEvent) => {
|
onStateChange: (event: YT.OnStateChangeEvent) => {
|
||||||
setIsPlaying(event.data === YT.PlayerState.PLAYING);
|
setIsPlaying(event.data === YT.PlayerState.PLAYING);
|
||||||
|
|
||||||
// Save progress immediately when video is paused
|
|
||||||
if (event.data === YT.PlayerState.PAUSED) {
|
if (event.data === YT.PlayerState.PAUSED) {
|
||||||
// Get current time and duration directly from player instance, not from React state (which may be stale)
|
|
||||||
const playerCurrentTime = playerRef.current?.getCurrentTime?.() || 0;
|
const playerCurrentTime = playerRef.current?.getCurrentTime?.() || 0;
|
||||||
const playerDuration = playerRef.current?.getDuration?.() || 0;
|
const playerDuration = playerRef.current?.getDuration?.() || 0;
|
||||||
|
|
||||||
@@ -493,21 +534,108 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
} else if (isBunny) {
|
||||||
|
// dynamically require player.js to avoid SSR window errors
|
||||||
|
const playerjs = require('player.js');
|
||||||
|
const player = new playerjs.Player(iframeRef.current);
|
||||||
|
playerRef.current = player;
|
||||||
|
|
||||||
|
player.on('ready', () => {
|
||||||
|
setIsReady(true);
|
||||||
|
player.getDuration((duration: number) => {
|
||||||
|
if (duration > 0) setVideoDuration(duration);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
player.on('play', () => {
|
||||||
|
setIsPlaying(true);
|
||||||
|
player.getDuration((duration: number) => {
|
||||||
|
if (duration > 0) setVideoDuration(duration);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
player.on('pause', () => {
|
||||||
|
setIsPlaying(false);
|
||||||
|
player.getCurrentTime((currentTime: number) => {
|
||||||
|
player.getDuration((duration: number) => {
|
||||||
|
if (video?.isAuthenticated && currentTime > 0 && activeVersionId) {
|
||||||
|
fetch(`/api/watch/${videoId}/progress`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
progress: currentTime,
|
||||||
|
duration: duration,
|
||||||
|
versionId: activeVersionId,
|
||||||
|
}),
|
||||||
|
}).catch(console.error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
let cachedTime = 0;
|
||||||
|
let cachedDuration = videoDuration || 0;
|
||||||
|
|
||||||
|
player.on('timeupdate', (data: { seconds: number, duration: number }) => {
|
||||||
|
cachedTime = data.seconds;
|
||||||
|
if (data.duration > 0 && data.duration !== cachedDuration) {
|
||||||
|
cachedDuration = data.duration;
|
||||||
|
setVideoDuration(data.duration);
|
||||||
|
}
|
||||||
|
if (!isDragging) {
|
||||||
|
setCurrentTime(data.seconds);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
playerRef.current = {
|
||||||
|
playVideo: () => player.play(),
|
||||||
|
pauseVideo: () => player.pause(),
|
||||||
|
seekTo: (time: number) => { player.setCurrentTime(time); },
|
||||||
|
mute: () => player.mute(),
|
||||||
|
unMute: () => player.unmute(),
|
||||||
|
isMuted: () => false,
|
||||||
|
getCurrentTime: () => cachedTime,
|
||||||
|
getDuration: () => cachedDuration,
|
||||||
|
getPlayerState: () => window.YT?.PlayerState?.PLAYING || 1,
|
||||||
|
setPlaybackRate: (rate: number) => {
|
||||||
|
try {
|
||||||
|
if (player && typeof player.setPlaybackRate === 'function') {
|
||||||
|
player.setPlaybackRate(rate);
|
||||||
|
}
|
||||||
|
} catch (e) { console.error('Error setting playback rate on Bunny Stream', e); }
|
||||||
|
},
|
||||||
|
destroy: () => {
|
||||||
|
try {
|
||||||
|
player.off('ready');
|
||||||
|
player.off('play');
|
||||||
|
player.off('pause');
|
||||||
|
player.off('timeupdate');
|
||||||
|
} catch { }
|
||||||
|
},
|
||||||
|
off: (event: string) => player.off(event)
|
||||||
|
};
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const timeout = setTimeout(() => {
|
const timeout = setTimeout(() => {
|
||||||
|
if (isYoutube) {
|
||||||
if (window.YT?.Player) {
|
if (window.YT?.Player) {
|
||||||
initPlayer();
|
initPlayer();
|
||||||
} else {
|
} else {
|
||||||
window.onYouTubeIframeAPIReady = initPlayer;
|
window.onYouTubeIframeAPIReady = initPlayer;
|
||||||
}
|
}
|
||||||
|
} else if (isBunny) {
|
||||||
|
initPlayer();
|
||||||
|
}
|
||||||
}, 100);
|
}, 100);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
|
if (isYoutube) {
|
||||||
window.onYouTubeIframeAPIReady = undefined;
|
window.onYouTubeIframeAPIReady = undefined;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}, [activeVersionId, isApiLoaded]);
|
}, [activeVersionId, isApiLoaded, video?.isAuthenticated, videoId]);
|
||||||
|
|
||||||
// Save detected duration to DB if the version doesn't have one stored
|
// Save detected duration to DB if the version doesn't have one stored
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -585,11 +713,11 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
|||||||
|
|
||||||
// Save progress every 5 seconds while playing
|
// Save progress every 5 seconds while playing
|
||||||
progressSaveTimerRef.current = setInterval(() => {
|
progressSaveTimerRef.current = setInterval(() => {
|
||||||
const playerCurrentTime = playerRef.current?.getCurrentTime?.() || 0;
|
const isYoutube = activeVersion?.providerId === 'youtube';
|
||||||
const playerDuration = playerRef.current?.getDuration?.() || 0;
|
const isBunny = activeVersion?.providerId === 'bunny';
|
||||||
|
|
||||||
|
const save = (playerCurrentTime: number, playerDuration: number) => {
|
||||||
if (playerCurrentTime > 0 && Math.abs(playerCurrentTime - lastSavedProgressRef.current) >= 2) {
|
if (playerCurrentTime > 0 && Math.abs(playerCurrentTime - lastSavedProgressRef.current) >= 2) {
|
||||||
// Save to API - use player duration directly
|
|
||||||
fetch(`/api/watch/${videoId}/progress`, {
|
fetch(`/api/watch/${videoId}/progress`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
@@ -599,9 +727,13 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
|||||||
versionId: activeVersionId,
|
versionId: activeVersionId,
|
||||||
}),
|
}),
|
||||||
}).catch((err) => console.error('Error saving watch progress:', err));
|
}).catch((err) => console.error('Error saving watch progress:', err));
|
||||||
|
|
||||||
lastSavedProgressRef.current = playerCurrentTime;
|
lastSavedProgressRef.current = playerCurrentTime;
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (playerRef.current?.getCurrentTime) {
|
||||||
|
save(playerRef.current.getCurrentTime(), playerRef.current.getDuration?.() || videoDuration);
|
||||||
|
}
|
||||||
}, 5000);
|
}, 5000);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
@@ -692,15 +824,16 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
|||||||
};
|
};
|
||||||
}, [video?.isAuthenticated, currentTime, videoDuration, activeVersionId, videoId]);
|
}, [video?.isAuthenticated, currentTime, videoDuration, activeVersionId, videoId]);
|
||||||
|
|
||||||
// Resume playback from saved position
|
|
||||||
const handleResumeFromSaved = useCallback(() => {
|
const handleResumeFromSaved = useCallback(() => {
|
||||||
if (savedProgress !== null && playerRef.current?.seekTo) {
|
if (savedProgress !== null && playerRef.current) {
|
||||||
|
if (playerRef.current.seekTo) {
|
||||||
playerRef.current.seekTo(savedProgress, true);
|
playerRef.current.seekTo(savedProgress, true);
|
||||||
|
}
|
||||||
setCurrentTime(savedProgress);
|
setCurrentTime(savedProgress);
|
||||||
setShowResumePrompt(false);
|
setShowResumePrompt(false);
|
||||||
setSavedProgress(null);
|
setSavedProgress(null);
|
||||||
}
|
}
|
||||||
}, [savedProgress]);
|
}, [savedProgress, activeVersion?.providerId]);
|
||||||
|
|
||||||
// Dismiss resume prompt
|
// Dismiss resume prompt
|
||||||
const handleDismissResume = useCallback(() => {
|
const handleDismissResume = useCallback(() => {
|
||||||
@@ -712,13 +845,15 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
|||||||
if (!isReady || !playerRef.current) return;
|
if (!isReady || !playerRef.current) return;
|
||||||
|
|
||||||
const interval = setInterval(() => {
|
const interval = setInterval(() => {
|
||||||
if (playerRef.current?.getCurrentTime && !isDragging) {
|
if (!isDragging && playerRef.current) {
|
||||||
|
if (playerRef.current.getCurrentTime) {
|
||||||
setCurrentTime(playerRef.current.getCurrentTime());
|
setCurrentTime(playerRef.current.getCurrentTime());
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}, 250);
|
}, 250);
|
||||||
|
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [isReady, isDragging]);
|
}, [isReady, isDragging, activeVersion?.providerId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
@@ -741,17 +876,21 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
|||||||
break;
|
break;
|
||||||
case 'ArrowLeft':
|
case 'ArrowLeft':
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (playerRef.current?.seekTo) {
|
if (playerRef.current) {
|
||||||
const newTime = Math.max(0, currentTime - 5);
|
const newTime = Math.max(0, currentTime - 5);
|
||||||
|
if (playerRef.current.seekTo) {
|
||||||
playerRef.current.seekTo(newTime, true);
|
playerRef.current.seekTo(newTime, true);
|
||||||
|
}
|
||||||
setCurrentTime(newTime);
|
setCurrentTime(newTime);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case 'ArrowRight':
|
case 'ArrowRight':
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (playerRef.current?.seekTo) {
|
if (playerRef.current) {
|
||||||
const newTime = Math.min(duration, currentTime + 5);
|
const newTime = Math.min(duration, currentTime + 5);
|
||||||
|
if (playerRef.current.seekTo) {
|
||||||
playerRef.current.seekTo(newTime, true);
|
playerRef.current.seekTo(newTime, true);
|
||||||
|
}
|
||||||
setCurrentTime(newTime);
|
setCurrentTime(newTime);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
@@ -1820,23 +1959,90 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleCreateVersion = async () => {
|
const handleCreateVersion = async () => {
|
||||||
if (!newVersionSource || !propProjectId) return;
|
if (!propProjectId) return;
|
||||||
setIsCreatingVersion(true);
|
setIsCreatingVersion(true);
|
||||||
|
setNewVersionUploadStatus('');
|
||||||
|
setNewVersionUploadProgress(0);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
let finalVideoUrl = '';
|
||||||
|
let finalProviderId = '';
|
||||||
|
let finalProviderVideoId = '';
|
||||||
|
let finalThumbnailUrl: string | null = null;
|
||||||
|
let finalDuration: number | null = null;
|
||||||
|
|
||||||
|
if (newVersionMode === 'url') {
|
||||||
|
if (!newVersionSource) throw new Error('Invalid URL');
|
||||||
const meta = await fetchVideoMetadata(newVersionSource);
|
const meta = await fetchVideoMetadata(newVersionSource);
|
||||||
const thumbnailUrl = getThumbnailUrl(newVersionSource, 'large');
|
finalVideoUrl = newVersionSource.originalUrl;
|
||||||
|
finalProviderId = newVersionSource.providerId;
|
||||||
|
finalProviderVideoId = newVersionSource.videoId;
|
||||||
|
finalThumbnailUrl = getThumbnailUrl(newVersionSource, 'large');
|
||||||
|
finalDuration = meta?.duration || null;
|
||||||
|
} else {
|
||||||
|
if (!newVersionFile) throw new Error('No file selected');
|
||||||
|
let title = newVersionFile.name;
|
||||||
|
if (newVersionLabel.trim()) {
|
||||||
|
title = newVersionLabel.trim();
|
||||||
|
} else {
|
||||||
|
title = title.replace(/\.[^/.]+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
setNewVersionUploadStatus('Initializing upload...');
|
||||||
|
const initRes = await fetch(`/api/projects/${propProjectId}/videos/bunny-init`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ title })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!initRes.ok) throw new Error('Failed to initialize upload');
|
||||||
|
const { data: { videoId, libraryId, signature, expirationTime } } = await initRes.json();
|
||||||
|
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
setNewVersionUploadStatus('Uploading video...');
|
||||||
|
const upload = new tus.Upload(newVersionFile, {
|
||||||
|
endpoint: 'https://video.bunnycdn.com/tusupload',
|
||||||
|
retryDelays: [0, 3000, 5000, 10000, 20000],
|
||||||
|
headers: {
|
||||||
|
AuthorizationSignature: signature,
|
||||||
|
AuthorizationExpire: expirationTime.toString(),
|
||||||
|
VideoId: videoId,
|
||||||
|
LibraryId: libraryId,
|
||||||
|
},
|
||||||
|
metadata: {
|
||||||
|
filetype: newVersionFile.type,
|
||||||
|
title: title,
|
||||||
|
},
|
||||||
|
onError: (error) => reject(new Error('Upload failed: ' + error.message)),
|
||||||
|
onProgress: (bytesUploaded, bytesTotal) => {
|
||||||
|
const percentage = ((bytesUploaded / bytesTotal) * 100).toFixed(1);
|
||||||
|
setNewVersionUploadProgress(Number(percentage));
|
||||||
|
setNewVersionUploadStatus(`Uploading... ${percentage}%`);
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
setNewVersionUploadStatus('Processing video...');
|
||||||
|
resolve(true);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
upload.start();
|
||||||
|
});
|
||||||
|
|
||||||
|
finalVideoUrl = `https://iframe.mediadelivery.net/embed/${libraryId}/${videoId}`;
|
||||||
|
finalProviderId = 'bunny';
|
||||||
|
finalProviderVideoId = videoId;
|
||||||
|
finalThumbnailUrl = `https://vz-965f4f4a-fc1.b-cdn.net/${videoId}/thumbnail.jpg`;
|
||||||
|
}
|
||||||
|
|
||||||
const res = await fetch(`/api/projects/${propProjectId}/videos/${videoId}/versions`, {
|
const res = await fetch(`/api/projects/${propProjectId}/videos/${videoId}/versions`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
videoUrl: newVersionSource.originalUrl,
|
videoUrl: finalVideoUrl,
|
||||||
providerId: newVersionSource.providerId,
|
providerId: finalProviderId,
|
||||||
providerVideoId: newVersionSource.videoId,
|
providerVideoId: finalProviderVideoId,
|
||||||
versionLabel: newVersionLabel.trim() || null,
|
versionLabel: newVersionLabel.trim() || null,
|
||||||
thumbnailUrl,
|
thumbnailUrl: finalThumbnailUrl,
|
||||||
duration: meta?.duration || null,
|
duration: finalDuration,
|
||||||
setActive: true,
|
setActive: true,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
@@ -1860,9 +2066,13 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
|||||||
setNewVersionUrl('');
|
setNewVersionUrl('');
|
||||||
setNewVersionLabel('');
|
setNewVersionLabel('');
|
||||||
setNewVersionSource(null);
|
setNewVersionSource(null);
|
||||||
|
setNewVersionFile(null);
|
||||||
|
setNewVersionUploadStatus('');
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to create version:', err);
|
const errorObj = err as Error;
|
||||||
|
console.error('Failed to create version:', errorObj);
|
||||||
|
toast.error(errorObj.message || 'Failed to create version');
|
||||||
} finally {
|
} finally {
|
||||||
setIsCreatingVersion(false);
|
setIsCreatingVersion(false);
|
||||||
}
|
}
|
||||||
@@ -2162,6 +2372,14 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
|||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="space-y-4 mt-2">
|
<div className="space-y-4 mt-2">
|
||||||
|
<Tabs value={newVersionMode} onValueChange={(v) => setNewVersionMode(v as 'url' | 'file')} className="mb-2">
|
||||||
|
<TabsList className="grid w-full grid-cols-2">
|
||||||
|
<TabsTrigger value="url">Link URL</TabsTrigger>
|
||||||
|
<TabsTrigger value="file">Upload File</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
{newVersionMode === 'url' ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Video URL</Label>
|
<Label>Video URL</Label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
@@ -2189,6 +2407,43 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="versionFile">Video File</Label>
|
||||||
|
<div className="flex items-center justify-center w-full">
|
||||||
|
<label htmlFor="versionFile" className={`flex flex-col items-center justify-center w-full h-32 border-2 border-dashed rounded-lg cursor-pointer bg-muted/30 hover:bg-muted/50 transition-colors ${newVersionFile ? 'border-primary' : 'border-border'}`}>
|
||||||
|
<div className="flex flex-col items-center justify-center pt-5 pb-6">
|
||||||
|
{newVersionFile ? (
|
||||||
|
<>
|
||||||
|
<FileVideo className="w-8 h-8 mb-2 text-primary" />
|
||||||
|
<p className="mb-1 text-sm text-foreground font-medium truncate max-w-[200px]">{newVersionFile.name}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{(newVersionFile.size / (1024 * 1024)).toFixed(2)} MB
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<UploadCloud className="w-8 h-8 mb-2 text-muted-foreground" />
|
||||||
|
<p className="mb-1 text-sm text-muted-foreground">
|
||||||
|
<span className="font-semibold">Click to upload</span> or drag and drop
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">MP4, WebM, or OGG</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<input id="versionFile" type="file" accept="video/*" className="hidden" onChange={(e) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (file && file.type.startsWith('video/')) {
|
||||||
|
setNewVersionFile(file);
|
||||||
|
} else {
|
||||||
|
toast.error('Please select a valid video file');
|
||||||
|
}
|
||||||
|
}} disabled={isCreatingVersion} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Version Label (optional)</Label>
|
<Label>Version Label (optional)</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -2198,9 +2453,21 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
|||||||
disabled={isCreatingVersion}
|
disabled={isCreatingVersion}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{newVersionUploadStatus && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-sm text-muted-foreground">{newVersionUploadStatus}</p>
|
||||||
|
{newVersionUploadProgress > 0 && newVersionUploadProgress < 100 && (
|
||||||
|
<div className="w-full bg-secondary rounded-full h-2">
|
||||||
|
<div className="bg-primary h-2 rounded-full transition-all" style={{ width: `${newVersionUploadProgress}%` }}></div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
onClick={handleCreateVersion}
|
onClick={handleCreateVersion}
|
||||||
disabled={!newVersionSource || isCreatingVersion}
|
disabled={(newVersionMode === 'url' && !newVersionSource) || (newVersionMode === 'file' && !newVersionFile) || isCreatingVersion}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
>
|
>
|
||||||
{isCreatingVersion && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
|
{isCreatingVersion && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
|
||||||
@@ -2267,7 +2534,9 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
|||||||
key={activeVersionId}
|
key={activeVersionId}
|
||||||
ref={iframeRef}
|
ref={iframeRef}
|
||||||
src={embedUrl}
|
src={embedUrl}
|
||||||
className="absolute inset-0 w-full h-full pointer-events-none"
|
width="100%"
|
||||||
|
height="100%"
|
||||||
|
className="absolute inset-0 w-full h-full border-0"
|
||||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||||
allowFullScreen
|
allowFullScreen
|
||||||
/>
|
/>
|
||||||
@@ -2280,11 +2549,11 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
|||||||
: 'opacity-100'
|
: 'opacity-100'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="w-16 h-16 rounded-full bg-black/60 flex items-center justify-center">
|
<div className="w-16 h-16 rounded-full bg-black/60 flex items-center justify-center relative z-10">
|
||||||
{isPlaying ? (
|
{isPlaying ? (
|
||||||
<Pause className="h-8 w-8 text-white" />
|
<Pause className="h-8 w-8 text-white relative right-[-1px]" />
|
||||||
) : (
|
) : (
|
||||||
<Play className="h-8 w-8 text-white ml-1" />
|
<Play className="h-8 w-8 text-white relative left-[2px]" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import type { VideoProvider, VideoMetadata, EmbedOptions, ThumbnailSize } from './types';
|
||||||
|
import { getCachedMetadata, setCachedMetadata } from './metadata-cache';
|
||||||
|
|
||||||
|
// Bunny Stream URL patterns
|
||||||
|
// e.g. https://iframe.mediadelivery.net/play/libraryId/videoId
|
||||||
|
// e.g. https://video.bunnycdn.com/play/libraryId/videoId
|
||||||
|
const BUNNY_PATTERNS = [
|
||||||
|
/(?:iframe\.mediadelivery\.net|video\.bunnycdn\.com)\/(?:play|embed)\/[0-9]+\/([a-zA-Z0-9_-]+)/,
|
||||||
|
];
|
||||||
|
|
||||||
|
export const bunnyProvider: VideoProvider = {
|
||||||
|
id: 'bunny',
|
||||||
|
name: 'Bunny Stream',
|
||||||
|
icon: 'Video',
|
||||||
|
|
||||||
|
canHandle(url: string): boolean {
|
||||||
|
return BUNNY_PATTERNS.some(pattern => pattern.test(url));
|
||||||
|
},
|
||||||
|
|
||||||
|
extractVideoId(url: string): string | null {
|
||||||
|
for (const pattern of BUNNY_PATTERNS) {
|
||||||
|
const match = url.match(pattern);
|
||||||
|
if (match && match[1]) {
|
||||||
|
return match[1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
|
||||||
|
getEmbedUrl(videoId: string, options: EmbedOptions = {}): string {
|
||||||
|
// Requires library ID, but our current DB only stores `videoId` for standard providers
|
||||||
|
// For Bunny, we typically store the full embed URL as `originalUrl`
|
||||||
|
// So if this function is called, we try to extract it from the environment or default
|
||||||
|
const libraryId = process.env.NEXT_PUBLIC_BUNNY_STREAM_LIBRARY_ID || process.env.BUNNY_STREAM_LIBRARY_ID || '0';
|
||||||
|
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
|
||||||
|
if (options.autoplay) params.set('autoplay', 'true');
|
||||||
|
if (options.loop) params.set('loop', 'true');
|
||||||
|
if (options.muted) params.set('muted', 'true');
|
||||||
|
|
||||||
|
// We can use video.bunnycdn.com or iframe.mediadelivery.net
|
||||||
|
return `https://iframe.mediadelivery.net/embed/${libraryId}/${videoId}?${params.toString()}`;
|
||||||
|
},
|
||||||
|
|
||||||
|
getThumbnailUrl(videoId: string, size: ThumbnailSize = 'medium'): string {
|
||||||
|
const libraryId = process.env.NEXT_PUBLIC_BUNNY_STREAM_LIBRARY_ID || process.env.BUNNY_STREAM_LIBRARY_ID || '0';
|
||||||
|
// Bunny stream thumbnails: https://vz-uuid.b-cdn.net/{videoId}/thumbnail.jpg
|
||||||
|
// Since we don't have the b-cdn pull zone readily available in pure abstract,
|
||||||
|
// we should rely on fetching metadata for actual thumbnails, OR construct via API
|
||||||
|
// Actually, Bunny's public thumbnail format is:
|
||||||
|
return `https://vz-965f4f4a-fc1.b-cdn.net/${videoId}/thumbnail.jpg`; // Fallback approximate
|
||||||
|
},
|
||||||
|
|
||||||
|
async getMetadata(videoId: string): Promise<VideoMetadata> {
|
||||||
|
const cacheKey = `bunny:${videoId}`;
|
||||||
|
const cached = getCachedMetadata(cacheKey);
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
// We can't fetch title/duration via public API without an API key,
|
||||||
|
// so we return basic metadata. When videos are uploaded via our server,
|
||||||
|
// the title will be passed during creation.
|
||||||
|
const fallback: VideoMetadata = {
|
||||||
|
title: 'Bunny Video',
|
||||||
|
thumbnailUrl: this.getThumbnailUrl(videoId, 'large'),
|
||||||
|
};
|
||||||
|
|
||||||
|
setCachedMetadata(cacheKey, fallback);
|
||||||
|
return fallback;
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { youtubeProvider } from './youtube';
|
import { youtubeProvider } from './youtube';
|
||||||
import { directProvider } from './direct';
|
import { directProvider } from './direct';
|
||||||
|
import { bunnyProvider } from './bunny';
|
||||||
import type { VideoProvider, VideoSource, VideoMetadata, VideoProviderType } from './types';
|
import type { VideoProvider, VideoSource, VideoMetadata, VideoProviderType } from './types';
|
||||||
|
|
||||||
// Export types
|
// Export types
|
||||||
@@ -11,6 +12,7 @@ export * from './types';
|
|||||||
const providers: VideoProvider[] = [
|
const providers: VideoProvider[] = [
|
||||||
youtubeProvider,
|
youtubeProvider,
|
||||||
directProvider,
|
directProvider,
|
||||||
|
bunnyProvider,
|
||||||
];
|
];
|
||||||
|
|
||||||
// Provider lookup map for quick access
|
// Provider lookup map for quick access
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export interface EmbedOptions {
|
|||||||
export type ThumbnailSize = 'small' | 'medium' | 'large' | 'maxres';
|
export type ThumbnailSize = 'small' | 'medium' | 'large' | 'maxres';
|
||||||
|
|
||||||
// Supported provider types - extend as we add more
|
// Supported provider types - extend as we add more
|
||||||
export type VideoProviderType = 'youtube' | 'direct';
|
export type VideoProviderType = 'youtube' | 'direct' | 'bunny';
|
||||||
|
|
||||||
// Video source stored in database
|
// Video source stored in database
|
||||||
export interface VideoSource {
|
export interface VideoSource {
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ const nextConfig: NextConfig = {
|
|||||||
{ protocol: 'https', hostname: 'img.youtube.com' },
|
{ protocol: 'https', hostname: 'img.youtube.com' },
|
||||||
{ protocol: 'https', hostname: 'i.ytimg.com' },
|
{ protocol: 'https', hostname: 'i.ytimg.com' },
|
||||||
{ protocol: 'https', hostname: 'images.unsplash.com' },
|
{ protocol: 'https', hostname: 'images.unsplash.com' },
|
||||||
|
{ protocol: 'https', hostname: 'vz-thumbnail.b-cdn.net' },
|
||||||
|
{ protocol: 'https', hostname: 'vz-965f4f4a-fc1.b-cdn.net' },
|
||||||
],
|
],
|
||||||
formats: ['image/avif', 'image/webp'],
|
formats: ['image/avif', 'image/webp'],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -35,6 +35,7 @@
|
|||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"nodemailer": "^8.0.1",
|
"nodemailer": "^8.0.1",
|
||||||
"pg": "^8.18.0",
|
"pg": "^8.18.0",
|
||||||
|
"player.js": "^0.1.0",
|
||||||
"prisma": "^7.3.0",
|
"prisma": "^7.3.0",
|
||||||
"radix-ui": "^1.4.3",
|
"radix-ui": "^1.4.3",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
@@ -42,6 +43,7 @@
|
|||||||
"react-window": "^2.2.7",
|
"react-window": "^2.2.7",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
|
"tus-js-client": "^4.3.1",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user