diff options
Diffstat (limited to 'apps/web/src/components/editor')
| -rw-r--r-- | apps/web/src/components/editor/media-panel.tsx | 285 | ||||
| -rw-r--r-- | apps/web/src/components/editor/preview-panel.tsx | 132 | ||||
| -rw-r--r-- | apps/web/src/components/editor/timeline.tsx | 803 |
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>
);
|
