5 Commits

Author SHA1 Message Date
f18fd4155e chore: bump version to 0.2.0 with audio splitting and waveform editing features 2025-11-11 10:35:52 +11:00
f4bea5e3fd feat: enhance TagService to improve tag extraction and fallback logic;
update TagEditor for category filtering and styling;
add middle click zoom reset in WaveformEditorCanvas;
refine app.css for layout adjustments
2025-11-11 10:31:32 +11:00
65ab2776eb feat: enhance TagEditor to support primary category selection and reorder categories 2025-11-11 08:25:36 +11:00
6515a30a0e feat: enhance waveform editor with playback controls and segment interaction
- Added onPlayFromCursor and playbackCursorMs props to WaveformEditorCanvas for playback control.
- Implemented visual feedback for segment selection and hover states, including color adjustments and labels.
- Introduced smooth cursor animation for better user experience during interaction.
- Enhanced segment metadata handling in types and IPC for better integration with the library.
- Added focusOnFile method in LibraryStore to manage file selection and refresh.
- Updated styles for edit mode, including new classes for waveform and playback controls.
- Introduced new IPC channel for splitting audio files and updated related interfaces.
- Added parentFileId to AudioFileSummary for tracking source files.
- Implemented tests for tagging service to ensure metadata integrity and tag preservation.
- Created scrubWorklet and assets type definitions for future audio processing features.
2025-11-11 08:19:19 +11:00
781187c427 feat: implement audio file splitting functionality
- Added `splitFile` method to `LibraryStore` for splitting audio files into segments.
- Introduced `SplitSegmentRequest` and `SegmentMetadataInput` interfaces in models for segment metadata handling.
- Created `EditModePanel` component for editing segments, including waveform visualization and metadata assignment.
- Developed `WaveformEditorCanvas` for interactive waveform editing, supporting segment creation and resizing.
- Enhanced metadata handling in IPC with new fields for author and copyright.
- Updated styles for new components and improved UI interactions.
2025-11-11 02:45:34 +11:00
21 changed files with 3163 additions and 309 deletions

View File

@@ -1,7 +1,7 @@
{
"name": "audio-sort",
"version": "0.1.1",
"description": "Electron audio sorting application with fuzzy search, tagging, and library management for WAV files.",
"version": "0.2.0",
"description": "Electron audio sorting application with fuzzy search, tagging, library management, and advanced waveform editing with audio splitting capabilities for WAV files.",
"main": "dist/main/main/index.js",
"type": "commonjs",
"scripts": {
@@ -24,7 +24,7 @@
"test:search": "node --import tsx --test src/test/SearchService.test.ts"
},
"dependencies": {
"better-sqlite3": "^9.4.5",
"better-sqlite3": "^11.0.0",
"csv-parse": "^5.5.5",
"fast-glob": "^3.3.2",
"fuse.js": "^6.6.2",
@@ -41,7 +41,7 @@
"@vitejs/plugin-react": "^4.3.3",
"concurrently": "^8.2.2",
"cross-env": "^7.0.3",
"electron": "^29.4.4",
"electron": "^32.0.0",
"electron-builder": "^26.0.12",
"tree-kill": "^1.2.2",
"tsx": "^4.15.7",

View File

@@ -3,7 +3,7 @@ import path from 'node:path';
import { app, BrowserWindow, dialog, ipcMain, nativeTheme, Menu } from 'electron';
import type { IpcMainInvokeEvent } from 'electron';
import { IPC_CHANNELS } from '../shared/ipc';
import { TagUpdatePayload } from '../shared/models';
import { SplitSegmentRequest, TagUpdatePayload } from '../shared/models';
import { DatabaseService } from './services/DatabaseService';
import { LibraryService } from './services/LibraryService';
import { SearchService } from './services/SearchService';
@@ -138,9 +138,10 @@ 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.librarySplit);
ipcMain.removeHandler(IPC_CHANNELS.libraryWaveformPreview);
ipcMain.removeHandler(IPC_CHANNELS.tagsUpdate);
ipcMain.removeHandler(IPC_CHANNELS.categoriesList);
@@ -163,7 +164,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 +187,18 @@ export class MainApp {
this.mainWindow = null;
});
const devServerUrl = (globalThis as { process?: { env?: Record<string, string | undefined> } }).process?.env?.VITE_DEV_SERVER_URL;
const devServerUrl = (globalThis as { process?: { env?: Record<string, string | undefined> } }).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 +215,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 +242,12 @@ 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 | null; author?: string | null; copyright?: string | null; rating?: number }
) =>
this.requireLibrary().organizeFile(fileId, metadata)
);
ipcMain.handle(
@@ -257,6 +264,12 @@ export class MainApp {
this.requireLibrary().deleteFiles(fileIds)
);
ipcMain.handle(
IPC_CHANNELS.librarySplit,
async (_event: IpcMainInvokeEvent, fileId: number, segments: SplitSegmentRequest[]) =>
this.requireLibrary().splitFile(fileId, segments)
);
ipcMain.handle(IPC_CHANNELS.libraryBuffer, async (_event: IpcMainInvokeEvent, fileId: number) =>
this.requireLibrary().getAudioBuffer(fileId)
);
@@ -283,7 +296,11 @@ 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 | null; copyright?: string | null; rating?: number }
) =>
this.requireLibrary().updateFileMetadata(fileId, metadata)
);
}

View File

