summaryrefslogtreecommitdiff
path: root/apps/web/src
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/src')
-rw-r--r--apps/web/src/app/editor/page.tsx4
-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
-rw-r--r--apps/web/src/components/ui/accordion.tsx2
-rw-r--r--apps/web/src/components/ui/alert-dialog.tsx2
-rw-r--r--apps/web/src/components/ui/aspect-ratio.tsx2
-rw-r--r--apps/web/src/components/ui/avatar.tsx2
-rw-r--r--apps/web/src/components/ui/breadcrumb.tsx4
-rw-r--r--apps/web/src/components/ui/button.tsx4
-rw-r--r--apps/web/src/components/ui/checkbox.tsx2
-rw-r--r--apps/web/src/components/ui/collapsible.tsx2
-rw-r--r--apps/web/src/components/ui/command.tsx2
-rw-r--r--apps/web/src/components/ui/context-menu.tsx2
-rw-r--r--apps/web/src/components/ui/dialog.tsx2
-rw-r--r--apps/web/src/components/ui/dropdown-menu.tsx2
-rw-r--r--apps/web/src/components/ui/form.tsx10
-rw-r--r--apps/web/src/components/ui/hover-card.tsx2
-rw-r--r--apps/web/src/components/ui/input.tsx2
-rw-r--r--apps/web/src/components/ui/label.tsx2
-rw-r--r--apps/web/src/components/ui/menubar.tsx2
-rw-r--r--apps/web/src/components/ui/navigation-menu.tsx2
-rw-r--r--apps/web/src/components/ui/popover.tsx2
-rw-r--r--apps/web/src/components/ui/progress.tsx2
-rw-r--r--apps/web/src/components/ui/radio-group.tsx2
-rw-r--r--apps/web/src/components/ui/scroll-area.tsx2
-rw-r--r--apps/web/src/components/ui/select.tsx2
-rw-r--r--apps/web/src/components/ui/separator.tsx2
-rw-r--r--apps/web/src/components/ui/sheet.tsx2
-rw-r--r--apps/web/src/components/ui/sidebar.tsx12
-rw-r--r--apps/web/src/components/ui/slider.tsx2
-rw-r--r--apps/web/src/components/ui/switch.tsx2
-rw-r--r--apps/web/src/components/ui/tabs.tsx2
-rw-r--r--apps/web/src/components/ui/toast.tsx2
-rw-r--r--apps/web/src/components/ui/toggle-group.tsx2
-rw-r--r--apps/web/src/components/ui/toggle.tsx2
-rw-r--r--apps/web/src/components/ui/tooltip.tsx2
-rw-r--r--apps/web/src/components/ui/video-player.tsx121
-rw-r--r--apps/web/src/hooks/use-playback-controls.ts18
-rw-r--r--apps/web/src/lib/ffmpeg-utils.ts197
-rw-r--r--apps/web/src/lib/media-processing.ts7
-rw-r--r--apps/web/src/middleware.ts2
-rw-r--r--apps/web/src/stores/playback-store.ts30
-rw-r--r--apps/web/src/stores/timeline-store.ts80
-rw-r--r--apps/web/src/types/playback.ts14
45 files changed, 1254 insertions, 527 deletions
diff --git a/apps/web/src/app/editor/page.tsx b/apps/web/src/app/editor/page.tsx
index cc2db108..2f825a22 100644
--- a/apps/web/src/app/editor/page.tsx
+++ b/apps/web/src/app/editor/page.tsx
@@ -14,6 +14,7 @@ import { EditorHeader } from "@/components/editor-header";
import { usePanelStore } from "@/stores/panel-store";
import { useProjectStore } from "@/stores/project-store";
import { EditorProvider } from "@/components/editor-provider";
+import { usePlaybackControls } from "@/hooks/use-playback-controls";
export default function Editor() {
const {
@@ -31,7 +32,8 @@ export default function Editor() {
const { activeProject, createNewProject } = useProjectStore();
- // Initialize a new project if none exists
+ usePlaybackControls();
+
useEffect(() => {
if (!activeProject) {
createNewProject("Untitled Project");
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>
);
diff --git a/apps/web/src/components/ui/accordion.tsx b/apps/web/src/components/ui/accordion.tsx
index 2fe12b37..68edd869 100644
--- a/apps/web/src/components/ui/accordion.tsx
+++ b/apps/web/src/components/ui/accordion.tsx
@@ -1,7 +1,7 @@
"use client";
import * as React from "react";
-import * as AccordionPrimitive from "@radix-ui/react-accordion";
+import { Accordion as AccordionPrimitive } from "radix-ui";
import { ChevronDown } from "lucide-react";
import { cn } from "../../lib/utils";
diff --git a/apps/web/src/components/ui/alert-dialog.tsx b/apps/web/src/components/ui/alert-dialog.tsx
index ea8ab4f1..2cce235c 100644
--- a/apps/web/src/components/ui/alert-dialog.tsx
+++ b/apps/web/src/components/ui/alert-dialog.tsx
@@ -1,7 +1,7 @@
"use client";
import * as React from "react";
-import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
+import { AlertDialog as AlertDialogPrimitive } from "radix-ui";
import { cn } from "../../lib/utils";
import { buttonVariants } from "./button";
diff --git a/apps/web/src/components/ui/aspect-ratio.tsx b/apps/web/src/components/ui/aspect-ratio.tsx
index d6a5226f..6f90bbb8 100644
--- a/apps/web/src/components/ui/aspect-ratio.tsx
+++ b/apps/web/src/components/ui/aspect-ratio.tsx
@@ -1,6 +1,6 @@
"use client"
-import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio"
+import { AspectRatio as AspectRatioPrimitive } from "radix-ui"
const AspectRatio = AspectRatioPrimitive.Root
diff --git a/apps/web/src/components/ui/avatar.tsx b/apps/web/src/components/ui/avatar.tsx
index 4c6f6a4b..7979c415 100644
--- a/apps/web/src/components/ui/avatar.tsx
+++ b/apps/web/src/components/ui/avatar.tsx
@@ -1,7 +1,7 @@
"use client";
import * as React from "react";
-import * as AvatarPrimitive from "@radix-ui/react-avatar";
+import { Avatar as AvatarPrimitive } from "radix-ui";
import { cn } from "../../lib/utils";
diff --git a/apps/web/src/components/ui/breadcrumb.tsx b/apps/web/src/components/ui/breadcrumb.tsx
index 987b14df..d9df9bf5 100644
--- a/apps/web/src/components/ui/breadcrumb.tsx
+++ b/apps/web/src/components/ui/breadcrumb.tsx
@@ -1,5 +1,5 @@
import * as React from "react";
-import { Slot } from "@radix-ui/react-slot";
+import { Slot as SlotPrimitive } from "radix-ui";
import { ChevronRight, MoreHorizontal } from "lucide-react";
import { cn } from "../../lib/utils";
@@ -45,7 +45,7 @@ const BreadcrumbLink = React.forwardRef<
asChild?: boolean;
}
>(({ asChild, className, ...props }, ref) => {
- const Comp = asChild ? Slot : "a";
+ const Comp = asChild ? SlotPrimitive.Slot : "a";
return (
<Comp
diff --git a/apps/web/src/components/ui/button.tsx b/apps/web/src/components/ui/button.tsx
index 67b4eca1..f5863e20 100644
--- a/apps/web/src/components/ui/button.tsx
+++ b/apps/web/src/components/ui/button.tsx
@@ -1,5 +1,5 @@
import * as React from "react";
-import { Slot } from "@radix-ui/react-slot";
+import { Slot as SlotPrimitive } from "radix-ui";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "../../lib/utils";
@@ -42,7 +42,7 @@ export interface ButtonProps
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
- const Comp = asChild ? Slot : "button";
+ const Comp = asChild ? SlotPrimitive.Slot : "button";
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
diff --git a/apps/web/src/components/ui/checkbox.tsx b/apps/web/src/components/ui/checkbox.tsx
index bedc253e..2b74bc52 100644
--- a/apps/web/src/components/ui/checkbox.tsx
+++ b/apps/web/src/components/ui/checkbox.tsx
@@ -1,7 +1,7 @@
"use client";
import * as React from "react";
-import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
+import { Checkbox as CheckboxPrimitive } from "radix-ui";
import { Check } from "lucide-react";
import { cn } from "../../lib/utils";
diff --git a/apps/web/src/components/ui/collapsible.tsx b/apps/web/src/components/ui/collapsible.tsx
index 9fa48946..6642d604 100644
--- a/apps/web/src/components/ui/collapsible.tsx
+++ b/apps/web/src/components/ui/collapsible.tsx
@@ -1,6 +1,6 @@
"use client"
-import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
+import { Collapsible as CollapsiblePrimitive } from "radix-ui"
const Collapsible = CollapsiblePrimitive.Root
diff --git a/apps/web/src/components/ui/command.tsx b/apps/web/src/components/ui/command.tsx
index 58ba329e..29f29cf2 100644
--- a/apps/web/src/components/ui/command.tsx
+++ b/apps/web/src/components/ui/command.tsx
@@ -1,7 +1,7 @@
"use client";
import * as React from "react";
-import { type DialogProps } from "@radix-ui/react-dialog";
+import { type DialogProps } from "radix-ui";
import { Command as CommandPrimitive } from "cmdk";
import { Search } from "lucide-react";
diff --git a/apps/web/src/components/ui/context-menu.tsx b/apps/web/src/components/ui/context-menu.tsx
index 4cc152f6..defa0755 100644
--- a/apps/web/src/components/ui/context-menu.tsx
+++ b/apps/web/src/components/ui/context-menu.tsx
@@ -1,7 +1,7 @@
"use client";
import * as React from "react";
-import * as ContextMenuPrimitive from "@radix-ui/react-context-menu";
+import { ContextMenu as ContextMenuPrimitive } from "radix-ui";
import { Check, ChevronRight, Circle } from "lucide-react";
import { cn } from "../../lib/utils";
diff --git a/apps/web/src/components/ui/dialog.tsx b/apps/web/src/components/ui/dialog.tsx
index 985f5603..0a487d0a 100644
--- a/apps/web/src/components/ui/dialog.tsx
+++ b/apps/web/src/components/ui/dialog.tsx
@@ -1,7 +1,7 @@
"use client";
import * as React from "react";
-import * as DialogPrimitive from "@radix-ui/react-dialog";
+import { Dialog as DialogPrimitive } from "radix-ui";
import { X } from "lucide-react";
import { cn } from "../../lib/utils";
diff --git a/apps/web/src/components/ui/dropdown-menu.tsx b/apps/web/src/components/ui/dropdown-menu.tsx
index 74839c7c..2b653e3b 100644
--- a/apps/web/src/components/ui/dropdown-menu.tsx
+++ b/apps/web/src/components/ui/dropdown-menu.tsx
@@ -1,7 +1,7 @@
"use client";
import * as React from "react";
-import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
+import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui";
import { Check, ChevronRight, Circle } from "lucide-react";
import { cva, type VariantProps } from "class-variance-authority";
diff --git a/apps/web/src/components/ui/form.tsx b/apps/web/src/components/ui/form.tsx
index d8328f55..cb4f53a9 100644
--- a/apps/web/src/components/ui/form.tsx
+++ b/apps/web/src/components/ui/form.tsx
@@ -1,8 +1,8 @@
"use client";
import * as React from "react";
-import * as LabelPrimitive from "@radix-ui/react-label";
-import { Slot } from "@radix-ui/react-slot";
+import { Label as LabelPrimitive, Slot as SlotPrimitive } from "radix-ui";
+
import {
Controller,
ControllerProps,
@@ -104,14 +104,14 @@ const FormLabel = React.forwardRef<
FormLabel.displayName = "FormLabel";
const FormControl = React.forwardRef<
- React.ElementRef<typeof Slot>,
- React.ComponentPropsWithoutRef<typeof Slot>
+ React.ElementRef<typeof SlotPrimitive.Slot>,
+ React.ComponentPropsWithoutRef<typeof SlotPrimitive.Slot>
>(({ ...props }, ref) => {
const { error, formItemId, formDescriptionId, formMessageId } =
useFormField();
return (
- <Slot
+ <SlotPrimitive.Slot
ref={ref}
id={formItemId}
aria-describedby={
diff --git a/apps/web/src/components/ui/hover-card.tsx b/apps/web/src/components/ui/hover-card.tsx
index d5a01936..c3958fcf 100644
--- a/apps/web/src/components/ui/hover-card.tsx
+++ b/apps/web/src/components/ui/hover-card.tsx
@@ -1,7 +1,7 @@
"use client";
import * as React from "react";
-import * as HoverCardPrimitive from "@radix-ui/react-hover-card";
+import { HoverCard as HoverCardPrimitive } from "radix-ui";
import { cn } from "../../lib/utils";
diff --git a/apps/web/src/components/ui/input.tsx b/apps/web/src/components/ui/input.tsx
index c458e036..42f4c452 100644
--- a/apps/web/src/components/ui/input.tsx
+++ b/apps/web/src/components/ui/input.tsx
@@ -16,7 +16,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
type,
showPassword,
onShowPasswordChange,
- value = "",
+ value,
...props
},
ref
diff --git a/apps/web/src/components/ui/label.tsx b/apps/web/src/components/ui/label.tsx
index fdcf3ffa..877af15b 100644
--- a/apps/web/src/components/ui/label.tsx
+++ b/apps/web/src/components/ui/label.tsx
@@ -1,7 +1,7 @@
"use client";
import * as React from "react";
-import * as LabelPrimitive from "@radix-ui/react-label";
+import { Label as LabelPrimitive } from "radix-ui";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "../../lib/utils";
diff --git a/apps/web/src/components/ui/menubar.tsx b/apps/web/src/components/ui/menubar.tsx
index a2fdb934..642ffa2b 100644
--- a/apps/web/src/components/ui/menubar.tsx
+++ b/apps/web/src/components/ui/menubar.tsx
@@ -1,7 +1,7 @@
"use client";
import * as React from "react";
-import * as MenubarPrimitive from "@radix-ui/react-menubar";
+import { Menubar as MenubarPrimitive } from "radix-ui";
import { Check, ChevronRight, Circle } from "lucide-react";
import { cn } from "../../lib/utils";
diff --git a/apps/web/src/components/ui/navigation-menu.tsx b/apps/web/src/components/ui/navigation-menu.tsx
index 5e3ed067..b5dd3622 100644
--- a/apps/web/src/components/ui/navigation-menu.tsx
+++ b/apps/web/src/components/ui/navigation-menu.tsx
@@ -1,5 +1,5 @@
import * as React from "react";
-import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu";
+import { NavigationMenu as NavigationMenuPrimitive } from "radix-ui";
import { cva } from "class-variance-authority";
import { ChevronDown } from "lucide-react";
diff --git a/apps/web/src/components/ui/popover.tsx b/apps/web/src/components/ui/popover.tsx
index a8c94079..cc4f6508 100644
--- a/apps/web/src/components/ui/popover.tsx
+++ b/apps/web/src/components/ui/popover.tsx
@@ -1,7 +1,7 @@
"use client";
import * as React from "react";
-import * as PopoverPrimitive from "@radix-ui/react-popover";
+import { Popover as PopoverPrimitive } from "radix-ui";
import { cn } from "../../lib/utils";
diff --git a/apps/web/src/components/ui/progress.tsx b/apps/web/src/components/ui/progress.tsx
index 887c1747..283ba17a 100644
--- a/apps/web/src/components/ui/progress.tsx
+++ b/apps/web/src/components/ui/progress.tsx
@@ -1,7 +1,7 @@
"use client";
import * as React from "react";
-import * as ProgressPrimitive from "@radix-ui/react-progress";
+import { Progress as ProgressPrimitive } from "radix-ui";
import { cn } from "../../lib/utils";
diff --git a/apps/web/src/components/ui/radio-group.tsx b/apps/web/src/components/ui/radio-group.tsx
index b43405c6..cc07d34f 100644
--- a/apps/web/src/components/ui/radio-group.tsx
+++ b/apps/web/src/components/ui/radio-group.tsx
@@ -1,7 +1,7 @@
"use client";
import * as React from "react";
-import * as RadioGroupPrimitive from "@radix-ui/react-radio-group";
+import { RadioGroup as RadioGroupPrimitive } from "radix-ui";
import { Circle } from "lucide-react";
import { cn } from "../../lib/utils";
diff --git a/apps/web/src/components/ui/scroll-area.tsx b/apps/web/src/components/ui/scroll-area.tsx
index b5b1b41b..6b7c3c53 100644
--- a/apps/web/src/components/ui/scroll-area.tsx
+++ b/apps/web/src/components/ui/scroll-area.tsx
@@ -1,7 +1,7 @@
"use client";
import * as React from "react";
-import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
+import { ScrollArea as ScrollAreaPrimitive } from "radix-ui";
import { cn } from "../../lib/utils";
diff --git a/apps/web/src/components/ui/select.tsx b/apps/web/src/components/ui/select.tsx
index c2ee4ef5..a0f5f7b2 100644
--- a/apps/web/src/components/ui/select.tsx
+++ b/apps/web/src/components/ui/select.tsx
@@ -1,7 +1,7 @@
"use client";
import * as React from "react";
-import * as SelectPrimitive from "@radix-ui/react-select";
+import { Select as SelectPrimitive } from "radix-ui";
import { Check, ChevronDown, ChevronUp } from "lucide-react";
import { cn } from "../../lib/utils";
diff --git a/apps/web/src/components/ui/separator.tsx b/apps/web/src/components/ui/separator.tsx
index 8e6ad205..6138cabf 100644
--- a/apps/web/src/components/ui/separator.tsx
+++ b/apps/web/src/components/ui/separator.tsx
@@ -1,7 +1,7 @@
"use client";
import * as React from "react";
-import * as SeparatorPrimitive from "@radix-ui/react-separator";
+import { Separator as SeparatorPrimitive } from "radix-ui";
import { cn } from "../../lib/utils";
diff --git a/apps/web/src/components/ui/sheet.tsx b/apps/web/src/components/ui/sheet.tsx
index c0a2ed99..6aa092dc 100644
--- a/apps/web/src/components/ui/sheet.tsx
+++ b/apps/web/src/components/ui/sheet.tsx
@@ -1,7 +1,7 @@
"use client";
import * as React from "react";
-import * as SheetPrimitive from "@radix-ui/react-dialog";
+import { Dialog as SheetPrimitive } from "radix-ui";
import { cva, type VariantProps } from "class-variance-authority";
import { X } from "lucide-react";
diff --git a/apps/web/src/components/ui/sidebar.tsx b/apps/web/src/components/ui/sidebar.tsx
index 53902fbe..e69d8751 100644
--- a/apps/web/src/components/ui/sidebar.tsx
+++ b/apps/web/src/components/ui/sidebar.tsx
@@ -1,7 +1,7 @@
"use client";
import * as React from "react";
-import { Slot } from "@radix-ui/react-slot";
+import { Slot as SlotPrimitive } from "radix-ui";
import { VariantProps, cva } from "class-variance-authority";
import { PanelLeft } from "lucide-react";
@@ -432,7 +432,7 @@ const SidebarGroupLabel = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & { asChild?: boolean }
>(({ className, asChild = false, ...props }, ref) => {
- const Comp = asChild ? Slot : "div";
+ const Comp = asChild ? SlotPrimitive.Slot : "div";
return (
<Comp
@@ -453,7 +453,7 @@ const SidebarGroupAction = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button"> & { asChild?: boolean }
>(({ className, asChild = false, ...props }, ref) => {
- const Comp = asChild ? Slot : "button";
+ const Comp = asChild ? SlotPrimitive.Slot : "button";
return (
<Comp
@@ -553,7 +553,7 @@ const SidebarMenuButton = React.forwardRef<
},
ref
) => {
- const Comp = asChild ? Slot : "button";
+ const Comp = asChild ? SlotPrimitive.Slot : "button";
const { isMobile, state } = useSidebar();
const button = (
@@ -599,7 +599,7 @@ const SidebarMenuAction = React.forwardRef<
showOnHover?: boolean;
}
>(({ className, asChild = false, showOnHover = false, ...props }, ref) => {
- const Comp = asChild ? Slot : "button";
+ const Comp = asChild ? SlotPrimitive.Slot : "button";
return (
<Comp
@@ -713,7 +713,7 @@ const SidebarMenuSubButton = React.forwardRef<
isActive?: boolean;
}
>(({ asChild = false, size = "md", isActive, className, ...props }, ref) => {
- const Comp = asChild ? Slot : "a";
+ const Comp = asChild ? SlotPrimitive.Slot : "a";
return (
<Comp
diff --git a/apps/web/src/components/ui/slider.tsx b/apps/web/src/components/ui/slider.tsx
index 2181bf0c..4789ae17 100644
--- a/apps/web/src/components/ui/slider.tsx
+++ b/apps/web/src/components/ui/slider.tsx
@@ -1,7 +1,7 @@
"use client";
import * as React from "react";
-import * as SliderPrimitive from "@radix-ui/react-slider";
+import { Slider as SliderPrimitive } from "radix-ui";
import { cn } from "../../lib/utils";
diff --git a/apps/web/src/components/ui/switch.tsx b/apps/web/src/components/ui/switch.tsx
index 8e5be044..ba324f1d 100644
--- a/apps/web/src/components/ui/switch.tsx
+++ b/apps/web/src/components/ui/switch.tsx
@@ -1,7 +1,7 @@
"use client";
import * as React from "react";
-import * as SwitchPrimitives from "@radix-ui/react-switch";
+import { Switch as SwitchPrimitives } from "radix-ui";
import { cn } from "../../lib/utils";
diff --git a/apps/web/src/components/ui/tabs.tsx b/apps/web/src/components/ui/tabs.tsx
index d60091cb..a51a28a2 100644
--- a/apps/web/src/components/ui/tabs.tsx
+++ b/apps/web/src/components/ui/tabs.tsx
@@ -1,7 +1,7 @@
"use client";
import * as React from "react";
-import * as TabsPrimitive from "@radix-ui/react-tabs";
+import { Tabs as TabsPrimitive } from "radix-ui";
import { cn } from "../../lib/utils";
diff --git a/apps/web/src/components/ui/toast.tsx b/apps/web/src/components/ui/toast.tsx
index ed8adcb7..bf89e350 100644
--- a/apps/web/src/components/ui/toast.tsx
+++ b/apps/web/src/components/ui/toast.tsx
@@ -1,7 +1,7 @@
"use client";
import * as React from "react";
-import * as ToastPrimitives from "@radix-ui/react-toast";
+import { Toast as ToastPrimitives } from "radix-ui";
import { cva, type VariantProps } from "class-variance-authority";
import { X } from "lucide-react";
diff --git a/apps/web/src/components/ui/toggle-group.tsx b/apps/web/src/components/ui/toggle-group.tsx
index 692a80ba..5842a07d 100644
--- a/apps/web/src/components/ui/toggle-group.tsx
+++ b/apps/web/src/components/ui/toggle-group.tsx
@@ -1,7 +1,7 @@
"use client";
import * as React from "react";
-import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group";
+import { ToggleGroup as ToggleGroupPrimitive } from "radix-ui";
import { type VariantProps } from "class-variance-authority";
import { cn } from "../../lib/utils";
diff --git a/apps/web/src/components/ui/toggle.tsx b/apps/web/src/components/ui/toggle.tsx
index 7d75e8ea..a4bfda42 100644
--- a/apps/web/src/components/ui/toggle.tsx
+++ b/apps/web/src/components/ui/toggle.tsx
@@ -1,7 +1,7 @@
"use client";
import * as React from "react";
-import * as TogglePrimitive from "@radix-ui/react-toggle";
+import { Toggle as TogglePrimitive } from "radix-ui";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "../../lib/utils";
diff --git a/apps/web/src/components/ui/tooltip.tsx b/apps/web/src/components/ui/tooltip.tsx
index d3940b50..a2fb0a01 100644
--- a/apps/web/src/components/ui/tooltip.tsx
+++ b/apps/web/src/components/ui/tooltip.tsx
@@ -1,7 +1,7 @@
"use client";
import * as React from "react";
-import * as TooltipPrimitive from "@radix-ui/react-tooltip";
+import { Tooltip as TooltipPrimitive } from "radix-ui";
import { cn } from "../../lib/utils";
diff --git a/apps/web/src/components/ui/video-player.tsx b/apps/web/src/components/ui/video-player.tsx
new file mode 100644
index 00000000..d31f7113
--- /dev/null
+++ b/apps/web/src/components/ui/video-player.tsx
@@ -0,0 +1,121 @@
+"use client";
+
+import { useRef, useEffect } from "react";
+import { Button } from "./button";
+import { Play, Pause, Volume2 } from "lucide-react";
+import { usePlaybackStore } from "@/stores/playback-store";
+
+interface VideoPlayerProps {
+ src: string;
+ poster?: string;
+ className?: string;
+ startTime?: number;
+}
+
+export function VideoPlayer({ src, poster, className = "", startTime = 0 }: VideoPlayerProps) {
+ const videoRef = useRef<HTMLVideoElement>(null);
+ const { isPlaying, currentTime, volume, play, pause, setVolume, setDuration, setCurrentTime } = usePlaybackStore();
+
+ useEffect(() => {
+ const video = videoRef.current;
+ if (!video) return;
+
+ const handleTimeUpdate = () => {
+ setCurrentTime(video.currentTime);
+ };
+
+ const handleLoadedMetadata = () => {
+ setDuration(video.duration);
+ if (startTime > 0) {
+ video.currentTime = startTime;
+ }
+ };
+
+ const handleSeekEvent = (e: CustomEvent) => {
+ video.currentTime = e.detail.time;
+ };
+
+ video.addEventListener("timeupdate", handleTimeUpdate);
+ video.addEventListener("loadedmetadata", handleLoadedMetadata);
+ window.addEventListener("playback-seek", handleSeekEvent as EventListener);
+
+ return () => {
+ video.removeEventListener("timeupdate", handleTimeUpdate);
+ video.removeEventListener("loadedmetadata", handleLoadedMetadata);
+ window.removeEventListener("playback-seek", handleSeekEvent as EventListener);
+ };
+ }, [setCurrentTime, setDuration]);
+
+ useEffect(() => {
+ const video = videoRef.current;
+ if (!video) return;
+
+ if (isPlaying) {
+ video.play().catch(console.error);
+ } else {
+ video.pause();
+ }
+ }, [isPlaying]);
+
+ useEffect(() => {
+ const video = videoRef.current;
+ if (!video) return;
+ video.volume = volume;
+ }, [volume]);
+
+ const handleSeek = (e: React.MouseEvent<HTMLVideoElement>) => {
+ const video = videoRef.current;
+ if (!video) return;
+
+ const rect = video.getBoundingClientRect();
+ const x = e.clientX - rect.left;
+ const percentage = x / rect.width;
+ const newTime = percentage * video.duration;
+
+ video.currentTime = newTime;
+ setCurrentTime(newTime);
+ };
+
+ return (
+ <div className={`relative group ${className}`}>
+ <video
+ ref={videoRef}
+ src={src}
+ poster={poster}
+ className="w-full h-full object-cover cursor-pointer"
+ onClick={handleSeek}
+ playsInline
+ preload="metadata"
+ />
+
+ <div className="absolute inset-0 bg-black/20 opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none" />
+
+ <div className="absolute bottom-2 left-2 right-2 flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
+ <Button
+ variant="ghost"
+ size="icon"
+ className="h-8 w-8 bg-black/50 text-white hover:bg-black/70"
+ onClick={isPlaying ? pause : play}
+ >
+ {isPlaying ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
+ </Button>
+
+ <div className="flex-1 h-1 bg-white/30 rounded-full overflow-hidden">
+ <div
+ className="h-full bg-white transition-all duration-100"
+ style={{ width: `${(currentTime / usePlaybackStore.getState().duration) * 100}%` }}
+ />
+ </div>
+
+ <Button
+ variant="ghost"
+ size="icon"
+ className="h-8 w-8 bg-black/50 text-white hover:bg-black/70"
+ onClick={() => setVolume(volume > 0 ? 0 : 1)}
+ >
+ <Volume2 className="h-4 w-4" />
+ </Button>
+ </div>
+ </div>
+ );
+} \ No newline at end of file
diff --git a/apps/web/src/hooks/use-playback-controls.ts b/apps/web/src/hooks/use-playback-controls.ts
new file mode 100644
index 00000000..5742e272
--- /dev/null
+++ b/apps/web/src/hooks/use-playback-controls.ts
@@ -0,0 +1,18 @@
+import { useEffect } from "react";
+import { usePlaybackStore } from "@/stores/playback-store";
+
+export function usePlaybackControls() {
+ const { toggle } = usePlaybackStore();
+
+ useEffect(() => {
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if (e.code === "Space" && e.target === document.body) {
+ e.preventDefault();
+ toggle();
+ }
+ };
+
+ document.addEventListener("keydown", handleKeyDown);
+ return () => document.removeEventListener("keydown", handleKeyDown);
+ }, [toggle]);
+} \ No newline at end of file
diff --git a/apps/web/src/lib/ffmpeg-utils.ts b/apps/web/src/lib/ffmpeg-utils.ts
new file mode 100644
index 00000000..56085a21
--- /dev/null
+++ b/apps/web/src/lib/ffmpeg-utils.ts
@@ -0,0 +1,197 @@
+import { FFmpeg } from '@ffmpeg/ffmpeg';
+import { toBlobURL } from '@ffmpeg/util';
+
+let ffmpeg: FFmpeg | null = null;
+
+export const initFFmpeg = async (): Promise<FFmpeg> => {
+ if (ffmpeg) return ffmpeg;
+
+ ffmpeg = new FFmpeg();
+
+ // Use locally hosted files instead of CDN
+ const baseURL = '/ffmpeg';
+
+ await ffmpeg.load({
+ coreURL: await toBlobURL(`${baseURL}/ffmpeg-core.js`, 'text/javascript'),
+ wasmURL: await toBlobURL(`${baseURL}/ffmpeg-core.wasm`, 'application/wasm'),
+ });
+
+ return ffmpeg;
+};
+
+export const generateThumbnail = async (
+ videoFile: File,
+ timeInSeconds: number = 1
+): Promise<string> => {
+ const ffmpeg = await initFFmpeg();
+
+ const inputName = 'input.mp4';
+ const outputName = 'thumbnail.jpg';
+
+ // Write input file
+ await ffmpeg.writeFile(inputName, new Uint8Array(await videoFile.arrayBuffer()));
+
+ // Generate thumbnail at specific time
+ await ffmpeg.exec([
+ '-i', inputName,
+ '-ss', timeInSeconds.toString(),
+ '-vframes', '1',
+ '-vf', 'scale=320:240',
+ '-q:v', '2',
+ outputName
+ ]);
+
+ // Read output file
+ const data = await ffmpeg.readFile(outputName);
+ const blob = new Blob([data], { type: 'image/jpeg' });
+
+ // Cleanup
+ await ffmpeg.deleteFile(inputName);
+ await ffmpeg.deleteFile(outputName);
+
+ return URL.createObjectURL(blob);
+};
+
+export const trimVideo = async (
+ videoFile: File,
+ startTime: number,
+ endTime: number,
+ onProgress?: (progress: number) => void
+): Promise<Blob> => {
+ const ffmpeg = await initFFmpeg();
+
+ const inputName = 'input.mp4';
+ const outputName = 'output.mp4';
+
+ // Set up progress callback
+ if (onProgress) {
+ ffmpeg.on('progress', ({ progress }) => {
+ onProgress(progress * 100);
+ });
+ }
+
+ // Write input file
+ await ffmpeg.writeFile(inputName, new Uint8Array(await videoFile.arrayBuffer()));
+
+ const duration = endTime - startTime;
+
+ // Trim video
+ await ffmpeg.exec([
+ '-i', inputName,
+ '-ss', startTime.toString(),
+ '-t', duration.toString(),
+ '-c', 'copy', // Use stream copy for faster processing
+ outputName
+ ]);
+
+ // Read output file
+ const data = await ffmpeg.readFile(outputName);
+ const blob = new Blob([data], { type: 'video/mp4' });
+
+ // Cleanup
+ await ffmpeg.deleteFile(inputName);
+ await ffmpeg.deleteFile(outputName);
+
+ return blob;
+};
+
+export const getVideoInfo = async (videoFile: File): Promise<{
+ duration: number;
+ width: number;
+ height: number;
+ fps: number;
+}> => {
+ const ffmpeg = await initFFmpeg();
+
+ const inputName = 'input.mp4';
+
+ // Write input file
+ await ffmpeg.writeFile(inputName, new Uint8Array(await videoFile.arrayBuffer()));
+
+ // Get video info
+ await ffmpeg.exec(['-i', inputName, '-f', 'null', '-']);
+
+ // Note: In a real implementation, you'd parse the FFmpeg output
+ // For now, we'll return default values and enhance this later
+
+ // Cleanup
+ await ffmpeg.deleteFile(inputName);
+
+ return {
+ duration: 10, // Placeholder - would parse from FFmpeg output
+ width: 1920, // Placeholder
+ height: 1080, // Placeholder
+ fps: 30 // Placeholder
+ };
+};
+
+export const convertToWebM = async (
+ videoFile: File,
+ onProgress?: (progress: number) => void
+): Promise<Blob> => {
+ const ffmpeg = await initFFmpeg();
+
+ const inputName = 'input.mp4';
+ const outputName = 'output.webm';
+
+ // Set up progress callback
+ if (onProgress) {
+ ffmpeg.on('progress', ({ progress }) => {
+ onProgress(progress * 100);
+ });
+ }
+
+ // Write input file
+ await ffmpeg.writeFile(inputName, new Uint8Array(await videoFile.arrayBuffer()));
+
+ // Convert to WebM
+ await ffmpeg.exec([
+ '-i', inputName,
+ '-c:v', 'libvpx-vp9',
+ '-crf', '30',
+ '-b:v', '0',
+ '-c:a', 'libopus',
+ outputName
+ ]);
+
+ // Read output file
+ const data = await ffmpeg.readFile(outputName);
+ const blob = new Blob([data], { type: 'video/webm' });
+
+ // Cleanup
+ await ffmpeg.deleteFile(inputName);
+ await ffmpeg.deleteFile(outputName);
+
+ return blob;
+};
+
+export const extractAudio = async (
+ videoFile: File,
+ format: 'mp3' | 'wav' = 'mp3'
+): Promise<Blob> => {
+ const ffmpeg = await initFFmpeg();
+
+ const inputName = 'input.mp4';
+ const outputName = `output.${format}`;
+
+ // Write input file
+ await ffmpeg.writeFile(inputName, new Uint8Array(await videoFile.arrayBuffer()));
+
+ // Extract audio
+ await ffmpeg.exec([
+ '-i', inputName,
+ '-vn', // Disable video
+ '-acodec', format === 'mp3' ? 'libmp3lame' : 'pcm_s16le',
+ outputName
+ ]);
+
+ // Read output file
+ const data = await ffmpeg.readFile(outputName);
+ const blob = new Blob([data], { type: `audio/${format}` });
+
+ // Cleanup
+ await ffmpeg.deleteFile(inputName);
+ await ffmpeg.deleteFile(outputName);
+
+ return blob;
+}; \ No newline at end of file
diff --git a/apps/web/src/lib/media-processing.ts b/apps/web/src/lib/media-processing.ts
index c51dde55..d241f411 100644
--- a/apps/web/src/lib/media-processing.ts
+++ b/apps/web/src/lib/media-processing.ts
@@ -6,6 +6,7 @@ import {
getImageAspectRatio,
type MediaItem,
} from "@/stores/media-store";
+// import { generateThumbnail, getVideoInfo } from "./ffmpeg-utils"; // Temporarily disabled
export interface ProcessedMediaItem extends Omit<MediaItem, "id"> {}
@@ -33,7 +34,7 @@ export async function processMediaFiles(
// Get image aspect ratio
aspectRatio = await getImageAspectRatio(file);
} else if (fileType === "video") {
- // Generate thumbnail and get aspect ratio for videos
+ // Use basic thumbnail generation for now
const videoResult = await generateVideoThumbnail(file);
thumbnailUrl = videoResult.thumbnailUrl;
aspectRatio = videoResult.aspectRatio;
@@ -42,8 +43,8 @@ export async function processMediaFiles(
aspectRatio = 1;
}
- // Get duration for videos and audio
- if (fileType === "video" || fileType === "audio") {
+ // Get duration for videos and audio (if not already set by FFmpeg)
+ if ((fileType === "video" || fileType === "audio") && !duration) {
duration = await getMediaDuration(file);
}
diff --git a/apps/web/src/middleware.ts b/apps/web/src/middleware.ts
index db02dc5c..7f096cd5 100644
--- a/apps/web/src/middleware.ts
+++ b/apps/web/src/middleware.ts
@@ -6,7 +6,7 @@ export async function middleware(request: NextRequest) {
const path = request.nextUrl.pathname;
const session = getSessionCookie(request);
- if (path === "/editor" && !session) {
+ if (path === "/editor" && !session && process.env.NODE_ENV === "production") {
const loginUrl = new URL("/auth/login", request.url);
loginUrl.searchParams.set("redirect", request.url);
return NextResponse.redirect(loginUrl);
diff --git a/apps/web/src/stores/playback-store.ts b/apps/web/src/stores/playback-store.ts
new file mode 100644
index 00000000..7db1fb93
--- /dev/null
+++ b/apps/web/src/stores/playback-store.ts
@@ -0,0 +1,30 @@
+import { create } from "zustand";
+import type { PlaybackState, PlaybackControls } from "@/types/playback";
+
+interface PlaybackStore extends PlaybackState, PlaybackControls {
+ setDuration: (duration: number) => void;
+ setCurrentTime: (time: number) => void;
+}
+
+export const usePlaybackStore = create<PlaybackStore>((set, get) => ({
+ isPlaying: false,
+ currentTime: 0,
+ duration: 0,
+ volume: 1,
+
+ play: () => set({ isPlaying: true }),
+ pause: () => set({ isPlaying: false }),
+ toggle: () => set((state) => ({ isPlaying: !state.isPlaying })),
+ seek: (time: number) => {
+ const { duration } = get();
+ const clampedTime = Math.max(0, Math.min(duration, time));
+ set({ currentTime: clampedTime });
+
+ // Notify video element to seek
+ const event = new CustomEvent('playback-seek', { detail: { time: clampedTime } });
+ window.dispatchEvent(event);
+ },
+ setVolume: (volume: number) => set({ volume: Math.max(0, Math.min(1, volume)) }),
+ setDuration: (duration: number) => set({ duration }),
+ setCurrentTime: (time: number) => set({ currentTime: time }),
+})); \ No newline at end of file
diff --git a/apps/web/src/stores/timeline-store.ts b/apps/web/src/stores/timeline-store.ts
index 12129afa..4b56e6bd 100644
--- a/apps/web/src/stores/timeline-store.ts
+++ b/apps/web/src/stores/timeline-store.ts
@@ -5,6 +5,9 @@ export interface TimelineClip {
mediaId: string;
name: string;
duration: number;
+ startTime: number;
+ trimStart: number;
+ trimEnd: number;
}
export interface TimelineTrack {
@@ -25,13 +28,18 @@ interface TimelineStore {
moveClipToTrack: (
fromTrackId: string,
toTrackId: string,
+ clipId: string
+ ) => void;
+ updateClipTrim: (
+ trackId: string,
clipId: string,
- insertIndex?: number
+ trimStart: number,
+ trimEnd: number
) => void;
- reorderClipInTrack: (
+ updateClipStartTime: (
trackId: string,
clipId: string,
- newIndex: number
+ startTime: number
) => void;
// Computed values
@@ -64,6 +72,9 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({
const newClip: TimelineClip = {
...clipData,
id: crypto.randomUUID(),
+ startTime: clipData.startTime || 0,
+ trimStart: 0,
+ trimEnd: 0,
};
set((state) => ({
@@ -88,9 +99,8 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({
}));
},
- moveClipToTrack: (fromTrackId, toTrackId, clipId, insertIndex) => {
+ moveClipToTrack: (fromTrackId, toTrackId, clipId) => {
set((state) => {
- // Find the clip to move
const fromTrack = state.tracks.find((track) => track.id === fromTrackId);
const clipToMove = fromTrack?.clips.find((clip) => clip.id === clipId);
@@ -99,20 +109,14 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({
return {
tracks: state.tracks.map((track) => {
if (track.id === fromTrackId) {
- // Remove clip from source track
return {
...track,
clips: track.clips.filter((clip) => clip.id !== clipId),
};
} else if (track.id === toTrackId) {
- // Add clip to destination track
- const newClips = [...track.clips];
- const index =
- insertIndex !== undefined ? insertIndex : newClips.length;
- newClips.splice(index, 0, clipToMove);
return {
...track,
- clips: newClips,
+ clips: [...track.clips, clipToMove],
};
}
return track;
@@ -121,20 +125,39 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({
});
},
- reorderClipInTrack: (trackId, clipId, newIndex) => {
- set((state) => ({
- tracks: state.tracks.map((track) => {
- if (track.id !== trackId) return track;
- const clipIndex = track.clips.findIndex((clip) => clip.id === clipId);
- if (clipIndex === -1) return track;
- const newClips = [...track.clips];
- const [movedClip] = newClips.splice(clipIndex, 1);
- newClips.splice(newIndex, 0, movedClip);
+ updateClipTrim: (trackId, clipId, trimStart, trimEnd) => {
+ set((state) => ({
+ tracks: state.tracks.map((track) =>
+ track.id === trackId
+ ? {
+ ...track,
+ clips: track.clips.map((clip) =>
+ clip.id === clipId
+ ? { ...clip, trimStart, trimEnd }
+ : clip
+ ),
+ }
+ : track
+ ),
+ }));
+ },
- return { ...track, clips: newClips };
- }),
+ updateClipStartTime: (trackId, clipId, startTime) => {
+ set((state) => ({
+ tracks: state.tracks.map((track) =>
+ track.id === trackId
+ ? {
+ ...track,
+ clips: track.clips.map((clip) =>
+ clip.id === clipId
+ ? { ...clip, startTime }
+ : clip
+ ),
+ }
+ : track
+ ),
}));
},
@@ -142,12 +165,13 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({
const { tracks } = get();
if (tracks.length === 0) return 0;
- // Calculate the duration of each track (sum of all clips in that track)
- const trackDurations = tracks.map((track) =>
- track.clips.reduce((total, clip) => total + clip.duration, 0)
+ const trackEndTimes = tracks.map((track) =>
+ track.clips.reduce((maxEnd, clip) => {
+ const clipEnd = clip.startTime + clip.duration - clip.trimStart - clip.trimEnd;
+ return Math.max(maxEnd, clipEnd);
+ }, 0)
);
- // Return the maximum track duration (longest track determines project duration)
- return Math.max(...trackDurations, 0);
+ return Math.max(...trackEndTimes, 0);
},
}));
diff --git a/apps/web/src/types/playback.ts b/apps/web/src/types/playback.ts
new file mode 100644
index 00000000..88113ef3
--- /dev/null
+++ b/apps/web/src/types/playback.ts
@@ -0,0 +1,14 @@
+export interface PlaybackState {
+ isPlaying: boolean;
+ currentTime: number;
+ duration: number;
+ volume: number;
+}
+
+export interface PlaybackControls {
+ play: () => void;
+ pause: () => void;
+ seek: (time: number) => void;
+ setVolume: (volume: number) => void;
+ toggle: () => void;
+} \ No newline at end of file