diff --git a/src/mainview/App.tsx b/src/mainview/App.tsx index 06a51ac..bad58d3 100644 --- a/src/mainview/App.tsx +++ b/src/mainview/App.tsx @@ -1,56 +1,34 @@ +import { useLayoutEffect, useRef, useState } from "react"; import { useVideoEditor } from "./hooks/useVideoEditor"; import { Header } from "./components/Header"; import { DownloadPage } from "./components/DownloadPage"; import { LandingPage } from "./components/LandingPage"; import { EditorPage } from "./components/EditorPage"; -import { OUTPUT_FORMATS } from "./constants"; +import { VideoEditorProvider } from "./context/VideoEditorContext"; const App = () => { + const editorState = useVideoEditor(); const { videoSrc, - position, - setPosition, - isPlaying, - setIsPlaying, - start, - setStart, - end, - setEnd, - videoDuration, - reencode, - setReencode, - outputFormat, - setOutputFormat, - bitrate, - setBitrate, - clipDuration, - outputWidth, - setOutputWidth, - outputHeight, - setOutputHeight, - originalWidth, - originalHeight, - lockAspectRatio, - setLockAspectRatio, - originalFps, - outputFps, - setOutputFps, - useNativeExport, - hasHwAccSupport, - hwAcc, - setHwAcc, - exporting, - exportProgress, - exportError, - exportSuccess, currentPage, setCurrentPage, handleFileSelect, handleNativeBrowse, handleChangeVideo, - exportVideo, - isConverting, - } = useVideoEditor(); + } = editorState; + + const cardRef = useRef(null); + const [cardHeight, setCardHeight] = useState(undefined); + + useLayoutEffect(() => { + const element = cardRef.current; + if (element) { + const newHeight = element.offsetHeight; + if (newHeight !== cardHeight) { + setCardHeight(newHeight); + } + } + }); return (
@@ -70,65 +48,33 @@ const App = () => { {currentPage === "download" || !videoSrc ? (
- {currentPage === "download" ? ( - setCurrentPage("editor")} /> - ) : ( - setCurrentPage("download")} - /> - )} +
+ {currentPage === "download" ? ( + setCurrentPage("editor")} /> + ) : ( + setCurrentPage("download")} + /> + )} +
) : ( - + + + )}
); diff --git a/src/mainview/components/EditorPage.tsx b/src/mainview/components/EditorPage.tsx index 78111f7..f598816 100644 --- a/src/mainview/components/EditorPage.tsx +++ b/src/mainview/components/EditorPage.tsx @@ -3,166 +3,24 @@ import { Timeline } from "./Timeline"; import { EncoderSettings } from "./EncoderSettings"; import { ExportPanel } from "./ExportPanel"; -interface OutputFormat { - readonly label: string; - readonly ext: string; - readonly mime: string; -} - -interface EditorPageProps { - // Video & playback - videoSrc: string; - position: number; - setPosition: (value: number) => void; - isPlaying: boolean; - setIsPlaying: (value: boolean) => void; - start: number; - setStart: (value: number) => void; - end: number; - setEnd: (value: number) => void; - videoDuration: number; - - // Encoder settings - reencode: boolean; - setReencode: (value: boolean) => void; - outputFormat: string; - setOutputFormat: (value: string) => void; - outputFormats: readonly OutputFormat[]; - isConverting: boolean; - bitrate: number; - setBitrate: (value: number) => void; - clipDuration: number; - outputWidth: number; - setOutputWidth: (value: number) => void; - outputHeight: number; - setOutputHeight: (value: number) => void; - originalWidth: number; - originalHeight: number; - lockAspectRatio: boolean; - setLockAspectRatio: (value: boolean) => void; - originalFps: number | null; - outputFps: number | null; - setOutputFps: (value: number | null) => void; - useNativeExport: boolean; - hasHwAccSupport: boolean; - hwAcc: boolean; - setHwAcc: (value: boolean) => void; - - // Export - exporting: boolean; - exportProgress: number; - exportError: string | null; - exportSuccess: string | null; - onExport: () => void; -} - -export const EditorPage = ({ - videoSrc, - position, - setPosition, - isPlaying, - setIsPlaying, - start, - setStart, - end, - setEnd, - videoDuration, - reencode, - setReencode, - outputFormat, - setOutputFormat, - outputFormats, - isConverting, - bitrate, - setBitrate, - clipDuration, - outputWidth, - setOutputWidth, - outputHeight, - setOutputHeight, - originalWidth, - originalHeight, - lockAspectRatio, - setLockAspectRatio, - originalFps, - outputFps, - setOutputFps, - useNativeExport, - hasHwAccSupport, - hwAcc, - setHwAcc, - exporting, - exportProgress, - exportError, - exportSuccess, - onExport, -}: EditorPageProps) => ( +export const EditorPage = () => (
{/* Left column: Player + Timeline */}
- +

Timeline & Trim Controls

- +
{/* Right column: Encoder & Export Settings Sidebar */}
- - - + +
); diff --git a/src/mainview/components/EncoderSettings.tsx b/src/mainview/components/EncoderSettings.tsx index ae2eb7f..5652f72 100644 --- a/src/mainview/components/EncoderSettings.tsx +++ b/src/mainview/components/EncoderSettings.tsx @@ -1,244 +1,216 @@ +import { useVideoEditorContext } from "../context/VideoEditorContext"; import { ToggleSwitch } from "./ToggleSwitch"; import { BitrateHandler } from "./BitrateHandler"; +import { OUTPUT_FORMATS } from "../constants"; -interface OutputFormat { - readonly label: string; - readonly ext: string; - readonly mime: string; -} +export const EncoderSettings = () => { + const { + reencode, + setReencode, + outputFormat, + setOutputFormat, + isConverting, + bitrate, + setBitrate, + clipDuration, + outputWidth, + setOutputWidth, + outputHeight, + setOutputHeight, + originalWidth, + originalHeight, + lockAspectRatio, + setLockAspectRatio, + originalFps, + outputFps, + setOutputFps, + useNativeExport, + hasHwAccSupport, + hwAcc, + setHwAcc, + } = useVideoEditorContext(); -interface EncoderSettingsProps { - reencode: boolean; - setReencode: (value: boolean) => void; - outputFormat: string; - setOutputFormat: (value: string) => void; - outputFormats: readonly OutputFormat[]; - isConverting: boolean; - bitrate: number; - setBitrate: (value: number) => void; - clipDuration: number; - outputWidth: number; - setOutputWidth: (value: number) => void; - outputHeight: number; - setOutputHeight: (value: number) => void; - originalWidth: number; - originalHeight: number; - lockAspectRatio: boolean; - setLockAspectRatio: (value: boolean) => void; - originalFps: number | null; - outputFps: number | null; - setOutputFps: (value: number | null) => void; - useNativeExport: boolean; - hasHwAccSupport: boolean; - hwAcc: boolean; - setHwAcc: (value: boolean) => void; -} + return ( +
+

+ Encoder Settings +

-export const EncoderSettings = ({ - reencode, - setReencode, - outputFormat, - setOutputFormat, - outputFormats, - isConverting, - bitrate, - setBitrate, - clipDuration, - outputWidth, - setOutputWidth, - outputHeight, - setOutputHeight, - originalWidth, - originalHeight, - lockAspectRatio, - setLockAspectRatio, - originalFps, - outputFps, - setOutputFps, - useNativeExport, - hasHwAccSupport, - hwAcc, - setHwAcc, -}: EncoderSettingsProps) => ( -
-

- Encoder Settings -

- - {/* Re-encode toggle */} -
-
- Re-encode Video - Required for custom settings -
- setReencode(!reencode)} - /> -
- - {/* Hardware Acceleration toggle (desktop standalone only) */} - {useNativeExport && ( -
+ {/* Re-encode toggle */} +
- Hardware Acceleration - - {hasHwAccSupport ? "Speeds up encoding using GPU" : "No compatible GPU encoder detected"} - + Re-encode Video + Required for custom settings
setHwAcc(!hwAcc)} - disabled={!hasHwAccSupport} + checked={reencode} + onChange={() => setReencode(!reencode)} />
- )} - {/* Output format selector */} -
- -
- -
- ▼ + {/* Hardware Acceleration toggle (desktop standalone only) */} + {useNativeExport && ( +
+
+ Hardware Acceleration + + {hasHwAccSupport ? "Speeds up encoding using GPU" : "No compatible GPU encoder detected"} + +
+ setHwAcc(!hwAcc)} + disabled={!hasHwAccSupport} + />
-
- {isConverting && ( -

- Format conversion requires re-encoding -

)} -
- {/* Bitrate controls */} -
- -
+ {/* Output format selector */} +
+ +
+ +
+ ▼ +
+
+ {isConverting && ( +

+ Format conversion requires re-encoding +

+ )} +
- {/* Resolution controls */} -
- -
- { - const w = parseInt(e.target.value) || originalWidth; - setOutputWidth(w); - if (lockAspectRatio && originalWidth > 0) { - setOutputHeight(Math.round(w * (originalHeight / originalWidth))); - } - }} + {/* Bitrate controls */} +
+ - × - { - const h = parseInt(e.target.value) || originalHeight; - setOutputHeight(h); - if (lockAspectRatio && originalHeight > 0) { - setOutputWidth(Math.round(h * (originalWidth / originalHeight))); - } - }} - disabled={!reencode} - className="w-full border border-mocha-surface2 rounded-lg px-2.5 py-1.5 text-xs text-mocha-text bg-mocha-surface0 focus:outline-none focus:ring-2 focus:ring-mocha-mauve disabled:opacity-50" - /> - -
-

