basic timeline synced to video playback
This commit is contained in:
Generated
+51
@@ -9,6 +9,7 @@
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@fix-webm-duration/fix": "^1.0.1",
|
||||
"@radix-ui/react-slider": "^1.3.6",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
@@ -21,6 +22,7 @@
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-icons": "^5.5.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"uiohook-napi": "^1.5.4"
|
||||
@@ -1576,6 +1578,12 @@
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/number": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz",
|
||||
"integrity": "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@radix-ui/primitive": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
|
||||
@@ -1749,6 +1757,39 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-slider": {
|
||||
"version": "1.3.6",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.3.6.tgz",
|
||||
"integrity": "sha512-JPYb1GuM1bxfjMRlNLE+BcmBC8onfCi60Blk7OBqi2MLTFdS+8401U4uFjnwkOr49BLmXxLC6JHkvAsx5OJvHw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/number": "1.1.1",
|
||||
"@radix-ui/primitive": "1.1.3",
|
||||
"@radix-ui/react-collection": "1.1.7",
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
"@radix-ui/react-context": "1.1.2",
|
||||
"@radix-ui/react-direction": "1.1.1",
|
||||
"@radix-ui/react-primitive": "2.1.3",
|
||||
"@radix-ui/react-use-controllable-state": "1.2.2",
|
||||
"@radix-ui/react-use-layout-effect": "1.1.1",
|
||||
"@radix-ui/react-use-previous": "1.1.1",
|
||||
"@radix-ui/react-use-size": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||
@@ -8645,6 +8686,16 @@
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/sonner": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz",
|
||||
"integrity": "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc",
|
||||
"react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map": {
|
||||
"version": "0.6.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@fix-webm-duration/fix": "^1.0.1",
|
||||
"@radix-ui/react-slider": "^1.3.6",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
@@ -23,6 +24,7 @@
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-icons": "^5.5.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"uiohook-napi": "^1.5.4"
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { PropsWithChildren } from "react";
|
||||
|
||||
interface ItemContentProps extends PropsWithChildren {
|
||||
classes: string;
|
||||
}
|
||||
|
||||
function ItemContent({ children, classes }: ItemContentProps) {
|
||||
return (
|
||||
<div
|
||||
className={`border-2 rounded-sm shadow-md w-full overflow-hidden flex flex-row pl-3 h-items-center ${classes}`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ItemContent;
|
||||
@@ -0,0 +1,26 @@
|
||||
import * as React from "react"
|
||||
import * as SliderPrimitive from "@radix-ui/react-slider"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Slider = React.forwardRef<
|
||||
React.ElementRef<typeof SliderPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SliderPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full touch-none select-none items-center",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SliderPrimitive.Track className="relative h-1.5 w-full grow overflow-hidden rounded-full bg-primary/20">
|
||||
<SliderPrimitive.Range className="absolute h-full bg-primary" />
|
||||
</SliderPrimitive.Track>
|
||||
<SliderPrimitive.Thumb className="block h-4 w-4 rounded-full border border-primary/50 bg-background shadow transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50" />
|
||||
</SliderPrimitive.Root>
|
||||
))
|
||||
Slider.displayName = SliderPrimitive.Root.displayName
|
||||
|
||||
export { Slider }
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Toaster as Sonner } from "sonner";
|
||||
|
||||
type ToasterProps = React.ComponentProps<typeof Sonner>;
|
||||
|
||||
const Toaster = ({ ...props }: ToasterProps) => {
|
||||
return (
|
||||
<Sonner
|
||||
theme="light"
|
||||
className="toaster group"
|
||||
duration={3000}
|
||||
toastOptions={{
|
||||
classNames: {
|
||||
toast:
|
||||
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
|
||||
description: "group-[.toast]:text-muted-foreground",
|
||||
actionButton:
|
||||
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
|
||||
cancelButton:
|
||||
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
|
||||
},
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export { Toaster };
|
||||
@@ -7,8 +7,6 @@ interface PlaybackControlsProps {
|
||||
duration: number;
|
||||
onTogglePlayPause: () => void;
|
||||
onSeek: (time: number) => void;
|
||||
onSeekStart: () => void;
|
||||
onSeekEnd: () => void;
|
||||
}
|
||||
|
||||
export default function PlaybackControls({
|
||||
@@ -17,8 +15,6 @@ export default function PlaybackControls({
|
||||
duration,
|
||||
onTogglePlayPause,
|
||||
onSeek,
|
||||
onSeekStart,
|
||||
onSeekEnd,
|
||||
}: PlaybackControlsProps) {
|
||||
function formatTime(seconds: number) {
|
||||
if (!isFinite(seconds) || isNaN(seconds) || seconds < 0) return '0:00';
|
||||
@@ -54,12 +50,8 @@ export default function PlaybackControls({
|
||||
max={duration}
|
||||
value={currentTime}
|
||||
onChange={handleSeekChange}
|
||||
onMouseDown={onSeekStart}
|
||||
onMouseUp={onSeekEnd}
|
||||
onTouchStart={onSeekStart}
|
||||
onTouchEnd={onSeekEnd}
|
||||
step="0.01"
|
||||
className="flex-1 h-2 accent-blue-500 rounded-full"
|
||||
className="flex-1 h-2 accent-blue-500 rounded-full transition-all duration-[33ms]"
|
||||
style={{
|
||||
background: `linear-gradient(to right, #3b82f6 0%, #3b82f6 ${(currentTime / duration) * 100}%, #e5e7eb ${(currentTime / duration) * 100}%, #e5e7eb 100%)`
|
||||
}}
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
export default function TimelineEditor() {
|
||||
return (
|
||||
<div className="bg-card border border-border rounded-xl p-8 flex-1 min-h-[180px] flex flex-col justify-center shadow-sm">
|
||||
<div className="h-12 rounded-lg flex items-center justify-center text-muted-foreground text-sm">
|
||||
Timeline
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
|
||||
import VideoPlayback, { VideoPlaybackRef } from "./VideoPlayback";
|
||||
import PlaybackControls from "./PlaybackControls";
|
||||
import TimelineEditor from "./TimelineEditor";
|
||||
import TimelineEditor from "./timeline/TimelineEditor";
|
||||
import SettingsPanel from "./SettingsPanel";
|
||||
|
||||
const WALLPAPER_COUNT = 12;
|
||||
@@ -20,7 +21,6 @@ export default function VideoEditor() {
|
||||
const [wallpaper, setWallpaper] = useState<string>(WALLPAPER_PATHS[0]);
|
||||
|
||||
const videoPlaybackRef = useRef<VideoPlaybackRef>(null);
|
||||
const isSeeking = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
async function loadVideo() {
|
||||
@@ -50,15 +50,6 @@ export default function VideoEditor() {
|
||||
const video = videoPlaybackRef.current?.video;
|
||||
if (!video) return;
|
||||
video.currentTime = time;
|
||||
setCurrentTime(time);
|
||||
}
|
||||
|
||||
function handleSeekStart() {
|
||||
isSeeking.current = true;
|
||||
}
|
||||
|
||||
function handleSeekEnd() {
|
||||
isSeeking.current = false;
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
@@ -78,6 +69,7 @@ export default function VideoEditor() {
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-background p-8 gap-8">
|
||||
<Toaster position="top-center" />
|
||||
<div className="flex flex-col flex-[7] min-w-0 gap-8">
|
||||
<div className="flex flex-col gap-6 flex-1">
|
||||
{videoPath && (
|
||||
@@ -86,7 +78,6 @@ export default function VideoEditor() {
|
||||
<VideoPlayback
|
||||
ref={videoPlaybackRef}
|
||||
videoPath={videoPath}
|
||||
isSeeking={isSeeking}
|
||||
onDurationChange={setDuration}
|
||||
onTimeUpdate={setCurrentTime}
|
||||
onPlayStateChange={setIsPlaying}
|
||||
@@ -100,13 +91,11 @@ export default function VideoEditor() {
|
||||
duration={duration}
|
||||
onTogglePlayPause={togglePlayPause}
|
||||
onSeek={handleSeek}
|
||||
onSeekStart={handleSeekStart}
|
||||
onSeekEnd={handleSeekEnd}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<TimelineEditor />
|
||||
<TimelineEditor videoDuration={duration} currentTime={currentTime} onSeek={handleSeek} />
|
||||
</div>
|
||||
<SettingsPanel selected={wallpaper} onWallpaperChange={setWallpaper} />
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useEffect, useRef, useImperativeHandle, forwardRef } from "react";
|
||||
|
||||
interface VideoPlaybackProps {
|
||||
videoPath: string;
|
||||
isSeeking: React.MutableRefObject<boolean>;
|
||||
onDurationChange: (duration: number) => void;
|
||||
onTimeUpdate: (time: number) => void;
|
||||
onPlayStateChange: (playing: boolean) => void;
|
||||
@@ -16,7 +15,6 @@ export interface VideoPlaybackRef {
|
||||
|
||||
const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(({
|
||||
videoPath,
|
||||
isSeeking,
|
||||
onDurationChange,
|
||||
onTimeUpdate,
|
||||
onPlayStateChange,
|
||||
@@ -26,6 +24,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(({
|
||||
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const drawFrameRef = useRef<(() => void) | null>(null);
|
||||
const timeUpdateAnimationRef = useRef<number | null>(null);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
video: videoRef.current,
|
||||
@@ -37,6 +36,15 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(({
|
||||
if (!video || !canvas) return;
|
||||
|
||||
let animationId: number;
|
||||
|
||||
function updateTime() {
|
||||
if (!video) return;
|
||||
onTimeUpdate(video.currentTime);
|
||||
if (!video.paused && !video.ended) {
|
||||
timeUpdateAnimationRef.current = requestAnimationFrame(updateTime);
|
||||
}
|
||||
}
|
||||
|
||||
function drawFrame() {
|
||||
if (!video || !canvas) return;
|
||||
if (canvas.width !== video.videoWidth || canvas.height !== video.videoHeight) {
|
||||
@@ -75,23 +83,42 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(({
|
||||
drawFrame();
|
||||
animationId = requestAnimationFrame(drawFrameLoop);
|
||||
}
|
||||
const handlePlay = () => drawFrameLoop();
|
||||
const handlePause = () => cancelAnimationFrame(animationId);
|
||||
const handlePlay = () => {
|
||||
drawFrameLoop();
|
||||
updateTime();
|
||||
};
|
||||
const handlePause = () => {
|
||||
cancelAnimationFrame(animationId);
|
||||
if (timeUpdateAnimationRef.current) {
|
||||
cancelAnimationFrame(timeUpdateAnimationRef.current);
|
||||
timeUpdateAnimationRef.current = null;
|
||||
}
|
||||
onTimeUpdate(video.currentTime);
|
||||
};
|
||||
const handleSeeked = () => {
|
||||
drawFrame();
|
||||
onTimeUpdate(video.currentTime);
|
||||
};
|
||||
const handleSeeking = () => {
|
||||
onTimeUpdate(video.currentTime);
|
||||
};
|
||||
video.addEventListener('play', handlePlay);
|
||||
video.addEventListener('pause', handlePause);
|
||||
video.addEventListener('ended', handlePause);
|
||||
video.addEventListener('seeked', handleSeeked);
|
||||
video.addEventListener('seeking', handleSeeking);
|
||||
return () => {
|
||||
video.removeEventListener('play', handlePlay);
|
||||
video.removeEventListener('pause', handlePause);
|
||||
video.removeEventListener('ended', handlePause);
|
||||
video.removeEventListener('seeked', handleSeeked);
|
||||
video.removeEventListener('seeking', handleSeeking);
|
||||
cancelAnimationFrame(animationId);
|
||||
if (timeUpdateAnimationRef.current) {
|
||||
cancelAnimationFrame(timeUpdateAnimationRef.current);
|
||||
}
|
||||
};
|
||||
}, [videoPath]);
|
||||
}, [videoPath, onTimeUpdate]);
|
||||
|
||||
// Draw first frame when metadata is loaded
|
||||
const handleLoadedMetadata = (e: React.SyntheticEvent<HTMLVideoElement, Event>) => {
|
||||
@@ -135,9 +162,6 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(({
|
||||
onDurationChange={e => {
|
||||
onDurationChange(e.currentTarget.duration);
|
||||
}}
|
||||
onTimeUpdate={e => {
|
||||
if (!isSeeking.current) onTimeUpdate(e.currentTarget.currentTime);
|
||||
}}
|
||||
onError={() => onError('Failed to load video')}
|
||||
onPlay={() => onPlayStateChange(true)}
|
||||
onPause={() => onPlayStateChange(false)}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export { default as VideoEditor } from './VideoEditor';
|
||||
export { default as VideoPlayback } from './VideoPlayback';
|
||||
export { default as PlaybackControls } from './PlaybackControls';
|
||||
export { default as TimelineEditor } from './TimelineEditor';
|
||||
export { default as TimelineEditor } from './timeline/TimelineEditor';
|
||||
export { default as SettingsPanel } from './SettingsPanel';
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useItem } from "dnd-timeline";
|
||||
import type { Span } from "dnd-timeline";
|
||||
|
||||
interface ItemProps {
|
||||
id: string;
|
||||
span: Span;
|
||||
rowId: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function Item({ id, span, rowId, children }: ItemProps) {
|
||||
const { setNodeRef, attributes, listeners, itemStyle, itemContentStyle } = useItem({
|
||||
id,
|
||||
span,
|
||||
data: { rowId },
|
||||
});
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef} style={itemStyle} {...listeners} {...attributes}>
|
||||
<div style={itemContentStyle}>
|
||||
<div
|
||||
className="border border-indigo-400/40 rounded-lg shadow-sm w-full overflow-hidden flex items-center justify-center px-3 bg-indigo-600 hover:bg-indigo-700 transition-all duration-150 cursor-grab active:cursor-grabbing group relative"
|
||||
style={{ height: 60 }}
|
||||
>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/10 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-150" />
|
||||
<span className="text-sm font-semibold text-white truncate relative z-10 drop-shadow-sm">
|
||||
{children}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { useRow } from "dnd-timeline";
|
||||
import type { RowDefinition } from "dnd-timeline";
|
||||
|
||||
interface RowProps extends RowDefinition {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function Row({ id, children }: RowProps) {
|
||||
const { setNodeRef, rowWrapperStyle, rowStyle } = useRow({ id });
|
||||
|
||||
return (
|
||||
<div
|
||||
className="border-b border-slate-100 bg-gradient-to-b from-slate-50/30 to-white/50"
|
||||
style={{ ...rowWrapperStyle, minHeight: 88 }}
|
||||
>
|
||||
<div ref={setNodeRef} style={rowStyle}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
interface SubrowProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function Subrow({ children }: SubrowProps) {
|
||||
return (
|
||||
<div style={{ height: 50, position: "relative" }}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,473 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTimelineContext } from "dnd-timeline";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Plus } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import TimelineWrapper from "./TimelineWrapper";
|
||||
import Row from "./Row";
|
||||
import Item from "./Item";
|
||||
import type { Range, Span } from "dnd-timeline";
|
||||
|
||||
const ROW_ID = "row-1";
|
||||
const FALLBACK_RANGE_MS = 1000;
|
||||
const TARGET_MARKER_COUNT = 12;
|
||||
|
||||
interface TimelineEditorProps {
|
||||
videoDuration: number;
|
||||
currentTime: number;
|
||||
onSeek?: (time: number) => void;
|
||||
}
|
||||
|
||||
interface TimelineItem {
|
||||
id: string;
|
||||
rowId: string;
|
||||
span: Span;
|
||||
}
|
||||
|
||||
interface TimelineScaleConfig {
|
||||
intervalMs: number;
|
||||
gridMs: number;
|
||||
minItemDurationMs: number;
|
||||
defaultItemDurationMs: number;
|
||||
minVisibleRangeMs: number;
|
||||
}
|
||||
|
||||
const SCALE_CANDIDATES = [
|
||||
{ intervalSeconds: 0.25, gridSeconds: 0.05 },
|
||||
{ intervalSeconds: 0.5, gridSeconds: 0.1 },
|
||||
{ intervalSeconds: 1, gridSeconds: 0.25 },
|
||||
{ intervalSeconds: 2, gridSeconds: 0.5 },
|
||||
{ intervalSeconds: 5, gridSeconds: 1 },
|
||||
{ intervalSeconds: 10, gridSeconds: 2 },
|
||||
{ intervalSeconds: 15, gridSeconds: 3 },
|
||||
{ intervalSeconds: 30, gridSeconds: 5 },
|
||||
{ intervalSeconds: 60, gridSeconds: 10 },
|
||||
{ intervalSeconds: 120, gridSeconds: 20 },
|
||||
{ intervalSeconds: 300, gridSeconds: 30 },
|
||||
{ intervalSeconds: 600, gridSeconds: 60 },
|
||||
{ intervalSeconds: 900, gridSeconds: 120 },
|
||||
{ intervalSeconds: 1800, gridSeconds: 180 },
|
||||
{ intervalSeconds: 3600, gridSeconds: 300 },
|
||||
];
|
||||
|
||||
function calculateTimelineScale(durationSeconds: number): TimelineScaleConfig {
|
||||
const totalMs = Math.max(0, Math.round(durationSeconds * 1000));
|
||||
|
||||
const selectedCandidate = SCALE_CANDIDATES.find((candidate) => {
|
||||
if (durationSeconds <= 0) {
|
||||
return true;
|
||||
}
|
||||
const markers = durationSeconds / candidate.intervalSeconds;
|
||||
return markers <= TARGET_MARKER_COUNT;
|
||||
}) ?? SCALE_CANDIDATES[SCALE_CANDIDATES.length - 1];
|
||||
|
||||
const intervalMs = Math.round(selectedCandidate.intervalSeconds * 1000);
|
||||
const gridMs = Math.round(selectedCandidate.gridSeconds * 1000);
|
||||
|
||||
const minItemDurationMs = Math.max(100, Math.min(intervalMs, gridMs * 2));
|
||||
const defaultItemDurationMs = Math.min(
|
||||
Math.max(minItemDurationMs, intervalMs * 2),
|
||||
totalMs > 0 ? totalMs : intervalMs * 2,
|
||||
);
|
||||
|
||||
const minVisibleRangeMs = totalMs > 0
|
||||
? Math.min(Math.max(intervalMs * 3, minItemDurationMs * 6, 1000), totalMs)
|
||||
: Math.max(intervalMs * 3, minItemDurationMs * 6, 1000);
|
||||
|
||||
return {
|
||||
intervalMs,
|
||||
gridMs,
|
||||
minItemDurationMs,
|
||||
defaultItemDurationMs,
|
||||
minVisibleRangeMs,
|
||||
};
|
||||
}
|
||||
|
||||
function createInitialRange(totalMs: number): Range {
|
||||
if (totalMs > 0) {
|
||||
return { start: 0, end: totalMs };
|
||||
}
|
||||
|
||||
return { start: 0, end: FALLBACK_RANGE_MS };
|
||||
}
|
||||
|
||||
function formatTimeLabel(milliseconds: number, intervalMs: number) {
|
||||
const totalSeconds = milliseconds / 1000;
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
|
||||
const fractionalDigits = intervalMs < 250 ? 2 : intervalMs < 1000 ? 1 : 0;
|
||||
|
||||
if (hours > 0) {
|
||||
const minutesString = minutes.toString().padStart(2, "0");
|
||||
const secondsString = Math.floor(seconds)
|
||||
.toString()
|
||||
.padStart(2, "0");
|
||||
return `${hours}:${minutesString}:${secondsString}`;
|
||||
}
|
||||
|
||||
if (fractionalDigits > 0) {
|
||||
const secondsWithFraction = seconds.toFixed(fractionalDigits);
|
||||
const [wholeSeconds, fraction] = secondsWithFraction.split(".");
|
||||
return `${minutes}:${wholeSeconds.padStart(2, "0")}.${fraction}`;
|
||||
}
|
||||
|
||||
return `${minutes}:${Math.floor(seconds).toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function PlaybackCursor({
|
||||
currentTimeMs,
|
||||
videoDurationMs
|
||||
}: {
|
||||
currentTimeMs: number;
|
||||
videoDurationMs: number;
|
||||
}) {
|
||||
const { sidebarWidth, direction, range, valueToPixels } = useTimelineContext();
|
||||
const sideProperty = direction === "rtl" ? "right" : "left";
|
||||
|
||||
if (videoDurationMs <= 0 || currentTimeMs < 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const clampedTime = Math.min(currentTimeMs, videoDurationMs);
|
||||
|
||||
if (clampedTime < range.start || clampedTime > range.end) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const offset = valueToPixels(clampedTime - range.start);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute top-0 bottom-0 pointer-events-none z-50"
|
||||
style={{
|
||||
[sideProperty === "right" ? "marginRight" : "marginLeft"]: `${sidebarWidth}px`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="absolute top-0 bottom-0 w-0.5 bg-gradient-to-b from-red-500 to-red-600 shadow-lg"
|
||||
style={{
|
||||
[sideProperty]: `${offset}px`,
|
||||
}}
|
||||
>
|
||||
<div className="absolute -top-1 -left-1.5 w-3 h-3 bg-red-500 rounded-full shadow-md border border-red-400" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TimelineAxis({
|
||||
intervalMs,
|
||||
videoDurationMs,
|
||||
}: {
|
||||
intervalMs: number;
|
||||
videoDurationMs: number;
|
||||
}) {
|
||||
const { sidebarWidth, direction, range, valueToPixels } = useTimelineContext();
|
||||
const sideProperty = direction === "rtl" ? "right" : "left";
|
||||
|
||||
const markers = useMemo(() => {
|
||||
if (intervalMs <= 0) {
|
||||
return [] as { time: number; label: string }[];
|
||||
}
|
||||
|
||||
const maxTime = videoDurationMs > 0 ? videoDurationMs : range.end;
|
||||
const visibleStart = Math.max(0, Math.min(range.start, maxTime));
|
||||
const visibleEnd = Math.min(range.end, maxTime);
|
||||
const markerTimes = new Set<number>();
|
||||
|
||||
const firstMarker = Math.ceil(visibleStart / intervalMs) * intervalMs;
|
||||
|
||||
for (let time = firstMarker; time <= maxTime; time += intervalMs) {
|
||||
if (time >= visibleStart && time <= visibleEnd) {
|
||||
markerTimes.add(Math.round(time));
|
||||
}
|
||||
}
|
||||
|
||||
if (visibleStart <= maxTime) {
|
||||
markerTimes.add(Math.round(visibleStart));
|
||||
}
|
||||
|
||||
if (videoDurationMs > 0) {
|
||||
markerTimes.add(Math.round(videoDurationMs));
|
||||
}
|
||||
|
||||
const sorted = Array.from(markerTimes)
|
||||
.filter(time => time <= maxTime)
|
||||
.sort((a, b) => a - b);
|
||||
|
||||
return sorted.map((time) => ({
|
||||
time,
|
||||
label: formatTimeLabel(time, intervalMs),
|
||||
}));
|
||||
}, [intervalMs, range.end, range.start, videoDurationMs]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="h-10 bg-gradient-to-b from-slate-50 to-slate-100/50 border-b border-slate-200/60 relative overflow-hidden"
|
||||
style={{
|
||||
[sideProperty === "right" ? "marginRight" : "marginLeft"]: `${sidebarWidth}px`,
|
||||
}}
|
||||
>
|
||||
{markers.map((marker) => {
|
||||
const offset = valueToPixels(marker.time - range.start);
|
||||
const markerStyle: React.CSSProperties = {
|
||||
position: "absolute",
|
||||
bottom: 0,
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
alignItems: "flex-end",
|
||||
[sideProperty]: `${offset}px`,
|
||||
};
|
||||
|
||||
return (
|
||||
<div key={marker.time} style={markerStyle}>
|
||||
<div
|
||||
style={{
|
||||
width: "1px",
|
||||
height: "60%",
|
||||
backgroundColor: "#cbd5e1",
|
||||
opacity: 0.5,
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
style={{
|
||||
paddingLeft: "4px",
|
||||
alignSelf: "flex-start",
|
||||
paddingTop: "3px",
|
||||
}}
|
||||
className="text-[10px] text-slate-500 font-medium select-none tracking-tight"
|
||||
>
|
||||
{marker.label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Timeline({
|
||||
items,
|
||||
videoDurationMs,
|
||||
intervalMs,
|
||||
currentTimeMs,
|
||||
onSeek,
|
||||
}: {
|
||||
items: TimelineItem[];
|
||||
videoDurationMs: number;
|
||||
intervalMs: number;
|
||||
currentTimeMs: number;
|
||||
onSeek?: (time: number) => void;
|
||||
}) {
|
||||
const { setTimelineRef, style, sidebarWidth, range, pixelsToValue } = useTimelineContext();
|
||||
|
||||
const handleTimelineClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!onSeek || videoDurationMs <= 0) return;
|
||||
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const clickX = e.clientX - rect.left - sidebarWidth;
|
||||
|
||||
if (clickX < 0) return;
|
||||
|
||||
const relativeMs = pixelsToValue(clickX);
|
||||
const absoluteMs = Math.max(0, Math.min(range.start + relativeMs, videoDurationMs));
|
||||
const timeInSeconds = absoluteMs / 1000;
|
||||
|
||||
onSeek(timeInSeconds);
|
||||
}, [onSeek, videoDurationMs, sidebarWidth, range.start, pixelsToValue]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setTimelineRef}
|
||||
style={style}
|
||||
className="select-none bg-white min-h-[120px] relative cursor-pointer"
|
||||
onClick={handleTimelineClick}
|
||||
>
|
||||
<TimelineAxis intervalMs={intervalMs} videoDurationMs={videoDurationMs} />
|
||||
<PlaybackCursor currentTimeMs={currentTimeMs} videoDurationMs={videoDurationMs} />
|
||||
<Row id={ROW_ID}>
|
||||
{items.map((item) => (
|
||||
<Item
|
||||
id={item.id}
|
||||
key={item.id}
|
||||
rowId={item.rowId}
|
||||
span={item.span}
|
||||
>
|
||||
{`Zoom ${item.id.replace("item-", "")}`}
|
||||
</Item>
|
||||
))}
|
||||
</Row>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TimelineEditor({ videoDuration, currentTime, onSeek }: TimelineEditorProps) {
|
||||
const [items, setItems] = useState<TimelineItem[]>([]);
|
||||
const [itemCounter, setItemCounter] = useState(1);
|
||||
|
||||
const totalMs = useMemo(() => Math.max(0, Math.round(videoDuration * 1000)), [videoDuration]);
|
||||
const currentTimeMs = useMemo(() => Math.round(currentTime * 1000), [currentTime]);
|
||||
const timelineScale = useMemo(() => calculateTimelineScale(videoDuration), [videoDuration]);
|
||||
const safeMinDurationMs = useMemo(
|
||||
() => (totalMs > 0 ? Math.min(timelineScale.minItemDurationMs, totalMs) : timelineScale.minItemDurationMs),
|
||||
[timelineScale.minItemDurationMs, totalMs],
|
||||
);
|
||||
|
||||
const [range, setRange] = useState<Range>(() => createInitialRange(totalMs));
|
||||
|
||||
useEffect(() => {
|
||||
const initialRange = createInitialRange(totalMs);
|
||||
setRange(initialRange);
|
||||
}, [totalMs]);
|
||||
|
||||
useEffect(() => {
|
||||
if (totalMs === 0) {
|
||||
setItems([]);
|
||||
setItemCounter(1);
|
||||
return;
|
||||
}
|
||||
|
||||
setItems((prev) => {
|
||||
if (safeMinDurationMs <= 0) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
let mutated = false;
|
||||
const updated = prev
|
||||
.map((item) => {
|
||||
const clampedStart = Math.max(0, Math.min(item.span.start, totalMs));
|
||||
const clampedEnd = Math.min(
|
||||
totalMs,
|
||||
Math.max(clampedStart + safeMinDurationMs, Math.min(item.span.end, totalMs)),
|
||||
);
|
||||
|
||||
if (clampedStart !== item.span.start || clampedEnd !== item.span.end) {
|
||||
mutated = true;
|
||||
return {
|
||||
...item,
|
||||
span: {
|
||||
start: Math.max(0, Math.min(clampedStart, totalMs - safeMinDurationMs)),
|
||||
end: Math.max(0, clampedEnd),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return item;
|
||||
})
|
||||
.filter((item) => item.span.end > item.span.start);
|
||||
|
||||
return mutated ? updated : prev;
|
||||
});
|
||||
}, [safeMinDurationMs, totalMs]);
|
||||
|
||||
const hasOverlap = useCallback((newSpan: Span, excludeId?: string): boolean => {
|
||||
return items.some(item => {
|
||||
if (item.id === excludeId) return false;
|
||||
return !(newSpan.end <= item.span.start || newSpan.start >= item.span.end);
|
||||
});
|
||||
}, [items]);
|
||||
|
||||
const addItem = useCallback(() => {
|
||||
if (!videoDuration || videoDuration === 0) return;
|
||||
|
||||
const defaultDuration = Math.min(
|
||||
Math.max(timelineScale.defaultItemDurationMs, safeMinDurationMs),
|
||||
totalMs,
|
||||
);
|
||||
|
||||
if (defaultDuration <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
let startPos = 0;
|
||||
const sortedItems = [...items].sort((a, b) => a.span.start - b.span.start);
|
||||
|
||||
for (const item of sortedItems) {
|
||||
if (startPos + defaultDuration <= item.span.start) {
|
||||
break;
|
||||
}
|
||||
startPos = Math.max(startPos, item.span.end);
|
||||
}
|
||||
|
||||
if (startPos + defaultDuration > totalMs) {
|
||||
toast.error("No space available", {
|
||||
description: "Remove or resize existing zoom regions to add more.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const newItem: TimelineItem = {
|
||||
id: `item-${itemCounter}`,
|
||||
rowId: ROW_ID,
|
||||
span: { start: startPos, end: startPos + defaultDuration },
|
||||
};
|
||||
|
||||
setItems((prev) => [...prev, newItem]);
|
||||
setItemCounter((c) => c + 1);
|
||||
}, [itemCounter, items, safeMinDurationMs, timelineScale.defaultItemDurationMs, totalMs, videoDuration]);
|
||||
|
||||
const clampedRange = useMemo<Range>(() => {
|
||||
if (totalMs === 0) {
|
||||
return range;
|
||||
}
|
||||
|
||||
return {
|
||||
start: Math.max(0, Math.min(range.start, totalMs)),
|
||||
end: Math.min(range.end, totalMs),
|
||||
};
|
||||
}, [range, totalMs]);
|
||||
|
||||
if (!videoDuration || videoDuration === 0) {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center bg-gray-50 border border-gray-300 rounded-lg">
|
||||
<span className="text-gray-500 text-sm">Load a video to see timeline</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col bg-white border border-slate-200 rounded-xl shadow-sm overflow-hidden">
|
||||
<div className="flex items-center gap-3 px-4 py-2.5 border-b border-slate-100 bg-gradient-to-b from-white to-slate-50/50">
|
||||
<Button onClick={addItem} variant="outline" size="sm" className="gap-2 h-8 px-3 text-xs">
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
Add Zoom
|
||||
</Button>
|
||||
<div className="flex-1" />
|
||||
<div className="flex items-center gap-3 text-[10px] text-slate-400 font-medium">
|
||||
<span className="flex items-center gap-1">
|
||||
<kbd className="px-1.5 py-0.5 bg-slate-100 border border-slate-200 rounded text-slate-600">Command + Shift + Scroll</kbd>
|
||||
<span>Pan</span>
|
||||
</span>
|
||||
<span className="text-slate-300">•</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<kbd className="px-1.5 py-0.5 bg-slate-100 border border-slate-200 rounded text-slate-600">Command + Scroll</kbd>
|
||||
<span>Zoom</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-x-auto overflow-y-hidden">
|
||||
<TimelineWrapper
|
||||
setItems={setItems}
|
||||
range={clampedRange}
|
||||
videoDuration={videoDuration}
|
||||
hasOverlap={hasOverlap}
|
||||
onRangeChange={setRange}
|
||||
minItemDurationMs={timelineScale.minItemDurationMs}
|
||||
minVisibleRangeMs={timelineScale.minVisibleRangeMs}
|
||||
gridSizeMs={timelineScale.gridMs}
|
||||
>
|
||||
<Timeline
|
||||
items={items}
|
||||
videoDurationMs={totalMs}
|
||||
intervalMs={timelineScale.intervalMs}
|
||||
currentTimeMs={currentTimeMs}
|
||||
onSeek={onSeek}
|
||||
/>
|
||||
</TimelineWrapper>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import { useCallback } from "react";
|
||||
import type { Dispatch, ReactNode, SetStateAction } from "react";
|
||||
import { TimelineContext } from "dnd-timeline";
|
||||
import type { DragEndEvent, Range, ResizeEndEvent, Span } from "dnd-timeline";
|
||||
|
||||
interface TimelineItem {
|
||||
id: string;
|
||||
rowId: string;
|
||||
span: Span;
|
||||
}
|
||||
|
||||
interface TimelineWrapperProps {
|
||||
children: ReactNode;
|
||||
setItems: Dispatch<SetStateAction<TimelineItem[]>>;
|
||||
range: Range;
|
||||
videoDuration: number;
|
||||
hasOverlap: (newSpan: Span, excludeId?: string) => boolean;
|
||||
onRangeChange: Dispatch<SetStateAction<Range>>;
|
||||
minItemDurationMs: number;
|
||||
minVisibleRangeMs: number;
|
||||
gridSizeMs: number;
|
||||
}
|
||||
|
||||
export default function TimelineWrapper({
|
||||
children,
|
||||
setItems,
|
||||
range,
|
||||
videoDuration,
|
||||
hasOverlap,
|
||||
onRangeChange,
|
||||
minItemDurationMs,
|
||||
minVisibleRangeMs,
|
||||
gridSizeMs,
|
||||
}: TimelineWrapperProps) {
|
||||
const totalMs = Math.max(0, Math.round(videoDuration * 1000));
|
||||
|
||||
const clampSpanToBounds = useCallback(
|
||||
(span: Span): Span => {
|
||||
const rawDuration = Math.max(span.end - span.start, 0);
|
||||
const normalizedStart = Number.isFinite(span.start) ? span.start : 0;
|
||||
|
||||
if (totalMs === 0) {
|
||||
const minDuration = Math.max(minItemDurationMs, 1);
|
||||
const duration = Math.max(rawDuration, minDuration);
|
||||
const start = Math.max(0, normalizedStart);
|
||||
return {
|
||||
start,
|
||||
end: start + duration,
|
||||
};
|
||||
}
|
||||
|
||||
const minDuration = Math.min(Math.max(minItemDurationMs, 1), totalMs);
|
||||
const duration = Math.min(Math.max(rawDuration, minDuration), totalMs);
|
||||
|
||||
const start = Math.max(0, Math.min(normalizedStart, totalMs - duration));
|
||||
const end = start + duration;
|
||||
|
||||
return { start, end };
|
||||
},
|
||||
[minItemDurationMs, totalMs],
|
||||
);
|
||||
|
||||
const clampRange = useCallback(
|
||||
(candidate: Range): Range => {
|
||||
if (totalMs === 0) {
|
||||
const minSpan = Math.max(minVisibleRangeMs, 1);
|
||||
const span = Math.max(candidate.end - candidate.start, minSpan);
|
||||
const start = Math.max(0, Math.min(candidate.start, candidate.end - span));
|
||||
return { start, end: start + span };
|
||||
}
|
||||
|
||||
const rawStart = Math.max(0, candidate.start);
|
||||
const rawEnd = candidate.end;
|
||||
const clampedEnd = Math.min(rawEnd, totalMs);
|
||||
|
||||
const minSpan = Math.min(Math.max(minVisibleRangeMs, 1), totalMs);
|
||||
const desiredSpan = clampedEnd - rawStart;
|
||||
const span = Math.min(Math.max(desiredSpan, minSpan), totalMs);
|
||||
|
||||
let finalStart = rawStart;
|
||||
let finalEnd = finalStart + span;
|
||||
|
||||
if (finalEnd > totalMs) {
|
||||
finalEnd = totalMs;
|
||||
finalStart = Math.max(0, finalEnd - span);
|
||||
}
|
||||
|
||||
return { start: finalStart, end: finalEnd };
|
||||
},
|
||||
[minVisibleRangeMs, totalMs],
|
||||
);
|
||||
|
||||
const onResizeEnd = useCallback(
|
||||
(event: ResizeEndEvent) => {
|
||||
const updatedSpan = event.active.data.current.getSpanFromResizeEvent?.(event);
|
||||
if (!updatedSpan) return;
|
||||
|
||||
const activeItemId = event.active.id as string;
|
||||
const clampedSpan = clampSpanToBounds(updatedSpan);
|
||||
|
||||
if (clampedSpan.end - clampedSpan.start < Math.min(minItemDurationMs, totalMs || minItemDurationMs)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasOverlap(clampedSpan, activeItemId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setItems((prev) =>
|
||||
prev.map((item) =>
|
||||
item.id === activeItemId ? { ...item, span: clampedSpan } : item
|
||||
)
|
||||
);
|
||||
},
|
||||
[clampSpanToBounds, hasOverlap, minItemDurationMs, setItems, totalMs]
|
||||
);
|
||||
|
||||
const onDragEnd = useCallback(
|
||||
(event: DragEndEvent) => {
|
||||
const activeRowId = event.over?.id as string;
|
||||
const updatedSpan = event.active.data.current.getSpanFromDragEvent?.(event);
|
||||
if (!updatedSpan || !activeRowId) return;
|
||||
|
||||
const activeItemId = event.active.id as string;
|
||||
const clampedSpan = clampSpanToBounds(updatedSpan);
|
||||
|
||||
if (hasOverlap(clampedSpan, activeItemId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setItems((prev) =>
|
||||
prev.map((item) =>
|
||||
item.id === activeItemId
|
||||
? { ...item, rowId: activeRowId, span: clampedSpan }
|
||||
: item
|
||||
)
|
||||
);
|
||||
},
|
||||
[clampSpanToBounds, hasOverlap, setItems]
|
||||
);
|
||||
|
||||
const handleRangeChange = useCallback(
|
||||
(updater: (previous: Range) => Range) => {
|
||||
onRangeChange((prev) => {
|
||||
const normalized = totalMs > 0 ? clampRange(prev) : prev;
|
||||
const desired = updater(normalized);
|
||||
|
||||
if (totalMs > 0) {
|
||||
const clamped = clampRange(desired);
|
||||
|
||||
if (clamped.end > totalMs) {
|
||||
const span = Math.min(clamped.end - clamped.start, totalMs);
|
||||
return {
|
||||
start: Math.max(0, totalMs - span),
|
||||
end: totalMs,
|
||||
};
|
||||
}
|
||||
|
||||
return clamped;
|
||||
}
|
||||
|
||||
return desired;
|
||||
});
|
||||
},
|
||||
[clampRange, onRangeChange, totalMs],
|
||||
);
|
||||
|
||||
return (
|
||||
<TimelineContext
|
||||
range={range}
|
||||
onRangeChanged={handleRangeChange}
|
||||
onResizeEnd={onResizeEnd}
|
||||
onDragEnd={onDragEnd}
|
||||
autoScroll={{ enabled: false }}
|
||||
rangeGridSizeDefinition={gridSizeMs > 0 ? gridSizeMs : undefined}
|
||||
>
|
||||
{children}
|
||||
</TimelineContext>
|
||||
);
|
||||
}
|
||||
@@ -67,3 +67,60 @@
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
/* Smooth timeline cursor animations */
|
||||
@layer utilities {
|
||||
.timeline-cursor-smooth {
|
||||
will-change: transform;
|
||||
transition: left 33ms linear, right 33ms linear;
|
||||
}
|
||||
|
||||
/* Smooth playback scrubber */
|
||||
input[type="range"] {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
input[type="range"]::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: #3b82f6;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
transition: all 0.08s ease-out;
|
||||
}
|
||||
|
||||
input[type="range"]::-webkit-slider-thumb:hover {
|
||||
background: #2563eb;
|
||||
transform: scale(1.15);
|
||||
box-shadow: 0 3px 10px rgba(59, 130, 246, 0.5);
|
||||
}
|
||||
|
||||
input[type="range"]::-webkit-slider-thumb:active {
|
||||
transform: scale(1.25);
|
||||
}
|
||||
|
||||
input[type="range"]::-moz-range-thumb {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: #3b82f6;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
transition: all 0.08s ease-out;
|
||||
}
|
||||
|
||||
input[type="range"]::-moz-range-thumb:hover {
|
||||
background: #2563eb;
|
||||
transform: scale(1.15);
|
||||
box-shadow: 0 3px 10px rgba(59, 130, 246, 0.5);
|
||||
}
|
||||
|
||||
input[type="range"]::-moz-range-thumb:active {
|
||||
transform: scale(1.25);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user