mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 09:36:08 +00:00
A comment held one image, and the paste handler took the first item off the clipboard and dropped the rest. Reviewing a cut usually means several screenshots about the same moment, which meant one comment per screenshot or one screenshot and a paragraph describing the others. Editing a comment could not attach anything at all: the edit box had no paste handler, no file picker and no way to remove what was already there. A comment now carries up to five images, in the composer, in a reply and in the editor. One paste stages every image on the clipboard, the file picker takes a multiple selection, and a drop lands on whichever editor is open. Over the cap the extras are refused out loud rather than dropped quietly. A single image still fills the width; several tile into a grid, and either opens full screen on click. The images move into their own table. `comments.imageUrl` stays and follows the first of them, so a reader that has not been updated keeps working, and the migration copies the existing attachments across so the new table is complete from the first read. Every path that resolves a URL back to a comment now asks the new table: R2 cleanup, the orphan sweep, the storage accounting and the reference checks that decide whether an object can be deleted. Left on the old column they would have treated images two through five as unreferenced and swept them. Detaching an image while editing only breaks the link. The file stays in R2 and in the assets pane, which is where it is deleted from and where its bytes are already billed.
1028 lines
36 KiB
TypeScript
1028 lines
36 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useRef, useCallback, useEffect, useMemo } from 'react';
|
|
import Hls from 'hls.js';
|
|
import { usePathname, useRouter } from 'next/navigation';
|
|
import { cn } from '@/lib/utils';
|
|
import { type AnnotationStroke, type AnnotationCanvasHandle } from '@/components/annotation-canvas';
|
|
import { PlayerCore } from '@/components/video-page/player-core';
|
|
import { VideoPageHeader } from '@/components/video-page/video-page-header';
|
|
import { ImagePreviewDialog } from '@/components/video-page/image-preview-dialog';
|
|
import { CompareVersionsDialog } from '@/components/video-page/compare-versions-dialog';
|
|
import { VideoPageLoading } from '@/components/video-page/video-page-loading';
|
|
import { VideoPageError } from '@/components/video-page/video-page-error';
|
|
import { GuestNameGate } from '@/components/video-page/guest-name-gate';
|
|
import { useCommentMedia } from '@/components/video-page/hooks/use-comment-media';
|
|
import { validateAnnotationStrokes } from '@/lib/validation';
|
|
import { resolveR2PlaybackUrl } from '@/lib/video-upload-validation';
|
|
import { useVersionActions } from '@/components/video-page/hooks/use-version-actions';
|
|
import { useWatchProgress } from '@/components/video-page/hooks/use-watch-progress';
|
|
import { useVideoPlayer } from '@/components/video-page/hooks/use-video-player';
|
|
import { useCommentActions } from '@/components/video-page/hooks/use-comment-actions';
|
|
import { useVideoPageData } from '@/components/video-page/hooks/use-video-page-data';
|
|
import { useCommentExport } from '@/components/video-page/hooks/use-comment-export';
|
|
import { useDownloadActions } from '@/components/video-page/hooks/use-download-actions';
|
|
import { useVersionDurationSync } from '@/components/video-page/hooks/use-version-duration-sync';
|
|
import { CommentComposer } from '@/components/video-page/comment-composer';
|
|
import { CommentsPane } from '@/components/video-page/comments-pane';
|
|
import { AssetsPane } from '@/components/video-page/assets-pane';
|
|
import { ApprovalRequestDialog } from '@/components/video-page/approval-request-dialog';
|
|
import { ApprovalRequestsPanel } from '@/components/video-page/approval-requests-panel';
|
|
import type {
|
|
CommentMarker,
|
|
PlayerAdapter,
|
|
VideoPageCommentsActions,
|
|
VideoPageCompareActions,
|
|
VideoPageComposerActions,
|
|
VideoPageHeaderActions,
|
|
} from '@/components/video-page/types';
|
|
import { useApprovals } from '@/components/video-page/hooks/use-approvals';
|
|
import { useVideoAssets } from '@/components/video-page/hooks/use-video-assets';
|
|
import { resolvePublicBunnyCdnHostname } from '@/lib/bunny-cdn';
|
|
|
|
function formatTime(seconds: number): string {
|
|
const totalSeconds = Math.floor(seconds);
|
|
const hrs = Math.floor(totalSeconds / 3600);
|
|
const mins = Math.floor((totalSeconds % 3600) / 60);
|
|
const secs = totalSeconds % 60;
|
|
if (hrs > 0) {
|
|
return `${hrs}:${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
|
}
|
|
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
|
}
|
|
|
|
function formatBunnyQualityLabel(
|
|
level: { height?: number; bitrate?: number },
|
|
index: number
|
|
): string {
|
|
if (typeof level.height === 'number' && level.height > 0) {
|
|
return `${level.height}p`;
|
|
}
|
|
if (typeof level.bitrate === 'number' && level.bitrate > 0) {
|
|
return `${Math.round(level.bitrate / 1000)} kbps`;
|
|
}
|
|
return `Level ${index + 1}`;
|
|
}
|
|
|
|
const SPEED_OPTIONS = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2];
|
|
|
|
export type VideoPageMode = 'dashboard' | 'watch';
|
|
|
|
interface VideoPageContentProps {
|
|
mode: VideoPageMode;
|
|
videoId: string;
|
|
projectId?: string;
|
|
directUploadsEnabled?: boolean;
|
|
directUploadProvider?: import('@/components/video-page/types').DirectUploadProvider;
|
|
}
|
|
|
|
export function VideoPageContent({
|
|
mode,
|
|
videoId,
|
|
projectId: propProjectId,
|
|
directUploadsEnabled = false,
|
|
directUploadProvider = 'bunny',
|
|
}: VideoPageContentProps) {
|
|
const iframeRef = useRef<HTMLIFrameElement>(null);
|
|
const videoRef = useRef<HTMLVideoElement>(null);
|
|
const bunnyViewportRef = useRef<HTMLDivElement>(null);
|
|
const hlsRef = useRef<Hls | null>(null);
|
|
const playerRef = useRef<YT.Player | PlayerAdapter | null>(null);
|
|
const timelineRef = useRef<HTMLDivElement>(null);
|
|
const progressRef = useRef<HTMLDivElement>(null);
|
|
const playheadRef = useRef<HTMLDivElement>(null);
|
|
const scrubReadoutRef = useRef<HTMLDivElement>(null);
|
|
const videoContainerRef = useRef<HTMLDivElement>(null);
|
|
const pathname = usePathname();
|
|
const scheduleWatchProgressSaveRef = useRef<
|
|
(input: { progress: number; duration?: number; immediate?: boolean; force?: boolean }) => void
|
|
>(() => {});
|
|
|
|
const {
|
|
playingVoiceId,
|
|
voiceProgress,
|
|
voiceCurrentTime,
|
|
voicePlaybackRate,
|
|
playVoice,
|
|
toggleVoiceSpeed,
|
|
} = useCommentMedia();
|
|
const [showResolved, setShowResolved] = useState(false);
|
|
const [activeSidePane, setActiveSidePane] = useState<'comments' | 'assets'>('comments');
|
|
const [highlightedAssetId, setHighlightedAssetId] = useState<string | null>(null);
|
|
|
|
const editAnnotationCanvasRef = useRef<AnnotationCanvasHandle>(null);
|
|
|
|
// Annotation state
|
|
const [isAnnotating, setIsAnnotating] = useState(false);
|
|
const [annotationStrokes, setAnnotationStrokes] = useState<AnnotationStroke[] | null>(null);
|
|
const [viewingAnnotation, setViewingAnnotation] = useState<AnnotationStroke[] | null>(null);
|
|
const annotationCanvasRef = useRef<AnnotationCanvasHandle>(null);
|
|
|
|
const [guestName, setGuestName] = useState(() => {
|
|
if (typeof window === 'undefined') return '';
|
|
return localStorage.getItem('openframe_guest_name') || '';
|
|
});
|
|
const [guestNameConfirmed, setGuestNameConfirmed] = useState(() => {
|
|
if (mode === 'dashboard') return true;
|
|
if (typeof window === 'undefined') return false;
|
|
return !!localStorage.getItem('openframe_guest_name');
|
|
});
|
|
|
|
// Compare dialog state
|
|
const [showCompareDialog, setShowCompareDialog] = useState(false);
|
|
const [selectedCompareVersions, setSelectedCompareVersions] = useState<Set<string>>(new Set());
|
|
const [showApprovalRequestDialog, setShowApprovalRequestDialog] = useState(false);
|
|
const [showApprovalsPanel, setShowApprovalsPanel] = useState(false);
|
|
const router = useRouter();
|
|
|
|
const {
|
|
video,
|
|
setVideo,
|
|
loading,
|
|
error,
|
|
activeVersionId,
|
|
setActiveVersionId,
|
|
availableTags,
|
|
selectedTagId,
|
|
setSelectedTagId,
|
|
projectId,
|
|
fetchVersionComments,
|
|
} = useVideoPageData({
|
|
mode,
|
|
videoId,
|
|
propProjectId,
|
|
});
|
|
|
|
const isGuest = video ? !video.isAuthenticated : false;
|
|
const canInitializePlayer = mode !== 'watch' || !isGuest || guestNameConfirmed;
|
|
const normalizedGuestName = guestName.trim();
|
|
const canUploadAssets = !!video?.canUploadAssets;
|
|
const canDownloadAssets = !!video?.canDownloadAssets;
|
|
|
|
const {
|
|
assets,
|
|
isLoadingAssets,
|
|
isCreatingAsset,
|
|
deletingAssetIds,
|
|
activeDownloadAssetId,
|
|
hasMoreAssets,
|
|
isLoadingMoreAssets,
|
|
fetchAssets,
|
|
loadMoreAssets,
|
|
createAsset,
|
|
deleteAsset,
|
|
downloadAsset,
|
|
getGuestUploadToken,
|
|
} = useVideoAssets({
|
|
videoId,
|
|
isAuthenticated: !!video?.isAuthenticated,
|
|
canUploadAssets,
|
|
canDownloadAssets,
|
|
guestName: normalizedGuestName,
|
|
});
|
|
|
|
const {
|
|
showVersionDialog,
|
|
setShowVersionDialog,
|
|
newVersionUrl,
|
|
newVersionLabel,
|
|
setNewVersionLabel,
|
|
newVersionSource,
|
|
newVersionUrlError,
|
|
isCreatingVersion,
|
|
newVersionMode,
|
|
setNewVersionMode,
|
|
newVersionFile,
|
|
setNewVersionFile,
|
|
newVersionUploadProgress,
|
|
newVersionUploadStatus,
|
|
handleNewVersionUrlChange,
|
|
handleCreateVersion,
|
|
showDeleteVersionDialog,
|
|
setShowDeleteVersionDialog,
|
|
setVersionToDelete,
|
|
isDeletingVersion,
|
|
handleDeleteVersion,
|
|
} = useVersionActions({
|
|
projectId: propProjectId,
|
|
videoId,
|
|
directUploadsEnabled,
|
|
directUploadProvider,
|
|
setVideo,
|
|
activeVersionId,
|
|
setActiveVersionId,
|
|
});
|
|
|
|
// Cursor idle detection: hide overlay when cursor idle for 3s while playing
|
|
// Memoize version selection handler to prevent recreating on each render
|
|
const handleVersionSelect = useCallback(
|
|
(versionId: string) => {
|
|
setActiveVersionId(versionId);
|
|
},
|
|
[setActiveVersionId]
|
|
);
|
|
|
|
// Memoize toggle show resolved handler
|
|
const handleToggleShowResolved = useCallback(() => {
|
|
setShowResolved((prev) => !prev);
|
|
}, []);
|
|
|
|
const handleAssetMentionClick = useCallback((assetId: string) => {
|
|
setActiveSidePane('assets');
|
|
setHighlightedAssetId(assetId);
|
|
}, []);
|
|
|
|
const { isExportingCsv, isExportingPdf, exportComments } = useCommentExport({
|
|
activeVersionId,
|
|
showResolved,
|
|
});
|
|
|
|
// Determine current user info for permission checks and comment display
|
|
const currentUserId = video?.currentUserId || null;
|
|
const currentUserName = video?.currentUserName || null;
|
|
const canResolveComments = !!video?.canResolveComments;
|
|
const canRequestApproval = !!video?.canRequestApproval;
|
|
const canShareVideo = !!video?.canShareVideo;
|
|
|
|
const {
|
|
requests: approvalRequests,
|
|
candidates: approvalCandidates,
|
|
isLoadingRequests: isLoadingApprovals,
|
|
isLoadingCandidates: isLoadingApprovalCandidates,
|
|
isSubmittingRequest: isSubmittingApprovalRequest,
|
|
isSubmittingDecision: isSubmittingApprovalDecision,
|
|
isCancelingRequest: isCancelingApprovalRequest,
|
|
activePendingRequest,
|
|
error: approvalError,
|
|
setError: setApprovalError,
|
|
fetchRequests: fetchApprovalRequests,
|
|
fetchCandidates: fetchApprovalCandidates,
|
|
createRequest: createApprovalRequest,
|
|
submitDecision: submitApprovalDecision,
|
|
cancelRequest: cancelApprovalRequest,
|
|
} = useApprovals({
|
|
projectId,
|
|
activeVersionId,
|
|
currentUserId,
|
|
});
|
|
|
|
// Memoize active version lookup to avoid recalculating on every render
|
|
const activeVersion = useMemo(() => {
|
|
return (
|
|
video?.versions?.find((v) => v.id === activeVersionId) ||
|
|
video?.versions?.find((v) => v.isActive) ||
|
|
video?.versions?.[0]
|
|
);
|
|
}, [video?.versions, activeVersionId]);
|
|
const activeProviderId = activeVersion?.providerId;
|
|
const activeVersionDuration = activeVersion?.duration;
|
|
const bunnyCdnHostname = useMemo(() => resolvePublicBunnyCdnHostname(), []);
|
|
const embedUrl = useMemo(() => {
|
|
if (!activeVersion) return '';
|
|
if (activeVersion.providerId === 'youtube') {
|
|
const base = `https://www.youtube.com/embed/${activeVersion.videoId}?enablejsapi=1&rel=0&modestbranding=1&controls=0&showinfo=0&iv_load_policy=3&disablekb=1`;
|
|
if (typeof window === 'undefined') return base;
|
|
const origin = window.location.origin;
|
|
return `${base}&origin=${encodeURIComponent(origin)}`;
|
|
}
|
|
if (activeVersion.providerId === 'bunny') {
|
|
if (!bunnyCdnHostname) return '';
|
|
return `https://${bunnyCdnHostname}/${activeVersion.videoId}/playlist.m3u8`;
|
|
}
|
|
if (activeVersion.providerId === 'r2') {
|
|
return resolveR2PlaybackUrl(activeVersion);
|
|
}
|
|
try {
|
|
const url = new URL(activeVersion.originalUrl);
|
|
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
|
return '';
|
|
}
|
|
return activeVersion.originalUrl;
|
|
} catch {
|
|
return '';
|
|
}
|
|
}, [activeVersion, bunnyCdnHostname]);
|
|
|
|
const {
|
|
isReady,
|
|
bunnyPlaybackState,
|
|
currentTime,
|
|
setCurrentTime,
|
|
videoDuration,
|
|
isPlaying,
|
|
isMuted,
|
|
isFrameMode,
|
|
frameStepLabel,
|
|
showScrubReadout,
|
|
playbackSpeed,
|
|
qualityOptions,
|
|
selectedQualityLevel,
|
|
isBunnyPortraitSource,
|
|
bunnyPortraitFrameWidth,
|
|
cursorIdle,
|
|
isFullscreenMode,
|
|
showComments,
|
|
isMobileCommentsOpen,
|
|
setShowComments,
|
|
setIsMobileCommentsOpen,
|
|
handleVideoMouseMove,
|
|
handleVideoMouseLeave,
|
|
handlePlayPause,
|
|
handleSeekToTimestamp,
|
|
handleMuteToggle,
|
|
handleFrameModeToggle,
|
|
handleSkip,
|
|
handleSpeedChange,
|
|
handleQualityChange,
|
|
handleTimelineMouseDown,
|
|
handleTimelineMouseMove,
|
|
toggleFullscreen,
|
|
} = useVideoPlayer({
|
|
activeVersion,
|
|
activeVersionId,
|
|
activeProviderId,
|
|
embedUrl,
|
|
canInitializePlayer,
|
|
iframeRef,
|
|
videoRef,
|
|
bunnyViewportRef,
|
|
timelineRef,
|
|
progressRef,
|
|
playheadRef,
|
|
scrubReadoutRef,
|
|
hlsRef,
|
|
playerRef,
|
|
formatTime,
|
|
formatBunnyQualityLabel,
|
|
speedOptions: SPEED_OPTIONS,
|
|
scheduleWatchProgressSaveRef,
|
|
setViewingAnnotation,
|
|
});
|
|
|
|
const {
|
|
savedProgress,
|
|
showResumePrompt,
|
|
scheduleWatchProgressSave,
|
|
handleResumeFromSaved,
|
|
handleDismissResume,
|
|
} = useWatchProgress({
|
|
videoId,
|
|
activeVersionId,
|
|
isAuthenticated: !!video?.isAuthenticated,
|
|
pathname,
|
|
playerRef,
|
|
isReady,
|
|
currentTime,
|
|
videoDuration,
|
|
});
|
|
|
|
useEffect(() => {
|
|
scheduleWatchProgressSaveRef.current = scheduleWatchProgressSave;
|
|
}, [scheduleWatchProgressSave]);
|
|
|
|
const handleResumeFromSavedWithSync = useCallback(() => {
|
|
const resumed = handleResumeFromSaved();
|
|
if (typeof resumed === 'number') {
|
|
setCurrentTime(resumed);
|
|
}
|
|
}, [handleResumeFromSaved, setCurrentTime]);
|
|
|
|
const { activeDownloadTarget, isDownloadingVideo, startDownload } = useDownloadActions({
|
|
activeVersion,
|
|
video,
|
|
});
|
|
|
|
// Memoize comments array
|
|
const comments = useMemo(() => {
|
|
return activeVersion?.comments || [];
|
|
}, [activeVersion]);
|
|
|
|
// Memoize filtered comments to avoid filtering on every render
|
|
const filteredComments = useMemo(() => {
|
|
return comments.filter((c) => showResolved || !c.isResolved);
|
|
}, [comments, showResolved]);
|
|
|
|
// Memoize sorted comments to avoid sorting on every render
|
|
const sortedComments = useMemo(() => {
|
|
return [...filteredComments].sort((a, b) => a.timestamp - b.timestamp);
|
|
}, [filteredComments]);
|
|
|
|
// Memoize duration computation
|
|
const duration = useMemo(() => {
|
|
return videoDuration || activeVersion?.duration || 0;
|
|
}, [videoDuration, activeVersion?.duration]);
|
|
|
|
const selectedQualityLabel = useMemo(() => {
|
|
if (selectedQualityLevel === -2) return 'Original';
|
|
if (selectedQualityLevel === -1) return 'Auto';
|
|
return qualityOptions.find((option) => option.level === selectedQualityLevel)?.label ?? 'Auto';
|
|
}, [qualityOptions, selectedQualityLevel]);
|
|
|
|
useEffect(() => {
|
|
if (!activeVersionId || mode !== 'dashboard') return;
|
|
void fetchApprovalRequests();
|
|
}, [activeVersionId, fetchApprovalRequests, mode]);
|
|
|
|
useEffect(() => {
|
|
if (!showApprovalRequestDialog || mode !== 'dashboard') return;
|
|
void fetchApprovalCandidates();
|
|
}, [fetchApprovalCandidates, mode, showApprovalRequestDialog]);
|
|
|
|
const {
|
|
commentText,
|
|
setCommentText,
|
|
isSubmittingComment,
|
|
isRecording,
|
|
recordingTime,
|
|
audioBlob,
|
|
isUploadingAudio,
|
|
imageFiles,
|
|
commentRangeStart,
|
|
commentRangeEnd,
|
|
toggleCommentRangeSelection,
|
|
clearCommentRangeSelection,
|
|
isUploadingImage,
|
|
imageInputRef,
|
|
removeImageFile,
|
|
handleAddComment,
|
|
handleImageSelect,
|
|
handlePaste,
|
|
handleDrop,
|
|
startRecording,
|
|
stopRecording,
|
|
cancelRecording,
|
|
submitCommentWithMedia,
|
|
replyingTo,
|
|
setReplyingTo,
|
|
replyText,
|
|
setReplyText,
|
|
isSubmittingReply,
|
|
isReplyRecording,
|
|
replyRecordingTime,
|
|
replyAudioBlob,
|
|
replyImageFiles,
|
|
replyRangeStart,
|
|
replyRangeEnd,
|
|
toggleReplyRangeSelection,
|
|
clearReplyRangeSelection,
|
|
isUploadingReplyAudio,
|
|
isUploadingReplyImage,
|
|
replyImageInputRef,
|
|
handleReplyComment,
|
|
startReplyRecording,
|
|
stopReplyRecording,
|
|
cancelReplyRecording,
|
|
submitReplyWithMedia,
|
|
editingCommentId,
|
|
editText,
|
|
setEditText,
|
|
editTagId,
|
|
setEditTagId,
|
|
editAnnotationData,
|
|
setEditAnnotationData,
|
|
isEditingAnnotation,
|
|
setIsEditingAnnotation,
|
|
editImageUrls,
|
|
editImageFiles,
|
|
editImageInputRef,
|
|
startEditingComment,
|
|
startEditingReply,
|
|
cancelEditingComment,
|
|
removeEditImageUrl,
|
|
isSubmittingEdit,
|
|
handleEditComment,
|
|
handleDeleteComment,
|
|
handleResolveComment,
|
|
previewImage,
|
|
setPreviewImage,
|
|
} = useCommentActions({
|
|
videoId,
|
|
setVideo,
|
|
activeVersionId,
|
|
activeVersion,
|
|
currentTime,
|
|
isGuest,
|
|
normalizedGuestName,
|
|
currentUserName,
|
|
canResolveComments,
|
|
availableTags,
|
|
selectedTagId,
|
|
setSelectedTagId,
|
|
annotationStrokes,
|
|
setAnnotationStrokes,
|
|
isAnnotating,
|
|
setIsAnnotating,
|
|
setViewingAnnotation,
|
|
annotationCanvasRef,
|
|
editAnnotationCanvasRef,
|
|
fetchVersionComments,
|
|
fetchAssets,
|
|
});
|
|
|
|
const commentMarkers = useMemo<CommentMarker[]>(() => {
|
|
return filteredComments.map((comment) => ({
|
|
id: comment.id,
|
|
timestamp: comment.timestamp,
|
|
timestampEnd: comment.timestampEnd,
|
|
color: comment.tag?.color || (comment.isResolved ? '#22C55E' : '#22D3EE'),
|
|
annotationData: comment.annotationData,
|
|
preview: `${comment.tag ? ` [${comment.tag.name}]` : ''} - ${comment.content?.substring(0, 30) || '(voice note)'}...`,
|
|
}));
|
|
}, [filteredComments]);
|
|
|
|
const editAnnotationInitialStrokes = useMemo<AnnotationStroke[] | undefined>(() => {
|
|
if (editAnnotationData) {
|
|
try {
|
|
const parsed = JSON.parse(editAnnotationData);
|
|
return (validateAnnotationStrokes(parsed) as AnnotationStroke[] | null) ?? undefined;
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
const editingComment = comments.find((comment) => comment.id === editingCommentId);
|
|
if (!editingComment?.annotationData) return undefined;
|
|
try {
|
|
const parsed = JSON.parse(editingComment.annotationData);
|
|
return (validateAnnotationStrokes(parsed) as AnnotationStroke[] | null) ?? undefined;
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}, [editAnnotationData, comments, editingCommentId]);
|
|
|
|
useVersionDurationSync({
|
|
videoDuration,
|
|
activeVersionDuration,
|
|
activeVersionId,
|
|
propProjectId,
|
|
videoId,
|
|
setVideo,
|
|
});
|
|
|
|
const containerHeight = 'h-screen';
|
|
const backHref =
|
|
mode === 'dashboard'
|
|
? `/projects/${propProjectId}`
|
|
: video?.projectId
|
|
? `/projects/${video.projectId}`
|
|
: '/';
|
|
const isBunnyVersion = activeVersion?.providerId === 'bunny';
|
|
const showBunnyProcessingOverlay =
|
|
isBunnyVersion && bunnyPlaybackState === 'processing' && !isReady;
|
|
const isR2Version = activeVersion?.providerId === 'r2';
|
|
const showBunnyErrorOverlay = (isBunnyVersion || isR2Version) && bunnyPlaybackState === 'error';
|
|
|
|
const confirmGuestName = useCallback(() => {
|
|
if (!guestName.trim()) return;
|
|
localStorage.setItem('openframe_guest_name', guestName.trim());
|
|
setGuestNameConfirmed(true);
|
|
}, [guestName]);
|
|
|
|
const handleDeleteCurrentVersionClick = useCallback(() => {
|
|
setVersionToDelete(activeVersionId);
|
|
setShowDeleteVersionDialog(true);
|
|
}, [activeVersionId, setShowDeleteVersionDialog, setVersionToDelete]);
|
|
|
|
const handleOpenCompare = useCallback(() => {
|
|
setSelectedCompareVersions(new Set(activeVersionId ? [activeVersionId] : []));
|
|
setShowCompareDialog(true);
|
|
}, [activeVersionId]);
|
|
|
|
const handleOpenApprovalRequestDialog = useCallback(() => {
|
|
setApprovalError('');
|
|
setShowApprovalRequestDialog(true);
|
|
}, [setApprovalError]);
|
|
|
|
const handleOpenApprovalsPanel = useCallback(() => {
|
|
setApprovalError('');
|
|
setShowApprovalsPanel(true);
|
|
void fetchApprovalRequests();
|
|
}, [fetchApprovalRequests, setApprovalError]);
|
|
|
|
const toggleCompareVersion = useCallback((versionId: string) => {
|
|
setSelectedCompareVersions((prev) => {
|
|
const next = new Set(prev);
|
|
if (next.has(versionId)) {
|
|
next.delete(versionId);
|
|
} else {
|
|
next.add(versionId);
|
|
}
|
|
return next;
|
|
});
|
|
}, []);
|
|
|
|
const handleCompareConfirm = useCallback(() => {
|
|
const ids = Array.from(selectedCompareVersions).join(',');
|
|
setShowCompareDialog(false);
|
|
router.push(`/projects/${propProjectId}/videos/${videoId}/compare?versions=${ids}`);
|
|
}, [propProjectId, router, selectedCompareVersions, videoId]);
|
|
|
|
const handleStartEditAnnotation = useCallback(() => {
|
|
if (playerRef.current?.pauseVideo) {
|
|
playerRef.current.pauseVideo();
|
|
}
|
|
setIsEditingAnnotation(true);
|
|
setIsAnnotating(false);
|
|
}, [setIsEditingAnnotation]);
|
|
|
|
const pauseVideoForAnnotation = useCallback(() => {
|
|
if (playerRef.current?.pauseVideo) {
|
|
playerRef.current.pauseVideo();
|
|
}
|
|
}, []);
|
|
|
|
const headerActions: VideoPageHeaderActions = useMemo(
|
|
() => ({
|
|
onVersionSelect: handleVersionSelect,
|
|
onDeleteCurrentVersionClick: handleDeleteCurrentVersionClick,
|
|
onDownload: startDownload,
|
|
onOpenCompare: handleOpenCompare,
|
|
onCreateVersion: handleCreateVersion,
|
|
}),
|
|
[
|
|
handleVersionSelect,
|
|
handleDeleteCurrentVersionClick,
|
|
startDownload,
|
|
handleOpenCompare,
|
|
handleCreateVersion,
|
|
]
|
|
);
|
|
|
|
const commentsActions: VideoPageCommentsActions = useMemo(
|
|
() => ({
|
|
onExportComments: exportComments,
|
|
onResolveComment: handleResolveComment,
|
|
onEditComment: handleEditComment,
|
|
onDeleteComment: handleDeleteComment,
|
|
onReplyComment: handleReplyComment,
|
|
onSubmitReplyWithMedia: submitReplyWithMedia,
|
|
onStartEditAnnotation: handleStartEditAnnotation,
|
|
}),
|
|
[
|
|
exportComments,
|
|
handleResolveComment,
|
|
handleEditComment,
|
|
handleDeleteComment,
|
|
handleReplyComment,
|
|
submitReplyWithMedia,
|
|
handleStartEditAnnotation,
|
|
]
|
|
);
|
|
|
|
const composerActions: VideoPageComposerActions = useMemo(
|
|
() => ({
|
|
onSubmitCommentWithMedia: submitCommentWithMedia,
|
|
onAddComment: handleAddComment,
|
|
onPauseVideoForAnnotation: pauseVideoForAnnotation,
|
|
}),
|
|
[submitCommentWithMedia, handleAddComment, pauseVideoForAnnotation]
|
|
);
|
|
|
|
const compareActions: VideoPageCompareActions = useMemo(
|
|
() => ({
|
|
onToggleVersion: toggleCompareVersion,
|
|
onCompare: handleCompareConfirm,
|
|
}),
|
|
[toggleCompareVersion, handleCompareConfirm]
|
|
);
|
|
|
|
if (loading) {
|
|
return (
|
|
<VideoPageLoading
|
|
containerHeight={containerHeight}
|
|
mode={mode}
|
|
isFullscreenMode={isFullscreenMode}
|
|
cursorIdle={cursorIdle}
|
|
isPlaying={isPlaying}
|
|
showComments={showComments}
|
|
/>
|
|
);
|
|
}
|
|
|
|
if (error || !video || !activeVersion) {
|
|
return (
|
|
<VideoPageError
|
|
containerHeight={containerHeight}
|
|
error={error}
|
|
mode={mode}
|
|
projectId={propProjectId}
|
|
/>
|
|
);
|
|
}
|
|
|
|
if (mode === 'watch' && isGuest && !guestNameConfirmed) {
|
|
return (
|
|
<GuestNameGate
|
|
guestName={guestName}
|
|
setGuestName={setGuestName}
|
|
onConfirm={confirmGuestName}
|
|
/>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className={cn(containerHeight, 'flex flex-col bg-background overflow-hidden')}>
|
|
<div className="flex-1 flex flex-col lg:flex-row overflow-y-auto lg:overflow-hidden min-h-0">
|
|
<div className={cn('flex-1 w-full flex flex-col min-h-0', isFullscreenMode && 'relative')}>
|
|
<VideoPageHeader
|
|
mode={mode}
|
|
backHref={backHref}
|
|
title={video.title}
|
|
projectName={video.project.name}
|
|
isFullscreenMode={isFullscreenMode}
|
|
cursorIdle={cursorIdle}
|
|
isPlaying={isPlaying}
|
|
versions={video.versions}
|
|
activeVersion={activeVersion}
|
|
activeVersionId={activeVersionId}
|
|
onVersionSelect={headerActions.onVersionSelect}
|
|
onDeleteCurrentVersionClick={headerActions.onDeleteCurrentVersionClick}
|
|
showDeleteVersionDialog={showDeleteVersionDialog}
|
|
setShowDeleteVersionDialog={setShowDeleteVersionDialog}
|
|
isDeletingVersion={isDeletingVersion}
|
|
onDeleteVersion={handleDeleteVersion}
|
|
videoCanDownload={!!video.canDownload}
|
|
isDownloadingVideo={isDownloadingVideo}
|
|
activeDownloadTarget={activeDownloadTarget}
|
|
onDownload={headerActions.onDownload}
|
|
projectId={projectId}
|
|
videoId={videoId}
|
|
directUploadsEnabled={directUploadsEnabled}
|
|
showVersionDialog={showVersionDialog}
|
|
setShowVersionDialog={setShowVersionDialog}
|
|
newVersionMode={newVersionMode}
|
|
setNewVersionMode={setNewVersionMode}
|
|
newVersionUrl={newVersionUrl}
|
|
handleNewVersionUrlChange={handleNewVersionUrlChange}
|
|
newVersionUrlError={newVersionUrlError}
|
|
newVersionSource={newVersionSource}
|
|
newVersionFile={newVersionFile}
|
|
setNewVersionFile={setNewVersionFile}
|
|
newVersionLabel={newVersionLabel}
|
|
setNewVersionLabel={setNewVersionLabel}
|
|
newVersionUploadStatus={newVersionUploadStatus}
|
|
newVersionUploadProgress={newVersionUploadProgress}
|
|
isCreatingVersion={isCreatingVersion}
|
|
onCreateVersion={headerActions.onCreateVersion}
|
|
onOpenCompare={headerActions.onOpenCompare}
|
|
canRequestApproval={canRequestApproval}
|
|
canShareVideo={canShareVideo}
|
|
hasPendingApprovalRequest={!!activePendingRequest}
|
|
onOpenApprovalRequest={handleOpenApprovalRequestDialog}
|
|
onOpenApprovalsPanel={handleOpenApprovalsPanel}
|
|
/>
|
|
|
|
<PlayerCore
|
|
activeVersionId={activeVersionId}
|
|
activeProviderId={activeVersion?.providerId}
|
|
embedUrl={embedUrl}
|
|
videoRef={videoRef}
|
|
iframeRef={iframeRef}
|
|
bunnyViewportRef={bunnyViewportRef}
|
|
timelineRef={timelineRef}
|
|
progressRef={progressRef}
|
|
playheadRef={playheadRef}
|
|
scrubReadoutRef={scrubReadoutRef}
|
|
videoContainerRef={videoContainerRef}
|
|
showScrubReadout={showScrubReadout}
|
|
isFullscreenMode={isFullscreenMode}
|
|
cursorIdle={cursorIdle}
|
|
isPlaying={isPlaying}
|
|
handlePlayPause={handlePlayPause}
|
|
handleVideoMouseMove={handleVideoMouseMove}
|
|
handleVideoMouseLeave={handleVideoMouseLeave}
|
|
isBunnyPortraitSource={isBunnyPortraitSource}
|
|
bunnyPortraitFrameWidth={bunnyPortraitFrameWidth}
|
|
showBunnyProcessingOverlay={showBunnyProcessingOverlay}
|
|
showBunnyErrorOverlay={showBunnyErrorOverlay}
|
|
showResumePrompt={showResumePrompt}
|
|
savedProgress={savedProgress}
|
|
formatTime={formatTime}
|
|
handleResumeFromSaved={handleResumeFromSavedWithSync}
|
|
handleDismissResume={handleDismissResume}
|
|
isAnnotating={isAnnotating}
|
|
annotationCanvasRef={annotationCanvasRef}
|
|
setAnnotationStrokes={setAnnotationStrokes}
|
|
setIsAnnotating={setIsAnnotating}
|
|
setViewingAnnotation={setViewingAnnotation}
|
|
viewingAnnotation={viewingAnnotation}
|
|
isEditingAnnotation={isEditingAnnotation}
|
|
editAnnotationCanvasRef={editAnnotationCanvasRef}
|
|
editAnnotationInitialStrokes={editAnnotationInitialStrokes}
|
|
setEditAnnotationData={setEditAnnotationData}
|
|
setIsEditingAnnotation={setIsEditingAnnotation}
|
|
currentTime={currentTime}
|
|
duration={duration}
|
|
isFrameMode={isFrameMode}
|
|
frameStepLabel={frameStepLabel}
|
|
handleSkip={handleSkip}
|
|
handleFrameModeToggle={handleFrameModeToggle}
|
|
handleMuteToggle={handleMuteToggle}
|
|
isMuted={isMuted}
|
|
selectedQualityLabel={selectedQualityLabel}
|
|
selectedQualityLevel={selectedQualityLevel}
|
|
qualityOptions={qualityOptions}
|
|
handleQualityChange={handleQualityChange}
|
|
playbackSpeed={playbackSpeed}
|
|
speedOptions={SPEED_OPTIONS}
|
|
handleSpeedChange={handleSpeedChange}
|
|
toggleFullscreen={toggleFullscreen}
|
|
showComments={showComments}
|
|
setShowComments={setShowComments}
|
|
setIsMobileCommentsOpen={setIsMobileCommentsOpen}
|
|
handleTimelineMouseDown={handleTimelineMouseDown}
|
|
handleTimelineMouseMove={handleTimelineMouseMove}
|
|
handleSeekToTimestamp={handleSeekToTimestamp}
|
|
commentMarkers={commentMarkers}
|
|
/>
|
|
</div>
|
|
|
|
<CommentsPane
|
|
isMobileCommentsOpen={isMobileCommentsOpen}
|
|
setIsMobileCommentsOpen={setIsMobileCommentsOpen}
|
|
isFullscreenMode={isFullscreenMode}
|
|
showComments={showComments}
|
|
comments={comments}
|
|
filteredComments={filteredComments}
|
|
sortedComments={sortedComments}
|
|
showResolved={showResolved}
|
|
handleToggleShowResolved={handleToggleShowResolved}
|
|
activeVersion={activeVersion}
|
|
isGuest={isGuest}
|
|
isExportingCsv={isExportingCsv}
|
|
isExportingPdf={isExportingPdf}
|
|
handleExportComments={commentsActions.onExportComments}
|
|
canResolveComments={canResolveComments}
|
|
handleResolveComment={commentsActions.onResolveComment}
|
|
handleSeekToTimestamp={handleSeekToTimestamp}
|
|
currentUserId={currentUserId}
|
|
projectOwnerId={video.project.ownerId}
|
|
editingCommentId={editingCommentId}
|
|
startEditingComment={startEditingComment}
|
|
startEditingReply={startEditingReply}
|
|
cancelEditingComment={cancelEditingComment}
|
|
editText={editText}
|
|
setEditText={setEditText}
|
|
editTagId={editTagId}
|
|
setEditTagId={setEditTagId}
|
|
editImageUrls={editImageUrls}
|
|
editImageFiles={editImageFiles}
|
|
editImageInputRef={editImageInputRef}
|
|
removeEditImageUrl={removeEditImageUrl}
|
|
onStartEditAnnotation={commentsActions.onStartEditAnnotation}
|
|
isSubmittingEdit={isSubmittingEdit}
|
|
availableTags={availableTags}
|
|
handleEditComment={commentsActions.onEditComment}
|
|
handleDeleteComment={commentsActions.onDeleteComment}
|
|
playVoice={playVoice}
|
|
playingVoiceId={playingVoiceId}
|
|
voiceProgress={voiceProgress}
|
|
voiceCurrentTime={voiceCurrentTime}
|
|
voicePlaybackRate={voicePlaybackRate}
|
|
toggleVoiceSpeed={toggleVoiceSpeed}
|
|
formatTime={formatTime}
|
|
setPreviewImage={setPreviewImage}
|
|
replyingTo={replyingTo}
|
|
setReplyingTo={setReplyingTo}
|
|
replyText={replyText}
|
|
setReplyText={setReplyText}
|
|
replyRangeStart={replyRangeStart}
|
|
replyRangeEnd={replyRangeEnd}
|
|
toggleReplyRangeSelection={toggleReplyRangeSelection}
|
|
clearReplyRangeSelection={clearReplyRangeSelection}
|
|
handleReplyComment={commentsActions.onReplyComment}
|
|
startReplyRecording={startReplyRecording}
|
|
isReplyRecording={isReplyRecording}
|
|
replyRecordingTime={replyRecordingTime}
|
|
stopReplyRecording={stopReplyRecording}
|
|
cancelReplyRecording={cancelReplyRecording}
|
|
replyAudioBlob={replyAudioBlob}
|
|
replyImageFiles={replyImageFiles}
|
|
replyImageInputRef={replyImageInputRef}
|
|
removeImageFile={removeImageFile}
|
|
handleImageSelect={handleImageSelect}
|
|
handlePaste={handlePaste}
|
|
handleDrop={handleDrop}
|
|
submitReplyWithMedia={commentsActions.onSubmitReplyWithMedia}
|
|
isSubmittingReply={isSubmittingReply}
|
|
isUploadingReplyAudio={isUploadingReplyAudio}
|
|
isUploadingReplyImage={isUploadingReplyImage}
|
|
assets={assets}
|
|
onAssetMentionClick={handleAssetMentionClick}
|
|
activePane={activeSidePane}
|
|
setActivePane={setActiveSidePane}
|
|
assetsPane={
|
|
<AssetsPane
|
|
videoId={videoId}
|
|
assets={assets}
|
|
isLoadingAssets={isLoadingAssets}
|
|
isCreatingAsset={isCreatingAsset}
|
|
deletingAssetIds={deletingAssetIds}
|
|
activeDownloadAssetId={activeDownloadAssetId}
|
|
canUploadAssets={canUploadAssets}
|
|
canDownloadAssets={canDownloadAssets}
|
|
getGuestUploadToken={getGuestUploadToken}
|
|
createAsset={createAsset}
|
|
deleteAsset={deleteAsset}
|
|
downloadAsset={downloadAsset}
|
|
hasMoreAssets={hasMoreAssets}
|
|
isLoadingMoreAssets={isLoadingMoreAssets}
|
|
loadMoreAssets={loadMoreAssets}
|
|
highlightedAssetId={highlightedAssetId}
|
|
onHighlightedAssetHandled={() => setHighlightedAssetId(null)}
|
|
directUploadProvider={directUploadProvider}
|
|
/>
|
|
}
|
|
composer={
|
|
<CommentComposer
|
|
isRecording={isRecording}
|
|
recordingTime={recordingTime}
|
|
stopRecording={stopRecording}
|
|
cancelRecording={cancelRecording}
|
|
audioBlob={audioBlob}
|
|
imageFiles={imageFiles}
|
|
imageInputRef={imageInputRef}
|
|
removeImageFile={(index) => removeImageFile(index, 'comment')}
|
|
commentText={commentText}
|
|
setCommentText={setCommentText}
|
|
commentRangeStart={commentRangeStart}
|
|
commentRangeEnd={commentRangeEnd}
|
|
toggleCommentRangeSelection={toggleCommentRangeSelection}
|
|
clearCommentRangeSelection={clearCommentRangeSelection}
|
|
playVoice={playVoice}
|
|
playingVoiceId={playingVoiceId}
|
|
voiceProgress={voiceProgress}
|
|
voiceCurrentTime={voiceCurrentTime}
|
|
formatTime={formatTime}
|
|
toggleVoiceSpeed={toggleVoiceSpeed}
|
|
voicePlaybackRate={voicePlaybackRate}
|
|
submitCommentWithMedia={composerActions.onSubmitCommentWithMedia}
|
|
isUploadingAudio={isUploadingAudio}
|
|
isUploadingImage={isUploadingImage}
|
|
annotationStrokes={annotationStrokes}
|
|
isAnnotating={isAnnotating}
|
|
setAnnotationStrokes={setAnnotationStrokes}
|
|
setIsAnnotating={setIsAnnotating}
|
|
handleAddComment={composerActions.onAddComment}
|
|
isSubmittingComment={isSubmittingComment}
|
|
startRecording={startRecording}
|
|
handlePaste={handlePaste}
|
|
handleImageSelect={handleImageSelect}
|
|
availableTags={availableTags}
|
|
selectedTagId={selectedTagId}
|
|
setSelectedTagId={setSelectedTagId}
|
|
canManageTags={!!video.canManageTags}
|
|
projectId={projectId}
|
|
pauseVideoForAnnotation={composerActions.onPauseVideoForAnnotation}
|
|
assets={assets}
|
|
/>
|
|
}
|
|
/>
|
|
</div>
|
|
|
|
<ImagePreviewDialog previewImage={previewImage} onClose={() => setPreviewImage(null)} />
|
|
|
|
<CompareVersionsDialog
|
|
open={showCompareDialog}
|
|
onOpenChange={setShowCompareDialog}
|
|
versions={video.versions}
|
|
selectedCompareVersions={selectedCompareVersions}
|
|
onToggleVersion={compareActions.onToggleVersion}
|
|
onCompare={compareActions.onCompare}
|
|
/>
|
|
|
|
{mode === 'dashboard' ? (
|
|
<>
|
|
<ApprovalRequestDialog
|
|
open={showApprovalRequestDialog}
|
|
onOpenChange={setShowApprovalRequestDialog}
|
|
candidates={approvalCandidates}
|
|
currentUserId={currentUserId}
|
|
activePendingRequest={activePendingRequest}
|
|
isLoadingCandidates={isLoadingApprovalCandidates}
|
|
isSubmittingRequest={isSubmittingApprovalRequest}
|
|
error={approvalError}
|
|
onRefreshCandidates={fetchApprovalCandidates}
|
|
onCreateRequest={createApprovalRequest}
|
|
/>
|
|
<ApprovalRequestsPanel
|
|
open={showApprovalsPanel}
|
|
onOpenChange={setShowApprovalsPanel}
|
|
requests={approvalRequests}
|
|
currentUserId={currentUserId}
|
|
canRequestApproval={canRequestApproval}
|
|
onOpenApprovalRequest={handleOpenApprovalRequestDialog}
|
|
isLoadingRequests={isLoadingApprovals}
|
|
isSubmittingDecision={isSubmittingApprovalDecision}
|
|
isCancelingRequest={isCancelingApprovalRequest}
|
|
error={approvalError}
|
|
onRefresh={fetchApprovalRequests}
|
|
onSubmitDecision={submitApprovalDecision}
|
|
onCancelRequest={cancelApprovalRequest}
|
|
/>
|
|
</>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|