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
+20 -26
View File
@@ -1,40 +1,34 @@
import { LaunchWindow } from "./components/LaunchWindow";
import { SourceSelector } from "./components/SourceSelector";
import VideoEditor from "./components/VideoEditor";
import { useEffect, useState } from "react";
import { LaunchWindow } from "./components/launch/LaunchWindow";
import { SourceSelector } from "./components/launch/SourceSelector";
import VideoEditor from "./components/video-editor/VideoEditor";
export default function App() {
const [windowType, setWindowType] = useState<string>('');
const [windowType, setWindowType] = useState('');
useEffect(() => {
const urlParams = new URLSearchParams(window.location.search);
const type = urlParams.get('windowType') || 'default';
const params = new URLSearchParams(window.location.search);
const type = params.get('windowType') || '';
setWindowType(type);
// Apply transparency only for HUD overlay windows
if (type === 'hud-overlay') {
document.body.style.background = 'transparent';
document.documentElement.style.background = 'transparent';
const root = document.getElementById('root');
if (root) root.style.background = 'transparent';
document.getElementById('root')?.style.setProperty('background', 'transparent');
}
}, []);
if (windowType === 'hud-overlay') {
return <LaunchWindow />;
switch (windowType) {
case 'hud-overlay':
return <LaunchWindow />;
case 'source-selector':
return <SourceSelector />;
case 'editor':
return <VideoEditor />;
default:
return (
<div className="w-full h-full bg-background text-foreground">
<h1>Pangolin</h1>
</div>
);
}
if (windowType === 'source-selector') {
return <SourceSelector />;
}
if (windowType === 'editor') {
return <VideoEditor />;
}
return (
<div className="w-full h-full bg-background text-foreground">
<h1>Pangolin</h1>
</div>
);
}
-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';
+38 -76
View File
@@ -8,19 +8,19 @@ type UseScreenRecorderReturn = {
export function useScreenRecorder(): UseScreenRecorderReturn {
const [recording, setRecording] = useState(false);
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const streamRef = useRef<MediaStream | null>(null);
const chunksRef = useRef<Blob[]>([]);
const startTimeRef = useRef<number>(0);
const mediaRecorder = useRef<MediaRecorder | null>(null);
const stream = useRef<MediaStream | null>(null);
const chunks = useRef<Blob[]>([]);
const startTime = useRef<number>(0);
useEffect(() => {
return () => {
if (mediaRecorderRef.current?.state === "recording") {
mediaRecorderRef.current.stop();
if (mediaRecorder.current?.state === "recording") {
mediaRecorder.current.stop();
}
if (streamRef.current) {
streamRef.current.getTracks().forEach((track) => track.stop());
streamRef.current = null;
if (stream.current) {
stream.current.getTracks().forEach(track => track.stop());
stream.current = null;
}
};
}, []);
@@ -28,15 +28,12 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
const startRecording = async () => {
try {
const selectedSource = await window.electronAPI.getSelectedSource();
if (!selectedSource) {
alert("Please select a source to record");
return;
}
await window.electronAPI.startMouseTracking();
const stream = await (navigator.mediaDevices as any).getUserMedia({
const mediaStream = await (navigator.mediaDevices as any).getUserMedia({
audio: false,
video: {
mandatory: {
@@ -45,113 +42,78 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
},
},
});
streamRef.current = stream;
if (!streamRef.current) {
throw new Error("Failed to get media stream");
stream.current = mediaStream;
if (!stream.current) {
throw new Error("Media stream is not available.");
}
const videoTrack = streamRef.current.getVideoTracks()[0];
const settings = videoTrack.getSettings();
const width = settings.width || 1920;
const height = settings.height || 1080;
const videoTrack = stream.current.getVideoTracks()[0];
const { width = 1920, height = 1080 } = videoTrack.getSettings();
const totalPixels = width * height;
let bitrate: number;
if (totalPixels <= 1920 * 1080) {
bitrate = 150_000_000;
} else if (totalPixels <= 2560 * 1440) {
let bitrate = 150_000_000;
if (totalPixels > 1920 * 1080 && totalPixels <= 2560 * 1440) {
bitrate = 250_000_000;
} else {
} else if (totalPixels > 2560 * 1440) {
bitrate = 400_000_000;
}
chunksRef.current = [];
chunks.current = [];
const mimeType = "video/webm;codecs=vp9";
const recorder = new MediaRecorder(streamRef.current, {
mimeType,
videoBitsPerSecond: bitrate,
});
mediaRecorderRef.current = recorder;
recorder.ondataavailable = (e) => {
if (e.data && e.data.size > 0) {
chunksRef.current.push(e.data);
}
const recorder = new MediaRecorder(stream.current, { mimeType, videoBitsPerSecond: bitrate });
mediaRecorder.current = recorder;
recorder.ondataavailable = e => {
if (e.data && e.data.size > 0) chunks.current.push(e.data);
};
recorder.onstop = async () => {
streamRef.current = null;
if (chunksRef.current.length === 0) return;
const duration = Date.now() - startTimeRef.current;
const buggyBlob = new Blob(chunksRef.current, { type: mimeType });
stream.current = null;
if (chunks.current.length === 0) return;
const duration = Date.now() - startTime.current;
const buggyBlob = new Blob(chunks.current, { type: mimeType });
const timestamp = Date.now();
const videoFileName = `recording-${timestamp}.webm`;
const trackingFileName = `recording-${timestamp}_tracking.json`;
try {
const videoBlob = await fixWebmDuration(buggyBlob, duration);
const arrayBuffer = await videoBlob.arrayBuffer();
const videoResult = await window.electronAPI.storeRecordedVideo(
arrayBuffer,
videoFileName
);
const videoResult = await window.electronAPI.storeRecordedVideo(arrayBuffer, videoFileName);
if (!videoResult.success) {
console.error('Failed to store video:', videoResult.message);
return;
}
const trackingResult = await window.electronAPI.storeMouseTrackingData(trackingFileName);
if (!trackingResult.success) {
console.warn('Failed to store mouse tracking:', trackingResult.message);
}
await window.electronAPI.switchToEditor();
} catch (error) {
console.error('Error saving recording:', error);
}
};
recorder.onerror = () => {
setRecording(false);
};
recorder.onerror = () => setRecording(false);
recorder.start(1000);
startTimeRef.current = Date.now();
startTime.current = Date.now();
setRecording(true);
} catch (error) {
console.error('Failed to start recording:', error);
setRecording(false);
if (streamRef.current) {
streamRef.current.getTracks().forEach((track) => track.stop());
streamRef.current = null;
if (stream.current) {
stream.current.getTracks().forEach(track => track.stop());
stream.current = null;
}
}
};
const stopRecording = () => {
if (mediaRecorderRef.current?.state === "recording") {
if (streamRef.current) {
streamRef.current.getTracks().forEach((track) => track.stop());
if (mediaRecorder.current?.state === "recording") {
if (stream.current) {
stream.current.getTracks().forEach(track => track.stop());
}
mediaRecorderRef.current.stop();
mediaRecorder.current.stop();
setRecording(false);
window.electronAPI.stopMouseTracking();
}
};
const toggleRecording = () => {
if (!recording) {
startRecording();
} else {
stopRecording();
}
recording ? stopRecording() : startRecording();
};
return { recording, toggleRecording };