feat: enhance comment functionality with timestamp range support

- Added timestampEnd to Comment and CommentReply interfaces.
- Implemented logic for handling comment timestamp ranges in the comment composer and comments pane.
- Updated video player and player core to support frame stepping and improved seeking functionality.
- Introduced frame mode toggle for precise navigation during video playback.
- Closes #12
This commit is contained in:
yusufipk
2026-04-25 22:58:16 +03:00
parent 63f331d220
commit 378ca1977b
8 changed files with 537 additions and 110 deletions
+39 -4
View File
@@ -275,9 +275,44 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
return apiErrors.badRequest('Timestamp is required'); return apiErrors.badRequest('Timestamp is required');
} }
const parsedTimestamp = parseFloat(timestamp); const maxTimestamp =
if (isNaN(parsedTimestamp)) { typeof version.duration === 'number' && Number.isFinite(version.duration)
return apiErrors.badRequest('Timestamp must be a valid number'); ? version.duration
: null;
const parseCommentTimestamp = (value: unknown, fieldName: string) => {
const parsed = typeof value === 'number' ? value : Number(value);
if (!Number.isFinite(parsed) || parsed < 0) {
return {
error: apiErrors.badRequest(`${fieldName} must be a finite non-negative number`),
};
}
if (maxTimestamp !== null && parsed > maxTimestamp) {
return {
error: apiErrors.badRequest(`${fieldName} must be less than or equal to video duration`),
};
}
return { value: parsed };
};
const parsedTimestampResult = parseCommentTimestamp(timestamp, 'Timestamp');
if ('error' in parsedTimestampResult) {
return parsedTimestampResult.error;
}
const parsedTimestamp = parsedTimestampResult.value;
let parsedTimestampEnd: number | null = null;
if (timestampEnd !== undefined && timestampEnd !== null) {
const parsedTimestampEndResult = parseCommentTimestamp(timestampEnd, 'Timestamp end');
if ('error' in parsedTimestampEndResult) {
return parsedTimestampEndResult.error;
}
parsedTimestampEnd = parsedTimestampEndResult.value;
if (parsedTimestampEnd < parsedTimestamp) {
return apiErrors.badRequest('Timestamp end must be greater than or equal to timestamp');
}
} }
if (!content && !voiceUrl && !imageUrl && !annotationData) { if (!content && !voiceUrl && !imageUrl && !annotationData) {
@@ -401,7 +436,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
data: { data: {
content: content?.trim() || null, content: content?.trim() || null,
timestamp: parsedTimestamp, timestamp: parsedTimestamp,
timestampEnd: timestampEnd ? parseFloat(timestampEnd) : null, timestampEnd: parsedTimestampEnd,
parentId: parentId || null, parentId: parentId || null,
voiceUrl: voiceUrl || null, voiceUrl: voiceUrl || null,
voiceDuration: voiceDuration || null, voiceDuration: voiceDuration || null,
+23 -1
View File
@@ -301,6 +301,8 @@ export function VideoPageContent({
videoDuration, videoDuration,
isPlaying, isPlaying,
isMuted, isMuted,
isFrameMode,
frameStepLabel,
isDragging, isDragging,
playbackSpeed, playbackSpeed,
qualityOptions, qualityOptions,
@@ -318,6 +320,7 @@ export function VideoPageContent({
handlePlayPause, handlePlayPause,
handleSeekToTimestamp, handleSeekToTimestamp,
handleMuteToggle, handleMuteToggle,
handleFrameModeToggle,
handleSkip, handleSkip,
handleSpeedChange, handleSpeedChange,
handleQualityChange, handleQualityChange,
@@ -422,6 +425,10 @@ export function VideoPageContent({
isUploadingAudio, isUploadingAudio,
imageBlob, imageBlob,
setImageBlob, setImageBlob,
commentRangeStart,
commentRangeEnd,
toggleCommentRangeSelection,
clearCommentRangeSelection,
isUploadingImage, isUploadingImage,
imageInputRef, imageInputRef,
handleAddComment, handleAddComment,
@@ -442,6 +449,10 @@ export function VideoPageContent({
replyAudioBlob, replyAudioBlob,
replyImageBlob, replyImageBlob,
setReplyImageBlob, setReplyImageBlob,
replyRangeStart,
replyRangeEnd,
toggleReplyRangeSelection,
clearReplyRangeSelection,
isUploadingReplyAudio, isUploadingReplyAudio,
isUploadingReplyImage, isUploadingReplyImage,
replyImageInputRef, replyImageInputRef,
@@ -471,7 +482,6 @@ export function VideoPageContent({
setVideo, setVideo,
activeVersionId, activeVersionId,
activeVersion, activeVersion,
comments,
currentTime, currentTime,
isGuest, isGuest,
normalizedGuestName, normalizedGuestName,
@@ -495,6 +505,7 @@ export function VideoPageContent({
return filteredComments.map((comment) => ({ return filteredComments.map((comment) => ({
id: comment.id, id: comment.id,
timestamp: comment.timestamp, timestamp: comment.timestamp,
timestampEnd: comment.timestampEnd,
color: comment.tag?.color || (comment.isResolved ? '#22C55E' : '#22D3EE'), color: comment.tag?.color || (comment.isResolved ? '#22C55E' : '#22D3EE'),
annotationData: comment.annotationData, annotationData: comment.annotationData,
preview: `${comment.tag ? ` [${comment.tag.name}]` : ''} - ${comment.content?.substring(0, 30) || '(voice note)'}...`, preview: `${comment.tag ? ` [${comment.tag.name}]` : ''} - ${comment.content?.substring(0, 30) || '(voice note)'}...`,
@@ -783,7 +794,10 @@ export function VideoPageContent({
setIsEditingAnnotation={setIsEditingAnnotation} setIsEditingAnnotation={setIsEditingAnnotation}
currentTime={currentTime} currentTime={currentTime}
duration={duration} duration={duration}
isFrameMode={isFrameMode}
frameStepLabel={frameStepLabel}
handleSkip={handleSkip} handleSkip={handleSkip}
handleFrameModeToggle={handleFrameModeToggle}
handleMuteToggle={handleMuteToggle} handleMuteToggle={handleMuteToggle}
isMuted={isMuted} isMuted={isMuted}
selectedQualityLabel={selectedQualityLabel} selectedQualityLabel={selectedQualityLabel}
@@ -849,6 +863,10 @@ export function VideoPageContent({
setReplyingTo={setReplyingTo} setReplyingTo={setReplyingTo}
replyText={replyText} replyText={replyText}
setReplyText={setReplyText} setReplyText={setReplyText}
replyRangeStart={replyRangeStart}
replyRangeEnd={replyRangeEnd}
toggleReplyRangeSelection={toggleReplyRangeSelection}
clearReplyRangeSelection={clearReplyRangeSelection}
handleReplyComment={commentsActions.onReplyComment} handleReplyComment={commentsActions.onReplyComment}
startReplyRecording={startReplyRecording} startReplyRecording={startReplyRecording}
isReplyRecording={isReplyRecording} isReplyRecording={isReplyRecording}
@@ -903,6 +921,10 @@ export function VideoPageContent({
setImageBlob={setImageBlob} setImageBlob={setImageBlob}
commentText={commentText} commentText={commentText}
setCommentText={setCommentText} setCommentText={setCommentText}
commentRangeStart={commentRangeStart}
commentRangeEnd={commentRangeEnd}
toggleCommentRangeSelection={toggleCommentRangeSelection}
clearCommentRangeSelection={clearCommentRangeSelection}
playVoice={playVoice} playVoice={playVoice}
playingVoiceId={playingVoiceId} playingVoiceId={playingVoiceId}
voiceProgress={voiceProgress} voiceProgress={voiceProgress}
@@ -37,6 +37,10 @@ interface CommentComposerProps {
setImageBlob: (blob: File | null) => void; setImageBlob: (blob: File | null) => void;
commentText: string; commentText: string;
setCommentText: (value: string) => void; setCommentText: (value: string) => void;
commentRangeStart: number | null;
commentRangeEnd: number | null;
toggleCommentRangeSelection: () => void;
clearCommentRangeSelection: () => void;
playVoice: (commentId: string, voiceUrl: string, knownDuration?: number) => void; playVoice: (commentId: string, voiceUrl: string, knownDuration?: number) => void;
playingVoiceId: string | null; playingVoiceId: string | null;
voiceProgress: number; voiceProgress: number;
@@ -76,6 +80,10 @@ export const CommentComposer = memo(function CommentComposer({
setImageBlob, setImageBlob,
commentText, commentText,
setCommentText, setCommentText,
commentRangeStart,
commentRangeEnd,
toggleCommentRangeSelection,
clearCommentRangeSelection,
playVoice, playVoice,
playingVoiceId, playingVoiceId,
voiceProgress, voiceProgress,
@@ -103,6 +111,16 @@ export const CommentComposer = memo(function CommentComposer({
pauseVideoForAnnotation, pauseVideoForAnnotation,
assets, assets,
}: CommentComposerProps) { }: CommentComposerProps) {
const rangeButtonLabel =
commentRangeStart === null || commentRangeEnd !== null ? 'Set In' : 'Set Out';
const hasCommentRange = commentRangeStart !== null;
const commentRangeLabel =
commentRangeStart !== null
? commentRangeEnd !== null
? `${formatTime(commentRangeStart)} - ${formatTime(commentRangeEnd)}`
: `In ${formatTime(commentRangeStart)}`
: null;
return ( return (
<div className="shrink-0 p-4 border-t bg-background"> <div className="shrink-0 p-4 border-t bg-background">
{isRecording ? ( {isRecording ? (
@@ -192,6 +210,31 @@ export const CommentComposer = memo(function CommentComposer({
rows={1} rows={1}
className="resize-none text-sm" className="resize-none text-sm"
/> />
<div className="flex items-center gap-2 flex-wrap">
<Button
size="sm"
variant={hasCommentRange ? 'default' : 'outline'}
className="h-7 text-xs"
onClick={toggleCommentRangeSelection}
>
{rangeButtonLabel}
</Button>
{commentRangeLabel && (
<span className="rounded-md border px-2 py-1 text-xs text-muted-foreground tabular-nums">
{commentRangeLabel}
</span>
)}
{hasCommentRange && (
<Button
size="sm"
variant="ghost"
className="h-7 text-xs"
onClick={clearCommentRangeSelection}
>
Clear
</Button>
)}
</div>
<Button <Button
size="sm" size="sm"
onClick={submitCommentWithMedia} onClick={submitCommentWithMedia}
@@ -250,6 +293,31 @@ export const CommentComposer = memo(function CommentComposer({
</div> </div>
</div> </div>
)} )}
<div className="mb-2 flex items-center gap-2 flex-wrap">
<Button
size="sm"
variant={hasCommentRange ? 'default' : 'outline'}
className="h-7 text-xs"
onClick={toggleCommentRangeSelection}
>
{rangeButtonLabel}
</Button>
{commentRangeLabel && (
<span className="rounded-md border px-2 py-1 text-xs text-muted-foreground tabular-nums">
{commentRangeLabel}
</span>
)}
{hasCommentRange && (
<Button
size="sm"
variant="ghost"
className="h-7 text-xs"
onClick={clearCommentRangeSelection}
>
Clear
</Button>
)}
</div>
<div className="flex gap-2 items-stretch"> <div className="flex gap-2 items-stretch">
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<MentionTextarea <MentionTextarea
+90 -2
View File
@@ -58,7 +58,7 @@ interface CommentsPaneProps {
handleSeekToTimestamp: ( handleSeekToTimestamp: (
timestamp: number, timestamp: number,
annotation?: string | null, annotation?: string | null,
options?: { pauseAfterSeek?: boolean } options?: { pauseAfterSeek?: boolean; timestampEnd?: number | null }
) => void; ) => void;
currentUserId: string | null; currentUserId: string | null;
projectOwnerId: string; projectOwnerId: string;
@@ -87,6 +87,10 @@ interface CommentsPaneProps {
setReplyingTo: (id: string | null) => void; setReplyingTo: (id: string | null) => void;
replyText: string; replyText: string;
setReplyText: (value: string) => void; setReplyText: (value: string) => void;
replyRangeStart: number | null;
replyRangeEnd: number | null;
toggleReplyRangeSelection: () => void;
clearReplyRangeSelection: () => void;
handleReplyComment: ( handleReplyComment: (
parentId: string, parentId: string,
voiceData?: { url: string; duration: number }, voiceData?: { url: string; duration: number },
@@ -161,6 +165,10 @@ export const CommentsPane = memo(function CommentsPane({
setReplyingTo, setReplyingTo,
replyText, replyText,
setReplyText, setReplyText,
replyRangeStart,
replyRangeEnd,
toggleReplyRangeSelection,
clearReplyRangeSelection,
handleReplyComment, handleReplyComment,
startReplyRecording, startReplyRecording,
isReplyRecording, isReplyRecording,
@@ -186,6 +194,18 @@ export const CommentsPane = memo(function CommentsPane({
assetsPane, assetsPane,
}: CommentsPaneProps) { }: CommentsPaneProps) {
const [isPaneDraggingOver, setIsPaneDraggingOver] = useState(false); const [isPaneDraggingOver, setIsPaneDraggingOver] = useState(false);
const formatCommentRange = (timestamp: number, timestampEnd: number | null) => {
if (timestampEnd === null) return formatTime(timestamp);
return `${formatTime(timestamp)} - ${formatTime(timestampEnd)}`;
};
const replyRangeButtonLabel =
replyRangeStart === null || replyRangeEnd !== null ? 'Set In' : 'Set Out';
const replyRangeLabel =
replyRangeStart !== null
? replyRangeEnd !== null
? `${formatTime(replyRangeStart)} - ${formatTime(replyRangeEnd)}`
: `In ${formatTime(replyRangeStart)}`
: null;
return ( return (
<> <>
@@ -383,13 +403,14 @@ export const CommentsPane = memo(function CommentsPane({
onClick={() => onClick={() =>
handleSeekToTimestamp(comment.timestamp, comment.annotationData, { handleSeekToTimestamp(comment.timestamp, comment.annotationData, {
pauseAfterSeek: true, pauseAfterSeek: true,
timestampEnd: comment.timestampEnd,
}) })
} }
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" 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" title="Jump to this timestamp"
> >
<Clock className="h-3 w-3" /> <Clock className="h-3 w-3" />
{formatTime(comment.timestamp)} {formatCommentRange(comment.timestamp, comment.timestampEnd)}
<ArrowUpRight className="h-3 w-3" /> <ArrowUpRight className="h-3 w-3" />
</button> </button>
{canResolveComments && ( {canResolveComments && (
@@ -416,6 +437,7 @@ export const CommentsPane = memo(function CommentsPane({
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
<DropdownMenuItem <DropdownMenuItem
onClick={() => { onClick={() => {
clearReplyRangeSelection();
setReplyingTo(comment.id); setReplyingTo(comment.id);
setReplyText(''); setReplyText('');
}} }}
@@ -670,6 +692,19 @@ export const CommentsPane = memo(function CommentsPane({
</AvatarFallback> </AvatarFallback>
</Avatar> </Avatar>
<span className="font-medium text-xs">{replyAuthor}</span> <span className="font-medium text-xs">{replyAuthor}</span>
<button
onClick={() =>
handleSeekToTimestamp(reply.timestamp, reply.annotationData, {
pauseAfterSeek: true,
timestampEnd: reply.timestampEnd,
})
}
className="flex items-center gap-1 rounded bg-primary/10 px-1.5 py-0.5 text-[10px] text-primary transition-colors hover:bg-primary/20"
title="Jump to this reply"
>
<Clock className="h-2.5 w-2.5" />
{formatCommentRange(reply.timestamp, reply.timestampEnd)}
</button>
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
{new Date(reply.createdAt).toLocaleDateString()} {new Date(reply.createdAt).toLocaleDateString()}
</span> </span>
@@ -938,6 +973,31 @@ export const CommentsPane = memo(function CommentsPane({
rows={1} rows={1}
className="resize-none text-sm" className="resize-none text-sm"
/> />
<div className="flex items-center gap-2 flex-wrap">
<Button
size="sm"
variant={replyRangeStart !== null ? 'default' : 'outline'}
className="h-7 text-xs"
onClick={toggleReplyRangeSelection}
>
{replyRangeButtonLabel}
</Button>
{replyRangeLabel && (
<span className="rounded-md border px-2 py-1 text-xs text-muted-foreground tabular-nums">
{replyRangeLabel}
</span>
)}
{replyRangeStart !== null && (
<Button
size="sm"
variant="ghost"
className="h-7 text-xs"
onClick={clearReplyRangeSelection}
>
Clear
</Button>
)}
</div>
<div className="flex gap-1 mt-2"> <div className="flex gap-1 mt-2">
<Button <Button
size="sm" size="sm"
@@ -1001,6 +1061,7 @@ export const CommentsPane = memo(function CommentsPane({
handleReplyComment(comment.id); handleReplyComment(comment.id);
} }
if (e.key === 'Escape') { if (e.key === 'Escape') {
clearReplyRangeSelection();
setReplyingTo(null); setReplyingTo(null);
setReplyText(''); setReplyText('');
} }
@@ -1033,6 +1094,31 @@ export const CommentsPane = memo(function CommentsPane({
onChange={(e) => handleImageSelect(e, true)} onChange={(e) => handleImageSelect(e, true)}
/> />
</div> </div>
<div className="mt-2 flex items-center gap-2 flex-wrap">
<Button
size="sm"
variant={replyRangeStart !== null ? 'default' : 'outline'}
className="h-7 text-xs"
onClick={toggleReplyRangeSelection}
>
{replyRangeButtonLabel}
</Button>
{replyRangeLabel && (
<span className="rounded-md border px-2 py-1 text-xs text-muted-foreground tabular-nums">
{replyRangeLabel}
</span>
)}
{replyRangeStart !== null && (
<Button
size="sm"
variant="ghost"
className="h-7 text-xs"
onClick={clearReplyRangeSelection}
>
Clear
</Button>
)}
</div>
<div className="flex gap-1 mt-1"> <div className="flex gap-1 mt-1">
<Button <Button
size="sm" size="sm"
@@ -1054,6 +1140,7 @@ export const CommentsPane = memo(function CommentsPane({
size="sm" size="sm"
variant="ghost" variant="ghost"
onClick={() => { onClick={() => {
clearReplyRangeSelection();
setReplyingTo(null); setReplyingTo(null);
setReplyText(''); setReplyText('');
}} }}
@@ -1070,6 +1157,7 @@ export const CommentsPane = memo(function CommentsPane({
{!isReplying && !isEditing && ( {!isReplying && !isEditing && (
<button <button
onClick={() => { onClick={() => {
clearReplyRangeSelection();
setReplyingTo(comment.id); setReplyingTo(comment.id);
setReplyText(''); setReplyText('');
}} }}
@@ -17,6 +17,7 @@ import type { AnnotationCanvasHandle, AnnotationStroke } from '@/components/anno
import type { import type {
Comment, Comment,
CommentActionsConfig, CommentActionsConfig,
CommentReply,
CommentTag, CommentTag,
Version, Version,
VideoData, VideoData,
@@ -31,7 +32,6 @@ interface UseCommentActionsParams extends CommentActionsConfig {
setVideo: Dispatch<SetStateAction<VideoData | null>>; setVideo: Dispatch<SetStateAction<VideoData | null>>;
activeVersionId: string | null; activeVersionId: string | null;
activeVersion: (Version & { comments: Comment[] }) | undefined; activeVersion: (Version & { comments: Comment[] }) | undefined;
comments: Comment[];
currentTime: number; currentTime: number;
isGuest: boolean; isGuest: boolean;
normalizedGuestName: string; normalizedGuestName: string;
@@ -56,7 +56,6 @@ export function useCommentActions({
setVideo, setVideo,
activeVersionId, activeVersionId,
activeVersion, activeVersion,
comments,
currentTime, currentTime,
isGuest, isGuest,
normalizedGuestName, normalizedGuestName,
@@ -83,6 +82,8 @@ export function useCommentActions({
const [isUploadingAudio, setIsUploadingAudio] = useState(false); const [isUploadingAudio, setIsUploadingAudio] = useState(false);
const [imageBlob, setImageBlob] = useState<File | null>(null); const [imageBlob, setImageBlob] = useState<File | null>(null);
const [isUploadingImage, setIsUploadingImage] = useState(false); const [isUploadingImage, setIsUploadingImage] = useState(false);
const [commentRangeStart, setCommentRangeStart] = useState<number | null>(null);
const [commentRangeEnd, setCommentRangeEnd] = useState<number | null>(null);
const imageInputRef = useRef<HTMLInputElement>(null); const imageInputRef = useRef<HTMLInputElement>(null);
const mediaRecorderRef = useRef<MediaRecorder | null>(null); const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const audioChunksRef = useRef<Blob[]>([]); const audioChunksRef = useRef<Blob[]>([]);
@@ -97,6 +98,8 @@ export function useCommentActions({
const [isUploadingReplyAudio, setIsUploadingReplyAudio] = useState(false); const [isUploadingReplyAudio, setIsUploadingReplyAudio] = useState(false);
const [replyImageBlob, setReplyImageBlob] = useState<File | null>(null); const [replyImageBlob, setReplyImageBlob] = useState<File | null>(null);
const [isUploadingReplyImage, setIsUploadingReplyImage] = useState(false); const [isUploadingReplyImage, setIsUploadingReplyImage] = useState(false);
const [replyRangeStart, setReplyRangeStart] = useState<number | null>(null);
const [replyRangeEnd, setReplyRangeEnd] = useState<number | null>(null);
const replyImageInputRef = useRef<HTMLInputElement>(null); const replyImageInputRef = useRef<HTMLInputElement>(null);
const replyMediaRecorderRef = useRef<MediaRecorder | null>(null); const replyMediaRecorderRef = useRef<MediaRecorder | null>(null);
const replyAudioChunksRef = useRef<Blob[]>([]); const replyAudioChunksRef = useRef<Blob[]>([]);
@@ -114,6 +117,38 @@ export function useCommentActions({
const isMutatingRef = useRef(false); const isMutatingRef = useRef(false);
const clearCommentRangeSelection = useCallback(() => {
setCommentRangeStart(null);
setCommentRangeEnd(null);
}, []);
const clearReplyRangeSelection = useCallback(() => {
setReplyRangeStart(null);
setReplyRangeEnd(null);
}, []);
const toggleCommentRangeSelection = useCallback(() => {
if (commentRangeStart === null || commentRangeEnd !== null) {
setCommentRangeStart(currentTime);
setCommentRangeEnd(null);
return;
}
setCommentRangeStart(Math.min(commentRangeStart, currentTime));
setCommentRangeEnd(Math.max(commentRangeStart, currentTime));
}, [commentRangeEnd, commentRangeStart, currentTime]);
const toggleReplyRangeSelection = useCallback(() => {
if (replyRangeStart === null || replyRangeEnd !== null) {
setReplyRangeStart(currentTime);
setReplyRangeEnd(null);
return;
}
setReplyRangeStart(Math.min(replyRangeStart, currentTime));
setReplyRangeEnd(Math.max(replyRangeStart, currentTime));
}, [currentTime, replyRangeEnd, replyRangeStart]);
const getGuestUploadToken = useCallback( const getGuestUploadToken = useCallback(
async (intent: 'audio' | 'image') => { async (intent: 'audio' | 'image') => {
if (!isGuest) return null; if (!isGuest) return null;
@@ -151,11 +186,13 @@ export function useCommentActions({
} }
const tempId = `temp-${Date.now()}`; const tempId = `temp-${Date.now()}`;
const commentTimestamp = commentRangeStart ?? currentTime;
const serializedAnnotation = effectiveStrokes ? JSON.stringify(effectiveStrokes) : null; const serializedAnnotation = effectiveStrokes ? JSON.stringify(effectiveStrokes) : null;
const optimisticComment: Comment = { const optimisticComment: Comment = {
id: tempId, id: tempId,
content: voiceData || imageBlob ? commentText.trim() || null : commentText, content: voiceData || imageBlob ? commentText.trim() || null : commentText,
timestamp: currentTime, timestamp: commentTimestamp,
timestampEnd: commentRangeEnd,
voiceUrl: voiceData?.url ?? null, voiceUrl: voiceData?.url ?? null,
voiceDuration: voiceData?.duration ?? null, voiceDuration: voiceData?.duration ?? null,
imageUrl: imageBlob ? URL.createObjectURL(imageBlob) : null, imageUrl: imageBlob ? URL.createObjectURL(imageBlob) : null,
@@ -186,6 +223,7 @@ export function useCommentActions({
setImageBlob(null); setImageBlob(null);
setAnnotationStrokes(null); setAnnotationStrokes(null);
setIsAnnotating(false); setIsAnnotating(false);
clearCommentRangeSelection();
setViewingAnnotation(effectiveStrokes || null); setViewingAnnotation(effectiveStrokes || null);
setIsSubmittingComment(true); setIsSubmittingComment(true);
@@ -217,7 +255,8 @@ export function useCommentActions({
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
content: voiceData || imageBlob ? commentText.trim() || null : commentText, content: voiceData || imageBlob ? commentText.trim() || null : commentText,
timestamp: currentTime, timestamp: commentTimestamp,
...(commentRangeEnd !== null && { timestampEnd: commentRangeEnd }),
...(voiceData && { voiceUrl: voiceData.url, voiceDuration: voiceData.duration }), ...(voiceData && { voiceUrl: voiceData.url, voiceDuration: voiceData.duration }),
...(imageData && { imageUrl: imageData.url }), ...(imageData && { imageUrl: imageData.url }),
...(isGuest && normalizedGuestName && { guestName: normalizedGuestName }), ...(isGuest && normalizedGuestName && { guestName: normalizedGuestName }),
@@ -287,6 +326,8 @@ export function useCommentActions({
}, },
[ [
commentText, commentText,
commentRangeEnd,
commentRangeStart,
currentTime, currentTime,
activeVersion, activeVersion,
activeVersionId, activeVersionId,
@@ -304,6 +345,7 @@ export function useCommentActions({
setSelectedTagId, setSelectedTagId,
setAnnotationStrokes, setAnnotationStrokes,
setIsAnnotating, setIsAnnotating,
clearCommentRangeSelection,
setViewingAnnotation, setViewingAnnotation,
setVideo, setVideo,
fetchAssets, fetchAssets,
@@ -594,10 +636,12 @@ export function useCommentActions({
if (!activeVersion || !activeVersionId) return; if (!activeVersion || !activeVersionId) return;
const tempId = `temp-reply-${Date.now()}`; const tempId = `temp-reply-${Date.now()}`;
const parentComment = comments.find((c) => c.id === parentId); const replyTimestamp = replyRangeStart ?? currentTime;
const optimisticReply = { const optimisticReply: CommentReply = {
id: tempId, id: tempId,
content: voiceData || replyImageBlob ? replyText.trim() || null : replyText, content: voiceData || replyImageBlob ? replyText.trim() || null : replyText,
timestamp: replyTimestamp,
timestampEnd: replyRangeEnd,
voiceUrl: voiceData?.url ?? null, voiceUrl: voiceData?.url ?? null,
voiceDuration: voiceData?.duration ?? null, voiceDuration: voiceData?.duration ?? null,
imageUrl: replyImageBlob ? URL.createObjectURL(replyImageBlob) : null, imageUrl: replyImageBlob ? URL.createObjectURL(replyImageBlob) : null,
@@ -634,6 +678,7 @@ export function useCommentActions({
setReplyAudioBlob(null); setReplyAudioBlob(null);
setReplyRecordingTime(0); setReplyRecordingTime(0);
setReplyImageBlob(null); setReplyImageBlob(null);
clearReplyRangeSelection();
setIsSubmittingReply(true); setIsSubmittingReply(true);
isMutatingRef.current = true; isMutatingRef.current = true;
@@ -664,7 +709,8 @@ export function useCommentActions({
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
content: voiceData || submittedImageData ? replyText.trim() || null : replyText, content: voiceData || submittedImageData ? replyText.trim() || null : replyText,
timestamp: parentComment?.timestamp ?? currentTime, timestamp: replyTimestamp,
...(replyRangeEnd !== null && { timestampEnd: replyRangeEnd }),
parentId, parentId,
...(voiceData && { voiceUrl: voiceData.url, voiceDuration: voiceData.duration }), ...(voiceData && { voiceUrl: voiceData.url, voiceDuration: voiceData.duration }),
...(submittedImageData && { imageUrl: submittedImageData.url }), ...(submittedImageData && { imageUrl: submittedImageData.url }),
@@ -752,9 +798,10 @@ export function useCommentActions({
}, },
[ [
replyText, replyText,
replyRangeEnd,
replyRangeStart,
activeVersion, activeVersion,
activeVersionId, activeVersionId,
comments,
currentTime, currentTime,
isGuest, isGuest,
normalizedGuestName, normalizedGuestName,
@@ -764,6 +811,7 @@ export function useCommentActions({
getGuestUploadToken, getGuestUploadToken,
setVideo, setVideo,
fetchAssets, fetchAssets,
clearReplyRangeSelection,
] ]
); );
@@ -1099,6 +1147,10 @@ export function useCommentActions({
isUploadingAudio, isUploadingAudio,
imageBlob, imageBlob,
setImageBlob, setImageBlob,
commentRangeStart,
commentRangeEnd,
toggleCommentRangeSelection,
clearCommentRangeSelection,
isUploadingImage, isUploadingImage,
imageInputRef, imageInputRef,
handleAddComment, handleAddComment,
@@ -1120,6 +1172,10 @@ export function useCommentActions({
replyAudioBlob, replyAudioBlob,
replyImageBlob, replyImageBlob,
setReplyImageBlob, setReplyImageBlob,
replyRangeStart,
replyRangeEnd,
toggleReplyRangeSelection,
clearReplyRangeSelection,
isUploadingReplyAudio, isUploadingReplyAudio,
isUploadingReplyImage, isUploadingReplyImage,
replyImageInputRef, replyImageInputRef,
+162 -77
View File
@@ -57,6 +57,8 @@ export function useVideoPlayer({
const [videoDuration, setVideoDuration] = useState(0); const [videoDuration, setVideoDuration] = useState(0);
const [isPlaying, setIsPlaying] = useState(false); const [isPlaying, setIsPlaying] = useState(false);
const [isMuted, setIsMuted] = useState(false); const [isMuted, setIsMuted] = useState(false);
const [isFrameMode, setIsFrameMode] = useState(false);
const [estimatedFrameRate, setEstimatedFrameRate] = useState<number | null>(null);
const [isDragging, setIsDragging] = useState(false); const [isDragging, setIsDragging] = useState(false);
const isDraggingRef = useRef(false); const isDraggingRef = useRef(false);
const [playbackSpeed, setPlaybackSpeed] = useState(1); const [playbackSpeed, setPlaybackSpeed] = useState(1);
@@ -71,10 +73,69 @@ export function useVideoPlayer({
const [cursorIdle, setCursorIdle] = useState(false); const [cursorIdle, setCursorIdle] = useState(false);
const cursorIdleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const cursorIdleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const bunnyRetryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const bunnyRetryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const bunnyFrameCallbackIdRef = useRef<number | null>(null);
const bunnyFrameSampleRef = useRef<{ mediaTime: number; presentedFrames: number } | null>(null);
const [isFullscreenMode, setIsFullscreenMode] = useState(false); const [isFullscreenMode, setIsFullscreenMode] = useState(false);
const [showComments, setShowComments] = useState(true); const [showComments, setShowComments] = useState(true);
const [isMobileCommentsOpen, setIsMobileCommentsOpen] = useState(false); const [isMobileCommentsOpen, setIsMobileCommentsOpen] = useState(false);
const frameStepSeconds = useMemo(() => {
if (estimatedFrameRate && Number.isFinite(estimatedFrameRate) && estimatedFrameRate > 0) {
return 1 / estimatedFrameRate;
}
return 1;
}, [estimatedFrameRate]);
const frameStepLabel = useMemo(() => {
if (estimatedFrameRate && Number.isFinite(estimatedFrameRate) && estimatedFrameRate > 0) {
return '1f';
}
return '1s';
}, [estimatedFrameRate]);
const stopBunnyFrameTracking = useCallback(() => {
const videoEl = videoRef.current;
const callbackId = bunnyFrameCallbackIdRef.current;
if (videoEl && callbackId !== null && typeof videoEl.cancelVideoFrameCallback === 'function') {
videoEl.cancelVideoFrameCallback(callbackId);
}
bunnyFrameCallbackIdRef.current = null;
bunnyFrameSampleRef.current = null;
}, [videoRef]);
const startBunnyFrameTracking = useCallback(() => {
const videoEl = videoRef.current;
if (!videoEl || typeof videoEl.requestVideoFrameCallback !== 'function') return;
stopBunnyFrameTracking();
const trackFrameRate = (
_now: number,
metadata: { mediaTime: number; presentedFrames: number }
) => {
const previousSample = bunnyFrameSampleRef.current;
bunnyFrameSampleRef.current = {
mediaTime: metadata.mediaTime,
presentedFrames: metadata.presentedFrames,
};
if (previousSample) {
const deltaFrames = metadata.presentedFrames - previousSample.presentedFrames;
const deltaTime = metadata.mediaTime - previousSample.mediaTime;
if (deltaFrames > 0 && deltaTime > 0) {
const nextFrameRate = deltaFrames / deltaTime;
if (Number.isFinite(nextFrameRate) && nextFrameRate >= 12 && nextFrameRate <= 120) {
setEstimatedFrameRate(nextFrameRate);
}
}
}
bunnyFrameCallbackIdRef.current = videoEl.requestVideoFrameCallback(trackFrameRate);
};
bunnyFrameCallbackIdRef.current = videoEl.requestVideoFrameCallback(trackFrameRate);
}, [stopBunnyFrameTracking, videoRef]);
useEffect(() => { useEffect(() => {
isDraggingRef.current = isDragging; isDraggingRef.current = isDragging;
}, [isDragging]); }, [isDragging]);
@@ -157,6 +218,7 @@ export function useVideoPlayer({
setVideoDuration(0); setVideoDuration(0);
setIsPlaying(false); setIsPlaying(false);
setIsMuted(false); setIsMuted(false);
setEstimatedFrameRate(null);
setPlaybackSpeed(1); setPlaybackSpeed(1);
setQualityOptions((prev) => (versionChanged ? [] : prev)); setQualityOptions((prev) => (versionChanged ? [] : prev));
setSelectedQualityLevel(bunnySourcePreference === 'original' ? -2 : -1); setSelectedQualityLevel(bunnySourcePreference === 'original' ? -2 : -1);
@@ -182,6 +244,7 @@ export function useVideoPlayer({
clearTimeout(bunnyRetryTimerRef.current); clearTimeout(bunnyRetryTimerRef.current);
bunnyRetryTimerRef.current = null; bunnyRetryTimerRef.current = null;
} }
stopBunnyFrameTracking();
const initPlayer = () => { const initPlayer = () => {
if (isYoutube) { if (isYoutube) {
@@ -343,6 +406,9 @@ export function useVideoPlayer({
} }
} }
syncDuration(); syncDuration();
if (!videoEl.paused) {
startBunnyFrameTracking();
}
}; };
const onPlay = () => { const onPlay = () => {
@@ -351,15 +417,18 @@ export function useVideoPlayer({
setBunnyPlaybackState('none'); setBunnyPlaybackState('none');
} }
syncDuration(); syncDuration();
startBunnyFrameTracking();
}; };
const onPause = () => { const onPause = () => {
setIsPlaying(false); setIsPlaying(false);
stopBunnyFrameTracking();
saveProgress(); saveProgress();
}; };
const onEnded = () => { const onEnded = () => {
setIsPlaying(false); setIsPlaying(false);
stopBunnyFrameTracking();
saveProgress(); saveProgress();
}; };
@@ -535,6 +604,7 @@ export function useVideoPlayer({
destroy: () => { destroy: () => {
destroyed = true; destroyed = true;
clearRetryTimer(); clearRetryTimer();
stopBunnyFrameTracking();
videoEl.removeEventListener('loadedmetadata', onLoadedMetadata); videoEl.removeEventListener('loadedmetadata', onLoadedMetadata);
videoEl.removeEventListener('play', onPlay); videoEl.removeEventListener('play', onPlay);
videoEl.removeEventListener('pause', onPause); videoEl.removeEventListener('pause', onPause);
@@ -593,6 +663,7 @@ export function useVideoPlayer({
clearTimeout(bunnyRetryTimerRef.current); clearTimeout(bunnyRetryTimerRef.current);
bunnyRetryTimerRef.current = null; bunnyRetryTimerRef.current = null;
} }
stopBunnyFrameTracking();
}; };
}, [ }, [
activeProviderId, activeProviderId,
@@ -606,6 +677,8 @@ export function useVideoPlayer({
iframeRef, iframeRef,
playerRef, playerRef,
scheduleWatchProgressSaveRef, scheduleWatchProgressSaveRef,
startBunnyFrameTracking,
stopBunnyFrameTracking,
videoRef, videoRef,
]); ]);
@@ -667,6 +740,88 @@ export function useVideoPlayer({
return videoDuration || activeVersion?.duration || 0; return videoDuration || activeVersion?.duration || 0;
}, [videoDuration, activeVersion?.duration]); }, [videoDuration, activeVersion?.duration]);
const resolveSkipAmount = useCallback(
(seconds: number) => {
if (!isFrameMode) return seconds;
const direction = seconds === 0 ? 1 : Math.sign(seconds);
return frameStepSeconds * direction;
},
[frameStepSeconds, isFrameMode]
);
const handleFrameModeToggle = useCallback(() => {
setIsFrameMode((prev) => !prev);
}, []);
const handlePlayPause = useCallback(() => {
if (!playerRef.current) return;
if (isPlaying) {
playerRef.current.pauseVideo();
} else {
playerRef.current.playVideo();
}
}, [isPlaying, playerRef]);
const handleSeekToTimestamp = useCallback(
(
timestamp: number,
annotation?: string | null,
options?: { pauseAfterSeek?: boolean; timestampEnd?: number | 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;
const hasRangeEnd = options?.timestampEnd !== undefined && options.timestampEnd !== null;
const shouldPauseAfterSeek = options?.pauseAfterSeek || hasRangeEnd;
playerRef.current.seekTo(timestamp, true);
if (shouldPauseAfterSeek) {
playerRef.current.pauseVideo();
} else if (wasPlayingBeforeSeek) {
playerRef.current.playVideo();
} else {
playerRef.current.pauseVideo();
}
}
if (annotation) {
try {
const parsed = JSON.parse(annotation);
const safe = validateAnnotationStrokes(parsed);
setViewingAnnotation(safe as AnnotationStroke[] | null);
} 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 + resolveSkipAmount(seconds)));
handleSeekToTimestamp(newTime);
},
[currentTime, duration, handleSeekToTimestamp, resolveSkipAmount]
);
useEffect(() => { useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
if (document.querySelector('[data-slot="dialog-content"]')) { if (document.querySelector('[data-slot="dialog-content"]')) {
@@ -692,23 +847,11 @@ export function useVideoPlayer({
break; break;
case 'ArrowLeft': case 'ArrowLeft':
e.preventDefault(); e.preventDefault();
if (playerRef.current) { handleSkip(-5);
const newTime = Math.max(0, currentTime - 5);
if (playerRef.current.seekTo) {
playerRef.current.seekTo(newTime, true);
}
setCurrentTime(newTime);
}
break; break;
case 'ArrowRight': case 'ArrowRight':
e.preventDefault(); e.preventDefault();
if (playerRef.current) { handleSkip(5);
const newTime = Math.min(duration, currentTime + 5);
if (playerRef.current.seekTo) {
playerRef.current.seekTo(newTime, true);
}
setCurrentTime(newTime);
}
break; break;
case 'ArrowUp': case 'ArrowUp':
e.preventDefault(); e.preventDefault();
@@ -797,73 +940,11 @@ export function useVideoPlayer({
isMuted, isMuted,
playbackSpeed, playbackSpeed,
speedOptions, speedOptions,
handleSkip,
toggleFullscreen, toggleFullscreen,
playerRef, playerRef,
]); ]);
const handlePlayPause = useCallback(() => {
if (!playerRef.current) return;
if (isPlaying) {
playerRef.current.pauseVideo();
} else {
playerRef.current.playVideo();
}
}, [isPlaying, playerRef]);
const handleSeekToTimestamp = useCallback(
(timestamp: number, annotation?: string | null, options?: { pauseAfterSeek?: boolean }) => {
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 (options?.pauseAfterSeek) {
playerRef.current.pauseVideo();
} else if (wasPlayingBeforeSeek) {
playerRef.current.playVideo();
} else {
playerRef.current.pauseVideo();
}
}
if (annotation) {
try {
const parsed = JSON.parse(annotation);
const safe = validateAnnotationStrokes(parsed);
setViewingAnnotation(safe as AnnotationStroke[] | null);
} 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( const handleSpeedChange = useCallback(
(speed: number) => { (speed: number) => {
setPlaybackSpeed(speed); setPlaybackSpeed(speed);
@@ -965,6 +1046,9 @@ export function useVideoPlayer({
setVideoDuration, setVideoDuration,
isPlaying, isPlaying,
isMuted, isMuted,
isFrameMode,
frameStepSeconds,
frameStepLabel,
isDragging, isDragging,
playbackSpeed, playbackSpeed,
qualityOptions, qualityOptions,
@@ -982,6 +1066,7 @@ export function useVideoPlayer({
handlePlayPause, handlePlayPause,
handleSeekToTimestamp, handleSeekToTimestamp,
handleMuteToggle, handleMuteToggle,
handleFrameModeToggle,
handleSkip, handleSkip,
handleSpeedChange, handleSpeedChange,
handleQualityChange, handleQualityChange,
+77 -8
View File
@@ -70,7 +70,10 @@ interface PlayerCoreProps {
setIsEditingAnnotation: (value: boolean) => void; setIsEditingAnnotation: (value: boolean) => void;
currentTime: number; currentTime: number;
duration: number; duration: number;
isFrameMode: boolean;
frameStepLabel: string;
handleSkip: (seconds: number) => void; handleSkip: (seconds: number) => void;
handleFrameModeToggle: () => void;
handleMuteToggle: () => void; handleMuteToggle: () => void;
isMuted: boolean; isMuted: boolean;
selectedQualityLabel: string; selectedQualityLabel: string;
@@ -86,7 +89,11 @@ interface PlayerCoreProps {
setIsMobileCommentsOpen: (value: boolean) => void; setIsMobileCommentsOpen: (value: boolean) => void;
handleTimelineMouseDown: (e: React.MouseEvent<HTMLDivElement>) => void; handleTimelineMouseDown: (e: React.MouseEvent<HTMLDivElement>) => void;
handleTimelineMouseMove: (e: React.MouseEvent<HTMLDivElement>) => void; handleTimelineMouseMove: (e: React.MouseEvent<HTMLDivElement>) => void;
handleSeekToTimestamp: (timestamp: number, annotation?: string | null) => void; handleSeekToTimestamp: (
timestamp: number,
annotation?: string | null,
options?: { pauseAfterSeek?: boolean; timestampEnd?: number | null }
) => void;
commentMarkers: CommentMarker[]; commentMarkers: CommentMarker[];
} }
@@ -127,7 +134,10 @@ export const PlayerCore = memo(function PlayerCore({
setIsEditingAnnotation, setIsEditingAnnotation,
currentTime, currentTime,
duration, duration,
isFrameMode,
frameStepLabel,
handleSkip, handleSkip,
handleFrameModeToggle,
handleMuteToggle, handleMuteToggle,
isMuted, isMuted,
selectedQualityLabel, selectedQualityLabel,
@@ -351,7 +361,7 @@ export const PlayerCore = memo(function PlayerCore({
size="icon" size="icon"
className="h-8 w-8" className="h-8 w-8"
onClick={() => handleSkip(-10)} onClick={() => handleSkip(-10)}
title="Back 10s" title={isFrameMode ? `Back ${frameStepLabel}` : 'Back 10s'}
> >
<SkipBack className="h-4 w-4" /> <SkipBack className="h-4 w-4" />
</Button> </Button>
@@ -361,7 +371,7 @@ export const PlayerCore = memo(function PlayerCore({
size="icon" size="icon"
className="h-8 w-8" className="h-8 w-8"
onClick={() => handleSkip(10)} onClick={() => handleSkip(10)}
title="Forward 10s" title={isFrameMode ? `Forward ${frameStepLabel}` : 'Forward 10s'}
> >
<SkipForward className="h-4 w-4" /> <SkipForward className="h-4 w-4" />
</Button> </Button>
@@ -375,6 +385,16 @@ export const PlayerCore = memo(function PlayerCore({
</span> </span>
<div className="ml-auto flex items-center"> <div className="ml-auto flex items-center">
<Button
variant={isFrameMode ? 'default' : 'ghost'}
size="sm"
className="h-8 gap-1 text-xs"
onClick={handleFrameModeToggle}
title="Toggle frame step mode"
>
Frame {frameStepLabel}
</Button>
{activeProviderId === 'bunny' && ( {activeProviderId === 'bunny' && (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
@@ -489,21 +509,70 @@ export const PlayerCore = memo(function PlayerCore({
style={{ left: `calc(${duration > 0 ? (currentTime / duration) * 100 : 0}% - 2px)` }} style={{ left: `calc(${duration > 0 ? (currentTime / duration) * 100 : 0}% - 2px)` }}
/> />
{commentMarkers.map((comment) => ( {commentMarkers.map((comment) => {
const startPercent = duration > 0 ? (comment.timestamp / duration) * 100 : 0;
const hasRange =
comment.timestampEnd !== null && Number.isFinite(comment.timestampEnd)
? comment.timestampEnd > comment.timestamp
: false;
const endPercent =
hasRange && comment.timestampEnd !== null
? (comment.timestampEnd / duration) * 100
: 0;
if (hasRange && comment.timestampEnd !== null) {
return (
<button <button
key={comment.id} key={comment.id}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
handleSeekToTimestamp(comment.timestamp, comment.annotationData); handleSeekToTimestamp(comment.timestamp, comment.annotationData, {
pauseAfterSeek: true,
timestampEnd: comment.timestampEnd,
});
}} }}
className="absolute top-1/2 -translate-y-1/2 w-3 h-3 rounded-full transition-transform hover:scale-150 z-10" className="absolute top-1/2 z-10 h-4 -translate-y-1/2 transition-opacity hover:opacity-100"
style={{ style={{
left: `calc(${duration > 0 ? (comment.timestamp / duration) * 100 : 0}% - 6px)`, left: `calc(${startPercent}% - 6px)`,
width: `calc(${Math.max(endPercent - startPercent, 0)}% + 12px)`,
}}
title={`${formatTime(comment.timestamp)} - ${formatTime(comment.timestampEnd)}${comment.preview}`}
>
<span
className="absolute left-[6px] right-[6px] top-1/2 h-1 -translate-y-1/2 rounded-full opacity-70"
style={{ backgroundColor: comment.color }}
/>
<span
className="absolute left-0 top-1/2 h-3 w-3 -translate-y-1/2 rounded-full border border-background/80"
style={{ backgroundColor: comment.color }}
/>
<span
className="absolute right-0 top-1/2 h-3 w-3 -translate-y-1/2 rounded-full border border-background/80"
style={{ backgroundColor: comment.color }}
/>
</button>
);
}
return (
<button
key={comment.id}
onClick={(e) => {
e.stopPropagation();
handleSeekToTimestamp(comment.timestamp, comment.annotationData, {
pauseAfterSeek: comment.timestampEnd !== null,
timestampEnd: comment.timestampEnd,
});
}}
className="absolute top-1/2 z-10 h-3 w-3 -translate-y-1/2 rounded-full transition-transform hover:scale-150"
style={{
left: `calc(${startPercent}% - 6px)`,
backgroundColor: comment.color, backgroundColor: comment.color,
}} }}
title={`${formatTime(comment.timestamp)}${comment.preview}`} title={`${formatTime(comment.timestamp)}${comment.preview}`}
/> />
))} );
})}
</div> </div>
</div> </div>
</> </>
+4
View File
@@ -78,6 +78,8 @@ export interface ApprovalRequest {
export interface CommentReply { export interface CommentReply {
id: string; id: string;
content: string | null; content: string | null;
timestamp: number;
timestampEnd: number | null;
voiceUrl: string | null; voiceUrl: string | null;
voiceDuration: number | null; voiceDuration: number | null;
imageUrl: string | null; imageUrl: string | null;
@@ -94,6 +96,7 @@ export interface Comment {
id: string; id: string;
content: string | null; content: string | null;
timestamp: number; timestamp: number;
timestampEnd: number | null;
voiceUrl: string | null; voiceUrl: string | null;
voiceDuration: number | null; voiceDuration: number | null;
imageUrl: string | null; imageUrl: string | null;
@@ -145,6 +148,7 @@ export type DownloadTarget = BunnyDownloadPreference | 'direct';
export interface CommentMarker { export interface CommentMarker {
id: string; id: string;
timestamp: number; timestamp: number;
timestampEnd: number | null;
color: string; color: string;
annotationData: string | null; annotationData: string | null;
preview: string; preview: string;