diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index 32f3f102..252f4d76 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -19,6 +19,7 @@ import { RxDragHandleDots2 } from "react-icons/rx"; import { useI18n, useScopedT } from "@/contexts/I18nContext"; import { type Locale, SUPPORTED_LOCALES } from "@/i18n/config"; import { getLocaleName } from "@/i18n/loader"; +import { isMac as getIsMac } from "@/utils/platformUtils"; import { useAudioLevelMeter } from "../../hooks/useAudioLevelMeter"; import { useMicrophoneDevices } from "../../hooks/useMicrophoneDevices"; import { useScreenRecorder } from "../../hooks/useScreenRecorder"; @@ -67,6 +68,11 @@ const windowBtnClasses = export function LaunchWindow() { const t = useScopedT("launch"); const { locale, setLocale } = useI18n(); + const [isMac, setIsMac] = useState(false); + + useEffect(() => { + getIsMac().then(setIsMac); + }, []); const { recording, @@ -196,7 +202,7 @@ export function LaunchWindow() {
{/* Language switcher — top-left, beside traffic lights */}
+ +
{/* Left Column - Video & Timeline */} - - - - {/* Top section: video preview and controls */} - -
- {/* Video preview */} -
-
- 0} - shadowIntensity={shadowIntensity} - showBlur={showBlur} - motionBlurAmount={motionBlurAmount} - borderRadius={borderRadius} - padding={padding} - cropRegion={cropRegion} - trimRegions={trimRegions} - speedRegions={speedRegions} - annotationRegions={annotationRegions} - selectedAnnotationId={selectedAnnotationId} - onSelectAnnotation={handleSelectAnnotation} - onAnnotationPositionChange={handleAnnotationPositionChange} - onAnnotationSizeChange={handleAnnotationSizeChange} - /> -
-
- {/* Playback controls */} -
-
- -
+
+ + {/* Top section: video preview and controls */} + +
+ {/* Video preview */} +
+
+ updateState({ webcamPosition: pos })} + onWebcamPositionDragEnd={commitState} + onDurationChange={setDuration} + onTimeUpdate={setCurrentTime} + currentTime={currentTime} + onPlayStateChange={setIsPlaying} + onError={setError} + wallpaper={wallpaper} + zoomRegions={zoomRegions} + selectedZoomId={selectedZoomId} + onSelectZoom={handleSelectZoom} + onZoomFocusChange={handleZoomFocusChange} + onZoomFocusDragEnd={commitState} + isPlaying={isPlaying} + showShadow={shadowIntensity > 0} + shadowIntensity={shadowIntensity} + showBlur={showBlur} + motionBlurAmount={motionBlurAmount} + borderRadius={borderRadius} + padding={padding} + cropRegion={cropRegion} + trimRegions={trimRegions} + speedRegions={speedRegions} + annotationRegions={annotationRegions} + selectedAnnotationId={selectedAnnotationId} + onSelectAnnotation={handleSelectAnnotation} + onAnnotationPositionChange={handleAnnotationPositionChange} + onAnnotationSizeChange={handleAnnotationSizeChange} + />
- - - -
-
- - {/* Timeline section */} - -
- pushState({ aspectRatio: ar })} - /> + {/* Playback controls */} +
+
+ +
- - - - - -
-
- - {/* Right section: settings panel */} - - pushState({ wallpaper: w })} - selectedZoomDepth={ - selectedZoomId ? zoomRegions.find((z) => z.id === selectedZoomId)?.depth : null - } - onZoomDepthChange={(depth) => selectedZoomId && handleZoomDepthChange(depth)} - selectedZoomId={selectedZoomId} - onZoomDelete={handleZoomDelete} - selectedTrimId={selectedTrimId} - onTrimDelete={handleTrimDelete} - shadowIntensity={shadowIntensity} - onShadowChange={(v) => updateState({ shadowIntensity: v })} - onShadowCommit={commitState} - showBlur={showBlur} - onBlurChange={(v) => pushState({ showBlur: v })} - motionBlurAmount={motionBlurAmount} - onMotionBlurChange={(v) => updateState({ motionBlurAmount: v })} - onMotionBlurCommit={commitState} - borderRadius={borderRadius} - onBorderRadiusChange={(v) => updateState({ borderRadius: v })} - onBorderRadiusCommit={commitState} - padding={padding} - onPaddingChange={(v) => updateState({ padding: v })} - onPaddingCommit={commitState} - cropRegion={cropRegion} - onCropChange={(r) => pushState({ cropRegion: r })} - aspectRatio={aspectRatio} - hasWebcam={Boolean(webcamVideoPath)} - webcamLayoutPreset={webcamLayoutPreset} - onWebcamLayoutPresetChange={(preset) => pushState({ webcamLayoutPreset: preset })} - videoElement={videoPlaybackRef.current?.video || null} - exportQuality={exportQuality} - onExportQualityChange={setExportQuality} - exportFormat={exportFormat} - onExportFormatChange={setExportFormat} - gifFrameRate={gifFrameRate} - onGifFrameRateChange={setGifFrameRate} - gifLoop={gifLoop} - onGifLoopChange={setGifLoop} - gifSizePreset={gifSizePreset} - onGifSizePresetChange={setGifSizePreset} - gifOutputDimensions={calculateOutputDimensions( - videoPlaybackRef.current?.video?.videoWidth || 1920, - videoPlaybackRef.current?.video?.videoHeight || 1080, - gifSizePreset, - GIF_SIZE_PRESETS, - aspectRatio === "native" - ? getNativeAspectRatioValue( - videoPlaybackRef.current?.video?.videoWidth || 1920, - videoPlaybackRef.current?.video?.videoHeight || 1080, - cropRegion, - ) - : getAspectRatioValue(aspectRatio), - )} - onExport={handleOpenExportDialog} - selectedAnnotationId={selectedAnnotationId} - annotationRegions={annotationRegions} - onAnnotationContentChange={handleAnnotationContentChange} - onAnnotationTypeChange={handleAnnotationTypeChange} - onAnnotationStyleChange={handleAnnotationStyleChange} - onAnnotationFigureDataChange={handleAnnotationFigureDataChange} - onAnnotationDelete={handleAnnotationDelete} - onSaveProject={handleSaveProject} - onLoadProject={handleLoadProject} - selectedSpeedId={selectedSpeedId} - selectedSpeedValue={ - selectedSpeedId - ? (speedRegions.find((r) => r.id === selectedSpeedId)?.speed ?? null) - : null - } - onSpeedChange={handleSpeedChange} - onSpeedDelete={handleSpeedDelete} - unsavedExport={unsavedExport} - onSaveUnsavedExport={handleSaveUnsavedExport} - /> - - +
+
+ + +
+
+ + {/* Timeline section */} + +
+ + pushState({ + aspectRatio: ar, + webcamLayoutPreset: + !isPortraitAspectRatio(ar) && webcamLayoutPreset === "vertical-stack" + ? "picture-in-picture" + : webcamLayoutPreset, + }) + } + /> +
+
+ +
+ + {/* Right section: settings panel */} +
+ pushState({ wallpaper: w })} + selectedZoomDepth={ + selectedZoomId ? zoomRegions.find((z) => z.id === selectedZoomId)?.depth : null + } + onZoomDepthChange={(depth) => selectedZoomId && handleZoomDepthChange(depth)} + selectedZoomId={selectedZoomId} + onZoomDelete={handleZoomDelete} + selectedTrimId={selectedTrimId} + onTrimDelete={handleTrimDelete} + shadowIntensity={shadowIntensity} + onShadowChange={(v) => updateState({ shadowIntensity: v })} + onShadowCommit={commitState} + showBlur={showBlur} + onBlurChange={(v) => pushState({ showBlur: v })} + motionBlurAmount={motionBlurAmount} + onMotionBlurChange={(v) => updateState({ motionBlurAmount: v })} + onMotionBlurCommit={commitState} + borderRadius={borderRadius} + onBorderRadiusChange={(v) => updateState({ borderRadius: v })} + onBorderRadiusCommit={commitState} + padding={padding} + onPaddingChange={(v) => updateState({ padding: v })} + onPaddingCommit={commitState} + cropRegion={cropRegion} + onCropChange={(r) => pushState({ cropRegion: r })} + aspectRatio={aspectRatio} + hasWebcam={Boolean(webcamVideoPath)} + webcamLayoutPreset={webcamLayoutPreset} + onWebcamLayoutPresetChange={(preset) => + pushState({ + webcamLayoutPreset: preset, + webcamPosition: preset === "vertical-stack" ? null : webcamPosition, + }) + } + videoElement={videoPlaybackRef.current?.video || null} + exportQuality={exportQuality} + onExportQualityChange={setExportQuality} + exportFormat={exportFormat} + onExportFormatChange={setExportFormat} + gifFrameRate={gifFrameRate} + onGifFrameRateChange={setGifFrameRate} + gifLoop={gifLoop} + onGifLoopChange={setGifLoop} + gifSizePreset={gifSizePreset} + onGifSizePresetChange={setGifSizePreset} + gifOutputDimensions={calculateOutputDimensions( + videoPlaybackRef.current?.video?.videoWidth || 1920, + videoPlaybackRef.current?.video?.videoHeight || 1080, + gifSizePreset, + GIF_SIZE_PRESETS, + aspectRatio === "native" + ? getNativeAspectRatioValue( + videoPlaybackRef.current?.video?.videoWidth || 1920, + videoPlaybackRef.current?.video?.videoHeight || 1080, + cropRegion, + ) + : getAspectRatioValue(aspectRatio), + )} + onExport={handleOpenExportDialog} + selectedAnnotationId={selectedAnnotationId} + annotationRegions={annotationRegions} + onAnnotationContentChange={handleAnnotationContentChange} + onAnnotationTypeChange={handleAnnotationTypeChange} + onAnnotationStyleChange={handleAnnotationStyleChange} + onAnnotationFigureDataChange={handleAnnotationFigureDataChange} + onAnnotationDelete={handleAnnotationDelete} + selectedSpeedId={selectedSpeedId} + selectedSpeedValue={ + selectedSpeedId + ? (speedRegions.find((r) => r.id === selectedSpeedId)?.speed ?? null) + : null + } + onSpeedChange={handleSpeedChange} + onSpeedDelete={handleSpeedDelete} + unsavedExport={unsavedExport} + onSaveUnsavedExport={handleSaveUnsavedExport} + /> +
void; + onWebcamPositionDragEnd?: () => void; onDurationChange: (duration: number) => void; onTimeUpdate: (time: number) => void; currentTime: number; @@ -108,6 +111,9 @@ const VideoPlayback = forwardRef( videoPath, webcamVideoPath, webcamLayoutPreset, + webcamPosition, + onWebcamPositionChange, + onWebcamPositionDragEnd, onDurationChange, onTimeUpdate, currentTime, @@ -167,6 +173,8 @@ const VideoPlayback = forwardRef( const blurFilterRef = useRef(null); const motionBlurFilterRef = useRef(null); const isDraggingFocusRef = useRef(false); + const isDraggingWebcamRef = useRef(false); + const webcamDragOffsetRef = useRef({ dx: 0, dy: 0 }); const stageSizeRef = useRef({ width: 0, height: 0 }); const videoSizeRef = useRef({ width: 0, height: 0 }); const baseScaleRef = useRef(1); @@ -263,6 +271,7 @@ const VideoPlayback = forwardRef( padding, webcamDimensions, webcamLayoutPreset, + webcamPosition, }); if (result) { @@ -292,6 +301,7 @@ const VideoPlayback = forwardRef( padding, webcamDimensions, webcamLayoutPreset, + webcamPosition, ]); useEffect(() => { @@ -401,6 +411,53 @@ const VideoPlayback = forwardRef( endFocusDrag(event); }; + // ── Webcam PiP drag handlers ── + + const handleWebcamPointerDown = (event: React.PointerEvent) => { + if (isPlayingRef.current) return; + if (webcamLayoutPreset !== "picture-in-picture") return; + event.preventDefault(); + event.stopPropagation(); + isDraggingWebcamRef.current = true; + event.currentTarget.setPointerCapture(event.pointerId); + + const webcamEl = event.currentTarget; + const webcamRect = webcamEl.getBoundingClientRect(); + webcamDragOffsetRef.current = { + dx: event.clientX - (webcamRect.left + webcamRect.width / 2), + dy: event.clientY - (webcamRect.top + webcamRect.height / 2), + }; + }; + + const handleWebcamPointerMove = (event: React.PointerEvent) => { + if (!isDraggingWebcamRef.current) return; + event.preventDefault(); + event.stopPropagation(); + + const containerEl = containerRef.current; + if (!containerEl || !onWebcamPositionChange) return; + + const containerRect = containerEl.getBoundingClientRect(); + const cx = clamp01( + (event.clientX - webcamDragOffsetRef.current.dx - containerRect.left) / containerRect.width, + ); + const cy = clamp01( + (event.clientY - webcamDragOffsetRef.current.dy - containerRect.top) / containerRect.height, + ); + onWebcamPositionChange({ cx, cy }); + }; + + const handleWebcamPointerUp = (event: React.PointerEvent) => { + if (!isDraggingWebcamRef.current) return; + isDraggingWebcamRef.current = false; + try { + event.currentTarget.releasePointerCapture(event.pointerId); + } catch { + // Pointer may already be released. + } + onWebcamPositionDragEnd?.(); + }; + useEffect(() => { zoomRegionsRef.current = zoomRegions; }, [zoomRegions]); @@ -1101,7 +1158,7 @@ const VideoPlayback = forwardRef(