- Original: {originalWidth}×{originalHeight} -

-
- {/* Framerate control */} -
- -
- {originalFps === null ? ( -
- - - - - Detecting... -
- ) : ( - <> - setOutputFps(Math.max(1, parseInt(e.target.value) || originalFps || 30))} - disabled={!reencode} - className="w-full border border-mocha-surface2 rounded-lg px-2.5 py-1.5 text-xs text-mocha-text bg-mocha-surface0 focus:outline-none focus:ring-2 focus:ring-mocha-mauve disabled:opacity-50" - /> - fps - - - )} + {/* Resolution controls */} +
+ +
+ { + const w = parseInt(e.target.value) || originalWidth; + setOutputWidth(w); + if (lockAspectRatio && originalWidth > 0) { + setOutputHeight(Math.round(w * (originalHeight / originalWidth))); + } + }} + disabled={!reencode} + className="w-full border border-mocha-surface2 rounded-lg px-2.5 py-1.5 text-xs text-mocha-text bg-mocha-surface0 focus:outline-none focus:ring-2 focus:ring-mocha-mauve disabled:opacity-50" + /> + × + { + const h = parseInt(e.target.value) || originalHeight; + setOutputHeight(h); + if (lockAspectRatio && originalHeight > 0) { + setOutputWidth(Math.round(h * (originalWidth / originalHeight))); + } + }} + disabled={!reencode} + className="w-full border border-mocha-surface2 rounded-lg px-2.5 py-1.5 text-xs text-mocha-text bg-mocha-surface0 focus:outline-none focus:ring-2 focus:ring-mocha-mauve disabled:opacity-50" + /> + + +
+

