summaryrefslogtreecommitdiff
path: root/apps/web/src
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/src')
-rw-r--r--apps/web/src/app/projects/page.tsx1091
-rw-r--r--apps/web/src/stores/timeline-store.ts2495
2 files changed, 1810 insertions, 1776 deletions
diff --git a/apps/web/src/app/projects/page.tsx b/apps/web/src/app/projects/page.tsx
index 7ac08107..a1a61d14 100644
--- a/apps/web/src/app/projects/page.tsx
+++ b/apps/web/src/app/projects/page.tsx
@@ -1,607 +1,592 @@
"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 { Checkbox } from "@/components/ui/checkbox";
-import { Input } from "@/components/ui/input";
import {
- ChevronLeft,
- Plus,
- Calendar,
- MoreHorizontal,
- Video,
- Loader2,
- X,
- Trash2,
- Search,
+ Calendar,
+ ChevronLeft,
+ Loader2,
+ MoreHorizontal,
+ Plus,
+ Search,
+ Trash2,
+ Video,
+ X,
} 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 Link from "next/link";
import { useRouter } from "next/navigation";
+import { useCallback, useEffect, useState } from "react";
import { DeleteProjectDialog } from "@/components/delete-project-dialog";
import { RenameProjectDialog } from "@/components/rename-project-dialog";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent } from "@/components/ui/card";
+import { Checkbox } from "@/components/ui/checkbox";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import { Input } from "@/components/ui/input";
import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
} from "@/components/ui/select";
+import { Skeleton } from "@/components/ui/skeleton";
+import { useProjectStore } from "@/stores/project-store";
+import { useTimelineStore } from "@/stores/timeline-store";
+import type { TProject } from "@/types/project";
export default function ProjectsPage() {
- const {
- createNewProject,
- savedProjects,
- isLoading,
- isInitialized,
- deleteProject,
- getFilteredAndSortedProjects,
- } = useProjectStore();
- const router = useRouter();
- const [isSelectionMode, setIsSelectionMode] = useState(false);
- const [selectedProjects, setSelectedProjects] = useState<Set<string>>(
- new Set()
- );
- const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false);
- const [searchQuery, setSearchQuery] = useState("");
- const [sortOption, setSortOption] = useState("createdAt-desc");
+ const {
+ savedProjects,
+ isLoading,
+ isInitialized,
+ deleteProject,
+ createNewProject,
+ getFilteredAndSortedProjects,
+ } = useProjectStore();
+ const [thumbnailCache, setThumbnailCache] = useState<
+ Record<string, string | null>
+ >({});
+ const [_loadingThumbnails, setLoadingThumbnails] = useState<Set<string>>(
+ new Set(),
+ );
+ const [isSelectionMode, setIsSelectionMode] = useState(false);
+ const [selectedProjects, setSelectedProjects] = useState<Set<string>>(
+ new Set(),
+ );
+ const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false);
+ const [searchQuery, setSearchQuery] = useState("");
+ const [sortOption, setSortOption] = useState("createdAt-desc");
+ const router = useRouter();
+
+ const getProjectThumbnail = useCallback(
+ async (projectId: string): Promise<string | null> => {
+ if (thumbnailCache[projectId] !== undefined) {
+ return thumbnailCache[projectId];
+ }
+
+ setLoadingThumbnails((prev) => new Set(prev).add(projectId));
- const handleCreateProject = async () => {
- const projectId = await createNewProject("New Project");
- console.log("projectId", projectId);
- router.push(`/editor/${projectId}`);
- };
+ try {
+ const thumbnail = await useTimelineStore
+ .getState()
+ .getProjectThumbnail(projectId);
+ setThumbnailCache((prev) => ({ ...prev, [projectId]: thumbnail }));
+ return thumbnail;
+ } finally {
+ setLoadingThumbnails((prev) => {
+ const newSet = new Set(prev);
+ newSet.delete(projectId);
+ return newSet;
+ });
+ }
+ },
+ [thumbnailCache],
+ );
- const handleSelectProject = (projectId: string, checked: boolean) => {
- const newSelected = new Set(selectedProjects);
- if (checked) {
- newSelected.add(projectId);
- } else {
- newSelected.delete(projectId);
- }
- setSelectedProjects(newSelected);
- };
+ const handleCreateProject = async () => {
+ const projectId = await createNewProject("New Project");
+ console.log("projectId", projectId);
+ router.push(`/editor/${projectId}`);
+ };
- const handleSelectAll = (checked: boolean) => {
- if (checked) {
- setSelectedProjects(new Set(sortedProjects.map((p) => p.id)));
- } else {
- setSelectedProjects(new Set());
- }
- };
+ const handleSelectProject = (projectId: string, checked: boolean) => {
+ const newSelected = new Set(selectedProjects);
+ if (checked) {
+ newSelected.add(projectId);
+ } else {
+ newSelected.delete(projectId);
+ }
+ setSelectedProjects(newSelected);
+ };
- const handleCancelSelection = () => {
- setIsSelectionMode(false);
- setSelectedProjects(new Set());
- };
+ const handleSelectAll = (checked: boolean) => {
+ if (checked) {
+ setSelectedProjects(new Set(sortedProjects.map((p) => p.id)));
+ } else {
+ setSelectedProjects(new Set());
+ }
+ };
- const handleBulkDelete = async () => {
- await Promise.all(
- Array.from(selectedProjects).map((projectId) => deleteProject(projectId))
- );
- setSelectedProjects(new Set());
- setIsSelectionMode(false);
- setIsBulkDeleteDialogOpen(false);
- };
+ const handleCancelSelection = () => {
+ setIsSelectionMode(false);
+ setSelectedProjects(new Set());
+ };
- const sortedProjects = getFilteredAndSortedProjects(searchQuery, sortOption);
+ const handleBulkDelete = async () => {
+ await Promise.all(
+ Array.from(selectedProjects).map((projectId) => deleteProject(projectId)),
+ );
+ setSelectedProjects(new Set());
+ setIsSelectionMode(false);
+ setIsBulkDeleteDialogOpen(false);
+ };
- const allSelected =
- sortedProjects.length > 0 &&
- selectedProjects.size === sortedProjects.length;
- const someSelected =
- selectedProjects.size > 0 && selectedProjects.size < sortedProjects.length;
+ const sortedProjects = getFilteredAndSortedProjects(searchQuery, sortOption);
- 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">
- {isSelectionMode ? (
- <div className="flex items-center gap-2">
- <Button
- variant="outline"
- size="sm"
- onClick={handleCancelSelection}
- >
- <X className="!size-4" />
- Cancel
- </Button>
- {selectedProjects.size > 0 && (
- <Button
- variant="destructive"
- size="sm"
- onClick={() => setIsBulkDeleteDialogOpen(true)}
- >
- <Trash2 className="!size-4" />
- Delete ({selectedProjects.size})
- </Button>
- )}
- </div>
- ) : (
- <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"}
- {isSelectionMode && selectedProjects.size > 0 && (
- <span className="ml-2 text-primary">
- • {selectedProjects.size} selected
- </span>
- )}
- </p>
- </div>
- <div className="hidden md:block">
- {isSelectionMode ? (
- <div className="flex items-center gap-2">
- <Button variant="outline" onClick={handleCancelSelection}>
- <X className="!size-4" />
- Cancel
- </Button>
- {selectedProjects.size > 0 && (
- <Button
- variant="destructive"
- onClick={() => setIsBulkDeleteDialogOpen(true)}
- >
- <Trash2 className="!size-4" />
- Delete Selected ({selectedProjects.size})
- </Button>
- )}
- </div>
- ) : (
- <div className="flex items-center gap-2">
- <Button
- variant="outline"
- onClick={() => setIsSelectionMode(true)}
- disabled={savedProjects.length === 0}
- >
- Select Projects
- </Button>
- <CreateButton onClick={handleCreateProject} />
- </div>
- )}
- </div>
- </div>
+ const allSelected =
+ sortedProjects.length > 0 &&
+ selectedProjects.size === sortedProjects.length;
+ const someSelected =
+ selectedProjects.size > 0 && selectedProjects.size < sortedProjects.length;
- <div className="mb-4 flex items-center justify-between gap-4">
- <div className="flex-1 max-w-72">
- <Input
- placeholder="Search projects..."
- value={searchQuery}
- onChange={(e) => setSearchQuery(e.target.value)}
- />
- </div>
- <Select value={sortOption} onValueChange={setSortOption}>
- <SelectTrigger className="w-[180px]">
- <SelectValue placeholder="Sort by" />
- </SelectTrigger>
- <SelectContent>
- <SelectItem value="createdAt-desc">Newest to Oldest</SelectItem>
- <SelectItem value="createdAt-asc">Oldest to Newest</SelectItem>
- <SelectItem value="name-asc">Name (A-Z)</SelectItem>
- <SelectItem value="name-desc">Name (Z-A)</SelectItem>
- </SelectContent>
- </Select>
- </div>
+ 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">
+ {isSelectionMode ? (
+ <div className="flex items-center gap-2">
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleCancelSelection}
+ >
+ <X className="!size-4" />
+ Cancel
+ </Button>
+ {selectedProjects.size > 0 && (
+ <Button
+ variant="destructive"
+ size="sm"
+ onClick={() => setIsBulkDeleteDialogOpen(true)}
+ >
+ <Trash2 className="!size-4" />
+ Delete ({selectedProjects.size})
+ </Button>
+ )}
+ </div>
+ ) : (
+ <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"}
+ {isSelectionMode && selectedProjects.size > 0 && (
+ <span className="ml-2 text-primary">
+ • {selectedProjects.size} selected
+ </span>
+ )}
+ </p>
+ </div>
+ <div className="hidden md:block">
+ {isSelectionMode ? (
+ <div className="flex items-center gap-2">
+ <Button variant="outline" onClick={handleCancelSelection}>
+ <X className="!size-4" />
+ Cancel
+ </Button>
+ {selectedProjects.size > 0 && (
+ <Button
+ variant="destructive"
+ onClick={() => setIsBulkDeleteDialogOpen(true)}
+ >
+ <Trash2 className="!size-4" />
+ Delete Selected ({selectedProjects.size})
+ </Button>
+ )}
+ </div>
+ ) : (
+ <div className="flex items-center gap-2">
+ <Button
+ variant="outline"
+ onClick={() => setIsSelectionMode(true)}
+ disabled={savedProjects.length === 0}
+ >
+ Select Projects
+ </Button>
+ <CreateButton onClick={handleCreateProject} />
+ </div>
+ )}
+ </div>
+ </div>
- {isSelectionMode && sortedProjects.length > 0 && (
- <div className="mb-6 p-4 bg-muted/30 rounded-lg border">
- <div className="flex items-center gap-3">
- <Checkbox
- checked={someSelected ? "indeterminate" : allSelected}
- onCheckedChange={(value) => handleSelectAll(!!value)}
- />
- <span className="text-sm font-medium">
- {allSelected ? "Deselect All" : "Select All"}
- </span>
- <span className="text-sm text-muted-foreground">
- ({selectedProjects.size} of {sortedProjects.length} selected)
- </span>
- </div>
- </div>
- )}
+ <div className="mb-4 flex items-center justify-between gap-4">
+ <div className="flex-1 max-w-72">
+ <Input
+ placeholder="Search projects..."
+ value={searchQuery}
+ onChange={(e) => setSearchQuery(e.target.value)}
+ />
+ </div>
+ <Select value={sortOption} onValueChange={setSortOption}>
+ <SelectTrigger className="w-[180px]">
+ <SelectValue placeholder="Sort by" />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="createdAt-desc">Newest to Oldest</SelectItem>
+ <SelectItem value="createdAt-asc">Oldest to Newest</SelectItem>
+ <SelectItem value="name-asc">Name (A-Z)</SelectItem>
+ <SelectItem value="name-desc">Name (Z-A)</SelectItem>
+ </SelectContent>
+ </Select>
+ </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} />
- ) : sortedProjects.length === 0 ? (
- <NoResults
- searchQuery={searchQuery}
- onClearSearch={() => setSearchQuery("")}
- />
- ) : (
- <div className="grid grid-cols-1 xs:grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-6">
- {sortedProjects.map((project) => (
- <ProjectCard
- key={project.id}
- project={project}
- isSelectionMode={isSelectionMode}
- isSelected={selectedProjects.has(project.id)}
- onSelect={handleSelectProject}
- />
- ))}
- </div>
- )}
- </main>
+ {isSelectionMode && sortedProjects.length > 0 && (
+ <button
+ type="button"
+ onClick={() => handleSelectAll(!allSelected)}
+ onKeyDown={(e) => {
+ if (e.key === "Enter" || e.key === " ") {
+ e.preventDefault();
+ handleSelectAll(!allSelected);
+ }
+ }}
+ className="w-full hover:cursor-pointer gap-2 mb-6 p-4 bg-muted/30 rounded-lg border items-center flex"
+ tabIndex={0}
+ >
+ <Checkbox checked={someSelected ? "indeterminate" : allSelected} />
+ <span className="text-sm font-medium">
+ {allSelected ? "Deselect All" : "Select All"}
+ </span>
+ <span className="text-sm text-muted-foreground">
+ ({selectedProjects.size} of {sortedProjects.length} selected)
+ </span>
+ </button>
+ )}
- <DeleteProjectDialog
- isOpen={isBulkDeleteDialogOpen}
- onOpenChange={setIsBulkDeleteDialogOpen}
- onConfirm={handleBulkDelete}
- />
- </div>
- );
+ {isLoading || !isInitialized ? (
+ <div className="grid grid-cols-1 xs:grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-6">
+ {Array.from({ length: 8 }, (_, index) => (
+ <div
+ key={`skeleton-${index}-${Date.now()}`}
+ className="overflow-hidden bg-background border-none p-0"
+ >
+ <Skeleton className="aspect-square w-full bg-muted/50" />
+ <div className="px-0 pt-5 flex flex-col gap-1">
+ <Skeleton className="h-4 w-3/4 bg-muted/50" />
+ <div className="flex items-center gap-1.5">
+ <Skeleton className="h-4 w-4 bg-muted/50" />
+ <Skeleton className="h-4 w-24 bg-muted/50" />
+ </div>
+ </div>
+ </div>
+ ))}
+ </div>
+ ) : savedProjects.length === 0 ? (
+ <NoProjects onCreateProject={handleCreateProject} />
+ ) : sortedProjects.length === 0 ? (
+ <NoResults
+ searchQuery={searchQuery}
+ onClearSearch={() => setSearchQuery("")}
+ />
+ ) : (
+ <div className="grid grid-cols-1 xs:grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-6">
+ {sortedProjects.map((project) => (
+ <ProjectCard
+ key={project.id}
+ project={project}
+ isSelectionMode={isSelectionMode}
+ isSelected={selectedProjects.has(project.id)}
+ onSelect={handleSelectProject}
+ getProjectThumbnail={getProjectThumbnail}
+ />
+ ))}
+ </div>
+ )}
+ </main>
+
+ <DeleteProjectDialog
+ isOpen={isBulkDeleteDialogOpen}
+ onOpenChange={setIsBulkDeleteDialogOpen}
+ onConfirm={handleBulkDelete}
+ />
+ </div>
+ );
}
interface ProjectCardProps {
- project: TProject;
- isSelectionMode?: boolean;
- isSelected?: boolean;
- onSelect?: (projectId: string, checked: boolean) => void;
+ project: TProject;
+ isSelectionMode?: boolean;
+ isSelected?: boolean;
+ onSelect?: (projectId: string, checked: boolean) => void;
+ getProjectThumbnail: (projectId: string) => Promise<string | null>;
}
function ProjectCard({
- project,
- isSelectionMode = false,
- isSelected = false,
- onSelect,
+ project,
+ isSelectionMode = false,
+ isSelected = false,
+ onSelect,
+ getProjectThumbnail,
}: ProjectCardProps) {
- const [isDropdownOpen, setIsDropdownOpen] = useState(false);
- const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
- const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false);
- const { deleteProject, renameProject, duplicateProject } = useProjectStore();
+ const [isDropdownOpen, setIsDropdownOpen] = useState(false);
+ const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
+ const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false);
+ const [dynamicThumbnail, setDynamicThumbnail] = useState<string | null>(null);
+ const [isLoadingThumbnail, setIsLoadingThumbnail] = useState(true);
+ const { deleteProject, renameProject, duplicateProject } = useProjectStore();
+
+ useEffect(() => {
+ const loadThumbnail = async () => {
+ setIsLoadingThumbnail(true);
+ try {
+ const thumbnail = await getProjectThumbnail(project.id);
+ setDynamicThumbnail(thumbnail);
+ } finally {
+ setIsLoadingThumbnail(false);
+ }
+ };
+ loadThumbnail();
+ }, [project.id, getProjectThumbnail]);
- const formatDate = (date: Date): string => {
- return date.toLocaleDateString("en-US", {
- month: "short",
- day: "numeric",
- year: "numeric",
- });
- };
+ 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 handleDeleteProject = async () => {
+ await deleteProject(project.id);
+ setIsDropdownOpen(false);
+ };
- const handleRenameProject = async (newName: string) => {
- await renameProject(project.id, newName);
- setIsRenameDialogOpen(false);
- };
+ const handleRenameProject = async (newName: string) => {
+ await renameProject(project.id, newName);
+ setIsRenameDialogOpen(false);
+ };
- const handleDuplicateProject = async () => {
- setIsDropdownOpen(false);
- await duplicateProject(project.id);
- };
+ const handleDuplicateProject = async () => {
+ setIsDropdownOpen(false);
+ await duplicateProject(project.id);
+ };
- const handleCardClick = (e: React.MouseEvent) => {
- if (isSelectionMode) {
- e.preventDefault();
- onSelect?.(project.id, !isSelected);
- }
- };
+ const handleCardClick = (e: React.MouseEvent) => {
+ if (isSelectionMode) {
+ e.preventDefault();
+ onSelect?.(project.id, !isSelected);
+ }
+ };
- return (
- <>
- {isSelectionMode ? (
- <div onClick={handleCardClick} className="block group cursor-pointer">
- <Card
- className={`overflow-hidden bg-background border-none p-0 transition-all ${
- isSelectionMode && isSelected ? "ring-2 ring-primary" : ""
- }`}
- >
- <div
- className={`relative aspect-square bg-muted transition-opacity ${
- isDropdownOpen
- ? "opacity-65"
- : "opacity-100 group-hover:opacity-65"
- }`}
- >
- {/* Selection checkbox */}
- {isSelectionMode && (
- <div className="absolute top-3 left-3 z-10">
- <div className="w-5 h-5 rounded bg-background/80 backdrop-blur-sm border flex items-center justify-center">
- <Checkbox
- checked={isSelected}
- onCheckedChange={(checked) =>
- onSelect?.(project.id, checked as boolean)
- }
- onClick={(e) => e.stopPropagation()}
- className="w-4 h-4"
- />
- </div>
- </div>
- )}
+ const handleCardKeyDown = (e: React.KeyboardEvent) => {
+ if (isSelectionMode && (e.key === "Enter" || e.key === " ")) {
+ e.preventDefault();
+ onSelect?.(project.id, !isSelected);
+ }
+ };
- {/* 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>
+ const cardContent = (
+ <Card
+ className={`overflow-hidden bg-background border-none p-0 transition-all ${
+ isSelectionMode && isSelected ? "ring-2 ring-primary" : ""
+ }`}
+ >
+ <div
+ className={`relative aspect-square bg-muted transition-opacity ${
+ isDropdownOpen ? "opacity-65" : "opacity-100 group-hover:opacity-65"
+ }`}
+ >
+ {isSelectionMode && (
+ <div className="absolute top-3 left-3 z-10">
+ <div className="w-5 h-5 rounded bg-background/80 backdrop-blur-sm border flex items-center justify-center">
+ <Checkbox
+ checked={isSelected}
+ onCheckedChange={(checked) =>
+ onSelect?.(project.id, checked as boolean)
+ }
+ onClick={(e) => e.stopPropagation()}
+ className="w-4 h-4"
+ />
+ </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>
- {!isSelectionMode && (
- <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="absolute inset-0">
+ {isLoadingThumbnail ? (
+ <div className="w-full h-full bg-muted/50 flex items-center justify-center">
+ <Loader2 className="h-12 w-12 text-muted-foreground animate-spin" />
+ </div>
+ ) : dynamicThumbnail ? (
+ <Image
+ src={dynamicThumbnail}
+ 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>
- <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>
- </div>
- ) : (
- <Link href={`/editor/${project.id}`} className="block group">
- <Card
- className={`overflow-hidden bg-background border-none p-0 transition-all ${
- isSelectionMode && isSelected ? "ring-2 ring-primary" : ""
- }`}
- >
- <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>
+ {!isSelectionMode && (
+ <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>
- <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>
+ );
- <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}
- projectName={project.name}
- />
- <RenameProjectDialog
- isOpen={isRenameDialogOpen}
- onOpenChange={setIsRenameDialogOpen}
- onConfirm={handleRenameProject}
- projectName={project.name}
- />
- </>
- );
+ return (
+ <>
+ {isSelectionMode ? (
+ <button
+ type="button"
+ onClick={handleCardClick}
+ onKeyDown={handleCardKeyDown}
+ className="block group cursor-pointer w-full text-left"
+ >
+ {cardContent}
+ </button>
+ ) : (
+ <Link href={`/editor/${project.id}`} className="block group">
+ {cardContent}
+ </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>
- );
+ 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>
- );
+ 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>
+ );
}
function NoResults({
- searchQuery,
- onClearSearch,
+ searchQuery,
+ onClearSearch,
}: {
- searchQuery: string;
- onClearSearch: () => void;
+ searchQuery: string;
+ onClearSearch: () => 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">
- <Search className="h-8 w-8 text-muted-foreground" />
- </div>
- <h3 className="text-lg font-medium mb-2">No results found</h3>
- <p className="text-muted-foreground mb-6 max-w-md">
- Your search for "{searchQuery}" did not return any results.
- </p>
- <Button onClick={onClearSearch} variant="outline">
- Clear Search
- </Button>
- </div>
- );
+ 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">
+ <Search className="h-8 w-8 text-muted-foreground" />
+ </div>
+ <h3 className="text-lg font-medium mb-2">No results found</h3>
+ <p className="text-muted-foreground mb-6 max-w-md">
+ Your search for "{searchQuery}" did not return any results.
+ </p>
+ <Button onClick={onClearSearch} variant="outline">
+ Clear Search
+ </Button>
+ </div>
+ );
}
diff --git a/apps/web/src/stores/timeline-store.ts b/apps/web/src/stores/timeline-store.ts
index 1fcfc60e..ca4da916 100644
--- a/apps/web/src/stores/timeline-store.ts
+++ b/apps/web/src/stores/timeline-store.ts
@@ -1,1418 +1,1467 @@
+import { toast } from "sonner";
import { create } from "zustand";
+import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
+import { storageService } from "@/lib/storage/storage-service";
+import { checkElementOverlaps, resolveElementOverlaps } from "@/lib/timeline";
+import { generateUUID } from "@/lib/utils";
import {
- TrackType,
- TimelineElement,
- CreateTimelineElement,
- TimelineTrack,
- TextElement,
- DragData,
- sortTracksByOrder,
- ensureMainTrack,
- validateElementTrackCompatibility,
+ type CreateTimelineElement,
+ type DragData,
+ ensureMainTrack,
+ sortTracksByOrder,
+ type TextElement,
+ type TimelineElement,
+ type TimelineTrack,
+ type TrackType,
+ validateElementTrackCompatibility,
} from "@/types/timeline";
import { useEditorStore } from "./editor-store";
import {
- useMediaStore,
- getMediaAspectRatio,
- type MediaItem,
+ getMediaAspectRatio,
+ type MediaItem,
+ type MediaType,
+ useMediaStore,
} from "./media-store";
-import { storageService } from "@/lib/storage/storage-service";
import { useProjectStore } from "./project-store";
-import { generateUUID } from "@/lib/utils";
-import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
-import { toast } from "sonner";
-import { checkElementOverlaps, resolveElementOverlaps } from "@/lib/timeline";
// Helper function to manage element naming with suffixes
const getElementNameWithSuffix = (
- originalName: string,
- suffix: string
+ originalName: string,
+ suffix: string,
): string => {
- // Remove existing suffixes to prevent accumulation
- const baseName = originalName
- .replace(/ \(left\)$/, "")
- .replace(/ \(right\)$/, "")
- .replace(/ \(audio\)$/, "")
- .replace(/ \(split \d+\)$/, "");
+ // Remove existing suffixes to prevent accumulation
+ const baseName = originalName
+ .replace(/ \(left\)$/, "")
+ .replace(/ \(right\)$/, "")
+ .replace(/ \(audio\)$/, "")
+ .replace(/ \(split \d+\)$/, "");
- return `${baseName} (${suffix})`;
+ return `${baseName} (${suffix})`;
};
interface TimelineStore {
- // Private track storage
- _tracks: TimelineTrack[];
- history: TimelineTrack[][];
- redoStack: TimelineTrack[][];
+ // Private track storage
+ _tracks: TimelineTrack[];
+ history: TimelineTrack[][];
+ redoStack: TimelineTrack[][];
- // Always returns properly ordered tracks with main track ensured
- tracks: TimelineTrack[];
+ // Always returns properly ordered tracks with main track ensured
+ tracks: TimelineTrack[];
- // Manual method if you need to force recomputation
- getSortedTracks: () => TimelineTrack[];
+ // Manual method if you need to force recomputation
+ getSortedTracks: () => TimelineTrack[];
- // Snapping settings
- snappingEnabled: boolean;
+ // Snapping settings
+ snappingEnabled: boolean;
- // Snapping actions
- toggleSnapping: () => void;
+ // Snapping actions
+ toggleSnapping: () => void;
- // Ripple editing mode
- rippleEditingEnabled: boolean;
- toggleRippleEditing: () => void;
+ // Ripple editing mode
+ rippleEditingEnabled: boolean;
+ toggleRippleEditing: () => void;
- // Multi-selection
- selectedElements: { trackId: string; elementId: string }[];
- selectElement: (trackId: string, elementId: string, multi?: boolean) => void;
- deselectElement: (trackId: string, elementId: string) => void;
- clearSelectedElements: () => void;
- setSelectedElements: (
- elements: { trackId: string; elementId: string }[]
- ) => void;
+ // Multi-selection
+ selectedElements: { trackId: string; elementId: string }[];
+ selectElement: (trackId: string, elementId: string, multi?: boolean) => void;
+ deselectElement: (trackId: string, elementId: string) => void;
+ clearSelectedElements: () => void;
+ setSelectedElements: (
+ elements: { trackId: string; elementId: string }[],
+ ) => void;
- // Drag state
- dragState: {
- isDragging: boolean;
- elementId: string | null;
- trackId: string | null;
- startMouseX: number;
- startElementTime: number;
- clickOffsetTime: number;
- currentTime: number;
- };
- setDragState: (dragState: Partial<TimelineStore["dragState"]>) => void;
- startDrag: (
- elementId: string,
- trackId: string,
- startMouseX: number,
- startElementTime: number,
- clickOffsetTime: number
- ) => void;
- updateDragTime: (currentTime: number) => void;
- endDrag: () => void;
+ // Drag state
+ dragState: {
+ isDragging: boolean;
+ elementId: string | null;
+ trackId: string | null;
+ startMouseX: number;
+ startElementTime: number;
+ clickOffsetTime: number;
+ currentTime: number;
+ };
+ setDragState: (dragState: Partial<TimelineStore["dragState"]>) => void;
+ startDrag: (
+ elementId: string,
+ trackId: string,
+ startMouseX: number,
+ startElementTime: number,
+ clickOffsetTime: number,
+ ) => void;
+ updateDragTime: (currentTime: number) => void;
+ endDrag: () => void;
- // Actions
- addTrack: (type: TrackType) => string;
- insertTrackAt: (type: TrackType, index: number) => string;
- removeTrack: (trackId: string) => void;
- removeTrackWithRipple: (trackId: string) => void;
- addElementToTrack: (trackId: string, element: CreateTimelineElement) => void;
- removeElementFromTrack: (trackId: string, elementId: string) => void;
- moveElementToTrack: (
- fromTrackId: string,
- toTrackId: string,
- elementId: string
- ) => void;
- updateElementTrim: (
- trackId: string,
- elementId: string,
- trimStart: number,
- trimEnd: number
- ) => void;
- updateElementDuration: (
- trackId: string,
- elementId: string,
- duration: number
- ) => void;
- updateElementStartTime: (
- trackId: string,
- elementId: string,
- startTime: number
- ) => void;
- toggleTrackMute: (trackId: string) => void;
+ // Actions
+ addTrack: (type: TrackType) => string;
+ insertTrackAt: (type: TrackType, index: number) => string;
+ removeTrack: (trackId: string) => void;
+ removeTrackWithRipple: (trackId: string) => void;
+ addElementToTrack: (trackId: string, element: CreateTimelineElement) => void;
+ removeElementFromTrack: (trackId: string, elementId: string) => void;
+ moveElementToTrack: (
+ fromTrackId: string,
+ toTrackId: string,
+ elementId: string,
+ ) => void;
+ updateElementTrim: (
+ trackId: string,
+ elementId: string,
+ trimStart: number,
+ trimEnd: number,
+ ) => void;
+ updateElementDuration: (
+ trackId: string,
+ elementId: string,
+ duration: number,
+ ) => void;
+ updateElementStartTime: (
+ trackId: string,
+ elementId: string,
+ startTime: number,
+ ) => void;
+ toggleTrackMute: (trackId: string) => void;
- // Split operations for elements
- splitElement: (
- trackId: string,
- elementId: string,
- splitTime: number
- ) => string | null;
- splitAndKeepLeft: (
- trackId: string,
- elementId: string,
- splitTime: number
- ) => void;
- splitAndKeepRight: (
- trackId: string,
- elementId: string,
- splitTime: number
- ) => void;
- separateAudio: (trackId: string, elementId: string) => string | null;
+ // Split operations for elements
+ splitElement: (
+ trackId: string,
+ elementId: string,
+ splitTime: number,
+ ) => string | null;
+ splitAndKeepLeft: (
+ trackId: string,
+ elementId: string,
+ splitTime: number,
+ ) => void;
+ splitAndKeepRight: (
+ trackId: string,
+ elementId: string,
+ splitTime: number,
+ ) => void;
+ separateAudio: (trackId: string, elementId: string) => string | null;
- // Replace media for an element
- replaceElementMedia: (
- trackId: string,
- elementId: string,
- newFile: File
- ) => Promise<boolean>;
+ // Replace media for an element
+ replaceElementMedia: (
+ trackId: string,
+ elementId: string,
+ newFile: File,
+ ) => Promise<boolean>;
- // Ripple editing functions
- updateElementStartTimeWithRipple: (
- trackId: string,
- elementId: string,
- newStartTime: number
- ) => void;
- removeElementFromTrackWithRipple: (
- trackId: string,
- elementId: string
- ) => void;
+ // Ripple editing functions
+ updateElementStartTimeWithRipple: (
+ trackId: string,
+ elementId: string,
+ newStartTime: number,
+ ) => void;
+ removeElementFromTrackWithRipple: (
+ trackId: string,
+ elementId: string,
+ ) => void;
- // Computed values
- getTotalDuration: () => number;
+ // Computed values
+ getTotalDuration: () => number;
+ getProjectThumbnail: (projectId: string) => Promise<string | null>;
- // History actions
- undo: () => void;
- redo: () => void;
- pushHistory: () => void;
+ // History actions
+ undo: () => void;
+ redo: () => void;
+ pushHistory: () => void;
- // Persistence actions
- loadProjectTimeline: (projectId: string) => Promise<void>;
- saveProjectTimeline: (projectId: string) => Promise<void>;
- clearTimeline: () => void;
- updateTextElement: (
- trackId: string,
- elementId: string,
- updates: Partial<
- Pick<
- TextElement,
- | "content"
- | "fontSize"
- | "fontFamily"
- | "color"
- | "backgroundColor"
- | "textAlign"
- | "fontWeight"
- | "fontStyle"
- | "textDecoration"
- | "x"
- | "y"
- | "rotation"
- | "opacity"
- >
- >
- ) => void;
- checkElementOverlap: (
- trackId: string,
- startTime: number,
- duration: number,
- excludeElementId?: string
- ) => boolean;
- findOrCreateTrack: (trackType: TrackType) => string;
- addMediaAtTime: (item: MediaItem, currentTime?: number) => boolean;
- addTextAtTime: (item: TextElement, currentTime?: number) => boolean;
- addMediaToNewTrack: (item: MediaItem) => boolean;
- addTextToNewTrack: (item: TextElement | DragData) => boolean;
+ // Persistence actions
+ loadProjectTimeline: (projectId: string) => Promise<void>;
+ saveProjectTimeline: (projectId: string) => Promise<void>;
+ clearTimeline: () => void;
+ updateTextElement: (
+ trackId: string,
+ elementId: string,
+ updates: Partial<
+ Pick<
+ TextElement,
+ | "content"
+ | "fontSize"
+ | "fontFamily"
+ | "color"
+ | "backgroundColor"
+ | "textAlign"
+ | "fontWeight"
+ | "fontStyle"
+ | "textDecoration"
+ | "x"
+ | "y"
+ | "rotation"
+ | "opacity"
+ >
+ >,
+ ) => void;
+ checkElementOverlap: (
+ trackId: string,
+ startTime: number,
+ duration: number,
+ excludeElementId?: string,
+ ) => boolean;
+ findOrCreateTrack: (trackType: TrackType) => string;
+ addMediaAtTime: (item: MediaItem, currentTime?: number) => boolean;
+ addTextAtTime: (item: TextElement, currentTime?: number) => boolean;
+ addMediaToNewTrack: (item: MediaItem) => boolean;
+ addTextToNewTrack: (item: TextElement | DragData) => boolean;
}
export const useTimelineStore = create<TimelineStore>((set, get) => {
- // Helper to update tracks and maintain ordering
- const updateTracks = (newTracks: TimelineTrack[]) => {
- const tracksWithMain = ensureMainTrack(newTracks);
- const sortedTracks = sortTracksByOrder(tracksWithMain);
- set({
- _tracks: tracksWithMain,
- tracks: sortedTracks,
- });
- };
+ // Helper to update tracks and maintain ordering
+ const updateTracks = (newTracks: TimelineTrack[]) => {
+ const tracksWithMain = ensureMainTrack(newTracks);
+ const sortedTracks = sortTracksByOrder(tracksWithMain);
+ set({
+ _tracks: tracksWithMain,
+ tracks: sortedTracks,
+ });
+ };
+
+ // Helper to auto-save timeline changes
+ const autoSaveTimeline = async () => {
+ const activeProject = useProjectStore.getState().activeProject;
+ if (activeProject) {
+ try {
+ await storageService.saveTimeline(activeProject.id, get()._tracks);
+ } catch (error) {
+ console.error("Failed to auto-save timeline:", error);
+ }
+ }
+ };
+
+ // Helper to update tracks and auto-save
+ const updateTracksAndSave = (newTracks: TimelineTrack[]) => {
+ updateTracks(newTracks);
+ // Auto-save in background
+ setTimeout(autoSaveTimeline, 100);
+ };
+
+ // Initialize with proper track ordering
+ const initialTracks = ensureMainTrack([]);
+ const sortedInitialTracks = sortTracksByOrder(initialTracks);
+
+ return {
+ _tracks: initialTracks,
+ tracks: sortedInitialTracks,
+ history: [],
+ redoStack: [],
+ selectedElements: [],
+ rippleEditingEnabled: false,
+
+ // Snapping settings defaults
+ snappingEnabled: true,
+
+ getSortedTracks: () => {
+ const { _tracks } = get();
+ const tracksWithMain = ensureMainTrack(_tracks);
+ return sortTracksByOrder(tracksWithMain);
+ },
+
+ pushHistory: () => {
+ const { _tracks, history } = get();
+ set({
+ history: [...history, JSON.parse(JSON.stringify(_tracks))],
+ redoStack: [],
+ });
+ },
- // Helper to auto-save timeline changes
- const autoSaveTimeline = async () => {
- const activeProject = useProjectStore.getState().activeProject;
- if (activeProject) {
- try {
- await storageService.saveTimeline(activeProject.id, get()._tracks);
- } catch (error) {
- console.error("Failed to auto-save timeline:", error);
- }
- }
- };
+ undo: () => {
+ const { history, redoStack, _tracks } = get();
+ if (history.length === 0) return;
+ const prev = history[history.length - 1];
+ updateTracksAndSave(prev);
+ set({
+ history: history.slice(0, -1),
+ redoStack: [...redoStack, JSON.parse(JSON.stringify(_tracks))],
+ });
+ },
- // Helper to update tracks and auto-save
- const updateTracksAndSave = (newTracks: TimelineTrack[]) => {
- updateTracks(newTracks);
- // Auto-save in background
- setTimeout(autoSaveTimeline, 100);
- };
+ selectElement: (trackId, elementId, multi = false) => {
+ set((state) => {
+ const exists = state.selectedElements.some(
+ (c) => c.trackId === trackId && c.elementId === elementId,
+ );
+ if (multi) {
+ return exists
+ ? {
+ selectedElements: state.selectedElements.filter(
+ (c) => !(c.trackId === trackId && c.elementId === elementId),
+ ),
+ }
+ : {
+ selectedElements: [
+ ...state.selectedElements,
+ { trackId, elementId },
+ ],
+ };
+ } else {
+ return { selectedElements: [{ trackId, elementId }] };
+ }
+ });
+ },
- // Initialize with proper track ordering
- const initialTracks = ensureMainTrack([]);
- const sortedInitialTracks = sortTracksByOrder(initialTracks);
+ deselectElement: (trackId, elementId) => {
+ set((state) => ({
+ selectedElements: state.selectedElements.filter(
+ (c) => !(c.trackId === trackId && c.elementId === elementId),
+ ),
+ }));
+ },
- return {
- _tracks: initialTracks,
- tracks: sortedInitialTracks,
- history: [],
- redoStack: [],
- selectedElements: [],
- rippleEditingEnabled: false,
+ clearSelectedElements: () => {
+ set({ selectedElements: [] });
+ },
- // Snapping settings defaults
- snappingEnabled: true,
+ setSelectedElements: (elements) => set({ selectedElements: elements }),
- getSortedTracks: () => {
- const { _tracks } = get();
- const tracksWithMain = ensureMainTrack(_tracks);
- return sortTracksByOrder(tracksWithMain);
- },
+ addTrack: (type) => {
+ get().pushHistory();
- pushHistory: () => {
- const { _tracks, history } = get();
- set({
- history: [...history, JSON.parse(JSON.stringify(_tracks))],
- redoStack: [],
- });
- },
+ // Generate proper track name based on type
+ const trackName =
+ type === "media"
+ ? "Media Track"
+ : type === "text"
+ ? "Text Track"
+ : type === "audio"
+ ? "Audio Track"
+ : "Track";
- undo: () => {
- const { history, redoStack, _tracks } = get();
- if (history.length === 0) return;
- const prev = history[history.length - 1];
- updateTracksAndSave(prev);
- set({
- history: history.slice(0, -1),
- redoStack: [...redoStack, JSON.parse(JSON.stringify(_tracks))],
- });
- },
+ const newTrack: TimelineTrack = {
+ id: generateUUID(),
+ name: trackName,
+ type,
+ elements: [],
+ muted: false,
+ };
- selectElement: (trackId, elementId, multi = false) => {
- set((state) => {
- const exists = state.selectedElements.some(
- (c) => c.trackId === trackId && c.elementId === elementId
- );
- if (multi) {
- return exists
- ? {
- selectedElements: state.selectedElements.filter(
- (c) => !(c.trackId === trackId && c.elementId === elementId)
- ),
- }
- : {
- selectedElements: [
- ...state.selectedElements,
- { trackId, elementId },
- ],
- };
- } else {
- return { selectedElements: [{ trackId, elementId }] };
- }
- });
- },
+ updateTracksAndSave([...get()._tracks, newTrack]);
+ return newTrack.id;
+ },
- deselectElement: (trackId, elementId) => {
- set((state) => ({
- selectedElements: state.selectedElements.filter(
- (c) => !(c.trackId === trackId && c.elementId === elementId)
- ),
- }));
- },
+ insertTrackAt: (type, index) => {
+ get().pushHistory();
- clearSelectedElements: () => {
- set({ selectedElements: [] });
- },
+ // Generate proper track name based on type
+ const trackName =
+ type === "media"
+ ? "Media Track"
+ : type === "text"
+ ? "Text Track"
+ : type === "audio"
+ ? "Audio Track"
+ : "Track";
- setSelectedElements: (elements) => set({ selectedElements: elements }),
+ const newTrack: TimelineTrack = {
+ id: generateUUID(),
+ name: trackName,
+ type,
+ elements: [],
+ muted: false,
+ };
- addTrack: (type) => {
- get().pushHistory();
+ const newTracks = [...get()._tracks];
+ newTracks.splice(index, 0, newTrack);
+ updateTracksAndSave(newTracks);
+ return newTrack.id;
+ },
- // Generate proper track name based on type
- const trackName =
- type === "media"
- ? "Media Track"
- : type === "text"
- ? "Text Track"
- : type === "audio"
- ? "Audio Track"
- : "Track";
+ removeTrack: (trackId) => {
+ const { rippleEditingEnabled } = get();
- const newTrack: TimelineTrack = {
- id: generateUUID(),
- name: trackName,
- type,
- elements: [],
- muted: false,
- };
+ if (rippleEditingEnabled) {
+ get().removeTrackWithRipple(trackId);
+ } else {
+ get().pushHistory();
+ updateTracksAndSave(
+ get()._tracks.filter((track) => track.id !== trackId),
+ );
+ }
+ },
- updateTracksAndSave([...get()._tracks, newTrack]);
- return newTrack.id;
- },
+ removeTrackWithRipple: (trackId) => {
+ const { _tracks } = get();
+ const trackToRemove = _tracks.find((t) => t.id === trackId);
- insertTrackAt: (type, index) => {
- get().pushHistory();
+ if (!trackToRemove) return;
- // Generate proper track name based on type
- const trackName =
- type === "media"
- ? "Media Track"
- : type === "text"
- ? "Text Track"
- : type === "audio"
- ? "Audio Track"
- : "Track";
+ get().pushHistory();
- const newTrack: TimelineTrack = {
- id: generateUUID(),
- name: trackName,
- type,
- elements: [],
- muted: false,
- };
+ // If track has no elements, just remove it normally
+ if (trackToRemove.elements.length === 0) {
+ updateTracksAndSave(_tracks.filter((track) => track.id !== trackId));
+ return;
+ }
- const newTracks = [...get()._tracks];
- newTracks.splice(index, 0, newTrack);
- updateTracksAndSave(newTracks);
- return newTrack.id;
- },
+ // Find all the time ranges occupied by elements in the track being removed
+ const occupiedRanges = trackToRemove.elements.map((element) => ({
+ startTime: element.startTime,
+ endTime:
+ element.startTime +
+ (element.duration - element.trimStart - element.trimEnd),
+ }));
- removeTrack: (trackId) => {
- const { rippleEditingEnabled } = get();
+ // Sort ranges by start time
+ occupiedRanges.sort((a, b) => a.startTime - b.startTime);
- if (rippleEditingEnabled) {
- get().removeTrackWithRipple(trackId);
- } else {
- get().pushHistory();
- updateTracksAndSave(
- get()._tracks.filter((track) => track.id !== trackId)
- );
- }
- },
+ // Merge overlapping ranges to get consolidated gaps
+ const mergedRanges: Array<{
+ startTime: number;
+ endTime: number;
+ duration: number;
+ }> = [];
- removeTrackWithRipple: (trackId) => {
- const { _tracks } = get();
- const trackToRemove = _tracks.find((t) => t.id === trackId);
+ for (const range of occupiedRanges) {
+ if (mergedRanges.length === 0) {
+ mergedRanges.push({
+ startTime: range.startTime,
+ endTime: range.endTime,
+ duration: range.endTime - range.startTime,
+ });
+ } else {
+ const lastRange = mergedRanges[mergedRanges.length - 1];
+ if (range.startTime <= lastRange.endTime) {
+ // Overlapping or adjacent ranges, merge them
+ lastRange.endTime = Math.max(lastRange.endTime, range.endTime);
+ lastRange.duration = lastRange.endTime - lastRange.startTime;
+ } else {
+ // Non-overlapping range, add as new
+ mergedRanges.push({
+ startTime: range.startTime,
+ endTime: range.endTime,
+ duration: range.endTime - range.startTime,
+ });
+ }
+ }
+ }
- if (!trackToRemove) return;
+ // Remove the track and apply ripple effects to remaining tracks
+ const updatedTracks = _tracks
+ .filter((track) => track.id !== trackId)
+ .map((track) => {
+ const updatedElements = track.elements.map((element) => {
+ let newStartTime = element.startTime;
- get().pushHistory();
+ // Process gaps from right to left (latest to earliest) to avoid cumulative shifts
+ for (let i = mergedRanges.length - 1; i >= 0; i--) {
+ const gap = mergedRanges[i];
+ // If this element starts after the gap, shift it left by the gap duration
+ if (newStartTime >= gap.endTime) {
+ newStartTime -= gap.duration;
+ }
+ }
- // If track has no elements, just remove it normally
- if (trackToRemove.elements.length === 0) {
- updateTracksAndSave(_tracks.filter((track) => track.id !== trackId));
- return;
- }
+ return {
+ ...element,
+ startTime: Math.max(0, newStartTime),
+ };
+ });
- // Find all the time ranges occupied by elements in the track being removed
- const occupiedRanges = trackToRemove.elements.map((element) => ({
- startTime: element.startTime,
- endTime:
- element.startTime +
- (element.duration - element.trimStart - element.trimEnd),
- }));
+ // Check for overlaps and resolve them if necessary
+ const hasOverlaps = checkElementOverlaps(updatedElements);
+ if (hasOverlaps) {
+ const resolvedElements = resolveElementOverlaps(updatedElements);
+ return { ...track, elements: resolvedElements };
+ }
- // Sort ranges by start time
- occupiedRanges.sort((a, b) => a.startTime - b.startTime);
+ return { ...track, elements: updatedElements };
+ });
- // Merge overlapping ranges to get consolidated gaps
- const mergedRanges: Array<{
- startTime: number;
- endTime: number;
- duration: number;
- }> = [];
+ updateTracksAndSave(updatedTracks);
+ },
- for (const range of occupiedRanges) {
- if (mergedRanges.length === 0) {
- mergedRanges.push({
- startTime: range.startTime,
- endTime: range.endTime,
- duration: range.endTime - range.startTime,
- });
- } else {
- const lastRange = mergedRanges[mergedRanges.length - 1];
- if (range.startTime <= lastRange.endTime) {
- // Overlapping or adjacent ranges, merge them
- lastRange.endTime = Math.max(lastRange.endTime, range.endTime);
- lastRange.duration = lastRange.endTime - lastRange.startTime;
- } else {
- // Non-overlapping range, add as new
- mergedRanges.push({
- startTime: range.startTime,
- endTime: range.endTime,
- duration: range.endTime - range.startTime,
- });
- }
- }
- }
+ addElementToTrack: (trackId, elementData) => {
+ get().pushHistory();
- // Remove the track and apply ripple effects to remaining tracks
- const updatedTracks = _tracks
- .filter((track) => track.id !== trackId)
- .map((track) => {
- const updatedElements = track.elements.map((element) => {
- let newStartTime = element.startTime;
+ // Validate element type matches track type
+ const track = get()._tracks.find((t) => t.id === trackId);
+ if (!track) {
+ console.error("Track not found:", trackId);
+ return;
+ }
- // Process gaps from right to left (latest to earliest) to avoid cumulative shifts
- for (let i = mergedRanges.length - 1; i >= 0; i--) {
- const gap = mergedRanges[i];
- // If this element starts after the gap, shift it left by the gap duration
- if (newStartTime >= gap.endTime) {
- newStartTime -= gap.duration;
- }
- }
+ // Use utility function for validation
+ const validation = validateElementTrackCompatibility(elementData, track);
+ if (!validation.isValid) {
+ console.error(validation.errorMessage);
+ return;
+ }
- return {
- ...element,
- startTime: Math.max(0, newStartTime),
- };
- });
+ // For media elements, validate mediaId exists
+ if (elementData.type === "media" && !elementData.mediaId) {
+ console.error("Media element must have mediaId");
+ return;
+ }
- // Check for overlaps and resolve them if necessary
- const hasOverlaps = checkElementOverlaps(updatedElements);
- if (hasOverlaps) {
- const resolvedElements = resolveElementOverlaps(updatedElements);
- return { ...track, elements: resolvedElements };
- }
+ // For text elements, validate required text properties
+ if (elementData.type === "text" && !elementData.content) {
+ console.error("Text element must have content");
+ return;
+ }
- return { ...track, elements: updatedElements };
- });
+ // Check if this is the first element being added to the timeline
+ const currentState = get();
+ const totalElementsInTimeline = currentState._tracks.reduce(
+ (total, track) => total + track.elements.length,
+ 0,
+ );
+ const isFirstElement = totalElementsInTimeline === 0;
- updateTracksAndSave(updatedTracks);
- },
+ const newElement: TimelineElement = {
+ ...elementData,
+ id: generateUUID(),
+ startTime: elementData.startTime || 0,
+ trimStart: 0,
+ trimEnd: 0,
+ } as TimelineElement; // Type assertion since we trust the caller passes valid data
- addElementToTrack: (trackId, elementData) => {
- get().pushHistory();
+ // If this is the first element and it's a media element, automatically set the project canvas size
+ // to match the media's aspect ratio and FPS (for videos)
+ if (isFirstElement && newElement.type === "media") {
+ const mediaStore = useMediaStore.getState();
+ const mediaItem = mediaStore.mediaItems.find(
+ (item) => item.id === newElement.mediaId,
+ );
- // Validate element type matches track type
- const track = get()._tracks.find((t) => t.id === trackId);
- if (!track) {
- console.error("Track not found:", trackId);
- return;
- }
+ if (
+ mediaItem &&
+ (mediaItem.type === "image" || mediaItem.type === "video")
+ ) {
+ const editorStore = useEditorStore.getState();
+ editorStore.setCanvasSizeFromAspectRatio(
+ getMediaAspectRatio(mediaItem),
+ );
+ }
- // Use utility function for validation
- const validation = validateElementTrackCompatibility(elementData, track);
- if (!validation.isValid) {
- console.error(validation.errorMessage);
- return;
- }
+ // Set project FPS from the first video element
+ if (mediaItem && mediaItem.type === "video" && mediaItem.fps) {
+ const projectStore = useProjectStore.getState();
+ if (projectStore.activeProject) {
+ projectStore.updateProjectFps(mediaItem.fps);
+ }
+ }
+ }
- // For media elements, validate mediaId exists
- if (elementData.type === "media" && !elementData.mediaId) {
- console.error("Media element must have mediaId");
- return;
- }
+ updateTracksAndSave(
+ get()._tracks.map((track) =>
+ track.id === trackId
+ ? { ...track, elements: [...track.elements, newElement] }
+ : track,
+ ),
+ );
+ },
- // For text elements, validate required text properties
- if (elementData.type === "text" && !elementData.content) {
- console.error("Text element must have content");
- return;
- }
+ removeElementFromTrack: (trackId, elementId) => {
+ const { rippleEditingEnabled } = get();
- // Check if this is the first element being added to the timeline
- const currentState = get();
- const totalElementsInTimeline = currentState._tracks.reduce(
- (total, track) => total + track.elements.length,
- 0
- );
- const isFirstElement = totalElementsInTimeline === 0;
+ if (rippleEditingEnabled) {
+ get().removeElementFromTrackWithRipple(trackId, elementId);
+ } else {
+ get().pushHistory();
+ updateTracksAndSave(
+ get()
+ ._tracks.map((track) =>
+ track.id === trackId
+ ? {
+ ...track,
+ elements: track.elements.filter(
+ (element) => element.id !== elementId,
+ ),
+ }
+ : track,
+ )
+ .filter((track) => track.elements.length > 0),
+ );
+ }
+ },
- const newElement: TimelineElement = {
- ...elementData,
- id: generateUUID(),
- startTime: elementData.startTime || 0,
- trimStart: 0,
- trimEnd: 0,
- } as TimelineElement; // Type assertion since we trust the caller passes valid data
+ removeElementFromTrackWithRipple: (trackId, elementId) => {
+ const { _tracks, rippleEditingEnabled } = get();
- // If this is the first element and it's a media element, automatically set the project canvas size
- // to match the media's aspect ratio and FPS (for videos)
- if (isFirstElement && newElement.type === "media") {
- const mediaStore = useMediaStore.getState();
- const mediaItem = mediaStore.mediaItems.find(
- (item) => item.id === newElement.mediaId
- );
+ if (!rippleEditingEnabled) {
+ // If ripple editing is disabled, use regular removal
+ get().removeElementFromTrack(trackId, elementId);
+ return;
+ }
- if (
- mediaItem &&
- (mediaItem.type === "image" || mediaItem.type === "video")
- ) {
- const editorStore = useEditorStore.getState();
- editorStore.setCanvasSizeFromAspectRatio(
- getMediaAspectRatio(mediaItem)
- );
- }
+ const track = _tracks.find((t) => t.id === trackId);
+ const element = track?.elements.find((e) => e.id === elementId);
- // Set project FPS from the first video element
- if (mediaItem && mediaItem.type === "video" && mediaItem.fps) {
- const projectStore = useProjectStore.getState();
- if (projectStore.activeProject) {
- projectStore.updateProjectFps(mediaItem.fps);
- }
- }
- }
+ if (!element || !track) return;
- updateTracksAndSave(
- get()._tracks.map((track) =>
- track.id === trackId
- ? { ...track, elements: [...track.elements, newElement] }
- : track
- )
- );
- },
+ get().pushHistory();
- removeElementFromTrack: (trackId, elementId) => {
- const { rippleEditingEnabled } = get();
+ const elementStartTime = element.startTime;
+ const elementDuration =
+ element.duration - element.trimStart - element.trimEnd;
+ const elementEndTime = elementStartTime + elementDuration;
- if (rippleEditingEnabled) {
- get().removeElementFromTrackWithRipple(trackId, elementId);
- } else {
- get().pushHistory();
- updateTracksAndSave(
- get()
- ._tracks.map((track) =>
- track.id === trackId
- ? {
- ...track,
- elements: track.elements.filter(
- (element) => element.id !== elementId
- ),
- }
- : track
- )
- .filter((track) => track.elements.length > 0)
- );
- }
- },
+ // Remove the element and shift all elements that come after it
+ const updatedTracks = _tracks
+ .map((currentTrack) => {
+ // Only apply ripple effects to the same track unless multi-track ripple is enabled
+ const shouldApplyRipple = currentTrack.id === trackId;
- removeElementFromTrackWithRipple: (trackId, elementId) => {
- const { _tracks, rippleEditingEnabled } = get();
+ const updatedElements = currentTrack.elements
+ .filter((currentElement) => {
+ // Remove the target element
+ if (
+ currentElement.id === elementId &&
+ currentTrack.id === trackId
+ ) {
+ return false;
+ }
+ return true;
+ })
+ .map((currentElement) => {
+ // Only apply ripple effects if we should process this track
+ if (!shouldApplyRipple) {
+ return currentElement;
+ }
- if (!rippleEditingEnabled) {
- // If ripple editing is disabled, use regular removal
- get().removeElementFromTrack(trackId, elementId);
- return;
- }
+ // Shift elements that start after the removed element
+ if (currentElement.startTime >= elementEndTime) {
+ return {
+ ...currentElement,
+ startTime: Math.max(
+ 0,
+ currentElement.startTime - elementDuration,
+ ),
+ };
+ }
+ return currentElement;
+ });
- const track = _tracks.find((t) => t.id === trackId);
- const element = track?.elements.find((e) => e.id === elementId);
+ // Check for overlaps and resolve them if necessary
+ const hasOverlaps = checkElementOverlaps(updatedElements);
+ if (hasOverlaps) {
+ // Resolve overlaps by adjusting element positions
+ const resolvedElements = resolveElementOverlaps(updatedElements);
+ return { ...currentTrack, elements: resolvedElements };
+ }
- if (!element || !track) return;
+ return { ...currentTrack, elements: updatedElements };
+ })
+ .filter((track) => track.elements.length > 0 || track.isMain);
- get().pushHistory();
+ updateTracksAndSave(updatedTracks);
+ },
- const elementStartTime = element.startTime;
- const elementDuration =
- element.duration - element.trimStart - element.trimEnd;
- const elementEndTime = elementStartTime + elementDuration;
+ moveElementToTrack: (fromTrackId, toTrackId, elementId) => {
+ get().pushHistory();
- // Remove the element and shift all elements that come after it
- const updatedTracks = _tracks
- .map((currentTrack) => {
- // Only apply ripple effects to the same track unless multi-track ripple is enabled
- const shouldApplyRipple = currentTrack.id === trackId;
+ const fromTrack = get()._tracks.find((track) => track.id === fromTrackId);
+ const toTrack = get()._tracks.find((track) => track.id === toTrackId);
+ const elementToMove = fromTrack?.elements.find(
+ (element) => element.id === elementId,
+ );
- const updatedElements = currentTrack.elements
- .filter((currentElement) => {
- // Remove the target element
- if (
- currentElement.id === elementId &&
- currentTrack.id === trackId
- ) {
- return false;
- }
- return true;
- })
- .map((currentElement) => {
- // Only apply ripple effects if we should process this track
- if (!shouldApplyRipple) {
- return currentElement;
- }
+ if (!elementToMove || !toTrack) return;
- // Shift elements that start after the removed element
- if (currentElement.startTime >= elementEndTime) {
- return {
- ...currentElement,
- startTime: Math.max(
- 0,
- currentElement.startTime - elementDuration
- ),
- };
- }
- return currentElement;
- });
+ // Validate element type compatibility with target track
+ const validation = validateElementTrackCompatibility(
+ elementToMove,
+ toTrack,
+ );
+ if (!validation.isValid) {
+ console.error(validation.errorMessage);
+ return;
+ }
- // Check for overlaps and resolve them if necessary
- const hasOverlaps = checkElementOverlaps(updatedElements);
- if (hasOverlaps) {
- // Resolve overlaps by adjusting element positions
- const resolvedElements = resolveElementOverlaps(updatedElements);
- return { ...currentTrack, elements: resolvedElements };
- }
+ const newTracks = get()
+ ._tracks.map((track) => {
+ if (track.id === fromTrackId) {
+ return {
+ ...track,
+ elements: track.elements.filter(
+ (element) => element.id !== elementId,
+ ),
+ };
+ } else if (track.id === toTrackId) {
+ return {
+ ...track,
+ elements: [...track.elements, elementToMove],
+ };
+ }
+ return track;
+ })
+ .filter((track) => track.elements.length > 0);
- return { ...currentTrack, elements: updatedElements };
- })
- .filter((track) => track.elements.length > 0 || track.isMain);
+ updateTracksAndSave(newTracks);
+ },
- updateTracksAndSave(updatedTracks);
- },
+ updateElementTrim: (trackId, elementId, trimStart, trimEnd) => {
+ get().pushHistory();
+ updateTracksAndSave(
+ get()._tracks.map((track) =>
+ track.id === trackId
+ ? {
+ ...track,
+ elements: track.elements.map((element) =>
+ element.id === elementId
+ ? { ...element, trimStart, trimEnd }
+ : element,
+ ),
+ }
+ : track,
+ ),
+ );
+ },
- moveElementToTrack: (fromTrackId, toTrackId, elementId) => {
- get().pushHistory();
+ updateElementDuration: (trackId, elementId, duration) => {
+ get().pushHistory();
+ updateTracksAndSave(
+ get()._tracks.map((track) =>
+ track.id === trackId
+ ? {
+ ...track,
+ elements: track.elements.map((element) =>
+ element.id === elementId ? { ...element, duration } : element,
+ ),
+ }
+ : track,
+ ),
+ );
+ },
- const fromTrack = get()._tracks.find((track) => track.id === fromTrackId);
- const toTrack = get()._tracks.find((track) => track.id === toTrackId);
- const elementToMove = fromTrack?.elements.find(
- (element) => element.id === elementId
- );
+ updateElementStartTime: (trackId, elementId, startTime) => {
+ get().pushHistory();
+ updateTracksAndSave(
+ get()._tracks.map((track) =>
+ track.id === trackId
+ ? {
+ ...track,
+ elements: track.elements.map((element) =>
+ element.id === elementId
+ ? { ...element, startTime }
+ : element,
+ ),
+ }
+ : track,
+ ),
+ );
+ },
- if (!elementToMove || !toTrack) return;
+ updateElementStartTimeWithRipple: (trackId, elementId, newStartTime) => {
+ const { _tracks, rippleEditingEnabled } = get();
- // Validate element type compatibility with target track
- const validation = validateElementTrackCompatibility(
- elementToMove,
- toTrack
- );
- if (!validation.isValid) {
- console.error(validation.errorMessage);
- return;
- }
+ if (!rippleEditingEnabled) {
+ // If ripple editing is disabled, use regular update
+ get().updateElementStartTime(trackId, elementId, newStartTime);
+ return;
+ }
- const newTracks = get()
- ._tracks.map((track) => {
- if (track.id === fromTrackId) {
- return {
- ...track,
- elements: track.elements.filter(
- (element) => element.id !== elementId
- ),
- };
- } else if (track.id === toTrackId) {
- return {
- ...track,
- elements: [...track.elements, elementToMove],
- };
- }
- return track;
- })
- .filter((track) => track.elements.length > 0);
+ const track = _tracks.find((t) => t.id === trackId);
+ const element = track?.elements.find((e) => e.id === elementId);
- updateTracksAndSave(newTracks);
- },
+ if (!element || !track) return;
- updateElementTrim: (trackId, elementId, trimStart, trimEnd) => {
- get().pushHistory();
- updateTracksAndSave(
- get()._tracks.map((track) =>
- track.id === trackId
- ? {
- ...track,
- elements: track.elements.map((element) =>
- element.id === elementId
- ? { ...element, trimStart, trimEnd }
- : element
- ),
- }
- : track
- )
- );
- },
+ get().pushHistory();
- updateElementDuration: (trackId, elementId, duration) => {
- get().pushHistory();
- updateTracksAndSave(
- get()._tracks.map((track) =>
- track.id === trackId
- ? {
- ...track,
- elements: track.elements.map((element) =>
- element.id === elementId ? { ...element, duration } : element
- ),
- }
- : track
- )
- );
- },
+ const oldStartTime = element.startTime;
+ const oldEndTime =
+ element.startTime +
+ (element.duration - element.trimStart - element.trimEnd);
+ const newEndTime =
+ newStartTime + (element.duration - element.trimStart - element.trimEnd);
+ const timeDelta = newStartTime - oldStartTime;
- updateElementStartTime: (trackId, elementId, startTime) => {
- get().pushHistory();
- updateTracksAndSave(
- get()._tracks.map((track) =>
- track.id === trackId
- ? {
- ...track,
- elements: track.elements.map((element) =>
- element.id === elementId ? { ...element, startTime } : element
- ),
- }
- : track
- )
- );
- },
+ // Update tracks based on multi-track ripple setting
+ const updatedTracks = _tracks.map((currentTrack) => {
+ // Only apply ripple effects to the same track unless multi-track ripple is enabled
+ const shouldApplyRipple = currentTrack.id === trackId;
- updateElementStartTimeWithRipple: (trackId, elementId, newStartTime) => {
- const { _tracks, rippleEditingEnabled } = get();
+ const updatedElements = currentTrack.elements.map((currentElement) => {
+ if (currentElement.id === elementId && currentTrack.id === trackId) {
+ // Update the moved element
+ return { ...currentElement, startTime: newStartTime };
+ }
- if (!rippleEditingEnabled) {
- // If ripple editing is disabled, use regular update
- get().updateElementStartTime(trackId, elementId, newStartTime);
- return;
- }
+ // Only apply ripple effects if we should process this track
+ if (!shouldApplyRipple) {
+ return currentElement;
+ }
- const track = _tracks.find((t) => t.id === trackId);
- const element = track?.elements.find((e) => e.id === elementId);
+ // For ripple editing, we need to move elements that come after the moved element
+ const currentElementStart = currentElement.startTime;
+ const _currentElementEnd =
+ currentElement.startTime +
+ (currentElement.duration -
+ currentElement.trimStart -
+ currentElement.trimEnd);
- if (!element || !track) return;
+ // If moving element to the right (positive delta)
+ if (timeDelta > 0) {
+ // Move elements that start after the original position of the moved element
+ if (currentElementStart >= oldEndTime) {
+ return {
+ ...currentElement,
+ startTime: currentElementStart + timeDelta,
+ };
+ }
+ }
+ // If moving element to the left (negative delta)
+ else if (timeDelta < 0) {
+ // Move elements that start after the new position of the moved element
+ if (
+ currentElementStart >= newEndTime &&
+ currentElementStart >= oldStartTime
+ ) {
+ return {
+ ...currentElement,
+ startTime: Math.max(0, currentElementStart + timeDelta),
+ };
+ }
+ }
- get().pushHistory();
+ return currentElement;
+ });
- const oldStartTime = element.startTime;
- const oldEndTime =
- element.startTime +
- (element.duration - element.trimStart - element.trimEnd);
- const newEndTime =
- newStartTime + (element.duration - element.trimStart - element.trimEnd);
- const timeDelta = newStartTime - oldStartTime;
+ // Check for overlaps and resolve them if necessary
+ const hasOverlaps = checkElementOverlaps(updatedElements);
+ if (hasOverlaps) {
+ // Resolve overlaps by adjusting element positions
+ const resolvedElements = resolveElementOverlaps(updatedElements);
+ return { ...currentTrack, elements: resolvedElements };
+ }
- // Update tracks based on multi-track ripple setting
- const updatedTracks = _tracks.map((currentTrack) => {
- // Only apply ripple effects to the same track unless multi-track ripple is enabled
- const shouldApplyRipple = currentTrack.id === trackId;
+ return { ...currentTrack, elements: updatedElements };
+ });
- const updatedElements = currentTrack.elements.map((currentElement) => {
- if (currentElement.id === elementId && currentTrack.id === trackId) {
- // Update the moved element
- return { ...currentElement, startTime: newStartTime };
- }
+ updateTracksAndSave(updatedTracks);
+ },
- // Only apply ripple effects if we should process this track
- if (!shouldApplyRipple) {
- return currentElement;
- }
+ toggleTrackMute: (trackId) => {
+ get().pushHistory();
+ updateTracksAndSave(
+ get()._tracks.map((track) =>
+ track.id === trackId ? { ...track, muted: !track.muted } : track,
+ ),
+ );
+ },
- // For ripple editing, we need to move elements that come after the moved element
- const currentElementStart = currentElement.startTime;
- const currentElementEnd =
- currentElement.startTime +
- (currentElement.duration -
- currentElement.trimStart -
- currentElement.trimEnd);
+ updateTextElement: (trackId, elementId, updates) => {
+ get().pushHistory();
+ updateTracksAndSave(
+ get()._tracks.map((track) =>
+ track.id === trackId
+ ? {
+ ...track,
+ elements: track.elements.map((element) =>
+ element.id === elementId && element.type === "text"
+ ? { ...element, ...updates }
+ : element,
+ ),
+ }
+ : track,
+ ),
+ );
+ },
- // If moving element to the right (positive delta)
- if (timeDelta > 0) {
- // Move elements that start after the original position of the moved element
- if (currentElementStart >= oldEndTime) {
- return {
- ...currentElement,
- startTime: currentElementStart + timeDelta,
- };
- }
- }
- // If moving element to the left (negative delta)
- else if (timeDelta < 0) {
- // Move elements that start after the new position of the moved element
- if (
- currentElementStart >= newEndTime &&
- currentElementStart >= oldStartTime
- ) {
- return {
- ...currentElement,
- startTime: Math.max(0, currentElementStart + timeDelta),
- };
- }
- }
+ splitElement: (trackId, elementId, splitTime) => {
+ const { _tracks } = get();
+ const track = _tracks.find((t) => t.id === trackId);
+ const element = track?.elements.find((c) => c.id === elementId);
- return currentElement;
- });
+ if (!element) return null;
- // Check for overlaps and resolve them if necessary
- const hasOverlaps = checkElementOverlaps(updatedElements);
- if (hasOverlaps) {
- // Resolve overlaps by adjusting element positions
- const resolvedElements = resolveElementOverlaps(updatedElements);
- return { ...currentTrack, elements: resolvedElements };
- }
+ const effectiveStart = element.startTime;
+ const effectiveEnd =
+ element.startTime +
+ (element.duration - element.trimStart - element.trimEnd);
- return { ...currentTrack, elements: updatedElements };
- });
+ if (splitTime <= effectiveStart || splitTime >= effectiveEnd) return null;
- updateTracksAndSave(updatedTracks);
- },
+ get().pushHistory();
- toggleTrackMute: (trackId) => {
- get().pushHistory();
- updateTracksAndSave(
- get()._tracks.map((track) =>
- track.id === trackId ? { ...track, muted: !track.muted } : track
- )
- );
- },
+ const relativeTime = splitTime - element.startTime;
+ const firstDuration = relativeTime;
+ const secondDuration =
+ element.duration - element.trimStart - element.trimEnd - relativeTime;
- updateTextElement: (trackId, elementId, updates) => {
- get().pushHistory();
- updateTracksAndSave(
- get()._tracks.map((track) =>
- track.id === trackId
- ? {
- ...track,
- elements: track.elements.map((element) =>
- element.id === elementId && element.type === "text"
- ? { ...element, ...updates }
- : element
- ),
- }
- : track
- )
- );
- },
+ const secondElementId = generateUUID();
- splitElement: (trackId, elementId, splitTime) => {
- const { _tracks } = get();
- const track = _tracks.find((t) => t.id === trackId);
- const element = track?.elements.find((c) => c.id === elementId);
+ updateTracksAndSave(
+ get()._tracks.map((track) =>
+ track.id === trackId
+ ? {
+ ...track,
+ elements: track.elements.flatMap((c) =>
+ c.id === elementId
+ ? [
+ {
+ ...c,
+ trimEnd: c.trimEnd + secondDuration,
+ name: getElementNameWithSuffix(c.name, "left"),
+ },
+ {
+ ...c,
+ id: secondElementId,
+ startTime: splitTime,
+ trimStart: c.trimStart + firstDuration,
+ name: getElementNameWithSuffix(c.name, "right"),
+ },
+ ]
+ : [c],
+ ),
+ }
+ : track,
+ ),
+ );
- if (!element) return null;
+ return secondElementId;
+ },
- const effectiveStart = element.startTime;
- const effectiveEnd =
- element.startTime +
- (element.duration - element.trimStart - element.trimEnd);
+ // Split element and keep only the left portion
+ splitAndKeepLeft: (trackId, elementId, splitTime) => {
+ const { _tracks } = get();
+ const track = _tracks.find((t) => t.id === trackId);
+ const element = track?.elements.find((c) => c.id === elementId);
- if (splitTime <= effectiveStart || splitTime >= effectiveEnd) return null;
+ if (!element) return;
- get().pushHistory();
+ const effectiveStart = element.startTime;
+ const effectiveEnd =
+ element.startTime +
+ (element.duration - element.trimStart - element.trimEnd);
- const relativeTime = splitTime - element.startTime;
- const firstDuration = relativeTime;
- const secondDuration =
- element.duration - element.trimStart - element.trimEnd - relativeTime;
+ if (splitTime <= effectiveStart || splitTime >= effectiveEnd) return;
- const secondElementId = generateUUID();
+ get().pushHistory();
- updateTracksAndSave(
- get()._tracks.map((track) =>
- track.id === trackId
- ? {
- ...track,
- elements: track.elements.flatMap((c) =>
- c.id === elementId
- ? [
- {
- ...c,
- trimEnd: c.trimEnd + secondDuration,
- name: getElementNameWithSuffix(c.name, "left"),
- },
- {
- ...c,
- id: secondElementId,
- startTime: splitTime,
- trimStart: c.trimStart + firstDuration,
- name: getElementNameWithSuffix(c.name, "right"),
- },
- ]
- : [c]
- ),
- }
- : track
- )
- );
+ const relativeTime = splitTime - element.startTime;
+ const durationToRemove =
+ element.duration - element.trimStart - element.trimEnd - relativeTime;
- return secondElementId;
- },
+ updateTracksAndSave(
+ get()._tracks.map((track) =>
+ track.id === trackId
+ ? {
+ ...track,
+ elements: track.elements.map((c) =>
+ c.id === elementId
+ ? {
+ ...c,
+ trimEnd: c.trimEnd + durationToRemove,
+ name: getElementNameWithSuffix(c.name, "left"),
+ }
+ : c,
+ ),
+ }
+ : track,
+ ),
+ );
+ },
- // Split element and keep only the left portion
- splitAndKeepLeft: (trackId, elementId, splitTime) => {
- const { _tracks } = get();
- const track = _tracks.find((t) => t.id === trackId);
- const element = track?.elements.find((c) => c.id === elementId);
+ // Split element and keep only the right portion
+ splitAndKeepRight: (trackId, elementId, splitTime) => {
+ const { _tracks } = get();
+ const track = _tracks.find((t) => t.id === trackId);
+ const element = track?.elements.find((c) => c.id === elementId);
- if (!element) return;
+ if (!element) return;
- const effectiveStart = element.startTime;
- const effectiveEnd =
- element.startTime +
- (element.duration - element.trimStart - element.trimEnd);
+ const effectiveStart = element.startTime;
+ const effectiveEnd =
+ element.startTime +
+ (element.duration - element.trimStart - element.trimEnd);
- if (splitTime <= effectiveStart || splitTime >= effectiveEnd) return;
+ if (splitTime <= effectiveStart || splitTime >= effectiveEnd) return;
- get().pushHistory();
+ get().pushHistory();
- const relativeTime = splitTime - element.startTime;
- const durationToRemove =
- element.duration - element.trimStart - element.trimEnd - relativeTime;
+ const relativeTime = splitTime - element.startTime;
- updateTracksAndSave(
- get()._tracks.map((track) =>
- track.id === trackId
- ? {
- ...track,
- elements: track.elements.map((c) =>
- c.id === elementId
- ? {
- ...c,
- trimEnd: c.trimEnd + durationToRemove,
- name: getElementNameWithSuffix(c.name, "left"),
- }
- : c
- ),
- }
- : track
- )
- );
- },
+ updateTracksAndSave(
+ get()._tracks.map((track) =>
+ track.id === trackId
+ ? {
+ ...track,
+ elements: track.elements.map((c) =>
+ c.id === elementId
+ ? {
+ ...c,
+ startTime: splitTime,
+ trimStart: c.trimStart + relativeTime,
+ name: getElementNameWithSuffix(c.name, "right"),
+ }
+ : c,
+ ),
+ }
+ : track,
+ ),
+ );
+ },
- // Split element and keep only the right portion
- splitAndKeepRight: (trackId, elementId, splitTime) => {
- const { _tracks } = get();
- const track = _tracks.find((t) => t.id === trackId);
- const element = track?.elements.find((c) => c.id === elementId);
+ // Extract audio from video element to an audio track
+ separateAudio: (trackId, elementId) => {
+ const { _tracks } = get();
+ const track = _tracks.find((t) => t.id === trackId);
+ const element = track?.elements.find((c) => c.id === elementId);
- if (!element) return;
+ if (!element || track?.type !== "media") return null;
- const effectiveStart = element.startTime;
- const effectiveEnd =
- element.startTime +
- (element.duration - element.trimStart - element.trimEnd);
+ get().pushHistory();
- if (splitTime <= effectiveStart || splitTime >= effectiveEnd) return;
+ // Find existing audio track or prepare to create one
+ const existingAudioTrack = _tracks.find((t) => t.type === "audio");
+ const audioElementId = generateUUID();
- get().pushHistory();
+ if (existingAudioTrack) {
+ // Add audio element to existing audio track
+ updateTracksAndSave(
+ get()._tracks.map((track) =>
+ track.id === existingAudioTrack.id
+ ? {
+ ...track,
+ elements: [
+ ...track.elements,
+ {
+ ...element,
+ id: audioElementId,
+ name: getElementNameWithSuffix(element.name, "audio"),
+ },
+ ],
+ }
+ : track,
+ ),
+ );
+ } else {
+ // Create new audio track with the audio element in a single atomic update
+ const newAudioTrack: TimelineTrack = {
+ id: generateUUID(),
+ name: "Audio Track",
+ type: "audio",
+ elements: [
+ {
+ ...element,
+ id: audioElementId,
+ name: getElementNameWithSuffix(element.name, "audio"),
+ },
+ ],
+ muted: false,
+ };
- const relativeTime = splitTime - element.startTime;
+ updateTracksAndSave([...get()._tracks, newAudioTrack]);
+ }
- updateTracksAndSave(
- get()._tracks.map((track) =>
- track.id === trackId
- ? {
- ...track,
- elements: track.elements.map((c) =>
- c.id === elementId
- ? {
- ...c,
- startTime: splitTime,
- trimStart: c.trimStart + relativeTime,
- name: getElementNameWithSuffix(c.name, "right"),
- }
- : c
- ),
- }
- : track
- )
- );
- },
+ return audioElementId;
+ },
- // Extract audio from video element to an audio track
- separateAudio: (trackId, elementId) => {
- const { _tracks } = get();
- const track = _tracks.find((t) => t.id === trackId);
- const element = track?.elements.find((c) => c.id === elementId);
+ // Replace media for an element
+ replaceElementMedia: async (trackId, elementId, newFile) => {
+ const { _tracks } = get();
+ const track = _tracks.find((t) => t.id === trackId);
+ const element = track?.elements.find((c) => c.id === elementId);
- if (!element || track?.type !== "media") return null;
+ if (!element || element.type !== "media") return false;
- get().pushHistory();
+ try {
+ const mediaStore = useMediaStore.getState();
+ const projectStore = useProjectStore.getState();
- // Find existing audio track or prepare to create one
- const existingAudioTrack = _tracks.find((t) => t.type === "audio");
- const audioElementId = generateUUID();
+ if (!projectStore.activeProject) return false;
- if (existingAudioTrack) {
- // Add audio element to existing audio track
- updateTracksAndSave(
- get()._tracks.map((track) =>
- track.id === existingAudioTrack.id
- ? {
- ...track,
- elements: [
- ...track.elements,
- {
- ...element,
- id: audioElementId,
- name: getElementNameWithSuffix(element.name, "audio"),
- },
- ],
- }
- : track
- )
- );
- } else {
- // Create new audio track with the audio element in a single atomic update
- const newAudioTrack: TimelineTrack = {
- id: generateUUID(),
- name: "Audio Track",
- type: "audio",
- elements: [
- {
- ...element,
- id: audioElementId,
- name: getElementNameWithSuffix(element.name, "audio"),
- },
- ],
- muted: false,
- };
+ // Import required media processing functions
+ const {
+ getFileType,
+ getImageDimensions,
+ generateVideoThumbnail,
+ getMediaDuration,
+ } = await import("./media-store");
- updateTracksAndSave([...get()._tracks, newAudioTrack]);
- }
+ const fileType = getFileType(newFile);
+ if (!fileType) return false;
- return audioElementId;
- },
+ // Process the new media file
+ const mediaData: {
+ name: string;
+ type: MediaType;
+ file: File;
+ url: string;
+ duration?: number;
+ thumbnailUrl?: string;
+ width?: number;
+ height?: number;
+ } = {
+ name: newFile.name,
+ type: fileType,
+ file: newFile,
+ url: URL.createObjectURL(newFile),
+ };
- // Replace media for an element
- replaceElementMedia: async (trackId, elementId, newFile) => {
- const { _tracks } = get();
- const track = _tracks.find((t) => t.id === trackId);
- const element = track?.elements.find((c) => c.id === elementId);
+ // Get media-specific metadata
+ if (fileType === "image") {
+ const { width, height } = await getImageDimensions(newFile);
+ mediaData.width = width;
+ mediaData.height = height;
+ } else if (fileType === "video") {
+ const [duration, { thumbnailUrl, width, height }] = await Promise.all(
+ [getMediaDuration(newFile), generateVideoThumbnail(newFile)],
+ );
+ mediaData.duration = duration;
+ mediaData.thumbnailUrl = thumbnailUrl;
+ mediaData.width = width;
+ mediaData.height = height;
+ } else if (fileType === "audio") {
+ mediaData.duration = await getMediaDuration(newFile);
+ }
- if (!element || element.type !== "media") return false;
+ // Add new media item to store
+ await mediaStore.addMediaItem(projectStore.activeProject.id, mediaData);
- try {
- const mediaStore = useMediaStore.getState();
- const projectStore = useProjectStore.getState();
+ // Find the newly created media item
+ const newMediaItem = mediaStore.mediaItems.find(
+ (item) => item.file === newFile,
+ );
- if (!projectStore.activeProject) return false;
+ if (!newMediaItem) return false;
- // Import required media processing functions
- const {
- getFileType,
- getImageDimensions,
- generateVideoThumbnail,
- getMediaDuration,
- } = await import("./media-store");
+ get().pushHistory();
- const fileType = getFileType(newFile);
- if (!fileType) return false;
+ // Update the timeline element to reference the new media
+ updateTracksAndSave(
+ _tracks.map((track) =>
+ track.id === trackId
+ ? {
+ ...track,
+ elements: track.elements.map((c) =>
+ c.id === elementId
+ ? {
+ ...c,
+ mediaId: newMediaItem.id,
+ name: newMediaItem.name,
+ // Update duration if the new media has a different duration
+ duration: newMediaItem.duration || c.duration,
+ }
+ : c,
+ ),
+ }
+ : track,
+ ),
+ );
- // Process the new media file
- let mediaData: any = {
- name: newFile.name,
- type: fileType,
- file: newFile,
- url: URL.createObjectURL(newFile),
- };
+ return true;
+ } catch (error) {
+ console.log(
+ JSON.stringify({
+ error: "Failed to replace element media",
+ details: error,
+ }),
+ );
+ return false;
+ }
+ },
- // Get media-specific metadata
- if (fileType === "image") {
- const { width, height } = await getImageDimensions(newFile);
- mediaData.width = width;
- mediaData.height = height;
- } else if (fileType === "video") {
- const [duration, { thumbnailUrl, width, height }] = await Promise.all(
- [getMediaDuration(newFile), generateVideoThumbnail(newFile)]
- );
- mediaData.duration = duration;
- mediaData.thumbnailUrl = thumbnailUrl;
- mediaData.width = width;
- mediaData.height = height;
- } else if (fileType === "audio") {
- mediaData.duration = await getMediaDuration(newFile);
- }
+ getTotalDuration: () => {
+ const { _tracks } = get();
+ if (_tracks.length === 0) return 0;
- // Add new media item to store
- await mediaStore.addMediaItem(projectStore.activeProject.id, mediaData);
+ const trackEndTimes = _tracks.map((track) =>
+ track.elements.reduce((maxEnd, element) => {
+ const elementEnd =
+ element.startTime +
+ element.duration -
+ element.trimStart -
+ element.trimEnd;
+ return Math.max(maxEnd, elementEnd);
+ }, 0),
+ );
- // Find the newly created media item
- const newMediaItem = mediaStore.mediaItems.find(
- (item) => item.file === newFile
- );
+ return Math.max(...trackEndTimes, 0);
+ },
- if (!newMediaItem) return false;
+ getProjectThumbnail: async (projectId) => {
+ try {
+ const tracks = await storageService.loadTimeline(projectId);
+ const mediaItems = await storageService.loadAllMediaItems(projectId);
- get().pushHistory();
+ if (!tracks || !mediaItems.length) return null;
- // Update the timeline element to reference the new media
- updateTracksAndSave(
- _tracks.map((track) =>
- track.id === trackId
- ? {
- ...track,
- elements: track.elements.map((c) =>
- c.id === elementId
- ? {
- ...c,
- mediaId: newMediaItem.id,
- name: newMediaItem.name,
- // Update duration if the new media has a different duration
- duration: newMediaItem.duration || c.duration,
- }
- : c
- ),
- }
- : track
- )
- );
+ const firstMediaElement = tracks
+ .flatMap((track) => track.elements)
+ .filter((element) => element.type === "media")
+ .sort((a, b) => a.startTime - b.startTime)[0];
- return true;
- } catch (error) {
- console.log(
- JSON.stringify({
- error: "Failed to replace element media",
- details: error,
- })
- );
- return false;
- }
- },
+ if (!firstMediaElement) return null;
- getTotalDuration: () => {
- const { _tracks } = get();
- if (_tracks.length === 0) return 0;
+ const mediaItem = mediaItems.find(
+ (item) => item.id === firstMediaElement.mediaId,
+ );
+ if (!mediaItem) return null;
- const trackEndTimes = _tracks.map((track) =>
- track.elements.reduce((maxEnd, element) => {
- const elementEnd =
- element.startTime +
- element.duration -
- element.trimStart -
- element.trimEnd;
- return Math.max(maxEnd, elementEnd);
- }, 0)
- );
+ if (mediaItem.type === "video" && mediaItem.file) {
+ const { generateVideoThumbnail } = await import(
+ "@/stores/media-store"
+ );
+ const { thumbnailUrl } = await generateVideoThumbnail(mediaItem.file);
+ return thumbnailUrl;
+ } else if (mediaItem.type === "image" && mediaItem.url) {
+ return mediaItem.url;
+ }
- return Math.max(...trackEndTimes, 0);
- },
+ return null;
+ } catch (error) {
+ console.error("Failed to get project thumbnail:", error);
+ return null;
+ }
+ },
- redo: () => {
- const { redoStack } = get();
- if (redoStack.length === 0) return;
- const next = redoStack[redoStack.length - 1];
- updateTracksAndSave(next);
- set({ redoStack: redoStack.slice(0, -1) });
- },
+ redo: () => {
+ const { redoStack } = get();
+ if (redoStack.length === 0) return;
+ const next = redoStack[redoStack.length - 1];
+ updateTracksAndSave(next);
+ set({ redoStack: redoStack.slice(0, -1) });
+ },
- dragState: {
- isDragging: false,
- elementId: null,
- trackId: null,
- startMouseX: 0,
- startElementTime: 0,
- clickOffsetTime: 0,
- currentTime: 0,
- },
+ dragState: {
+ isDragging: false,
+ elementId: null,
+ trackId: null,
+ startMouseX: 0,
+ startElementTime: 0,
+ clickOffsetTime: 0,
+ currentTime: 0,
+ },
- setDragState: (dragState) =>
- set((state) => ({
- dragState: { ...state.dragState, ...dragState },
- })),
+ setDragState: (dragState) =>
+ set((state) => ({
+ dragState: { ...state.dragState, ...dragState },
+ })),
- startDrag: (
- elementId,
- trackId,
- startMouseX,
- startElementTime,
- clickOffsetTime
- ) => {
- set({
- dragState: {
- isDragging: true,
- elementId,
- trackId,
- startMouseX,
- startElementTime,
- clickOffsetTime,
- currentTime: startElementTime,
- },
- });
- },
+ startDrag: (
+ elementId,
+ trackId,
+ startMouseX,
+ startElementTime,
+ clickOffsetTime,
+ ) => {
+ set({
+ dragState: {
+ isDragging: true,
+ elementId,
+ trackId,
+ startMouseX,
+ startElementTime,
+ clickOffsetTime,
+ currentTime: startElementTime,
+ },
+ });
+ },
- updateDragTime: (currentTime) => {
- set((state) => ({
- dragState: {
- ...state.dragState,
- currentTime,
- },
- }));
- },
+ updateDragTime: (currentTime) => {
+ set((state) => ({
+ dragState: {
+ ...state.dragState,
+ currentTime,
+ },
+ }));
+ },
- endDrag: () => {
- set({
- dragState: {
- isDragging: false,
- elementId: null,
- trackId: null,
- startMouseX: 0,
- startElementTime: 0,
- clickOffsetTime: 0,
- currentTime: 0,
- },
- });
- },
+ endDrag: () => {
+ set({
+ dragState: {
+ isDragging: false,
+ elementId: null,
+ trackId: null,
+ startMouseX: 0,
+ startElementTime: 0,
+ clickOffsetTime: 0,
+ currentTime: 0,
+ },
+ });
+ },
- // Persistence methods
- loadProjectTimeline: async (projectId) => {
- try {
- const tracks = await storageService.loadTimeline(projectId);
- if (tracks) {
- updateTracks(tracks);
- } else {
- // No timeline saved yet, initialize with default
- const defaultTracks = ensureMainTrack([]);
- updateTracks(defaultTracks);
- }
- // Clear history when loading a project
- set({ history: [], redoStack: [] });
- } catch (error) {
- console.error("Failed to load timeline:", error);
- // Initialize with default on error
- const defaultTracks = ensureMainTrack([]);
- updateTracks(defaultTracks);
- set({ history: [], redoStack: [] });
- }
- },
+ // Persistence methods
+ loadProjectTimeline: async (projectId) => {
+ try {
+ const tracks = await storageService.loadTimeline(projectId);
+ if (tracks) {
+ updateTracks(tracks);
+ } else {
+ // No timeline saved yet, initialize with default
+ const defaultTracks = ensureMainTrack([]);
+ updateTracks(defaultTracks);
+ }
+ // Clear history when loading a project
+ set({ history: [], redoStack: [] });
+ } catch (error) {
+ console.error("Failed to load timeline:", error);
+ // Initialize with default on error
+ const defaultTracks = ensureMainTrack([]);
+ updateTracks(defaultTracks);
+ set({ history: [], redoStack: [] });
+ }
+ },
- saveProjectTimeline: async (projectId) => {
- try {
- await storageService.saveTimeline(projectId, get()._tracks);
- } catch (error) {
- console.error("Failed to save timeline:", error);
- }
- },
+ saveProjectTimeline: async (projectId) => {
+ try {
+ await storageService.saveTimeline(projectId, get()._tracks);
+ } catch (error) {
+ console.error("Failed to save timeline:", error);
+ }
+ },
- clearTimeline: () => {
- const defaultTracks = ensureMainTrack([]);
- updateTracks(defaultTracks);
- set({ history: [], redoStack: [], selectedElements: [] });
- },
+ clearTimeline: () => {
+ const defaultTracks = ensureMainTrack([]);
+ updateTracks(defaultTracks);
+ set({ history: [], redoStack: [], selectedElements: [] });
+ },
- // Snapping actions
- toggleSnapping: () => {
- set((state) => ({ snappingEnabled: !state.snappingEnabled }));
- },
+ // Snapping actions
+ toggleSnapping: () => {
+ set((state) => ({ snappingEnabled: !state.snappingEnabled }));
+ },
- // Ripple editing functions
- toggleRippleEditing: () => {
- set((state) => ({
- rippleEditingEnabled: !state.rippleEditingEnabled,
- }));
- },
+ // Ripple editing functions
+ toggleRippleEditing: () => {
+ set((state) => ({
+ rippleEditingEnabled: !state.rippleEditingEnabled,
+ }));
+ },
- checkElementOverlap: (trackId, startTime, duration, excludeElementId) => {
- const track = get()._tracks.find((t) => t.id === trackId);
- if (!track) return false;
+ checkElementOverlap: (trackId, startTime, duration, excludeElementId) => {
+ const track = get()._tracks.find((t) => t.id === trackId);
+ if (!track) return false;
- const overlap = track.elements.some((element) => {
- const elementEnd =
- element.startTime +
- element.duration -
- element.trimStart -
- element.trimEnd;
+ const overlap = track.elements.some((element) => {
+ const elementEnd =
+ element.startTime +
+ element.duration -
+ element.trimStart -
+ element.trimEnd;
- if (element.id === excludeElementId) {
- return false;
- }
+ if (element.id === excludeElementId) {
+ return false;
+ }
- return (
- (startTime >= element.startTime && startTime < elementEnd) ||
- (startTime + duration > element.startTime &&
- startTime + duration <= elementEnd) ||
- (startTime < element.startTime && startTime + duration > elementEnd)
- );
- });
- return overlap;
- },
+ return (
+ (startTime >= element.startTime && startTime < elementEnd) ||
+ (startTime + duration > element.startTime &&
+ startTime + duration <= elementEnd) ||
+ (startTime < element.startTime && startTime + duration > elementEnd)
+ );
+ });
+ return overlap;
+ },
- findOrCreateTrack: (trackType) => {
- // Always create new text track to allow multiple text elements
- if (trackType === "text") {
- return get().addTrack(trackType);
- }
+ findOrCreateTrack: (trackType) => {
+ // Always create new text track to allow multiple text elements
+ if (trackType === "text") {
+ return get().addTrack(trackType);
+ }
- const existingTrack = get()._tracks.find((t) => t.type === trackType);
- if (existingTrack) {
- return existingTrack.id;
- }
+ const existingTrack = get()._tracks.find((t) => t.type === trackType);
+ if (existingTrack) {
+ return existingTrack.id;
+ }
- return get().addTrack(trackType);
- },
+ return get().addTrack(trackType);
+ },
- addMediaAtTime: (item, currentTime = 0) => {
- const trackType = item.type === "audio" ? "audio" : "media";
- const targetTrackId = get().findOrCreateTrack(trackType);
+ addMediaAtTime: (item, currentTime = 0) => {
+ const trackType = item.type === "audio" ? "audio" : "media";
+ const targetTrackId = get().findOrCreateTrack(trackType);
- const duration =
- item.duration || TIMELINE_CONSTANTS.DEFAULT_IMAGE_DURATION;
+ const duration =
+ item.duration || TIMELINE_CONSTANTS.DEFAULT_IMAGE_DURATION;
- if (get().checkElementOverlap(targetTrackId, currentTime, duration)) {
- toast.error(
- "Cannot place element here - it would overlap with existing elements"
- );
- return false;
- }
+ if (get().checkElementOverlap(targetTrackId, currentTime, duration)) {
+ toast.error(
+ "Cannot place element here - it would overlap with existing elements",
+ );
+ return false;
+ }
- get().addElementToTrack(targetTrackId, {
- type: "media",
- mediaId: item.id,
- name: item.name,
- duration,
- startTime: currentTime,
- trimStart: 0,
- trimEnd: 0,
- });
- return true;
- },
+ get().addElementToTrack(targetTrackId, {
+ type: "media",
+ mediaId: item.id,
+ name: item.name,
+ duration,
+ startTime: currentTime,
+ trimStart: 0,
+ trimEnd: 0,
+ });
+ return true;
+ },
- addTextAtTime: (item, currentTime = 0) => {
- const targetTrackId = get().addTrack("text"); // Always create new text track to allow multiple text elements
+ addTextAtTime: (item, currentTime = 0) => {
+ const targetTrackId = get().addTrack("text"); // Always create new text track to allow multiple text elements
- get().addElementToTrack(targetTrackId, {
- type: "text",
- name: item.name || "Text",
- content: item.content || "Default Text",
- duration: item.duration || TIMELINE_CONSTANTS.DEFAULT_TEXT_DURATION,
- startTime: currentTime,
- trimStart: 0,
- trimEnd: 0,
- fontSize: item.fontSize || 48,
- fontFamily: item.fontFamily || "Arial",
- color: item.color || "#ffffff",
- backgroundColor: item.backgroundColor || "transparent",
- textAlign: item.textAlign || "center",
- fontWeight: item.fontWeight || "normal",
- fontStyle: item.fontStyle || "normal",
- textDecoration: item.textDecoration || "none",
- x: item.x || 0,
- y: item.y || 0,
- rotation: item.rotation || 0,
- opacity: item.opacity !== undefined ? item.opacity : 1,
- });
- return true;
- },
+ get().addElementToTrack(targetTrackId, {
+ type: "text",
+ name: item.name || "Text",
+ content: item.content || "Default Text",
+ duration: item.duration || TIMELINE_CONSTANTS.DEFAULT_TEXT_DURATION,
+ startTime: currentTime,
+ trimStart: 0,
+ trimEnd: 0,
+ fontSize: item.fontSize || 48,
+ fontFamily: item.fontFamily || "Arial",
+ color: item.color || "#ffffff",
+ backgroundColor: item.backgroundColor || "transparent",
+ textAlign: item.textAlign || "center",
+ fontWeight: item.fontWeight || "normal",
+ fontStyle: item.fontStyle || "normal",
+ textDecoration: item.textDecoration || "none",
+ x: item.x || 0,
+ y: item.y || 0,
+ rotation: item.rotation || 0,
+ opacity: item.opacity !== undefined ? item.opacity : 1,
+ });
+ return true;
+ },
- addMediaToNewTrack: (item) => {
- const trackType = item.type === "audio" ? "audio" : "media";
- const targetTrackId = get().findOrCreateTrack(trackType);
+ addMediaToNewTrack: (item) => {
+ const trackType = item.type === "audio" ? "audio" : "media";
+ const targetTrackId = get().findOrCreateTrack(trackType);
- get().addElementToTrack(targetTrackId, {
- type: "media",
- mediaId: item.id,
- name: item.name,
- duration: item.duration || TIMELINE_CONSTANTS.DEFAULT_IMAGE_DURATION,
- startTime: 0,
- trimStart: 0,
- trimEnd: 0,
- });
- return true;
- },
+ get().addElementToTrack(targetTrackId, {
+ type: "media",
+ mediaId: item.id,
+ name: item.name,
+ duration: item.duration || TIMELINE_CONSTANTS.DEFAULT_IMAGE_DURATION,
+ startTime: 0,
+ trimStart: 0,
+ trimEnd: 0,
+ });
+ return true;
+ },
- addTextToNewTrack: (item) => {
- const targetTrackId = get().addTrack("text"); // Always create new text track to allow multiple text elements
+ addTextToNewTrack: (item) => {
+ const targetTrackId = get().addTrack("text"); // Always create new text track to allow multiple text elements
- get().addElementToTrack(targetTrackId, {
- type: "text",
- name: item.name || "Text",
- content:
- ("content" in item ? item.content : "Default Text") || "Default Text",
- duration: TIMELINE_CONSTANTS.DEFAULT_TEXT_DURATION,
- startTime: 0,
- trimStart: 0,
- trimEnd: 0,
- fontSize: ("fontSize" in item ? item.fontSize : 48) || 48,
- fontFamily:
- ("fontFamily" in item ? item.fontFamily : "Arial") || "Arial",
- color: ("color" in item ? item.color : "#ffffff") || "#ffffff",
- backgroundColor:
- ("backgroundColor" in item ? item.backgroundColor : "transparent") ||
- "transparent",
- textAlign:
- ("textAlign" in item ? item.textAlign : "center") || "center",
- fontWeight:
- ("fontWeight" in item ? item.fontWeight : "normal") || "normal",
- fontStyle:
- ("fontStyle" in item ? item.fontStyle : "normal") || "normal",
- textDecoration:
- ("textDecoration" in item ? item.textDecoration : "none") || "none",
- x: ("x" in item ? item.x : 0) || 0,
- y: ("y" in item ? item.y : 0) || 0,
- rotation: ("rotation" in item ? item.rotation : 0) || 0,
- opacity:
- "opacity" in item && item.opacity !== undefined ? item.opacity : 1,
- });
- return true;
- },
- };
+ get().addElementToTrack(targetTrackId, {
+ type: "text",
+ name: item.name || "Text",
+ content:
+ ("content" in item ? item.content : "Default Text") || "Default Text",
+ duration: TIMELINE_CONSTANTS.DEFAULT_TEXT_DURATION,
+ startTime: 0,
+ trimStart: 0,
+ trimEnd: 0,
+ fontSize: ("fontSize" in item ? item.fontSize : 48) || 48,
+ fontFamily:
+ ("fontFamily" in item ? item.fontFamily : "Arial") || "Arial",
+ color: ("color" in item ? item.color : "#ffffff") || "#ffffff",
+ backgroundColor:
+ ("backgroundColor" in item ? item.backgroundColor : "transparent") ||
+ "transparent",
+ textAlign:
+ ("textAlign" in item ? item.textAlign : "center") || "center",
+ fontWeight:
+ ("fontWeight" in item ? item.fontWeight : "normal") || "normal",
+ fontStyle:
+ ("fontStyle" in item ? item.fontStyle : "normal") || "normal",
+ textDecoration:
+ ("textDecoration" in item ? item.textDecoration : "none") || "none",
+ x: ("x" in item ? item.x : 0) || 0,
+ y: ("y" in item ? item.y : 0) || 0,
+ rotation: ("rotation" in item ? item.rotation : 0) || 0,
+ opacity:
+ "opacity" in item && item.opacity !== undefined ? item.opacity : 1,
+ });
+ return true;
+ },
+ };
});