refactor: store media relative to project, add storage for timeline data, and other things
This commit is contained in:
@ -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 {
|
||||||
|
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");
|
||||||
}
|
}
|
||||||
}, [activeProject, createNewProject]);
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
initializeProject();
|
||||||
|
}, [projectId, activeProject, loadProject, createNewProject]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<EditorProvider>
|
<EditorProvider>
|
||||||
|
@ -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) => {
|
||||||
|
@ -17,7 +17,8 @@ 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);
|
||||||
|
|
||||||
@ -672,20 +677,30 @@ 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") {
|
||||||
@ -699,7 +714,9 @@ export function TimelineTrackContent({
|
|||||||
|
|
||||||
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;
|
||||||
}
|
}
|
||||||
@ -715,7 +732,9 @@ 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
|
||||||
@ -724,7 +743,9 @@ export function TimelineTrackContent({
|
|||||||
|
|
||||||
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>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
@ -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) =>
|
||||||
|
@ -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>
|
||||||
|
@ -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,
|
|
||||||
};
|
|
||||||
}
|
|
@ -2,18 +2,23 @@ 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,
|
||||||
|
StorageConfig,
|
||||||
|
SerializedProject,
|
||||||
|
TimelineData,
|
||||||
|
} from "./types";
|
||||||
|
import { TimelineTrack } from "@/types/timeline";
|
||||||
|
|
||||||
class StorageService {
|
class StorageService {
|
||||||
private projectsAdapter: IndexedDBAdapter<SerializedProject>;
|
private projectsAdapter: IndexedDBAdapter<SerializedProject>;
|
||||||
private mediaMetadataAdapter: IndexedDBAdapter<MediaFileData>;
|
|
||||||
private mediaFilesAdapter: OPFSAdapter;
|
|
||||||
private config: StorageConfig;
|
private config: StorageConfig;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.config = {
|
this.config = {
|
||||||
projectsDb: "video-editor-projects",
|
projectsDb: "video-editor-projects",
|
||||||
mediaDb: "video-editor-media",
|
mediaDb: "video-editor-media",
|
||||||
|
timelineDb: "video-editor-timelines",
|
||||||
version: 1,
|
version: 1,
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -22,14 +27,28 @@ class StorageService {
|
|||||||
"projects",
|
"projects",
|
||||||
this.config.version
|
this.config.version
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
this.mediaMetadataAdapter = new IndexedDBAdapter<MediaFileData>(
|
// Helper to get project-specific media adapters
|
||||||
this.config.mediaDb,
|
private getProjectMediaAdapters(projectId: string) {
|
||||||
|
const mediaMetadataAdapter = new IndexedDBAdapter<MediaFileData>(
|
||||||
|
`${this.config.mediaDb}-${projectId}`,
|
||||||
"media-metadata",
|
"media-metadata",
|
||||||
this.config.version
|
this.config.version
|
||||||
);
|
);
|
||||||
|
|
||||||
this.mediaFilesAdapter = new OPFSAdapter("media-files");
|
const mediaFilesAdapter = new OPFSAdapter(`media-files-${projectId}`);
|
||||||
|
|
||||||
|
return { mediaMetadataAdapter, mediaFilesAdapter };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to get project-specific timeline adapter
|
||||||
|
private getProjectTimelineAdapter(projectId: string) {
|
||||||
|
return new IndexedDBAdapter<TimelineData>(
|
||||||
|
`${this.config.timelineDb}-${projectId}`,
|
||||||
|
"timeline",
|
||||||
|
this.config.version
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Project operations
|
// Project operations
|
||||||
@ -82,12 +101,15 @@ class StorageService {
|
|||||||
await this.projectsAdapter.remove(id);
|
await this.projectsAdapter.remove(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Media operations
|
// Media operations - now project-specific
|
||||||
async saveMediaItem(mediaItem: MediaItem): Promise<void> {
|
async saveMediaItem(projectId: string, mediaItem: MediaItem): Promise<void> {
|
||||||
// Save file to OPFS
|
const { mediaMetadataAdapter, mediaFilesAdapter } =
|
||||||
await this.mediaFilesAdapter.set(mediaItem.id, mediaItem.file);
|
this.getProjectMediaAdapters(projectId);
|
||||||
|
|
||||||
// Save metadata to IndexedDB
|
// Save file to project-specific OPFS
|
||||||
|
await mediaFilesAdapter.set(mediaItem.id, mediaItem.file);
|
||||||
|
|
||||||
|
// Save metadata to project-specific IndexedDB
|
||||||
const metadata: MediaFileData = {
|
const metadata: MediaFileData = {
|
||||||
id: mediaItem.id,
|
id: mediaItem.id,
|
||||||
name: mediaItem.name,
|
name: mediaItem.name,
|
||||||
@ -99,13 +121,19 @@ class StorageService {
|
|||||||
duration: mediaItem.duration,
|
duration: mediaItem.duration,
|
||||||
};
|
};
|
||||||
|
|
||||||
await this.mediaMetadataAdapter.set(mediaItem.id, metadata);
|
await mediaMetadataAdapter.set(mediaItem.id, metadata);
|
||||||
}
|
}
|
||||||
|
|
||||||
async loadMediaItem(id: string): Promise<MediaItem | null> {
|
async loadMediaItem(
|
||||||
|
projectId: string,
|
||||||
|
id: string
|
||||||
|
): Promise<MediaItem | null> {
|
||||||
|
const { mediaMetadataAdapter, mediaFilesAdapter } =
|
||||||
|
this.getProjectMediaAdapters(projectId);
|
||||||
|
|
||||||
const [file, metadata] = await Promise.all([
|
const [file, metadata] = await Promise.all([
|
||||||
this.mediaFilesAdapter.get(id),
|
mediaFilesAdapter.get(id),
|
||||||
this.mediaMetadataAdapter.get(id),
|
mediaMetadataAdapter.get(id),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!file || !metadata) return null;
|
if (!file || !metadata) return null;
|
||||||
@ -126,12 +154,14 @@ class StorageService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async loadAllMediaItems(): Promise<MediaItem[]> {
|
async loadAllMediaItems(projectId: string): Promise<MediaItem[]> {
|
||||||
const mediaIds = await this.mediaMetadataAdapter.list();
|
const { mediaMetadataAdapter } = this.getProjectMediaAdapters(projectId);
|
||||||
|
|
||||||
|
const mediaIds = await mediaMetadataAdapter.list();
|
||||||
const mediaItems: MediaItem[] = [];
|
const mediaItems: MediaItem[] = [];
|
||||||
|
|
||||||
for (const id of mediaIds) {
|
for (const id of mediaIds) {
|
||||||
const item = await this.loadMediaItem(id);
|
const item = await this.loadMediaItem(projectId, id);
|
||||||
if (item) {
|
if (item) {
|
||||||
mediaItems.push(item);
|
mediaItems.push(item);
|
||||||
}
|
}
|
||||||
@ -140,41 +170,90 @@ class StorageService {
|
|||||||
return mediaItems;
|
return mediaItems;
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteMediaItem(id: string): Promise<void> {
|
async deleteMediaItem(projectId: string, id: string): Promise<void> {
|
||||||
|
const { mediaMetadataAdapter, mediaFilesAdapter } =
|
||||||
|
this.getProjectMediaAdapters(projectId);
|
||||||
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
this.mediaFilesAdapter.remove(id),
|
mediaFilesAdapter.remove(id),
|
||||||
this.mediaMetadataAdapter.remove(id),
|
mediaMetadataAdapter.remove(id),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async deleteProjectMedia(projectId: string): Promise<void> {
|
||||||
|
const { mediaMetadataAdapter, mediaFilesAdapter } =
|
||||||
|
this.getProjectMediaAdapters(projectId);
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
mediaMetadataAdapter.clear(),
|
||||||
|
mediaFilesAdapter.clear(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Timeline operations - now project-specific
|
||||||
|
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
|
// Utility methods
|
||||||
async clearAllData(): Promise<void> {
|
async clearAllData(): Promise<void> {
|
||||||
await Promise.all([
|
// Clear all projects
|
||||||
this.projectsAdapter.clear(),
|
await this.projectsAdapter.clear();
|
||||||
this.mediaMetadataAdapter.clear(),
|
|
||||||
this.mediaFilesAdapter.clear(),
|
// Note: Project-specific media and timelines will be cleaned up when projects are deleted
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getStorageInfo(): Promise<{
|
async getStorageInfo(): Promise<{
|
||||||
projects: number;
|
projects: number;
|
||||||
mediaItems: number;
|
|
||||||
isOPFSSupported: boolean;
|
isOPFSSupported: boolean;
|
||||||
isIndexedDBSupported: boolean;
|
isIndexedDBSupported: boolean;
|
||||||
}> {
|
}> {
|
||||||
const [projectIds, mediaIds] = await Promise.all([
|
const projectIds = await this.projectsAdapter.list();
|
||||||
this.projectsAdapter.list(),
|
|
||||||
this.mediaMetadataAdapter.list(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
projects: projectIds.length,
|
projects: projectIds.length,
|
||||||
mediaItems: mediaIds.length,
|
|
||||||
isOPFSSupported: this.isOPFSSupported(),
|
isOPFSSupported: this.isOPFSSupported(),
|
||||||
isIndexedDBSupported: this.isIndexedDBSupported(),
|
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
|
// Check browser support
|
||||||
isOPFSSupported(): boolean {
|
isOPFSSupported(): boolean {
|
||||||
return OPFSAdapter.isSupported();
|
return OPFSAdapter.isSupported();
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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: [] });
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
@ -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) => {
|
||||||
|
@ -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: [] });
|
||||||
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
@ -4,4 +4,5 @@ export interface TProject {
|
|||||||
thumbnail: string;
|
thumbnail: string;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
|
mediaItems?: string[];
|
||||||
}
|
}
|
||||||
|
@ -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 };
|
||||||
|
}
|
||||||
|
Reference in New Issue
Block a user