diff --git a/app/(dashboard)/projects/[projectId]/videos/[videoId]/page.tsx b/app/(dashboard)/projects/[projectId]/videos/[videoId]/page.tsx index b78418b..26aace5 100644 --- a/app/(dashboard)/projects/[projectId]/videos/[videoId]/page.tsx +++ b/app/(dashboard)/projects/[projectId]/videos/[videoId]/page.tsx @@ -82,6 +82,8 @@ interface Comment { replies: { id: string; content: string | null; + voiceUrl: string | null; + voiceDuration: number | null; createdAt: string; author: { id: string; name: string | null; image: string | null } | null; guestName: string | null; @@ -133,6 +135,19 @@ export default function VideoPage() { const [commentText, setCommentText] = useState(''); const [isSubmittingComment, setIsSubmittingComment] = useState(false); const [isRecording, setIsRecording] = useState(false); + const [recordingTime, setRecordingTime] = useState(0); + const [audioBlob, setAudioBlob] = useState(null); + const [isUploadingAudio, setIsUploadingAudio] = useState(false); + const [playingVoiceId, setPlayingVoiceId] = useState(null); + const [voiceProgress, setVoiceProgress] = useState(0); + const [voiceCurrentTime, setVoiceCurrentTime] = useState(0); + const [voicePlaybackRate, setVoicePlaybackRate] = useState(1); + const mediaRecorderRef = useRef(null); + const audioChunksRef = useRef([]); + const recordingTimerRef = useRef | null>(null); + const audioPlayerRef = useRef(null); + const voiceRafRef = useRef(null); + const voiceKnownDurationRef = useRef(0); const [selectedTimestamp, setSelectedTimestamp] = useState(null); const [showResolved, setShowResolved] = useState(false); @@ -140,6 +155,13 @@ export default function VideoPage() { const [replyingTo, setReplyingTo] = useState(null); const [replyText, setReplyText] = useState(''); const [isSubmittingReply, setIsSubmittingReply] = useState(false); + const [isReplyRecording, setIsReplyRecording] = useState(false); + const [replyRecordingTime, setReplyRecordingTime] = useState(0); + const [replyAudioBlob, setReplyAudioBlob] = useState(null); + const [isUploadingReplyAudio, setIsUploadingReplyAudio] = useState(false); + const replyMediaRecorderRef = useRef(null); + const replyAudioChunksRef = useRef([]); + const replyRecordingTimerRef = useRef | null>(null); const [editingCommentId, setEditingCommentId] = useState(null); const [editText, setEditText] = useState(''); const [isSubmittingEdit, setIsSubmittingEdit] = useState(false); @@ -336,8 +358,9 @@ export default function VideoPage() { } }, [isDragging, currentTime, handleSeekToTimestamp]); - const handleAddComment = useCallback(async () => { - if (!commentText.trim() || !activeVersion) return; + const handleAddComment = useCallback(async (voiceData?: { url: string; duration: number }) => { + if (!voiceData && !commentText.trim()) return; + if (!activeVersion) return; setIsSubmittingComment(true); try { @@ -345,8 +368,9 @@ export default function VideoPage() { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - content: commentText, + content: voiceData ? commentText.trim() || null : commentText, timestamp: selectedTimestamp ?? currentTime, + ...(voiceData && { voiceUrl: voiceData.url, voiceDuration: voiceData.duration }), }), }); @@ -373,6 +397,192 @@ export default function VideoPage() { } }, [commentText, currentTime, selectedTimestamp, activeVersion, activeVersionId]); + // Voice recording handlers + 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 = () => { + const blob = new Blob(audioChunksRef.current, { type: 'audio/webm' }); + setAudioBlob(blob); + stream.getTracks().forEach((track) => track.stop()); + if (recordingTimerRef.current) { + clearInterval(recordingTimerRef.current); + recordingTimerRef.current = null; + } + }; + + mediaRecorder.start(100); + setIsRecording(true); + setRecordingTime(0); + recordingTimerRef.current = setInterval(() => { + setRecordingTime((prev) => prev + 0.1); + }, 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 { + // Upload audio to R2 + const formData = new FormData(); + formData.append('audio', audioBlob, 'recording.webm'); + + const uploadRes = await fetch('/api/upload/audio', { + method: 'POST', + body: formData, + }); + + if (!uploadRes.ok) { + throw new Error('Failed to upload audio'); + } + + const { url } = await uploadRes.json(); + + // Submit comment with voice URL + 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]); + + // Voice playback + const stopVoiceTracking = useCallback(() => { + if (voiceRafRef.current) { + cancelAnimationFrame(voiceRafRef.current); + voiceRafRef.current = null; + } + }, []); + + const startVoiceTracking = useCallback(() => { + stopVoiceTracking(); + const tick = () => { + const audio = audioPlayerRef.current; + if (audio) { + const dur = isFinite(audio.duration) && audio.duration > 0 + ? audio.duration + : voiceKnownDurationRef.current; + if (dur > 0) { + setVoiceProgress((audio.currentTime / dur) * 100); + setVoiceCurrentTime(audio.currentTime); + } + } + voiceRafRef.current = requestAnimationFrame(tick); + }; + voiceRafRef.current = requestAnimationFrame(tick); + }, [stopVoiceTracking]); + + const playVoice = useCallback((commentId: string, voiceUrl: string, knownDuration?: number) => { + if (playingVoiceId === commentId) { + if (audioPlayerRef.current) { + audioPlayerRef.current.pause(); + audioPlayerRef.current = null; + } + stopVoiceTracking(); + setPlayingVoiceId(null); + setVoiceProgress(0); + setVoiceCurrentTime(0); + return; + } + + if (audioPlayerRef.current) { + audioPlayerRef.current.pause(); + } + stopVoiceTracking(); + + voiceKnownDurationRef.current = knownDuration || 0; + const audio = new Audio(voiceUrl); + audio.playbackRate = voicePlaybackRate; + audioPlayerRef.current = audio; + setPlayingVoiceId(commentId); + setVoiceProgress(0); + setVoiceCurrentTime(0); + + audio.onplay = () => { + startVoiceTracking(); + }; + + audio.onended = () => { + stopVoiceTracking(); + setPlayingVoiceId(null); + setVoiceProgress(0); + setVoiceCurrentTime(0); + audioPlayerRef.current = null; + }; + + audio.onerror = () => { + stopVoiceTracking(); + setPlayingVoiceId(null); + setVoiceProgress(0); + setVoiceCurrentTime(0); + audioPlayerRef.current = null; + }; + + audio.play(); + }, [playingVoiceId, voicePlaybackRate, startVoiceTracking, stopVoiceTracking]); + + const toggleVoiceSpeed = useCallback(() => { + setVoicePlaybackRate((prev) => { + const next = prev === 1 ? 2 : 1; + if (audioPlayerRef.current) { + audioPlayerRef.current.playbackRate = next; + } + return next; + }); + }, []); + + // Cleanup audio on unmount + useEffect(() => { + return () => { + if (audioPlayerRef.current) { + audioPlayerRef.current.pause(); + audioPlayerRef.current = null; + } + stopVoiceTracking(); + if (recordingTimerRef.current) { + clearInterval(recordingTimerRef.current); + } + }; + }, []); + const handleResolveComment = useCallback( async (commentId: string, currentlyResolved: boolean) => { try { @@ -408,17 +618,19 @@ export default function VideoPage() { ); // Reply to a comment - const handleReplyComment = useCallback(async (parentId: string) => { - if (!replyText.trim() || !activeVersion) return; + const handleReplyComment = useCallback(async (parentId: string, voiceData?: { url: string; duration: number }) => { + if (!voiceData && !replyText.trim()) return; + if (!activeVersion) return; setIsSubmittingReply(true); try { const res = await fetch(`/api/versions/${activeVersion.id}/comments`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - content: replyText, + content: voiceData ? replyText.trim() || null : replyText, timestamp: comments.find((c) => c.id === parentId)?.timestamp ?? currentTime, parentId, + ...(voiceData && { voiceUrl: voiceData.url, voiceDuration: voiceData.duration }), }), }); if (res.ok) { @@ -443,6 +655,8 @@ export default function VideoPage() { }); setReplyText(''); setReplyingTo(null); + setReplyAudioBlob(null); + setReplyRecordingTime(0); } } catch (err) { console.error('Failed to reply:', err); @@ -451,6 +665,73 @@ export default function VideoPage() { } }, [replyText, activeVersion, activeVersionId, comments, currentTime]); + // Voice recording for replies + 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 = () => { + const blob = new Blob(replyAudioChunksRef.current, { type: 'audio/webm' }); + setReplyAudioBlob(blob); + stream.getTracks().forEach((track) => track.stop()); + if (replyRecordingTimerRef.current) { + clearInterval(replyRecordingTimerRef.current); + replyRecordingTimerRef.current = null; + } + }; + mediaRecorder.start(100); + setIsReplyRecording(true); + setReplyRecordingTime(0); + replyRecordingTimerRef.current = setInterval(() => { + setReplyRecordingTime((prev) => prev + 0.1); + }, 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, 'recording.webm'); + const uploadRes = await fetch('/api/upload/audio', { method: 'POST', body: formData }); + if (!uploadRes.ok) throw new Error('Failed to upload audio'); + const { url } = await uploadRes.json(); + await handleReplyComment(parentId, { url, duration: replyRecordingTime }); + } catch (err) { + console.error('Failed to submit voice reply:', err); + } finally { + setIsUploadingReplyAudio(false); + } + }, [replyAudioBlob, activeVersion, replyRecordingTime, handleReplyComment]); + // Edit a comment const handleEditComment = useCallback(async (commentId: string) => { if (!editText.trim()) return; @@ -1055,15 +1336,37 @@ export default function VideoPage() { {comment.voiceUrl && (
- -
-
+
+
- - {formatTime(comment.voiceDuration || 0)} + + {playingVoiceId === comment.id + ? `${formatTime(voiceCurrentTime)} / ${formatTime(comment.voiceDuration || 0)}` + : formatTime(comment.voiceDuration || 0)} + {playingVoiceId === comment.id && ( + + )}
)} @@ -1158,7 +1461,42 @@ export default function VideoPage() {
) : ( -

{reply.content}

+ reply.content &&

{reply.content}

+ )} + {reply.voiceUrl && ( +
+ +
+
+
+ + {playingVoiceId === reply.id + ? `${formatTime(voiceCurrentTime)} / ${formatTime(reply.voiceDuration || 0)}` + : formatTime(reply.voiceDuration || 0)} + + {playingVoiceId === reply.id && ( + + )} +
)}
); @@ -1169,41 +1507,126 @@ export default function VideoPage() { {/* Inline reply form */} {isReplying && (
-