'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 { 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; } export function VideoPageContent({ mode, videoId, projectId: propProjectId }: 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 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, activeDeleteAssetId, 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, 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`; } 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, isDragging, playbackSpeed, qualityOptions, selectedQualityLevel, isBunnyPortraitSource, bunnyPortraitFrameWidth, cursorIdle, isFullscreenMode, showComments, isMobileCommentsOpen, setShowComments, setIsMobileCommentsOpen, handleVideoMouseMove, handleVideoMouseLeave, handlePlayPause, handleSeekToTimestamp, handleMuteToggle, handleSkip, handleSpeedChange, handleQualityChange, handleTimelineMouseDown, handleTimelineMouseMove, handleTimelineMouseUp, toggleFullscreen, } = useVideoPlayer({ activeVersion, activeVersionId, activeProviderId, embedUrl, canInitializePlayer, iframeRef, videoRef, bunnyViewportRef, timelineRef, hlsRef, playerRef, 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, imageBlob, setImageBlob, isUploadingImage, imageInputRef, handleAddComment, handleImageSelect, handlePaste, startRecording, stopRecording, cancelRecording, submitCommentWithMedia, replyingTo, setReplyingTo, replyText, setReplyText, isSubmittingReply, isReplyRecording, replyRecordingTime, replyAudioBlob, replyImageBlob, setReplyImageBlob, isUploadingReplyAudio, isUploadingReplyImage, replyImageInputRef, handleReplyComment, startReplyRecording, stopReplyRecording, cancelReplyRecording, submitReplyWithMedia, editingCommentId, setEditingCommentId, editText, setEditText, editTagId, setEditTagId, editAnnotationData, setEditAnnotationData, isEditingAnnotation, setIsEditingAnnotation, isSubmittingEdit, handleEditComment, handleDeleteComment, handleResolveComment, previewImage, setPreviewImage, } = useCommentActions({ videoId, setVideo, activeVersionId, activeVersion, comments, 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, 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 { return JSON.parse(editAnnotationData) as AnnotationStroke[]; } catch { return undefined; } } const editingComment = comments.find((comment) => comment.id === editingCommentId); if (!editingComment?.annotationData) return undefined; try { return JSON.parse(editingComment.annotationData) as AnnotationStroke[]; } 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 showBunnyErrorOverlay = isBunnyVersion && 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 (
isDragging && handleTimelineMouseUp()} >
setHighlightedAssetId(null)} /> )} composer={( )} />
setPreviewImage(null)} /> {mode === 'dashboard' ? ( <> ) : null}
); }