import { TProject } from "@/types/project"; import { MediaItem } from "@/stores/media-store"; import { IndexedDBAdapter } from "./indexeddb-adapter"; import { OPFSAdapter } from "./opfs-adapter"; import { MediaFileData, StorageConfig, SerializedProject, TimelineData, } from "./types"; import { TimelineTrack } from "@/types/timeline"; import { SavedSoundsData, SavedSound, SoundEffect } from "@/types/sounds"; class StorageService { private projectsAdapter: IndexedDBAdapter; private savedSoundsAdapter: IndexedDBAdapter; private config: StorageConfig; constructor() { this.config = { projectsDb: "video-editor-projects", mediaDb: "video-editor-media", timelineDb: "video-editor-timelines", savedSoundsDb: "video-editor-saved-sounds", version: 1, }; this.projectsAdapter = new IndexedDBAdapter( this.config.projectsDb, "projects", this.config.version ); this.savedSoundsAdapter = new IndexedDBAdapter( this.config.savedSoundsDb, "saved-sounds", this.config.version ); } // Helper to get project-specific media adapters private getProjectMediaAdapters(projectId: string) { const mediaMetadataAdapter = new IndexedDBAdapter( `${this.config.mediaDb}-${projectId}`, "media-metadata", this.config.version ); const mediaFilesAdapter = new OPFSAdapter(`media-files-${projectId}`); return { mediaMetadataAdapter, mediaFilesAdapter }; } // Helper to get project-specific timeline adapter private getProjectTimelineAdapter(projectId: string) { return new IndexedDBAdapter( `${this.config.timelineDb}-${projectId}`, "timeline", this.config.version ); } // Project operations async saveProject(project: TProject): Promise { // Convert TProject to serializable format const serializedProject: SerializedProject = { id: project.id, name: project.name, thumbnail: project.thumbnail, createdAt: project.createdAt.toISOString(), updatedAt: project.updatedAt.toISOString(), backgroundColor: project.backgroundColor, backgroundType: project.backgroundType, blurIntensity: project.blurIntensity, bookmarks: project.bookmarks, fps: project.fps, canvasSize: project.canvasSize, canvasMode: project.canvasMode, }; await this.projectsAdapter.set(project.id, serializedProject); } async loadProject(id: string): Promise { const serializedProject = await this.projectsAdapter.get(id); if (!serializedProject) return null; // Convert back to TProject format return { id: serializedProject.id, name: serializedProject.name, thumbnail: serializedProject.thumbnail, createdAt: new Date(serializedProject.createdAt), updatedAt: new Date(serializedProject.updatedAt), backgroundColor: serializedProject.backgroundColor, backgroundType: serializedProject.backgroundType, blurIntensity: serializedProject.blurIntensity, bookmarks: serializedProject.bookmarks, fps: serializedProject.fps, canvasSize: serializedProject.canvasSize, canvasMode: serializedProject.canvasMode, }; } async loadAllProjects(): Promise { const projectIds = await this.projectsAdapter.list(); const projects: TProject[] = []; for (const id of projectIds) { const project = await this.loadProject(id); if (project) { projects.push(project); } } // Sort by last updated (most recent first) return projects.sort( (a, b) => b.updatedAt.getTime() - a.updatedAt.getTime() ); } async deleteProject(id: string): Promise { await this.projectsAdapter.remove(id); } // Media operations - now project-specific async saveMediaItem(projectId: string, mediaItem: MediaItem): Promise { const { mediaMetadataAdapter, mediaFilesAdapter } = this.getProjectMediaAdapters(projectId); // Save file to project-specific OPFS await mediaFilesAdapter.set(mediaItem.id, mediaItem.file); // Save metadata to project-specific IndexedDB const metadata: MediaFileData = { id: mediaItem.id, name: mediaItem.name, type: mediaItem.type, size: mediaItem.file.size, lastModified: mediaItem.file.lastModified, width: mediaItem.width, height: mediaItem.height, duration: mediaItem.duration, ephemeral: mediaItem.ephemeral, }; await mediaMetadataAdapter.set(mediaItem.id, metadata); } async loadMediaItem( projectId: string, id: string ): Promise { const { mediaMetadataAdapter, mediaFilesAdapter } = this.getProjectMediaAdapters(projectId); const [file, metadata] = await Promise.all([ mediaFilesAdapter.get(id), mediaMetadataAdapter.get(id), ]); if (!file || !metadata) return null; let url: string; if (metadata.type === "image" && (!file.type || file.type === "")) { try { const text = await file.text(); if (text.trim().startsWith(" { const { mediaMetadataAdapter } = this.getProjectMediaAdapters(projectId); const mediaIds = await mediaMetadataAdapter.list(); const mediaItems: MediaItem[] = []; for (const id of mediaIds) { const item = await this.loadMediaItem(projectId, id); if (item) { mediaItems.push(item); } } return mediaItems; } async deleteMediaItem(projectId: string, id: string): Promise { const { mediaMetadataAdapter, mediaFilesAdapter } = this.getProjectMediaAdapters(projectId); await Promise.all([ mediaFilesAdapter.remove(id), mediaMetadataAdapter.remove(id), ]); } async deleteProjectMedia(projectId: string): Promise { 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 { const timelineAdapter = this.getProjectTimelineAdapter(projectId); const timelineData: TimelineData = { tracks, lastModified: new Date().toISOString(), }; await timelineAdapter.set("timeline", timelineData); } async loadTimeline(projectId: string): Promise { const timelineAdapter = this.getProjectTimelineAdapter(projectId); const timelineData = await timelineAdapter.get("timeline"); return timelineData ? timelineData.tracks : null; } async deleteProjectTimeline(projectId: string): Promise { const timelineAdapter = this.getProjectTimelineAdapter(projectId); await timelineAdapter.remove("timeline"); } // Utility methods async clearAllData(): Promise { // 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, }; } async loadSavedSounds(): Promise { try { const savedSoundsData = await this.savedSoundsAdapter.get("user-sounds"); return ( savedSoundsData || { sounds: [], lastModified: new Date().toISOString(), } ); } catch (error) { console.error("Failed to load saved sounds:", error); return { sounds: [], lastModified: new Date().toISOString() }; } } async saveSoundEffect(soundEffect: SoundEffect): Promise { try { const currentData = await this.loadSavedSounds(); // Check if sound is already saved if (currentData.sounds.some((sound) => sound.id === soundEffect.id)) { return; // Already saved } const savedSound: SavedSound = { id: soundEffect.id, name: soundEffect.name, username: soundEffect.username, previewUrl: soundEffect.previewUrl, downloadUrl: soundEffect.downloadUrl, duration: soundEffect.duration, tags: soundEffect.tags, license: soundEffect.license, savedAt: new Date().toISOString(), }; const updatedData: SavedSoundsData = { sounds: [...currentData.sounds, savedSound], lastModified: new Date().toISOString(), }; await this.savedSoundsAdapter.set("user-sounds", updatedData); } catch (error) { console.error("Failed to save sound effect:", error); throw error; } } async removeSavedSound(soundId: number): Promise { try { const currentData = await this.loadSavedSounds(); const updatedData: SavedSoundsData = { sounds: currentData.sounds.filter((sound) => sound.id !== soundId), lastModified: new Date().toISOString(), }; await this.savedSoundsAdapter.set("user-sounds", updatedData); } catch (error) { console.error("Failed to remove saved sound:", error); throw error; } } async isSoundSaved(soundId: number): Promise { try { const currentData = await this.loadSavedSounds(); return currentData.sounds.some((sound) => sound.id === soundId); } catch (error) { console.error("Failed to check if sound is saved:", error); return false; } } async clearSavedSounds(): Promise { try { await this.savedSoundsAdapter.remove("user-sounds"); } catch (error) { console.error("Failed to clear saved sounds:", error); throw error; } } // 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 };