basic timeline synced to video playback

This commit is contained in:
Siddharth
2025-10-31 22:37:12 -07:00
parent 5440a39146
commit a597ea619d
16 changed files with 936 additions and 42 deletions
+51
View File
@@ -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",
+2
View File
@@ -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"
+17
View File
@@ -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;
+26
View File
@@ -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 }
+27
View File
@@ -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>
);
}
+4 -15
View File
@@ -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>
+32 -8
View File
@@ -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 -1
View File
@@ -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>
);
}
+57
View File
@@ -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);
}
}