From 294ba01abeb2d8c3d3a04cb7ecc54abf97a6d4d3 Mon Sep 17 00:00:00 2001 From: Hyteq Date: Mon, 23 Jun 2025 15:39:56 +0300 Subject: [PATCH 01/62] feat: initial state for composition and overlay editing on the video player --- apps/web/src/app/editor/page.tsx | 6 +- .../src/components/editor/preview-panel.tsx | 536 +++++++++++++++--- 2 files changed, 449 insertions(+), 93 deletions(-) diff --git a/apps/web/src/app/editor/page.tsx b/apps/web/src/app/editor/page.tsx index 2f825a2..64186a3 100644 --- a/apps/web/src/app/editor/page.tsx +++ b/apps/web/src/app/editor/page.tsx @@ -7,7 +7,7 @@ import { ResizableHandle, } from "../../components/ui/resizable"; import { MediaPanel } from "../../components/editor/media-panel"; -import { PropertiesPanel } from "../../components/editor/properties-panel"; +// import { PropertiesPanel } from "../../components/editor/properties-panel"; import { Timeline } from "../../components/editor/timeline"; import { PreviewPanel } from "../../components/editor/preview-panel"; import { EditorHeader } from "@/components/editor-header"; @@ -74,13 +74,13 @@ export default function Editor() { {/* Properties Panel */} - - + */} diff --git a/apps/web/src/components/editor/preview-panel.tsx b/apps/web/src/components/editor/preview-panel.tsx index 132d6c0..5d03e94 100644 --- a/apps/web/src/components/editor/preview-panel.tsx +++ b/apps/web/src/components/editor/preview-panel.tsx @@ -3,110 +3,397 @@ import { useTimelineStore } from "@/stores/timeline-store"; import { useMediaStore } from "@/stores/media-store"; import { usePlaybackStore } from "@/stores/playback-store"; -import { ImageTimelineTreatment } from "@/components/ui/image-timeline-treatment"; import { VideoPlayer } from "@/components/ui/video-player"; import { Button } from "@/components/ui/button"; -import { Play, Pause } from "lucide-react"; +import { Play, Pause, Move, RotateCw, Crop, ZoomIn, ZoomOut } from "lucide-react"; +import { useState, useRef, useEffect, useCallback } from "react"; + +interface ClipTransform { + x: number; + y: number; + scale: number; + rotation: number; + opacity: number; + width: number; + height: number; + blendMode: string; + cropTop: number; + cropBottom: number; + cropLeft: number; + cropRight: number; +} + +interface DragState { + isDragging: boolean; + dragType: 'move' | 'resize-nw' | 'resize-ne' | 'resize-sw' | 'resize-se' | 'rotate' | 'scale' | 'crop-n' | 'crop-s' | 'crop-e' | 'crop-w'; + startMouseX: number; + startMouseY: number; + startTransform: ClipTransform; + clipId: string; +} export function PreviewPanel() { const { tracks } = useTimelineStore(); const { mediaItems } = useMediaStore(); const { isPlaying, toggle, currentTime } = usePlaybackStore(); - // Find the active clip at the current playback time - const getActiveClip = () => { - for (const track of tracks) { - for (const clip of track.clips) { + const [clipTransforms, setClipTransforms] = useState>({}); + const [canvasSize, setCanvasSize] = useState({ width: 1920, height: 1080 }); // Default 16:9 + const [dragState, setDragState] = useState(null); + const previewRef = useRef(null); + + // Get all active clips at current time (for overlaying) + const getActiveClips = () => { + const activeClips: Array<{ + clip: any; + track: any; + mediaItem: any; + layer: number; + }> = []; + + tracks.forEach((track, trackIndex) => { + track.clips.forEach((clip) => { const clipStart = clip.startTime; const clipEnd = clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd); if (currentTime >= clipStart && currentTime < clipEnd) { - return clip; + const mediaItem = clip.mediaId === "test" + ? { type: "test", name: clip.name, url: "", thumbnailUrl: "" } + : mediaItems.find((item) => item.id === clip.mediaId); + + if (mediaItem || clip.mediaId === "test") { + activeClips.push({ + clip, + track, + mediaItem, + layer: trackIndex, // Track index determines layer order + }); + } } + }); + }); + + // Sort by layer (track order) - higher index = on top + return activeClips.sort((a, b) => a.layer - b.layer); + }; + + const activeClips = getActiveClips(); + const aspectRatio = canvasSize.width / canvasSize.height; + + // Get or create transform for a clip + const getClipTransform = (clipId: string): ClipTransform => { + return clipTransforms[clipId] || { + x: 0, + y: 0, + scale: 1, + rotation: 0, + opacity: 1, + width: 100, // Percentage of canvas + height: 100, + blendMode: 'normal', + cropTop: 0, + cropBottom: 0, + cropLeft: 0, + cropRight: 0, + }; + }; + + // Update clip transform + const updateClipTransform = useCallback((clipId: string, updates: Partial) => { + setClipTransforms(prev => { + const currentTransform = prev[clipId] || { + x: 0, + y: 0, + scale: 1, + rotation: 0, + opacity: 1, + width: 100, + height: 100, + blendMode: 'normal', + cropTop: 0, + cropBottom: 0, + cropLeft: 0, + cropRight: 0, + }; + + return { + ...prev, + [clipId]: { ...currentTransform, ...updates } + }; + }); + }, []); + + // Mouse event handlers + const handleMouseDown = (e: React.MouseEvent, clipId: string, dragType: DragState['dragType']) => { + e.preventDefault(); + e.stopPropagation(); + + setDragState({ + isDragging: true, + dragType, + startMouseX: e.clientX, + startMouseY: e.clientY, + startTransform: getClipTransform(clipId), + clipId + }); + }; + + const handleMouseMove = useCallback((e: MouseEvent) => { + if (!dragState || !dragState.isDragging) return; + + const deltaX = e.clientX - dragState.startMouseX; + const deltaY = e.clientY - dragState.startMouseY; + const { startTransform, clipId, dragType } = dragState; + + switch (dragType) { + case 'move': + updateClipTransform(clipId, { + x: Math.max(-100, Math.min(100, startTransform.x + deltaX * 0.3)), + y: Math.max(-100, Math.min(100, startTransform.y + deltaY * 0.3)) + }); + break; + + case 'resize-nw': + updateClipTransform(clipId, { + width: Math.max(20, startTransform.width - deltaX * 0.5), + height: Math.max(20, startTransform.height - deltaY * 0.5) + }); + break; + + case 'resize-ne': + updateClipTransform(clipId, { + width: Math.max(20, startTransform.width + deltaX * 0.5), + height: Math.max(20, startTransform.height - deltaY * 0.5) + }); + break; + + case 'resize-sw': + updateClipTransform(clipId, { + width: Math.max(20, startTransform.width - deltaX * 0.5), + height: Math.max(20, startTransform.height + deltaY * 0.5) + }); + break; + + case 'resize-se': + updateClipTransform(clipId, { + width: Math.max(20, startTransform.width + deltaX * 0.5), + height: Math.max(20, startTransform.height + deltaY * 0.5) + }); + break; + + case 'rotate': + updateClipTransform(clipId, { + rotation: (startTransform.rotation + deltaX * 2) % 360 + }); + break; + + case 'scale': + updateClipTransform(clipId, { + scale: Math.max(0.1, Math.min(3, startTransform.scale + deltaX * 0.01)) + }); + break; + + case 'crop-n': + updateClipTransform(clipId, { + cropTop: Math.max(0, Math.min(40, startTransform.cropTop + deltaY * 0.2)) + }); + break; + + case 'crop-s': + updateClipTransform(clipId, { + cropBottom: Math.max(0, Math.min(40, startTransform.cropBottom - deltaY * 0.2)) + }); + break; + + case 'crop-e': + updateClipTransform(clipId, { + cropRight: Math.max(0, Math.min(40, startTransform.cropRight - deltaX * 0.2)) + }); + break; + + case 'crop-w': + updateClipTransform(clipId, { + cropLeft: Math.max(0, Math.min(40, startTransform.cropLeft + deltaX * 0.2)) + }); + break; + } + }, [dragState, updateClipTransform]); + + const handleMouseUp = useCallback(() => { + setDragState(null); + }, []); + + // Add global mouse event listeners + useEffect(() => { + if (dragState?.isDragging) { + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + return () => { + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + }; + } + }, [dragState, handleMouseMove, handleMouseUp]); + + // Initialize transforms for new clips + useEffect(() => { + const activeClips = getActiveClips(); + const newTransforms: Record = {}; + let hasNewClips = false; + + activeClips.forEach(({ clip }) => { + if (!clipTransforms[clip.id]) { + hasNewClips = true; + newTransforms[clip.id] = { + x: 0, + y: 0, + scale: 1, + rotation: 0, + opacity: 1, + width: 100, + height: 100, + blendMode: 'normal', + cropTop: 0, + cropBottom: 0, + cropLeft: 0, + cropRight: 0, + }; } + }); + + if (hasNewClips) { + setClipTransforms(prev => ({ ...prev, ...newTransforms })); } - return null; - }; + }, [tracks, currentTime]); // Re-run when tracks or time changes - const activeClip = getActiveClip(); - const activeMediaItem = activeClip - ? mediaItems.find((item) => item.id === activeClip.mediaId) - : null; - const aspectRatio = activeMediaItem?.aspectRatio || 16 / 9; - const renderContent = () => { - if (!activeClip) { + // Render a single clip layer + const renderClipLayer = (clipData: any, index: number) => { + const { clip, mediaItem } = clipData; + const transform = getClipTransform(clip.id); + + const layerStyle = { + position: 'absolute' as const, + left: '50%', + top: '50%', + width: `${transform.width}%`, + height: `${transform.height}%`, + transform: `translate(-50%, -50%) translate(${transform.x}%, ${transform.y}%) scale(${transform.scale}) rotate(${transform.rotation}deg)`, + opacity: transform.opacity, + mixBlendMode: transform.blendMode as any, + clipPath: `inset(${transform.cropTop}% ${transform.cropRight}% ${transform.cropBottom}% ${transform.cropLeft}%)`, + zIndex: index + 10, + cursor: dragState?.isDragging && dragState.clipId === clip.id ? 'grabbing' : 'grab', + userSelect: 'none' as const, + }; + + const handleClipMouseDown = (e: React.MouseEvent) => { + e.stopPropagation(); + handleMouseDown(e, clip.id, 'move'); + }; + + // Handle test clips + if (!mediaItem || clip.mediaId === "test") { return ( -
- {tracks.length === 0 ? "Drop media to start editing" : "No clip at current time"} -
- ); - } - - // Handle test clips without media items - if (!activeMediaItem && activeClip.mediaId === "test") { - return ( -
-
-
🎬
-

