204 lines
6.8 KiB
TypeScript
204 lines
6.8 KiB
TypeScript
|
|
import { useEffect, useRef, useState } from "react";
|
|
|
|
export default function VideoEditor() {
|
|
// --- State ---
|
|
const [videoPath, setVideoPath] = useState<string | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [isPlaying, setIsPlaying] = useState(false);
|
|
const [currentTime, setCurrentTime] = useState(0);
|
|
const [duration, setDuration] = useState(0);
|
|
|
|
// --- Refs ---
|
|
const videoRef = useRef<HTMLVideoElement | null>(null);
|
|
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
|
|
|
// --- Load video path on mount ---
|
|
useEffect(() => {
|
|
async function loadVideo() {
|
|
try {
|
|
const result = await window.electronAPI.getRecordedVideoPath();
|
|
if (result.success && result.path) {
|
|
setVideoPath(`file://${result.path}`);
|
|
} else {
|
|
setError(result.message || 'Failed to load video');
|
|
}
|
|
} catch (err) {
|
|
setError('Error loading video: ' + String(err));
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
loadVideo();
|
|
}, []);
|
|
|
|
// --- Canvas drawing and video event listeners ---
|
|
useEffect(() => {
|
|
const video = videoRef.current;
|
|
const canvas = canvasRef.current;
|
|
if (!video || !canvas) return;
|
|
let animationId: number;
|
|
|
|
function drawFrame() {
|
|
if (!video || !canvas) return;
|
|
if (video.paused || video.ended) return;
|
|
// Keep canvas size in sync with video
|
|
if (canvas.width !== video.videoWidth || canvas.height !== video.videoHeight) {
|
|
canvas.width = video.videoWidth;
|
|
canvas.height = video.videoHeight;
|
|
}
|
|
const ctx = canvas.getContext('2d');
|
|
if (ctx) {
|
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
|
|
}
|
|
animationId = requestAnimationFrame(drawFrame);
|
|
}
|
|
|
|
function handlePlay() {
|
|
drawFrame();
|
|
}
|
|
function handlePause() {
|
|
cancelAnimationFrame(animationId);
|
|
}
|
|
|
|
video.addEventListener('play', handlePlay);
|
|
video.addEventListener('pause', handlePause);
|
|
video.addEventListener('ended', handlePause);
|
|
|
|
return () => {
|
|
video.removeEventListener('play', handlePlay);
|
|
video.removeEventListener('pause', handlePause);
|
|
video.removeEventListener('ended', handlePause);
|
|
cancelAnimationFrame(animationId);
|
|
};
|
|
}, [videoPath]);
|
|
|
|
// --- Handlers ---
|
|
function togglePlayPause() {
|
|
if (!videoRef.current) return;
|
|
if (isPlaying) {
|
|
videoRef.current.pause();
|
|
} else {
|
|
videoRef.current.play();
|
|
}
|
|
}
|
|
|
|
function handleSeek(e: React.ChangeEvent<HTMLInputElement>) {
|
|
if (!videoRef.current) return;
|
|
const newTime = parseFloat(e.target.value);
|
|
videoRef.current.currentTime = newTime;
|
|
}
|
|
|
|
function formatTime(seconds: number) {
|
|
if (!isFinite(seconds) || isNaN(seconds) || seconds < 0) {
|
|
return '0:00';
|
|
}
|
|
const mins = Math.floor(seconds / 60);
|
|
const secs = Math.floor(seconds % 60);
|
|
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
|
}
|
|
|
|
// --- Early returns for loading/error ---
|
|
if (loading) {
|
|
return (
|
|
<div className="flex items-center justify-center h-screen bg-background">
|
|
<div className="text-foreground">Loading video...</div>
|
|
</div>
|
|
);
|
|
}
|
|
if (error) {
|
|
return (
|
|
<div className="flex items-center justify-center h-screen bg-background">
|
|
<div className="text-destructive">{error}</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// --- Main render ---
|
|
return (
|
|
<div className="flex flex-col h-screen bg-background p-6">
|
|
<div className="flex-1 flex items-center justify-center overflow-hidden relative">
|
|
{videoPath && (
|
|
<>
|
|
<canvas
|
|
ref={canvasRef}
|
|
className="max-w-full max-h-full"
|
|
style={{
|
|
borderRadius: 8,
|
|
objectFit: 'contain',
|
|
width: 'auto',
|
|
height: 'auto'
|
|
}}
|
|
/>
|
|
<video
|
|
ref={videoRef}
|
|
src={videoPath}
|
|
style={{ display: 'none' }}
|
|
preload="metadata"
|
|
onLoadedMetadata={e => {
|
|
const video = e.currentTarget;
|
|
if (isFinite(video.duration) && !isNaN(video.duration) && video.duration > 0) {
|
|
setDuration(video.duration);
|
|
}
|
|
}}
|
|
onCanPlay={() => {
|
|
const video = videoRef.current;
|
|
if (video && isFinite(video.duration) && video.duration > 0) {
|
|
setDuration(video.duration);
|
|
}
|
|
}}
|
|
onTimeUpdate={e => {
|
|
const time = e.currentTarget.currentTime;
|
|
if (isFinite(time) && !isNaN(time)) {
|
|
setCurrentTime(time);
|
|
}
|
|
}}
|
|
onError={() => setError('Failed to play video')}
|
|
onPlay={() => setIsPlaying(true)}
|
|
onPause={() => setIsPlaying(false)}
|
|
onEnded={() => setIsPlaying(false)}
|
|
/>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
<div className="mt-6 bg-card border border-border rounded-lg p-4">
|
|
<div className="flex items-center gap-4">
|
|
<button
|
|
onClick={togglePlayPause}
|
|
className="flex items-center justify-center w-10 h-10 rounded-full bg-primary text-primary-foreground hover:bg-primary/90 transition-colors"
|
|
aria-label={isPlaying ? 'Pause' : 'Play'}
|
|
>
|
|
{isPlaying ? (
|
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
|
<rect x="4" y="3" width="3" height="10" rx="0.5" />
|
|
<rect x="9" y="3" width="3" height="10" rx="0.5" />
|
|
</svg>
|
|
) : (
|
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
|
<path d="M5 3.5v9l7-4.5z" />
|
|
</svg>
|
|
)}
|
|
</button>
|
|
<span className="text-sm text-muted-foreground font-mono min-w-[80px]">
|
|
{formatTime(currentTime)}
|
|
</span>
|
|
<input
|
|
type="range"
|
|
min="0"
|
|
max={duration || 100}
|
|
value={currentTime}
|
|
onChange={handleSeek}
|
|
step="0.01"
|
|
className="flex-1 h-2 bg-muted rounded-lg appearance-none cursor-pointer [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-primary [&::-webkit-slider-thumb]:cursor-pointer hover:[&::-webkit-slider-thumb]:bg-primary/80"
|
|
/>
|
|
<span className="text-sm text-muted-foreground font-mono min-w-[80px] text-right">
|
|
{formatTime(duration)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
} |