mirror of
https://github.com/litruv/AudioSort.git
synced 2026-07-24 18:56:01 +10:00
feat: implement audio file splitting functionality
- Added `splitFile` method to `LibraryStore` for splitting audio files into segments. - Introduced `SplitSegmentRequest` and `SegmentMetadataInput` interfaces in models for segment metadata handling. - Created `EditModePanel` component for editing segments, including waveform visualization and metadata assignment. - Developed `WaveformEditorCanvas` for interactive waveform editing, supporting segment creation and resizing. - Enhanced metadata handling in IPC with new fields for author and copyright. - Updated styles for new components and improved UI interactions.
This commit is contained in:
@@ -138,9 +138,9 @@ export class MainApp {
|
|||||||
ipcMain.removeHandler(IPC_CHANNELS.libraryMove);
|
ipcMain.removeHandler(IPC_CHANNELS.libraryMove);
|
||||||
ipcMain.removeHandler(IPC_CHANNELS.libraryOrganize);
|
ipcMain.removeHandler(IPC_CHANNELS.libraryOrganize);
|
||||||
ipcMain.removeHandler(IPC_CHANNELS.libraryBuffer);
|
ipcMain.removeHandler(IPC_CHANNELS.libraryBuffer);
|
||||||
ipcMain.removeHandler(IPC_CHANNELS.libraryMetadata);
|
ipcMain.removeHandler(IPC_CHANNELS.libraryMetadata);
|
||||||
ipcMain.removeHandler(IPC_CHANNELS.libraryMetadataSuggestions);
|
ipcMain.removeHandler(IPC_CHANNELS.libraryMetadataSuggestions);
|
||||||
ipcMain.removeHandler(IPC_CHANNELS.libraryUpdateMetadata);
|
ipcMain.removeHandler(IPC_CHANNELS.libraryUpdateMetadata);
|
||||||
ipcMain.removeHandler(IPC_CHANNELS.libraryWaveformPreview);
|
ipcMain.removeHandler(IPC_CHANNELS.libraryWaveformPreview);
|
||||||
ipcMain.removeHandler(IPC_CHANNELS.tagsUpdate);
|
ipcMain.removeHandler(IPC_CHANNELS.tagsUpdate);
|
||||||
ipcMain.removeHandler(IPC_CHANNELS.categoriesList);
|
ipcMain.removeHandler(IPC_CHANNELS.categoriesList);
|
||||||
@@ -163,7 +163,7 @@ export class MainApp {
|
|||||||
* Creates the renderer window and loads the UI.
|
* Creates the renderer window and loads the UI.
|
||||||
*/
|
*/
|
||||||
private createWindow(): void {
|
private createWindow(): void {
|
||||||
const preloadPath = this.resolvePreloadPath();
|
const preloadPath = this.resolvePreloadPath();
|
||||||
this.mainWindow = new BrowserWindow({
|
this.mainWindow = new BrowserWindow({
|
||||||
width: 1280,
|
width: 1280,
|
||||||
height: 800,
|
height: 800,
|
||||||
@@ -186,16 +186,18 @@ export class MainApp {
|
|||||||
this.mainWindow = null;
|
this.mainWindow = null;
|
||||||
});
|
});
|
||||||
|
|
||||||
const devServerUrl = (globalThis as { process?: { env?: Record<string, string | undefined> } }).process?.env?.VITE_DEV_SERVER_URL;
|
const devServerUrl = (globalThis as { process?: { env?: Record<string, string | undefined> } }).process?.env
|
||||||
|
?.VITE_DEV_SERVER_URL;
|
||||||
if (devServerUrl) {
|
if (devServerUrl) {
|
||||||
this.mainWindow.loadURL(devServerUrl).catch((error: unknown) => {
|
this.mainWindow.loadURL(devServerUrl).catch((error: unknown) => {
|
||||||
|
// eslint-disable-next-line no-console -- Logging is useful during development to diagnose boot issues.
|
||||||
console.error('Failed to load renderer URL', error);
|
console.error('Failed to load renderer URL', error);
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
const rendererIndex = this.resolveRendererIndex();
|
const rendererIndex = this.resolveRendererIndex();
|
||||||
this.mainWindow.loadFile(rendererIndex).catch((error: unknown) => {
|
this.mainWindow
|
||||||
console.error('Failed to load renderer bundle', error);
|
.loadFile(rendererIndex)
|
||||||
});
|
.catch((error: unknown) => console.error('Failed to load renderer bundle', error));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -212,7 +214,7 @@ export class MainApp {
|
|||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle(IPC_CHANNELS.dialogSelectLibrary, async () => {
|
ipcMain.handle(IPC_CHANNELS.dialogSelectLibrary, async () => {
|
||||||
const options: Electron.OpenDialogOptions = { properties: ['openDirectory'] };
|
const options: Electron.OpenDialogOptions = { properties: ['openDirectory'] };
|
||||||
const targetWindow = this.mainWindow;
|
const targetWindow = this.mainWindow;
|
||||||
const result = targetWindow
|
const result = targetWindow
|
||||||
? await dialog.showOpenDialog(targetWindow, options)
|
? await dialog.showOpenDialog(targetWindow, options)
|
||||||
@@ -239,8 +241,8 @@ export class MainApp {
|
|||||||
|
|
||||||
ipcMain.handle(
|
ipcMain.handle(
|
||||||
IPC_CHANNELS.libraryOrganize,
|
IPC_CHANNELS.libraryOrganize,
|
||||||
async (_event: IpcMainInvokeEvent, fileId: number, metadata: { customName?: string | null; author?: string | null; copyright?: string | null; rating?: number }) =>
|
async (_event: IpcMainInvokeEvent, fileId: number, metadata: { customName?: string; author?: string; copyright?: string; rating?: number }) =>
|
||||||
this.requireLibrary().organizeFile(fileId, metadata)
|
this.requireLibrary().organizeFile(fileId, metadata)
|
||||||
);
|
);
|
||||||
|
|
||||||
ipcMain.handle(
|
ipcMain.handle(
|
||||||
@@ -283,7 +285,7 @@ export class MainApp {
|
|||||||
|
|
||||||
ipcMain.handle(
|
ipcMain.handle(
|
||||||
IPC_CHANNELS.libraryUpdateMetadata,
|
IPC_CHANNELS.libraryUpdateMetadata,
|
||||||
async (_event: IpcMainInvokeEvent, fileId: number, metadata: { author?: string | null; copyright?: string | null; rating?: number }) =>
|
async (_event: IpcMainInvokeEvent, fileId: number, metadata: { author?: string; copyright?: string; rating?: number }) =>
|
||||||
this.requireLibrary().updateFileMetadata(fileId, metadata)
|
this.requireLibrary().updateFileMetadata(fileId, metadata)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { createHash } from 'node:crypto';
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import fg from 'fast-glob';
|
import fg from 'fast-glob';
|
||||||
import { parse } from 'csv-parse/sync';
|
import { parse } from 'csv-parse/sync';
|
||||||
import { AppSettings, AudioBufferPayload, AudioFileSummary, CategoryRecord, LibraryScanSummary, TagUpdatePayload } from '../../shared/models';
|
import { AppSettings, AudioBufferPayload, AudioFileSummary, CategoryRecord, LibraryScanSummary, SplitSegmentRequest, TagUpdatePayload } from '../../shared/models';
|
||||||
import { DatabaseService, FileRecordInput } from './DatabaseService';
|
import { DatabaseService, FileRecordInput } from './DatabaseService';
|
||||||
import { SettingsService } from './SettingsService';
|
import { SettingsService } from './SettingsService';
|
||||||
import { TagService } from './TagService';
|
import { TagService } from './TagService';
|
||||||
@@ -292,7 +292,7 @@ export class LibraryService {
|
|||||||
*/
|
*/
|
||||||
public async getWaveformPreview(fileId: number, pointCount = 160): Promise<{ samples: number[]; rms: number }> {
|
public async getWaveformPreview(fileId: number, pointCount = 160): Promise<{ samples: number[]; rms: number }> {
|
||||||
const record = this.database.getFileById(fileId);
|
const record = this.database.getFileById(fileId);
|
||||||
const effectivePoints = Math.min(Math.max(pointCount ?? 160, 32), 512);
|
const effectivePoints = Math.min(Math.max(pointCount ?? 160, 32), 16384);
|
||||||
const cacheHit = this.waveformPreviewCache.get(fileId);
|
const cacheHit = this.waveformPreviewCache.get(fileId);
|
||||||
if (cacheHit && cacheHit.modifiedAt === record.modifiedAt && cacheHit.pointCount === effectivePoints) {
|
if (cacheHit && cacheHit.modifiedAt === record.modifiedAt && cacheHit.pointCount === effectivePoints) {
|
||||||
return { samples: cacheHit.samples, rms: cacheHit.rms };
|
return { samples: cacheHit.samples, rms: cacheHit.rms };
|
||||||
@@ -636,6 +636,199 @@ export class LibraryService {
|
|||||||
this.search.rebuildIndex();
|
this.search.rebuildIndex();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Splits an audio file into multiple segments, writing each segment to disk and registering it in the library.
|
||||||
|
*/
|
||||||
|
public async splitFile(fileId: number, requestSegments: SplitSegmentRequest[]): Promise<AudioFileSummary[]> {
|
||||||
|
if (!Array.isArray(requestSegments) || requestSegments.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const record = this.database.getFileById(fileId);
|
||||||
|
const fileBuffer = await fs.readFile(record.absolutePath);
|
||||||
|
const wave = new WaveFile(fileBuffer);
|
||||||
|
const format = (wave as WaveFile & { fmt?: { sampleRate?: number; bitsPerSample?: number } }).fmt;
|
||||||
|
const rawSamples = wave.getSamples(false, Float64Array) as Float64Array | Float64Array[];
|
||||||
|
const channels = Array.isArray(rawSamples) ? rawSamples : [rawSamples];
|
||||||
|
if (channels.length === 0 || channels[0].length === 0) {
|
||||||
|
throw new Error('Unable to split file: no audio data available.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const sampleRate = format?.sampleRate ?? record.sampleRate ?? null;
|
||||||
|
if (!sampleRate) {
|
||||||
|
throw new Error('Unable to split file: missing sample rate information.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceSampleCount = channels[0].length;
|
||||||
|
const totalDurationMs = Math.round((sourceSampleCount / sampleRate) * 1000);
|
||||||
|
const bitDepthNumeric = (() => {
|
||||||
|
const explicit = format?.bitsPerSample;
|
||||||
|
if (typeof explicit === 'number' && Number.isFinite(explicit) && explicit > 0) {
|
||||||
|
return explicit;
|
||||||
|
}
|
||||||
|
if (typeof record.bitDepth === 'number' && Number.isFinite(record.bitDepth) && record.bitDepth > 0) {
|
||||||
|
return record.bitDepth;
|
||||||
|
}
|
||||||
|
const parsedFromText = Number.parseInt(typeof wave.bitDepth === 'string' ? wave.bitDepth : '', 10);
|
||||||
|
return Number.isFinite(parsedFromText) && parsedFromText > 0 ? parsedFromText : null;
|
||||||
|
})();
|
||||||
|
const bitDepthText = (() => {
|
||||||
|
if (typeof wave.bitDepth === 'string' && wave.bitDepth.trim().length > 0) {
|
||||||
|
return wave.bitDepth.trim();
|
||||||
|
}
|
||||||
|
if (typeof bitDepthNumeric === 'number') {
|
||||||
|
return bitDepthNumeric.toString();
|
||||||
|
}
|
||||||
|
return '16';
|
||||||
|
})();
|
||||||
|
const container = (wave as WaveFile & { container?: string }).container ?? 'RIFF';
|
||||||
|
|
||||||
|
let originalMetadata: { author?: string | null; rating?: number; title?: string | null } = {};
|
||||||
|
try {
|
||||||
|
const metadata = this.tagService.readMetadata(record.absolutePath);
|
||||||
|
originalMetadata = {
|
||||||
|
author: metadata.author ?? null,
|
||||||
|
rating: metadata.rating ?? undefined,
|
||||||
|
title: metadata.title ?? null
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to read original metadata before splitting', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedSegments = requestSegments
|
||||||
|
.map((segment) => {
|
||||||
|
const startMs = Number.isFinite(segment.startMs) ? Math.max(0, Math.min(Math.floor(segment.startMs), totalDurationMs)) : 0;
|
||||||
|
const endMs = Number.isFinite(segment.endMs) ? Math.max(0, Math.min(Math.floor(segment.endMs), totalDurationMs)) : startMs;
|
||||||
|
const safeEnd = Math.max(startMs + 1, endMs);
|
||||||
|
return {
|
||||||
|
startMs,
|
||||||
|
endMs: safeEnd,
|
||||||
|
label: segment.label?.trim() ?? undefined,
|
||||||
|
metadata: segment.metadata
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((segment) => segment.endMs - segment.startMs >= 5)
|
||||||
|
.sort((a, b) => a.startMs - b.startMs);
|
||||||
|
|
||||||
|
if (normalizedSegments.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const libraryRoot = this.settings.ensureLibraryPath();
|
||||||
|
const sourceDirectory = path.dirname(record.absolutePath);
|
||||||
|
const relativeFolder = path.dirname(record.relativePath);
|
||||||
|
const folderForJoin = relativeFolder === '.' ? '' : relativeFolder;
|
||||||
|
const baseName = path.basename(record.fileName, path.extname(record.fileName));
|
||||||
|
const usedNames = new Set<string>();
|
||||||
|
let sequence = 1;
|
||||||
|
const created: AudioFileSummary[] = [];
|
||||||
|
|
||||||
|
for (const segment of normalizedSegments) {
|
||||||
|
const startSample = Math.max(0, Math.min(Math.floor((segment.startMs / 1000) * sampleRate), sourceSampleCount - 1));
|
||||||
|
const endSample = Math.max(
|
||||||
|
startSample + 1,
|
||||||
|
Math.min(Math.floor((segment.endMs / 1000) * sampleRate), sourceSampleCount)
|
||||||
|
);
|
||||||
|
if (endSample <= startSample) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const segmentChannels = channels.map((channel) => channel.slice(startSample, endSample));
|
||||||
|
const nextWave = new WaveFile();
|
||||||
|
nextWave.fromScratch(channels.length, sampleRate, bitDepthText, segmentChannels.length === 1 ? segmentChannels[0] : segmentChannels);
|
||||||
|
(nextWave as WaveFile & { container?: string }).container = container;
|
||||||
|
const segmentBytes = Buffer.from(nextWave.toBuffer());
|
||||||
|
|
||||||
|
let segmentFileName = '';
|
||||||
|
let segmentAbsolutePath = '';
|
||||||
|
for (let attempt = 0; attempt < 1000; attempt += 1) {
|
||||||
|
const suffix = this.organization.formatSequenceNumber(sequence);
|
||||||
|
sequence += 1;
|
||||||
|
const candidateName = `${baseName}_segment${suffix}.wav`;
|
||||||
|
if (usedNames.has(candidateName)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const candidatePath = path.join(sourceDirectory, candidateName);
|
||||||
|
const exists = await this.pathExists(candidatePath);
|
||||||
|
if (exists) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
segmentFileName = candidateName;
|
||||||
|
segmentAbsolutePath = candidatePath;
|
||||||
|
usedNames.add(candidateName);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!segmentFileName || !segmentAbsolutePath) {
|
||||||
|
throw new Error('Failed to allocate filename for split segment.');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.assertWithinLibrary(libraryRoot, segmentAbsolutePath);
|
||||||
|
await fs.writeFile(segmentAbsolutePath, segmentBytes);
|
||||||
|
const stats = await fs.stat(segmentAbsolutePath);
|
||||||
|
const checksum = createHash('md5').update(segmentBytes).digest('hex');
|
||||||
|
const relativePath = this.toLibraryRelativePath(folderForJoin, segmentFileName);
|
||||||
|
const durationMs = Math.round(((endSample - startSample) / sampleRate) * 1000);
|
||||||
|
|
||||||
|
const resolvedTagsSource = segment.metadata?.tags ?? record.tags;
|
||||||
|
const resolvedCategoriesSource = segment.metadata?.categories ?? record.categories;
|
||||||
|
const resolvedTags = Array.isArray(resolvedTagsSource) ? resolvedTagsSource.slice() : record.tags.slice();
|
||||||
|
const resolvedCategories = Array.isArray(resolvedCategoriesSource)
|
||||||
|
? resolvedCategoriesSource.slice()
|
||||||
|
: record.categories.slice();
|
||||||
|
const fileRecord = this.database.upsertFile({
|
||||||
|
absolutePath: segmentAbsolutePath,
|
||||||
|
relativePath,
|
||||||
|
fileName: segmentFileName,
|
||||||
|
displayName: path.basename(segmentFileName, '.wav'),
|
||||||
|
modifiedAt: stats.mtimeMs,
|
||||||
|
createdAt: Number.isNaN(stats.birthtimeMs) ? null : stats.birthtimeMs,
|
||||||
|
size: stats.size,
|
||||||
|
durationMs,
|
||||||
|
sampleRate,
|
||||||
|
bitDepth: bitDepthNumeric,
|
||||||
|
checksum,
|
||||||
|
tags: resolvedTags,
|
||||||
|
categories: resolvedCategories
|
||||||
|
});
|
||||||
|
|
||||||
|
const resolvedCustomName = segment.metadata?.customName !== undefined
|
||||||
|
? this.normaliseMetadataInput(segment.metadata.customName)
|
||||||
|
: record.customName ?? null;
|
||||||
|
const updatedRecord = resolvedCustomName !== fileRecord.customName
|
||||||
|
? this.database.updateCustomName(fileRecord.id, resolvedCustomName ?? null)
|
||||||
|
: fileRecord;
|
||||||
|
|
||||||
|
const resolvedAuthor = segment.metadata?.author !== undefined
|
||||||
|
? this.normaliseMetadataInput(segment.metadata.author)
|
||||||
|
: this.normaliseMetadataInput(originalMetadata.author ?? null);
|
||||||
|
const resolvedRating = segment.metadata?.rating !== undefined
|
||||||
|
? segment.metadata.rating ?? undefined
|
||||||
|
: originalMetadata.rating;
|
||||||
|
|
||||||
|
this.tagService.writeMetadataOnly(segmentAbsolutePath, {
|
||||||
|
tags: resolvedTags,
|
||||||
|
categories: resolvedCategories,
|
||||||
|
title: resolvedCustomName ?? updatedRecord.displayName,
|
||||||
|
author: resolvedAuthor ?? undefined,
|
||||||
|
rating: resolvedRating
|
||||||
|
});
|
||||||
|
|
||||||
|
if (typeof resolvedAuthor === 'string' && resolvedAuthor.length > 0) {
|
||||||
|
this.updateMetadataSuggestionsCache(resolvedAuthor);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.waveformPreviewCache.delete(updatedRecord.id);
|
||||||
|
created.push(updatedRecord);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.waveformPreviewCache.delete(record.id);
|
||||||
|
this.resetMetadataSuggestionsCache();
|
||||||
|
this.search.rebuildIndex();
|
||||||
|
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the previously parsed UCS category catalog.
|
* Returns the previously parsed UCS category catalog.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -11,14 +11,22 @@ export class TagService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Applies tags and categories to a file record and embeds the metadata into the WAV container.
|
* Applies tags and categories to a file record and embeds the metadata into the WAV container.
|
||||||
|
* Preserves existing author, title, and rating fields.
|
||||||
*/
|
*/
|
||||||
public applyTagging(fileId: number, tags: string[], categories: string[]): AudioFileSummary {
|
public applyTagging(fileId: number, tags: string[], categories: string[]): AudioFileSummary {
|
||||||
const normalisedTags = this.normaliseValues(tags);
|
const normalisedTags = this.normaliseValues(tags);
|
||||||
const normalisedCategories = this.normaliseValues(categories);
|
const normalisedCategories = this.normaliseValues(categories);
|
||||||
const updated = this.database.updateTagging(fileId, normalisedTags, normalisedCategories);
|
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, {
|
this.writeWaveMetadata(updated.absolutePath, {
|
||||||
tags: normalisedTags,
|
tags: normalisedTags,
|
||||||
categories: normalisedCategories
|
categories: normalisedCategories,
|
||||||
|
title: existing.title,
|
||||||
|
author: existing.author,
|
||||||
|
rating: existing.rating
|
||||||
});
|
});
|
||||||
return updated;
|
return updated;
|
||||||
}
|
}
|
||||||
@@ -69,15 +77,16 @@ export class TagService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Writes tags and categories to an organized file as embedded metadata.
|
* Writes tags and categories to an organized file as embedded metadata.
|
||||||
|
* All metadata fields are required to prevent accidental clearing.
|
||||||
*/
|
*/
|
||||||
public writeMetadataOnly(
|
public writeMetadataOnly(
|
||||||
filePath: string,
|
filePath: string,
|
||||||
metadata: {
|
metadata: {
|
||||||
tags: string[];
|
tags: string[];
|
||||||
categories: string[];
|
categories: string[];
|
||||||
title?: string | null;
|
title: string | null | undefined;
|
||||||
author?: string | null;
|
author: string | null | undefined;
|
||||||
rating?: number;
|
rating: number | undefined;
|
||||||
}
|
}
|
||||||
): void {
|
): void {
|
||||||
this.writeWaveMetadata(filePath, metadata);
|
this.writeWaveMetadata(filePath, metadata);
|
||||||
@@ -85,15 +94,16 @@ export class TagService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Writes a simple INFO chunk with the provided metadata. Failures are swallowed so DB state remains authoritative.
|
* Writes a simple INFO chunk with the provided metadata. Failures are swallowed so DB state remains authoritative.
|
||||||
|
* All metadata fields must be explicitly provided to prevent accidental data loss.
|
||||||
*/
|
*/
|
||||||
private writeWaveMetadata(
|
private writeWaveMetadata(
|
||||||
filePath: string,
|
filePath: string,
|
||||||
metadata: {
|
metadata: {
|
||||||
tags: string[];
|
tags: string[];
|
||||||
categories: string[];
|
categories: string[];
|
||||||
title?: string | null;
|
title: string | null | undefined;
|
||||||
author?: string | null;
|
author: string | null | undefined;
|
||||||
rating?: number;
|
rating: number | undefined;
|
||||||
}
|
}
|
||||||
): void {
|
): void {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ const api: RendererApi = {
|
|||||||
async moveFile(fileId: number, targetRelativeDirectory: string): Promise<AudioFileSummary> {
|
async moveFile(fileId: number, targetRelativeDirectory: string): Promise<AudioFileSummary> {
|
||||||
return ipcRenderer.invoke(IPC_CHANNELS.libraryMove, fileId, targetRelativeDirectory);
|
return ipcRenderer.invoke(IPC_CHANNELS.libraryMove, fileId, targetRelativeDirectory);
|
||||||
},
|
},
|
||||||
async organizeFile(fileId: number, metadata: { customName?: string | null; author?: string | null; copyright?: string | null; rating?: number }): Promise<AudioFileSummary> {
|
async organizeFile(fileId: number, metadata: { customName?: string; author?: string; copyright?: string; rating?: number }): Promise<AudioFileSummary> {
|
||||||
return ipcRenderer.invoke(IPC_CHANNELS.libraryOrganize, fileId, metadata);
|
return ipcRenderer.invoke(IPC_CHANNELS.libraryOrganize, fileId, metadata);
|
||||||
},
|
},
|
||||||
async updateCustomName(fileId: number, customName: string | null): Promise<AudioFileSummary> {
|
async updateCustomName(fileId: number, customName: string | null): Promise<AudioFileSummary> {
|
||||||
@@ -70,7 +70,7 @@ const api: RendererApi = {
|
|||||||
async listMetadataSuggestions(): Promise<{ authors: string[]; copyrights: string[] }> {
|
async listMetadataSuggestions(): Promise<{ authors: string[]; copyrights: string[] }> {
|
||||||
return ipcRenderer.invoke(IPC_CHANNELS.libraryMetadataSuggestions);
|
return ipcRenderer.invoke(IPC_CHANNELS.libraryMetadataSuggestions);
|
||||||
},
|
},
|
||||||
async updateFileMetadata(fileId: number, metadata: { author?: string | null; copyright?: string | null; rating?: number }): Promise<void> {
|
async updateFileMetadata(fileId: number, metadata: { author?: string; copyright?: string; rating?: number }): Promise<void> {
|
||||||
return ipcRenderer.invoke(IPC_CHANNELS.libraryUpdateMetadata, fileId, metadata);
|
return ipcRenderer.invoke(IPC_CHANNELS.libraryUpdateMetadata, fileId, metadata);
|
||||||
},
|
},
|
||||||
onMenuAction(channel: string, callback: () => void): () => void {
|
onMenuAction(channel: string, callback: () => void): () => void {
|
||||||
|
|||||||
@@ -6,11 +6,14 @@ import CategorySidebar from './components/CategorySidebar';
|
|||||||
import AudioPlayer from './components/AudioPlayer';
|
import AudioPlayer from './components/AudioPlayer';
|
||||||
import SettingsDialog from './components/SettingsDialog';
|
import SettingsDialog from './components/SettingsDialog';
|
||||||
import DuplicateComparisonDialog from './components/DuplicateComparisonDialog';
|
import DuplicateComparisonDialog from './components/DuplicateComparisonDialog';
|
||||||
|
import EditModePanel from './components/edit/EditModePanel';
|
||||||
import { useLibrarySnapshot } from './hooks/useLibrarySnapshot';
|
import { useLibrarySnapshot } from './hooks/useLibrarySnapshot';
|
||||||
import { loadPlayerFile, usePlayerSnapshot } from './hooks/usePlayerSnapshot';
|
import { loadPlayerFile, usePlayerSnapshot } from './hooks/usePlayerSnapshot';
|
||||||
import { libraryStore, type CategoryFilterValue } from './stores/LibraryStore';
|
import { libraryStore, type CategoryFilterValue } from './stores/LibraryStore';
|
||||||
import type { AudioFileSummary } from '../../shared/models';
|
import type { AudioFileSummary } from '../../shared/models';
|
||||||
|
|
||||||
|
type RightPanelTab = 'listen' | 'edit';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Root renderer component orchestrating layout and interactions.
|
* Root renderer component orchestrating layout and interactions.
|
||||||
*/
|
*/
|
||||||
@@ -21,6 +24,7 @@ function App(): JSX.Element {
|
|||||||
const [duplicateGroups, setDuplicateGroups] = useState<{ checksum: string; files: AudioFileSummary[] }[] | null>(null);
|
const [duplicateGroups, setDuplicateGroups] = useState<{ checksum: string; files: AudioFileSummary[] }[] | null>(null);
|
||||||
const [showStatusMessage, setShowStatusMessage] = useState(false);
|
const [showStatusMessage, setShowStatusMessage] = useState(false);
|
||||||
const [statusFadingOut, setStatusFadingOut] = useState(false);
|
const [statusFadingOut, setStatusFadingOut] = useState(false);
|
||||||
|
const [activeTab, setActiveTab] = useState<RightPanelTab>('listen');
|
||||||
|
|
||||||
const statusMessage = useMemo(() => {
|
const statusMessage = useMemo(() => {
|
||||||
if (!library.lastScan) {
|
if (!library.lastScan) {
|
||||||
@@ -132,10 +136,6 @@ function App(): JSX.Element {
|
|||||||
libraryStore.selectFile(fileId, options);
|
libraryStore.selectFile(fileId, options);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSelectAllFiles = () => {
|
|
||||||
libraryStore.selectAllVisibleFiles();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePlayFile = (file: AudioFileSummary) => {
|
const handlePlayFile = (file: AudioFileSummary) => {
|
||||||
loadPlayerFile(file, true);
|
loadPlayerFile(file, true);
|
||||||
};
|
};
|
||||||
@@ -158,7 +158,7 @@ function App(): JSX.Element {
|
|||||||
await libraryStore.moveFile(selectedFile.id, targetDirectory);
|
await libraryStore.moveFile(selectedFile.id, targetDirectory);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleOrganize = async (metadata: { customName?: string | null; author?: string | null; copyright?: string | null; rating?: number }) => {
|
const handleOrganize = async (metadata: { customName?: string; author?: string; copyright?: string; rating?: number }) => {
|
||||||
if (!selectedFile) {
|
if (!selectedFile) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -187,11 +187,11 @@ function App(): JSX.Element {
|
|||||||
await libraryStore.updateCustomName(fileId, customName);
|
await libraryStore.updateCustomName(fileId, customName);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMultiFileOrganize = async (fileId: number, metadata: { customName?: string | null; author?: string | null; copyright?: string | null; rating?: number }) => {
|
const handleMultiFileOrganize = async (fileId: number, metadata: { customName?: string; author?: string; copyright?: string; rating?: number }) => {
|
||||||
await libraryStore.organizeFile(fileId, metadata);
|
await libraryStore.organizeFile(fileId, metadata);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMultiFileUpdateMetadata = async (fileId: number, metadata: { author?: string | null; copyright?: string | null; rating?: number }) => {
|
const handleMultiFileUpdateMetadata = async (fileId: number, metadata: { author?: string; copyright?: string; rating?: number }) => {
|
||||||
await libraryStore.updateFileMetadata(fileId, metadata);
|
await libraryStore.updateFileMetadata(fileId, metadata);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -217,6 +217,18 @@ function App(): JSX.Element {
|
|||||||
setSettingsOpen(false);
|
setSettingsOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleEditModeClose = () => {
|
||||||
|
setActiveTab('listen');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditModeSplitComplete = async (created: AudioFileSummary[]) => {
|
||||||
|
await libraryStore.rescan();
|
||||||
|
setActiveTab('listen');
|
||||||
|
if (created.length > 0) {
|
||||||
|
libraryStore.selectFile(created[0].id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app-root">
|
<div className="app-root">
|
||||||
{showStatusMessage && statusMessage && (
|
{showStatusMessage && statusMessage && (
|
||||||
@@ -240,7 +252,6 @@ function App(): JSX.Element {
|
|||||||
onPlay={handlePlayFile}
|
onPlay={handlePlayFile}
|
||||||
searchValue={library.searchQuery}
|
searchValue={library.searchQuery}
|
||||||
onSearchChange={handleSearch}
|
onSearchChange={handleSearch}
|
||||||
onSelectAll={handleSelectAllFiles}
|
|
||||||
/>
|
/>
|
||||||
<div className="app-details">
|
<div className="app-details">
|
||||||
{isMultiSelect ? (
|
{isMultiSelect ? (
|
||||||
@@ -255,17 +266,45 @@ function App(): JSX.Element {
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<FileDetailPanel
|
<div className="detail-tabs">
|
||||||
file={selectedFile}
|
<button
|
||||||
categories={library.categories}
|
type="button"
|
||||||
onRename={handleRename}
|
className={activeTab === 'listen' ? 'detail-tab detail-tab--active' : 'detail-tab'}
|
||||||
onMove={handleMove}
|
onClick={() => setActiveTab('listen')}
|
||||||
onOrganize={handleOrganize}
|
>
|
||||||
onUpdateTags={handleTagUpdate}
|
Listen
|
||||||
onUpdateCustomName={handleUpdateCustomName}
|
</button>
|
||||||
metadataSuggestionsVersion={library.metadataSuggestionsVersion}
|
<button
|
||||||
/>
|
type="button"
|
||||||
<AudioPlayer snapshot={player} />
|
className={activeTab === 'edit' ? 'detail-tab detail-tab--active' : 'detail-tab'}
|
||||||
|
onClick={() => setActiveTab('edit')}
|
||||||
|
disabled={!selectedFile}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{activeTab === 'listen' ? (
|
||||||
|
<>
|
||||||
|
<FileDetailPanel
|
||||||
|
file={selectedFile}
|
||||||
|
categories={library.categories}
|
||||||
|
onRename={handleRename}
|
||||||
|
onMove={handleMove}
|
||||||
|
onOrganize={handleOrganize}
|
||||||
|
onUpdateTags={handleTagUpdate}
|
||||||
|
onUpdateCustomName={handleUpdateCustomName}
|
||||||
|
metadataSuggestionsVersion={library.metadataSuggestionsVersion}
|
||||||
|
/>
|
||||||
|
<AudioPlayer snapshot={player} />
|
||||||
|
</>
|
||||||
|
) : selectedFile ? (
|
||||||
|
<EditModePanel
|
||||||
|
file={selectedFile}
|
||||||
|
categories={library.categories}
|
||||||
|
onClose={handleEditModeClose}
|
||||||
|
onSplitComplete={handleEditModeSplitComplete}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ export interface FileDetailPanelProps {
|
|||||||
categories: CategoryRecord[];
|
categories: CategoryRecord[];
|
||||||
onRename(newName: string): Promise<void>;
|
onRename(newName: string): Promise<void>;
|
||||||
onMove(targetRelativeDirectory: string): Promise<void>;
|
onMove(targetRelativeDirectory: string): Promise<void>;
|
||||||
/** Organizes a file with optional metadata fields (customName, author, rating 1-5). */
|
/** Organizes a file with optional metadata fields (customName, author, copyright, rating 1-5). */
|
||||||
onOrganize(metadata: { customName?: string | null; author?: string | null; rating?: number }): Promise<void>;
|
onOrganize(metadata: { customName?: string; author?: string; copyright?: string; rating?: number }): Promise<void>;
|
||||||
onUpdateTags(data: { tags: string[]; categories: string[] }): Promise<void>;
|
onUpdateTags(data: { tags: string[]; categories: string[] }): Promise<void>;
|
||||||
onUpdateCustomName(customName: string | null): Promise<void>;
|
onUpdateCustomName(customName: string | null): Promise<void>;
|
||||||
metadataSuggestionsVersion: number;
|
metadataSuggestionsVersion: number;
|
||||||
@@ -29,8 +29,9 @@ export function FileDetailPanel({ file, categories, onRename, onMove, onOrganize
|
|||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [isTagSectionExpanded, setIsTagSectionExpanded] = useState(true);
|
const [isTagSectionExpanded, setIsTagSectionExpanded] = useState(true);
|
||||||
const [suggestions, setSuggestions] = useState<{ authors: string[] }>({ authors: [] });
|
const [suggestions, setSuggestions] = useState<{ authors: string[] }>({ authors: [] });
|
||||||
const initialMetadataRef = useRef<{ author: string; rating: number; customName: string }>({
|
const initialMetadataRef = useRef<{ author: string; copyright: string; rating: number; customName: string }>({
|
||||||
author: '',
|
author: '',
|
||||||
|
copyright: '',
|
||||||
rating: 0,
|
rating: 0,
|
||||||
customName: ''
|
customName: ''
|
||||||
});
|
});
|
||||||
@@ -93,7 +94,7 @@ export function FileDetailPanel({ file, categories, onRename, onMove, onOrganize
|
|||||||
setAuthorDraft('');
|
setAuthorDraft('');
|
||||||
setRatingDraft(0);
|
setRatingDraft(0);
|
||||||
setIsEditingCustomName(false);
|
setIsEditingCustomName(false);
|
||||||
initialMetadataRef.current = { author: '', rating: 0, customName: '' };
|
initialMetadataRef.current = { author: '', copyright: '', rating: 0, customName: '' };
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,7 +102,7 @@ export function FileDetailPanel({ file, categories, onRename, onMove, onOrganize
|
|||||||
setCustomNameDraft(file.customName ?? '');
|
setCustomNameDraft(file.customName ?? '');
|
||||||
setIsEditingCustomName(false);
|
setIsEditingCustomName(false);
|
||||||
const baseCustomName = (file.customName ?? '').trim();
|
const baseCustomName = (file.customName ?? '').trim();
|
||||||
initialMetadataRef.current = { author: '', rating: 0, customName: baseCustomName };
|
initialMetadataRef.current = { author: '', copyright: '', rating: 0, customName: baseCustomName };
|
||||||
|
|
||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
@@ -117,6 +118,7 @@ export function FileDetailPanel({ file, categories, onRename, onMove, onOrganize
|
|||||||
setRatingDraft(ratingValue);
|
setRatingDraft(ratingValue);
|
||||||
initialMetadataRef.current = {
|
initialMetadataRef.current = {
|
||||||
author: authorValue,
|
author: authorValue,
|
||||||
|
copyright: '',
|
||||||
rating: ratingValue,
|
rating: ratingValue,
|
||||||
customName: customNameValue
|
customName: customNameValue
|
||||||
};
|
};
|
||||||
@@ -127,7 +129,7 @@ export function FileDetailPanel({ file, categories, onRename, onMove, onOrganize
|
|||||||
console.error('Failed to read file metadata:', error);
|
console.error('Failed to read file metadata:', error);
|
||||||
setAuthorDraft('');
|
setAuthorDraft('');
|
||||||
setRatingDraft(0);
|
setRatingDraft(0);
|
||||||
initialMetadataRef.current = { author: '', rating: 0, customName: baseCustomName };
|
initialMetadataRef.current = { author: '', copyright: '', rating: 0, customName: baseCustomName };
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
}, [appendSuggestion, file?.id]);
|
}, [appendSuggestion, file?.id]);
|
||||||
@@ -190,7 +192,7 @@ export function FileDetailPanel({ file, categories, onRename, onMove, onOrganize
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const triggerOrganize = async (overrides?: { author?: string | null; rating?: number; customName?: string | null }) => {
|
const triggerOrganize = async (overrides?: { author?: string; rating?: number; customName?: string | null }) => {
|
||||||
if (!file) {
|
if (!file) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -200,10 +202,11 @@ export function FileDetailPanel({ file, categories, onRename, onMove, onOrganize
|
|||||||
const nextCustomNameRaw =
|
const nextCustomNameRaw =
|
||||||
overrides?.customName !== undefined ? overrides.customName ?? '' : customNameDraft;
|
overrides?.customName !== undefined ? overrides.customName ?? '' : customNameDraft;
|
||||||
|
|
||||||
const trimmedAuthor = (nextAuthorRaw ?? '').trim();
|
const trimmedAuthor = nextAuthorRaw.trim();
|
||||||
const trimmedCustomName = (nextCustomNameRaw ?? '').toString().trim();
|
const trimmedCustomName = (nextCustomNameRaw ?? '').toString().trim();
|
||||||
const comparisonState = {
|
const comparisonState = {
|
||||||
author: trimmedAuthor,
|
author: trimmedAuthor,
|
||||||
|
copyright: '',
|
||||||
rating: nextRatingValue,
|
rating: nextRatingValue,
|
||||||
customName: trimmedCustomName
|
customName: trimmedCustomName
|
||||||
};
|
};
|
||||||
@@ -212,6 +215,7 @@ export function FileDetailPanel({ file, categories, onRename, onMove, onOrganize
|
|||||||
if (
|
if (
|
||||||
previous &&
|
previous &&
|
||||||
previous.author === comparisonState.author &&
|
previous.author === comparisonState.author &&
|
||||||
|
previous.copyright === comparisonState.copyright &&
|
||||||
previous.rating === comparisonState.rating &&
|
previous.rating === comparisonState.rating &&
|
||||||
previous.customName === comparisonState.customName
|
previous.customName === comparisonState.customName
|
||||||
) {
|
) {
|
||||||
@@ -220,18 +224,17 @@ export function FileDetailPanel({ file, categories, onRename, onMove, onOrganize
|
|||||||
|
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
const authorPayload = trimmedAuthor.length > 0 ? trimmedAuthor : null;
|
|
||||||
if (file.categories.length === 0) {
|
if (file.categories.length === 0) {
|
||||||
// No categories - just save metadata without organizing
|
// No categories - just save metadata without organizing
|
||||||
await window.api.updateFileMetadata(file.id, {
|
await window.api.updateFileMetadata(file.id, {
|
||||||
author: authorPayload,
|
author: trimmedAuthor.length > 0 ? trimmedAuthor : undefined,
|
||||||
rating: comparisonState.rating > 0 ? comparisonState.rating : undefined
|
rating: comparisonState.rating > 0 ? comparisonState.rating : undefined
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// Has categories - organize the file
|
// Has categories - organize the file
|
||||||
await onOrganize({
|
await onOrganize({
|
||||||
customName: trimmedCustomName.length > 0 ? trimmedCustomName : null,
|
customName: trimmedCustomName.length > 0 ? trimmedCustomName : undefined,
|
||||||
author: authorPayload,
|
author: trimmedAuthor.length > 0 ? trimmedAuthor : undefined,
|
||||||
rating: comparisonState.rating > 0 ? comparisonState.rating : undefined
|
rating: comparisonState.rating > 0 ? comparisonState.rating : undefined
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,26 +9,21 @@ export interface FileListProps {
|
|||||||
onPlay?(file: AudioFileSummary): void;
|
onPlay?(file: AudioFileSummary): void;
|
||||||
searchValue: string;
|
searchValue: string;
|
||||||
onSearchChange(value: string): void;
|
onSearchChange(value: string): void;
|
||||||
/**
|
|
||||||
* Invoked when the user requests to select every visible file (Ctrl+A).
|
|
||||||
*/
|
|
||||||
onSelectAll?(): void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Vertical list of WAV files with highlighting for the active selection.
|
* Vertical list of WAV files with highlighting for the active selection.
|
||||||
*/
|
*/
|
||||||
export function FileList({ files, selectedId, selectedIds, onSelect, onPlay, searchValue, onSearchChange, onSelectAll }: FileListProps): JSX.Element {
|
export function FileList({ files, selectedId, selectedIds, onSelect, onPlay, searchValue, onSearchChange }: FileListProps): JSX.Element {
|
||||||
const buttonRefs = useRef(new Map<number, HTMLButtonElement>());
|
const buttonRefs = useRef(new Map<number, HTMLButtonElement>());
|
||||||
const waveformCacheRef = useRef<Record<number, WaveformVisual>>({});
|
const waveformCacheRef = useRef<Record<number, WaveformVisual>>({});
|
||||||
const [, forceWaveformUpdate] = useReducer((value: number) => value + 1, 0);
|
const [, forceWaveformUpdate] = useReducer((value: number) => value + 1, 0);
|
||||||
const dragGhostRef = useRef<HTMLElement | null>(null);
|
const dragGhostRef = useRef<HTMLElement | null>(null);
|
||||||
const dragRotation = useRef(0);
|
const dragRotation = useRef<number>(0);
|
||||||
const lastDragX = useRef(0);
|
const lastDragX = useRef<number>(0);
|
||||||
const dropTargetPosition = useRef<{ x: number; y: number } | null>(null);
|
const dropTargetPosition = useRef<{ x: number; y: number } | null>(null);
|
||||||
const dragOffset = useRef({ x: 0, y: 0 });
|
const dragOffset = useRef<{ x: number; y: number }>({ x: 0, y: 0 });
|
||||||
const dragStartTime = useRef(0);
|
const dragStartTime = useRef<number>(0);
|
||||||
const listContainerRef = useRef<HTMLDivElement | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedId === null) {
|
if (selectedId === null) {
|
||||||
@@ -44,6 +39,7 @@ export function FileList({ files, selectedId, selectedIds, onSelect, onPlay, sea
|
|||||||
if (isTypingContext || isEditable) {
|
if (isTypingContext || isEditable) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Keep keyboard focus aligned with the active selection.
|
||||||
button.focus();
|
button.focus();
|
||||||
}, [selectedId]);
|
}, [selectedId]);
|
||||||
|
|
||||||
@@ -92,43 +88,6 @@ export function FileList({ files, selectedId, selectedIds, onSelect, onPlay, sea
|
|||||||
};
|
};
|
||||||
}, [files]);
|
}, [files]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!onSelectAll) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
|
||||||
if (!(event.ctrlKey || event.metaKey)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (event.key !== 'a' && event.key !== 'A') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const activeElement = document.activeElement as HTMLElement | null;
|
|
||||||
if (!activeElement) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const isTypingContext = activeElement instanceof HTMLInputElement || activeElement instanceof HTMLTextAreaElement;
|
|
||||||
const isEditable = activeElement.getAttribute('contenteditable') === 'true';
|
|
||||||
if (isTypingContext || isEditable) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (listContainerRef.current && !listContainerRef.current.contains(activeElement)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
event.preventDefault();
|
|
||||||
onSelectAll();
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener('keydown', handleKeyDown);
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener('keydown', handleKeyDown);
|
|
||||||
};
|
|
||||||
}, [onSelectAll]);
|
|
||||||
|
|
||||||
const handleClick = (fileId: number, event: React.MouseEvent) => {
|
const handleClick = (fileId: number, event: React.MouseEvent) => {
|
||||||
onSelect(fileId, {
|
onSelect(fileId, {
|
||||||
multi: event.ctrlKey || event.metaKey,
|
multi: event.ctrlKey || event.metaKey,
|
||||||
@@ -166,7 +125,7 @@ export function FileList({ files, selectedId, selectedIds, onSelect, onPlay, sea
|
|||||||
placeholder="Search files..."
|
placeholder="Search files..."
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="file-list-items" ref={listContainerRef}>
|
<div className="file-list-items">
|
||||||
{files.map((file) => {
|
{files.map((file) => {
|
||||||
const isActive = file.id === selectedId;
|
const isActive = file.id === selectedId;
|
||||||
const isSelected = selectedIds.has(file.id);
|
const isSelected = selectedIds.has(file.id);
|
||||||
@@ -196,6 +155,8 @@ export function FileList({ files, selectedId, selectedIds, onSelect, onPlay, sea
|
|||||||
onClick={(event) => handleClick(file.id, event)}
|
onClick={(event) => handleClick(file.id, event)}
|
||||||
onDoubleClick={() => handleDoubleClick(file)}
|
onDoubleClick={() => handleDoubleClick(file)}
|
||||||
onDragStart={(event) => {
|
onDragStart={(event) => {
|
||||||
|
// If dragging an unselected item, only drag that item
|
||||||
|
// If dragging a selected item, drag all selected items
|
||||||
const draggedIds = selectedIds.has(file.id) ? Array.from(selectedIds) : [file.id];
|
const draggedIds = selectedIds.has(file.id) ? Array.from(selectedIds) : [file.id];
|
||||||
|
|
||||||
event.dataTransfer.setData('application/audiosort-file', JSON.stringify({
|
event.dataTransfer.setData('application/audiosort-file', JSON.stringify({
|
||||||
@@ -204,6 +165,7 @@ export function FileList({ files, selectedId, selectedIds, onSelect, onPlay, sea
|
|||||||
}));
|
}));
|
||||||
event.dataTransfer.effectAllowed = 'copy';
|
event.dataTransfer.effectAllowed = 'copy';
|
||||||
|
|
||||||
|
// Create transparent drag image to hide the default
|
||||||
const transparent = document.createElement('div');
|
const transparent = document.createElement('div');
|
||||||
transparent.style.width = '1px';
|
transparent.style.width = '1px';
|
||||||
transparent.style.height = '1px';
|
transparent.style.height = '1px';
|
||||||
@@ -212,6 +174,7 @@ export function FileList({ files, selectedId, selectedIds, onSelect, onPlay, sea
|
|||||||
event.dataTransfer.setDragImage(transparent, 0, 0);
|
event.dataTransfer.setDragImage(transparent, 0, 0);
|
||||||
setTimeout(() => transparent.remove(), 0);
|
setTimeout(() => transparent.remove(), 0);
|
||||||
|
|
||||||
|
// Calculate offset from click position to element center
|
||||||
const sourceElement = event.currentTarget as HTMLElement;
|
const sourceElement = event.currentTarget as HTMLElement;
|
||||||
const rect = sourceElement.getBoundingClientRect();
|
const rect = sourceElement.getBoundingClientRect();
|
||||||
const elementCenterX = rect.left + rect.width / 2;
|
const elementCenterX = rect.left + rect.width / 2;
|
||||||
@@ -221,18 +184,21 @@ export function FileList({ files, selectedId, selectedIds, onSelect, onPlay, sea
|
|||||||
y: elementCenterY - event.clientY
|
y: elementCenterY - event.clientY
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Calculate transition duration based on offset distance
|
||||||
const offsetDistance = Math.sqrt(
|
const offsetDistance = Math.sqrt(
|
||||||
dragOffset.current.x * dragOffset.current.x +
|
dragOffset.current.x * dragOffset.current.x +
|
||||||
dragOffset.current.y * dragOffset.current.y
|
dragOffset.current.y * dragOffset.current.y
|
||||||
);
|
);
|
||||||
const maxDistance = Math.sqrt(rect.width * rect.width + rect.height * rect.height) / 2;
|
const maxDistance = Math.sqrt(rect.width * rect.width + rect.height * rect.height) / 2;
|
||||||
const normalizedDistance = Math.min(offsetDistance / maxDistance, 1);
|
const normalizedDistance = Math.min(offsetDistance / maxDistance, 1);
|
||||||
const transitionDuration = 0.1 + normalizedDistance * 0.2;
|
const transitionDuration = 0.1 + normalizedDistance * 0.2; // 0.1s to 0.3s
|
||||||
|
|
||||||
dragStartTime.current = Date.now();
|
dragStartTime.current = Date.now();
|
||||||
|
|
||||||
|
// Clone the current element for custom floating preview
|
||||||
const ghost = sourceElement.cloneNode(true) as HTMLElement;
|
const ghost = sourceElement.cloneNode(true) as HTMLElement;
|
||||||
|
|
||||||
|
// Create a wrapper for rotation that doesn't affect position transition
|
||||||
const rotationWrapper = document.createElement('div');
|
const rotationWrapper = document.createElement('div');
|
||||||
rotationWrapper.style.position = 'fixed';
|
rotationWrapper.style.position = 'fixed';
|
||||||
rotationWrapper.style.top = `${event.clientY}px`;
|
rotationWrapper.style.top = `${event.clientY}px`;
|
||||||
@@ -256,6 +222,7 @@ export function FileList({ files, selectedId, selectedIds, onSelect, onPlay, sea
|
|||||||
rotationWrapper.appendChild(ghost);
|
rotationWrapper.appendChild(ghost);
|
||||||
document.body.appendChild(rotationWrapper);
|
document.body.appendChild(rotationWrapper);
|
||||||
|
|
||||||
|
// Trigger the offset transition to center
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
if (ghost.parentNode) {
|
if (ghost.parentNode) {
|
||||||
ghost.style.transform = `translate(-50%, -50%)`;
|
ghost.style.transform = `translate(-50%, -50%)`;
|
||||||
@@ -277,19 +244,25 @@ export function FileList({ files, selectedId, selectedIds, onSelect, onPlay, sea
|
|||||||
const clamped = Math.max(-12, Math.min(12, blended));
|
const clamped = Math.max(-12, Math.min(12, blended));
|
||||||
dragRotation.current = clamped;
|
dragRotation.current = clamped;
|
||||||
|
|
||||||
|
// Update wrapper position (no rotation here)
|
||||||
dragGhostRef.current.style.top = `${event.clientY}px`;
|
dragGhostRef.current.style.top = `${event.clientY}px`;
|
||||||
dragGhostRef.current.style.left = `${event.clientX}px`;
|
dragGhostRef.current.style.left = `${event.clientX}px`;
|
||||||
|
|
||||||
|
// Apply rotation directly to wrapper without transition
|
||||||
dragGhostRef.current.style.transform = `rotate(${clamped}deg)`;
|
dragGhostRef.current.style.transform = `rotate(${clamped}deg)`;
|
||||||
|
|
||||||
|
// Track position for potential drop animation
|
||||||
dropTargetPosition.current = { x: event.clientX, y: event.clientY };
|
dropTargetPosition.current = { x: event.clientX, y: event.clientY };
|
||||||
}}
|
}}
|
||||||
onDragEnd={(event) => {
|
onDragEnd={(event) => {
|
||||||
const wrapper = dragGhostRef.current;
|
const wrapper = dragGhostRef.current;
|
||||||
if (!wrapper) return;
|
if (!wrapper) return;
|
||||||
|
|
||||||
|
// Check if this was a successful drop (dropEffect will be 'copy' if accepted)
|
||||||
const wasDropped = event.dataTransfer.dropEffect === 'copy';
|
const wasDropped = event.dataTransfer.dropEffect === 'copy';
|
||||||
|
|
||||||
if (wasDropped && dropTargetPosition.current) {
|
if (wasDropped && dropTargetPosition.current) {
|
||||||
|
// Animate shrink into drop position
|
||||||
wrapper.style.transition = 'all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1)';
|
wrapper.style.transition = 'all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1)';
|
||||||
wrapper.style.transform = `rotate(0deg) scale(0)`;
|
wrapper.style.transform = `rotate(0deg) scale(0)`;
|
||||||
wrapper.style.opacity = '0';
|
wrapper.style.opacity = '0';
|
||||||
@@ -299,6 +272,7 @@ export function FileList({ files, selectedId, selectedIds, onSelect, onPlay, sea
|
|||||||
dragGhostRef.current = null;
|
dragGhostRef.current = null;
|
||||||
}, 300);
|
}, 300);
|
||||||
} else {
|
} else {
|
||||||
|
// No drop or cancelled - remove immediately
|
||||||
wrapper.remove();
|
wrapper.remove();
|
||||||
dragGhostRef.current = null;
|
dragGhostRef.current = null;
|
||||||
}
|
}
|
||||||
|
|||||||
407
src/renderer/src/components/edit/EditModePanel.tsx
Normal file
407
src/renderer/src/components/edit/EditModePanel.tsx
Normal file
@@ -0,0 +1,407 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import type { AudioFileSummary, CategoryRecord } from '../../../../shared/models';
|
||||||
|
import { TagEditor } from '../TagEditor';
|
||||||
|
import WaveformEditorCanvas from './WaveformEditorCanvas';
|
||||||
|
import { libraryStore } from '../../stores/LibraryStore';
|
||||||
|
import { toSplitRequest, type SegmentDraft, type SegmentMetadataDraft } from './types';
|
||||||
|
|
||||||
|
export interface EditModePanelProps {
|
||||||
|
/** File currently being edited. */
|
||||||
|
file: AudioFileSummary;
|
||||||
|
/** Available category catalog used by the tag editor. */
|
||||||
|
categories: CategoryRecord[];
|
||||||
|
/** Invoked after the panel should be closed without committing. */
|
||||||
|
onClose(): void;
|
||||||
|
/** Invoked after a successful split with the newly created files. */
|
||||||
|
onSplitComplete(created: AudioFileSummary[]): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MIN_SEGMENT_MS = 50;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full-screen overlay that enables waveform driven segment editing and metadata assignment before splitting.
|
||||||
|
*/
|
||||||
|
export function EditModePanel({ file, categories, onClose, onSplitComplete }: EditModePanelProps): JSX.Element {
|
||||||
|
const [waveformSamples, setWaveformSamples] = useState<number[] | null>(null);
|
||||||
|
const [waveformError, setWaveformError] = useState<string | null>(null);
|
||||||
|
const [durationMs, setDurationMs] = useState<number | null>(file.durationMs ?? null);
|
||||||
|
const [segments, setSegments] = useState<SegmentDraft[]>([]);
|
||||||
|
const [selectedSegmentId, setSelectedSegmentId] = useState<string | null>(null);
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||||
|
const [baseMetadata, setBaseMetadata] = useState<{ author: string | null; rating: number | null }>(
|
||||||
|
() => ({ author: null, rating: null })
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
setWaveformSamples(null);
|
||||||
|
setWaveformError(null);
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const preview = await window.api.getWaveformPreview(file.id, 8192);
|
||||||
|
if (!cancelled) {
|
||||||
|
setWaveformSamples(preview.samples);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load waveform preview', error);
|
||||||
|
if (!cancelled) {
|
||||||
|
setWaveformSamples([]);
|
||||||
|
setWaveformError('Failed to load waveform preview for this file.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [file.id]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
setSegments([]);
|
||||||
|
setSelectedSegmentId(null);
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const metadata = await window.api.readFileMetadata(file.id);
|
||||||
|
if (!cancelled) {
|
||||||
|
setBaseMetadata({
|
||||||
|
author: metadata.author?.trim() ?? null,
|
||||||
|
rating: metadata.rating ?? null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to read metadata for edit mode', error);
|
||||||
|
if (!cancelled) {
|
||||||
|
setBaseMetadata({ author: null, rating: null });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [file.id]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (durationMs !== null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const payload = await window.api.getAudioBuffer(file.id);
|
||||||
|
const AudioContextCtor = (window as typeof window & { webkitAudioContext?: typeof AudioContext }).AudioContext
|
||||||
|
?? (window as typeof window & { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
|
||||||
|
if (!AudioContextCtor) {
|
||||||
|
if (!cancelled) {
|
||||||
|
setDurationMs(file.durationMs ?? null);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const context = new AudioContextCtor();
|
||||||
|
try {
|
||||||
|
const buffer = await context.decodeAudioData(payload.buffer.slice(0));
|
||||||
|
if (!cancelled) {
|
||||||
|
setDurationMs(Math.round(buffer.duration * 1000));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await context.close();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to decode audio buffer for duration', error);
|
||||||
|
if (!cancelled) {
|
||||||
|
setDurationMs(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [file.id, durationMs]);
|
||||||
|
|
||||||
|
const selectedSegment = useMemo(
|
||||||
|
() => segments.find((segment) => segment.id === selectedSegmentId) ?? null,
|
||||||
|
[segments, selectedSegmentId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const isWaveformReady = waveformSamples !== null && durationMs !== null;
|
||||||
|
const effectiveDuration = durationMs ?? 0;
|
||||||
|
|
||||||
|
const handleCreateSegment = (rawStart: number, rawEnd: number) => {
|
||||||
|
if (!isWaveformReady) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { start, end } = normaliseRange(rawStart, rawEnd, effectiveDuration);
|
||||||
|
const newSegment: SegmentDraft = {
|
||||||
|
id: generateSegmentId(),
|
||||||
|
startMs: start,
|
||||||
|
endMs: end,
|
||||||
|
label: `Segment ${segments.length + 1}`,
|
||||||
|
metadata: buildDefaultMetadata(file, baseMetadata)
|
||||||
|
};
|
||||||
|
setSegments((current) => {
|
||||||
|
const next = [...current, newSegment].sort((a, b) => a.startMs - b.startMs);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
setSelectedSegmentId(newSegment.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResizeSegment = (segmentId: string, rawStart: number, rawEnd: number) => {
|
||||||
|
if (!isWaveformReady) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { start, end } = normaliseRange(rawStart, rawEnd, effectiveDuration);
|
||||||
|
setSegments((current) => {
|
||||||
|
const next = current.map((segment) =>
|
||||||
|
segment.id === segmentId
|
||||||
|
? { ...segment, startMs: start, endMs: end }
|
||||||
|
: segment
|
||||||
|
);
|
||||||
|
next.sort((a, b) => a.startMs - b.startMs);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectSegment = (segmentId: string | null) => {
|
||||||
|
setSelectedSegmentId(segmentId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpdateLabel = (segmentId: string, label: string) => {
|
||||||
|
setSegments((current) =>
|
||||||
|
current.map((segment) =>
|
||||||
|
segment.id === segmentId
|
||||||
|
? { ...segment, label }
|
||||||
|
: segment
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveSegment = (segmentId: string) => {
|
||||||
|
setSegments((current) => current.filter((segment) => segment.id !== segmentId));
|
||||||
|
if (selectedSegmentId === segmentId) {
|
||||||
|
setSelectedSegmentId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMetadataPatch = (segmentId: string, patch: Partial<SegmentMetadataDraft>) => {
|
||||||
|
setSegments((current) =>
|
||||||
|
current.map((segment) =>
|
||||||
|
segment.id === segmentId
|
||||||
|
? { ...segment, metadata: { ...segment.metadata, ...patch } }
|
||||||
|
: segment
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTagSave = (segmentId: string, data: { tags: string[]; categories: string[] }) => {
|
||||||
|
handleMetadataPatch(segmentId, {
|
||||||
|
tags: data.tags,
|
||||||
|
categories: data.categories
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCommit = async () => {
|
||||||
|
if (segments.length === 0 || !isWaveformReady) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSubmitError(null);
|
||||||
|
setIsSaving(true);
|
||||||
|
try {
|
||||||
|
const payload = segments.map((segment) => toSplitRequest(segment));
|
||||||
|
const created = await libraryStore.splitFile(file.id, payload);
|
||||||
|
onSplitComplete(created);
|
||||||
|
onClose();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Split operation failed', error);
|
||||||
|
setSubmitError(error instanceof Error ? error.message : String(error));
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="edit-mode-overlay" role="dialog" aria-modal="true">
|
||||||
|
<div className="edit-mode-panel">
|
||||||
|
<header className="edit-mode-header">
|
||||||
|
<div>
|
||||||
|
<h1>{file.customName || file.displayName}</h1>
|
||||||
|
<p>{file.relativePath}</p>
|
||||||
|
</div>
|
||||||
|
<div className="edit-mode-header-actions">
|
||||||
|
<button type="button" className="ghost-button" onClick={onClose}>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>
|
||||||
|
<aside className="edit-mode-sidebar">
|
||||||
|
<section className="segment-list">
|
||||||
|
<header className="segment-list-header">
|
||||||
|
<h2>Segments</h2>
|
||||||
|
<span>{segments.length}</span>
|
||||||
|
</header>
|
||||||
|
{segments.length === 0 ? (
|
||||||
|
<p className="segment-list-empty">Drag on the waveform to create segments.</p>
|
||||||
|
) : (
|
||||||
|
<ul>
|
||||||
|
{segments.map((segment) => (
|
||||||
|
<li key={segment.id} className={segment.id === selectedSegmentId ? 'segment-item segment-item--selected' : 'segment-item'}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="segment-item-select"
|
||||||
|
onClick={() => handleSelectSegment(segment.id)}
|
||||||
|
aria-pressed={segment.id === selectedSegmentId}
|
||||||
|
>
|
||||||
|
<span className="segment-item-time">{formatTimecode(segment.startMs)} – {formatTimecode(segment.endMs)}</span>
|
||||||
|
</button>
|
||||||
|
<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>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
<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-empty">Select a segment to edit its metadata.</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
<footer className="edit-mode-footer">
|
||||||
|
{submitError && <span className="edit-mode-error">{submitError}</span>}
|
||||||
|
<div className="edit-mode-footer-actions">
|
||||||
|
<button type="button" className="ghost-button" onClick={onClose} disabled={isSaving}>Cancel</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="primary-button"
|
||||||
|
onClick={handleCommit}
|
||||||
|
disabled={isSaving || segments.length === 0 || !isWaveformReady}
|
||||||
|
>
|
||||||
|
{isSaving ? 'Splitting…' : `Create ${segments.length} segment${segments.length === 1 ? '' : 's'}`}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normaliseRange(start: number, end: number, duration: number): { start: number; end: number } {
|
||||||
|
let safeStart = Math.max(0, Math.min(start, duration - MIN_SEGMENT_MS));
|
||||||
|
let safeEnd = Math.max(safeStart + MIN_SEGMENT_MS, Math.min(end, duration));
|
||||||
|
if (safeEnd > duration) {
|
||||||
|
safeEnd = duration;
|
||||||
|
safeStart = Math.max(0, safeEnd - MIN_SEGMENT_MS);
|
||||||
|
}
|
||||||
|
return { start: safeStart, end: safeEnd };
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDefaultMetadata(
|
||||||
|
file: AudioFileSummary,
|
||||||
|
baseMetadata: { author: string | null; rating: number | null }
|
||||||
|
): SegmentMetadataDraft {
|
||||||
|
return {
|
||||||
|
customName: file.customName ?? null,
|
||||||
|
author: baseMetadata.author,
|
||||||
|
rating: baseMetadata.rating,
|
||||||
|
tags: file.tags.slice(),
|
||||||
|
categories: file.categories.slice()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateSegmentId(): string {
|
||||||
|
if (typeof window !== 'undefined' && window.crypto?.randomUUID) {
|
||||||
|
return window.crypto.randomUUID();
|
||||||
|
}
|
||||||
|
return `segment-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normaliseText(value: string): string | null {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
return trimmed.length === 0 ? null : trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTimecode(ms: number): string {
|
||||||
|
if (!Number.isFinite(ms) || ms < 0) {
|
||||||
|
return '00:00.000';
|
||||||
|
}
|
||||||
|
const totalSeconds = Math.floor(ms / 1000);
|
||||||
|
const minutes = Math.floor(totalSeconds / 60);
|
||||||
|
const seconds = totalSeconds % 60;
|
||||||
|
const milliseconds = Math.floor(ms % 1000);
|
||||||
|
return `${minutes.toString().padStart(2, '0')}:${seconds
|
||||||
|
.toString()
|
||||||
|
.padStart(2, '0')}.${milliseconds.toString().padStart(3, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default EditModePanel;
|
||||||
392
src/renderer/src/components/edit/WaveformEditorCanvas.tsx
Normal file
392
src/renderer/src/components/edit/WaveformEditorCanvas.tsx
Normal file
@@ -0,0 +1,392 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import type { MouseEvent as ReactMouseEvent, PointerEvent as ReactPointerEvent, WheelEvent as ReactWheelEvent } from 'react';
|
||||||
|
import type { SegmentDraft } from './types';
|
||||||
|
|
||||||
|
export interface WaveformEditorCanvasProps {
|
||||||
|
/** Normalised waveform peaks in the range 0..1 across the whole file. */
|
||||||
|
samples: number[];
|
||||||
|
/** Total duration of the file in milliseconds. */
|
||||||
|
durationMs: number;
|
||||||
|
/** Current set of draft segments. */
|
||||||
|
segments: SegmentDraft[];
|
||||||
|
/** Currently focused segment identifier. */
|
||||||
|
selectedSegmentId: string | null;
|
||||||
|
/** Invoked when the focused segment should change. */
|
||||||
|
onSelectSegment(segmentId: string | null): void;
|
||||||
|
/** Invoked when a new segment is created via drag selection. */
|
||||||
|
onCreateSegment(startMs: number, endMs: number): void;
|
||||||
|
/** Invoked while resizing an existing segment. */
|
||||||
|
onResizeSegment(segmentId: string, nextStartMs: number, nextEndMs: number): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DraftSelection {
|
||||||
|
startMs: number;
|
||||||
|
endMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
type DragState =
|
||||||
|
| { type: 'none' }
|
||||||
|
| { type: 'creating'; anchorMs: number; pointerMs: number }
|
||||||
|
| { type: 'resizing'; segmentId: string; handle: 'start' | 'end' }
|
||||||
|
| { type: 'panning'; startViewportMs: number; pointerStartX: number };
|
||||||
|
|
||||||
|
const MIN_SEGMENT_MS = 50;
|
||||||
|
const MIN_VIEWPORT_MS = 200;
|
||||||
|
const MAX_VIEWPORT_MS = Number.POSITIVE_INFINITY;
|
||||||
|
const HANDLE_WIDTH_PX = 6;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interactive waveform canvas supporting zoom, pan, and region selection for splitting.
|
||||||
|
*/
|
||||||
|
export function WaveformEditorCanvas({
|
||||||
|
samples,
|
||||||
|
durationMs,
|
||||||
|
segments,
|
||||||
|
selectedSegmentId,
|
||||||
|
onSelectSegment,
|
||||||
|
onCreateSegment,
|
||||||
|
onResizeSegment
|
||||||
|
}: WaveformEditorCanvasProps): JSX.Element {
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||||
|
const dragStateRef = useRef<DragState>({ type: 'none' });
|
||||||
|
const viewportRef = useRef<{ startMs: number; durationMs: number }>({ startMs: 0, durationMs });
|
||||||
|
const [viewportVersion, setViewportVersion] = useState(0);
|
||||||
|
const [draftSelection, setDraftSelection] = useState<DraftSelection | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
viewportRef.current = {
|
||||||
|
startMs: 0,
|
||||||
|
durationMs
|
||||||
|
};
|
||||||
|
setViewportVersion((value) => value + 1);
|
||||||
|
}, [durationMs]);
|
||||||
|
|
||||||
|
const samplesPerMs = useMemo(() => {
|
||||||
|
if (durationMs <= 0 || samples.length === 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return samples.length / durationMs;
|
||||||
|
}, [samples.length, durationMs]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const context = canvas.getContext('2d');
|
||||||
|
if (!context) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dpr = window.devicePixelRatio || 1;
|
||||||
|
const width = canvas.clientWidth;
|
||||||
|
const height = canvas.clientHeight;
|
||||||
|
canvas.width = Math.max(1, Math.floor(width * dpr));
|
||||||
|
canvas.height = Math.max(1, Math.floor(height * dpr));
|
||||||
|
context.scale(dpr, dpr);
|
||||||
|
|
||||||
|
context.clearRect(0, 0, width, height);
|
||||||
|
context.fillStyle = '#0c0f13';
|
||||||
|
context.fillRect(0, 0, width, height);
|
||||||
|
|
||||||
|
const viewport = viewportRef.current;
|
||||||
|
const viewportStart = viewport.startMs;
|
||||||
|
const viewportDuration = Math.min(durationMs, viewport.durationMs);
|
||||||
|
const viewportEnd = Math.min(durationMs, viewportStart + viewportDuration);
|
||||||
|
const baselineY = height / 2;
|
||||||
|
|
||||||
|
const visibleSampleStart = Math.max(0, Math.floor(viewportStart * samplesPerMs));
|
||||||
|
const visibleSampleEnd = Math.min(samples.length, Math.ceil(viewportEnd * samplesPerMs));
|
||||||
|
const visibleSampleCount = Math.max(1, visibleSampleEnd - visibleSampleStart);
|
||||||
|
|
||||||
|
context.lineWidth = 1;
|
||||||
|
|
||||||
|
// Draw timeline grid lines.
|
||||||
|
const gridStepMs = pickGridSpacing(viewportDuration);
|
||||||
|
context.strokeStyle = 'rgba(255, 255, 255, 0.06)';
|
||||||
|
context.beginPath();
|
||||||
|
for (let grid = Math.ceil(viewportStart / gridStepMs) * gridStepMs; grid < viewportEnd; grid += gridStepMs) {
|
||||||
|
const ratio = (grid - viewportStart) / viewportDuration;
|
||||||
|
if (ratio < 0 || ratio > 1) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const x = ratio * width;
|
||||||
|
context.moveTo(x, 0);
|
||||||
|
context.lineTo(x, height);
|
||||||
|
}
|
||||||
|
context.stroke();
|
||||||
|
|
||||||
|
// Draw waveform peaks.
|
||||||
|
context.strokeStyle = '#2c8cff';
|
||||||
|
context.beginPath();
|
||||||
|
for (let index = visibleSampleStart; index < visibleSampleEnd; index += 1) {
|
||||||
|
const sample = samples[index] ?? 0;
|
||||||
|
const clamped = Math.max(0, Math.min(1, sample));
|
||||||
|
const ratio = (index - visibleSampleStart) / visibleSampleCount;
|
||||||
|
const x = ratio * width;
|
||||||
|
const amplitude = clamped * (height * 0.45);
|
||||||
|
context.moveTo(x, baselineY - amplitude);
|
||||||
|
context.lineTo(x, baselineY + amplitude);
|
||||||
|
}
|
||||||
|
context.stroke();
|
||||||
|
|
||||||
|
// Draw segments.
|
||||||
|
segments.forEach((segment) => {
|
||||||
|
const startRatio = (segment.startMs - viewportStart) / viewportDuration;
|
||||||
|
const endRatio = (segment.endMs - viewportStart) / viewportDuration;
|
||||||
|
const startX = Math.max(0, Math.min(width, startRatio * width));
|
||||||
|
const endX = Math.max(0, Math.min(width, endRatio * width));
|
||||||
|
if (endX <= startX) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const isSelected = segment.id === selectedSegmentId;
|
||||||
|
context.fillStyle = isSelected ? 'rgba(255, 204, 0, 0.25)' : 'rgba(255, 255, 255, 0.18)';
|
||||||
|
context.fillRect(startX, 0, endX - startX, height);
|
||||||
|
context.fillStyle = isSelected ? '#ffcc00' : 'rgba(255, 255, 255, 0.45)';
|
||||||
|
context.fillRect(startX - HANDLE_WIDTH_PX / 2, 0, HANDLE_WIDTH_PX, height);
|
||||||
|
context.fillRect(endX - HANDLE_WIDTH_PX / 2, 0, HANDLE_WIDTH_PX, height);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (draftSelection) {
|
||||||
|
const draftStartRatio = (draftSelection.startMs - viewportStart) / viewportDuration;
|
||||||
|
const draftEndRatio = (draftSelection.endMs - viewportStart) / viewportDuration;
|
||||||
|
const draftStartX = Math.max(0, Math.min(width, draftStartRatio * width));
|
||||||
|
const draftEndX = Math.max(0, Math.min(width, draftEndRatio * width));
|
||||||
|
const draftWidth = draftEndX - draftStartX;
|
||||||
|
if (draftWidth > 0) {
|
||||||
|
context.fillStyle = 'rgba(76, 201, 240, 0.22)';
|
||||||
|
context.fillRect(draftStartX, 0, draftWidth, height);
|
||||||
|
context.fillStyle = '#4cc9f0';
|
||||||
|
context.fillRect(draftStartX - HANDLE_WIDTH_PX / 2, 0, HANDLE_WIDTH_PX, height);
|
||||||
|
context.fillRect(draftEndX - HANDLE_WIDTH_PX / 2, 0, HANDLE_WIDTH_PX, height);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [samples, segments, selectedSegmentId, samplesPerMs, durationMs, viewportVersion, draftSelection]);
|
||||||
|
|
||||||
|
const resolveClientToMs = (clientX: number): number => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
const x = clientX - rect.left;
|
||||||
|
const ratio = Math.min(Math.max(x / rect.width, 0), 1);
|
||||||
|
const viewport = viewportRef.current;
|
||||||
|
return viewport.startMs + ratio * viewport.durationMs;
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolvePointerMs = (event: ReactPointerEvent<HTMLCanvasElement>): number => {
|
||||||
|
return resolveClientToMs(event.clientX);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateViewport = (nextStart: number, nextDuration: number) => {
|
||||||
|
const clampedDuration = clamp(nextDuration, MIN_VIEWPORT_MS, Math.min(MAX_VIEWPORT_MS, durationMs));
|
||||||
|
const maxStart = Math.max(0, durationMs - clampedDuration);
|
||||||
|
const clampedStart = clamp(nextStart, 0, Math.max(0, maxStart));
|
||||||
|
viewportRef.current = {
|
||||||
|
startMs: clampedStart,
|
||||||
|
durationMs: clampedDuration
|
||||||
|
};
|
||||||
|
setViewportVersion((value) => value + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePointerDown = (event: ReactPointerEvent<HTMLCanvasElement>) => {
|
||||||
|
if (event.button === 2) {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (canvas) {
|
||||||
|
canvas.setPointerCapture(event.pointerId);
|
||||||
|
}
|
||||||
|
dragStateRef.current = {
|
||||||
|
type: 'panning',
|
||||||
|
startViewportMs: viewportRef.current.startMs,
|
||||||
|
pointerStartX: event.clientX
|
||||||
|
};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.button !== 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pointerMs = resolvePointerMs(event);
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (canvas) {
|
||||||
|
canvas.setPointerCapture(event.pointerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const viewport = viewportRef.current;
|
||||||
|
const toleranceMs = viewport.durationMs * (HANDLE_WIDTH_PX / Math.max(canvas?.clientWidth ?? 1, 1));
|
||||||
|
const hit = findSegmentHandle(pointerMs, segments, toleranceMs);
|
||||||
|
|
||||||
|
if (hit) {
|
||||||
|
onSelectSegment(hit.segmentId);
|
||||||
|
dragStateRef.current = { type: 'resizing', segmentId: hit.segmentId, handle: hit.handle };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const containing = segments.find((segment) => pointerMs >= segment.startMs && pointerMs <= segment.endMs);
|
||||||
|
if (containing) {
|
||||||
|
onSelectSegment(containing.id);
|
||||||
|
dragStateRef.current = { type: 'none' };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
dragStateRef.current = { type: 'creating', anchorMs: pointerMs, pointerMs };
|
||||||
|
setDraftSelection({ startMs: pointerMs, endMs: pointerMs });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePointerMove = (event: ReactPointerEvent<HTMLCanvasElement>) => {
|
||||||
|
const state = dragStateRef.current;
|
||||||
|
if (state.type === 'none') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (state.type === 'panning') {
|
||||||
|
const viewport = viewportRef.current;
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const deltaX = event.clientX - state.pointerStartX;
|
||||||
|
const ratio = deltaX / Math.max(canvas.clientWidth, 1);
|
||||||
|
const deltaMs = -ratio * viewport.durationMs;
|
||||||
|
updateViewport(state.startViewportMs + deltaMs, viewport.durationMs);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pointerMs = resolvePointerMs(event);
|
||||||
|
if (state.type === 'creating') {
|
||||||
|
dragStateRef.current = { ...state, pointerMs };
|
||||||
|
const start = Math.min(state.anchorMs, pointerMs);
|
||||||
|
const end = Math.max(state.anchorMs, pointerMs);
|
||||||
|
setDraftSelection({ startMs: start, endMs: end });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.type === 'resizing') {
|
||||||
|
const segment = segments.find((entry) => entry.id === state.segmentId);
|
||||||
|
if (!segment) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const minEnd = segment.startMs + MIN_SEGMENT_MS;
|
||||||
|
const minStart = segment.endMs - MIN_SEGMENT_MS;
|
||||||
|
if (state.handle === 'start') {
|
||||||
|
const nextStart = clamp(pointerMs, 0, Math.min(minStart, segment.endMs - 1));
|
||||||
|
onResizeSegment(segment.id, nextStart, segment.endMs);
|
||||||
|
} else {
|
||||||
|
const nextEnd = clamp(pointerMs, Math.max(minEnd, segment.startMs + MIN_SEGMENT_MS), durationMs);
|
||||||
|
onResizeSegment(segment.id, segment.startMs, nextEnd);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePointerUp = (event: ReactPointerEvent<HTMLCanvasElement>) => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (canvas && canvas.hasPointerCapture(event.pointerId)) {
|
||||||
|
canvas.releasePointerCapture(event.pointerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = dragStateRef.current;
|
||||||
|
dragStateRef.current = { type: 'none' };
|
||||||
|
|
||||||
|
if (state.type === 'creating') {
|
||||||
|
const pointerMs = resolvePointerMs(event);
|
||||||
|
const start = Math.min(state.anchorMs, pointerMs);
|
||||||
|
const end = Math.max(state.anchorMs, pointerMs);
|
||||||
|
setDraftSelection(null);
|
||||||
|
if (end - start >= MIN_SEGMENT_MS) {
|
||||||
|
onCreateSegment(start, end);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.type === 'panning') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.type === 'resizing') {
|
||||||
|
// Final adjustment already applied during move.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleWheel = (event: ReactWheelEvent<HTMLCanvasElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (durationMs <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const viewport = viewportRef.current;
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
const ratio = Math.min(Math.max((event.clientX - rect.left) / rect.width, 0), 1);
|
||||||
|
const anchor = viewport.startMs + ratio * viewport.durationMs;
|
||||||
|
const zoomFactor = event.deltaY > 0 ? 1.1 : 0.9;
|
||||||
|
const nextDuration = clamp(viewport.durationMs * zoomFactor, MIN_VIEWPORT_MS, durationMs);
|
||||||
|
const nextStart = clamp(anchor - ratio * nextDuration, 0, Math.max(0, durationMs - nextDuration));
|
||||||
|
updateViewport(nextStart, nextDuration);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDoubleClick = (event: ReactMouseEvent<HTMLCanvasElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const pointerMs = resolveClientToMs(event.clientX);
|
||||||
|
const viewport = viewportRef.current;
|
||||||
|
const windowSpan = viewport.durationMs * 0.25;
|
||||||
|
const nextDuration = clamp(windowSpan, MIN_VIEWPORT_MS, durationMs);
|
||||||
|
const nextStart = clamp(pointerMs - nextDuration / 2, 0, Math.max(0, durationMs - nextDuration));
|
||||||
|
updateViewport(nextStart, nextDuration);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleContextMenu = (event: ReactMouseEvent<HTMLCanvasElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
className="edit-waveform-canvas"
|
||||||
|
onPointerDown={handlePointerDown}
|
||||||
|
onPointerMove={handlePointerMove}
|
||||||
|
onPointerUp={handlePointerUp}
|
||||||
|
onWheel={handleWheel}
|
||||||
|
onDoubleClick={handleDoubleClick}
|
||||||
|
onContextMenu={handleContextMenu}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp(value: number, min: number, max: number): number {
|
||||||
|
if (!Number.isFinite(value)) {
|
||||||
|
return min;
|
||||||
|
}
|
||||||
|
return Math.min(Math.max(value, min), max);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickGridSpacing(windowMs: number): number {
|
||||||
|
const candidates = [10, 20, 50, 100, 250, 500, 1000, 2000, 5000, 10000, 20000, 60000];
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if (windowMs / candidate <= 12) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return candidates[candidates.length - 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
function findSegmentHandle(
|
||||||
|
pointerMs: number,
|
||||||
|
segments: SegmentDraft[],
|
||||||
|
toleranceMs: number
|
||||||
|
): { segmentId: string; handle: 'start' | 'end' } | null {
|
||||||
|
for (const segment of segments) {
|
||||||
|
if (Math.abs(pointerMs - segment.startMs) <= toleranceMs) {
|
||||||
|
return { segmentId: segment.id, handle: 'start' };
|
||||||
|
}
|
||||||
|
if (Math.abs(pointerMs - segment.endMs) <= toleranceMs) {
|
||||||
|
return { segmentId: segment.id, handle: 'end' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default WaveformEditorCanvas;
|
||||||
51
src/renderer/src/components/edit/types.ts
Normal file
51
src/renderer/src/components/edit/types.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import type { SplitSegmentRequest } from '../../../../shared/models';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Editable metadata captured for each draft segment.
|
||||||
|
*/
|
||||||
|
export interface SegmentMetadataDraft {
|
||||||
|
/** Custom name override applied to the generated segment (null clears). */
|
||||||
|
customName: string | null;
|
||||||
|
/** Author/artist override applied to the generated segment. */
|
||||||
|
author: string | null;
|
||||||
|
/** Rating override (1-5) applied to the generated segment, null preserves none. */
|
||||||
|
rating: number | null;
|
||||||
|
/** Tag collection applied to the generated segment. */
|
||||||
|
tags: string[];
|
||||||
|
/** UCS categories applied to the generated segment. */
|
||||||
|
categories: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Draft segment structure maintained inside the edit mode panel before saving.
|
||||||
|
*/
|
||||||
|
export interface SegmentDraft {
|
||||||
|
/** Stable identifier used for React rendering and user interactions. */
|
||||||
|
id: string;
|
||||||
|
/** Inclusive start offset in milliseconds. */
|
||||||
|
startMs: number;
|
||||||
|
/** Exclusive end offset in milliseconds. */
|
||||||
|
endMs: number;
|
||||||
|
/** User facing label displayed in the segment list. */
|
||||||
|
label: string;
|
||||||
|
/** Captured metadata overrides. */
|
||||||
|
metadata: SegmentMetadataDraft;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts a draft segment into the IPC request payload.
|
||||||
|
*/
|
||||||
|
export function toSplitRequest(segment: SegmentDraft): SplitSegmentRequest {
|
||||||
|
return {
|
||||||
|
startMs: Math.round(segment.startMs),
|
||||||
|
endMs: Math.round(segment.endMs),
|
||||||
|
label: segment.label.trim().length > 0 ? segment.label.trim() : undefined,
|
||||||
|
metadata: {
|
||||||
|
customName: segment.metadata.customName,
|
||||||
|
author: segment.metadata.author,
|
||||||
|
rating: segment.metadata.rating ?? undefined,
|
||||||
|
tags: segment.metadata.tags,
|
||||||
|
categories: segment.metadata.categories
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import type {
|
|||||||
AudioFileSummary,
|
AudioFileSummary,
|
||||||
CategoryRecord,
|
CategoryRecord,
|
||||||
LibraryScanSummary,
|
LibraryScanSummary,
|
||||||
|
SplitSegmentRequest,
|
||||||
TagUpdatePayload
|
TagUpdatePayload
|
||||||
} from '../../../shared/models';
|
} from '../../../shared/models';
|
||||||
|
|
||||||
@@ -361,6 +362,32 @@ export class LibraryStore extends EventTarget {
|
|||||||
this.emitChange();
|
this.emitChange();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Splits a file into multiple segments and refreshes the library snapshot.
|
||||||
|
*/
|
||||||
|
public async splitFile(fileId: number, segments: SplitSegmentRequest[]): Promise<AudioFileSummary[]> {
|
||||||
|
const created = await window.api.splitFile(fileId, segments);
|
||||||
|
const files = await window.api.listAudioFiles();
|
||||||
|
const { categoryFilter } = this.snapshot;
|
||||||
|
let visible: AudioFileSummary[];
|
||||||
|
if (categoryFilter === CATEGORY_FILTER_UNTAGGED) {
|
||||||
|
visible = files.filter((file) => file.categories.length === 0);
|
||||||
|
} else if (categoryFilter) {
|
||||||
|
visible = files.filter((file) => file.categories.includes(categoryFilter));
|
||||||
|
} else {
|
||||||
|
visible = files.slice();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.snapshot = {
|
||||||
|
...this.snapshot,
|
||||||
|
files,
|
||||||
|
visibleFiles: visible,
|
||||||
|
metadataSuggestionsVersion: this.snapshot.metadataSuggestionsVersion + 1
|
||||||
|
};
|
||||||
|
this.emitChange();
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Assigns a new library path and refreshes the cached settings + file list.
|
* Assigns a new library path and refreshes the cached settings + file list.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -8,16 +8,6 @@
|
|||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
background-color: #0c0f13;
|
background-color: #0c0f13;
|
||||||
user-select: none;
|
|
||||||
-webkit-user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
input,
|
|
||||||
textarea,
|
|
||||||
select,
|
|
||||||
[contenteditable='true'] {
|
|
||||||
user-select: text;
|
|
||||||
-webkit-user-select: text;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
@@ -318,6 +308,7 @@ select,
|
|||||||
|
|
||||||
.file-list-search .search-input {
|
.file-list-search .search-input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.file-list-items {
|
.file-list-items {
|
||||||
@@ -429,6 +420,55 @@ select,
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.detail-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.25rem;
|
||||||
|
padding: 0.5rem 0.5rem 0 0.5rem;
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-tab {
|
||||||
|
padding: 0.5rem 1.25rem;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-bottom: none;
|
||||||
|
border-radius: 0.5rem 0.5rem 0 0;
|
||||||
|
background: transparent;
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-tab:hover:not(:disabled) {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
color: rgba(255, 255, 255, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-tab:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-tab--active {
|
||||||
|
background: rgba(39, 110, 241, 0.15);
|
||||||
|
border-color: rgba(39, 110, 241, 0.3);
|
||||||
|
color: #eef3f8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-tab--active::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
bottom: -1px;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 2px;
|
||||||
|
background: #276ef1;
|
||||||
|
}
|
||||||
|
|
||||||
.file-detail {
|
.file-detail {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ export interface RendererApi {
|
|||||||
/** Moves a file to another subdirectory under the library root. */
|
/** Moves a file to another subdirectory under the library root. */
|
||||||
moveFile(fileId: number, targetRelativeDirectory: string): Promise<import('./models').AudioFileSummary>;
|
moveFile(fileId: number, targetRelativeDirectory: string): Promise<import('./models').AudioFileSummary>;
|
||||||
/** Automatically organizes a file based on its categories. */
|
/** Automatically organizes a file based on its categories. */
|
||||||
organizeFile(fileId: number, metadata: { customName?: string | null; author?: string | null; rating?: number }): Promise<import('./models').AudioFileSummary>;
|
organizeFile(fileId: number, metadata: { customName?: string; author?: string; copyright?: string; rating?: number }): Promise<import('./models').AudioFileSummary>;
|
||||||
/** Updates the custom name for a file. */
|
/** Updates the custom name for a file. */
|
||||||
updateCustomName(fileId: number, customName: string | null): Promise<import('./models').AudioFileSummary>;
|
updateCustomName(fileId: number, customName: string | null): Promise<import('./models').AudioFileSummary>;
|
||||||
/** Opens the file's containing folder in the system file explorer. */
|
/** Opens the file's containing folder in the system file explorer. */
|
||||||
@@ -63,11 +63,11 @@ export interface RendererApi {
|
|||||||
/** Runs fuzzy search returning ranked results. */
|
/** Runs fuzzy search returning ranked results. */
|
||||||
search(query: string): Promise<import('./models').AudioFileSummary[]>;
|
search(query: string): Promise<import('./models').AudioFileSummary[]>;
|
||||||
/** Reads embedded metadata from a WAV file. */
|
/** Reads embedded metadata from a WAV file. */
|
||||||
readFileMetadata(fileId: number): Promise<{ author?: string; title?: string; rating?: number }>;
|
readFileMetadata(fileId: number): Promise<{ author?: string; copyright?: string; title?: string; rating?: number }>;
|
||||||
/** Lists distinct metadata values gathered from the library for quick suggestions. */
|
/** Lists distinct metadata values gathered from the library for quick suggestions. */
|
||||||
listMetadataSuggestions(): Promise<{ authors: string[] }>;
|
listMetadataSuggestions(): Promise<{ authors: string[]; copyrights: string[] }>;
|
||||||
/** Updates metadata (author, rating) without organizing the file. */
|
/** Updates metadata (author, copyright, rating) without organizing the file. */
|
||||||
updateFileMetadata(fileId: number, metadata: { author?: string | null; rating?: number }): Promise<void>;
|
updateFileMetadata(fileId: number, metadata: { author?: string; copyright?: string; rating?: number }): Promise<void>;
|
||||||
/** Listens for menu actions from the main process. Returns a cleanup function. */
|
/** Listens for menu actions from the main process. Returns a cleanup function. */
|
||||||
onMenuAction(channel: string, callback: () => void): () => void;
|
onMenuAction(channel: string, callback: () => void): () => void;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -107,3 +107,33 @@ export interface AudioBufferPayload {
|
|||||||
/** MIME type for the audio data. */
|
/** MIME type for the audio data. */
|
||||||
mimeType: string;
|
mimeType: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional metadata overrides applied while creating split segments.
|
||||||
|
*/
|
||||||
|
export interface SegmentMetadataInput {
|
||||||
|
/** Overrides the custom name embedded into the segment (null clears it). */
|
||||||
|
customName?: string | null;
|
||||||
|
/** Overrides the author/creator metadata. */
|
||||||
|
author?: string | null;
|
||||||
|
/** Overrides the rating metadata (1-5). */
|
||||||
|
rating?: number;
|
||||||
|
/** Overrides the applied free-form tags. */
|
||||||
|
tags?: string[];
|
||||||
|
/** Overrides the applied UCS categories. */
|
||||||
|
categories?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Describes a region that should be extracted as a new audio segment.
|
||||||
|
*/
|
||||||
|
export interface SplitSegmentRequest {
|
||||||
|
/** Inclusive start offset in milliseconds. */
|
||||||
|
startMs: number;
|
||||||
|
/** Exclusive end offset in milliseconds. */
|
||||||
|
endMs: number;
|
||||||
|
/** Optional display label used for segment naming. */
|
||||||
|
label?: string;
|
||||||
|
/** Metadata overrides to apply to the generated file. */
|
||||||
|
metadata?: SegmentMetadataInput;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user