{activeClip.name}

-

Test clip for playback

+
+
+
🎬
+

{clip.name}

+ + {/* Hover resize corners */} +
{ e.stopPropagation(); handleMouseDown(e, clip.id, 'resize-nw'); }}>
+
{ e.stopPropagation(); handleMouseDown(e, clip.id, 'resize-ne'); }}>
+
{ e.stopPropagation(); handleMouseDown(e, clip.id, 'resize-sw'); }}>
+
{ e.stopPropagation(); handleMouseDown(e, clip.id, 'resize-se'); }}>
); } - if (!activeMediaItem) { + // Render video + if (mediaItem.type === "video") { return ( -
- Media not found +
+ + + {/* Hover resize corners */} +
{ e.stopPropagation(); handleMouseDown(e, clip.id, 'resize-nw'); }}>
+
{ e.stopPropagation(); handleMouseDown(e, clip.id, 'resize-ne'); }}>
+
{ e.stopPropagation(); handleMouseDown(e, clip.id, 'resize-sw'); }}>
+
{ e.stopPropagation(); handleMouseDown(e, clip.id, 'resize-se'); }}>
); } - if (activeMediaItem.type === "video") { + // Render image + if (mediaItem.type === "image") { return ( - +
+ {mediaItem.name} + + {/* Hover resize corners */} +
{ e.stopPropagation(); handleMouseDown(e, clip.id, 'resize-nw'); }}>
+
{ e.stopPropagation(); handleMouseDown(e, clip.id, 'resize-ne'); }}>
+
{ e.stopPropagation(); handleMouseDown(e, clip.id, 'resize-sw'); }}>
+
{ e.stopPropagation(); handleMouseDown(e, clip.id, 'resize-se'); }}>
+
); } - if (activeMediaItem.type === "image") { + // Render audio (visual representation) + if (mediaItem.type === "audio") { return ( - - ); - } - - if (activeMediaItem.type === "audio") { - return ( -
-
-
🎵
-

{activeMediaItem.name}

- +
+
+
🎵
+

{mediaItem.name}

+ + {/* Hover resize corners */} +
{ e.stopPropagation(); handleMouseDown(e, clip.id, 'resize-nw'); }}>
+
{ e.stopPropagation(); handleMouseDown(e, clip.id, 'resize-ne'); }}>
+
{ e.stopPropagation(); handleMouseDown(e, clip.id, 'resize-sw'); }}>
+
{ e.stopPropagation(); handleMouseDown(e, clip.id, 'resize-se'); }}>
); } @@ -114,31 +401,100 @@ export function PreviewPanel() { return null; }; + // Canvas size presets + const canvasPresets = [ + { name: "16:9 HD", width: 1920, height: 1080 }, + { name: "16:9 4K", width: 3840, height: 2160 }, + { name: "9:16 Mobile", width: 1080, height: 1920 }, + { name: "1:1 Square", width: 1080, height: 1080 }, + { name: "4:3 Standard", width: 1440, height: 1080 }, + ]; + return ( -
-
1 ? "100%" : "auto", - height: aspectRatio <= 1 ? "100%" : "auto", - maxWidth: "100%", - maxHeight: "100%", - }} - > - {renderContent()} +
+ {/* Canvas Controls */} +
+ Canvas: + + +
- {activeMediaItem && ( -
-

- {activeMediaItem.name} -

-

- {aspectRatio.toFixed(2)} • {aspectRatio > 1 ? "Landscape" : aspectRatio < 1 ? "Portrait" : "Square"} -

+ {/* Preview Area - Full Width */} +
+
1 ? "100%" : "auto", + height: aspectRatio <= 1 ? "100%" : "auto", + maxWidth: "100%", + maxHeight: "100%", + background: '#000000', + border: '1px solid #374151' + }} + + > + + + {/* Render all active clips as layers */} + {activeClips.length === 0 ? ( +
+ {tracks.length === 0 ? "Drop media to start editing" : "No clips at current time"} +
+ ) : ( + activeClips.map((clipData, index) => renderClipLayer(clipData, index)) + )} + +
- )} +
+ + {/* Bottom Info Panel */} +
+ {/* Layer List */} +
+
Active Layers ({activeClips.length})
+
+ {activeClips.map((clipData, index) => ( +
+ + {index + 1} + + {clipData.clip.name} + {clipData.track.name} +
+ ))} +
+
+ + +
); } From 054c68140c874ce7e42ad9a36f74867f5b01a239 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Mon, 23 Jun 2025 16:25:06 +0200 Subject: [PATCH 02/62] feat: add redirect from appcut to opencut in Netlify configuration --- apps/web/netlify.toml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/apps/web/netlify.toml b/apps/web/netlify.toml index 5aadb56..4cf5484 100644 --- a/apps/web/netlify.toml +++ b/apps/web/netlify.toml @@ -4,3 +4,9 @@ [[plugins]] package = "@netlify/plugin-nextjs" + +[[redirects]] + from = "https://appcut.app/*" + to = "https://opencut.app/:splat" + status = 301 + force = true From f72f5c100badb8ad744a20b9110f9f5e7a9bf774 Mon Sep 17 00:00:00 2001 From: StarKnightt Date: Mon, 23 Jun 2025 20:01:12 +0530 Subject: [PATCH 03/62] Add video speed control and improve playback smoothness Added a speed control dropdown in the timeline toolbar that lets users change video playback speed between 0.5x to 2x. The dropdown shows the current speed and offers preset options. Made video playback smoother by: - Using better timing for speed changes - Improving video synchronization - Reducing playback stutters - Making speed changes more responsive The speed control is now easily accessible while editing and works smoothly with all video clips. --- .../components/editor/properties-panel.tsx | 415 +++++++++--------- .../src/components/editor/speed-control.tsx | 46 ++ apps/web/src/components/editor/timeline.tsx | 32 +- apps/web/src/components/ui/command.tsx | 2 +- apps/web/src/components/ui/video-player.tsx | 23 +- apps/web/src/stores/playback-store.ts | 34 +- apps/web/src/types/playback.ts | 2 + 7 files changed, 344 insertions(+), 210 deletions(-) create mode 100644 apps/web/src/components/editor/speed-control.tsx diff --git a/apps/web/src/components/editor/properties-panel.tsx b/apps/web/src/components/editor/properties-panel.tsx index af80f46..2b211c2 100644 --- a/apps/web/src/components/editor/properties-panel.tsx +++ b/apps/web/src/components/editor/properties-panel.tsx @@ -1,197 +1,218 @@ -"use client"; - -import { Input } from "../ui/input"; -import { Label } from "../ui/label"; -import { Slider } from "../ui/slider"; -import { ScrollArea } from "../ui/scroll-area"; -import { Separator } from "../ui/separator"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "../ui/select"; -import { useTimelineStore } from "@/stores/timeline-store"; -import { useMediaStore } from "@/stores/media-store"; -import { ImageTimelineTreatment } from "@/components/ui/image-timeline-treatment"; -import { useState } from "react"; - -export function PropertiesPanel() { - const { tracks } = useTimelineStore(); - const { mediaItems } = useMediaStore(); - const [backgroundType, setBackgroundType] = useState< - "blur" | "mirror" | "color" - >("blur"); - const [backgroundColor, setBackgroundColor] = useState("#000000"); - - // Get the first image clip for preview (simplified) - const firstImageClip = tracks - .flatMap((track) => track.clips) - .find((clip) => { - const mediaItem = mediaItems.find((item) => item.id === clip.mediaId); - return mediaItem?.type === "image"; - }); - - const firstImageItem = firstImageClip - ? mediaItems.find((item) => item.id === firstImageClip.mediaId) - : null; - - return ( - -
- {/* Image Treatment - only show if an image is selected */} - {firstImageItem && ( - <> -
-

Image Treatment

-
- {/* Preview */} -
- -
- -
-
- - {/* Background Type */} -
- - -
- - {/* Background Color - only show for color type */} - {backgroundType === "color" && ( -
- -
- setBackgroundColor(e.target.value)} - className="w-16 h-10 p-1" - /> - setBackgroundColor(e.target.value)} - placeholder="#000000" - className="flex-1" - /> -
-
- )} -
-
- - - - )} - - {/* Transform */} -
-

