mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 17:46:06 +00:00
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:
@@ -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}
|
||||
|
||||
@@ -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 (
|
||||
<div className="shrink-0 p-4 border-t bg-background">
|
||||
{isRecording ? (
|
||||
@@ -192,6 +210,31 @@ export const CommentComposer = memo(function CommentComposer({
|
||||
rows={1}
|
||||
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
|
||||
size="sm"
|
||||
onClick={submitCommentWithMedia}
|
||||
@@ -250,6 +293,31 @@ export const CommentComposer = memo(function CommentComposer({
|
||||
</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-1 min-w-0">
|
||||
<MentionTextarea
|
||||
|
||||
@@ -58,7 +58,7 @@ interface CommentsPaneProps {
|
||||
handleSeekToTimestamp: (
|
||||
timestamp: number,
|
||||
annotation?: string | null,
|
||||
options?: { pauseAfterSeek?: boolean }
|
||||
options?: { pauseAfterSeek?: boolean; timestampEnd?: number | null }
|
||||
) => 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"
|
||||
>
|
||||
<Clock className="h-3 w-3" />
|
||||
{formatTime(comment.timestamp)}
|
||||
{formatCommentRange(comment.timestamp, comment.timestampEnd)}
|
||||
<ArrowUpRight className="h-3 w-3" />
|
||||
</button>
|
||||
{canResolveComments && (
|
||||
@@ -416,6 +437,7 @@ export const CommentsPane = memo(function CommentsPane({
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
clearReplyRangeSelection();
|
||||
setReplyingTo(comment.id);
|
||||
setReplyText('');
|
||||
}}
|
||||
@@ -670,6 +692,19 @@ export const CommentsPane = memo(function CommentsPane({
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<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">
|
||||
{new Date(reply.createdAt).toLocaleDateString()}
|
||||
</span>
|
||||
@@ -938,6 +973,31 @@ export const CommentsPane = memo(function CommentsPane({
|
||||
rows={1}
|
||||
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">
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -1001,6 +1061,7 @@ export const CommentsPane = memo(function CommentsPane({
|
||||
handleReplyComment(comment.id);
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
clearReplyRangeSelection();
|
||||
setReplyingTo(null);
|
||||
setReplyText('');
|
||||
}
|
||||
@@ -1033,6 +1094,31 @@ export const CommentsPane = memo(function CommentsPane({
|
||||
onChange={(e) => handleImageSelect(e, true)}
|
||||
/>
|
||||
</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">
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -1054,6 +1140,7 @@ export const CommentsPane = memo(function CommentsPane({
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
clearReplyRangeSelection();
|
||||
setReplyingTo(null);
|
||||
setReplyText('');
|
||||
}}
|
||||
@@ -1070,6 +1157,7 @@ export const CommentsPane = memo(function CommentsPane({
|
||||
{!isReplying && !isEditing && (
|
||||
<button
|
||||
onClick={() => {
|
||||
clearReplyRangeSelection();
|
||||
setReplyingTo(comment.id);
|
||||
setReplyText('');
|
||||
}}
|
||||
|
||||
@@ -17,6 +17,7 @@ import type { AnnotationCanvasHandle, AnnotationStroke } from '@/components/anno
|
||||
import type {
|
||||
Comment,
|
||||
CommentActionsConfig,
|
||||
CommentReply,
|
||||
CommentTag,
|
||||
Version,
|
||||
VideoData,
|
||||
@@ -31,7 +32,6 @@ interface UseCommentActionsParams extends CommentActionsConfig {
|
||||
setVideo: Dispatch<SetStateAction<VideoData | null>>;
|
||||
activeVersionId: string | null;
|
||||
activeVersion: (Version & { comments: Comment[] }) | undefined;
|
||||
comments: Comment[];
|
||||
currentTime: number;
|
||||
isGuest: boolean;
|
||||
normalizedGuestName: string;
|
||||
@@ -56,7 +56,6 @@ export function useCommentActions({
|
||||
setVideo,
|
||||
activeVersionId,
|
||||
activeVersion,
|
||||
comments,
|
||||
currentTime,
|
||||
isGuest,
|
||||
normalizedGuestName,
|
||||
@@ -83,6 +82,8 @@ export function useCommentActions({
|
||||
const [isUploadingAudio, setIsUploadingAudio] = useState(false);
|
||||
const [imageBlob, setImageBlob] = useState<File | null>(null);
|
||||
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 mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
||||
const audioChunksRef = useRef<Blob[]>([]);
|
||||
@@ -97,6 +98,8 @@ export function useCommentActions({
|
||||
const [isUploadingReplyAudio, setIsUploadingReplyAudio] = useState(false);
|
||||
const [replyImageBlob, setReplyImageBlob] = useState<File | null>(null);
|
||||
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 replyMediaRecorderRef = useRef<MediaRecorder | null>(null);
|
||||
const replyAudioChunksRef = useRef<Blob[]>([]);
|
||||
@@ -114,6 +117,38 @@ export function useCommentActions({
|
||||
|
||||
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(
|
||||
async (intent: 'audio' | 'image') => {
|
||||
if (!isGuest) return null;
|
||||
@@ -151,11 +186,13 @@ export function useCommentActions({
|
||||
}
|
||||
|
||||
const tempId = `temp-${Date.now()}`;
|
||||
const commentTimestamp = commentRangeStart ?? currentTime;
|
||||
const serializedAnnotation = effectiveStrokes ? JSON.stringify(effectiveStrokes) : null;
|
||||
const optimisticComment: Comment = {
|
||||
id: tempId,
|
||||
content: voiceData || imageBlob ? commentText.trim() || null : commentText,
|
||||
timestamp: currentTime,
|
||||
timestamp: commentTimestamp,
|
||||
timestampEnd: commentRangeEnd,
|
||||
voiceUrl: voiceData?.url ?? null,
|
||||
voiceDuration: voiceData?.duration ?? null,
|
||||
imageUrl: imageBlob ? URL.createObjectURL(imageBlob) : null,
|
||||
@@ -186,6 +223,7 @@ export function useCommentActions({
|
||||
setImageBlob(null);
|
||||
setAnnotationStrokes(null);
|
||||
setIsAnnotating(false);
|
||||
clearCommentRangeSelection();
|
||||
setViewingAnnotation(effectiveStrokes || null);
|
||||
|
||||
setIsSubmittingComment(true);
|
||||
@@ -217,7 +255,8 @@ export function useCommentActions({
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
content: voiceData || imageBlob ? commentText.trim() || null : commentText,
|
||||
timestamp: currentTime,
|
||||
timestamp: commentTimestamp,
|
||||
...(commentRangeEnd !== null && { timestampEnd: commentRangeEnd }),
|
||||
...(voiceData && { voiceUrl: voiceData.url, voiceDuration: voiceData.duration }),
|
||||
...(imageData && { imageUrl: imageData.url }),
|
||||
...(isGuest && normalizedGuestName && { guestName: normalizedGuestName }),
|
||||
@@ -287,6 +326,8 @@ export function useCommentActions({
|
||||
},
|
||||
[
|
||||
commentText,
|
||||
commentRangeEnd,
|
||||
commentRangeStart,
|
||||
currentTime,
|
||||
activeVersion,
|
||||
activeVersionId,
|
||||
@@ -304,6 +345,7 @@ export function useCommentActions({
|
||||
setSelectedTagId,
|
||||
setAnnotationStrokes,
|
||||
setIsAnnotating,
|
||||
clearCommentRangeSelection,
|
||||
setViewingAnnotation,
|
||||
setVideo,
|
||||
fetchAssets,
|
||||
@@ -594,10 +636,12 @@ export function useCommentActions({
|
||||
if (!activeVersion || !activeVersionId) return;
|
||||
|
||||
const tempId = `temp-reply-${Date.now()}`;
|
||||
const parentComment = comments.find((c) => c.id === parentId);
|
||||
const optimisticReply = {
|
||||
const replyTimestamp = replyRangeStart ?? currentTime;
|
||||
const optimisticReply: CommentReply = {
|
||||
id: tempId,
|
||||
content: voiceData || replyImageBlob ? replyText.trim() || null : replyText,
|
||||
timestamp: replyTimestamp,
|
||||
timestampEnd: replyRangeEnd,
|
||||
voiceUrl: voiceData?.url ?? null,
|
||||
voiceDuration: voiceData?.duration ?? null,
|
||||
imageUrl: replyImageBlob ? URL.createObjectURL(replyImageBlob) : null,
|
||||
@@ -634,6 +678,7 @@ export function useCommentActions({
|
||||
setReplyAudioBlob(null);
|
||||
setReplyRecordingTime(0);
|
||||
setReplyImageBlob(null);
|
||||
clearReplyRangeSelection();
|
||||
|
||||
setIsSubmittingReply(true);
|
||||
isMutatingRef.current = true;
|
||||
@@ -664,7 +709,8 @@ export function useCommentActions({
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
content: voiceData || submittedImageData ? replyText.trim() || null : replyText,
|
||||
timestamp: parentComment?.timestamp ?? currentTime,
|
||||
timestamp: replyTimestamp,
|
||||
...(replyRangeEnd !== null && { timestampEnd: replyRangeEnd }),
|
||||
parentId,
|
||||
...(voiceData && { voiceUrl: voiceData.url, voiceDuration: voiceData.duration }),
|
||||
...(submittedImageData && { imageUrl: submittedImageData.url }),
|
||||
@@ -752,9 +798,10 @@ export function useCommentActions({
|
||||
},
|
||||
[
|
||||
replyText,
|
||||
replyRangeEnd,
|
||||
replyRangeStart,
|
||||
activeVersion,
|
||||
activeVersionId,
|
||||
comments,
|
||||
currentTime,
|
||||
isGuest,
|
||||
normalizedGuestName,
|
||||
@@ -764,6 +811,7 @@ export function useCommentActions({
|
||||
getGuestUploadToken,
|
||||
setVideo,
|
||||
fetchAssets,
|
||||
clearReplyRangeSelection,
|
||||
]
|
||||
);
|
||||
|
||||
@@ -1099,6 +1147,10 @@ export function useCommentActions({
|
||||
isUploadingAudio,
|
||||
imageBlob,
|
||||
setImageBlob,
|
||||
commentRangeStart,
|
||||
commentRangeEnd,
|
||||
toggleCommentRangeSelection,
|
||||
clearCommentRangeSelection,
|
||||
isUploadingImage,
|
||||
imageInputRef,
|
||||
handleAddComment,
|
||||
@@ -1120,6 +1172,10 @@ export function useCommentActions({
|
||||
replyAudioBlob,
|
||||
replyImageBlob,
|
||||
setReplyImageBlob,
|
||||
replyRangeStart,
|
||||
replyRangeEnd,
|
||||
toggleReplyRangeSelection,
|
||||
clearReplyRangeSelection,
|
||||
isUploadingReplyAudio,
|
||||
isUploadingReplyImage,
|
||||
replyImageInputRef,
|
||||
|
||||
@@ -57,6 +57,8 @@ export function useVideoPlayer({
|
||||
const [videoDuration, setVideoDuration] = useState(0);
|
||||
const [isPlaying, setIsPlaying] = 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 isDraggingRef = useRef(false);
|
||||
const [playbackSpeed, setPlaybackSpeed] = useState(1);
|
||||
@@ -71,10 +73,69 @@ export function useVideoPlayer({
|
||||
const [cursorIdle, setCursorIdle] = useState(false);
|
||||
const cursorIdleTimerRef = 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 [showComments, setShowComments] = useState(true);
|
||||
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(() => {
|
||||
isDraggingRef.current = isDragging;
|
||||
}, [isDragging]);
|
||||
@@ -157,6 +218,7 @@ export function useVideoPlayer({
|
||||
setVideoDuration(0);
|
||||
setIsPlaying(false);
|
||||
setIsMuted(false);
|
||||
setEstimatedFrameRate(null);
|
||||
setPlaybackSpeed(1);
|
||||
setQualityOptions((prev) => (versionChanged ? [] : prev));
|
||||
setSelectedQualityLevel(bunnySourcePreference === 'original' ? -2 : -1);
|
||||
@@ -182,6 +244,7 @@ export function useVideoPlayer({
|
||||
clearTimeout(bunnyRetryTimerRef.current);
|
||||
bunnyRetryTimerRef.current = null;
|
||||
}
|
||||
stopBunnyFrameTracking();
|
||||
|
||||
const initPlayer = () => {
|
||||
if (isYoutube) {
|
||||
@@ -343,6 +406,9 @@ export function useVideoPlayer({
|
||||
}
|
||||
}
|
||||
syncDuration();
|
||||
if (!videoEl.paused) {
|
||||
startBunnyFrameTracking();
|
||||
}
|
||||
};
|
||||
|
||||
const onPlay = () => {
|
||||
@@ -351,15 +417,18 @@ export function useVideoPlayer({
|
||||
setBunnyPlaybackState('none');
|
||||
}
|
||||
syncDuration();
|
||||
startBunnyFrameTracking();
|
||||
};
|
||||
|
||||
const onPause = () => {
|
||||
setIsPlaying(false);
|
||||
stopBunnyFrameTracking();
|
||||
saveProgress();
|
||||
};
|
||||
|
||||
const onEnded = () => {
|
||||
setIsPlaying(false);
|
||||
stopBunnyFrameTracking();
|
||||
saveProgress();
|
||||
};
|
||||
|
||||
@@ -535,6 +604,7 @@ export function useVideoPlayer({
|
||||
destroy: () => {
|
||||
destroyed = true;
|
||||
clearRetryTimer();
|
||||
stopBunnyFrameTracking();
|
||||
videoEl.removeEventListener('loadedmetadata', onLoadedMetadata);
|
||||
videoEl.removeEventListener('play', onPlay);
|
||||
videoEl.removeEventListener('pause', onPause);
|
||||
@@ -593,6 +663,7 @@ export function useVideoPlayer({
|
||||
clearTimeout(bunnyRetryTimerRef.current);
|
||||
bunnyRetryTimerRef.current = null;
|
||||
}
|
||||
stopBunnyFrameTracking();
|
||||
};
|
||||
}, [
|
||||
activeProviderId,
|
||||
@@ -606,6 +677,8 @@ export function useVideoPlayer({
|
||||
iframeRef,
|
||||
playerRef,
|
||||
scheduleWatchProgressSaveRef,
|
||||
startBunnyFrameTracking,
|
||||
stopBunnyFrameTracking,
|
||||
videoRef,
|
||||
]);
|
||||
|
||||
@@ -667,6 +740,88 @@ export function useVideoPlayer({
|
||||
return videoDuration || activeVersion?.duration || 0;
|
||||
}, [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(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (document.querySelector('[data-slot="dialog-content"]')) {
|
||||
@@ -692,23 +847,11 @@ export function useVideoPlayer({
|
||||
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);
|
||||
}
|
||||
handleSkip(-5);
|
||||
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);
|
||||
}
|
||||
handleSkip(5);
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
@@ -797,73 +940,11 @@ export function useVideoPlayer({
|
||||
isMuted,
|
||||
playbackSpeed,
|
||||
speedOptions,
|
||||
handleSkip,
|
||||
toggleFullscreen,
|
||||
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(
|
||||
(speed: number) => {
|
||||
setPlaybackSpeed(speed);
|
||||
@@ -965,6 +1046,9 @@ export function useVideoPlayer({
|
||||
setVideoDuration,
|
||||
isPlaying,
|
||||
isMuted,
|
||||
isFrameMode,
|
||||
frameStepSeconds,
|
||||
frameStepLabel,
|
||||
isDragging,
|
||||
playbackSpeed,
|
||||
qualityOptions,
|
||||
@@ -982,6 +1066,7 @@ export function useVideoPlayer({
|
||||
handlePlayPause,
|
||||
handleSeekToTimestamp,
|
||||
handleMuteToggle,
|
||||
handleFrameModeToggle,
|
||||
handleSkip,
|
||||
handleSpeedChange,
|
||||
handleQualityChange,
|
||||
|
||||
@@ -70,7 +70,10 @@ interface PlayerCoreProps {
|
||||
setIsEditingAnnotation: (value: boolean) => void;
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
isFrameMode: boolean;
|
||||
frameStepLabel: string;
|
||||
handleSkip: (seconds: number) => void;
|
||||
handleFrameModeToggle: () => void;
|
||||
handleMuteToggle: () => void;
|
||||
isMuted: boolean;
|
||||
selectedQualityLabel: string;
|
||||
@@ -86,7 +89,11 @@ interface PlayerCoreProps {
|
||||
setIsMobileCommentsOpen: (value: boolean) => void;
|
||||
handleTimelineMouseDown: (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[];
|
||||
}
|
||||
|
||||
@@ -127,7 +134,10 @@ export const PlayerCore = memo(function PlayerCore({
|
||||
setIsEditingAnnotation,
|
||||
currentTime,
|
||||
duration,
|
||||
isFrameMode,
|
||||
frameStepLabel,
|
||||
handleSkip,
|
||||
handleFrameModeToggle,
|
||||
handleMuteToggle,
|
||||
isMuted,
|
||||
selectedQualityLabel,
|
||||
@@ -351,7 +361,7 @@ export const PlayerCore = memo(function PlayerCore({
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => handleSkip(-10)}
|
||||
title="Back 10s"
|
||||
title={isFrameMode ? `Back ${frameStepLabel}` : 'Back 10s'}
|
||||
>
|
||||
<SkipBack className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -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'}
|
||||
>
|
||||
<SkipForward className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -375,6 +385,16 @@ export const PlayerCore = memo(function PlayerCore({
|
||||
</span>
|
||||
|
||||
<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' && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
@@ -489,21 +509,70 @@ export const PlayerCore = memo(function PlayerCore({
|
||||
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}`}
|
||||
/>
|
||||
))}
|
||||
{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
|
||||
key={comment.id}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleSeekToTimestamp(comment.timestamp, comment.annotationData, {
|
||||
pauseAfterSeek: true,
|
||||
timestampEnd: comment.timestampEnd,
|
||||
});
|
||||
}}
|
||||
className="absolute top-1/2 z-10 h-4 -translate-y-1/2 transition-opacity hover:opacity-100"
|
||||
style={{
|
||||
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,
|
||||
}}
|
||||
title={`${formatTime(comment.timestamp)}${comment.preview}`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user