mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 09:36:08 +00:00
feat: enable S3 video uploads and update related configurations
- Added support for self-hosted S3 video uploads with new environment variables: OPENFRAME_ENABLE_S3_VIDEO_UPLOADS and OPENFRAME_MAX_VIDEO_UPLOAD_BYTES. - Updated .env.example and .env.docker.example to reflect new configuration options. - Enhanced Content Security Policy to include origins for S3-compatible storage. - Updated dependencies for AWS SDK to support new features. - Refactored upload logic to accommodate both Bunny and S3 upload providers. - Updated documentation to clarify the usage of direct uploads and S3 configurations. - Closes #11
This commit is contained in:
@@ -51,6 +51,15 @@ interface UseCommentActionsParams extends CommentActionsConfig {
|
||||
fetchAssets: () => Promise<void>;
|
||||
}
|
||||
|
||||
function getAudioUploadFilename(blob: Blob): string {
|
||||
const mime = blob.type.split(';')[0].trim().toLowerCase();
|
||||
if (mime === 'audio/mp4') return 'recording.m4a';
|
||||
if (mime === 'audio/ogg' || mime === 'audio/opus') return 'recording.ogg';
|
||||
if (mime === 'audio/mpeg') return 'recording.mp3';
|
||||
if (mime === 'audio/wav') return 'recording.wav';
|
||||
return 'recording.webm';
|
||||
}
|
||||
|
||||
export function useCommentActions({
|
||||
videoId,
|
||||
setVideo,
|
||||
@@ -430,7 +439,8 @@ export function useCommentActions({
|
||||
};
|
||||
|
||||
mediaRecorder.onstop = () => {
|
||||
const blob = new Blob(audioChunksRef.current, { type: 'audio/webm' });
|
||||
const recordedMime = mediaRecorder.mimeType || 'audio/webm';
|
||||
const blob = new Blob(audioChunksRef.current, { type: recordedMime });
|
||||
setAudioBlob(blob);
|
||||
stream.getTracks().forEach((track) => track.stop());
|
||||
if (recordingTimerRef.current) {
|
||||
@@ -472,7 +482,8 @@ export function useCommentActions({
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('audio', audioBlob, 'recording.webm');
|
||||
const uploadFilename = getAudioUploadFilename(audioBlob);
|
||||
formData.append('audio', audioBlob, uploadFilename);
|
||||
formData.append('videoId', videoId);
|
||||
const uploadToken = await getGuestUploadToken('audio');
|
||||
if (uploadToken) formData.append('uploadToken', uploadToken);
|
||||
@@ -514,7 +525,7 @@ export function useCommentActions({
|
||||
let voiceData: { url: string; duration: number } | undefined;
|
||||
if (audioBlob) {
|
||||
const formData = new FormData();
|
||||
formData.append('audio', audioBlob, 'recording.webm');
|
||||
formData.append('audio', audioBlob, getAudioUploadFilename(audioBlob));
|
||||
formData.append('videoId', videoId);
|
||||
const uploadToken = await getGuestUploadToken('audio');
|
||||
if (uploadToken) formData.append('uploadToken', uploadToken);
|
||||
@@ -829,7 +840,8 @@ export function useCommentActions({
|
||||
if (e.data.size > 0) replyAudioChunksRef.current.push(e.data);
|
||||
};
|
||||
mediaRecorder.onstop = () => {
|
||||
const blob = new Blob(replyAudioChunksRef.current, { type: 'audio/webm' });
|
||||
const recordedMime = mediaRecorder.mimeType || 'audio/webm';
|
||||
const blob = new Blob(replyAudioChunksRef.current, { type: recordedMime });
|
||||
setReplyAudioBlob(blob);
|
||||
stream.getTracks().forEach((track) => track.stop());
|
||||
if (replyRecordingTimerRef.current) {
|
||||
@@ -870,7 +882,7 @@ export function useCommentActions({
|
||||
setIsUploadingReplyAudio(true);
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('audio', replyAudioBlob, 'recording.webm');
|
||||
formData.append('audio', replyAudioBlob, getAudioUploadFilename(replyAudioBlob));
|
||||
formData.append('videoId', videoId);
|
||||
const uploadToken = await getGuestUploadToken('audio');
|
||||
if (uploadToken) formData.append('uploadToken', uploadToken);
|
||||
@@ -912,7 +924,7 @@ export function useCommentActions({
|
||||
|
||||
if (replyAudioBlob) {
|
||||
const formData = new FormData();
|
||||
formData.append('audio', replyAudioBlob, 'recording.webm');
|
||||
formData.append('audio', replyAudioBlob, getAudioUploadFilename(replyAudioBlob));
|
||||
formData.append('videoId', videoId);
|
||||
const uploadToken = await getGuestUploadToken('audio');
|
||||
if (uploadToken) formData.append('uploadToken', uploadToken);
|
||||
|
||||
@@ -67,7 +67,11 @@ export function useDownloadActions({ activeVersion, video }: UseDownloadActionsP
|
||||
toast.error('Download is disabled for this shared link');
|
||||
return;
|
||||
}
|
||||
if (activeVersion.providerId !== 'bunny' && activeVersion.providerId !== 'direct') {
|
||||
if (
|
||||
activeVersion.providerId !== 'bunny' &&
|
||||
activeVersion.providerId !== 'direct' &&
|
||||
activeVersion.providerId !== 'r2'
|
||||
) {
|
||||
toast.error('This video source does not support direct download');
|
||||
return;
|
||||
}
|
||||
@@ -97,6 +101,11 @@ export function useDownloadActions({ activeVersion, video }: UseDownloadActionsP
|
||||
}
|
||||
|
||||
downloadUrl = `/api/versions/${activeVersion.id}/download?source=${preference}`;
|
||||
} else if (activeVersion.providerId === 'r2') {
|
||||
if (!activeVersion.originalUrl.startsWith('/api/upload/video/')) {
|
||||
throw new Error('Direct download URL is not allowed');
|
||||
}
|
||||
downloadUrl = activeVersion.originalUrl;
|
||||
} else {
|
||||
downloadUrl = getSafeDirectDownloadUrl(activeVersion.originalUrl);
|
||||
if (!downloadUrl) {
|
||||
@@ -113,8 +122,9 @@ export function useDownloadActions({ activeVersion, video }: UseDownloadActionsP
|
||||
const baseName = sanitizeDownloadFileName(`${video.title} ${versionLabel}`) || 'video';
|
||||
const a = document.createElement('a');
|
||||
a.href = downloadUrl;
|
||||
if (activeVersion.providerId === 'direct') {
|
||||
a.download = `${baseName}.mp4`;
|
||||
if (activeVersion.providerId === 'direct' || activeVersion.providerId === 'r2') {
|
||||
const ext = activeVersion.originalUrl.split('.').pop()?.toLowerCase() || 'mp4';
|
||||
a.download = `${baseName}.${ext}`;
|
||||
}
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from '@/lib/video-providers';
|
||||
import type { VersionActionsConfig, VideoData } from '@/components/video-page/types';
|
||||
import { resolvePublicBunnyCdnHostname } from '@/lib/bunny-cdn';
|
||||
import { cleanupPendingR2VideoUpload, uploadVideoToR2 } from '@/lib/client/r2-video-upload';
|
||||
|
||||
interface UseVersionActionsParams extends VersionActionsConfig {
|
||||
setVideo: Dispatch<SetStateAction<VideoData | null>>;
|
||||
@@ -21,7 +22,8 @@ interface UseVersionActionsParams extends VersionActionsConfig {
|
||||
export function useVersionActions({
|
||||
projectId,
|
||||
videoId,
|
||||
bunnyUploadsEnabled = true,
|
||||
directUploadsEnabled = false,
|
||||
directUploadProvider = 'bunny',
|
||||
setVideo,
|
||||
activeVersionId,
|
||||
setActiveVersionId,
|
||||
@@ -58,13 +60,111 @@ export function useVersionActions({
|
||||
}
|
||||
};
|
||||
|
||||
const uploadNewVersionFile = async (file: File, title: string) => {
|
||||
if (!projectId) throw new Error('Missing project');
|
||||
|
||||
if (directUploadProvider === 'r2') {
|
||||
setNewVersionUploadStatus('Initializing upload...');
|
||||
const uploaded = await uploadVideoToR2(projectId, file, {
|
||||
onProgress: (progress) => {
|
||||
setNewVersionUploadProgress(progress);
|
||||
setNewVersionUploadStatus(`Uploading... ${progress}%`);
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
finalVideoUrl: uploaded.proxyUrl,
|
||||
finalProviderId: 'r2',
|
||||
finalProviderVideoId: uploaded.objectKey,
|
||||
finalThumbnailUrl: uploaded.thumbnailUrl || '/placeholder-video-thumbnail.png',
|
||||
finalDuration: uploaded.duration,
|
||||
uploadToken: uploaded.uploadToken,
|
||||
objectKey: uploaded.objectKey,
|
||||
reservationId: uploaded.reservationId,
|
||||
pendingCleanup: {
|
||||
objectKey: uploaded.objectKey,
|
||||
uploadToken: uploaded.uploadToken,
|
||||
reservationId: uploaded.reservationId,
|
||||
thumbnailObjectKey: uploaded.thumbnailObjectKey,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
setNewVersionUploadStatus('Initializing upload...');
|
||||
const initRes = await fetch(`/api/projects/${projectId}/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: bunnyVideoId, libraryId, signature, expirationTime, uploadToken },
|
||||
} = await initRes.json();
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
setNewVersionUploadStatus('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: bunnyVideoId,
|
||||
LibraryId: libraryId,
|
||||
},
|
||||
metadata: {
|
||||
filetype: file.type,
|
||||
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();
|
||||
});
|
||||
|
||||
return {
|
||||
finalVideoUrl: `https://iframe.mediadelivery.net/embed/${libraryId}/${bunnyVideoId}`,
|
||||
finalProviderId: 'bunny',
|
||||
finalProviderVideoId: bunnyVideoId,
|
||||
finalThumbnailUrl: bunnyCdnHostname
|
||||
? `https://${bunnyCdnHostname}/${bunnyVideoId}/thumbnail.jpg`
|
||||
: null,
|
||||
finalDuration: null as number | null,
|
||||
uploadToken,
|
||||
objectKey: null as string | null,
|
||||
reservationId: null as string | null,
|
||||
pendingCleanup: {
|
||||
bunnyVideoId,
|
||||
uploadToken,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const handleCreateVersion = async () => {
|
||||
if (!projectId) return;
|
||||
setIsCreatingVersion(true);
|
||||
setNewVersionUploadStatus('');
|
||||
setNewVersionUploadProgress(0);
|
||||
let uploadedBunnyVideoId: string | null = null;
|
||||
let uploadedBunnyUploadToken: string | null = null;
|
||||
let pendingCleanup:
|
||||
| {
|
||||
objectKey: string;
|
||||
uploadToken: string;
|
||||
reservationId: string | null;
|
||||
}
|
||||
| {
|
||||
bunnyVideoId: string;
|
||||
uploadToken: string;
|
||||
}
|
||||
| null = null;
|
||||
|
||||
try {
|
||||
let finalVideoUrl = '';
|
||||
@@ -72,6 +172,9 @@ export function useVersionActions({
|
||||
let finalProviderVideoId = '';
|
||||
let finalThumbnailUrl: string | null = null;
|
||||
let finalDuration: number | null = null;
|
||||
let uploadToken: string | null = null;
|
||||
let objectKey: string | null = null;
|
||||
let reservationId: string | null = null;
|
||||
|
||||
if (newVersionMode === 'url') {
|
||||
if (!newVersionSource) throw new Error('Invalid URL');
|
||||
@@ -82,7 +185,7 @@ export function useVersionActions({
|
||||
finalThumbnailUrl = getThumbnailUrl(newVersionSource, 'large');
|
||||
finalDuration = meta?.duration || null;
|
||||
} else {
|
||||
if (!bunnyUploadsEnabled) throw new Error('Direct uploads are disabled by this host');
|
||||
if (!directUploadsEnabled) throw new Error('Direct uploads are disabled by this host');
|
||||
if (!newVersionFile) throw new Error('No file selected');
|
||||
let title = newVersionFile.name;
|
||||
if (newVersionLabel.trim()) {
|
||||
@@ -91,55 +194,16 @@ export function useVersionActions({
|
||||
title = title.replace(/\.[^/.]+$/, '');
|
||||
}
|
||||
|
||||
setNewVersionUploadStatus('Initializing upload...');
|
||||
const initRes = await fetch(`/api/projects/${projectId}/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: bunnyVideoId, libraryId, signature, expirationTime, uploadToken },
|
||||
} = await initRes.json();
|
||||
uploadedBunnyVideoId = bunnyVideoId;
|
||||
uploadedBunnyUploadToken = uploadToken;
|
||||
|
||||
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: bunnyVideoId,
|
||||
LibraryId: libraryId,
|
||||
},
|
||||
metadata: {
|
||||
filetype: newVersionFile.type,
|
||||
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}/${bunnyVideoId}`;
|
||||
finalProviderId = 'bunny';
|
||||
finalProviderVideoId = bunnyVideoId;
|
||||
finalThumbnailUrl = bunnyCdnHostname
|
||||
? `https://${bunnyCdnHostname}/${bunnyVideoId}/thumbnail.jpg`
|
||||
: null;
|
||||
const uploaded = await uploadNewVersionFile(newVersionFile, title);
|
||||
finalVideoUrl = uploaded.finalVideoUrl;
|
||||
finalProviderId = uploaded.finalProviderId;
|
||||
finalProviderVideoId = uploaded.finalProviderVideoId;
|
||||
finalThumbnailUrl = uploaded.finalThumbnailUrl;
|
||||
finalDuration = uploaded.finalDuration;
|
||||
uploadToken = uploaded.uploadToken;
|
||||
objectKey = uploaded.objectKey;
|
||||
reservationId = uploaded.reservationId;
|
||||
pendingCleanup = uploaded.pendingCleanup;
|
||||
}
|
||||
|
||||
const res = await fetch(`/api/projects/${projectId}/videos/${videoId}/versions`, {
|
||||
@@ -149,7 +213,9 @@ export function useVersionActions({
|
||||
videoUrl: finalVideoUrl,
|
||||
providerId: finalProviderId,
|
||||
providerVideoId: finalProviderVideoId,
|
||||
uploadToken: uploadedBunnyUploadToken,
|
||||
uploadToken,
|
||||
objectKey,
|
||||
reservationId,
|
||||
versionLabel: newVersionLabel.trim() || null,
|
||||
thumbnailUrl: finalThumbnailUrl,
|
||||
duration: finalDuration,
|
||||
@@ -180,24 +246,30 @@ export function useVersionActions({
|
||||
setNewVersionSource(null);
|
||||
setNewVersionFile(null);
|
||||
setNewVersionUploadStatus('');
|
||||
pendingCleanup = null;
|
||||
} catch (err) {
|
||||
const errorObj = err as Error;
|
||||
if (uploadedBunnyVideoId && uploadedBunnyUploadToken) {
|
||||
await fetch(`/api/projects/${projectId}/videos/bunny-init`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
videoId: uploadedBunnyVideoId,
|
||||
uploadToken: uploadedBunnyUploadToken,
|
||||
}),
|
||||
}).catch((cleanupError) => {
|
||||
console.error('Failed to cleanup pending Bunny version upload:', cleanupError);
|
||||
});
|
||||
if (pendingCleanup && projectId) {
|
||||
if ('objectKey' in pendingCleanup) {
|
||||
await cleanupPendingR2VideoUpload(projectId, pendingCleanup);
|
||||
} else {
|
||||
await fetch(`/api/projects/${projectId}/videos/bunny-init`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
videoId: pendingCleanup.bunnyVideoId,
|
||||
uploadToken: pendingCleanup.uploadToken,
|
||||
}),
|
||||
}).catch((cleanupError) => {
|
||||
console.error('Failed to cleanup pending Bunny version upload:', cleanupError);
|
||||
});
|
||||
}
|
||||
}
|
||||
console.error('Failed to create version:', errorObj);
|
||||
toast.error(errorObj.message || 'Failed to create version');
|
||||
} finally {
|
||||
setIsCreatingVersion(false);
|
||||
setNewVersionUploadProgress(0);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -209,34 +281,28 @@ export function useVersionActions({
|
||||
`/api/projects/${projectId}/videos/${videoId}/versions/${versionToDelete}`,
|
||||
{ method: 'DELETE' }
|
||||
);
|
||||
if (res.ok) {
|
||||
setVideo((prev) => {
|
||||
if (!prev) return prev;
|
||||
const remaining = prev.versions.filter((v) => v.id !== versionToDelete);
|
||||
return { ...prev, versions: remaining };
|
||||
});
|
||||
|
||||
if (activeVersionId === versionToDelete) {
|
||||
setVideo((prev) => {
|
||||
if (!prev) return prev;
|
||||
const remaining = prev.versions.filter((v) => v.id !== versionToDelete);
|
||||
if (remaining.length > 0) {
|
||||
setActiveVersionId(remaining[0].id);
|
||||
} else {
|
||||
setActiveVersionId(null);
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
}
|
||||
|
||||
setShowDeleteVersionDialog(false);
|
||||
setVersionToDelete(null);
|
||||
} else {
|
||||
const data = await res.json();
|
||||
toast.error(data.error || 'Failed to delete version');
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => null);
|
||||
throw new Error(data?.error || 'Failed to delete version');
|
||||
}
|
||||
} catch {
|
||||
toast.error('Failed to delete version');
|
||||
|
||||
setVideo((prev) => {
|
||||
if (!prev) return prev;
|
||||
const remaining = prev.versions.filter((v) => v.id !== versionToDelete);
|
||||
if (activeVersionId === versionToDelete && remaining.length > 0) {
|
||||
const nextActive = remaining.find((v) => v.isActive) ?? remaining[0];
|
||||
setActiveVersionId(nextActive.id);
|
||||
}
|
||||
return { ...prev, versions: remaining };
|
||||
});
|
||||
|
||||
setShowDeleteVersionDialog(false);
|
||||
setVersionToDelete(null);
|
||||
toast.success('Version deleted');
|
||||
} catch (err) {
|
||||
const errorObj = err as Error;
|
||||
console.error('Failed to delete version:', errorObj);
|
||||
toast.error(errorObj.message || 'Failed to delete version');
|
||||
} finally {
|
||||
setIsDeletingVersion(false);
|
||||
}
|
||||
@@ -259,10 +325,8 @@ export function useVersionActions({
|
||||
newVersionUploadStatus,
|
||||
handleNewVersionUrlChange,
|
||||
handleCreateVersion,
|
||||
|
||||
showDeleteVersionDialog,
|
||||
setShowDeleteVersionDialog,
|
||||
versionToDelete,
|
||||
setVersionToDelete,
|
||||
isDeletingVersion,
|
||||
handleDeleteVersion,
|
||||
|
||||
@@ -204,9 +204,10 @@ export function useVideoPlayer({
|
||||
if (!activeProviderId) return;
|
||||
const isYoutube = activeProviderId === 'youtube';
|
||||
const isBunny = activeProviderId === 'bunny';
|
||||
const isR2 = activeProviderId === 'r2';
|
||||
|
||||
if (isYoutube && !isApiLoaded) return;
|
||||
if (!isYoutube && !isBunny) return;
|
||||
if (!isYoutube && !isBunny && !isR2) return;
|
||||
|
||||
const currentVersionKey = `${activeProviderId ?? 'none'}:${activeVersionId ?? 'none'}`;
|
||||
const versionChanged = previousVersionKeyRef.current !== currentVersionKey;
|
||||
@@ -623,6 +624,132 @@ export function useVideoPlayer({
|
||||
videoEl.load();
|
||||
},
|
||||
};
|
||||
} else if (isR2) {
|
||||
const videoEl = videoRef.current;
|
||||
if (!videoEl) return;
|
||||
|
||||
let cachedDuration = 0;
|
||||
let destroyed = false;
|
||||
|
||||
const syncDuration = () => {
|
||||
if (Number.isFinite(videoEl.duration) && videoEl.duration > 0) {
|
||||
cachedDuration = videoEl.duration;
|
||||
setVideoDuration(videoEl.duration);
|
||||
}
|
||||
};
|
||||
|
||||
const saveProgress = () => {
|
||||
const current = videoEl.currentTime || 0;
|
||||
const duration =
|
||||
Number.isFinite(videoEl.duration) && videoEl.duration > 0
|
||||
? videoEl.duration
|
||||
: cachedDuration;
|
||||
scheduleWatchProgressSaveRef.current({
|
||||
progress: current,
|
||||
duration,
|
||||
immediate: true,
|
||||
force: true,
|
||||
});
|
||||
};
|
||||
|
||||
const onLoadedMetadata = () => {
|
||||
if (destroyed) return;
|
||||
setBunnyPlaybackState('none');
|
||||
if (videoEl.videoWidth > 0 && videoEl.videoHeight > 0) {
|
||||
setIsBunnyPortraitSource(videoEl.videoHeight > videoEl.videoWidth);
|
||||
}
|
||||
setIsReady(true);
|
||||
syncDuration();
|
||||
if (!videoEl.paused) {
|
||||
startBunnyFrameTracking();
|
||||
}
|
||||
};
|
||||
|
||||
const onPlay = () => {
|
||||
setIsPlaying(true);
|
||||
setBunnyPlaybackState('none');
|
||||
syncDuration();
|
||||
startBunnyFrameTracking();
|
||||
};
|
||||
|
||||
const onPause = () => {
|
||||
setIsPlaying(false);
|
||||
stopBunnyFrameTracking();
|
||||
saveProgress();
|
||||
};
|
||||
|
||||
const onEnded = () => {
|
||||
setIsPlaying(false);
|
||||
stopBunnyFrameTracking();
|
||||
saveProgress();
|
||||
};
|
||||
|
||||
const onTimeUpdate = () => {
|
||||
if (!isDraggingRef.current) {
|
||||
setCurrentTime(videoEl.currentTime || 0);
|
||||
}
|
||||
syncDuration();
|
||||
};
|
||||
|
||||
const onVideoError = () => {
|
||||
if (destroyed) return;
|
||||
setBunnyPlaybackState('error');
|
||||
};
|
||||
|
||||
videoEl.addEventListener('loadedmetadata', onLoadedMetadata);
|
||||
videoEl.addEventListener('play', onPlay);
|
||||
videoEl.addEventListener('pause', onPause);
|
||||
videoEl.addEventListener('ended', onEnded);
|
||||
videoEl.addEventListener('timeupdate', onTimeUpdate);
|
||||
videoEl.addEventListener('error', onVideoError);
|
||||
|
||||
const playbackSrc =
|
||||
embedUrl.startsWith('/') && typeof window !== 'undefined'
|
||||
? `${window.location.origin}${embedUrl}`
|
||||
: embedUrl;
|
||||
videoEl.src = playbackSrc;
|
||||
videoEl.load();
|
||||
|
||||
playerRef.current = {
|
||||
playVideo: () => {
|
||||
videoEl.play().catch((err) => console.error('Error playing video:', err));
|
||||
},
|
||||
pauseVideo: () => videoEl.pause(),
|
||||
seekTo: (time: number) => {
|
||||
videoEl.currentTime = time;
|
||||
},
|
||||
mute: () => {
|
||||
videoEl.muted = true;
|
||||
},
|
||||
unMute: () => {
|
||||
videoEl.muted = false;
|
||||
},
|
||||
isMuted: () => videoEl.muted,
|
||||
getCurrentTime: () => videoEl.currentTime || 0,
|
||||
getDuration: () => {
|
||||
if (Number.isFinite(videoEl.duration) && videoEl.duration > 0) return videoEl.duration;
|
||||
return cachedDuration;
|
||||
},
|
||||
getPlayerState: () =>
|
||||
videoEl.paused
|
||||
? (window.YT?.PlayerState?.PAUSED ?? 2)
|
||||
: (window.YT?.PlayerState?.PLAYING ?? 1),
|
||||
setPlaybackRate: (rate: number) => {
|
||||
videoEl.playbackRate = rate;
|
||||
},
|
||||
destroy: () => {
|
||||
destroyed = true;
|
||||
stopBunnyFrameTracking();
|
||||
videoEl.removeEventListener('loadedmetadata', onLoadedMetadata);
|
||||
videoEl.removeEventListener('play', onPlay);
|
||||
videoEl.removeEventListener('pause', onPause);
|
||||
videoEl.removeEventListener('ended', onEnded);
|
||||
videoEl.removeEventListener('timeupdate', onTimeUpdate);
|
||||
videoEl.removeEventListener('error', onVideoError);
|
||||
videoEl.removeAttribute('src');
|
||||
videoEl.load();
|
||||
},
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -633,7 +760,7 @@ export function useVideoPlayer({
|
||||
} else {
|
||||
window.onYouTubeIframeAPIReady = initPlayer;
|
||||
}
|
||||
} else if (isBunny) {
|
||||
} else if (isBunny || isR2) {
|
||||
initPlayer();
|
||||
}
|
||||
}, 100);
|
||||
|
||||
Reference in New Issue
Block a user