mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 09:36:08 +00:00
The recording clock counted setInterval ticks, which a background tab throttles away: a recording that kept going looked frozen at 13 seconds and was saved with that length. It now reads the wall clock instead. MediaRecorder also writes WebM with no usable duration. Chrome omits the element entirely, Firefox reserves a Duration of 0.0 it never fills in, so players had no length to show and played past the end of the seek bar. lib/webm-duration.ts stamps the recorded length into Segment > Info when the recording stops, in place where the browser reserved room for it.
1249 lines
40 KiB
TypeScript
1249 lines
40 KiB
TypeScript
'use client';
|
|
|
|
import {
|
|
useCallback,
|
|
useEffect,
|
|
useRef,
|
|
useState,
|
|
type ChangeEvent,
|
|
type ClipboardEvent,
|
|
type DragEvent,
|
|
type Dispatch,
|
|
type RefObject,
|
|
type SetStateAction,
|
|
} from 'react';
|
|
import { toast } from 'sonner';
|
|
import type { AnnotationCanvasHandle, AnnotationStroke } from '@/components/annotation-canvas';
|
|
import type {
|
|
Comment,
|
|
CommentActionsConfig,
|
|
CommentReply,
|
|
CommentTag,
|
|
Version,
|
|
VideoData,
|
|
} from '@/components/video-page/types';
|
|
import {
|
|
extractPastedImageFile,
|
|
validateImageFile,
|
|
} from '@/components/video-page/image-upload-utils';
|
|
import { validateAnnotationStrokes } from '@/lib/validation';
|
|
import { withWebmDuration } from '@/lib/webm-duration';
|
|
|
|
interface UseCommentActionsParams extends CommentActionsConfig {
|
|
setVideo: Dispatch<SetStateAction<VideoData | null>>;
|
|
activeVersionId: string | null;
|
|
activeVersion: (Version & { comments: Comment[] }) | undefined;
|
|
currentTime: number;
|
|
isGuest: boolean;
|
|
normalizedGuestName: string;
|
|
currentUserName: string | null;
|
|
canResolveComments: boolean;
|
|
availableTags: CommentTag[];
|
|
selectedTagId: string | null;
|
|
setSelectedTagId: Dispatch<SetStateAction<string | null>>;
|
|
annotationStrokes: AnnotationStroke[] | null;
|
|
setAnnotationStrokes: Dispatch<SetStateAction<AnnotationStroke[] | null>>;
|
|
isAnnotating: boolean;
|
|
setIsAnnotating: Dispatch<SetStateAction<boolean>>;
|
|
setViewingAnnotation: Dispatch<SetStateAction<AnnotationStroke[] | null>>;
|
|
annotationCanvasRef: RefObject<AnnotationCanvasHandle | null>;
|
|
editAnnotationCanvasRef: RefObject<AnnotationCanvasHandle | null>;
|
|
fetchVersionComments: (versionId: string, useEtag: boolean) => Promise<void>;
|
|
fetchAssets: () => Promise<void>;
|
|
}
|
|
|
|
function getAudioUploadFilename(blob: Blob): string {
|
|
const mime = blob.type.split(';')[0].trim().toLowerCase();
|
|
if (mime === 'audio/mp4') return 'recording.m4a';
|
|
if (mime === 'audio/ogg' || mime === 'audio/opus') return 'recording.ogg';
|
|
if (mime === 'audio/mpeg') return 'recording.mp3';
|
|
if (mime === 'audio/wav') return 'recording.wav';
|
|
return 'recording.webm';
|
|
}
|
|
|
|
export function useCommentActions({
|
|
videoId,
|
|
setVideo,
|
|
activeVersionId,
|
|
activeVersion,
|
|
currentTime,
|
|
isGuest,
|
|
normalizedGuestName,
|
|
currentUserName,
|
|
canResolveComments,
|
|
availableTags,
|
|
selectedTagId,
|
|
setSelectedTagId,
|
|
annotationStrokes,
|
|
setAnnotationStrokes,
|
|
isAnnotating,
|
|
setIsAnnotating,
|
|
setViewingAnnotation,
|
|
annotationCanvasRef,
|
|
editAnnotationCanvasRef,
|
|
fetchVersionComments,
|
|
fetchAssets,
|
|
}: UseCommentActionsParams) {
|
|
const [commentText, setCommentText] = useState('');
|
|
const [isSubmittingComment, setIsSubmittingComment] = useState(false);
|
|
const [isRecording, setIsRecording] = useState(false);
|
|
const [recordingTime, setRecordingTime] = useState(0);
|
|
const [audioBlob, setAudioBlob] = useState<Blob | null>(null);
|
|
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[]>([]);
|
|
const recordingTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
const recordingStartedAtRef = useRef(0);
|
|
|
|
const [replyingTo, setReplyingTo] = useState<string | null>(null);
|
|
const [replyText, setReplyText] = useState('');
|
|
const [isSubmittingReply, setIsSubmittingReply] = useState(false);
|
|
const [isReplyRecording, setIsReplyRecording] = useState(false);
|
|
const [replyRecordingTime, setReplyRecordingTime] = useState(0);
|
|
const [replyAudioBlob, setReplyAudioBlob] = useState<Blob | null>(null);
|
|
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[]>([]);
|
|
const replyRecordingTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
const replyRecordingStartedAtRef = useRef(0);
|
|
|
|
const [editingCommentId, setEditingCommentId] = useState<string | null>(null);
|
|
const [editText, setEditText] = useState('');
|
|
// `undefined` means "this editor does not manage a tag", which is the reply editor:
|
|
// replies have no tag picker. `null` means "no tag", which the comment editor seeds
|
|
// from the comment itself. Initialising to `null` made `editTagId !== undefined` always
|
|
// true, so editing a reply's text sent `tagId: null` and cleared its tag, or sent a
|
|
// stale value left over from a previous edit.
|
|
const [editTagId, setEditTagId] = useState<string | null | undefined>(undefined);
|
|
const [editAnnotationData, setEditAnnotationData] = useState<string | null | undefined>(
|
|
undefined
|
|
);
|
|
const [isEditingAnnotation, setIsEditingAnnotation] = useState(false);
|
|
const [isSubmittingEdit, setIsSubmittingEdit] = useState(false);
|
|
const [previewImage, setPreviewImage] = useState<string | null>(null);
|
|
|
|
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;
|
|
|
|
const response = await fetch(`/api/watch/${videoId}/upload-token`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ intent }),
|
|
});
|
|
const payload = (await response.json().catch(() => null)) as {
|
|
data?: { token?: string };
|
|
error?: string;
|
|
} | null;
|
|
const token = payload?.data?.token;
|
|
if (!response.ok || !token) {
|
|
throw new Error(payload?.error || 'Failed to prepare upload');
|
|
}
|
|
return token;
|
|
},
|
|
[isGuest, videoId]
|
|
);
|
|
|
|
const handleAddComment = useCallback(
|
|
async (voiceData?: { url: string; duration: number }) => {
|
|
if (!voiceData && !imageBlob && !commentText.trim() && !annotationStrokes && !isAnnotating)
|
|
return;
|
|
if (!activeVersion || !activeVersionId) return;
|
|
|
|
let effectiveStrokes = annotationStrokes;
|
|
if (isAnnotating && annotationCanvasRef.current) {
|
|
const canvasStrokes = annotationCanvasRef.current.getStrokes();
|
|
if (canvasStrokes.length > 0) {
|
|
effectiveStrokes = canvasStrokes;
|
|
}
|
|
}
|
|
|
|
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: commentTimestamp,
|
|
timestampEnd: commentRangeEnd,
|
|
voiceUrl: voiceData?.url ?? null,
|
|
voiceDuration: voiceData?.duration ?? null,
|
|
imageUrl: imageBlob ? URL.createObjectURL(imageBlob) : null,
|
|
annotationData: serializedAnnotation,
|
|
isResolved: false,
|
|
createdAt: new Date().toISOString(),
|
|
author: isGuest ? null : { id: 'current-user', name: currentUserName, image: null },
|
|
guestName: isGuest ? normalizedGuestName : null,
|
|
canEdit: true,
|
|
canDelete: true,
|
|
tag: availableTags.find((t) => t.id === selectedTagId) || null,
|
|
replies: [],
|
|
};
|
|
|
|
setVideo((prev) => {
|
|
if (!prev) return prev;
|
|
return {
|
|
...prev,
|
|
versions: prev.versions.map((v) =>
|
|
v.id === activeVersionId ? { ...v, comments: [...v.comments, optimisticComment] } : v
|
|
),
|
|
};
|
|
});
|
|
|
|
setCommentText('');
|
|
setSelectedTagId(availableTags.length > 0 ? availableTags[0].id : null);
|
|
setAudioBlob(null);
|
|
setImageBlob(null);
|
|
setAnnotationStrokes(null);
|
|
setIsAnnotating(false);
|
|
clearCommentRangeSelection();
|
|
setViewingAnnotation(effectiveStrokes || null);
|
|
|
|
setIsSubmittingComment(true);
|
|
isMutatingRef.current = true;
|
|
|
|
try {
|
|
let imageData: { url: string } | undefined;
|
|
|
|
if (imageBlob) {
|
|
setIsUploadingImage(true);
|
|
const imageFormData = new FormData();
|
|
imageFormData.append('image', imageBlob);
|
|
imageFormData.append('videoId', videoId);
|
|
const uploadToken = await getGuestUploadToken('image');
|
|
if (uploadToken) imageFormData.append('uploadToken', uploadToken);
|
|
|
|
const imageRes = await fetch('/api/upload/image', {
|
|
method: 'POST',
|
|
body: imageFormData,
|
|
});
|
|
|
|
if (!imageRes.ok) throw new Error('Failed to upload image');
|
|
const imageDataResponse = await imageRes.json();
|
|
imageData = { url: imageDataResponse.data.url };
|
|
}
|
|
|
|
const res = await fetch(`/api/versions/${activeVersion.id}/comments`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
content: voiceData || imageBlob ? commentText.trim() || null : commentText,
|
|
timestamp: commentTimestamp,
|
|
...(commentRangeEnd !== null && { timestampEnd: commentRangeEnd }),
|
|
...(voiceData && { voiceUrl: voiceData.url, voiceDuration: voiceData.duration }),
|
|
...(imageData && { imageUrl: imageData.url }),
|
|
...(isGuest && normalizedGuestName && { guestName: normalizedGuestName }),
|
|
...(selectedTagId && { tagId: selectedTagId }),
|
|
...(effectiveStrokes && { annotationData: effectiveStrokes }),
|
|
}),
|
|
});
|
|
|
|
if (res.ok) {
|
|
const response = await res.json();
|
|
const newComment = response.data;
|
|
setVideo((prev) => {
|
|
if (!prev) return prev;
|
|
return {
|
|
...prev,
|
|
versions: prev.versions.map((v) =>
|
|
v.id === activeVersionId
|
|
? {
|
|
...v,
|
|
comments: v.comments.map((c) =>
|
|
c.id === tempId
|
|
? { ...newComment, replies: newComment.replies || [] }
|
|
: { ...c, replies: c.replies || [] }
|
|
),
|
|
}
|
|
: v
|
|
),
|
|
};
|
|
});
|
|
|
|
// If an image was attached, refresh the assets list
|
|
if (imageData) {
|
|
void fetchAssets();
|
|
}
|
|
} else {
|
|
setVideo((prev) => {
|
|
if (!prev) return prev;
|
|
return {
|
|
...prev,
|
|
versions: prev.versions.map((v) =>
|
|
v.id === activeVersionId
|
|
? { ...v, comments: v.comments.filter((c) => c.id !== tempId) }
|
|
: v
|
|
),
|
|
};
|
|
});
|
|
toast.error('Failed to add comment');
|
|
}
|
|
} catch {
|
|
setVideo((prev) => {
|
|
if (!prev) return prev;
|
|
return {
|
|
...prev,
|
|
versions: prev.versions.map((v) =>
|
|
v.id === activeVersionId
|
|
? { ...v, comments: v.comments.filter((c) => c.id !== tempId) }
|
|
: v
|
|
),
|
|
};
|
|
});
|
|
toast.error('Failed to add comment');
|
|
} finally {
|
|
setIsSubmittingComment(false);
|
|
setIsUploadingImage(false);
|
|
isMutatingRef.current = false;
|
|
}
|
|
},
|
|
[
|
|
commentText,
|
|
commentRangeEnd,
|
|
commentRangeStart,
|
|
currentTime,
|
|
activeVersion,
|
|
activeVersionId,
|
|
isGuest,
|
|
normalizedGuestName,
|
|
currentUserName,
|
|
selectedTagId,
|
|
availableTags,
|
|
imageBlob,
|
|
annotationStrokes,
|
|
isAnnotating,
|
|
videoId,
|
|
getGuestUploadToken,
|
|
annotationCanvasRef,
|
|
setSelectedTagId,
|
|
setAnnotationStrokes,
|
|
setIsAnnotating,
|
|
clearCommentRangeSelection,
|
|
setViewingAnnotation,
|
|
setVideo,
|
|
fetchAssets,
|
|
]
|
|
);
|
|
|
|
const handleImageSelect = useCallback(
|
|
async (e: ChangeEvent<HTMLInputElement>, isReply: boolean = false) => {
|
|
const file = e.target.files?.[0];
|
|
if (!file) return;
|
|
|
|
const imageError = await validateImageFile(file);
|
|
if (imageError) {
|
|
toast.error(imageError);
|
|
return;
|
|
}
|
|
|
|
if (isReply) {
|
|
setReplyImageBlob(file);
|
|
} else {
|
|
setImageBlob(file);
|
|
}
|
|
},
|
|
[]
|
|
);
|
|
|
|
const handlePaste = useCallback(
|
|
async (e: ClipboardEvent<HTMLTextAreaElement>, isReply: boolean = false) => {
|
|
const file = extractPastedImageFile(e.clipboardData);
|
|
if (!file) return;
|
|
e.preventDefault();
|
|
|
|
const imageError = await validateImageFile(file);
|
|
if (imageError) {
|
|
toast.error(imageError);
|
|
return;
|
|
}
|
|
|
|
if (isReply) {
|
|
setReplyImageBlob(file);
|
|
} else {
|
|
setImageBlob(file);
|
|
}
|
|
},
|
|
[]
|
|
);
|
|
|
|
const handleDrop = useCallback(async (e: DragEvent<HTMLDivElement>, isReply: boolean = false) => {
|
|
e.preventDefault();
|
|
const file = extractPastedImageFile(e.dataTransfer);
|
|
if (!file) return;
|
|
|
|
const imageError = await validateImageFile(file);
|
|
if (imageError) {
|
|
toast.error(imageError);
|
|
return;
|
|
}
|
|
|
|
if (isReply) {
|
|
setReplyImageBlob(file);
|
|
} else {
|
|
setImageBlob(file);
|
|
}
|
|
}, []);
|
|
|
|
const startRecording = useCallback(async () => {
|
|
try {
|
|
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
|
const mediaRecorder = new MediaRecorder(stream, {
|
|
mimeType: MediaRecorder.isTypeSupported('audio/webm;codecs=opus')
|
|
? 'audio/webm;codecs=opus'
|
|
: 'audio/webm',
|
|
});
|
|
|
|
audioChunksRef.current = [];
|
|
mediaRecorderRef.current = mediaRecorder;
|
|
|
|
mediaRecorder.ondataavailable = (e) => {
|
|
if (e.data.size > 0) {
|
|
audioChunksRef.current.push(e.data);
|
|
}
|
|
};
|
|
|
|
mediaRecorder.onstop = async () => {
|
|
const elapsedMs = Date.now() - recordingStartedAtRef.current;
|
|
const recordedMime = mediaRecorder.mimeType || 'audio/webm';
|
|
const raw = new Blob(audioChunksRef.current, { type: recordedMime });
|
|
// MediaRecorder leaves the WebM duration unset, so stamp it in before the
|
|
// blob reaches a player or the upload.
|
|
setAudioBlob(await withWebmDuration(raw, elapsedMs));
|
|
setRecordingTime(elapsedMs / 1000);
|
|
stream.getTracks().forEach((track) => track.stop());
|
|
if (recordingTimerRef.current) {
|
|
clearInterval(recordingTimerRef.current);
|
|
recordingTimerRef.current = null;
|
|
}
|
|
};
|
|
|
|
mediaRecorder.start(100);
|
|
setIsRecording(true);
|
|
setRecordingTime(0);
|
|
// Background tabs throttle timers, so read the clock instead of counting ticks.
|
|
recordingStartedAtRef.current = Date.now();
|
|
recordingTimerRef.current = setInterval(() => {
|
|
setRecordingTime((Date.now() - recordingStartedAtRef.current) / 1000);
|
|
}, 100);
|
|
} catch (err) {
|
|
console.error('Failed to start recording:', err);
|
|
}
|
|
}, []);
|
|
|
|
const stopRecording = useCallback(() => {
|
|
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== 'inactive') {
|
|
mediaRecorderRef.current.stop();
|
|
}
|
|
setIsRecording(false);
|
|
}, []);
|
|
|
|
const cancelRecording = useCallback(() => {
|
|
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== 'inactive') {
|
|
mediaRecorderRef.current.stop();
|
|
}
|
|
setIsRecording(false);
|
|
setAudioBlob(null);
|
|
setRecordingTime(0);
|
|
}, []);
|
|
|
|
const submitVoiceComment = useCallback(async () => {
|
|
if (!audioBlob || !activeVersion) return;
|
|
setIsUploadingAudio(true);
|
|
|
|
try {
|
|
const formData = new FormData();
|
|
const uploadFilename = getAudioUploadFilename(audioBlob);
|
|
formData.append('audio', audioBlob, uploadFilename);
|
|
formData.append('videoId', videoId);
|
|
const uploadToken = await getGuestUploadToken('audio');
|
|
if (uploadToken) formData.append('uploadToken', uploadToken);
|
|
|
|
const uploadRes = await fetch('/api/upload/audio', {
|
|
method: 'POST',
|
|
body: formData,
|
|
});
|
|
|
|
if (!uploadRes.ok) {
|
|
throw new Error('Failed to upload audio');
|
|
}
|
|
|
|
const uploadData = await uploadRes.json();
|
|
const { url } = uploadData.data;
|
|
|
|
await handleAddComment({ url, duration: recordingTime });
|
|
setAudioBlob(null);
|
|
setRecordingTime(0);
|
|
} catch (err) {
|
|
console.error('Failed to submit voice comment:', err);
|
|
} finally {
|
|
setIsUploadingAudio(false);
|
|
}
|
|
}, [audioBlob, activeVersion, recordingTime, handleAddComment, videoId, getGuestUploadToken]);
|
|
|
|
const submitCommentWithMedia = useCallback(async () => {
|
|
if (!activeVersion) return;
|
|
|
|
if (audioBlob && !imageBlob && !commentText.trim()) {
|
|
submitVoiceComment();
|
|
return;
|
|
}
|
|
|
|
if (audioBlob) setIsUploadingAudio(true);
|
|
if (imageBlob) setIsUploadingImage(true);
|
|
|
|
try {
|
|
let voiceData: { url: string; duration: number } | undefined;
|
|
if (audioBlob) {
|
|
const formData = new FormData();
|
|
formData.append('audio', audioBlob, getAudioUploadFilename(audioBlob));
|
|
formData.append('videoId', videoId);
|
|
const uploadToken = await getGuestUploadToken('audio');
|
|
if (uploadToken) formData.append('uploadToken', uploadToken);
|
|
const uploadRes = await fetch('/api/upload/audio', { method: 'POST', body: formData });
|
|
if (!uploadRes.ok) throw new Error('Failed to upload audio');
|
|
const uploadData = await uploadRes.json();
|
|
voiceData = { url: uploadData.data.url, duration: recordingTime };
|
|
}
|
|
|
|
await handleAddComment(voiceData);
|
|
|
|
setAudioBlob(null);
|
|
setRecordingTime(0);
|
|
setImageBlob(null);
|
|
if (imageInputRef.current) imageInputRef.current.value = '';
|
|
} catch (err) {
|
|
console.error('Failed to submit comment with media:', err);
|
|
toast.error('Failed to upload media');
|
|
} finally {
|
|
setIsUploadingAudio(false);
|
|
setIsUploadingImage(false);
|
|
}
|
|
}, [
|
|
audioBlob,
|
|
imageBlob,
|
|
activeVersion,
|
|
recordingTime,
|
|
commentText,
|
|
submitVoiceComment,
|
|
handleAddComment,
|
|
videoId,
|
|
getGuestUploadToken,
|
|
]);
|
|
|
|
const handleResolveComment = useCallback(
|
|
async (commentId: string, currentlyResolved: boolean) => {
|
|
if (!canResolveComments) {
|
|
toast.error('Only admins can resolve comments');
|
|
return;
|
|
}
|
|
if (!activeVersionId) return;
|
|
|
|
isMutatingRef.current = true;
|
|
// The optimistic flip, the request body and the rollback all derive from the same
|
|
// value. Flipping relative to the row (`!c.isResolved`) while the body and the
|
|
// rollback came from `currentlyResolved` meant a failed request could leave the
|
|
// comment in a state it was never in whenever the two disagreed.
|
|
const nextResolved = !currentlyResolved;
|
|
setVideo((prev) => {
|
|
if (!prev) return prev;
|
|
return {
|
|
...prev,
|
|
versions: prev.versions.map((v) =>
|
|
v.id === activeVersionId
|
|
? {
|
|
...v,
|
|
comments: v.comments.map((c) =>
|
|
c.id === commentId ? { ...c, isResolved: nextResolved } : c
|
|
),
|
|
}
|
|
: v
|
|
),
|
|
};
|
|
});
|
|
|
|
try {
|
|
const res = await fetch(`/api/comments/${commentId}`, {
|
|
method: 'PATCH',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ isResolved: nextResolved }),
|
|
});
|
|
|
|
if (!res.ok) {
|
|
setVideo((prev) => {
|
|
if (!prev) return prev;
|
|
return {
|
|
...prev,
|
|
versions: prev.versions.map((v) =>
|
|
v.id === activeVersionId
|
|
? {
|
|
...v,
|
|
comments: v.comments.map((c) =>
|
|
c.id === commentId ? { ...c, isResolved: currentlyResolved } : c
|
|
),
|
|
}
|
|
: v
|
|
),
|
|
};
|
|
});
|
|
toast.error('Failed to update comment');
|
|
}
|
|
} catch {
|
|
setVideo((prev) => {
|
|
if (!prev) return prev;
|
|
return {
|
|
...prev,
|
|
versions: prev.versions.map((v) =>
|
|
v.id === activeVersionId
|
|
? {
|
|
...v,
|
|
comments: v.comments.map((c) =>
|
|
c.id === commentId ? { ...c, isResolved: currentlyResolved } : c
|
|
),
|
|
}
|
|
: v
|
|
),
|
|
};
|
|
});
|
|
toast.error('Failed to update comment');
|
|
} finally {
|
|
isMutatingRef.current = false;
|
|
}
|
|
},
|
|
[activeVersionId, canResolveComments, setVideo]
|
|
);
|
|
|
|
const handleReplyComment = useCallback(
|
|
async (
|
|
parentId: string,
|
|
voiceData?: { url: string; duration: number },
|
|
imageData?: { url: string }
|
|
) => {
|
|
if (!voiceData && !replyImageBlob && !replyText.trim()) return;
|
|
if (!activeVersion || !activeVersionId) return;
|
|
|
|
const tempId = `temp-reply-${Date.now()}`;
|
|
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,
|
|
annotationData: null,
|
|
createdAt: new Date().toISOString(),
|
|
author: isGuest ? null : { id: 'current-user', name: currentUserName, image: null },
|
|
guestName: isGuest ? normalizedGuestName : null,
|
|
canEdit: true,
|
|
canDelete: true,
|
|
tag: null,
|
|
};
|
|
|
|
setVideo((prev) => {
|
|
if (!prev) return prev;
|
|
return {
|
|
...prev,
|
|
versions: prev.versions.map((v) =>
|
|
v.id === activeVersionId
|
|
? {
|
|
...v,
|
|
comments: v.comments.map((c) =>
|
|
c.id === parentId
|
|
? { ...c, replies: [...(c.replies || []), optimisticReply] }
|
|
: c
|
|
),
|
|
}
|
|
: v
|
|
),
|
|
};
|
|
});
|
|
|
|
setReplyText('');
|
|
setReplyingTo(null);
|
|
setReplyAudioBlob(null);
|
|
setReplyRecordingTime(0);
|
|
setReplyImageBlob(null);
|
|
clearReplyRangeSelection();
|
|
|
|
setIsSubmittingReply(true);
|
|
isMutatingRef.current = true;
|
|
|
|
try {
|
|
let submittedImageData: { url: string } | undefined = imageData;
|
|
|
|
if (replyImageBlob && !imageData) {
|
|
setIsUploadingReplyImage(true);
|
|
const imageFormData = new FormData();
|
|
imageFormData.append('image', replyImageBlob);
|
|
imageFormData.append('videoId', videoId);
|
|
const uploadToken = await getGuestUploadToken('image');
|
|
if (uploadToken) imageFormData.append('uploadToken', uploadToken);
|
|
|
|
const imageRes = await fetch('/api/upload/image', {
|
|
method: 'POST',
|
|
body: imageFormData,
|
|
});
|
|
|
|
if (!imageRes.ok) throw new Error('Failed to upload image reply');
|
|
const imageDataResponse = await imageRes.json();
|
|
submittedImageData = { url: imageDataResponse.data.url };
|
|
}
|
|
|
|
const res = await fetch(`/api/versions/${activeVersion.id}/comments`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
content: voiceData || submittedImageData ? replyText.trim() || null : replyText,
|
|
timestamp: replyTimestamp,
|
|
...(replyRangeEnd !== null && { timestampEnd: replyRangeEnd }),
|
|
parentId,
|
|
...(voiceData && { voiceUrl: voiceData.url, voiceDuration: voiceData.duration }),
|
|
...(submittedImageData && { imageUrl: submittedImageData.url }),
|
|
...(isGuest && normalizedGuestName && { guestName: normalizedGuestName }),
|
|
}),
|
|
});
|
|
|
|
if (res.ok) {
|
|
const response = await res.json();
|
|
const newReply = response.data;
|
|
setVideo((prev) => {
|
|
if (!prev) return prev;
|
|
return {
|
|
...prev,
|
|
versions: prev.versions.map((v) =>
|
|
v.id === activeVersionId
|
|
? {
|
|
...v,
|
|
comments: v.comments.map((c) =>
|
|
c.id === parentId
|
|
? {
|
|
...c,
|
|
replies: (c.replies || []).map((r) =>
|
|
r.id === tempId ? newReply : r
|
|
),
|
|
}
|
|
: { ...c, replies: c.replies || [] }
|
|
),
|
|
}
|
|
: v
|
|
),
|
|
};
|
|
});
|
|
|
|
// If an image was attached, refresh the assets list
|
|
if (submittedImageData) {
|
|
void fetchAssets();
|
|
}
|
|
} else {
|
|
setVideo((prev) => {
|
|
if (!prev) return prev;
|
|
return {
|
|
...prev,
|
|
versions: prev.versions.map((v) =>
|
|
v.id === activeVersionId
|
|
? {
|
|
...v,
|
|
comments: v.comments.map((c) =>
|
|
c.id === parentId
|
|
? { ...c, replies: (c.replies || []).filter((r) => r.id !== tempId) }
|
|
: c
|
|
),
|
|
}
|
|
: v
|
|
),
|
|
};
|
|
});
|
|
toast.error('Failed to add reply');
|
|
}
|
|
} catch {
|
|
setVideo((prev) => {
|
|
if (!prev) return prev;
|
|
return {
|
|
...prev,
|
|
versions: prev.versions.map((v) =>
|
|
v.id === activeVersionId
|
|
? {
|
|
...v,
|
|
comments: v.comments.map((c) =>
|
|
c.id === parentId
|
|
? { ...c, replies: (c.replies || []).filter((r) => r.id !== tempId) }
|
|
: c
|
|
),
|
|
}
|
|
: v
|
|
),
|
|
};
|
|
});
|
|
toast.error('Failed to add reply');
|
|
} finally {
|
|
setIsSubmittingReply(false);
|
|
setIsUploadingReplyImage(false);
|
|
isMutatingRef.current = false;
|
|
}
|
|
},
|
|
[
|
|
replyText,
|
|
replyRangeEnd,
|
|
replyRangeStart,
|
|
activeVersion,
|
|
activeVersionId,
|
|
currentTime,
|
|
isGuest,
|
|
normalizedGuestName,
|
|
currentUserName,
|
|
replyImageBlob,
|
|
videoId,
|
|
getGuestUploadToken,
|
|
setVideo,
|
|
fetchAssets,
|
|
clearReplyRangeSelection,
|
|
]
|
|
);
|
|
|
|
const startReplyRecording = useCallback(async () => {
|
|
try {
|
|
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
|
const mediaRecorder = new MediaRecorder(stream, {
|
|
mimeType: MediaRecorder.isTypeSupported('audio/webm;codecs=opus')
|
|
? 'audio/webm;codecs=opus'
|
|
: 'audio/webm',
|
|
});
|
|
replyAudioChunksRef.current = [];
|
|
replyMediaRecorderRef.current = mediaRecorder;
|
|
mediaRecorder.ondataavailable = (e) => {
|
|
if (e.data.size > 0) replyAudioChunksRef.current.push(e.data);
|
|
};
|
|
mediaRecorder.onstop = async () => {
|
|
const elapsedMs = Date.now() - replyRecordingStartedAtRef.current;
|
|
const recordedMime = mediaRecorder.mimeType || 'audio/webm';
|
|
const raw = new Blob(replyAudioChunksRef.current, { type: recordedMime });
|
|
setReplyAudioBlob(await withWebmDuration(raw, elapsedMs));
|
|
setReplyRecordingTime(elapsedMs / 1000);
|
|
stream.getTracks().forEach((track) => track.stop());
|
|
if (replyRecordingTimerRef.current) {
|
|
clearInterval(replyRecordingTimerRef.current);
|
|
replyRecordingTimerRef.current = null;
|
|
}
|
|
};
|
|
mediaRecorder.start(100);
|
|
setIsReplyRecording(true);
|
|
setReplyRecordingTime(0);
|
|
replyRecordingStartedAtRef.current = Date.now();
|
|
replyRecordingTimerRef.current = setInterval(() => {
|
|
setReplyRecordingTime((Date.now() - replyRecordingStartedAtRef.current) / 1000);
|
|
}, 100);
|
|
} catch (err) {
|
|
console.error('Failed to start reply recording:', err);
|
|
}
|
|
}, []);
|
|
|
|
const stopReplyRecording = useCallback(() => {
|
|
if (replyMediaRecorderRef.current && replyMediaRecorderRef.current.state !== 'inactive') {
|
|
replyMediaRecorderRef.current.stop();
|
|
}
|
|
setIsReplyRecording(false);
|
|
}, []);
|
|
|
|
const cancelReplyRecording = useCallback(() => {
|
|
if (replyMediaRecorderRef.current && replyMediaRecorderRef.current.state !== 'inactive') {
|
|
replyMediaRecorderRef.current.stop();
|
|
}
|
|
setIsReplyRecording(false);
|
|
setReplyAudioBlob(null);
|
|
setReplyRecordingTime(0);
|
|
}, []);
|
|
|
|
const submitVoiceReply = useCallback(
|
|
async (parentId: string) => {
|
|
if (!replyAudioBlob || !activeVersion) return;
|
|
setIsUploadingReplyAudio(true);
|
|
try {
|
|
const formData = new FormData();
|
|
formData.append('audio', replyAudioBlob, getAudioUploadFilename(replyAudioBlob));
|
|
formData.append('videoId', videoId);
|
|
const uploadToken = await getGuestUploadToken('audio');
|
|
if (uploadToken) formData.append('uploadToken', uploadToken);
|
|
const uploadRes = await fetch('/api/upload/audio', { method: 'POST', body: formData });
|
|
if (!uploadRes.ok) throw new Error('Failed to upload audio');
|
|
const uploadData = await uploadRes.json();
|
|
const { url } = uploadData.data;
|
|
await handleReplyComment(parentId, { url, duration: replyRecordingTime });
|
|
} catch (err) {
|
|
console.error('Failed to submit voice reply:', err);
|
|
} finally {
|
|
setIsUploadingReplyAudio(false);
|
|
}
|
|
},
|
|
[
|
|
replyAudioBlob,
|
|
activeVersion,
|
|
replyRecordingTime,
|
|
handleReplyComment,
|
|
videoId,
|
|
getGuestUploadToken,
|
|
]
|
|
);
|
|
|
|
const submitReplyWithMedia = useCallback(
|
|
async (parentId: string) => {
|
|
if (!activeVersion) return;
|
|
|
|
if (replyAudioBlob && !replyImageBlob && !replyText.trim()) {
|
|
submitVoiceReply(parentId);
|
|
return;
|
|
}
|
|
|
|
if (replyAudioBlob) setIsUploadingReplyAudio(true);
|
|
if (replyImageBlob) setIsUploadingReplyImage(true);
|
|
|
|
try {
|
|
let voiceData: { url: string; duration: number } | undefined;
|
|
|
|
if (replyAudioBlob) {
|
|
const formData = new FormData();
|
|
formData.append('audio', replyAudioBlob, getAudioUploadFilename(replyAudioBlob));
|
|
formData.append('videoId', videoId);
|
|
const uploadToken = await getGuestUploadToken('audio');
|
|
if (uploadToken) formData.append('uploadToken', uploadToken);
|
|
const uploadRes = await fetch('/api/upload/audio', { method: 'POST', body: formData });
|
|
if (!uploadRes.ok) throw new Error('Failed to upload audio reply');
|
|
const uploadData = await uploadRes.json();
|
|
voiceData = { url: uploadData.data.url, duration: replyRecordingTime };
|
|
}
|
|
|
|
await handleReplyComment(parentId, voiceData);
|
|
|
|
setReplyAudioBlob(null);
|
|
setReplyRecordingTime(0);
|
|
setReplyImageBlob(null);
|
|
if (replyImageInputRef.current) replyImageInputRef.current.value = '';
|
|
} catch (err) {
|
|
console.error('Failed to submit reply with media:', err);
|
|
toast.error('Failed to upload media');
|
|
} finally {
|
|
setIsUploadingReplyAudio(false);
|
|
setIsUploadingReplyImage(false);
|
|
}
|
|
},
|
|
[
|
|
replyAudioBlob,
|
|
replyImageBlob,
|
|
activeVersion,
|
|
replyRecordingTime,
|
|
replyText,
|
|
submitVoiceReply,
|
|
handleReplyComment,
|
|
videoId,
|
|
getGuestUploadToken,
|
|
]
|
|
);
|
|
|
|
const handleEditComment = useCallback(
|
|
async (commentId: string) => {
|
|
if (!editText.trim() && !editAnnotationData) return;
|
|
if (!activeVersionId) return;
|
|
|
|
setIsSubmittingEdit(true);
|
|
isMutatingRef.current = true;
|
|
|
|
let finalAnnotationData = editAnnotationData;
|
|
if (isEditingAnnotation && editAnnotationCanvasRef.current) {
|
|
const strokes = editAnnotationCanvasRef.current.getStrokes();
|
|
if (strokes.length > 0) {
|
|
finalAnnotationData = JSON.stringify(strokes);
|
|
}
|
|
}
|
|
|
|
try {
|
|
const body: Record<string, unknown> = { content: editText };
|
|
if (editTagId !== undefined) body.tagId = editTagId;
|
|
if (finalAnnotationData !== undefined) {
|
|
body.annotationData =
|
|
finalAnnotationData !== null ? JSON.parse(finalAnnotationData) : null;
|
|
}
|
|
if (isGuest && normalizedGuestName) body.guestName = normalizedGuestName;
|
|
const res = await fetch(`/api/comments/${commentId}`, {
|
|
method: 'PATCH',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(body),
|
|
});
|
|
if (res.ok) {
|
|
const editedTag = editTagId
|
|
? availableTags.find((t) => t.id === editTagId) || null
|
|
: null;
|
|
setVideo((prev) => {
|
|
if (!prev) return prev;
|
|
return {
|
|
...prev,
|
|
versions: prev.versions.map((v) =>
|
|
v.id === activeVersionId
|
|
? {
|
|
...v,
|
|
comments: v.comments.map((c) => {
|
|
if (c.id === commentId)
|
|
return {
|
|
...c,
|
|
content: editText.trim(),
|
|
tag: editTagId !== undefined ? editedTag : c.tag,
|
|
annotationData:
|
|
finalAnnotationData !== undefined
|
|
? finalAnnotationData
|
|
: c.annotationData,
|
|
};
|
|
return {
|
|
...c,
|
|
replies: (c.replies || []).map((r) =>
|
|
r.id === commentId ? { ...r, content: editText.trim() } : r
|
|
),
|
|
};
|
|
}),
|
|
}
|
|
: v
|
|
),
|
|
};
|
|
});
|
|
setEditingCommentId(null);
|
|
setEditText('');
|
|
setEditTagId(undefined);
|
|
setEditAnnotationData(undefined);
|
|
setIsEditingAnnotation(false);
|
|
if (finalAnnotationData !== undefined && finalAnnotationData) {
|
|
try {
|
|
const parsed = JSON.parse(finalAnnotationData);
|
|
const safe = validateAnnotationStrokes(parsed);
|
|
if (safe) setViewingAnnotation(safe as AnnotationStroke[]);
|
|
} catch {
|
|
// ignore parse errors
|
|
}
|
|
} else if (finalAnnotationData === null) {
|
|
setViewingAnnotation(null);
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to edit comment:', err);
|
|
} finally {
|
|
setIsSubmittingEdit(false);
|
|
isMutatingRef.current = false;
|
|
}
|
|
},
|
|
[
|
|
editText,
|
|
editTagId,
|
|
editAnnotationData,
|
|
isEditingAnnotation,
|
|
activeVersionId,
|
|
availableTags,
|
|
isGuest,
|
|
normalizedGuestName,
|
|
editAnnotationCanvasRef,
|
|
setVideo,
|
|
setViewingAnnotation,
|
|
]
|
|
);
|
|
|
|
const handleDeleteComment = useCallback(
|
|
async (commentId: string) => {
|
|
if (!activeVersionId) return;
|
|
|
|
isMutatingRef.current = true;
|
|
|
|
// The snapshot is taken once, however many times React runs the updater. React is
|
|
// free to invoke an updater more than once (StrictMode, and React 19 retries a
|
|
// render that threw), and a second run would otherwise capture the post-delete
|
|
// state, turning a failed delete into a silent confirmation of it.
|
|
const previousVideoRef: { current: VideoData | null } = { current: null };
|
|
let capturedSnapshot = false;
|
|
setVideo((prev) => {
|
|
if (!capturedSnapshot) {
|
|
previousVideoRef.current = prev;
|
|
capturedSnapshot = true;
|
|
}
|
|
if (!prev) return prev;
|
|
return {
|
|
...prev,
|
|
versions: prev.versions.map((v) =>
|
|
v.id === activeVersionId
|
|
? {
|
|
...v,
|
|
comments: v.comments
|
|
.filter((c) => c.id !== commentId)
|
|
.map((c) => ({
|
|
...c,
|
|
replies: c.replies?.filter((r) => r.id !== commentId) || [],
|
|
})),
|
|
}
|
|
: v
|
|
),
|
|
};
|
|
});
|
|
|
|
try {
|
|
const res = await fetch(`/api/comments/${commentId}`, { method: 'DELETE' });
|
|
if (!res.ok && previousVideoRef.current) {
|
|
setVideo(previousVideoRef.current);
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to delete comment:', err);
|
|
if (previousVideoRef.current) {
|
|
setVideo(previousVideoRef.current);
|
|
}
|
|
} finally {
|
|
isMutatingRef.current = false;
|
|
}
|
|
},
|
|
[activeVersionId, setVideo]
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (!activeVersionId) return;
|
|
|
|
let intervalId: ReturnType<typeof setInterval> | null = null;
|
|
let isPageVisible = true;
|
|
|
|
const poll = async () => {
|
|
try {
|
|
if (isMutatingRef.current || !isPageVisible) return;
|
|
await fetchVersionComments(activeVersionId, true);
|
|
} catch {
|
|
// silent
|
|
}
|
|
};
|
|
|
|
intervalId = setInterval(poll, 10000);
|
|
|
|
const handleVisibilityChange = () => {
|
|
isPageVisible = document.visibilityState === 'visible';
|
|
};
|
|
|
|
document.addEventListener('visibilitychange', handleVisibilityChange);
|
|
|
|
return () => {
|
|
if (intervalId) clearInterval(intervalId);
|
|
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
|
};
|
|
}, [activeVersionId, fetchVersionComments]);
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
if (recordingTimerRef.current) {
|
|
clearInterval(recordingTimerRef.current);
|
|
}
|
|
if (replyRecordingTimerRef.current) {
|
|
clearInterval(replyRecordingTimerRef.current);
|
|
}
|
|
};
|
|
}, []);
|
|
|
|
return {
|
|
commentText,
|
|
setCommentText,
|
|
isSubmittingComment,
|
|
isRecording,
|
|
recordingTime,
|
|
audioBlob,
|
|
isUploadingAudio,
|
|
imageBlob,
|
|
setImageBlob,
|
|
commentRangeStart,
|
|
commentRangeEnd,
|
|
toggleCommentRangeSelection,
|
|
clearCommentRangeSelection,
|
|
isUploadingImage,
|
|
imageInputRef,
|
|
handleAddComment,
|
|
handleImageSelect,
|
|
handlePaste,
|
|
handleDrop,
|
|
startRecording,
|
|
stopRecording,
|
|
cancelRecording,
|
|
submitCommentWithMedia,
|
|
|
|
replyingTo,
|
|
setReplyingTo,
|
|
replyText,
|
|
setReplyText,
|
|
isSubmittingReply,
|
|
isReplyRecording,
|
|
replyRecordingTime,
|
|
replyAudioBlob,
|
|
replyImageBlob,
|
|
setReplyImageBlob,
|
|
replyRangeStart,
|
|
replyRangeEnd,
|
|
toggleReplyRangeSelection,
|
|
clearReplyRangeSelection,
|
|
isUploadingReplyAudio,
|
|
isUploadingReplyImage,
|
|
replyImageInputRef,
|
|
handleReplyComment,
|
|
startReplyRecording,
|
|
stopReplyRecording,
|
|
cancelReplyRecording,
|
|
submitReplyWithMedia,
|
|
|
|
editingCommentId,
|
|
setEditingCommentId,
|
|
editText,
|
|
setEditText,
|
|
editTagId,
|
|
setEditTagId,
|
|
editAnnotationData,
|
|
setEditAnnotationData,
|
|
isEditingAnnotation,
|
|
setIsEditingAnnotation,
|
|
isSubmittingEdit,
|
|
handleEditComment,
|
|
handleDeleteComment,
|
|
handleResolveComment,
|
|
|
|
previewImage,
|
|
setPreviewImage,
|
|
};
|
|
}
|