+
+ {currentMode === 'view' && hasVideo && (
+
+ Speed
+
+
+ )}
+
+
+
-
-
- {currentMode === 'record' ? (
-
- ) : (
+
- )}
- {isRecording && (
+ {currentMode === 'record' ? (
+
+ ) : (
+
+ )}
+
+ {isRecording && (
+
+ )}
+
- )}
-
-
-
-
+
-
+
-
- Camera Settings
-
- {showCameraMenu && (
-
-
-
-
Select Camera
+
+
+ {currentMode === 'record' && (
+
+ Camera Settings
+
+ )}
+
+ {showCameraMenu && currentMode === 'record' && (
+
+
+ {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;