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 (
-
- );
-}
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 (
+
+ );
+}
\ 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 (
+
+ );
+}
\ 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 (
+
+ );
+ })}
+
+ );
+}
+
+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);
+ }
+}