diff --git a/renderer/src/components/EditorPage/AudioEditor/AudioEditor.js b/renderer/src/components/EditorPage/AudioEditor/AudioEditor.js index 83d7efb2..ee9b2e0b 100644 --- a/renderer/src/components/EditorPage/AudioEditor/AudioEditor.js +++ b/renderer/src/components/EditorPage/AudioEditor/AudioEditor.js @@ -96,6 +96,33 @@ const loadVerseStructureFromFile = (projectsDir, bookId, chapter) => { } }; +const parsedUsfmCache = new Map(); + +const getParsedUsfmBook = (usfmPath) => { + const fs = window.require('fs'); + + const stat = fs.statSync(usfmPath); + const cached = parsedUsfmCache.get(usfmPath); + + if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) { + logger.debug('Using cached parsed USFM:', usfmPath); + return cached.bookContent; + } + + const usfm = fs.readFileSync(usfmPath, 'utf8'); + const myUsfmParser = new grammar.USFMParser(usfm, grammar.LEVEL.RELAXED); + const jsonOutput = myUsfmParser.toJSON(); + const bookContent = jsonOutput.chapters; + + parsedUsfmCache.set(usfmPath, { + mtimeMs: stat.mtimeMs, + size: stat.size, + bookContent, + }); + + return bookContent; +}; + // Priority 2: Load from USFM const loadVersesFromUSFM = (projectsDir, bookId, chapter) => { try { @@ -116,19 +143,7 @@ const loadVersesFromUSFM = (projectsDir, bookId, chapter) => { }; } - const usfm = fs.readFileSync(usfmPath, 'utf8'); - const myUsfmParser = new grammar.USFMParser(usfm, grammar.LEVEL.RELAXED); - const isJsonValid = myUsfmParser.validate(); - - if (!isJsonValid) { - logger.error('Invalid USFM file'); - return { - success: false, bookContent: null, verses: null, source: null, - }; - } - - const jsonOutput = myUsfmParser.toJSON(); - const bookContent = jsonOutput.chapters; + const bookContent = getParsedUsfmBook(usfmPath); const chapterData = bookContent.find( (ch) => ch.chapterNumber === chapter.toString(), @@ -495,6 +510,8 @@ const AudioEditor = ({ editor }) => { useEffect(() => { if (!isElectron()) { return; } + let cancelled = false; + structureAppliedRef.current = false; setIsLoading(true); @@ -605,6 +622,8 @@ const AudioEditor = ({ editor }) => { logger.debug('Data source:', dataSource); logger.debug('Verse count:', finalVerses.length); + if (cancelled) { return; } + setOriginalBookContent(bookContent); const versesWithAudios = structureAppliedRef.current @@ -629,25 +648,30 @@ const AudioEditor = ({ editor }) => { }), ); - if (_books.includes(bookId.toUpperCase()) === false) { + if (!cancelled && _books.includes(bookId.toUpperCase()) === false) { setAudioContent(); setDisplayScreen(true); + setIsLoading(false); } } } catch (error) { logger.error('Error in useEffect:', error); - setIsLoading(false); - setDisplayScreen(true); + if (!cancelled) { + setIsLoading(false); + setDisplayScreen(true); + } } }); + + return () => { cancelled = true; }; }, [bookId, chapter]); return (
- {((isLoading || !audioContent) && displyScreen) && } - {isLoading && !displyScreen && } - {audioContent && isLoading === false + {isLoading && } + {!isLoading && displyScreen && } + {!isLoading && audioContent && ( { const { t } = useTranslation(); - if (!visible) { + const hasMenuItems = (!isFirstVerse && !isJoinedVerse) + || isJoinedVerse + || canOpenComments; + + if (!visible || !hasMenuItems) { return null; } diff --git a/renderer/src/components/EditorPage/VideoEditor/VideoEditor.js b/renderer/src/components/EditorPage/VideoEditor/VideoEditor.js index 40ab58af..0edc6209 100644 --- a/renderer/src/components/EditorPage/VideoEditor/VideoEditor.js +++ b/renderer/src/components/EditorPage/VideoEditor/VideoEditor.js @@ -118,6 +118,33 @@ const loadVerseStructureFromFile = (projectsDir, bookId, chapter) => { } }; +const parsedUsfmCache = new Map(); + +const getParsedUsfmBook = (usfmPath) => { + const fs = window.require('fs'); + + const stat = fs.statSync(usfmPath); + const cached = parsedUsfmCache.get(usfmPath); + + if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) { + logger.debug('Using cached parsed USFM:', usfmPath); + return cached.bookContent; + } + + const usfm = fs.readFileSync(usfmPath, 'utf8'); + const myUsfmParser = new grammar.USFMParser(usfm, grammar.LEVEL.RELAXED); + const jsonOutput = myUsfmParser.toJSON(); + const bookContent = jsonOutput.chapters; + + parsedUsfmCache.set(usfmPath, { + mtimeMs: stat.mtimeMs, + size: stat.size, + bookContent, + }); + + return bookContent; +}; + const loadVersesFromUSFM = (projectsDir, bookId, chapter) => { try { const fs = window.require('fs'); @@ -137,19 +164,7 @@ const loadVersesFromUSFM = (projectsDir, bookId, chapter) => { }; } - const usfm = fs.readFileSync(usfmPath, 'utf8'); - const myUsfmParser = new grammar.USFMParser(usfm, grammar.LEVEL.RELAXED); - const isJsonValid = myUsfmParser.validate(); - - if (!isJsonValid) { - logger.error('Invalid USFM file'); - return { - success: false, bookContent: null, verses: null, source: null, - }; - } - - const jsonOutput = myUsfmParser.toJSON(); - const bookContent = jsonOutput.chapters; + const bookContent = getParsedUsfmBook(usfmPath); const chapterData = bookContent.find( (ch) => ch.chapterNumber === chapter.toString(), @@ -609,6 +624,8 @@ const VideoEditor = ({ editor }) => { modelClose(); }; useEffect(() => { + let cancelled = false; + if (isElectron()) { setIsLoading(true); setDisplayScreen(false); @@ -728,6 +745,8 @@ const VideoEditor = ({ editor }) => { chapter, ); + if (cancelled) { return; } + setOriginalBookContent(bookContent); logger.debug('Starting video attachment process...'); @@ -760,18 +779,23 @@ const VideoEditor = ({ editor }) => { }), ); - if (_books.includes(bookId.toUpperCase()) === false) { + if (!cancelled && _books.includes(bookId.toUpperCase()) === false) { setVideoContent(); setDisplayScreen(true); + setIsLoading(false); } } } catch (error) { logger.error('Error in useEffect:', error); - setIsLoading(false); - setDisplayScreen(true); + if (!cancelled) { + setIsLoading(false); + setDisplayScreen(true); + } } }); } + + return () => { cancelled = true; }; }, [bookId, chapter]); useEffect(() => { @@ -808,9 +832,9 @@ const VideoEditor = ({ editor }) => { return ( - {((isLoading || !videoContent) && displyScreen) && } - {isLoading && !displyScreen && } - {videoContent && isLoading === false + {isLoading && } + {!isLoading && displyScreen && } + {!isLoading && videoContent && (
{ + const openImportPicker = () => { + if (importInputRef.current) { + importInputRef.current.value = ''; + importInputRef.current.click(); + } + }; + + if (existingVideo) { + setOpenModal({ + openModel: true, + title: 'Replace Existing Video?', + confirmMessage: 'A video already exists for this verse. Importing a new video will replace the current recording.', + buttonName: 'Replace', + action: 'custom', + actionData: { callback: openImportPicker }, + }); + return; + } + + openImportPicker(); + }; + + const handleImportFileChange = async (e) => { + const file = e.target.files?.[0]; + if (!file) { return; } + + const supportedTypes = ['video/webm']; + const supportedExtensions = ['.webm']; + const fileExtension = file.name.substring(file.name.lastIndexOf('.')).toLowerCase(); + + if (!supportedTypes.includes(file.type) && !supportedExtensions.includes(fileExtension)) { + setError('Unsupported file type. Please select a .webm video file.'); + return; + } + + try { + setIsImporting(true); + + const destFilename = fileNameOverride || `${chapter}_${verse}.webm`; + const destPath = path.join(projectPath, destFilename); + + const arrayBuffer = await file.arrayBuffer(); + const buffer = Buffer.from(arrayBuffer); + fs.writeFileSync(destPath, buffer); + + if (onRecordingComplete) { + onRecordingComplete({ + verse, + chapter, + filePath: destPath, + filename: destFilename, + }); + } + + setExistingVideo(true); + setCurrentMode('view'); + + setNotify('success'); + setSnackText(`Video "${file.name}" imported successfully`); + setOpenSnackBar(true); + + setTimeout(() => { + if (videoPreviewRef.current) { + const blob = new Blob([buffer], { type: file.type || 'video/mp4' }); + const objectUrl = URL.createObjectURL(blob); + + videoPreviewRef.current.pause(); + videoPreviewRef.current.srcObject = null; + videoPreviewRef.current.removeAttribute('src'); + videoPreviewRef.current.load(); + + videoPreviewRef.current.src = objectUrl; + videoPreviewRef.current.load(); + } + }, 100); + } catch (err) { + setError(`Failed to import video: ${err.message}`); + } finally { + setIsImporting(false); + } + }; + const formatTime = (seconds) => { if (!Number.isFinite(seconds) || seconds <= 0) { return '00:00'; } const mins = Math.floor(seconds / 60); @@ -272,10 +358,17 @@ const VideoRecorder = ({ const hasNextVerse = () => getNextVerseNumber(verse, content) !== null; return ( -
+ +

{(() => { + if (isImporting) { + return 'Importing video...'; + } if (isRecording) { return `${isPaused ? 'Paused' : 'Recording'}: ${formatTime(recordingTime)}`; } @@ -310,7 +406,6 @@ const VideoRecorder = ({ return 'Ready to record'; })()}

-
@@ -318,12 +413,11 @@ const VideoRecorder = ({ type="button" onClick={onClose} className="hover:bg-white hover:bg-opacity-20 p-2 rounded-full transition-colors" - disabled={isRecording} + disabled={isRecording || isImporting} title="Close" > -
@@ -379,11 +473,11 @@ const VideoRecorder = ({
)} - {isProcessing && ( + {(isProcessing || isImporting) && (
-

Saving video...

+

{isImporting ? 'Importing video...' : 'Saving video...'}

)} @@ -520,7 +614,7 @@ const VideoRecorder = ({ )} -
+
- {!hideDeleteButton && ( +
+ {!hideDeleteButton && ( + + )} + +
+
+ +
+
- )} -
- -
+
- ); }; @@ -713,7 +832,6 @@ VideoRecorder.defaultProps = { commentCount: 0, onOpenComments: null, showCommentsButton: false, - }; export default VideoRecorder;