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.
This commit is contained in:
StarKnightt
2025-06-23 20:01:12 +05:30
parent 294ba01abe
commit f72f5c100b
7 changed files with 344 additions and 210 deletions

View File

@ -1,197 +1,218 @@
"use client"; "use client";
import { Input } from "../ui/input"; import { Input } from "../ui/input";
import { Label } from "../ui/label"; import { Label } from "../ui/label";
import { Slider } from "../ui/slider"; import { Slider } from "../ui/slider";
import { ScrollArea } from "../ui/scroll-area"; import { ScrollArea } from "../ui/scroll-area";
import { Separator } from "../ui/separator"; import { Separator } from "../ui/separator";
import { import {
Select, Select,
SelectContent, SelectContent,
SelectItem, SelectItem,
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "../ui/select"; } from "../ui/select";
import { useTimelineStore } from "@/stores/timeline-store"; import { useTimelineStore } from "@/stores/timeline-store";
import { useMediaStore } from "@/stores/media-store"; import { useMediaStore } from "@/stores/media-store";
import { ImageTimelineTreatment } from "@/components/ui/image-timeline-treatment"; import { ImageTimelineTreatment } from "@/components/ui/image-timeline-treatment";
import { useState } from "react"; import { useState } from "react";
import { SpeedControl } from "./speed-control";
export function PropertiesPanel() {
const { tracks } = useTimelineStore(); export function PropertiesPanel() {
const { mediaItems } = useMediaStore(); const { tracks } = useTimelineStore();
const [backgroundType, setBackgroundType] = useState< const { mediaItems } = useMediaStore();
"blur" | "mirror" | "color" const [backgroundType, setBackgroundType] = useState<
>("blur"); "blur" | "mirror" | "color"
const [backgroundColor, setBackgroundColor] = useState("#000000"); >("blur");
const [backgroundColor, setBackgroundColor] = useState("#000000");
// Get the first image clip for preview (simplified)
const firstImageClip = tracks // Get the first video clip for preview (simplified)
.flatMap((track) => track.clips) const firstVideoClip = tracks
.find((clip) => { .flatMap((track) => track.clips)
const mediaItem = mediaItems.find((item) => item.id === clip.mediaId); .find((clip) => {
return mediaItem?.type === "image"; const mediaItem = mediaItems.find((item) => item.id === clip.mediaId);
}); return mediaItem?.type === "video";
});
const firstImageItem = firstImageClip
? mediaItems.find((item) => item.id === firstImageClip.mediaId) const firstVideoItem = firstVideoClip
: null; ? mediaItems.find((item) => item.id === firstVideoClip.mediaId)
: null;
return (
<ScrollArea className="h-full"> // Get the first image clip for preview (simplified)
<div className="space-y-6 p-5"> const firstImageClip = tracks
{/* Image Treatment - only show if an image is selected */} .flatMap((track) => track.clips)
{firstImageItem && ( .find((clip) => {
<> const mediaItem = mediaItems.find((item) => item.id === clip.mediaId);
<div className="space-y-4"> return mediaItem?.type === "image";
<h3 className="text-sm font-medium">Image Treatment</h3> });
<div className="space-y-4">
{/* Preview */} const firstImageItem = firstImageClip
<div className="space-y-2"> ? mediaItems.find((item) => item.id === firstImageClip.mediaId)
<Label>Preview</Label> : null;
<div className="w-full aspect-video max-w-48">
<ImageTimelineTreatment return (
src={firstImageItem.url} <ScrollArea className="h-full">
alt={firstImageItem.name} <div className="space-y-6 p-5">
targetAspectRatio={16 / 9} {/* Image Treatment - only show if an image is selected */}
className="rounded-sm border" {firstImageItem && (
backgroundType={backgroundType} <>
backgroundColor={backgroundColor} <div className="space-y-4">
/> <h3 className="text-sm font-medium">Image Treatment</h3>
</div> <div className="space-y-4">
</div> {/* Preview */}
<div className="space-y-2">
{/* Background Type */} <Label>Preview</Label>
<div className="space-y-2"> <div className="w-full aspect-video max-w-48">
<Label htmlFor="bg-type">Background Type</Label> <ImageTimelineTreatment
<Select src={firstImageItem.url}
value={backgroundType} alt={firstImageItem.name}
onValueChange={(value: any) => setBackgroundType(value)} targetAspectRatio={16 / 9}
> className="rounded-sm border"
<SelectTrigger> backgroundType={backgroundType}
<SelectValue placeholder="Select background type" /> backgroundColor={backgroundColor}
</SelectTrigger> />
<SelectContent> </div>
<SelectItem value="blur">Blur</SelectItem> </div>
<SelectItem value="mirror">Mirror</SelectItem>
<SelectItem value="color">Solid Color</SelectItem> {/* Background Type */}
</SelectContent> <div className="space-y-2">
</Select> <Label htmlFor="bg-type">Background Type</Label>
</div> <Select
value={backgroundType}
{/* Background Color - only show for color type */} onValueChange={(value: any) => setBackgroundType(value)}
{backgroundType === "color" && ( >
<div className="space-y-2"> <SelectTrigger>
<Label htmlFor="bg-color">Background Color</Label> <SelectValue placeholder="Select background type" />
<div className="flex gap-2"> </SelectTrigger>
<Input <SelectContent>
id="bg-color" <SelectItem value="blur">Blur</SelectItem>
type="color" <SelectItem value="mirror">Mirror</SelectItem>
value={backgroundColor} <SelectItem value="color">Solid Color</SelectItem>
onChange={(e) => setBackgroundColor(e.target.value)} </SelectContent>
className="w-16 h-10 p-1" </Select>
/> </div>
<Input
value={backgroundColor} {/* Background Color - only show for color type */}
onChange={(e) => setBackgroundColor(e.target.value)} {backgroundType === "color" && (
placeholder="#000000" <div className="space-y-2">
className="flex-1" <Label htmlFor="bg-color">Background Color</Label>
/> <div className="flex gap-2">
</div> <Input
</div> id="bg-color"
)} type="color"
</div> value={backgroundColor}
</div> onChange={(e) => setBackgroundColor(e.target.value)}
className="w-16 h-10 p-1"
<Separator /> />
</> <Input
)} value={backgroundColor}
onChange={(e) => setBackgroundColor(e.target.value)}
{/* Transform */} placeholder="#000000"
<div className="space-y-4"> className="flex-1"
<h3 className="text-sm font-medium">Transform</h3> />
<div className="space-y-2"> </div>
<div className="grid grid-cols-2 gap-2"> </div>
<div className="space-y-1"> )}
<Label htmlFor="x">X Position</Label> </div>
<Input id="x" type="number" defaultValue="0" /> </div>
</div>
<div className="space-y-1"> <Separator />
<Label htmlFor="y">Y Position</Label> </>
<Input id="y" type="number" defaultValue="0" /> )}
</div>
</div> {/* Video Controls - only show if a video is selected */}
<div className="space-y-1"> {firstVideoItem && (
<Label htmlFor="rotation">Rotation</Label> <>
<Slider <SpeedControl />
id="rotation" <Separator />
max={360} </>
step={1} )}
defaultValue={[0]}
className="mt-2" {/* Transform */}
/> <div className="space-y-4">
</div> <h3 className="text-sm font-medium">Transform</h3>
</div> <div className="space-y-2">
</div> <div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<Separator /> <Label htmlFor="x">X Position</Label>
<Input id="x" type="number" defaultValue="0" />
{/* Effects */} </div>
<div className="space-y-4"> <div className="space-y-1">
<h3 className="text-sm font-medium">Effects</h3> <Label htmlFor="y">Y Position</Label>
<div className="space-y-4"> <Input id="y" type="number" defaultValue="0" />
<div className="space-y-1"> </div>
<Label htmlFor="opacity">Opacity</Label> </div>
<Slider <div className="space-y-1">
id="opacity" <Label htmlFor="rotation">Rotation</Label>
max={100} <Slider
step={1} id="rotation"
defaultValue={[100]} max={360}
className="mt-2" step={1}
/> defaultValue={[0]}
</div> className="mt-2"
<div className="space-y-1"> />
<Label htmlFor="blur">Blur</Label> </div>
<Slider </div>
id="blur" </div>
max={20}
step={0.5} <Separator />
defaultValue={[0]}
className="mt-2" {/* Effects */}
/> <div className="space-y-4">
</div> <h3 className="text-sm font-medium">Effects</h3>
</div> <div className="space-y-4">
</div> <div className="space-y-1">
<Label htmlFor="opacity">Opacity</Label>
<Separator /> <Slider
id="opacity"
{/* Timing */} max={100}
<div className="space-y-4"> step={1}
<h3 className="text-sm font-medium">Timing</h3> defaultValue={[100]}
<div className="space-y-2"> className="mt-2"
<div className="space-y-1"> />
<Label htmlFor="duration">Duration (seconds)</Label> </div>
<Input <div className="space-y-1">
id="duration" <Label htmlFor="blur">Blur</Label>
type="number" <Slider
min="0" id="blur"
step="0.1" max={20}
defaultValue="5" step={0.5}
/> defaultValue={[0]}
</div> className="mt-2"
<div className="space-y-1"> />
<Label htmlFor="delay">Delay (seconds)</Label> </div>
<Input </div>
id="delay" </div>
type="number"
min="0" <Separator />
step="0.1"
defaultValue="0" {/* Timing */}
/> <div className="space-y-4">
</div> <h3 className="text-sm font-medium">Timing</h3>
</div> <div className="space-y-2">
</div> <div className="space-y-1">
</div> <Label htmlFor="duration">Duration (seconds)</Label>
</ScrollArea> <Input
); id="duration"
} type="number"
min="0"
step="0.1"
defaultValue="5"
/>
</div>
<div className="space-y-1">
<Label htmlFor="delay">Delay (seconds)</Label>
<Input
id="delay"
type="number"
min="0"
step="0.1"
defaultValue="0"
/>
</div>
</div>
</div>
</div>
</ScrollArea>
);
}

View File

@ -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 (
<div className="space-y-4">
<h3 className="text-sm font-medium">Playback Speed</h3>
<div className="space-y-4">
<div className="flex gap-2">
{SPEED_PRESETS.map((preset) => (
<Button
key={preset.value}
variant={speed === preset.value ? "default" : "outline"}
className="flex-1"
onClick={() => setSpeed(preset.value)}
>
{preset.label}
</Button>
))}
</div>
<div className="space-y-1">
<Label>Custom ({speed.toFixed(1)}x)</Label>
<Slider
value={[speed]}
min={0.1}
max={2.0}
step={0.1}
onValueChange={(value) => setSpeed(value[0])}
className="mt-2"
/>
</div>
</div>
</div>
);
}

