From 378ca1977b2592e2ae921adcd26d7d5eb1546967 Mon Sep 17 00:00:00 2001 From: yusufipk Date: Sat, 25 Apr 2026 22:58:16 +0300 Subject: [PATCH] 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 --- .../versions/[versionId]/comments/route.ts | 43 +++- components/video-page-content.tsx | 24 +- components/video-page/comment-composer.tsx | 68 +++++ components/video-page/comments-pane.tsx | 92 ++++++- .../video-page/hooks/use-comment-actions.ts | 72 +++++- .../video-page/hooks/use-video-player.ts | 239 ++++++++++++------ components/video-page/player-core.tsx | 105 ++++++-- components/video-page/types.ts | 4 + 8 files changed, 537 insertions(+), 110 deletions(-) diff --git a/app/api/versions/[versionId]/comments/route.ts b/app/api/versions/[versionId]/comments/route.ts index 1b5835f..ca33ada 100644 --- a/app/api/versions/[versionId]/comments/route.ts +++ b/app/api/versions/[versionId]/comments/route.ts @@ -275,9 +275,44 @@ export async function POST(request: NextRequest, { params }: RouteParams) { return apiErrors.badRequest('Timestamp is required'); } - const parsedTimestamp = parseFloat(timestamp); - if (isNaN(parsedTimestamp)) { - return apiErrors.badRequest('Timestamp must be a valid number'); + const maxTimestamp = + typeof version.duration === 'number' && Number.isFinite(version.duration) + ? 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) { @@ -401,7 +436,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) { data: { content: content?.trim() || null, timestamp: parsedTimestamp, - timestampEnd: timestampEnd ? parseFloat(timestampEnd) : null, + timestampEnd: parsedTimestampEnd, parentId: parentId || null, voiceUrl: voiceUrl || null, voiceDuration: voiceDuration || null, diff --git a/components/video-page-content.tsx b/components/video-page-content.tsx index 694713b..8ebd8c2 100644 --- a/components/video-page-content.tsx +++ b/components/video-page-content.tsx @@ -301,6 +301,8 @@ export function VideoPageContent({ videoDuration, isPlaying, isMuted, + isFrameMode, + frameStepLabel, isDragging, playbackSpeed, qualityOptions, @@ -318,6 +320,7 @@ export function VideoPageContent({ handlePlayPause, handleSeekToTimestamp, handleMuteToggle, + handleFrameModeToggle, handleSkip, handleSpeedChange, handleQualityChange, @@ -422,6 +425,10 @@ export function VideoPageContent({ isUploadingAudio, imageBlob, setImageBlob, + commentRangeStart, + commentRangeEnd, + toggleCommentRangeSelection, + clearCommentRangeSelection, isUploadingImage, imageInputRef, handleAddComment, @@ -442,6 +449,10 @@ export function VideoPageContent({ replyAudioBlob, replyImageBlob, setReplyImageBlob, + replyRangeStart, + replyRangeEnd, + toggleReplyRangeSelection, + clearReplyRangeSelection, isUploadingReplyAudio, isUploadingReplyImage, replyImageInputRef, @@ -471,7 +482,6 @@ export function VideoPageContent({ setVideo, activeVersionId, activeVersion, - comments, currentTime, isGuest, normalizedGuestName, @@ -495,6 +505,7 @@ export function VideoPageContent({ return filteredComments.map((comment) => ({ id: comment.id, timestamp: comment.timestamp, + timestampEnd: comment.timestampEnd, color: comment.tag?.color || (comment.isResolved ? '#22C55E' : '#22D3EE'), annotationData: comment.annotationData, preview: `${comment.tag ? ` [${comment.tag.name}]` : ''} - ${comment.content?.substring(0, 30) || '(voice note)'}...`, @@ -783,7 +794,10 @@ export function VideoPageContent({ setIsEditingAnnotation={setIsEditingAnnotation} currentTime={currentTime} duration={duration} + isFrameMode={isFrameMode} + frameStepLabel={frameStepLabel} handleSkip={handleSkip} + handleFrameModeToggle={handleFrameModeToggle} handleMuteToggle={handleMuteToggle} isMuted={isMuted} selectedQualityLabel={selectedQualityLabel} @@ -849,6 +863,10 @@ export function VideoPageContent({ setReplyingTo={setReplyingTo} replyText={replyText} setReplyText={setReplyText} + replyRangeStart={replyRangeStart} + replyRangeEnd={replyRangeEnd} + toggleReplyRangeSelection={toggleReplyRangeSelection} + clearReplyRangeSelection={clearReplyRangeSelection} handleReplyComment={commentsActions.onReplyComment} startReplyRecording={startReplyRecording} isReplyRecording={isReplyRecording} @@ -903,6 +921,10 @@ export function VideoPageContent({ setImageBlob={setImageBlob} commentText={commentText} setCommentText={setCommentText} + commentRangeStart={commentRangeStart} + commentRangeEnd={commentRangeEnd} + toggleCommentRangeSelection={toggleCommentRangeSelection} + clearCommentRangeSelection={clearCommentRangeSelection} playVoice={playVoice} playingVoiceId={playingVoiceId} voiceProgress={voiceProgress} diff --git a/components/video-page/comment-composer.tsx b/components/video-page/comment-composer.tsx index 9a31502..ae20336 100644 --- a/components/video-page/comment-composer.tsx +++ b/components/video-page/comment-composer.tsx @@ -37,6 +37,10 @@ interface CommentComposerProps { setImageBlob: (blob: File | null) => void; commentText: string; setCommentText: (value: string) => void; + commentRangeStart: number | null; + commentRangeEnd: number | null; + toggleCommentRangeSelection: () => void; + clearCommentRangeSelection: () => void; playVoice: (commentId: string, voiceUrl: string, knownDuration?: number) => void; playingVoiceId: string | null; voiceProgress: number; @@ -76,6 +80,10 @@ export const CommentComposer = memo(function CommentComposer({ setImageBlob, commentText, setCommentText, + commentRangeStart, + commentRangeEnd, + toggleCommentRangeSelection, + clearCommentRangeSelection, playVoice, playingVoiceId, voiceProgress, @@ -103,6 +111,16 @@ export const CommentComposer = memo(function CommentComposer({ pauseVideoForAnnotation, assets, }: 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 (
{isRecording ? ( @@ -192,6 +210,31 @@ export const CommentComposer = memo(function CommentComposer({ rows={1} className="resize-none text-sm" /> +
+ + {commentRangeLabel && ( + + {commentRangeLabel} + + )} + {hasCommentRange && ( + + )} +
)} +
+ + {commentRangeLabel && ( + + {commentRangeLabel} + + )} + {hasCommentRange && ( + + )} +
void; currentUserId: string | null; projectOwnerId: string; @@ -87,6 +87,10 @@ interface CommentsPaneProps { setReplyingTo: (id: string | null) => void; replyText: string; setReplyText: (value: string) => void; + replyRangeStart: number | null; + replyRangeEnd: number | null; + toggleReplyRangeSelection: () => void; + clearReplyRangeSelection: () => void; handleReplyComment: ( parentId: string, voiceData?: { url: string; duration: number }, @@ -161,6 +165,10 @@ export const CommentsPane = memo(function CommentsPane({ setReplyingTo, replyText, setReplyText, + replyRangeStart, + replyRangeEnd, + toggleReplyRangeSelection, + clearReplyRangeSelection, handleReplyComment, startReplyRecording, isReplyRecording, @@ -186,6 +194,18 @@ export const CommentsPane = memo(function CommentsPane({ assetsPane, }: CommentsPaneProps) { 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 ( <> @@ -383,13 +403,14 @@ export const CommentsPane = memo(function CommentsPane({ onClick={() => handleSeekToTimestamp(comment.timestamp, comment.annotationData, { 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" title="Jump to this timestamp" > - {formatTime(comment.timestamp)} + {formatCommentRange(comment.timestamp, comment.timestampEnd)} {canResolveComments && ( @@ -416,6 +437,7 @@ export const CommentsPane = memo(function CommentsPane({ { + clearReplyRangeSelection(); setReplyingTo(comment.id); setReplyText(''); }} @@ -670,6 +692,19 @@ export const CommentsPane = memo(function CommentsPane({ {replyAuthor} + {new Date(reply.createdAt).toLocaleDateString()} @@ -938,6 +973,31 @@ export const CommentsPane = memo(function CommentsPane({ rows={1} className="resize-none text-sm" /> +
+ + {replyRangeLabel && ( + + {replyRangeLabel} + + )} + {replyRangeStart !== null && ( + + )} +
+
+ + {replyRangeLabel && ( + + {replyRangeLabel} + + )} + {replyRangeStart !== null && ( + + )} +
@@ -361,7 +371,7 @@ export const PlayerCore = memo(function PlayerCore({ size="icon" className="h-8 w-8" onClick={() => handleSkip(10)} - title="Forward 10s" + title={isFrameMode ? `Forward ${frameStepLabel}` : 'Forward 10s'} > @@ -375,6 +385,16 @@ export const PlayerCore = memo(function PlayerCore({
+ + {activeProviderId === 'bunny' && ( @@ -489,21 +509,70 @@ export const PlayerCore = memo(function PlayerCore({ style={{ left: `calc(${duration > 0 ? (currentTime / duration) * 100 : 0}% - 2px)` }} /> - {commentMarkers.map((comment) => ( - + ); + } + + return ( +
diff --git a/components/video-page/types.ts b/components/video-page/types.ts index 4d1bbdd..3f8b08e 100644 --- a/components/video-page/types.ts +++ b/components/video-page/types.ts @@ -78,6 +78,8 @@ export interface ApprovalRequest { export interface CommentReply { id: string; content: string | null; + timestamp: number; + timestampEnd: number | null; voiceUrl: string | null; voiceDuration: number | null; imageUrl: string | null; @@ -94,6 +96,7 @@ export interface Comment { id: string; content: string | null; timestamp: number; + timestampEnd: number | null; voiceUrl: string | null; voiceDuration: number | null; imageUrl: string | null; @@ -145,6 +148,7 @@ export type DownloadTarget = BunnyDownloadPreference | 'direct'; export interface CommentMarker { id: string; timestamp: number; + timestampEnd: number | null; color: string; annotationData: string | null; preview: string;