diff --git a/components/dialogs/ProjectsDialog.tsx b/components/dialogs/ProjectsDialog.tsx new file mode 100644 index 0000000..b8cd5bd --- /dev/null +++ b/components/dialogs/ProjectsDialog.tsx @@ -0,0 +1,142 @@ +'use client'; + +import * as React from 'react'; +import { X, Plus, Trash2, Copy, FolderOpen } from 'lucide-react'; +import { Button } from '@/components/ui/Button'; +import type { ProjectMetadata } from '@/lib/storage/db'; +import { formatDuration } from '@/lib/audio/decoder'; + +export interface ProjectsDialogProps { + open: boolean; + onClose: () => void; + projects: ProjectMetadata[]; + onNewProject: () => void; + onLoadProject: (projectId: string) => void; + onDeleteProject: (projectId: string) => void; + onDuplicateProject: (projectId: string) => void; +} + +export function ProjectsDialog({ + open, + onClose, + projects, + onNewProject, + onLoadProject, + onDeleteProject, + onDuplicateProject, +}: ProjectsDialogProps) { + if (!open) return null; + + const formatDate = (timestamp: number) => { + return new Date(timestamp).toLocaleString(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); + }; + + return ( +
+
+ {/* Header */} +
+

Projects

+
+ + +
+
+ + {/* Projects List */} +
+ {projects.length === 0 ? ( +
+ +

+ No projects yet +

+

+ Create your first project to get started +

+ +
+ ) : ( +
+ {projects.map((project) => ( +
+
+
+

+ {project.name} +

+ {project.description && ( +

+ {project.description} +

+ )} +
+ {project.trackCount} tracks + {formatDuration(project.duration)} + {project.sampleRate / 1000}kHz + Updated {formatDate(project.updatedAt)} +
+
+ +
+ + + +
+
+
+ ))} +
+ )} +
+
+
+ ); +} diff --git a/lib/storage/db.ts b/lib/storage/db.ts new file mode 100644 index 0000000..cbe532b --- /dev/null +++ b/lib/storage/db.ts @@ -0,0 +1,193 @@ +/** + * IndexedDB database for project storage + */ + +export const DB_NAME = 'audio-editor-db'; +export const DB_VERSION = 1; + +export interface ProjectMetadata { + id: string; + name: string; + description?: string; + createdAt: number; + updatedAt: number; + duration: number; // Total project duration in seconds + sampleRate: number; + trackCount: number; + thumbnail?: string; // Base64 encoded waveform thumbnail +} + +export interface SerializedAudioBuffer { + sampleRate: number; + length: number; + numberOfChannels: number; + channelData: Float32Array[]; // Array of channel data +} + +export interface SerializedTrack { + id: string; + name: string; + color: string; + volume: number; + pan: number; + muted: boolean; + soloed: boolean; + collapsed: boolean; + height: number; + audioBuffer: SerializedAudioBuffer | null; + effects: any[]; // Effect chain + automation: any; // Automation data + recordEnabled: boolean; +} + +export interface ProjectData { + metadata: ProjectMetadata; + tracks: SerializedTrack[]; + settings: { + zoom: number; + currentTime: number; + sampleRate: number; + }; +} + +/** + * Initialize IndexedDB database + */ +export function initDB(): Promise { + return new Promise((resolve, reject) => { + const request = indexedDB.open(DB_NAME, DB_VERSION); + + request.onerror = () => reject(request.error); + request.onsuccess = () => resolve(request.result); + + request.onupgradeneeded = (event) => { + const db = (event.target as IDBOpenDBRequest).result; + + // Create projects object store + if (!db.objectStoreNames.contains('projects')) { + const projectStore = db.createObjectStore('projects', { keyPath: 'metadata.id' }); + projectStore.createIndex('updatedAt', 'metadata.updatedAt', { unique: false }); + projectStore.createIndex('name', 'metadata.name', { unique: false }); + } + + // Create audio buffers object store (for large files) + if (!db.objectStoreNames.contains('audioBuffers')) { + db.createObjectStore('audioBuffers', { keyPath: 'id' }); + } + }; + }); +} + +/** + * Get all projects (metadata only for list view) + */ +export async function getAllProjects(): Promise { + const db = await initDB(); + + return new Promise((resolve, reject) => { + const transaction = db.transaction(['projects'], 'readonly'); + const store = transaction.objectStore('projects'); + const index = store.index('updatedAt'); + const request = index.openCursor(null, 'prev'); // Most recent first + + const projects: ProjectMetadata[] = []; + + request.onsuccess = () => { + const cursor = request.result; + if (cursor) { + projects.push(cursor.value.metadata); + cursor.continue(); + } else { + resolve(projects); + } + }; + + request.onerror = () => reject(request.error); + }); +} + +/** + * Save project to IndexedDB + */ +export async function saveProject(project: ProjectData): Promise { + const db = await initDB(); + + return new Promise((resolve, reject) => { + const transaction = db.transaction(['projects'], 'readwrite'); + const store = transaction.objectStore('projects'); + const request = store.put(project); + + request.onsuccess = () => resolve(); + request.onerror = () => reject(request.error); + }); +} + +/** + * Load project from IndexedDB + */ +export async function loadProject(projectId: string): Promise { + const db = await initDB(); + + return new Promise((resolve, reject) => { + const transaction = db.transaction(['projects'], 'readonly'); + const store = transaction.objectStore('projects'); + const request = store.get(projectId); + + request.onsuccess = () => resolve(request.result || null); + request.onerror = () => reject(request.error); + }); +} + +/** + * Delete project from IndexedDB + */ +export async function deleteProject(projectId: string): Promise { + const db = await initDB(); + + return new Promise((resolve, reject) => { + const transaction = db.transaction(['projects'], 'readwrite'); + const store = transaction.objectStore('projects'); + const request = store.delete(projectId); + + request.onsuccess = () => resolve(); + request.onerror = () => reject(request.error); + }); +} + +/** + * Serialize AudioBuffer for storage + */ +export function serializeAudioBuffer(buffer: AudioBuffer): SerializedAudioBuffer { + const channelData: Float32Array[] = []; + + for (let i = 0; i < buffer.numberOfChannels; i++) { + channelData.push(new Float32Array(buffer.getChannelData(i))); + } + + return { + sampleRate: buffer.sampleRate, + length: buffer.length, + numberOfChannels: buffer.numberOfChannels, + channelData, + }; +} + +/** + * Deserialize AudioBuffer from storage + */ +export function deserializeAudioBuffer( + serialized: SerializedAudioBuffer, + audioContext: AudioContext +): AudioBuffer { + const buffer = audioContext.createBuffer( + serialized.numberOfChannels, + serialized.length, + serialized.sampleRate + ); + + for (let i = 0; i < serialized.numberOfChannels; i++) { + buffer.copyToChannel(new Float32Array(serialized.channelData[i]), i); + } + + return buffer; +} diff --git a/lib/storage/projects.ts b/lib/storage/projects.ts new file mode 100644 index 0000000..3477152 --- /dev/null +++ b/lib/storage/projects.ts @@ -0,0 +1,190 @@ +/** + * Project management service + */ + +import type { Track } from '@/types/track'; +import { + saveProject, + loadProject, + getAllProjects, + deleteProject, + serializeAudioBuffer, + deserializeAudioBuffer, + type ProjectData, + type ProjectMetadata, + type SerializedTrack, +} from './db'; +import { getAudioContext } from '../audio/context'; +import { generateId } from '../audio/effects/chain'; + +/** + * Generate unique project ID + */ +export function generateProjectId(): string { + return `project_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; +} + +/** + * Convert tracks to serialized format + */ +function serializeTracks(tracks: Track[]): SerializedTrack[] { + return tracks.map(track => ({ + id: track.id, + name: track.name, + color: track.color, + volume: track.volume, + pan: track.pan, + muted: track.mute, + soloed: track.solo, + collapsed: track.collapsed, + height: track.height, + audioBuffer: track.audioBuffer ? serializeAudioBuffer(track.audioBuffer) : null, + effects: track.effectChain?.effects || [], + automation: track.automation, + recordEnabled: track.recordEnabled, + })); +} + +/** + * Convert serialized tracks back to Track format + */ +function deserializeTracks(serialized: SerializedTrack[]): Track[] { + const audioContext = getAudioContext(); + + return serialized.map(track => ({ + id: track.id, + name: track.name, + color: track.color, + volume: track.volume, + pan: track.pan, + mute: track.muted, + solo: track.soloed, + collapsed: track.collapsed, + height: track.height, + audioBuffer: track.audioBuffer ? deserializeAudioBuffer(track.audioBuffer, audioContext) : null, + effectChain: { + id: generateId(), + name: `${track.name} FX`, + effects: track.effects, + }, + automation: track.automation, + recordEnabled: track.recordEnabled, + selected: false, + showEffects: false, + selection: null, // Reset selection on load + })); +} + +/** + * Calculate total project duration + */ +function calculateDuration(tracks: Track[]): number { + let maxDuration = 0; + + for (const track of tracks) { + if (track.audioBuffer) { + maxDuration = Math.max(maxDuration, track.audioBuffer.duration); + } + } + + return maxDuration; +} + +/** + * Save current project state + */ +export async function saveCurrentProject( + projectId: string | null, + projectName: string, + tracks: Track[], + settings: { + zoom: number; + currentTime: number; + sampleRate: number; + }, + description?: string +): Promise { + const id = projectId || generateProjectId(); + const now = Date.now(); + + const metadata: ProjectMetadata = { + id, + name: projectName, + description, + createdAt: projectId ? (await loadProject(id))?.metadata.createdAt || now : now, + updatedAt: now, + duration: calculateDuration(tracks), + sampleRate: settings.sampleRate, + trackCount: tracks.length, + }; + + const projectData: ProjectData = { + metadata, + tracks: serializeTracks(tracks), + settings, + }; + + await saveProject(projectData); + return id; +} + +/** + * Load project and restore state + */ +export async function loadProjectById(projectId: string): Promise<{ + tracks: Track[]; + settings: { + zoom: number; + currentTime: number; + sampleRate: number; + }; + metadata: ProjectMetadata; +} | null> { + const project = await loadProject(projectId); + if (!project) return null; + + return { + tracks: deserializeTracks(project.tracks), + settings: project.settings, + metadata: project.metadata, + }; +} + +/** + * Get list of all projects + */ +export async function listProjects(): Promise { + return getAllProjects(); +} + +/** + * Delete a project + */ +export async function removeProject(projectId: string): Promise { + return deleteProject(projectId); +} + +/** + * Duplicate a project + */ +export async function duplicateProject(sourceProjectId: string, newName: string): Promise { + const project = await loadProject(sourceProjectId); + if (!project) throw new Error('Project not found'); + + const newId = generateProjectId(); + const now = Date.now(); + + const newProject: ProjectData = { + ...project, + metadata: { + ...project.metadata, + id: newId, + name: newName, + createdAt: now, + updatedAt: now, + }, + }; + + await saveProject(newProject); + return newId; +}