refactor(video-page): split video page into modular components, hooks, and shared types

This commit is contained in:
Yusuf İpek
2026-02-25 15:01:37 +03:00
parent 86a995aed4
commit 0ae1becc1b
23 changed files with 6344 additions and 4539 deletions
+337
View File
@@ -0,0 +1,337 @@
'use client';
import { memo, type RefObject } from 'react';
import Link from 'next/link';
import { Image as ImageIcon, Loader2, Mic, Pause, Pencil, Play, Send, Tag, Trash2, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import type { AnnotationStroke } from '@/components/annotation-canvas';
import type { CommentTag } from '@/components/video-page/types';
interface CommentComposerProps {
isRecording: boolean;
recordingTime: number;
stopRecording: () => void;
cancelRecording: () => void;
audioBlob: Blob | null;
imageBlob: File | null;
imageInputRef: RefObject<HTMLInputElement | null>;
setImageBlob: (blob: File | null) => void;
commentText: string;
setCommentText: (value: string) => void;
playVoice: (commentId: string, voiceUrl: string, knownDuration?: number) => void;
playingVoiceId: string | null;
voiceProgress: number;
voiceCurrentTime: number;
formatTime: (value: number) => string;
toggleVoiceSpeed: () => void;
voicePlaybackRate: number;
submitCommentWithMedia: () => void;
isUploadingAudio: boolean;
isUploadingImage: boolean;
annotationStrokes: AnnotationStroke[] | null;
isAnnotating: boolean;
setAnnotationStrokes: (strokes: AnnotationStroke[] | null) => void;
setIsAnnotating: (value: boolean) => void;
handleAddComment: () => void;
isSubmittingComment: boolean;
startRecording: () => void;
handlePaste: (e: React.ClipboardEvent<HTMLTextAreaElement>, isReply?: boolean) => void;
handleImageSelect: (e: React.ChangeEvent<HTMLInputElement>, isReply?: boolean) => void;
availableTags: CommentTag[];
selectedTagId: string | null;
setSelectedTagId: (value: string | null) => void;
canManageTags: boolean;
projectId?: string;
pauseVideoForAnnotation: () => void;
}
export const CommentComposer = memo(function CommentComposer({
isRecording,
recordingTime,
stopRecording,
cancelRecording,
audioBlob,
imageBlob,
imageInputRef,
setImageBlob,
commentText,
setCommentText,
playVoice,
playingVoiceId,
voiceProgress,
voiceCurrentTime,
formatTime,
toggleVoiceSpeed,
voicePlaybackRate,
submitCommentWithMedia,
isUploadingAudio,
isUploadingImage,
annotationStrokes,
isAnnotating,
setAnnotationStrokes,
setIsAnnotating,
handleAddComment,
isSubmittingComment,
startRecording,
handlePaste,
handleImageSelect,
availableTags,
selectedTagId,
setSelectedTagId,
canManageTags,
projectId,
pauseVideoForAnnotation,
}: CommentComposerProps) {
return (
<div className="shrink-0 p-4 border-t bg-background">
{isRecording ? (
<div className="flex items-center gap-3 p-3 bg-destructive/10 border border-destructive/30 rounded-lg">
<div className="h-3 w-3 rounded-full bg-destructive animate-pulse" />
<span className="text-sm font-medium text-destructive">
Recording {formatTime(recordingTime)}
</span>
<div className="flex-1" />
<Button size="sm" variant="destructive" onClick={stopRecording}>
Stop
</Button>
<Button size="sm" variant="ghost" onClick={cancelRecording}>
<X className="h-4 w-4" />
</Button>
</div>
) : audioBlob ? (
<div className="space-y-2">
<div className="flex items-center gap-3 p-3 bg-muted rounded-lg">
<Button
size="icon"
variant="ghost"
className="h-8 w-8"
onClick={() => {
const url = URL.createObjectURL(audioBlob);
playVoice('preview', url, recordingTime);
}}
>
{playingVoiceId === 'preview' ? (
<Pause className="h-4 w-4" />
) : (
<Play className="h-4 w-4" />
)}
</Button>
<div className="flex-1 h-2 bg-primary/20 rounded-full overflow-hidden">
<div
className="h-full bg-primary rounded-full"
style={{ width: playingVoiceId === 'preview' ? `${voiceProgress}%` : '0%' }}
/>
</div>
<span className="text-xs text-muted-foreground tabular-nums">
{playingVoiceId === 'preview'
? `${formatTime(voiceCurrentTime)} / ${formatTime(recordingTime)}`
: formatTime(recordingTime)}
</span>
{playingVoiceId === 'preview' && (
<button
onClick={toggleVoiceSpeed}
className="text-[10px] font-bold px-1 py-0.5 rounded bg-muted hover:bg-muted-foreground/20 tabular-nums shrink-0"
>
{voicePlaybackRate}x
</button>
)}
<Button
size="icon"
variant="ghost"
className="h-8 w-8"
onClick={cancelRecording}
>
<X className="h-4 w-4" />
</Button>
</div>
{imageBlob && (
<div className="relative group rounded-md overflow-hidden bg-muted flex items-center justify-center max-h-40 mb-2">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={URL.createObjectURL(imageBlob)} alt="Preview" className="max-h-40 w-auto object-contain" />
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
<Button size="icon" variant="destructive" onClick={() => {
setImageBlob(null);
if (imageInputRef.current) imageInputRef.current.value = '';
}}>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
)}
<Textarea
placeholder="Add a note to your voice comment (optional)..."
value={commentText}
onChange={(e) => setCommentText(e.target.value)}
rows={1}
className="resize-none text-sm"
/>
<Button
size="sm"
onClick={submitCommentWithMedia}
disabled={isUploadingAudio || isUploadingImage}
className="w-full"
>
{isUploadingAudio || isUploadingImage ? (
<>
<Loader2 className="h-4 w-4 animate-spin mr-2" />
Uploading Media...
</>
) : (
<>
<Send className="h-4 w-4 mr-2" />
Send Voice Comment
</>
)}
</Button>
</div>
) : (
<>
{(annotationStrokes || isAnnotating) && (
<div className="flex items-center gap-2 px-2 py-1.5 mb-2 rounded-md bg-violet-500/10 border border-violet-500/30">
<Pencil className="h-3.5 w-3.5 text-violet-500 shrink-0" />
<span className="text-xs text-violet-400 font-medium">Annotation attached</span>
<button
className="ml-auto text-xs text-muted-foreground hover:text-destructive transition-colors"
onClick={() => { setAnnotationStrokes(null); setIsAnnotating(false); }}
>
Remove
</button>
</div>
)}
{imageBlob && (
<div className="relative group rounded-md overflow-hidden bg-muted flex items-center justify-center max-h-40 mb-2">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={URL.createObjectURL(imageBlob)} alt="Preview" className="max-h-40 w-auto object-contain" />
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
<Button size="icon" variant="destructive" onClick={() => {
setImageBlob(null);
if (imageInputRef.current) imageInputRef.current.value = '';
}}>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
)}
<div className="flex gap-2">
<Textarea
placeholder="Add a comment..."
value={commentText}
onChange={(e) => setCommentText(e.target.value)}
rows={2}
className="resize-none text-sm"
onKeyDown={(e) => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
handleAddComment();
}
}}
onPaste={(e) => handlePaste(e, false)}
/>
<div className="flex flex-col gap-1">
<Button
size="icon"
onClick={handleAddComment}
disabled={(!commentText.trim() && !imageBlob && !annotationStrokes) || isSubmittingComment || isUploadingImage}
>
{isSubmittingComment || isUploadingImage ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Send className="h-4 w-4" />
)}
</Button>
<Button
size="icon"
variant="outline"
onClick={startRecording}
title="Record voice comment"
>
<Mic className="h-4 w-4" />
</Button>
<Button
size="icon"
variant="outline"
onClick={() => imageInputRef.current?.click()}
title="Attach Image"
>
<ImageIcon className="h-4 w-4" />
</Button>
<Button
size="icon"
variant={annotationStrokes ? 'default' : 'outline'}
className={annotationStrokes ? 'bg-violet-500 hover:bg-violet-600' : ''}
onClick={() => {
if (isAnnotating) return;
pauseVideoForAnnotation();
setIsAnnotating(true);
}}
title={annotationStrokes ? 'Annotation added ✓ (click to redraw)' : 'Draw annotation on video'}
>
<Pencil className="h-4 w-4" />
</Button>
<input
type="file"
accept="image/*"
className="hidden"
ref={imageInputRef}
onChange={handleImageSelect}
/>
{availableTags.length > 0 && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="icon"
variant={selectedTagId ? 'default' : 'outline'}
title="Select tag"
style={selectedTagId ? {
backgroundColor: availableTags.find(t => t.id === selectedTagId)?.color
} : undefined}
>
<Tag className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{availableTags.map((tag) => (
<DropdownMenuItem
key={tag.id}
onClick={() => setSelectedTagId(tag.id)}
className="gap-2"
>
<span
className="w-3 h-3 rounded-full shrink-0"
style={{ backgroundColor: tag.color }}
/>
{tag.name}
{selectedTagId === tag.id && <span className="ml-auto"></span>}
</DropdownMenuItem>
))}
{canManageTags && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link href={`/projects/${projectId}/settings#comment-tags`} className="gap-2 text-muted-foreground">
<Tag className="h-3 w-3" />
Manage Tags
</Link>
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</div>
<p className="text-xs text-muted-foreground mt-1">Cmd+Enter to submit</p>
</>
)}
</div>
);
});
+814
View File
@@ -0,0 +1,814 @@
'use client';
import { memo, type ReactNode, type RefObject } from 'react';
import { ArrowUpRight, CheckCircle2, Circle, Clock, Download, FileText, Image as ImageIcon, Loader2, MessageSquare, Mic, MoreVertical, Pause, Pencil, Play, Reply, Tag, Trash2, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Textarea } from '@/components/ui/textarea';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { cn } from '@/lib/utils';
import { Linkify } from '@/components/linkify';
import type { Comment, CommentTag, Version } from '@/components/video-page/types';
interface CommentsPaneProps {
isMobileCommentsOpen: boolean;
setIsMobileCommentsOpen: (open: boolean) => void;
isFullscreenMode: boolean;
showComments: boolean;
comments: Comment[];
filteredComments: Comment[];
sortedComments: Comment[];
showResolved: boolean;
handleToggleShowResolved: () => void;
activeVersion: Version | undefined;
isGuest: boolean;
isExportingCsv: boolean;
isExportingPdf: boolean;
handleExportComments: (format: 'csv' | 'pdf') => void;
canResolveComments: boolean;
handleResolveComment: (commentId: string, currentlyResolved: boolean) => void;
handleSeekToTimestamp: (timestamp: number, annotation?: string | null) => void;
currentUserId: string | null;
projectOwnerId: string;
editingCommentId: string | null;
setEditingCommentId: (id: string | null) => void;
editText: string;
setEditText: (value: string) => void;
editTagId: string | null;
setEditTagId: (value: string | null) => void;
setEditAnnotationData: (value: string | null | undefined) => void;
setIsEditingAnnotation: (value: boolean) => void;
onStartEditAnnotation: () => void;
isSubmittingEdit: boolean;
availableTags: CommentTag[];
handleEditComment: (commentId: string) => void;
handleDeleteComment: (commentId: string) => void;
playVoice: (commentId: string, voiceUrl: string, knownDuration?: number) => void;
playingVoiceId: string | null;
voiceProgress: number;
voiceCurrentTime: number;
voicePlaybackRate: number;
toggleVoiceSpeed: () => void;
formatTime: (seconds: number) => string;
setPreviewImage: (url: string | null) => void;
replyingTo: string | null;
setReplyingTo: (id: string | null) => void;
replyText: string;
setReplyText: (value: string) => void;
handleReplyComment: (parentId: string, voiceData?: { url: string; duration: number }, imageData?: { url: string }) => void;
startReplyRecording: () => void;
isReplyRecording: boolean;
replyRecordingTime: number;
stopReplyRecording: () => void;
cancelReplyRecording: () => void;
replyAudioBlob: Blob | null;
replyImageBlob: File | null;
setReplyImageBlob: (file: File | null) => void;
replyImageInputRef: RefObject<HTMLInputElement | null>;
handleImageSelect: (e: React.ChangeEvent<HTMLInputElement>, isReply?: boolean) => void;
handlePaste: (e: React.ClipboardEvent<HTMLTextAreaElement>, isReply?: boolean) => void;
submitReplyWithMedia: (parentId: string) => void;
isSubmittingReply: boolean;
isUploadingReplyAudio: boolean;
isUploadingReplyImage: boolean;
composer: ReactNode;
}
export const CommentsPane = memo(function CommentsPane({
isMobileCommentsOpen,
setIsMobileCommentsOpen,
isFullscreenMode,
showComments,
comments,
filteredComments,
sortedComments,
showResolved,
handleToggleShowResolved,
activeVersion,
isGuest,
isExportingCsv,
isExportingPdf,
handleExportComments,
canResolveComments,
handleResolveComment,
handleSeekToTimestamp,
currentUserId,
projectOwnerId,
editingCommentId,
setEditingCommentId,
editText,
setEditText,
editTagId,
setEditTagId,
setEditAnnotationData,
setIsEditingAnnotation,
onStartEditAnnotation,
isSubmittingEdit,
availableTags,
handleEditComment,
handleDeleteComment,
playVoice,
playingVoiceId,
voiceProgress,
voiceCurrentTime,
voicePlaybackRate,
toggleVoiceSpeed,
formatTime,
setPreviewImage,
replyingTo,
setReplyingTo,
replyText,
setReplyText,
handleReplyComment,
startReplyRecording,
isReplyRecording,
replyRecordingTime,
stopReplyRecording,
cancelReplyRecording,
replyAudioBlob,
replyImageBlob,
setReplyImageBlob,
replyImageInputRef,
handleImageSelect,
handlePaste,
submitReplyWithMedia,
isSubmittingReply,
isUploadingReplyAudio,
isUploadingReplyImage,
composer,
}: CommentsPaneProps) {
return (
<>
<div
className={cn(
'fixed inset-0 z-40 bg-black/50 backdrop-blur-sm lg:hidden transition-opacity duration-300',
isMobileCommentsOpen ? 'opacity-100' : 'opacity-0 pointer-events-none'
)}
onClick={() => setIsMobileCommentsOpen(false)}
/>
<div className={cn(
'bg-card flex flex-col overflow-hidden z-50',
'fixed inset-y-0 right-0 w-[85%] sm:w-[400px] shadow-2xl transition-transform duration-300 transform',
isMobileCommentsOpen ? 'translate-x-0' : 'translate-x-full',
'lg:static lg:w-80 lg:shrink-0 lg:border-l lg:transition-none lg:translate-x-0 lg:shadow-none lg:z-auto',
isFullscreenMode && !showComments ? 'hidden' : ''
)}>
<div
className="shrink-0 flex items-center justify-between p-4 border-b lg:cursor-default"
>
<div className="flex items-center gap-2">
<MessageSquare className="h-5 w-5" />
<span className="font-medium">Comments</span>
<Badge variant="secondary">{comments.length}</Badge>
</div>
<div className="flex items-center gap-2">
<Button variant="ghost" size="sm" onClick={(e) => { e.stopPropagation(); handleToggleShowResolved(); }}>
{showResolved ? 'Hide' : 'Show'} Resolved
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
disabled={!activeVersion || isGuest || isExportingCsv || isExportingPdf}
onClick={(e) => {
e.stopPropagation();
handleExportComments('csv');
}}
title={isGuest ? 'CSV export requires an authenticated account' : 'Download comments as CSV'}
>
{isExportingCsv ? <Loader2 className="h-4 w-4 animate-spin" /> : <Download className="h-4 w-4" />}
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
disabled={!activeVersion || isExportingCsv || isExportingPdf}
onClick={(e) => {
e.stopPropagation();
handleExportComments('pdf');
}}
title="Download comments as PDF"
>
{isExportingPdf ? <Loader2 className="h-4 w-4 animate-spin" /> : <FileText className="h-4 w-4" />}
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 lg:hidden" onClick={() => setIsMobileCommentsOpen(false)}>
<X className="h-4 w-4" />
</Button>
</div>
</div>
<div className="flex-1 overflow-y-auto p-4 space-y-3">
{filteredComments.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
<MessageSquare className="h-8 w-8 mx-auto mb-2 opacity-50" />
<p>No comments yet</p>
<p className="text-sm">Be the first to leave feedback!</p>
</div>
) : (
sortedComments.map((comment) => {
const authorName =
comment.author?.name || comment.guestName || 'Anonymous';
const isEditing = editingCommentId === comment.id;
const isReplying = replyingTo === comment.id;
const canEditComment = comment.canEdit ?? (comment.author?.id === currentUserId);
const canDeleteComment = comment.canDelete ?? (comment.author?.id === currentUserId || projectOwnerId === currentUserId);
const canManageComment = canEditComment || canDeleteComment;
return (
<div
key={comment.id}
className={cn(
'group rounded-lg border p-3 transition-colors hover:bg-accent/50',
comment.isResolved && 'opacity-60'
)}
>
<div className="flex items-start justify-between gap-2 mb-2">
<div className="flex items-center gap-2 min-w-0">
<Avatar className="h-6 w-6 shrink-0">
<AvatarImage src={comment.author?.image ?? undefined} />
<AvatarFallback className="text-xs">
{authorName.charAt(0)}
</AvatarFallback>
</Avatar>
<span className="text-sm font-medium truncate">{authorName}</span>
</div>
<div className="flex items-center gap-1 shrink-0">
<button
onClick={() => handleSeekToTimestamp(comment.timestamp, comment.annotationData)}
className="flex items-center gap-1 text-xs text-primary hover:underline px-1.5 py-0.5 rounded bg-primary/10 hover:bg-primary/20 transition-colors"
title="Jump to this timestamp"
>
<Clock className="h-3 w-3" />
{formatTime(comment.timestamp)}
<ArrowUpRight className="h-3 w-3" />
</button>
{canResolveComments && (
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() =>
handleResolveComment(comment.id, comment.isResolved)
}
>
{comment.isResolved ? (
<CheckCircle2 className="h-4 w-4 text-green-500" />
) : (
<Circle className="h-4 w-4" />
)}
</Button>
)}
{canManageComment && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
>
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => {
setReplyingTo(comment.id);
setReplyText('');
}}>
<Reply className="h-4 w-4 mr-2" />
Reply
</DropdownMenuItem>
{canEditComment && (
<DropdownMenuItem onClick={() => {
setEditingCommentId(comment.id);
setEditText(comment.content || '');
setEditTagId(comment.tag?.id || null);
}}>
<Pencil className="h-4 w-4 mr-2" />
Edit
</DropdownMenuItem>
)}
{canDeleteComment && (
<DropdownMenuItem
className="text-destructive"
onClick={() => handleDeleteComment(comment.id)}
>
<Trash2 className="h-4 w-4 mr-2" />
Delete
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</div>
{isEditing ? (
<div className="mb-2">
<Textarea
value={editText}
onChange={(e) => setEditText(e.target.value)}
rows={2}
className="resize-none text-sm mb-1"
autoFocus
onKeyDown={(e) => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
handleEditComment(comment.id);
}
if (e.key === 'Escape') {
setEditingCommentId(null);
setEditText('');
setEditTagId(null);
setEditAnnotationData(undefined);
setIsEditingAnnotation(false);
}
}}
/>
<div className="flex items-center gap-1 flex-wrap">
<Button
size="sm"
onClick={() => handleEditComment(comment.id)}
disabled={!editText.trim() || isSubmittingEdit}
className="h-7 text-xs"
>
{isSubmittingEdit ? <Loader2 className="h-3 w-3 animate-spin" /> : 'Save'}
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => { setEditingCommentId(null); setEditText(''); setEditTagId(null); setEditAnnotationData(undefined); setIsEditingAnnotation(false); }}
className="h-7 text-xs"
>
Cancel
</Button>
<Button
size="icon"
variant={comment.annotationData ? 'default' : 'outline'}
className={`h-7 w-7 ${comment.annotationData ? 'bg-violet-500 hover:bg-violet-600' : ''}`}
onClick={() => {
onStartEditAnnotation();
}}
title={comment.annotationData ? 'Redraw annotation' : 'Add annotation'}
>
<Pencil className="h-3.5 w-3.5" />
</Button>
{availableTags.length > 0 && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="sm"
variant={editTagId ? 'default' : 'outline'}
className="h-7 text-xs ml-auto"
style={editTagId ? {
backgroundColor: availableTags.find(t => t.id === editTagId)?.color
} : undefined}
>
<Tag className="h-3 w-3 mr-1" />
{editTagId ? availableTags.find(t => t.id === editTagId)?.name || 'Tag' : 'Tag'}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setEditTagId(null)} className="gap-2">
<X className="h-3 w-3" />
No Tag
{!editTagId && <span className="ml-auto"></span>}
</DropdownMenuItem>
<DropdownMenuSeparator />
{availableTags.map((tag) => (
<DropdownMenuItem
key={tag.id}
onClick={() => setEditTagId(tag.id)}
className="gap-2"
>
<span
className="w-3 h-3 rounded-full shrink-0"
style={{ backgroundColor: tag.color }}
/>
{tag.name}
{editTagId === tag.id && <span className="ml-auto"></span>}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</div>
) : (
<div className="mb-2">
{comment.content && <p className="text-sm mb-2"><Linkify>{comment.content}</Linkify></p>}
{comment.imageUrl && (
<div
className="rounded-md overflow-hidden bg-muted mb-2 max-h-60 flex items-center justify-center cursor-pointer hover:opacity-90 transition-opacity"
onClick={() => setPreviewImage(comment.imageUrl)}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={comment.imageUrl} alt="Attachment" className="max-h-60 w-auto object-contain" />
</div>
)}
</div>
)}
{comment.voiceUrl && (
<div className="flex items-center gap-2 p-2 bg-muted rounded mb-2">
<Button
size="icon"
variant="ghost"
className="h-8 w-8 shrink-0"
onClick={() => playVoice(comment.id, comment.voiceUrl!, comment.voiceDuration || 0)}
>
{playingVoiceId === comment.id ? (
<Pause className="h-4 w-4" />
) : (
<Play className="h-4 w-4" />
)}
</Button>
<div className="flex-1 h-2 bg-primary/20 rounded-full overflow-hidden">
<div
className="h-full bg-primary rounded-full"
style={{ width: playingVoiceId === comment.id ? `${voiceProgress}%` : '0%' }}
/>
</div>
<span className="text-xs text-muted-foreground tabular-nums shrink-0">
{playingVoiceId === comment.id
? `${formatTime(voiceCurrentTime)} / ${formatTime(comment.voiceDuration || 0)}`
: formatTime(comment.voiceDuration || 0)}
</span>
{playingVoiceId === comment.id && (
<button
onClick={toggleVoiceSpeed}
className="text-[10px] font-bold px-1 py-0.5 rounded bg-muted hover:bg-muted-foreground/20 tabular-nums shrink-0"
>
{voicePlaybackRate}x
</button>
)}
</div>
)}
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground">
{new Date(comment.createdAt).toLocaleDateString()}
</p>
{comment.annotationData && (
<span className="text-[10px] font-medium px-2 py-0.5 rounded-full bg-violet-500 text-white shrink-0 flex items-center gap-1">
<Pencil className="h-2.5 w-2.5" />
Annotated
</span>
)}
{comment.tag && (
<span
className="text-[10px] font-medium px-2 py-0.5 rounded-full text-white shrink-0"
style={{ backgroundColor: comment.tag.color }}
>
{comment.tag.name}
</span>
)}
</div>
{comment.replies && comment.replies.length > 0 && (
<div className="mt-3 pl-3 border-l-2 space-y-2">
{comment.replies.map((reply) => {
const replyAuthor =
reply.author?.name || reply.guestName || 'Anonymous';
const isEditingReply = editingCommentId === reply.id;
const canEditReply = reply.canEdit ?? (reply.author?.id === currentUserId);
const canDeleteReply = reply.canDelete ?? (reply.author?.id === currentUserId || projectOwnerId === currentUserId);
const canManageReply = canEditReply || canDeleteReply;
return (
<div key={reply.id} className="group/reply text-sm">
<div className="flex items-center justify-between gap-2 mb-1">
<div className="flex items-center gap-2">
<Avatar className="h-5 w-5">
<AvatarFallback className="text-xs">
{replyAuthor.charAt(0)}
</AvatarFallback>
</Avatar>
<span className="font-medium text-xs">{replyAuthor}</span>
<span className="text-xs text-muted-foreground">
{new Date(reply.createdAt).toLocaleDateString()}
</span>
</div>
{canManageReply && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-5 w-5 shrink-0"
>
<MoreVertical className="h-3 w-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{canEditReply && (
<DropdownMenuItem onClick={() => {
setEditingCommentId(reply.id);
setEditText(reply.content || '');
}}>
<Pencil className="h-4 w-4 mr-2" />
Edit
</DropdownMenuItem>
)}
{canDeleteReply && (
<DropdownMenuItem
className="text-destructive"
onClick={() => handleDeleteComment(reply.id)}
>
<Trash2 className="h-4 w-4 mr-2" />
Delete
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
{isEditingReply ? (
<div className="mb-1">
<Textarea
value={editText}
onChange={(e) => setEditText(e.target.value)}
rows={2}
className="resize-none text-sm mb-1"
autoFocus
onKeyDown={(e) => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
handleEditComment(reply.id);
}
if (e.key === 'Escape') {
setEditingCommentId(null);
setEditText('');
}
}}
/>
<div className="flex gap-1">
<Button
size="sm"
onClick={() => handleEditComment(reply.id)}
disabled={!editText.trim() || isSubmittingEdit}
className="h-7 text-xs"
>
{isSubmittingEdit ? <Loader2 className="h-3 w-3 animate-spin" /> : 'Save'}
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => { setEditingCommentId(null); setEditText(''); }}
className="h-7 text-xs"
>
Cancel
</Button>
</div>
</div>
) : (
<div className="mb-1">
{reply.content && <p className="text-sm"><Linkify>{reply.content}</Linkify></p>}
{reply.imageUrl && (
<div
className="rounded-md overflow-hidden bg-muted mt-2 max-h-40 flex items-center justify-center cursor-pointer hover:opacity-90 transition-opacity"
onClick={() => setPreviewImage(reply.imageUrl)}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={reply.imageUrl} alt="Attachment" className="max-h-40 w-auto object-contain" />
</div>
)}
</div>
)}
{reply.voiceUrl && (
<div className="flex items-center gap-2 p-1.5 bg-muted rounded mt-1">
<Button
size="icon"
variant="ghost"
className="h-6 w-6 shrink-0"
onClick={() => playVoice(reply.id, reply.voiceUrl!, reply.voiceDuration || 0)}
>
{playingVoiceId === reply.id ? (
<Pause className="h-3 w-3" />
) : (
<Play className="h-3 w-3" />
)}
</Button>
<div className="flex-1 h-1.5 bg-primary/20 rounded-full overflow-hidden">
<div
className="h-full bg-primary rounded-full"
style={{ width: playingVoiceId === reply.id ? `${voiceProgress}%` : '0%' }}
/>
</div>
<span className="text-xs text-muted-foreground tabular-nums shrink-0">
{playingVoiceId === reply.id
? `${formatTime(voiceCurrentTime)} / ${formatTime(reply.voiceDuration || 0)}`
: formatTime(reply.voiceDuration || 0)}
</span>
{playingVoiceId === reply.id && (
<button
onClick={toggleVoiceSpeed}
className="text-[10px] font-bold px-1 py-0.5 rounded bg-muted hover:bg-muted-foreground/20 tabular-nums shrink-0"
>
{voicePlaybackRate}x
</button>
)}
</div>
)}
</div>
);
})}
</div>
)}
{isReplying && (
<div className="mt-3 pl-3 border-l-2">
{isReplyRecording ? (
<div className="flex items-center gap-2 p-2 bg-destructive/10 border border-destructive/30 rounded-lg mb-1">
<div className="h-2 w-2 rounded-full bg-destructive animate-pulse" />
<span className="text-xs font-medium text-destructive">
{formatTime(replyRecordingTime)}
</span>
<div className="flex-1" />
<Button size="sm" variant="destructive" onClick={stopReplyRecording} className="h-6 text-xs">
Stop
</Button>
<Button size="sm" variant="ghost" onClick={cancelReplyRecording} className="h-6 text-xs">
<X className="h-3 w-3" />
</Button>
</div>
) : replyAudioBlob ? (
<div className="space-y-1 mb-1">
<div className="flex items-center gap-2 p-2 bg-muted rounded-lg">
<Button
size="icon"
variant="ghost"
className="h-6 w-6"
onClick={() => {
const url = URL.createObjectURL(replyAudioBlob);
playVoice('reply-preview', url, replyRecordingTime);
}}
>
{playingVoiceId === 'reply-preview' ? <Pause className="h-3 w-3" /> : <Play className="h-3 w-3" />}
</Button>
<div className="flex-1 h-1.5 bg-primary/20 rounded-full overflow-hidden">
<div
className="h-full bg-primary rounded-full"
style={{ width: playingVoiceId === 'reply-preview' ? `${voiceProgress}%` : '0%' }}
/>
</div>
<span className="text-xs text-muted-foreground tabular-nums">
{playingVoiceId === 'reply-preview'
? `${formatTime(voiceCurrentTime)} / ${formatTime(replyRecordingTime)}`
: formatTime(replyRecordingTime)}
</span>
{playingVoiceId === 'reply-preview' && (
<button
onClick={toggleVoiceSpeed}
className="text-[10px] font-bold px-1 py-0.5 rounded bg-muted hover:bg-muted-foreground/20 tabular-nums shrink-0"
>
{voicePlaybackRate}x
</button>
)}
<Button size="icon" variant="ghost" className="h-6 w-6" onClick={cancelReplyRecording}>
<X className="h-3 w-3" />
</Button>
</div>
{replyImageBlob && (
<div className="relative group rounded-md overflow-hidden bg-muted flex items-center justify-center h-20 mb-2">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={URL.createObjectURL(replyImageBlob)} alt="Preview" className="h-full object-contain" />
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
<Button size="icon" variant="destructive" className="h-6 w-6" onClick={() => {
setReplyImageBlob(null);
if (replyImageInputRef.current) replyImageInputRef.current.value = '';
}}>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</div>
)}
<Textarea
value={replyText}
onChange={(e) => setReplyText(e.target.value)}
placeholder="Add a note (optional)..."
rows={1}
className="resize-none text-sm"
/>
<div className="flex gap-1 mt-2">
<Button
size="sm"
onClick={() => submitReplyWithMedia(comment.id)}
disabled={isUploadingReplyAudio || isUploadingReplyImage}
className="h-7 text-xs"
>
{isUploadingReplyAudio || isUploadingReplyImage ? <Loader2 className="h-3 w-3 animate-spin" /> : 'Send Reply'}
</Button>
<Button size="sm" variant="ghost" onClick={cancelReplyRecording} className="h-7 text-xs">Cancel</Button>
</div>
</div>
) : (
<>
{replyImageBlob && (
<div className="relative group rounded-md overflow-hidden bg-muted flex items-center justify-center h-20 mb-2">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={URL.createObjectURL(replyImageBlob)} alt="Preview" className="h-full object-contain" />
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
<Button size="icon" variant="destructive" className="h-6 w-6" onClick={() => {
setReplyImageBlob(null);
if (replyImageInputRef.current) replyImageInputRef.current.value = '';
}}>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</div>
)}
<div className="flex gap-1">
<Textarea
value={replyText}
onChange={(e) => setReplyText(e.target.value)}
placeholder="Write a reply..."
rows={2}
className="resize-none text-sm flex-1"
autoFocus
onKeyDown={(e) => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
handleReplyComment(comment.id);
}
if (e.key === 'Escape') {
setReplyingTo(null);
setReplyText('');
}
}}
onPaste={(e) => handlePaste(e, true)}
/>
<Button
size="icon"
variant="outline"
onClick={startReplyRecording}
title="Record voice reply"
className="h-8 w-8 shrink-0 self-end"
>
<Mic className="h-3 w-3" />
</Button>
<Button
size="icon"
variant="outline"
onClick={() => replyImageInputRef.current?.click()}
title="Attach Image"
className="h-8 w-8 shrink-0 self-end"
>
<ImageIcon className="h-3 w-3" />
</Button>
<input
type="file"
accept="image/*"
className="hidden"
ref={replyImageInputRef}
onChange={(e) => handleImageSelect(e, true)}
/>
</div>
<div className="flex gap-1 mt-1">
<Button
size="sm"
onClick={() => handleReplyComment(comment.id)}
disabled={(!replyText.trim() && !replyImageBlob) || isSubmittingReply || isUploadingReplyImage}
className="h-7 text-xs"
>
{isSubmittingReply || isUploadingReplyImage ? <Loader2 className="h-3 w-3 animate-spin" /> : 'Reply'}
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => { setReplyingTo(null); setReplyText(''); }}
className="h-7 text-xs"
>
Cancel
</Button>
</div>
</>
)}
</div>
)}
{!isReplying && !isEditing && (
<button
onClick={() => { setReplyingTo(comment.id); setReplyText(''); }}
className="mt-2 text-xs text-muted-foreground hover:text-foreground flex items-center gap-1"
>
<Reply className="h-3 w-3" />
Reply
</button>
)}
</div>
);
})
)}
</div>
{composer}
</div>
</>
);
});
@@ -0,0 +1,89 @@
'use client';
import { memo } from 'react';
import { CheckCircle2, GitCompareArrows } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { cn } from '@/lib/utils';
import type { Version } from '@/components/video-page/types';
interface CompareVersionsDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
versions: Version[];
selectedCompareVersions: Set<string>;
onToggleVersion: (versionId: string) => void;
onCompare: () => void;
}
export const CompareVersionsDialog = memo(function CompareVersionsDialog({
open,
onOpenChange,
versions,
selectedCompareVersions,
onToggleVersion,
onCompare,
}: CompareVersionsDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Select Versions to Compare</DialogTitle>
<DialogDescription>
Choose 2 or more versions to compare side by side.
</DialogDescription>
</DialogHeader>
<div className="space-y-2 mt-2 max-h-64 overflow-y-auto">
{versions
.slice()
.sort((a, b) => a.versionNumber - b.versionNumber)
.map((v) => {
const isSelected = selectedCompareVersions.has(v.id);
return (
<button
key={v.id}
type="button"
className={cn(
'w-full flex items-center gap-3 px-3 py-2.5 rounded-lg border text-left transition-colors',
isSelected
? 'border-primary bg-primary/5'
: 'border-border hover:bg-accent/50'
)}
onClick={() => onToggleVersion(v.id)}
>
<div
className={cn(
'h-4 w-4 rounded border flex items-center justify-center shrink-0 transition-colors',
isSelected
? 'bg-primary border-primary text-primary-foreground'
: 'border-muted-foreground/40'
)}
>
{isSelected && (
<CheckCircle2 className="h-3 w-3" />
)}
</div>
<Badge variant="secondary">v{v.versionNumber}</Badge>
<span className="text-sm font-medium truncate">
{v.versionLabel || `Version ${v.versionNumber}`}
</span>
<span className="ml-auto text-xs text-muted-foreground shrink-0">
{v._count.comments} comments
</span>
</button>
);
})}
</div>
<Button
className="w-full mt-2"
disabled={selectedCompareVersions.size < 2}
onClick={onCompare}
>
<GitCompareArrows className="h-4 w-4 mr-2" />
Compare {selectedCompareVersions.size} Versions
</Button>
</DialogContent>
</Dialog>
);
});
+230
View File
@@ -0,0 +1,230 @@
'use client';
import { memo } from 'react';
import { ChevronDown, Download, Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { cn } from '@/lib/utils';
import type { BunnyDownloadPreference, DownloadTarget, Version } from '@/components/video-page/types';
interface DownloadControlsProps {
activeVersion: Version | null | undefined;
videoCanDownload: boolean;
isDownloading: boolean;
activeDownloadTarget: DownloadTarget | null;
onDownload: (preference?: BunnyDownloadPreference) => void;
compact?: boolean;
}
interface DownloadMenuItemsProps {
activeVersion: Version | null | undefined;
videoCanDownload: boolean;
isDownloading: boolean;
activeDownloadTarget: DownloadTarget | null;
onDownload: (preference?: BunnyDownloadPreference) => void;
}
export const DownloadControls = memo(function DownloadControls({
activeVersion,
videoCanDownload,
isDownloading,
activeDownloadTarget,
onDownload,
compact = false,
}: DownloadControlsProps) {
if (!activeVersion) return null;
const isVideoDownloadAvailable = videoCanDownload
&& (activeVersion.providerId === 'bunny' || activeVersion.providerId === 'direct');
if (activeVersion.providerId === 'bunny') {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size={compact ? 'icon' : 'sm'}
className={cn(
compact && 'h-8 w-8',
'transition-opacity duration-300',
isDownloading && 'opacity-50 pointer-events-none'
)}
disabled={!isVideoDownloadAvailable || isDownloading}
>
{isDownloading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Download className={cn('h-4 w-4', !compact && 'mr-1')} />
)}
{!compact && (
<>
Download
<ChevronDown className="h-4 w-4 ml-1" />
</>
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onSelect={(event) => {
event.preventDefault();
onDownload('original');
}}
disabled={!isVideoDownloadAvailable || isDownloading}
>
{activeDownloadTarget === 'original' ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<Download className="h-4 w-4 mr-2" />
)}
Download Original
</DropdownMenuItem>
<DropdownMenuItem
onSelect={(event) => {
event.preventDefault();
onDownload('compressed');
}}
disabled={!isVideoDownloadAvailable || isDownloading}
>
{activeDownloadTarget === 'compressed' ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<Download className="h-4 w-4 mr-2" />
)}
Download Compressed
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
if (compact) {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="icon"
className={cn(
'h-8 w-8 transition-opacity duration-300',
isDownloading && 'opacity-50 pointer-events-none'
)}
disabled={!isVideoDownloadAvailable || isDownloading}
>
{isDownloading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Download className="h-4 w-4" />
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onSelect={(event) => {
event.preventDefault();
onDownload();
}}
disabled={!isVideoDownloadAvailable || isDownloading}
>
{isDownloading ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<Download className="h-4 w-4 mr-2" />
)}
Download
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
return (
<Button
variant="outline"
size="sm"
className={cn(
'transition-opacity duration-300',
isDownloading && 'opacity-50 pointer-events-none'
)}
onClick={() => onDownload()}
disabled={!isVideoDownloadAvailable || isDownloading}
>
{isDownloading ? (
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
) : (
<Download className="h-4 w-4 mr-1" />
)}
Download
</Button>
);
});
export const DownloadMenuItems = memo(function DownloadMenuItems({
activeVersion,
videoCanDownload,
isDownloading,
activeDownloadTarget,
onDownload,
}: DownloadMenuItemsProps) {
if (!activeVersion) return null;
const isVideoDownloadAvailable = videoCanDownload
&& (activeVersion.providerId === 'bunny' || activeVersion.providerId === 'direct');
if (activeVersion.providerId === 'bunny') {
return (
<>
<DropdownMenuItem
onSelect={(event) => {
event.preventDefault();
onDownload('original');
}}
disabled={!isVideoDownloadAvailable || isDownloading}
>
{activeDownloadTarget === 'original' ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<Download className="h-4 w-4 mr-2" />
)}
Download Original
</DropdownMenuItem>
<DropdownMenuItem
onSelect={(event) => {
event.preventDefault();
onDownload('compressed');
}}
disabled={!isVideoDownloadAvailable || isDownloading}
>
{activeDownloadTarget === 'compressed' ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<Download className="h-4 w-4 mr-2" />
)}
Download Compressed
</DropdownMenuItem>
</>
);
}
return (
<DropdownMenuItem
onSelect={(event) => {
event.preventDefault();
onDownload();
}}
disabled={!isVideoDownloadAvailable || isDownloading}
>
{isDownloading ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<Download className="h-4 w-4 mr-2" />
)}
Download
</DropdownMenuItem>
);
});
+62
View File
@@ -0,0 +1,62 @@
'use client';
import { memo } from 'react';
import Link from 'next/link';
import { User } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
interface GuestNameGateProps {
guestName: string;
setGuestName: (value: string) => void;
onConfirm: () => void;
}
export const GuestNameGate = memo(function GuestNameGate({
guestName,
setGuestName,
onConfirm,
}: GuestNameGateProps) {
return (
<div className="h-screen flex items-center justify-center bg-background">
<div className="w-full max-w-sm mx-auto p-6">
<div className="text-center mb-6">
<div className="inline-flex items-center justify-center w-12 h-12 rounded-full bg-primary/10 mb-4">
<User className="h-6 w-6 text-primary" />
</div>
<h1 className="text-xl font-semibold mb-1">Welcome to OpenFrame</h1>
<p className="text-sm text-muted-foreground">
Enter your name to view and comment on this video
</p>
</div>
<div className="space-y-3">
<Input
placeholder="Your name"
value={guestName}
onChange={(e) => setGuestName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && guestName.trim()) {
onConfirm();
}
}}
autoFocus
/>
<Button
className="w-full"
disabled={!guestName.trim()}
onClick={onConfirm}
>
Continue
</Button>
</div>
<p className="text-xs text-muted-foreground text-center mt-4">
Or{' '}
<Link href="/login" className="text-primary hover:underline">
sign in
</Link>{' '}
for a full account
</p>
</div>
</div>
);
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,78 @@
'use client';
import { useCallback, useState } from 'react';
import { toast } from 'sonner';
interface UseCommentExportParams {
activeVersionId: string | null;
showResolved: boolean;
}
export function useCommentExport({ activeVersionId, showResolved }: UseCommentExportParams) {
const [isExportingCsv, setIsExportingCsv] = useState(false);
const [isExportingPdf, setIsExportingPdf] = useState(false);
const exportComments = useCallback(
async (format: 'csv' | 'pdf') => {
if (!activeVersionId) return;
if (format === 'csv') {
setIsExportingCsv(true);
} else {
setIsExportingPdf(true);
}
try {
const response = await fetch(
`/api/versions/${activeVersionId}/comments/export?format=${format}&includeResolved=${showResolved}`
);
if (!response.ok) {
let message = 'Failed to export comments';
try {
const data = await response.json();
if (typeof data?.error === 'string') {
message = data.error;
}
} catch {
// Keep fallback message when response is not JSON.
}
throw new Error(message);
}
const blob = await response.blob();
const disposition = response.headers.get('content-disposition');
const fallbackName = `comments.${format}`;
const matched = disposition?.match(/filename="?([^"]+)"?/i);
const filename = matched?.[1] || fallbackName;
const downloadUrl = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = downloadUrl;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(downloadUrl);
toast.success(`Comments exported as ${format.toUpperCase()}`);
} catch (error) {
console.error('Failed to export comments:', error);
toast.error(error instanceof Error ? error.message : 'Failed to export comments');
} finally {
if (format === 'csv') {
setIsExportingCsv(false);
} else {
setIsExportingPdf(false);
}
}
},
[activeVersionId, showResolved]
);
return {
isExportingCsv,
isExportingPdf,
exportComments,
};
}
@@ -0,0 +1,117 @@
'use client';
import { useCallback, useEffect, useRef, useState } from 'react';
export function useCommentMedia() {
const [playingVoiceId, setPlayingVoiceId] = useState<string | null>(null);
const [voiceProgress, setVoiceProgress] = useState(0);
const [voiceCurrentTime, setVoiceCurrentTime] = useState(0);
const [voicePlaybackRate, setVoicePlaybackRate] = useState(1);
const audioPlayerRef = useRef<HTMLAudioElement | null>(null);
const voiceRafRef = useRef<number | null>(null);
const voiceKnownDurationRef = useRef<number>(0);
const stopVoiceTracking = useCallback(() => {
if (voiceRafRef.current) {
cancelAnimationFrame(voiceRafRef.current);
voiceRafRef.current = null;
}
}, []);
const startVoiceTracking = useCallback(() => {
stopVoiceTracking();
const tick = () => {
const audio = audioPlayerRef.current;
if (audio) {
const dur = isFinite(audio.duration) && audio.duration > 0
? audio.duration
: voiceKnownDurationRef.current;
if (dur > 0) {
setVoiceProgress((audio.currentTime / dur) * 100);
setVoiceCurrentTime(audio.currentTime);
}
}
voiceRafRef.current = requestAnimationFrame(tick);
};
voiceRafRef.current = requestAnimationFrame(tick);
}, [stopVoiceTracking]);
const playVoice = useCallback((commentId: string, voiceUrl: string, knownDuration?: number) => {
if (playingVoiceId === commentId) {
if (audioPlayerRef.current) {
audioPlayerRef.current.pause();
audioPlayerRef.current = null;
}
stopVoiceTracking();
setPlayingVoiceId(null);
setVoiceProgress(0);
setVoiceCurrentTime(0);
return;
}
if (audioPlayerRef.current) {
audioPlayerRef.current.pause();
}
stopVoiceTracking();
voiceKnownDurationRef.current = knownDuration || 0;
const audio = new Audio(voiceUrl);
audio.playbackRate = voicePlaybackRate;
audioPlayerRef.current = audio;
setPlayingVoiceId(commentId);
setVoiceProgress(0);
setVoiceCurrentTime(0);
audio.onplay = () => {
startVoiceTracking();
};
audio.onended = () => {
stopVoiceTracking();
setPlayingVoiceId(null);
setVoiceProgress(0);
setVoiceCurrentTime(0);
audioPlayerRef.current = null;
};
audio.onerror = () => {
stopVoiceTracking();
setPlayingVoiceId(null);
setVoiceProgress(0);
setVoiceCurrentTime(0);
audioPlayerRef.current = null;
};
void audio.play();
}, [playingVoiceId, voicePlaybackRate, startVoiceTracking, stopVoiceTracking]);
const toggleVoiceSpeed = useCallback(() => {
setVoicePlaybackRate((prev) => {
const next = prev === 1 ? 2 : 1;
if (audioPlayerRef.current) {
audioPlayerRef.current.playbackRate = next;
}
return next;
});
}, []);
useEffect(() => {
return () => {
if (audioPlayerRef.current) {
audioPlayerRef.current.pause();
audioPlayerRef.current = null;
}
stopVoiceTracking();
};
}, [stopVoiceTracking]);
return {
playingVoiceId,
voiceProgress,
voiceCurrentTime,
voicePlaybackRate,
playVoice,
toggleVoiceSpeed,
};
}
@@ -0,0 +1,139 @@
'use client';
import { useCallback, useState } from 'react';
import { toast } from 'sonner';
import type { BunnyDownloadPreference, Comment, DownloadTarget, Version, VideoData } from '@/components/video-page/types';
const BUNNY_PULL_ZONE_HOSTNAME = 'vz-965f4f4a-fc1.b-cdn.net';
function sanitizeDownloadFileName(value: string): string {
return value
.replace(/[<>:"/\\|?*\u0000-\u001F]/g, '-')
.replace(/\s+/g, ' ')
.trim();
}
function getAllowedHosts() {
return [
BUNNY_PULL_ZONE_HOSTNAME,
...(process.env.NEXT_PUBLIC_BUNNY_CDN_URL
? (() => {
try {
return [new URL(process.env.NEXT_PUBLIC_BUNNY_CDN_URL).hostname];
} catch {
return [process.env.NEXT_PUBLIC_BUNNY_CDN_URL.replace(/^https?:\/\//, '').replace(/\/+$/, '')];
}
})()
: []),
...(process.env.NEXT_PUBLIC_DIRECT_DOWNLOAD_ALLOWED_HOSTS ?? '').split(','),
]
.map((host) => host.trim().toLowerCase())
.filter(Boolean);
}
function getSafeDirectDownloadUrl(rawUrl: string): string | null {
try {
const parsed = new URL(rawUrl);
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
return null;
}
const allowedHosts = getAllowedHosts();
if (allowedHosts.length === 0) {
return null;
}
const normalizedHost = parsed.hostname.toLowerCase();
if (!allowedHosts.includes(normalizedHost)) {
return null;
}
return parsed.toString();
} catch {
return null;
}
}
interface UseDownloadActionsParams {
activeVersion: (Version & { comments: Comment[] }) | undefined;
video: VideoData | null;
}
export function useDownloadActions({ activeVersion, video }: UseDownloadActionsParams) {
const [activeDownloadTarget, setActiveDownloadTarget] = useState<DownloadTarget | null>(null);
const isDownloadingVideo = activeDownloadTarget !== null;
const startDownload = useCallback(async (preference: BunnyDownloadPreference = 'compressed') => {
if (!activeVersion || !video || isDownloadingVideo) return;
if (!video.canDownload) {
toast.error('Download is disabled for this shared link');
return;
}
if (activeVersion.providerId !== 'bunny' && activeVersion.providerId !== 'direct') {
toast.error('This video source does not support direct download');
return;
}
const target: DownloadTarget = activeVersion.providerId === 'bunny' ? preference : 'direct';
setActiveDownloadTarget(target);
try {
let downloadUrl: string | null = null;
if (activeVersion.providerId === 'bunny') {
const prepareRes = await fetch(`/api/versions/${activeVersion.id}/download?source=${preference}&prepare=1`, {
cache: 'no-store',
});
if (!prepareRes.ok) {
const prepareBody = await prepareRes.json().catch(() => null);
const fallbackError = preference === 'original'
? 'Original file is not available for this video'
: 'Compressed file is not available for this video';
const errorMessage = typeof prepareBody?.error === 'string'
? prepareBody.error
: fallbackError;
throw new Error(errorMessage);
}
downloadUrl = `/api/versions/${activeVersion.id}/download?source=${preference}`;
} else {
downloadUrl = getSafeDirectDownloadUrl(activeVersion.originalUrl);
if (!downloadUrl) {
throw new Error('Direct download URL is not allowed');
}
}
if (!downloadUrl) {
throw new Error('Missing download URL');
}
const versionLabel = activeVersion.versionLabel?.trim() || `v${activeVersion.versionNumber}`;
const baseName = sanitizeDownloadFileName(`${video.title} ${versionLabel}`) || 'video';
const a = document.createElement('a');
a.href = downloadUrl;
if (activeVersion.providerId === 'direct') {
a.download = `${baseName}.mp4`;
}
document.body.appendChild(a);
a.click();
a.remove();
} catch (error) {
console.error('Failed to start video download:', error);
if (error instanceof Error && error.message === 'Direct download URL is not allowed') {
toast.error('This direct download host is not allowed');
} else if (error instanceof Error && error.message) {
toast.error(error.message);
} else {
toast.error('Failed to start download');
}
} finally {
setActiveDownloadTarget(null);
}
}, [activeVersion, video, isDownloadingVideo]);
return {
activeDownloadTarget,
isDownloadingVideo,
startDownload,
};
}
@@ -0,0 +1,254 @@
'use client';
import { useState, type Dispatch, type SetStateAction } from 'react';
import { toast } from 'sonner';
import * as tus from 'tus-js-client';
import { parseVideoUrl, getThumbnailUrl, fetchVideoMetadata, type VideoSource } from '@/lib/video-providers';
import type { VersionActionsConfig, VideoData } from '@/components/video-page/types';
interface UseVersionActionsParams extends VersionActionsConfig {
setVideo: Dispatch<SetStateAction<VideoData | null>>;
activeVersionId: string | null;
setActiveVersionId: Dispatch<SetStateAction<string | null>>;
}
export function useVersionActions({
projectId,
videoId,
setVideo,
activeVersionId,
setActiveVersionId,
}: UseVersionActionsParams) {
const [showVersionDialog, setShowVersionDialog] = useState(false);
const [newVersionUrl, setNewVersionUrl] = useState('');
const [newVersionLabel, setNewVersionLabel] = useState('');
const [newVersionSource, setNewVersionSource] = useState<VideoSource | null>(null);
const [newVersionUrlError, setNewVersionUrlError] = useState('');
const [isCreatingVersion, setIsCreatingVersion] = useState(false);
const [newVersionMode, setNewVersionMode] = useState<'url' | 'file'>('url');
const [newVersionFile, setNewVersionFile] = useState<File | null>(null);
const [newVersionUploadProgress, setNewVersionUploadProgress] = useState(0);
const [newVersionUploadStatus, setNewVersionUploadStatus] = useState('');
const [showDeleteVersionDialog, setShowDeleteVersionDialog] = useState(false);
const [versionToDelete, setVersionToDelete] = useState<string | null>(null);
const [isDeletingVersion, setIsDeletingVersion] = useState(false);
const handleNewVersionUrlChange = (url: string) => {
setNewVersionUrl(url);
setNewVersionUrlError('');
if (!url.trim()) {
setNewVersionSource(null);
return;
}
const source = parseVideoUrl(url);
if (source) {
setNewVersionSource(source);
} else {
setNewVersionSource(null);
if (url.length > 10) setNewVersionUrlError('Unsupported URL');
}
};
const handleCreateVersion = async () => {
if (!projectId) return;
setIsCreatingVersion(true);
setNewVersionUploadStatus('');
setNewVersionUploadProgress(0);
let uploadedBunnyVideoId: string | null = null;
let uploadedBunnyUploadToken: string | null = null;
try {
let finalVideoUrl = '';
let finalProviderId = '';
let finalProviderVideoId = '';
let finalThumbnailUrl: string | null = null;
let finalDuration: number | null = null;
if (newVersionMode === 'url') {
if (!newVersionSource) throw new Error('Invalid URL');
const meta = await fetchVideoMetadata(newVersionSource);
finalVideoUrl = newVersionSource.originalUrl;
finalProviderId = newVersionSource.providerId;
finalProviderVideoId = newVersionSource.videoId;
finalThumbnailUrl = getThumbnailUrl(newVersionSource, 'large');
finalDuration = meta?.duration || null;
} else {
if (!newVersionFile) throw new Error('No file selected');
let title = newVersionFile.name;
if (newVersionLabel.trim()) {
title = newVersionLabel.trim();
} else {
title = title.replace(/\.[^/.]+$/, '');
}
setNewVersionUploadStatus('Initializing upload...');
const initRes = await fetch(`/api/projects/${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 = `https://vz-965f4f4a-fc1.b-cdn.net/${bunnyVideoId}/thumbnail.jpg`;
}
const res = await fetch(`/api/projects/${projectId}/videos/${videoId}/versions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
videoUrl: finalVideoUrl,
providerId: finalProviderId,
providerVideoId: finalProviderVideoId,
uploadToken: uploadedBunnyUploadToken,
versionLabel: newVersionLabel.trim() || null,
thumbnailUrl: finalThumbnailUrl,
duration: finalDuration,
setActive: true,
}),
});
if (!res.ok) {
const data = await res.json().catch(() => null);
throw new Error(data?.error || 'Failed to create version');
}
const versionData = await res.json();
const newVersion = versionData.data;
setVideo((prev) => {
if (!prev) return prev;
const updatedVersions = prev.versions.map((v) => ({ ...v, isActive: false }));
updatedVersions.unshift({
...newVersion,
comments: [],
});
return { ...prev, versions: updatedVersions };
});
setActiveVersionId(newVersion.id);
setShowVersionDialog(false);
setNewVersionUrl('');
setNewVersionLabel('');
setNewVersionSource(null);
setNewVersionFile(null);
setNewVersionUploadStatus('');
} 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);
});
}
console.error('Failed to create version:', errorObj);
toast.error(errorObj.message || 'Failed to create version');
} finally {
setIsCreatingVersion(false);
}
};
const handleDeleteVersion = async () => {
if (!versionToDelete || !projectId) return;
setIsDeletingVersion(true);
try {
const res = await fetch(
`/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');
}
} catch {
toast.error('Failed to delete version');
} finally {
setIsDeletingVersion(false);
}
};
return {
showVersionDialog,
setShowVersionDialog,
newVersionUrl,
newVersionLabel,
setNewVersionLabel,
newVersionSource,
newVersionUrlError,
isCreatingVersion,
newVersionMode,
setNewVersionMode,
newVersionFile,
setNewVersionFile,
newVersionUploadProgress,
newVersionUploadStatus,
handleNewVersionUrlChange,
handleCreateVersion,
showDeleteVersionDialog,
setShowDeleteVersionDialog,
versionToDelete,
setVersionToDelete,
isDeletingVersion,
handleDeleteVersion,
};
}
@@ -0,0 +1,46 @@
'use client';
import { useEffect, type Dispatch, type SetStateAction } from 'react';
import type { VideoData } from '@/components/video-page/types';
interface UseVersionDurationSyncParams {
videoDuration: number;
activeVersionDuration?: number | null;
activeVersionId: string | null;
propProjectId?: string;
videoId: string;
setVideo: Dispatch<SetStateAction<VideoData | null>>;
}
export function useVersionDurationSync({
videoDuration,
activeVersionDuration,
activeVersionId,
propProjectId,
videoId,
setVideo,
}: UseVersionDurationSyncParams) {
useEffect(() => {
if (!videoDuration || !activeVersionId || !propProjectId) return;
if (activeVersionDuration && activeVersionDuration > 0) return;
const roundedDuration = Math.round(videoDuration);
fetch(`/api/projects/${propProjectId}/videos/${videoId}/versions/${activeVersionId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ duration: roundedDuration }),
}).catch(() => {
// ignore save errors
});
setVideo((prev) => {
if (!prev) return prev;
return {
...prev,
versions: prev.versions.map((v) =>
v.id === activeVersionId ? { ...v, duration: roundedDuration } : v
),
};
});
}, [videoDuration, activeVersionDuration, activeVersionId, propProjectId, videoId, setVideo]);
}
@@ -0,0 +1,151 @@
'use client';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { Comment, CommentTag, Version, VideoData } from '@/components/video-page/types';
interface UseVideoPageDataParams {
mode: 'dashboard' | 'watch';
videoId: string;
propProjectId?: string;
}
export function useVideoPageData({
mode,
videoId,
propProjectId,
}: UseVideoPageDataParams) {
const [video, setVideo] = useState<VideoData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [activeVersionId, setActiveVersionId] = useState<string | null>(null);
const [availableTags, setAvailableTags] = useState<CommentTag[]>([]);
const [selectedTagId, setSelectedTagId] = useState<string | null>(null);
const commentsEtagRef = useRef<Map<string, string>>(new Map());
const apiBasePath = useMemo(() => {
return mode === 'dashboard'
? `/api/projects/${propProjectId}/videos/${videoId}?includeComments=false`
: `/api/watch/${videoId}`;
}, [mode, propProjectId, videoId]);
const projectId = propProjectId || video?.projectId;
const fetchVersionComments = useCallback(async (versionId: string, useEtag: boolean) => {
const headers: HeadersInit = {};
if (useEtag) {
const etag = commentsEtagRef.current.get(versionId);
if (etag) headers['If-None-Match'] = etag;
}
const res = await fetch(`/api/versions/${versionId}/comments?includeResolved=true`, {
cache: 'no-store',
headers,
});
if (res.status === 304) return;
if (!res.ok) return;
const etag = res.headers.get('etag');
if (etag) commentsEtagRef.current.set(versionId, etag);
const payload = await res.json();
const commentsList = payload?.data?.comments;
if (!Array.isArray(commentsList)) return;
setVideo((prev) => {
if (!prev) return prev;
const totalComments = commentsList.reduce((sum: number, comment: Comment) => {
return sum + 1 + (comment.replies?.length ?? 0);
}, 0);
return {
...prev,
versions: prev.versions.map((version) => (
version.id === versionId
? { ...version, comments: commentsList, _count: { comments: totalComments } }
: version
)),
};
});
}, []);
useEffect(() => {
async function fetchVideo() {
try {
const res = await fetch(apiBasePath, { cache: 'no-store' });
if (!res.ok) {
const errorText = mode === 'dashboard' ? await res.text() : '';
setError(mode === 'dashboard'
? `Failed to load video: ${res.status} ${errorText}`
: 'Video not found or access denied'
);
setLoading(false);
return;
}
const response = await res.json();
const rawData = response.data as Omit<VideoData, 'versions'> & {
versions?: Array<Version & { comments?: Comment[] }>;
};
const normalizedData: VideoData = {
...rawData,
versions: (rawData.versions || []).map((version) => ({
...version,
comments: Array.isArray(version.comments) ? version.comments : [],
})),
};
setVideo(normalizedData);
const active = normalizedData.versions?.find((v) => v.isActive) || normalizedData.versions?.[0];
if (active) setActiveVersionId(active.id);
} catch (err) {
console.error('Error fetching video:', err);
setError('Failed to load video');
} finally {
setLoading(false);
}
}
void fetchVideo();
}, [apiBasePath, mode]);
useEffect(() => {
if (!activeVersionId) return;
void fetchVersionComments(activeVersionId, true);
}, [activeVersionId, fetchVersionComments]);
useEffect(() => {
if (!projectId) return;
async function fetchTags() {
try {
const query = videoId ? `?videoId=${encodeURIComponent(videoId)}` : '';
const res = await fetch(`/api/projects/${projectId}/tags${query}`);
if (res.ok) {
const data = await res.json();
const tags = data.data || [];
setAvailableTags(tags);
if (tags.length > 0 && !selectedTagId) {
setSelectedTagId(tags[0].id);
}
}
} catch {
// noop
}
}
void fetchTags();
}, [projectId, selectedTagId, videoId]);
return {
video,
setVideo,
loading,
error,
activeVersionId,
setActiveVersionId,
availableTags,
setAvailableTags,
selectedTagId,
setSelectedTagId,
projectId,
fetchVersionComments,
};
}
@@ -0,0 +1,814 @@
'use client';
/* eslint-disable react-hooks/set-state-in-effect */
import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from 'react';
import Hls, { type Level } from 'hls.js';
import { toast } from 'sonner';
import type { AnnotationStroke } from '@/components/annotation-canvas';
import type {
BunnyPlaybackState,
BunnyQualityOption,
PlayerAdapter,
Version,
} from '@/components/video-page/types';
interface UseVideoPlayerParams {
activeVersion: Version | undefined;
activeVersionId: string | null;
activeProviderId: string | undefined;
embedUrl: string;
canInitializePlayer: boolean;
iframeRef: RefObject<HTMLIFrameElement | null>;
videoRef: RefObject<HTMLVideoElement | null>;
bunnyViewportRef: RefObject<HTMLDivElement | null>;
timelineRef: RefObject<HTMLDivElement | null>;
hlsRef: RefObject<Hls | null>;
playerRef: RefObject<YT.Player | PlayerAdapter | null>;
formatBunnyQualityLabel: (level: { height?: number; bitrate?: number }, index: number) => string;
speedOptions: number[];
scheduleWatchProgressSaveRef: RefObject<(input: {
progress: number;
duration?: number;
immediate?: boolean;
force?: boolean;
}) => void>;
setViewingAnnotation: (strokes: AnnotationStroke[] | null) => void;
}
export function useVideoPlayer({
activeVersion,
activeVersionId,
activeProviderId,
embedUrl,
canInitializePlayer,
iframeRef,
videoRef,
bunnyViewportRef,
timelineRef,
hlsRef,
playerRef,
formatBunnyQualityLabel,
speedOptions,
scheduleWatchProgressSaveRef,
setViewingAnnotation,
}: UseVideoPlayerParams) {
const [isApiLoaded, setIsApiLoaded] = useState(false);
const [isReady, setIsReady] = useState(false);
const [bunnyPlaybackState, setBunnyPlaybackState] = useState<BunnyPlaybackState>('none');
const [currentTime, setCurrentTime] = useState(0);
const [videoDuration, setVideoDuration] = useState(0);
const [isPlaying, setIsPlaying] = useState(false);
const [isMuted, setIsMuted] = useState(false);
const [isDragging, setIsDragging] = useState(false);
const isDraggingRef = useRef(false);
const [playbackSpeed, setPlaybackSpeed] = useState(1);
const [qualityOptions, setQualityOptions] = useState<BunnyQualityOption[]>([]);
const [selectedQualityLevel, setSelectedQualityLevel] = useState<number>(-1);
const [isBunnyPortraitSource, setIsBunnyPortraitSource] = useState(false);
const [bunnyPortraitFrameWidth, setBunnyPortraitFrameWidth] = useState<number>(0);
const [cursorIdle, setCursorIdle] = useState(false);
const cursorIdleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const bunnyRetryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [isFullscreenMode, setIsFullscreenMode] = useState(false);
const [showComments, setShowComments] = useState(true);
const [isMobileCommentsOpen, setIsMobileCommentsOpen] = useState(false);
useEffect(() => {
isDraggingRef.current = isDragging;
}, [isDragging]);
useEffect(() => {
const viewportEl = bunnyViewportRef.current;
if (!viewportEl || typeof ResizeObserver === 'undefined') return;
const updateFrameWidth = () => {
const viewportWidth = viewportEl.clientWidth;
const viewportHeight = viewportEl.clientHeight;
if (viewportWidth <= 0 || viewportHeight <= 0) return;
setBunnyPortraitFrameWidth(Math.min(viewportWidth, viewportHeight * (9 / 16)));
};
updateFrameWidth();
const observer = new ResizeObserver(updateFrameWidth);
observer.observe(viewportEl);
return () => observer.disconnect();
}, [activeVersionId, bunnyViewportRef]);
const handleVideoMouseMove = useCallback(() => {
setCursorIdle(false);
if (cursorIdleTimerRef.current) clearTimeout(cursorIdleTimerRef.current);
const shouldHideControls = isFullscreenMode;
if (isPlaying || shouldHideControls) {
cursorIdleTimerRef.current = setTimeout(() => {
setCursorIdle(true);
}, 1000);
}
}, [isFullscreenMode, isPlaying]);
const handleVideoMouseLeave = useCallback(() => {
if (cursorIdleTimerRef.current) clearTimeout(cursorIdleTimerRef.current);
setCursorIdle(false);
}, []);
useEffect(() => {
return () => {
if (cursorIdleTimerRef.current) clearTimeout(cursorIdleTimerRef.current);
};
}, []);
useEffect(() => {
if (isApiLoaded) return;
if (window.YT) {
setIsApiLoaded(true);
return;
}
const tag = document.createElement('script');
tag.src = 'https://www.youtube.com/iframe_api';
const firstScriptTag = document.getElementsByTagName('script')[0];
firstScriptTag.parentNode?.insertBefore(tag, firstScriptTag);
window.onYouTubeIframeAPIReady = () => {
setIsApiLoaded(true);
};
}, [isApiLoaded]);
useEffect(() => {
if (!canInitializePlayer) return;
if (!activeProviderId) return;
const isYoutube = activeProviderId === 'youtube';
const isBunny = activeProviderId === 'bunny';
if (isYoutube && !isApiLoaded) return;
if (!isYoutube && !isBunny) return;
setIsReady(false);
setBunnyPlaybackState('none');
setCurrentTime(0);
setVideoDuration(0);
setIsPlaying(false);
setIsMuted(false);
setPlaybackSpeed(1);
setQualityOptions([]);
setSelectedQualityLevel(-1);
setIsBunnyPortraitSource(false);
if (playerRef.current) {
try { playerRef.current.destroy(); } catch { /* ignore */ }
playerRef.current = null;
}
if (hlsRef.current) {
try { hlsRef.current.destroy(); } catch { /* ignore */ }
hlsRef.current = null;
}
if (bunnyRetryTimerRef.current) {
clearTimeout(bunnyRetryTimerRef.current);
bunnyRetryTimerRef.current = null;
}
const initPlayer = () => {
if (isYoutube) {
if (!iframeRef.current) return;
playerRef.current = new YT.Player(iframeRef.current, {
events: {
onReady: (event: YT.PlayerEvent) => {
setIsReady(true);
const dur = event.target.getDuration();
if (dur > 0) setVideoDuration(dur);
},
onStateChange: (event: YT.OnStateChangeEvent) => {
setIsPlaying(event.data === YT.PlayerState.PLAYING);
if (event.data === YT.PlayerState.PAUSED) {
const playerCurrentTime = playerRef.current?.getCurrentTime?.() || 0;
const playerDuration = playerRef.current?.getDuration?.() || 0;
scheduleWatchProgressSaveRef.current({
progress: playerCurrentTime,
duration: playerDuration,
immediate: true,
force: true,
});
}
if (event.data === YT.PlayerState.PLAYING) {
const dur = event.target.getDuration();
if (dur > 0) setVideoDuration(dur);
}
},
},
});
} else if (isBunny) {
const videoEl = videoRef.current;
if (!videoEl) return;
let cachedDuration = 0;
let destroyed = false;
let retryAttempt = 0;
let usingHlsJs = false;
let hlsInstance: Hls | null = null;
const clearRetryTimer = () => {
if (bunnyRetryTimerRef.current) {
clearTimeout(bunnyRetryTimerRef.current);
bunnyRetryTimerRef.current = null;
}
};
const scheduleRetry = (retryFn: () => void) => {
clearRetryTimer();
bunnyRetryTimerRef.current = setTimeout(() => {
if (!destroyed) {
retryFn();
}
}, 3000);
};
const getRetryUrl = () => {
retryAttempt += 1;
const separator = embedUrl.includes('?') ? '&' : '?';
return `${embedUrl}${separator}retry=${Date.now()}-${retryAttempt}`;
};
const retryNativeLoad = () => {
videoEl.src = getRetryUrl();
videoEl.load();
};
const retryHlsLoad = () => {
if (destroyed || !hlsInstance) return;
const retryUrl = getRetryUrl();
try {
hlsInstance.stopLoad();
} catch {
// ignore stop-load failures and continue with a fresh loadSource
}
hlsInstance.loadSource(retryUrl);
hlsInstance.startLoad(-1);
};
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;
clearRetryTimer();
setBunnyPlaybackState('none');
if (videoEl.videoWidth > 0 && videoEl.videoHeight > 0) {
setIsBunnyPortraitSource(videoEl.videoHeight > videoEl.videoWidth);
}
setIsReady(true);
syncDuration();
};
const onPlay = () => {
setIsPlaying(true);
setBunnyPlaybackState('none');
syncDuration();
};
const onPause = () => {
setIsPlaying(false);
saveProgress();
};
const onEnded = () => {
setIsPlaying(false);
saveProgress();
};
const onTimeUpdate = () => {
if (!isDraggingRef.current) {
setCurrentTime(videoEl.currentTime || 0);
}
if (Number.isFinite(videoEl.duration) && videoEl.duration > 0 && videoEl.duration !== cachedDuration) {
cachedDuration = videoEl.duration;
setVideoDuration(videoEl.duration);
}
};
const onVideoError = () => {
if (destroyed) return;
if (usingHlsJs) return;
if (videoEl.readyState >= HTMLMediaElement.HAVE_METADATA) {
setBunnyPlaybackState('error');
return;
}
setIsReady(false);
setBunnyPlaybackState('processing');
scheduleRetry(retryNativeLoad);
};
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 configureHlsLevels = (levels: Level[]) => {
setQualityOptions(levels.map((level, index) => ({
level: index,
label: formatBunnyQualityLabel(level, index),
})));
setSelectedQualityLevel(-1);
};
if (videoEl.canPlayType('application/vnd.apple.mpegurl')) {
videoEl.src = embedUrl;
videoEl.load();
} else if (Hls.isSupported()) {
usingHlsJs = true;
const hls = new Hls();
hlsInstance = hls;
hlsRef.current = hls;
hls.attachMedia(videoEl);
hls.on(Hls.Events.MEDIA_ATTACHED, () => {
if (!destroyed) {
hls.loadSource(embedUrl);
}
});
hls.on(Hls.Events.MANIFEST_PARSED, (_, data) => {
if (destroyed) return;
clearRetryTimer();
setBunnyPlaybackState('none');
configureHlsLevels(data.levels);
setIsReady(true);
syncDuration();
});
hls.on(Hls.Events.ERROR, (_, data) => {
if (destroyed) return;
const responseCode = (data as { response?: { code?: number } }).response?.code;
const isManifestLoadFailure = data.details === Hls.ErrorDetails.MANIFEST_LOAD_ERROR
|| data.details === Hls.ErrorDetails.MANIFEST_LOAD_TIMEOUT;
const hasProcessingLikeStatus = responseCode === undefined
|| responseCode === 0
|| responseCode === 403
|| responseCode === 404
|| responseCode === 423
|| responseCode === 429
|| responseCode === 503;
const isLikelyProcessing = isManifestLoadFailure
&& hasProcessingLikeStatus;
const isNetworkPreMetadataProcessing = data.type === Hls.ErrorTypes.NETWORK_ERROR
&& hasProcessingLikeStatus
&& videoEl.readyState < HTMLMediaElement.HAVE_METADATA;
const isUnknownPreMetadataProcessing = !data.details
&& !data.type
&& videoEl.readyState < HTMLMediaElement.HAVE_METADATA;
if (isLikelyProcessing || isNetworkPreMetadataProcessing || isUnknownPreMetadataProcessing) {
setIsReady(false);
setBunnyPlaybackState('processing');
scheduleRetry(retryHlsLoad);
return;
}
if (data.fatal) {
setBunnyPlaybackState('error');
console.error('Fatal HLS error:', data);
}
});
} else {
setBunnyPlaybackState('error');
console.error('HLS is not supported in this browser.');
}
playerRef.current = {
playVideo: () => {
videoEl.play().catch((err) => console.error('Error playing Bunny 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;
clearRetryTimer();
videoEl.removeEventListener('loadedmetadata', onLoadedMetadata);
videoEl.removeEventListener('play', onPlay);
videoEl.removeEventListener('pause', onPause);
videoEl.removeEventListener('ended', onEnded);
videoEl.removeEventListener('timeupdate', onTimeUpdate);
videoEl.removeEventListener('error', onVideoError);
if (hlsRef.current) {
try { hlsRef.current.destroy(); } catch { /* ignore */ }
hlsRef.current = null;
}
videoEl.removeAttribute('src');
videoEl.load();
},
};
}
};
const timeout = setTimeout(() => {
if (isYoutube) {
if (window.YT?.Player) {
initPlayer();
} else {
window.onYouTubeIframeAPIReady = initPlayer;
}
} else if (isBunny) {
initPlayer();
}
}, 100);
return () => {
clearTimeout(timeout);
if (isYoutube) {
window.onYouTubeIframeAPIReady = undefined;
}
if (playerRef.current) {
try { playerRef.current.destroy(); } catch { /* ignore */ }
playerRef.current = null;
}
if (hlsRef.current) {
try { hlsRef.current.destroy(); } catch { /* ignore */ }
hlsRef.current = null;
}
if (bunnyRetryTimerRef.current) {
clearTimeout(bunnyRetryTimerRef.current);
bunnyRetryTimerRef.current = null;
}
};
}, [activeProviderId, activeVersionId, embedUrl, isApiLoaded, canInitializePlayer, formatBunnyQualityLabel, hlsRef, iframeRef, playerRef, scheduleWatchProgressSaveRef, videoRef]);
const toggleFullscreen = useCallback(() => {
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen().then(() => {
setIsFullscreenMode(true);
setShowComments(false);
}).catch((err) => {
console.error('Fullscreen failed:', err);
toast.error('Unable to enter fullscreen mode');
});
} else {
document.exitFullscreen().then(() => {
setIsFullscreenMode(false);
setShowComments(true);
}).catch((err) => {
console.error('Exit fullscreen failed:', err);
toast.error('Unable to exit fullscreen mode');
});
}
}, []);
useEffect(() => {
const handleFullscreenChange = () => {
const isCurrentlyFullscreen = !!document.fullscreenElement;
setIsFullscreenMode(isCurrentlyFullscreen);
if (isCurrentlyFullscreen) {
setShowComments(false);
} else {
setShowComments(true);
}
};
document.addEventListener('fullscreenchange', handleFullscreenChange);
return () => document.removeEventListener('fullscreenchange', handleFullscreenChange);
}, []);
useEffect(() => {
if (!isReady || !playerRef.current) return;
const interval = setInterval(() => {
if (!isDragging && playerRef.current) {
if (playerRef.current.getCurrentTime) {
setCurrentTime(playerRef.current.getCurrentTime());
}
}
}, 250);
return () => clearInterval(interval);
}, [isReady, isDragging, activeVersion?.providerId, playerRef]);
const duration = useMemo(() => {
return videoDuration || activeVersion?.duration || 0;
}, [videoDuration, activeVersion?.duration]);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
const target = e.target as HTMLElement;
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) {
return;
}
const isBunnyBlocked = activeVersion?.providerId === 'bunny' && bunnyPlaybackState !== 'none';
const isPlaybackControlKey = [
'Space',
'KeyK',
'ArrowLeft',
'ArrowRight',
'ArrowUp',
'ArrowDown',
'Comma',
'Period',
'KeyM',
'KeyJ',
'KeyL',
].includes(e.code);
if (isBunnyBlocked && isPlaybackControlKey) {
e.preventDefault();
return;
}
switch (e.code) {
case 'Space':
case 'KeyK':
e.preventDefault();
if (playerRef.current) {
if (isPlaying) {
playerRef.current.pauseVideo();
} else {
playerRef.current.playVideo();
}
}
break;
case 'ArrowLeft':
e.preventDefault();
if (playerRef.current) {
const newTime = Math.max(0, currentTime - 5);
if (playerRef.current.seekTo) {
playerRef.current.seekTo(newTime, true);
}
setCurrentTime(newTime);
}
break;
case 'ArrowRight':
e.preventDefault();
if (playerRef.current) {
const newTime = Math.min(duration, currentTime + 5);
if (playerRef.current.seekTo) {
playerRef.current.seekTo(newTime, true);
}
setCurrentTime(newTime);
}
break;
case 'ArrowUp':
e.preventDefault();
{
const currentIndex = speedOptions.indexOf(playbackSpeed);
if (currentIndex < speedOptions.length - 1) {
const newSpeed = speedOptions[currentIndex + 1];
setPlaybackSpeed(newSpeed);
playerRef.current?.setPlaybackRate(newSpeed);
}
}
break;
case 'ArrowDown':
e.preventDefault();
{
const currentIndex = speedOptions.indexOf(playbackSpeed);
if (currentIndex > 0) {
const newSpeed = speedOptions[currentIndex - 1];
setPlaybackSpeed(newSpeed);
playerRef.current?.setPlaybackRate(newSpeed);
}
}
break;
case 'Comma':
if (e.shiftKey) {
e.preventDefault();
const currentIndex = speedOptions.indexOf(playbackSpeed);
if (currentIndex > 0) {
const newSpeed = speedOptions[currentIndex - 1];
setPlaybackSpeed(newSpeed);
playerRef.current?.setPlaybackRate(newSpeed);
}
}
break;
case 'Period':
if (e.shiftKey) {
e.preventDefault();
const currentIndex = speedOptions.indexOf(playbackSpeed);
if (currentIndex < speedOptions.length - 1) {
const newSpeed = speedOptions[currentIndex + 1];
setPlaybackSpeed(newSpeed);
playerRef.current?.setPlaybackRate(newSpeed);
}
}
break;
case 'KeyM':
e.preventDefault();
if (playerRef.current) {
if (isMuted) {
playerRef.current.unMute();
} else {
playerRef.current.mute();
}
setIsMuted(!isMuted);
}
break;
case 'KeyJ':
e.preventDefault();
if (playerRef.current?.seekTo) {
const newTime = Math.max(0, currentTime - 10);
playerRef.current.seekTo(newTime, true);
setCurrentTime(newTime);
}
break;
case 'KeyL':
e.preventDefault();
if (playerRef.current?.seekTo) {
const newTime = Math.min(duration, currentTime + 10);
playerRef.current.seekTo(newTime, true);
setCurrentTime(newTime);
}
break;
case 'KeyF':
e.preventDefault();
toggleFullscreen();
break;
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [activeVersion?.providerId, bunnyPlaybackState, isPlaying, currentTime, duration, isMuted, playbackSpeed, speedOptions, toggleFullscreen, playerRef]);
const handlePlayPause = useCallback(() => {
if (activeVersion?.providerId === 'bunny' && bunnyPlaybackState !== 'none') return;
if (!playerRef.current) return;
if (isPlaying) {
playerRef.current.pauseVideo();
} else {
playerRef.current.playVideo();
}
}, [activeVersion?.providerId, bunnyPlaybackState, isPlaying, playerRef]);
const handleSeekToTimestamp = useCallback((timestamp: number, annotation?: string | null) => {
setCurrentTime(timestamp);
if (playerRef.current?.seekTo) {
const playerState = playerRef.current.getPlayerState?.();
const ytPlayingState = window.YT?.PlayerState?.PLAYING ?? 1;
const ytBufferingState = window.YT?.PlayerState?.BUFFERING ?? 3;
const wasPlayingBeforeSeek = typeof playerState === 'number'
? playerState === ytPlayingState || playerState === ytBufferingState
: isPlaying;
playerRef.current.seekTo(timestamp, true);
if (wasPlayingBeforeSeek) {
playerRef.current.playVideo();
} else {
playerRef.current.pauseVideo();
}
}
if (annotation) {
try {
const strokes = JSON.parse(annotation) as AnnotationStroke[];
setViewingAnnotation(strokes);
} catch {
setViewingAnnotation(null);
}
} else {
setViewingAnnotation(null);
}
}, [isPlaying, playerRef, setViewingAnnotation]);
const handleMuteToggle = useCallback(() => {
if (!playerRef.current) return;
if (isMuted) {
playerRef.current.unMute();
} else {
playerRef.current.mute();
}
setIsMuted(!isMuted);
}, [isMuted, playerRef]);
const handleSkip = useCallback(
(seconds: number) => {
const newTime = Math.max(0, Math.min(duration, currentTime + seconds));
handleSeekToTimestamp(newTime);
},
[currentTime, duration, handleSeekToTimestamp]
);
const handleSpeedChange = useCallback(
(speed: number) => {
setPlaybackSpeed(speed);
playerRef.current?.setPlaybackRate(speed);
},
[playerRef]
);
const handleQualityChange = useCallback((level: number) => {
const hls = hlsRef.current;
if (!hls) return;
if (level === -1) {
hls.currentLevel = -1;
hls.nextLevel = -1;
setSelectedQualityLevel(-1);
return;
}
hls.currentLevel = level;
hls.nextLevel = level;
setSelectedQualityLevel(level);
}, [hlsRef]);
const handleTimelineClick = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
if (!timelineRef.current) return;
const rect = timelineRef.current.getBoundingClientRect();
const x = e.clientX - rect.left;
const percentage = Math.max(0, Math.min(1, x / rect.width));
const newTime = percentage * duration;
handleSeekToTimestamp(newTime);
},
[duration, handleSeekToTimestamp, timelineRef]
);
const handleTimelineMouseDown = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
setIsDragging(true);
handleTimelineClick(e);
},
[handleTimelineClick]
);
const handleTimelineMouseMove = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
if (!isDragging || !timelineRef.current) return;
const rect = timelineRef.current.getBoundingClientRect();
const x = e.clientX - rect.left;
const percentage = Math.max(0, Math.min(1, x / rect.width));
setCurrentTime(percentage * duration);
},
[isDragging, duration, timelineRef]
);
const handleTimelineMouseUp = useCallback(() => {
if (isDragging) {
handleSeekToTimestamp(currentTime);
setIsDragging(false);
}
}, [isDragging, currentTime, handleSeekToTimestamp]);
return {
isReady,
bunnyPlaybackState,
currentTime,
setCurrentTime,
videoDuration,
setVideoDuration,
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,
};
}
@@ -0,0 +1,270 @@
'use client';
import { useCallback, useEffect, useRef, useState, type RefObject } from 'react';
import type { PlayerAdapter, WatchProgressConfig } from '@/components/video-page/types';
interface UseWatchProgressParams extends WatchProgressConfig {
playerRef: RefObject<YT.Player | PlayerAdapter | null>;
isReady: boolean;
currentTime: number;
videoDuration: number;
}
export function useWatchProgress({
videoId,
activeVersionId,
isAuthenticated,
pathname,
playerRef,
isReady,
currentTime,
videoDuration,
}: UseWatchProgressParams) {
const [savedProgress, setSavedProgress] = useState<number | null>(null);
const [showResumePrompt, setShowResumePrompt] = useState(false);
const [progressFetchKey, setProgressFetchKey] = useState(0);
const videoDurationRef = useRef(0);
const progressSaveTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const progressDebounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const progressWriteInFlightRef = useRef(false);
const pendingProgressPayloadRef = useRef<{ progress: number; duration: number; force: boolean } | null>(null);
const lastSavedProgressRef = useRef<number>(0);
const lastPathnameRef = useRef<string>(pathname);
const flushScheduledWatchProgress = useCallback(async () => {
if (!isAuthenticated || !activeVersionId || progressWriteInFlightRef.current) return;
const nextPayload = pendingProgressPayloadRef.current;
if (!nextPayload) return;
if (!nextPayload.force && Math.abs(nextPayload.progress - lastSavedProgressRef.current) < 2) {
pendingProgressPayloadRef.current = null;
return;
}
pendingProgressPayloadRef.current = null;
progressWriteInFlightRef.current = true;
try {
const response = await fetch(`/api/watch/${videoId}/progress`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
progress: nextPayload.progress,
duration: nextPayload.duration,
versionId: activeVersionId,
}),
});
if (response.ok) {
lastSavedProgressRef.current = nextPayload.progress;
}
} catch (err) {
console.error('Error saving watch progress:', err);
} finally {
progressWriteInFlightRef.current = false;
if (pendingProgressPayloadRef.current) {
void flushScheduledWatchProgress();
}
}
}, [isAuthenticated, activeVersionId, videoId]);
const scheduleWatchProgressSave = useCallback((input: {
progress: number;
duration?: number;
immediate?: boolean;
force?: boolean;
}) => {
if (!isAuthenticated || !activeVersionId) return;
const progress = Math.max(0, input.progress);
if (progress <= 0) return;
const duration = Math.max(0, input.duration ?? videoDurationRef.current ?? 0);
const force = input.force ?? false;
if (!force && Math.abs(progress - lastSavedProgressRef.current) < 2) {
return;
}
const existingPayload = pendingProgressPayloadRef.current;
pendingProgressPayloadRef.current = existingPayload
? {
progress: Math.max(existingPayload.progress, progress),
duration: Math.max(existingPayload.duration, duration),
force: existingPayload.force || force,
}
: { progress, duration, force };
if (input.immediate) {
if (progressDebounceTimerRef.current) {
clearTimeout(progressDebounceTimerRef.current);
progressDebounceTimerRef.current = null;
}
void flushScheduledWatchProgress();
return;
}
if (progressDebounceTimerRef.current) {
clearTimeout(progressDebounceTimerRef.current);
}
progressDebounceTimerRef.current = setTimeout(() => {
progressDebounceTimerRef.current = null;
void flushScheduledWatchProgress();
}, 800);
}, [isAuthenticated, activeVersionId, flushScheduledWatchProgress]);
useEffect(() => {
return () => {
if (progressDebounceTimerRef.current) {
clearTimeout(progressDebounceTimerRef.current);
progressDebounceTimerRef.current = null;
}
};
}, []);
useEffect(() => {
videoDurationRef.current = videoDuration;
}, [videoDuration]);
useEffect(() => {
lastSavedProgressRef.current = 0;
pendingProgressPayloadRef.current = null;
progressWriteInFlightRef.current = false;
if (progressDebounceTimerRef.current) {
clearTimeout(progressDebounceTimerRef.current);
progressDebounceTimerRef.current = null;
}
}, [videoId, activeVersionId]);
const loadWatchProgress = useCallback(async (showPrompt = true) => {
if (!isAuthenticated || !activeVersionId) return;
setSavedProgress(null);
setShowResumePrompt(false);
try {
const res = await fetch(`/api/watch/${videoId}/progress`, { cache: 'no-store' });
if (res.ok) {
const response = await res.json();
const progress = response.data?.progress || 0;
const percentage = response.data?.percentage || 0;
if (showPrompt && percentage > 5 && percentage < 95) {
setSavedProgress(progress);
setShowResumePrompt(true);
}
}
} catch (err) {
console.error('Error loading watch progress:', err);
}
}, [isAuthenticated, activeVersionId, videoId]);
useEffect(() => {
loadWatchProgress();
}, [loadWatchProgress, progressFetchKey]);
useEffect(() => {
if (lastPathnameRef.current !== pathname) {
const previousPath = lastPathnameRef.current;
lastPathnameRef.current = pathname;
if (previousPath !== pathname) {
setProgressFetchKey((k) => k + 1);
}
}
}, [pathname]);
useEffect(() => {
if (!isAuthenticated || !isReady || !activeVersionId) return;
progressSaveTimerRef.current = setInterval(() => {
if (playerRef.current?.getCurrentTime) {
scheduleWatchProgressSave({
progress: playerRef.current.getCurrentTime(),
duration: playerRef.current.getDuration?.() || videoDuration,
});
}
}, 5000);
return () => {
if (progressSaveTimerRef.current) {
clearInterval(progressSaveTimerRef.current);
progressSaveTimerRef.current = null;
}
};
}, [isAuthenticated, isReady, videoDuration, activeVersionId, scheduleWatchProgressSave, playerRef]);
useEffect(() => {
if (!isAuthenticated) return;
const saveProgressOnLeave = () => {
const playerCurrentTime = playerRef.current?.getCurrentTime?.() || currentTime;
const playerDuration = playerRef.current?.getDuration?.() || videoDuration;
const pendingPayload = pendingProgressPayloadRef.current;
const finalProgress = Math.max(playerCurrentTime, pendingPayload?.progress ?? 0);
const finalDuration = Math.max(playerDuration, pendingPayload?.duration ?? 0);
if (finalProgress > 0 && navigator.sendBeacon && activeVersionId) {
if (progressDebounceTimerRef.current) {
clearTimeout(progressDebounceTimerRef.current);
progressDebounceTimerRef.current = null;
}
const data = new Blob([JSON.stringify({
progress: finalProgress,
duration: finalDuration,
versionId: activeVersionId,
})], { type: 'application/json' });
navigator.sendBeacon(`/api/watch/${videoId}/progress`, data);
}
};
const handleVisibilityChange = () => {
const playerCurrentTime = playerRef.current?.getCurrentTime?.() || 0;
const playerDuration = playerRef.current?.getDuration?.() || videoDuration;
if (document.visibilityState === 'hidden') {
scheduleWatchProgressSave({
progress: playerCurrentTime,
duration: playerDuration,
immediate: true,
force: true,
});
}
};
window.addEventListener('beforeunload', saveProgressOnLeave);
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
window.removeEventListener('beforeunload', saveProgressOnLeave);
document.removeEventListener('visibilitychange', handleVisibilityChange);
};
}, [isAuthenticated, currentTime, videoDuration, activeVersionId, videoId, scheduleWatchProgressSave, playerRef]);
const handleResumeFromSaved = useCallback(() => {
if (savedProgress !== null && playerRef.current) {
if (playerRef.current.seekTo) {
playerRef.current.seekTo(savedProgress, true);
}
setShowResumePrompt(false);
setSavedProgress(null);
return savedProgress;
}
return null;
}, [savedProgress, playerRef]);
const handleDismissResume = useCallback(() => {
setShowResumePrompt(false);
setSavedProgress(null);
}, []);
return {
savedProgress,
showResumePrompt,
scheduleWatchProgressSave,
loadWatchProgress,
handleResumeFromSaved,
handleDismissResume,
};
}
@@ -0,0 +1,81 @@
'use client';
import { memo } from 'react';
import { Download, X } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog';
interface ImagePreviewDialogProps {
previewImage: string | null;
onClose: () => void;
}
export const ImagePreviewDialog = memo(function ImagePreviewDialog({
previewImage,
onClose,
}: ImagePreviewDialogProps) {
return (
<Dialog open={!!previewImage} onOpenChange={(open) => !open && onClose()}>
<DialogContent
showCloseButton={false}
className="max-w-none sm:max-w-none w-screen h-screen max-h-screen p-0 overflow-hidden bg-black/90 border-none shadow-none flex flex-col items-center justify-center rounded-none"
>
<DialogTitle className="sr-only">Image Preview</DialogTitle>
<div className="absolute top-4 right-4 flex gap-3 z-50">
<Button
variant="outline"
size="icon"
className="rounded-full bg-black/40 hover:bg-black/80 border-white/20 text-white h-10 w-10 backdrop-blur-md transition-all shrink-0"
onClick={async (e) => {
e.stopPropagation();
try {
if (!previewImage) return;
const response = await fetch(previewImage);
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = previewImage.split('/').pop() || 'attachment.png';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
} catch (error) {
console.error('Failed to download image:', error);
toast.error('Failed to download image');
}
}}
>
<Download className="h-5 w-5" />
</Button>
<Button
variant="outline"
size="icon"
className="rounded-full bg-black/40 hover:bg-black/80 border-white/20 text-white h-10 w-10 backdrop-blur-md transition-all shrink-0"
onClick={(e) => {
e.stopPropagation();
onClose();
}}
>
<X className="h-5 w-5" />
</Button>
</div>
<div
className="relative w-full h-full flex items-center justify-center p-4 cursor-zoom-out"
onClick={onClose}
>
{previewImage && (
// eslint-disable-next-line @next/next/no-img-element
<img
src={previewImage}
alt="Preview"
className="max-w-[95vw] max-h-[90vh] object-contain rounded-md select-none cursor-default"
onClick={(e) => e.stopPropagation()}
/>
)}
</div>
</DialogContent>
</Dialog>
);
});
+482
View File
@@ -0,0 +1,482 @@
'use client';
import { memo, type RefObject } from 'react';
import {
AlertCircle,
Clock,
Gauge,
Maximize,
MessageSquare,
MessageSquareOff,
Minimize,
Pause,
Play,
SkipBack,
SkipForward,
Volume2,
VolumeX,
Loader2,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { cn } from '@/lib/utils';
import { AnnotationCanvas, type AnnotationCanvasHandle, type AnnotationStroke } from '@/components/annotation-canvas';
import type { BunnyQualityOption, CommentMarker } from '@/components/video-page/types';
interface PlayerCoreProps {
activeVersionId: string | null;
activeProviderId: string | undefined;
embedUrl: string;
videoRef: RefObject<HTMLVideoElement | null>;
iframeRef: RefObject<HTMLIFrameElement | null>;
bunnyViewportRef: RefObject<HTMLDivElement | null>;
timelineRef: RefObject<HTMLDivElement | null>;
videoContainerRef: RefObject<HTMLDivElement | null>;
isFullscreenMode: boolean;
cursorIdle: boolean;
isPlaying: boolean;
handlePlayPause: () => void;
handleVideoMouseMove: () => void;
handleVideoMouseLeave: () => void;
isBunnyPortraitSource: boolean;
bunnyPortraitFrameWidth: number;
showBunnyProcessingOverlay: boolean;
showBunnyErrorOverlay: boolean;
showResumePrompt: boolean;
savedProgress: number | null;
formatTime: (value: number) => string;
handleResumeFromSaved: () => void;
handleDismissResume: () => void;
isAnnotating: boolean;
annotationCanvasRef: RefObject<AnnotationCanvasHandle | null>;
setAnnotationStrokes: (strokes: AnnotationStroke[] | null) => void;
setIsAnnotating: (value: boolean) => void;
setViewingAnnotation: (strokes: AnnotationStroke[] | null) => void;
viewingAnnotation: AnnotationStroke[] | null;
isEditingAnnotation: boolean;
editAnnotationCanvasRef: RefObject<AnnotationCanvasHandle | null>;
editAnnotationInitialStrokes?: AnnotationStroke[];
setEditAnnotationData: (value: string | null | undefined) => void;
setIsEditingAnnotation: (value: boolean) => void;
currentTime: number;
duration: number;
handleSkip: (seconds: number) => void;
handleMuteToggle: () => void;
isMuted: boolean;
selectedQualityLabel: string;
selectedQualityLevel: number;
qualityOptions: BunnyQualityOption[];
handleQualityChange: (level: number) => void;
playbackSpeed: number;
speedOptions: number[];
handleSpeedChange: (speed: number) => void;
toggleFullscreen: () => void;
showComments: boolean;
setShowComments: (value: boolean) => void;
setIsMobileCommentsOpen: (value: boolean) => void;
handleTimelineMouseDown: (e: React.MouseEvent<HTMLDivElement>) => void;
handleTimelineMouseMove: (e: React.MouseEvent<HTMLDivElement>) => void;
handleSeekToTimestamp: (timestamp: number, annotation?: string | null) => void;
commentMarkers: CommentMarker[];
}
export const PlayerCore = memo(function PlayerCore({
activeVersionId,
activeProviderId,
embedUrl,
videoRef,
iframeRef,
bunnyViewportRef,
timelineRef,
videoContainerRef,
isFullscreenMode,
cursorIdle,
isPlaying,
handlePlayPause,
handleVideoMouseMove,
handleVideoMouseLeave,
isBunnyPortraitSource,
bunnyPortraitFrameWidth,
showBunnyProcessingOverlay,
showBunnyErrorOverlay,
showResumePrompt,
savedProgress,
formatTime,
handleResumeFromSaved,
handleDismissResume,
isAnnotating,
annotationCanvasRef,
setAnnotationStrokes,
setIsAnnotating,
setViewingAnnotation,
viewingAnnotation,
isEditingAnnotation,
editAnnotationCanvasRef,
editAnnotationInitialStrokes,
setEditAnnotationData,
setIsEditingAnnotation,
currentTime,
duration,
handleSkip,
handleMuteToggle,
isMuted,
selectedQualityLabel,
selectedQualityLevel,
qualityOptions,
handleQualityChange,
playbackSpeed,
speedOptions,
handleSpeedChange,
toggleFullscreen,
showComments,
setShowComments,
setIsMobileCommentsOpen,
handleTimelineMouseDown,
handleTimelineMouseMove,
handleSeekToTimestamp,
commentMarkers,
}: PlayerCoreProps) {
return (
<>
<div
ref={videoContainerRef}
className={cn(
'flex-1 bg-black flex items-center justify-center relative cursor-pointer group min-h-0',
isFullscreenMode && 'absolute inset-0',
cursorIdle && isPlaying && 'cursor-none'
)}
onClick={handlePlayPause}
onMouseMove={handleVideoMouseMove}
onMouseLeave={handleVideoMouseLeave}
>
<div className={cn('relative w-full h-full', isFullscreenMode && 'absolute inset-0')}>
{activeProviderId === 'bunny' ? (
<div ref={bunnyViewportRef} className="absolute inset-0 flex items-center justify-center bg-black">
<div
className={cn(
'relative flex items-center justify-center bg-black',
isBunnyPortraitSource ? 'h-full overflow-hidden' : 'w-full h-full'
)}
style={isBunnyPortraitSource && bunnyPortraitFrameWidth > 0 ? { width: `${bunnyPortraitFrameWidth}px` } : undefined}
>
<video
key={activeVersionId}
ref={videoRef}
className="w-full h-full object-contain border-0 bg-black"
style={{
width: '100%',
height: '100%',
objectFit: 'contain',
objectPosition: 'center',
backgroundColor: 'black',
}}
preload="metadata"
playsInline
/>
</div>
</div>
) : (
<iframe
key={activeVersionId}
ref={iframeRef}
src={embedUrl}
width="100%"
height="100%"
className="absolute inset-0 w-full h-full border-0"
referrerPolicy="origin-when-cross-origin"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
/>
)}
<div
className={cn(
'absolute inset-0 flex items-center justify-center bg-black/20 transition-opacity duration-300',
(showBunnyProcessingOverlay || showBunnyErrorOverlay) && 'opacity-0 pointer-events-none',
isPlaying
? cursorIdle ? 'opacity-0' : 'opacity-0 group-hover:opacity-100'
: 'opacity-100'
)}
>
<div className="w-16 h-16 rounded-full bg-black/60 flex items-center justify-center relative z-10">
{isPlaying ? (
<Pause className="h-8 w-8 text-white relative right-[-1px]" />
) : (
<Play className="h-8 w-8 text-white relative left-[2px]" />
)}
</div>
</div>
{showBunnyProcessingOverlay && (
<div className="absolute inset-0 z-20 flex items-center justify-center bg-black/65">
<div className="max-w-sm rounded-md border bg-background/95 px-4 py-3 text-center shadow-lg">
<div className="mb-2 flex items-center justify-center gap-2 text-sm font-medium">
<Loader2 className="h-4 w-4 animate-spin" />
Video Is Processing
</div>
<p className="text-xs text-muted-foreground">
This video is still processing. We&apos;ll keep retrying every few seconds.
</p>
</div>
</div>
)}
{showBunnyErrorOverlay && (
<div className="absolute inset-0 z-20 flex items-center justify-center bg-black/65">
<div className="max-w-sm rounded-md border bg-background/95 px-4 py-3 text-center shadow-lg">
<div className="mb-2 flex items-center justify-center gap-2 text-sm font-medium">
<AlertCircle className="h-4 w-4 text-destructive" />
Unable To Load Video
</div>
<p className="text-xs text-muted-foreground">
The Bunny stream is unavailable right now. Please refresh this page in a moment.
</p>
</div>
</div>
)}
{showResumePrompt && savedProgress !== null && (
<div className="absolute inset-0 flex items-center justify-center bg-black/40 z-10">
<div className="bg-background/95 backdrop-blur-sm rounded-lg p-4 shadow-lg max-w-sm mx-4">
<div className="flex items-center gap-3 mb-3">
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center">
<Clock className="h-5 w-5 text-primary" />
</div>
<div>
<p className="font-medium text-sm">Continue watching?</p>
<p className="text-xs text-muted-foreground">
Resume from {formatTime(savedProgress)}
</p>
</div>
</div>
<div className="flex gap-2">
<Button
size="sm"
variant="default"
onClick={handleResumeFromSaved}
className="flex-1"
>
<Play className="h-4 w-4 mr-1" />
Resume
</Button>
<Button
size="sm"
variant="ghost"
onClick={handleDismissResume}
className="flex-1"
>
Start over
</Button>
</div>
</div>
</div>
)}
{isAnnotating && (
<AnnotationCanvas
ref={annotationCanvasRef}
mode="draw"
onConfirm={(strokes) => {
setAnnotationStrokes(strokes);
setIsAnnotating(false);
}}
onCancel={() => {
setIsAnnotating(false);
setAnnotationStrokes(null);
}}
/>
)}
{viewingAnnotation && !isAnnotating && !isEditingAnnotation && (
<AnnotationCanvas
mode="view"
strokes={viewingAnnotation}
onDismiss={() => setViewingAnnotation(null)}
/>
)}
{isEditingAnnotation && (
<AnnotationCanvas
ref={editAnnotationCanvasRef}
mode="draw"
strokes={editAnnotationInitialStrokes}
onConfirm={(strokes) => {
setEditAnnotationData(JSON.stringify(strokes));
setIsEditingAnnotation(false);
}}
onCancel={() => {
setIsEditingAnnotation(false);
}}
/>
)}
</div>
</div>
<div className={cn(
'shrink-0 px-4 py-2 bg-background border-t',
isFullscreenMode ? 'absolute bottom-0 left-0 right-0 z-50 transition-opacity duration-300' : '',
isFullscreenMode && cursorIdle && isPlaying && 'opacity-0 pointer-events-none'
)}>
<div className="flex items-center gap-1 mb-2">
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={handlePlayPause}>
{isPlaying ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4 ml-0.5" />}
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => handleSkip(-10)}
title="Back 10s"
>
<SkipBack className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => handleSkip(10)}
title="Forward 10s"
>
<SkipForward className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={handleMuteToggle}
>
{isMuted ? <VolumeX className="h-4 w-4" /> : <Volume2 className="h-4 w-4" />}
</Button>
<span className="text-xs text-muted-foreground ml-1 tabular-nums">
{formatTime(currentTime)} / {formatTime(duration)}
</span>
<div className="ml-auto flex items-center">
{activeProviderId === 'bunny' && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-8 gap-1 text-xs">
Quality {selectedQualityLabel}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-[120px]">
<DropdownMenuItem
onClick={() => handleQualityChange(-1)}
className={cn(selectedQualityLevel === -1 && 'font-bold text-primary')}
>
Auto
</DropdownMenuItem>
{qualityOptions.length > 0 && <DropdownMenuSeparator />}
{qualityOptions.map((option) => (
<DropdownMenuItem
key={option.level}
onClick={() => handleQualityChange(option.level)}
className={cn(option.level === selectedQualityLevel && 'font-bold text-primary')}
>
{option.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-8 gap-1 text-xs">
<Gauge className="h-3.5 w-3.5" />
{playbackSpeed === 1 ? '1x' : `${playbackSpeed}x`}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-[80px]">
{speedOptions.map((speed) => (
<DropdownMenuItem
key={speed}
onClick={() => handleSpeedChange(speed)}
className={cn(speed === playbackSpeed && 'font-bold text-primary')}
>
{speed}x
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={toggleFullscreen}
title={isFullscreenMode ? 'Exit fullscreen (F)' : 'Fullscreen (F)'}
>
{isFullscreenMode ? <Minimize className="h-4 w-4" /> : <Maximize className="h-4 w-4" />}
</Button>
{isFullscreenMode ? (
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => setShowComments(!showComments)}
title={showComments ? 'Hide comments' : 'Show comments'}
>
{showComments ? <MessageSquareOff className="h-4 w-4" /> : <MessageSquare className="h-4 w-4" />}
</Button>
) : (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 lg:hidden"
onClick={() => setIsMobileCommentsOpen(true)}
title="Show comments"
>
<MessageSquare className="h-4 w-4" />
</Button>
)}
</div>
</div>
<div
ref={timelineRef}
className="relative h-8 bg-muted rounded cursor-pointer select-none"
onMouseDown={handleTimelineMouseDown}
onMouseMove={handleTimelineMouseMove}
>
<div
className="absolute left-0 top-0 h-full bg-primary/30 rounded pointer-events-none"
style={{ width: `${duration > 0 ? (currentTime / duration) * 100 : 0}%` }}
/>
<div
className="absolute top-0 h-full w-1 bg-primary rounded pointer-events-none"
style={{ left: `calc(${duration > 0 ? (currentTime / duration) * 100 : 0}% - 2px)` }}
/>
{commentMarkers.map((comment) => (
<button
key={comment.id}
onClick={(e) => {
e.stopPropagation();
handleSeekToTimestamp(comment.timestamp, comment.annotationData);
}}
className="absolute top-1/2 -translate-y-1/2 w-3 h-3 rounded-full transition-transform hover:scale-150 z-10"
style={{
left: `calc(${duration > 0 ? (comment.timestamp / duration) * 100 : 0}% - 6px)`,
backgroundColor: comment.color,
}}
title={`${formatTime(comment.timestamp)}${comment.preview}`}
/>
))}
</div>
</div>
</>
);
});
+155
View File
@@ -0,0 +1,155 @@
export interface Version {
id: string;
versionNumber: number;
versionLabel: string | null;
providerId: string;
videoId: string;
originalUrl: string;
title: string | null;
thumbnailUrl: string | null;
duration: number | null;
isActive: boolean;
_count: { comments: number };
}
export interface CommentTag {
id: string;
name: string;
color: string;
}
export interface CommentReply {
id: string;
content: string | null;
voiceUrl: string | null;
voiceDuration: number | null;
imageUrl: string | null;
annotationData: string | null;
createdAt: string;
author: { id: string; name: string | null; image: string | null } | null;
guestName: string | null;
canEdit?: boolean;
canDelete?: boolean;
tag: CommentTag | null;
}
export interface Comment {
id: string;
content: string | null;
timestamp: number;
voiceUrl: string | null;
voiceDuration: number | null;
imageUrl: string | null;
annotationData: string | null;
isResolved: boolean;
createdAt: string;
author: { id: string; name: string | null; image: string | null } | null;
guestName: string | null;
canEdit?: boolean;
canDelete?: boolean;
tag: CommentTag | null;
replies: CommentReply[];
}
export interface VideoData {
id: string;
title: string;
description: string | null;
projectId: string;
project: {
name: string;
ownerId: string;
members?: { role: string }[];
visibility?: string;
};
versions: (Version & { comments: Comment[] })[];
isAuthenticated: boolean;
currentUserId: string | null;
currentUserName: string | null;
canComment?: boolean;
canDownload?: boolean;
canManageTags?: boolean;
canResolveComments?: boolean;
}
export interface BunnyQualityOption {
level: number;
label: string;
}
export type BunnyPlaybackState = 'none' | 'processing' | 'error';
export type BunnyDownloadPreference = 'original' | 'compressed';
export type DownloadTarget = BunnyDownloadPreference | 'direct';
export interface CommentMarker {
id: string;
timestamp: number;
color: string;
annotationData: string | null;
preview: string;
}
export interface PlayerAdapter {
playVideo: () => void;
pauseVideo: () => void;
seekTo: (time: number, allowSeekAhead?: boolean) => void;
mute: () => void;
unMute: () => void;
isMuted: () => boolean;
getCurrentTime: () => number;
getDuration: () => number;
getPlayerState: () => number;
setPlaybackRate: (rate: number) => void;
destroy: () => void;
off?: (event: string) => void;
}
export interface WatchProgressConfig {
videoId: string;
activeVersionId: string | null;
isAuthenticated: boolean;
pathname: string;
}
export interface WatchProgressState {
savedProgress: number | null;
showResumePrompt: boolean;
}
export interface CommentActionsConfig {
videoId: string;
}
export interface VersionActionsConfig {
projectId?: string;
videoId: string;
}
export interface VideoPageHeaderActions {
onVersionSelect: (versionId: string) => void;
onDeleteCurrentVersionClick: () => void;
onDownload: (preference?: BunnyDownloadPreference) => void;
onOpenCompare: () => void;
onCreateVersion: () => void;
}
export interface VideoPageCommentsActions {
onExportComments: (format: 'csv' | 'pdf') => void;
onResolveComment: (commentId: string, currentlyResolved: boolean) => void;
onEditComment: (commentId: string) => void;
onDeleteComment: (commentId: string) => void;
onReplyComment: (parentId: string, voiceData?: { url: string; duration: number }, imageData?: { url: string }) => void;
onSubmitReplyWithMedia: (parentId: string) => void;
onStartEditAnnotation: () => void;
}
export interface VideoPageComposerActions {
onSubmitCommentWithMedia: () => void;
onAddComment: () => void;
onPauseVideoForAnnotation: () => void;
}
export interface VideoPageCompareActions {
onToggleVersion: (versionId: string) => void;
onCompare: () => void;
}
@@ -0,0 +1,173 @@
'use client';
import { memo } from 'react';
import { AlertCircle, CheckCircle2, FileVideo, Link as LinkIcon, Loader2, Plus, UploadCloud } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import type { VideoSource } from '@/lib/video-providers';
interface VersionActionsDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
newVersionMode: 'url' | 'file';
onNewVersionModeChange: (mode: 'url' | 'file') => void;
newVersionUrl: string;
onNewVersionUrlChange: (url: string) => void;
newVersionUrlError: string;
newVersionSource: VideoSource | null;
newVersionFile: File | null;
onNewVersionFileChange: (file: File | null) => void;
newVersionLabel: string;
onNewVersionLabelChange: (label: string) => void;
newVersionUploadStatus: string;
newVersionUploadProgress: number;
isCreatingVersion: boolean;
versionsCount: number;
onCreateVersion: () => void;
}
export const VersionActionsDialog = memo(function VersionActionsDialog({
open,
onOpenChange,
newVersionMode,
onNewVersionModeChange,
newVersionUrl,
onNewVersionUrlChange,
newVersionUrlError,
newVersionSource,
newVersionFile,
onNewVersionFileChange,
newVersionLabel,
onNewVersionLabelChange,
newVersionUploadStatus,
newVersionUploadProgress,
isCreatingVersion,
versionsCount,
onCreateVersion,
}: VersionActionsDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogTrigger asChild>
<Button variant="outline" size="sm">
<Plus className="h-4 w-4 mr-1" />
New Version
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Add New Version</DialogTitle>
<DialogDescription>
Upload a new version of this video. The new version will become active.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 mt-2">
<Tabs value={newVersionMode} onValueChange={(v) => onNewVersionModeChange(v as 'url' | 'file')} className="mb-2">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="url">Link URL</TabsTrigger>
<TabsTrigger value="file">Upload File</TabsTrigger>
</TabsList>
</Tabs>
{newVersionMode === 'url' ? (
<div className="space-y-2">
<Label>Video URL</Label>
<div className="relative">
<LinkIcon className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="https://youtube.com/watch?v=..."
value={newVersionUrl}
onChange={(e) => onNewVersionUrlChange(e.target.value)}
className="pl-10"
disabled={isCreatingVersion}
/>
</div>
{newVersionUrlError && (
<p className="text-sm text-destructive flex items-center gap-1">
<AlertCircle className="h-4 w-4" />
{newVersionUrlError}
</p>
)}
{newVersionSource && (
<p className="text-sm text-green-600 flex items-center gap-1">
<CheckCircle2 className="h-4 w-4" />
{newVersionSource.providerId.charAt(0).toUpperCase() +
newVersionSource.providerId.slice(1)}{' '}
video detected
</p>
)}
</div>
) : (
<div className="space-y-2">
<Label htmlFor="versionFile">Video File</Label>
<div className="flex items-center justify-center w-full">
<label htmlFor="versionFile" className={`flex flex-col items-center justify-center w-full h-32 border-2 border-dashed rounded-lg cursor-pointer bg-muted/30 hover:bg-muted/50 transition-colors ${newVersionFile ? 'border-primary' : 'border-border'}`}>
<div className="flex flex-col items-center justify-center pt-5 pb-6">
{newVersionFile ? (
<>
<FileVideo className="w-8 h-8 mb-2 text-primary" />
<p className="mb-1 text-sm text-foreground font-medium truncate max-w-[200px]">{newVersionFile.name}</p>
<p className="text-xs text-muted-foreground">
{(newVersionFile.size / (1024 * 1024)).toFixed(2)} MB
</p>
</>
) : (
<>
<UploadCloud className="w-8 h-8 mb-2 text-muted-foreground" />
<p className="mb-1 text-sm text-muted-foreground">
<span className="font-semibold">Click to upload</span> or drag and drop
</p>
<p className="text-xs text-muted-foreground">MP4, WebM, or OGG</p>
</>
)}
</div>
<input id="versionFile" type="file" accept="video/*" className="hidden" onChange={(e) => {
const file = e.target.files?.[0];
if (file && file.type.startsWith('video/')) {
onNewVersionFileChange(file);
} else {
toast.error('Please select a valid video file');
}
}} disabled={isCreatingVersion} />
</label>
</div>
</div>
)}
<div className="space-y-2">
<Label>Version Label (optional)</Label>
<Input
placeholder="e.g. Final Cut, Review Round 2"
value={newVersionLabel}
onChange={(e) => onNewVersionLabelChange(e.target.value)}
disabled={isCreatingVersion}
/>
</div>
{newVersionUploadStatus && (
<div className="space-y-2">
<p className="text-sm text-muted-foreground">{newVersionUploadStatus}</p>
{newVersionUploadProgress > 0 && newVersionUploadProgress < 100 && (
<div className="w-full bg-secondary rounded-full h-2">
<div className="bg-primary h-2 rounded-full transition-all" style={{ width: `${newVersionUploadProgress}%` }}></div>
</div>
)}
</div>
)}
<Button
onClick={onCreateVersion}
disabled={(newVersionMode === 'url' && !newVersionSource) || (newVersionMode === 'file' && !newVersionFile) || isCreatingVersion}
className="w-full"
>
{isCreatingVersion && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
Add Version {versionsCount + 1}
</Button>
</div>
</DialogContent>
</Dialog>
);
});
@@ -0,0 +1,52 @@
'use client';
import { memo } from 'react';
import { Loader2 } from 'lucide-react';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
interface VersionDeleteDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
isDeletingVersion: boolean;
onDelete: () => void;
}
export const VersionDeleteDialog = memo(function VersionDeleteDialog({
open,
onOpenChange,
isDeletingVersion,
onDelete,
}: VersionDeleteDialogProps) {
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete this version?</AlertDialogTitle>
<AlertDialogDescription>
This will permanently delete this version and all its comments. This cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeletingVersion}>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={onDelete}
disabled={isDeletingVersion}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{isDeletingVersion && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
Delete Version
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
});
@@ -0,0 +1,33 @@
'use client';
import { memo } from 'react';
import Link from 'next/link';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
interface VideoPageErrorProps {
containerHeight: string;
error: string;
mode: 'dashboard' | 'watch';
projectId?: string;
}
export const VideoPageError = memo(function VideoPageError({
containerHeight,
error,
mode,
projectId,
}: VideoPageErrorProps) {
return (
<div className={cn(containerHeight, 'flex items-center justify-center bg-background')}>
<div className="text-center">
<p className="text-muted-foreground mb-4">{error || 'Video not found'}</p>
<Button asChild variant="outline">
<Link href={mode === 'dashboard' ? `/projects/${projectId}` : '/'}>
{mode === 'dashboard' ? 'Back to Project' : 'Go Home'}
</Link>
</Button>
</div>
</div>
);
});
+257
View File
@@ -0,0 +1,257 @@
'use client';
import { memo } from 'react';
import Link from 'next/link';
import { ArrowLeft, ChevronDown, GitCompareArrows, MoreVertical, Plus, Share2, Trash2 } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Separator } from '@/components/ui/separator';
import { cn } from '@/lib/utils';
import { DownloadControls, DownloadMenuItems } from '@/components/video-page/download-controls';
import { VersionDeleteDialog } from '@/components/video-page/version-delete-dialog';
import { VersionActionsDialog } from '@/components/video-page/version-actions-dialog';
import type { BunnyDownloadPreference, DownloadTarget, Version } from '@/components/video-page/types';
import type { VideoSource } from '@/lib/video-providers';
interface VideoPageHeaderProps {
mode: 'dashboard' | 'watch';
backHref: string;
title: string;
projectName: string;
isFullscreenMode: boolean;
cursorIdle: boolean;
isPlaying: boolean;
versions: Version[];
activeVersion: Version;
activeVersionId: string | null;
onVersionSelect: (versionId: string) => void;
onDeleteCurrentVersionClick: () => void;
showDeleteVersionDialog: boolean;
setShowDeleteVersionDialog: (open: boolean) => void;
isDeletingVersion: boolean;
onDeleteVersion: () => void;
videoCanDownload: boolean;
isDownloadingVideo: boolean;
activeDownloadTarget: DownloadTarget | null;
onDownload: (preference?: BunnyDownloadPreference) => void;
projectId?: string;
videoId: string;
showVersionDialog: boolean;
setShowVersionDialog: (open: boolean) => void;
newVersionMode: 'url' | 'file';
setNewVersionMode: (mode: 'url' | 'file') => void;
newVersionUrl: string;
handleNewVersionUrlChange: (url: string) => void;
newVersionUrlError: string;
newVersionSource: VideoSource | null;
newVersionFile: File | null;
setNewVersionFile: (file: File | null) => void;
newVersionLabel: string;
setNewVersionLabel: (value: string) => void;
newVersionUploadStatus: string;
newVersionUploadProgress: number;
isCreatingVersion: boolean;
onCreateVersion: () => void;
onOpenCompare: () => void;
}
export const VideoPageHeader = memo(function VideoPageHeader({
mode,
backHref,
title,
projectName,
isFullscreenMode,
cursorIdle,
isPlaying,
versions,
activeVersion,
activeVersionId,
onVersionSelect,
onDeleteCurrentVersionClick,
showDeleteVersionDialog,
setShowDeleteVersionDialog,
isDeletingVersion,
onDeleteVersion,
videoCanDownload,
isDownloadingVideo,
activeDownloadTarget,
onDownload,
projectId,
videoId,
showVersionDialog,
setShowVersionDialog,
newVersionMode,
setNewVersionMode,
newVersionUrl,
handleNewVersionUrlChange,
newVersionUrlError,
newVersionSource,
newVersionFile,
setNewVersionFile,
newVersionLabel,
setNewVersionLabel,
newVersionUploadStatus,
newVersionUploadProgress,
isCreatingVersion,
onCreateVersion,
onOpenCompare,
}: VideoPageHeaderProps) {
return (
<div className={cn(
'shrink-0 flex items-center justify-between h-12 px-4 border-b bg-background/50',
isFullscreenMode ? 'absolute top-0 left-0 right-0 z-50 transition-opacity duration-300' : '',
isFullscreenMode && cursorIdle && isPlaying && 'opacity-0 pointer-events-none'
)}>
<div className="flex items-center gap-3">
<Link
href={backHref}
className="inline-flex items-center text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<ArrowLeft className="h-4 w-4 mr-1" />
Back
</Link>
<Separator orientation="vertical" className="h-5" />
<div className="hidden sm:block min-w-0">
<span className="text-sm font-medium">{title}</span>
<span className="text-xs text-muted-foreground ml-2"> {projectName}</span>
</div>
</div>
<div className="flex items-center gap-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm">
<Badge variant="secondary" className="mr-2">
v{activeVersion.versionNumber}
</Badge>
{activeVersion.versionLabel || `Version ${activeVersion.versionNumber}`}
<ChevronDown className="h-4 w-4 ml-2" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{versions.map((version) => (
<DropdownMenuItem
key={version.id}
onClick={() => onVersionSelect(version.id)}
>
<Badge
variant={version.id === activeVersionId ? 'default' : 'secondary'}
className="mr-2"
>
v{version.versionNumber}
</Badge>
{version.versionLabel || `Version ${version.versionNumber}`}
<span className="ml-auto text-xs text-muted-foreground">
{version._count.comments} comments
</span>
</DropdownMenuItem>
))}
{mode === 'dashboard' && versions.length > 1 && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={onDeleteCurrentVersionClick}
>
<Trash2 className="h-4 w-4 mr-2" />
Delete Current Version
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
<VersionDeleteDialog
open={showDeleteVersionDialog}
onOpenChange={setShowDeleteVersionDialog}
isDeletingVersion={isDeletingVersion}
onDelete={onDeleteVersion}
/>
<DownloadControls
activeVersion={activeVersion}
videoCanDownload={videoCanDownload}
isDownloading={isDownloadingVideo}
activeDownloadTarget={activeDownloadTarget}
onDownload={onDownload}
/>
{mode === 'dashboard' && (
<>
<Button variant="outline" size="sm" asChild>
<Link href={`/projects/${projectId}/videos/${videoId}/share`}>
<Share2 className="h-4 w-4 mr-1" />
Share Video
</Link>
</Button>
<div className="hidden sm:flex items-center gap-2">
<VersionActionsDialog
open={showVersionDialog}
onOpenChange={setShowVersionDialog}
newVersionMode={newVersionMode}
onNewVersionModeChange={setNewVersionMode}
newVersionUrl={newVersionUrl}
onNewVersionUrlChange={handleNewVersionUrlChange}
newVersionUrlError={newVersionUrlError}
newVersionSource={newVersionSource}
newVersionFile={newVersionFile}
onNewVersionFileChange={setNewVersionFile}
newVersionLabel={newVersionLabel}
onNewVersionLabelChange={setNewVersionLabel}
newVersionUploadStatus={newVersionUploadStatus}
newVersionUploadProgress={newVersionUploadProgress}
isCreatingVersion={isCreatingVersion}
versionsCount={versions.length}
onCreateVersion={onCreateVersion}
/>
{versions.length >= 2 && (
<Button variant="outline" size="sm" onClick={onOpenCompare}>
<GitCompareArrows className="h-4 w-4 mr-1" />
Compare
</Button>
)}
</div>
<div className="sm:hidden">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon" className="h-8 w-8">
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DownloadMenuItems
activeVersion={activeVersion}
videoCanDownload={videoCanDownload}
isDownloading={isDownloadingVideo}
activeDownloadTarget={activeDownloadTarget}
onDownload={onDownload}
/>
<DropdownMenuItem onSelect={() => setShowVersionDialog(true)}>
<Plus className="h-4 w-4 mr-2" />
New Version
</DropdownMenuItem>
{versions.length >= 2 && (
<DropdownMenuItem onSelect={onOpenCompare}>
<GitCompareArrows className="h-4 w-4 mr-2" />
Compare
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
</>
)}
</div>
</div>
);
});
@@ -0,0 +1,93 @@
'use client';
import { memo } from 'react';
import { Separator } from '@/components/ui/separator';
import { Skeleton } from '@/components/ui/skeleton';
import { cn } from '@/lib/utils';
interface VideoPageLoadingProps {
containerHeight: string;
mode: 'dashboard' | 'watch';
isFullscreenMode: boolean;
cursorIdle: boolean;
isPlaying: boolean;
showComments: boolean;
}
export const VideoPageLoading = memo(function VideoPageLoading({
containerHeight,
mode,
isFullscreenMode,
cursorIdle,
isPlaying,
showComments,
}: VideoPageLoadingProps) {
return (
<div className={cn(containerHeight, 'flex flex-col bg-background overflow-hidden')}>
<div className="flex-1 flex overflow-hidden min-h-0">
<div className={cn('flex-1 flex flex-col overflow-hidden min-h-0', isFullscreenMode && 'relative')}>
<div className={cn('shrink-0 flex items-center justify-between h-12 px-4 border-b bg-background/50', isFullscreenMode && cursorIdle && isPlaying && 'opacity-0 pointer-events-none transition-opacity duration-300')}>
<div className="flex items-center gap-3">
<Skeleton className="h-4 w-12" />
<Separator orientation="vertical" className="h-5" />
<div className="flex items-center gap-2">
<Skeleton className="h-4 w-36" />
<Skeleton className="h-4 w-24" />
</div>
</div>
<div className="flex items-center gap-2">
<Skeleton className="h-8 w-32 rounded-md" />
{mode === 'dashboard' && <Skeleton className="h-8 w-28 rounded-md" />}
</div>
</div>
<div className="flex-1 bg-black min-h-0" />
<div className={cn('shrink-0 px-4 py-2 bg-background border-t', isFullscreenMode && cursorIdle && isPlaying && 'opacity-0 pointer-events-none transition-opacity duration-300')}>
<div className="flex items-center gap-1 mb-2">
<Skeleton className="h-8 w-8 rounded-md" />
<Skeleton className="h-8 w-8 rounded-md" />
<Skeleton className="h-8 w-8 rounded-md" />
<Skeleton className="h-8 w-8 rounded-md" />
<Skeleton className="h-4 w-24 ml-1" />
<div className="ml-auto">
<Skeleton className="h-8 w-12 rounded-md" />
</div>
</div>
<Skeleton className="h-8 w-full rounded" />
</div>
</div>
<div className={cn('hidden lg:flex w-80 shrink-0 border-l bg-card flex-col overflow-hidden', isFullscreenMode && !showComments && 'hidden')}>
<div className="shrink-0 flex items-center justify-between p-4 border-b">
<div className="flex items-center gap-2">
<Skeleton className="h-5 w-5" />
<Skeleton className="h-5 w-24" />
<Skeleton className="h-5 w-6 rounded-full" />
</div>
<Skeleton className="h-8 w-28 rounded-md" />
</div>
<div className="flex-1 overflow-y-auto p-4 space-y-3">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="rounded-lg border p-3">
<div className="flex items-start justify-between gap-2 mb-2">
<div className="flex items-center gap-2">
<Skeleton className="h-6 w-6 rounded-full" />
<Skeleton className="h-4 w-20" />
</div>
<Skeleton className="h-5 w-14 rounded" />
</div>
<Skeleton className="h-4 w-full mb-1" />
<Skeleton className="h-4 w-2/3" />
</div>
))}
</div>
<div className="shrink-0 border-t p-4">
<Skeleton className="h-20 w-full rounded-md" />
<div className="flex items-center justify-between mt-2">
<Skeleton className="h-8 w-8 rounded-md" />
<Skeleton className="h-8 w-20 rounded-md" />
</div>
</div>
</div>
</div>
</div>
);
});