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:
@ -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 (
|
||||
<ScrollArea className="h-full">
|
||||
<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 />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Transform */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium">Transform</h3>
|
||||
<div className="space-y-2">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="x">X Position</Label>
|
||||
<Input id="x" type="number" defaultValue="0" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="y">Y Position</Label>
|
||||
<Input id="y" type="number" defaultValue="0" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="rotation">Rotation</Label>
|
||||
<Slider
|
||||
id="rotation"
|
||||
max={360}
|
||||
step={1}
|
||||
defaultValue={[0]}
|
||||
className="mt-2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Effects */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium">Effects</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="opacity">Opacity</Label>
|
||||
<Slider
|
||||
id="opacity"
|
||||
max={100}
|
||||
step={1}
|
||||
defaultValue={[100]}
|
||||
className="mt-2"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="blur">Blur</Label>
|
||||
<Slider
|
||||
id="blur"
|
||||
max={20}
|
||||
step={0.5}
|
||||
defaultValue={[0]}
|
||||
className="mt-2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Timing */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium">Timing</h3>
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="duration">Duration (seconds)</Label>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
"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 (
|
||||
<ScrollArea className="h-full">
|
||||
<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 */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium">Transform</h3>
|
||||
<div className="space-y-2">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="x">X Position</Label>
|
||||
<Input id="x" type="number" defaultValue="0" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="y">Y Position</Label>
|
||||
<Input id="y" type="number" defaultValue="0" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="rotation">Rotation</Label>
|
||||
<Slider
|
||||
id="rotation"
|
||||
max={360}
|
||||
step={1}
|
||||
defaultValue={[0]}
|
||||
className="mt-2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Effects */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium">Effects</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="opacity">Opacity</Label>
|
||||
<Slider
|
||||
id="opacity"
|
||||
max={100}
|
||||
step={1}
|
||||
defaultValue={[100]}
|
||||
className="mt-2"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="blur">Blur</Label>
|
||||
<Slider
|
||||
id="blur"
|
||||
max={20}
|
||||
step={0.5}
|
||||
defaultValue={[0]}
|
||||
className="mt-2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Timing */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium">Timing</h3>
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="duration">Duration (seconds)</Label>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
46
apps/web/src/components/editor/speed-control.tsx
Normal file
46
apps/web/src/components/editor/speed-control.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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() {
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Delete clip (Delete)</TooltipContent>
|
||||
</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>
|
||||
</div>
|
||||
|
||||
|
@ -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";
|
||||
|
||||
|
@ -25,7 +25,7 @@ export function VideoPlayer({
|
||||
clipDuration
|
||||
}: VideoPlayerProps) {
|
||||
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
|
||||
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 (
|
||||
<div className={`relative group ${className}`}>
|
||||
<video
|
||||
@ -101,7 +116,7 @@ export function VideoPlayer({
|
||||
poster={poster}
|
||||
className="w-full h-full object-cover"
|
||||
playsInline
|
||||
preload="metadata"
|
||||
preload="auto"
|
||||
/>
|
||||
|
||||
<div className="absolute inset-0 bg-black/20 opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none" />
|
||||
|
@ -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<PlaybackStore>((set, get) => ({
|
||||
currentTime: 0,
|
||||
duration: 0,
|
||||
volume: 1,
|
||||
speed: 1.0,
|
||||
|
||||
play: () => {
|
||||
set({ isPlaying: true });
|
||||
@ -64,10 +74,20 @@ export const usePlaybackStore = create<PlaybackStore>((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 }),
|
||||
}));
|
@ -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;
|
||||
}
|
Reference in New Issue
Block a user