+ Original: {originalWidth}×{originalHeight} +

+
+ + {/* Framerate control */} +
+ +
+ {originalFps === null ? ( +
+ + + + + Detecting... +
+ ) : ( + <> + setOutputFps(Math.max(1, parseInt(e.target.value) || originalFps || 30))} + disabled={!reencode} + className="w-full border border-mocha-surface2 rounded-lg px-2.5 py-1.5 text-xs text-mocha-text bg-mocha-surface0 focus:outline-none focus:ring-2 focus:ring-mocha-mauve disabled:opacity-50" + /> + fps + + + )} +
+

+ Original: {originalFps !== null ? `${originalFps} fps` : "detecting..."} +

-

- Original: {originalFps !== null ? `${originalFps} fps` : "detecting..."} -

-
-); + ); +}; diff --git a/src/mainview/components/ExportPanel.tsx b/src/mainview/components/ExportPanel.tsx index 28bf368..2e9829c 100644 --- a/src/mainview/components/ExportPanel.tsx +++ b/src/mainview/components/ExportPanel.tsx @@ -1,79 +1,72 @@ -interface ExportPanelProps { - start: number; - end: number; - videoDuration: number; - outputFormat: string; - exporting: boolean; - exportProgress: number; - exportError: string | null; - exportSuccess: string | null; - useNativeExport: boolean; - onExport: () => void; -} +import { useVideoEditorContext } from "../context/VideoEditorContext"; -export const ExportPanel = ({ - start, - end, - videoDuration, - outputFormat, - exporting, - exportProgress, - exportError, - exportSuccess, - useNativeExport, - onExport, -}: ExportPanelProps) => ( -
-

- Export -

- -

- Trim range: {((start / 1000) * videoDuration).toFixed(2)}s to {((end / 1000) * videoDuration).toFixed(2)}s -

+export const ExportPanel = () => { + const { + start, + end, + videoDuration, + outputFormat, + exporting, + exportProgress, + exportError, + exportSuccess, + useNativeExport, + exportVideo, + } = useVideoEditorContext(); - {/* Export error */} - {exportError && ( -
- ⚠️ {exportError} -
- )} + return ( +
+

+ Export +

+ +

+ Trim range: {((start / 1000) * videoDuration).toFixed(2)}s to {((end / 1000) * videoDuration).toFixed(2)}s +

- {/* Export success */} - {exportSuccess && ( -
- ✅ {exportSuccess} -
- )} + {/* Export error */} + {exportError && ( +
+ ⚠️ {exportError} +
+ )} - {/* Progress bar */} - {exporting && ( -
-
-
+ {/* Export success */} + {exportSuccess && ( +
+ ✅ {exportSuccess}
-

- Exporting: {Math.round(exportProgress * 100)}% -

-
- )} + )} - + {/* Progress bar */} + {exporting && ( +
+
+
+
+

+ Exporting: {Math.round(exportProgress * 100)}% +

+
+ )} - {/* Footer: runtime info */} - {useNativeExport && ( -

- ⚡ Running native hardware-accelerated FFmpeg -

- )} -
-); + + + {/* Footer: runtime info */} + {useNativeExport && ( +

+ ⚡ Running native FFmpeg +

+ )} +
+ ); +}; diff --git a/src/mainview/components/Player.tsx b/src/mainview/components/Player.tsx index 8d1b8b3..36d643f 100644 --- a/src/mainview/components/Player.tsx +++ b/src/mainview/components/Player.tsx @@ -1,15 +1,19 @@ import { useCallback, useEffect, useRef, useState } from "react"; +import { useVideoEditorContext } from "../context/VideoEditorContext"; + +export const Player = () => { + const { + videoSrc, + position, + setPosition, + isPlaying, + setIsPlaying, + start, + setStart, + end, + videoDuration, + } = useVideoEditorContext(); -interface PlayerProps { - videoSrc: string; - positionState: { position: number; setPosition: (time: number) => void }; - isPlayingState: { isPlaying: boolean; setIsPlaying: (playing: boolean) => void }; - startState: { start: number; setStart: (time: number) => void }; - endState: { end: number; setEnd: (time: number) => void }; - videoDuration: number; -} - -export const Player = ({ videoSrc, positionState, isPlayingState, startState, endState, videoDuration }: PlayerProps) => { const videoRef = useRef(null); const [volume, setVolume] = useState(1); const [isMuted, setIsMuted] = useState(false); @@ -21,13 +25,13 @@ export const Player = ({ videoSrc, positionState, isPlayingState, startState, en if (!video.paused) { video.pause(); } else { - if (Math.ceil(positionState.position) >= endState.end) { - positionState.setPosition(startState.start); - video.currentTime = (startState.start / 1000) * (video.duration || 0); + if (Math.ceil(position) >= end) { + setPosition(start); + video.currentTime = (start / 1000) * (video.duration || 0); } video.play().catch((err) => console.error("Playback error:", err)); } - }, [positionState.position, endState.end, startState.start]); + }, [position, end, start, setPosition]); useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { @@ -44,22 +48,22 @@ export const Player = ({ videoSrc, positionState, isPlayingState, startState, en const video = videoRef.current; if (!video) return; - if (isPlayingState.isPlaying && video.paused) { + if (isPlaying && video.paused) { video.play().catch(() => {}); - } else if (!isPlayingState.isPlaying && !video.paused) { + } else if (!isPlaying && !video.paused) { video.pause(); } - }, [isPlayingState.isPlaying]); + }, [isPlaying]); useEffect(() => { const video = videoRef.current; if (!video || isNaN(video.duration)) return; - const newTime = (positionState.position / 1000) * video.duration; + const newTime = (position / 1000) * video.duration; if (Math.abs(video.currentTime - newTime) > 0.1) { video.currentTime = newTime; } - }, [positionState.position]); + }, [position]); useEffect(() => { const video = videoRef.current; @@ -72,7 +76,7 @@ export const Player = ({ videoSrc, positionState, isPlayingState, startState, en const video = videoRef.current; if (!video) return; const progress = (video.currentTime / video.duration) * 1000; - positionState.setPosition(progress); + setPosition(progress); }; const formatTimecode = (seconds: number) => { @@ -83,7 +87,7 @@ export const Player = ({ videoSrc, positionState, isPlayingState, startState, en return `${mins.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}.${ms.toString().padStart(2, "0")}`; }; - const currentSeconds = (positionState.position / 1000) * (videoRef.current?.duration || videoDuration); + const currentSeconds = (position / 1000) * (videoRef.current?.duration || videoDuration); const durationSeconds = videoRef.current?.duration || videoDuration; return ( @@ -92,24 +96,13 @@ export const Player = ({ videoSrc, positionState, isPlayingState, startState, en
{/* Playback Controls Toolbar */} @@ -120,8 +113,8 @@ export const Player = ({ videoSrc, positionState, isPlayingState, startState, en onClick={() => { const video = videoRef.current; if (video) { - video.currentTime = (startState.start / 1000) * video.duration; - positionState.setPosition(startState.start); + video.currentTime = (start / 1000) * video.duration; + setPosition(start); } }} className="p-2 rounded-lg hover:bg-mocha-surface0 text-mocha-text active:scale-95 transition-all text-sm" @@ -134,7 +127,7 @@ export const Player = ({ videoSrc, positionState, isPlayingState, startState, en const video = videoRef.current; if (video) { video.currentTime = Math.max(0, video.currentTime - 0.1); - positionState.setPosition((video.currentTime / video.duration) * 1000); + setPosition((video.currentTime / video.duration) * 1000); } }} className="p-2 rounded-lg hover:bg-mocha-surface0 text-mocha-text active:scale-95 transition-all text-sm" @@ -147,7 +140,7 @@ export const Player = ({ videoSrc, positionState, isPlayingState, startState, en className="px-4 py-2 rounded-lg bg-mocha-mauve text-mocha-crust font-bold hover:brightness-110 active:scale-95 transition-all text-xs flex items-center gap-1.5 shadow-md" title="Play / Pause (Space)" > - {isPlayingState.isPlaying ? ( + {isPlaying ? ( <> ❚❚ Pause @@ -164,7 +157,7 @@ export const Player = ({ videoSrc, positionState, isPlayingState, startState, en const video = videoRef.current; if (video) { video.currentTime = Math.min(video.duration, video.currentTime + 0.1); - positionState.setPosition((video.currentTime / video.duration) * 1000); + setPosition((video.currentTime / video.duration) * 1000); } }} className="p-2 rounded-lg hover:bg-mocha-surface0 text-mocha-text active:scale-95 transition-all text-sm" @@ -176,8 +169,8 @@ export const Player = ({ videoSrc, positionState, isPlayingState, startState, en onClick={() => { const video = videoRef.current; if (video) { - video.currentTime = (endState.end / 1000) * video.duration; - positionState.setPosition(endState.end); + video.currentTime = (end / 1000) * video.duration; + setPosition(end); } }} className="p-2 rounded-lg hover:bg-mocha-surface0 text-mocha-text active:scale-95 transition-all text-sm" diff --git a/src/mainview/components/Timeline.tsx b/src/mainview/components/Timeline.tsx index 75dc684..c42dbb1 100644 --- a/src/mainview/components/Timeline.tsx +++ b/src/mainview/components/Timeline.tsx @@ -1,13 +1,17 @@ import { useRef, useState, useEffect } from "react"; +import { useVideoEditorContext } from "../context/VideoEditorContext"; + +export const Timeline = () => { + const { + start, + setStart, + end, + setEnd, + position, + setPosition, + videoDuration, + } = useVideoEditorContext(); -interface TimelineProps { - startState: { start: number; setStart: (time: number) => void }; - endState: { end: number; setEnd: (time: number) => void }; - positionState: { position: number; setPosition: (time: number) => void }; - videoDuration: number; -} - -export const Timeline = ({ startState, endState, positionState, videoDuration }: TimelineProps) => { const timelineRef = useRef(null); const [isDragging, setIsDragging] = useState<"start" | "end" | "playhead" | null>(null); @@ -26,7 +30,7 @@ export const Timeline = ({ startState, endState, positionState, videoDuration }: setIsDragging(type); if (type === "playhead") { - positionState.setPosition(getValFromEvent(e)); + setPosition(getValFromEvent(e)); } }; @@ -36,19 +40,19 @@ export const Timeline = ({ startState, endState, positionState, videoDuration }: const handleMouseMove = (e: MouseEvent) => { const val = getValFromEvent(e); if (isDragging === "playhead") { - positionState.setPosition(val); + setPosition(val); } else if (isDragging === "start") { - if (val <= endState.end) { - startState.setStart(val); - if (positionState.position < val) { - positionState.setPosition(val); + if (val <= end) { + setStart(val); + if (position < val) { + setPosition(val); } } } else if (isDragging === "end") { - if (val >= startState.start) { - endState.setEnd(val); - if (positionState.position > val) { - positionState.setPosition(val); + if (val >= start) { + setEnd(val); + if (position > val) { + setPosition(val); } } } @@ -65,17 +69,17 @@ export const Timeline = ({ startState, endState, positionState, videoDuration }: window.removeEventListener("mousemove", handleMouseMove); window.removeEventListener("mouseup", handleMouseUp); }; - }, [isDragging, startState, endState, positionState]); + }, [isDragging, start, end, position, setStart, setEnd, setPosition]); const formatTime = (val: number) => { const seconds = (val / 1000) * videoDuration; return `${seconds.toFixed(2)}s`; }; - const startPct = (startState.start / 1000) * 100; - const endPct = (endState.end / 1000) * 100; - const widthPct = ((endState.end - startState.start) / 1000) * 100; - const posPct = (positionState.position / 1000) * 100; + const startPct = (start / 1000) * 100; + const endPct = (end / 1000) * 100; + const widthPct = ((end - start) / 1000) * 100; + const posPct = (position / 1000) * 100; const renderTicks = () => { const ticks = []; @@ -146,7 +150,7 @@ export const Timeline = ({ startState, endState, positionState, videoDuration }: {/* Label showing Clip Info */} - ✂️ ACTIVE CLIP ({formatTime(startState.start)} - {formatTime(endState.end)}) + ✂️ ACTIVE CLIP ({formatTime(start)} - {formatTime(end)}) {/* Right trim handle */} diff --git a/src/mainview/context/VideoEditorContext.tsx b/src/mainview/context/VideoEditorContext.tsx new file mode 100644 index 0000000..5618037 --- /dev/null +++ b/src/mainview/context/VideoEditorContext.tsx @@ -0,0 +1,25 @@ +import { createContext, useContext, ReactNode } from "react"; +import { useVideoEditor } from "../hooks/useVideoEditor"; + +type VideoEditorContextType = ReturnType; + +const VideoEditorContext = createContext(null); + +interface VideoEditorProviderProps { + value: VideoEditorContextType; + children: ReactNode; +} + +export const VideoEditorProvider = ({ value, children }: VideoEditorProviderProps) => ( + + {children} + +); + +export const useVideoEditorContext = () => { + const context = useContext(VideoEditorContext); + if (!context) { + throw new Error("useVideoEditorContext must be used within a VideoEditorProvider"); + } + return context; +};