mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 17:46:06 +00:00
refactor(video-page): split video page into modular components, hooks, and shared types
This commit is contained in:
+615
-4539
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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'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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user