diff --git a/src/main/MainApp.ts b/src/main/MainApp.ts index bb24b57..481b178 100644 --- a/src/main/MainApp.ts +++ b/src/main/MainApp.ts @@ -138,9 +138,9 @@ export class MainApp { ipcMain.removeHandler(IPC_CHANNELS.libraryMove); ipcMain.removeHandler(IPC_CHANNELS.libraryOrganize); ipcMain.removeHandler(IPC_CHANNELS.libraryBuffer); - ipcMain.removeHandler(IPC_CHANNELS.libraryMetadata); - ipcMain.removeHandler(IPC_CHANNELS.libraryMetadataSuggestions); - ipcMain.removeHandler(IPC_CHANNELS.libraryUpdateMetadata); + ipcMain.removeHandler(IPC_CHANNELS.libraryMetadata); + ipcMain.removeHandler(IPC_CHANNELS.libraryMetadataSuggestions); + ipcMain.removeHandler(IPC_CHANNELS.libraryUpdateMetadata); ipcMain.removeHandler(IPC_CHANNELS.libraryWaveformPreview); ipcMain.removeHandler(IPC_CHANNELS.tagsUpdate); ipcMain.removeHandler(IPC_CHANNELS.categoriesList); @@ -163,7 +163,7 @@ export class MainApp { * Creates the renderer window and loads the UI. */ private createWindow(): void { - const preloadPath = this.resolvePreloadPath(); + const preloadPath = this.resolvePreloadPath(); this.mainWindow = new BrowserWindow({ width: 1280, height: 800, @@ -186,16 +186,18 @@ export class MainApp { this.mainWindow = null; }); - const devServerUrl = (globalThis as { process?: { env?: Record } }).process?.env?.VITE_DEV_SERVER_URL; + const devServerUrl = (globalThis as { process?: { env?: Record } }).process?.env + ?.VITE_DEV_SERVER_URL; if (devServerUrl) { this.mainWindow.loadURL(devServerUrl).catch((error: unknown) => { + // eslint-disable-next-line no-console -- Logging is useful during development to diagnose boot issues. console.error('Failed to load renderer URL', error); }); } else { const rendererIndex = this.resolveRendererIndex(); - this.mainWindow.loadFile(rendererIndex).catch((error: unknown) => { - console.error('Failed to load renderer bundle', error); - }); + this.mainWindow + .loadFile(rendererIndex) + .catch((error: unknown) => console.error('Failed to load renderer bundle', error)); } } @@ -212,7 +214,7 @@ export class MainApp { }); ipcMain.handle(IPC_CHANNELS.dialogSelectLibrary, async () => { - const options: Electron.OpenDialogOptions = { properties: ['openDirectory'] }; + const options: Electron.OpenDialogOptions = { properties: ['openDirectory'] }; const targetWindow = this.mainWindow; const result = targetWindow ? await dialog.showOpenDialog(targetWindow, options) @@ -239,8 +241,8 @@ export class MainApp { ipcMain.handle( IPC_CHANNELS.libraryOrganize, - async (_event: IpcMainInvokeEvent, fileId: number, metadata: { customName?: string | null; author?: string | null; copyright?: string | null; rating?: number }) => - this.requireLibrary().organizeFile(fileId, metadata) + async (_event: IpcMainInvokeEvent, fileId: number, metadata: { customName?: string; author?: string; copyright?: string; rating?: number }) => + this.requireLibrary().organizeFile(fileId, metadata) ); ipcMain.handle( @@ -283,7 +285,7 @@ export class MainApp { ipcMain.handle( IPC_CHANNELS.libraryUpdateMetadata, - async (_event: IpcMainInvokeEvent, fileId: number, metadata: { author?: string | null; copyright?: string | null; rating?: number }) => + async (_event: IpcMainInvokeEvent, fileId: number, metadata: { author?: string; copyright?: string; rating?: number }) => this.requireLibrary().updateFileMetadata(fileId, metadata) ); } diff --git a/src/main/services/LibraryService.ts b/src/main/services/LibraryService.ts index 2527925..39ccb15 100644 --- a/src/main/services/LibraryService.ts +++ b/src/main/services/LibraryService.ts @@ -4,7 +4,7 @@ import { createHash } from 'node:crypto'; import path from 'node:path'; import fg from 'fast-glob'; import { parse } from 'csv-parse/sync'; -import { AppSettings, AudioBufferPayload, AudioFileSummary, CategoryRecord, LibraryScanSummary, TagUpdatePayload } from '../../shared/models'; +import { AppSettings, AudioBufferPayload, AudioFileSummary, CategoryRecord, LibraryScanSummary, SplitSegmentRequest, TagUpdatePayload } from '../../shared/models'; import { DatabaseService, FileRecordInput } from './DatabaseService'; import { SettingsService } from './SettingsService'; import { TagService } from './TagService'; @@ -292,7 +292,7 @@ export class LibraryService { */ public async getWaveformPreview(fileId: number, pointCount = 160): Promise<{ samples: number[]; rms: number }> { const record = this.database.getFileById(fileId); - const effectivePoints = Math.min(Math.max(pointCount ?? 160, 32), 512); + const effectivePoints = Math.min(Math.max(pointCount ?? 160, 32), 16384); const cacheHit = this.waveformPreviewCache.get(fileId); if (cacheHit && cacheHit.modifiedAt === record.modifiedAt && cacheHit.pointCount === effectivePoints) { return { samples: cacheHit.samples, rms: cacheHit.rms }; @@ -636,6 +636,199 @@ export class LibraryService { this.search.rebuildIndex(); } + /** + * Splits an audio file into multiple segments, writing each segment to disk and registering it in the library. + */ + public async splitFile(fileId: number, requestSegments: SplitSegmentRequest[]): Promise { + if (!Array.isArray(requestSegments) || requestSegments.length === 0) { + return []; + } + + const record = this.database.getFileById(fileId); + const fileBuffer = await fs.readFile(record.absolutePath); + const wave = new WaveFile(fileBuffer); + const format = (wave as WaveFile & { fmt?: { sampleRate?: number; bitsPerSample?: number } }).fmt; + const rawSamples = wave.getSamples(false, Float64Array) as Float64Array | Float64Array[]; + const channels = Array.isArray(rawSamples) ? rawSamples : [rawSamples]; + if (channels.length === 0 || channels[0].length === 0) { + throw new Error('Unable to split file: no audio data available.'); + } + + const sampleRate = format?.sampleRate ?? record.sampleRate ?? null; + if (!sampleRate) { + throw new Error('Unable to split file: missing sample rate information.'); + } + + const sourceSampleCount = channels[0].length; + const totalDurationMs = Math.round((sourceSampleCount / sampleRate) * 1000); + const bitDepthNumeric = (() => { + const explicit = format?.bitsPerSample; + if (typeof explicit === 'number' && Number.isFinite(explicit) && explicit > 0) { + return explicit; + } + if (typeof record.bitDepth === 'number' && Number.isFinite(record.bitDepth) && record.bitDepth > 0) { + return record.bitDepth; + } + const parsedFromText = Number.parseInt(typeof wave.bitDepth === 'string' ? wave.bitDepth : '', 10); + return Number.isFinite(parsedFromText) && parsedFromText > 0 ? parsedFromText : null; + })(); + const bitDepthText = (() => { + if (typeof wave.bitDepth === 'string' && wave.bitDepth.trim().length > 0) { + return wave.bitDepth.trim(); + } + if (typeof bitDepthNumeric === 'number') { + return bitDepthNumeric.toString(); + } + return '16'; + })(); + const container = (wave as WaveFile & { container?: string }).container ?? 'RIFF'; + + let originalMetadata: { author?: string | null; rating?: number; title?: string | null } = {}; + try { + const metadata = this.tagService.readMetadata(record.absolutePath); + originalMetadata = { + author: metadata.author ?? null, + rating: metadata.rating ?? undefined, + title: metadata.title ?? null + }; + } catch (error) { + console.warn('Failed to read original metadata before splitting', error); + } + + const normalizedSegments = requestSegments + .map((segment) => { + const startMs = Number.isFinite(segment.startMs) ? Math.max(0, Math.min(Math.floor(segment.startMs), totalDurationMs)) : 0; + const endMs = Number.isFinite(segment.endMs) ? Math.max(0, Math.min(Math.floor(segment.endMs), totalDurationMs)) : startMs; + const safeEnd = Math.max(startMs + 1, endMs); + return { + startMs, + endMs: safeEnd, + label: segment.label?.trim() ?? undefined, + metadata: segment.metadata + }; + }) + .filter((segment) => segment.endMs - segment.startMs >= 5) + .sort((a, b) => a.startMs - b.startMs); + + if (normalizedSegments.length === 0) { + return []; + } + + const libraryRoot = this.settings.ensureLibraryPath(); + const sourceDirectory = path.dirname(record.absolutePath); + const relativeFolder = path.dirname(record.relativePath); + const folderForJoin = relativeFolder === '.' ? '' : relativeFolder; + const baseName = path.basename(record.fileName, path.extname(record.fileName)); + const usedNames = new Set(); + let sequence = 1; + const created: AudioFileSummary[] = []; + + for (const segment of normalizedSegments) { + const startSample = Math.max(0, Math.min(Math.floor((segment.startMs / 1000) * sampleRate), sourceSampleCount - 1)); + const endSample = Math.max( + startSample + 1, + Math.min(Math.floor((segment.endMs / 1000) * sampleRate), sourceSampleCount) + ); + if (endSample <= startSample) { + continue; + } + + const segmentChannels = channels.map((channel) => channel.slice(startSample, endSample)); + const nextWave = new WaveFile(); + nextWave.fromScratch(channels.length, sampleRate, bitDepthText, segmentChannels.length === 1 ? segmentChannels[0] : segmentChannels); + (nextWave as WaveFile & { container?: string }).container = container; + const segmentBytes = Buffer.from(nextWave.toBuffer()); + + let segmentFileName = ''; + let segmentAbsolutePath = ''; + for (let attempt = 0; attempt < 1000; attempt += 1) { + const suffix = this.organization.formatSequenceNumber(sequence); + sequence += 1; + const candidateName = `${baseName}_segment${suffix}.wav`; + if (usedNames.has(candidateName)) { + continue; + } + const candidatePath = path.join(sourceDirectory, candidateName); + const exists = await this.pathExists(candidatePath); + if (exists) { + continue; + } + segmentFileName = candidateName; + segmentAbsolutePath = candidatePath; + usedNames.add(candidateName); + break; + } + + if (!segmentFileName || !segmentAbsolutePath) { + throw new Error('Failed to allocate filename for split segment.'); + } + + this.assertWithinLibrary(libraryRoot, segmentAbsolutePath); + await fs.writeFile(segmentAbsolutePath, segmentBytes); + const stats = await fs.stat(segmentAbsolutePath); + const checksum = createHash('md5').update(segmentBytes).digest('hex'); + const relativePath = this.toLibraryRelativePath(folderForJoin, segmentFileName); + const durationMs = Math.round(((endSample - startSample) / sampleRate) * 1000); + + const resolvedTagsSource = segment.metadata?.tags ?? record.tags; + const resolvedCategoriesSource = segment.metadata?.categories ?? record.categories; + const resolvedTags = Array.isArray(resolvedTagsSource) ? resolvedTagsSource.slice() : record.tags.slice(); + const resolvedCategories = Array.isArray(resolvedCategoriesSource) + ? resolvedCategoriesSource.slice() + : record.categories.slice(); + const fileRecord = this.database.upsertFile({ + absolutePath: segmentAbsolutePath, + relativePath, + fileName: segmentFileName, + displayName: path.basename(segmentFileName, '.wav'), + modifiedAt: stats.mtimeMs, + createdAt: Number.isNaN(stats.birthtimeMs) ? null : stats.birthtimeMs, + size: stats.size, + durationMs, + sampleRate, + bitDepth: bitDepthNumeric, + checksum, + tags: resolvedTags, + categories: resolvedCategories + }); + + const resolvedCustomName = segment.metadata?.customName !== undefined + ? this.normaliseMetadataInput(segment.metadata.customName) + : record.customName ?? null; + const updatedRecord = resolvedCustomName !== fileRecord.customName + ? this.database.updateCustomName(fileRecord.id, resolvedCustomName ?? null) + : fileRecord; + + const resolvedAuthor = segment.metadata?.author !== undefined + ? this.normaliseMetadataInput(segment.metadata.author) + : this.normaliseMetadataInput(originalMetadata.author ?? null); + const resolvedRating = segment.metadata?.rating !== undefined + ? segment.metadata.rating ?? undefined + : originalMetadata.rating; + + this.tagService.writeMetadataOnly(segmentAbsolutePath, { + tags: resolvedTags, + categories: resolvedCategories, + title: resolvedCustomName ?? updatedRecord.displayName, + author: resolvedAuthor ?? undefined, + rating: resolvedRating + }); + + if (typeof resolvedAuthor === 'string' && resolvedAuthor.length > 0) { + this.updateMetadataSuggestionsCache(resolvedAuthor); + } + + this.waveformPreviewCache.delete(updatedRecord.id); + created.push(updatedRecord); + } + + this.waveformPreviewCache.delete(record.id); + this.resetMetadataSuggestionsCache(); + this.search.rebuildIndex(); + + return created; + } + /** * Returns the previously parsed UCS category catalog. */ diff --git a/src/main/services/TagService.ts b/src/main/services/TagService.ts index cbe7795..d8daf09 100644 --- a/src/main/services/TagService.ts +++ b/src/main/services/TagService.ts @@ -11,14 +11,22 @@ export class TagService { /** * Applies tags and categories to a file record and embeds the metadata into the WAV container. + * Preserves existing author, title, and rating fields. */ public applyTagging(fileId: number, tags: string[], categories: string[]): AudioFileSummary { const normalisedTags = this.normaliseValues(tags); const normalisedCategories = this.normaliseValues(categories); const updated = this.database.updateTagging(fileId, normalisedTags, normalisedCategories); + + // Read existing metadata to preserve author, title, and rating + const existing = this.readMetadata(updated.absolutePath); + this.writeWaveMetadata(updated.absolutePath, { tags: normalisedTags, - categories: normalisedCategories + categories: normalisedCategories, + title: existing.title, + author: existing.author, + rating: existing.rating }); return updated; } @@ -69,15 +77,16 @@ export class TagService { /** * Writes tags and categories to an organized file as embedded metadata. + * All metadata fields are required to prevent accidental clearing. */ public writeMetadataOnly( filePath: string, metadata: { tags: string[]; categories: string[]; - title?: string | null; - author?: string | null; - rating?: number; + title: string | null | undefined; + author: string | null | undefined; + rating: number | undefined; } ): void { this.writeWaveMetadata(filePath, metadata); @@ -85,15 +94,16 @@ export class TagService { /** * Writes a simple INFO chunk with the provided metadata. Failures are swallowed so DB state remains authoritative. + * All metadata fields must be explicitly provided to prevent accidental data loss. */ private writeWaveMetadata( filePath: string, metadata: { tags: string[]; categories: string[]; - title?: string | null; - author?: string | null; - rating?: number; + title: string | null | undefined; + author: string | null | undefined; + rating: number | undefined; } ): void { try { diff --git a/src/preload/index.ts b/src/preload/index.ts index 3382498..c266bbe 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -37,7 +37,7 @@ const api: RendererApi = { async moveFile(fileId: number, targetRelativeDirectory: string): Promise { return ipcRenderer.invoke(IPC_CHANNELS.libraryMove, fileId, targetRelativeDirectory); }, - async organizeFile(fileId: number, metadata: { customName?: string | null; author?: string | null; copyright?: string | null; rating?: number }): Promise { + async organizeFile(fileId: number, metadata: { customName?: string; author?: string; copyright?: string; rating?: number }): Promise { return ipcRenderer.invoke(IPC_CHANNELS.libraryOrganize, fileId, metadata); }, async updateCustomName(fileId: number, customName: string | null): Promise { @@ -70,7 +70,7 @@ const api: RendererApi = { async listMetadataSuggestions(): Promise<{ authors: string[]; copyrights: string[] }> { return ipcRenderer.invoke(IPC_CHANNELS.libraryMetadataSuggestions); }, - async updateFileMetadata(fileId: number, metadata: { author?: string | null; copyright?: string | null; rating?: number }): Promise { + async updateFileMetadata(fileId: number, metadata: { author?: string; copyright?: string; rating?: number }): Promise { return ipcRenderer.invoke(IPC_CHANNELS.libraryUpdateMetadata, fileId, metadata); }, onMenuAction(channel: string, callback: () => void): () => void { diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 2e55832..b3b1044 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -6,11 +6,14 @@ import CategorySidebar from './components/CategorySidebar'; import AudioPlayer from './components/AudioPlayer'; import SettingsDialog from './components/SettingsDialog'; import DuplicateComparisonDialog from './components/DuplicateComparisonDialog'; +import EditModePanel from './components/edit/EditModePanel'; import { useLibrarySnapshot } from './hooks/useLibrarySnapshot'; import { loadPlayerFile, usePlayerSnapshot } from './hooks/usePlayerSnapshot'; import { libraryStore, type CategoryFilterValue } from './stores/LibraryStore'; import type { AudioFileSummary } from '../../shared/models'; +type RightPanelTab = 'listen' | 'edit'; + /** * Root renderer component orchestrating layout and interactions. */ @@ -21,6 +24,7 @@ function App(): JSX.Element { const [duplicateGroups, setDuplicateGroups] = useState<{ checksum: string; files: AudioFileSummary[] }[] | null>(null); const [showStatusMessage, setShowStatusMessage] = useState(false); const [statusFadingOut, setStatusFadingOut] = useState(false); + const [activeTab, setActiveTab] = useState('listen'); const statusMessage = useMemo(() => { if (!library.lastScan) { @@ -132,10 +136,6 @@ function App(): JSX.Element { libraryStore.selectFile(fileId, options); }; - const handleSelectAllFiles = () => { - libraryStore.selectAllVisibleFiles(); - }; - const handlePlayFile = (file: AudioFileSummary) => { loadPlayerFile(file, true); }; @@ -158,7 +158,7 @@ function App(): JSX.Element { await libraryStore.moveFile(selectedFile.id, targetDirectory); }; - const handleOrganize = async (metadata: { customName?: string | null; author?: string | null; copyright?: string | null; rating?: number }) => { + const handleOrganize = async (metadata: { customName?: string; author?: string; copyright?: string; rating?: number }) => { if (!selectedFile) { return; } @@ -187,11 +187,11 @@ function App(): JSX.Element { await libraryStore.updateCustomName(fileId, customName); }; - const handleMultiFileOrganize = async (fileId: number, metadata: { customName?: string | null; author?: string | null; copyright?: string | null; rating?: number }) => { + const handleMultiFileOrganize = async (fileId: number, metadata: { customName?: string; author?: string; copyright?: string; rating?: number }) => { await libraryStore.organizeFile(fileId, metadata); }; - const handleMultiFileUpdateMetadata = async (fileId: number, metadata: { author?: string | null; copyright?: string | null; rating?: number }) => { + const handleMultiFileUpdateMetadata = async (fileId: number, metadata: { author?: string; copyright?: string; rating?: number }) => { await libraryStore.updateFileMetadata(fileId, metadata); }; @@ -217,6 +217,18 @@ function App(): JSX.Element { setSettingsOpen(false); }; + const handleEditModeClose = () => { + setActiveTab('listen'); + }; + + const handleEditModeSplitComplete = async (created: AudioFileSummary[]) => { + await libraryStore.rescan(); + setActiveTab('listen'); + if (created.length > 0) { + libraryStore.selectFile(created[0].id); + } + }; + return (
{showStatusMessage && statusMessage && ( @@ -240,7 +252,6 @@ function App(): JSX.Element { onPlay={handlePlayFile} searchValue={library.searchQuery} onSearchChange={handleSearch} - onSelectAll={handleSelectAllFiles} />
{isMultiSelect ? ( @@ -255,17 +266,45 @@ function App(): JSX.Element { /> ) : ( <> - - +
+ + +
+ {activeTab === 'listen' ? ( + <> + + + + ) : selectedFile ? ( + + ) : null} )}
diff --git a/src/renderer/src/components/FileDetailPanel.tsx b/src/renderer/src/components/FileDetailPanel.tsx index 00419c1..216b88f 100644 --- a/src/renderer/src/components/FileDetailPanel.tsx +++ b/src/renderer/src/components/FileDetailPanel.tsx @@ -10,8 +10,8 @@ export interface FileDetailPanelProps { categories: CategoryRecord[]; onRename(newName: string): Promise; onMove(targetRelativeDirectory: string): Promise; - /** Organizes a file with optional metadata fields (customName, author, rating 1-5). */ - onOrganize(metadata: { customName?: string | null; author?: string | null; rating?: number }): Promise; + /** Organizes a file with optional metadata fields (customName, author, copyright, rating 1-5). */ + onOrganize(metadata: { customName?: string; author?: string; copyright?: string; rating?: number }): Promise; onUpdateTags(data: { tags: string[]; categories: string[] }): Promise; onUpdateCustomName(customName: string | null): Promise; metadataSuggestionsVersion: number; @@ -29,8 +29,9 @@ export function FileDetailPanel({ file, categories, onRename, onMove, onOrganize const [busy, setBusy] = useState(false); const [isTagSectionExpanded, setIsTagSectionExpanded] = useState(true); const [suggestions, setSuggestions] = useState<{ authors: string[] }>({ authors: [] }); - const initialMetadataRef = useRef<{ author: string; rating: number; customName: string }>({ + const initialMetadataRef = useRef<{ author: string; copyright: string; rating: number; customName: string }>({ author: '', + copyright: '', rating: 0, customName: '' }); @@ -93,7 +94,7 @@ export function FileDetailPanel({ file, categories, onRename, onMove, onOrganize setAuthorDraft(''); setRatingDraft(0); setIsEditingCustomName(false); - initialMetadataRef.current = { author: '', rating: 0, customName: '' }; + initialMetadataRef.current = { author: '', copyright: '', rating: 0, customName: '' }; return; } @@ -101,7 +102,7 @@ export function FileDetailPanel({ file, categories, onRename, onMove, onOrganize setCustomNameDraft(file.customName ?? ''); setIsEditingCustomName(false); const baseCustomName = (file.customName ?? '').trim(); - initialMetadataRef.current = { author: '', rating: 0, customName: baseCustomName }; + initialMetadataRef.current = { author: '', copyright: '', rating: 0, customName: baseCustomName }; void (async () => { try { @@ -117,6 +118,7 @@ export function FileDetailPanel({ file, categories, onRename, onMove, onOrganize setRatingDraft(ratingValue); initialMetadataRef.current = { author: authorValue, + copyright: '', rating: ratingValue, customName: customNameValue }; @@ -127,7 +129,7 @@ export function FileDetailPanel({ file, categories, onRename, onMove, onOrganize console.error('Failed to read file metadata:', error); setAuthorDraft(''); setRatingDraft(0); - initialMetadataRef.current = { author: '', rating: 0, customName: baseCustomName }; + initialMetadataRef.current = { author: '', copyright: '', rating: 0, customName: baseCustomName }; } })(); }, [appendSuggestion, file?.id]); @@ -190,7 +192,7 @@ export function FileDetailPanel({ file, categories, onRename, onMove, onOrganize } }; - const triggerOrganize = async (overrides?: { author?: string | null; rating?: number; customName?: string | null }) => { + const triggerOrganize = async (overrides?: { author?: string; rating?: number; customName?: string | null }) => { if (!file) { return; } @@ -200,10 +202,11 @@ export function FileDetailPanel({ file, categories, onRename, onMove, onOrganize const nextCustomNameRaw = overrides?.customName !== undefined ? overrides.customName ?? '' : customNameDraft; - const trimmedAuthor = (nextAuthorRaw ?? '').trim(); + const trimmedAuthor = nextAuthorRaw.trim(); const trimmedCustomName = (nextCustomNameRaw ?? '').toString().trim(); const comparisonState = { author: trimmedAuthor, + copyright: '', rating: nextRatingValue, customName: trimmedCustomName }; @@ -212,6 +215,7 @@ export function FileDetailPanel({ file, categories, onRename, onMove, onOrganize if ( previous && previous.author === comparisonState.author && + previous.copyright === comparisonState.copyright && previous.rating === comparisonState.rating && previous.customName === comparisonState.customName ) { @@ -220,18 +224,17 @@ export function FileDetailPanel({ file, categories, onRename, onMove, onOrganize setBusy(true); try { - const authorPayload = trimmedAuthor.length > 0 ? trimmedAuthor : null; if (file.categories.length === 0) { // No categories - just save metadata without organizing await window.api.updateFileMetadata(file.id, { - author: authorPayload, + author: trimmedAuthor.length > 0 ? trimmedAuthor : undefined, rating: comparisonState.rating > 0 ? comparisonState.rating : undefined }); } else { // Has categories - organize the file await onOrganize({ - customName: trimmedCustomName.length > 0 ? trimmedCustomName : null, - author: authorPayload, + customName: trimmedCustomName.length > 0 ? trimmedCustomName : undefined, + author: trimmedAuthor.length > 0 ? trimmedAuthor : undefined, rating: comparisonState.rating > 0 ? comparisonState.rating : undefined }); } diff --git a/src/renderer/src/components/FileList.tsx b/src/renderer/src/components/FileList.tsx index 6dcb779..f763eae 100644 --- a/src/renderer/src/components/FileList.tsx +++ b/src/renderer/src/components/FileList.tsx @@ -9,26 +9,21 @@ export interface FileListProps { onPlay?(file: AudioFileSummary): void; searchValue: string; onSearchChange(value: string): void; - /** - * Invoked when the user requests to select every visible file (Ctrl+A). - */ - onSelectAll?(): void; } /** * Vertical list of WAV files with highlighting for the active selection. */ -export function FileList({ files, selectedId, selectedIds, onSelect, onPlay, searchValue, onSearchChange, onSelectAll }: FileListProps): JSX.Element { +export function FileList({ files, selectedId, selectedIds, onSelect, onPlay, searchValue, onSearchChange }: FileListProps): JSX.Element { const buttonRefs = useRef(new Map()); const waveformCacheRef = useRef>({}); const [, forceWaveformUpdate] = useReducer((value: number) => value + 1, 0); const dragGhostRef = useRef(null); - const dragRotation = useRef(0); - const lastDragX = useRef(0); + const dragRotation = useRef(0); + const lastDragX = useRef(0); const dropTargetPosition = useRef<{ x: number; y: number } | null>(null); - const dragOffset = useRef({ x: 0, y: 0 }); - const dragStartTime = useRef(0); - const listContainerRef = useRef(null); + const dragOffset = useRef<{ x: number; y: number }>({ x: 0, y: 0 }); + const dragStartTime = useRef(0); useEffect(() => { if (selectedId === null) { @@ -44,6 +39,7 @@ export function FileList({ files, selectedId, selectedIds, onSelect, onPlay, sea if (isTypingContext || isEditable) { return; } + // Keep keyboard focus aligned with the active selection. button.focus(); }, [selectedId]); @@ -92,43 +88,6 @@ export function FileList({ files, selectedId, selectedIds, onSelect, onPlay, sea }; }, [files]); - useEffect(() => { - if (!onSelectAll) { - return undefined; - } - - const handleKeyDown = (event: KeyboardEvent) => { - if (!(event.ctrlKey || event.metaKey)) { - return; - } - if (event.key !== 'a' && event.key !== 'A') { - return; - } - - const activeElement = document.activeElement as HTMLElement | null; - if (!activeElement) { - return; - } - const isTypingContext = activeElement instanceof HTMLInputElement || activeElement instanceof HTMLTextAreaElement; - const isEditable = activeElement.getAttribute('contenteditable') === 'true'; - if (isTypingContext || isEditable) { - return; - } - - if (listContainerRef.current && !listContainerRef.current.contains(activeElement)) { - return; - } - - event.preventDefault(); - onSelectAll(); - }; - - window.addEventListener('keydown', handleKeyDown); - return () => { - window.removeEventListener('keydown', handleKeyDown); - }; - }, [onSelectAll]); - const handleClick = (fileId: number, event: React.MouseEvent) => { onSelect(fileId, { multi: event.ctrlKey || event.metaKey, @@ -166,7 +125,7 @@ export function FileList({ files, selectedId, selectedIds, onSelect, onPlay, sea placeholder="Search files..." />
-
+
{files.map((file) => { const isActive = file.id === selectedId; const isSelected = selectedIds.has(file.id); @@ -196,6 +155,8 @@ export function FileList({ files, selectedId, selectedIds, onSelect, onPlay, sea onClick={(event) => handleClick(file.id, event)} onDoubleClick={() => handleDoubleClick(file)} onDragStart={(event) => { + // If dragging an unselected item, only drag that item + // If dragging a selected item, drag all selected items const draggedIds = selectedIds.has(file.id) ? Array.from(selectedIds) : [file.id]; event.dataTransfer.setData('application/audiosort-file', JSON.stringify({ @@ -204,6 +165,7 @@ export function FileList({ files, selectedId, selectedIds, onSelect, onPlay, sea })); event.dataTransfer.effectAllowed = 'copy'; + // Create transparent drag image to hide the default const transparent = document.createElement('div'); transparent.style.width = '1px'; transparent.style.height = '1px'; @@ -212,6 +174,7 @@ export function FileList({ files, selectedId, selectedIds, onSelect, onPlay, sea event.dataTransfer.setDragImage(transparent, 0, 0); setTimeout(() => transparent.remove(), 0); + // Calculate offset from click position to element center const sourceElement = event.currentTarget as HTMLElement; const rect = sourceElement.getBoundingClientRect(); const elementCenterX = rect.left + rect.width / 2; @@ -221,18 +184,21 @@ export function FileList({ files, selectedId, selectedIds, onSelect, onPlay, sea y: elementCenterY - event.clientY }; + // Calculate transition duration based on offset distance const offsetDistance = Math.sqrt( dragOffset.current.x * dragOffset.current.x + dragOffset.current.y * dragOffset.current.y ); const maxDistance = Math.sqrt(rect.width * rect.width + rect.height * rect.height) / 2; const normalizedDistance = Math.min(offsetDistance / maxDistance, 1); - const transitionDuration = 0.1 + normalizedDistance * 0.2; + const transitionDuration = 0.1 + normalizedDistance * 0.2; // 0.1s to 0.3s dragStartTime.current = Date.now(); + // Clone the current element for custom floating preview const ghost = sourceElement.cloneNode(true) as HTMLElement; + // Create a wrapper for rotation that doesn't affect position transition const rotationWrapper = document.createElement('div'); rotationWrapper.style.position = 'fixed'; rotationWrapper.style.top = `${event.clientY}px`; @@ -256,6 +222,7 @@ export function FileList({ files, selectedId, selectedIds, onSelect, onPlay, sea rotationWrapper.appendChild(ghost); document.body.appendChild(rotationWrapper); + // Trigger the offset transition to center requestAnimationFrame(() => { if (ghost.parentNode) { ghost.style.transform = `translate(-50%, -50%)`; @@ -277,19 +244,25 @@ export function FileList({ files, selectedId, selectedIds, onSelect, onPlay, sea const clamped = Math.max(-12, Math.min(12, blended)); dragRotation.current = clamped; + // Update wrapper position (no rotation here) dragGhostRef.current.style.top = `${event.clientY}px`; dragGhostRef.current.style.left = `${event.clientX}px`; + + // Apply rotation directly to wrapper without transition dragGhostRef.current.style.transform = `rotate(${clamped}deg)`; + // Track position for potential drop animation dropTargetPosition.current = { x: event.clientX, y: event.clientY }; }} onDragEnd={(event) => { const wrapper = dragGhostRef.current; if (!wrapper) return; + // Check if this was a successful drop (dropEffect will be 'copy' if accepted) const wasDropped = event.dataTransfer.dropEffect === 'copy'; if (wasDropped && dropTargetPosition.current) { + // Animate shrink into drop position wrapper.style.transition = 'all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1)'; wrapper.style.transform = `rotate(0deg) scale(0)`; wrapper.style.opacity = '0'; @@ -299,6 +272,7 @@ export function FileList({ files, selectedId, selectedIds, onSelect, onPlay, sea dragGhostRef.current = null; }, 300); } else { + // No drop or cancelled - remove immediately wrapper.remove(); dragGhostRef.current = null; } diff --git a/src/renderer/src/components/edit/EditModePanel.tsx b/src/renderer/src/components/edit/EditModePanel.tsx new file mode 100644 index 0000000..361e954 --- /dev/null +++ b/src/renderer/src/components/edit/EditModePanel.tsx @@ -0,0 +1,407 @@ +import { useEffect, useMemo, useState } from 'react'; +import type { AudioFileSummary, CategoryRecord } from '../../../../shared/models'; +import { TagEditor } from '../TagEditor'; +import WaveformEditorCanvas from './WaveformEditorCanvas'; +import { libraryStore } from '../../stores/LibraryStore'; +import { toSplitRequest, type SegmentDraft, type SegmentMetadataDraft } from './types'; + +export interface EditModePanelProps { + /** File currently being edited. */ + file: AudioFileSummary; + /** Available category catalog used by the tag editor. */ + categories: CategoryRecord[]; + /** Invoked after the panel should be closed without committing. */ + onClose(): void; + /** Invoked after a successful split with the newly created files. */ + onSplitComplete(created: AudioFileSummary[]): void; +} + +const MIN_SEGMENT_MS = 50; + +/** + * Full-screen overlay that enables waveform driven segment editing and metadata assignment before splitting. + */ +export function EditModePanel({ file, categories, onClose, onSplitComplete }: EditModePanelProps): JSX.Element { + const [waveformSamples, setWaveformSamples] = useState(null); + const [waveformError, setWaveformError] = useState(null); + const [durationMs, setDurationMs] = useState(file.durationMs ?? null); + const [segments, setSegments] = useState([]); + const [selectedSegmentId, setSelectedSegmentId] = useState(null); + const [isSaving, setIsSaving] = useState(false); + const [submitError, setSubmitError] = useState(null); + const [baseMetadata, setBaseMetadata] = useState<{ author: string | null; rating: number | null }>( + () => ({ author: null, rating: null }) + ); + + useEffect(() => { + let cancelled = false; + setWaveformSamples(null); + setWaveformError(null); + (async () => { + try { + const preview = await window.api.getWaveformPreview(file.id, 8192); + if (!cancelled) { + setWaveformSamples(preview.samples); + } + } catch (error) { + console.error('Failed to load waveform preview', error); + if (!cancelled) { + setWaveformSamples([]); + setWaveformError('Failed to load waveform preview for this file.'); + } + } + })(); + return () => { + cancelled = true; + }; + }, [file.id]); + + useEffect(() => { + let cancelled = false; + setSegments([]); + setSelectedSegmentId(null); + (async () => { + try { + const metadata = await window.api.readFileMetadata(file.id); + if (!cancelled) { + setBaseMetadata({ + author: metadata.author?.trim() ?? null, + rating: metadata.rating ?? null + }); + } + } catch (error) { + console.warn('Failed to read metadata for edit mode', error); + if (!cancelled) { + setBaseMetadata({ author: null, rating: null }); + } + } + })(); + return () => { + cancelled = true; + }; + }, [file.id]); + + useEffect(() => { + if (durationMs !== null) { + return; + } + let cancelled = false; + (async () => { + try { + const payload = await window.api.getAudioBuffer(file.id); + const AudioContextCtor = (window as typeof window & { webkitAudioContext?: typeof AudioContext }).AudioContext + ?? (window as typeof window & { webkitAudioContext?: typeof AudioContext }).webkitAudioContext; + if (!AudioContextCtor) { + if (!cancelled) { + setDurationMs(file.durationMs ?? null); + } + return; + } + const context = new AudioContextCtor(); + try { + const buffer = await context.decodeAudioData(payload.buffer.slice(0)); + if (!cancelled) { + setDurationMs(Math.round(buffer.duration * 1000)); + } + } finally { + await context.close(); + } + } catch (error) { + console.error('Failed to decode audio buffer for duration', error); + if (!cancelled) { + setDurationMs(null); + } + } + })(); + return () => { + cancelled = true; + }; + }, [file.id, durationMs]); + + const selectedSegment = useMemo( + () => segments.find((segment) => segment.id === selectedSegmentId) ?? null, + [segments, selectedSegmentId] + ); + + const isWaveformReady = waveformSamples !== null && durationMs !== null; + const effectiveDuration = durationMs ?? 0; + + const handleCreateSegment = (rawStart: number, rawEnd: number) => { + if (!isWaveformReady) { + return; + } + const { start, end } = normaliseRange(rawStart, rawEnd, effectiveDuration); + const newSegment: SegmentDraft = { + id: generateSegmentId(), + startMs: start, + endMs: end, + label: `Segment ${segments.length + 1}`, + metadata: buildDefaultMetadata(file, baseMetadata) + }; + setSegments((current) => { + const next = [...current, newSegment].sort((a, b) => a.startMs - b.startMs); + return next; + }); + setSelectedSegmentId(newSegment.id); + }; + + const handleResizeSegment = (segmentId: string, rawStart: number, rawEnd: number) => { + if (!isWaveformReady) { + return; + } + const { start, end } = normaliseRange(rawStart, rawEnd, effectiveDuration); + setSegments((current) => { + const next = current.map((segment) => + segment.id === segmentId + ? { ...segment, startMs: start, endMs: end } + : segment + ); + next.sort((a, b) => a.startMs - b.startMs); + return next; + }); + }; + + const handleSelectSegment = (segmentId: string | null) => { + setSelectedSegmentId(segmentId); + }; + + const handleUpdateLabel = (segmentId: string, label: string) => { + setSegments((current) => + current.map((segment) => + segment.id === segmentId + ? { ...segment, label } + : segment + ) + ); + }; + + const handleRemoveSegment = (segmentId: string) => { + setSegments((current) => current.filter((segment) => segment.id !== segmentId)); + if (selectedSegmentId === segmentId) { + setSelectedSegmentId(null); + } + }; + + const handleMetadataPatch = (segmentId: string, patch: Partial) => { + setSegments((current) => + current.map((segment) => + segment.id === segmentId + ? { ...segment, metadata: { ...segment.metadata, ...patch } } + : segment + ) + ); + }; + + const handleTagSave = (segmentId: string, data: { tags: string[]; categories: string[] }) => { + handleMetadataPatch(segmentId, { + tags: data.tags, + categories: data.categories + }); + }; + + const handleCommit = async () => { + if (segments.length === 0 || !isWaveformReady) { + return; + } + setSubmitError(null); + setIsSaving(true); + try { + const payload = segments.map((segment) => toSplitRequest(segment)); + const created = await libraryStore.splitFile(file.id, payload); + onSplitComplete(created); + onClose(); + } catch (error) { + console.error('Split operation failed', error); + setSubmitError(error instanceof Error ? error.message : String(error)); + } finally { + setIsSaving(false); + } + }; + + return ( +
+
+
+
+

{file.customName || file.displayName}

+

{file.relativePath}

+
+
+ +
+
+
+
+ {waveformError &&
{waveformError}
} + {!waveformError && !isWaveformReady && ( +
Loading waveform…
+ )} + {!waveformError && isWaveformReady && waveformSamples && ( + + )} +
+ +
+
+ {submitError && {submitError}} +
+ + +
+
+
+
+ ); +} + +function normaliseRange(start: number, end: number, duration: number): { start: number; end: number } { + let safeStart = Math.max(0, Math.min(start, duration - MIN_SEGMENT_MS)); + let safeEnd = Math.max(safeStart + MIN_SEGMENT_MS, Math.min(end, duration)); + if (safeEnd > duration) { + safeEnd = duration; + safeStart = Math.max(0, safeEnd - MIN_SEGMENT_MS); + } + return { start: safeStart, end: safeEnd }; +} + +function buildDefaultMetadata( + file: AudioFileSummary, + baseMetadata: { author: string | null; rating: number | null } +): SegmentMetadataDraft { + return { + customName: file.customName ?? null, + author: baseMetadata.author, + rating: baseMetadata.rating, + tags: file.tags.slice(), + categories: file.categories.slice() + }; +} + +function generateSegmentId(): string { + if (typeof window !== 'undefined' && window.crypto?.randomUUID) { + return window.crypto.randomUUID(); + } + return `segment-${Date.now()}-${Math.random().toString(16).slice(2)}`; +} + +function normaliseText(value: string): string | null { + const trimmed = value.trim(); + return trimmed.length === 0 ? null : trimmed; +} + +function formatTimecode(ms: number): string { + if (!Number.isFinite(ms) || ms < 0) { + return '00:00.000'; + } + const totalSeconds = Math.floor(ms / 1000); + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + const milliseconds = Math.floor(ms % 1000); + return `${minutes.toString().padStart(2, '0')}:${seconds + .toString() + .padStart(2, '0')}.${milliseconds.toString().padStart(3, '0')}`; +} + +export default EditModePanel; diff --git a/src/renderer/src/components/edit/WaveformEditorCanvas.tsx b/src/renderer/src/components/edit/WaveformEditorCanvas.tsx new file mode 100644 index 0000000..bc05f71 --- /dev/null +++ b/src/renderer/src/components/edit/WaveformEditorCanvas.tsx @@ -0,0 +1,392 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; +import type { MouseEvent as ReactMouseEvent, PointerEvent as ReactPointerEvent, WheelEvent as ReactWheelEvent } from 'react'; +import type { SegmentDraft } from './types'; + +export interface WaveformEditorCanvasProps { + /** Normalised waveform peaks in the range 0..1 across the whole file. */ + samples: number[]; + /** Total duration of the file in milliseconds. */ + durationMs: number; + /** Current set of draft segments. */ + segments: SegmentDraft[]; + /** Currently focused segment identifier. */ + selectedSegmentId: string | null; + /** Invoked when the focused segment should change. */ + onSelectSegment(segmentId: string | null): void; + /** Invoked when a new segment is created via drag selection. */ + onCreateSegment(startMs: number, endMs: number): void; + /** Invoked while resizing an existing segment. */ + onResizeSegment(segmentId: string, nextStartMs: number, nextEndMs: number): void; +} + +interface DraftSelection { + startMs: number; + endMs: number; +} + +type DragState = + | { type: 'none' } + | { type: 'creating'; anchorMs: number; pointerMs: number } + | { type: 'resizing'; segmentId: string; handle: 'start' | 'end' } + | { type: 'panning'; startViewportMs: number; pointerStartX: number }; + +const MIN_SEGMENT_MS = 50; +const MIN_VIEWPORT_MS = 200; +const MAX_VIEWPORT_MS = Number.POSITIVE_INFINITY; +const HANDLE_WIDTH_PX = 6; + +/** + * Interactive waveform canvas supporting zoom, pan, and region selection for splitting. + */ +export function WaveformEditorCanvas({ + samples, + durationMs, + segments, + selectedSegmentId, + onSelectSegment, + onCreateSegment, + onResizeSegment +}: WaveformEditorCanvasProps): JSX.Element { + const canvasRef = useRef(null); + const dragStateRef = useRef({ type: 'none' }); + const viewportRef = useRef<{ startMs: number; durationMs: number }>({ startMs: 0, durationMs }); + const [viewportVersion, setViewportVersion] = useState(0); + const [draftSelection, setDraftSelection] = useState(null); + + useEffect(() => { + viewportRef.current = { + startMs: 0, + durationMs + }; + setViewportVersion((value) => value + 1); + }, [durationMs]); + + const samplesPerMs = useMemo(() => { + if (durationMs <= 0 || samples.length === 0) { + return 0; + } + return samples.length / durationMs; + }, [samples.length, durationMs]); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) { + return; + } + const context = canvas.getContext('2d'); + if (!context) { + return; + } + + const dpr = window.devicePixelRatio || 1; + const width = canvas.clientWidth; + const height = canvas.clientHeight; + canvas.width = Math.max(1, Math.floor(width * dpr)); + canvas.height = Math.max(1, Math.floor(height * dpr)); + context.scale(dpr, dpr); + + context.clearRect(0, 0, width, height); + context.fillStyle = '#0c0f13'; + context.fillRect(0, 0, width, height); + + const viewport = viewportRef.current; + const viewportStart = viewport.startMs; + const viewportDuration = Math.min(durationMs, viewport.durationMs); + const viewportEnd = Math.min(durationMs, viewportStart + viewportDuration); + const baselineY = height / 2; + + const visibleSampleStart = Math.max(0, Math.floor(viewportStart * samplesPerMs)); + const visibleSampleEnd = Math.min(samples.length, Math.ceil(viewportEnd * samplesPerMs)); + const visibleSampleCount = Math.max(1, visibleSampleEnd - visibleSampleStart); + + context.lineWidth = 1; + + // Draw timeline grid lines. + const gridStepMs = pickGridSpacing(viewportDuration); + context.strokeStyle = 'rgba(255, 255, 255, 0.06)'; + context.beginPath(); + for (let grid = Math.ceil(viewportStart / gridStepMs) * gridStepMs; grid < viewportEnd; grid += gridStepMs) { + const ratio = (grid - viewportStart) / viewportDuration; + if (ratio < 0 || ratio > 1) { + continue; + } + const x = ratio * width; + context.moveTo(x, 0); + context.lineTo(x, height); + } + context.stroke(); + + // Draw waveform peaks. + context.strokeStyle = '#2c8cff'; + context.beginPath(); + for (let index = visibleSampleStart; index < visibleSampleEnd; index += 1) { + const sample = samples[index] ?? 0; + const clamped = Math.max(0, Math.min(1, sample)); + const ratio = (index - visibleSampleStart) / visibleSampleCount; + const x = ratio * width; + const amplitude = clamped * (height * 0.45); + context.moveTo(x, baselineY - amplitude); + context.lineTo(x, baselineY + amplitude); + } + context.stroke(); + + // Draw segments. + segments.forEach((segment) => { + const startRatio = (segment.startMs - viewportStart) / viewportDuration; + const endRatio = (segment.endMs - viewportStart) / viewportDuration; + const startX = Math.max(0, Math.min(width, startRatio * width)); + const endX = Math.max(0, Math.min(width, endRatio * width)); + if (endX <= startX) { + return; + } + const isSelected = segment.id === selectedSegmentId; + context.fillStyle = isSelected ? 'rgba(255, 204, 0, 0.25)' : 'rgba(255, 255, 255, 0.18)'; + context.fillRect(startX, 0, endX - startX, height); + context.fillStyle = isSelected ? '#ffcc00' : 'rgba(255, 255, 255, 0.45)'; + context.fillRect(startX - HANDLE_WIDTH_PX / 2, 0, HANDLE_WIDTH_PX, height); + context.fillRect(endX - HANDLE_WIDTH_PX / 2, 0, HANDLE_WIDTH_PX, height); + }); + + if (draftSelection) { + const draftStartRatio = (draftSelection.startMs - viewportStart) / viewportDuration; + const draftEndRatio = (draftSelection.endMs - viewportStart) / viewportDuration; + const draftStartX = Math.max(0, Math.min(width, draftStartRatio * width)); + const draftEndX = Math.max(0, Math.min(width, draftEndRatio * width)); + const draftWidth = draftEndX - draftStartX; + if (draftWidth > 0) { + context.fillStyle = 'rgba(76, 201, 240, 0.22)'; + context.fillRect(draftStartX, 0, draftWidth, height); + context.fillStyle = '#4cc9f0'; + context.fillRect(draftStartX - HANDLE_WIDTH_PX / 2, 0, HANDLE_WIDTH_PX, height); + context.fillRect(draftEndX - HANDLE_WIDTH_PX / 2, 0, HANDLE_WIDTH_PX, height); + } + } + }, [samples, segments, selectedSegmentId, samplesPerMs, durationMs, viewportVersion, draftSelection]); + + const resolveClientToMs = (clientX: number): number => { + const canvas = canvasRef.current; + if (!canvas) { + return 0; + } + const rect = canvas.getBoundingClientRect(); + const x = clientX - rect.left; + const ratio = Math.min(Math.max(x / rect.width, 0), 1); + const viewport = viewportRef.current; + return viewport.startMs + ratio * viewport.durationMs; + }; + + const resolvePointerMs = (event: ReactPointerEvent): number => { + return resolveClientToMs(event.clientX); + }; + + const updateViewport = (nextStart: number, nextDuration: number) => { + const clampedDuration = clamp(nextDuration, MIN_VIEWPORT_MS, Math.min(MAX_VIEWPORT_MS, durationMs)); + const maxStart = Math.max(0, durationMs - clampedDuration); + const clampedStart = clamp(nextStart, 0, Math.max(0, maxStart)); + viewportRef.current = { + startMs: clampedStart, + durationMs: clampedDuration + }; + setViewportVersion((value) => value + 1); + }; + + const handlePointerDown = (event: ReactPointerEvent) => { + if (event.button === 2) { + const canvas = canvasRef.current; + if (canvas) { + canvas.setPointerCapture(event.pointerId); + } + dragStateRef.current = { + type: 'panning', + startViewportMs: viewportRef.current.startMs, + pointerStartX: event.clientX + }; + return; + } + + if (event.button !== 0) { + return; + } + + const pointerMs = resolvePointerMs(event); + const canvas = canvasRef.current; + if (canvas) { + canvas.setPointerCapture(event.pointerId); + } + + const viewport = viewportRef.current; + const toleranceMs = viewport.durationMs * (HANDLE_WIDTH_PX / Math.max(canvas?.clientWidth ?? 1, 1)); + const hit = findSegmentHandle(pointerMs, segments, toleranceMs); + + if (hit) { + onSelectSegment(hit.segmentId); + dragStateRef.current = { type: 'resizing', segmentId: hit.segmentId, handle: hit.handle }; + return; + } + + const containing = segments.find((segment) => pointerMs >= segment.startMs && pointerMs <= segment.endMs); + if (containing) { + onSelectSegment(containing.id); + dragStateRef.current = { type: 'none' }; + return; + } + + dragStateRef.current = { type: 'creating', anchorMs: pointerMs, pointerMs }; + setDraftSelection({ startMs: pointerMs, endMs: pointerMs }); + }; + + const handlePointerMove = (event: ReactPointerEvent) => { + const state = dragStateRef.current; + if (state.type === 'none') { + return; + } + if (state.type === 'panning') { + const viewport = viewportRef.current; + const canvas = canvasRef.current; + if (!canvas) { + return; + } + const deltaX = event.clientX - state.pointerStartX; + const ratio = deltaX / Math.max(canvas.clientWidth, 1); + const deltaMs = -ratio * viewport.durationMs; + updateViewport(state.startViewportMs + deltaMs, viewport.durationMs); + return; + } + + const pointerMs = resolvePointerMs(event); + if (state.type === 'creating') { + dragStateRef.current = { ...state, pointerMs }; + const start = Math.min(state.anchorMs, pointerMs); + const end = Math.max(state.anchorMs, pointerMs); + setDraftSelection({ startMs: start, endMs: end }); + return; + } + + if (state.type === 'resizing') { + const segment = segments.find((entry) => entry.id === state.segmentId); + if (!segment) { + return; + } + const minEnd = segment.startMs + MIN_SEGMENT_MS; + const minStart = segment.endMs - MIN_SEGMENT_MS; + if (state.handle === 'start') { + const nextStart = clamp(pointerMs, 0, Math.min(minStart, segment.endMs - 1)); + onResizeSegment(segment.id, nextStart, segment.endMs); + } else { + const nextEnd = clamp(pointerMs, Math.max(minEnd, segment.startMs + MIN_SEGMENT_MS), durationMs); + onResizeSegment(segment.id, segment.startMs, nextEnd); + } + } + }; + + const handlePointerUp = (event: ReactPointerEvent) => { + const canvas = canvasRef.current; + if (canvas && canvas.hasPointerCapture(event.pointerId)) { + canvas.releasePointerCapture(event.pointerId); + } + + const state = dragStateRef.current; + dragStateRef.current = { type: 'none' }; + + if (state.type === 'creating') { + const pointerMs = resolvePointerMs(event); + const start = Math.min(state.anchorMs, pointerMs); + const end = Math.max(state.anchorMs, pointerMs); + setDraftSelection(null); + if (end - start >= MIN_SEGMENT_MS) { + onCreateSegment(start, end); + } + return; + } + + if (state.type === 'panning') { + return; + } + + if (state.type === 'resizing') { + // Final adjustment already applied during move. + return; + } + }; + + const handleWheel = (event: ReactWheelEvent) => { + event.preventDefault(); + if (durationMs <= 0) { + return; + } + const viewport = viewportRef.current; + const canvas = canvasRef.current; + if (!canvas) { + return; + } + const rect = canvas.getBoundingClientRect(); + const ratio = Math.min(Math.max((event.clientX - rect.left) / rect.width, 0), 1); + const anchor = viewport.startMs + ratio * viewport.durationMs; + const zoomFactor = event.deltaY > 0 ? 1.1 : 0.9; + const nextDuration = clamp(viewport.durationMs * zoomFactor, MIN_VIEWPORT_MS, durationMs); + const nextStart = clamp(anchor - ratio * nextDuration, 0, Math.max(0, durationMs - nextDuration)); + updateViewport(nextStart, nextDuration); + }; + + const handleDoubleClick = (event: ReactMouseEvent) => { + event.preventDefault(); + const pointerMs = resolveClientToMs(event.clientX); + const viewport = viewportRef.current; + const windowSpan = viewport.durationMs * 0.25; + const nextDuration = clamp(windowSpan, MIN_VIEWPORT_MS, durationMs); + const nextStart = clamp(pointerMs - nextDuration / 2, 0, Math.max(0, durationMs - nextDuration)); + updateViewport(nextStart, nextDuration); + }; + + const handleContextMenu = (event: ReactMouseEvent) => { + event.preventDefault(); + }; + + return ( + + ); +} + +function clamp(value: number, min: number, max: number): number { + if (!Number.isFinite(value)) { + return min; + } + return Math.min(Math.max(value, min), max); +} + +function pickGridSpacing(windowMs: number): number { + const candidates = [10, 20, 50, 100, 250, 500, 1000, 2000, 5000, 10000, 20000, 60000]; + for (const candidate of candidates) { + if (windowMs / candidate <= 12) { + return candidate; + } + } + return candidates[candidates.length - 1]; +} + +function findSegmentHandle( + pointerMs: number, + segments: SegmentDraft[], + toleranceMs: number +): { segmentId: string; handle: 'start' | 'end' } | null { + for (const segment of segments) { + if (Math.abs(pointerMs - segment.startMs) <= toleranceMs) { + return { segmentId: segment.id, handle: 'start' }; + } + if (Math.abs(pointerMs - segment.endMs) <= toleranceMs) { + return { segmentId: segment.id, handle: 'end' }; + } + } + return null; +} + +export default WaveformEditorCanvas; diff --git a/src/renderer/src/components/edit/types.ts b/src/renderer/src/components/edit/types.ts new file mode 100644 index 0000000..1c772b0 --- /dev/null +++ b/src/renderer/src/components/edit/types.ts @@ -0,0 +1,51 @@ +import type { SplitSegmentRequest } from '../../../../shared/models'; + +/** + * Editable metadata captured for each draft segment. + */ +export interface SegmentMetadataDraft { + /** Custom name override applied to the generated segment (null clears). */ + customName: string | null; + /** Author/artist override applied to the generated segment. */ + author: string | null; + /** Rating override (1-5) applied to the generated segment, null preserves none. */ + rating: number | null; + /** Tag collection applied to the generated segment. */ + tags: string[]; + /** UCS categories applied to the generated segment. */ + categories: string[]; +} + +/** + * Draft segment structure maintained inside the edit mode panel before saving. + */ +export interface SegmentDraft { + /** Stable identifier used for React rendering and user interactions. */ + id: string; + /** Inclusive start offset in milliseconds. */ + startMs: number; + /** Exclusive end offset in milliseconds. */ + endMs: number; + /** User facing label displayed in the segment list. */ + label: string; + /** Captured metadata overrides. */ + metadata: SegmentMetadataDraft; +} + +/** + * Converts a draft segment into the IPC request payload. + */ +export function toSplitRequest(segment: SegmentDraft): SplitSegmentRequest { + return { + startMs: Math.round(segment.startMs), + endMs: Math.round(segment.endMs), + label: segment.label.trim().length > 0 ? segment.label.trim() : undefined, + metadata: { + customName: segment.metadata.customName, + author: segment.metadata.author, + rating: segment.metadata.rating ?? undefined, + tags: segment.metadata.tags, + categories: segment.metadata.categories + } + }; +} diff --git a/src/renderer/src/stores/LibraryStore.ts b/src/renderer/src/stores/LibraryStore.ts index e54ac30..cec12ee 100644 --- a/src/renderer/src/stores/LibraryStore.ts +++ b/src/renderer/src/stores/LibraryStore.ts @@ -3,6 +3,7 @@ import type { AudioFileSummary, CategoryRecord, LibraryScanSummary, + SplitSegmentRequest, TagUpdatePayload } from '../../../shared/models'; @@ -361,6 +362,32 @@ export class LibraryStore extends EventTarget { this.emitChange(); } + /** + * Splits a file into multiple segments and refreshes the library snapshot. + */ + public async splitFile(fileId: number, segments: SplitSegmentRequest[]): Promise { + const created = await window.api.splitFile(fileId, segments); + const files = await window.api.listAudioFiles(); + const { categoryFilter } = this.snapshot; + let visible: AudioFileSummary[]; + if (categoryFilter === CATEGORY_FILTER_UNTAGGED) { + visible = files.filter((file) => file.categories.length === 0); + } else if (categoryFilter) { + visible = files.filter((file) => file.categories.includes(categoryFilter)); + } else { + visible = files.slice(); + } + + this.snapshot = { + ...this.snapshot, + files, + visibleFiles: visible, + metadataSuggestionsVersion: this.snapshot.metadataSuggestionsVersion + 1 + }; + this.emitChange(); + return created; + } + /** * Assigns a new library path and refreshes the cached settings + file list. */ diff --git a/src/renderer/src/styles/app.css b/src/renderer/src/styles/app.css index fdeffd2..c79f779 100644 --- a/src/renderer/src/styles/app.css +++ b/src/renderer/src/styles/app.css @@ -8,16 +8,6 @@ body { margin: 0; background-color: #0c0f13; - user-select: none; - -webkit-user-select: none; -} - -input, -textarea, -select, -[contenteditable='true'] { - user-select: text; - -webkit-user-select: text; } * { @@ -318,6 +308,7 @@ select, .file-list-search .search-input { width: 100%; + box-sizing: border-box; } .file-list-items { @@ -429,6 +420,55 @@ select, overflow: hidden; } +.detail-tabs { + display: flex; + gap: 0.25rem; + padding: 0.5rem 0.5rem 0 0.5rem; + background: rgba(255, 255, 255, 0.02); + border-bottom: 1px solid rgba(255, 255, 255, 0.06); +} + +.detail-tab { + padding: 0.5rem 1.25rem; + border: 1px solid transparent; + border-bottom: none; + border-radius: 0.5rem 0.5rem 0 0; + background: transparent; + color: rgba(255, 255, 255, 0.6); + font: inherit; + font-size: 0.9rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; + position: relative; +} + +.detail-tab:hover:not(:disabled) { + background: rgba(255, 255, 255, 0.05); + color: rgba(255, 255, 255, 0.8); +} + +.detail-tab:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.detail-tab--active { + background: rgba(39, 110, 241, 0.15); + border-color: rgba(39, 110, 241, 0.3); + color: #eef3f8; +} + +.detail-tab--active::after { + content: ''; + position: absolute; + bottom: -1px; + left: 0; + right: 0; + height: 2px; + background: #276ef1; +} + .file-detail { flex: 1; overflow-y: auto; diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index a965448..3c89a90 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -45,7 +45,7 @@ export interface RendererApi { /** Moves a file to another subdirectory under the library root. */ moveFile(fileId: number, targetRelativeDirectory: string): Promise; /** Automatically organizes a file based on its categories. */ - organizeFile(fileId: number, metadata: { customName?: string | null; author?: string | null; rating?: number }): Promise; + organizeFile(fileId: number, metadata: { customName?: string; author?: string; copyright?: string; rating?: number }): Promise; /** Updates the custom name for a file. */ updateCustomName(fileId: number, customName: string | null): Promise; /** Opens the file's containing folder in the system file explorer. */ @@ -63,11 +63,11 @@ export interface RendererApi { /** Runs fuzzy search returning ranked results. */ search(query: string): Promise; /** Reads embedded metadata from a WAV file. */ - readFileMetadata(fileId: number): Promise<{ author?: string; title?: string; rating?: number }>; + readFileMetadata(fileId: number): Promise<{ author?: string; copyright?: string; title?: string; rating?: number }>; /** Lists distinct metadata values gathered from the library for quick suggestions. */ - listMetadataSuggestions(): Promise<{ authors: string[] }>; - /** Updates metadata (author, rating) without organizing the file. */ - updateFileMetadata(fileId: number, metadata: { author?: string | null; rating?: number }): Promise; + listMetadataSuggestions(): Promise<{ authors: string[]; copyrights: string[] }>; + /** Updates metadata (author, copyright, rating) without organizing the file. */ + updateFileMetadata(fileId: number, metadata: { author?: string; copyright?: string; rating?: number }): Promise; /** Listens for menu actions from the main process. Returns a cleanup function. */ onMenuAction(channel: string, callback: () => void): () => void; } diff --git a/src/shared/models.ts b/src/shared/models.ts index 98561cf..f81075c 100644 --- a/src/shared/models.ts +++ b/src/shared/models.ts @@ -107,3 +107,33 @@ export interface AudioBufferPayload { /** MIME type for the audio data. */ mimeType: string; } + +/** + * Optional metadata overrides applied while creating split segments. + */ +export interface SegmentMetadataInput { + /** Overrides the custom name embedded into the segment (null clears it). */ + customName?: string | null; + /** Overrides the author/creator metadata. */ + author?: string | null; + /** Overrides the rating metadata (1-5). */ + rating?: number; + /** Overrides the applied free-form tags. */ + tags?: string[]; + /** Overrides the applied UCS categories. */ + categories?: string[]; +} + +/** + * Describes a region that should be extracted as a new audio segment. + */ +export interface SplitSegmentRequest { + /** Inclusive start offset in milliseconds. */ + startMs: number; + /** Exclusive end offset in milliseconds. */ + endMs: number; + /** Optional display label used for segment naming. */ + label?: string; + /** Metadata overrides to apply to the generated file. */ + metadata?: SegmentMetadataInput; +}