Merge pull request #15 from StarKnightt/feature/speed-control

Add video speed control and improve playback smoothness
This commit is contained in:
iza
2025-06-23 17:53:13 +03:00
committed by GitHub
7 changed files with 344 additions and 145 deletions

View File

@ -16,6 +16,7 @@ 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() { export function PropertiesPanel() {
const { tracks } = useTimelineStore(); const { tracks } = useTimelineStore();
@ -25,6 +26,18 @@ export function PropertiesPanel() {
>("blur"); >("blur");
const [backgroundColor, setBackgroundColor] = useState("#000000"); 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) // Get the first image clip for preview (simplified)
const firstImageClip = tracks const firstImageClip = tracks
.flatMap((track) => track.clips) .flatMap((track) => track.clips)
@ -40,7 +53,80 @@ export function PropertiesPanel() {
return ( return (
<ScrollArea className="h-full"> <ScrollArea className="h-full">
<div className="space-y-6 p-5"> <div className="space-y-6 p-5">
{/* Image Treatment - only show if an image is selected */}
{firstImageItem && (
<>
<div className="space-y-4">
<h3 className="text-sm font-medium">Image Treatment</h3>
<div className="space-y-4">
{/* Preview */}
<div className="space-y-2">
<Label>Preview</Label>
<div className="w-full aspect-video max-w-48">
<ImageTimelineTreatment
src={firstImageItem.url}
alt={firstImageItem.name}
targetAspectRatio={16 / 9}
className="rounded-sm border"
backgroundType={backgroundType}
backgroundColor={backgroundColor}
/>
</div>
</div>
{/* Background Type */}
<div className="space-y-2">
<Label htmlFor="bg-type">Background Type</Label>
<Select
value={backgroundType}
onValueChange={(value: any) => setBackgroundType(value)}
>
<SelectTrigger>
<SelectValue placeholder="Select background type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="blur">Blur</SelectItem>
<SelectItem value="mirror">Mirror</SelectItem>
<SelectItem value="color">Solid Color</SelectItem>
</SelectContent>
</Select>
</div>
{/* Background Color - only show for color type */}
{backgroundType === "color" && (
<div className="space-y-2">
<Label htmlFor="bg-color">Background Color</Label>
<div className="flex gap-2">
<Input
id="bg-color"
type="color"
value={backgroundColor}
onChange={(e) => setBackgroundColor(e.target.value)}
className="w-16 h-10 p-1"
/>
<Input
value={backgroundColor}
onChange={(e) => setBackgroundColor(e.target.value)}
placeholder="#000000"
className="flex-1"
/>
</div>
</div>
)}
</div>
</div>
<Separator />
</>
)}
{/* Video Controls - only show if a video is selected */}
{firstVideoItem && (
<>
<SpeedControl />
<Separator />
</>
)}
{/* Transform */} {/* Transform */}
<div className="space-y-4"> <div className="space-y-4">

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, selectedClip, clearSelectedClip } = const { tracks, addTrack, addClipToTrack, removeTrack, toggleTrackMute, removeClipFromTrack, moveClipToTrack, getTotalDuration, selectedClip, clearSelectedClip } =
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);
@ -343,6 +350,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;
} }