diff --git a/apps/web/src/components/editor/properties-panel.tsx b/apps/web/src/components/editor/properties-panel.tsx
index 496550b..2b211c2 100644
--- a/apps/web/src/components/editor/properties-panel.tsx
+++ b/apps/web/src/components/editor/properties-panel.tsx
@@ -1,132 +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 (
-
-
-
-
- {/* Transform */}
-
-
-
-
- {/* Effects */}
-
-
Effects
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {/* 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" && (
+
+ )}
+
+
+
+
+ >
+ )}
+
+ {/* Video Controls - only show if a video is selected */}
+ {firstVideoItem && (
+ <>
+
+
+ >
+ )}
+
+ {/* Transform */}
+
+
+
+
+ {/* Effects */}
+
+
Effects
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* 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 e824d42..e548a50 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, selectedClips, selectClip, deselectClip, clearSelectedClips, setSelectedClips } =
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);
@@ -428,6 +435,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 (
diff --git a/apps/web/src/stores/playback-store.ts b/apps/web/src/stores/playback-store.ts
index 3c0bfde..a4794b7 100644
--- a/apps/web/src/stores/playback-store.ts
+++ b/apps/web/src/stores/playback-store.ts
@@ -6,15 +6,20 @@ interface PlaybackStore extends PlaybackState, PlaybackControls {
setCurrentTime: (time: number) => void;
}
-let playbackTimer: NodeJS.Timeout | null = null;
+let playbackTimer: number | null = null;
const startTimer = (store: any) => {
- if (playbackTimer) clearInterval(playbackTimer);
+ if (playbackTimer) cancelAnimationFrame(playbackTimer);
- playbackTimer = setInterval(() => {
+ // Use requestAnimationFrame for smoother updates
+ const updateTime = () => {
const state = store();
if (state.isPlaying && state.currentTime < state.duration) {
- const newTime = state.currentTime + 0.1;
+ const now = performance.now();
+ const delta = (now - lastUpdate) / 1000; // Convert to seconds
+ lastUpdate = now;
+
+ const newTime = state.currentTime + (delta * state.speed);
if (newTime >= state.duration) {
state.pause();
} else {
@@ -23,12 +28,16 @@ const startTimer = (store: any) => {
window.dispatchEvent(new CustomEvent('playback-update', { detail: { time: newTime } }));
}
}
- }, 100);
+ playbackTimer = requestAnimationFrame(updateTime);
+ };
+
+ let lastUpdate = performance.now();
+ playbackTimer = requestAnimationFrame(updateTime);
};
const stopTimer = () => {
if (playbackTimer) {
- clearInterval(playbackTimer);
+ cancelAnimationFrame(playbackTimer);
playbackTimer = null;
}
};
@@ -38,6 +47,7 @@ export const usePlaybackStore = create
((set, get) => ({
currentTime: 0,
duration: 0,
volume: 1,
+ speed: 1.0,
play: () => {
set({ isPlaying: true });
@@ -64,10 +74,20 @@ export const usePlaybackStore = create((set, get) => ({
set({ currentTime: clampedTime });
// Notify video elements to seek
- window.dispatchEvent(new CustomEvent('playback-seek', { detail: { time: clampedTime } }));
+ const event = new CustomEvent('playback-seek', { detail: { time: clampedTime } });
+ window.dispatchEvent(event);
},
setVolume: (volume: number) => set({ volume: Math.max(0, Math.min(1, volume)) }),
+
+ setSpeed: (speed: number) => {
+ const newSpeed = Math.max(0.1, Math.min(2.0, speed));
+ set({ speed: newSpeed });
+
+ const event = new CustomEvent('playback-speed', { detail: { speed: newSpeed } });
+ window.dispatchEvent(event);
+ },
+
setDuration: (duration: number) => set({ duration }),
setCurrentTime: (time: number) => set({ currentTime: time }),
}));
\ No newline at end of file
diff --git a/apps/web/src/types/playback.ts b/apps/web/src/types/playback.ts
index 88113ef..3fbd1a0 100644
--- a/apps/web/src/types/playback.ts
+++ b/apps/web/src/types/playback.ts
@@ -3,6 +3,7 @@ export interface PlaybackState {
currentTime: number;
duration: number;
volume: number;
+ speed: number;
}
export interface PlaybackControls {
@@ -10,5 +11,6 @@ export interface PlaybackControls {
pause: () => void;
seek: (time: number) => void;
setVolume: (volume: number) => void;
+ setSpeed: (speed: number) => void;
toggle: () => void;
}
\ No newline at end of file