From 439540256d3dc3e405426c17720c5d3d5cb02240 Mon Sep 17 00:00:00 2001 From: "Bernise.john" Date: Tue, 5 May 2026 16:27:55 +0530 Subject: [PATCH 01/10] Added option to add video/text comments --- .../VideoEditor/VerseContextMenu.jsx | 37 ++ .../VideoEditor/VideoCommentsPanel.jsx | 606 ++++++++++++++++++ .../EditorPage/VideoEditor/VideoEditor.js | 10 + .../EditorPage/VideoEditor/VideoPlayer.js | 54 +- .../EditorPage/VideoEditor/VideoRecorder.js | 94 ++- .../components/hooks/video/useVerseJoining.js | 9 + .../hooks/video/useVideoPlayback.js | 9 +- .../hooks/video/useVideoRecording.js | 50 +- 8 files changed, 824 insertions(+), 45 deletions(-) create mode 100644 renderer/src/components/EditorPage/VideoEditor/VideoCommentsPanel.jsx diff --git a/renderer/src/components/EditorPage/VideoEditor/VerseContextMenu.jsx b/renderer/src/components/EditorPage/VideoEditor/VerseContextMenu.jsx index a15ad1d2..ba8fc502 100644 --- a/renderer/src/components/EditorPage/VideoEditor/VerseContextMenu.jsx +++ b/renderer/src/components/EditorPage/VideoEditor/VerseContextMenu.jsx @@ -11,6 +11,8 @@ const VerseContextMenu = ({ isJoinedVerse, onJoinVerse, onDisjoinVerse, + onOpenComments, + canOpenComments, onClose, }) => { const { t } = useTranslation(); @@ -29,6 +31,11 @@ const VerseContextMenu = ({ onClose(); }; + const handleOpenComments = () => { + onOpenComments(verse); + onClose(); + }; + return ( <>
)} + {canOpenComments && ( + + )} +
); @@ -106,6 +136,13 @@ VerseContextMenu.propTypes = { isJoinedVerse: PropTypes.bool.isRequired, onJoinVerse: PropTypes.func.isRequired, onDisjoinVerse: PropTypes.func.isRequired, + onOpenComments: PropTypes.func.isRequired, + canOpenComments: PropTypes.bool, onClose: PropTypes.func.isRequired, }; + +VerseContextMenu.defaultProps = { + canOpenComments: false, +}; + export default VerseContextMenu; diff --git a/renderer/src/components/EditorPage/VideoEditor/VideoCommentsPanel.jsx b/renderer/src/components/EditorPage/VideoEditor/VideoCommentsPanel.jsx new file mode 100644 index 00000000..baf51b0e --- /dev/null +++ b/renderer/src/components/EditorPage/VideoEditor/VideoCommentsPanel.jsx @@ -0,0 +1,606 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import PropTypes from 'prop-types'; +import * as localforage from 'localforage'; +import { + CheckIcon, + PencilSquareIcon, + PlusIcon, + TrashIcon, + VideoCameraIcon, + XMarkIcon, +} from '@heroicons/react/24/outline'; +import VideoRecorder from './VideoRecorder'; +import * as logger from '../../../logger'; + +const getCurrentUsername = async () => { + const userProfile = await localforage.getItem('userProfile'); + return userProfile?.username || userProfile?.user?.email || 'unknown-user'; +}; + +const getStructureFilePath = (videoPath, bookId) => { + const path = window.require('path'); + const bookFolder = path.dirname(videoPath); + return path.join(bookFolder, `${bookId.toLowerCase()}.json`); +}; + +const readStructureFile = (structureFile) => { + const fs = window.require('fs'); + if (!fs.existsSync(structureFile)) { + return {}; + } + + try { + return JSON.parse(fs.readFileSync(structureFile, 'utf8')); + } catch (err) { + logger.warn('Could not parse video structure while saving comments:', err); + return {}; + } +}; + +const writeVerseComments = ({ + bookId, + chapter, + verseNumber, + verseData, + videoPath, + comments, +}) => { + const fs = window.require('fs'); + const path = window.require('path'); + const bookIdUpper = bookId.toUpperCase(); + const structureFile = getStructureFilePath(videoPath, bookId); + const chapterKey = chapter.toString(); + const allStructure = readStructureFile(structureFile); + + if (!allStructure[bookIdUpper]) { + allStructure[bookIdUpper] = {}; + } + + if (!allStructure[bookIdUpper][chapterKey]) { + allStructure[bookIdUpper][chapterKey] = { + chapter: chapterKey, + verses: [], + }; + } + + const chapterData = allStructure[bookIdUpper][chapterKey]; + const existingIndex = chapterData.verses.findIndex( + (item) => item.verseNumber === verseNumber, + ); + const nextVerseData = { + verseNumber, + verseText: verseData?.verseText || '', + joinedVerses: verseData?.joinedVerses || null, + verseSegments: verseData?.verseSegments, + isPreCombined: verseData?.isPreCombined || false, + }; + + if (comments.length > 0) { + nextVerseData.comments = comments; + } + + if (!nextVerseData.verseSegments) { + delete nextVerseData.verseSegments; + } + + if (existingIndex >= 0) { + chapterData.verses[existingIndex] = { + ...chapterData.verses[existingIndex], + ...nextVerseData, + }; + if (comments.length === 0) { + delete chapterData.verses[existingIndex].comments; + } + } else { + chapterData.verses.push(nextVerseData); + } + + chapterData.lastModified = new Date().toISOString(); + fs.mkdirSync(path.dirname(structureFile), { recursive: true }); + fs.writeFileSync(structureFile, JSON.stringify(allStructure, null, 2), 'utf8'); +}; + +const getNextCommentNumber = (comments) => comments.reduce( + (max, comment) => Math.max(max, Number(comment.commentNumber) || 0), + 0, +) + 1; + +const VideoCommentsPanel = ({ + open, + verse, + chapter, + bookId, + videoPath, + content, + onClose, + onContentChange, + setNotify, + setSnackText, + setOpenSnackBar, + setOpenModal, +}) => { + const [currentUser, setCurrentUser] = useState('unknown-user'); + const [isAdding, setIsAdding] = useState(false); + const [draftText, setDraftText] = useState(''); + const [pendingComment, setPendingComment] = useState(null); + const [editingCommentId, setEditingCommentId] = useState(null); + const [editingText, setEditingText] = useState(''); + + const comments = useMemo(() => verse?.comments || [], [verse]); + + useEffect(() => { + if (!open) { return; } + getCurrentUsername().then(setCurrentUser); + }, [open]); + + if (!open || !verse) { + return null; + } + + const saveComments = (nextComments) => { + try { + const updatedContent = content.map((item) => ( + item.verseNumber === verse.verseNumber + ? (() => { + const updatedVerse = { ...item }; + if (nextComments.length > 0) { + updatedVerse.comments = nextComments; + } else { + delete updatedVerse.comments; + } + return updatedVerse; + })() + : item + )); + + writeVerseComments({ + bookId, + chapter, + verseNumber: verse.verseNumber, + verseData: verse, + videoPath, + comments: nextComments, + }); + + onContentChange(updatedContent); + setNotify('success'); + setSnackText('Comment saved'); + setOpenSnackBar(true); + } catch (err) { + logger.error('Error saving video comment:', err); + setNotify('failure'); + setSnackText('Failed to save comment'); + setOpenSnackBar(true); + } + }; + + const showCommentError = (message) => { + setNotify('failure'); + setSnackText(message); + setOpenSnackBar(true); + }; + + const createCommentDraft = ({ note, videoFileName = null }) => { + const commentNumber = getNextCommentNumber(comments); + + return { + id: `${chapter}-${verse.verseNumber}-${commentNumber}-${Date.now()}`, + commentNumber, + videoFileName, + note, + username: currentUser, + }; + }; + + const handleSaveTextComment = () => { + const note = draftText.trim(); + if (!note) { + showCommentError('Add text or record a video before saving'); + return; + } + + const now = new Date().toISOString(); + saveComments([...comments, { + ...createCommentDraft({ note }), + createdAt: now, + updatedAt: now, + }]); + setDraftText(''); + setIsAdding(false); + }; + + const handleStartNewCommentRecording = () => { + const commentNumber = getNextCommentNumber(comments); + const videoFileName = `${chapter}_${verse.verseNumber}_${commentNumber}.webm`; + + setPendingComment({ + ...createCommentDraft({ + note: draftText.trim(), + videoFileName, + }), + isNewComment: true, + }); + }; + + const handleStartCommentRecording = (comment) => { + if (comment.username !== currentUser) { + return; + } + + const videoFileName = comment.videoFileName + || `${chapter}_${verse.verseNumber}_${comment.commentNumber}.webm`; + + setPendingComment({ + ...comment, + videoFileName, + isNewComment: false, + }); + }; + + const handleRecordingComplete = ({ filename }) => { + const now = new Date().toISOString(); + + if (pendingComment.isNewComment) { + saveComments([...comments, { + ...pendingComment, + videoFileName: filename, + createdAt: now, + updatedAt: now, + videoUpdatedAt: now, + }]); + } else { + saveComments(comments.map((comment) => ( + comment.id === pendingComment.id + ? { + ...comment, + videoFileName: filename, + updatedAt: now, + videoUpdatedAt: now, + } + : comment + ))); + } + + setPendingComment(null); + setDraftText(''); + setIsAdding(false); + }; + + const handleDeleteVideo = (comment) => { + if (comment.username !== currentUser || !comment.videoFileName) { + return; + } + + try { + const fs = window.require('fs'); + const path = window.require('path'); + const filePath = path.join(videoPath, comment.videoFileName); + + if (comment.videoFileName && fs.existsSync(filePath)) { + fs.unlinkSync(filePath); + } + + const hasText = Boolean((comment.note || '').trim()); + if (!hasText) { + saveComments(comments.filter((item) => item.id !== comment.id)); + return; + } + + saveComments(comments.map((item) => ( + item.id === comment.id + ? { + ...item, + videoFileName: null, + updatedAt: new Date().toISOString(), + } + : item + ))); + } catch (err) { + logger.error('Error deleting comment video:', err); + setNotify('failure'); + setSnackText('Failed to delete video comment'); + setOpenSnackBar(true); + } + }; + + const handleDeleteText = (comment) => { + if (comment.username !== currentUser) { + return; + } + + if (!comment.videoFileName) { + saveComments(comments.filter((item) => item.id !== comment.id)); + return; + } + + saveComments(comments.map((item) => ( + item.id === comment.id + ? { + ...item, + note: '', + updatedAt: new Date().toISOString(), + } + : item + ))); + }; + + const handleSaveEdit = (comment) => { + if (comment.username !== currentUser) { + return; + } + + const nextNote = editingText.trim(); + if (!nextNote && !comment.videoFileName) { + saveComments(comments.filter((item) => item.id !== comment.id)); + setEditingCommentId(null); + setEditingText(''); + return; + } + + const nextComments = comments.map((item) => ( + item.id === comment.id + ? { ...item, note: nextNote, updatedAt: new Date().toISOString() } + : item + )); + + saveComments(nextComments); + setEditingCommentId(null); + setEditingText(''); + }; + + const getVideoSrc = (fileName, version) => { + const path = window.require('path'); + const cacheKey = version ? `?v=${encodeURIComponent(version)}` : ''; + return `file://${path.join(videoPath, fileName)}${cacheKey}`; + }; + + return ( +
+
+
+
+

+ Comments - + {' '} + {bookId.toUpperCase()} + {' '} + {chapter} + : + {verse.verseNumber} +

+

Video comments

+
+ +
+ +
+ {comments.length === 0 && !isAdding && ( +
+ No comments recorded for this verse. +
+ )} + +
+ {comments.map((comment) => { + const isOwnComment = comment.username === currentUser; + const isEditing = editingCommentId === comment.id; + const hasVideo = Boolean(comment.videoFileName); + const hasText = Boolean((comment.note || '').trim()); + + return ( +
+
+
+

+ {comment.username} + {' '} + + Comment + {' '} + {comment.commentNumber} + +

+

{comment.updatedAt || comment.createdAt}

+
+ + {isOwnComment && ( +
+ + {hasVideo && ( + + )} +
+ )} +
+ + + {hasVideo && ( +