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:
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user