From a597ea619d24837e16dbb4740b0a8de608d199b6 Mon Sep 17 00:00:00 2001 From: Siddharth Date: Fri, 31 Oct 2025 22:37:12 -0700 Subject: [PATCH] basic timeline synced to video playback --- package-lock.json | 51 ++ package.json | 2 + src/components/ui/item-content.tsx | 17 + src/components/ui/slider.tsx | 26 + src/components/ui/sonner.tsx | 27 + .../video-editor/PlaybackControls.tsx | 10 +- .../video-editor/TimelineEditor.tsx | 9 - src/components/video-editor/VideoEditor.tsx | 19 +- src/components/video-editor/VideoPlayback.tsx | 40 +- src/components/video-editor/index.ts | 2 +- src/components/video-editor/timeline/Item.tsx | 33 ++ src/components/video-editor/timeline/Row.tsx | 21 + .../video-editor/timeline/Subrow.tsx | 11 + .../video-editor/timeline/TimelineEditor.tsx | 473 ++++++++++++++++++ .../video-editor/timeline/TimelineWrapper.tsx | 180 +++++++ src/index.css | 57 +++ 16 files changed, 936 insertions(+), 42 deletions(-) create mode 100644 src/components/ui/item-content.tsx create mode 100644 src/components/ui/slider.tsx create mode 100644 src/components/ui/sonner.tsx delete mode 100644 src/components/video-editor/TimelineEditor.tsx create mode 100644 src/components/video-editor/timeline/Item.tsx create mode 100644 src/components/video-editor/timeline/Row.tsx create mode 100644 src/components/video-editor/timeline/Subrow.tsx create mode 100644 src/components/video-editor/timeline/TimelineEditor.tsx create mode 100644 src/components/video-editor/timeline/TimelineWrapper.tsx diff --git a/package-lock.json b/package-lock.json index 39d7923..d3e2bcc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 12a148d..55cce7c 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/src/components/ui/item-content.tsx b/src/components/ui/item-content.tsx new file mode 100644 index 0000000..9d60271 --- /dev/null +++ b/src/components/ui/item-content.tsx @@ -0,0 +1,17 @@ +import type { PropsWithChildren } from "react"; + +interface ItemContentProps extends PropsWithChildren { + classes: string; +} + +function ItemContent({ children, classes }: ItemContentProps) { + return ( +
+ {children} +
+ ); +} + +export default ItemContent; \ No newline at end of file diff --git a/src/components/ui/slider.tsx b/src/components/ui/slider.tsx new file mode 100644 index 0000000..9398b33 --- /dev/null +++ b/src/components/ui/slider.tsx @@ -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, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + + + +)) +Slider.displayName = SliderPrimitive.Root.displayName + +export { Slider } diff --git a/src/components/ui/sonner.tsx b/src/components/ui/sonner.tsx new file mode 100644 index 0000000..415988a --- /dev/null +++ b/src/components/ui/sonner.tsx @@ -0,0 +1,27 @@ +import { Toaster as Sonner } from "sonner"; + +type ToasterProps = React.ComponentProps; + +const Toaster = ({ ...props }: ToasterProps) => { + return ( + + ); +}; + +export { Toaster }; diff --git a/src/components/video-editor/PlaybackControls.tsx b/src/components/video-editor/PlaybackControls.tsx index a48e5e0..14fdc8e 100644 --- a/src/components/video-editor/PlaybackControls.tsx +++ b/src/components/video-editor/PlaybackControls.tsx @@ -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%)` }} diff --git a/src/components/video-editor/TimelineEditor.tsx b/src/components/video-editor/TimelineEditor.tsx deleted file mode 100644 index 070d3ba..0000000 --- a/src/components/video-editor/TimelineEditor.tsx +++ /dev/null @@ -1,9 +0,0 @@ -export default function TimelineEditor() { - return ( -
-
- Timeline -
-
- ); -} diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index f9bb649..4faf4f9 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -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(WALLPAPER_PATHS[0]); const videoPlaybackRef = useRef(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 (
+
{videoPath && ( @@ -86,7 +78,6 @@ export default function VideoEditor() { )}
- +
diff --git a/src/components/video-editor/VideoPlayback.tsx b/src/components/video-editor/VideoPlayback.tsx index 1846aeb..e4dc868 100644 --- a/src/components/video-editor/VideoPlayback.tsx +++ b/src/components/video-editor/VideoPlayback.tsx @@ -2,7 +2,6 @@ import { useEffect, useRef, useImperativeHandle, forwardRef } from "react"; interface VideoPlaybackProps { videoPath: string; - isSeeking: React.MutableRefObject; onDurationChange: (duration: number) => void; onTimeUpdate: (time: number) => void; onPlayStateChange: (playing: boolean) => void; @@ -16,7 +15,6 @@ export interface VideoPlaybackRef { const VideoPlayback = forwardRef(({ videoPath, - isSeeking, onDurationChange, onTimeUpdate, onPlayStateChange, @@ -26,6 +24,7 @@ const VideoPlayback = forwardRef(({ const videoRef = useRef(null); const canvasRef = useRef(null); const drawFrameRef = useRef<(() => void) | null>(null); + const timeUpdateAnimationRef = useRef(null); useImperativeHandle(ref, () => ({ video: videoRef.current, @@ -37,6 +36,15 @@ const VideoPlayback = forwardRef(({ 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(({ 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) => { @@ -135,9 +162,6 @@ const VideoPlayback = forwardRef(({ 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)} diff --git a/src/components/video-editor/index.ts b/src/components/video-editor/index.ts index f7db441..5fcf1bb 100644 --- a/src/components/video-editor/index.ts +++ b/src/components/video-editor/index.ts @@ -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'; diff --git a/src/components/video-editor/timeline/Item.tsx b/src/components/video-editor/timeline/Item.tsx new file mode 100644 index 0000000..bedd074 --- /dev/null +++ b/src/components/video-editor/timeline/Item.tsx @@ -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 ( +
+
+
+
+ + {children} + +
+
+
+ ); +} \ No newline at end of file diff --git a/src/components/video-editor/timeline/Row.tsx b/src/components/video-editor/timeline/Row.tsx new file mode 100644 index 0000000..a3360d9 --- /dev/null +++ b/src/components/video-editor/timeline/Row.tsx @@ -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 ( +
+
+ {children} +
+
+ ); +} \ No newline at end of file diff --git a/src/components/video-editor/timeline/Subrow.tsx b/src/components/video-editor/timeline/Subrow.tsx new file mode 100644 index 0000000..1596d86 --- /dev/null +++ b/src/components/video-editor/timeline/Subrow.tsx @@ -0,0 +1,11 @@ +interface SubrowProps { + children: React.ReactNode; +} + +export default function Subrow({ children }: SubrowProps) { + return ( +
+ {children} +
+ ); +} \ No newline at end of file diff --git a/src/components/video-editor/timeline/TimelineEditor.tsx b/src/components/video-editor/timeline/TimelineEditor.tsx new file mode 100644 index 0000000..013d084 --- /dev/null +++ b/src/components/video-editor/timeline/TimelineEditor.tsx @@ -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 ( +
+
+
+
+
+ ); +} + +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(); + + 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 ( +
+ {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 ( +
+
+ + {marker.label} + +
+ ); + })} +
+ ); +} + +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) => { + 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 ( +
+ + + + {items.map((item) => ( + + {`Zoom ${item.id.replace("item-", "")}`} + + ))} + +
+ ); +} + +export default function TimelineEditor({ videoDuration, currentTime, onSeek }: TimelineEditorProps) { + const [items, setItems] = useState([]); + 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(() => 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(() => { + 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 ( +
+ Load a video to see timeline +
+ ); + } + + return ( +
+
+ +
+
+ + Command + Shift + Scroll + Pan + + + + Command + Scroll + Zoom + +
+
+
+ + + +
+
+ ); +} diff --git a/src/components/video-editor/timeline/TimelineWrapper.tsx b/src/components/video-editor/timeline/TimelineWrapper.tsx new file mode 100644 index 0000000..3f780aa --- /dev/null +++ b/src/components/video-editor/timeline/TimelineWrapper.tsx @@ -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>; + range: Range; + videoDuration: number; + hasOverlap: (newSpan: Span, excludeId?: string) => boolean; + onRangeChange: Dispatch>; + 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 ( + 0 ? gridSizeMs : undefined} + > + {children} + + ); +} \ No newline at end of file diff --git a/src/index.css b/src/index.css index 628823a..9faef3f 100644 --- a/src/index.css +++ b/src/index.css @@ -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); + } +}