mirror of
https://github.com/litruv/AudioSort.git
synced 2026-07-24 02:36:01 +10:00
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.
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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';
|
||||
@@ -141,6 +141,7 @@ export class MainApp {
|
||||
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);
|
||||
@@ -241,7 +242,11 @@ export class MainApp {
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.libraryOrganize,
|
||||
async (_event: IpcMainInvokeEvent, fileId: number, metadata: { customName?: string; author?: string; copyright?: string; rating?: number }) =>
|
||||
async (
|
||||
_event: IpcMainInvokeEvent,
|
||||
fileId: number,
|
||||
metadata: { customName?: string | null; author?: string | null; copyright?: string | null; rating?: number }
|
||||
) =>
|
||||
this.requireLibrary().organizeFile(fileId, metadata)
|
||||
);
|
||||
|
||||
@@ -259,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)
|
||||
);
|
||||
@@ -285,7 +296,11 @@ export class MainApp {
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.libraryUpdateMetadata,
|
||||
async (_event: IpcMainInvokeEvent, fileId: number, metadata: { author?: string; copyright?: string; rating?: number }) =>
|
||||
async (
|
||||
_event: IpcMainInvokeEvent,
|
||||
fileId: number,
|
||||
metadata: { author?: string | null; copyright?: string | null; rating?: number }
|
||||
) =>
|
||||
this.requireLibrary().updateFileMetadata(fileId, metadata)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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)');
|
||||
|
||||
@@ -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) {
|
||||
@@ -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
|
||||
@@ -683,13 +729,14 @@ export class LibraryService {
|
||||
})();
|
||||
const container = (wave as WaveFile & { container?: string }).container ?? 'RIFF';
|
||||
|
||||
let originalMetadata: { author?: string | null; rating?: number; title?: string | null } = {};
|
||||
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
|
||||
title: metadata.title ?? null,
|
||||
copyright: metadata.copyright ?? null
|
||||
};
|
||||
} catch (error) {
|
||||
console.warn('Failed to read original metadata before splitting', error);
|
||||
@@ -739,12 +786,33 @@ export class LibraryService {
|
||||
(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) {
|
||||
const suffix = this.organization.formatSequenceNumber(sequence);
|
||||
sequence += 1;
|
||||
const candidateName = `${baseName}_segment${suffix}.wav`;
|
||||
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;
|
||||
}
|
||||
@@ -789,12 +857,15 @@ export class LibraryService {
|
||||
bitDepth: bitDepthNumeric,
|
||||
checksum,
|
||||
tags: resolvedTags,
|
||||
categories: resolvedCategories
|
||||
categories: resolvedCategories,
|
||||
parentFileId: record.id
|
||||
});
|
||||
|
||||
const resolvedCustomName = segment.metadata?.customName !== undefined
|
||||
? this.normaliseMetadataInput(segment.metadata.customName)
|
||||
: record.customName ?? null;
|
||||
: segment.label !== undefined
|
||||
? this.normaliseMetadataInput(segment.label)
|
||||
: record.customName ?? null;
|
||||
const updatedRecord = resolvedCustomName !== fileRecord.customName
|
||||
? this.database.updateCustomName(fileRecord.id, resolvedCustomName ?? null)
|
||||
: fileRecord;
|
||||
@@ -811,7 +882,9 @@ export class LibraryService {
|
||||
categories: resolvedCategories,
|
||||
title: resolvedCustomName ?? updatedRecord.displayName,
|
||||
author: resolvedAuthor ?? undefined,
|
||||
rating: resolvedRating
|
||||
rating: resolvedRating,
|
||||
copyright: originalMetadata.copyright ?? null,
|
||||
parentId: record.id
|
||||
});
|
||||
|
||||
if (typeof resolvedAuthor === 'string' && resolvedAuthor.length > 0) {
|
||||
@@ -872,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
|
||||
|
||||
@@ -10,23 +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
|
||||
rating: existing.rating,
|
||||
copyright: existing.copyright,
|
||||
parentId: existing.parentId ?? updated.parentFileId ?? null
|
||||
});
|
||||
return updated;
|
||||
}
|
||||
@@ -52,6 +54,8 @@ export class TagService {
|
||||
author?: string;
|
||||
title?: string;
|
||||
rating?: number;
|
||||
copyright?: string;
|
||||
parentId?: number;
|
||||
} {
|
||||
try {
|
||||
const buffer = fs.readFileSync(filePath);
|
||||
@@ -63,10 +67,44 @@ export class TagService {
|
||||
rating = Math.floor(ratingValue / 2);
|
||||
}
|
||||
|
||||
const copyright = tags.ICOP?.trim() || undefined;
|
||||
|
||||
// Try to read parentId from JSON comment first
|
||||
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 or invalid, try fallback to IPAR
|
||||
const parentRaw = tags.IPAR?.trim();
|
||||
if (parentRaw && parentRaw.length > 0) {
|
||||
const parsedNum = Number.parseInt(parentRaw, 10);
|
||||
if (Number.isFinite(parsedNum)) {
|
||||
parentId = parsedNum;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No comment, try fallback to IPAR
|
||||
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
|
||||
rating,
|
||||
copyright,
|
||||
parentId
|
||||
};
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console -- Logging to devtools console is helpful for diagnosis.
|
||||
@@ -84,9 +122,11 @@ export class TagService {
|
||||
metadata: {
|
||||
tags: string[];
|
||||
categories: string[];
|
||||
title: string | null | undefined;
|
||||
author: string | null | undefined;
|
||||
rating: number | undefined;
|
||||
title?: string | null;
|
||||
author?: string | null;
|
||||
rating?: number;
|
||||
copyright?: string | null;
|
||||
parentId?: number | null;
|
||||
}
|
||||
): void {
|
||||
this.writeWaveMetadata(filePath, metadata);
|
||||
@@ -101,9 +141,11 @@ export class TagService {
|
||||
metadata: {
|
||||
tags: string[];
|
||||
categories: string[];
|
||||
title: string | null | undefined;
|
||||
author: string | null | undefined;
|
||||
rating: number | undefined;
|
||||
title?: string | null;
|
||||
author?: string | null;
|
||||
rating?: number;
|
||||
copyright?: string | null;
|
||||
parentId?: number | null;
|
||||
}
|
||||
): void {
|
||||
try {
|
||||
@@ -114,22 +156,47 @@ 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 = {
|
||||
version: 1,
|
||||
tags: tagValuesList,
|
||||
categories: categoryValuesList,
|
||||
parentId: metadata.parentId ?? null,
|
||||
title: effectiveTitle,
|
||||
author: effectiveAuthor,
|
||||
rating: metadata.rating ?? 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'
|
||||
};
|
||||
|
||||
@@ -139,11 +206,13 @@ export class TagService {
|
||||
fs.writeFileSync(filePath, updatedBuffer);
|
||||
|
||||
console.log(`Wrote metadata to ${filePath}:`, {
|
||||
tags: metadata.tags.join(', '),
|
||||
tags: Array.isArray(metadata.tags) ? metadata.tags.join(', ') : '',
|
||||
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.
|
||||
|
||||
@@ -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; author?: string; copyright?: string; 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; copyright?: string; 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 {
|
||||
|
||||
@@ -69,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]
|
||||
@@ -172,15 +180,20 @@ 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) => {
|
||||
@@ -204,10 +217,9 @@ function App(): JSX.Element {
|
||||
if (existingCategories.includes(categoryId)) continue;
|
||||
|
||||
const newCategories = [...existingCategories, categoryId];
|
||||
await libraryStore.updateTagging({
|
||||
fileId,
|
||||
tags: file.tags,
|
||||
categories: newCategories
|
||||
await libraryStore.updateTagging({
|
||||
fileId,
|
||||
categories: newCategories
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -287,12 +299,14 @@ function App(): JSX.Element {
|
||||
<>
|
||||
<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} />
|
||||
|
||||
0
src/renderer/src/audio/scrubWorklet.ts
Normal file
0
src/renderer/src/audio/scrubWorklet.ts
Normal 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, copyright, rating 1-5). */
|
||||
onOrganize(metadata: { customName?: string; author?: string; copyright?: string; rating?: number }): Promise<void>;
|
||||
onUpdateTags(data: { tags: string[]; categories: string[] }): 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('');
|
||||
@@ -192,7 +203,7 @@ export function FileDetailPanel({ file, categories, onRename, onMove, onOrganize
|
||||
}
|
||||
};
|
||||
|
||||
const triggerOrganize = async (overrides?: { author?: string; rating?: number; customName?: string | null }) => {
|
||||
const triggerOrganize = async (overrides?: { author?: string; rating?: number; customName?: string | null }, force = false) => {
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
@@ -213,6 +224,7 @@ export function FileDetailPanel({ file, categories, onRename, onMove, onOrganize
|
||||
|
||||
const previous = initialMetadataRef.current;
|
||||
if (
|
||||
!force &&
|
||||
previous &&
|
||||
previous.author === comparisonState.author &&
|
||||
previous.copyright === comparisonState.copyright &&
|
||||
@@ -250,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);
|
||||
}
|
||||
@@ -268,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);
|
||||
};
|
||||
@@ -292,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>
|
||||
@@ -374,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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
@@ -146,20 +134,7 @@ 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) });
|
||||
};
|
||||
|
||||
const handleTagBlur = () => {
|
||||
const parsedTags = tagDraft
|
||||
.split(',')
|
||||
.map((value: string) => value.trim())
|
||||
.filter((value: string) => value.length > 0);
|
||||
onSave({ tags: parsedTags, categories: Array.from(selectedCategories) });
|
||||
onSave(Array.from(next));
|
||||
};
|
||||
|
||||
const handleRemoveCategory = (categoryId: string) => {
|
||||
@@ -174,11 +149,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 +158,11 @@ 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="category-filter">
|
||||
<input
|
||||
value={categoryFilter}
|
||||
onChange={(event: ChangeEvent<HTMLInputElement>) => setCategoryFilter(event.target.value)}
|
||||
onChange={(event) => setCategoryFilter(event.target.value)}
|
||||
placeholder="Filter UCS categories"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { AudioFileSummary, CategoryRecord } from '../../../../shared/models';
|
||||
import { TagEditor } from '../TagEditor';
|
||||
import WaveformEditorCanvas from './WaveformEditorCanvas';
|
||||
@@ -18,6 +18,25 @@ export interface EditModePanelProps {
|
||||
|
||||
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.
|
||||
*/
|
||||
@@ -32,6 +51,228 @@ export function EditModePanel({ file, categories, onClose, onSplitComplete }: Ed
|
||||
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;
|
||||
@@ -82,41 +323,29 @@ export function EditModePanel({ file, categories, onClose, onSplitComplete }: Ed
|
||||
}, [file.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (durationMs !== null) {
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const payload = await window.api.getAudioBuffer(file.id);
|
||||
const AudioContextCtor = (window as typeof window & { webkitAudioContext?: typeof AudioContext }).AudioContext
|
||||
?? (window as typeof window & { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
|
||||
if (!AudioContextCtor) {
|
||||
if (!cancelled) {
|
||||
setDurationMs(file.durationMs ?? null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const context = new AudioContextCtor();
|
||||
try {
|
||||
const buffer = await context.decodeAudioData(payload.buffer.slice(0));
|
||||
if (!cancelled) {
|
||||
setDurationMs(Math.round(buffer.duration * 1000));
|
||||
}
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to decode audio buffer for duration', error);
|
||||
if (!cancelled) {
|
||||
setDurationMs(null);
|
||||
}
|
||||
const buffer = await loadAudioBuffer();
|
||||
if (cancelled || !buffer) {
|
||||
return;
|
||||
}
|
||||
setDurationMs((current) => (current === null ? Math.round(buffer.duration * 1000) : current));
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [file.id, durationMs]);
|
||||
}, [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,
|
||||
@@ -126,6 +355,43 @@ export function EditModePanel({ file, categories, onClose, onSplitComplete }: Ed
|
||||
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;
|
||||
@@ -136,13 +402,15 @@ export function EditModePanel({ file, categories, onClose, onSplitComplete }: Ed
|
||||
startMs: start,
|
||||
endMs: end,
|
||||
label: `Segment ${segments.length + 1}`,
|
||||
metadata: buildDefaultMetadata(file, baseMetadata)
|
||||
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) => {
|
||||
@@ -161,9 +429,29 @@ export function EditModePanel({ file, categories, onClose, onSplitComplete }: Ed
|
||||
});
|
||||
};
|
||||
|
||||
const handleSelectSegment = (segmentId: string | null) => {
|
||||
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) =>
|
||||
@@ -175,12 +463,13 @@ export function EditModePanel({ file, categories, onClose, onSplitComplete }: Ed
|
||||
);
|
||||
};
|
||||
|
||||
const handleRemoveSegment = (segmentId: string) => {
|
||||
const handleRemoveSegment = useCallback((segmentId: string) => {
|
||||
setSegments((current) => current.filter((segment) => segment.id !== segmentId));
|
||||
if (selectedSegmentId === segmentId) {
|
||||
setSelectedSegmentId(null);
|
||||
setSelectedSegmentId((current) => (current === segmentId ? null : current));
|
||||
if (playbackInfoRef.current?.segmentId === segmentId) {
|
||||
cancelPlayback();
|
||||
}
|
||||
};
|
||||
}, [cancelPlayback]);
|
||||
|
||||
const handleMetadataPatch = (segmentId: string, patch: Partial<SegmentMetadataDraft>) => {
|
||||
setSegments((current) =>
|
||||
@@ -192,10 +481,43 @@ export function EditModePanel({ file, categories, onClose, onSplitComplete }: Ed
|
||||
);
|
||||
};
|
||||
|
||||
const handleTagSave = (segmentId: string, data: { tags: string[]; categories: string[] }) => {
|
||||
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, {
|
||||
tags: data.tags,
|
||||
categories: data.categories
|
||||
categories
|
||||
});
|
||||
};
|
||||
|
||||
@@ -209,7 +531,7 @@ export function EditModePanel({ file, categories, onClose, onSplitComplete }: Ed
|
||||
const payload = segments.map((segment) => toSplitRequest(segment));
|
||||
const created = await libraryStore.splitFile(file.id, payload);
|
||||
onSplitComplete(created);
|
||||
onClose();
|
||||
handleClose();
|
||||
} catch (error) {
|
||||
console.error('Split operation failed', error);
|
||||
setSubmitError(error instanceof Error ? error.message : String(error));
|
||||
@@ -227,26 +549,78 @@ export function EditModePanel({ file, categories, onClose, onSplitComplete }: Ed
|
||||
<p>{file.relativePath}</p>
|
||||
</div>
|
||||
<div className="edit-mode-header-actions">
|
||||
<button type="button" className="ghost-button" onClick={onClose}>Close</button>
|
||||
<button type="button" className="ghost-button" onClick={handleClose}>Close</button>
|
||||
</div>
|
||||
</header>
|
||||
<div className="edit-mode-body">
|
||||
<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}
|
||||
/>
|
||||
)}
|
||||
<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">
|
||||
@@ -260,6 +634,7 @@ export function EditModePanel({ file, categories, onClose, onSplitComplete }: Ed
|
||||
<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"
|
||||
@@ -268,15 +643,65 @@ export function EditModePanel({ file, categories, onClose, onSplitComplete }: Ed
|
||||
>
|
||||
<span className="segment-item-time">{formatTimecode(segment.startMs)} – {formatTimecode(segment.endMs)}</span>
|
||||
</button>
|
||||
<input
|
||||
type="text"
|
||||
value={segment.label}
|
||||
onChange={(event) => handleUpdateLabel(segment.id, event.target.value)}
|
||||
placeholder="Label"
|
||||
/>
|
||||
<button type="button" className="ghost-button segment-item-remove" onClick={() => handleRemoveSegment(segment.id)}>
|
||||
Remove
|
||||
</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>
|
||||
@@ -285,52 +710,7 @@ export function EditModePanel({ file, categories, onClose, onSplitComplete }: Ed
|
||||
<section className="segment-metadata">
|
||||
<h2>Metadata</h2>
|
||||
{selectedSegment ? (
|
||||
<>
|
||||
<label className="segment-field">
|
||||
<span>Custom Name</span>
|
||||
<input
|
||||
type="text"
|
||||
value={selectedSegment.metadata.customName ?? ''}
|
||||
onChange={(event) => handleMetadataPatch(selectedSegment.id, { customName: normaliseText(event.target.value) })}
|
||||
placeholder="Optional custom title"
|
||||
/>
|
||||
</label>
|
||||
<label className="segment-field">
|
||||
<span>Author</span>
|
||||
<input
|
||||
type="text"
|
||||
value={selectedSegment.metadata.author ?? ''}
|
||||
onChange={(event) => handleMetadataPatch(selectedSegment.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={selectedSegment.metadata.rating !== null && selectedSegment.metadata.rating >= star ? 'star star--filled' : 'star'}
|
||||
onClick={() => handleMetadataPatch(selectedSegment.id, {
|
||||
rating:
|
||||
selectedSegment.metadata.rating === star ? null : star
|
||||
})}
|
||||
aria-label={`${star} star${star > 1 ? 's' : ''}`}
|
||||
>
|
||||
★
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<TagEditor
|
||||
tags={selectedSegment.metadata.tags}
|
||||
categories={selectedSegment.metadata.categories}
|
||||
availableCategories={categories}
|
||||
onSave={(data) => handleTagSave(selectedSegment.id, data)}
|
||||
showHeading={false}
|
||||
/>
|
||||
</>
|
||||
<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>
|
||||
)}
|
||||
@@ -340,7 +720,7 @@ export function EditModePanel({ file, categories, onClose, onSplitComplete }: Ed
|
||||
<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={onClose} disabled={isSaving}>Cancel</button>
|
||||
<button type="button" className="ghost-button" onClick={handleClose} disabled={isSaving}>Cancel</button>
|
||||
<button
|
||||
type="button"
|
||||
className="primary-button"
|
||||
@@ -366,12 +746,19 @@ function normaliseRange(start: number, end: number, duration: number): { start:
|
||||
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: file.customName ?? null,
|
||||
customName: null,
|
||||
author: baseMetadata.author,
|
||||
rating: baseMetadata.rating,
|
||||
tags: file.tags.slice(),
|
||||
@@ -386,6 +773,46 @@ function generateSegmentId(): string {
|
||||
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;
|
||||
|
||||
@@ -17,6 +17,10 @@ export interface WaveformEditorCanvasProps {
|
||||
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 {
|
||||
@@ -34,6 +38,8 @@ 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.
|
||||
@@ -45,19 +51,38 @@ export function WaveformEditorCanvas({
|
||||
selectedSegmentId,
|
||||
onSelectSegment,
|
||||
onCreateSegment,
|
||||
onResizeSegment
|
||||
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]);
|
||||
|
||||
@@ -140,11 +165,65 @@ export function WaveformEditorCanvas({
|
||||
return;
|
||||
}
|
||||
const isSelected = segment.id === selectedSegmentId;
|
||||
context.fillStyle = isSelected ? 'rgba(255, 204, 0, 0.25)' : 'rgba(255, 255, 255, 0.18)';
|
||||
|
||||
// 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);
|
||||
context.fillStyle = isSelected ? '#ffcc00' : 'rgba(255, 255, 255, 0.45)';
|
||||
|
||||
// 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) {
|
||||
@@ -159,9 +238,114 @@ export function WaveformEditorCanvas({
|
||||
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);
|
||||
}
|
||||
}
|
||||
}, [samples, segments, selectedSegmentId, samplesPerMs, durationMs, viewportVersion, draftSelection]);
|
||||
|
||||
// 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;
|
||||
@@ -179,19 +363,60 @@ export function WaveformEditorCanvas({
|
||||
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));
|
||||
viewportRef.current = {
|
||||
|
||||
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
|
||||
};
|
||||
setViewportVersion((value) => value + 1);
|
||||
|
||||
if (viewportAnimationFrameRef.current === null) {
|
||||
viewportAnimationFrameRef.current = requestAnimationFrame(animateViewport);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePointerDown = (event: ReactPointerEvent<HTMLCanvasElement>) => {
|
||||
if (event.button === 2) {
|
||||
clickContextRef.current = null;
|
||||
const canvas = canvasRef.current;
|
||||
if (canvas) {
|
||||
canvas.setPointerCapture(event.pointerId);
|
||||
@@ -213,6 +438,7 @@ export function WaveformEditorCanvas({
|
||||
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));
|
||||
@@ -221,13 +447,15 @@ export function WaveformEditorCanvas({
|
||||
if (hit) {
|
||||
onSelectSegment(hit.segmentId);
|
||||
dragStateRef.current = { type: 'resizing', segmentId: hit.segmentId, handle: hit.handle };
|
||||
clickContextRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const containing = segments.find((segment) => pointerMs >= segment.startMs && pointerMs <= segment.endMs);
|
||||
const containing = pickSegmentAtMs(pointerMs, segments);
|
||||
if (containing) {
|
||||
onSelectSegment(containing.id);
|
||||
dragStateRef.current = { type: 'none' };
|
||||
clickContextRef.current = { startMs: pointerMs, insideSegmentId: containing.id };
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -237,6 +465,86 @@ export function WaveformEditorCanvas({
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -293,8 +601,17 @@ export function WaveformEditorCanvas({
|
||||
const start = Math.min(state.anchorMs, pointerMs);
|
||||
const end = Math.max(state.anchorMs, pointerMs);
|
||||
setDraftSelection(null);
|
||||
if (end - start >= MIN_SEGMENT_MS) {
|
||||
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;
|
||||
}
|
||||
@@ -307,6 +624,21 @@ export function WaveformEditorCanvas({
|
||||
// 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>) => {
|
||||
@@ -331,6 +663,23 @@ export function WaveformEditorCanvas({
|
||||
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);
|
||||
@@ -342,6 +691,113 @@ export function WaveformEditorCanvas({
|
||||
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}
|
||||
@@ -349,6 +805,8 @@ export function WaveformEditorCanvas({
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onWheel={handleWheel}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
onContextMenu={handleContextMenu}
|
||||
@@ -389,4 +847,44 @@ function findSegmentHandle(
|
||||
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;
|
||||
|
||||
@@ -30,18 +30,29 @@ export interface SegmentDraft {
|
||||
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: segment.label.trim().length > 0 ? segment.label.trim() : undefined,
|
||||
label: effectiveLabel,
|
||||
metadata: {
|
||||
customName: segment.metadata.customName,
|
||||
customName: customNameOverride,
|
||||
author: segment.metadata.author,
|
||||
rating: segment.metadata.rating ?? undefined,
|
||||
tags: segment.metadata.tags,
|
||||
|
||||
@@ -207,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.
|
||||
*/
|
||||
|
||||
@@ -522,6 +522,23 @@ body {
|
||||
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));
|
||||
@@ -1604,3 +1621,342 @@ body {
|
||||
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
0
src/renderer/src/types/assets.d.ts
vendored
Normal 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,18 +46,23 @@ 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; author?: string; copyright?: string; 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[]>;
|
||||
@@ -67,7 +73,10 @@ export interface RendererApi {
|
||||
/** Lists distinct metadata values gathered from the library for quick suggestions. */
|
||||
listMetadataSuggestions(): Promise<{ authors: string[]; copyrights: string[] }>;
|
||||
/** Updates metadata (author, copyright, rating) without organizing the file. */
|
||||
updateFileMetadata(fileId: number, metadata: { author?: string; copyright?: string; rating?: number }): Promise<void>;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user