Transform

-
-
-
- - -
-
- - -
-
-
- - -
-
-
- - - - {/* Effects */} -
-

Effects

-
-
- - -
-
- - -
-
-
- - - - {/* Timing */} -
-

Timing

-
-
- - -
-
- - -
-
-
-
-
- ); -} +"use client"; + +import { Input } from "../ui/input"; +import { Label } from "../ui/label"; +import { Slider } from "../ui/slider"; +import { ScrollArea } from "../ui/scroll-area"; +import { Separator } from "../ui/separator"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "../ui/select"; +import { useTimelineStore } from "@/stores/timeline-store"; +import { useMediaStore } from "@/stores/media-store"; +import { ImageTimelineTreatment } from "@/components/ui/image-timeline-treatment"; +import { useState } from "react"; +import { SpeedControl } from "./speed-control"; + +export function PropertiesPanel() { + const { tracks } = useTimelineStore(); + const { mediaItems } = useMediaStore(); + const [backgroundType, setBackgroundType] = useState< + "blur" | "mirror" | "color" + >("blur"); + const [backgroundColor, setBackgroundColor] = useState("#000000"); + + // Get the first video clip for preview (simplified) + const firstVideoClip = tracks + .flatMap((track) => track.clips) + .find((clip) => { + const mediaItem = mediaItems.find((item) => item.id === clip.mediaId); + return mediaItem?.type === "video"; + }); + + const firstVideoItem = firstVideoClip + ? mediaItems.find((item) => item.id === firstVideoClip.mediaId) + : null; + + // Get the first image clip for preview (simplified) + const firstImageClip = tracks + .flatMap((track) => track.clips) + .find((clip) => { + const mediaItem = mediaItems.find((item) => item.id === clip.mediaId); + return mediaItem?.type === "image"; + }); + + const firstImageItem = firstImageClip + ? mediaItems.find((item) => item.id === firstImageClip.mediaId) + : null; + + return ( + +
+ {/* Image Treatment - only show if an image is selected */} + {firstImageItem && ( + <> +
+

Image Treatment

+
+ {/* Preview */} +
+ +
+ +
+
+ + {/* Background Type */} +
+ + +
+ + {/* Background Color - only show for color type */} + {backgroundType === "color" && ( +
+ +
+ setBackgroundColor(e.target.value)} + className="w-16 h-10 p-1" + /> + setBackgroundColor(e.target.value)} + placeholder="#000000" + className="flex-1" + /> +
+
+ )} +
+
+ + + + )} + + {/* Video Controls - only show if a video is selected */} + {firstVideoItem && ( + <> + + + + )} + + {/* Transform */} +
+

