summaryrefslogtreecommitdiff
path: root/apps/web/src/components/editor
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/src/components/editor')
-rw-r--r--apps/web/src/components/editor/media-panel.tsx285
-rw-r--r--apps/web/src/components/editor/preview-panel.tsx132
-rw-r--r--apps/web/src/components/editor/timeline.tsx803
3 files changed, 770 insertions, 450 deletions
diff --git a/apps/web/src/components/editor/media-panel.tsx b/apps/web/src/components/editor/media-panel.tsx
index 17fe0417..ab2e66e2 100644
--- a/apps/web/src/components/editor/media-panel.tsx
+++ b/apps/web/src/components/editor/media-panel.tsx
@@ -16,17 +16,16 @@ export function MediaPanel() {
const [isProcessing, setIsProcessing] = useState(false);
const processFiles = async (files: FileList | File[]) => {
- setIsProcessing(true);
+ if (!files?.length) return;
+ setIsProcessing(true);
try {
- const processedItems = await processMediaFiles(files);
-
- for (const processedItem of processedItems) {
- addMediaItem(processedItem);
- toast.success(`Added ${processedItem.name} to project`);
- }
+ const items = await processMediaFiles(files);
+ items.forEach(item => {
+ addMediaItem(item);
+ });
} catch (error) {
- console.error("Error processing files:", error);
+ console.error("File processing failed:", error);
toast.error("Failed to process files");
} finally {
setIsProcessing(false);
@@ -34,82 +33,63 @@ export function MediaPanel() {
};
const { isDragOver, dragProps } = useDragDrop({
- onDrop: (files) => {
- processFiles(files);
- },
+ onDrop: processFiles,
});
- const handleFileSelect = () => {
- fileInputRef.current?.click();
- };
+ const handleFileSelect = () => fileInputRef.current?.click();
- const handleFileInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
- if (e.target.files && e.target.files.length > 0) {
- processFiles(e.target.files);
- // Reset the input so the same file can be selected again
- e.target.value = "";
- }
+ const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
+ if (e.target.files) processFiles(e.target.files);
+ e.target.value = "";
};
- const handleRemoveItem = (e: React.MouseEvent, itemId: string) => {
+ const handleRemove = (e: React.MouseEvent, id: string) => {
e.stopPropagation();
- removeMediaItem(itemId);
- toast.success("Media removed from project");
+ removeMediaItem(id);
};
- const handleDragStart = (e: React.DragEvent, item: any) => {
- // Mark this as an internal app drag
- e.dataTransfer.setData(
- "application/x-media-item",
- JSON.stringify({
- id: item.id,
- type: item.type,
- name: item.name,
- })
- );
- e.dataTransfer.effectAllowed = "copy";
+ const formatDuration = (duration: number) => {
+ const min = Math.floor(duration / 60);
+ const sec = Math.floor(duration % 60);
+ return `${min}:${sec.toString().padStart(2, "0")}`;
};
- const formatDuration = (duration: number) => {
- const minutes = Math.floor(duration / 60);
- const seconds = Math.floor(duration % 60);
- return `${minutes}:${seconds.toString().padStart(2, "0")}`;
+ const startDrag = (e: React.DragEvent, item: any) => {
+ e.dataTransfer.setData("application/x-media-item", JSON.stringify({
+ id: item.id,
+ type: item.type,
+ name: item.name,
+ }));
+ e.dataTransfer.effectAllowed = "copy";
};
- const getMediaIcon = (type: string) => {
- switch (type) {
- case "video":
- return <Video className="h-4 w-4" />;
- case "audio":
- return <Music className="h-4 w-4" />;
- default:
- return <Image className="h-4 w-4" />;
+ const renderPreview = (item: any) => {
+ const baseDragProps = {
+ draggable: true,
+ onDragStart: (e: React.DragEvent) => startDrag(e, item),
+ };
+
+ if (item.type === "image") {
+ return (
+ <img
+ src={item.url}
+ alt={item.name}
+ className="w-full h-full object-cover rounded cursor-grab active:cursor-grabbing"
+ loading="lazy"
+ {...baseDragProps}
+ />
+ );
}
- };
- const renderMediaPreview = (item: any) => {
- switch (item.type) {
- case "image":
+ if (item.type === "video") {
+ if (item.thumbnailUrl) {
return (
- <img
- src={item.url}
- alt={item.name}
- className="w-full h-full object-cover rounded cursor-grab active:cursor-grabbing"
- loading="lazy"
- draggable={true}
- onDragStart={(e) => handleDragStart(e, item)}
- />
- );
- case "video":
- return item.thumbnailUrl ? (
- <div className="relative w-full h-full">
+ <div className="relative w-full h-full cursor-grab active:cursor-grabbing" {...baseDragProps}>
<img
src={item.thumbnailUrl}
alt={item.name}
- className="w-full h-full object-cover rounded cursor-grab active:cursor-grabbing"
+ className="w-full h-full object-cover rounded"
loading="lazy"
- draggable={true}
- onDragStart={(e) => handleDragStart(e, item)}
/>
<div className="absolute inset-0 flex items-center justify-center bg-black/20 rounded">
<Video className="h-6 w-6 text-white drop-shadow-md" />
@@ -120,49 +100,37 @@ export function MediaPanel() {
</div>
)}
</div>
- ) : (
- <div
- className="w-full h-full bg-muted/30 flex flex-col items-center justify-center text-muted-foreground rounded cursor-grab active:cursor-grabbing"
- draggable={true}
- onDragStart={(e) => handleDragStart(e, item)}
- >
- <Video className="h-6 w-6 mb-1" />
- <span className="text-xs">Video</span>
- {item.duration && (
- <span className="text-xs opacity-70">
- {formatDuration(item.duration)}
- </span>
- )}
- </div>
- );
- case "audio":
- return (
- <div
- className="w-full h-full bg-gradient-to-br from-green-500/20 to-emerald-500/20 flex flex-col items-center justify-center text-muted-foreground rounded border border-green-500/20 cursor-grab active:cursor-grabbing"
- draggable={true}
- onDragStart={(e) => handleDragStart(e, item)}
- >
- <Music className="h-6 w-6 mb-1" />
- <span className="text-xs">Audio</span>
- {item.duration && (
- <span className="text-xs opacity-70">
- {formatDuration(item.duration)}
- </span>
- )}
- </div>
- );
- default:
- return (
- <div
- className="w-full h-full bg-muted/30 flex flex-col items-center justify-center text-muted-foreground rounded cursor-grab active:cursor-grabbing"
- draggable={true}
- onDragStart={(e) => handleDragStart(e, item)}
- >
- {getMediaIcon(item.type)}
- <span className="text-xs mt-1">Unknown</span>
- </div>
);
+ }
+ return (
+ <div className="w-full h-full bg-muted/30 flex flex-col items-center justify-center text-muted-foreground rounded cursor-grab active:cursor-grabbing" {...baseDragProps}>
+ <Video className="h-6 w-6 mb-1" />
+ <span className="text-xs">Video</span>
+ {item.duration && (
+ <span className="text-xs opacity-70">{formatDuration(item.duration)}</span>
+ )}
+ </div>
+ );
+ }
+
+ if (item.type === "audio") {
+ return (
+ <div className="w-full h-full bg-gradient-to-br from-green-500/20 to-emerald-500/20 flex flex-col items-center justify-center text-muted-foreground rounded border border-green-500/20 cursor-grab active:cursor-grabbing" {...baseDragProps}>
+ <Music className="h-6 w-6 mb-1" />
+ <span className="text-xs">Audio</span>
+ {item.duration && (
+ <span className="text-xs opacity-70">{formatDuration(item.duration)}</span>
+ )}
+ </div>
+ );
}
+
+ return (
+ <div className="w-full h-full bg-muted/30 flex flex-col items-center justify-center text-muted-foreground rounded cursor-grab active:cursor-grabbing" {...baseDragProps}>
+ <Image className="h-6 w-6" />
+ <span className="text-xs mt-1">Unknown</span>
+ </div>
+ );
};
return (
@@ -173,51 +141,71 @@ export function MediaPanel() {
accept="image/*,video/*,audio/*"
multiple
className="hidden"
- onChange={handleFileInputChange}
+ onChange={handleFileChange}
/>
<div
- className={`h-full overflow-y-auto transition-colors duration-200 relative ${
- isDragOver ? "bg-accent/30 border-accent" : ""
- }`}
+ className={`h-full flex flex-col transition-colors relative ${isDragOver ? "bg-accent/30" : ""
+ }`}
{...dragProps}
>
<DragOverlay isVisible={isDragOver} />
- <div className="space-y-4 p-2 h-full">
- {/* Media Grid */}
+ <div className="p-2 border-b">
+ <Button
+ variant="outline"
+ size="sm"
+ className="w-full"
+ onClick={handleFileSelect}
+ disabled={isProcessing}
+ >
+ {isProcessing ? (
+ <>
+ <Upload className="h-4 w-4 mr-2 animate-spin" />
+ Processing...
+ </>
+ ) : (
+ <>
+ <Plus className="h-4 w-4 mr-2" />
+ Add Media
+ </>
+ )}
+ </Button>
+ </div>
+
+ <div className="flex-1 overflow-y-auto p-2">
{mediaItems.length === 0 ? (
- <EmptyMedia
- onFileSelect={handleFileSelect}
- isProcessing={isProcessing}
- />
+ <div className="flex flex-col items-center justify-center py-8 text-center h-full">
+ <div className="w-16 h-16 rounded-full bg-muted/30 flex items-center justify-center mb-4">
+ <Image className="h-8 w-8 text-muted-foreground" />
+ </div>
+ <p className="text-sm text-muted-foreground">No media in project</p>
+ <p className="text-xs text-muted-foreground/70 mt-1">
+ Drag files here or use the button above
+ </p>
+ </div>
) : (
<div className="grid grid-cols-2 gap-2">
{mediaItems.map((item) => (
<div key={item.id} className="relative group">
<Button
variant="outline"
- className="flex flex-col gap-2 p-2 h-auto overflow-hidden w-full relative"
+ className="flex flex-col gap-2 p-2 h-auto w-full relative"
>
- <AspectRatio ratio={item.aspectRatio} className="w-full">
- {renderMediaPreview(item)}
+ <AspectRatio ratio={item.aspectRatio}>
+ {renderPreview(item)}
</AspectRatio>
- <span className="text-xs truncate max-w-full px-1">
+ <span className="text-xs truncate px-1">
{item.name}
</span>
</Button>
- {/* Remove button - positioned outside the button container */}
- <div
- className="absolute -top-2 -right-2 opacity-0 group-hover:opacity-100 transition-opacity z-20"
- onDragStart={(e) => e.preventDefault()}
- onDrag={(e) => e.preventDefault()}
- >
+ <div className="absolute -top-2 -right-2 opacity-0 group-hover:opacity-100 transition-opacity">
<Button
variant="destructive"
size="icon"
- className="h-6 w-6 pointer-events-auto"
- onClick={(e) => handleRemoveItem(e, item.id)}
+ className="h-6 w-6"
+ onClick={(e) => handleRemove(e, item.id)}
>
<Trash2 className="h-3 w-3" />
</Button>
@@ -231,44 +219,3 @@ export function MediaPanel() {
</>
);
}
-
-function EmptyMedia({
- onFileSelect,
- isProcessing,
-}: {
- onFileSelect: () => void;
- isProcessing: boolean;
-}) {
- return (
- <div className="flex flex-col items-center justify-center py-8 text-center h-full">
- <div className="w-16 h-16 rounded-full bg-muted/30 flex items-center justify-center mb-4">
- {isProcessing ? (
- <div className="animate-spin">
- <Upload className="h-8 w-8 text-muted-foreground" />
- </div>
- ) : (
- <Image className="h-8 w-8 text-muted-foreground" />
- )}
- </div>
- <p className="text-sm text-muted-foreground">
- {isProcessing ? "Processing files..." : "No media in project"}
- </p>
- <p className="text-xs text-muted-foreground/70 mt-1">
- {isProcessing
- ? "Please wait while files are being processed"
- : "Drag files or click to add media"}
- </p>
- {!isProcessing && (
- <Button
- variant="outline"
- size="sm"
- className="mt-4"
- onClick={onFileSelect}
- >
- <Plus className="h-4 w-4 mr-2" />
- Add Media
- </Button>
- )}
- </div>
- );
-}
diff --git a/apps/web/src/components/editor/preview-panel.tsx b/apps/web/src/components/editor/preview-panel.tsx
index 16c808f4..74bf5f98 100644
--- a/apps/web/src/components/editor/preview-panel.tsx
+++ b/apps/web/src/components/editor/preview-panel.tsx
@@ -2,66 +2,89 @@
import { useTimelineStore } from "@/stores/timeline-store";
import { useMediaStore } from "@/stores/media-store";
+import { usePlaybackStore } from "@/stores/playback-store";
import { ImageTimelineTreatment } from "@/components/ui/image-timeline-treatment";
+import { VideoPlayer } from "@/components/ui/video-player";
import { Button } from "@/components/ui/button";
import { Play, Pause } from "lucide-react";
-import { useState } from "react";
export function PreviewPanel() {
const { tracks } = useTimelineStore();
const { mediaItems } = useMediaStore();
- const [isPlaying, setIsPlaying] = useState(false);
+ const { isPlaying, toggle, currentTime } = usePlaybackStore();
- // Get the first clip from the first track for preview (simplified for now)
- const firstClip = tracks[0]?.clips[0];
- const firstMediaItem = firstClip
- ? mediaItems.find((item) => item.id === firstClip.mediaId)
+ // Find the active clip at the current playback time
+ const getActiveClip = () => {
+ for (const track of tracks) {
+ for (const clip of track.clips) {
+ const clipStart = clip.startTime;
+ const clipEnd = clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd);
+
+ if (currentTime >= clipStart && currentTime < clipEnd) {
+ return clip;
+ }
+ }
+ }
+ return null;
+ };
+
+ const activeClip = getActiveClip();
+ const activeMediaItem = activeClip
+ ? mediaItems.find((item) => item.id === activeClip.mediaId)
: null;
- // Calculate dynamic aspect ratio - default to 16:9 if no media
- const aspectRatio = firstMediaItem?.aspectRatio || 16 / 9;
+ const aspectRatio = activeMediaItem?.aspectRatio || 16 / 9;
- const renderPreviewContent = () => {
- if (!firstMediaItem) {
+ const renderContent = () => {
+ if (!activeMediaItem || !activeClip) {
return (
- <div className="absolute inset-0 flex items-center justify-center text-muted-foreground/50 group-hover:text-muted-foreground/80 transition-colors">
- Drop media here or click to import
+ <div className="absolute inset-0 flex items-center justify-center text-muted-foreground/50">
+ {tracks.length === 0 ? "Drop media to start editing" : "No clip at current time"}
</div>
);
}
- if (firstMediaItem.type === "image") {
+ // Calculate the relative time within the clip (accounting for trim)
+ const relativeTime = Math.max(0, currentTime - activeClip.startTime + activeClip.trimStart);
+
+ if (activeMediaItem.type === "video") {
return (
- <ImageTimelineTreatment
- src={firstMediaItem.url}
- alt={firstMediaItem.name}
- targetAspectRatio={aspectRatio}
- className="w-full h-full rounded-lg"
- backgroundType="blur"
+ <VideoPlayer
+ src={activeMediaItem.url}
+ poster={activeMediaItem.thumbnailUrl}
+ className="w-full h-full"
+ startTime={relativeTime}
+ key={`${activeClip.id}-${activeClip.trimStart}-${activeClip.trimEnd}`}
/>
);
}
- if (firstMediaItem.type === "video") {
- return firstMediaItem.thumbnailUrl ? (
- <img
- src={firstMediaItem.thumbnailUrl}
- alt={firstMediaItem.name}
- className="w-full h-full object-cover rounded-lg"
+ if (activeMediaItem.type === "image") {
+ return (
+ <ImageTimelineTreatment
+ src={activeMediaItem.url}
+ alt={activeMediaItem.name}
+ targetAspectRatio={aspectRatio}
+ className="w-full h-full"
+ backgroundType="blur"
/>
- ) : (
- <div className="absolute inset-0 flex items-center justify-center text-muted-foreground/50">
- Video Preview
- </div>
);
}
- if (firstMediaItem.type === "audio") {
+ if (activeMediaItem.type === "audio") {
return (
<div className="absolute inset-0 flex items-center justify-center bg-gradient-to-br from-green-500/20 to-emerald-500/20">
<div className="text-center">
<div className="text-6xl mb-4">🎵</div>
- <p className="text-muted-foreground">{firstMediaItem.name}</p>
+ <p className="text-muted-foreground">{activeMediaItem.name}</p>
+ <Button
+ variant="outline"
+ className="mt-4"
+ onClick={toggle}
+ >
+ {isPlaying ? <Pause className="h-4 w-4 mr-2" /> : <Play className="h-4 w-4 mr-2" />}
+ {isPlaying ? "Pause" : "Play"}
+ </Button>
</div>
</div>
);
@@ -73,7 +96,7 @@ export function PreviewPanel() {
return (
<div className="h-full flex flex-col items-center justify-center p-4 overflow-hidden">
<div
- className="bg-black/90 rounded-lg shadow-lg relative group overflow-hidden flex-shrink"
+ className="bg-black rounded-lg shadow-lg relative overflow-hidden flex-shrink-0"
style={{
aspectRatio: aspectRatio.toString(),
width: aspectRatio > 1 ? "100%" : "auto",
@@ -82,49 +105,16 @@ export function PreviewPanel() {
maxHeight: "100%",
}}
>
- {renderPreviewContent()}
-
- {/* Playback Controls Overlay */}
- {firstMediaItem && (
- <div className="absolute bottom-4 left-1/2 transform -translate-x-1/2 opacity-0 group-hover:opacity-100 transition-opacity">
- <div className="flex items-center gap-2 bg-black/80 rounded-lg px-4 py-2">
- <Button
- variant="ghost"
- size="icon"
- className="text-white hover:bg-white/20"
- onClick={() => setIsPlaying(!isPlaying)}
- >
- {isPlaying ? (
- <Pause className="h-5 w-5" />
- ) : (
- <Play className="h-5 w-5" />
- )}
- </Button>
- <span className="text-white text-sm">
- {firstClip?.name || "No clip selected"}
- </span>
- </div>
- </div>
- )}
+ {renderContent()}
</div>
- {/* Preview Info */}
- {firstMediaItem && (
+ {activeMediaItem && (
<div className="mt-4 text-center">
<p className="text-sm text-muted-foreground">
- Preview: {firstMediaItem.name}
- {firstMediaItem.type === "image" &&
- " (with CapCut-style treatment)"}
- <br />
- <span className="text-xs text-muted-foreground/70">
- Aspect Ratio: {aspectRatio.toFixed(2)} (
- {aspectRatio > 1
- ? "Landscape"
- : aspectRatio < 1
- ? "Portrait"
- : "Square"}
- )
- </span>
+ {activeMediaItem.name}
+ </p>
+ <p className="text-xs text-muted-foreground/70">
+ {aspectRatio.toFixed(2)} • {aspectRatio > 1 ? "Landscape" : aspectRatio < 1 ? "Portrait" : "Square"}
</p>
</div>
)}
diff --git a/apps/web/src/components/editor/timeline.tsx b/apps/web/src/components/editor/timeline.tsx
index 672122b7..7f7794a7 100644
--- a/apps/web/src/components/editor/timeline.tsx
+++ b/apps/web/src/components/editor/timeline.tsx
@@ -20,17 +20,21 @@ import {
import { DragOverlay } from "../ui/drag-overlay";
import { useTimelineStore, type TimelineTrack } from "@/stores/timeline-store";
import { useMediaStore } from "@/stores/media-store";
+import { usePlaybackStore } from "@/stores/playback-store";
import { processMediaFiles } from "@/lib/media-processing";
import { ImageTimelineTreatment } from "@/components/ui/image-timeline-treatment";
import { toast } from "sonner";
-import { useState, useRef } from "react";
+import { useState, useRef, useEffect } from "react";
export function Timeline() {
const { tracks, addTrack, addClipToTrack } = useTimelineStore();
const { mediaItems, addMediaItem } = useMediaStore();
+ const { currentTime, duration, seek } = usePlaybackStore();
const [isDragOver, setIsDragOver] = useState(false);
const [isProcessing, setIsProcessing] = useState(false);
+ const [zoomLevel, setZoomLevel] = useState(1);
const dragCounterRef = useRef(0);
+ const timelineRef = useRef<HTMLDivElement>(null);
const handleDragEnter = (e: React.DragEvent) => {
e.preventDefault();
@@ -69,97 +73,62 @@ export function Timeline() {
setIsDragOver(false);
dragCounterRef.current = 0;
- // Check if this is a timeline clip drop - now we'll handle it!
- const timelineClipData = e.dataTransfer.getData(
- "application/x-timeline-clip"
- );
- if (timelineClipData) {
- // Timeline clips dropped on the main timeline area (not on a specific track)
- // For now, we'll just ignore these - clips should be dropped on specific tracks
- return;
- }
-
- // Check if this is an internal media item drop
const mediaItemData = e.dataTransfer.getData("application/x-media-item");
if (mediaItemData) {
+ // Handle media item drops by creating new tracks
try {
- const { id, type, name } = JSON.parse(mediaItemData);
-
- // Find the full media item from the store
+ const { id, type } = JSON.parse(mediaItemData);
const mediaItem = mediaItems.find((item) => item.id === id);
+
if (!mediaItem) {
toast.error("Media item not found");
return;
}
- // Determine track type based on media type
- let trackType: "video" | "audio" | "effects";
- if (type === "video") {
- trackType = "video";
- } else if (type === "audio") {
- trackType = "audio";
- } else {
- // For images, we'll put them on video tracks
- trackType = "video";
- }
-
- // Create a new track and get its ID
+ const trackType = type === "audio" ? "audio" : "video";
const newTrackId = addTrack(trackType);
- // Add the clip to the new track
addClipToTrack(newTrackId, {
mediaId: mediaItem.id,
name: mediaItem.name,
- duration: mediaItem.duration || 5, // Default 5 seconds for images
+ duration: mediaItem.duration || 5,
+ startTime: 0,
+ trimStart: 0,
+ trimEnd: 0,
});
- toast.success(`Added ${name} to ${trackType} track`);
+ toast.success(`Added ${mediaItem.name} to new ${trackType} track`);
} catch (error) {
console.error("Error parsing media item data:", error);
toast.error("Failed to add media to timeline");
}
- } else if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
- // Handle external file drops
+ } else if (e.dataTransfer.files?.length > 0) {
+ // Handle file drops by creating new tracks
setIsProcessing(true);
try {
const processedItems = await processMediaFiles(e.dataTransfer.files);
for (const processedItem of processedItems) {
- // Add to media store first
addMediaItem(processedItem);
- // The media item now has an ID, let's get it from the latest state
- // Since addMediaItem is synchronous, we can get the latest item
const currentMediaItems = useMediaStore.getState().mediaItems;
const addedItem = currentMediaItems.find(
- (item) =>
- item.name === processedItem.name && item.url === processedItem.url
+ (item) => item.name === processedItem.name && item.url === processedItem.url
);
if (addedItem) {
- // Determine track type based on media type
- let trackType: "video" | "audio" | "effects";
- if (processedItem.type === "video") {
- trackType = "video";
- } else if (processedItem.type === "audio") {
- trackType = "audio";
- } else {
- // For images, we'll put them on video tracks
- trackType = "video";
- }
-
- // Create a new track and get its ID
+ const trackType = processedItem.type === "audio" ? "audio" : "video";
const newTrackId = addTrack(trackType);
- // Add the clip to the new track
addClipToTrack(newTrackId, {
mediaId: addedItem.id,
name: addedItem.name,
- duration: addedItem.duration || 5, // Default 5 seconds for images
+ duration: addedItem.duration || 5,
+ startTime: 0,
+ trimStart: 0,
+ trimEnd: 0,
});
-
- toast.success(`Added ${processedItem.name} to timeline`);
}
}
} catch (error) {
@@ -171,6 +140,25 @@ export function Timeline() {
}
};
+ const handleTimelineClick = (e: React.MouseEvent) => {
+ const timeline = timelineRef.current;
+ if (!timeline || duration === 0) return;
+
+ const rect = timeline.getBoundingClientRect();
+ const x = e.clientX - rect.left;
+ const timelineWidth = rect.width;
+ const visibleDuration = duration / zoomLevel;
+ const clickedTime = (x / timelineWidth) * visibleDuration;
+
+ seek(Math.max(0, Math.min(duration, clickedTime)));
+ };
+
+ const handleWheel = (e: React.WheelEvent) => {
+ e.preventDefault();
+ const delta = e.deltaY > 0 ? -0.05 : 0.05;
+ setZoomLevel(prev => Math.max(0.1, Math.min(10, prev + delta)));
+ };
+
const dragProps = {
onDragEnter: handleDragEnter,
onDragOver: handleDragOver,
@@ -180,20 +168,10 @@ export function Timeline() {
return (
<div
- className={`h-full flex flex-col transition-colors duration-200 relative ${
- isDragOver ? "bg-accent/30 border-accent" : ""
- }`}
+ className={`h-full flex flex-col transition-colors duration-200 relative ${isDragOver ? "bg-accent/30 border-accent" : ""
+ }`}
{...dragProps}
>
- <DragOverlay
- isVisible={isDragOver}
- title={isProcessing ? "Processing files..." : "Drop media here"}
- description={
- isProcessing
- ? "Please wait while files are being processed"
- : "Add media to timeline tracks"
- }
- />
{/* Toolbar */}
<div className="border-b flex items-center px-2 py-1 gap-1">
@@ -263,187 +241,544 @@ export function Timeline() {
</TooltipProvider>
</div>
- {/* Tracks Area */}
- <ScrollArea className="flex-1">
- <div className="min-w-[800px]">
- {/* Time Markers */}
- <div className="py-2 pt-1 flex items-center">
- {Array.from({ length: 16 }).map((_, i) => (
+ {/* Timeline Container */}
+ <div className="flex-1 flex flex-col overflow-hidden">
+ {/* Timeline Header with Ruler */}
+ <div className="flex border-b bg-background sticky top-0 z-10">
+ {/* Track Labels Header */}
+ <div className="w-48 flex-shrink-0 bg-muted/30 border-r flex items-center justify-between px-3 py-2">
+ <span className="text-sm font-medium text-muted-foreground">Tracks</span>
+ <div className="text-xs text-muted-foreground">
+ {zoomLevel.toFixed(1)}x
+ </div>
+ </div>
+
+ {/* Timeline Ruler */}
+ <div className="flex-1 relative overflow-hidden">
+ <ScrollArea className="w-full">
<div
- key={i}
- className="w-[50px] flex items-end justify-center text-xs text-muted-foreground"
+ className="relative h-12 bg-muted/30"
+ style={{ width: `${Math.max(1000, duration * 50 * zoomLevel)}px` }}
>
- {i}s
+ {/* Time markers */}
+ {(() => {
+ // Calculate appropriate time interval based on zoom level
+ const getTimeInterval = (zoom: number) => {
+ const pixelsPerSecond = 50 * zoom;
+ if (pixelsPerSecond >= 200) return 0.1; // Every 0.1s when very zoomed in
+ if (pixelsPerSecond >= 100) return 0.5; // Every 0.5s when zoomed in
+ if (pixelsPerSecond >= 50) return 1; // Every 1s at normal zoom
+ if (pixelsPerSecond >= 25) return 2; // Every 2s when zoomed out
+ if (pixelsPerSecond >= 12) return 5; // Every 5s when more zoomed out
+ if (pixelsPerSecond >= 6) return 10; // Every 10s when very zoomed out
+ return 30; // Every 30s when extremely zoomed out
+ };
+
+ const interval = getTimeInterval(zoomLevel);
+ const markerCount = Math.ceil(duration / interval) + 1;
+
+ return Array.from({ length: markerCount }, (_, i) => {
+ const time = i * interval;
+ if (time > duration) return null;
+
+ const isMainMarker = time % (interval >= 1 ? Math.max(1, interval) : 1) === 0;
+
+ return (
+ <div
+ key={i}
+ className={`absolute top-0 bottom-0 ${isMainMarker
+ ? 'border-l border-muted-foreground/40'
+ : 'border-l border-muted-foreground/20'
+ }`}
+ style={{ left: `${time * 50 * zoomLevel}px` }}
+ >
+ <span className={`absolute top-1 left-1 text-xs ${isMainMarker
+ ? 'text-muted-foreground font-medium'
+ : 'text-muted-foreground/70'
+ }`}>
+ {(() => {
+ const formatTime = (seconds: number) => {
+ const hours = Math.floor(seconds / 3600);
+ const minutes = Math.floor((seconds % 3600) / 60);
+ const secs = seconds % 60;
+
+ if (hours > 0) {
+ return `${hours}:${minutes.toString().padStart(2, '0')}:${Math.floor(secs).toString().padStart(2, '0')}`;
+ } else if (minutes > 0) {
+ return `${minutes}:${Math.floor(secs).toString().padStart(2, '0')}`;
+ } else if (interval >= 1) {
+ return `${Math.floor(secs)}s`;
+ } else {
+ return `${secs.toFixed(1)}s`;
+ }
+ };
+ return formatTime(time);
+ })()}
+ </span>
+ </div>
+ );
+ }).filter(Boolean);
+ })()}
+
+ {/* Playhead in ruler */}
+ <div
+ className="absolute top-0 bottom-0 w-0.5 bg-red-500 pointer-events-none z-10"
+ style={{ left: `${currentTime * 50 * zoomLevel}px` }}
+ >
+ <div className="absolute top-1 left-1/2 transform -translate-x-1/2 w-3 h-3 bg-red-500 rounded-full border-2 border-white shadow-sm" />
+ </div>
</div>
- ))}
+ </ScrollArea>
</div>
+ </div>
- {/* Timeline Tracks */}
- {tracks.length === 0 ? (
- <div className="flex flex-col items-center justify-center py-12 text-center">
- <div className="w-16 h-16 rounded-full bg-muted/30 flex items-center justify-center mb-4">
- <SplitSquareHorizontal className="h-8 w-8 text-muted-foreground" />
+ {/* Tracks Area */}
+ <div className="flex-1 flex overflow-hidden">
+ {/* Track Labels */}
+ <div className="w-48 flex-shrink-0 border-r bg-background overflow-y-auto">
+ {tracks.length === 0 ? (
+ <div className="flex flex-col items-center justify-center h-full py-8 text-center px-4">
+ <div className="w-12 h-12 rounded-full bg-muted/30 flex items-center justify-center mb-3">
+ <SplitSquareHorizontal className="h-6 w-6 text-muted-foreground" />
+ </div>
+ <p className="text-sm text-muted-foreground">No tracks</p>
+ <p className="text-xs text-muted-foreground/70 mt-1">
+ Drop media to create tracks
+ </p>
</div>
- <p className="text-sm text-muted-foreground">
- No tracks in timeline
- </p>
- <p className="text-xs text-muted-foreground/70 mt-1">
- Add a video or audio track to get started
- </p>
- </div>
- ) : (
- <div className="flex flex-col gap-2.5">
- {tracks.map((track) => (
- <TimelineTrackComponent key={track.id} track={track} />
- ))}
- </div>
- )}
+ ) : (
+ <div className="flex flex-col">
+ {tracks.map((track) => (
+ <div
+ key={track.id}
+ className="h-[60px] flex items-center px-3 border-b border-muted/30 bg-background"
+ >
+ <div className="flex items-center gap-2 flex-1 min-w-0">
+ <div className={`w-3 h-3 rounded-full flex-shrink-0 ${track.type === 'video' ? 'bg-blue-500' :
+ track.type === 'audio' ? 'bg-green-500' : 'bg-purple-500'
+ }`} />
+ <span className="text-sm font-medium truncate">{track.name}</span>
+ </div>
+ </div>
+ ))}
+ </div>
+ )}
+ </div>
+
+ {/* Timeline Tracks Content */}
+ <div className="flex-1 relative">
+ <ScrollArea className="h-full w-full">
+ <div
+ ref={timelineRef}
+ className="relative cursor-pointer select-none"
+ style={{
+ width: `${Math.max(1000, duration * 50 * zoomLevel)}px`,
+ minHeight: tracks.length > 0 ? `${tracks.length * 60}px` : '200px'
+ }}
+ onClick={handleTimelineClick}
+ onWheel={handleWheel}
+ >
+ {tracks.length === 0 ? (
+ <div className="absolute inset-0 flex items-center justify-center">
+ <div className="text-center">
+ <div className="w-16 h-16 rounded-full bg-muted/30 flex items-center justify-center mb-4 mx-auto">
+ <SplitSquareHorizontal className="h-8 w-8 text-muted-foreground" />
+ </div>
+ <p className="text-sm text-muted-foreground">Drop media here to start</p>
+ </div>
+ </div>
+ ) : (
+ <>
+ {tracks.map((track, index) => (
+ <div
+ key={track.id}
+ className="absolute left-0 right-0 border-b border-muted/30"
+ style={{
+ top: `${index * 60}px`,
+ height: '60px'
+ }}
+ >
+ <TimelineTrackContent track={track} zoomLevel={zoomLevel} />
+ </div>
+ ))}
+
+ {/* Playhead for tracks area */}
+ <div
+ className="absolute top-0 w-0.5 bg-red-500 pointer-events-none z-20"
+ style={{
+ left: `${currentTime * 50 * zoomLevel}px`,
+ height: `${tracks.length * 60}px`
+ }}
+ />
+ </>
+ )}
+ </div>
+ </ScrollArea>
+ </div>
</div>
- </ScrollArea>
+ </div>
</div>
);
}
-function TimelineTrackComponent({ track }: { track: TimelineTrack }) {
+function TimelineTrackContent({ track, zoomLevel }: { track: TimelineTrack, zoomLevel: number }) {
const { mediaItems } = useMediaStore();
- const { moveClipToTrack, reorderClipInTrack } = useTimelineStore();
+ const { tracks, moveClipToTrack, updateClipTrim, updateClipStartTime, addClipToTrack } = useTimelineStore();
const [isDropping, setIsDropping] = useState(false);
+ const [dropPosition, setDropPosition] = useState<number | null>(null);
+ const [isDraggedOver, setIsDraggedOver] = useState(false);
+ const [wouldOverlap, setWouldOverlap] = useState(false);
+ const [resizing, setResizing] = useState<{
+ clipId: string;
+ side: 'left' | 'right';
+ startX: number;
+ initialTrimStart: number;
+ initialTrimEnd: number;
+ } | null>(null);
+ const dragCounterRef = useRef(0);
- const handleClipDragStart = (e: React.DragEvent, clip: any) => {
- // Mark this as an timeline clip drag to differentiate from media items
- const dragData = {
- clipId: clip.id,
- trackId: track.id,
- name: clip.name,
+ const handleResizeStart = (e: React.MouseEvent, clipId: string, side: 'left' | 'right') => {
+ e.stopPropagation();
+ e.preventDefault();
+
+ const clip = track.clips.find(c => c.id === clipId);
+ if (!clip) return;
+
+ setResizing({
+ clipId,
+ side,
+ startX: e.clientX,
+ initialTrimStart: clip.trimStart,
+ initialTrimEnd: clip.trimEnd
+ });
+ };
+
+ const updateTrimFromMouseMove = (e: { clientX: number }) => {
+ if (!resizing) return;
+
+ const clip = track.clips.find(c => c.id === resizing.clipId);
+ if (!clip) return;
+
+ const deltaX = e.clientX - resizing.startX;
+ const deltaTime = deltaX / (50 * zoomLevel);
+
+ if (resizing.side === 'left') {
+ const newTrimStart = Math.max(0, Math.min(
+ clip.duration - clip.trimEnd - 0.1,
+ resizing.initialTrimStart + deltaTime
+ ));
+ updateClipTrim(track.id, clip.id, newTrimStart, clip.trimEnd);
+ } else {
+ const newTrimEnd = Math.max(0, Math.min(
+ clip.duration - clip.trimStart - 0.1,
+ resizing.initialTrimEnd - deltaTime
+ ));
+ updateClipTrim(track.id, clip.id, clip.trimStart, newTrimEnd);
+ }
+ };
+
+ const handleResizeMove = (e: React.MouseEvent) => {
+ updateTrimFromMouseMove(e);
+ };
+
+ const handleResizeEnd = () => {
+ setResizing(null);
+ };
+
+ useEffect(() => {
+ if (!resizing) return;
+
+ const handleGlobalMouseMove = (e: MouseEvent) => {
+ updateTrimFromMouseMove(e);
};
- e.dataTransfer.setData(
- "application/x-timeline-clip",
- JSON.stringify(dragData)
- );
+ const handleGlobalMouseUp = () => {
+ setResizing(null);
+ };
+
+ document.addEventListener('mousemove', handleGlobalMouseMove);
+ document.addEventListener('mouseup', handleGlobalMouseUp);
+
+ return () => {
+ document.removeEventListener('mousemove', handleGlobalMouseMove);
+ document.removeEventListener('mouseup', handleGlobalMouseUp);
+ };
+ }, [resizing, track.id, zoomLevel, updateClipTrim]);
+
+ const handleClipDragStart = (e: React.DragEvent, clip: any) => {
+ const dragData = { clipId: clip.id, trackId: track.id, name: clip.name };
+
+ e.dataTransfer.setData("application/x-timeline-clip", JSON.stringify(dragData));
e.dataTransfer.effectAllowed = "move";
- // Use the entire clip container as the drag image instead of just the content
- const target = e.currentTarget as HTMLElement;
- e.dataTransfer.setDragImage(
- target,
- target.offsetWidth / 2,
- target.offsetHeight / 2
- );
+ // Add visual feedback to the dragged element
+ const target = e.currentTarget.parentElement as HTMLElement;
+ target.style.opacity = "0.5";
+ target.style.transform = "scale(0.95)";
+ };
+
+ const handleClipDragEnd = (e: React.DragEvent) => {
+ // Reset visual feedback
+ const target = e.currentTarget.parentElement as HTMLElement;
+ target.style.opacity = "";
+ target.style.transform = "";
};
const handleTrackDragOver = (e: React.DragEvent) => {
e.preventDefault();
- // Only handle timeline clip drags
- if (!e.dataTransfer.types.includes("application/x-timeline-clip")) {
+ // Handle both timeline clips and media items
+ const hasTimelineClip = e.dataTransfer.types.includes("application/x-timeline-clip");
+ const hasMediaItem = e.dataTransfer.types.includes("application/x-media-item");
+
+ if (!hasTimelineClip && !hasMediaItem) return;
+
+ if (hasMediaItem) {
+ try {
+ const mediaItemData = e.dataTransfer.getData("application/x-media-item");
+ if (mediaItemData) {
+ const { type } = JSON.parse(mediaItemData);
+ const isCompatible =
+ (track.type === "video" && (type === "video" || type === "image")) ||
+ (track.type === "audio" && type === "audio");
+
+ if (!isCompatible) {
+ e.dataTransfer.dropEffect = "none";
+ return;
+ }
+ }
+ } catch (error) {
+ }
+ }
+
+ // Calculate drop position for overlap checking
+ const trackContainer = e.currentTarget.querySelector(".track-clips-container") as HTMLElement;
+ let dropTime = 0;
+ if (trackContainer) {
+ const rect = trackContainer.getBoundingClientRect();
+ const mouseX = Math.max(0, e.clientX - rect.left);
+ dropTime = mouseX / (50 * zoomLevel);
+ }
+
+ // Check for potential overlaps and show appropriate feedback
+ let wouldOverlap = false;
+
+ if (hasMediaItem) {
+ try {
+ const mediaItemData = e.dataTransfer.getData("application/x-media-item");
+ if (mediaItemData) {
+ const { id } = JSON.parse(mediaItemData);
+ const mediaItem = mediaItems.find((item) => item.id === id);
+ if (mediaItem) {
+ const newClipDuration = mediaItem.duration || 5;
+ const snappedTime = Math.round(dropTime * 10) / 10;
+ const newClipEnd = snappedTime + newClipDuration;
+
+ wouldOverlap = track.clips.some(existingClip => {
+ const existingStart = existingClip.startTime;
+ const existingEnd = existingClip.startTime + (existingClip.duration - existingClip.trimStart - existingClip.trimEnd);
+ return (snappedTime < existingEnd && newClipEnd > existingStart);
+ });
+ }
+ }
+ } catch (error) {
+ // Continue with default behavior
+ }
+ } else if (hasTimelineClip) {
+ try {
+ const timelineClipData = e.dataTransfer.getData("application/x-timeline-clip");
+ if (timelineClipData) {
+ const { clipId, trackId: fromTrackId } = JSON.parse(timelineClipData);
+ const sourceTrack = tracks.find((t: TimelineTrack) => t.id === fromTrackId);
+ const movingClip = sourceTrack?.clips.find((c: any) => c.id === clipId);
+
+ if (movingClip) {
+ const movingClipDuration = movingClip.duration - movingClip.trimStart - movingClip.trimEnd;
+ const snappedTime = Math.round(dropTime * 10) / 10;
+ const movingClipEnd = snappedTime + movingClipDuration;
+
+ wouldOverlap = track.clips.some(existingClip => {
+ if (fromTrackId === track.id && existingClip.id === clipId) return false;
+
+ const existingStart = existingClip.startTime;
+ const existingEnd = existingClip.startTime + (existingClip.duration - existingClip.trimStart - existingClip.trimEnd);
+ return (snappedTime < existingEnd && movingClipEnd > existingStart);
+ });
+ }
+ }
+ } catch (error) {
+ // Continue with default behavior
+ }
+ }
+
+ if (wouldOverlap) {
+ e.dataTransfer.dropEffect = "none";
+ setIsDraggedOver(true);
+ setWouldOverlap(true);
+ setDropPosition(Math.round(dropTime * 10) / 10);
return;
}
- e.dataTransfer.dropEffect = "move";
+ e.dataTransfer.dropEffect = hasTimelineClip ? "move" : "copy";
+ setIsDraggedOver(true);
+ setWouldOverlap(false);
+ setDropPosition(Math.round(dropTime * 10) / 10);
};
const handleTrackDragEnter = (e: React.DragEvent) => {
e.preventDefault();
- // Only handle timeline clip drags
- if (!e.dataTransfer.types.includes("application/x-timeline-clip")) {
- return;
- }
+ const hasTimelineClip = e.dataTransfer.types.includes("application/x-timeline-clip");
+ const hasMediaItem = e.dataTransfer.types.includes("application/x-media-item");
+ if (!hasTimelineClip && !hasMediaItem) return;
+
+ dragCounterRef.current++;
setIsDropping(true);
+ setIsDraggedOver(true);
};
const handleTrackDragLeave = (e: React.DragEvent) => {
e.preventDefault();
- // Only handle timeline clip drags
- if (!e.dataTransfer.types.includes("application/x-timeline-clip")) {
- return;
- }
+ const hasTimelineClip = e.dataTransfer.types.includes("application/x-timeline-clip");
+ const hasMediaItem = e.dataTransfer.types.includes("application/x-media-item");
- // Check if we're actually leaving the track area
- const rect = e.currentTarget.getBoundingClientRect();
- const x = e.clientX;
- const y = e.clientY;
+ if (!hasTimelineClip && !hasMediaItem) return;
- const isActuallyLeaving =
- x < rect.left || x > rect.right || y < rect.top || y > rect.bottom;
+ dragCounterRef.current--;
- if (isActuallyLeaving) {
+ if (dragCounterRef.current === 0) {
setIsDropping(false);
+ setIsDraggedOver(false);
+ setWouldOverlap(false);
+ setDropPosition(null);
}
};
const handleTrackDrop = (e: React.DragEvent) => {
e.preventDefault();
+
+ // Reset all drag states
+ dragCounterRef.current = 0;
setIsDropping(false);
+ setIsDraggedOver(false);
+ setWouldOverlap(false);
+ const currentDropPosition = dropPosition;
+ setDropPosition(null);
- // Only handle timeline clip drags
- if (!e.dataTransfer.types.includes("application/x-timeline-clip")) {
- return;
- }
+ const hasTimelineClip = e.dataTransfer.types.includes("application/x-timeline-clip");
+ const hasMediaItem = e.dataTransfer.types.includes("application/x-media-item");
- const timelineClipData = e.dataTransfer.getData(
- "application/x-timeline-clip"
- );
+ if (!hasTimelineClip && !hasMediaItem) return;
- if (!timelineClipData) {
- return;
- }
+ const trackContainer = e.currentTarget.querySelector(".track-clips-container") as HTMLElement;
+ if (!trackContainer) return;
+
+ const rect = trackContainer.getBoundingClientRect();
+ const mouseX = Math.max(0, e.clientX - rect.left);
+ const newStartTime = mouseX / (50 * zoomLevel);
+ const snappedTime = Math.round(newStartTime * 10) / 10;
try {
- const parsedData = JSON.parse(timelineClipData);
- const { clipId, trackId: fromTrackId } = parsedData;
+ if (hasTimelineClip) {
+ // Handle timeline clip movement
+ const timelineClipData = e.dataTransfer.getData("application/x-timeline-clip");
+ if (!timelineClipData) return;
- // Calculate where to insert the clip based on mouse position
- const trackContainer = e.currentTarget.querySelector(
- ".track-clips-container"
- ) as HTMLElement;
+ const { clipId, trackId: fromTrackId } = JSON.parse(timelineClipData);
- if (!trackContainer) {
- return;
- }
+ // Find the clip being moved
+ const sourceTrack = tracks.find((t: TimelineTrack) => t.id === fromTrackId);
+ const movingClip = sourceTrack?.clips.find((c: any) => c.id === clipId);
- const rect = trackContainer.getBoundingClientRect();
- const mouseX = e.clientX - rect.left;
+ if (!movingClip) {
+ toast.error("Clip not found");
+ return;
+ }
+
+ // Check for overlaps with existing clips (excluding the moving clip itself)
+ const movingClipDuration = movingClip.duration - movingClip.trimStart - movingClip.trimEnd;
+ const movingClipEnd = snappedTime + movingClipDuration;
+
+ const hasOverlap = track.clips.some(existingClip => {
+ // Skip the clip being moved if it's on the same track
+ if (fromTrackId === track.id && existingClip.id === clipId) return false;
- // Calculate insertion index based on position
- let insertIndex = 0;
- const clipElements = trackContainer.querySelectorAll(".timeline-clip");
+ const existingStart = existingClip.startTime;
+ const existingEnd = existingClip.startTime + (existingClip.duration - existingClip.trimStart - existingClip.trimEnd);
+
+ // Check if clips overlap
+ return (snappedTime < existingEnd && movingClipEnd > existingStart);
+ });
- for (let i = 0; i < clipElements.length; i++) {
- const clipRect = clipElements[i].getBoundingClientRect();
- const clipCenterX = clipRect.left + clipRect.width / 2 - rect.left;
+ if (hasOverlap) {
+ toast.error("Cannot move clip here - it would overlap with existing clips");
+ return;
+ }
- if (mouseX > clipCenterX) {
- insertIndex = i + 1;
+ if (fromTrackId === track.id) {
+ updateClipStartTime(track.id, clipId, snappedTime);
} else {
- break;
+ moveClipToTrack(fromTrackId, track.id, clipId);
+ requestAnimationFrame(() => {
+ updateClipStartTime(track.id, clipId, snappedTime);
+ });
}
- }
+ } else if (hasMediaItem) {
+ // Handle media item drop
+ const mediaItemData = e.dataTransfer.getData("application/x-media-item");
+ if (!mediaItemData) return;
+
+ const { id, type } = JSON.parse(mediaItemData);
+ const mediaItem = mediaItems.find((item) => item.id === id);
+
+ if (!mediaItem) {
+ toast.error("Media item not found");
+ return;
+ }
+
+ // Check if track type is compatible
+ const isCompatible =
+ (track.type === "video" && (type === "video" || type === "image")) ||
+ (track.type === "audio" && type === "audio");
+
+ if (!isCompatible) {
+ toast.error(`Cannot add ${type} to ${track.type} track`);
+ return;
+ }
+
+ // Check for overlaps with existing clips
+ const newClipDuration = mediaItem.duration || 5;
+ const newClipEnd = snappedTime + newClipDuration;
- if (fromTrackId === track.id) {
- // Moving within the same track - reorder
- const currentIndex = track.clips.findIndex(
- (clip) => clip.id === clipId
- );
+ const hasOverlap = track.clips.some(existingClip => {
+ const existingStart = existingClip.startTime;
+ const existingEnd = existingClip.startTime + (existingClip.duration - existingClip.trimStart - existingClip.trimEnd);
- if (currentIndex !== -1 && currentIndex !== insertIndex) {
- // Adjust index if we're moving to a position after the current one
- const adjustedIndex =
- insertIndex > currentIndex ? insertIndex - 1 : insertIndex;
+ // Check if clips overlap
+ return (snappedTime < existingEnd && newClipEnd > existingStart);
+ });
- reorderClipInTrack(track.id, clipId, adjustedIndex);
+ if (hasOverlap) {
+ toast.error("Cannot place clip here - it would overlap with existing clips");
+ return;
}
- } else {
- // Moving between different tracks
- moveClipToTrack(fromTrackId, track.id, clipId, insertIndex);
- toast.success("Clip moved to different track");
+
+ addClipToTrack(track.id, {
+ mediaId: mediaItem.id,
+ name: mediaItem.name,
+ duration: mediaItem.duration || 5,
+ startTime: snappedTime,
+ trimStart: 0,
+ trimEnd: 0,
+ });
+
+ toast.success(`Added ${mediaItem.name} to ${track.name}`);
}
} catch (error) {
- console.error("Error moving clip:", error);
- toast.error("Failed to move clip");
+ console.error("Error handling drop:", error);
+ toast.error("Failed to add media to track");
}
};
@@ -505,41 +840,89 @@ function TimelineTrackComponent({ track }: { track: TimelineTrack }) {
};
return (
- <div className="flex items-center px-2">
- <div className="w-24 text-xs text-muted-foreground flex-shrink-0 mr-2">
- {track.name}
- </div>
-
- <div
- className={`flex-1 h-[60px] transition-colors ${
- isDropping ? "bg-accent/50 border-2 border-dashed border-accent" : ""
+ <div
+ className={`w-full h-full transition-all duration-150 ease-out ${isDraggedOver
+ ? wouldOverlap
+ ? "bg-red-500/15 border-2 border-dashed border-red-400 shadow-lg"
+ : "bg-blue-500/15 border-2 border-dashed border-blue-400 shadow-lg"
+ : "hover:bg-muted/20"
}`}
- onDragOver={handleTrackDragOver}
- onDragEnter={handleTrackDragEnter}
- onDragLeave={handleTrackDragLeave}
- onDrop={handleTrackDrop}
- >
- <div className="h-full flex gap-1 track-clips-container">
- {track.clips.length === 0 ? (
- <div className="h-full w-full rounded-sm border-2 border-dashed border-muted/30 flex items-center justify-center text-xs text-muted-foreground">
- Drop media here
- </div>
- ) : (
- track.clips.map((clip, index) => (
+ onDragOver={handleTrackDragOver}
+ onDragEnter={handleTrackDragEnter}
+ onDragLeave={handleTrackDragLeave}
+ onDrop={handleTrackDrop}
+ onMouseMove={handleResizeMove}
+ onMouseUp={handleResizeEnd}
+ onMouseLeave={handleResizeEnd}
+ >
+ <div className="h-full relative track-clips-container min-w-full">
+ {track.clips.length === 0 ? (
+ <div className={`h-full w-full rounded-sm border-2 border-dashed flex items-center justify-center text-xs text-muted-foreground transition-colors ${isDropping
+ ? wouldOverlap
+ ? "border-red-500 bg-red-500/10 text-red-600"
+ : "border-blue-500 bg-blue-500/10 text-blue-600"
+ : "border-muted/30"
+ }`}>
+ {isDropping
+ ? wouldOverlap
+ ? "Cannot drop - would overlap"
+ : "Drop clip here"
+ : "Drop media here"}
+ </div>
+ ) : (
+ <>
+ {track.clips.map((clip) => {
+ const effectiveDuration = clip.duration - clip.trimStart - clip.trimEnd;
+ const clipWidth = Math.max(80, effectiveDuration * 50 * zoomLevel);
+ const clipLeft = clip.startTime * 50 * zoomLevel;
+
+ return (
+ <div
+ key={clip.id}
+ className={`timeline-clip absolute h-full rounded-sm border transition-all duration-200 ${getTrackColor(track.type)} flex items-center py-3 min-w-[80px] overflow-hidden group hover:shadow-lg`}
+ style={{ width: `${clipWidth}px`, left: `${clipLeft}px` }}
+ >
+ <div
+ className="absolute left-0 top-0 bottom-0 w-2 cursor-w-resize opacity-0 group-hover:opacity-100 transition-opacity bg-blue-500/50 hover:bg-blue-500"
+ onMouseDown={(e) => handleResizeStart(e, clip.id, 'left')}
+ />
+
+ <div
+ className="flex-1 cursor-grab active:cursor-grabbing"
+ draggable={true}
+ onDragStart={(e) => handleClipDragStart(e, clip)}
+ onDragEnd={handleClipDragEnd}
+ >
+ {renderClipContent(clip)}
+ </div>
+
+ <div
+ className="absolute right-0 top-0 bottom-0 w-2 cursor-e-resize opacity-0 group-hover:opacity-100 transition-opacity bg-blue-500/50 hover:bg-blue-500"
+ onMouseDown={(e) => handleResizeStart(e, clip.id, 'right')}
+ />
+ </div>
+ );
+ })}
+
+ {/* Drop position indicator */}
+ {isDraggedOver && dropPosition !== null && (
<div
- key={clip.id}
- className={`timeline-clip h-full rounded-sm border cursor-grab active:cursor-grabbing transition-colors ${getTrackColor(track.type)} flex items-center py-3 min-w-[80px] overflow-hidden`}
- style={{
- width: `${Math.max(80, clip.duration * 50)}px`,
- }}
- draggable={true}
- onDragStart={(e) => handleClipDragStart(e, clip)}
+ className={`absolute top-0 bottom-0 w-1 pointer-events-none z-30 transition-all duration-75 ease-out ${wouldOverlap ? "bg-red-500" : "bg-blue-500"
+ }`}
+ style={{ left: `${dropPosition * 50 * zoomLevel}px`, transform: 'translateX(-50%)' }}
>
- {renderClipContent(clip)}
+ <div className={`absolute -top-2 left-1/2 transform -translate-x-1/2 w-3 h-3 rounded-full border-2 border-white shadow-md ${wouldOverlap ? "bg-red-500" : "bg-blue-500"
+ }`} />
+ <div className={`absolute -bottom-2 left-1/2 transform -translate-x-1/2 w-3 h-3 rounded-full border-2 border-white shadow-md ${wouldOverlap ? "bg-red-500" : "bg-blue-500"
+ }`} />
+ <div className={`absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 text-xs text-white px-1 py-0.5 rounded whitespace-nowrap ${wouldOverlap ? "bg-red-500" : "bg-blue-500"
+ }`}>
+ {wouldOverlap ? "⚠️" : ""}{dropPosition.toFixed(1)}s
+ </div>
</div>
- ))
- )}
- </div>
+ )}
+ </>
+ )}
</div>
</div>
);