'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'; import { getSpeedOptionsForProvider } from '@/components/video-page/hooks/video-player-utils'; 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}`; } 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(null); const videoRef = useRef(null); const bunnyViewportRef = useRef(null); const hlsRef = useRef(null); const playerRef = useRef(null); const timelineRef = useRef(null); const progressRef = useRef(null); const playheadRef = useRef(null); const scrubReadoutRef = useRef(null); const videoContainerRef = useRef(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(null); const editAnnotationCanvasRef = useRef(null); // Annotation state const [isAnnotating, setIsAnnotating] = useState(false); const [annotationStrokes, setAnnotationStrokes] = useState(null); const [viewingAnnotation, setViewingAnnotation] = useState(null); const annotationCanvasRef = useRef(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>(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 speedOptions = getSpeedOptionsForProvider(activeProviderId); 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, 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(() => { 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(() => { 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 ( ); } if (error || !video || !activeVersion) { return ( ); } if (mode === 'watch' && isGuest && !guestNameConfirmed) { return ( ); } return (
setHighlightedAssetId(null)} directUploadProvider={directUploadProvider} /> } composer={ 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} /> } />
setPreviewImage(null)} /> {mode === 'dashboard' ? ( <> ) : null}
); }