View File

@ -28,6 +28,13 @@ import { usePlaybackStore } from "@/stores/playback-store";
import { processMediaFiles } from "@/lib/media-processing"; import { processMediaFiles } from "@/lib/media-processing";
import { toast } from "sonner"; import { toast } from "sonner";
import { useState, useRef, useEffect } from "react"; import { useState, useRef, useEffect } from "react";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "../ui/select";
export function Timeline() { export function Timeline() {
// Timeline shows all tracks (video, audio, effects) and their clips. // 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 } = const { tracks, addTrack, addClipToTrack, removeTrack, toggleTrackMute, removeClipFromTrack, moveClipToTrack, getTotalDuration } =
useTimelineStore(); useTimelineStore();
const { mediaItems, addMediaItem } = useMediaStore(); 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 [isDragOver, setIsDragOver] = useState(false);
const [isProcessing, setIsProcessing] = useState(false); const [isProcessing, setIsProcessing] = useState(false);
const [zoomLevel, setZoomLevel] = useState(1); const [zoomLevel, setZoomLevel] = useState(1);
@ -331,6 +338,29 @@ export function Timeline() {
</TooltipTrigger> </TooltipTrigger>
<TooltipContent>Delete clip (Delete)</TooltipContent> <TooltipContent>Delete clip (Delete)</TooltipContent>
</Tooltip> </Tooltip>
<div className="w-px h-6 bg-border mx-1" />
{/* Speed Control */}
<Tooltip>
<TooltipTrigger asChild>
<Select
value={speed.toFixed(1)}
onValueChange={(value) => setSpeed(parseFloat(value))}
>
<SelectTrigger className="w-[90px] h-8">
<SelectValue placeholder="1.0x" />
</SelectTrigger>
<SelectContent>
<SelectItem value="0.5">0.5x</SelectItem>
<SelectItem value="1.0">1.0x</SelectItem>
<SelectItem value="1.5">1.5x</SelectItem>
<SelectItem value="2.0">2.0x</SelectItem>
</SelectContent>
</Select>
</TooltipTrigger>
<TooltipContent>Playback Speed</TooltipContent>
</Tooltip>
</TooltipProvider> </TooltipProvider>
</div> </div>

View File

@ -1,7 +1,7 @@
"use client"; "use client";
import * as React from "react"; 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 { Command as CommandPrimitive } from "cmdk";
import { Search } from "lucide-react"; import { Search } from "lucide-react";

View File

@ -25,7 +25,7 @@ export function VideoPlayer({
clipDuration clipDuration
}: VideoPlayerProps) { }: VideoPlayerProps) {
const videoRef = useRef<HTMLVideoElement>(null); const videoRef = useRef<HTMLVideoElement>(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 // Calculate if we're within this clip's timeline range
const clipEndTime = clipStartTime + (clipDuration - trimStart - trimEnd); const clipEndTime = clipStartTime + (clipDuration - trimStart - trimEnd);
@ -59,18 +59,26 @@ export function VideoPlayer({
timelineTime - clipStartTime + trimStart timelineTime - clipStartTime + trimStart
)); ));
// Only sync if there's a significant difference // Only sync if there's a significant difference to avoid micro-adjustments
if (Math.abs(video.currentTime - targetVideoTime) > 0.2) { if (Math.abs(video.currentTime - targetVideoTime) > 0.5) {
video.currentTime = targetVideoTime; 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-seek", handleSeekEvent as EventListener);
window.addEventListener("playback-update", handleUpdateEvent as EventListener); window.addEventListener("playback-update", handleUpdateEvent as EventListener);
window.addEventListener("playback-speed", handleSpeedEvent as EventListener);
return () => { return () => {
window.removeEventListener("playback-seek", handleSeekEvent as EventListener); window.removeEventListener("playback-seek", handleSeekEvent as EventListener);
window.removeEventListener("playback-update", handleUpdateEvent as EventListener); window.removeEventListener("playback-update", handleUpdateEvent as EventListener);
window.removeEventListener("playback-speed", handleSpeedEvent as EventListener);
}; };
}, [clipStartTime, trimStart, trimEnd, clipDuration, isInClipRange]); }, [clipStartTime, trimStart, trimEnd, clipDuration, isInClipRange]);
@ -93,6 +101,13 @@ export function VideoPlayer({
video.volume = volume; video.volume = volume;
}, [volume]); }, [volume]);
// Sync speed immediately when it changes
useEffect(() => {
const video = videoRef.current;
if (!video) return;
video.playbackRate = speed;
}, [speed]);
return ( return (
<div className={`relative group ${className}`}> <div className={`relative group ${className}`}>
<video <video
@ -101,7 +116,7 @@ export function VideoPlayer({
poster={poster} poster={poster}
className="w-full h-full object-cover" className="w-full h-full object-cover"
playsInline playsInline
preload="metadata" preload="auto"
/> />
<div className="absolute inset-0 bg-black/20 opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none" /> <div className="absolute inset-0 bg-black/20 opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none" />

View File

@ -6,15 +6,20 @@ interface PlaybackStore extends PlaybackState, PlaybackControls {
setCurrentTime: (time: number) => void; setCurrentTime: (time: number) => void;
} }
let playbackTimer: NodeJS.Timeout | null = null; let playbackTimer: number | null = null;
const startTimer = (store: any) => { const startTimer = (store: any) => {
if (playbackTimer) clearInterval(playbackTimer); if (playbackTimer) cancelAnimationFrame(playbackTimer);
playbackTimer = setInterval(() => { // Use requestAnimationFrame for smoother updates
const updateTime = () => {
const state = store(); const state = store();
if (state.isPlaying && state.currentTime < state.duration) { 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) { if (newTime >= state.duration) {
state.pause(); state.pause();
} else { } else {
@ -23,12 +28,16 @@ const startTimer = (store: any) => {
window.dispatchEvent(new CustomEvent('playback-update', { detail: { time: newTime } })); window.dispatchEvent(new CustomEvent('playback-update', { detail: { time: newTime } }));
} }
} }
}, 100); playbackTimer = requestAnimationFrame(updateTime);
};
let lastUpdate = performance.now();
playbackTimer = requestAnimationFrame(updateTime);
}; };
const stopTimer = () => { const stopTimer = () => {
if (playbackTimer) { if (playbackTimer) {
clearInterval(playbackTimer); cancelAnimationFrame(playbackTimer);
playbackTimer = null; playbackTimer = null;
} }
}; };
@ -38,6 +47,7 @@ export const usePlaybackStore = create<PlaybackStore>((set, get) => ({
currentTime: 0, currentTime: 0,
duration: 0, duration: 0,
volume: 1, volume: 1,
speed: 1.0,
play: () => { play: () => {
set({ isPlaying: true }); set({ isPlaying: true });
@ -64,10 +74,20 @@ export const usePlaybackStore = create<PlaybackStore>((set, get) => ({
set({ currentTime: clampedTime }); set({ currentTime: clampedTime });
// Notify video elements to seek // 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)) }), 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 }), setDuration: (duration: number) => set({ duration }),
setCurrentTime: (time: number) => set({ currentTime: time }), setCurrentTime: (time: number) => set({ currentTime: time }),
})); }));

View File

@ -3,6 +3,7 @@ export interface PlaybackState {
currentTime: number; currentTime: number;
duration: number; duration: number;
volume: number; volume: number;
speed: number;
} }
export interface PlaybackControls { export interface PlaybackControls {
@ -10,5 +11,6 @@ export interface PlaybackControls {
pause: () => void; pause: () => void;
seek: (time: number) => void; seek: (time: number) => void;
setVolume: (volume: number) => void; setVolume: (volume: number) => void;
setSpeed: (speed: number) => void;
toggle: () => void; toggle: () => void;
} }