Transform

+
+
+
+ + +
+
+ + +
+
+
+ + +
+
+
+ + + + {/* Effects */} +
+

Effects

+
+
+ + +
+
+ + +
+
+
+ + + + {/* Timing */} +
+

Timing

+
+
+ + +
+
+ + +
+
+
+
+
+ ); +} diff --git a/apps/web/src/components/editor/speed-control.tsx b/apps/web/src/components/editor/speed-control.tsx new file mode 100644 index 0000000..39281a9 --- /dev/null +++ b/apps/web/src/components/editor/speed-control.tsx @@ -0,0 +1,46 @@ +import { Slider } from "../ui/slider"; +import { Label } from "../ui/label"; +import { Button } from "../ui/button"; +import { usePlaybackStore } from "@/stores/playback-store"; + +const SPEED_PRESETS = [ + { label: "0.5x", value: 0.5 }, + { label: "1x", value: 1.0 }, + { label: "1.5x", value: 1.5 }, + { label: "2x", value: 2.0 }, +]; + +export function SpeedControl() { + const { speed, setSpeed } = usePlaybackStore(); + + return ( +
+

Playback Speed

+
+
+ {SPEED_PRESETS.map((preset) => ( + + ))} +
+
+ + setSpeed(value[0])} + className="mt-2" + /> +
+
+
+ ); +} \ No newline at end of file diff --git a/apps/web/src/components/editor/timeline.tsx b/apps/web/src/components/editor/timeline.tsx index 1d2df66..7c13bff 100644 --- a/apps/web/src/components/editor/timeline.tsx +++ b/apps/web/src/components/editor/timeline.tsx @@ -28,6 +28,13 @@ import { usePlaybackStore } from "@/stores/playback-store"; import { processMediaFiles } from "@/lib/media-processing"; import { toast } from "sonner"; import { useState, useRef, useEffect } from "react"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "../ui/select"; export function Timeline() { // Timeline shows all tracks (video, audio, effects) and their clips. @@ -36,7 +43,7 @@ export function Timeline() { const { tracks, addTrack, addClipToTrack, removeTrack, toggleTrackMute, removeClipFromTrack, moveClipToTrack, getTotalDuration } = useTimelineStore(); const { mediaItems, addMediaItem } = useMediaStore(); - const { currentTime, duration, seek, setDuration, isPlaying, play, pause, toggle } = usePlaybackStore(); + const { currentTime, duration, seek, setDuration, isPlaying, play, pause, toggle, setSpeed, speed } = usePlaybackStore(); const [isDragOver, setIsDragOver] = useState(false); const [isProcessing, setIsProcessing] = useState(false); const [zoomLevel, setZoomLevel] = useState(1); @@ -331,6 +338,29 @@ export function Timeline() { Delete clip (Delete) + +
+ + {/* Speed Control */} + + + + + Playback Speed +
diff --git a/apps/web/src/components/ui/command.tsx b/apps/web/src/components/ui/command.tsx index 29f29cf..11b2704 100644 --- a/apps/web/src/components/ui/command.tsx +++ b/apps/web/src/components/ui/command.tsx @@ -1,7 +1,7 @@ "use client"; import * as React from "react"; -import { type DialogProps } from "radix-ui"; +import { DialogProps } from "@radix-ui/react-dialog"; import { Command as CommandPrimitive } from "cmdk"; import { Search } from "lucide-react"; diff --git a/apps/web/src/components/ui/video-player.tsx b/apps/web/src/components/ui/video-player.tsx index 0c4f849..8731e1a 100644 --- a/apps/web/src/components/ui/video-player.tsx +++ b/apps/web/src/components/ui/video-player.tsx @@ -25,7 +25,7 @@ export function VideoPlayer({ clipDuration }: VideoPlayerProps) { const videoRef = useRef(null); - const { isPlaying, currentTime, volume, play, pause, setVolume } = usePlaybackStore(); + const { isPlaying, currentTime, volume, speed, play, pause, setVolume } = usePlaybackStore(); // Calculate if we're within this clip's timeline range const clipEndTime = clipStartTime + (clipDuration - trimStart - trimEnd); @@ -59,18 +59,26 @@ export function VideoPlayer({ timelineTime - clipStartTime + trimStart )); - // Only sync if there's a significant difference - if (Math.abs(video.currentTime - targetVideoTime) > 0.2) { + // Only sync if there's a significant difference to avoid micro-adjustments + if (Math.abs(video.currentTime - targetVideoTime) > 0.5) { video.currentTime = targetVideoTime; } }; + const handleSpeedEvent = (e: CustomEvent) => { + if (!isInClipRange) return; + // Set playbackRate directly without any additional checks + video.playbackRate = e.detail.speed; + }; + window.addEventListener("playback-seek", handleSeekEvent as EventListener); window.addEventListener("playback-update", handleUpdateEvent as EventListener); + window.addEventListener("playback-speed", handleSpeedEvent as EventListener); return () => { window.removeEventListener("playback-seek", handleSeekEvent as EventListener); window.removeEventListener("playback-update", handleUpdateEvent as EventListener); + window.removeEventListener("playback-speed", handleSpeedEvent as EventListener); }; }, [clipStartTime, trimStart, trimEnd, clipDuration, isInClipRange]); @@ -93,6 +101,13 @@ export function VideoPlayer({ video.volume = volume; }, [volume]); + // Sync speed immediately when it changes + useEffect(() => { + const video = videoRef.current; + if (!video) return; + video.playbackRate = speed; + }, [speed]); + return (