refactor: store media relative to project, add storage for timeline data, and other things

This commit is contained in:
Maze Winther
2025-07-07 19:06:36 +02:00
parent 11c0b89bd1
commit bd0c7f2206
13 changed files with 573 additions and 514 deletions

View File

@ -1,6 +1,7 @@
"use client"; "use client";
import { useEffect } from "react"; import { useEffect } from "react";
import { useParams } from "next/navigation";
import { import {
ResizablePanelGroup, ResizablePanelGroup,
ResizablePanel, ResizablePanel,
@ -28,15 +29,27 @@ export default function Editor() {
setTimeline, setTimeline,
} = usePanelStore(); } = usePanelStore();
const { activeProject, createNewProject } = useProjectStore(); const { activeProject, loadProject, createNewProject } = useProjectStore();
const params = useParams();
const projectId = params.project_id as string;
usePlaybackControls(); usePlaybackControls();
useEffect(() => { useEffect(() => {
if (!activeProject) { const initializeProject = async () => {
createNewProject("Untitled Project"); if (projectId && (!activeProject || activeProject.id !== projectId)) {
} try {
}, [activeProject, createNewProject]); await loadProject(projectId);
} catch (error) {
console.error("Failed to load project:", error);
// If project doesn't exist, create a new one
await createNewProject("Untitled Project");
}
}
};
initializeProject();
}, [projectId, activeProject, loadProject, createNewProject]);
return ( return (
<EditorProvider> <EditorProvider>

View File

@ -24,9 +24,11 @@ import {
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { DraggableMediaItem } from "@/components/ui/draggable-item"; import { DraggableMediaItem } from "@/components/ui/draggable-item";
import { useProjectStore } from "@/stores/project-store";
export function MediaView() { export function MediaView() {
const { mediaItems, addMediaItem, removeMediaItem } = useMediaStore(); const { mediaItems, addMediaItem, removeMediaItem } = useMediaStore();
const { activeProject } = useProjectStore();
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const [isProcessing, setIsProcessing] = useState(false); const [isProcessing, setIsProcessing] = useState(false);
const [progress, setProgress] = useState(0); const [progress, setProgress] = useState(0);
@ -35,6 +37,11 @@ export function MediaView() {
const processFiles = async (files: FileList | File[]) => { const processFiles = async (files: FileList | File[]) => {
if (!files || files.length === 0) return; if (!files || files.length === 0) return;
if (!activeProject) {
toast.error("No active project");
return;
}
setIsProcessing(true); setIsProcessing(true);
setProgress(0); setProgress(0);
try { try {
@ -44,7 +51,7 @@ export function MediaView() {
); );
// Add each processed media item to the store // Add each processed media item to the store
for (const item of processedItems) { for (const item of processedItems) {
await addMediaItem(item); await addMediaItem(activeProject.id, item);
} }
} catch (error) { } catch (error) {
// Show error toast if processing fails // Show error toast if processing fails
@ -73,6 +80,11 @@ export function MediaView() {
// Remove a media item from the store // Remove a media item from the store
e.stopPropagation(); e.stopPropagation();
if (!activeProject) {
toast.error("No active project");
return;
}
// Remove elements automatically when delete media // Remove elements automatically when delete media
const { tracks, removeTrack } = useTimelineStore.getState(); const { tracks, removeTrack } = useTimelineStore.getState();
tracks.forEach((track) => { tracks.forEach((track) => {
@ -92,7 +104,7 @@ export function MediaView() {
removeTrack(track.id); removeTrack(track.id);
} }
}); });
await removeMediaItem(id); await removeMediaItem(activeProject.id, id);
}; };
const formatDuration = (duration: number) => { const formatDuration = (duration: number) => {

View File

@ -13,11 +13,12 @@ import {
ContextMenuSeparator, ContextMenuSeparator,
ContextMenuTrigger, ContextMenuTrigger,
} from "../ui/context-menu"; } from "../ui/context-menu";
import { import {
TimelineTrack, TimelineTrack,
sortTracksByOrder, sortTracksByOrder,
ensureMainTrack, ensureMainTrack,
getMainTrack getMainTrack,
canElementGoOnTrack,
} from "@/types/timeline"; } from "@/types/timeline";
import { usePlaybackStore } from "@/stores/playback-store"; import { usePlaybackStore } from "@/stores/playback-store";
import type { import type {
@ -572,7 +573,9 @@ export function TimelineTrackContent({
// dropPosition === "on" but track is not text type // dropPosition === "on" but track is not text type
// Insert above main track if main track exists, otherwise at top // Insert above main track if main track exists, otherwise at top
if (mainTrack) { if (mainTrack) {
const mainTrackIndex = tracks.findIndex(t => t.id === mainTrack.id); const mainTrackIndex = tracks.findIndex(
(t) => t.id === mainTrack.id
);
insertIndex = mainTrackIndex; insertIndex = mainTrackIndex;
} else { } else {
insertIndex = 0; // Top of timeline insertIndex = 0; // Top of timeline
@ -648,9 +651,11 @@ export function TimelineTrackContent({
const isVideoOrImage = const isVideoOrImage =
dragData.type === "video" || dragData.type === "image"; dragData.type === "video" || dragData.type === "image";
const isAudio = dragData.type === "audio"; const isAudio = dragData.type === "audio";
const isCompatible = const isCompatible = isVideoOrImage
(track.type === "media" && isVideoOrImage) || ? canElementGoOnTrack("media", track.type)
(track.type === "audio" && isAudio); : isAudio
? canElementGoOnTrack("media", track.type)
: false;
let targetTrack = tracks.find((t) => t.id === targetTrackId); let targetTrack = tracks.find((t) => t.id === targetTrackId);
@ -662,7 +667,7 @@ export function TimelineTrackContent({
if (isVideoOrImage) { if (isVideoOrImage) {
// For video/image, check if we need a main track or additional media track // For video/image, check if we need a main track or additional media track
const mainTrack = getMainTrack(tracks); const mainTrack = getMainTrack(tracks);
if (!mainTrack) { if (!mainTrack) {
// No main track exists, create it // No main track exists, create it
const updatedTracks = ensureMainTrack(tracks); const updatedTracks = ensureMainTrack(tracks);
@ -672,22 +677,32 @@ export function TimelineTrackContent({
targetTrack = newMainTrack; targetTrack = newMainTrack;
} else { } else {
// Main track was created but somehow has elements, create new media track // Main track was created but somehow has elements, create new media track
const mainTrackIndex = updatedTracks.findIndex(t => t.id === newMainTrack?.id); const mainTrackIndex = updatedTracks.findIndex(
(t) => t.id === newMainTrack?.id
);
targetTrackId = insertTrackAt("media", mainTrackIndex); targetTrackId = insertTrackAt("media", mainTrackIndex);
const updatedTracksAfterInsert = useTimelineStore.getState().tracks; const updatedTracksAfterInsert =
const newTargetTrack = updatedTracksAfterInsert.find(t => t.id === targetTrackId); useTimelineStore.getState().tracks;
const newTargetTrack = updatedTracksAfterInsert.find(
(t) => t.id === targetTrackId
);
if (!newTargetTrack) return; if (!newTargetTrack) return;
targetTrack = newTargetTrack; targetTrack = newTargetTrack;
} }
} else if (mainTrack.elements.length === 0 && dropPosition === "on") { } else if (
mainTrack.elements.length === 0 &&
dropPosition === "on"
) {
// Main track exists and is empty, use it // Main track exists and is empty, use it
targetTrackId = mainTrack.id; targetTrackId = mainTrack.id;
targetTrack = mainTrack; targetTrack = mainTrack;
} else { } else {
// Create new media track above main track // Create new media track above main track
const mainTrackIndex = tracks.findIndex(t => t.id === mainTrack.id); const mainTrackIndex = tracks.findIndex(
(t) => t.id === mainTrack.id
);
let insertIndex: number; let insertIndex: number;
if (dropPosition === "above") { if (dropPosition === "above") {
insertIndex = currentTrackIndex; insertIndex = currentTrackIndex;
} else if (dropPosition === "below") { } else if (dropPosition === "below") {
@ -696,10 +711,12 @@ export function TimelineTrackContent({
// Insert above main track // Insert above main track
insertIndex = mainTrackIndex; insertIndex = mainTrackIndex;
} }
targetTrackId = insertTrackAt("media", insertIndex); targetTrackId = insertTrackAt("media", insertIndex);
const updatedTracks = useTimelineStore.getState().tracks; const updatedTracks = useTimelineStore.getState().tracks;
const newTargetTrack = updatedTracks.find(t => t.id === targetTrackId); const newTargetTrack = updatedTracks.find(
(t) => t.id === targetTrackId
);
if (!newTargetTrack) return; if (!newTargetTrack) return;
targetTrack = newTargetTrack; targetTrack = newTargetTrack;
} }
@ -707,7 +724,7 @@ export function TimelineTrackContent({
// Audio tracks go at the bottom // Audio tracks go at the bottom
const mainTrack = getMainTrack(tracks); const mainTrack = getMainTrack(tracks);
let insertIndex: number; let insertIndex: number;
if (dropPosition === "above") { if (dropPosition === "above") {
insertIndex = currentTrackIndex; insertIndex = currentTrackIndex;
} else if (dropPosition === "below") { } else if (dropPosition === "below") {
@ -715,16 +732,20 @@ export function TimelineTrackContent({
} else { } else {
// Insert after main track (bottom area) // Insert after main track (bottom area)
if (mainTrack) { if (mainTrack) {
const mainTrackIndex = tracks.findIndex(t => t.id === mainTrack.id); const mainTrackIndex = tracks.findIndex(
(t) => t.id === mainTrack.id
);
insertIndex = mainTrackIndex + 1; insertIndex = mainTrackIndex + 1;
} else { } else {
insertIndex = tracks.length; // Bottom of timeline insertIndex = tracks.length; // Bottom of timeline
} }
} }
targetTrackId = insertTrackAt("audio", insertIndex); targetTrackId = insertTrackAt("audio", insertIndex);
const updatedTracks = useTimelineStore.getState().tracks; const updatedTracks = useTimelineStore.getState().tracks;
const newTargetTrack = updatedTracks.find(t => t.id === targetTrackId); const newTargetTrack = updatedTracks.find(
(t) => t.id === targetTrackId
);
if (!newTargetTrack) return; if (!newTargetTrack) return;
targetTrack = newTargetTrack; targetTrack = newTargetTrack;
} }
@ -805,7 +826,7 @@ export function TimelineTrackContent({
? wouldOverlap ? wouldOverlap
? "Cannot drop - would overlap" ? "Cannot drop - would overlap"
: "Drop element here" : "Drop element here"
: "Drop media here"} : ""}
</div> </div>
) : ( ) : (
<> <>

View File

@ -31,6 +31,7 @@ import {
import { useTimelineStore } from "@/stores/timeline-store"; import { useTimelineStore } from "@/stores/timeline-store";
import { useMediaStore } from "@/stores/media-store"; import { useMediaStore } from "@/stores/media-store";
import { usePlaybackStore } from "@/stores/playback-store"; import { usePlaybackStore } from "@/stores/playback-store";
import { useProjectStore } from "@/stores/project-store";
import { processMediaFiles } from "@/lib/media-processing"; import { processMediaFiles } from "@/lib/media-processing";
import { toast } from "sonner"; import { toast } from "sonner";
import { useState, useRef, useEffect, useCallback } from "react"; import { useState, useRef, useEffect, useCallback } from "react";
@ -65,6 +66,7 @@ export function Timeline() {
redo, redo,
} = useTimelineStore(); } = useTimelineStore();
const { mediaItems, addMediaItem } = useMediaStore(); const { mediaItems, addMediaItem } = useMediaStore();
const { activeProject } = useProjectStore();
const { const {
currentTime, currentTime,
duration, duration,
@ -377,6 +379,11 @@ export function Timeline() {
} }
} else if (e.dataTransfer.files?.length > 0) { } else if (e.dataTransfer.files?.length > 0) {
// Handle file drops by creating new tracks // Handle file drops by creating new tracks
if (!activeProject) {
toast.error("No active project");
return;
}
setIsProcessing(true); setIsProcessing(true);
setProgress(0); setProgress(0);
try { try {
@ -385,7 +392,7 @@ export function Timeline() {
(p) => setProgress(p) (p) => setProgress(p)
); );
for (const processedItem of processedItems) { for (const processedItem of processedItems) {
await addMediaItem(processedItem); await addMediaItem(activeProject.id, processedItem);
const currentMediaItems = useMediaStore.getState().mediaItems; const currentMediaItems = useMediaStore.getState().mediaItems;
const addedItem = currentMediaItems.find( const addedItem = currentMediaItems.find(
(item) => (item) =>

View File

@ -36,7 +36,6 @@ export function StorageProvider({ children }: StorageProviderProps) {
}); });
const loadAllProjects = useProjectStore((state) => state.loadAllProjects); const loadAllProjects = useProjectStore((state) => state.loadAllProjects);
const loadAllMedia = useMediaStore((state) => state.loadAllMedia);
useEffect(() => { useEffect(() => {
const initializeStorage = async () => { const initializeStorage = async () => {
@ -52,8 +51,8 @@ export function StorageProvider({ children }: StorageProviderProps) {
); );
} }
// Load saved data in parallel // Load saved projects (media will be loaded when a project is loaded)
await Promise.all([loadAllProjects(), loadAllMedia()]); await loadAllProjects();
setStatus({ setStatus({
isInitialized: true, isInitialized: true,
@ -73,7 +72,7 @@ export function StorageProvider({ children }: StorageProviderProps) {
}; };
initializeStorage(); initializeStorage();
}, [loadAllProjects, loadAllMedia]); }, [loadAllProjects]);
return ( return (
<StorageContext.Provider value={status}>{children}</StorageContext.Provider> <StorageContext.Provider value={status}>{children}</StorageContext.Provider>

View File

@ -1,237 +0,0 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { useTimelineStore } from "@/stores/timeline-store";
interface DragState {
isDragging: boolean;
elementId: string | null;
trackId: string | null;
startMouseX: number;
startElementTime: number;
clickOffsetTime: number;
currentTime: number;
}
export function useDragClip(zoomLevel: number) {
const { tracks, updateElementStartTime, moveElementToTrack } =
useTimelineStore();
const [dragState, setDragState] = useState<DragState>({
isDragging: false,
elementId: null,
trackId: null,
startMouseX: 0,
startElementTime: 0,
clickOffsetTime: 0,
currentTime: 0,
});
const timelineRef = useRef<HTMLDivElement>(null);
const dragStateRef = useRef(dragState);
// Keep ref in sync with state
dragStateRef.current = dragState;
const startDrag = useCallback(
(
e: React.MouseEvent,
elementId: string,
trackId: string,
elementStartTime: number,
clickOffsetTime: number
) => {
e.preventDefault();
e.stopPropagation();
setDragState({
isDragging: true,
elementId,
trackId,
startMouseX: e.clientX,
startElementTime: elementStartTime,
clickOffsetTime,
currentTime: elementStartTime,
});
},
[]
);
const updateDrag = useCallback(
(e: MouseEvent) => {
if (!dragState.isDragging || !timelineRef.current) {
return;
}
const timelineRect = timelineRef.current.getBoundingClientRect();
const mouseX = e.clientX - timelineRect.left;
const mouseTime = Math.max(0, mouseX / (50 * zoomLevel));
const adjustedTime = Math.max(0, mouseTime - dragState.clickOffsetTime);
const snappedTime = Math.round(adjustedTime * 10) / 10;
setDragState((prev) => ({
...prev,
currentTime: snappedTime,
}));
},
[dragState.isDragging, dragState.clickOffsetTime, zoomLevel]
);
const endDrag = useCallback(
(targetTrackId?: string) => {
if (!dragState.isDragging || !dragState.elementId || !dragState.trackId)
return;
const finalTrackId = targetTrackId || dragState.trackId;
const finalTime = dragState.currentTime;
// Check for overlaps
const sourceTrack = tracks.find((t) => t.id === dragState.trackId);
const targetTrack = tracks.find((t) => t.id === finalTrackId);
const movingElement = sourceTrack?.elements.find(
(e) => e.id === dragState.elementId
);
if (!movingElement || !targetTrack) {
setDragState((prev) => ({ ...prev, isDragging: false }));
return;
}
const movingElementDuration =
movingElement.duration -
movingElement.trimStart -
movingElement.trimEnd;
const movingElementEnd = finalTime + movingElementDuration;
const hasOverlap = targetTrack.elements.some((existingElement) => {
// Skip the element being moved if it's on the same track
if (
dragState.trackId === finalTrackId &&
existingElement.id === dragState.elementId
) {
return false;
}
const existingStart = existingElement.startTime;
const existingEnd =
existingElement.startTime +
(existingElement.duration -
existingElement.trimStart -
existingElement.trimEnd);
return finalTime < existingEnd && movingElementEnd > existingStart;
});
if (!hasOverlap) {
if (dragState.trackId === finalTrackId) {
// Moving within same track
updateElementStartTime(finalTrackId, dragState.elementId!, finalTime);
} else {
// Moving to different track
moveElementToTrack(
dragState.trackId!,
finalTrackId,
dragState.elementId!
);
requestAnimationFrame(() => {
updateElementStartTime(
finalTrackId,
dragState.elementId!,
finalTime
);
});
}
}
setDragState({
isDragging: false,
elementId: null,
trackId: null,
startMouseX: 0,
startElementTime: 0,
clickOffsetTime: 0,
currentTime: 0,
});
},
[dragState, tracks, updateElementStartTime, moveElementToTrack]
);
const cancelDrag = useCallback(() => {
setDragState({
isDragging: false,
elementId: null,
trackId: null,
startMouseX: 0,
startElementTime: 0,
clickOffsetTime: 0,
currentTime: 0,
});
}, []);
// Global mouse events
useEffect(() => {
if (!dragState.isDragging) return;
const handleMouseMove = (e: MouseEvent) => updateDrag(e);
const handleMouseUp = () => endDrag();
const handleEscape = (e: KeyboardEvent) => {
if (e.key === "Escape") cancelDrag();
};
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
document.addEventListener("keydown", handleEscape);
return () => {
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
document.removeEventListener("keydown", handleEscape);
};
}, [dragState.isDragging, updateDrag, endDrag, cancelDrag]);
const getDraggedElementPosition = useCallback(
(elementId: string) => {
// Use ref to get current state, not stale closure
const currentDragState = dragStateRef.current;
const isMatch =
currentDragState.isDragging && currentDragState.elementId === elementId;
if (isMatch) {
return currentDragState.currentTime;
}
return null;
},
[] // No dependencies needed since we use ref
);
const isValidDropTarget = useCallback(
(trackId: string) => {
if (!dragState.isDragging) return false;
const sourceTrack = tracks.find((t) => t.id === dragState.trackId);
const targetTrack = tracks.find((t) => t.id === trackId);
if (!sourceTrack || !targetTrack) return false;
// For now, allow drops on same track type
return sourceTrack.type === targetTrack.type;
},
[dragState.isDragging, dragState.trackId, tracks]
);
return {
// State
isDragging: dragState.isDragging,
draggedElementId: dragState.elementId,
currentDragTime: dragState.currentTime,
clickOffsetTime: dragState.clickOffsetTime,
// Methods
startDrag,
endDrag,
cancelDrag,
getDraggedElementPosition,
isValidDropTarget,
// Refs
timelineRef,
};
}

View File

@ -1,194 +1,273 @@
import { TProject } from "@/types/project"; import { TProject } from "@/types/project";
import { MediaItem } from "@/stores/media-store"; import { MediaItem } from "@/stores/media-store";
import { IndexedDBAdapter } from "./indexeddb-adapter"; import { IndexedDBAdapter } from "./indexeddb-adapter";
import { OPFSAdapter } from "./opfs-adapter"; import { OPFSAdapter } from "./opfs-adapter";
import { MediaFileData, StorageConfig, SerializedProject } from "./types"; import {
MediaFileData,
class StorageService { StorageConfig,
private projectsAdapter: IndexedDBAdapter<SerializedProject>; SerializedProject,
private mediaMetadataAdapter: IndexedDBAdapter<MediaFileData>; TimelineData,
private mediaFilesAdapter: OPFSAdapter; } from "./types";
private config: StorageConfig; import { TimelineTrack } from "@/types/timeline";
constructor() { class StorageService {
this.config = { private projectsAdapter: IndexedDBAdapter<SerializedProject>;
projectsDb: "video-editor-projects", private config: StorageConfig;
mediaDb: "video-editor-media",
version: 1, constructor() {
}; this.config = {
projectsDb: "video-editor-projects",
this.projectsAdapter = new IndexedDBAdapter<SerializedProject>( mediaDb: "video-editor-media",
this.config.projectsDb, timelineDb: "video-editor-timelines",
"projects", version: 1,
this.config.version };
);
this.projectsAdapter = new IndexedDBAdapter<SerializedProject>(
this.mediaMetadataAdapter = new IndexedDBAdapter<MediaFileData>( this.config.projectsDb,
this.config.mediaDb, "projects",
"media-metadata", this.config.version
this.config.version );
); }
this.mediaFilesAdapter = new OPFSAdapter("media-files"); // Helper to get project-specific media adapters
} private getProjectMediaAdapters(projectId: string) {
const mediaMetadataAdapter = new IndexedDBAdapter<MediaFileData>(
// Project operations `${this.config.mediaDb}-${projectId}`,
async saveProject(project: TProject): Promise<void> { "media-metadata",
// Convert TProject to serializable format this.config.version
const serializedProject: SerializedProject = { );
id: project.id,
name: project.name, const mediaFilesAdapter = new OPFSAdapter(`media-files-${projectId}`);
thumbnail: project.thumbnail,
createdAt: project.createdAt.toISOString(), return { mediaMetadataAdapter, mediaFilesAdapter };
updatedAt: project.updatedAt.toISOString(), }
};
// Helper to get project-specific timeline adapter
await this.projectsAdapter.set(project.id, serializedProject); private getProjectTimelineAdapter(projectId: string) {
} return new IndexedDBAdapter<TimelineData>(
`${this.config.timelineDb}-${projectId}`,
async loadProject(id: string): Promise<TProject | null> { "timeline",
const serializedProject = await this.projectsAdapter.get(id); this.config.version
);
if (!serializedProject) return null; }
// Convert back to TProject format // Project operations
return { async saveProject(project: TProject): Promise<void> {
id: serializedProject.id, // Convert TProject to serializable format
name: serializedProject.name, const serializedProject: SerializedProject = {
thumbnail: serializedProject.thumbnail, id: project.id,
createdAt: new Date(serializedProject.createdAt), name: project.name,
updatedAt: new Date(serializedProject.updatedAt), thumbnail: project.thumbnail,
}; createdAt: project.createdAt.toISOString(),
} updatedAt: project.updatedAt.toISOString(),
};
async loadAllProjects(): Promise<TProject[]> {
const projectIds = await this.projectsAdapter.list(); await this.projectsAdapter.set(project.id, serializedProject);
const projects: TProject[] = []; }
for (const id of projectIds) { async loadProject(id: string): Promise<TProject | null> {
const project = await this.loadProject(id); const serializedProject = await this.projectsAdapter.get(id);
if (project) {
projects.push(project); if (!serializedProject) return null;
}
} // Convert back to TProject format
return {
// Sort by last updated (most recent first) id: serializedProject.id,
return projects.sort( name: serializedProject.name,
(a, b) => b.updatedAt.getTime() - a.updatedAt.getTime() thumbnail: serializedProject.thumbnail,
); createdAt: new Date(serializedProject.createdAt),
} updatedAt: new Date(serializedProject.updatedAt),
};
async deleteProject(id: string): Promise<void> { }
await this.projectsAdapter.remove(id);
} async loadAllProjects(): Promise<TProject[]> {
const projectIds = await this.projectsAdapter.list();
// Media operations const projects: TProject[] = [];
async saveMediaItem(mediaItem: MediaItem): Promise<void> {
// Save file to OPFS for (const id of projectIds) {
await this.mediaFilesAdapter.set(mediaItem.id, mediaItem.file); const project = await this.loadProject(id);
if (project) {
// Save metadata to IndexedDB projects.push(project);
const metadata: MediaFileData = { }
id: mediaItem.id, }
name: mediaItem.name,
type: mediaItem.type, // Sort by last updated (most recent first)
size: mediaItem.file.size, return projects.sort(
lastModified: mediaItem.file.lastModified, (a, b) => b.updatedAt.getTime() - a.updatedAt.getTime()
width: mediaItem.width, );
height: mediaItem.height, }
duration: mediaItem.duration,
}; async deleteProject(id: string): Promise<void> {
await this.projectsAdapter.remove(id);
await this.mediaMetadataAdapter.set(mediaItem.id, metadata); }
}
// Media operations - now project-specific
async loadMediaItem(id: string): Promise<MediaItem | null> { async saveMediaItem(projectId: string, mediaItem: MediaItem): Promise<void> {
const [file, metadata] = await Promise.all([ const { mediaMetadataAdapter, mediaFilesAdapter } =
this.mediaFilesAdapter.get(id), this.getProjectMediaAdapters(projectId);
this.mediaMetadataAdapter.get(id),
]); // Save file to project-specific OPFS
await mediaFilesAdapter.set(mediaItem.id, mediaItem.file);
if (!file || !metadata) return null;
// Save metadata to project-specific IndexedDB
// Create new object URL for the file const metadata: MediaFileData = {
const url = URL.createObjectURL(file); id: mediaItem.id,
name: mediaItem.name,
return { type: mediaItem.type,
id: metadata.id, size: mediaItem.file.size,
name: metadata.name, lastModified: mediaItem.file.lastModified,
type: metadata.type, width: mediaItem.width,
file, height: mediaItem.height,
url, duration: mediaItem.duration,
width: metadata.width, };
height: metadata.height,
duration: metadata.duration, await mediaMetadataAdapter.set(mediaItem.id, metadata);
// thumbnailUrl would need to be regenerated or cached separately }
};
} async loadMediaItem(
projectId: string,
async loadAllMediaItems(): Promise<MediaItem[]> { id: string
const mediaIds = await this.mediaMetadataAdapter.list(); ): Promise<MediaItem | null> {
const mediaItems: MediaItem[] = []; const { mediaMetadataAdapter, mediaFilesAdapter } =
this.getProjectMediaAdapters(projectId);
for (const id of mediaIds) {
const item = await this.loadMediaItem(id); const [file, metadata] = await Promise.all([
if (item) { mediaFilesAdapter.get(id),
mediaItems.push(item); mediaMetadataAdapter.get(id),
} ]);
}
if (!file || !metadata) return null;
return mediaItems;
} // Create new object URL for the file
const url = URL.createObjectURL(file);
async deleteMediaItem(id: string): Promise<void> {
await Promise.all([ return {
this.mediaFilesAdapter.remove(id), id: metadata.id,
this.mediaMetadataAdapter.remove(id), name: metadata.name,
]); type: metadata.type,
} file,
url,
// Utility methods width: metadata.width,
async clearAllData(): Promise<void> { height: metadata.height,
await Promise.all([ duration: metadata.duration,
this.projectsAdapter.clear(), // thumbnailUrl would need to be regenerated or cached separately
this.mediaMetadataAdapter.clear(), };
this.mediaFilesAdapter.clear(), }
]);
} async loadAllMediaItems(projectId: string): Promise<MediaItem[]> {
const { mediaMetadataAdapter } = this.getProjectMediaAdapters(projectId);
async getStorageInfo(): Promise<{
projects: number; const mediaIds = await mediaMetadataAdapter.list();
mediaItems: number; const mediaItems: MediaItem[] = [];
isOPFSSupported: boolean;
isIndexedDBSupported: boolean; for (const id of mediaIds) {
}> { const item = await this.loadMediaItem(projectId, id);
const [projectIds, mediaIds] = await Promise.all([ if (item) {
this.projectsAdapter.list(), mediaItems.push(item);
this.mediaMetadataAdapter.list(), }
]); }
return { return mediaItems;
projects: projectIds.length, }
mediaItems: mediaIds.length,
isOPFSSupported: this.isOPFSSupported(), async deleteMediaItem(projectId: string, id: string): Promise<void> {
isIndexedDBSupported: this.isIndexedDBSupported(), const { mediaMetadataAdapter, mediaFilesAdapter } =
}; this.getProjectMediaAdapters(projectId);
}
await Promise.all([
// Check browser support mediaFilesAdapter.remove(id),
isOPFSSupported(): boolean { mediaMetadataAdapter.remove(id),
return OPFSAdapter.isSupported(); ]);
} }
isIndexedDBSupported(): boolean { async deleteProjectMedia(projectId: string): Promise<void> {
return "indexedDB" in window; const { mediaMetadataAdapter, mediaFilesAdapter } =
} this.getProjectMediaAdapters(projectId);
isFullySupported(): boolean { await Promise.all([
return this.isIndexedDBSupported() && this.isOPFSSupported(); mediaMetadataAdapter.clear(),
} mediaFilesAdapter.clear(),
} ]);
}
// Export singleton instance
export const storageService = new StorageService(); // Timeline operations - now project-specific
export { StorageService }; async saveTimeline(
projectId: string,
tracks: TimelineTrack[]
): Promise<void> {
const timelineAdapter = this.getProjectTimelineAdapter(projectId);
const timelineData: TimelineData = {
tracks,
lastModified: new Date().toISOString(),
};
await timelineAdapter.set("timeline", timelineData);
}
async loadTimeline(projectId: string): Promise<TimelineTrack[] | null> {
const timelineAdapter = this.getProjectTimelineAdapter(projectId);
const timelineData = await timelineAdapter.get("timeline");
return timelineData ? timelineData.tracks : null;
}
async deleteProjectTimeline(projectId: string): Promise<void> {
const timelineAdapter = this.getProjectTimelineAdapter(projectId);
await timelineAdapter.remove("timeline");
}
// Utility methods
async clearAllData(): Promise<void> {
// Clear all projects
await this.projectsAdapter.clear();
// Note: Project-specific media and timelines will be cleaned up when projects are deleted
}
async getStorageInfo(): Promise<{
projects: number;
isOPFSSupported: boolean;
isIndexedDBSupported: boolean;
}> {
const projectIds = await this.projectsAdapter.list();
return {
projects: projectIds.length,
isOPFSSupported: this.isOPFSSupported(),
isIndexedDBSupported: this.isIndexedDBSupported(),
};
}
async getProjectStorageInfo(projectId: string): Promise<{
mediaItems: number;
hasTimeline: boolean;
}> {
const { mediaMetadataAdapter } = this.getProjectMediaAdapters(projectId);
const timelineAdapter = this.getProjectTimelineAdapter(projectId);
const [mediaIds, timelineData] = await Promise.all([
mediaMetadataAdapter.list(),
timelineAdapter.get("timeline"),
]);
return {
mediaItems: mediaIds.length,
hasTimeline: !!timelineData,
};
}
// Check browser support
isOPFSSupported(): boolean {
return OPFSAdapter.isSupported();
}
isIndexedDBSupported(): boolean {
return "indexedDB" in window;
}
isFullySupported(): boolean {
return this.isIndexedDBSupported() && this.isOPFSSupported();
}
}
// Export singleton instance
export const storageService = new StorageService();
export { StorageService };

View File

@ -1,4 +1,5 @@
import { TProject } from "@/types/project"; import { TProject } from "@/types/project";
import { TimelineTrack } from "@/types/timeline";
export interface StorageAdapter<T> { export interface StorageAdapter<T> {
get(key: string): Promise<T | null>; get(key: string): Promise<T | null>;
@ -20,9 +21,15 @@ export interface MediaFileData {
// File will be stored separately in OPFS // File will be stored separately in OPFS
} }
export interface TimelineData {
tracks: TimelineTrack[];
lastModified: string;
}
export interface StorageConfig { export interface StorageConfig {
projectsDb: string; projectsDb: string;
mediaDb: string; mediaDb: string;
timelineDb: string;
version: number; version: number;
} }

View File

@ -1,5 +1,6 @@
import { create } from "zustand"; import { create } from "zustand";
import { storageService } from "@/lib/storage/storage-service"; import { storageService } from "@/lib/storage/storage-service";
import { useProjectStore } from "./project-store";
export type MediaType = "image" | "video" | "audio"; export type MediaType = "image" | "video" | "audio";
@ -26,11 +27,15 @@ interface MediaStore {
mediaItems: MediaItem[]; mediaItems: MediaItem[];
isLoading: boolean; isLoading: boolean;
// Actions // Actions - now require projectId
addMediaItem: (item: Omit<MediaItem, "id">) => Promise<void>; addMediaItem: (
removeMediaItem: (id: string) => Promise<void>; projectId: string,
loadAllMedia: () => Promise<void>; item: Omit<MediaItem, "id">
clearAllMedia: () => Promise<void>; ) => Promise<void>;
removeMediaItem: (projectId: string, id: string) => Promise<void>;
loadProjectMedia: (projectId: string) => Promise<void>;
clearProjectMedia: (projectId: string) => Promise<void>;
clearAllMedia: () => void; // Clear local state only
} }
// Helper function to determine file type // Helper function to determine file type
@ -153,7 +158,7 @@ export const useMediaStore = create<MediaStore>((set, get) => ({
mediaItems: [], mediaItems: [],
isLoading: false, isLoading: false,
addMediaItem: async (item) => { addMediaItem: async (projectId, item) => {
const newItem: MediaItem = { const newItem: MediaItem = {
...item, ...item,
id: crypto.randomUUID(), id: crypto.randomUUID(),
@ -166,7 +171,7 @@ export const useMediaStore = create<MediaStore>((set, get) => ({
// Save to persistent storage in background // Save to persistent storage in background
try { try {
await storageService.saveMediaItem(newItem); await storageService.saveMediaItem(projectId, newItem);
} catch (error) { } catch (error) {
console.error("Failed to save media item:", error); console.error("Failed to save media item:", error);
// Remove from local state if save failed // Remove from local state if save failed
@ -176,7 +181,7 @@ export const useMediaStore = create<MediaStore>((set, get) => ({
} }
}, },
removeMediaItem: async (id: string) => { removeMediaItem: async (projectId, id: string) => {
const state = get(); const state = get();
const item = state.mediaItems.find((media) => media.id === id); const item = state.mediaItems.find((media) => media.id === id);
@ -195,17 +200,17 @@ export const useMediaStore = create<MediaStore>((set, get) => ({
// Remove from persistent storage // Remove from persistent storage
try { try {
await storageService.deleteMediaItem(id); await storageService.deleteMediaItem(projectId, id);
} catch (error) { } catch (error) {
console.error("Failed to delete media item:", error); console.error("Failed to delete media item:", error);
} }
}, },
loadAllMedia: async () => { loadProjectMedia: async (projectId) => {
set({ isLoading: true }); set({ isLoading: true });
try { try {
const mediaItems = await storageService.loadAllMediaItems(); const mediaItems = await storageService.loadAllMediaItems(projectId);
set({ mediaItems }); set({ mediaItems });
} catch (error) { } catch (error) {
console.error("Failed to load media items:", error); console.error("Failed to load media items:", error);
@ -214,7 +219,7 @@ export const useMediaStore = create<MediaStore>((set, get) => ({
} }
}, },
clearAllMedia: async () => { clearProjectMedia: async (projectId) => {
const state = get(); const state = get();
// Cleanup all object URLs // Cleanup all object URLs
@ -234,10 +239,27 @@ export const useMediaStore = create<MediaStore>((set, get) => ({
try { try {
const mediaIds = state.mediaItems.map((item) => item.id); const mediaIds = state.mediaItems.map((item) => item.id);
await Promise.all( await Promise.all(
mediaIds.map((id) => storageService.deleteMediaItem(id)) mediaIds.map((id) => storageService.deleteMediaItem(projectId, id))
); );
} catch (error) { } catch (error) {
console.error("Failed to clear media items from storage:", error); console.error("Failed to clear media items from storage:", error);
} }
}, },
clearAllMedia: () => {
const state = get();
// Cleanup all object URLs
state.mediaItems.forEach((item) => {
if (item.url) {
URL.revokeObjectURL(item.url);
}
if (item.thumbnailUrl) {
URL.revokeObjectURL(item.thumbnailUrl);
}
});
// Clear local state
set({ mediaItems: [] });
},
})); }));

View File

@ -2,6 +2,8 @@ import { TProject } from "@/types/project";
import { create } from "zustand"; import { create } from "zustand";
import { storageService } from "@/lib/storage/storage-service"; import { storageService } from "@/lib/storage/storage-service";
import { toast } from "sonner"; import { toast } from "sonner";
import { useMediaStore } from "./media-store";
import { useTimelineStore } from "./timeline-store";
interface ProjectStore { interface ProjectStore {
activeProject: TProject | null; activeProject: TProject | null;
@ -53,13 +55,28 @@ export const useProjectStore = create<ProjectStore>((set, get) => ({
set({ isLoading: true }); set({ isLoading: true });
} }
// Clear media and timeline immediately to prevent flickering when switching projects
const mediaStore = useMediaStore.getState();
const timelineStore = useTimelineStore.getState();
mediaStore.clearAllMedia();
timelineStore.clearTimeline();
try { try {
const project = await storageService.loadProject(id); const project = await storageService.loadProject(id);
if (project) { if (project) {
set({ activeProject: project }); set({ activeProject: project });
// Load project-specific data in parallel
await Promise.all([
mediaStore.loadProjectMedia(id),
timelineStore.loadProjectTimeline(id),
]);
} else {
throw new Error(`Project with id ${id} not found`);
} }
} catch (error) { } catch (error) {
console.error("Failed to load project:", error); console.error("Failed to load project:", error);
throw error; // Re-throw so the editor page can handle it
} finally { } finally {
set({ isLoading: false }); set({ isLoading: false });
} }
@ -70,7 +87,12 @@ export const useProjectStore = create<ProjectStore>((set, get) => ({
if (!activeProject) return; if (!activeProject) return;
try { try {
await storageService.saveProject(activeProject); // Save project metadata and timeline data in parallel
const timelineStore = useTimelineStore.getState();
await Promise.all([
storageService.saveProject(activeProject),
timelineStore.saveProjectTimeline(activeProject.id),
]);
await get().loadAllProjects(); // Refresh the list await get().loadAllProjects(); // Refresh the list
} catch (error) { } catch (error) {
console.error("Failed to save project:", error); console.error("Failed to save project:", error);
@ -94,13 +116,22 @@ export const useProjectStore = create<ProjectStore>((set, get) => ({
deleteProject: async (id: string) => { deleteProject: async (id: string) => {
try { try {
await storageService.deleteProject(id); // Delete project data in parallel
await Promise.all([
storageService.deleteProjectMedia(id),
storageService.deleteProjectTimeline(id),
storageService.deleteProject(id),
]);
await get().loadAllProjects(); // Refresh the list await get().loadAllProjects(); // Refresh the list
// If we deleted the active project, close it // If we deleted the active project, close it and clear data
const { activeProject } = get(); const { activeProject } = get();
if (activeProject?.id === id) { if (activeProject?.id === id) {
set({ activeProject: null }); set({ activeProject: null });
const mediaStore = useMediaStore.getState();
const timelineStore = useTimelineStore.getState();
mediaStore.clearAllMedia();
timelineStore.clearTimeline();
} }
} catch (error) { } catch (error) {
console.error("Failed to delete project:", error); console.error("Failed to delete project:", error);
@ -109,6 +140,12 @@ export const useProjectStore = create<ProjectStore>((set, get) => ({
closeProject: () => { closeProject: () => {
set({ activeProject: null }); set({ activeProject: null });
// Clear data from stores when closing project
const mediaStore = useMediaStore.getState();
const timelineStore = useTimelineStore.getState();
mediaStore.clearAllMedia();
timelineStore.clearTimeline();
}, },
renameProject: async (id: string, name: string) => { renameProject: async (id: string, name: string) => {

View File

@ -6,9 +6,12 @@ import {
TimelineTrack, TimelineTrack,
sortTracksByOrder, sortTracksByOrder,
ensureMainTrack, ensureMainTrack,
validateElementTrackCompatibility,
} from "@/types/timeline"; } from "@/types/timeline";
import { useEditorStore } from "./editor-store"; import { useEditorStore } from "./editor-store";
import { useMediaStore, getMediaAspectRatio } from "./media-store"; import { useMediaStore, getMediaAspectRatio } from "./media-store";
import { storageService } from "@/lib/storage/storage-service";
import { useProjectStore } from "./project-store";
// Helper function to manage element naming with suffixes // Helper function to manage element naming with suffixes
const getElementNameWithSuffix = ( const getElementNameWithSuffix = (
@ -116,6 +119,11 @@ interface TimelineStore {
undo: () => void; undo: () => void;
redo: () => void; redo: () => void;
pushHistory: () => void; pushHistory: () => void;
// Persistence actions
loadProjectTimeline: (projectId: string) => Promise<void>;
saveProjectTimeline: (projectId: string) => Promise<void>;
clearTimeline: () => void;
} }
export const useTimelineStore = create<TimelineStore>((set, get) => { export const useTimelineStore = create<TimelineStore>((set, get) => {
@ -129,6 +137,25 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
}); });
}; };
// 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 // Initialize with proper track ordering
const initialTracks = ensureMainTrack([]); const initialTracks = ensureMainTrack([]);
const sortedInitialTracks = sortTracksByOrder(initialTracks); const sortedInitialTracks = sortTracksByOrder(initialTracks);
@ -158,7 +185,7 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
const { history, redoStack, _tracks } = get(); const { history, redoStack, _tracks } = get();
if (history.length === 0) return; if (history.length === 0) return;
const prev = history[history.length - 1]; const prev = history[history.length - 1];
updateTracks(prev); updateTracksAndSave(prev);
set({ set({
history: history.slice(0, -1), history: history.slice(0, -1),
redoStack: [...redoStack, JSON.parse(JSON.stringify(_tracks))], redoStack: [...redoStack, JSON.parse(JSON.stringify(_tracks))],
@ -224,7 +251,7 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
muted: false, muted: false,
}; };
updateTracks([...get()._tracks, newTrack]); updateTracksAndSave([...get()._tracks, newTrack]);
return newTrack.id; return newTrack.id;
}, },
@ -251,13 +278,13 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
const newTracks = [...get()._tracks]; const newTracks = [...get()._tracks];
newTracks.splice(index, 0, newTrack); newTracks.splice(index, 0, newTrack);
updateTracks(newTracks); updateTracksAndSave(newTracks);
return newTrack.id; return newTrack.id;
}, },
removeTrack: (trackId) => { removeTrack: (trackId) => {
get().pushHistory(); get().pushHistory();
updateTracks(get()._tracks.filter((track) => track.id !== trackId)); updateTracksAndSave(get()._tracks.filter((track) => track.id !== trackId));
}, },
addElementToTrack: (trackId, elementData) => { addElementToTrack: (trackId, elementData) => {
@ -270,17 +297,10 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
return; return;
} }
// Validate element can be added to this track type // Use utility function for validation
if (track.type === "media" && elementData.type !== "media") { const validation = validateElementTrackCompatibility(elementData, track);
console.error("Media track only accepts media elements"); if (!validation.isValid) {
return; console.error(validation.errorMessage);
}
if (track.type === "text" && elementData.type !== "text") {
console.error("Text track only accepts text elements");
return;
}
if (track.type === "audio" && elementData.type !== "media") {
console.error("Audio track only accepts media elements");
return; return;
} }
@ -331,7 +351,7 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
} }
} }
updateTracks( updateTracksAndSave(
get()._tracks.map((track) => get()._tracks.map((track) =>
track.id === trackId track.id === trackId
? { ...track, elements: [...track.elements, newElement] } ? { ...track, elements: [...track.elements, newElement] }
@ -342,7 +362,7 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
removeElementFromTrack: (trackId, elementId) => { removeElementFromTrack: (trackId, elementId) => {
get().pushHistory(); get().pushHistory();
updateTracks( updateTracksAndSave(
get() get()
._tracks.map((track) => ._tracks.map((track) =>
track.id === trackId track.id === trackId
@ -362,11 +382,22 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
get().pushHistory(); get().pushHistory();
const fromTrack = get()._tracks.find((track) => track.id === fromTrackId); const fromTrack = get()._tracks.find((track) => track.id === fromTrackId);
const toTrack = get()._tracks.find((track) => track.id === toTrackId);
const elementToMove = fromTrack?.elements.find( const elementToMove = fromTrack?.elements.find(
(element) => element.id === elementId (element) => element.id === elementId
); );
if (!elementToMove) return; if (!elementToMove || !toTrack) return;
// Validate element type compatibility with target track
const validation = validateElementTrackCompatibility(
elementToMove,
toTrack
);
if (!validation.isValid) {
console.error(validation.errorMessage);
return;
}
const newTracks = get() const newTracks = get()
._tracks.map((track) => { ._tracks.map((track) => {
@ -387,12 +418,12 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
}) })
.filter((track) => track.elements.length > 0); .filter((track) => track.elements.length > 0);
updateTracks(newTracks); updateTracksAndSave(newTracks);
}, },
updateElementTrim: (trackId, elementId, trimStart, trimEnd) => { updateElementTrim: (trackId, elementId, trimStart, trimEnd) => {
get().pushHistory(); get().pushHistory();
updateTracks( updateTracksAndSave(
get()._tracks.map((track) => get()._tracks.map((track) =>
track.id === trackId track.id === trackId
? { ? {
@ -410,7 +441,7 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
updateElementStartTime: (trackId, elementId, startTime) => { updateElementStartTime: (trackId, elementId, startTime) => {
get().pushHistory(); get().pushHistory();
updateTracks( updateTracksAndSave(
get()._tracks.map((track) => get()._tracks.map((track) =>
track.id === trackId track.id === trackId
? { ? {
@ -426,7 +457,7 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
toggleTrackMute: (trackId) => { toggleTrackMute: (trackId) => {
get().pushHistory(); get().pushHistory();
updateTracks( updateTracksAndSave(
get()._tracks.map((track) => get()._tracks.map((track) =>
track.id === trackId ? { ...track, muted: !track.muted } : track track.id === trackId ? { ...track, muted: !track.muted } : track
) )
@ -456,7 +487,7 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
const secondElementId = crypto.randomUUID(); const secondElementId = crypto.randomUUID();
updateTracks( updateTracksAndSave(
get()._tracks.map((track) => get()._tracks.map((track) =>
track.id === trackId track.id === trackId
? { ? {
@ -508,7 +539,7 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
const durationToRemove = const durationToRemove =
element.duration - element.trimStart - element.trimEnd - relativeTime; element.duration - element.trimStart - element.trimEnd - relativeTime;
updateTracks( updateTracksAndSave(
get()._tracks.map((track) => get()._tracks.map((track) =>
track.id === trackId track.id === trackId
? { ? {
@ -547,7 +578,7 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
const relativeTime = splitTime - element.startTime; const relativeTime = splitTime - element.startTime;
updateTracks( updateTracksAndSave(
get()._tracks.map((track) => get()._tracks.map((track) =>
track.id === trackId track.id === trackId
? { ? {
@ -584,7 +615,7 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
if (existingAudioTrack) { if (existingAudioTrack) {
// Add audio element to existing audio track // Add audio element to existing audio track
updateTracks( updateTracksAndSave(
get()._tracks.map((track) => get()._tracks.map((track) =>
track.id === existingAudioTrack.id track.id === existingAudioTrack.id
? { ? {
@ -617,7 +648,7 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
muted: false, muted: false,
}; };
updateTracks([...get()._tracks, newAudioTrack]); updateTracksAndSave([...get()._tracks, newAudioTrack]);
} }
return audioElementId; return audioElementId;
@ -645,7 +676,7 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
const { redoStack } = get(); const { redoStack } = get();
if (redoStack.length === 0) return; if (redoStack.length === 0) return;
const next = redoStack[redoStack.length - 1]; const next = redoStack[redoStack.length - 1];
updateTracks(next); updateTracksAndSave(next);
set({ redoStack: redoStack.slice(0, -1) }); set({ redoStack: redoStack.slice(0, -1) });
}, },
@ -706,5 +737,41 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
}, },
}); });
}, },
// 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);
}
},
clearTimeline: () => {
const defaultTracks = ensureMainTrack([]);
updateTracks(defaultTracks);
set({ history: [], redoStack: [], selectedElements: [] });
},
}; };
}); });

View File

@ -1,7 +1,8 @@
export interface TProject { export interface TProject {
id: string; id: string;
name: string; name: string;
thumbnail: string; thumbnail: string;
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
} mediaItems?: string[];
}

View File

@ -123,3 +123,34 @@ export function ensureMainTrack(tracks: TimelineTrack[]): TimelineTrack[] {
return tracks; return tracks;
} }
// Timeline validation utilities
export function canElementGoOnTrack(
elementType: "text" | "media",
trackType: TrackType
): boolean {
if (elementType === "text") {
return trackType === "text";
} else if (elementType === "media") {
return trackType === "media" || trackType === "audio";
}
return false;
}
export function validateElementTrackCompatibility(
element: { type: "text" | "media" },
track: { type: TrackType }
): { isValid: boolean; errorMessage?: string } {
const isValid = canElementGoOnTrack(element.type, track.type);
if (!isValid) {
const errorMessage =
element.type === "text"
? "Text elements can only be placed on text tracks"
: "Media elements can only be placed on media or audio tracks";
return { isValid: false, errorMessage };
}
return { isValid: true };
}