diff options
42 files changed, 3763 insertions, 2626 deletions
@@ -10,10 +10,6 @@ </tr> </table> - - - - ## Why? - **Privacy**: Your videos stay on your device @@ -50,6 +46,7 @@ Before you begin, ensure you have the following installed on your system: ### Setup 1. **Clone the repository** + ```bash git clone <repo-url> cd OpenCut @@ -57,29 +54,33 @@ Before you begin, ensure you have the following installed on your system: 2. **Start backend services** From the project root, start the PostgreSQL and Redis services: + ```bash docker-compose up -d ``` 3. **Set up environment variables** Navigate into the web app's directory and create a `.env` file from the example: + ```bash cd apps/web - + # Unix/Linux/Mac cp .env.example .env.local # Windows Command Prompt copy .env.example .env.local - + # Windows PowerShell Copy-Item .env.example .env.local ``` - *The default values in the `.env` file should work for local development.* + + _The default values in the `.env` file should work for local development._ 4. **Install dependencies** Install the project dependencies using `bun` (recommended) or `npm`. + ```bash # With bun bun install @@ -90,6 +91,7 @@ Before you begin, ensure you have the following installed on your system: 5. **Run database migrations** Apply the database schema to your local database: + ```bash # With bun bun run db:push:local @@ -99,6 +101,7 @@ Before you begin, ensure you have the following installed on your system: ``` 6. **Start the development server** + ```bash # With bun bun run dev @@ -109,13 +112,12 @@ Before you begin, ensure you have the following installed on your system: The application will be available at [http://localhost:3000](http://localhost:3000). ---- +## Contributing +**Note**: We're currently moving at an extremely fast pace with rapid development and breaking changes. While we appreciate the interest, it's recommended to wait until the project stabilizes before contributing to avoid conflicts and wasted effort. -## Contributing +## Visit [CONTRIBUTING.md](.github/CONTRIBUTING.md) -Visit [CONTRIBUTING.md](.github/CONTRIBUTING.md) ---- We welcome contributions! Please see our [Contributing Guide](.github/CONTRIBUTING.md) for detailed setup instructions and development guidelines. Quick start for contributors: diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index 319ca134..a0a0d31b 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -7,6 +7,18 @@ const nextConfig: NextConfig = { reactStrictMode: true, productionBrowserSourceMaps: true, output: "standalone", + images: { + remotePatterns: [ + { + protocol: "https", + hostname: "plus.unsplash.com", + }, + { + protocol: "https", + hostname: "images.unsplash.com", + }, + ], + }, }; export default nextConfig; 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; +} diff --git a/apps/web/tailwind.config.ts b/apps/web/tailwind.config.ts index 00309f84..7a094dfd 100644 --- a/apps/web/tailwind.config.ts +++ b/apps/web/tailwind.config.ts @@ -8,6 +8,9 @@ export default { ], theme: { extend: { + screens: { + xs: "480px", + }, fontSize: { base: "0.95rem", }, |
