diff options
Diffstat (limited to 'apps/web/src')
39 files changed, 3735 insertions, 2615 deletions
diff --git a/apps/web/src/app/contributors/page.tsx b/apps/web/src/app/contributors/page.tsx index 67957c32..feee2513 100644 --- a/apps/web/src/app/contributors/page.tsx +++ b/apps/web/src/app/contributors/page.tsx @@ -140,9 +140,6 @@ export default async function ContributorsPage() { {contributor.login.charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
- <div className="absolute -top-2 -right-2 bg-foreground text-background rounded-full w-8 h-8 flex items-center justify-center text-sm font-bold">
- {index + 1}
- </div>
</div>
<h3 className="text-xl font-semibold mb-2 group-hover:text-foreground/80 transition-colors">
{contributor.login}
diff --git a/apps/web/src/app/editor/page.tsx b/apps/web/src/app/editor/[project_id]/page.tsx index e0013937..8eee5e0a 100644 --- a/apps/web/src/app/editor/page.tsx +++ b/apps/web/src/app/editor/[project_id]/page.tsx @@ -1,16 +1,15 @@ "use client"; import { useEffect } from "react"; -import "./editor.css"; import { ResizablePanelGroup, ResizablePanel, ResizableHandle, -} from "../../components/ui/resizable"; -import { MediaPanel } from "../../components/editor/media-panel"; +} from "../../../components/ui/resizable"; +import { MediaPanel } from "../../../components/editor/media-panel"; // import { PropertiesPanel } from "../../components/editor/properties-panel"; -import { Timeline } from "../../components/editor/timeline"; -import { PreviewPanel } from "../../components/editor/preview-panel"; +import { Timeline } from "../../../components/editor/timeline"; +import { PreviewPanel } from "../../../components/editor/preview-panel"; import { EditorHeader } from "@/components/editor-header"; import { usePanelStore } from "@/stores/panel-store"; import { useProjectStore } from "@/stores/project-store"; @@ -21,12 +20,10 @@ export default function Editor() { const { toolsPanel, previewPanel, - propertiesPanel, mainContent, timeline, setToolsPanel, setPreviewPanel, - setPropertiesPanel, setMainContent, setTimeline, } = usePanelStore(); @@ -55,7 +52,10 @@ export default function Editor() { className="min-h-0" > {/* Main content area */} - <ResizablePanelGroup direction="horizontal" className="h-full w-full"> + <ResizablePanelGroup + direction="horizontal" + className="h-full w-full" + > {/* Tools Panel */} <ResizablePanel defaultSize={toolsPanel} diff --git a/apps/web/src/app/editor/editor.css b/apps/web/src/app/editor/editor.css deleted file mode 100644 index f482c6de..00000000 --- a/apps/web/src/app/editor/editor.css +++ /dev/null @@ -1,4 +0,0 @@ -/* Prevent scroll jumping on Mac devices when using the editor */ -body { - overflow: hidden; -}
\ No newline at end of file diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css index e8727432..eafdc861 100644 --- a/apps/web/src/app/globals.css +++ b/apps/web/src/app/globals.css @@ -40,10 +40,10 @@ } .dark { --background: 0 0% 8%; - --foreground: 0 0% 98%; + --foreground: 0 0% 89%; --card: 0 0% 3.9%; --card-foreground: 0 0% 98%; - --popover: 0 0% 3.9%; + --popover: 0 0% 14.9%; --popover-foreground: 0 0% 98%; --primary: 0 0% 98%; --primary-foreground: 0 0% 9%; diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index 8188d31b..4f8243b5 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -6,6 +6,7 @@ import "./globals.css"; import { Toaster } from "../components/ui/sonner"; import { TooltipProvider } from "../components/ui/tooltip"; import { DevelopmentDebug } from "../components/development-debug"; +import { StorageProvider } from "../components/storage-provider"; import { baseMetaData } from "./metadata"; const inter = Inter({ @@ -25,7 +26,7 @@ export default function RootLayout({ <body className={`${inter.variable} font-sans antialiased`}> <ThemeProvider attribute="class" forcedTheme="dark" enableSystem> <TooltipProvider> - {children} + <StorageProvider>{children}</StorageProvider> <Analytics /> <Toaster /> <DevelopmentDebug /> diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx index 183b6a56..f78e9d52 100644 --- a/apps/web/src/app/page.tsx +++ b/apps/web/src/app/page.tsx @@ -12,13 +12,6 @@ export default async function Home() { return ( <div> - <Image - className="fixed top-0 left-0 -z-50 size-full object-cover" - src="/landing-page-bg.png" - height={1903.5} - width={1269} - alt="landing-page.bg" - /> <Header /> <Hero signupCount={signupCount} /> <Footer /> diff --git a/apps/web/src/app/projects/page.tsx b/apps/web/src/app/projects/page.tsx new file mode 100644 index 00000000..c983894d --- /dev/null +++ b/apps/web/src/app/projects/page.tsx @@ -0,0 +1,260 @@ +"use client"; + +import Link from "next/link"; +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { + ChevronLeft, + Plus, + Calendar, + MoreHorizontal, + Video, + Loader2, +} from "lucide-react"; +import { TProject } from "@/types/project"; +import Image from "next/image"; +import { + DropdownMenu, + DropdownMenuItem, + DropdownMenuContent, + DropdownMenuTrigger, + DropdownMenuSeparator, +} from "@/components/ui/dropdown-menu"; +import { useProjectStore } from "@/stores/project-store"; +import { useRouter } from "next/navigation"; +import { DeleteProjectDialog } from "@/components/delete-project-dialog"; +import { RenameProjectDialog } from "@/components/rename-project-dialog"; + +export default function ProjectsPage() { + const { createNewProject, savedProjects, isLoading, isInitialized } = + useProjectStore(); + const router = useRouter(); + + const handleCreateProject = async () => { + const projectId = await createNewProject("New Project"); + console.log("projectId", projectId); + router.push(`/editor/${projectId}`); + }; + + return ( + <div className="min-h-screen bg-background"> + <div className="pt-6 px-6 flex items-center justify-between w-full h-16"> + <Link + href="/" + className="flex items-center gap-1 hover:text-muted-foreground transition-colors" + > + <ChevronLeft className="!size-5 shrink-0" /> + <span className="text-sm font-medium">Back</span> + </Link> + <div className="block md:hidden"> + <CreateButton onClick={handleCreateProject} /> + </div> + </div> + <main className="max-w-6xl mx-auto px-6 pt-6 pb-6"> + <div className="mb-8 flex items-center justify-between"> + <div className="flex flex-col gap-3"> + <h1 className="text-2xl md:text-3xl font-bold tracking-tight"> + Your Projects + </h1> + <p className="text-muted-foreground"> + {savedProjects.length}{" "} + {savedProjects.length === 1 ? "project" : "projects"} + </p> + </div> + <div className="hidden md:block"> + <CreateButton onClick={handleCreateProject} /> + </div> + </div> + + {isLoading || !isInitialized ? ( + <div className="flex items-center justify-center py-16"> + <Loader2 className="h-8 w-8 text-muted-foreground animate-spin" /> + </div> + ) : savedProjects.length === 0 ? ( + <NoProjects onCreateProject={handleCreateProject} /> + ) : ( + <div className="grid grid-cols-1 xs:grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-6"> + {savedProjects.map((project) => ( + <ProjectCard key={project.id} project={project} /> + ))} + </div> + )} + </main> + </div> + ); +} + +function ProjectCard({ project }: { project: TProject }) { + const [isDropdownOpen, setIsDropdownOpen] = useState(false); + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); + const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false); + const { deleteProject, renameProject, duplicateProject } = useProjectStore(); + + const formatDate = (date: Date): string => { + return date.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + }); + }; + + const handleDeleteProject = async () => { + await deleteProject(project.id); + setIsDropdownOpen(false); + }; + + const handleRenameProject = async (newName: string) => { + await renameProject(project.id, newName); + setIsRenameDialogOpen(false); + }; + + const handleDuplicateProject = async () => { + setIsDropdownOpen(false); + await duplicateProject(project.id); + }; + + return ( + <> + <Link href={`/editor/${project.id}`} className="block group"> + <Card className="overflow-hidden bg-background border-none p-0"> + <div + className={`relative aspect-square bg-muted transition-opacity ${ + isDropdownOpen + ? "opacity-65" + : "opacity-100 group-hover:opacity-65" + }`} + > + {/* Thumbnail preview or placeholder */} + <div className="absolute inset-0"> + {project.thumbnail ? ( + <Image + src={project.thumbnail} + alt="Project thumbnail" + fill + className="object-cover" + /> + ) : ( + <div className="w-full h-full bg-muted/50 flex items-center justify-center"> + <Video className="h-12 w-12 flex-shrink-0 text-muted-foreground" /> + </div> + )} + </div> + </div> + + <CardContent className="px-0 pt-5 flex flex-col gap-1"> + <div className="flex items-start justify-between"> + <h3 className="font-medium text-sm leading-snug group-hover:text-foreground/90 transition-colors line-clamp-2"> + {project.name} + </h3> + <DropdownMenu + open={isDropdownOpen} + onOpenChange={setIsDropdownOpen} + > + <DropdownMenuTrigger asChild> + <Button + variant="text" + size="sm" + className={`size-6 p-0 transition-all shrink-0 ml-2 ${ + isDropdownOpen + ? "opacity-100" + : "opacity-0 group-hover:opacity-100" + }`} + onClick={(e) => e.preventDefault()} + > + <MoreHorizontal /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent + align="end" + onCloseAutoFocus={(e) => { + e.preventDefault(); + e.stopPropagation(); + }} + > + <DropdownMenuItem + onClick={(e) => { + e.preventDefault(); + e.stopPropagation(); + setIsDropdownOpen(false); + setIsRenameDialogOpen(true); + }} + > + Rename + </DropdownMenuItem> + <DropdownMenuItem + onClick={(e) => { + e.preventDefault(); + e.stopPropagation(); + handleDuplicateProject(); + }} + > + Duplicate + </DropdownMenuItem> + <DropdownMenuSeparator /> + <DropdownMenuItem + variant="destructive" + onClick={(e) => { + e.preventDefault(); + e.stopPropagation(); + setIsDropdownOpen(false); + setIsDeleteDialogOpen(true); + }} + > + Delete + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + </div> + + <div className="space-y-1"> + <div className="flex items-center gap-1.5 text-sm text-muted-foreground"> + <Calendar className="!size-4" /> + <span>Created {formatDate(project.createdAt)}</span> + </div> + </div> + </CardContent> + </Card> + </Link> + <DeleteProjectDialog + isOpen={isDeleteDialogOpen} + onOpenChange={setIsDeleteDialogOpen} + onConfirm={handleDeleteProject} + /> + <RenameProjectDialog + isOpen={isRenameDialogOpen} + onOpenChange={setIsRenameDialogOpen} + onConfirm={handleRenameProject} + projectName={project.name} + /> + </> + ); +} + +function CreateButton({ onClick }: { onClick?: () => void }) { + return ( + <Button className="flex" onClick={onClick}> + <Plus className="!size-4" /> + <span className="text-sm font-medium">New project</span> + </Button> + ); +} + +function NoProjects({ onCreateProject }: { onCreateProject: () => void }) { + return ( + <div className="flex flex-col items-center justify-center py-16 text-center"> + <div className="w-16 h-16 rounded-full bg-muted/30 flex items-center justify-center mb-4"> + <Video className="h-8 w-8 text-muted-foreground" /> + </div> + <h3 className="text-lg font-medium mb-2">No projects yet</h3> + <p className="text-muted-foreground mb-6 max-w-md"> + Start creating your first video project. Import media, edit, and export + professional videos. + </p> + <Button size="lg" className="gap-2" onClick={onCreateProject}> + <Plus className="h-4 w-4" /> + Create Your First Project + </Button> + </div> + ); +} diff --git a/apps/web/src/components/delete-project-dialog.tsx b/apps/web/src/components/delete-project-dialog.tsx new file mode 100644 index 00000000..db709647 --- /dev/null +++ b/apps/web/src/components/delete-project-dialog.tsx @@ -0,0 +1,53 @@ +import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+
+export function DeleteProjectDialog({
+ isOpen,
+ onOpenChange,
+ onConfirm,
+}: {
+ isOpen: boolean;
+ onOpenChange: (open: boolean) => void;
+ onConfirm: () => void;
+}) {
+ return (
+ <Dialog open={isOpen} onOpenChange={onOpenChange}>
+ <DialogContent
+ onOpenAutoFocus={(e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ }}
+ >
+ <DialogHeader>
+ <DialogTitle>Delete Project</DialogTitle>
+ <DialogDescription>
+ Are you sure you want to delete this project? This action cannot be
+ undone.
+ </DialogDescription>
+ </DialogHeader>
+ <DialogFooter>
+ <Button
+ variant="outline"
+ onClick={(e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ onOpenChange(false);
+ }}
+ >
+ Cancel
+ </Button>
+ <Button variant="destructive" onClick={onConfirm}>
+ Delete
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ );
+}
diff --git a/apps/web/src/components/editor-header.tsx b/apps/web/src/components/editor-header.tsx index 5131230f..e3d2359e 100644 --- a/apps/web/src/components/editor-header.tsx +++ b/apps/web/src/components/editor-header.tsx @@ -5,38 +5,33 @@ import { Button } from "./ui/button"; import { ChevronLeft, Download } from "lucide-react";
import { useTimelineStore } from "@/stores/timeline-store";
import { HeaderBase } from "./header-base";
-import { ProjectNameEditor } from "./editor/project-name-editor";
+import { formatTimeCode } from "@/lib/time";
+import { useProjectStore } from "@/stores/project-store";
export function EditorHeader() {
const { getTotalDuration } = useTimelineStore();
+ const { activeProject } = useProjectStore();
const handleExport = () => {
// TODO: Implement export functionality
console.log("Export project");
};
- // Format duration from seconds to MM:SS format
- const formatDuration = (seconds: number): string => {
- const minutes = Math.floor(seconds / 60);
- const remainingSeconds = Math.floor(seconds % 60);
- return `${minutes}:${remainingSeconds.toString().padStart(2, "0")}`;
- };
-
const leftContent = (
<div className="flex items-center gap-2">
<Link
- href="/"
+ href="/projects"
className="font-medium tracking-tight flex items-center gap-2 hover:opacity-80 transition-opacity"
>
<ChevronLeft className="h-4 w-4" />
+ <span className="text-sm">{activeProject?.name}</span>
</Link>
- <ProjectNameEditor />
</div>
);
const centerContent = (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
- <span>{formatDuration(getTotalDuration())}</span>
+ <span>{formatTimeCode(getTotalDuration(), "HH:MM:SS:CS")}</span>
</div>
);
diff --git a/apps/web/src/components/editor/media-panel.tsx b/apps/web/src/components/editor/media-panel.tsx index f717234d..3d83426e 100644 --- a/apps/web/src/components/editor/media-panel.tsx +++ b/apps/web/src/components/editor/media-panel.tsx @@ -4,12 +4,26 @@ import { useDragDrop } from "@/hooks/use-drag-drop"; import { processMediaFiles } from "@/lib/media-processing"; import { useMediaStore, type MediaItem } from "@/stores/media-store"; import { useTimelineStore } from "@/stores/timeline-store"; -import { Image, Music, Plus, Trash2, Upload, Video } from "lucide-react"; +import { Image, Music, Plus, Upload, Video } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import { toast } from "sonner"; import { AspectRatio } from "../ui/aspect-ratio"; import { Button } from "../ui/button"; import { DragOverlay } from "../ui/drag-overlay"; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuTrigger, +} from "../ui/context-menu"; +import { Input } from "../ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "../ui/select"; // MediaPanel lets users add, view, and drag media (images, videos, audio) into the project. // You can upload files or drag them from your computer. Dragging from here to the timeline adds them to your video project. @@ -32,7 +46,9 @@ export function MediaPanel() { setProgress(p) ); // Add each processed media item to the store - processedItems.forEach((item) => addMediaItem(item)); + for (const item of processedItems) { + await addMediaItem(item); + } } catch (error) { // Show error toast if processing fails console.error("Error processing files:", error); @@ -56,12 +72,11 @@ export function MediaPanel() { e.target.value = ""; // Reset input }; - const handleRemove = (e: React.MouseEvent, id: string) => { + const handleRemove = async (e: React.MouseEvent, id: string) => { // Remove a media item from the store e.stopPropagation(); - - // Remove tracks automatically when delete media + // Remove tracks automatically when delete media const { tracks, removeTrack } = useTimelineStore.getState(); tracks.forEach((track) => { const clipsToRemove = track.clips.filter((clip) => clip.mediaId === id); @@ -69,12 +84,14 @@ export function MediaPanel() { useTimelineStore.getState().removeClipFromTrack(track.id, clip.id); }); // Only remove track if it becomes empty and has no other clips - const updatedTrack = useTimelineStore.getState().tracks.find(t => t.id === track.id); + const updatedTrack = useTimelineStore + .getState() + .tracks.find((t) => t.id === track.id); if (updatedTrack && updatedTrack.clips.length === 0) { removeTrack(track.id); } }); - removeMediaItem(id); + await removeMediaItem(id); }; const formatDuration = (duration: number) => { @@ -128,23 +145,22 @@ export function MediaPanel() { 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} - /> + <div className="w-full h-full flex items-center justify-center"> + <img + src={item.url} + alt={item.name} + className="max-w-full max-h-full object-contain rounded" + loading="lazy" + {...baseDragProps} + /> + </div> ); } if (item.type === "video") { if (item.thumbnailUrl) { return ( - <div - className="relative w-full h-full cursor-grab active:cursor-grabbing" - {...baseDragProps} - > + <div className="relative w-full h-full" {...baseDragProps}> <img src={item.thumbnailUrl} alt={item.name} @@ -164,7 +180,7 @@ export function MediaPanel() { } 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" + className="w-full h-full bg-muted/30 flex flex-col items-center justify-center text-muted-foreground rounded" {...baseDragProps} > <Video className="h-6 w-6 mb-1" /> @@ -181,7 +197,7 @@ export function MediaPanel() { 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" + 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" {...baseDragProps} > <Music className="h-6 w-6 mb-1" /> @@ -197,7 +213,7 @@ export function MediaPanel() { 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" + className="w-full h-full bg-muted/30 flex flex-col items-center justify-center text-muted-foreground rounded" {...baseDragProps} > <Image className="h-6 w-6" /> @@ -229,20 +245,21 @@ export function MediaPanel() { {/* Button to add/upload media */} <div className="flex gap-2"> {/* Search and filter controls */} - <select - value={mediaFilter} - onChange={(e) => setMediaFilter(e.target.value)} - className="px-2 py-1 text-xs border rounded bg-background" - > - <option value="all">All</option> - <option value="video">Video</option> - <option value="audio">Audio</option> - <option value="image">Image</option> - </select> - <input + <Select value={mediaFilter} onValueChange={setMediaFilter}> + <SelectTrigger className="w-[80px] h-7 text-xs"> + <SelectValue /> + </SelectTrigger> + <SelectContent> + <SelectItem value="all">All</SelectItem> + <SelectItem value="video">Video</SelectItem> + <SelectItem value="audio">Audio</SelectItem> + <SelectItem value="image">Image</SelectItem> + </SelectContent> + </Select> + <Input type="text" placeholder="Search media..." - className="min-w-[60px] flex-1 px-2 py-1 text-xs border rounded bg-background" + className="min-w-[60px] flex-1 h-7 text-xs" value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} /> @@ -287,39 +304,45 @@ export function MediaPanel() { </p> </div> ) : ( - <div className="grid grid-cols-2 gap-2"> + <div + className="grid gap-2" + style={{ + gridTemplateColumns: "repeat(auto-fill, 160px)", + }} + > {/* Render each media item as a draggable button */} {filteredMediaItems.map((item) => ( <div key={item.id} className="relative group"> - <Button - variant="outline" - className="flex flex-col gap-2 p-2 h-auto w-full relative" - > - <AspectRatio ratio={item.aspectRatio}> - {renderPreview(item)} - </AspectRatio> - <span - className="text-xs truncate px-1 max-w-full" - aria-label={item.name} - title={item.name} - > - {item.name.length > 8 - ? `${item.name.slice(0, 4)}...${item.name.slice(-3)}` - : item.name} - </span> - </Button> - - {/* Show remove button on hover */} - <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" - onClick={(e) => handleRemove(e, item.id)} - > - <Trash2 className="h-3 w-3" /> - </Button> - </div> + <ContextMenu> + <ContextMenuTrigger asChild> + <Button + variant="outline" + className="flex flex-col gap-1 p-2 h-auto w-full relative border-none !bg-transparent cursor-default" + > + <AspectRatio ratio={16 / 9} className="bg-accent"> + {renderPreview(item)} + </AspectRatio> + <span + className="text-[0.7rem] text-muted-foreground truncate w-full text-left" + aria-label={item.name} + title={item.name} + > + {item.name.length > 8 + ? `${item.name.slice(0, 4)}...${item.name.slice(-3)}` + : item.name} + </span> + </Button> + </ContextMenuTrigger> + <ContextMenuContent> + <ContextMenuItem>Export clips</ContextMenuItem> + <ContextMenuItem + variant="destructive" + onClick={(e) => handleRemove(e, item.id)} + > + Delete + </ContextMenuItem> + </ContextMenuContent> + </ContextMenu> </div> ))} </div> diff --git a/apps/web/src/components/editor/preview-panel.tsx b/apps/web/src/components/editor/preview-panel.tsx index 0eee6002..65901365 100644 --- a/apps/web/src/components/editor/preview-panel.tsx +++ b/apps/web/src/components/editor/preview-panel.tsx @@ -5,12 +5,26 @@ import { type TimelineClip, type TimelineTrack, } from "@/stores/timeline-store"; -import { useMediaStore, type MediaItem } from "@/stores/media-store"; +import { + useMediaStore, + type MediaItem, + getMediaAspectRatio, +} from "@/stores/media-store"; import { usePlaybackStore } from "@/stores/playback-store"; +import { useEditorStore } from "@/stores/editor-store"; import { VideoPlayer } from "@/components/ui/video-player"; import { Button } from "@/components/ui/button"; -import { Play, Pause, Volume2, VolumeX, Plus } from "lucide-react"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + DropdownMenuSeparator, +} from "@/components/ui/dropdown-menu"; +import { Play, Pause, Volume2, VolumeX, Plus, Square } from "lucide-react"; import { useState, useRef, useEffect } from "react"; +import { cn } from "@/lib/utils"; +import { formatTimeCode } from "@/lib/time"; interface ActiveClip { clip: TimelineClip; @@ -22,7 +36,7 @@ export function PreviewPanel() { const { tracks } = useTimelineStore(); const { mediaItems } = useMediaStore(); const { currentTime, muted, toggleMute, volume } = usePlaybackStore(); - const [canvasSize, setCanvasSize] = useState({ width: 1920, height: 1080 }); + const { canvasSize, canvasPresets, setCanvasSize } = useEditorStore(); const previewRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null); const [previewDimensions, setPreviewDimensions] = useState({ @@ -116,6 +130,9 @@ export function PreviewPanel() { const activeClips = getActiveClips(); + // Check if there are any clips in the timeline at all + const hasAnyClips = tracks.some((track) => track.clips.length > 0); + // Render a clip const renderClip = (clipData: ActiveClip, index: number) => { const { clip, mediaItem } = clipData; @@ -183,101 +200,150 @@ export function PreviewPanel() { return null; }; - // Canvas presets - const canvasPresets = [ - { name: "16:9 HD", width: 1920, height: 1080 }, - { name: "16:9 4K", width: 3840, height: 2160 }, - { name: "9:16 Mobile", width: 1080, height: 1920 }, - { name: "1:1 Square", width: 1080, height: 1080 }, - { name: "4:3 Standard", width: 1440, height: 1080 }, - ]; - return ( <div className="h-full w-full flex flex-col min-h-0 min-w-0"> - {/* Controls */} - <div className="border-b p-2 flex items-center gap-2 text-xs flex-shrink-0"> - <span className="text-muted-foreground">Canvas:</span> - <select - value={`${canvasSize.width}x${canvasSize.height}`} - onChange={(e) => { - const preset = canvasPresets.find( - (p) => `${p.width}x${p.height}` === e.target.value - ); - if (preset) - setCanvasSize({ width: preset.width, height: preset.height }); - }} - className="bg-background border rounded px-2 py-1 text-xs" - > - {canvasPresets.map((preset) => ( - <option - key={preset.name} - value={`${preset.width}x${preset.height}`} - > - {preset.name} ({preset.width}×{preset.height}) - </option> - ))} - </select> - - <Button - variant="outline" - size="sm" - onClick={toggleMute} - className="ml-auto" - > - {muted || volume === 0 ? ( - <VolumeX className="h-3 w-3 mr-1" /> - ) : ( - <Volume2 className="h-3 w-3 mr-1" /> - )} - {muted || volume === 0 ? "Unmute" : "Mute"} - </Button> - </div> - - {/* Preview Area */} <div ref={containerRef} className="flex-1 flex flex-col items-center justify-center p-3 min-h-0 min-w-0 gap-4" > - <div - ref={previewRef} - className="relative overflow-hidden rounded-sm bg-black border" - style={{ - width: previewDimensions.width, - height: previewDimensions.height, - }} - > - {activeClips.length === 0 ? ( - <div className="absolute inset-0 flex items-center justify-center text-muted-foreground"> - {tracks.length === 0 - ? "No media added to timeline" - : "No clips at current time"} - </div> - ) : ( - activeClips.map((clipData, index) => renderClip(clipData, index)) - )} - </div> + {hasAnyClips ? ( + <div + ref={previewRef} + className="relative overflow-hidden rounded-sm bg-black border" + style={{ + width: previewDimensions.width, + height: previewDimensions.height, + }} + > + {activeClips.length === 0 ? ( + <div className="absolute inset-0 flex items-center justify-center text-muted-foreground"> + No clips at current time + </div> + ) : ( + activeClips.map((clipData, index) => renderClip(clipData, index)) + )} + </div> + ) : ( + <> + {/* Empty div so toolbar stays at the bottom */} + <div className="w-full h-full"></div> + </> + )} - <PreviewToolbar /> + <PreviewToolbar hasAnyClips={hasAnyClips} /> </div> </div> ); } -function PreviewToolbar() { - const { isPlaying, toggle } = usePlaybackStore(); +function PreviewToolbar({ hasAnyClips }: { hasAnyClips: boolean }) { + const { isPlaying, toggle, currentTime } = usePlaybackStore(); + const { + canvasSize, + canvasPresets, + setCanvasSize, + setCanvasSizeFromAspectRatio, + } = useEditorStore(); + const { mediaItems } = useMediaStore(); + const { tracks, getTotalDuration } = useTimelineStore(); + + // Find the current preset based on canvas size + const currentPreset = canvasPresets.find( + (preset) => + preset.width === canvasSize.width && preset.height === canvasSize.height + ); + + const handlePresetSelect = (preset: { width: number; height: number }) => { + setCanvasSize({ width: preset.width, height: preset.height }); + }; + + // Get the first video/image media item to determine original aspect ratio + const getOriginalAspectRatio = () => { + // Find first video or image in timeline + for (const track of tracks) { + for (const clip of track.clips) { + const mediaItem = mediaItems.find((item) => item.id === clip.mediaId); + if ( + mediaItem && + (mediaItem.type === "video" || mediaItem.type === "image") + ) { + return getMediaAspectRatio(mediaItem); + } + } + } + return 16 / 9; // Default aspect ratio + }; + + const handleOriginalSelect = () => { + const aspectRatio = getOriginalAspectRatio(); + setCanvasSizeFromAspectRatio(aspectRatio); + }; + + // Check if current size is "Original" (not matching any preset) + const isOriginal = !currentPreset; return ( <div data-toolbar - className="flex items-center justify-center gap-2 px-4 pt-2 bg-background-500 w-full" + className="flex items-end justify-between gap-2 p-1 pt-2 bg-background-500 w-full" > - <Button variant="text" size="icon" onClick={toggle}> + <div> + <p + className={cn( + "text-xs text-muted-foreground tabular-nums", + !hasAnyClips && "opacity-50" + )} + > + {formatTimeCode(currentTime, "HH:MM:SS:CS")}/ + {formatTimeCode(getTotalDuration(), "HH:MM:SS:CS")} + </p> + </div> + <Button + variant="text" + size="icon" + onClick={toggle} + disabled={!hasAnyClips} + > {isPlaying ? ( <Pause className="h-3 w-3" /> ) : ( <Play className="h-3 w-3" /> )} </Button> + <div> + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button + size="sm" + className="!bg-background text-foreground text-xs h-auto rounded-none border border-foreground px-0.5 py-0 font-light" + disabled={!hasAnyClips} + > + {currentPreset?.name || "Ratio"} + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end"> + <DropdownMenuItem + onClick={handleOriginalSelect} + className={cn("text-xs", isOriginal && "font-semibold")} + > + Original + </DropdownMenuItem> + <DropdownMenuSeparator /> + {canvasPresets.map((preset) => ( + <DropdownMenuItem + key={preset.name} + onClick={() => handlePresetSelect(preset)} + className={cn( + "text-xs", + currentPreset?.name === preset.name && "font-semibold" + )} + > + {preset.name} + </DropdownMenuItem> + ))} + </DropdownMenuContent> + </DropdownMenu> + </div> </div> ); } diff --git a/apps/web/src/components/editor/project-name-editor.tsx b/apps/web/src/components/editor/project-name-editor.tsx deleted file mode 100644 index aa0141ae..00000000 --- a/apps/web/src/components/editor/project-name-editor.tsx +++ /dev/null @@ -1,110 +0,0 @@ -"use client"; - -import { useState, useRef, useEffect } from "react"; -import { Input } from "../ui/input"; -import { useProjectStore } from "@/stores/project-store"; -import { Edit2, Check, X } from "lucide-react"; -import { Button } from "../ui/button"; - -interface ProjectNameEditorProps { - className?: string; -} - -export function ProjectNameEditor({ className }: ProjectNameEditorProps) { - const { activeProject, updateProjectName } = useProjectStore(); - const [isEditing, setIsEditing] = useState(false); - const [editValue, setEditValue] = useState(""); - const inputRef = useRef<HTMLInputElement>(null); - - useEffect(() => { - if (activeProject) { - setEditValue(activeProject.name); - } - }, [activeProject]); - - useEffect(() => { - if (isEditing && inputRef.current) { - inputRef.current.focus(); - inputRef.current.select(); - } - }, [isEditing]); - - const handleStartEdit = () => { - if (activeProject) { - setEditValue(activeProject.name); - setIsEditing(true); - } - }; - - const handleSave = () => { - if (editValue.trim()) { - updateProjectName(editValue.trim()); - setIsEditing(false); - } - }; - - const handleCancel = () => { - if (activeProject) { - setEditValue(activeProject.name); - } - setIsEditing(false); - }; - - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === "Enter") { - handleSave(); - } else if (e.key === "Escape") { - handleCancel(); - } - }; - - if (!activeProject) { - return <span className="text-sm text-muted-foreground">Loading...</span>; - } - - if (isEditing) { - return ( - <div className="flex items-center gap-1"> - <Input - ref={inputRef} - value={editValue} - onChange={(e) => setEditValue(e.target.value)} - onKeyDown={handleKeyDown} - className="h-7 text-sm px-3 py-1 min-w-[200px]" - size={1} - /> - <Button - size="sm" - variant="text" - onClick={handleSave} - className="h-7 w-7 p-0" - disabled={!editValue.trim()} - > - <Check className="h-3 w-3" /> - </Button> - <Button - size="sm" - variant="text" - onClick={handleCancel} - className="h-7 w-7 p-0" - > - <X className="h-3 w-3" /> - </Button> - </div> - ); - } - - return ( - <div className="flex items-center gap-1 group"> - <span className="text-sm font-medium">{activeProject.name}</span> - <Button - size="sm" - variant="text" - onClick={handleStartEdit} - className="h-6 w-6 p-0 opacity-0 group-hover:opacity-100 transition-opacity" - > - <Edit2 className="h-3 w-3" /> - </Button> - </div> - ); -}
\ No newline at end of file diff --git a/apps/web/src/components/editor/timeline-clip.tsx b/apps/web/src/components/editor/timeline-clip.tsx index b9d3b9a6..88767a15 100644 --- a/apps/web/src/components/editor/timeline-clip.tsx +++ b/apps/web/src/components/editor/timeline-clip.tsx @@ -34,7 +34,6 @@ export function TimelineClip({ track, zoomLevel, isSelected, - onContextMenu, onClipMouseDown, onClipClick, }: TimelineClipProps) { @@ -282,9 +281,9 @@ export function TimelineClip({ return ( <div - className={`absolute top-0 h-full select-none transition-all duration-75 ${ + className={`absolute top-0 h-full select-none transition-all duration-75${ isBeingDragged ? "z-50" : "z-10" - } ${isSelected ? "ring-2 ring-primary" : ""}`} + }`} style={{ left: `${clipLeft}px`, width: `${clipWidth}px`, @@ -294,25 +293,31 @@ export function TimelineClip({ onMouseLeave={resizing ? handleResizeEnd : undefined} > <div - className={`relative h-full rounded border cursor-pointer overflow-hidden ${getTrackColor( + className={`relative h-full rounded-[0.15rem] cursor-pointer overflow-hidden ${getTrackColor( track.type - )} ${isSelected ? "ring-2 ring-primary ring-offset-1" : ""}`} + )} ${isSelected ? "border-b-[0.5px] border-t-[0.5px] border-primary" : ""} ${ + isBeingDragged ? "z-50" : "z-10" + }`} onClick={(e) => onClipClick && onClipClick(e, clip)} onMouseDown={handleClipMouseDown} - onContextMenu={(e) => onContextMenu && onContextMenu(e, clip.id)} + onContextMenu={(e) => onClipMouseDown && onClipMouseDown(e, clip)} > <div className="absolute inset-1 flex items-center p-1"> {renderClipContent()} </div> - <div - className="absolute left-0 top-0 bottom-0 w-1 cursor-w-resize hover:bg-primary/50 transition-colors" - onMouseDown={(e) => handleResizeStart(e, clip.id, "left")} - /> - <div - className="absolute right-0 top-0 bottom-0 w-1 cursor-e-resize hover:bg-primary/50 transition-colors" - onMouseDown={(e) => handleResizeStart(e, clip.id, "right")} - /> + {isSelected && ( + <> + <div + className="absolute left-0 top-0 bottom-0 w-1 cursor-w-resize bg-foreground z-50" + onMouseDown={(e) => handleResizeStart(e, clip.id, "left")} + /> + <div + className="absolute right-0 top-0 bottom-0 w-1 cursor-e-resize bg-foreground z-50" + onMouseDown={(e) => handleResizeStart(e, clip.id, "right")} + /> + </> + )} <div className="absolute top-1 right-1"> <DropdownMenu open={clipMenuOpen} onOpenChange={setClipMenuOpen}> diff --git a/apps/web/src/components/editor/timeline-track.tsx b/apps/web/src/components/editor/timeline-track.tsx new file mode 100644 index 00000000..54c50798 --- /dev/null +++ b/apps/web/src/components/editor/timeline-track.tsx @@ -0,0 +1,672 @@ +"use client";
+
+import { useRef, useState, useEffect } from "react";
+import { useTimelineStore } from "@/stores/timeline-store";
+import { useMediaStore } from "@/stores/media-store";
+import { toast } from "sonner";
+import { Copy, Scissors, Trash2 } from "lucide-react";
+import { TimelineClip } from "./timeline-clip";
+import {
+ ContextMenu,
+ ContextMenuContent,
+ ContextMenuItem,
+ ContextMenuSeparator,
+ ContextMenuTrigger,
+} from "../ui/context-menu";
+import {
+ TimelineTrack,
+ TimelineClip as TypeTimelineClip,
+} from "@/stores/timeline-store";
+import { usePlaybackStore } from "@/stores/playback-store";
+
+export function TimelineTrackContent({
+ track,
+ zoomLevel,
+}: {
+ track: TimelineTrack;
+ zoomLevel: number;
+}) {
+ const { mediaItems } = useMediaStore();
+ const {
+ tracks,
+ moveClipToTrack,
+ updateClipStartTime,
+ addClipToTrack,
+ selectedClips,
+ selectClip,
+ deselectClip,
+ dragState,
+ startDrag: startDragAction,
+ updateDragTime,
+ endDrag: endDragAction,
+ } = useTimelineStore();
+
+ const timelineRef = useRef<HTMLDivElement>(null);
+ const [isDropping, setIsDropping] = useState(false);
+ const [dropPosition, setDropPosition] = useState<number | null>(null);
+ const [wouldOverlap, setWouldOverlap] = useState(false);
+ const dragCounterRef = useRef(0);
+ const [mouseDownLocation, setMouseDownLocation] = useState<{
+ x: number;
+ y: number;
+ } | null>(null);
+
+ // Set up mouse event listeners for drag
+ useEffect(() => {
+ if (!dragState.isDragging) return;
+
+ const handleMouseMove = (e: MouseEvent) => {
+ if (!timelineRef.current) return;
+
+ // On first mouse move during drag, ensure the clip is selected
+ if (dragState.clipId && dragState.trackId) {
+ const isSelected = selectedClips.some(
+ (c) =>
+ c.trackId === dragState.trackId && c.clipId === dragState.clipId
+ );
+
+ if (!isSelected) {
+ // Select this clip (replacing other selections) since we're dragging it
+ selectClip(dragState.trackId, dragState.clipId, false);
+ }
+ }
+
+ const timelineRect = timelineRef.current.getBoundingClientRect();
+ const mouseX = e.clientX - timelineRect.left;
+ const mouseTime = Math.max(0, mouseX / (50 * zoomLevel));
+ const adjustedTime = Math.max(0, mouseTime - dragState.clickOffsetTime);
+ const snappedTime = Math.round(adjustedTime * 10) / 10;
+
+ updateDragTime(snappedTime);
+ };
+
+ const handleMouseUp = () => {
+ if (!dragState.clipId || !dragState.trackId) return;
+
+ const finalTime = dragState.currentTime;
+
+ // Check for overlaps and update position
+ const sourceTrack = tracks.find((t) => t.id === dragState.trackId);
+ const movingClip = sourceTrack?.clips.find(
+ (c) => c.id === dragState.clipId
+ );
+
+ if (movingClip) {
+ const movingClipDuration =
+ movingClip.duration - movingClip.trimStart - movingClip.trimEnd;
+ const movingClipEnd = finalTime + movingClipDuration;
+
+ const targetTrack = tracks.find((t) => t.id === track.id);
+ const hasOverlap = targetTrack?.clips.some((existingClip) => {
+ if (
+ dragState.trackId === track.id &&
+ existingClip.id === dragState.clipId
+ ) {
+ return false;
+ }
+ const existingStart = existingClip.startTime;
+ const existingEnd =
+ existingClip.startTime +
+ (existingClip.duration -
+ existingClip.trimStart -
+ existingClip.trimEnd);
+ return finalTime < existingEnd && movingClipEnd > existingStart;
+ });
+
+ if (!hasOverlap) {
+ if (dragState.trackId === track.id) {
+ updateClipStartTime(track.id, dragState.clipId, finalTime);
+ } else {
+ moveClipToTrack(dragState.trackId, track.id, dragState.clipId);
+ requestAnimationFrame(() => {
+ updateClipStartTime(track.id, dragState.clipId!, finalTime);
+ });
+ }
+ }
+ }
+
+ endDragAction();
+ };
+
+ document.addEventListener("mousemove", handleMouseMove);
+ document.addEventListener("mouseup", handleMouseUp);
+
+ return () => {
+ document.removeEventListener("mousemove", handleMouseMove);
+ document.removeEventListener("mouseup", handleMouseUp);
+ };
+ }, [
+ dragState.isDragging,
+ dragState.clickOffsetTime,
+ dragState.clipId,
+ dragState.trackId,
+ dragState.currentTime,
+ zoomLevel,
+ tracks,
+ track.id,
+ updateDragTime,
+ updateClipStartTime,
+ moveClipToTrack,
+ endDragAction,
+ ]);
+
+ const handleClipMouseDown = (e: React.MouseEvent, clip: TypeTimelineClip) => {
+ setMouseDownLocation({ x: e.clientX, y: e.clientY });
+
+ // Handle multi-selection only in mousedown
+ if (e.metaKey || e.ctrlKey || e.shiftKey) {
+ selectClip(track.id, clip.id, true);
+ }
+
+ // Calculate the offset from the left edge of the clip to where the user clicked
+ const clipElement = e.currentTarget as HTMLElement;
+ const clipRect = clipElement.getBoundingClientRect();
+ const clickOffsetX = e.clientX - clipRect.left;
+ const clickOffsetTime = clickOffsetX / (50 * zoomLevel);
+
+ startDragAction(
+ clip.id,
+ track.id,
+ e.clientX,
+ clip.startTime,
+ clickOffsetTime
+ );
+ };
+
+ const handleClipClick = (e: React.MouseEvent, clip: TypeTimelineClip) => {
+ e.stopPropagation();
+
+ // Check if mouse moved significantly
+ if (mouseDownLocation) {
+ const deltaX = Math.abs(e.clientX - mouseDownLocation.x);
+ const deltaY = Math.abs(e.clientY - mouseDownLocation.y);
+ // If it moved more than a few pixels, consider it a drag and not a click.
+ if (deltaX > 5 || deltaY > 5) {
+ setMouseDownLocation(null); // Reset for next interaction
+ return;
+ }
+ }
+
+ // Skip selection logic for multi-selection (handled in mousedown)
+ if (e.metaKey || e.ctrlKey || e.shiftKey) {
+ return;
+ }
+
+ // Handle single selection/deselection
+ const isSelected = selectedClips.some(
+ (c) => c.trackId === track.id && c.clipId === clip.id
+ );
+
+ if (isSelected) {
+ // If clip is selected, deselect it
+ deselectClip(track.id, clip.id);
+ } else {
+ // If clip is not selected, select it (replacing other selections)
+ selectClip(track.id, clip.id, false);
+ }
+ };
+
+ const handleTrackDragOver = (e: React.DragEvent) => {
+ e.preventDefault();
+
+ // 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) {
+ console.error("Error parsing dropped media item:", 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";
+ setWouldOverlap(true);
+ setDropPosition(Math.round(dropTime * 10) / 10);
+ return;
+ }
+
+ e.dataTransfer.dropEffect = hasTimelineClip ? "move" : "copy";
+ setWouldOverlap(false);
+ setDropPosition(Math.round(dropTime * 10) / 10);
+ };
+
+ const handleTrackDragEnter = (e: React.DragEvent) => {
+ e.preventDefault();
+
+ 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);
+ };
+
+ const handleTrackDragLeave = (e: React.DragEvent) => {
+ e.preventDefault();
+
+ 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--;
+
+ if (dragCounterRef.current === 0) {
+ setIsDropping(false);
+ setWouldOverlap(false);
+ setDropPosition(null);
+ }
+ };
+
+ const handleTrackDrop = (e: React.DragEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+
+ // Reset all drag states
+ dragCounterRef.current = 0;
+ setIsDropping(false);
+ setWouldOverlap(false);
+ const currentDropPosition = dropPosition;
+ setDropPosition(null);
+
+ const hasTimelineClip = e.dataTransfer.types.includes(
+ "application/x-timeline-clip"
+ );
+ const hasMediaItem = e.dataTransfer.types.includes(
+ "application/x-media-item"
+ );
+
+ if (!hasTimelineClip && !hasMediaItem) 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 {
+ if (hasTimelineClip) {
+ // Handle timeline clip movement
+ const timelineClipData = e.dataTransfer.getData(
+ "application/x-timeline-clip"
+ );
+ if (!timelineClipData) return;
+
+ const {
+ clipId,
+ trackId: fromTrackId,
+ clickOffsetTime = 0,
+ } = JSON.parse(timelineClipData);
+
+ // Find the clip being moved
+ const sourceTrack = tracks.find(
+ (t: TimelineTrack) => t.id === fromTrackId
+ );
+ const movingClip = sourceTrack?.clips.find(
+ (c: TypeTimelineClip) => c.id === clipId
+ );
+
+ if (!movingClip) {
+ toast.error("Clip not found");
+ return;
+ }
+
+ // Adjust position based on where user clicked on the clip
+ const adjustedStartTime = snappedTime - clickOffsetTime;
+ const finalStartTime = Math.max(
+ 0,
+ Math.round(adjustedStartTime * 10) / 10
+ );
+
+ // Check for overlaps with existing clips (excluding the moving clip itself)
+ const movingClipDuration =
+ movingClip.duration - movingClip.trimStart - movingClip.trimEnd;
+ const movingClipEnd = finalStartTime + 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;
+
+ const existingStart = existingClip.startTime;
+ const existingEnd =
+ existingClip.startTime +
+ (existingClip.duration -
+ existingClip.trimStart -
+ existingClip.trimEnd);
+
+ // Check if clips overlap
+ return finalStartTime < existingEnd && movingClipEnd > existingStart;
+ });
+
+ if (hasOverlap) {
+ toast.error(
+ "Cannot move clip here - it would overlap with existing clips"
+ );
+ return;
+ }
+
+ if (fromTrackId === track.id) {
+ // Moving within same track
+ updateClipStartTime(track.id, clipId, finalStartTime);
+ } else {
+ // Moving to different track
+ moveClipToTrack(fromTrackId, track.id, clipId);
+ requestAnimationFrame(() => {
+ updateClipStartTime(track.id, clipId, finalStartTime);
+ });
+ }
+ } 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;
+
+ const hasOverlap = track.clips.some((existingClip) => {
+ const existingStart = existingClip.startTime;
+ const existingEnd =
+ existingClip.startTime +
+ (existingClip.duration -
+ existingClip.trimStart -
+ existingClip.trimEnd);
+
+ // Check if clips overlap
+ return snappedTime < existingEnd && newClipEnd > existingStart;
+ });
+
+ if (hasOverlap) {
+ toast.error(
+ "Cannot place clip here - it would overlap with existing clips"
+ );
+ return;
+ }
+
+ addClipToTrack(track.id, {
+ mediaId: mediaItem.id,
+ name: mediaItem.name,
+ duration: mediaItem.duration || 5,
+ startTime: snappedTime,
+ trimStart: 0,
+ trimEnd: 0,
+ });
+ }
+ } catch (error) {
+ console.error("Error handling drop:", error);
+ toast.error("Failed to add media to track");
+ }
+ };
+
+ return (
+ <div
+ className="w-full h-full hover:bg-muted/20"
+ onClick={(e) => {
+ // If clicking empty area (not on a clip), deselect all clips
+ if (!(e.target as HTMLElement).closest(".timeline-clip")) {
+ const { clearSelectedClips } = useTimelineStore.getState();
+ clearSelectedClips();
+ }
+ }}
+ onDragOver={handleTrackDragOver}
+ onDragEnter={handleTrackDragEnter}
+ onDragLeave={handleTrackDragLeave}
+ onDrop={handleTrackDrop}
+ >
+ <div
+ ref={timelineRef}
+ 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 isSelected = selectedClips.some(
+ (c) => c.trackId === track.id && c.clipId === clip.id
+ );
+
+ const handleClipSplit = () => {
+ const { currentTime } = usePlaybackStore();
+ const { updateClipTrim, addClipToTrack } = useTimelineStore();
+ const splitTime = currentTime;
+ const effectiveStart = clip.startTime;
+ const effectiveEnd =
+ clip.startTime +
+ (clip.duration - clip.trimStart - clip.trimEnd);
+
+ if (splitTime > effectiveStart && splitTime < effectiveEnd) {
+ updateClipTrim(
+ track.id,
+ clip.id,
+ clip.trimStart,
+ clip.trimEnd + (effectiveEnd - splitTime)
+ );
+ addClipToTrack(track.id, {
+ mediaId: clip.mediaId,
+ name: clip.name + " (split)",
+ duration: clip.duration,
+ startTime: splitTime,
+ trimStart: clip.trimStart + (splitTime - effectiveStart),
+ trimEnd: clip.trimEnd,
+ });
+ toast.success("Clip split successfully");
+ } else {
+ toast.error("Playhead must be within clip to split");
+ }
+ };
+
+ const handleClipDuplicate = () => {
+ const { addClipToTrack } = useTimelineStore.getState();
+ addClipToTrack(track.id, {
+ mediaId: clip.mediaId,
+ name: clip.name + " (copy)",
+ duration: clip.duration,
+ startTime:
+ clip.startTime +
+ (clip.duration - clip.trimStart - clip.trimEnd) +
+ 0.1,
+ trimStart: clip.trimStart,
+ trimEnd: clip.trimEnd,
+ });
+ toast.success("Clip duplicated");
+ };
+
+ const handleClipDelete = () => {
+ const { removeClipFromTrack } = useTimelineStore.getState();
+ removeClipFromTrack(track.id, clip.id);
+ toast.success("Clip deleted");
+ };
+
+ return (
+ <ContextMenu key={clip.id}>
+ <ContextMenuTrigger asChild>
+ <div>
+ <TimelineClip
+ clip={clip}
+ track={track}
+ zoomLevel={zoomLevel}
+ isSelected={isSelected}
+ onClipMouseDown={handleClipMouseDown}
+ onClipClick={handleClipClick}
+ />
+ </div>
+ </ContextMenuTrigger>
+ <ContextMenuContent>
+ <ContextMenuItem onClick={handleClipSplit}>
+ <Scissors className="h-4 w-4 mr-2" />
+ Split at Playhead
+ </ContextMenuItem>
+ <ContextMenuItem onClick={handleClipDuplicate}>
+ <Copy className="h-4 w-4 mr-2" />
+ Duplicate Clip
+ </ContextMenuItem>
+ <ContextMenuSeparator />
+ <ContextMenuItem
+ onClick={handleClipDelete}
+ className="text-destructive focus:text-destructive"
+ >
+ <Trash2 className="h-4 w-4 mr-2" />
+ Delete Clip
+ </ContextMenuItem>
+ </ContextMenuContent>
+ </ContextMenu>
+ );
+ })}
+ </>
+ )}
+ </div>
+ </div>
+ );
+}
diff --git a/apps/web/src/components/editor/timeline.tsx b/apps/web/src/components/editor/timeline.tsx index d43ae588..3f984f92 100644 --- a/apps/web/src/components/editor/timeline.tsx +++ b/apps/web/src/components/editor/timeline.tsx @@ -1,1948 +1,1175 @@ -"use client";
-
-import { ScrollArea } from "../ui/scroll-area";
-import { Button } from "../ui/button";
-import {
- Scissors,
- ArrowLeftToLine,
- ArrowRightToLine,
- Trash2,
- Snowflake,
- Copy,
- SplitSquareHorizontal,
- Volume2,
- VolumeX,
- Pause,
- Play,
-} from "lucide-react";
-import {
- Tooltip,
- TooltipContent,
- TooltipTrigger,
- TooltipProvider,
-} from "../ui/tooltip";
-import {
- useTimelineStore,
- type TimelineTrack,
- type TimelineClip as TypeTimelineClip,
-} from "@/stores/timeline-store";
-import { useMediaStore } from "@/stores/media-store";
-import { usePlaybackStore } from "@/stores/playback-store";
-import { useDragClip } from "@/hooks/use-drag-clip";
-import { processMediaFiles } from "@/lib/media-processing";
-import { toast } from "sonner";
-import { useState, useRef, useEffect, useCallback } from "react";
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "../ui/select";
-import { TimelineClip } from "./timeline-clip";
-import { ContextMenuState } from "@/types/timeline";
-
-export function Timeline() {
- // Timeline shows all tracks (video, audio, effects) and their clips.
- // You can drag media here to add it to your project.
- // Clips can be trimmed, deleted, and moved.
- const {
- tracks,
- addTrack,
- addClipToTrack,
- removeTrack,
- toggleTrackMute,
- removeClipFromTrack,
- getTotalDuration,
- selectedClips,
- clearSelectedClips,
- setSelectedClips,
- updateClipTrim,
- splitClip,
- splitAndKeepLeft,
- splitAndKeepRight,
- separateAudio,
- undo,
- redo,
- } = useTimelineStore();
- const { mediaItems, addMediaItem } = useMediaStore();
- const {
- currentTime,
- duration,
- seek,
- setDuration,
- isPlaying,
- toggle,
- setSpeed,
- speed,
- } = usePlaybackStore();
- const [isDragOver, setIsDragOver] = useState(false);
- const [isProcessing, setIsProcessing] = useState(false);
- const [progress, setProgress] = useState(0);
- const [zoomLevel, setZoomLevel] = useState(1);
- const dragCounterRef = useRef(0);
- const timelineRef = useRef<HTMLDivElement>(null);
- const [isInTimeline, setIsInTimeline] = useState(false);
-
- // Unified context menu state
- const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
-
- // Marquee selection state
- const [marquee, setMarquee] = useState<{
- startX: number;
- startY: number;
- endX: number;
- endY: number;
- active: boolean;
- additive: boolean;
- } | null>(null);
-
- // Playhead scrubbing state
- const [isScrubbing, setIsScrubbing] = useState(false);
- const [scrubTime, setScrubTime] = useState<number | null>(null);
-
- // Dynamic timeline width calculation based on playhead position and duration
- const dynamicTimelineWidth = Math.max(
- (duration || 0) * 50 * zoomLevel, // Base width from duration
- (currentTime + 30) * 50 * zoomLevel, // Width to show current time + 30 seconds buffer
- timelineRef.current?.clientWidth || 1000 // Minimum width
- );
-
- // Scroll synchronization and auto-scroll to playhead
- const rulerScrollRef = useRef<HTMLDivElement>(null);
- const tracksScrollRef = useRef<HTMLDivElement>(null);
- const isUpdatingRef = useRef(false);
- const lastRulerSync = useRef(0);
- const lastTracksSync = useRef(0);
-
- // New refs for direct playhead DOM manipulation
- const rulerPlayheadRef = useRef<HTMLDivElement>(null);
- const tracksPlayheadRef = useRef<HTMLDivElement>(null);
-
- // Refs to store initial mouse and scroll positions for drag calculations
- const initialMouseXRef = useRef(0);
- const initialTimelineScrollLeftRef = useRef(0);
-
- // Update timeline duration when tracks change
- useEffect(() => {
- const totalDuration = getTotalDuration();
- setDuration(Math.max(totalDuration, 10)); // Minimum 10 seconds for empty timeline
- }, [tracks, setDuration, getTotalDuration]);
-
- // Close context menu on click elsewhere
- useEffect(() => {
- const handleClick = () => setContextMenu(null);
- if (contextMenu) {
- window.addEventListener("click", handleClick);
- return () => window.removeEventListener("click", handleClick);
- }
- }, [contextMenu]);
-
- // Keyboard event for deleting selected clips
- useEffect(() => {
- const handleKeyDown = (e: KeyboardEvent) => {
- if (
- (e.key === "Delete" || e.key === "Backspace") &&
- selectedClips.length > 0
- ) {
- selectedClips.forEach(({ trackId, clipId }) => {
- removeClipFromTrack(trackId, clipId);
- });
- clearSelectedClips();
- }
- };
- window.addEventListener("keydown", handleKeyDown);
- return () => window.removeEventListener("keydown", handleKeyDown);
- }, [selectedClips, removeClipFromTrack, clearSelectedClips]);
-
- // Keyboard event for undo (Cmd+Z)
- useEffect(() => {
- const handleKeyDown = (e: KeyboardEvent) => {
- if ((e.metaKey || e.ctrlKey) && e.key === "z" && !e.shiftKey) {
- e.preventDefault();
- undo();
- }
- };
- window.addEventListener("keydown", handleKeyDown);
- return () => window.removeEventListener("keydown", handleKeyDown);
- }, [undo]);
-
- // Keyboard event for redo (Cmd+Shift+Z or Cmd+Y)
- useEffect(() => {
- const handleKeyDown = (e: KeyboardEvent) => {
- if ((e.metaKey || e.ctrlKey) && e.key === "z" && e.shiftKey) {
- e.preventDefault();
- redo();
- } else if ((e.metaKey || e.ctrlKey) && e.key === "y") {
- e.preventDefault();
- redo();
- }
- };
- window.addEventListener("keydown", handleKeyDown);
- return () => window.removeEventListener("keydown", handleKeyDown);
- }, [redo]);
-
- // Mouse down on timeline background to start marquee
- const handleTimelineMouseDown = (e: React.MouseEvent) => {
- if (e.target === e.currentTarget && e.button === 0) {
- setMarquee({
- startX: e.clientX,
- startY: e.clientY,
- endX: e.clientX,
- endY: e.clientY,
- active: true,
- additive: e.metaKey || e.ctrlKey || e.shiftKey,
- });
- }
- };
-
- // Mouse move to update marquee
- useEffect(() => {
- if (!marquee || !marquee.active) return;
- const handleMouseMove = (e: MouseEvent) => {
- setMarquee(
- (prev) => prev && { ...prev, endX: e.clientX, endY: e.clientY }
- );
- };
- const handleMouseUp = (e: MouseEvent) => {
- setMarquee(
- (prev) =>
- prev && { ...prev, endX: e.clientX, endY: e.clientY, active: false }
- );
- };
- window.addEventListener("mousemove", handleMouseMove);
- window.addEventListener("mouseup", handleMouseUp);
- return () => {
- window.removeEventListener("mousemove", handleMouseMove);
- window.removeEventListener("mouseup", handleMouseUp);
- };
- }, [marquee]);
-
- // On marquee end, select clips in box
- useEffect(() => {
- if (!marquee || marquee.active) return;
- const timeline = timelineRef.current;
- if (!timeline) return;
- const rect = timeline.getBoundingClientRect();
- const x1 = Math.min(marquee.startX, marquee.endX) - rect.left;
- const x2 = Math.max(marquee.startX, marquee.endX) - rect.left;
- const y1 = Math.min(marquee.startY, marquee.endY) - rect.top;
- const y2 = Math.max(marquee.startY, marquee.endY) - rect.top;
- // Validation: skip if too small
- if (Math.abs(x2 - x1) < 5 || Math.abs(y2 - y1) < 5) {
- setMarquee(null);
- return;
- }
- // Clamp to timeline bounds
- const clamp = (val: number, min: number, max: number) =>
- Math.max(min, Math.min(max, val));
- const bx1 = clamp(x1, 0, rect.width);
- const bx2 = clamp(x2, 0, rect.width);
- const by1 = clamp(y1, 0, rect.height);
- const by2 = clamp(y2, 0, rect.height);
- let newSelection: { trackId: string; clipId: string }[] = [];
- tracks.forEach((track, trackIdx) => {
- track.clips.forEach((clip) => {
- const effectiveDuration = clip.duration - clip.trimStart - clip.trimEnd;
- const clipWidth = Math.max(80, effectiveDuration * 50 * zoomLevel);
- const clipLeft = clip.startTime * 50 * zoomLevel;
- const clipTop = trackIdx * 60;
- const clipBottom = clipTop + 60;
- const clipRight = clipLeft + 60; // Set a fixed width for time display
- if (
- bx1 < clipRight &&
- bx2 > clipLeft &&
- by1 < clipBottom &&
- by2 > clipTop
- ) {
- newSelection.push({ trackId: track.id, clipId: clip.id });
- }
- });
- });
- if (newSelection.length > 0) {
- if (marquee.additive) {
- const selectedSet = new Set(
- selectedClips.map((c) => c.trackId + ":" + c.clipId)
- );
- newSelection = [
- ...selectedClips,
- ...newSelection.filter(
- (c) => !selectedSet.has(c.trackId + ":" + c.clipId)
- ),
- ];
- }
- setSelectedClips(newSelection);
- } else if (!marquee.additive) {
- clearSelectedClips();
- }
- setMarquee(null);
- }, [
- marquee,
- tracks,
- zoomLevel,
- selectedClips,
- setSelectedClips,
- clearSelectedClips,
- ]);
-
- const handleDragEnter = (e: React.DragEvent) => {
- // When something is dragged over the timeline, show overlay
- e.preventDefault();
- // Don't show overlay for timeline clips - they're handled by tracks
- if (e.dataTransfer.types.includes("application/x-timeline-clip")) {
- return;
- }
- dragCounterRef.current += 1;
- if (!isDragOver) {
- setIsDragOver(true);
- }
- };
-
- const handleDragOver = (e: React.DragEvent) => {
- e.preventDefault();
- };
-
- const handleDragLeave = (e: React.DragEvent) => {
- e.preventDefault();
-
- // Don't update state for timeline clips - they're handled by tracks
- if (e.dataTransfer.types.includes("application/x-timeline-clip")) {
- return;
- }
-
- dragCounterRef.current -= 1;
- if (dragCounterRef.current === 0) {
- setIsDragOver(false);
- }
- };
-
- const handleDrop = async (e: React.DragEvent) => {
- // When media is dropped, add it as a new track/clip
- e.preventDefault();
- setIsDragOver(false);
- dragCounterRef.current = 0;
-
- // Ignore timeline clip drags - they're handled by track-specific handlers
- const hasTimelineClip = e.dataTransfer.types.includes(
- "application/x-timeline-clip"
- );
- if (hasTimelineClip) {
- return;
- }
-
- const mediaItemData = e.dataTransfer.getData("application/x-media-item");
- if (mediaItemData) {
- // Handle media item drops by creating new tracks
- try {
- const { id, type } = JSON.parse(mediaItemData);
- const mediaItem = mediaItems.find((item) => item.id === id);
- if (!mediaItem) {
- toast.error("Media item not found");
- return;
- }
- // Add to video or audio track depending on type
- const trackType = type === "audio" ? "audio" : "video";
- const newTrackId = addTrack(trackType);
- addClipToTrack(newTrackId, {
- mediaId: mediaItem.id,
- name: mediaItem.name,
- duration: mediaItem.duration || 5,
- startTime: 0,
- trimStart: 0,
- trimEnd: 0,
- });
- toast.success(`Added ${mediaItem.name} to new ${trackType} track`);
- } catch (error) {
- // Show error if parsing fails
- console.error("Error parsing media item data:", error);
- toast.error("Failed to add media to timeline");
- }
- } else if (e.dataTransfer.files?.length > 0) {
- // Handle file drops by creating new tracks
- setIsProcessing(true);
- setProgress(0);
- try {
- const processedItems = await processMediaFiles(
- e.dataTransfer.files,
- (p) => setProgress(p)
- );
- for (const processedItem of processedItems) {
- addMediaItem(processedItem);
- const currentMediaItems = useMediaStore.getState().mediaItems;
- const addedItem = currentMediaItems.find(
- (item) =>
- item.name === processedItem.name && item.url === processedItem.url
- );
- if (addedItem) {
- const trackType =
- processedItem.type === "audio" ? "audio" : "video";
- const newTrackId = addTrack(trackType);
- addClipToTrack(newTrackId, {
- mediaId: addedItem.id,
- name: addedItem.name,
- duration: addedItem.duration || 5,
- startTime: 0,
- trimStart: 0,
- trimEnd: 0,
- });
- }
- }
- } catch (error) {
- // Show error if file processing fails
- console.error("Error processing external files:", error);
- toast.error("Failed to process dropped files");
- } finally {
- setIsProcessing(false);
- setProgress(0);
- }
- }
- };
-
- const handleSeekToPosition = (e: React.MouseEvent) => {
- const rect = e.currentTarget.getBoundingClientRect();
- const clickX = e.clientX - rect.left;
- const clickedTime = clickX / (50 * zoomLevel);
- const clampedTime = Math.max(0, Math.min(duration, clickedTime));
-
- seek(clampedTime);
- };
-
- const handleTimelineAreaClick = (e: React.MouseEvent) => {
- if (e.target === e.currentTarget) {
- clearSelectedClips();
-
- // Calculate the clicked time position and seek to it
- handleSeekToPosition(e);
- }
- };
-
- const handleWheel = (e: React.WheelEvent) => {
- // Only zoom if user is using pinch gesture (ctrlKey or metaKey is true)
- if (e.ctrlKey || e.metaKey) {
- e.preventDefault();
- const delta = e.deltaY > 0 ? -0.05 : 0.05;
- setZoomLevel((prev) => Math.max(0.1, Math.min(10, prev + delta)));
- }
- // Otherwise, allow normal scrolling
- };
-
- // --- Playhead Scrubbing Handlers ---
- const handlePlayheadMouseDown = useCallback(
- (e: React.MouseEvent) => {
- e.preventDefault();
- setIsScrubbing(true);
- handleScrub(e);
- },
- [duration, zoomLevel]
- );
-
- const handleScrub = useCallback(
- (e: MouseEvent | React.MouseEvent) => {
- const timeline = timelineRef.current;
- if (!timeline) return;
- const rect = timeline.getBoundingClientRect();
- const x = e.clientX - rect.left;
- const time = Math.max(0, Math.min(duration, x / (50 * zoomLevel)));
- setScrubTime(time);
- seek(time); // update video preview in real time
- },
- [duration, zoomLevel, seek]
- );
-
- useEffect(() => {
- if (!isScrubbing) return;
- const onMouseMove = (e: MouseEvent) => handleScrub(e);
- const onMouseUp = (e: MouseEvent) => {
- setIsScrubbing(false);
- if (scrubTime !== null) seek(scrubTime); // finalize seek
- setScrubTime(null);
- };
- window.addEventListener("mousemove", onMouseMove);
- window.addEventListener("mouseup", onMouseUp);
- return () => {
- window.removeEventListener("mousemove", onMouseMove);
- window.removeEventListener("mouseup", onMouseUp);
- };
- }, [isScrubbing, scrubTime, seek, handleScrub]);
-
- const playheadPosition =
- isScrubbing && scrubTime !== null ? scrubTime : currentTime;
-
- const dragProps = {
- onDragEnter: handleDragEnter,
- onDragOver: handleDragOver,
- onDragLeave: handleDragLeave,
- onDrop: handleDrop,
- };
-
- // Action handlers for toolbar
- const handleSplitSelected = () => {
- if (selectedClips.length === 0) {
- toast.error("No clips selected");
- return;
- }
- let splitCount = 0;
- selectedClips.forEach(({ trackId, clipId }) => {
- const track = tracks.find((t) => t.id === trackId);
- const clip = track?.clips.find((c) => c.id === clipId);
- if (clip && track) {
- const effectiveStart = clip.startTime;
- const effectiveEnd =
- clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd);
-
- if (currentTime > effectiveStart && currentTime < effectiveEnd) {
- const newClipId = splitClip(trackId, clipId, currentTime);
- if (newClipId) splitCount++;
- }
- }
- });
- if (splitCount > 0) {
- toast.success(`Split ${splitCount} clip(s) at playhead`);
- } else {
- toast.error("Playhead must be within selected clips to split");
- }
- };
-
- const handleDuplicateSelected = () => {
- if (selectedClips.length === 0) {
- toast.error("No clips selected");
- return;
- }
- selectedClips.forEach(({ trackId, clipId }) => {
- const track = tracks.find((t) => t.id === trackId);
- const clip = track?.clips.find((c) => c.id === clipId);
- if (clip && track) {
- addClipToTrack(track.id, {
- mediaId: clip.mediaId,
- name: clip.name + " (copy)",
- duration: clip.duration,
- startTime:
- clip.startTime +
- (clip.duration - clip.trimStart - clip.trimEnd) +
- 0.1,
- trimStart: clip.trimStart,
- trimEnd: clip.trimEnd,
- });
- }
- });
- toast.success("Duplicated selected clip(s)");
- };
-
- const handleFreezeSelected = () => {
- if (selectedClips.length === 0) {
- toast.error("No clips selected");
- return;
- }
- selectedClips.forEach(({ trackId, clipId }) => {
- const track = tracks.find((t) => t.id === trackId);
- const clip = track?.clips.find((c) => c.id === clipId);
- if (clip && track) {
- // Add a new freeze frame clip at the playhead
- addClipToTrack(track.id, {
- mediaId: clip.mediaId,
- name: clip.name + " (freeze)",
- duration: 1, // 1 second freeze frame
- startTime: currentTime,
- trimStart: 0,
- trimEnd: clip.duration - 1,
- });
- }
- });
- toast.success("Freeze frame added for selected clip(s)");
- };
- const handleSplitAndKeepLeft = () => {
- if (selectedClips.length === 0) {
- toast.error("No clips selected");
- return;
- }
-
- let splitCount = 0;
- selectedClips.forEach(({ trackId, clipId }) => {
- const track = tracks.find((t) => t.id === trackId);
- const clip = track?.clips.find((c) => c.id === clipId);
- if (clip && track) {
- const effectiveStart = clip.startTime;
- const effectiveEnd =
- clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd);
-
- if (currentTime > effectiveStart && currentTime < effectiveEnd) {
- splitAndKeepLeft(trackId, clipId, currentTime);
- splitCount++;
- }
- }
- });
-
- if (splitCount > 0) {
- toast.success(`Split and kept left portion of ${splitCount} clip(s)`);
- } else {
- toast.error("Playhead must be within selected clips");
- }
- };
-
- const handleSplitAndKeepRight = () => {
- if (selectedClips.length === 0) {
- toast.error("No clips selected");
- return;
- }
-
- let splitCount = 0;
- selectedClips.forEach(({ trackId, clipId }) => {
- const track = tracks.find((t) => t.id === trackId);
- const clip = track?.clips.find((c) => c.id === clipId);
- if (clip && track) {
- const effectiveStart = clip.startTime;
- const effectiveEnd =
- clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd);
-
- if (currentTime > effectiveStart && currentTime < effectiveEnd) {
- splitAndKeepRight(trackId, clipId, currentTime);
- splitCount++;
- }
- }
- });
-
- if (splitCount > 0) {
- toast.success(`Split and kept right portion of ${splitCount} clip(s)`);
- } else {
- toast.error("Playhead must be within selected clips");
- }
- };
-
- const handleSeparateAudio = () => {
- if (selectedClips.length === 0) {
- toast.error("No clips selected");
- return;
- }
-
- let separatedCount = 0;
- selectedClips.forEach(({ trackId, clipId }) => {
- const track = tracks.find((t) => t.id === trackId);
- const clip = track?.clips.find((c) => c.id === clipId);
- const mediaItem = mediaItems.find((item) => item.id === clip?.mediaId);
-
- if (
- clip &&
- track &&
- mediaItem?.type === "video" &&
- track.type === "video"
- ) {
- const audioClipId = separateAudio(trackId, clipId);
- if (audioClipId) separatedCount++;
- }
- });
-
- if (separatedCount > 0) {
- toast.success(`Separated audio from ${separatedCount} video clip(s)`);
- } else {
- toast.error("Select video clips to separate audio");
- }
- };
- const handleDeleteSelected = () => {
- if (selectedClips.length === 0) {
- toast.error("No clips selected");
- return;
- }
- selectedClips.forEach(({ trackId, clipId }) => {
- removeClipFromTrack(trackId, clipId);
- });
- clearSelectedClips();
- toast.success("Deleted selected clip(s)");
- };
-
- // Prevent explorer zooming in/out when in timeline
- useEffect(() => {
- const preventZoom = (e: WheelEvent) => {
- // if (isInTimeline && (e.ctrlKey || e.metaKey)) {
- if (
- isInTimeline &&
- (e.ctrlKey || e.metaKey) &&
- timelineRef.current?.contains(e.target as Node)
- ) {
- e.preventDefault();
- }
- };
-
- document.addEventListener("wheel", preventZoom, { passive: false });
-
- return () => {
- document.removeEventListener("wheel", preventZoom);
- };
- }, [isInTimeline]);
-
- // --- Scroll synchronization effect ---
- useEffect(() => {
- const rulerViewport = rulerScrollRef.current?.querySelector(
- "[data-radix-scroll-area-viewport]"
- ) as HTMLElement;
- const tracksViewport = tracksScrollRef.current?.querySelector(
- "[data-radix-scroll-area-viewport]"
- ) as HTMLElement;
- if (!rulerViewport || !tracksViewport) return;
- const handleRulerScroll = () => {
- const now = Date.now();
- if (isUpdatingRef.current || now - lastRulerSync.current < 16) return;
- lastRulerSync.current = now;
- isUpdatingRef.current = true;
- tracksViewport.scrollLeft = rulerViewport.scrollLeft;
- isUpdatingRef.current = false;
- };
- const handleTracksScroll = () => {
- const now = Date.now();
- if (isUpdatingRef.current || now - lastTracksSync.current < 16) return;
- lastTracksSync.current = now;
- isUpdatingRef.current = true;
- rulerViewport.scrollLeft = tracksViewport.scrollLeft;
- isUpdatingRef.current = false;
- };
- rulerViewport.addEventListener("scroll", handleRulerScroll);
- tracksViewport.addEventListener("scroll", handleTracksScroll);
- return () => {
- rulerViewport.removeEventListener("scroll", handleRulerScroll);
- tracksViewport.removeEventListener("scroll", handleTracksScroll);
- };
- }, []);
-
- // --- Playhead auto-scroll effect ---
- useEffect(() => {
- const rulerViewport = rulerScrollRef.current?.querySelector(
- "[data-radix-scroll-area-viewport]"
- ) as HTMLElement;
- const tracksViewport = tracksScrollRef.current?.querySelector(
- "[data-radix-scroll-area-viewport]"
- ) as HTMLElement;
- if (!rulerViewport || !tracksViewport) return;
- const playheadPx = playheadPosition * 50 * zoomLevel;
- const viewportWidth = rulerViewport.clientWidth;
- const scrollMin = 0;
- const scrollMax = rulerViewport.scrollWidth - viewportWidth;
- // Center the playhead if it's not visible (100px buffer)
- const desiredScroll = Math.max(
- scrollMin,
- Math.min(scrollMax, playheadPx - viewportWidth / 2)
- );
- if (
- playheadPx < rulerViewport.scrollLeft + 100 ||
- playheadPx > rulerViewport.scrollLeft + viewportWidth - 100
- ) {
- rulerViewport.scrollLeft = tracksViewport.scrollLeft = desiredScroll;
- }
- }, [playheadPosition, duration, zoomLevel]);
-
- const getContextMenuPosition = (x: number, y: number) => {
- const menuWidth = 160;
- const menuHeight = 200;
- const margin = 4;
- const verticalOffset = 80;
-
- const viewportWidth = window.innerWidth;
- const viewportHeight = window.innerHeight;
-
- let adjustedX = x + 2;
- let adjustedY = y + 2;
-
- // Horizontal positioning
- if (adjustedX + menuWidth > viewportWidth - margin) {
- adjustedX = x - menuWidth - 2;
- if (adjustedX < margin) {
- adjustedX = viewportWidth - menuWidth - margin;
- }
- }
-
- if (adjustedX < margin) {
- adjustedX = margin;
- }
-
- // Vertical positioning
- if (adjustedY + menuHeight > viewportHeight - margin) {
- adjustedY = y - menuHeight + verticalOffset;
-
- if (adjustedY < margin) {
- adjustedY = viewportHeight - menuHeight - margin;
- }
- }
-
- if (adjustedY < margin) {
- adjustedY = margin;
- }
-
- adjustedX = Math.max(
- margin,
- Math.min(adjustedX, viewportWidth - menuWidth - margin)
- );
- adjustedY = Math.max(
- margin,
- Math.min(adjustedY, viewportHeight - menuHeight - margin)
- );
-
- return { x: adjustedX, y: adjustedY };
- };
-
- return (
- <div
- className={`h-full flex flex-col transition-colors duration-200 relative ${isDragOver ? "bg-accent/30 border-accent" : ""}`}
- {...dragProps}
- onMouseEnter={() => setIsInTimeline(true)}
- onMouseLeave={() => setIsInTimeline(false)}
- onWheel={handleWheel}
- >
- {/* Toolbar */}
- <div className="border-b flex items-center px-2 py-1 gap-1">
- <TooltipProvider delayDuration={500}>
- {/* Play/Pause Button */}
- <Tooltip>
- <TooltipTrigger asChild>
- <Button
- variant="text"
- size="icon"
- onClick={toggle}
- className="mr-2"
- >
- {isPlaying ? (
- <Pause className="h-4 w-4" />
- ) : (
- <Play className="h-4 w-4" />
- )}
- </Button>
- </TooltipTrigger>
- <TooltipContent>
- {isPlaying ? "Pause (Space)" : "Play (Space)"}
- </TooltipContent>
- </Tooltip>
-
- <div className="w-px h-6 bg-border mx-1" />
-
- {/* Time Display */}
- <div
- className="text-xs text-muted-foreground font-mono px-2"
- style={{ minWidth: "18ch", textAlign: "center" }}
- >
- {currentTime.toFixed(1)}s / {duration.toFixed(1)}s
- </div>
-
- {/* Test Clip Button - for debugging */}
- {tracks.length === 0 && (
- <>
- <div className="w-px h-6 bg-border mx-1" />
- <Tooltip>
- <TooltipTrigger asChild>
- <Button
- variant="outline"
- size="sm"
- onClick={() => {
- const trackId = addTrack("video");
- addClipToTrack(trackId, {
- mediaId: "test",
- name: "Test Clip",
- duration: 5,
- startTime: 0,
- trimStart: 0,
- trimEnd: 0,
- });
- }}
- className="text-xs"
- >
- Add Test Clip
- </Button>
- </TooltipTrigger>
- <TooltipContent>Add a test clip to try playback</TooltipContent>
- </Tooltip>
- </>
- )}
-
- <div className="w-px h-6 bg-border mx-1" />
-
- <Tooltip>
- <TooltipTrigger asChild>
- <Button variant="text" size="icon" onClick={handleSplitSelected}>
- <Scissors className="h-4 w-4" />
- </Button>
- </TooltipTrigger>
- <TooltipContent>Split clip (Ctrl+S)</TooltipContent>
- </Tooltip>
-
- <Tooltip>
- <TooltipTrigger asChild>
- <Button
- variant="text"
- size="icon"
- onClick={handleSplitAndKeepLeft}
- >
- <ArrowLeftToLine className="h-4 w-4" />
- </Button>
- </TooltipTrigger>
- <TooltipContent>Split and keep left (Ctrl+Q)</TooltipContent>
- </Tooltip>
-
- <Tooltip>
- <TooltipTrigger asChild>
- <Button
- variant="text"
- size="icon"
- onClick={handleSplitAndKeepRight}
- >
- <ArrowRightToLine className="h-4 w-4" />
- </Button>
- </TooltipTrigger>
- <TooltipContent>Split and keep right (Ctrl+W)</TooltipContent>
- </Tooltip>
-
- <Tooltip>
- <TooltipTrigger asChild>
- <Button variant="text" size="icon" onClick={handleSeparateAudio}>
- <SplitSquareHorizontal className="h-4 w-4" />
- </Button>
- </TooltipTrigger>
- <TooltipContent>Separate audio (Ctrl+D)</TooltipContent>
- </Tooltip>
-
- <Tooltip>
- <TooltipTrigger asChild>
- <Button
- variant="text"
- size="icon"
- onClick={handleDuplicateSelected}
- >
- <Copy className="h-4 w-4" />
- </Button>
- </TooltipTrigger>
- <TooltipContent>Duplicate clip (Ctrl+D)</TooltipContent>
- </Tooltip>
-
- <Tooltip>
- <TooltipTrigger asChild>
- <Button variant="text" size="icon" onClick={handleFreezeSelected}>
- <Snowflake className="h-4 w-4" />
- </Button>
- </TooltipTrigger>
- <TooltipContent>Freeze frame (F)</TooltipContent>
- </Tooltip>
-
- <Tooltip>
- <TooltipTrigger asChild>
- <Button variant="text" size="icon" onClick={handleDeleteSelected}>
- <Trash2 className="h-4 w-4" />
- </Button>
- </TooltipTrigger>
- <TooltipContent>Delete clip (Delete)</TooltipContent>
- </Tooltip>
-
- <div className="w-px h-6 bg-border mx-1" />
-
- {/* Speed Control */}
- <Tooltip>
- <TooltipTrigger asChild>
- <Select
- value={speed.toFixed(1)}
- onValueChange={(value) => setSpeed(parseFloat(value))}
- >
- <SelectTrigger className="w-[90px] h-8">
- <SelectValue placeholder="1.0x" />
- </SelectTrigger>
- <SelectContent>
- <SelectItem value="0.5">0.5x</SelectItem>
- <SelectItem value="1.0">1.0x</SelectItem>
- <SelectItem value="1.5">1.5x</SelectItem>
- <SelectItem value="2.0">2.0x</SelectItem>
- </SelectContent>
- </Select>
- </TooltipTrigger>
- <TooltipContent>Playback Speed</TooltipContent>
- </Tooltip>
- </TooltipProvider>
- </div>
-
- {/* 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" ref={rulerScrollRef}>
- <div
- className="relative h-12 bg-muted/30 cursor-pointer"
- style={{
- width: `${dynamicTimelineWidth}px`,
- }}
- onClick={(e) => {
- // Calculate the clicked time position and seek to it
- handleSeekToPosition(e);
- }}
- >
- {/* 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 (scrubbable) */}
- <div
- className="absolute top-0 bottom-0 w-0.5 bg-red-500 pointer-events-auto z-10 cursor-ew-resize"
- style={{ left: `${playheadPosition * 50 * zoomLevel}px` }}
- onMouseDown={handlePlayheadMouseDown}
- >
- <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>
-
- {/* Tracks Area */}
- <div className="flex-1 flex overflow-hidden">
- {/* Track Labels */}
- {tracks.length > 0 && (
- <div className="w-48 flex-shrink-0 border-r bg-background overflow-y-auto">
- <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 group"
- onContextMenu={(e) => {
- e.preventDefault();
- setContextMenu({
- type: "track",
- trackId: track.id,
- x: e.clientX,
- y: e.clientY,
- });
- }}
- >
- <div className="flex items-center 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="ml-2 text-sm font-medium truncate">
- {track.name}
- </span>
- </div>
- {track.muted && (
- <span className="ml-2 text-xs text-red-500 font-semibold flex-shrink-0">
- Muted
- </span>
- )}
- </div>
- ))}
- </div>
- </div>
- )}
-
- {/* Timeline Tracks Content */}
- <div className="flex-1 relative overflow-hidden">
- <ScrollArea className="w-full h-full" ref={tracksScrollRef}>
- <div
- className="relative flex-1"
- style={{
- height: `${Math.max(200, Math.min(800, tracks.length * 60))}px`,
- width: `${dynamicTimelineWidth}px`,
- }}
- onClick={handleTimelineAreaClick}
- onMouseDown={handleTimelineMouseDown}
- >
- {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",
- }}
- // Show context menu on right click
- onContextMenu={(e) => {
- e.preventDefault();
- setContextMenu({
- type: "track",
- trackId: track.id,
- x: e.clientX,
- y: e.clientY,
- });
- }}
- onClick={(e) => {
- // If clicking empty area (not on a clip), deselect all clips
- if (
- !(e.target as HTMLElement).closest(".timeline-clip")
- ) {
- const { clearSelectedClips } =
- useTimelineStore.getState();
- clearSelectedClips();
- }
- }}
- >
- <TimelineTrackContent
- track={track}
- zoomLevel={zoomLevel}
- setContextMenu={setContextMenu}
- contextMenu={contextMenu}
- />
- </div>
- ))}
-
- {/* Playhead for tracks area (scrubbable) */}
- {tracks.length > 0 && (
- <div
- className="absolute top-0 w-0.5 bg-red-500 pointer-events-auto z-20 cursor-ew-resize"
- style={{
- left: `${playheadPosition * 50 * zoomLevel}px`,
- height: `${tracks.length * 60}px`,
- }}
- onMouseDown={handlePlayheadMouseDown}
- />
- )}
- </>
- )}
- {isDragOver && (
- <div className="absolute inset-0 z-20 flex items-center justify-center pointer-events-none backdrop-blur-lg">
- <div>
- {isProcessing
- ? `Processing ${progress}%`
- : "Drop media here to add to timeline"}
- </div>
- </div>
- )}
- </div>
- </ScrollArea>
- </div>
- </div>
- </div>
-
- {/* Clean Unified Context Menu */}
- {contextMenu && (
- <div
- className="fixed z-50 min-w-[160px] bg-popover border border-border rounded-md shadow-md py-1 text-sm"
- style={{
- left: getContextMenuPosition(contextMenu.x, contextMenu.y).x,
- top: getContextMenuPosition(contextMenu.x, contextMenu.y).y,
- }}
- onContextMenu={(e) => e.preventDefault()}
- >
- {contextMenu.type === "track" ? (
- // Track context menu
- <>
- <button
- className="flex items-center w-full px-3 py-2 hover:bg-accent hover:text-accent-foreground transition-colors text-left"
- onClick={() => {
- const track = tracks.find(
- (t) => t.id === contextMenu.trackId
- );
- if (track) toggleTrackMute(track.id);
- setContextMenu(null);
- }}
- >
- {contextMenu.trackId ? (
- <div className="flex items-center w-full px-3 py-2 hover:bg-accent hover:text-accent-foreground transition-colors text-left">
- <Volume2 className="h-4 w-4 mr-2" />
- Unmute Track
- </div>
- ) : (
- <div className="flex items-center w-full px-3 py-2 hover:bg-accent hover:text-accent-foreground transition-colors text-left">
- <VolumeX className="h-4 w-4 mr-2" />
- Mute Track
- </div>
- )}
- </button>
- <div className="h-px bg-border mx-1 my-1" />
- <button
- className="flex items-center w-full px-3 py-2 text-destructive hover:bg-destructive/10 transition-colors text-left"
- onClick={() => {
- removeTrack(contextMenu.trackId);
- setContextMenu(null);
- toast.success("Track deleted");
- }}
- >
- <Trash2 className="h-4 w-4 mr-2" />
- Delete Track
- </button>
- </>
- ) : (
- // Clip context menu
- <>
- <button
- className="flex items-center w-full px-3 py-2 hover:bg-accent hover:text-accent-foreground transition-colors text-left"
- onClick={() => {
- if (contextMenu.clipId) {
- const track = tracks.find(
- (t) => t.id === contextMenu.trackId
- );
- const clip = track?.clips.find(
- (c) => c.id === contextMenu.clipId
- );
- if (clip && track) {
- const splitTime = currentTime;
- const effectiveStart = clip.startTime;
- const effectiveEnd =
- clip.startTime +
- (clip.duration - clip.trimStart - clip.trimEnd);
-
- if (
- splitTime > effectiveStart &&
- splitTime < effectiveEnd
- ) {
- updateClipTrim(
- track.id,
- clip.id,
- clip.trimStart,
- clip.trimEnd + (effectiveEnd - splitTime)
- );
- useTimelineStore.getState().addClipToTrack(track.id, {
- mediaId: clip.mediaId,
- name: clip.name + " (split)",
- duration: clip.duration,
- startTime: splitTime,
- trimStart:
- clip.trimStart + (splitTime - effectiveStart),
- trimEnd: clip.trimEnd,
- });
- toast.success("Clip split successfully");
- } else {
- toast.error("Playhead must be within clip to split");
- }
- }
- }
- setContextMenu(null);
- }}
- >
- <Scissors className="h-4 w-4 mr-2" />
- Split at Playhead
- </button>
- <button
- className="flex items-center w-full px-3 py-2 hover:bg-accent hover:text-accent-foreground transition-colors text-left"
- onClick={() => {
- if (contextMenu.clipId) {
- const track = tracks.find(
- (t) => t.id === contextMenu.trackId
- );
- const clip = track?.clips.find(
- (c) => c.id === contextMenu.clipId
- );
- if (clip && track) {
- useTimelineStore.getState().addClipToTrack(track.id, {
- mediaId: clip.mediaId,
- name: clip.name + " (copy)",
- duration: clip.duration,
- startTime:
- clip.startTime +
- (clip.duration - clip.trimStart - clip.trimEnd) +
- 0.1,
- trimStart: clip.trimStart,
- trimEnd: clip.trimEnd,
- });
- toast.success("Clip duplicated");
- }
- }
- setContextMenu(null);
- }}
- >
- <Copy className="h-4 w-4 mr-2" />
- Duplicate Clip
- </button>
- <div className="h-px bg-border mx-1 my-1" />
- <button
- className="flex items-center w-full px-3 py-2 text-destructive hover:bg-destructive/10 transition-colors text-left"
- onClick={() => {
- if (contextMenu.clipId) {
- removeClipFromTrack(
- contextMenu.trackId,
- contextMenu.clipId
- );
- toast.success("Clip deleted");
- }
- setContextMenu(null);
- }}
- >
- <Trash2 className="h-4 w-4 mr-2" />
- Delete Clip
- </button>
- </>
- )}
- </div>
- )}
- </div>
- );
-}
-
-function TimelineTrackContent({
- track,
- zoomLevel,
- setContextMenu,
- contextMenu,
-}: {
- track: TimelineTrack;
- zoomLevel: number;
- setContextMenu: (menu: ContextMenuState | null) => void;
- contextMenu: ContextMenuState | null;
-}) {
- const { mediaItems } = useMediaStore();
- const {
- tracks,
- moveClipToTrack,
- updateClipStartTime,
- addClipToTrack,
- selectedClips,
- selectClip,
- deselectClip,
- dragState,
- startDrag: startDragAction,
- updateDragTime,
- endDrag: endDragAction,
- } = useTimelineStore();
-
- const timelineRef = useRef<HTMLDivElement>(null);
- const [isDropping, setIsDropping] = useState(false);
- const [dropPosition, setDropPosition] = useState<number | null>(null);
- const [wouldOverlap, setWouldOverlap] = useState(false);
- const dragCounterRef = useRef(0);
- const [mouseDownLocation, setMouseDownLocation] = useState<{
- x: number;
- y: number;
- } | null>(null);
-
- // Set up mouse event listeners for drag
- useEffect(() => {
- if (!dragState.isDragging) return;
-
- const handleMouseMove = (e: MouseEvent) => {
- if (!timelineRef.current) return;
-
- const timelineRect = timelineRef.current.getBoundingClientRect();
- const mouseX = e.clientX - timelineRect.left;
- const mouseTime = Math.max(0, mouseX / (50 * zoomLevel));
- const adjustedTime = Math.max(0, mouseTime - dragState.clickOffsetTime);
- const snappedTime = Math.round(adjustedTime * 10) / 10;
-
- updateDragTime(snappedTime);
- };
-
- const handleMouseUp = () => {
- if (!dragState.clipId || !dragState.trackId) return;
-
- const finalTime = dragState.currentTime;
-
- // Check for overlaps and update position
- const sourceTrack = tracks.find((t) => t.id === dragState.trackId);
- const movingClip = sourceTrack?.clips.find(
- (c) => c.id === dragState.clipId
- );
-
- if (movingClip) {
- const movingClipDuration =
- movingClip.duration - movingClip.trimStart - movingClip.trimEnd;
- const movingClipEnd = finalTime + movingClipDuration;
-
- const targetTrack = tracks.find((t) => t.id === track.id);
- const hasOverlap = targetTrack?.clips.some((existingClip) => {
- if (
- dragState.trackId === track.id &&
- existingClip.id === dragState.clipId
- ) {
- return false;
- }
- const existingStart = existingClip.startTime;
- const existingEnd =
- existingClip.startTime +
- (existingClip.duration -
- existingClip.trimStart -
- existingClip.trimEnd);
- return finalTime < existingEnd && movingClipEnd > existingStart;
- });
-
- if (!hasOverlap) {
- if (dragState.trackId === track.id) {
- updateClipStartTime(track.id, dragState.clipId, finalTime);
- } else {
- moveClipToTrack(dragState.trackId, track.id, dragState.clipId);
- requestAnimationFrame(() => {
- updateClipStartTime(track.id, dragState.clipId!, finalTime);
- });
- }
- }
- }
-
- endDragAction();
- };
-
- document.addEventListener("mousemove", handleMouseMove);
- document.addEventListener("mouseup", handleMouseUp);
-
- return () => {
- document.removeEventListener("mousemove", handleMouseMove);
- document.removeEventListener("mouseup", handleMouseUp);
- };
- }, [
- dragState.isDragging,
- dragState.clickOffsetTime,
- dragState.clipId,
- dragState.trackId,
- dragState.currentTime,
- zoomLevel,
- tracks,
- track.id,
- updateDragTime,
- updateClipStartTime,
- moveClipToTrack,
- endDragAction,
- ]);
-
- const handleClipMouseDown = (e: React.MouseEvent, clip: TypeTimelineClip) => {
- setMouseDownLocation({ x: e.clientX, y: e.clientY });
- // Handle multi-selection only in mousedown
- if (e.metaKey || e.ctrlKey || e.shiftKey) {
- selectClip(track.id, clip.id, true);
- }
-
- // Calculate the offset from the left edge of the clip to where the user clicked
- const clipElement = e.currentTarget as HTMLElement;
- const clipRect = clipElement.getBoundingClientRect();
- const clickOffsetX = e.clientX - clipRect.left;
- const clickOffsetTime = clickOffsetX / (50 * zoomLevel);
-
- startDragAction(
- clip.id,
- track.id,
- e.clientX,
- clip.startTime,
- clickOffsetTime
- );
- };
-
- const handleClipClick = (e: React.MouseEvent, clip: TypeTimelineClip) => {
- e.stopPropagation();
-
- // Check if mouse moved significantly
- if (mouseDownLocation) {
- const deltaX = Math.abs(e.clientX - mouseDownLocation.x);
- const deltaY = Math.abs(e.clientY - mouseDownLocation.y);
- // If it moved more than a few pixels, consider it a drag and not a click.
- if (deltaX > 5 || deltaY > 5) {
- setMouseDownLocation(null); // Reset for next interaction
- return;
- }
- }
-
- // Close context menu if it's open
- if (contextMenu) {
- setContextMenu(null);
- return; // Don't handle selection when closing context menu
- }
-
- // Skip selection logic for multi-selection (handled in mousedown)
- if (e.metaKey || e.ctrlKey || e.shiftKey) {
- return;
- }
-
- // Handle single selection/deselection
- const isSelected = selectedClips.some(
- (c) => c.trackId === track.id && c.clipId === clip.id
- );
-
- if (isSelected) {
- // If clip is selected, deselect it
- deselectClip(track.id, clip.id);
- } else {
- // If clip is not selected, select it (replacing other selections)
- selectClip(track.id, clip.id, false);
- }
- };
-
- const handleClipContextMenu = (e: React.MouseEvent, clipId: string) => {
- e.preventDefault();
- e.stopPropagation();
- setContextMenu({
- type: "clip",
- trackId: track.id,
- clipId: clipId,
- x: e.clientX,
- y: e.clientY,
- });
- };
-
- const handleTrackDragOver = (e: React.DragEvent) => {
- e.preventDefault();
-
- // 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) {
- console.error("Error parsing dropped media item:", 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";
- setWouldOverlap(true);
- setDropPosition(Math.round(dropTime * 10) / 10);
- return;
- }
-
- e.dataTransfer.dropEffect = hasTimelineClip ? "move" : "copy";
- setWouldOverlap(false);
- setDropPosition(Math.round(dropTime * 10) / 10);
- };
-
- const handleTrackDragEnter = (e: React.DragEvent) => {
- e.preventDefault();
-
- 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);
- };
-
- const handleTrackDragLeave = (e: React.DragEvent) => {
- e.preventDefault();
-
- 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--;
-
- if (dragCounterRef.current === 0) {
- setIsDropping(false);
- setWouldOverlap(false);
- setDropPosition(null);
- }
- };
-
- const handleTrackDrop = (e: React.DragEvent) => {
- e.preventDefault();
- e.stopPropagation();
-
- // Reset all drag states
- dragCounterRef.current = 0;
- setIsDropping(false);
- setWouldOverlap(false);
- const currentDropPosition = dropPosition;
- setDropPosition(null);
-
- const hasTimelineClip = e.dataTransfer.types.includes(
- "application/x-timeline-clip"
- );
- const hasMediaItem = e.dataTransfer.types.includes(
- "application/x-media-item"
- );
-
- if (!hasTimelineClip && !hasMediaItem) 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 {
- if (hasTimelineClip) {
- // Handle timeline clip movement
- const timelineClipData = e.dataTransfer.getData(
- "application/x-timeline-clip"
- );
- if (!timelineClipData) return;
-
- const {
- clipId,
- trackId: fromTrackId,
- clickOffsetTime = 0,
- } = JSON.parse(timelineClipData);
-
- // Find the clip being moved
- const sourceTrack = tracks.find(
- (t: TimelineTrack) => t.id === fromTrackId
- );
- const movingClip = sourceTrack?.clips.find(
- (c: TypeTimelineClip) => c.id === clipId
- );
-
- if (!movingClip) {
- toast.error("Clip not found");
- return;
- }
-
- // Adjust position based on where user clicked on the clip
- const adjustedStartTime = snappedTime - clickOffsetTime;
- const finalStartTime = Math.max(
- 0,
- Math.round(adjustedStartTime * 10) / 10
- );
-
- // Check for overlaps with existing clips (excluding the moving clip itself)
- const movingClipDuration =
- movingClip.duration - movingClip.trimStart - movingClip.trimEnd;
- const movingClipEnd = finalStartTime + 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;
-
- const existingStart = existingClip.startTime;
- const existingEnd =
- existingClip.startTime +
- (existingClip.duration -
- existingClip.trimStart -
- existingClip.trimEnd);
-
- // Check if clips overlap
- return finalStartTime < existingEnd && movingClipEnd > existingStart;
- });
-
- if (hasOverlap) {
- toast.error(
- "Cannot move clip here - it would overlap with existing clips"
- );
- return;
- }
-
- if (fromTrackId === track.id) {
- // Moving within same track
- updateClipStartTime(track.id, clipId, finalStartTime);
- } else {
- // Moving to different track
- moveClipToTrack(fromTrackId, track.id, clipId);
- requestAnimationFrame(() => {
- updateClipStartTime(track.id, clipId, finalStartTime);
- });
- }
- } 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;
-
- const hasOverlap = track.clips.some((existingClip) => {
- const existingStart = existingClip.startTime;
- const existingEnd =
- existingClip.startTime +
- (existingClip.duration -
- existingClip.trimStart -
- existingClip.trimEnd);
-
- // Check if clips overlap
- return snappedTime < existingEnd && newClipEnd > existingStart;
- });
-
- if (hasOverlap) {
- toast.error(
- "Cannot place clip here - it would overlap with existing clips"
- );
- return;
- }
-
- 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 handling drop:", error);
- toast.error("Failed to add media to track");
- }
- };
-
- return (
- <div
- className="w-full h-full hover:bg-muted/20"
- onContextMenu={(e) => {
- e.preventDefault();
- // Only show track menu if we didn't click on a clip
- if (!(e.target as HTMLElement).closest(".timeline-clip")) {
- setContextMenu({
- type: "track",
- trackId: track.id,
- x: e.clientX,
- y: e.clientY,
- });
- }
- }}
- onClick={(e) => {
- // If clicking empty area (not on a clip), deselect all clips
- if (!(e.target as HTMLElement).closest(".timeline-clip")) {
- const { clearSelectedClips } = useTimelineStore.getState();
- clearSelectedClips();
- }
- }}
- onDragOver={handleTrackDragOver}
- onDragEnter={handleTrackDragEnter}
- onDragLeave={handleTrackDragLeave}
- onDrop={handleTrackDrop}
- >
- <div
- ref={timelineRef}
- 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 isSelected = selectedClips.some(
- (c) => c.trackId === track.id && c.clipId === clip.id
- );
-
- return (
- <TimelineClip
- key={clip.id}
- clip={clip}
- track={track}
- zoomLevel={zoomLevel}
- isSelected={isSelected}
- onContextMenu={handleClipContextMenu}
- onClipMouseDown={handleClipMouseDown}
- onClipClick={handleClipClick}
- />
- );
- })}
- </>
- )}
- </div>
- </div>
- );
-}
+"use client"; + +import { ScrollArea } from "../ui/scroll-area"; +import { Button } from "../ui/button"; +import { + Scissors, + ArrowLeftToLine, + ArrowRightToLine, + Trash2, + Snowflake, + Copy, + SplitSquareHorizontal, + Volume2, + VolumeX, + Pause, + Play, +} from "lucide-react"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, + TooltipProvider, +} from "../ui/tooltip"; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuTrigger, +} from "../ui/context-menu"; +import { useTimelineStore } from "@/stores/timeline-store"; +import { useMediaStore } from "@/stores/media-store"; +import { usePlaybackStore } from "@/stores/playback-store"; +import { processMediaFiles } from "@/lib/media-processing"; +import { toast } from "sonner"; +import { useState, useRef, useEffect, useCallback } from "react"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "../ui/select"; +import { TimelineTrackContent } from "./timeline-track"; + +export function Timeline() { + // Timeline shows all tracks (video, audio, effects) and their clips. + // You can drag media here to add it to your project. + // Clips can be trimmed, deleted, and moved. + const { + tracks, + addTrack, + addClipToTrack, + removeTrack, + toggleTrackMute, + removeClipFromTrack, + getTotalDuration, + selectedClips, + clearSelectedClips, + setSelectedClips, + splitClip, + splitAndKeepLeft, + splitAndKeepRight, + separateAudio, + undo, + redo, + } = useTimelineStore(); + const { mediaItems, addMediaItem } = useMediaStore(); + const { + currentTime, + duration, + seek, + setDuration, + isPlaying, + toggle, + setSpeed, + speed, + } = usePlaybackStore(); + const [isDragOver, setIsDragOver] = useState(false); + const [isProcessing, setIsProcessing] = useState(false); + const [progress, setProgress] = useState(0); + const [zoomLevel, setZoomLevel] = useState(1); + const dragCounterRef = useRef(0); + const timelineRef = useRef<HTMLDivElement>(null); + const [isInTimeline, setIsInTimeline] = useState(false); + + // Marquee selection state + const [marquee, setMarquee] = useState<{ + startX: number; + startY: number; + endX: number; + endY: number; + active: boolean; + additive: boolean; + } | null>(null); + + // Playhead scrubbing state + const [isScrubbing, setIsScrubbing] = useState(false); + const [scrubTime, setScrubTime] = useState<number | null>(null); + + // Add new state for ruler drag detection + const [isDraggingRuler, setIsDraggingRuler] = useState(false); + const [hasDraggedRuler, setHasDraggedRuler] = useState(false); + + // Dynamic timeline width calculation based on playhead position and duration + const dynamicTimelineWidth = Math.max( + (duration || 0) * 50 * zoomLevel, // Base width from duration + (currentTime + 30) * 50 * zoomLevel, // Width to show current time + 30 seconds buffer + timelineRef.current?.clientWidth || 1000 // Minimum width + ); + + // Scroll synchronization and auto-scroll to playhead + const rulerScrollRef = useRef<HTMLDivElement>(null); + const tracksScrollRef = useRef<HTMLDivElement>(null); + const isUpdatingRef = useRef(false); + const lastRulerSync = useRef(0); + const lastTracksSync = useRef(0); + + // New refs for direct playhead DOM manipulation + const rulerPlayheadRef = useRef<HTMLDivElement>(null); + const tracksPlayheadRef = useRef<HTMLDivElement>(null); + + // Refs to store initial mouse and scroll positions for drag calculations + const initialMouseXRef = useRef(0); + const initialTimelineScrollLeftRef = useRef(0); + + // Update timeline duration when tracks change + useEffect(() => { + const totalDuration = getTotalDuration(); + setDuration(Math.max(totalDuration, 10)); // Minimum 10 seconds for empty timeline + }, [tracks, setDuration, getTotalDuration]); + + // Keyboard event for deleting selected clips + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if ( + (e.key === "Delete" || e.key === "Backspace") && + selectedClips.length > 0 + ) { + selectedClips.forEach(({ trackId, clipId }) => { + removeClipFromTrack(trackId, clipId); + }); + clearSelectedClips(); + } + }; + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [selectedClips, removeClipFromTrack, clearSelectedClips]); + + // Keyboard event for undo (Cmd+Z) + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key === "z" && !e.shiftKey) { + e.preventDefault(); + undo(); + } + }; + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [undo]); + + // Keyboard event for redo (Cmd+Shift+Z or Cmd+Y) + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key === "z" && e.shiftKey) { + e.preventDefault(); + redo(); + } else if ((e.metaKey || e.ctrlKey) && e.key === "y") { + e.preventDefault(); + redo(); + } + }; + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [redo]); + + // Mouse down on timeline background to start marquee + const handleTimelineMouseDown = (e: React.MouseEvent) => { + if (e.target === e.currentTarget && e.button === 0) { + setMarquee({ + startX: e.clientX, + startY: e.clientY, + endX: e.clientX, + endY: e.clientY, + active: true, + additive: e.metaKey || e.ctrlKey || e.shiftKey, + }); + } + }; + + // Mouse move to update marquee + useEffect(() => { + if (!marquee || !marquee.active) return; + const handleMouseMove = (e: MouseEvent) => { + setMarquee( + (prev) => prev && { ...prev, endX: e.clientX, endY: e.clientY } + ); + }; + const handleMouseUp = (e: MouseEvent) => { + setMarquee( + (prev) => + prev && { ...prev, endX: e.clientX, endY: e.clientY, active: false } + ); + }; + window.addEventListener("mousemove", handleMouseMove); + window.addEventListener("mouseup", handleMouseUp); + return () => { + window.removeEventListener("mousemove", handleMouseMove); + window.removeEventListener("mouseup", handleMouseUp); + }; + }, [marquee]); + + // On marquee end, select clips in box + useEffect(() => { + if (!marquee || marquee.active) return; + const timeline = timelineRef.current; + if (!timeline) return; + const rect = timeline.getBoundingClientRect(); + const x1 = Math.min(marquee.startX, marquee.endX) - rect.left; + const x2 = Math.max(marquee.startX, marquee.endX) - rect.left; + const y1 = Math.min(marquee.startY, marquee.endY) - rect.top; + const y2 = Math.max(marquee.startY, marquee.endY) - rect.top; + // Validation: skip if too small + if (Math.abs(x2 - x1) < 5 || Math.abs(y2 - y1) < 5) { + setMarquee(null); + return; + } + // Clamp to timeline bounds + const clamp = (val: number, min: number, max: number) => + Math.max(min, Math.min(max, val)); + const bx1 = clamp(x1, 0, rect.width); + const bx2 = clamp(x2, 0, rect.width); + const by1 = clamp(y1, 0, rect.height); + const by2 = clamp(y2, 0, rect.height); + let newSelection: { trackId: string; clipId: string }[] = []; + tracks.forEach((track, trackIdx) => { + track.clips.forEach((clip) => { + const effectiveDuration = clip.duration - clip.trimStart - clip.trimEnd; + const clipWidth = Math.max(80, effectiveDuration * 50 * zoomLevel); + const clipLeft = clip.startTime * 50 * zoomLevel; + const clipTop = trackIdx * 60; + const clipBottom = clipTop + 60; + const clipRight = clipLeft + 60; // Set a fixed width for time display + if ( + bx1 < clipRight && + bx2 > clipLeft && + by1 < clipBottom && + by2 > clipTop + ) { + newSelection.push({ trackId: track.id, clipId: clip.id }); + } + }); + }); + if (newSelection.length > 0) { + if (marquee.additive) { + const selectedSet = new Set( + selectedClips.map((c) => c.trackId + ":" + c.clipId) + ); + newSelection = [ + ...selectedClips, + ...newSelection.filter( + (c) => !selectedSet.has(c.trackId + ":" + c.clipId) + ), + ]; + } + setSelectedClips(newSelection); + } else if (!marquee.additive) { + clearSelectedClips(); + } + setMarquee(null); + }, [ + marquee, + tracks, + zoomLevel, + selectedClips, + setSelectedClips, + clearSelectedClips, + ]); + + const handleDragEnter = (e: React.DragEvent) => { + // When something is dragged over the timeline, show overlay + e.preventDefault(); + // Don't show overlay for timeline clips - they're handled by tracks + if (e.dataTransfer.types.includes("application/x-timeline-clip")) { + return; + } + dragCounterRef.current += 1; + if (!isDragOver) { + setIsDragOver(true); + } + }; + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault(); + }; + + const handleDragLeave = (e: React.DragEvent) => { + e.preventDefault(); + + // Don't update state for timeline clips - they're handled by tracks + if (e.dataTransfer.types.includes("application/x-timeline-clip")) { + return; + } + + dragCounterRef.current -= 1; + if (dragCounterRef.current === 0) { + setIsDragOver(false); + } + }; + + const handleDrop = async (e: React.DragEvent) => { + // When media is dropped, add it as a new track/clip + e.preventDefault(); + setIsDragOver(false); + dragCounterRef.current = 0; + + // Ignore timeline clip drags - they're handled by track-specific handlers + const hasTimelineClip = e.dataTransfer.types.includes( + "application/x-timeline-clip" + ); + if (hasTimelineClip) { + return; + } + + const mediaItemData = e.dataTransfer.getData("application/x-media-item"); + if (mediaItemData) { + // Handle media item drops by creating new tracks + try { + const { id, type } = JSON.parse(mediaItemData); + const mediaItem = mediaItems.find((item) => item.id === id); + if (!mediaItem) { + toast.error("Media item not found"); + return; + } + // Add to video or audio track depending on type + const trackType = type === "audio" ? "audio" : "video"; + const newTrackId = addTrack(trackType); + addClipToTrack(newTrackId, { + mediaId: mediaItem.id, + name: mediaItem.name, + duration: mediaItem.duration || 5, + startTime: 0, + trimStart: 0, + trimEnd: 0, + }); + } catch (error) { + // Show error if parsing fails + console.error("Error parsing media item data:", error); + toast.error("Failed to add media to timeline"); + } + } else if (e.dataTransfer.files?.length > 0) { + // Handle file drops by creating new tracks + setIsProcessing(true); + setProgress(0); + try { + const processedItems = await processMediaFiles( + e.dataTransfer.files, + (p) => setProgress(p) + ); + for (const processedItem of processedItems) { + await addMediaItem(processedItem); + const currentMediaItems = useMediaStore.getState().mediaItems; + const addedItem = currentMediaItems.find( + (item) => + item.name === processedItem.name && item.url === processedItem.url + ); + if (addedItem) { + const trackType = + processedItem.type === "audio" ? "audio" : "video"; + const newTrackId = addTrack(trackType); + addClipToTrack(newTrackId, { + mediaId: addedItem.id, + name: addedItem.name, + duration: addedItem.duration || 5, + startTime: 0, + trimStart: 0, + trimEnd: 0, + }); + } + } + } catch (error) { + // Show error if file processing fails + console.error("Error processing external files:", error); + toast.error("Failed to process dropped files"); + } finally { + setIsProcessing(false); + setProgress(0); + } + } + }; + + const handleWheel = (e: React.WheelEvent) => { + // Only zoom if user is using pinch gesture (ctrlKey or metaKey is true) + if (e.ctrlKey || e.metaKey) { + e.preventDefault(); + const delta = e.deltaY > 0 ? -0.05 : 0.05; + setZoomLevel((prev) => Math.max(0.1, Math.min(10, prev + delta))); + } + // Otherwise, allow normal scrolling + }; + + // --- Playhead Scrubbing Handlers --- + const handlePlayheadMouseDown = useCallback( + (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); // Prevent ruler drag from triggering + setIsScrubbing(true); + handleScrub(e); + }, + [duration, zoomLevel] + ); + + // Add new ruler mouse down handler + const handleRulerMouseDown = useCallback( + (e: React.MouseEvent) => { + // Only handle left mouse button + if (e.button !== 0) return; + + // Don't interfere if clicking on the playhead itself + if ((e.target as HTMLElement).closest(".playhead")) return; + + e.preventDefault(); + setIsDraggingRuler(true); + setHasDraggedRuler(false); + + // Start scrubbing immediately + setIsScrubbing(true); + handleScrub(e); + }, + [duration, zoomLevel] + ); + + const handleScrub = useCallback( + (e: MouseEvent | React.MouseEvent) => { + const timeline = timelineRef.current; + if (!timeline) return; + const rect = timeline.getBoundingClientRect(); + const x = e.clientX - rect.left; + const time = Math.max(0, Math.min(duration, x / (50 * zoomLevel))); + setScrubTime(time); + seek(time); // update video preview in real time + }, + [duration, zoomLevel, seek] + ); + + useEffect(() => { + if (!isScrubbing) return; + const onMouseMove = (e: MouseEvent) => { + handleScrub(e); + // Mark that we've dragged if ruler drag is active + if (isDraggingRuler) { + setHasDraggedRuler(true); + } + }; + const onMouseUp = (e: MouseEvent) => { + setIsScrubbing(false); + if (scrubTime !== null) seek(scrubTime); // finalize seek + setScrubTime(null); + + // Handle ruler click vs drag + if (isDraggingRuler) { + setIsDraggingRuler(false); + // If we didn't drag, treat it as a click-to-seek + if (!hasDraggedRuler) { + handleScrub(e); + } + setHasDraggedRuler(false); + } + }; + window.addEventListener("mousemove", onMouseMove); + window.addEventListener("mouseup", onMouseUp); + return () => { + window.removeEventListener("mousemove", onMouseMove); + window.removeEventListener("mouseup", onMouseUp); + }; + }, [ + isScrubbing, + scrubTime, + seek, + handleScrub, + isDraggingRuler, + hasDraggedRuler, + ]); + + const playheadPosition = + isScrubbing && scrubTime !== null ? scrubTime : currentTime; + + const dragProps = { + onDragEnter: handleDragEnter, + onDragOver: handleDragOver, + onDragLeave: handleDragLeave, + onDrop: handleDrop, + }; + + // Action handlers for toolbar + const handleSplitSelected = () => { + if (selectedClips.length === 0) { + toast.error("No clips selected"); + return; + } + let splitCount = 0; + selectedClips.forEach(({ trackId, clipId }) => { + const track = tracks.find((t) => t.id === trackId); + const clip = track?.clips.find((c) => c.id === clipId); + if (clip && track) { + const effectiveStart = clip.startTime; + const effectiveEnd = + clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd); + + if (currentTime > effectiveStart && currentTime < effectiveEnd) { + const newClipId = splitClip(trackId, clipId, currentTime); + if (newClipId) splitCount++; + } + } + }); + if (splitCount > 0) { + toast.success(`Split ${splitCount} clip(s) at playhead`); + } else { + toast.error("Playhead must be within selected clips to split"); + } + }; + + const handleDuplicateSelected = () => { + if (selectedClips.length === 0) { + toast.error("No clips selected"); + return; + } + selectedClips.forEach(({ trackId, clipId }) => { + const track = tracks.find((t) => t.id === trackId); + const clip = track?.clips.find((c) => c.id === clipId); + if (clip && track) { + addClipToTrack(track.id, { + mediaId: clip.mediaId, + name: clip.name + " (copy)", + duration: clip.duration, + startTime: + clip.startTime + + (clip.duration - clip.trimStart - clip.trimEnd) + + 0.1, + trimStart: clip.trimStart, + trimEnd: clip.trimEnd, + }); + } + }); + toast.success("Duplicated selected clip(s)"); + }; + + const handleFreezeSelected = () => { + if (selectedClips.length === 0) { + toast.error("No clips selected"); + return; + } + selectedClips.forEach(({ trackId, clipId }) => { + const track = tracks.find((t) => t.id === trackId); + const clip = track?.clips.find((c) => c.id === clipId); + if (clip && track) { + // Add a new freeze frame clip at the playhead + addClipToTrack(track.id, { + mediaId: clip.mediaId, + name: clip.name + " (freeze)", + duration: 1, // 1 second freeze frame + startTime: currentTime, + trimStart: 0, + trimEnd: clip.duration - 1, + }); + } + }); + }; + const handleSplitAndKeepLeft = () => { + if (selectedClips.length === 0) { + toast.error("No clips selected"); + return; + } + + let splitCount = 0; + selectedClips.forEach(({ trackId, clipId }) => { + const track = tracks.find((t) => t.id === trackId); + const clip = track?.clips.find((c) => c.id === clipId); + if (clip && track) { + const effectiveStart = clip.startTime; + const effectiveEnd = + clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd); + + if (currentTime > effectiveStart && currentTime < effectiveEnd) { + splitAndKeepLeft(trackId, clipId, currentTime); + splitCount++; + } + } + }); + + if (splitCount > 0) { + toast.success(`Split and kept left portion of ${splitCount} clip(s)`); + } else { + toast.error("Playhead must be within selected clips"); + } + }; + + const handleSplitAndKeepRight = () => { + if (selectedClips.length === 0) { + toast.error("No clips selected"); + return; + } + + let splitCount = 0; + selectedClips.forEach(({ trackId, clipId }) => { + const track = tracks.find((t) => t.id === trackId); + const clip = track?.clips.find((c) => c.id === clipId); + if (clip && track) { + const effectiveStart = clip.startTime; + const effectiveEnd = + clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd); + + if (currentTime > effectiveStart && currentTime < effectiveEnd) { + splitAndKeepRight(trackId, clipId, currentTime); + splitCount++; + } + } + }); + + if (splitCount > 0) { + toast.success(`Split and kept right portion of ${splitCount} clip(s)`); + } else { + toast.error("Playhead must be within selected clips"); + } + }; + + const handleSeparateAudio = () => { + if (selectedClips.length === 0) { + toast.error("No clips selected"); + return; + } + + let separatedCount = 0; + selectedClips.forEach(({ trackId, clipId }) => { + const track = tracks.find((t) => t.id === trackId); + const clip = track?.clips.find((c) => c.id === clipId); + const mediaItem = mediaItems.find((item) => item.id === clip?.mediaId); + + if ( + clip && + track && + mediaItem?.type === "video" && + track.type === "video" + ) { + const audioClipId = separateAudio(trackId, clipId); + if (audioClipId) separatedCount++; + } + }); + + if (separatedCount > 0) { + toast.success(`Separated audio from ${separatedCount} video clip(s)`); + } else { + toast.error("Select video clips to separate audio"); + } + }; + const handleDeleteSelected = () => { + if (selectedClips.length === 0) { + toast.error("No clips selected"); + return; + } + selectedClips.forEach(({ trackId, clipId }) => { + removeClipFromTrack(trackId, clipId); + }); + clearSelectedClips(); + toast.success("Deleted selected clip(s)"); + }; + + // Prevent explorer zooming in/out when in timeline + useEffect(() => { + const preventZoom = (e: WheelEvent) => { + // if (isInTimeline && (e.ctrlKey || e.metaKey)) { + if ( + isInTimeline && + (e.ctrlKey || e.metaKey) && + timelineRef.current?.contains(e.target as Node) + ) { + e.preventDefault(); + } + }; + + document.addEventListener("wheel", preventZoom, { passive: false }); + + return () => { + document.removeEventListener("wheel", preventZoom); + }; + }, [isInTimeline]); + + // --- Scroll synchronization effect --- + useEffect(() => { + const rulerViewport = rulerScrollRef.current?.querySelector( + "[data-radix-scroll-area-viewport]" + ) as HTMLElement; + const tracksViewport = tracksScrollRef.current?.querySelector( + "[data-radix-scroll-area-viewport]" + ) as HTMLElement; + if (!rulerViewport || !tracksViewport) return; + const handleRulerScroll = () => { + const now = Date.now(); + if (isUpdatingRef.current || now - lastRulerSync.current < 16) return; + lastRulerSync.current = now; + isUpdatingRef.current = true; + tracksViewport.scrollLeft = rulerViewport.scrollLeft; + isUpdatingRef.current = false; + }; + const handleTracksScroll = () => { + const now = Date.now(); + if (isUpdatingRef.current || now - lastTracksSync.current < 16) return; + lastTracksSync.current = now; + isUpdatingRef.current = true; + rulerViewport.scrollLeft = tracksViewport.scrollLeft; + isUpdatingRef.current = false; + }; + rulerViewport.addEventListener("scroll", handleRulerScroll); + tracksViewport.addEventListener("scroll", handleTracksScroll); + return () => { + rulerViewport.removeEventListener("scroll", handleRulerScroll); + tracksViewport.removeEventListener("scroll", handleTracksScroll); + }; + }, []); + + // --- Playhead auto-scroll effect --- + useEffect(() => { + const rulerViewport = rulerScrollRef.current?.querySelector( + "[data-radix-scroll-area-viewport]" + ) as HTMLElement; + const tracksViewport = tracksScrollRef.current?.querySelector( + "[data-radix-scroll-area-viewport]" + ) as HTMLElement; + if (!rulerViewport || !tracksViewport) return; + const playheadPx = playheadPosition * 50 * zoomLevel; + const viewportWidth = rulerViewport.clientWidth; + const scrollMin = 0; + const scrollMax = rulerViewport.scrollWidth - viewportWidth; + // Center the playhead if it's not visible (100px buffer) + const desiredScroll = Math.max( + scrollMin, + Math.min(scrollMax, playheadPx - viewportWidth / 2) + ); + if ( + playheadPx < rulerViewport.scrollLeft + 100 || + playheadPx > rulerViewport.scrollLeft + viewportWidth - 100 + ) { + rulerViewport.scrollLeft = tracksViewport.scrollLeft = desiredScroll; + } + }, [playheadPosition, duration, zoomLevel]); + + return ( + <div + className={`h-full flex flex-col transition-colors duration-200 relative ${isDragOver ? "bg-accent/30 border-accent" : ""}`} + {...dragProps} + onMouseEnter={() => setIsInTimeline(true)} + onMouseLeave={() => setIsInTimeline(false)} + onWheel={handleWheel} + > + {/* Toolbar */} + <div className="border-b flex items-center px-2 py-1 gap-1"> + <TooltipProvider delayDuration={500}> + {/* Play/Pause Button */} + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="text" + size="icon" + onClick={toggle} + className="mr-2" + > + {isPlaying ? ( + <Pause className="h-4 w-4" /> + ) : ( + <Play className="h-4 w-4" /> + )} + </Button> + </TooltipTrigger> + <TooltipContent> + {isPlaying ? "Pause (Space)" : "Play (Space)"} + </TooltipContent> + </Tooltip> + + <div className="w-px h-6 bg-border mx-1" /> + + {/* Time Display */} + <div + className="text-xs text-muted-foreground font-mono px-2" + style={{ minWidth: "18ch", textAlign: "center" }} + > + {currentTime.toFixed(1)}s / {duration.toFixed(1)}s + </div> + + {/* Test Clip Button - for debugging */} + {tracks.length === 0 && ( + <> + <div className="w-px h-6 bg-border mx-1" /> + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="outline" + size="sm" + onClick={() => { + const trackId = addTrack("video"); + addClipToTrack(trackId, { + mediaId: "test", + name: "Test Clip", + duration: 5, + startTime: 0, + trimStart: 0, + trimEnd: 0, + }); + }} + className="text-xs" + > + Add Test Clip + </Button> + </TooltipTrigger> + <TooltipContent>Add a test clip to try playback</TooltipContent> + </Tooltip> + </> + )} + + <div className="w-px h-6 bg-border mx-1" /> + + <Tooltip> + <TooltipTrigger asChild> + <Button variant="text" size="icon" onClick={handleSplitSelected}> + <Scissors className="h-4 w-4" /> + </Button> + </TooltipTrigger> + <TooltipContent>Split clip (Ctrl+S)</TooltipContent> + </Tooltip> + + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="text" + size="icon" + onClick={handleSplitAndKeepLeft} + > + <ArrowLeftToLine className="h-4 w-4" /> + </Button> + </TooltipTrigger> + <TooltipContent>Split and keep left (Ctrl+Q)</TooltipContent> + </Tooltip> + + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="text" + size="icon" + onClick={handleSplitAndKeepRight} + > + <ArrowRightToLine className="h-4 w-4" /> + </Button> + </TooltipTrigger> + <TooltipContent>Split and keep right (Ctrl+W)</TooltipContent> + </Tooltip> + + <Tooltip> + <TooltipTrigger asChild> + <Button variant="text" size="icon" onClick={handleSeparateAudio}> + <SplitSquareHorizontal className="h-4 w-4" /> + </Button> + </TooltipTrigger> + <TooltipContent>Separate audio (Ctrl+D)</TooltipContent> + </Tooltip> + + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="text" + size="icon" + onClick={handleDuplicateSelected} + > + <Copy className="h-4 w-4" /> + </Button> + </TooltipTrigger> + <TooltipContent>Duplicate clip (Ctrl+D)</TooltipContent> + </Tooltip> + + <Tooltip> + <TooltipTrigger asChild> + <Button variant="text" size="icon" onClick={handleFreezeSelected}> + <Snowflake className="h-4 w-4" /> + </Button> + </TooltipTrigger> + <TooltipContent>Freeze frame (F)</TooltipContent> + </Tooltip> + + <Tooltip> + <TooltipTrigger asChild> + <Button variant="text" size="icon" onClick={handleDeleteSelected}> + <Trash2 className="h-4 w-4" /> + </Button> + </TooltipTrigger> + <TooltipContent>Delete clip (Delete)</TooltipContent> + </Tooltip> + + <div className="w-px h-6 bg-border mx-1" /> + + {/* Speed Control */} + <Tooltip> + <TooltipTrigger asChild> + <Select + value={speed.toFixed(1)} + onValueChange={(value) => setSpeed(parseFloat(value))} + > + <SelectTrigger className="w-[90px] h-8"> + <SelectValue placeholder="1.0x" /> + </SelectTrigger> + <SelectContent> + <SelectItem value="0.5">0.5x</SelectItem> + <SelectItem value="1.0">1.0x</SelectItem> + <SelectItem value="1.5">1.5x</SelectItem> + <SelectItem value="2.0">2.0x</SelectItem> + </SelectContent> + </Select> + </TooltipTrigger> + <TooltipContent>Playback Speed</TooltipContent> + </Tooltip> + </TooltipProvider> + </div> + + {/* 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" ref={rulerScrollRef}> + <div + ref={timelineRef} + className={`relative h-12 bg-muted/30 select-none ${ + isDraggingRuler ? "cursor-grabbing" : "cursor-grab" + }`} + style={{ + width: `${dynamicTimelineWidth}px`, + }} + onMouseDown={handleRulerMouseDown} + > + {/* 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 (scrubbable) */} + <div + className="playhead absolute top-0 bottom-0 w-0.5 bg-red-500 pointer-events-auto z-50 cursor-col-resize" + style={{ left: `${playheadPosition * 50 * zoomLevel}px` }} + onMouseDown={handlePlayheadMouseDown} + > + <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> + + {/* Tracks Area */} + <div className="flex-1 flex overflow-hidden"> + {/* Track Labels */} + {tracks.length > 0 && ( + <div className="w-48 flex-shrink-0 border-r bg-background overflow-y-auto"> + <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 group" + > + <div className="flex items-center 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="ml-2 text-sm font-medium truncate"> + {track.name} + </span> + </div> + {track.muted && ( + <span className="ml-2 text-xs text-red-500 font-semibold flex-shrink-0"> + Muted + </span> + )} + </div> + ))} + </div> + </div> + )} + + {/* Timeline Tracks Content */} + <div className="flex-1 relative overflow-hidden"> + <ScrollArea className="w-full h-full" ref={tracksScrollRef}> + <div + className="relative flex-1" + style={{ + height: `${Math.max(200, Math.min(800, tracks.length * 60))}px`, + width: `${dynamicTimelineWidth}px`, + }} + onClick={handleTimelineMouseDown} + onMouseDown={handleTimelineMouseDown} + > + {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) => ( + <ContextMenu key={track.id}> + <ContextMenuTrigger asChild> + <div + className="absolute left-0 right-0 border-b border-muted/30" + style={{ + top: `${index * 60}px`, + height: "60px", + }} + onClick={(e) => { + // If clicking empty area (not on a clip), deselect all clips + if ( + !(e.target as HTMLElement).closest( + ".timeline-clip" + ) + ) { + clearSelectedClips(); + } + }} + > + <TimelineTrackContent + track={track} + zoomLevel={zoomLevel} + /> + </div> + </ContextMenuTrigger> + <ContextMenuContent> + <ContextMenuItem + onClick={() => { + toggleTrackMute(track.id); + }} + > + {track.muted ? ( + <> + <Volume2 className="h-4 w-4 mr-2" /> + Unmute Track + </> + ) : ( + <> + <VolumeX className="h-4 w-4 mr-2" /> + Mute Track + </> + )} + </ContextMenuItem> + <ContextMenuSeparator /> + <ContextMenuItem + onClick={() => { + removeTrack(track.id); + toast.success("Track deleted"); + }} + className="text-destructive focus:text-destructive" + > + <Trash2 className="h-4 w-4 mr-2" /> + Delete Track + </ContextMenuItem> + </ContextMenuContent> + </ContextMenu> + ))} + + {/* Playhead for tracks area (scrubbable) */} + {tracks.length > 0 && ( + <div + className="absolute top-0 w-0.5 bg-red-500 pointer-events-auto z-50 cursor-col" + style={{ + left: `${playheadPosition * 50 * zoomLevel}px`, + height: `${tracks.length * 60}px`, + }} + onMouseDown={handlePlayheadMouseDown} + /> + )} + </> + )} + {isDragOver && ( + <div className="absolute inset-0 z-20 flex items-center justify-center pointer-events-none backdrop-blur-lg"> + <div> + {isProcessing + ? `Processing ${progress}%` + : "Drop media here to add to timeline"} + </div> + </div> + )} + </div> + </ScrollArea> + </div> + </div> + </div> + </div> + ); +} diff --git a/apps/web/src/components/footer.tsx b/apps/web/src/components/footer.tsx index 7cc1440f..ea2c33e8 100644 --- a/apps/web/src/components/footer.tsx +++ b/apps/web/src/components/footer.tsx @@ -25,12 +25,12 @@ export function Footer() { return (
<motion.footer
- className="bg-background/80 backdrop-blur-sm border mt-16 m-6 rounded-sm"
+ className="bg-background border-t"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.8, duration: 0.8 }}
>
- <div className="max-w-5xl mx-auto px-4 py-10">
+ <div className="max-w-5xl mx-auto px-8 py-10">
<div className="grid grid-cols-1 md:grid-cols-2 gap-12 mb-8">
{/* Brand Section */}
<div className="md:col-span-1 max-w-sm">
diff --git a/apps/web/src/components/header.tsx b/apps/web/src/components/header.tsx index ff15774e..56317c08 100644 --- a/apps/web/src/components/header.tsx +++ b/apps/web/src/components/header.tsx @@ -41,7 +41,7 @@ export function Header() { </Button>
</Link>
{process.env.NODE_ENV === "development" ? (
- <Link href="/editor">
+ <Link href="/editor/1">
<Button size="sm" className="text-sm ml-4">
Editor
<ArrowRight className="h-4 w-4" />
@@ -61,7 +61,7 @@ export function Header() { return (
<div className="mx-4 md:mx-0">
<HeaderBase
- className="bg-[#1D1D1D] border border-white/10 rounded-2xl max-w-3xl mx-auto mt-4 pl-4 pr-[14px]"
+ className="bg-accent border rounded-2xl max-w-3xl mx-auto mt-4 pl-4 pr-[14px]"
leftContent={leftContent}
rightContent={rightContent}
/>
diff --git a/apps/web/src/components/landing/hero.tsx b/apps/web/src/components/landing/hero.tsx index 1ac9390f..aac63e94 100644 --- a/apps/web/src/components/landing/hero.tsx +++ b/apps/web/src/components/landing/hero.tsx @@ -69,7 +69,14 @@ export function Hero({ signupCount }: HeroProps) { }; return ( - <div className="min-h-[calc(100vh-6rem)] supports-[height:100dvh]:min-h-[calc(100dvh-6rem)] flex flex-col justify-between items-center text-center px-4"> + <div className="min-h-[calc(100vh-4.5rem)] supports-[height:100dvh]:min-h-[calc(100dvh-4.5rem)] flex flex-col justify-between items-center text-center px-4"> + <Image + className="absolute top-0 left-0 -z-50 size-full object-cover" + src="/landing-page-bg.png" + height={1903.5} + width={1269} + alt="landing-page.bg" + /> <motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} diff --git a/apps/web/src/components/rename-project-dialog.tsx b/apps/web/src/components/rename-project-dialog.tsx new file mode 100644 index 00000000..d84bad71 --- /dev/null +++ b/apps/web/src/components/rename-project-dialog.tsx @@ -0,0 +1,73 @@ +import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Input } from "@/components/ui/input";
+import { useState } from "react";
+
+export function RenameProjectDialog({
+ isOpen,
+ onOpenChange,
+ onConfirm,
+ projectName,
+}: {
+ isOpen: boolean;
+ onOpenChange: (open: boolean) => void;
+ onConfirm: (name: string) => void;
+ projectName: string;
+}) {
+ const [name, setName] = useState(projectName);
+
+ // Reset the name when dialog opens - this is better UX than syncing with every prop change
+ const handleOpenChange = (open: boolean) => {
+ if (open) {
+ setName(projectName);
+ }
+ onOpenChange(open);
+ };
+
+ return (
+ <Dialog open={isOpen} onOpenChange={handleOpenChange}>
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>Rename Project</DialogTitle>
+ <DialogDescription>
+ Enter a new name for your project.
+ </DialogDescription>
+ </DialogHeader>
+
+ <Input
+ value={name}
+ onChange={(e) => setName(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") {
+ e.preventDefault();
+ onConfirm(name);
+ }
+ }}
+ placeholder="Enter a new name"
+ className="mt-4"
+ />
+
+ <DialogFooter>
+ <Button
+ variant="outline"
+ onClick={(e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ onOpenChange(false);
+ }}
+ >
+ Cancel
+ </Button>
+ <Button onClick={() => onConfirm(name)}>Rename</Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ );
+}
diff --git a/apps/web/src/components/storage-provider.tsx b/apps/web/src/components/storage-provider.tsx new file mode 100644 index 00000000..e007ca96 --- /dev/null +++ b/apps/web/src/components/storage-provider.tsx @@ -0,0 +1,81 @@ +"use client";
+
+import { createContext, useContext, useEffect, useState } from "react";
+import { useProjectStore } from "@/stores/project-store";
+import { useMediaStore } from "@/stores/media-store";
+import { storageService } from "@/lib/storage/storage-service";
+import { toast } from "sonner";
+
+interface StorageContextType {
+ isInitialized: boolean;
+ isLoading: boolean;
+ hasSupport: boolean;
+ error: string | null;
+}
+
+const StorageContext = createContext<StorageContextType | null>(null);
+
+export function useStorage() {
+ const context = useContext(StorageContext);
+ if (!context) {
+ throw new Error("useStorage must be used within StorageProvider");
+ }
+ return context;
+}
+
+interface StorageProviderProps {
+ children: React.ReactNode;
+}
+
+export function StorageProvider({ children }: StorageProviderProps) {
+ const [status, setStatus] = useState<StorageContextType>({
+ isInitialized: false,
+ isLoading: true,
+ hasSupport: false,
+ error: null,
+ });
+
+ const loadAllProjects = useProjectStore((state) => state.loadAllProjects);
+ const loadAllMedia = useMediaStore((state) => state.loadAllMedia);
+
+ useEffect(() => {
+ const initializeStorage = async () => {
+ setStatus((prev) => ({ ...prev, isLoading: true }));
+
+ try {
+ // Check browser support
+ const hasSupport = storageService.isFullySupported();
+
+ if (!hasSupport) {
+ toast.warning(
+ "Storage not fully supported. Some features may not work."
+ );
+ }
+
+ // Load saved data in parallel
+ await Promise.all([loadAllProjects(), loadAllMedia()]);
+
+ setStatus({
+ isInitialized: true,
+ isLoading: false,
+ hasSupport,
+ error: null,
+ });
+ } catch (error) {
+ console.error("Failed to initialize storage:", error);
+ setStatus({
+ isInitialized: false,
+ isLoading: false,
+ hasSupport: storageService.isFullySupported(),
+ error: error instanceof Error ? error.message : "Unknown error",
+ });
+ }
+ };
+
+ initializeStorage();
+ }, [loadAllProjects, loadAllMedia]);
+
+ return (
+ <StorageContext.Provider value={status}>{children}</StorageContext.Provider>
+ );
+}
diff --git a/apps/web/src/components/ui/card.tsx b/apps/web/src/components/ui/card.tsx index 845bc9c8..c5ca9d9f 100644 --- a/apps/web/src/components/ui/card.tsx +++ b/apps/web/src/components/ui/card.tsx @@ -9,7 +9,7 @@ const Card = React.forwardRef< <div ref={ref} className={cn( - "rounded-xl border bg-card text-card-foreground shadow", + "rounded-xl border bg-card text-card-foreground", className )} {...props} diff --git a/apps/web/src/components/ui/context-menu.tsx b/apps/web/src/components/ui/context-menu.tsx index defa0755..7aa938f6 100644 --- a/apps/web/src/components/ui/context-menu.tsx +++ b/apps/web/src/components/ui/context-menu.tsx @@ -3,6 +3,7 @@ import * as React from "react"; import { ContextMenu as ContextMenuPrimitive } from "radix-ui"; import { Check, ChevronRight, Circle } from "lucide-react"; +import { cva, type VariantProps } from "class-variance-authority"; import { cn } from "../../lib/utils"; @@ -18,23 +19,40 @@ const ContextMenuSub = ContextMenuPrimitive.Sub; const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup; +const contextMenuItemVariants = cva( + "relative flex cursor-pointer select-none items-center gap-2 px-2 py-1.5 text-sm outline-none transition-opacity data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0", + { + variants: { + variant: { + default: "focus:opacity-65 focus:text-accent-foreground", + destructive: "text-destructive focus:text-destructive/80", + }, + }, + defaultVariants: { + variant: "default", + }, + } +); + const ContextMenuSubTrigger = React.forwardRef< React.ElementRef<typeof ContextMenuPrimitive.SubTrigger>, React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger> & { inset?: boolean; + variant?: VariantProps<typeof contextMenuItemVariants>["variant"]; } ->(({ className, inset, children, ...props }, ref) => ( +>(({ className, inset, children, variant = "default", ...props }, ref) => ( <ContextMenuPrimitive.SubTrigger ref={ref} className={cn( - "flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground", + contextMenuItemVariants({ variant }), + "data-[state=open]:bg-accent data-[state=open]:opacity-65", inset && "pl-8", className )} {...props} > {children} - <ChevronRight className="ml-auto h-4 w-4" /> + <ChevronRight className="ml-auto" /> </ContextMenuPrimitive.SubTrigger> )); ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName; @@ -62,7 +80,8 @@ const ContextMenuContent = React.forwardRef< <ContextMenuPrimitive.Content ref={ref} className={cn( - "z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", + "z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 bg-popover text-popover-foreground shadow-md", + "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", className )} {...props} @@ -75,12 +94,13 @@ const ContextMenuItem = React.forwardRef< React.ElementRef<typeof ContextMenuPrimitive.Item>, React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item> & { inset?: boolean; + variant?: VariantProps<typeof contextMenuItemVariants>["variant"]; } ->(({ className, inset, ...props }, ref) => ( +>(({ className, inset, variant = "default", ...props }, ref) => ( <ContextMenuPrimitive.Item ref={ref} className={cn( - "relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", + contextMenuItemVariants({ variant }), inset && "pl-8", className )} @@ -91,14 +111,13 @@ ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName; const ContextMenuCheckboxItem = React.forwardRef< React.ElementRef<typeof ContextMenuPrimitive.CheckboxItem>, - React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.CheckboxItem> ->(({ className, children, checked, ...props }, ref) => ( + React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.CheckboxItem> & { + variant?: VariantProps<typeof contextMenuItemVariants>["variant"]; + } +>(({ className, children, checked, variant = "default", ...props }, ref) => ( <ContextMenuPrimitive.CheckboxItem ref={ref} - className={cn( - "relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", - className - )} + className={cn(contextMenuItemVariants({ variant }), "pl-8 pr-2", className)} checked={checked} {...props} > @@ -115,19 +134,18 @@ ContextMenuCheckboxItem.displayName = const ContextMenuRadioItem = React.forwardRef< React.ElementRef<typeof ContextMenuPrimitive.RadioItem>, - React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.RadioItem> ->(({ className, children, ...props }, ref) => ( + React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.RadioItem> & { + variant?: VariantProps<typeof contextMenuItemVariants>["variant"]; + } +>(({ className, children, variant = "default", ...props }, ref) => ( <ContextMenuPrimitive.RadioItem ref={ref} - className={cn( - "relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", - className - )} + className={cn(contextMenuItemVariants({ variant }), "pl-8 pr-2", className)} {...props} > <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center"> <ContextMenuPrimitive.ItemIndicator> - <Circle className="h-4 w-4 fill-current" /> + <Circle className="h-2 w-2 fill-current" /> </ContextMenuPrimitive.ItemIndicator> </span> {children} @@ -144,7 +162,7 @@ const ContextMenuLabel = React.forwardRef< <ContextMenuPrimitive.Label ref={ref} className={cn( - "px-2 py-1.5 text-sm font-semibold text-foreground", + "px-2 py-1.5 text-sm font-semibold", inset && "pl-8", className )} @@ -159,7 +177,7 @@ const ContextMenuSeparator = React.forwardRef< >(({ className, ...props }, ref) => ( <ContextMenuPrimitive.Separator ref={ref} - className={cn("-mx-1 my-1 h-px bg-border", className)} + className={cn("-mx-1 my-1 h-px bg-foreground/10", className)} {...props} /> )); @@ -171,10 +189,7 @@ const ContextMenuShortcut = ({ }: React.HTMLAttributes<HTMLSpanElement>) => { return ( <span - className={cn( - "ml-auto text-xs tracking-widest text-muted-foreground", - className - )} + className={cn("ml-auto text-xs tracking-widest opacity-60", className)} {...props} /> ); diff --git a/apps/web/src/components/ui/dropdown-menu.tsx b/apps/web/src/components/ui/dropdown-menu.tsx index 2b653e3b..9db8a268 100644 --- a/apps/web/src/components/ui/dropdown-menu.tsx +++ b/apps/web/src/components/ui/dropdown-menu.tsx @@ -19,16 +19,33 @@ const DropdownMenuSub = DropdownMenuPrimitive.Sub; const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup; +const dropdownMenuItemVariants = cva( + "relative flex cursor-pointer select-none items-center gap-2 px-2 py-1.5 text-sm outline-none transition-opacity data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0", + { + variants: { + variant: { + default: "focus:opacity-65 focus:text-accent-foreground", + destructive: "text-destructive focus:text-destructive/80", + }, + }, + defaultVariants: { + variant: "default", + }, + } +); + const DropdownMenuSubTrigger = React.forwardRef< React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>, React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & { inset?: boolean; + variant?: VariantProps<typeof dropdownMenuItemVariants>["variant"]; } ->(({ className, inset, children, ...props }, ref) => ( +>(({ className, inset, children, variant = "default", ...props }, ref) => ( <DropdownMenuPrimitive.SubTrigger ref={ref} className={cn( - "flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", + dropdownMenuItemVariants({ variant }), + "data-[state=open]:bg-accent data-[state=open]:opacity-65", inset && "pl-8", className )} @@ -66,7 +83,7 @@ const DropdownMenuContent = React.forwardRef< ref={ref} sideOffset={sideOffset} className={cn( - "z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md", + "z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 bg-popover text-popover-foreground shadow-md", "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", className )} @@ -76,22 +93,6 @@ const DropdownMenuContent = React.forwardRef< )); DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName; -const dropdownMenuItemVariants = cva( - "relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0", - { - variants: { - variant: { - default: "focus:bg-accent focus:text-accent-foreground", - destructive: - "text-destructive focus:bg-destructive focus:text-destructive-foreground", - }, - }, - defaultVariants: { - variant: "default", - }, - } -); - const DropdownMenuItem = React.forwardRef< React.ElementRef<typeof DropdownMenuPrimitive.Item>, React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & { @@ -113,12 +114,15 @@ DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName; const DropdownMenuCheckboxItem = React.forwardRef< React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>, - React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem> ->(({ className, children, checked, ...props }, ref) => ( + React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem> & { + variant?: VariantProps<typeof dropdownMenuItemVariants>["variant"]; + } +>(({ className, children, checked, variant = "default", ...props }, ref) => ( <DropdownMenuPrimitive.CheckboxItem ref={ref} className={cn( - "relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", + dropdownMenuItemVariants({ variant }), + "pl-8 pr-2", className )} checked={checked} @@ -137,12 +141,15 @@ DropdownMenuCheckboxItem.displayName = const DropdownMenuRadioItem = React.forwardRef< React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>, - React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem> ->(({ className, children, ...props }, ref) => ( + React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem> & { + variant?: VariantProps<typeof dropdownMenuItemVariants>["variant"]; + } +>(({ className, children, variant = "default", ...props }, ref) => ( <DropdownMenuPrimitive.RadioItem ref={ref} className={cn( - "relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", + dropdownMenuItemVariants({ variant }), + "pl-8 pr-2", className )} {...props} @@ -181,7 +188,7 @@ const DropdownMenuSeparator = React.forwardRef< >(({ className, ...props }, ref) => ( <DropdownMenuPrimitive.Separator ref={ref} - className={cn("-mx-1 my-1 h-px bg-muted", className)} + className={cn("-mx-1 my-1 h-px bg-foreground/10", className)} {...props} /> )); diff --git a/apps/web/src/components/ui/select.tsx b/apps/web/src/components/ui/select.tsx index a0f5f7b2..4332dc6b 100644 --- a/apps/web/src/components/ui/select.tsx +++ b/apps/web/src/components/ui/select.tsx @@ -3,6 +3,7 @@ import * as React from "react"; import { Select as SelectPrimitive } from "radix-ui"; import { Check, ChevronDown, ChevronUp } from "lucide-react"; +import { cva, type VariantProps } from "class-variance-authority"; import { cn } from "../../lib/utils"; @@ -12,6 +13,21 @@ const SelectGroup = SelectPrimitive.Group; const SelectValue = SelectPrimitive.Value; +const selectItemVariants = cva( + "relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none transition-opacity data-[disabled]:pointer-events-none data-[disabled]:opacity-50", + { + variants: { + variant: { + default: "focus:opacity-65 focus:text-accent-foreground", + destructive: "text-destructive focus:text-destructive/80", + }, + }, + defaultVariants: { + variant: "default", + }, + } +); + const SelectTrigger = React.forwardRef< React.ElementRef<typeof SelectPrimitive.Trigger>, React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger> @@ -113,14 +129,13 @@ SelectLabel.displayName = SelectPrimitive.Label.displayName; const SelectItem = React.forwardRef< React.ElementRef<typeof SelectPrimitive.Item>, - React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item> ->(({ className, children, ...props }, ref) => ( + React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item> & { + variant?: VariantProps<typeof selectItemVariants>["variant"]; + } +>(({ className, children, variant = "default", ...props }, ref) => ( <SelectPrimitive.Item ref={ref} - className={cn( - "relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", - className - )} + className={cn(selectItemVariants({ variant }), className)} {...props} > <span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center"> @@ -139,7 +154,7 @@ const SelectSeparator = React.forwardRef< >(({ className, ...props }, ref) => ( <SelectPrimitive.Separator ref={ref} - className={cn("-mx-1 my-1 h-px bg-muted", className)} + className={cn("-mx-1 my-1 h-px bg-foreground/10", className)} {...props} /> )); diff --git a/apps/web/src/lib/media-processing.ts b/apps/web/src/lib/media-processing.ts index daf22b34..4c1294ae 100644 --- a/apps/web/src/lib/media-processing.ts +++ b/apps/web/src/lib/media-processing.ts @@ -1,81 +1,85 @@ -import { toast } from "sonner";
-import {
- getFileType,
- generateVideoThumbnail,
- getMediaDuration,
- getImageAspectRatio,
- type MediaItem,
-} from "@/stores/media-store";
-// import { generateThumbnail, getVideoInfo } from "./ffmpeg-utils"; // Temporarily disabled
-
-export interface ProcessedMediaItem extends Omit<MediaItem, "id"> {}
-
-export async function processMediaFiles(
- files: FileList | File[],
- onProgress?: (progress: number) => void
-): Promise<ProcessedMediaItem[]> {
- const fileArray = Array.from(files);
- const processedItems: ProcessedMediaItem[] = [];
-
- const total = fileArray.length;
- let completed = 0;
-
- for (const file of fileArray) {
- const fileType = getFileType(file);
-
- if (!fileType) {
- toast.error(`Unsupported file type: ${file.name}`);
- continue;
- }
-
- const url = URL.createObjectURL(file);
- let thumbnailUrl: string | undefined;
- let duration: number | undefined;
- let aspectRatio: number = 16 / 9; // Default fallback
-
- try {
- if (fileType === "image") {
- // Get image aspect ratio
- aspectRatio = await getImageAspectRatio(file);
- } else if (fileType === "video") {
- // Use basic thumbnail generation for now
- const videoResult = await generateVideoThumbnail(file);
- thumbnailUrl = videoResult.thumbnailUrl;
- aspectRatio = videoResult.aspectRatio;
- } else if (fileType === "audio") {
- // For audio, use a square aspect ratio
- aspectRatio = 1;
- }
-
- // Get duration for videos and audio (if not already set by FFmpeg)
- if ((fileType === "video" || fileType === "audio") && !duration) {
- duration = await getMediaDuration(file);
- }
-
- processedItems.push({
- name: file.name,
- type: fileType,
- file,
- url,
- thumbnailUrl,
- duration,
- aspectRatio,
- });
-
- // Yield back to the event loop to keep the UI responsive
- await new Promise((resolve) => setTimeout(resolve, 0));
-
- completed += 1;
- if (onProgress) {
- const percent = Math.round((completed / total) * 100);
- onProgress(percent);
- }
- } catch (error) {
- console.error("Error processing file:", file.name, error);
- toast.error(`Failed to process ${file.name}`);
- URL.revokeObjectURL(url); // Clean up on error
- }
- }
-
- return processedItems;
-}
+import { toast } from "sonner"; +import { + getFileType, + generateVideoThumbnail, + getMediaDuration, + getImageDimensions, + type MediaItem, +} from "@/stores/media-store"; +// import { generateThumbnail, getVideoInfo } from "./ffmpeg-utils"; // Temporarily disabled + +export interface ProcessedMediaItem extends Omit<MediaItem, "id"> {} + +export async function processMediaFiles( + files: FileList | File[], + onProgress?: (progress: number) => void +): Promise<ProcessedMediaItem[]> { + const fileArray = Array.from(files); + const processedItems: ProcessedMediaItem[] = []; + + const total = fileArray.length; + let completed = 0; + + for (const file of fileArray) { + const fileType = getFileType(file); + + if (!fileType) { + toast.error(`Unsupported file type: ${file.name}`); + continue; + } + + const url = URL.createObjectURL(file); + let thumbnailUrl: string | undefined; + let duration: number | undefined; + let width: number | undefined; + let height: number | undefined; + + try { + if (fileType === "image") { + // Get image dimensions + const dimensions = await getImageDimensions(file); + width = dimensions.width; + height = dimensions.height; + } else if (fileType === "video") { + // Use basic thumbnail generation for now + const videoResult = await generateVideoThumbnail(file); + thumbnailUrl = videoResult.thumbnailUrl; + width = videoResult.width; + height = videoResult.height; + } else if (fileType === "audio") { + // For audio, we don't set width/height (they'll be undefined) + } + + // Get duration for videos and audio (if not already set by FFmpeg) + if ((fileType === "video" || fileType === "audio") && !duration) { + duration = await getMediaDuration(file); + } + + processedItems.push({ + name: file.name, + type: fileType, + file, + url, + thumbnailUrl, + duration, + width, + height, + }); + + // Yield back to the event loop to keep the UI responsive + await new Promise((resolve) => setTimeout(resolve, 0)); + + completed += 1; + if (onProgress) { + const percent = Math.round((completed / total) * 100); + onProgress(percent); + } + } catch (error) { + console.error("Error processing file:", file.name, error); + toast.error(`Failed to process ${file.name}`); + URL.revokeObjectURL(url); // Clean up on error + } + } + + return processedItems; +} diff --git a/apps/web/src/lib/storage/indexeddb-adapter.ts b/apps/web/src/lib/storage/indexeddb-adapter.ts new file mode 100644 index 00000000..f43a8798 --- /dev/null +++ b/apps/web/src/lib/storage/indexeddb-adapter.ts @@ -0,0 +1,89 @@ +import { StorageAdapter } from "./types";
+
+export class IndexedDBAdapter<T> implements StorageAdapter<T> {
+ private dbName: string;
+ private storeName: string;
+ private version: number;
+
+ constructor(dbName: string, storeName: string, version: number = 1) {
+ this.dbName = dbName;
+ this.storeName = storeName;
+ this.version = version;
+ }
+
+ private async getDB(): Promise<IDBDatabase> {
+ return new Promise((resolve, reject) => {
+ const request = indexedDB.open(this.dbName, this.version);
+
+ request.onerror = () => reject(request.error);
+ request.onsuccess = () => resolve(request.result);
+
+ request.onupgradeneeded = (event) => {
+ const db = (event.target as IDBOpenDBRequest).result;
+ if (!db.objectStoreNames.contains(this.storeName)) {
+ db.createObjectStore(this.storeName, { keyPath: "id" });
+ }
+ };
+ });
+ }
+
+ async get(key: string): Promise<T | null> {
+ const db = await this.getDB();
+ const transaction = db.transaction([this.storeName], "readonly");
+ const store = transaction.objectStore(this.storeName);
+
+ return new Promise((resolve, reject) => {
+ const request = store.get(key);
+ request.onerror = () => reject(request.error);
+ request.onsuccess = () => resolve(request.result || null);
+ });
+ }
+
+ async set(key: string, value: T): Promise<void> {
+ const db = await this.getDB();
+ const transaction = db.transaction([this.storeName], "readwrite");
+ const store = transaction.objectStore(this.storeName);
+
+ return new Promise((resolve, reject) => {
+ const request = store.put({ id: key, ...value });
+ request.onerror = () => reject(request.error);
+ request.onsuccess = () => resolve();
+ });
+ }
+
+ async remove(key: string): Promise<void> {
+ const db = await this.getDB();
+ const transaction = db.transaction([this.storeName], "readwrite");
+ const store = transaction.objectStore(this.storeName);
+
+ return new Promise((resolve, reject) => {
+ const request = store.delete(key);
+ request.onerror = () => reject(request.error);
+ request.onsuccess = () => resolve();
+ });
+ }
+
+ async list(): Promise<string[]> {
+ const db = await this.getDB();
+ const transaction = db.transaction([this.storeName], "readonly");
+ const store = transaction.objectStore(this.storeName);
+
+ return new Promise((resolve, reject) => {
+ const request = store.getAllKeys();
+ request.onerror = () => reject(request.error);
+ request.onsuccess = () => resolve(request.result as string[]);
+ });
+ }
+
+ async clear(): Promise<void> {
+ const db = await this.getDB();
+ const transaction = db.transaction([this.storeName], "readwrite");
+ const store = transaction.objectStore(this.storeName);
+
+ return new Promise((resolve, reject) => {
+ const request = store.clear();
+ request.onerror = () => reject(request.error);
+ request.onsuccess = () => resolve();
+ });
+ }
+}
diff --git a/apps/web/src/lib/storage/opfs-adapter.ts b/apps/web/src/lib/storage/opfs-adapter.ts new file mode 100644 index 00000000..c01954e5 --- /dev/null +++ b/apps/web/src/lib/storage/opfs-adapter.ts @@ -0,0 +1,73 @@ +import { StorageAdapter } from "./types"; + +export class OPFSAdapter implements StorageAdapter<File> { + private directoryName: string; + + constructor(directoryName: string = "media") { + this.directoryName = directoryName; + } + + private async getDirectory(): Promise<FileSystemDirectoryHandle> { + const opfsRoot = await navigator.storage.getDirectory(); + return await opfsRoot.getDirectoryHandle(this.directoryName, { + create: true, + }); + } + + async get(key: string): Promise<File | null> { + try { + const directory = await this.getDirectory(); + const fileHandle = await directory.getFileHandle(key); + return await fileHandle.getFile(); + } catch (error) { + if ((error as Error).name === "NotFoundError") { + return null; + } + throw error; + } + } + + async set(key: string, file: File): Promise<void> { + const directory = await this.getDirectory(); + const fileHandle = await directory.getFileHandle(key, { create: true }); + const writable = await fileHandle.createWritable(); + + await writable.write(file); + await writable.close(); + } + + async remove(key: string): Promise<void> { + try { + const directory = await this.getDirectory(); + await directory.removeEntry(key); + } catch (error) { + if ((error as Error).name !== "NotFoundError") { + throw error; + } + } + } + + async list(): Promise<string[]> { + const directory = await this.getDirectory(); + const keys: string[] = []; + + for await (const name of directory.keys()) { + keys.push(name); + } + + return keys; + } + + async clear(): Promise<void> { + const directory = await this.getDirectory(); + + for await (const name of directory.keys()) { + await directory.removeEntry(name); + } + } + + // Helper method to check OPFS support + static isSupported(): boolean { + return "storage" in navigator && "getDirectory" in navigator.storage; + } +} diff --git a/apps/web/src/lib/storage/storage-service.ts b/apps/web/src/lib/storage/storage-service.ts new file mode 100644 index 00000000..65b3c134 --- /dev/null +++ b/apps/web/src/lib/storage/storage-service.ts @@ -0,0 +1,194 @@ +import { TProject } from "@/types/project";
+import { MediaItem } from "@/stores/media-store";
+import { IndexedDBAdapter } from "./indexeddb-adapter";
+import { OPFSAdapter } from "./opfs-adapter";
+import { MediaFileData, StorageConfig, SerializedProject } from "./types";
+
+class StorageService {
+ private projectsAdapter: IndexedDBAdapter<SerializedProject>;
+ private mediaMetadataAdapter: IndexedDBAdapter<MediaFileData>;
+ private mediaFilesAdapter: OPFSAdapter;
+ private config: StorageConfig;
+
+ constructor() {
+ this.config = {
+ projectsDb: "video-editor-projects",
+ mediaDb: "video-editor-media",
+ version: 1,
+ };
+
+ this.projectsAdapter = new IndexedDBAdapter<SerializedProject>(
+ this.config.projectsDb,
+ "projects",
+ this.config.version
+ );
+
+ this.mediaMetadataAdapter = new IndexedDBAdapter<MediaFileData>(
+ this.config.mediaDb,
+ "media-metadata",
+ this.config.version
+ );
+
+ this.mediaFilesAdapter = new OPFSAdapter("media-files");
+ }
+
+ // Project operations
+ async saveProject(project: TProject): Promise<void> {
+ // Convert TProject to serializable format
+ const serializedProject: SerializedProject = {
+ id: project.id,
+ name: project.name,
+ thumbnail: project.thumbnail,
+ createdAt: project.createdAt.toISOString(),
+ updatedAt: project.updatedAt.toISOString(),
+ };
+
+ await this.projectsAdapter.set(project.id, serializedProject);
+ }
+
+ async loadProject(id: string): Promise<TProject | null> {
+ const serializedProject = await this.projectsAdapter.get(id);
+
+ if (!serializedProject) return null;
+
+ // Convert back to TProject format
+ return {
+ id: serializedProject.id,
+ name: serializedProject.name,
+ thumbnail: serializedProject.thumbnail,
+ createdAt: new Date(serializedProject.createdAt),
+ updatedAt: new Date(serializedProject.updatedAt),
+ };
+ }
+
+ async loadAllProjects(): Promise<TProject[]> {
+ const projectIds = await this.projectsAdapter.list();
+ const projects: TProject[] = [];
+
+ for (const id of projectIds) {
+ const project = await this.loadProject(id);
+ if (project) {
+ projects.push(project);
+ }
+ }
+
+ // Sort by last updated (most recent first)
+ return projects.sort(
+ (a, b) => b.updatedAt.getTime() - a.updatedAt.getTime()
+ );
+ }
+
+ async deleteProject(id: string): Promise<void> {
+ await this.projectsAdapter.remove(id);
+ }
+
+ // Media operations
+ async saveMediaItem(mediaItem: MediaItem): Promise<void> {
+ // Save file to OPFS
+ await this.mediaFilesAdapter.set(mediaItem.id, mediaItem.file);
+
+ // Save metadata to IndexedDB
+ const metadata: MediaFileData = {
+ id: mediaItem.id,
+ name: mediaItem.name,
+ type: mediaItem.type,
+ size: mediaItem.file.size,
+ lastModified: mediaItem.file.lastModified,
+ width: mediaItem.width,
+ height: mediaItem.height,
+ duration: mediaItem.duration,
+ };
+
+ await this.mediaMetadataAdapter.set(mediaItem.id, metadata);
+ }
+
+ async loadMediaItem(id: string): Promise<MediaItem | null> {
+ const [file, metadata] = await Promise.all([
+ this.mediaFilesAdapter.get(id),
+ this.mediaMetadataAdapter.get(id),
+ ]);
+
+ if (!file || !metadata) return null;
+
+ // Create new object URL for the file
+ const url = URL.createObjectURL(file);
+
+ return {
+ id: metadata.id,
+ name: metadata.name,
+ type: metadata.type,
+ file,
+ url,
+ width: metadata.width,
+ height: metadata.height,
+ duration: metadata.duration,
+ // thumbnailUrl would need to be regenerated or cached separately
+ };
+ }
+
+ async loadAllMediaItems(): Promise<MediaItem[]> {
+ const mediaIds = await this.mediaMetadataAdapter.list();
+ const mediaItems: MediaItem[] = [];
+
+ for (const id of mediaIds) {
+ const item = await this.loadMediaItem(id);
+ if (item) {
+ mediaItems.push(item);
+ }
+ }
+
+ return mediaItems;
+ }
+
+ async deleteMediaItem(id: string): Promise<void> {
+ await Promise.all([
+ this.mediaFilesAdapter.remove(id),
+ this.mediaMetadataAdapter.remove(id),
+ ]);
+ }
+
+ // Utility methods
+ async clearAllData(): Promise<void> {
+ await Promise.all([
+ this.projectsAdapter.clear(),
+ this.mediaMetadataAdapter.clear(),
+ this.mediaFilesAdapter.clear(),
+ ]);
+ }
+
+ async getStorageInfo(): Promise<{
+ projects: number;
+ mediaItems: number;
+ isOPFSSupported: boolean;
+ isIndexedDBSupported: boolean;
+ }> {
+ const [projectIds, mediaIds] = await Promise.all([
+ this.projectsAdapter.list(),
+ this.mediaMetadataAdapter.list(),
+ ]);
+
+ return {
+ projects: projectIds.length,
+ mediaItems: mediaIds.length,
+ isOPFSSupported: this.isOPFSSupported(),
+ isIndexedDBSupported: this.isIndexedDBSupported(),
+ };
+ }
+
+ // Check browser support
+ isOPFSSupported(): boolean {
+ return OPFSAdapter.isSupported();
+ }
+
+ isIndexedDBSupported(): boolean {
+ return "indexedDB" in window;
+ }
+
+ isFullySupported(): boolean {
+ return this.isIndexedDBSupported() && this.isOPFSSupported();
+ }
+}
+
+// Export singleton instance
+export const storageService = new StorageService();
+export { StorageService };
diff --git a/apps/web/src/lib/storage/types.ts b/apps/web/src/lib/storage/types.ts new file mode 100644 index 00000000..7ad18e19 --- /dev/null +++ b/apps/web/src/lib/storage/types.ts @@ -0,0 +1,42 @@ +import { TProject } from "@/types/project"; + +export interface StorageAdapter<T> { + get(key: string): Promise<T | null>; + set(key: string, value: T): Promise<void>; + remove(key: string): Promise<void>; + list(): Promise<string[]>; + clear(): Promise<void>; +} + +export interface MediaFileData { + id: string; + name: string; + type: "image" | "video" | "audio"; + size: number; + lastModified: number; + width?: number; + height?: number; + duration?: number; + // File will be stored separately in OPFS +} + +export interface StorageConfig { + projectsDb: string; + mediaDb: string; + version: number; +} + +// Helper type for serialization - converts Date objects to strings +export type SerializedProject = Omit<TProject, "createdAt" | "updatedAt"> & { + createdAt: string; + updatedAt: string; +}; + +// Extend FileSystemDirectoryHandle with missing async iterator methods +declare global { + interface FileSystemDirectoryHandle { + keys(): AsyncIterableIterator<string>; + values(): AsyncIterableIterator<FileSystemHandle>; + entries(): AsyncIterableIterator<[string, FileSystemHandle]>; + } +} diff --git a/apps/web/src/lib/time.ts b/apps/web/src/lib/time.ts new file mode 100644 index 00000000..e2db28c2 --- /dev/null +++ b/apps/web/src/lib/time.ts @@ -0,0 +1,21 @@ +// Time-related utility functions
+
+// Helper function to format time in various formats (MM:SS, HH:MM:SS, HH:MM:SS:CS)
+export const formatTimeCode = (
+ timeInSeconds: number,
+ format: "MM:SS" | "HH:MM:SS" | "HH:MM:SS:CS" = "HH:MM:SS:CS"
+): string => {
+ const hours = Math.floor(timeInSeconds / 3600);
+ const minutes = Math.floor((timeInSeconds % 3600) / 60);
+ const seconds = Math.floor(timeInSeconds % 60);
+ const centiseconds = Math.floor((timeInSeconds % 1) * 100);
+
+ switch (format) {
+ case "MM:SS":
+ return `${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
+ case "HH:MM:SS":
+ return `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
+ case "HH:MM:SS:CS":
+ return `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}:${centiseconds.toString().padStart(2, "0")}`;
+ }
+};
diff --git a/apps/web/src/middleware.ts b/apps/web/src/middleware.ts index f2d3f590..d20c4b1d 100644 --- a/apps/web/src/middleware.ts +++ b/apps/web/src/middleware.ts @@ -1,15 +1,13 @@ import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
-import { getSessionCookie } from "better-auth/cookies";
export async function middleware(request: NextRequest) {
const path = request.nextUrl.pathname;
- const session = getSessionCookie(request);
- if (path === "/editor" && !session && process.env.NODE_ENV === "production") {
- const loginUrl = new URL("/login", request.url);
- loginUrl.searchParams.set("redirect", request.url);
- return NextResponse.redirect(loginUrl);
+ if (path === "/editor" && process.env.NODE_ENV === "production") {
+ const homeUrl = new URL("/", request.url);
+ homeUrl.searchParams.set("redirect", request.url);
+ return NextResponse.redirect(homeUrl);
}
return NextResponse.next();
diff --git a/apps/web/src/stores/editor-store.ts b/apps/web/src/stores/editor-store.ts index e8dd8e21..5382c3e2 100644 --- a/apps/web/src/stores/editor-store.ts +++ b/apps/web/src/stores/editor-store.ts @@ -1,20 +1,71 @@ import { create } from "zustand";
+import { CanvasSize, CanvasPreset } from "@/types/editor";
interface EditorState {
// Loading states
isInitializing: boolean;
isPanelsReady: boolean;
+ // Canvas/Project settings
+ canvasSize: CanvasSize;
+ canvasPresets: CanvasPreset[];
+
// Actions
setInitializing: (loading: boolean) => void;
setPanelsReady: (ready: boolean) => void;
initializeApp: () => Promise<void>;
+ setCanvasSize: (size: CanvasSize) => void;
+ setCanvasSizeFromAspectRatio: (aspectRatio: number) => void;
}
+const DEFAULT_CANVAS_PRESETS: CanvasPreset[] = [
+ { name: "16:9", width: 1920, height: 1080 },
+ { name: "9:16", width: 1080, height: 1920 },
+ { name: "1:1", width: 1080, height: 1080 },
+ { name: "4:3", width: 1440, height: 1080 },
+];
+
+// Helper function to find the best matching canvas preset for an aspect ratio
+const findBestCanvasPreset = (aspectRatio: number): CanvasSize => {
+ // Calculate aspect ratio for each preset and find the closest match
+ let bestMatch = DEFAULT_CANVAS_PRESETS[0]; // Default to 16:9 HD
+ let smallestDifference = Math.abs(
+ aspectRatio - bestMatch.width / bestMatch.height
+ );
+
+ for (const preset of DEFAULT_CANVAS_PRESETS) {
+ const presetAspectRatio = preset.width / preset.height;
+ const difference = Math.abs(aspectRatio - presetAspectRatio);
+
+ if (difference < smallestDifference) {
+ smallestDifference = difference;
+ bestMatch = preset;
+ }
+ }
+
+ // If the difference is still significant (> 0.1), create a custom size
+ // based on the media aspect ratio with a reasonable resolution
+ const bestAspectRatio = bestMatch.width / bestMatch.height;
+ if (Math.abs(aspectRatio - bestAspectRatio) > 0.1) {
+ // Create custom dimensions based on the aspect ratio
+ if (aspectRatio > 1) {
+ // Landscape - use 1920 width
+ return { width: 1920, height: Math.round(1920 / aspectRatio) };
+ } else {
+ // Portrait or square - use 1080 height
+ return { width: Math.round(1080 * aspectRatio), height: 1080 };
+ }
+ }
+
+ return { width: bestMatch.width, height: bestMatch.height };
+};
+
export const useEditorStore = create<EditorState>((set, get) => ({
// Initial states
isInitializing: true,
isPanelsReady: false,
+ canvasSize: { width: 1920, height: 1080 }, // Default 16:9 HD
+ canvasPresets: DEFAULT_CANVAS_PRESETS,
// Actions
setInitializing: (loading) => {
@@ -32,4 +83,17 @@ export const useEditorStore = create<EditorState>((set, get) => ({ set({ isPanelsReady: true, isInitializing: false });
console.log("Video editor ready");
},
+
+ setCanvasSize: (size) => {
+ set({ canvasSize: size });
+ },
+
+ setCanvasSizeFromAspectRatio: (aspectRatio) => {
+ const newCanvasSize = findBestCanvasPreset(aspectRatio);
+ console.log(
+ `Setting canvas size based on aspect ratio ${aspectRatio}:`,
+ newCanvasSize
+ );
+ set({ canvasSize: newCanvasSize });
+ },
}));
diff --git a/apps/web/src/stores/media-store.ts b/apps/web/src/stores/media-store.ts index bdb4d52a..48932db5 100644 --- a/apps/web/src/stores/media-store.ts +++ b/apps/web/src/stores/media-store.ts @@ -1,170 +1,233 @@ -import { create } from "zustand";
-
-export interface MediaItem {
- id: string;
- name: string;
- type: "image" | "video" | "audio";
- file: File;
- url: string; // Object URL for preview
- thumbnailUrl?: string; // For video thumbnails
- duration?: number; // For video/audio duration
- aspectRatio: number; // width / height
-}
-
-interface MediaStore {
- mediaItems: MediaItem[];
-
- // Actions
- addMediaItem: (item: Omit<MediaItem, "id">) => void;
- removeMediaItem: (id: string) => void;
- clearAllMedia: () => void;
-}
-
-// Helper function to determine file type
-export const getFileType = (file: File): "image" | "video" | "audio" | null => {
- const { type } = file;
-
- if (type.startsWith("image/")) {
- return "image";
- }
- if (type.startsWith("video/")) {
- return "video";
- }
- if (type.startsWith("audio/")) {
- return "audio";
- }
-
- return null;
-};
-
-// Helper function to get image aspect ratio
-export const getImageAspectRatio = (file: File): Promise<number> => {
- return new Promise((resolve, reject) => {
- const img = new Image();
-
- img.addEventListener("load", () => {
- const aspectRatio = img.naturalWidth / img.naturalHeight;
- resolve(aspectRatio);
- img.remove();
- });
-
- img.addEventListener("error", () => {
- reject(new Error("Could not load image"));
- img.remove();
- });
-
- img.src = URL.createObjectURL(file);
- });
-};
-
-// Helper function to generate video thumbnail and get aspect ratio
-export const generateVideoThumbnail = (
- file: File
-): Promise<{ thumbnailUrl: string; aspectRatio: number }> => {
- return new Promise((resolve, reject) => {
- const video = document.createElement("video");
- const canvas = document.createElement("canvas");
- const ctx = canvas.getContext("2d");
-
- if (!ctx) {
- reject(new Error("Could not get canvas context"));
- return;
- }
-
- video.addEventListener("loadedmetadata", () => {
- canvas.width = video.videoWidth;
- canvas.height = video.videoHeight;
-
- // Seek to 1 second or 10% of duration, whichever is smaller
- video.currentTime = Math.min(1, video.duration * 0.1);
- });
-
- video.addEventListener("seeked", () => {
- ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
- const thumbnailUrl = canvas.toDataURL("image/jpeg", 0.8);
- const aspectRatio = video.videoWidth / video.videoHeight;
-
- resolve({ thumbnailUrl, aspectRatio });
-
- // Cleanup
- video.remove();
- canvas.remove();
- });
-
- video.addEventListener("error", () => {
- reject(new Error("Could not load video"));
- video.remove();
- canvas.remove();
- });
-
- video.src = URL.createObjectURL(file);
- video.load();
- });
-};
-
-// Helper function to get media duration
-export const getMediaDuration = (file: File): Promise<number> => {
- return new Promise((resolve, reject) => {
- const element = document.createElement(
- file.type.startsWith("video/") ? "video" : "audio"
- ) as HTMLVideoElement | HTMLAudioElement;
-
- element.addEventListener("loadedmetadata", () => {
- resolve(element.duration);
- element.remove();
- });
-
- element.addEventListener("error", () => {
- reject(new Error("Could not load media"));
- element.remove();
- });
-
- element.src = URL.createObjectURL(file);
- element.load();
- });
-};
-
-export const useMediaStore = create<MediaStore>((set, get) => ({
- mediaItems: [],
-
- addMediaItem: (item) => {
- const newItem: MediaItem = {
- ...item,
- id: crypto.randomUUID(),
- };
- set((state) => ({
- mediaItems: [...state.mediaItems, newItem],
- }));
- },
-
- removeMediaItem: (id) => {
- const state = get();
- const item = state.mediaItems.find((item) => item.id === id);
-
- // Cleanup object URLs to prevent memory leaks
- if (item) {
- URL.revokeObjectURL(item.url);
- if (item.thumbnailUrl) {
- URL.revokeObjectURL(item.thumbnailUrl);
- }
- }
-
- set((state) => ({
- mediaItems: state.mediaItems.filter((item) => item.id !== id),
- }));
- },
-
- clearAllMedia: () => {
- const state = get();
-
- // Cleanup all object URLs
- state.mediaItems.forEach((item) => {
- URL.revokeObjectURL(item.url);
- if (item.thumbnailUrl) {
- URL.revokeObjectURL(item.thumbnailUrl);
- }
- });
-
- set({ mediaItems: [] });
- },
-}));
+import { create } from "zustand"; +import { storageService } from "@/lib/storage/storage-service"; + +export interface MediaItem { + id: string; + name: string; + type: "image" | "video" | "audio"; + file: File; + url: string; // Object URL for preview + thumbnailUrl?: string; // For video thumbnails + duration?: number; // For video/audio duration + width?: number; // For video/image width + height?: number; // For video/image height +} + +interface MediaStore { + mediaItems: MediaItem[]; + isLoading: boolean; + + // Actions + addMediaItem: (item: Omit<MediaItem, "id">) => Promise<void>; + removeMediaItem: (id: string) => Promise<void>; + loadAllMedia: () => Promise<void>; + clearAllMedia: () => Promise<void>; +} + +// Helper function to determine file type +export const getFileType = (file: File): "image" | "video" | "audio" | null => { + const { type } = file; + + if (type.startsWith("image/")) { + return "image"; + } + if (type.startsWith("video/")) { + return "video"; + } + if (type.startsWith("audio/")) { + return "audio"; + } + + return null; +}; + +// Helper function to get image dimensions +export const getImageDimensions = ( + file: File +): Promise<{ width: number; height: number }> => { + return new Promise((resolve, reject) => { + const img = new Image(); + + img.addEventListener("load", () => { + const width = img.naturalWidth; + const height = img.naturalHeight; + resolve({ width, height }); + img.remove(); + }); + + img.addEventListener("error", () => { + reject(new Error("Could not load image")); + img.remove(); + }); + + img.src = URL.createObjectURL(file); + }); +}; + +// Helper function to generate video thumbnail and get dimensions +export const generateVideoThumbnail = ( + file: File +): Promise<{ thumbnailUrl: string; width: number; height: number }> => { + return new Promise((resolve, reject) => { + const video = document.createElement("video"); + const canvas = document.createElement("canvas"); + const ctx = canvas.getContext("2d"); + + if (!ctx) { + reject(new Error("Could not get canvas context")); + return; + } + + video.addEventListener("loadedmetadata", () => { + canvas.width = video.videoWidth; + canvas.height = video.videoHeight; + + // Seek to 1 second or 10% of duration, whichever is smaller + video.currentTime = Math.min(1, video.duration * 0.1); + }); + + video.addEventListener("seeked", () => { + ctx.drawImage(video, 0, 0, canvas.width, canvas.height); + const thumbnailUrl = canvas.toDataURL("image/jpeg", 0.8); + const width = video.videoWidth; + const height = video.videoHeight; + + resolve({ thumbnailUrl, width, height }); + + // Cleanup + video.remove(); + canvas.remove(); + }); + + video.addEventListener("error", () => { + reject(new Error("Could not load video")); + video.remove(); + canvas.remove(); + }); + + video.src = URL.createObjectURL(file); + video.load(); + }); +}; + +// Helper function to get media duration +export const getMediaDuration = (file: File): Promise<number> => { + return new Promise((resolve, reject) => { + const element = document.createElement( + file.type.startsWith("video/") ? "video" : "audio" + ) as HTMLVideoElement | HTMLAudioElement; + + element.addEventListener("loadedmetadata", () => { + resolve(element.duration); + element.remove(); + }); + + element.addEventListener("error", () => { + reject(new Error("Could not load media")); + element.remove(); + }); + + element.src = URL.createObjectURL(file); + element.load(); + }); +}; + +// Helper to get aspect ratio from MediaItem +export const getMediaAspectRatio = (item: MediaItem): number => { + if (item.width && item.height) { + return item.width / item.height; + } + return 16 / 9; // Default aspect ratio +}; + +export const useMediaStore = create<MediaStore>((set, get) => ({ + mediaItems: [], + isLoading: false, + + addMediaItem: async (item) => { + const newItem: MediaItem = { + ...item, + id: crypto.randomUUID(), + }; + + // Add to local state immediately for UI responsiveness + set((state) => ({ + mediaItems: [...state.mediaItems, newItem], + })); + + // Save to persistent storage in background + try { + await storageService.saveMediaItem(newItem); + } catch (error) { + console.error("Failed to save media item:", error); + // Remove from local state if save failed + set((state) => ({ + mediaItems: state.mediaItems.filter((item) => item.id !== newItem.id), + })); + } + }, + + removeMediaItem: async (id) => { + const state = get(); + const item = state.mediaItems.find((item) => item.id === id); + + // Cleanup object URLs to prevent memory leaks + if (item) { + URL.revokeObjectURL(item.url); + if (item.thumbnailUrl) { + URL.revokeObjectURL(item.thumbnailUrl); + } + } + + // Remove from local state immediately + set((state) => ({ + mediaItems: state.mediaItems.filter((item) => item.id !== id), + })); + + // Remove from persistent storage + try { + await storageService.deleteMediaItem(id); + } catch (error) { + console.error("Failed to delete media item:", error); + // Could re-add to local state here if needed + } + }, + + loadAllMedia: async () => { + set({ isLoading: true }); + + try { + const mediaItems = await storageService.loadAllMediaItems(); + set({ mediaItems }); + } catch (error) { + console.error("Failed to load media items:", error); + } finally { + set({ isLoading: false }); + } + }, + + clearAllMedia: async () => { + const state = get(); + + // Cleanup all object URLs + state.mediaItems.forEach((item) => { + URL.revokeObjectURL(item.url); + if (item.thumbnailUrl) { + URL.revokeObjectURL(item.thumbnailUrl); + } + }); + + // Clear local state + set({ mediaItems: [] }); + + // Clear persistent storage + try { + const mediaIds = state.mediaItems.map((item) => item.id); + await Promise.all( + mediaIds.map((id) => storageService.deleteMediaItem(id)) + ); + } catch (error) { + console.error("Failed to clear media items from storage:", error); + } + }, +})); diff --git a/apps/web/src/stores/panel-store.ts b/apps/web/src/stores/panel-store.ts index 7706417a..450b6a6e 100644 --- a/apps/web/src/stores/panel-store.ts +++ b/apps/web/src/stores/panel-store.ts @@ -1,6 +1,14 @@ import { create } from "zustand";
import { persist } from "zustand/middleware";
+const DEFAULT_PANEL_SIZES = {
+ toolsPanel: 45,
+ previewPanel: 75,
+ propertiesPanel: 20,
+ mainContent: 70,
+ timeline: 30,
+} as const;
+
interface PanelState {
// Panel sizes as percentages
toolsPanel: number;
@@ -21,11 +29,7 @@ export const usePanelStore = create<PanelState>()( persist(
(set) => ({
// Default sizes - optimized for responsiveness
- toolsPanel: 25,
- previewPanel: 75,
- propertiesPanel: 20,
- mainContent: 70,
- timeline: 30,
+ ...DEFAULT_PANEL_SIZES,
// Actions
setToolsPanel: (size) => set({ toolsPanel: size }),
diff --git a/apps/web/src/stores/project-store.ts b/apps/web/src/stores/project-store.ts index 0d0812cd..4fff83c2 100644 --- a/apps/web/src/stores/project-store.ts +++ b/apps/web/src/stores/project-store.ts @@ -1,41 +1,200 @@ import { TProject } from "@/types/project";
import { create } from "zustand";
+import { storageService } from "@/lib/storage/storage-service";
+import { toast } from "sonner";
interface ProjectStore {
activeProject: TProject | null;
+ savedProjects: TProject[];
+ isLoading: boolean;
+ isInitialized: boolean;
// Actions
- createNewProject: (name: string) => void;
+ createNewProject: (name: string) => Promise<string>;
+ loadProject: (id: string) => Promise<void>;
+ saveCurrentProject: () => Promise<void>;
+ loadAllProjects: () => Promise<void>;
+ deleteProject: (id: string) => Promise<void>;
closeProject: () => void;
- updateProjectName: (name: string) => void;
+ renameProject: (projectId: string, name: string) => Promise<void>;
+ duplicateProject: (projectId: string) => Promise<string>;
}
-export const useProjectStore = create<ProjectStore>((set) => ({
+export const useProjectStore = create<ProjectStore>((set, get) => ({
activeProject: null,
+ savedProjects: [],
+ isLoading: true,
+ isInitialized: false,
- createNewProject: (name: string) => {
+ createNewProject: async (name: string) => {
const newProject: TProject = {
id: crypto.randomUUID(),
name,
+ thumbnail: "",
createdAt: new Date(),
updatedAt: new Date(),
};
+
set({ activeProject: newProject });
+
+ try {
+ await storageService.saveProject(newProject);
+ // Reload all projects to update the list
+ await get().loadAllProjects();
+ return newProject.id;
+ } catch (error) {
+ toast.error("Failed to save new project");
+ throw error;
+ }
+ },
+
+ loadProject: async (id: string) => {
+ if (!get().isInitialized) {
+ set({ isLoading: true });
+ }
+
+ try {
+ const project = await storageService.loadProject(id);
+ if (project) {
+ set({ activeProject: project });
+ }
+ } catch (error) {
+ console.error("Failed to load project:", error);
+ } finally {
+ set({ isLoading: false });
+ }
+ },
+
+ saveCurrentProject: async () => {
+ const { activeProject } = get();
+ if (!activeProject) return;
+
+ try {
+ await storageService.saveProject(activeProject);
+ await get().loadAllProjects(); // Refresh the list
+ } catch (error) {
+ console.error("Failed to save project:", error);
+ }
+ },
+
+ loadAllProjects: async () => {
+ if (!get().isInitialized) {
+ set({ isLoading: true });
+ }
+
+ try {
+ const projects = await storageService.loadAllProjects();
+ set({ savedProjects: projects });
+ } catch (error) {
+ console.error("Failed to load projects:", error);
+ } finally {
+ set({ isLoading: false, isInitialized: true });
+ }
+ },
+
+ deleteProject: async (id: string) => {
+ try {
+ await storageService.deleteProject(id);
+ await get().loadAllProjects(); // Refresh the list
+
+ // If we deleted the active project, close it
+ const { activeProject } = get();
+ if (activeProject?.id === id) {
+ set({ activeProject: null });
+ }
+ } catch (error) {
+ console.error("Failed to delete project:", error);
+ }
},
closeProject: () => {
set({ activeProject: null });
},
- updateProjectName: (name: string) => {
- set((state) => ({
- activeProject: state.activeProject
- ? {
- ...state.activeProject,
- name,
- updatedAt: new Date(),
- }
- : null,
- }));
+ renameProject: async (id: string, name: string) => {
+ const { savedProjects } = get();
+
+ // Find the project to rename
+ const projectToRename = savedProjects.find((p) => p.id === id);
+ if (!projectToRename) {
+ toast.error("Project not found", {
+ description: "Please try again",
+ });
+ return;
+ }
+
+ const updatedProject = {
+ ...projectToRename,
+ name,
+ updatedAt: new Date(),
+ };
+
+ try {
+ // Save to storage
+ await storageService.saveProject(updatedProject);
+
+ await get().loadAllProjects();
+
+ // Update activeProject if it's the same project
+ const { activeProject } = get();
+ if (activeProject?.id === id) {
+ set({ activeProject: updatedProject });
+ }
+ } catch (error) {
+ console.error("Failed to rename project:", error);
+ toast.error("Failed to rename project", {
+ description:
+ error instanceof Error ? error.message : "Please try again",
+ });
+ }
+ },
+
+ duplicateProject: async (projectId: string) => {
+ try {
+ const project = await storageService.loadProject(projectId);
+ if (!project) {
+ toast.error("Project not found", {
+ description: "Please try again",
+ });
+ throw new Error("Project not found");
+ }
+
+ const { savedProjects } = get();
+
+ // Extract the base name (remove any existing numbering)
+ const numberMatch = project.name.match(/^\((\d+)\)\s+(.+)$/);
+ const baseName = numberMatch ? numberMatch[2] : project.name;
+ const existingNumbers: number[] = [];
+
+ // Check for pattern "(number) baseName" in existing projects
+ savedProjects.forEach((p) => {
+ const match = p.name.match(/^\((\d+)\)\s+(.+)$/);
+ if (match && match[2] === baseName) {
+ existingNumbers.push(parseInt(match[1], 10));
+ }
+ });
+
+ const nextNumber =
+ existingNumbers.length > 0 ? Math.max(...existingNumbers) + 1 : 1;
+
+ const newProject: TProject = {
+ id: crypto.randomUUID(),
+ name: `(${nextNumber}) ${baseName}`,
+ thumbnail: project.thumbnail,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ };
+
+ await storageService.saveProject(newProject);
+ await get().loadAllProjects();
+ return newProject.id;
+ } catch (error) {
+ console.error("Failed to duplicate project:", error);
+ toast.error("Failed to duplicate project", {
+ description:
+ error instanceof Error ? error.message : "Please try again",
+ });
+ throw error;
+ }
},
}));
diff --git a/apps/web/src/stores/timeline-store.ts b/apps/web/src/stores/timeline-store.ts index 73628497..01f4fea6 100644 --- a/apps/web/src/stores/timeline-store.ts +++ b/apps/web/src/stores/timeline-store.ts @@ -1,5 +1,7 @@ import { create } from "zustand"; import type { TrackType } from "@/types/timeline"; +import { useEditorStore } from "./editor-store"; +import { useMediaStore, getMediaAspectRatio } from "./media-store"; // Helper function to manage clip naming with suffixes const getClipNameWithSuffix = ( @@ -199,6 +201,15 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({ addClipToTrack: (trackId, clipData) => { get().pushHistory(); + + // Check if this is the first clip being added to the timeline + const currentState = get(); + const totalClipsInTimeline = currentState.tracks.reduce( + (total, track) => total + track.clips.length, + 0 + ); + const isFirstClip = totalClipsInTimeline === 0; + const newClip: TimelineClip = { ...clipData, id: crypto.randomUUID(), @@ -207,6 +218,25 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({ trimEnd: 0, }; + // If this is the first clip, automatically set the project canvas size + // to match the media's aspect ratio + if (isFirstClip && clipData.mediaId) { + const mediaStore = useMediaStore.getState(); + const mediaItem = mediaStore.mediaItems.find( + (item) => item.id === clipData.mediaId + ); + + if ( + mediaItem && + (mediaItem.type === "image" || mediaItem.type === "video") + ) { + const editorStore = useEditorStore.getState(); + editorStore.setCanvasSizeFromAspectRatio( + getMediaAspectRatio(mediaItem) + ); + } + } + set((state) => ({ tracks: state.tracks.map((track) => track.id === trackId diff --git a/apps/web/src/types/editor.ts b/apps/web/src/types/editor.ts index bf1dae2f..dce7ac50 100644 --- a/apps/web/src/types/editor.ts +++ b/apps/web/src/types/editor.ts @@ -1 +1,12 @@ export type BackgroundType = "blur" | "mirror" | "color";
+
+export interface CanvasSize {
+ width: number;
+ height: number;
+}
+
+export interface CanvasPreset {
+ name: string;
+ width: number;
+ height: number;
+}
diff --git a/apps/web/src/types/project.ts b/apps/web/src/types/project.ts index 74886b24..8b1eff3f 100644 --- a/apps/web/src/types/project.ts +++ b/apps/web/src/types/project.ts @@ -1,6 +1,7 @@ export interface TProject {
id: string;
name: string;
+ thumbnail: string;
createdAt: Date;
updatedAt: Date;
}
diff --git a/apps/web/src/types/timeline.ts b/apps/web/src/types/timeline.ts index 7fafc187..54a433a7 100644 --- a/apps/web/src/types/timeline.ts +++ b/apps/web/src/types/timeline.ts @@ -1,29 +1,20 @@ -import { TimelineTrack, TimelineClip } from "@/stores/timeline-store";
-
-export type TrackType = "video" | "audio" | "effects";
-
-export interface TimelineClipProps {
- clip: TimelineClip;
- track: TimelineTrack;
- zoomLevel: number;
- isSelected: boolean;
- onContextMenu: (e: React.MouseEvent, clipId: string) => void;
- onClipMouseDown: (e: React.MouseEvent, clip: TimelineClip) => void;
- onClipClick: (e: React.MouseEvent, clip: TimelineClip) => void;
-}
-
-export interface ResizeState {
- clipId: string;
- side: "left" | "right";
- startX: number;
- initialTrimStart: number;
- initialTrimEnd: number;
-}
-
-export interface ContextMenuState {
- type: "track" | "clip";
- trackId: string;
- clipId?: string;
- x: number;
- y: number;
-}
+import { TimelineTrack, TimelineClip } from "@/stores/timeline-store"; + +export type TrackType = "video" | "audio" | "effects"; + +export interface TimelineClipProps { + clip: TimelineClip; + track: TimelineTrack; + zoomLevel: number; + isSelected: boolean; + onClipMouseDown: (e: React.MouseEvent, clip: TimelineClip) => void; + onClipClick: (e: React.MouseEvent, clip: TimelineClip) => void; +} + +export interface ResizeState { + clipId: string; + side: "left" | "right"; + startX: number; + initialTrimStart: number; + initialTrimEnd: number; +} |