@@ -33,6 +33,8 @@ export interface FileRecordInput {
tags?: string[];
/** Optional category payload (stored as JSON string). */
categories?: string[];
/** Optional parent file reference when generated from another file. */
parentFileId?: number | null;
}
export interface FileRecordRow extends AudioFileSummary {}
@@ -89,7 +91,8 @@ export class DatabaseService {
bit_depth,
checksum,
tags_json,
categories_json
categories_json,
parent_file_id
) VALUES (
@absolutePath,
@relativePath,
@@ -103,7 +106,8 @@ export class DatabaseService {
@bitDepth,
@checksum,
@tagsJson,
@categoriesJson
@categoriesJson,
@parentFileId
)
ON CONFLICT(absolute_path) DO UPDATE SET
library_relative_path = excluded.library_relative_path,
@@ -117,7 +121,8 @@ export class DatabaseService {
bit_depth = excluded.bit_depth,
checksum = excluded.checksum,
tags_json = CASE WHEN files.tags_json = '[]' THEN excluded.tags_json ELSE files.tags_json END,
categories_json = CASE WHEN files.categories_json = '[]' THEN excluded.categories_json ELSE files.categories_json END
categories_json = CASE WHEN files.categories_json = '[]' THEN excluded.categories_json ELSE files.categories_json END,
parent_file_id = CASE WHEN excluded.parent_file_id IS NOT NULL THEN excluded.parent_file_id ELSE files.parent_file_id END
RETURNING *`
);
@@ -132,9 +137,10 @@ export class DatabaseService {
durationMs: record.durationMs,
sampleRate: record.sampleRate,
bitDepth: record.bitDepth,
checksum: record.checksum,
checksum: record.checksum,
tagsJson: JSON.stringify(record.tags ?? []),
categoriesJson: JSON.stringify(record.categories ?? [])
categoriesJson: JSON.stringify(record.categories ?? []),
parentFileId: record.parentFileId ?? null
}) as DbRow | undefined;
if (!row) {
@@ -145,22 +151,35 @@ export class DatabaseService {
}
/**
* Updates the stored tags and categories for a file.
* Updates the stored tags and/or categories for a file.
* When a field is omitted it remains unchanged.
*/
public updateTagging(fileId: number, tags: string[], categories: string[]): AudioFileSummary {
public updateTagging(fileId: number, tags?: string[], categories?: string[]): AudioFileSummary {
const connection = this.requireDb();
const updates: string[] = [];
const parameters: Record<string, unknown> = { id: fileId };
if (tags !== undefined) {
updates.push('tags_json = @tags');
parameters.tags = JSON.stringify(tags);
}
if (categories !== undefined) {
updates.push('categories_json = @categories');
parameters.categories = JSON.stringify(categories);
}
if (updates.length === 0) {
return this.getFileById(fileId);
}
const statement = connection.prepare(
`UPDATE files
SET tags_json = @tags,
categories_json = @categories
SET ${updates.join(', ')}
WHERE id = @id
RETURNING *`
);
const row = statement.get({
id: fileId,
tags: JSON.stringify(tags),
categories: JSON.stringify(categories)
}) as DbRow | undefined;
const row = statement.get(parameters) as DbRow | undefined;
if (!row) {
throw new Error(`File with id ${fileId} not found`);
}
@@ -408,6 +427,7 @@ export class DatabaseService {
sampleRate: row.sample_rate === null ? null : (row.sample_rate as number),
bitDepth: row.bit_depth === null ? null : (row.bit_depth as number),
checksum: typeof row.checksum === 'string' ? (row.checksum as string) : null,
parentFileId: typeof row.parent_file_id === 'number' ? (row.parent_file_id as number) : null,
tags: this.parseJsonArray(row.tags_json),
categories: this.parseJsonArray(row.categories_json),
customName: typeof row.custom_name === 'string' ? (row.custom_name as string) : null
@@ -444,7 +464,8 @@ export class DatabaseService {
bit_depth INTEGER,
checksum TEXT,
tags_json TEXT NOT NULL DEFAULT '[]',
categories_json TEXT NOT NULL DEFAULT '[]'
categories_json TEXT NOT NULL DEFAULT '[]',
parent_file_id INTEGER REFERENCES files(id)
);
CREATE INDEX IF NOT EXISTS idx_files_display_name ON files(display_name);
CREATE INDEX IF NOT EXISTS idx_files_modified ON files(modified_at);
@@ -478,6 +499,7 @@ export class DatabaseService {
this.addColumnIfMissing(connection, 'files', 'created_at', 'INTEGER');
this.addColumnIfMissing(connection, 'files', 'checksum', 'TEXT');
this.addColumnIfMissing(connection, 'files', 'custom_name', 'TEXT');
this.addColumnIfMissing(connection, 'files', 'parent_file_id', 'INTEGER');
// Create checksum index after ensuring column exists
connection.exec('CREATE INDEX IF NOT EXISTS idx_files_checksum ON files(checksum)');

View File

@@ -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';
@@ -31,7 +31,6 @@ export class LibraryService {
private readonly organization: OrganizationService;
private metadataSuggestionCache: { authors: Set<string> } | null = null;
private readonly waveformPreviewCache = new Map<number, { modifiedAt: number; pointCount: number; samples: number[]; rms: number }>();
public constructor(
private readonly database: DatabaseService,
private readonly settings: SettingsService,
@@ -49,10 +48,10 @@ export class LibraryService {
return;
}
const fileContent = await fs.readFile(csvAbsolutePath, 'utf-8');
const rows = parse(fileContent, {
columns: true,
skip_empty_lines: true
}) as CsvCategoryRow[];
const rows = parse(fileContent, {
columns: true,
skip_empty_lines: true
}) as CsvCategoryRow[];
for (const row of rows) {
const category: CategoryRecord = {
id: row.CatID,
@@ -87,8 +86,9 @@ export class LibraryService {
const cleanedTempFiles = await this.cleanupTempFiles();
const libraryRoot = this.settings.ensureLibraryPath();
const existing = this.database.listFiles();
const existingByPath = new Map(existing.map((file) => [file.absolutePath, file] as const));
const existingByChecksum = new Map(existing.filter((file) => file.checksum).map((file) => [file.checksum!, file] as const));
const existingByPath = new Map(existing.map((file) => [file.absolutePath, file] as const));
const existingByChecksum = new Map(existing.filter((file) => file.checksum).map((file) => [file.checksum!, file] as const));
const discoveredByPath = new Map<string, AudioFileSummary>();
const pattern = ['**/*.wav', '**/*.wave'];
const absolutePaths = await fg(pattern, {
@@ -117,9 +117,29 @@ export class LibraryService {
const knownFile = knownByPath ?? knownByChecksum ?? null;
const wasKnown = knownFile !== null;
// Read embedded WAV metadata (author, copyright, rating, title)
// Read embedded WAV metadata (author, copyright, rating, title, parentId)
const embeddedMetadata = this.tagService.readMetadata(absolutePath);
let parentFileId = knownFile?.parentFileId ?? null;
// First try embedded metadata parentId
if (parentFileId === null && embeddedMetadata.parentId !== undefined) {
parentFileId = embeddedMetadata.parentId;
}
// Fall back to filename pattern matching for segments
if (parentFileId === null) {
const segmentMatch = fileName.match(/^(.*)_segment\d+(\.[^.]+)$/i);
if (segmentMatch) {
const parentFileName = `${segmentMatch[1]}${segmentMatch[2]}`;
const parentAbsolutePath = path.join(path.dirname(absolutePath), parentFileName);
const parentRecord = existingByPath.get(parentAbsolutePath) ?? discoveredByPath.get(parentAbsolutePath) ?? null;
if (parentRecord) {
parentFileId = parentRecord.id;
}
}
}
const record: FileRecordInput = {
absolutePath,
relativePath,
@@ -133,14 +153,36 @@ export class LibraryService {
bitDepth: metadata.bitDepth,
checksum,
tags: metadata.tags.length > 0 ? metadata.tags : (knownFile?.tags ?? []),
categories: metadata.categories.length > 0 ? metadata.categories : (knownFile?.categories ?? [])
categories: metadata.categories.length > 0 ? metadata.categories : (knownFile?.categories ?? []),
parentFileId
};
const upserted = this.database.upsertFile(record);
// Update custom name from embedded title if present, otherwise keep existing
const customName = embeddedMetadata.title?.trim() || knownFile?.customName || null;
if (customName !== upserted.customName) {
this.database.updateCustomName(upserted.id, customName);
const finalRecord = customName !== upserted.customName
? this.database.updateCustomName(upserted.id, customName)
: upserted;
existingByPath.set(absolutePath, finalRecord);
if (checksum) {
existingByChecksum.set(checksum, finalRecord);
}
discoveredByPath.set(absolutePath, finalRecord);
if (parentFileId !== null) {
const embeddedParent = embeddedMetadata.parentId ?? null;
if (embeddedParent === null || embeddedParent !== parentFileId) {
this.tagService.writeMetadataOnly(absolutePath, {
tags: finalRecord.tags,
categories: finalRecord.categories,
title: customName ?? embeddedMetadata.title ?? finalRecord.displayName,
author: embeddedMetadata.author ?? null,
rating: embeddedMetadata.rating,
copyright: embeddedMetadata.copyright ?? null,
parentId: parentFileId
});
}
}
if (wasKnown) {
@@ -292,7 +334,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 };
@@ -510,7 +552,9 @@ export class LibraryService {
categories: updatedRecord.categories,
title: effectiveCustomName,
author: mergedAuthor,
rating: mergedRating
rating: mergedRating,
copyright: existing.copyright ?? null,
parentId: updatedRecord.parentFileId ?? null
});
// Update suggestions cache for any metadata that was provided
@@ -604,7 +648,9 @@ export class LibraryService {
categories: updated.categories,
title: effectiveCustomName,
author: mergedAuthor,
rating: mergedRating
rating: mergedRating,
copyright: existing.copyright ?? null,
parentId: updated.parentFileId ?? null
});
// Update suggestions cache for any metadata that was provided
@@ -636,6 +682,226 @@ 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<AudioFileSummary[]> {
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; copyright?: string | null } = {};
try {
const metadata = this.tagService.readMetadata(record.absolutePath);
originalMetadata = {
author: metadata.author ?? null,
rating: metadata.rating ?? undefined,
title: metadata.title ?? null,
copyright: metadata.copyright ?? 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<string>();
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());
// Determine the segment name part - use label if available, otherwise use sequence
let segmentNamePart = '';
if (segment.label) {
const sanitized = this.organization.sanitizeCustomName(segment.label);
if (sanitized) {
segmentNamePart = sanitized;
}
}
let segmentFileName = '';
let segmentAbsolutePath = '';
for (let attempt = 0; attempt < 1000; attempt += 1) {
let candidateName: string;
if (segmentNamePart) {
// Use label-based name, with optional suffix for duplicates
if (attempt === 0) {
candidateName = `${baseName}_${segmentNamePart}.wav`;
} else {
candidateName = `${baseName}_${segmentNamePart}_${this.organization.formatSequenceNumber(attempt)}.wav`;
}
} else {
// Fallback to sequential numbering
const suffix = this.organization.formatSequenceNumber(sequence);
sequence += 1;
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,
parentFileId: record.id
});
const resolvedCustomName = segment.metadata?.customName !== undefined
? this.normaliseMetadataInput(segment.metadata.customName)
: segment.label !== undefined
? this.normaliseMetadataInput(segment.label)
: 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,
copyright: originalMetadata.copyright ?? null,
parentId: record.id
});
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.
*/
@@ -679,7 +945,9 @@ export class LibraryService {
categories: record.categories,
title: record.customName ?? existing.title,
author: requestedAuthor !== undefined ? requestedAuthor : existing.author ?? null,
rating: metadata.rating !== undefined ? metadata.rating : existing.rating
rating: metadata.rating !== undefined ? metadata.rating : existing.rating,
copyright: existing.copyright ?? null,
parentId: record.parentFileId ?? null
};
// Write merged metadata to the WAV file

View File

@@ -10,15 +10,25 @@ export class TagService {
public constructor(private readonly database: DatabaseService) {}
/**
* Applies tags and categories to a file record and embeds the metadata into the WAV container.
* Applies category updates (and optional tag overrides) then embeds 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);
public applyTagging(fileId: number, tags: string[] | undefined, categories: string[]): AudioFileSummary {
const normalisedCategories = this.normaliseValues(categories);
const normalisedTags = Array.isArray(tags) ? this.normaliseValues(tags) : undefined;
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
tags: normalisedTags ?? updated.tags,
categories: normalisedCategories,
title: existing.title,
author: existing.author,
rating: existing.rating,
copyright: existing.copyright,
parentId: existing.parentId ?? updated.parentFileId ?? null
});
return updated;
}
@@ -44,21 +54,56 @@ export class TagService {
author?: string;
title?: string;
rating?: number;
copyright?: string;
parentId?: number;
} {
try {
const buffer = fs.readFileSync(filePath);
const wave = new WaveFile(buffer);
const tags = this.extractInfoTagMap(wave);
// Read from individual fields
const title = tags.INAM?.trim() || undefined;
const author = tags.IART?.trim() || undefined;
const copyright = tags.ICOP?.trim() || undefined;
const ratingValue = tags.IRTD ? Number.parseInt(tags.IRTD, 10) : undefined;
let rating: number | undefined;
if (typeof ratingValue === 'number' && Number.isFinite(ratingValue)) {
rating = Math.floor(ratingValue / 2);
}
// Try to read parentId from JSON comment
let parentId: number | undefined;
const comment = tags.ICMT?.trim();
if (comment) {
try {
const parsed = JSON.parse(comment);
if (parsed && typeof parsed.parentId === 'number') {
parentId = parsed.parentId;
}
} catch {
// Not JSON, ignore
}
}
// Fallback to IPAR field if no JSON parentId
if (parentId === undefined) {
const parentRaw = tags.IPAR?.trim();
if (parentRaw && parentRaw.length > 0) {
const parsedNum = Number.parseInt(parentRaw, 10);
if (Number.isFinite(parsedNum)) {
parentId = parsedNum;
}
}
}
return {
author: tags.IART?.trim() || undefined,
title: tags.INAM?.trim() || undefined,
rating
author,
title,
rating,
copyright,
parentId
};
} catch (error) {
// eslint-disable-next-line no-console -- Logging to devtools console is helpful for diagnosis.
@@ -69,15 +114,18 @@ 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;
title?: string | null;
author?: string | null;
rating?: number;
copyright?: string | null;
parentId?: number | null;
}
): void {
this.writeWaveMetadata(filePath, metadata);
@@ -85,15 +133,18 @@ 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;
title?: string | null;
author?: string | null;
rating?: number;
copyright?: string | null;
parentId?: number | null;
}
): void {
try {
@@ -104,22 +155,41 @@ export class TagService {
listInfoTags?: Record<string, string>;
};
const tagValuesList = metadata.tags.map((entry) => entry.trim()).filter((entry) => entry.length > 0);
const categoryValuesList = metadata.categories.map((entry) => entry.trim()).filter((entry) => entry.length > 0);
const tagValuesList = (metadata.tags ?? [])
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0);
const categoryValuesList = (metadata.categories ?? [])
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0);
const tagText = tagValuesList.length > 0 ? tagValuesList.join('; ') : null;
const categoryText = categoryValuesList.length > 0 ? categoryValuesList.join('; ') : null;
const primaryCategory = categoryValuesList.at(0) ?? null;
const trimmedTitle = metadata.title?.toString().trim();
const effectiveTitle = trimmedTitle && trimmedTitle.length > 0 ? trimmedTitle : null;
const trimmedAuthor = metadata.author?.toString().trim();
const effectiveAuthor = trimmedAuthor && trimmedAuthor.length > 0 ? trimmedAuthor : null;
const trimmedCopyright = metadata.copyright?.toString().trim();
const effectiveCopyright = trimmedCopyright && trimmedCopyright.length > 0 ? trimmedCopyright : null;
const parentText = metadata.parentId !== undefined && metadata.parentId !== null
? String(metadata.parentId)
: null;
const commentPayload = {
parentId: metadata.parentId ?? null
} satisfies Record<string, unknown>;
const commentText = JSON.stringify(commentPayload);
const tagValues: Record<string, string | null> = {
IKEY: tagText,
ICMT: tagText,
ICMT: commentText,
ISBJ: categoryText,
ISUB: primaryCategory,
IGNR: null,
INAM: metadata.title?.trim()?.length ? metadata.title.trim() : null,
IART: metadata.author?.trim()?.length ? metadata.author.trim() : null,
INAM: effectiveTitle,
IART: effectiveAuthor,
IRTD: metadata.rating && metadata.rating > 0 ? String(metadata.rating * 2) : null,
ICOP: effectiveCopyright,
IPAR: parentText,
ISFT: 'AudioSort'
};
@@ -129,11 +199,13 @@ export class TagService {
fs.writeFileSync(filePath, updatedBuffer);
console.log(`Wrote metadata to ${filePath}:`, {
tags: metadata.tags.join(', '),
comment: commentText,
categories: metadata.categories.join(', '),
title: metadata.title,
author: metadata.author,
rating: metadata.rating
title: effectiveTitle,
author: effectiveAuthor,
copyright: effectiveCopyright,
rating: metadata.rating,
parentId: metadata.parentId ?? null
});
} catch (error) {
// eslint-disable-next-line no-console -- Logging to devtools console is helpful for diagnosis.

View File

@@ -6,6 +6,7 @@ import type {
AudioFileSummary,
CategoryRecord,
LibraryScanSummary,
SplitSegmentRequest,
TagUpdatePayload
} from '../shared/models';
@@ -37,7 +38,10 @@ const api: RendererApi = {
async moveFile(fileId: number, targetRelativeDirectory: string): Promise<AudioFileSummary> {
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<AudioFileSummary> {
async organizeFile(
fileId: number,
metadata: { customName?: string | null; author?: string | null; copyright?: string | null; rating?: number }
): Promise<AudioFileSummary> {
return ipcRenderer.invoke(IPC_CHANNELS.libraryOrganize, fileId, metadata);
},
async updateCustomName(fileId: number, customName: string | null): Promise<AudioFileSummary> {
@@ -49,6 +53,9 @@ const api: RendererApi = {
async deleteFiles(fileIds: number[]): Promise<void> {
return ipcRenderer.invoke(IPC_CHANNELS.libraryDelete, fileIds);
},
async splitFile(fileId: number, segments: SplitSegmentRequest[]): Promise<AudioFileSummary[]> {
return ipcRenderer.invoke(IPC_CHANNELS.librarySplit, fileId, segments);
},
async getAudioBuffer(fileId: number): Promise<AudioBufferPayload> {
return ipcRenderer.invoke(IPC_CHANNELS.libraryBuffer, fileId);
},
@@ -70,7 +77,10 @@ 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<void> {
async updateFileMetadata(
fileId: number,
metadata: { author?: string | null; copyright?: string | null; rating?: number }
): Promise<void> {
return ipcRenderer.invoke(IPC_CHANNELS.libraryUpdateMetadata, fileId, metadata);
},
onMenuAction(channel: string, callback: () => void): () => void {

View File

@@ -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<RightPanelTab>('listen');
const statusMessage = useMemo(() => {
if (!library.lastScan) {
@@ -65,6 +69,14 @@ function App(): JSX.Element {
[library.files, library.selectedFileId]
);
const parentFile = useMemo(() => {
const parentId = selectedFile?.parentFileId ?? null;
if (parentId === null) {
return null;
}
return library.files.find((file) => file.id === parentId) ?? null;
}, [library.files, selectedFile?.parentFileId]);
const selectedFiles = useMemo(
() => library.files.filter((file) => library.selectedFileIds.has(file.id)),
[library.files, library.selectedFileIds]
@@ -132,10 +144,6 @@ function App(): JSX.Element {
libraryStore.selectFile(fileId, options);
};
const handleSelectAllFiles = () => {
libraryStore.selectAllVisibleFiles();
};
const handlePlayFile = (file: AudioFileSummary) => {
loadPlayerFile(file, true);
};
@@ -158,11 +166,19 @@ 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;
}
await libraryStore.organizeFile(selectedFile.id, metadata);
// Scroll the file into view after organizing (filename may have changed)
setTimeout(() => {
const element = document.querySelector(`[data-file-id="${selectedFile.id}"]`);
if (element) {
element.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
}, 100);
};
const handleUpdateCustomName = async (customName: string | null) => {
@@ -172,26 +188,31 @@ function App(): JSX.Element {
await libraryStore.updateCustomName(selectedFile.id, customName);
};
const handleTagUpdate = async (data: { tags: string[]; categories: string[] }) => {
const handleTagUpdate = async (categories: string[]) => {
if (!selectedFile) {
return;
}
await libraryStore.updateTagging({ fileId: selectedFile.id, ...data });
await libraryStore.updateTagging({ fileId: selectedFile.id, categories });
};
const handleMultiFileTagUpdate = async (fileId: number, data: { tags: string[]; categories: string[] }) => {
await libraryStore.updateTagging({ fileId, ...data });
const handleOpenParent = (parentId: number) => {
void libraryStore.focusOnFile(parentId);
setActiveTab('listen');
};
const handleMultiFileTagUpdate = async (fileId: number, categories: string[]) => {
await libraryStore.updateTagging({ fileId, categories });
};
const handleMultiFileCustomName = async (fileId: number, customName: string | null) => {
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);
};
@@ -206,7 +227,6 @@ function App(): JSX.Element {
const newCategories = [...existingCategories, categoryId];
await libraryStore.updateTagging({
fileId,
tags: file.tags,
categories: newCategories
});
}
@@ -217,6 +237,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 (
<div className="app-root">
{showStatusMessage && statusMessage && (
@@ -240,7 +272,6 @@ function App(): JSX.Element {
onPlay={handlePlayFile}
searchValue={library.searchQuery}
onSearchChange={handleSearch}
onSelectAll={handleSelectAllFiles}
/>
<div className="app-details">
{isMultiSelect ? (
@@ -255,17 +286,47 @@ function App(): JSX.Element {
/>
) : (
<>
<FileDetailPanel
file={selectedFile}
categories={library.categories}
onRename={handleRename}
onMove={handleMove}
onOrganize={handleOrganize}
onUpdateTags={handleTagUpdate}
onUpdateCustomName={handleUpdateCustomName}
metadataSuggestionsVersion={library.metadataSuggestionsVersion}
/>
<AudioPlayer snapshot={player} />
<div className="detail-tabs">
<button
type="button"
className={activeTab === 'listen' ? 'detail-tab detail-tab--active' : 'detail-tab'}
onClick={() => setActiveTab('listen')}
>
Listen
</button>
<button
type="button"
className={activeTab === 'edit' ? 'detail-tab detail-tab--active' : 'detail-tab'}
onClick={() => setActiveTab('edit')}
disabled={!selectedFile}
>
Edit
</button>
</div>
{activeTab === 'listen' ? (
<>
<FileDetailPanel
file={selectedFile}
parentFile={parentFile}
categories={library.categories}
onRename={handleRename}
onMove={handleMove}
onOrganize={handleOrganize}
onUpdateTags={handleTagUpdate}
onUpdateCustomName={handleUpdateCustomName}
onOpenParent={handleOpenParent}
metadataSuggestionsVersion={library.metadataSuggestionsVersion}
/>
<AudioPlayer snapshot={player} />
</>
) : selectedFile ? (
<EditModePanel
file={selectedFile}
categories={library.categories}
onClose={handleEditModeClose}
onSplitComplete={handleEditModeSplitComplete}
/>
) : null}
</>
)}
</div>

View File

View File

@@ -2,25 +2,36 @@ import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent, ty
import type { AudioFileSummary, CategoryRecord } from '../../../shared/models';
import { TagEditor } from './TagEditor';
/**
* Normalizes path separators to backslashes for consistent display on Windows.
*/
function normalizePathDisplay(path: string): string {
return path.replace(/\//g, '\\');
}
/**
* Props for FileDetailPanel component.
*/
export interface FileDetailPanelProps {
file: AudioFileSummary | null;
/** Optional summary of the parent file when this entry was generated from another file. */
parentFile: AudioFileSummary | null;
categories: CategoryRecord[];
onRename(newName: string): Promise<void>;
onMove(targetRelativeDirectory: string): Promise<void>;
/** Organizes a file with optional metadata fields (customName, author, rating 1-5). */
onOrganize(metadata: { customName?: string | null; author?: string | null; rating?: number }): Promise<void>;
onUpdateTags(data: { tags: string[]; categories: string[] }): Promise<void>;
/** Organizes a file with optional metadata fields (customName, author, copyright, rating 1-5). */
onOrganize(metadata: { customName?: string; author?: string; copyright?: string; rating?: number }): Promise<void>;
onUpdateTags(categories: string[]): Promise<void>;
onUpdateCustomName(customName: string | null): Promise<void>;
/** Invoked when the user requests to open the parent file. */
onOpenParent?(parentId: number): void;
metadataSuggestionsVersion: number;
}
/**
* Displays metadata for the selected file with rename, move, and tagging controls.
*/
export function FileDetailPanel({ file, categories, onRename, onMove, onOrganize, onUpdateTags, onUpdateCustomName, metadataSuggestionsVersion }: FileDetailPanelProps): JSX.Element {
export function FileDetailPanel({ file, parentFile, categories, onRename, onMove, onOrganize, onUpdateTags, onUpdateCustomName, onOpenParent, metadataSuggestionsVersion }: FileDetailPanelProps): JSX.Element {
const [isEditingCustomName, setIsEditingCustomName] = useState(false);
const [moveDraft, setMoveDraft] = useState('');
const [customNameDraft, setCustomNameDraft] = useState('');
@@ -29,8 +40,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 +105,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 +113,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 +129,7 @@ export function FileDetailPanel({ file, categories, onRename, onMove, onOrganize
setRatingDraft(ratingValue);
initialMetadataRef.current = {
author: authorValue,
copyright: '',
rating: ratingValue,
customName: customNameValue
};
@@ -127,7 +140,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 +203,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 }, force = false) => {
if (!file) {
return;
}
@@ -200,18 +213,21 @@ 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
};
const previous = initialMetadataRef.current;
if (
!force &&
previous &&
previous.author === comparisonState.author &&
previous.copyright === comparisonState.copyright &&
previous.rating === comparisonState.rating &&
previous.customName === comparisonState.customName
) {
@@ -220,18 +236,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
});
}
@@ -247,10 +262,12 @@ export function FileDetailPanel({ file, categories, onRename, onMove, onOrganize
}
};
const handleTagSave = async (data: { tags: string[]; categories: string[] }) => {
const handleCategorySave = async (selectedCategories: string[]) => {
setBusy(true);
try {
await onUpdateTags(data);
await onUpdateTags(selectedCategories);
} catch (error) {
console.error('Failed to update categories', error);
} finally {
setBusy(false);
}
@@ -265,6 +282,13 @@ export function FileDetailPanel({ file, categories, onRename, onMove, onOrganize
}
};
const handleOpenParent = () => {
if (!file || file.parentFileId === null || !onOpenParent) {
return;
}
onOpenParent(file.parentFileId);
};
const toggleTagSection = () => {
setIsTagSectionExpanded((value) => !value);
};
@@ -289,15 +313,65 @@ export function FileDetailPanel({ file, categories, onRename, onMove, onOrganize
{file.customName || file.displayName}
</h1>
)}
<p
onClick={handleOpenFolder}
style={{ cursor: 'pointer', opacity: 0.7, transition: 'opacity 0.2s ease' }}
onMouseEnter={(e) => e.currentTarget.style.opacity = '1'}
onMouseLeave={(e) => e.currentTarget.style.opacity = '0.7'}
title="Click to open in folder"
<div
style={{ display: 'flex', alignItems: 'center', gap: '8px' }}
onMouseEnter={(e) => {
const btn = e.currentTarget.querySelector('.file-regenerate-name') as HTMLElement;
if (btn) btn.style.opacity = '1';
}}
onMouseLeave={(e) => {
const btn = e.currentTarget.querySelector('.file-regenerate-name') as HTMLElement;
if (btn) btn.style.opacity = '0';
}}
>
{file.relativePath}
</p>
<p
onClick={handleOpenFolder}
style={{ cursor: 'pointer', opacity: 0.7, transition: 'opacity 0.2s ease', margin: 0 }}
onMouseEnter={(e) => e.currentTarget.style.opacity = '1'}
onMouseLeave={(e) => e.currentTarget.style.opacity = '0.7'}
title="Click to open in folder"
>
{normalizePathDisplay(file.relativePath)}
</p>
<button
type="button"
className="file-regenerate-name"
onClick={async (e) => {
e.preventDefault();
e.stopPropagation();
setBusy(true);
try {
await triggerOrganize({}, true);
} finally {
setBusy(false);
}
}}
disabled={busy}
title="Regenerate filename based on current metadata"
style={{
padding: '4px 8px',
fontSize: '18px',
opacity: 0,
transition: 'opacity 0.2s ease',
background: 'none',
border: 'none',
cursor: 'pointer',
color: 'inherit'
}}
>
</button>
</div>
{file.parentFileId !== null && onOpenParent ? (
<button
type="button"
className="file-parent-link"
onClick={handleOpenParent}
title={parentFile ? `Open source file ${parentFile.displayName}` : 'Open source file'}
>
🔗 {parentFile ? parentFile.customName || parentFile.displayName : `File #${file.parentFileId}`}
</button>
) : null}
</div>
<div className="file-meta-grid">
<div>{formatBytes(file.size)}</div>
@@ -371,17 +445,16 @@ export function FileDetailPanel({ file, categories, onRename, onMove, onOrganize
onClick={toggleTagSection}
aria-expanded={isTagSectionExpanded}
>
<span className="tag-editor-toggle-label">Tags</span>
<span className="tag-editor-toggle-label">Categories</span>
<span className="tag-editor-toggle-icon" aria-hidden="true">
{isTagSectionExpanded ? 'v' : '>'}
</span>
</button>
<div className={isTagSectionExpanded ? 'tag-editor-content tag-editor-content--open' : 'tag-editor-content'} aria-hidden={!isTagSectionExpanded}>
<TagEditor
tags={file.tags}
categories={file.categories}
availableCategories={categories}
onSave={handleTagSave}
onSave={handleCategorySave}
showHeading={false}
/>
</div>

View File

@@ -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<number, HTMLButtonElement>());
const waveformCacheRef = useRef<Record<number, WaveformVisual>>({});
const [, forceWaveformUpdate] = useReducer((value: number) => value + 1, 0);
const dragGhostRef = useRef<HTMLElement | null>(null);
const dragRotation = useRef(0);
const lastDragX = useRef(0);
const dragRotation = useRef<number>(0);
const lastDragX = useRef<number>(0);
const dropTargetPosition = useRef<{ x: number; y: number } | null>(null);
const dragOffset = useRef({ x: 0, y: 0 });
const dragStartTime = useRef(0);
const listContainerRef = useRef<HTMLDivElement | null>(null);
const dragOffset = useRef<{ x: number; y: number }>({ x: 0, y: 0 });
const dragStartTime = useRef<number>(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..."
/>
</div>
<div className="file-list-items" ref={listContainerRef}>
<div className="file-list-items">
{files.map((file) => {
const isActive = file.id === selectedId;
const isSelected = selectedIds.has(file.id);
@@ -186,6 +145,7 @@ export function FileList({ files, selectedId, selectedIds, onSelect, onPlay, sea
type="button"
className={className}
draggable={true}
data-file-id={file.id}
ref={(node) => {
if (node) {
buttonRefs.current.set(file.id, node);
@@ -196,6 +156,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 +166,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 +175,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 +185,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 +223,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 +245,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 +273,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;
}

View File

@@ -6,7 +6,7 @@ import { TagEditor } from './TagEditor';
export interface MultiFileEditorProps {
files: AudioFileSummary[];
categories: CategoryRecord[];
onUpdateTags(fileId: number, data: { tags: string[]; categories: string[] }): Promise<void>;
onUpdateTags(fileId: number, categories: string[]): Promise<void>;
onUpdateCustomName(fileId: number, customName: string | null): Promise<void>;
onOrganize(fileId: number, metadata: { customName?: string | null; author?: string | null; rating?: number }): Promise<void>;
onUpdateMetadata(fileId: number, metadata: { author?: string | null; rating?: number }): Promise<void>;
@@ -14,7 +14,7 @@ export interface MultiFileEditorProps {
}
/**
* Multi-file tag editor that shows aggregated metadata and allows batch editing.
* Multi-file metadata editor that focuses on shared categories and attributes.
*/
export function MultiFileEditor({ files, categories, onUpdateTags, onUpdateCustomName, onOrganize, onUpdateMetadata, metadataSuggestionsVersion }: MultiFileEditorProps): JSX.Element {
const [sharedCustomName, setSharedCustomName] = useState('');
@@ -23,20 +23,6 @@ export function MultiFileEditor({ files, categories, onUpdateTags, onUpdateCusto
const [suggestions, setSuggestions] = useState<{ authors: string[] }>({ authors: [] });
const [isTagSectionExpanded, setIsTagSectionExpanded] = useState(true);
// Compute aggregated tags and categories for TagEditor
const aggregatedTags = useMemo(() => {
const tagCounts = new Map<string, number>();
for (const file of files) {
for (const tag of file.tags) {
tagCounts.set(tag, (tagCounts.get(tag) || 0) + 1);
}
}
// Only show tags that appear in all files
return Array.from(tagCounts.entries())
.filter(([, count]) => count === files.length)
.map(([tag]) => tag);
}, [files]);
const aggregatedCategories = useMemo(() => {
const categoryCounts = new Map<string, number>();
for (const file of files) {
@@ -199,22 +185,22 @@ export function MultiFileEditor({ files, categories, onUpdateTags, onUpdateCusto
}
};
const handleTagSave = async (data: { tags: string[]; categories: string[] }) => {
const handleCategorySave = async (selectedCategories: string[]) => {
try {
let failureCount = 0;
for (const file of files) {
try {
await onUpdateTags(file.id, data);
await onUpdateTags(file.id, selectedCategories);
} catch (error) {
failureCount += 1;
console.error(`Failed to update tags for file ${file.id} (${file.fileName}):`, error);
console.error(`Failed to update categories for file ${file.id} (${file.fileName}):`, error);
}
}
if (failureCount > 0) {
console.error(`${failureCount} file(s) failed to update tags`);
console.error(`${failureCount} file(s) failed to update categories`);
}
} catch (error) {
console.error('Failed to update tags:', error);
console.error('Failed to update categories:', error);
}
};
@@ -309,17 +295,16 @@ export function MultiFileEditor({ files, categories, onUpdateTags, onUpdateCusto
onClick={toggleTagSection}
aria-expanded={isTagSectionExpanded}
>
<span className="tag-editor-toggle-label">Tags</span>
<span className="tag-editor-toggle-label">Categories</span>
<span className="tag-editor-toggle-icon" aria-hidden="true">
{isTagSectionExpanded ? 'v' : '>'}
</span>
</button>
<div className={isTagSectionExpanded ? 'tag-editor-content tag-editor-content--open' : 'tag-editor-content'} aria-hidden={!isTagSectionExpanded}>
<TagEditor
tags={aggregatedTags}
categories={aggregatedCategories}
availableCategories={categories}
onSave={handleTagSave}
onSave={handleCategorySave}
showHeading={false}
/>
</div>

View File

@@ -1,13 +1,4 @@
import {
useEffect,
useMemo,
useState,
useRef,
useLayoutEffect,
type ChangeEvent,
type UIEvent,
type CSSProperties
} from 'react';
import { useEffect, useMemo, useState, useRef, useLayoutEffect, type UIEvent } from 'react';
import type { CategoryRecord } from '../../../shared/models';
import {
getCollapsedGroups,
@@ -24,19 +15,17 @@ import {
import type { CategorySwatch } from '../utils/categoryColors';
export interface TagEditorProps {
tags: string[];
categories: string[];
availableCategories: CategoryRecord[];
onSave(data: { tags: string[]; categories: string[] }): void;
onSave(categories: string[]): void | Promise<void>;
/** If false the internal heading is omitted. Defaults to true. */
showHeading?: boolean;
}
/**
* Allows editing free-form tags and selecting UCS categories.
* Provides category selection controls without exposing free-form tag editing.
*/
export function TagEditor({ tags, categories, availableCategories, onSave, showHeading = true }: TagEditorProps): JSX.Element {
const [tagDraft, setTagDraft] = useState(tags.join(', '));
export function TagEditor({ categories, availableCategories, onSave, showHeading = true }: TagEditorProps): JSX.Element {
const [categoryFilter, setCategoryFilter] = useState('');
const [selectedCategories, setSelectedCategories] = useState(new Set(categories));
const [isCategoryListExpanded, setIsCategoryListExpanded] = useState(false);
@@ -52,7 +41,6 @@ export function TagEditor({ tags, categories, availableCategories, onSave, showH
const isFirstRender = useRef(true);
useEffect(() => {
setTagDraft(tags.join(', '));
setSelectedCategories(new Set(categories));
if (!isFirstRender.current) {
@@ -61,7 +49,7 @@ export function TagEditor({ tags, categories, availableCategories, onSave, showH
} else {
isFirstRender.current = false;
}
}, [tags, categories]);
}, [categories]);
const { groupedCategories, filteredResults } = useMemo(() => {
const filter = categoryFilter.trim().toLowerCase();
@@ -83,16 +71,18 @@ export function TagEditor({ tags, categories, availableCategories, onSave, showH
}, [availableCategories, categoryFilter]);
const selectedCategoryRecords = useMemo(
() => availableCategories
.filter((entry) => selectedCategories.has(entry.id))
.sort((left, right) => {
const topLevelComparison = left.category.localeCompare(right.category);
if (topLevelComparison !== 0) {
return topLevelComparison;
() => {
// Preserve the order from the categories prop (first category is primary)
const orderedRecords: CategoryRecord[] = [];
for (const catId of categories) {
const record = availableCategories.find((entry) => entry.id === catId);
if (record && selectedCategories.has(catId)) {
orderedRecords.push(record);
}
return left.subCategory.localeCompare(right.subCategory);
}),
[availableCategories, selectedCategories]
}
return orderedRecords;
},
[availableCategories, selectedCategories, categories]
);
const categoryColorMap = useMemo(() => {
@@ -146,20 +136,17 @@ export function TagEditor({ tags, categories, availableCategories, onSave, showH
next.add(categoryId);
}
setSelectedCategories(next);
const parsedTags = tagDraft
.split(',')
.map((value: string) => value.trim())
.filter((value: string) => value.length > 0);
onSave({ tags: parsedTags, categories: Array.from(next) });
onSave(Array.from(next));
};
const handleTagBlur = () => {
const parsedTags = tagDraft
.split(',')
.map((value: string) => value.trim())
.filter((value: string) => value.length > 0);
onSave({ tags: parsedTags, categories: Array.from(selectedCategories) });
const handleSetPrimaryCategory = (categoryId: string) => {
const currentList = Array.from(selectedCategories);
// Remove the category from its current position
const filteredList = currentList.filter((id) => id !== categoryId);
// Add it to the front
const reorderedList = [categoryId, ...filteredList];
setSelectedCategories(new Set(reorderedList));
void onSave(reorderedList);
};
const handleRemoveCategory = (categoryId: string) => {
@@ -174,11 +161,7 @@ export function TagEditor({ tags, categories, availableCategories, onSave, showH
return;
}
setSelectedCategories(new Set());
const parsedTags = tagDraft
.split(',')
.map((value: string) => value.trim())
.filter((value: string) => value.length > 0);
onSave({ tags: parsedTags, categories: [] });
onSave([]);
};
const toggleCategoryList = () => {
@@ -187,18 +170,127 @@ export function TagEditor({ tags, categories, availableCategories, onSave, showH
return (
<section className="tag-editor">
{showHeading && <h2>Tags</h2>}
<textarea
value={tagDraft}
onChange={(event: ChangeEvent<HTMLTextAreaElement>) => setTagDraft(event.target.value)}
onBlur={handleTagBlur}
placeholder="Comma separated tags"
rows={3}
/>
{showHeading && <h2>Categories</h2>}
<div className="selected-categories">
<div className="selected-category-chip-list">
{selectedCategoryRecords.length === 0 && <span className="selected-category-empty">No categories selected</span>}
{selectedCategoryRecords.map((entry, index) => {
const categoryLabel = formatCategoryLabel(entry.category);
const subCategoryLabel = formatCategoryLabel(entry.subCategory);
const isPrimary = index === 0;
return (
<div
key={entry.id}
className="selected-category-chip"
style={{
...createCategoryStyleVars(categoryColorMap.get(entry.id)),
display: 'inline-flex',
alignItems: 'center',
gap: '4px',
position: 'relative'
}}
onMouseEnter={(e) => {
const star = e.currentTarget.querySelector('.category-star-button') as HTMLElement;
if (star && !isPrimary) {
star.style.opacity = '1';
star.style.width = 'auto';
star.style.padding = '0 4px';
}
const removeBtn = e.currentTarget.querySelector('.selected-category-chip-remove-button') as HTMLElement;
if (removeBtn) {
removeBtn.style.opacity = '1';
removeBtn.style.width = 'auto';
removeBtn.style.padding = '0 4px';
}
}}
onMouseLeave={(e) => {
const star = e.currentTarget.querySelector('.category-star-button') as HTMLElement;
if (star && !isPrimary) {
star.style.opacity = '0';
star.style.width = '0';
star.style.padding = '0';
}
const removeBtn = e.currentTarget.querySelector('.selected-category-chip-remove-button') as HTMLElement;
if (removeBtn) {
removeBtn.style.opacity = '0';
removeBtn.style.width = '0';
removeBtn.style.padding = '0';
}
}}
title={isPrimary ? `${categoryLabel} ${subCategoryLabel} (Primary - used for organization)` : `${categoryLabel} ${subCategoryLabel}`}
>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handleSetPrimaryCategory(entry.id);
}}
className="category-star-button"
style={{
background: 'none',
border: 'none',
padding: isPrimary ? '0 4px' : '0',
margin: 0,
cursor: 'pointer',
fontSize: '14px',
opacity: isPrimary ? 1 : 0,
width: isPrimary ? 'auto' : '0',
overflow: 'hidden',
transition: 'opacity 0.2s ease, width 0.2s ease, padding 0.2s ease',
color: isPrimary ? 'gold' : 'currentColor',
flexShrink: 0
}}
title={isPrimary ? 'Primary category' : 'Set as primary category'}
aria-label={isPrimary ? 'Primary category' : 'Set as primary category'}
>
{isPrimary ? '★' : '☆'}
</button>
<span className="selected-category-chip-label">
<span className="category-label category-label--muted">{categoryLabel}</span>
<span className="category-label category-label--primary">{subCategoryLabel}</span>
</span>
<button
type="button"
className="selected-category-chip-remove-button"
onClick={(e) => {
e.stopPropagation();
handleRemoveCategory(entry.id);
}}
style={{
background: 'none',
border: 'none',
padding: '0',
margin: 0,
cursor: 'pointer',
fontSize: '18px',
lineHeight: 1,
opacity: 0,
width: '0',
overflow: 'hidden',
transition: 'opacity 0.2s ease, width 0.2s ease, padding 0.2s ease',
flexShrink: 0
}}
aria-label="Remove category"
>
<span aria-hidden="true">×</span>
</button>
</div>
);
})}
</div>
<button
type="button"
className="ghost-button selected-category-clear"
onClick={handleClearCategories}
disabled={selectedCategories.size === 0}
>
Clear All
</button>
</div>
<div className="category-filter">
<input
value={categoryFilter}
onChange={(event: ChangeEvent<HTMLInputElement>) => setCategoryFilter(event.target.value)}
onChange={(event) => setCategoryFilter(event.target.value)}
placeholder="Filter UCS categories"
/>
</div>
@@ -234,39 +326,6 @@ export function TagEditor({ tags, categories, availableCategories, onSave, showH
<div className="category-filter-empty">No categories match this filter.</div>
)
)}
<div className="selected-categories">
<div className="selected-category-chip-list">
{selectedCategoryRecords.length === 0 && <span className="selected-category-empty">No categories selected</span>}
{selectedCategoryRecords.map((entry) => {
const categoryLabel = formatCategoryLabel(entry.category);
const subCategoryLabel = formatCategoryLabel(entry.subCategory);
return (
<button
key={entry.id}
type="button"
className="selected-category-chip"
onClick={() => handleRemoveCategory(entry.id)}
style={createCategoryStyleVars(categoryColorMap.get(entry.id))}
title={`${categoryLabel} ${subCategoryLabel}`}
>
<span className="selected-category-chip-label">
<span className="category-label category-label--muted">{categoryLabel}</span>
<span className="category-label category-label--primary">{subCategoryLabel}</span>
</span>
<span className="selected-category-chip-remove" aria-hidden="true">×</span>
</button>
);
})}
</div>
<button
type="button"
className="ghost-button selected-category-clear"
onClick={handleClearCategories}
disabled={selectedCategories.size === 0}
>
Clear All
</button>
</div>
<div className="category-list-pane">
<button
type="button"

View File

@@ -0,0 +1,834 @@
import { useCallback, useEffect, useMemo, useRef, 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;
type PlaybackMode = 'segment' | 'cursor';
interface PlaybackInfo {
mode: PlaybackMode;
startMs: number;
endMs: number | null;
label: string;
segmentId: string | null;
pausedOffsetMs: number;
}
interface PlaybackUiState {
mode: PlaybackMode;
label: string;
startMs: number;
endMs: number | null;
isPlaying: boolean;
}
/**
* 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<number[] | null>(null);
const [waveformError, setWaveformError] = useState<string | null>(null);
const [durationMs, setDurationMs] = useState<number | null>(file.durationMs ?? null);
const [segments, setSegments] = useState<SegmentDraft[]>([]);
const [selectedSegmentId, setSelectedSegmentId] = useState<string | null>(null);
const [isSaving, setIsSaving] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
const [baseMetadata, setBaseMetadata] = useState<{ author: string | null; rating: number | null }>(
() => ({ author: null, rating: null })
);
const audioContextRef = useRef<AudioContext | null>(null);
const audioBufferRef = useRef<AudioBuffer | null>(null);
const audioBufferPromiseRef = useRef<Promise<AudioBuffer | null> | null>(null);
const activeSourceRef = useRef<AudioBufferSourceNode | null>(null);
const playbackStartTimeRef = useRef<number | null>(null);
const playbackInfoRef = useRef<PlaybackInfo | null>(null);
const activePlaybackIdRef = useRef<string | null>(null);
const lastSegmentPlayRef = useRef<{ segmentId: string; timestamp: number } | null>(null);
const playbackCursorFrameRef = useRef<number | null>(null);
const [playbackCursorMs, setPlaybackCursorMs] = useState<number | null>(null);
const [playbackUi, setPlaybackUi] = useState<PlaybackUiState | null>(null);
const ensureAudioContext = useCallback((): AudioContext | null => {
if (audioContextRef.current) {
return audioContextRef.current;
}
const globalWindow = window as typeof window & { webkitAudioContext?: typeof AudioContext };
const AudioContextCtor = globalWindow.AudioContext ?? globalWindow.webkitAudioContext;
if (!AudioContextCtor) {
setWaveformError((current) => current ?? 'Audio playback is not supported in this environment.');
return null;
}
audioContextRef.current = new AudioContextCtor();
return audioContextRef.current;
}, []);
const stopActiveSource = useCallback(() => {
const source = activeSourceRef.current;
if (!source) {
return;
}
source.onended = null;
try {
source.stop();
} catch (error) {
// Ignore errors when the source has already completed playback.
}
source.disconnect();
activeSourceRef.current = null;
}, []);
const stopPlaybackCursorTracking = useCallback((options?: { preservePosition?: boolean }) => {
if (playbackCursorFrameRef.current !== null) {
cancelAnimationFrame(playbackCursorFrameRef.current);
playbackCursorFrameRef.current = null;
}
if (!options?.preservePosition) {
setPlaybackCursorMs(null);
}
}, []);
const schedulePlaybackCursorUpdate = useCallback(() => {
const context = audioContextRef.current;
const info = playbackInfoRef.current;
if (!context || !info || playbackStartTimeRef.current === null) {
stopPlaybackCursorTracking();
return;
}
const elapsedMs = Math.max(0, (context.currentTime - playbackStartTimeRef.current) * 1000);
const buffer = audioBufferRef.current;
const bufferDurationMs = buffer ? buffer.duration * 1000 : Number.POSITIVE_INFINITY;
const absoluteEndMs = info.endMs ?? bufferDurationMs;
const position = info.startMs + info.pausedOffsetMs + elapsedMs;
const clampedPosition = Math.min(position, absoluteEndMs);
setPlaybackCursorMs(clampedPosition);
if (position >= absoluteEndMs) {
stopPlaybackCursorTracking({ preservePosition: true });
return;
}
playbackCursorFrameRef.current = window.requestAnimationFrame(schedulePlaybackCursorUpdate);
}, [stopPlaybackCursorTracking]);
const cancelPlayback = useCallback(() => {
stopActiveSource();
activePlaybackIdRef.current = null;
playbackStartTimeRef.current = null;
playbackInfoRef.current = null;
stopPlaybackCursorTracking();
setPlaybackUi(null);
}, [stopActiveSource, stopPlaybackCursorTracking]);
const loadAudioBuffer = useCallback(async (): Promise<AudioBuffer | null> => {
if (audioBufferRef.current) {
return audioBufferRef.current;
}
if (audioBufferPromiseRef.current) {
return audioBufferPromiseRef.current;
}
const context = ensureAudioContext();
if (!context) {
return null;
}
const promise = (async () => {
try {
const payload = await window.api.getAudioBuffer(file.id);
const buffer = await context.decodeAudioData(payload.buffer.slice(0));
audioBufferRef.current = buffer;
return buffer;
} catch (error) {
console.error('Failed to decode audio buffer for playback', error);
return null;
} finally {
audioBufferPromiseRef.current = null;
}
})();
audioBufferPromiseRef.current = promise;
return promise;
}, [ensureAudioContext, file.id]);
const beginPlayback = useCallback(
async (request: PlaybackInfo) => {
try {
const buffer = await loadAudioBuffer();
if (!buffer) {
return;
}
const context = ensureAudioContext();
if (!context) {
return;
}
await context.resume();
stopActiveSource();
const bufferDurationMs = buffer.duration * 1000;
const absoluteEndMs = request.endMs ?? bufferDurationMs;
const playbackStartMs = request.startMs + request.pausedOffsetMs;
if (playbackStartMs >= absoluteEndMs) {
return;
}
const id = generatePlaybackId();
const playbackLengthMs = absoluteEndMs - playbackStartMs;
const source = context.createBufferSource();
source.buffer = buffer;
source.connect(context.destination);
source.start(0, playbackStartMs / 1000, playbackLengthMs / 1000);
playbackInfoRef.current = { ...request };
activePlaybackIdRef.current = id;
playbackStartTimeRef.current = context.currentTime;
activeSourceRef.current = source;
stopPlaybackCursorTracking({ preservePosition: true });
setPlaybackCursorMs(request.startMs + request.pausedOffsetMs);
schedulePlaybackCursorUpdate();
setPlaybackUi({
mode: request.mode,
label: request.label,
startMs: request.startMs,
endMs: request.endMs,
isPlaying: true
});
source.onended = () => {
if (activePlaybackIdRef.current === id) {
const infoSnapshot = playbackInfoRef.current;
const buffer = audioBufferRef.current;
const bufferDurationMs = buffer ? buffer.duration * 1000 : Number.POSITIVE_INFINITY;
const finalPosition = infoSnapshot ? (infoSnapshot.endMs ?? bufferDurationMs) : null;
if (finalPosition !== null && Number.isFinite(finalPosition)) {
setPlaybackCursorMs(finalPosition);
}
stopPlaybackCursorTracking({ preservePosition: true });
activePlaybackIdRef.current = null;
playbackStartTimeRef.current = null;
playbackInfoRef.current = null;
activeSourceRef.current = null;
setPlaybackUi(null);
}
};
} catch (error) {
console.error('Playback start failed', error);
stopPlaybackCursorTracking();
}
},
[ensureAudioContext, loadAudioBuffer, stopActiveSource, schedulePlaybackCursorUpdate, stopPlaybackCursorTracking]
);
const pausePlayback = useCallback(() => {
const info = playbackInfoRef.current;
const context = audioContextRef.current;
if (!info || !context) {
return;
}
if (activePlaybackIdRef.current === null || playbackStartTimeRef.current === null) {
return;
}
const elapsedMs = (context.currentTime - playbackStartTimeRef.current) * 1000;
const bufferDurationMs = audioBufferRef.current ? audioBufferRef.current.duration * 1000 : Number.POSITIVE_INFINITY;
const absoluteEndMs = info.endMs ?? bufferDurationMs;
const nextOffset = Math.min(absoluteEndMs - info.startMs, info.pausedOffsetMs + elapsedMs);
const absolutePosition = info.startMs + nextOffset;
playbackInfoRef.current = { ...info, pausedOffsetMs: nextOffset };
activePlaybackIdRef.current = null;
playbackStartTimeRef.current = null;
stopActiveSource();
stopPlaybackCursorTracking({ preservePosition: true });
setPlaybackCursorMs(absolutePosition);
setPlaybackUi((current) => (current ? { ...current, isPlaying: false } : current));
}, [stopActiveSource, stopPlaybackCursorTracking]);
const resumePlayback = useCallback(() => {
const info = playbackInfoRef.current;
if (!info) {
return;
}
beginPlayback(info);
}, [beginPlayback]);
const togglePlayback = useCallback(() => {
if (activePlaybackIdRef.current) {
pausePlayback();
return;
}
if (playbackInfoRef.current) {
resumePlayback();
}
}, [pausePlayback, resumePlayback]);
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(() => {
let cancelled = false;
(async () => {
const buffer = await loadAudioBuffer();
if (cancelled || !buffer) {
return;
}
setDurationMs((current) => (current === null ? Math.round(buffer.duration * 1000) : current));
})();
return () => {
cancelled = true;
};
}, [loadAudioBuffer]);
useEffect(() => {
return () => {
cancelPlayback();
const context = audioContextRef.current;
if (context) {
context.close().catch(() => undefined);
audioContextRef.current = null;
}
};
}, [cancelPlayback]);
const selectedSegment = useMemo(
() => segments.find((segment) => segment.id === selectedSegmentId) ?? null,
[segments, selectedSegmentId]
);
const isWaveformReady = waveformSamples !== null && durationMs !== null;
const effectiveDuration = durationMs ?? 0;
const playSegment = useCallback(
(segment: SegmentDraft) => {
const trimmedLabel = segment.label.trim();
const label = trimmedLabel.length > 0
? trimmedLabel
: `${formatTimecode(segment.startMs)}${formatTimecode(segment.endMs)}`;
beginPlayback({
mode: 'segment',
startMs: segment.startMs,
endMs: segment.endMs,
label,
segmentId: segment.id,
pausedOffsetMs: 0
});
},
[beginPlayback]
);
const playFromCursor = useCallback(
(startMs: number) => {
beginPlayback({
mode: 'cursor',
startMs,
endMs: null,
label: `From ${formatTimecode(startMs)}`,
segmentId: null,
pausedOffsetMs: 0
});
},
[beginPlayback]
);
const handleClose = useCallback(() => {
cancelPlayback();
onClose();
}, [cancelPlayback, onClose]);
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),
color: generateSegmentColor(segments.length)
};
setSegments((current) => {
const next = [...current, newSegment].sort((a, b) => a.startMs - b.startMs);
return next;
});
setSelectedSegmentId(newSegment.id);
playSegment(newSegment);
};
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 = useCallback((segmentId: string | null) => {
setSelectedSegmentId(segmentId);
if (!segmentId) {
return;
}
const segment = segments.find((entry) => entry.id === segmentId);
if (segment) {
const now = performance.now();
const lastPlay = lastSegmentPlayRef.current;
const recentlyStartedSameSegment =
lastPlay &&
lastPlay.segmentId === segmentId &&
now - lastPlay.timestamp < 500;
if (!recentlyStartedSameSegment) {
playSegment(segment);
lastSegmentPlayRef.current = {
segmentId,
timestamp: now
};
}
}
}, [segments, playSegment]);
const handleUpdateLabel = (segmentId: string, label: string) => {
setSegments((current) =>
current.map((segment) =>
segment.id === segmentId
? { ...segment, label }
: segment
)
);
};
const handleRemoveSegment = useCallback((segmentId: string) => {
setSegments((current) => current.filter((segment) => segment.id !== segmentId));
setSelectedSegmentId((current) => (current === segmentId ? null : current));
if (playbackInfoRef.current?.segmentId === segmentId) {
cancelPlayback();
}
}, [cancelPlayback]);
const handleMetadataPatch = (segmentId: string, patch: Partial<SegmentMetadataDraft>) => {
setSegments((current) =>
current.map((segment) =>
segment.id === segmentId
? { ...segment, metadata: { ...segment.metadata, ...patch } }
: segment
)
);
};
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
const activeElement = document.activeElement;
if (activeElement && (activeElement.tagName === 'INPUT' || activeElement.tagName === 'TEXTAREA' || activeElement.getAttribute('contenteditable') === 'true')) {
return;
}
if (event.key === 'Delete') {
if (selectedSegmentId) {
event.preventDefault();
handleRemoveSegment(selectedSegmentId);
}
return;
}
if (event.code === 'Space' || event.key === ' ') {
event.preventDefault();
if (playbackInfoRef.current) {
togglePlayback();
} else if (selectedSegmentId) {
const segment = segments.find((seg) => seg.id === selectedSegmentId);
if (segment) {
playSegment(segment);
}
}
}
};
window.addEventListener('keydown', handleKeyDown);
return () => {
window.removeEventListener('keydown', handleKeyDown);
};
}, [selectedSegmentId, segments, handleRemoveSegment, togglePlayback, playSegment]);
const handleCategorySave = (segmentId: string, categories: string[]) => {
handleMetadataPatch(segmentId, {
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);
handleClose();
} catch (error) {
console.error('Split operation failed', error);
setSubmitError(error instanceof Error ? error.message : String(error));
} finally {
setIsSaving(false);
}
};
return (
<div className="edit-mode-overlay" role="dialog" aria-modal="true">
<div className="edit-mode-panel">
<header className="edit-mode-header">
<div>
<h1>{file.customName || file.displayName}</h1>
<p>{file.relativePath}</p>
</div>
<div className="edit-mode-header-actions">
<button type="button" className="ghost-button" onClick={handleClose}>Close</button>
</div>
</header>
<div className="edit-mode-body">
<div className="edit-mode-waveform-container">
<div className="edit-mode-waveform">
{waveformError && <div className="edit-mode-error">{waveformError}</div>}
{!waveformError && !isWaveformReady && (
<div className="edit-mode-placeholder">Loading waveform</div>
)}
{!waveformError && isWaveformReady && waveformSamples && (
<WaveformEditorCanvas
samples={waveformSamples}
durationMs={effectiveDuration}
segments={segments}
selectedSegmentId={selectedSegmentId}
onSelectSegment={handleSelectSegment}
onCreateSegment={handleCreateSegment}
onResizeSegment={handleResizeSegment}
onPlayFromCursor={playFromCursor}
playbackCursorMs={playbackCursorMs}
/>
)}
</div>
<div className="edit-mode-playback-controls">
{playbackUi ? (
<>
<button
type="button"
className="ghost-button edit-mode-playback-toggle"
onClick={togglePlayback}
>
{playbackUi.isPlaying ? 'Pause (Space)' : 'Play (Space)'}
</button>
<div className="edit-mode-playback-details">
<span className="edit-mode-playback-title">{playbackUi.label}</span>
<span className="edit-mode-playback-range">
{formatTimecode(playbackUi.startMs)}
{' → '}
{formatTimecode(playbackUi.endMs ?? effectiveDuration)}
{playbackUi.endMs === null ? ' (end)' : ''}
</span>
</div>
</>
) : (
<>
{selectedSegment ? (
<>
<button
type="button"
className="ghost-button edit-mode-playback-toggle"
onClick={() => playSegment(selectedSegment)}
>
Play (Space)
</button>
<div className="edit-mode-playback-details">
<span className="edit-mode-playback-title">
{selectedSegment.label.trim() || `${formatTimecode(selectedSegment.startMs)}${formatTimecode(selectedSegment.endMs)}`}
</span>
<span className="edit-mode-playback-range">
{formatTimecode(selectedSegment.startMs)}
{' → '}
{formatTimecode(selectedSegment.endMs)}
</span>
</div>
</>
) : (
<div className="edit-mode-playback-idle">No segment selected</div>
)}
</>
)}
</div>
</div>
<aside className="edit-mode-sidebar">
<section className="segment-list">
<header className="segment-list-header">
<h2>Segments</h2>
<span>{segments.length}</span>
</header>
{segments.length === 0 ? (
<p className="segment-list-empty">Drag on the waveform to create segments.</p>
) : (
<ul>
{segments.map((segment) => (
<li key={segment.id} className={segment.id === selectedSegmentId ? 'segment-item segment-item--selected' : 'segment-item'}>
<span className="segment-color-indicator" style={{ backgroundColor: segment.color }} />
<button
type="button"
className="segment-item-select"
onClick={() => handleSelectSegment(segment.id)}
aria-pressed={segment.id === selectedSegmentId}
>
<span className="segment-item-time">{formatTimecode(segment.startMs)} {formatTimecode(segment.endMs)}</span>
</button>
<div className="segment-item-label-row">
<input
type="text"
value={segment.label}
onChange={(event) => handleUpdateLabel(segment.id, event.target.value)}
placeholder="Segment Name"
/>
{segment.id === selectedSegmentId && (
<button
type="button"
className="segment-item-delete"
onClick={() => handleRemoveSegment(segment.id)}
aria-label="Delete segment"
title="Delete segment"
>
🗑
</button>
)}
</div>
{segment.id === selectedSegmentId && (
<div className="segment-item-metadata">
<label className="segment-field">
<span>Author</span>
<input
type="text"
value={segment.metadata.author ?? ''}
onChange={(event) => handleMetadataPatch(segment.id, { author: normaliseText(event.target.value) })}
placeholder="Artist or creator"
/>
</label>
<div className="segment-field">
<span>Rating</span>
<div className="star-rating">
{[1, 2, 3, 4, 5].map((star) => (
<button
key={star}
type="button"
className={segment.metadata.rating !== null && segment.metadata.rating >= star ? 'star star--filled' : 'star'}
onClick={() => handleMetadataPatch(segment.id, {
rating: segment.metadata.rating === star ? null : star
})}
aria-label={`${star} star${star > 1 ? 's' : ''}`}
>
</button>
))}
</div>
</div>
<div className="segment-field">
<span>Categories</span>
<TagEditor
categories={segment.metadata.categories}
availableCategories={categories}
onSave={(nextCategories) => handleCategorySave(segment.id, nextCategories)}
showHeading={false}
/>
</div>
</div>
)}
</li>
))}
</ul>
)}
</section>
<section className="segment-metadata">
<h2>Metadata</h2>
{selectedSegment ? (
<p className="segment-metadata-helper">Edit segment metadata in the segment list above.</p>
) : (
<p className="segment-metadata-empty">Select a segment to edit its metadata.</p>
)}
</section>
</aside>
</div>
<footer className="edit-mode-footer">
{submitError && <span className="edit-mode-error">{submitError}</span>}
<div className="edit-mode-footer-actions">
<button type="button" className="ghost-button" onClick={handleClose} disabled={isSaving}>Cancel</button>
<button
type="button"
className="primary-button"
onClick={handleCommit}
disabled={isSaving || segments.length === 0 || !isWaveformReady}
>
{isSaving ? 'Splitting…' : `Create ${segments.length} segment${segments.length === 1 ? '' : 's'}`}
</button>
</div>
</footer>
</div>
</div>
);
}
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 generatePlaybackId(): string {
if (typeof window !== 'undefined' && window.crypto?.randomUUID) {
return window.crypto.randomUUID();
}
return `playback-${Date.now()}-${Math.random().toString(16).slice(2)}`;
}
function buildDefaultMetadata(
file: AudioFileSummary,
baseMetadata: { author: string | null; rating: number | null }
): SegmentMetadataDraft {
return {
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 generateSegmentColor(index: number): string {
// Use 36-degree spacing on hue wheel (360/36 = 10 unique colors per cycle)
const hue = (index * 36) % 360;
const saturation = 70;
const lightness = 65;
return hslToHex(hue, saturation, lightness);
}
function hslToHex(h: number, s: number, l: number): string {
const sNorm = s / 100;
const lNorm = l / 100;
const c = (1 - Math.abs(2 * lNorm - 1)) * sNorm;
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
const m = lNorm - c / 2;
let r = 0;
let g = 0;
let b = 0;
if (h >= 0 && h < 60) {
r = c; g = x; b = 0;
} else if (h >= 60 && h < 120) {
r = x; g = c; b = 0;
} else if (h >= 120 && h < 180) {
r = 0; g = c; b = x;
} else if (h >= 180 && h < 240) {
r = 0; g = x; b = c;
} else if (h >= 240 && h < 300) {
r = x; g = 0; b = c;
} else if (h >= 300 && h < 360) {
r = c; g = 0; b = x;
}
const toHex = (n: number) => {
const hex = Math.round((n + m) * 255).toString(16);
return hex.length === 1 ? '0' + hex : hex;
};
return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
}
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;

View File

@@ -0,0 +1,897 @@
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;
/** Invoked when the user clicks the waveform background to audition from that cursor position. */
onPlayFromCursor(startMs: number): void;
/** Location of the active playback cursor relative to the source audio, if playing. */
playbackCursorMs: number | null;
}
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;
const CLICK_TOLERANCE_MS = 20;
const VIEWPORT_EASING = 0.18;
/**
* Interactive waveform canvas supporting zoom, pan, and region selection for splitting.
*/
export function WaveformEditorCanvas({
samples,
durationMs,
segments,
selectedSegmentId,
onSelectSegment,
onCreateSegment,
onResizeSegment,
onPlayFromCursor,
playbackCursorMs
}: WaveformEditorCanvasProps): JSX.Element {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const dragStateRef = useRef<DragState>({ type: 'none' });
const viewportRef = useRef<{ startMs: number; durationMs: number }>({ startMs: 0, durationMs });
const targetViewportRef = useRef<{ startMs: number; durationMs: number }>({ startMs: 0, durationMs });
const [viewportVersion, setViewportVersion] = useState(0);
const [draftSelection, setDraftSelection] = useState<DraftSelection | null>(null);
const clickContextRef = useRef<{ startMs: number; insideSegmentId: string | null } | null>(null);
const [cursorX, setCursorX] = useState<number | null>(null);
const [cursorMs, setCursorMs] = useState<number | null>(null);
const [hoveredSegmentId, setHoveredSegmentId] = useState<string | null>(null);
const targetCursorXRef = useRef<number | null>(null);
const targetCursorMsRef = useRef<number | null>(null);
const animationFrameRef = useRef<number | null>(null);
const viewportAnimationFrameRef = useRef<number | null>(null);
useEffect(() => {
viewportRef.current = {
startMs: 0,
durationMs
};
targetViewportRef.current = {
startMs: 0,
durationMs
};
if (viewportAnimationFrameRef.current !== null) {
cancelAnimationFrame(viewportAnimationFrameRef.current);
viewportAnimationFrameRef.current = null;
}
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;
// Use segment color with adjusted opacity
const color = segment.color;
const rgbMatch = color.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i);
if (rgbMatch) {
const r = parseInt(rgbMatch[1], 16);
const g = parseInt(rgbMatch[2], 16);
const b = parseInt(rgbMatch[3], 16);
context.fillStyle = isSelected ? `rgba(${r}, ${g}, ${b}, 0.4)` : `rgba(${r}, ${g}, ${b}, 0.25)`;
} else {
context.fillStyle = isSelected ? 'rgba(255, 204, 0, 0.25)' : 'rgba(255, 255, 255, 0.18)';
}
context.fillRect(startX, 0, endX - startX, height);
// Use segment color for handles
context.fillStyle = isSelected ? '#ffcc00' : color;
context.fillRect(startX - HANDLE_WIDTH_PX / 2, 0, HANDLE_WIDTH_PX, height);
context.fillRect(endX - HANDLE_WIDTH_PX / 2, 0, HANDLE_WIDTH_PX, height);
// Draw segment labels when being resized or hovered
const dragState = dragStateRef.current;
const shouldShowLabels = (dragState.type === 'resizing' && dragState.segmentId === segment.id) ||
(hoveredSegmentId === segment.id && dragState.type === 'none');
if (shouldShowLabels) {
context.font = '11px "Consolas", "Monaco", monospace';
context.textBaseline = 'top';
const segmentWidth = endX - startX;
const durationMs = segment.endMs - segment.startMs;
// Start time label
const startText = formatTimecode(segment.startMs);
const startTextWidth = context.measureText(startText).width;
context.fillStyle = 'rgba(0, 0, 0, 0.7)';
context.fillRect(startX + 2, height - 22, startTextWidth + 6, 18);
context.fillStyle = 'rgba(255, 255, 255, 0.95)';
context.textAlign = 'left';
context.fillText(startText, startX + 5, height - 19);
// End time label
const endText = formatTimecode(segment.endMs);
const endTextWidth = context.measureText(endText).width;
context.fillStyle = 'rgba(0, 0, 0, 0.7)';
context.fillRect(endX - endTextWidth - 8, height - 22, endTextWidth + 6, 18);
context.fillStyle = 'rgba(255, 255, 255, 0.95)';
context.textAlign = 'right';
context.fillText(endText, endX - 5, height - 19);
// Duration label in the middle
const durationText = formatTimecode(durationMs);
const durationTextWidth = context.measureText(durationText).width;
const centerX = startX + segmentWidth / 2;
context.fillStyle = 'rgba(0, 0, 0, 0.7)';
context.fillRect(centerX - durationTextWidth / 2 - 3, height / 2 - 9, durationTextWidth + 6, 18);
context.fillStyle = 'rgba(255, 204, 0, 1)';
context.textAlign = 'center';
context.fillText(durationText, centerX, height / 2 - 6);
}
});
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);
// Draw draft segment labels
context.font = '11px "Consolas", "Monaco", monospace';
context.textBaseline = 'top';
const durationMs = draftSelection.endMs - draftSelection.startMs;
// Start time label
const startText = formatTimecode(draftSelection.startMs);
const startTextWidth = context.measureText(startText).width;
context.fillStyle = 'rgba(0, 0, 0, 0.7)';
context.fillRect(draftStartX + 2, height - 22, startTextWidth + 6, 18);
context.fillStyle = 'rgba(76, 201, 240, 1)';
context.textAlign = 'left';
context.fillText(startText, draftStartX + 5, height - 19);
// End time label
const endText = formatTimecode(draftSelection.endMs);
const endTextWidth = context.measureText(endText).width;
context.fillStyle = 'rgba(0, 0, 0, 0.7)';
context.fillRect(draftEndX - endTextWidth - 8, height - 22, endTextWidth + 6, 18);
context.fillStyle = 'rgba(76, 201, 240, 1)';
context.textAlign = 'right';
context.fillText(endText, draftEndX - 5, height - 19);
// Duration label in the middle
const durationText = formatTimecode(durationMs);
const durationTextWidth = context.measureText(durationText).width;
const centerX = draftStartX + draftWidth / 2;
context.fillStyle = 'rgba(0, 0, 0, 0.7)';
context.fillRect(centerX - durationTextWidth / 2 - 3, height / 2 - 9, durationTextWidth + 6, 18);
context.fillStyle = 'rgba(76, 201, 240, 1)';
context.textAlign = 'center';
context.fillText(durationText, centerX, height / 2 - 6);
}
}
// Draw live playback cursor when the audio is currently playing
if (typeof playbackCursorMs === 'number') {
const playbackRatio = (playbackCursorMs - viewportStart) / viewportDuration;
if (playbackRatio >= 0 && playbackRatio <= 1) {
const playbackX = playbackRatio * width;
context.strokeStyle = 'rgba(76, 201, 240, 0.85)';
context.lineWidth = 2;
context.beginPath();
context.moveTo(playbackX, 0);
context.lineTo(playbackX, height);
context.stroke();
context.fillStyle = 'rgba(76, 201, 240, 0.85)';
context.beginPath();
context.arc(playbackX, Math.max(8, height * 0.08), 3, 0, Math.PI * 2);
context.fill();
context.lineWidth = 1;
}
}
// Draw time indicators showing remaining time on left and right
context.font = '12px "Consolas", "Monaco", monospace';
context.fillStyle = 'rgba(255, 255, 255, 0.8)';
context.textBaseline = 'top';
// Left indicator - time before viewport start
if (viewportStart > 0) {
const leftTimeText = '◀ ' + formatTimecode(viewportStart);
context.textAlign = 'left';
context.fillStyle = 'rgba(0, 0, 0, 0.6)';
context.fillRect(4, 4, context.measureText(leftTimeText).width + 8, 20);
context.fillStyle = 'rgba(255, 255, 255, 0.9)';
context.fillText(leftTimeText, 8, 8);
}
// Right indicator - time after viewport end
const timeRemaining = durationMs - viewportEnd;
if (timeRemaining > 0) {
const rightTimeText = formatTimecode(timeRemaining) + ' ▶';
const rightTextWidth = context.measureText(rightTimeText).width;
context.textAlign = 'right';
context.fillStyle = 'rgba(0, 0, 0, 0.6)';
context.fillRect(width - rightTextWidth - 12, 4, rightTextWidth + 8, 20);
context.fillStyle = 'rgba(255, 255, 255, 0.9)';
context.fillText(rightTimeText, width - 8, 8);
}
// Draw cursor line (hide when draft selection is active)
const showCursor = cursorX !== null && cursorMs !== null && !draftSelection;
if (showCursor) {
context.strokeStyle = 'rgba(255, 255, 255, 0.5)';
context.lineWidth = 1;
context.beginPath();
context.moveTo(cursorX, 0);
context.lineTo(cursorX, height);
context.stroke();
// Draw cursor timestamp
const cursorTimeText = formatTimecode(cursorMs);
const cursorTextWidth = context.measureText(cursorTimeText).width;
const cursorLabelX = Math.max(4, Math.min(width - cursorTextWidth - 12, cursorX - cursorTextWidth / 2 - 4));
context.fillStyle = 'rgba(0, 0, 0, 0.6)';
context.fillRect(cursorLabelX, 28, cursorTextWidth + 8, 20);
context.fillStyle = 'rgba(255, 255, 255, 0.9)';
context.textAlign = 'left';
context.fillText(cursorTimeText, cursorLabelX + 4, 32);
}
}, [samples, segments, selectedSegmentId, samplesPerMs, durationMs, viewportVersion, draftSelection, cursorX, cursorMs, hoveredSegmentId, playbackCursorMs]);
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<HTMLCanvasElement>): number => {
return resolveClientToMs(event.clientX);
};
// Smoothly interpolate viewport transitions for zoom and pan actions.
const animateViewport = () => {
const current = viewportRef.current;
const target = targetViewportRef.current;
const startDelta = target.startMs - current.startMs;
const durationDelta = target.durationMs - current.durationMs;
const maxDelta = Math.max(Math.abs(startDelta), Math.abs(durationDelta));
if (maxDelta < 0.5) {
if (maxDelta > 0) {
viewportRef.current = {
startMs: target.startMs,
durationMs: target.durationMs
};
setViewportVersion((value) => value + 1);
}
viewportAnimationFrameRef.current = null;
return;
}
viewportRef.current = {
startMs: current.startMs + startDelta * VIEWPORT_EASING,
durationMs: current.durationMs + durationDelta * VIEWPORT_EASING
};
setViewportVersion((value) => value + 1);
viewportAnimationFrameRef.current = requestAnimationFrame(animateViewport);
};
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));
const pointerDelta = Math.max(
Math.abs(clampedStart - targetViewportRef.current.startMs),
Math.abs(clampedDuration - targetViewportRef.current.durationMs)
);
if (pointerDelta < 0.01) {
return;
}
targetViewportRef.current = {
startMs: clampedStart,
durationMs: clampedDuration
};
if (viewportAnimationFrameRef.current === null) {
viewportAnimationFrameRef.current = requestAnimationFrame(animateViewport);
}
};
const handlePointerDown = (event: ReactPointerEvent<HTMLCanvasElement>) => {
// Middle click - reset zoom to full view
if (event.button === 1) {
event.preventDefault();
updateViewport(0, durationMs);
return;
}
if (event.button === 2) {
clickContextRef.current = null;
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);
}
clickContextRef.current = { startMs: pointerMs, insideSegmentId: null };
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 };
clickContextRef.current = null;
return;
}
const containing = pickSegmentAtMs(pointerMs, segments);
if (containing) {
onSelectSegment(containing.id);
dragStateRef.current = { type: 'none' };
clickContextRef.current = { startMs: pointerMs, insideSegmentId: containing.id };
return;
}
dragStateRef.current = { type: 'creating', anchorMs: pointerMs, pointerMs };
setDraftSelection({ startMs: pointerMs, endMs: pointerMs });
};
const handlePointerMove = (event: ReactPointerEvent<HTMLCanvasElement>) => {
const state = dragStateRef.current;
// Update cursor position for display (works during drag too)
const canvas = canvasRef.current;
if (canvas) {
const rect = canvas.getBoundingClientRect();
const x = event.clientX - rect.left;
const ms = resolveClientToMs(event.clientX);
targetCursorXRef.current = x;
targetCursorMsRef.current = ms;
setCursorX(x);
setCursorMs(ms);
if (animationFrameRef.current === null) {
const animate = () => {
const targetX = targetCursorXRef.current;
const targetMs = targetCursorMsRef.current;
if (targetX === null || targetMs === null) {
setCursorX(null);
setCursorMs(null);
animationFrameRef.current = null;
return;
}
let needsUpdate = false;
setCursorX((currentX) => {
if (currentX === null) {
needsUpdate = true;
return targetX;
}
const delta = targetX - currentX;
const distance = Math.abs(delta);
const progress = 1 - (distance / 200);
const easedProgress = Math.max(0, Math.min(1, progress));
const speed = 0.08 + (easedProgress * easedProgress * easedProgress) * 0.25;
const newX = currentX + delta * speed;
if (Math.abs(targetX - newX) < 0.5) {
return targetX;
}
needsUpdate = true;
return newX;
});
setCursorMs((currentMs) => {
if (currentMs === null) {
return targetMs;
}
const delta = targetMs - currentMs;
const distance = Math.abs(delta);
const progress = 1 - (distance / 300);
const easedProgress = Math.max(0, Math.min(1, progress));
const speed = 0.08 + (easedProgress * easedProgress * easedProgress) * 0.25;
const newMs = currentMs + delta * speed;
if (Math.abs(targetMs - newMs) < 0.5) {
return targetMs;
}
return newMs;
});
if (needsUpdate || targetCursorXRef.current !== null) {
animationFrameRef.current = requestAnimationFrame(animate);
} else {
animationFrameRef.current = null;
}
};
animationFrameRef.current = requestAnimationFrame(animate);
}
// Check segment hover
const overlapping = segments.filter((segment) => ms >= segment.startMs && ms <= segment.endMs);
const hovered = pickSegmentAtMs(ms, segments);
setHoveredSegmentId(hovered ? hovered.id : null);
}
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<HTMLCanvasElement>) => {
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);
const delta = Math.abs(end - start);
if (delta >= MIN_SEGMENT_MS) {
onCreateSegment(start, end);
clickContextRef.current = null;
return;
}
// If drag was too small, treat as a click to play from cursor
const clickContext = clickContextRef.current;
clickContextRef.current = null;
if (clickContext) {
onPlayFromCursor(clickContext.startMs);
}
return;
}
if (state.type === 'panning') {
return;
}
if (state.type === 'resizing') {
// Final adjustment already applied during move.
return;
}
const clickContext = clickContextRef.current;
clickContextRef.current = null;
if (state.type === 'none' && clickContext && !clickContext.insideSegmentId) {
const pointerMs = resolvePointerMs(event);
const delta = Math.abs(pointerMs - clickContext.startMs);
const viewport = viewportRef.current;
const toleranceMs = Math.max(CLICK_TOLERANCE_MS, viewport.durationMs * 0.002);
if (delta <= toleranceMs) {
if (event.detail > 1) {
return;
}
onPlayFromCursor(clickContext.startMs);
}
}
};
const handleWheel = (event: ReactWheelEvent<HTMLCanvasElement>) => {
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<HTMLCanvasElement>) => {
event.preventDefault();
const pointerMs = resolveClientToMs(event.clientX);
const targetSegment = pickSegmentAtMs(pointerMs, segments);
if (targetSegment) {
const segmentSpan = Math.max(targetSegment.endMs - targetSegment.startMs, 1);
const paddedSpan = clamp(segmentSpan * 1.1, MIN_VIEWPORT_MS, durationMs);
const padding = Math.max(0, (paddedSpan - segmentSpan) / 2);
let nextStart = targetSegment.startMs - padding;
let nextDuration = paddedSpan;
if (nextStart < 0) {
nextStart = 0;
}
if (nextStart + nextDuration > durationMs) {
nextStart = Math.max(0, durationMs - nextDuration);
}
updateViewport(nextStart, nextDuration);
return;
}
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<HTMLCanvasElement>) => {
event.preventDefault();
};
const handleMouseMove = (event: ReactMouseEvent<HTMLCanvasElement>) => {
const canvas = canvasRef.current;
if (!canvas) {
return;
}
const rect = canvas.getBoundingClientRect();
const x = event.clientX - rect.left;
const ms = resolveClientToMs(event.clientX);
// Set target values for smooth animation
targetCursorXRef.current = x;
targetCursorMsRef.current = ms;
setCursorX(x);
setCursorMs(ms);
setCursorX(x);
setCursorMs(ms);
// Start animation if not already running
if (animationFrameRef.current === null) {
const animate = () => {
const targetX = targetCursorXRef.current;
const targetMs = targetCursorMsRef.current;
if (targetX === null || targetMs === null) {
setCursorX(null);
setCursorMs(null);
animationFrameRef.current = null;
return;
}
let needsUpdate = false;
setCursorX((currentX) => {
if (currentX === null) {
needsUpdate = true;
return targetX;
}
const delta = targetX - currentX;
const distance = Math.abs(delta);
// Ease-in cubic for tape acceleration feel (starts slow, speeds up)
const progress = 1 - (distance / 200); // Normalize distance
const easedProgress = Math.max(0, Math.min(1, progress));
const speed = 0.08 + (easedProgress * easedProgress * easedProgress) * 0.25;
const newX = currentX + delta * speed;
if (Math.abs(targetX - newX) < 0.5) {
return targetX;
}
needsUpdate = true;
return newX;
});
setCursorMs((currentMs) => {
if (currentMs === null) {
return targetMs;
}
const delta = targetMs - currentMs;
const distance = Math.abs(delta);
// Ease-in cubic for tape acceleration feel
const progress = 1 - (distance / 300);
const easedProgress = Math.max(0, Math.min(1, progress));
const speed = 0.08 + (easedProgress * easedProgress * easedProgress) * 0.25;
const newMs = currentMs + delta * speed;
if (Math.abs(targetMs - newMs) < 0.5) {
return targetMs;
}
return newMs;
});
if (needsUpdate || targetCursorXRef.current !== null) {
animationFrameRef.current = requestAnimationFrame(animate);
} else {
animationFrameRef.current = null;
}
};
animationFrameRef.current = requestAnimationFrame(animate);
}
// Check if hovering over a segment, prefer the one whose center is closest to pointer
const hovered = pickSegmentAtMs(ms, segments);
setHoveredSegmentId(hovered ? hovered.id : null);
};
const handleMouseLeave = () => {
targetCursorXRef.current = null;
targetCursorMsRef.current = null;
setCursorX(null);
setCursorMs(null);
setHoveredSegmentId(null);
clickContextRef.current = null;
};
useEffect(() => {
return () => {
if (animationFrameRef.current !== null) {
cancelAnimationFrame(animationFrameRef.current);
}
if (viewportAnimationFrameRef.current !== null) {
cancelAnimationFrame(viewportAnimationFrameRef.current);
}
};
}, []);
return (
<canvas
ref={canvasRef}
className="edit-waveform-canvas"
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave}
onWheel={handleWheel}
onDoubleClick={handleDoubleClick}
onContextMenu={handleContextMenu}
/>
);
}
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;
}
/**
* Determine which segment should be chosen for interaction at the supplied timestamp.
* Prefers the segment whose midpoint is closest to the pointer when multiple segments overlap.
*
* @param pointerMs - Absolute timestamp within the audio file to test.
* @param segments - Segment collection to evaluate.
* @returns The best matching segment or null when no segments cover the timestamp.
*/
function pickSegmentAtMs(pointerMs: number, segments: SegmentDraft[]): SegmentDraft | null {
const overlapping = segments.filter((segment) => pointerMs >= segment.startMs && pointerMs <= segment.endMs);
if (overlapping.length === 0) {
return null;
}
let best = overlapping[0];
let bestDistance = Math.abs(pointerMs - ((best.startMs + best.endMs) / 2));
for (let index = 1; index < overlapping.length; index += 1) {
const candidate = overlapping[index];
const candidateCenter = (candidate.startMs + candidate.endMs) / 2;
const candidateDistance = Math.abs(pointerMs - candidateCenter);
if (candidateDistance < bestDistance) {
best = candidate;
bestDistance = candidateDistance;
}
}
return best;
}
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 WaveformEditorCanvas;

View File

@@ -0,0 +1,62 @@
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;
/** Color used for visual identification in UI and waveform. */
color: string;
}
/**
* Converts a draft segment into the IPC request payload.
*/
export function toSplitRequest(segment: SegmentDraft): SplitSegmentRequest {
const trimmedLabel = segment.label.trim();
const effectiveLabel = trimmedLabel.length > 0 ? trimmedLabel : undefined;
// Only include customName in metadata if it differs from the label
// This allows the backend to use the label as the customName by default
const customNameOverride = segment.metadata.customName !== null && segment.metadata.customName !== trimmedLabel
? segment.metadata.customName
: undefined;
return {
startMs: Math.round(segment.startMs),
endMs: Math.round(segment.endMs),
label: effectiveLabel,
metadata: {
customName: customNameOverride,
author: segment.metadata.author,
rating: segment.metadata.rating ?? undefined,
tags: segment.metadata.tags,
categories: segment.metadata.categories
}
};
}

View File

@@ -3,6 +3,7 @@ import type {
AudioFileSummary,
CategoryRecord,
LibraryScanSummary,
SplitSegmentRequest,
TagUpdatePayload
} from '../../../shared/models';
@@ -206,6 +207,36 @@ export class LibraryStore extends EventTarget {
this.emitChange();
}
/**
* Clears active filters, refreshes the file cache, and selects the requested file.
*/
public async focusOnFile(fileId: number): Promise<void> {
if (!Number.isFinite(fileId)) {
return;
}
const files = await window.api.listAudioFiles();
if (!files.some((file) => file.id === fileId)) {
return;
}
this.snapshot = {
...this.snapshot,
files,
searchQuery: '',
categoryFilter: null
};
this.refreshVisibleFiles(fileId, false);
const focused = this.snapshot.files.find((file) => file.id === fileId) ?? null;
this.snapshot = {
...this.snapshot,
selectedFileId: fileId,
selectedFileIds: new Set([fileId]),
focusedFile: focused
};
this.emitChange();
}
/**
* Executes a fuzzy search request.
*/
@@ -361,6 +392,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<AudioFileSummary[]> {
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.
*/

View File

@@ -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;
@@ -482,6 +522,23 @@ select,
opacity: 0.7;
}
.file-parent-link {
margin-top: 0.25rem;
padding: 0;
border: none;
background: none;
color: #6aa8ff;
font-size: 0.85rem;
cursor: pointer;
text-align: left;
transition: color 0.15s ease;
}
.file-parent-link:hover {
color: #9bc4ff;
text-decoration: underline;
}
.file-meta-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -745,11 +802,11 @@ select,
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.75rem;
border: 1px solid rgba(255, 255, 255, 0.08);
gap: 0.5rem;
border: none;
border-radius: 0.6rem;
padding: 0.65rem 0.75rem;
background: rgba(12, 16, 22, 0.65);
padding: 0;
background: transparent;
}
.selected-category-chip-list {
@@ -768,21 +825,20 @@ select,
display: flex;
align-items: center;
gap: 0.55rem;
border-radius: 999px;
border-radius: 6px;
border: 1px solid var(--category-color-base, rgba(39, 110, 241, 0.5));
background: var(--category-color-soft, rgba(39, 110, 241, 0.2));
color: var(--category-color-text, inherit);
padding: 0.35rem 0.8rem;
padding: 0.35rem 0.5rem;
font-size: 0.82rem;
cursor: pointer;
transition: background 0.15s ease, transform 0.15s ease;
transition: background 0.15s ease;
max-width: 100%;
flex-wrap: wrap;
}
.selected-category-chip:hover {
background: var(--category-color-strong, rgba(39, 110, 241, 0.35));
transform: translateY(-1px);
}
.selected-category-chip-label {
@@ -1564,3 +1620,342 @@ select,
gap: 1rem;
}
}
/* Edit Mode Styles */
.edit-mode-overlay {
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
overflow: hidden;
}
.edit-mode-panel {
display: flex;
flex-direction: column;
height: 100%;
background: rgba(12, 16, 22, 0.95);
}
.edit-mode-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 1.25rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
flex-shrink: 0;
}
.edit-mode-header h1 {
margin: 0;
font-size: 1.1rem;
font-weight: 600;
}
.edit-mode-header p {
margin: 0.25rem 0 0 0;
font-size: 0.85rem;
opacity: 0.6;
}
.edit-mode-header-actions {
display: flex;
gap: 0.5rem;
}
.edit-mode-body {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
.edit-mode-waveform-container {
display: flex;
flex-direction: column;
height: 540px;
width: 100%;
}
.edit-mode-waveform {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.2);
position: relative;
width: 100%;
min-height: 0;
}
.edit-mode-playback-controls {
flex-shrink: 0;
display: flex;
align-items: center;
gap: 1rem;
padding: 0.75rem 1rem;
background: rgba(15, 19, 25, 0.95);
border-top: 1px solid rgba(255, 255, 255, 0.08);
min-height: 3.5rem;
}
.edit-mode-playback-toggle {
font-weight: 600;
padding: 0.5rem 1rem;
border-radius: 0.5rem;
flex-shrink: 0;
}
.edit-mode-playback-details {
display: flex;
flex-direction: column;
gap: 0.2rem;
flex: 1;
min-width: 0;
}
.edit-mode-playback-title {
font-size: 0.95rem;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.edit-mode-playback-range {
font-family: 'Consolas', 'Monaco', monospace;
font-size: 0.85rem;
opacity: 0.7;
}
.edit-mode-playback-idle {
font-size: 0.9rem;
opacity: 0.5;
font-style: italic;
}
.edit-mode-waveform canvas {
width: 100%;
height: 100%;
display: block;
}
.edit-mode-error,
.edit-mode-placeholder {
padding: 2rem;
text-align: center;
opacity: 0.6;
}
.edit-mode-sidebar {
flex-shrink: 0;
max-height: 50%;
overflow-y: auto;
border-top: 1px solid rgba(255, 255, 255, 0.08);
padding: 1rem;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.segment-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.segment-list-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.segment-list-header h2 {
margin: 0;
font-size: 0.9rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
opacity: 0.8;
}
.segment-list-header span {
font-size: 0.85rem;
opacity: 0.6;
background: rgba(255, 255, 255, 0.08);
padding: 0.2rem 0.5rem;
border-radius: 0.25rem;
}
.segment-list-empty {
padding: 1.5rem;
text-align: center;
opacity: 0.5;
font-size: 0.9rem;
}
.segment-list ul {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.segment-item {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.75rem;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 0.5rem;
transition: all 0.2s ease;
position: relative;
max-width: 100%;
}
.segment-item--selected {
background: rgba(39, 110, 241, 0.15);
border-color: rgba(39, 110, 241, 0.3);
}
.segment-color-indicator {
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 4px;
border-radius: 0.5rem 0 0 0.5rem;
}
.segment-item-select {
all: unset;
cursor: pointer;
display: block;
}
.segment-item-time {
font-size: 0.9rem;
font-family: 'Consolas', 'Monaco', monospace;
opacity: 0.9;
}
.segment-item-label-row {
display: flex;
gap: 0.5rem;
align-items: center;
}
.segment-item input[type="text"] {
flex: 1;
padding: 0.4rem 0.6rem;
border-radius: 0.35rem;
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(12, 16, 22, 0.9);
color: inherit;
font: inherit;
font-size: 0.9rem;
}
.segment-item-remove {
align-self: flex-start;
font-size: 0.85rem;
padding: 0.3rem 0.6rem;
}
.segment-metadata {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.segment-metadata h2 {
margin: 0;
font-size: 0.9rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
opacity: 0.8;
}
.segment-item-delete {
flex-shrink: 0;
background: rgba(255, 59, 59, 0.15);
border: 1px solid rgba(255, 59, 59, 0.3);
color: #ff6b6b;
padding: 0.4rem 0.6rem;
border-radius: 0.35rem;
cursor: pointer;
font-size: 1rem;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
}
.segment-item-delete:hover {
background: rgba(255, 59, 59, 0.25);
border-color: rgba(255, 59, 59, 0.5);
transform: scale(1.05);
}
.segment-item-delete:active {
transform: scale(0.95);
}
.segment-field {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.segment-field span {
font-size: 0.85rem;
opacity: 0.7;
}
.segment-field input[type="text"],
.segment-field input[type="number"] {
padding: 0.5rem 0.7rem;
border-radius: 0.35rem;
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(12, 16, 22, 0.9);
color: inherit;
font: inherit;
}
.star-rating {
display: flex;
gap: 0.25rem;
}
.star {
all: unset;
cursor: pointer;
font-size: 1.25rem;
opacity: 0.3;
transition: opacity 0.15s ease, transform 0.15s ease;
}
.star:hover {
opacity: 0.6;
transform: scale(1.1);
}
.star--filled {
opacity: 1;
color: #f59e0b;
}
.segment-actions {
display: flex;
gap: 0.5rem;
padding: 1rem 1.25rem;
border-top: 1px solid rgba(255, 255, 255, 0.08);
justify-content: flex-end;
flex-shrink: 0;
}

0
src/renderer/src/types/assets.d.ts vendored Normal file
View File

View File

@@ -12,6 +12,7 @@ export const IPC_CHANNELS = {
libraryCustomName: 'library:custom-name',
libraryOpenFolder: 'library:open-folder',
libraryDelete: 'library:delete',
librarySplit: 'library:split',
libraryBuffer: 'library:buffer',
libraryMetadata: 'library:metadata',
libraryMetadataSuggestions: 'library:metadata-suggestions',
@@ -45,29 +46,37 @@ export interface RendererApi {
/** Moves a file to another subdirectory under the library root. */
moveFile(fileId: number, targetRelativeDirectory: string): Promise<import('./models').AudioFileSummary>;
/** Automatically organizes a file based on its categories. */
organizeFile(fileId: number, metadata: { customName?: string | null; author?: string | null; rating?: number }): Promise<import('./models').AudioFileSummary>;
organizeFile(
fileId: number,
metadata: { customName?: string | null; author?: string | null; copyright?: string | null; rating?: number }
): Promise<import('./models').AudioFileSummary>;
/** Updates the custom name for a file. */
updateCustomName(fileId: number, customName: string | null): Promise<import('./models').AudioFileSummary>;
/** Opens the file's containing folder in the system file explorer. */
openFileFolder(fileId: number): Promise<void>;
/** Deletes files from disk and database. */
deleteFiles(fileIds: number[]): Promise<void>;
/** Splits an audio file into segment files. */
splitFile(fileId: number, segments: import('./models').SplitSegmentRequest[]): Promise<import('./models').AudioFileSummary[]>;
/** Fetches the binary data required for playback. */
getAudioBuffer(fileId: number): Promise<import('./models').AudioBufferPayload>;
/** Returns a lightweight waveform preview for rendering list backgrounds. */
getWaveformPreview(fileId: number, pointCount?: number): Promise<{ samples: number[]; rms: number }>;
/** Updates free-form tags and UCS categories. */
/** Updates UCS categories for a file (free-form tags are preserved unless explicitly provided). */
updateTagging(payload: import('./models').TagUpdatePayload): Promise<import('./models').AudioFileSummary>;
/** Returns the catalog of UCS categories. */
listCategories(): Promise<import('./models').CategoryRecord[]>;
/** Runs fuzzy search returning ranked results. */
search(query: string): Promise<import('./models').AudioFileSummary[]>;
/** 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<void>;
listMetadataSuggestions(): Promise<{ authors: string[]; copyrights: string[] }>;
/** Updates metadata (author, copyright, rating) without organizing the file. */
updateFileMetadata(
fileId: number,
metadata: { author?: string | null; copyright?: string | null; rating?: number }
): Promise<void>;
/** Listens for menu actions from the main process. Returns a cleanup function. */
onMenuAction(channel: string, callback: () => void): () => void;
}

View File

@@ -26,6 +26,8 @@ export interface AudioFileSummary {
bitDepth: number | null;
/** Content checksum used for duplicate detection. */
checksum: string | null;
/** Identifier of the source file this entry originated from, if any. */
parentFileId: number | null;
/** Applied descriptive tags. */
tags: string[];
/** Associated UCS categories. */
@@ -90,8 +92,8 @@ export interface AppSettings {
export interface TagUpdatePayload {
/** Target audio file id. */
fileId: number;
/** Free-form tag collection. */
tags: string[];
/** Optional free-form tag collection. When omitted, existing tags are preserved. */
tags?: string[];
/** UCS category identifiers to attach. */
categories: string[];
}
@@ -107,3 +109,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;
}

View File

@@ -184,6 +184,22 @@ describe('TagService', () => {
// Verify write succeeded
assert.ok(true);
});
it('should embed parent id in INFO chunk', () => {
tagService.writeMetadataOnly(testFilePath, {
tags: ['test'],
categories: ['AMBNatr'],
parentId: 42
});
const info = tagService.readInfoTags(testFilePath);
assert.strictEqual(info.IPAR, '42');
const comment = info.ICMT ?? '';
assert.ok(comment.length > 0, 'Expected INFO comment to be populated with JSON payload');
const parsed = JSON.parse(comment);
assert.strictEqual(parsed.parentId, 42);
assert.deepStrictEqual(parsed.categories, ['AMBNatr']);
});
});
describe('applyTagging', () => {
@@ -241,5 +257,15 @@ describe('TagService', () => {
assert.deepStrictEqual(updated.tags, ['tag1', 'tag2']);
});
it('should preserve existing tags when omitted', () => {
const files = database.listFiles();
const fileId = files[0].id;
const before = database.getFileById(fileId);
const updated = tagService.applyTagging(fileId, undefined, ['AMBNatr']);
assert.deepStrictEqual(updated.tags, before.tags);
});
});
});