code cleanup

This commit is contained in:
Siddharth
2025-10-17 17:06:03 -07:00
parent d43becbf81
commit ec37cd7f11
12 changed files with 530 additions and 514 deletions
-203
View File
@@ -1,203 +0,0 @@
import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Card } from "@/components/ui/card";
interface DesktopSource {
id: string;
name: string;
thumbnail: string | null;
display_id: string;
appIcon: string | null;
}
export function SourceSelector() {
const [sources, setSources] = useState<DesktopSource[]>([]);
const [selectedSource, setSelectedSource] = useState<DesktopSource | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
loadSources();
}, []);
const loadSources = async () => {
try {
setLoading(true);
const rawSources = await window.electronAPI.getSources({
types: ['screen', 'window'],
thumbnailSize: { width: 320, height: 180 },
fetchWindowIcons: true
});
const formattedSources = rawSources.map(source => {
let displayName = source.name;
if (source.id.startsWith('window:') && source.name.includes(' — ')) {
displayName = source.name.split(' — ')[1] || source.name;
}
return {
id: source.id,
name: displayName,
thumbnail: source.thumbnail,
display_id: source.display_id,
appIcon: source.appIcon
};
});
setSources(formattedSources);
} catch (error) {
console.error('Error loading sources:', error);
} finally {
setLoading(false);
}
};
const screenSources = sources.filter(source => source.id.startsWith('screen:'));
const windowSources = sources.filter(source => source.id.startsWith('window:'));
const handleSourceSelect = (source: DesktopSource) => {
setSelectedSource(source);
};
const handleShare = async () => {
if (selectedSource) {
await window.electronAPI.selectSource(selectedSource);
}
};
if (loading) {
return (
<div className="h-full bg-white flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-600 mx-auto mb-3"></div>
<p className="text-sm text-gray-600">Loading sources...</p>
</div>
</div>
);
}
return (
<div className="h-full bg-white flex flex-col">
<div className="p-4 bg-white">
<Tabs defaultValue="screens" className="flex flex-col">
<TabsList className="grid w-full grid-cols-2 mb-4 bg-gray-100">
<TabsTrigger value="screens" className="data-[state=active]:bg-gray-700 data-[state=active]:text-white text-gray-700">
Screens
</TabsTrigger>
<TabsTrigger value="windows" className="data-[state=active]:bg-gray-700 data-[state=active]:text-white text-gray-700">
Windows
</TabsTrigger>
</TabsList>
<div className="h-64 overflow-hidden bg-white">
<TabsContent value="screens" className="h-full mt-0">
<div className="grid grid-cols-2 gap-3 h-full overflow-y-auto pr-2">
{screenSources.map((source) => (
<Card
key={source.id}
className={`cursor-pointer transition-all hover:shadow-lg h-fit scale-90 p-2 ${
selectedSource?.id === source.id
? 'ring-2 ring-gray-700 bg-gray-50'
: 'hover:ring-1 hover:ring-gray-300 bg-white border border-gray-200'
}`}
onClick={() => handleSourceSelect(source)}
>
<div className="p-3">
<div className="relative mb-2">
<img
src={source.thumbnail || ''}
alt={source.name}
className="w-full aspect-video object-cover rounded border border-gray-300"
/>
{selectedSource?.id === source.id && (
<div className="absolute -top-1 -right-1">
<div className="w-5 h-5 bg-gray-700 rounded-full flex items-center justify-center shadow-md">
<svg className="w-3 h-3 text-white" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
</svg>
</div>
</div>
)}
</div>
<div className="text-xs font-medium text-gray-800 truncate">
{source.name}
</div>
</div>
</Card>
))}
</div>
</TabsContent>
<TabsContent value="windows" className="h-full mt-0">
<div className="grid grid-cols-2 gap-3 h-full overflow-y-auto pr-2">
{windowSources.map((source) => (
<Card
key={source.id}
className={`cursor-pointer transition-all hover:shadow-lg h-fit ${
selectedSource?.id === source.id
? 'ring-2 ring-gray-700 bg-gray-50'
: 'hover:ring-1 hover:ring-gray-300 bg-white border border-gray-200'
}`}
style={{ transform: 'scale(0.9)', margin: '8px' }}
onClick={() => handleSourceSelect(source)}
>
<div className="p-3">
<div className="relative mb-2">
<img
src={source.thumbnail || ''}
alt={source.name}
className="w-full aspect-video object-cover rounded border border-gray-300"
/>
{selectedSource?.id === source.id && (
<div className="absolute -top-1 -right-1">
<div className="w-5 h-5 bg-gray-700 rounded-full flex items-center justify-center shadow-md">
<svg className="w-3 h-3 text-white" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
</svg>
</div>
</div>
)}
</div>
<div className="flex items-center gap-2">
{source.appIcon && (
<img
src={source.appIcon}
alt="App icon"
className="w-3 h-3 flex-shrink-0"
/>
)}
<div className="text-xs font-medium text-gray-800 truncate">
{source.name}
</div>
</div>
</div>
</Card>
))}
</div>
</TabsContent>
</div>
</Tabs>
</div>
<div className="bg-white border-t border-gray-200 p-3">
<div className="flex justify-center gap-3">
<Button
variant="outline"
onClick={() => window.close()}
className="px-6 py-1.5 text-sm bg-gray-600 border-gray-600 text-white hover:bg-gray-700"
>
Cancel
</Button>
<Button
onClick={handleShare}
disabled={!selectedSource}
className="px-6 py-1.5 text-sm bg-gray-600 text-white hover:bg-gray-700 disabled:opacity-50 disabled:bg-gray-400"
>
Share
</Button>
</div>
</div>
</div>
);
}
-202
View File
@@ -1,202 +0,0 @@
import { useEffect, useRef, useState } from "react";
export default function VideoEditor() {
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);
const videoRef = useRef<HTMLVideoElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const isSeeking = useRef(false);
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();
}, []);
useEffect(() => {
const video = videoRef.current;
const canvas = canvasRef.current;
if (!video || !canvas) return;
let animationId: number;
function drawFrame() {
if (!video || !canvas || video.paused || video.ended) return;
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);
}
const handlePlay = () => drawFrame();
const 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]);
function togglePlayPause() {
if (!videoRef.current) return;
isPlaying ? videoRef.current.pause() : videoRef.current.play();
}
function handleSeek(e: React.ChangeEvent<HTMLInputElement>) {
if (!videoRef.current) return;
const newTime = parseFloat(e.target.value);
videoRef.current.currentTime = newTime;
setCurrentTime(newTime);
}
function handleSeekStart() {
isSeeking.current = true;
}
function handleSeekEnd() {
isSeeking.current = false;
}
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')}`;
}
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>
);
}
return (
<div className="flex h-screen bg-background p-8 gap-8">
<div className="flex flex-col flex-[7] min-w-0 gap-8">
<div className="flex flex-col gap-6 flex-1">
<div
className="w-full aspect-video rounded-xl p-8 flex items-center justify-center overflow-hidden bg-cover bg-center"
style={{ backgroundImage: 'url(/wallpaper.png)' }}
>
{videoPath && (
<>
<canvas
ref={canvasRef}
className="w-full h-full object-contain rounded-lg"
/>
<video
ref={videoRef}
src={videoPath}
className="hidden"
preload="metadata"
onLoadedMetadata={e => {
const { duration } = e.currentTarget;
if (isFinite(duration) && duration > 0) setDuration(duration);
}}
onDurationChange={e => {
const { duration } = e.currentTarget;
if (isFinite(duration) && duration > 0) setDuration(duration);
}}
onTimeUpdate={e => {
const time = e.currentTarget.currentTime;
if (isFinite(time) && !isSeeking.current) setCurrentTime(time);
}}
onError={() => setError('Failed to load video')}
onPlay={() => setIsPlaying(true)}
onPause={() => setIsPlaying(false)}
onEnded={() => setIsPlaying(false)}
/>
</>
)}
</div>
<div className="flex items-center gap-4 px-4">
<button
onClick={togglePlayPause}
className="flex items-center justify-center w-8 h-8 rounded-full bg-primary text-primary-foreground hover:bg-primary/90 transition-colors shadow-md"
aria-label={isPlaying ? 'Pause' : 'Play'}
>
{isPlaying ? (
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor">
<rect x="6" y="4" width="4" height="16" rx="1" />
<rect x="14" y="4" width="4" height="16" rx="1" />
</svg>
) : (
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor">
<path d="M8 5v14l11-7z" />
</svg>
)}
</button>
<span className="text-xs text-muted-foreground font-mono tabular-nums min-w-[50px]">
{formatTime(currentTime)}
</span>
<input
type="range"
min="0"
max={duration || 100}
value={currentTime}
onChange={handleSeek}
onMouseDown={handleSeekStart}
onMouseUp={handleSeekEnd}
onTouchStart={handleSeekStart}
onTouchEnd={handleSeekEnd}
step="0.01"
className="flex-1 h-1.5 bg-gray-200 rounded-full appearance-none cursor-pointer [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-blue-500 [&::-webkit-slider-thumb]:cursor-pointer [&::-webkit-slider-thumb]:transition-transform [&::-webkit-slider-thumb]:shadow-sm hover:[&::-webkit-slider-thumb]:scale-125"
style={{
background: `linear-gradient(to right, rgb(59 130 246) 0%, rgb(59 130 246) ${(currentTime / (duration || 100)) * 100}%, rgb(229 231 235) ${(currentTime / (duration || 100)) * 100}%, rgb(229 231 235) 100%)`
}}
/>
<span className="text-xs text-muted-foreground font-mono tabular-nums min-w-[50px] text-right">
{formatTime(duration)}
</span>
</div>
</div>
<div className="bg-card border border-border rounded-xl p-8 flex-1 min-h-[180px] flex flex-col justify-center shadow-sm">
<div className="h-12 bg-muted rounded-lg flex items-center justify-center text-muted-foreground text-sm">
Timeline/Editor controls coming soon...
</div>
</div>
</div>
<div className="flex-[3] min-w-0 bg-card border border-border rounded-xl p-8 flex flex-col shadow-sm">
<div className="w-full h-10 bg-muted rounded-lg mb-6" />
<div className="flex-1 w-full flex items-center justify-center text-muted-foreground text-base">
Settings panel (coming soon)
</div>
</div>
</div>
);
}
@@ -1,6 +1,6 @@
import { useState, useEffect } from "react";
import { useScreenRecorder } from "../hooks/useScreenRecorder";
import { Button } from "@/components/ui/button";
import { useScreenRecorder } from "../../hooks/useScreenRecorder";
import { Button } from "../ui/button";
import { BsRecordCircle } from "react-icons/bs";
import { FaRegStopCircle } from "react-icons/fa";
import { MdMonitor } from "react-icons/md";
@@ -42,12 +42,12 @@ export function LaunchWindow() {
};
return (
<div className="w-full h-full flex items-center justify-center bg-transparent">
<div className="flex items-center gap-6 backdrop-blur-xl bg-black/80 rounded-full px-6 py-3 shadow-2xl border border-white/20">
<div className="w-full h-full flex items-center bg-transparent">
<div className="w-full max-w-2xl mx-auto flex items-center justify-between backdrop-blur-xl bg-black/80 rounded-full px-6 py-3 shadow-2xl border border-white/20">
<Button
variant="link"
size="sm"
className="gap-2 text-white bg-transparent hover:bg-transparent px-0"
className="gap-2 text-white bg-transparent hover:bg-transparent px-0 flex-1 text-left"
onClick={openSourceSelector}
>
<MdMonitor size={16} className="text-white" />
@@ -61,7 +61,7 @@ export function LaunchWindow() {
size="sm"
onClick={hasSelectedSource ? toggleRecording : openSourceSelector}
disabled={!hasSelectedSource && !recording}
className="gap-2 bg-transparent hover:bg-transparent px-0"
className="gap-2 bg-transparent hover:bg-transparent px-0 flex-1 text-right"
>
{recording ? (
<>
@@ -78,4 +78,4 @@ export function LaunchWindow() {
</div>
</div>
);
}
}
+158
View File
@@ -0,0 +1,158 @@
import { useState, useEffect } from "react";
import { Button } from "../ui/button";
import { MdCheck } from "react-icons/md";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs";
import { Card } from "../ui/card";
interface DesktopSource {
id: string;
name: string;
thumbnail: string | null;
display_id: string;
appIcon: string | null;
}
export function SourceSelector() {
const [sources, setSources] = useState<DesktopSource[]>([]);
const [selectedSource, setSelectedSource] = useState<DesktopSource | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
async function fetchSources() {
setLoading(true);
try {
const rawSources = await window.electronAPI.getSources({
types: ['screen', 'window'],
thumbnailSize: { width: 320, height: 180 },
fetchWindowIcons: true
});
setSources(
rawSources.map(source => ({
id: source.id,
name:
source.id.startsWith('window:') && source.name.includes(' — ')
? source.name.split(' — ')[1] || source.name
: source.name,
thumbnail: source.thumbnail,
display_id: source.display_id,
appIcon: source.appIcon
}))
);
} catch (error) {
console.error('Error loading sources:', error);
} finally {
setLoading(false);
}
}
fetchSources();
}, []);
const screenSources = sources.filter(s => s.id.startsWith('screen:'));
const windowSources = sources.filter(s => s.id.startsWith('window:'));
const handleSourceSelect = (source: DesktopSource) => setSelectedSource(source);
const handleShare = async () => {
if (selectedSource) await window.electronAPI.selectSource(selectedSource);
};
if (loading) {
return (
<div className="h-full flex items-center justify-center bg-white">
<div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-600 mx-auto mb-3" />
<p className="text-sm text-gray-600">Loading sources...</p>
</div>
</div>
);
}
return (
<div className="min-h-screen flex flex-col bg-white">
<div className="flex-1 flex flex-col p-4 bg-white">
<Tabs defaultValue="screens">
<TabsList className="grid grid-cols-2 mb-4 bg-blue-50 rounded-full">
<TabsTrigger value="screens" className="data-[state=active]:bg-blue-600 data-[state=active]:text-white text-blue-700 rounded-full">Screens</TabsTrigger>
<TabsTrigger value="windows" className="data-[state=active]:bg-blue-600 data-[state=active]:text-white text-blue-700 rounded-full">Windows</TabsTrigger>
</TabsList>
<div className="h-64">
<TabsContent value="screens" className="h-full">
<div className="grid grid-cols-2 gap-3 h-full overflow-y-auto pr-2">
{screenSources.map(source => (
<Card
key={source.id}
className={`cursor-pointer transition hover:shadow-lg h-fit p-3 scale-90 ${selectedSource?.id === source.id ? 'ring-2 ring-blue-600 bg-blue-50 z-10' : 'hover:ring-1 hover:ring-blue-200 bg-white border border-blue-100'}`}
style={{ margin: 12, width: '80%', maxWidth: 320 }}
onClick={() => handleSourceSelect(source)}
>
<div className="p-2">
<div className="relative mb-2">
<img
src={source.thumbnail || ''}
alt={source.name}
className="w-full aspect-video object-cover rounded border border-gray-300"
/>
{selectedSource?.id === source.id && (
<div className="absolute -top-1 -right-1">
<div className="w-5 h-5 bg-blue-600 rounded-full flex items-center justify-center shadow-md">
<MdCheck className="w-3 h-3 text-white" />
</div>
</div>
)}
</div>
<div className="text-xs font-medium text-blue-700 truncate">{source.name}</div>
</div>
</Card>
))}
</div>
</TabsContent>
<TabsContent value="windows" className="h-full">
<div className="grid grid-cols-2 gap-3 h-full overflow-y-auto pr-2">
{windowSources.map(source => (
<Card
key={source.id}
className={`cursor-pointer transition hover:shadow-lg h-fit p-3 scale-90 ${selectedSource?.id === source.id ? 'ring-2 ring-blue-600 bg-blue-50 z-10' : 'hover:ring-1 hover:ring-blue-200 bg-white border border-blue-100'}`}
style={{ margin: 12, width: '80%', maxWidth: 320 }}
onClick={() => handleSourceSelect(source)}
>
<div className="p-2">
<div className="relative mb-2">
<img
src={source.thumbnail || ''}
alt={source.name}
className="w-full aspect-video object-cover rounded border border-gray-300"
/>
{selectedSource?.id === source.id && (
<div className="absolute -top-1 -right-1">
<div className="w-5 h-5 bg-blue-600 rounded-full flex items-center justify-center shadow-md">
<MdCheck className="w-3 h-3 text-white" />
</div>
</div>
)}
</div>
<div className="flex items-center gap-2">
{source.appIcon && (
<img
src={source.appIcon}
alt="App icon"
className="w-3 h-3 flex-shrink-0"
/>
)}
<div className="text-xs font-medium text-blue-700 truncate">{source.name}</div>
</div>
</div>
</Card>
))}
</div>
</TabsContent>
</div>
</Tabs>
</div>
<div className="border-t border-blue-100 p-3">
<div className="flex justify-center gap-3">
<Button variant="outline" onClick={() => window.close()} className="px-6 py-1.5 text-sm bg-blue-600 border-blue-600 text-white hover:bg-blue-700">Cancel</Button>
<Button onClick={handleShare} disabled={!selectedSource} className="px-6 py-1.5 text-sm bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50 disabled:bg-blue-300">Share</Button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,72 @@
import { Button } from "../ui/button";
import { MdPlayArrow, MdPause } from "react-icons/md";
interface PlaybackControlsProps {
isPlaying: boolean;
currentTime: number;
duration: number;
onTogglePlayPause: () => void;
onSeek: (time: number) => void;
onSeekStart: () => void;
onSeekEnd: () => void;
}
export default function PlaybackControls({
isPlaying,
currentTime,
duration,
onTogglePlayPause,
onSeek,
onSeekStart,
onSeekEnd,
}: PlaybackControlsProps) {
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')}`;
}
function handleSeekChange(e: React.ChangeEvent<HTMLInputElement>) {
onSeek(parseFloat(e.target.value));
}
return (
<div className="flex items-center gap-4 px-4">
<Button
onClick={onTogglePlayPause}
size="icon"
className="w-8 h-8 rounded-full bg-primary text-primary-foreground hover:bg-primary/90 transition-colors shadow-md"
aria-label={isPlaying ? 'Pause' : 'Play'}
>
{isPlaying ? (
<MdPause width={18} height={18} />
) : (
<MdPlayArrow width={18} height={18} />
)}
</Button>
<span className="text-xs text-muted-foreground font-mono">
{formatTime(currentTime)}
</span>
<input
type="range"
min="0"
max={duration}
value={currentTime}
onChange={handleSeekChange}
onMouseDown={onSeekStart}
onMouseUp={onSeekEnd}
onTouchStart={onSeekStart}
onTouchEnd={onSeekEnd}
step="0.01"
className="flex-1 h-2 accent-blue-500 rounded-full"
style={{
background: `linear-gradient(to right, #3b82f6 0%, #3b82f6 ${(currentTime / duration) * 100}%, #e5e7eb ${(currentTime / duration) * 100}%, #e5e7eb 100%)`
}}
/>
<span className="text-xs text-muted-foreground font-mono">
{formatTime(duration)}
</span>
</div>
);
}
@@ -0,0 +1,9 @@
export default function SettingsPanel() {
return (
<div className="flex-[3] min-w-0 bg-card border border-border rounded-xl p-8 flex flex-col shadow-sm">
<div className="flex-1 w-full flex items-center justify-center text-muted-foreground text-base">
Settings
</div>
</div>
);
}
@@ -0,0 +1,9 @@
export default function TimelineEditor() {
return (
<div className="bg-card border border-border rounded-xl p-8 flex-1 min-h-[180px] flex flex-col justify-center shadow-sm">
<div className="h-12 rounded-lg flex items-center justify-center text-muted-foreground text-sm">
Timeline
</div>
</div>
);
}
+106
View File
@@ -0,0 +1,106 @@
import { useEffect, useRef, useState } from "react";
import VideoPlayback, { VideoPlaybackRef } from "./VideoPlayback";
import PlaybackControls from "./PlaybackControls";
import TimelineEditor from "./TimelineEditor";
import SettingsPanel from "./SettingsPanel";
export default function VideoEditor() {
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);
const videoPlaybackRef = useRef<VideoPlaybackRef>(null);
const isSeeking = useRef(false);
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();
}, []);
function togglePlayPause() {
const video = videoPlaybackRef.current?.video;
if (!video) return;
isPlaying ? video.pause() : video.play();
}
function handleSeek(time: number) {
const video = videoPlaybackRef.current?.video;
if (!video) return;
video.currentTime = time;
setCurrentTime(time);
}
function handleSeekStart() {
isSeeking.current = true;
}
function handleSeekEnd() {
isSeeking.current = false;
}
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>
);
}
return (
<div className="flex h-screen bg-background p-8 gap-8">
<div className="flex flex-col flex-[7] min-w-0 gap-8">
<div className="flex flex-col gap-6 flex-1">
{videoPath && (
<>
<VideoPlayback
ref={videoPlaybackRef}
videoPath={videoPath}
isSeeking={isSeeking}
onDurationChange={setDuration}
onTimeUpdate={setCurrentTime}
onPlayStateChange={setIsPlaying}
onError={setError}
/>
<PlaybackControls
isPlaying={isPlaying}
currentTime={currentTime}
duration={duration}
onTogglePlayPause={togglePlayPause}
onSeek={handleSeek}
onSeekStart={handleSeekStart}
onSeekEnd={handleSeekEnd}
/>
</>
)}
</div>
<TimelineEditor />
</div>
<SettingsPanel />
</div>
);
}
@@ -0,0 +1,106 @@
import { useEffect, useRef, useImperativeHandle, forwardRef } from "react";
interface VideoPlaybackProps {
videoPath: string;
isSeeking: React.MutableRefObject<boolean>;
onDurationChange: (duration: number) => void;
onTimeUpdate: (time: number) => void;
onPlayStateChange: (playing: boolean) => void;
onError: (error: string) => void;
}
export interface VideoPlaybackRef {
video: HTMLVideoElement | null;
}
const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(({
videoPath,
isSeeking,
onDurationChange,
onTimeUpdate,
onPlayStateChange,
onError,
}, ref) => {
const videoRef = useRef<HTMLVideoElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
useImperativeHandle(ref, () => ({
video: videoRef.current,
}));
useEffect(() => {
const video = videoRef.current;
const canvas = canvasRef.current;
if (!video || !canvas) return;
let animationId: number;
function drawFrame() {
if (!video || !canvas) return;
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);
}
}
function drawFrameLoop() {
if (!video || !canvas || video.paused || video.ended) return;
drawFrame();
animationId = requestAnimationFrame(drawFrameLoop);
}
const handlePlay = () => drawFrameLoop();
const handlePause = () => cancelAnimationFrame(animationId);
const handleSeeked = () => {
drawFrame();
};
video.addEventListener('play', handlePlay);
video.addEventListener('pause', handlePause);
video.addEventListener('ended', handlePause);
video.addEventListener('seeked', handleSeeked);
return () => {
video.removeEventListener('play', handlePlay);
video.removeEventListener('pause', handlePause);
video.removeEventListener('ended', handlePause);
video.removeEventListener('seeked', handleSeeked);
cancelAnimationFrame(animationId);
};
}, [videoPath]);
return (
<div
className="w-full aspect-video rounded-xl p-8 flex items-center justify-center overflow-hidden bg-cover bg-center"
style={{ backgroundImage: 'url(/wallpaper.png)' }}
>
<canvas
ref={canvasRef}
className="w-full h-full object-contain rounded-lg"
/>
<video
ref={videoRef}
src={videoPath}
className="hidden"
preload="metadata"
onLoadedMetadata={e => {
onDurationChange(e.currentTarget.duration);
}}
onDurationChange={e => {
onDurationChange(e.currentTarget.duration);
}}
onTimeUpdate={e => {
if (!isSeeking.current) onTimeUpdate(e.currentTarget.currentTime);
}}
onError={() => onError('Failed to load video')}
onPlay={() => onPlayStateChange(true)}
onPause={() => onPlayStateChange(false)}
onEnded={() => onPlayStateChange(false)}
/>
</div>
);
});
VideoPlayback.displayName = 'VideoPlayback';
export default VideoPlayback;
+5
View File
@@ -0,0 +1,5 @@
export { default as VideoEditor } from './VideoEditor';
export { default as VideoPlayback } from './VideoPlayback';
export { default as PlaybackControls } from './PlaybackControls';
export { default as TimelineEditor } from './TimelineEditor';
export { default as SettingsPanel } from './SettingsPanel';