diff --git a/renderer/src/components/EditorPage/VideoEditor/VideoEditor.js b/renderer/src/components/EditorPage/VideoEditor/VideoEditor.js index 9587791c..e4a748e1 100644 --- a/renderer/src/components/EditorPage/VideoEditor/VideoEditor.js +++ b/renderer/src/components/EditorPage/VideoEditor/VideoEditor.js @@ -5,7 +5,7 @@ import { SnackBar } from '@/components/SnackBar'; import { readFile } from '@/core/editor/readFile'; import { isElectron } from '@/core/handleElectron'; import { useState, useEffect, useContext } from 'react'; -import { useVerseJoining } from '@/hooks/useVerseJoining'; +import { useVerseJoining } from '@/hooks/video/useVerseJoining'; import EmptyScreen from '@/components/Loading/EmptySrceen'; import { readRefMeta } from '@/core/reference/readRefMeta'; import LoadingScreen from '@/components/Loading/LoadingScreen'; diff --git a/renderer/src/components/EditorPage/VideoEditor/VideoPlayer.js b/renderer/src/components/EditorPage/VideoEditor/VideoPlayer.js index 62129694..9c4aa323 100644 --- a/renderer/src/components/EditorPage/VideoEditor/VideoPlayer.js +++ b/renderer/src/components/EditorPage/VideoEditor/VideoPlayer.js @@ -37,7 +37,6 @@ const VideoPlayer = ({ const [showVideoRecorder, setShowVideoRecorder] = useState(false); const [currentRecordingVerse, setCurrentRecordingVerse] = useState(null); const [recorderMode, setRecorderMode] = useState('record'); - const [isRecorderVisible, setIsRecorderVisible] = useState(true); const [contextMenu, setContextMenu] = useState({ visible: false, x: 0, @@ -107,10 +106,13 @@ const VideoPlayer = ({ e.stopPropagation(); const fs = window.require('fs'); const path = window.require('path'); - const filename = `${chapter}_${verseNumber}.webm`; - const filePath = path.join(location, filename); + const videoExists = ['webm', 'mp4'].some((ext) => fs.existsSync(path.join(location, `${chapter}_${verseNumber}.${ext}`))); + + if (videoExists) { + const ext = ['webm', 'mp4'].find((ext) => fs.existsSync(path.join(location, `${chapter}_${verseNumber}.${ext}`))); + const filename = `${chapter}_${verseNumber}.${ext}`; + const filePath = path.join(location, filename); - if (fs.existsSync(filePath)) { setOpenModal({ openModel: true, title: t('modal-title-re-record-video'), @@ -158,9 +160,14 @@ const VideoPlayer = ({ onChangeVerse(newVerseNumber.toString(), verse); }; - const handleDeleteVideo = (e, verseNumber, videoFileName) => { + const handleDeleteVideo = (e, verseNumber) => { e.stopPropagation(); + const fs = window.require('fs'); + const path = window.require('path'); + const ext = ['webm', 'mp4'].find((ext) => fs.existsSync(path.join(location, `${chapter}_${verseNumber}.${ext}`))); + const videoFileName = ext ? `${chapter}_${verseNumber}.${ext}` : null; + setOpenModal({ openModel: true, title: t('modal-title-delete-video'), @@ -177,11 +184,7 @@ const VideoPlayer = ({ const fs = window.require('fs'); const path = window.require('path'); - const doesVideoExistForVerse = (verseNumber) => { - const filename = `${chapter}_${verseNumber}.webm`; - const filePath = path.join(location, filename); - return fs.existsSync(filePath); - }; + const doesVideoExistForVerse = (verseNumber) => ['webm', 'mp4'].some((ext) => fs.existsSync(path.join(location, `${chapter}_${verseNumber}.${ext}`))); return (
@@ -253,7 +256,7 @@ const VideoPlayer = ({ +
-
+
{error && (
@@ -725,9 +309,22 @@ const VideoRecorder = ({

Error

{error}

+
)} -
+
+ {' '} + {' '} {currentMode === 'record' && !cameraReady && !error && (
@@ -775,13 +369,90 @@ const VideoRecorder = ({
)}
-
-
-
-
+ + {currentMode === 'view' && hasVideo && ( +
+ seek(Number(e.target.value))} + onMouseMove={(e) => handleSeekHover(e, seekBarRef, videoDuration)} + onMouseLeave={clearSeekHover} + className="w-full accent-primary cursor-pointer" + /> + + {showHoverTime && hoverTime !== null && ( +
{ + const thumbnailWidth = 160; + const rect = seekBarRef.current?.getBoundingClientRect(); + if (!rect) { return '0px'; } + + const position = (hoverTime / videoDuration) * 100; + const pixelPosition = (position / 100) * rect.width; + const halfThumb = thumbnailWidth / 2; + + const clampedPosition = Math.max( + halfThumb, + Math.min(rect.width - halfThumb, pixelPosition), + ); + + return `${clampedPosition}px`; + })(), + transform: 'translateX(-50%)', + bottom: '100%', + marginBottom: '0.5rem', + }} + > +
+ {thumbnailData && ( +
+ Video preview +
+ )} +
+ {formatTime(hoverTime)} +
+
+
+ )} +
+ )} + +
+
+
+ {currentMode === 'view' && hasVideo && ( +
+ Speed + +
+ )} +
+ +
-
- - {currentMode === 'record' ? ( - - ) : ( +
- )} - {isRecording && ( + {currentMode === 'record' ? ( + + ) : ( + + )} + + {isRecording && ( + + )} + - )} - - - -
+
-
+
-
- Camera Settings -
- {showCameraMenu && ( -
-
- -

Select Camera

+
+ + {currentMode === 'record' && ( +
+ Camera Settings +
+ )} + + {showCameraMenu && currentMode === 'record' && ( +
+
+ +

Select Camera

+
+ {videoDevices.map((device) => ( + + ))}
- {' '} - {videoDevices.map((device) => ( - - ))} -
- )} + )} +
diff --git a/renderer/src/components/hooks/video/useCamera.js b/renderer/src/components/hooks/video/useCamera.js new file mode 100644 index 00000000..24c26ecb --- /dev/null +++ b/renderer/src/components/hooks/video/useCamera.js @@ -0,0 +1,191 @@ +import { + useState, useRef, useEffect, useCallback, +} from 'react'; +import * as logger from '../../../logger'; + +const getCameraErrorMessage = (err) => { + if (!err) { return 'Unknown error'; } + + switch (err.name) { + case 'NotAllowedError': + return 'Camera permission denied. Please allow camera access.'; + case 'NotFoundError': + return 'No camera found. Please connect a camera.'; + case 'OverconstrainedError': + return 'Camera constraints not supported. Trying fallback...'; + default: + return 'Failed to access camera. Please check your camera connection.'; + } +}; + +const getPreferredCamera = async () => { + try { + const devices = await navigator.mediaDevices.enumerateDevices(); + const videoDevices = devices.filter((device) => device.kind === 'videoinput'); + + if (videoDevices.length === 0) { + throw new Error('No camera devices found'); + } + + const builtInKeywords = ['integrated', 'built-in', 'webcam', 'facetime']; + const usbCamera = videoDevices.find((device) => device.label.toLowerCase().includes('usb')); + + if (usbCamera) { + return usbCamera.deviceId; + } + + const externalCamera = videoDevices.find((device) => { + const label = device.label.toLowerCase(); + return !builtInKeywords.some((keyword) => label.includes(keyword)); + }); + + if (externalCamera) { + return externalCamera.deviceId; + } + + return videoDevices[0].deviceId; + } catch (err) { + logger.error('Error enumerating devices:', err); + return null; + } +}; + +export const useCamera = ({ + isAudioEnabled, + currentMode, + selectedCamera, + videoPreviewRef, + onError, + clearError, + verse, + chapter, +}) => { + const [cameraReady, setCameraReady] = useState(false); + const [videoDevices, setVideoDevices] = useState([]); + const streamRef = useRef(null); + + useEffect(() => { + let mounted = true; + + const initCamera = async () => { + if (currentMode === 'view') { + if (streamRef.current) { + streamRef.current.getTracks().forEach((track) => track.stop()); + streamRef.current = null; + } + setCameraReady(false); + return; + } + + try { + const tempStream = await navigator.mediaDevices.getUserMedia({ + video: true, + audio: false, + }); + tempStream.getTracks().forEach((track) => track.stop()); + + const devices = await navigator.mediaDevices.enumerateDevices(); + const cams = devices.filter((d) => d.kind === 'videoinput'); + if (mounted) { + setVideoDevices(cams); + } + + const targetCameraId = selectedCamera || (await getPreferredCamera()); + const constraints = { + video: targetCameraId + ? { + deviceId: { exact: targetCameraId }, + width: { ideal: 1920 }, + height: { ideal: 1080 }, + frameRate: { ideal: 30 }, + } + : { + width: { ideal: 1920 }, + height: { ideal: 1080 }, + frameRate: { ideal: 30 }, + }, + audio: isAudioEnabled, + }; + + const stream = await navigator.mediaDevices.getUserMedia(constraints); + + if (!mounted) { + stream.getTracks().forEach((track) => track.stop()); + return; + } + + streamRef.current = stream; + + if (videoPreviewRef.current) { + videoPreviewRef.current.srcObject = stream; + videoPreviewRef.current.onloadedmetadata = () => { + if (mounted) { + videoPreviewRef.current.play(); + setCameraReady(true); + + const videoTrack = stream.getVideoTracks()[0]; + logger.debug('Using camera:', videoTrack.label); + } + }; + } + } catch (err) { + if (mounted) { + logger.error('Camera initialization error:', err); + onError(getCameraErrorMessage(err)); + + if (err.name === 'OverconstrainedError') { + try { + const fallbackStream = await navigator.mediaDevices.getUserMedia({ + video: true, + audio: isAudioEnabled, + }); + + if (mounted && videoPreviewRef.current) { + streamRef.current = fallbackStream; + videoPreviewRef.current.srcObject = fallbackStream; + videoPreviewRef.current.onloadedmetadata = () => { + if (mounted) { + videoPreviewRef.current.play(); + setCameraReady(true); + clearError(); + } + }; + } + } catch (fallbackErr) { + logger.error('Fallback camera access failed:', fallbackErr); + } + } + } + } + }; + + initCamera(); + + return () => { + mounted = false; + if (streamRef.current) { + streamRef.current.getTracks().forEach((track) => track.stop()); + } + }; + }, [isAudioEnabled, currentMode, selectedCamera, verse, chapter, videoPreviewRef, onError, clearError]); + + const toggleAudio = useCallback(async (isRecording) => { + if (isRecording) { + onError('Cannot change audio settings while recording'); + return false; + } + + setCameraReady(false); + if (streamRef.current) { + streamRef.current.getTracks().forEach((track) => track.stop()); + } + return true; + }, [onError]); + + return { + cameraReady, + videoDevices, + streamRef, + toggleAudio, + }; +}; diff --git a/renderer/src/components/hooks/video/useCameraMenu.js b/renderer/src/components/hooks/video/useCameraMenu.js new file mode 100644 index 00000000..24f1cb0b --- /dev/null +++ b/renderer/src/components/hooks/video/useCameraMenu.js @@ -0,0 +1,28 @@ +import { useState, useRef, useEffect } from 'react'; + +export const useCameraMenu = () => { + const [showCameraMenu, setShowCameraMenu] = useState(false); + const cameraMenuRef = useRef(null); + + useEffect(() => { + const handleClickOutside = (event) => { + if (cameraMenuRef.current && !cameraMenuRef.current.contains(event.target)) { + setShowCameraMenu(false); + } + }; + + if (showCameraMenu) { + document.addEventListener('mousedown', handleClickOutside); + } + + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [showCameraMenu]); + + return { + showCameraMenu, + setShowCameraMenu, + cameraMenuRef, + }; +}; diff --git a/renderer/src/components/hooks/video/useErrorHandler.js b/renderer/src/components/hooks/video/useErrorHandler.js new file mode 100644 index 00000000..cfd810a9 --- /dev/null +++ b/renderer/src/components/hooks/video/useErrorHandler.js @@ -0,0 +1,38 @@ +import { + useState, useRef, useCallback, useEffect, +} from 'react'; + +export const useErrorHandler = () => { + const [error, setError] = useState(null); + const errorTimeoutRef = useRef(null); + + const clearError = useCallback(() => { + setError(null); + if (errorTimeoutRef.current) { + clearTimeout(errorTimeoutRef.current); + errorTimeoutRef.current = null; + } + }, []); + + const setErrorWithTimeout = useCallback((message, timeout = 5000) => { + setError(message); + if (errorTimeoutRef.current) { + clearTimeout(errorTimeoutRef.current); + } + errorTimeoutRef.current = setTimeout(() => { + setError(null); + }, timeout); + }, []); + + useEffect(() => () => { + if (errorTimeoutRef.current) { + clearTimeout(errorTimeoutRef.current); + } + }, []); + + return { + error, + setError: setErrorWithTimeout, + clearError, + }; +}; diff --git a/renderer/src/components/hooks/video/useFullscreen.js b/renderer/src/components/hooks/video/useFullscreen.js new file mode 100644 index 00000000..7f4b72d8 --- /dev/null +++ b/renderer/src/components/hooks/video/useFullscreen.js @@ -0,0 +1,28 @@ +import { useState, useEffect, useCallback } from 'react'; + +export const useFullscreen = () => { + const [isFullscreen, setIsFullscreen] = useState(false); + + const toggleFullscreen = useCallback(() => { + setIsFullscreen((prev) => !prev); + }, []); + + useEffect(() => { + const handleKeyDown = (e) => { + if (e.key === 'Escape' && isFullscreen) { + setIsFullscreen(false); + } else if (e.key === 'f' && !e.target.matches('input, textarea')) { + e.preventDefault(); + toggleFullscreen(); + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [isFullscreen, toggleFullscreen]); + + return { + isFullscreen, + toggleFullscreen, + }; +}; diff --git a/renderer/src/components/hooks/useVerseJoining.js b/renderer/src/components/hooks/video/useVerseJoining.js similarity index 99% rename from renderer/src/components/hooks/useVerseJoining.js rename to renderer/src/components/hooks/video/useVerseJoining.js index 45384cea..9a8175ce 100644 --- a/renderer/src/components/hooks/useVerseJoining.js +++ b/renderer/src/components/hooks/video/useVerseJoining.js @@ -6,7 +6,7 @@ import { validateVerseJoin, validateVerseDisjoin, } from '@/core/editor/verseJoining'; -import * as logger from '../../logger'; +import * as logger from '../../../logger'; export const useVerseJoining = ({ videoContent, diff --git a/renderer/src/components/hooks/video/useVideoPlayback.js b/renderer/src/components/hooks/video/useVideoPlayback.js new file mode 100644 index 00000000..6c9bf386 --- /dev/null +++ b/renderer/src/components/hooks/video/useVideoPlayback.js @@ -0,0 +1,193 @@ +import { + useState, useRef, useEffect, useCallback, +} from 'react'; +import * as logger from '../../../logger'; + +export const useVideoPlayback = ({ + currentMode, + hasVideo, + verse, + chapter, + projectPath, + videoPreviewRef, + onError, +}) => { + const [isPlaying, setIsPlaying] = useState(false); + const [playbackTime, setPlaybackTime] = useState(0); + const [videoDuration, setVideoDuration] = useState(0); + const [playbackSpeed, setPlaybackSpeed] = useState(1); + const playbackTimerRef = useRef(null); + + const fs = window.require('fs'); + const path = window.require('path'); + + useEffect(() => { + if (currentMode === 'view' && hasVideo && videoPreviewRef.current) { + const videoExt = ['webm', 'mp4'].find((ext) => fs.existsSync(path.join(projectPath, `${chapter}_${verse}.${ext}`))); + + if (!videoExt) { + onError('Video file not found'); + return; + } + + const filename = `${chapter}_${verse}.${videoExt}`; + const fullPath = path.join(projectPath, filename); + + try { + if (!fs.existsSync(fullPath)) { + logger.error('Video file does not exist:', fullPath); + onError(`Video file not found: ${filename}`); + return; + } + + const stats = fs.statSync(fullPath); + logger.debug('Video file size:', stats.size); + + if (stats.size === 0) { + onError('Video file is empty'); + return; + } + } catch (err) { + logger.error('Error checking video file:', err); + onError(`Cannot access video: ${err.message}`); + return; + } + + const buffer = fs.readFileSync(fullPath); + const mimeType = videoExt === 'mp4' ? 'video/mp4' : 'video/webm'; + const blob = new Blob([buffer], { type: mimeType }); + const videoPath = URL.createObjectURL(blob); + + logger.debug('Loading video from Blob URL:', videoPath); + + setIsPlaying(false); + setPlaybackTime(0); + + const video = videoPreviewRef.current; + + video.pause(); + video.srcObject = null; + video.removeAttribute('src'); + video.load(); + + setTimeout(() => { + if (!videoPreviewRef.current) { return; } + video.src = videoPath; + video.load(); + video.onloadedmetadata = () => { + if (!videoPreviewRef.current) { return; } + let dur = video.duration; + + if (!Number.isFinite(dur) || dur === 0) { + logger.warn('Duration invalid, forcing recalculation...'); + video.currentTime = 1e101; + video.ontimeupdate = () => { + if (!videoPreviewRef.current) { return; } + video.ontimeupdate = null; + dur = video.duration; + if (Number.isFinite(dur)) { + setVideoDuration(dur); + } else { + setVideoDuration(0); + } + video.currentTime = 0; + }; + } else { + setVideoDuration(dur); + } + }; + + video.onloadeddata = () => { + if (!videoPreviewRef.current) { return; } + logger.debug('Video data loaded and ready to play'); + }; + + video.onerror = (e) => { + logger.error('Video load error:', e); + onError('Failed to load video file'); + }; + }, 50); + } + }, [currentMode, hasVideo, verse, chapter, projectPath]); + + useEffect(() => { + if (currentMode === 'view' && videoPreviewRef.current) { + if (isPlaying) { + videoPreviewRef.current.play().catch((err) => { + logger.error('Error playing video:', err); + setIsPlaying(false); + }); + + playbackTimerRef.current = setInterval(() => { + if (videoPreviewRef.current) { + setPlaybackTime(videoPreviewRef.current.currentTime); + } + }, 100); + } else { + videoPreviewRef.current.pause(); + + if (playbackTimerRef.current) { + clearInterval(playbackTimerRef.current); + playbackTimerRef.current = null; + } + if (videoPreviewRef.current) { + setPlaybackTime(videoPreviewRef.current.currentTime); + } + } + } + + return () => { + if (playbackTimerRef.current) { + clearInterval(playbackTimerRef.current); + } + }; + }, [isPlaying, currentMode]); + + useEffect(() => { + if (videoPreviewRef.current && currentMode === 'view') { + videoPreviewRef.current.playbackRate = playbackSpeed; + } + }, [playbackSpeed, currentMode]); + + const togglePlayPause = useCallback(() => { + setIsPlaying(!isPlaying); + }, [isPlaying]); + + const seek = useCallback((time) => { + if (videoPreviewRef.current) { + videoPreviewRef.current.currentTime = time; + setPlaybackTime(time); + } + }, []); + + const cycleSpeed = useCallback(() => { + setPlaybackSpeed((prev) => { + const speeds = [0.5, 0.75, 1, 1.25, 1.5, 2]; + const index = speeds.indexOf(prev); + return speeds[(index + 1) % speeds.length]; + }); + }, []); + + const resetPlayback = useCallback(() => { + if (videoPreviewRef.current) { + const video = videoPreviewRef.current; + video.pause(); + video.srcObject = null; + video.removeAttribute('src'); + video.load(); + } + setIsPlaying(false); + }, []); + + return { + isPlaying, + playbackTime, + videoDuration, + playbackSpeed, + togglePlayPause, + seek, + cycleSpeed, + resetPlayback, + setIsPlaying, + }; +}; diff --git a/renderer/src/components/hooks/video/useVideoRecording.js b/renderer/src/components/hooks/video/useVideoRecording.js new file mode 100644 index 00000000..f8317b67 --- /dev/null +++ b/renderer/src/components/hooks/video/useVideoRecording.js @@ -0,0 +1,163 @@ +import { useState, useRef, useCallback } from 'react'; +import * as logger from '../../../logger'; + +export const useVideoRecording = ({ + chapter, + verse, + projectPath, + streamRef, + onSaveComplete, + onError, +}) => { + const [isRecording, setIsRecording] = useState(false); + const [isPaused, setIsPaused] = useState(false); + const [recordingTime, setRecordingTime] = useState(0); + const [isProcessing, setIsProcessing] = useState(false); + + const mediaRecorderRef = useRef(null); + const timerRef = useRef(null); + + const fs = window.require('fs'); + const path = window.require('path'); + + const saveVideo = useCallback(async (blob) => { + setIsProcessing(true); + + try { + const arrayBuffer = await blob.arrayBuffer(); + const buffer = Buffer.from(arrayBuffer); + + const filename = `${chapter}_${verse}.webm`; + const filePath = path.join(projectPath, filename); + + fs.writeFileSync(filePath, buffer); + + if (onSaveComplete) { + onSaveComplete({ + verse, + chapter, + filePath, + filename, + buffer, + }); + } + + setIsProcessing(false); + return true; + } catch (err) { + logger.error('Error saving video:', err); + onError(`Failed to save video: ${err.message}`); + setIsProcessing(false); + return false; + } + }, [chapter, verse, projectPath, onSaveComplete, onError]); + + const startRecording = useCallback(async () => { + if (!streamRef.current) { + onError('Camera not ready'); + return; + } + + try { + const filename = `${chapter}_${verse}.webm`; + const filePath = path.join(projectPath, filename); + + if (fs.existsSync(filePath)) { + fs.unlinkSync(filePath); + logger.debug('Deleted existing video for re-recording'); + } + + const options = { + mimeType: 'video/webm;codecs=vp9', + videoBitsPerSecond: 5000000, + }; + + if (!MediaRecorder.isTypeSupported(options.mimeType)) { + options.mimeType = 'video/webm;codecs=vp8'; + } + + const mediaRecorder = new MediaRecorder(streamRef.current, options); + mediaRecorderRef.current = mediaRecorder; + + const chunks = []; + mediaRecorder.ondataavailable = (event) => { + if (event.data && event.data.size > 0) { + chunks.push(event.data); + } + }; + + mediaRecorder.onstop = async () => { + const blob = new Blob(chunks, { type: 'video/webm' }); + await saveVideo(blob); + }; + + mediaRecorder.onerror = () => { + onError('Recording failed. Please try again.'); + setIsRecording(false); + setIsPaused(false); + }; + + mediaRecorder.start(100); + setIsRecording(true); + setIsPaused(false); + setRecordingTime(0); + + timerRef.current = setInterval(() => { + setRecordingTime((prev) => prev + 1); + }, 1000); + } catch (err) { + onError('Failed to start recording. Please try again.'); + } + }, [chapter, verse, projectPath, streamRef, saveVideo, onError]); + + const pauseRecording = useCallback(() => { + if (mediaRecorderRef.current && isRecording && !isPaused) { + mediaRecorderRef.current.pause(); + setIsPaused(true); + + if (timerRef.current) { + clearInterval(timerRef.current); + timerRef.current = null; + } + + logger.debug('Recording paused'); + } + }, [isRecording, isPaused]); + + const resumeRecording = useCallback(() => { + if (mediaRecorderRef.current && isRecording && isPaused) { + mediaRecorderRef.current.resume(); + setIsPaused(false); + + timerRef.current = setInterval(() => { + setRecordingTime((prev) => prev + 1); + }, 1000); + + logger.debug('Recording resumed'); + } + }, [isRecording, isPaused]); + + const stopRecording = useCallback(() => { + if (mediaRecorderRef.current && isRecording) { + mediaRecorderRef.current.stop(); + setIsRecording(false); + setIsPaused(false); + + if (timerRef.current) { + clearInterval(timerRef.current); + timerRef.current = null; + } + } + }, [isRecording]); + + return { + isRecording, + isPaused, + recordingTime, + isProcessing, + startRecording, + pauseRecording, + resumeRecording, + stopRecording, + }; +}; diff --git a/renderer/src/components/hooks/video/useVideoThumbnail.js b/renderer/src/components/hooks/video/useVideoThumbnail.js new file mode 100644 index 00000000..75816f52 --- /dev/null +++ b/renderer/src/components/hooks/video/useVideoThumbnail.js @@ -0,0 +1,107 @@ +import { + useState, useRef, useEffect, useCallback, +} from 'react'; +import { debounce } from 'lodash'; +import * as logger from '../../../logger'; + +export const useVideoThumbnail = ({ currentMode, hasVideo, videoPreviewRef }) => { + const [thumbnailData, setThumbnailData] = useState(null); + const [hoverTime, setHoverTime] = useState(null); + const [showHoverTime, setShowHoverTime] = useState(false); + + const thumbnailVideoRef = useRef(null); + const thumbnailCanvasRef = useRef(null); + + useEffect(() => { + if (currentMode === 'view' && hasVideo && !thumbnailVideoRef.current) { + thumbnailVideoRef.current = document.createElement('video'); + thumbnailVideoRef.current.muted = true; + thumbnailVideoRef.current.preload = 'metadata'; + thumbnailVideoRef.current.style.display = 'none'; + document.body.appendChild(thumbnailVideoRef.current); + } + + return () => { + if (thumbnailVideoRef.current) { + thumbnailVideoRef.current.remove(); + thumbnailVideoRef.current = null; + } + }; + }, [currentMode, hasVideo]); + + const generateThumbnail = useCallback((time) => { + if (!videoPreviewRef.current || !thumbnailVideoRef.current) { return; } + + const thumbnailVideo = thumbnailVideoRef.current; + + if (thumbnailVideo.src !== videoPreviewRef.current.src) { + thumbnailVideo.src = videoPreviewRef.current.src; + } + + if (!thumbnailCanvasRef.current) { + thumbnailCanvasRef.current = document.createElement('canvas'); + thumbnailCanvasRef.current.width = 160; + thumbnailCanvasRef.current.height = 90; + } + + const canvas = thumbnailCanvasRef.current; + const ctx = canvas.getContext('2d', { willReadFrequently: true }); + + thumbnailVideo.currentTime = time; + + const drawFrame = () => { + try { + ctx.clearRect(0, 0, canvas.width, canvas.height); + ctx.drawImage(thumbnailVideo, 0, 0, canvas.width, canvas.height); + const dataUrl = canvas.toDataURL('image/jpeg', 0.6); + setThumbnailData(dataUrl); + } catch (err) { + logger.error('Error generating thumbnail:', err); + } + }; + + thumbnailVideo.onseeked = drawFrame; + }, [videoPreviewRef]); + + const debouncedGenerateThumbnail = useCallback( + debounce((time) => { + if (videoPreviewRef.current && currentMode === 'view') { + generateThumbnail(time); + } + }, 100), + [generateThumbnail, currentMode], + ); + + const handleSeekHover = useCallback((e, seekBarRef, videoDuration) => { + if (!seekBarRef.current || !videoDuration) { return; } + + const rect = seekBarRef.current.getBoundingClientRect(); + const percent = (e.clientX - rect.left) / rect.width; + const time = Math.max(0, Math.min(videoDuration, percent * videoDuration)); + + setHoverTime(time); + setShowHoverTime(true); + debouncedGenerateThumbnail(time); + }, [debouncedGenerateThumbnail]); + + const clearSeekHover = useCallback(() => { + setShowHoverTime(false); + setHoverTime(null); + setThumbnailData(null); + }, []); + + useEffect(() => () => { + if (thumbnailCanvasRef.current) { + const ctx = thumbnailCanvasRef.current.getContext('2d'); + ctx?.clearRect(0, 0, thumbnailCanvasRef.current.width, thumbnailCanvasRef.current.height); + } + }, []); + + return { + thumbnailData, + hoverTime, + showHoverTime, + handleSeekHover, + clearSeekHover, + }; +}; diff --git a/renderer/src/core/editor/verseJoining.js b/renderer/src/core/editor/verseJoining.js index 1d48f2da..f3dd6fa7 100644 --- a/renderer/src/core/editor/verseJoining.js +++ b/renderer/src/core/editor/verseJoining.js @@ -74,17 +74,17 @@ export const deleteVerseVideos = (chapter, verseNumber, videoDirPath) => { const fs = window.require('fs'); const path = require('path'); - const filename = `${chapter}_${verseNumber}.webm`; - const fullVideoPath = path.join(videoDirPath, filename); - - if (fs.existsSync(fullVideoPath)) { - fs.unlinkSync(fullVideoPath); - logger.debug('Deleted video:', fullVideoPath); - return true; - } + let deleted = false; + ['webm', 'mp4'].forEach((ext) => { + const fullVideoPath = path.join(videoDirPath, `${chapter}_${verseNumber}.${ext}`); + if (fs.existsSync(fullVideoPath)) { + fs.unlinkSync(fullVideoPath); + deleted = true; + } + }); - logger.warn('Video file not found:', fullVideoPath); - return false; + if (!deleted) { logger.warn('Video file not found:', chapter, verseNumber); } + return deleted; } catch (error) { logger.error('Error deleting verse video:', error); return false; @@ -96,10 +96,7 @@ export const hasVerseRecordings = (chapter, verseNumber, videoDirPath) => { const fs = window.require('fs'); const path = require('path'); - const filename = `${chapter}_${verseNumber}.webm`; - const fullVideoPath = path.join(videoDirPath, filename); - - return fs.existsSync(fullVideoPath); + return ['webm', 'mp4'].some((ext) => fs.existsSync(path.join(videoDirPath, `${chapter}_${verseNumber}.${ext}`))); } catch (error) { logger.error('Error checking verse recording:', error); return false;