mirror of
https://github.com/jeffvli/feishin.git
synced 2026-07-21 18:06:30 +02:00
Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bd328657d8 | |||
| b0e66fcf45 | |||
| aa5309c54f | |||
| b11ad3e069 | |||
| 2777a33f9a | |||
| 9ff705a483 | |||
| 82d64489ce | |||
| 48ff8ed6ff | |||
| 38ad3e5d5a | |||
| 45c660dcc5 | |||
| 4b15bcb7d7 | |||
| 107727c65d | |||
| 030ecca898 | |||
| dd0b645ee4 | |||
| 5ed4397f8f | |||
| c594545658 | |||
| cde2b85148 | |||
| 8917a5d076 | |||
| 56f05ddafc | |||
| aa12585b20 | |||
| f3966e3211 | |||
| 651fe46519 | |||
| 7e82ab146e |
@@ -120,6 +120,7 @@
|
||||
"md5": "^2.3.0",
|
||||
"motion": "^12.40.0",
|
||||
"mpris-service": "^2.1.2",
|
||||
"music-metadata": "^11.12.3",
|
||||
"nanoid": "^3.3.12",
|
||||
"node-mpv": "github:jeffvli/Node-MPV#32b4d64395289ad710c41d481d2707a7acfc228f",
|
||||
"overlayscrollbars": "^2.16.0",
|
||||
@@ -139,6 +140,7 @@
|
||||
"react-window-v2": "npm:react-window@^2.2.7",
|
||||
"semver": "^7.8.2",
|
||||
"string-to-color": "^2.2.2",
|
||||
"taglib-wasm": "^1.5.0",
|
||||
"wavesurfer.js": "^7.12.7",
|
||||
"ws": "^8.21.0",
|
||||
"zod": "^3.25.76",
|
||||
|
||||
Generated
+755
-647
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,8 @@ allowBuilds:
|
||||
electron-winstaller: true
|
||||
esbuild: true
|
||||
minimumReleaseAge: 1440
|
||||
minimumReleaseAgeExclude:
|
||||
- 'taglib-wasm@1.3.0'
|
||||
overrides:
|
||||
'xml2js': '0.5.0'
|
||||
'react-router': '7.14.0'
|
||||
|
||||
@@ -161,6 +161,7 @@
|
||||
"trackGain": "Track gain",
|
||||
"trackPeak": "Track peak",
|
||||
"translation": "Translation",
|
||||
"undo": "Undo",
|
||||
"unknown": "Unknown",
|
||||
"version": "Version",
|
||||
"year": "Year",
|
||||
@@ -541,7 +542,8 @@
|
||||
"goTo": "Go to",
|
||||
"goToAlbum": "Go to $t(entity.album, {\"count\": 1})",
|
||||
"goToAlbumArtist": "Go to $t(entity.albumArtist, {\"count\": 1})",
|
||||
"showDetails": "Get info"
|
||||
"showDetails": "Get info",
|
||||
"editMetadata": "Edit Metadata"
|
||||
},
|
||||
"fullscreenPlayer": {
|
||||
"config": {
|
||||
@@ -602,9 +604,27 @@
|
||||
"title": "$t(common.home)"
|
||||
},
|
||||
"itemDetail": {
|
||||
"multiValueFields": "Multi-value fields",
|
||||
"multiValueFieldsDescription": "Fields in this list use a tag input and are saved as separate metadata values. Parsed multivalue fields use it automatically. Lyrics are always handled separately.",
|
||||
"addMultiValueField": "Add multivalue field…",
|
||||
"addFavoriteValue": "Add favorite value…",
|
||||
"addFavoriteValueOption": "Add \"{{value}}\" as a favorite…",
|
||||
"copyPath": "Copy path to clipboard",
|
||||
"copiedPath": "Path copied successfully",
|
||||
"openFile": "Show track in file manager"
|
||||
"openFile": "Show track in file manager",
|
||||
"fileNotWritable": "File is not accessible or not writable",
|
||||
"triggerRescan": "Trigger library rescan after saving",
|
||||
"addField": "Add field…",
|
||||
"emptyFields": "Fields cannot be empty",
|
||||
"tagsTab": "Tags",
|
||||
"artworkTab": "Artwork",
|
||||
"removeArtwork": "Remove Artwork",
|
||||
"noArtwork": "No Artwork",
|
||||
"multipleValues": "(Multiple Values)",
|
||||
"multipleArtworks": "Multiple Artworks",
|
||||
"noLocalSongs": "No songs with local file paths found",
|
||||
"readPartialFailure": "Could not read metadata from {{count}} of {{total}} file(s)",
|
||||
"writePartialFailure": "Failed to save {{count}} file(s)"
|
||||
},
|
||||
"playlist": {
|
||||
"reorder": "Reordering only enabled when sorting by ID"
|
||||
|
||||
@@ -4,4 +4,5 @@ import './player';
|
||||
import './remote';
|
||||
import './settings';
|
||||
import './discord-rpc';
|
||||
import './tag-editor';
|
||||
import './visualizer';
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import type {
|
||||
ArtworkOp,
|
||||
ReadSongMetadataBatchResult,
|
||||
TagValue,
|
||||
WriteSongTagsBatchResult,
|
||||
} from '/@/shared/types/tag-editor';
|
||||
|
||||
import { ipcMain, type IpcMainInvokeEvent } from 'electron';
|
||||
|
||||
import { readFilesMetadataBatch, readLocalImageFile, writeFilesTags } from './taglib-service';
|
||||
|
||||
const sendBatchProgress = (event: IpcMainInvokeEvent, processed: number, total: number) => {
|
||||
event.sender.send('batch-progress', { processed, total });
|
||||
};
|
||||
|
||||
let activeReadController: AbortController | null = null;
|
||||
|
||||
ipcMain.handle('cancel-read-song-metadata', () => {
|
||||
activeReadController?.abort();
|
||||
activeReadController = null;
|
||||
});
|
||||
|
||||
ipcMain.handle(
|
||||
'read-song-metadata-batch',
|
||||
async (event, filePaths: string[]): Promise<ReadSongMetadataBatchResult> => {
|
||||
activeReadController?.abort();
|
||||
activeReadController = new AbortController();
|
||||
const { signal } = activeReadController;
|
||||
|
||||
try {
|
||||
const result = await readFilesMetadataBatch(
|
||||
filePaths,
|
||||
(processed, total) => sendBatchProgress(event, processed, total),
|
||||
signal,
|
||||
);
|
||||
|
||||
if (signal.aborted) {
|
||||
return { artworkKind: 'none', success: false };
|
||||
}
|
||||
|
||||
if (!result.success) {
|
||||
return {
|
||||
artworkKind: 'none',
|
||||
error: result.failedFiles[0]?.error ?? 'No readable audio files in selection',
|
||||
failedFiles: result.failedFiles,
|
||||
readCount: result.readCount,
|
||||
success: false,
|
||||
totalCount: result.totalCount,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
artworkKind: result.artworkKind,
|
||||
failedFiles: result.failedFiles.length > 0 ? result.failedFiles : undefined,
|
||||
multiValueKeys: result.multiValueKeys,
|
||||
readCount: result.readCount,
|
||||
success: true,
|
||||
tagSummary: result.tagSummary,
|
||||
totalCount: result.totalCount,
|
||||
...(result.artworkData
|
||||
? {
|
||||
artworkData: result.artworkData,
|
||||
artworkMimeType: result.artworkMimeType,
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
} catch (err) {
|
||||
return { artworkKind: 'none', error: String(err), success: false };
|
||||
} finally {
|
||||
activeReadController = null;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
'write-song-tags-batch',
|
||||
async (
|
||||
event,
|
||||
filePaths: string[],
|
||||
edits: Record<string, TagValue>,
|
||||
removed: string[],
|
||||
artworkOp?: ArtworkOp,
|
||||
): Promise<WriteSongTagsBatchResult> => {
|
||||
try {
|
||||
const result = await writeFilesTags(
|
||||
filePaths,
|
||||
edits,
|
||||
removed,
|
||||
artworkOp,
|
||||
(processed, total) => sendBatchProgress(event, processed, total),
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
return {
|
||||
error: result.failedFiles[0]?.error,
|
||||
failedFiles: result.failedFiles,
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
return { error: String(err), success: false };
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
ipcMain.handle('read-local-image', async (_event, filePath: string) => {
|
||||
try {
|
||||
const { data, mimeType } = await readLocalImageFile(filePath);
|
||||
return { data, mimeType, success: true };
|
||||
} catch (err) {
|
||||
return { error: String(err), success: false };
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,298 @@
|
||||
import type { ArtworkKind, ArtworkOp, BatchFileError, TagValue } from '/@/shared/types/tag-editor';
|
||||
|
||||
import { constants, promises as fsPromises } from 'fs';
|
||||
import { PROPERTIES, TagLib } from 'taglib-wasm';
|
||||
|
||||
import { getImageMimeTypeFromPath } from '/@/shared/utils/image-mime';
|
||||
|
||||
let _taglib: null | TagLib = null;
|
||||
|
||||
const getTagLib = async (): Promise<TagLib> => {
|
||||
if (!_taglib) _taglib = await TagLib.initialize();
|
||||
return _taglib;
|
||||
};
|
||||
|
||||
const BATCH_CONCURRENCY = 8;
|
||||
|
||||
/** Returns an error entry for each path that is missing or not writable by the current process. */
|
||||
export async function checkPathsWritable(paths: string[]): Promise<BatchFileError[]> {
|
||||
const failed: BatchFileError[] = [];
|
||||
await Promise.all(
|
||||
paths.map(async (filePath) => {
|
||||
try {
|
||||
await fsPromises.access(filePath, constants.F_OK | constants.W_OK);
|
||||
} catch (err) {
|
||||
failed.push({
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
path: filePath,
|
||||
});
|
||||
}
|
||||
}),
|
||||
);
|
||||
return failed;
|
||||
}
|
||||
|
||||
/** Reads an image file and returns it as base64 + MIME type for IPC transport to the renderer. */
|
||||
export async function readLocalImageFile(filePath: string) {
|
||||
const buf = await fsPromises.readFile(filePath);
|
||||
return {
|
||||
data: buf.toString('base64'),
|
||||
mimeType: getImageMimeTypeFromPath(filePath),
|
||||
};
|
||||
}
|
||||
|
||||
/** Normalizes a taglib-wasm PropertyMap while preserving true multivalue arrays. */
|
||||
function normalizeProperties(
|
||||
props: Record<string, string[] | undefined>,
|
||||
): Record<string, TagValue> {
|
||||
const normalized: Record<string, TagValue> = {};
|
||||
for (const [key, values] of Object.entries(props)) {
|
||||
if (values && values.length > 0 && values[0] !== '') {
|
||||
normalized[key] = values.length === 1 ? values[0] : [...values];
|
||||
}
|
||||
}
|
||||
const coveredByUpperCase = new Set(
|
||||
Object.keys(normalized)
|
||||
.filter((k) => k !== k.toUpperCase())
|
||||
.map((k) => k.toUpperCase()),
|
||||
);
|
||||
for (const key of Object.keys(normalized)) {
|
||||
if (key === key.toUpperCase() && coveredByUpperCase.has(key)) {
|
||||
delete normalized[key];
|
||||
}
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
const tagValuesEqual = (a: TagValue, b: TagValue | undefined): boolean => {
|
||||
if (Array.isArray(a) || Array.isArray(b)) {
|
||||
return (
|
||||
Array.isArray(a) &&
|
||||
Array.isArray(b) &&
|
||||
a.length === b.length &&
|
||||
a.every((value, index) => value === b[index])
|
||||
);
|
||||
}
|
||||
return a === b;
|
||||
};
|
||||
|
||||
/** Runs `fn` over `items` with at most `concurrency` tasks in flight at once. Stops early if `signal` is aborted. */
|
||||
const mapWithConcurrency = async <T>(
|
||||
items: T[],
|
||||
concurrency: number,
|
||||
fn: (item: T) => Promise<void>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> => {
|
||||
let nextIndex = 0;
|
||||
const worker = async () => {
|
||||
while (nextIndex < items.length) {
|
||||
if (signal?.aborted) return;
|
||||
const index = nextIndex;
|
||||
nextIndex += 1;
|
||||
await fn(items[index]);
|
||||
}
|
||||
};
|
||||
await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, () => worker()));
|
||||
};
|
||||
|
||||
/**
|
||||
* Reads all tags and artwork from a batch of audio files via taglib-wasm.
|
||||
* Returns every tag present on disk — not limited to any known-field list.
|
||||
* Values that differ across files are merged to `null` and shown as "(Multiple Values)" in the editor.
|
||||
* Artwork bytes are captured from the first successful file; subsequent files only contribute their size for comparison.
|
||||
*/
|
||||
export async function readFilesMetadataBatch(
|
||||
filePaths: string[],
|
||||
onProgress?: (processed: number, total: number) => void,
|
||||
signal?: AbortSignal,
|
||||
): Promise<{
|
||||
artworkData?: string;
|
||||
artworkKind: ArtworkKind;
|
||||
artworkMimeType?: string;
|
||||
failedFiles: BatchFileError[];
|
||||
multiValueKeys: string[];
|
||||
readCount: number;
|
||||
success: boolean;
|
||||
tagSummary: Record<string, null | TagValue>;
|
||||
totalCount: number;
|
||||
}> {
|
||||
const totalCount = filePaths.length;
|
||||
const failedFiles: BatchFileError[] = [];
|
||||
const multiValueKeys = new Set<string>();
|
||||
const tagSummary: Record<string, null | TagValue> = {};
|
||||
let artworkKind = 'none' as ArtworkKind;
|
||||
let artworkByteSize: number | undefined;
|
||||
let artworkData: string | undefined;
|
||||
let artworkMimeType: string | undefined;
|
||||
let readCount = 0;
|
||||
let processed = 0;
|
||||
|
||||
const taglib = await getTagLib();
|
||||
|
||||
await mapWithConcurrency(
|
||||
filePaths,
|
||||
BATCH_CONCURRENCY,
|
||||
async (filePath) => {
|
||||
try {
|
||||
const file = await taglib.open(filePath);
|
||||
try {
|
||||
const rawProperties = file.properties();
|
||||
const embeddedLyrics = file.getLyrics();
|
||||
if (embeddedLyrics.length > 0) {
|
||||
rawProperties.lyrics = [
|
||||
embeddedLyrics.map(({ text }) => text).join('\n\n'),
|
||||
];
|
||||
}
|
||||
const normalized = normalizeProperties(rawProperties);
|
||||
for (const [key, value] of Object.entries(normalized)) {
|
||||
if (Array.isArray(value)) multiValueKeys.add(key);
|
||||
}
|
||||
const pictures = file.getPictures();
|
||||
const frontCover = pictures.find((p) => p.type === 'FrontCover') ?? pictures[0];
|
||||
const hasCoverArt = frontCover !== undefined;
|
||||
const picSize = hasCoverArt ? frontCover.data.length : undefined;
|
||||
|
||||
if (readCount === 0) {
|
||||
Object.assign(tagSummary, normalized);
|
||||
artworkKind = hasCoverArt ? 'common' : 'none';
|
||||
artworkByteSize = picSize;
|
||||
if (frontCover) {
|
||||
artworkData = Buffer.from(frontCover.data).toString('base64');
|
||||
artworkMimeType = frontCover.mimeType;
|
||||
}
|
||||
} else {
|
||||
for (const k of Object.keys(tagSummary)) {
|
||||
if (
|
||||
tagSummary[k] !== null &&
|
||||
!tagValuesEqual(tagSummary[k], normalized[k])
|
||||
)
|
||||
tagSummary[k] = null;
|
||||
}
|
||||
for (const k of Object.keys(normalized)) {
|
||||
if (!(k in tagSummary)) tagSummary[k] = null;
|
||||
}
|
||||
if (artworkKind !== 'mixed') {
|
||||
if (
|
||||
hasCoverArt !== (artworkKind === 'common') ||
|
||||
picSize !== artworkByteSize
|
||||
) {
|
||||
artworkKind = 'mixed';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
readCount += 1;
|
||||
} finally {
|
||||
file.dispose();
|
||||
}
|
||||
} catch (err) {
|
||||
failedFiles.push({
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
path: filePath,
|
||||
});
|
||||
}
|
||||
processed += 1;
|
||||
onProgress?.(processed, totalCount);
|
||||
},
|
||||
signal,
|
||||
);
|
||||
|
||||
return {
|
||||
artworkKind,
|
||||
failedFiles,
|
||||
multiValueKeys: [...multiValueKeys],
|
||||
readCount,
|
||||
success: readCount > 0,
|
||||
tagSummary,
|
||||
totalCount,
|
||||
...(artworkData ? { artworkData, artworkMimeType } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes tag edits and/or artwork to a batch of audio files in-place via taglib-wasm (WASI).
|
||||
* Only the fields present in `edits`/`removed` are touched — all other existing tags are preserved.
|
||||
* Array values are written as true multivalue properties; scalar values remain single-valued.
|
||||
* Lyrics use TagLib's dedicated unsynchronized-lyrics API.
|
||||
*/
|
||||
export async function writeFilesTags(
|
||||
filePaths: string[],
|
||||
edits: Record<string, TagValue>,
|
||||
removed: string[],
|
||||
artworkOp?: ArtworkOp,
|
||||
onProgress?: (processed: number, total: number) => void,
|
||||
): Promise<{ failedFiles: BatchFileError[]; success: boolean }> {
|
||||
const notWritable = await checkPathsWritable(filePaths);
|
||||
const notWritableSet = new Set(notWritable.map((f) => f.path));
|
||||
|
||||
const writablePaths = filePaths.filter((p) => !notWritableSet.has(p));
|
||||
const failedFiles: BatchFileError[] = [...notWritable];
|
||||
|
||||
if (writablePaths.length === 0) {
|
||||
return { failedFiles, success: false };
|
||||
}
|
||||
|
||||
const hasEdits = Object.keys(edits).length > 0 || removed.length > 0;
|
||||
|
||||
if (!hasEdits && !artworkOp) {
|
||||
return { failedFiles, success: failedFiles.length === 0 };
|
||||
}
|
||||
|
||||
const taglib = await getTagLib();
|
||||
let processed = 0;
|
||||
const total = writablePaths.length;
|
||||
|
||||
await mapWithConcurrency(writablePaths, BATCH_CONCURRENCY, async (path) => {
|
||||
try {
|
||||
await taglib.edit(path, (file) => {
|
||||
const propertyEdits = Object.entries(edits).filter(([key]) => key !== 'lyrics');
|
||||
const propertyRemovals = removed.filter((key) => key !== 'lyrics');
|
||||
|
||||
if (propertyEdits.length > 0 || propertyRemovals.length > 0) {
|
||||
const properties = file.properties();
|
||||
|
||||
for (const [key, value] of propertyEdits) {
|
||||
const propertyKey = key in PROPERTIES ? key : key.toUpperCase();
|
||||
properties[propertyKey] = Array.isArray(value) ? value : [value];
|
||||
}
|
||||
|
||||
for (const key of propertyRemovals) {
|
||||
const propertyKey = key in PROPERTIES ? key : key.toUpperCase();
|
||||
delete properties[propertyKey];
|
||||
}
|
||||
|
||||
file.setProperties(properties);
|
||||
}
|
||||
|
||||
if (removed.includes('lyrics')) {
|
||||
file.setLyrics([]);
|
||||
} else if (edits.lyrics !== undefined) {
|
||||
file.setLyrics([
|
||||
{
|
||||
text: Array.isArray(edits.lyrics)
|
||||
? edits.lyrics.join('\n\n')
|
||||
: edits.lyrics,
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
if (artworkOp?.type === 'clear') {
|
||||
file.removePictures();
|
||||
} else if (artworkOp?.type === 'set') {
|
||||
file.removePictures();
|
||||
file.addPicture({
|
||||
data: artworkOp.bytes,
|
||||
mimeType: artworkOp.mimeType,
|
||||
type: 'FrontCover',
|
||||
});
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
failedFiles.push({ error: String(err), path });
|
||||
}
|
||||
processed += 1;
|
||||
onProgress?.(processed, total);
|
||||
});
|
||||
|
||||
return { failedFiles, success: failedFiles.length === 0 };
|
||||
}
|
||||
@@ -24,6 +24,7 @@ import electronLocalShortcut from 'electron-localshortcut';
|
||||
import log from 'electron-log/main';
|
||||
import { AppImageUpdater, autoUpdater, MacUpdater, NsisUpdater } from 'electron-updater';
|
||||
import { access, constants } from 'fs';
|
||||
import { createRequire } from 'module';
|
||||
import path, { join } from 'path';
|
||||
import semver from 'semver';
|
||||
|
||||
@@ -39,6 +40,10 @@ import { autoUpdaterLogInterface, createLog, hotkeyToElectronAccelerator } from
|
||||
import { disableAutoUpdates, isLinux, isMacOS, isWindows } from '/@/main/env';
|
||||
import { PlayerRepeat, PlayerStatus, PlayerType, TitleTheme } from '/@/shared/types/types';
|
||||
|
||||
if (isWindows() && typeof (global as any).require !== 'function') {
|
||||
(global as any).require = createRequire(__filename);
|
||||
}
|
||||
|
||||
const ALPHA_UPDATER_CONFIG: {
|
||||
bucket: string;
|
||||
channel: string;
|
||||
|
||||
+45
-1
@@ -1,4 +1,13 @@
|
||||
import { ipcRenderer, webFrame } from 'electron';
|
||||
import { ipcRenderer, type IpcRendererEvent, webFrame } from 'electron';
|
||||
|
||||
import type {
|
||||
ArtworkOp,
|
||||
BatchProgress,
|
||||
ReadLocalImageResult,
|
||||
ReadSongMetadataBatchResult,
|
||||
TagValue,
|
||||
WriteSongTagsBatchResult,
|
||||
} from '../shared/types/tag-editor';
|
||||
|
||||
import { disableAutoUpdates, isLinux, isMacOS, isWindows } from '../main/env';
|
||||
|
||||
@@ -6,6 +15,35 @@ const openItem = async (path: string) => {
|
||||
return ipcRenderer.invoke('open-item', path);
|
||||
};
|
||||
|
||||
const cancelReadSongMetadata = (): void => {
|
||||
ipcRenderer.invoke('cancel-read-song-metadata');
|
||||
};
|
||||
|
||||
const readSongMetadataBatch = (filePaths: string[]): Promise<ReadSongMetadataBatchResult> => {
|
||||
return ipcRenderer.invoke('read-song-metadata-batch', filePaths);
|
||||
};
|
||||
|
||||
const writeSongTagsBatch = (
|
||||
filePaths: string[],
|
||||
edits: Record<string, TagValue>,
|
||||
removed: string[],
|
||||
artworkOp?: ArtworkOp,
|
||||
): Promise<WriteSongTagsBatchResult> => {
|
||||
return ipcRenderer.invoke('write-song-tags-batch', filePaths, edits, removed, artworkOp);
|
||||
};
|
||||
|
||||
const onBatchProgress = (cb: (event: IpcRendererEvent, data: BatchProgress) => void) => {
|
||||
ipcRenderer.on('batch-progress', cb);
|
||||
};
|
||||
|
||||
const offBatchProgress = (cb: (event: IpcRendererEvent, data: BatchProgress) => void) => {
|
||||
ipcRenderer.removeListener('batch-progress', cb);
|
||||
};
|
||||
|
||||
const readLocalImage = (filePath: string): Promise<ReadLocalImageResult> => {
|
||||
return ipcRenderer.invoke('read-local-image', filePath);
|
||||
};
|
||||
|
||||
const openApplicationDirectory = async () => {
|
||||
return ipcRenderer.invoke('open-application-directory');
|
||||
};
|
||||
@@ -117,6 +155,7 @@ const rendererUpdateAvailable = (cb: (version: string) => void) => {
|
||||
};
|
||||
|
||||
export const utils = {
|
||||
cancelReadSongMetadata,
|
||||
checkForUpdates,
|
||||
customCssUpdatedListener,
|
||||
disableAutoUpdates,
|
||||
@@ -127,10 +166,14 @@ export const utils = {
|
||||
isMacOS,
|
||||
isWindows,
|
||||
mainMessageListener,
|
||||
offBatchProgress,
|
||||
onBatchProgress,
|
||||
openApplicationDirectory,
|
||||
openCustomCssFolder,
|
||||
openItem,
|
||||
playerErrorListener,
|
||||
readLocalImage,
|
||||
readSongMetadataBatch,
|
||||
rendererOpenCommandPalette,
|
||||
rendererOpenManageServers,
|
||||
rendererOpenReleaseNotes,
|
||||
@@ -143,6 +186,7 @@ export const utils = {
|
||||
setInputFocused,
|
||||
startPowerSaveBlocker,
|
||||
stopPowerSaveBlocker,
|
||||
writeSongTagsBatch,
|
||||
};
|
||||
|
||||
export type Utils = typeof utils;
|
||||
|
||||
@@ -786,6 +786,18 @@ export const controller: GeneralController = {
|
||||
server.type,
|
||||
)?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } }));
|
||||
},
|
||||
refreshItems(args) {
|
||||
const server = getServerById(args.apiClientProps.serverId);
|
||||
|
||||
if (!server) {
|
||||
throw new Error(`${i18n.t('error.apiRouteError')}: refreshItems`);
|
||||
}
|
||||
|
||||
return apiController(
|
||||
'refreshItems',
|
||||
server.type,
|
||||
)?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } }));
|
||||
},
|
||||
removeFromPlaylist(args) {
|
||||
const server = getServerById(args.apiClientProps.serverId);
|
||||
|
||||
|
||||
@@ -301,6 +301,18 @@ export const contract = c.router({
|
||||
400: jfType._response.error,
|
||||
},
|
||||
},
|
||||
refreshItem: {
|
||||
body: z.null(),
|
||||
method: 'POST',
|
||||
path: 'Items/:id/Refresh',
|
||||
query: z.object({
|
||||
MetadataRefreshMode: z.string().optional(),
|
||||
}),
|
||||
responses: {
|
||||
204: z.null(),
|
||||
400: jfType._response.error,
|
||||
},
|
||||
},
|
||||
removeFavorite: {
|
||||
body: jfType._parameters.favorite,
|
||||
method: 'DELETE',
|
||||
|
||||
@@ -1556,6 +1556,21 @@ export const JellyfinController: InternalControllerEndpoint = {
|
||||
throw new Error('Failed to move item in playlist');
|
||||
}
|
||||
},
|
||||
refreshItems: async (args) => {
|
||||
const { apiClientProps, query } = args;
|
||||
|
||||
await Promise.all(
|
||||
query.ids.map((id) =>
|
||||
jfApiClient(apiClientProps).refreshItem({
|
||||
body: null,
|
||||
params: { id },
|
||||
query: { MetadataRefreshMode: 'FullRefresh' },
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
return null;
|
||||
},
|
||||
removeFromPlaylist: async (args) => {
|
||||
const { apiClientProps, query } = args;
|
||||
|
||||
|
||||
@@ -1027,6 +1027,7 @@ export const NavidromeController: InternalControllerEndpoint = {
|
||||
throw new Error('Failed to move item in playlist');
|
||||
}
|
||||
},
|
||||
refreshItems: SubsonicController.refreshItems,
|
||||
removeFromPlaylist: async (args) => {
|
||||
const { apiClientProps, query } = args;
|
||||
|
||||
|
||||
@@ -186,6 +186,14 @@ export const contract = c.router({
|
||||
200: ssType._response.randomSongList,
|
||||
},
|
||||
},
|
||||
getScanStatus: {
|
||||
method: 'GET',
|
||||
path: 'getScanStatus.view',
|
||||
query: ssType._parameters.getScanStatus,
|
||||
responses: {
|
||||
200: ssType._response.getScanStatus,
|
||||
},
|
||||
},
|
||||
getServerInfo: {
|
||||
method: 'GET',
|
||||
path: 'getOpenSubsonicExtensions.view',
|
||||
@@ -345,6 +353,14 @@ export const contract = c.router({
|
||||
200: ssType._response.setRating,
|
||||
},
|
||||
},
|
||||
startScan: {
|
||||
method: 'GET',
|
||||
path: 'startScan.view',
|
||||
query: ssType._parameters.startScan,
|
||||
responses: {
|
||||
200: ssType._response.startScan,
|
||||
},
|
||||
},
|
||||
updateInternetRadioStation: {
|
||||
method: 'GET',
|
||||
path: 'updateInternetRadioStation.view',
|
||||
|
||||
@@ -2082,6 +2082,17 @@ export const SubsonicController: InternalControllerEndpoint = {
|
||||
|
||||
return res.body;
|
||||
},
|
||||
refreshItems: async (args) => {
|
||||
const { apiClientProps } = args;
|
||||
|
||||
const res = await ssApiClient(apiClientProps).startScan({ query: {} });
|
||||
|
||||
if (res.status !== 200) {
|
||||
throw new Error('Failed to start scan');
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
removeFromPlaylist: async ({ apiClientProps, query }) => {
|
||||
const res = await ssApiClient(apiClientProps).updatePlaylist({
|
||||
query: {
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { openContextModal } from '@mantine/modals';
|
||||
import isElectron from 'is-electron';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { ContextMenu } from '/@/shared/components/context-menu/context-menu';
|
||||
import { Song } from '/@/shared/types/domain-types';
|
||||
|
||||
interface EditMetadataActionProps {
|
||||
albumIds?: string[];
|
||||
songs?: Song[];
|
||||
}
|
||||
|
||||
const utils = isElectron() ? window.api.utils : null;
|
||||
|
||||
export const EditMetadataAction = ({ albumIds, songs: songItems }: EditMetadataActionProps) => {
|
||||
const { t } = useTranslation();
|
||||
const songs = useMemo(() => songItems?.filter((s) => s.path) ?? [], [songItems]);
|
||||
const count = albumIds?.length ?? songs.length;
|
||||
|
||||
const onSelect = useCallback(() => {
|
||||
openContextModal({
|
||||
innerProps: { albumIds, songs },
|
||||
modal: 'editMetadata',
|
||||
size: 'xl',
|
||||
styles: { body: { paddingBottom: 'var(--theme-spacing-xl)' } },
|
||||
title: t('page.contextMenu.editMetadata'),
|
||||
});
|
||||
}, [albumIds, songs, t]);
|
||||
|
||||
if (!utils) return null;
|
||||
|
||||
return (
|
||||
<ContextMenu.Item disabled={count === 0} leftIcon="edit" onSelect={onSelect}>
|
||||
{t('page.contextMenu.editMetadata')}
|
||||
</ContextMenu.Item>
|
||||
);
|
||||
};
|
||||
@@ -2,6 +2,7 @@ import { useMemo } from 'react';
|
||||
|
||||
import { AddToPlaylistAction } from '/@/renderer/features/context-menu/actions/add-to-playlist-action';
|
||||
import { DownloadAction } from '/@/renderer/features/context-menu/actions/download-action';
|
||||
import { EditMetadataAction } from '/@/renderer/features/context-menu/actions/edit-metadata-action';
|
||||
import { GetInfoAction } from '/@/renderer/features/context-menu/actions/get-info-action';
|
||||
import { GoToAction } from '/@/renderer/features/context-menu/actions/go-to-action';
|
||||
import { PlayAction } from '/@/renderer/features/context-menu/actions/play-action';
|
||||
@@ -41,6 +42,7 @@ export const AlbumContextMenu = ({ items, type }: AlbumContextMenuProps) => {
|
||||
<ContextMenu.Divider />
|
||||
<GoToAction items={items} />
|
||||
<ContextMenu.Divider />
|
||||
<EditMetadataAction albumIds={ids} />
|
||||
<GetInfoAction disabled={items.length === 0} items={items} />
|
||||
</ContextMenu.Content>
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useMemo } from 'react';
|
||||
|
||||
import { AddToPlaylistAction } from '/@/renderer/features/context-menu/actions/add-to-playlist-action';
|
||||
import { DownloadAction } from '/@/renderer/features/context-menu/actions/download-action';
|
||||
import { EditMetadataAction } from '/@/renderer/features/context-menu/actions/edit-metadata-action';
|
||||
import { GetInfoAction } from '/@/renderer/features/context-menu/actions/get-info-action';
|
||||
import { GoToAction } from '/@/renderer/features/context-menu/actions/go-to-action';
|
||||
import { PlayAction } from '/@/renderer/features/context-menu/actions/play-action';
|
||||
@@ -46,6 +47,7 @@ export const PlaylistSongContextMenu = ({ items, type }: PlaylistSongContextMenu
|
||||
<GoToAction items={items} />
|
||||
<ShowInFileExplorerAction items={items} />
|
||||
<ContextMenu.Divider />
|
||||
<EditMetadataAction songs={items} />
|
||||
<GetInfoAction disabled={items.length === 0} items={items} />
|
||||
</ContextMenu.Content>
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useMemo } from 'react';
|
||||
|
||||
import { AddToPlaylistAction } from '/@/renderer/features/context-menu/actions/add-to-playlist-action';
|
||||
import { DownloadAction } from '/@/renderer/features/context-menu/actions/download-action';
|
||||
import { EditMetadataAction } from '/@/renderer/features/context-menu/actions/edit-metadata-action';
|
||||
import { GetInfoAction } from '/@/renderer/features/context-menu/actions/get-info-action';
|
||||
import { GoToAction } from '/@/renderer/features/context-menu/actions/go-to-action';
|
||||
import { PlayAction } from '/@/renderer/features/context-menu/actions/play-action';
|
||||
@@ -43,6 +44,7 @@ export const SongContextMenu = ({ items, type }: SongContextMenuProps) => {
|
||||
<GoToAction items={items} />
|
||||
<ShowInFileExplorerAction items={items} />
|
||||
<ContextMenu.Divider />
|
||||
<EditMetadataAction songs={items} />
|
||||
<GetInfoAction disabled={items.length === 0} items={items} />
|
||||
</ContextMenu.Content>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { KNOWN_TAG_MAP, resolveTagKey } from '../utils/known-tags';
|
||||
|
||||
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
|
||||
import { Autocomplete } from '/@/shared/components/autocomplete/autocomplete';
|
||||
import { Group } from '/@/shared/components/group/group';
|
||||
|
||||
interface AddFieldInputProps {
|
||||
availableFields: Array<{ label: string; value: string }>;
|
||||
existingFieldKeys: string[];
|
||||
onAddField: (key: string) => void;
|
||||
}
|
||||
|
||||
export const AddFieldInput = ({
|
||||
availableFields,
|
||||
existingFieldKeys,
|
||||
onAddField,
|
||||
}: AddFieldInputProps) => {
|
||||
const { t } = useTranslation();
|
||||
const skipNextEnterRef = useRef(false);
|
||||
const skipNextOnChangeResetRef = useRef(false);
|
||||
const [input, setInput] = useState('');
|
||||
const [duplicateAttempted, setDuplicateAttempted] = useState(false);
|
||||
|
||||
const trimmedInput = input.trim();
|
||||
const customKeyError =
|
||||
trimmedInput && !KNOWN_TAG_MAP.has(trimmedInput)
|
||||
? trimmedInput.includes('=')
|
||||
? "Tag key cannot contain '='"
|
||||
: // eslint-disable-next-line no-control-regex
|
||||
/[^\x00-\x7F]/.test(trimmedInput)
|
||||
? 'Tag key must use ASCII characters only'
|
||||
: null
|
||||
: null;
|
||||
|
||||
const resolvedInputKey = trimmedInput ? resolveTagKey(trimmedInput) : '';
|
||||
const duplicateError =
|
||||
trimmedInput && !customKeyError && existingFieldKeys.includes(resolvedInputKey)
|
||||
? 'Field already exists'
|
||||
: null;
|
||||
const fieldError = customKeyError ?? (duplicateAttempted ? duplicateError : null);
|
||||
|
||||
const addField = (key: string): boolean => {
|
||||
const trimmed = key.trim();
|
||||
if (!trimmed) return false;
|
||||
if (
|
||||
!KNOWN_TAG_MAP.has(trimmed) &&
|
||||
(trimmed.includes('=') || // eslint-disable-next-line no-control-regex
|
||||
/[^\x00-\x7F]/.test(trimmed))
|
||||
)
|
||||
return false;
|
||||
|
||||
const normalizedKey = resolveTagKey(trimmed);
|
||||
if (existingFieldKeys.includes(normalizedKey)) {
|
||||
setDuplicateAttempted(true);
|
||||
return false;
|
||||
}
|
||||
|
||||
setDuplicateAttempted(false);
|
||||
setInput('');
|
||||
onAddField(normalizedKey);
|
||||
return true;
|
||||
};
|
||||
|
||||
return (
|
||||
<Group gap="xs">
|
||||
<Autocomplete
|
||||
data={availableFields}
|
||||
error={fieldError}
|
||||
flex={1}
|
||||
onChange={(value) => {
|
||||
setInput(value);
|
||||
if (skipNextOnChangeResetRef.current) {
|
||||
skipNextOnChangeResetRef.current = false;
|
||||
} else {
|
||||
setDuplicateAttempted(false);
|
||||
}
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
if (skipNextEnterRef.current) {
|
||||
skipNextEnterRef.current = false;
|
||||
return;
|
||||
}
|
||||
addField(input);
|
||||
}
|
||||
}}
|
||||
onOptionSubmit={(value) => {
|
||||
skipNextEnterRef.current = true;
|
||||
skipNextOnChangeResetRef.current = true;
|
||||
const added = addField(value);
|
||||
if (added) {
|
||||
queueMicrotask(() => setInput(''));
|
||||
}
|
||||
}}
|
||||
placeholder={t('page.itemDetail.addField', 'Add field…')}
|
||||
value={input}
|
||||
/>
|
||||
<ActionIcon
|
||||
disabled={!!fieldError}
|
||||
icon="add"
|
||||
onClick={() => addField(input)}
|
||||
variant="filled"
|
||||
/>
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
.artwork-box {
|
||||
width: 100%;
|
||||
aspect-ratio: 1 / 1;
|
||||
container-type: inline-size;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
background: var(--mantine-color-dark-6);
|
||||
border: 1px solid var(--mantine-color-default-border);
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
}
|
||||
|
||||
.artwork-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
height: 100%;
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.placeholder-text {
|
||||
font-size: 5cqw;
|
||||
}
|
||||
|
||||
.remove-button {
|
||||
color: var(--theme-colors-state-error);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import styles from './artwork-panel.module.css';
|
||||
|
||||
import { Button } from '/@/shared/components/button/button';
|
||||
import { DragDropZone } from '/@/shared/components/drag-drop-zone/drag-drop-zone';
|
||||
import { Stack } from '/@/shared/components/stack/stack';
|
||||
import { Text } from '/@/shared/components/text/text';
|
||||
|
||||
interface ArtworkPanelProps {
|
||||
artworkDisplayUrl: null | string;
|
||||
artworkIsMixed: boolean;
|
||||
multipleArtworksLabel: string;
|
||||
noArtworkLabel: string;
|
||||
onApplyBytes: (bytes: Uint8Array, mimeType: string) => void;
|
||||
onBrowse: () => void;
|
||||
onRemove: () => void;
|
||||
removeArtworkLabel: string;
|
||||
showRemoveButton: boolean;
|
||||
}
|
||||
|
||||
export const ArtworkPanel = ({
|
||||
artworkDisplayUrl,
|
||||
artworkIsMixed,
|
||||
multipleArtworksLabel,
|
||||
noArtworkLabel,
|
||||
onApplyBytes,
|
||||
onBrowse,
|
||||
onRemove,
|
||||
removeArtworkLabel,
|
||||
showRemoveButton,
|
||||
}: ArtworkPanelProps) => (
|
||||
<Stack align="center" gap="md" pt="md">
|
||||
<DragDropZone
|
||||
className={styles.artworkBox}
|
||||
mode="file"
|
||||
onClick={onBrowse}
|
||||
onFileSelected={async (file) => {
|
||||
const buf = await file.arrayBuffer();
|
||||
onApplyBytes(new Uint8Array(buf), file.type);
|
||||
}}
|
||||
>
|
||||
{artworkDisplayUrl ? (
|
||||
<img alt="Cover art" className={styles.artworkImage} src={artworkDisplayUrl} />
|
||||
) : (
|
||||
<Stack align="center" className={styles.placeholder} justify="center">
|
||||
<Text className={styles.placeholderText}>
|
||||
{artworkIsMixed ? multipleArtworksLabel : noArtworkLabel}
|
||||
</Text>
|
||||
</Stack>
|
||||
)}
|
||||
</DragDropZone>
|
||||
{showRemoveButton && (
|
||||
<Button className={styles.removeButton} onClick={onRemove} size="sm" variant="subtle">
|
||||
{removeArtworkLabel}
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
@@ -0,0 +1,52 @@
|
||||
import { ContextModalProps } from '@mantine/modals';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { controller } from '/@/renderer/api/controller';
|
||||
import { SongEditModal } from '/@/renderer/features/tag-editor/components/song-edit-modal';
|
||||
import { useCurrentServer } from '/@/renderer/store';
|
||||
import { Spinner } from '/@/shared/components/spinner/spinner';
|
||||
import { Stack } from '/@/shared/components/stack/stack';
|
||||
import { Song } from '/@/shared/types/domain-types';
|
||||
|
||||
type SongEditInnerProps = {
|
||||
albumIds?: string[];
|
||||
songs?: Song[];
|
||||
};
|
||||
|
||||
export const SongEditContextModal = ({ innerProps }: ContextModalProps<SongEditInnerProps>) => {
|
||||
const server = useCurrentServer();
|
||||
const [resolvedSongs, setResolvedSongs] = useState<null | Song[]>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (innerProps.albumIds) {
|
||||
if (!server?.id) {
|
||||
setResolvedSongs([]);
|
||||
return;
|
||||
}
|
||||
Promise.all(
|
||||
innerProps.albumIds.map((id) =>
|
||||
controller.getAlbumDetail({
|
||||
apiClientProps: { serverId: server.id },
|
||||
query: { id },
|
||||
}),
|
||||
),
|
||||
).then((albums) => {
|
||||
setResolvedSongs(
|
||||
albums.flatMap((album) => album?.songs ?? []).filter((s) => s.path),
|
||||
);
|
||||
});
|
||||
} else {
|
||||
setResolvedSongs((innerProps.songs ?? []).filter((s) => s.path));
|
||||
}
|
||||
}, [innerProps.albumIds, innerProps.songs, server?.id]);
|
||||
|
||||
if (resolvedSongs === null) {
|
||||
return (
|
||||
<Stack align="center" p="xl">
|
||||
<Spinner />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return <SongEditModal songs={resolvedSongs} />;
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
.table-scroller {
|
||||
max-height: 55vh;
|
||||
padding-top: var(--theme-spacing-sm);
|
||||
padding-bottom: var(--theme-spacing-sm);
|
||||
margin-top: var(--theme-spacing-sm);
|
||||
overflow-y: auto;
|
||||
border-top: 1px solid var(--mantine-color-default-border);
|
||||
}
|
||||
|
||||
.table-cell {
|
||||
padding: var(--theme-spacing-xs) var(--theme-spacing-sm);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.table-header {
|
||||
width: 1%;
|
||||
padding: var(--theme-spacing-xs) var(--theme-spacing-sm);
|
||||
font-weight: 500;
|
||||
vertical-align: middle;
|
||||
color: var(--theme-colors-foreground-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import { closeAllModals } from '@mantine/modals';
|
||||
import { useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useMetadataEditor } from '../hooks/use-metadata-editor';
|
||||
import { AddFieldInput } from './add-field-input';
|
||||
import { ArtworkPanel } from './artwork-panel';
|
||||
import styles from './song-edit-modal.module.css';
|
||||
import { TagEditorSettings } from './tag-editor-settings';
|
||||
import { TagFieldRow } from './tag-field-row';
|
||||
|
||||
import { Button } from '/@/shared/components/button/button';
|
||||
import { Checkbox } from '/@/shared/components/checkbox/checkbox';
|
||||
import { Group } from '/@/shared/components/group/group';
|
||||
import { Spinner } from '/@/shared/components/spinner/spinner';
|
||||
import { Stack } from '/@/shared/components/stack/stack';
|
||||
import { Table } from '/@/shared/components/table/table';
|
||||
import { Tabs } from '/@/shared/components/tabs/tabs';
|
||||
import { Text } from '/@/shared/components/text/text';
|
||||
import { Song } from '/@/shared/types/domain-types';
|
||||
|
||||
export const SongEditModal = ({ songs }: { songs: Song[] }) => {
|
||||
const { t } = useTranslation();
|
||||
const tableContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const editor = useMetadataEditor({
|
||||
browser: window.api.browser,
|
||||
songs,
|
||||
utils: window.api.utils,
|
||||
});
|
||||
|
||||
const handleAddField = (key: string) => {
|
||||
editor.handleAddField(key);
|
||||
requestAnimationFrame(() => {
|
||||
const row = tableContainerRef.current?.querySelector<HTMLElement>(
|
||||
`[data-field-key="${key}"]`,
|
||||
);
|
||||
row?.scrollIntoView({ block: 'nearest' });
|
||||
row?.querySelector<HTMLElement>('input, textarea')?.focus();
|
||||
});
|
||||
};
|
||||
|
||||
// While loading, shows a spinner
|
||||
if (editor.isLoading) {
|
||||
return (
|
||||
<Stack align="center" gap="xs" p="xl">
|
||||
<Spinner />
|
||||
{editor.loadProgress && editor.loadProgress.total > 1 && (
|
||||
<Text c="dimmed" size="sm">
|
||||
{editor.loadProgress.processed} / {editor.loadProgress.total}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
// If there was an error loading the metadata, shows the error message
|
||||
if (editor.error) {
|
||||
return (
|
||||
<Stack p="md">
|
||||
<Text c="red">{editor.error}</Text>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
<Tabs defaultValue="tags" keepMounted={false}>
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="tags">{t('page.itemDetail.tagsTab', 'Tags')}</Tabs.Tab>
|
||||
<Tabs.Tab value="artwork">
|
||||
{t('page.itemDetail.artworkTab', 'Artwork')}
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="settings">{t('common.settings', 'Settings')}</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="tags">
|
||||
<Stack gap="xs" pt="xs">
|
||||
{editor.readWarning && (
|
||||
<Text c="orange" size="sm">
|
||||
{editor.readWarning}
|
||||
</Text>
|
||||
)}
|
||||
<AddFieldInput
|
||||
availableFields={editor.availableToAdd}
|
||||
existingFieldKeys={editor.sortedFieldEntries.map(([key]) => key)}
|
||||
onAddField={handleAddField}
|
||||
/>
|
||||
<div className={styles.tableScroller} ref={tableContainerRef}>
|
||||
<Table
|
||||
classNames={{ td: styles.tableCell, th: styles.tableHeader }}
|
||||
highlightOnHover={false}
|
||||
withRowBorders
|
||||
>
|
||||
<Table.Tbody>
|
||||
{editor.sortedFieldEntries.map(([key, value]) => (
|
||||
<TagFieldRow
|
||||
favoriteValues={editor.favoriteValues[key] ?? []}
|
||||
isDirty={
|
||||
key in editor.editedFields ||
|
||||
editor.removedKeys.has(key)
|
||||
}
|
||||
isMixed={editor.mixedKeys.has(key)}
|
||||
isMultiValue={
|
||||
editor.multiValueKeys.has(key) ||
|
||||
Array.isArray(value)
|
||||
}
|
||||
isRemoved={editor.removedKeys.has(key)}
|
||||
key={key}
|
||||
meta={editor.getFieldMeta(key)}
|
||||
mixedPlaceholder={
|
||||
editor.mixedKeys.has(key)
|
||||
? editor.mixedPlaceholder
|
||||
: undefined
|
||||
}
|
||||
onAddFavorite={(value) =>
|
||||
editor.handleAddFavoriteValue(key, value)
|
||||
}
|
||||
onChange={(v) => editor.handleFieldChange(key, v)}
|
||||
onRemove={() => editor.handleRemoveField(key)}
|
||||
onReset={() => editor.handleResetField(key)}
|
||||
onRevert={() => editor.handleRevertField(key)}
|
||||
tagKey={key}
|
||||
value={value}
|
||||
/>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</div>
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="artwork">
|
||||
<ArtworkPanel
|
||||
artworkDisplayUrl={editor.artworkDisplayUrl}
|
||||
artworkIsMixed={editor.artworkIsMixed}
|
||||
multipleArtworksLabel={t(
|
||||
'page.itemDetail.multipleArtworks',
|
||||
'Multiple Artworks',
|
||||
)}
|
||||
noArtworkLabel={t('page.itemDetail.noArtwork', 'No Artwork')}
|
||||
onApplyBytes={editor.applyArtworkBytes}
|
||||
onBrowse={editor.handleChangeArtwork}
|
||||
onRemove={editor.handleRemoveArtwork}
|
||||
removeArtworkLabel={t('page.itemDetail.removeArtwork', 'Remove Artwork')}
|
||||
showRemoveButton={editor.showRemoveArtworkButton}
|
||||
/>
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="settings">
|
||||
<TagEditorSettings />
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
|
||||
<Checkbox
|
||||
checked={editor.rescan}
|
||||
label={t('page.itemDetail.triggerRescan')}
|
||||
onChange={(e) => editor.setRescan(e.currentTarget.checked)}
|
||||
/>
|
||||
|
||||
<Group justify="flex-end">
|
||||
<Button
|
||||
disabled={editor.isSaving}
|
||||
onClick={() => closeAllModals()}
|
||||
variant="subtle"
|
||||
>
|
||||
{t('common.cancel', 'Cancel')}
|
||||
</Button>
|
||||
<Button loading={editor.isSaving} onClick={editor.handleSave} variant="filled">
|
||||
{editor.isSaving && editor.loadProgress && editor.loadProgress.total > 1
|
||||
? `${t('common.save', 'Save')} (${editor.loadProgress.processed}/${editor.loadProgress.total})`
|
||||
: t('common.save', 'Save')}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,119 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { RiAddLine, RiCloseLine } from 'react-icons/ri';
|
||||
|
||||
import { KNOWN_TAG_MAP, KNOWN_TAGS, resolveTagKey } from '../utils/known-tags';
|
||||
|
||||
import { useSettingsStoreActions, useTagEditorSettings } from '/@/renderer/store';
|
||||
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
|
||||
import { Autocomplete } from '/@/shared/components/autocomplete/autocomplete';
|
||||
import { Fieldset } from '/@/shared/components/fieldset/fieldset';
|
||||
import { Group } from '/@/shared/components/group/group';
|
||||
import { Stack } from '/@/shared/components/stack/stack';
|
||||
import { TagsInput } from '/@/shared/components/tags-input/tags-input';
|
||||
import { Text } from '/@/shared/components/text/text';
|
||||
|
||||
export const TagEditorSettings = () => {
|
||||
const { t } = useTranslation();
|
||||
const { favoriteValues, multiValueFields } = useTagEditorSettings();
|
||||
const { setSettings } = useSettingsStoreActions();
|
||||
const [input, setInput] = useState('');
|
||||
|
||||
const updateFields = (fields: string[]) => {
|
||||
setSettings({ tagEditor: { multiValueFields: fields } });
|
||||
};
|
||||
|
||||
const updateFavoriteValues = (key: string, values: string[]) => {
|
||||
setSettings({
|
||||
tagEditor: {
|
||||
favoriteValues: { ...favoriteValues, [key]: values },
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const addField = (value: string) => {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return;
|
||||
|
||||
const key = resolveTagKey(trimmed);
|
||||
if (key === 'lyrics' || multiValueFields.includes(key)) {
|
||||
setInput('');
|
||||
return;
|
||||
}
|
||||
|
||||
updateFields([...multiValueFields, key]);
|
||||
setInput('');
|
||||
};
|
||||
|
||||
const availableFields = KNOWN_TAGS.filter(
|
||||
({ key }) => key !== 'lyrics' && !multiValueFields.includes(key),
|
||||
)
|
||||
.map(({ key, label }) => ({ label, value: key }))
|
||||
.sort((a, b) => a.label.localeCompare(b.label));
|
||||
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
<Text fw={500} size="md">
|
||||
{t('page.itemDetail.multiValueFields')}
|
||||
</Text>
|
||||
<Text isMuted size="sm">
|
||||
{t('page.itemDetail.multiValueFieldsDescription')}
|
||||
</Text>
|
||||
<Autocomplete
|
||||
data={availableFields}
|
||||
onChange={setInput}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
addField(input);
|
||||
}
|
||||
}}
|
||||
onOptionSubmit={addField}
|
||||
placeholder={t('page.itemDetail.addMultiValueField')}
|
||||
py="md"
|
||||
rightSection={
|
||||
<ActionIcon onClick={() => addField(input)} variant="filled">
|
||||
<RiAddLine size={16} />
|
||||
</ActionIcon>
|
||||
}
|
||||
rightSectionPointerEvents="all"
|
||||
value={input}
|
||||
/>
|
||||
{multiValueFields.map((key) => {
|
||||
const label = KNOWN_TAG_MAP.get(key)?.label ?? key;
|
||||
return (
|
||||
<Fieldset key={key}>
|
||||
<Stack gap="xs">
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<div>
|
||||
<Text size="sm">{label}</Text>
|
||||
<Text ff="monospace" isMuted size="xs">
|
||||
{key}
|
||||
</Text>
|
||||
</div>
|
||||
<ActionIcon
|
||||
aria-label={t('common.remove', 'Remove')}
|
||||
onClick={() =>
|
||||
updateFields(
|
||||
multiValueFields.filter((field) => field !== key),
|
||||
)
|
||||
}
|
||||
variant="subtle"
|
||||
>
|
||||
<RiCloseLine size={16} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
<TagsInput
|
||||
aria-label={`${t('page.itemDetail.favoriteValues')} - ${label}`}
|
||||
onChange={(values) => updateFavoriteValues(key, values)}
|
||||
placeholder={t('page.itemDetail.addFavoriteValue')}
|
||||
splitChars={[]}
|
||||
value={favoriteValues[key] ?? []}
|
||||
/>
|
||||
</Stack>
|
||||
</Fieldset>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
.remove-cell {
|
||||
width: 32px;
|
||||
}
|
||||
|
||||
.remove-button {
|
||||
font-size: 1.05rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.dirty-label {
|
||||
box-shadow: inset 2px 0 0 var(--mantine-primary-color-filled);
|
||||
}
|
||||
|
||||
.removed-row {
|
||||
opacity: 0.55;
|
||||
transition: opacity 0.15s ease-in-out;
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
import type { TagValue } from '/@/shared/types/tag-editor';
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import type { KnownTag } from '../utils/known-tags';
|
||||
|
||||
import styles from './tag-field-row.module.css';
|
||||
|
||||
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
|
||||
import { Checkbox } from '/@/shared/components/checkbox/checkbox';
|
||||
import { NumberInput } from '/@/shared/components/number-input/number-input';
|
||||
import { Table } from '/@/shared/components/table/table';
|
||||
import { TagsInput } from '/@/shared/components/tags-input/tags-input';
|
||||
import { TextInput } from '/@/shared/components/text-input/text-input';
|
||||
import { Textarea } from '/@/shared/components/textarea/textarea';
|
||||
|
||||
interface FavoriteTagsInputProps {
|
||||
disabled: boolean;
|
||||
favoriteValues: string[];
|
||||
mixedPlaceholder?: string;
|
||||
onAddFavorite: (value: string) => void;
|
||||
onChange: (value: string[]) => void;
|
||||
value: string[];
|
||||
}
|
||||
|
||||
interface TagFieldRowProps {
|
||||
favoriteValues: string[];
|
||||
isDirty?: boolean;
|
||||
isMixed: boolean;
|
||||
isMultiValue: boolean;
|
||||
isRemoved: boolean;
|
||||
meta: KnownTag;
|
||||
mixedPlaceholder?: string;
|
||||
onAddFavorite: (value: string) => void;
|
||||
onChange: (value: TagValue) => void;
|
||||
onRemove: () => void;
|
||||
onReset: () => void;
|
||||
onRevert: () => void;
|
||||
tagKey: string;
|
||||
value: TagValue;
|
||||
}
|
||||
|
||||
const ADD_FAVORITE_PREFIX = '__feishin_add_favorite__:';
|
||||
|
||||
const FavoriteTagsInput = ({
|
||||
disabled,
|
||||
favoriteValues,
|
||||
mixedPlaceholder,
|
||||
onAddFavorite,
|
||||
onChange,
|
||||
value,
|
||||
}: FavoriteTagsInputProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [searchValue, setSearchValue] = useState('');
|
||||
const candidate = searchValue.trim();
|
||||
const canAddFavorite =
|
||||
candidate.length > 0 &&
|
||||
!favoriteValues.some((favorite) => favorite.toLowerCase() === candidate.toLowerCase());
|
||||
const data = useMemo(
|
||||
() =>
|
||||
canAddFavorite
|
||||
? [
|
||||
...favoriteValues,
|
||||
{
|
||||
label: t('page.itemDetail.addFavoriteValueOption', {
|
||||
value: candidate,
|
||||
}),
|
||||
value: `${ADD_FAVORITE_PREFIX}${candidate}`,
|
||||
},
|
||||
]
|
||||
: favoriteValues,
|
||||
[canAddFavorite, candidate, favoriteValues, t],
|
||||
);
|
||||
|
||||
return (
|
||||
<TagsInput
|
||||
clearable
|
||||
data={data}
|
||||
disabled={disabled}
|
||||
onChange={(values) => {
|
||||
const normalizedValues = values.map((item) =>
|
||||
item.startsWith(ADD_FAVORITE_PREFIX)
|
||||
? item.slice(ADD_FAVORITE_PREFIX.length)
|
||||
: item,
|
||||
);
|
||||
onChange(
|
||||
normalizedValues.filter(
|
||||
(item, index) =>
|
||||
normalizedValues.findIndex(
|
||||
(other) => other.toLowerCase() === item.toLowerCase(),
|
||||
) === index,
|
||||
),
|
||||
);
|
||||
}}
|
||||
onOptionSubmit={(submittedValue) => {
|
||||
if (submittedValue.startsWith(ADD_FAVORITE_PREFIX))
|
||||
onAddFavorite(submittedValue.slice(ADD_FAVORITE_PREFIX.length));
|
||||
}}
|
||||
onSearchChange={setSearchValue}
|
||||
placeholder={mixedPlaceholder}
|
||||
searchValue={searchValue}
|
||||
size="sm"
|
||||
splitChars={[]}
|
||||
value={value}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const TagFieldRow = ({
|
||||
favoriteValues,
|
||||
isDirty,
|
||||
isMixed,
|
||||
isMultiValue,
|
||||
isRemoved,
|
||||
meta,
|
||||
mixedPlaceholder,
|
||||
onAddFavorite,
|
||||
onChange,
|
||||
onRemove,
|
||||
onReset,
|
||||
onRevert,
|
||||
tagKey,
|
||||
value,
|
||||
}: TagFieldRowProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Table.Tr
|
||||
className={isRemoved ? styles.removedRow : undefined}
|
||||
data-field-key={tagKey}
|
||||
key={tagKey}
|
||||
>
|
||||
<Table.Th className={isDirty ? styles.dirtyLabel : undefined}>{meta.label}</Table.Th>
|
||||
<Table.Td>
|
||||
{isMultiValue && tagKey !== 'lyrics' ? (
|
||||
<FavoriteTagsInput
|
||||
disabled={isRemoved}
|
||||
favoriteValues={favoriteValues}
|
||||
mixedPlaceholder={mixedPlaceholder}
|
||||
onAddFavorite={onAddFavorite}
|
||||
onChange={onChange}
|
||||
value={Array.isArray(value) ? value : value ? [value] : []}
|
||||
/>
|
||||
) : meta.type === 'textarea' ? (
|
||||
<Textarea
|
||||
autosize
|
||||
disabled={isRemoved}
|
||||
maxRows={6}
|
||||
minRows={2}
|
||||
onChange={(e) => onChange(e.currentTarget.value)}
|
||||
placeholder={mixedPlaceholder}
|
||||
size="sm"
|
||||
value={Array.isArray(value) ? value.join('\n\n') : value}
|
||||
/>
|
||||
) : meta.type === 'number' ? (
|
||||
<NumberInput
|
||||
disabled={isRemoved}
|
||||
onChange={(v) => onChange(v === undefined ? '' : String(v))}
|
||||
placeholder={mixedPlaceholder}
|
||||
size="sm"
|
||||
value={
|
||||
isMixed || value === '' || Array.isArray(value)
|
||||
? undefined
|
||||
: Number(value)
|
||||
}
|
||||
/>
|
||||
) : meta.type === 'boolean' ? (
|
||||
<Checkbox
|
||||
checked={!isMixed && value === '1'}
|
||||
disabled={isRemoved}
|
||||
indeterminate={isMixed}
|
||||
onChange={(e) => onChange(e.currentTarget.checked ? '1' : '0')}
|
||||
size="sm"
|
||||
/>
|
||||
) : (
|
||||
<TextInput
|
||||
disabled={isRemoved}
|
||||
onChange={(e) => onChange(e.currentTarget.value)}
|
||||
placeholder={mixedPlaceholder}
|
||||
size="sm"
|
||||
value={Array.isArray(value) ? value.join('; ') : value}
|
||||
/>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td className={styles.removeCell}>
|
||||
<ActionIcon
|
||||
aria-label={isRemoved || isDirty ? t('common.undo') : t('common.delete')}
|
||||
className={styles.removeButton}
|
||||
icon={isRemoved || isDirty ? 'undo' : 'x'}
|
||||
iconProps={{
|
||||
color: isRemoved ? 'error' : 'default',
|
||||
size: 'md',
|
||||
}}
|
||||
onClick={isRemoved ? onReset : isDirty ? onRevert : onRemove}
|
||||
size="sm"
|
||||
tooltip={{
|
||||
label: isRemoved || isDirty ? t('common.undo') : t('common.delete'),
|
||||
}}
|
||||
variant="subtle"
|
||||
/>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,433 @@
|
||||
import type {
|
||||
ArtworkKind,
|
||||
ArtworkOp,
|
||||
BatchProgress,
|
||||
TagEditorUtils,
|
||||
TagValue,
|
||||
} from '/@/shared/types/tag-editor';
|
||||
|
||||
import { closeAllModals } from '@mantine/modals';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { FIELD_PRIORITY, KNOWN_TAG_MAP, KNOWN_TAGS, type KnownTag } from '../utils/known-tags';
|
||||
import { base64ToBytes, formatBatchFileErrors } from '../utils/utils';
|
||||
|
||||
import { controller } from '/@/renderer/api/controller';
|
||||
import { useCurrentServer, useSettingsStoreActions, useTagEditorSettings } from '/@/renderer/store';
|
||||
import { resolveSongPath } from '/@/renderer/utils/resolve-song-path';
|
||||
import { toast } from '/@/shared/components/toast/toast';
|
||||
import { Song } from '/@/shared/types/domain-types';
|
||||
|
||||
/**
|
||||
* Subscribes to batch progress events for the duration of `fn`, then unsubscribes.
|
||||
* Ensures the progress handler is always cleaned up even if `fn` throws.
|
||||
*/
|
||||
const withBatchProgress = async <T>(
|
||||
utils: TagEditorUtils,
|
||||
onProgress: (data: BatchProgress) => void,
|
||||
fn: () => Promise<T>,
|
||||
): Promise<T> => {
|
||||
const handler = (_e: unknown, data: BatchProgress) => onProgress(data);
|
||||
utils.onBatchProgress(handler);
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
utils.offBatchProgress(handler);
|
||||
}
|
||||
};
|
||||
|
||||
interface UseMetadataEditorArgs {
|
||||
browser: null | { clearCache: () => Promise<void> };
|
||||
songs?: Song[];
|
||||
utils: TagEditorUtils;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drives the metadata editor UI: loads song tags from disk, tracks field edits
|
||||
* and artwork changes, and writes them back on save.
|
||||
*/
|
||||
export const useMetadataEditor = ({ browser, songs: songsProp, utils }: UseMetadataEditorArgs) => {
|
||||
const { t } = useTranslation();
|
||||
const server = useCurrentServer();
|
||||
const { favoriteValues, multiValueFields, triggerRescan } = useTagEditorSettings();
|
||||
const { setSettings } = useSettingsStoreActions();
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [loadProgress, setLoadProgress] = useState<BatchProgress | null>(null);
|
||||
const [error, setError] = useState<null | string>(null);
|
||||
const [readWarning, setReadWarning] = useState<null | string>(null);
|
||||
const [resolvedSongs, setResolvedSongs] = useState<Song[]>([]);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [tagSummary, setTagSummary] = useState<Record<string, null | TagValue>>({});
|
||||
const [editedFields, setEditedFields] = useState<Record<string, TagValue>>({});
|
||||
const [parsedMultiValueKeys, setParsedMultiValueKeys] = useState<Set<string>>(new Set());
|
||||
const [removedKeys, setRemovedKeys] = useState<Set<string>>(new Set());
|
||||
const [loadedArtwork, setLoadedArtwork] = useState<{ kind: ArtworkKind }>({ kind: 'none' });
|
||||
const [artworkDisplayUrl, setArtworkDisplayUrl] = useState<null | string>(null);
|
||||
const [artworkOp, setArtworkOp] = useState<ArtworkOp | null>(null);
|
||||
|
||||
const setRescan = useCallback(
|
||||
(value: boolean) => setSettings({ tagEditor: { triggerRescan: value } }),
|
||||
[setSettings],
|
||||
);
|
||||
|
||||
/**
|
||||
* Merges `tagSummary` (on-disk values) and `editedFields` (unsaved edits) into
|
||||
* `displayFields`. Keys present in `tagSummary` with a `null` value (differing
|
||||
* across files) are added to `mixedKeys`. `sortedFieldEntries` applies
|
||||
* `FIELD_PRIORITY` ordering, then alphabetical by label for unlisted keys.
|
||||
*/
|
||||
const multiValueKeys = useMemo(
|
||||
() => new Set([...multiValueFields, ...parsedMultiValueKeys]),
|
||||
[multiValueFields, parsedMultiValueKeys],
|
||||
);
|
||||
|
||||
const { displayFields, mixedKeys, sortedFieldEntries } = useMemo(() => {
|
||||
const allKeys = new Set<string>();
|
||||
for (const k of Object.keys(tagSummary)) allKeys.add(k);
|
||||
for (const k of Object.keys(editedFields)) allKeys.add(k);
|
||||
|
||||
const displayFields: Record<string, TagValue> = {};
|
||||
const mixedKeys = new Set<string>();
|
||||
|
||||
for (const key of allKeys) {
|
||||
if (key in editedFields) {
|
||||
displayFields[key] = editedFields[key];
|
||||
continue;
|
||||
}
|
||||
const summaryVal = tagSummary[key];
|
||||
if (summaryVal === null) {
|
||||
mixedKeys.add(key);
|
||||
displayFields[key] = multiValueKeys.has(key) ? [] : '';
|
||||
} else if (summaryVal !== undefined) {
|
||||
displayFields[key] = summaryVal;
|
||||
}
|
||||
}
|
||||
|
||||
const sortedFieldEntries = Object.entries(displayFields).sort(([a], [b]) => {
|
||||
const pa = FIELD_PRIORITY.indexOf(a);
|
||||
const pb = FIELD_PRIORITY.indexOf(b);
|
||||
if (pa !== -1 && pb !== -1) return pa - pb;
|
||||
if (pa !== -1) return -1;
|
||||
if (pb !== -1) return 1;
|
||||
const labelA = KNOWN_TAG_MAP.get(a)?.label ?? a;
|
||||
const labelB = KNOWN_TAG_MAP.get(b)?.label ?? b;
|
||||
return labelA.localeCompare(labelB);
|
||||
});
|
||||
|
||||
return { displayFields, mixedKeys, sortedFieldEntries };
|
||||
}, [tagSummary, editedFields, multiValueKeys]);
|
||||
|
||||
/**
|
||||
* Runs once on mount: reads metadata for all songs in batch and populates
|
||||
* tag summary and initial artwork state.
|
||||
*/
|
||||
useEffect(() => {
|
||||
const initialize = async () => {
|
||||
const songs = (songsProp ?? []).filter((s) => s.path);
|
||||
|
||||
if (songs.length === 0) {
|
||||
setError(t('page.itemDetail.noLocalSongs', 'No songs with local file paths found'));
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setResolvedSongs(songs);
|
||||
const paths = songs.map((s) => resolveSongPath(s.path)).filter(Boolean) as string[];
|
||||
|
||||
const batchResult = await withBatchProgress(utils, setLoadProgress, () =>
|
||||
utils.readSongMetadataBatch(paths),
|
||||
);
|
||||
|
||||
if (!batchResult.success || !batchResult.tagSummary) {
|
||||
setError(batchResult.error ?? t('page.itemDetail.fileNotWritable'));
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (batchResult.failedFiles?.length) {
|
||||
const count = batchResult.failedFiles.length;
|
||||
const total = batchResult.totalCount ?? paths.length;
|
||||
setReadWarning(
|
||||
t('page.itemDetail.readPartialFailure', {
|
||||
count,
|
||||
defaultValue: `Could not read metadata from ${count} of ${total} file(s).`,
|
||||
total,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
setTagSummary(batchResult.tagSummary);
|
||||
setParsedMultiValueKeys(new Set(batchResult.multiValueKeys ?? []));
|
||||
|
||||
if (
|
||||
batchResult.artworkKind === 'common' &&
|
||||
batchResult.artworkData &&
|
||||
batchResult.artworkMimeType
|
||||
) {
|
||||
setArtworkDisplayUrl(
|
||||
`data:${batchResult.artworkMimeType};base64,${batchResult.artworkData}`,
|
||||
);
|
||||
}
|
||||
setLoadedArtwork({ kind: batchResult.artworkKind });
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
initialize().catch((err) => {
|
||||
setError(String(err));
|
||||
setIsLoading(false);
|
||||
});
|
||||
|
||||
return () => utils.cancelReadSongMetadata();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
/** Records an edited value for `key`, overriding the on-disk summary. */
|
||||
const handleFieldChange = useCallback((key: string, value: TagValue) => {
|
||||
setEditedFields((prev) => ({ ...prev, [key]: value }));
|
||||
}, []);
|
||||
|
||||
const handleAddFavoriteValue = useCallback(
|
||||
(key: string, value: string) => {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return;
|
||||
|
||||
const currentValues = favoriteValues[key] ?? [];
|
||||
if (currentValues.some((favorite) => favorite.toLowerCase() === trimmed.toLowerCase()))
|
||||
return;
|
||||
|
||||
setSettings({
|
||||
tagEditor: {
|
||||
favoriteValues: {
|
||||
...favoriteValues,
|
||||
[key]: [...currentValues, trimmed],
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
[favoriteValues, setSettings],
|
||||
);
|
||||
|
||||
/** Marks `key` for deletion while preserving its displayed value for undo. */
|
||||
const handleRemoveField = useCallback((key: string) => {
|
||||
setRemovedKeys((prev) => new Set(prev).add(key));
|
||||
}, []);
|
||||
|
||||
/** Re-enables a field that was marked for deletion. */
|
||||
const handleResetField = useCallback((key: string) => {
|
||||
setRemovedKeys((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(key);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
/** Restores a field to its original on-disk value and removal state. */
|
||||
const handleRevertField = useCallback((key: string) => {
|
||||
setEditedFields((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[key];
|
||||
return next;
|
||||
});
|
||||
setRemovedKeys((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(key);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Refs so the functional updater inside handleAddField can see latest values without needing them in the dependency array.
|
||||
const tagSummaryRef = useRef(tagSummary);
|
||||
tagSummaryRef.current = tagSummary;
|
||||
const removedKeysRef = useRef(removedKeys);
|
||||
removedKeysRef.current = removedKeys;
|
||||
|
||||
/** Adds `key` to `editedFields` with an empty value and un-marks it from removal. */
|
||||
const handleAddField = useCallback(
|
||||
(key: null | string) => {
|
||||
if (!key) return;
|
||||
setEditedFields((prev) => {
|
||||
const wasRemoved = removedKeysRef.current.has(key);
|
||||
if (wasRemoved) return prev;
|
||||
const alreadyVisible = (key in tagSummaryRef.current || key in prev) && !wasRemoved;
|
||||
if (alreadyVisible) return prev;
|
||||
return { ...prev, [key]: multiValueKeys.has(key) ? [] : '' };
|
||||
});
|
||||
setRemovedKeys((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(key);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[multiValueKeys],
|
||||
);
|
||||
|
||||
/** Creates a blob URL from raw image bytes and queues a `set` artwork operation. */
|
||||
const applyArtworkBytes = useCallback((bytes: Uint8Array, mimeType: string) => {
|
||||
const blob = new Blob([bytes.buffer as ArrayBuffer], { type: mimeType });
|
||||
setArtworkDisplayUrl(URL.createObjectURL(blob));
|
||||
setArtworkOp({ bytes, mimeType, type: 'set' });
|
||||
}, []);
|
||||
|
||||
/** Opens a native file picker, reads the selected image, and applies it as the new artwork. */
|
||||
const handleChangeArtwork = useCallback(async () => {
|
||||
const path = await window.api.localSettings.openFileSelector({
|
||||
filters: [{ extensions: ['jpg', 'jpeg', 'png', 'gif', 'webp'], name: 'Images' }],
|
||||
});
|
||||
if (!path) return;
|
||||
const result = await utils.readLocalImage(path);
|
||||
if (result.success && result.data && result.mimeType) {
|
||||
applyArtworkBytes(base64ToBytes(result.data), result.mimeType);
|
||||
}
|
||||
}, [applyArtworkBytes, utils]);
|
||||
|
||||
/** Clears the artwork preview and queues a `clear` artwork operation. */
|
||||
const handleRemoveArtwork = useCallback(() => {
|
||||
setArtworkDisplayUrl(null);
|
||||
setArtworkOp({ type: 'clear' });
|
||||
}, []);
|
||||
|
||||
/** Returns the `KnownTag` descriptor for `key`, falling back to a generic string entry. */
|
||||
const getFieldMeta = useCallback(
|
||||
(key: string): KnownTag => KNOWN_TAG_MAP.get(key) ?? { key, label: key, type: 'string' },
|
||||
[],
|
||||
);
|
||||
|
||||
/**
|
||||
* Validates that no editable fields are empty, writes all tag and artwork
|
||||
* changes to disk in batch, then optionally triggers a server rescan and
|
||||
* closes the modal.
|
||||
*/
|
||||
const handleSave = useCallback(async () => {
|
||||
if (resolvedSongs.length === 0) return;
|
||||
|
||||
const activeEdits = Object.fromEntries(
|
||||
Object.entries(editedFields).filter(([key]) => !removedKeys.has(key)),
|
||||
) as Record<string, TagValue>;
|
||||
const emptyFields = Object.entries(activeEdits)
|
||||
.filter(([key, value]) => {
|
||||
const meta = KNOWN_TAG_MAP.get(key);
|
||||
if (meta?.type === 'boolean') return false;
|
||||
return Array.isArray(value)
|
||||
? value.length === 0 || value.some((part) => part.trim() === '')
|
||||
: value.trim() === '';
|
||||
})
|
||||
.map(([key]) => KNOWN_TAG_MAP.get(key)?.label ?? key);
|
||||
|
||||
if (emptyFields.length > 0) {
|
||||
toast.error({
|
||||
message: `${t('page.itemDetail.emptyFields', 'Fields cannot be empty')}: ${emptyFields.join(', ')}`,
|
||||
title: t('error.generalError', 'Error'),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
const paths = resolvedSongs.map((s) => resolveSongPath(s.path)).filter(Boolean) as string[];
|
||||
|
||||
try {
|
||||
const writeResult = await withBatchProgress(utils, setLoadProgress, () =>
|
||||
utils.writeSongTagsBatch(
|
||||
paths,
|
||||
activeEdits,
|
||||
[...removedKeys],
|
||||
artworkOp ?? undefined,
|
||||
),
|
||||
);
|
||||
|
||||
if (!writeResult.success) {
|
||||
const failed = writeResult.failedFiles ?? [];
|
||||
const message =
|
||||
failed.length > 0
|
||||
? formatBatchFileErrors(
|
||||
failed,
|
||||
t('page.itemDetail.writePartialFailure', {
|
||||
count: failed.length,
|
||||
defaultValue: `Failed to save ${failed.length} file(s).`,
|
||||
}),
|
||||
)
|
||||
: (writeResult.error ?? t('page.itemDetail.fileNotWritable'));
|
||||
toast.error({ message, title: t('error.generalError', 'Error') });
|
||||
return;
|
||||
}
|
||||
|
||||
if (artworkOp) {
|
||||
await browser?.clearCache();
|
||||
}
|
||||
|
||||
if (triggerRescan && server) {
|
||||
try {
|
||||
await controller.refreshItems({
|
||||
apiClientProps: { serverId: server.id },
|
||||
query: { ids: resolvedSongs.map((s) => s.id) },
|
||||
});
|
||||
} catch {
|
||||
// non-fatal
|
||||
}
|
||||
}
|
||||
|
||||
closeAllModals();
|
||||
} finally {
|
||||
setLoadProgress(null);
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [
|
||||
artworkOp,
|
||||
browser,
|
||||
editedFields,
|
||||
removedKeys,
|
||||
resolvedSongs,
|
||||
server,
|
||||
t,
|
||||
triggerRescan,
|
||||
utils,
|
||||
]);
|
||||
|
||||
/** Known tags not yet present in `displayFields`, sorted alphabetically for the add-field dropdown. */
|
||||
const availableToAdd = useMemo(
|
||||
() =>
|
||||
KNOWN_TAGS.filter((tag) => !(tag.key in displayFields))
|
||||
.map((tag) => ({ label: tag.label, value: tag.key }))
|
||||
.sort((a, b) => a.label.localeCompare(b.label)),
|
||||
[displayFields],
|
||||
);
|
||||
|
||||
const artworkIsMixed = artworkOp === null && loadedArtwork.kind === 'mixed';
|
||||
const showRemoveArtworkButton =
|
||||
artworkOp?.type !== 'clear' && (artworkOp?.type === 'set' || loadedArtwork.kind !== 'none');
|
||||
|
||||
const mixedPlaceholder = t('page.itemDetail.multipleValues', '(Multiple Values)');
|
||||
|
||||
return {
|
||||
applyArtworkBytes,
|
||||
artworkDisplayUrl,
|
||||
artworkIsMixed,
|
||||
availableToAdd,
|
||||
editedFields,
|
||||
error,
|
||||
favoriteValues,
|
||||
getFieldMeta,
|
||||
handleAddFavoriteValue,
|
||||
handleAddField,
|
||||
handleChangeArtwork,
|
||||
handleFieldChange,
|
||||
handleRemoveArtwork,
|
||||
handleRemoveField,
|
||||
handleResetField,
|
||||
handleRevertField,
|
||||
handleSave,
|
||||
isLoading,
|
||||
isSaving,
|
||||
loadProgress,
|
||||
mixedKeys,
|
||||
mixedPlaceholder,
|
||||
multiValueKeys,
|
||||
readWarning,
|
||||
removedKeys,
|
||||
rescan: triggerRescan,
|
||||
setRescan,
|
||||
showRemoveArtworkButton,
|
||||
sortedFieldEntries,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,100 @@
|
||||
import { PROPERTIES } from 'taglib-wasm';
|
||||
|
||||
/** Tags pinned to the top of the editor table (most frequently edited). */
|
||||
export const FIELD_PRIORITY: readonly string[] = [
|
||||
'title',
|
||||
'artist',
|
||||
'album',
|
||||
'albumArtist',
|
||||
'trackNumber',
|
||||
'discNumber',
|
||||
'date',
|
||||
];
|
||||
|
||||
export interface KnownTag {
|
||||
key: string;
|
||||
label: string;
|
||||
type: TagFieldType;
|
||||
}
|
||||
|
||||
/** Which form control to render for a tag row. */
|
||||
export type TagFieldType = 'boolean' | 'number' | 'string' | 'textarea';
|
||||
|
||||
/**
|
||||
* Per-key overrides and extras. Keys present in taglib-wasm PROPERTIES get their
|
||||
* label/type overridden; keys absent from PROPERTIES are appended as extra entries.
|
||||
*/
|
||||
const TAG_CONFIG: Record<string, { label?: string; type?: TagFieldType }> = {
|
||||
acoustidFingerprint: { type: 'textarea' },
|
||||
acoustidId: { label: 'AcoustID' },
|
||||
albumArtist: { label: 'Album Artist' },
|
||||
albumArtistSort: { label: 'Album Artist Sort' },
|
||||
albumSort: { label: 'Album Sort' },
|
||||
// extras not in PROPERTIES (common MusicBrainz Picard tags)
|
||||
ARTISTS: { label: 'Artists', type: 'string' },
|
||||
artistSort: { label: 'Artist Sort' },
|
||||
bpm: { type: 'number' },
|
||||
catalogNumber: { label: 'Catalog Number' },
|
||||
comment: { type: 'textarea' },
|
||||
composerSort: { label: 'Composer Sort' },
|
||||
discNumber: { label: 'Disc Number' },
|
||||
lyrics: { type: 'textarea' },
|
||||
musicbrainzArtistId: { label: 'MusicBrainz Artist ID' },
|
||||
musicbrainzReleaseArtistId: { label: 'MusicBrainz Album Artist ID' },
|
||||
musicbrainzReleaseGroupId: { label: 'MusicBrainz Release Group ID' },
|
||||
musicbrainzReleaseId: { label: 'MusicBrainz Album ID' },
|
||||
musicbrainzReleaseTrackId: { label: 'MusicBrainz Release Track ID' },
|
||||
musicbrainzTrackId: { label: 'MusicBrainz Track ID' },
|
||||
musicbrainzWorkId: { label: 'MusicBrainz Work ID' },
|
||||
originalAlbum: { label: 'Original Album' },
|
||||
originalArtist: { label: 'Original Artist' },
|
||||
originalDate: { label: 'Original Date' },
|
||||
ORIGINALYEAR: { label: 'Original Year', type: 'number' },
|
||||
RELEASECOUNTRY: { label: 'Release Country', type: 'string' },
|
||||
RELEASESTATUS: { label: 'Release Status', type: 'string' },
|
||||
RELEASETYPE: { label: 'Release Type', type: 'string' },
|
||||
remixedBy: { label: 'Remixer' },
|
||||
titleSort: { label: 'Title Sort' },
|
||||
totalDiscs: { label: 'Total Discs' },
|
||||
totalTracks: { label: 'Total Tracks' },
|
||||
trackNumber: { label: 'Track Number' },
|
||||
};
|
||||
|
||||
const humanizeKey = (key: string): string =>
|
||||
key
|
||||
.replace(/([A-Z])/g, ' $1')
|
||||
.replace(/^./, (c) => c.toUpperCase())
|
||||
.trim();
|
||||
|
||||
/** Field definitions derived from taglib-wasm's PROPERTIES plus any extras in TAG_CONFIG. */
|
||||
export const KNOWN_TAGS: KnownTag[] = [
|
||||
...Object.entries(PROPERTIES).map(([key, prop]) => {
|
||||
const cfg = TAG_CONFIG[key];
|
||||
return {
|
||||
key,
|
||||
label: cfg?.label ?? humanizeKey(key),
|
||||
type: cfg?.type ?? (prop.type as TagFieldType),
|
||||
};
|
||||
}),
|
||||
...Object.entries(TAG_CONFIG)
|
||||
.filter(([key]) => !(key in PROPERTIES))
|
||||
.map(([key, cfg]) => ({
|
||||
key,
|
||||
label: cfg.label ?? humanizeKey(key),
|
||||
type: cfg.type ?? ('string' as TagFieldType),
|
||||
})),
|
||||
];
|
||||
|
||||
export const KNOWN_TAG_MAP = new Map(KNOWN_TAGS.map((t) => [t.key, t]));
|
||||
|
||||
/**
|
||||
* Resolves a user-typed string to a canonical tag key.
|
||||
* Matches by label first (e.g. "Album Sort" → "albumSort"), then falls back to
|
||||
* the raw input. Unknown keys are uppercased so TagLib writes them as valid
|
||||
* Vorbis comment field names.
|
||||
*/
|
||||
export const resolveTagKey = (input: string): string => {
|
||||
const byLabel = KNOWN_TAGS.find((t) => t.label.toLowerCase() === input.toLowerCase());
|
||||
const key = byLabel?.key ?? input;
|
||||
return KNOWN_TAG_MAP.has(key) ? key : key.toUpperCase();
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { BatchFileError } from '/@/shared/types/tag-editor';
|
||||
|
||||
export const base64ToBytes = (base64: string): Uint8Array => {
|
||||
const binary = atob(base64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
||||
return bytes;
|
||||
};
|
||||
|
||||
export const formatBatchFileErrors = (failed: BatchFileError[], summary: string): string => {
|
||||
const details = failed
|
||||
.slice(0, 3)
|
||||
.map((f) => f.path.split(/[/\\]/).pop() ?? f.path)
|
||||
.join(', ');
|
||||
const suffix = failed.length > 3 ? '…' : '';
|
||||
return `${summary} ${details}${suffix}`;
|
||||
};
|
||||
@@ -175,9 +175,22 @@ const VisualizerSettingsContextModal = (props: any) => (
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
const LazySongEditContextModal = lazy(() =>
|
||||
import('/@/renderer/features/tag-editor/components/song-edit-context-modal').then((module) => ({
|
||||
default: module.SongEditContextModal,
|
||||
})),
|
||||
);
|
||||
|
||||
const SongEditContextModal = (props: any) => (
|
||||
<Suspense fallback={<Spinner container />}>
|
||||
<LazySongEditContextModal {...props} />
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
const appRouterModals = {
|
||||
addToPlaylist: AddToPlaylistContextModal,
|
||||
base: BaseContextModal,
|
||||
editMetadata: SongEditContextModal,
|
||||
lyricsSettings: LyricsSettingsContextModal,
|
||||
saveAndReplace: SaveAndReplaceContextModal,
|
||||
settings: SettingsContextModal,
|
||||
|
||||
@@ -744,6 +744,12 @@ const AutoDJSettingsSchema = z.object({
|
||||
timing: z.number(),
|
||||
});
|
||||
|
||||
const TagEditorSettingsSchema = z.object({
|
||||
favoriteValues: z.record(z.string(), z.array(z.string())),
|
||||
multiValueFields: z.array(z.string()),
|
||||
triggerRescan: z.boolean(),
|
||||
});
|
||||
|
||||
/**
|
||||
* This schema is used for validation of the imported settings json
|
||||
*/
|
||||
@@ -767,6 +773,7 @@ export const ValidationSettingsStateSchema = z.object({
|
||||
z.literal('window'),
|
||||
z.string(),
|
||||
]),
|
||||
tagEditor: TagEditorSettingsSchema,
|
||||
visualizer: VisualizerSettingsSchema,
|
||||
window: WindowSettingsSchema,
|
||||
});
|
||||
@@ -1988,6 +1995,11 @@ const initialState: SettingsState = {
|
||||
username: 'feishin',
|
||||
},
|
||||
tab: 'general',
|
||||
tagEditor: {
|
||||
favoriteValues: {},
|
||||
multiValueFields: ['artist', 'albumArtist', 'genre', 'composer', 'lyricist'],
|
||||
triggerRescan: true,
|
||||
},
|
||||
visualizer: {
|
||||
audiomotionanalyzer: {
|
||||
alphaBars: false,
|
||||
@@ -2677,6 +2689,8 @@ export const useCssSettings = () => useSettingsStore((state) => state.css, shall
|
||||
export const useQueryBuilderSettings = () =>
|
||||
useSettingsStore((state) => state.queryBuilder, shallow);
|
||||
|
||||
export const useTagEditorSettings = () => useSettingsStore((state) => state.tagEditor, shallow);
|
||||
|
||||
const getSettingsStoreVersion = () => useSettingsStore.persist.getOptions().version!;
|
||||
|
||||
export const useSettingsForExport = (): SettingsState & { version: number } =>
|
||||
|
||||
@@ -347,6 +347,19 @@ const scrobbleParameters = z.object({
|
||||
|
||||
const scrobble = z.null();
|
||||
|
||||
const scanStatusBody = z.object({
|
||||
count: z.number(),
|
||||
folderCount: z.number(),
|
||||
lastScan: z.string().optional(),
|
||||
scanning: z.boolean(),
|
||||
});
|
||||
|
||||
const startScanParameters = z.object({});
|
||||
const startScan = z.object({ scanStatus: scanStatusBody });
|
||||
|
||||
const getScanStatusParameters = z.object({});
|
||||
const getScanStatus = z.object({ scanStatus: scanStatusBody });
|
||||
|
||||
const search3 = z.object({
|
||||
searchResult3: z
|
||||
.object({
|
||||
@@ -907,6 +920,7 @@ export const ssType = {
|
||||
getMusicDirectory: getMusicDirectoryParameters,
|
||||
getPlaylist: getPlaylistParameters,
|
||||
getPlaylists: getPlaylistsParameters,
|
||||
getScanStatus: getScanStatusParameters,
|
||||
getSong: getSongParameters,
|
||||
getSongsByGenre: getSongsByGenreParameters,
|
||||
getStarred: getStarredParameters,
|
||||
@@ -923,6 +937,7 @@ export const ssType = {
|
||||
setRating: setRatingParameters,
|
||||
similarSongs: similarSongsParameters,
|
||||
similarSongs2: similarSongs2Parameters,
|
||||
startScan: startScanParameters,
|
||||
structuredLyrics: structuredLyricsParameters,
|
||||
topSongsList: topSongsListParameters,
|
||||
updateInternetRadioStation: updateInternetRadioStationParameters,
|
||||
@@ -956,6 +971,7 @@ export const ssType = {
|
||||
getMusicDirectory,
|
||||
getPlaylist,
|
||||
getPlaylists,
|
||||
getScanStatus,
|
||||
getSong,
|
||||
getSongsByGenre,
|
||||
getStarred,
|
||||
@@ -981,6 +997,7 @@ export const ssType = {
|
||||
similarSongs,
|
||||
similarSongs2,
|
||||
song,
|
||||
startScan,
|
||||
structuredLyrics,
|
||||
topSongsList,
|
||||
updateInternetRadioStation,
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
.root {
|
||||
& [data-disabled='true'] {
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
.input {
|
||||
width: 100%;
|
||||
border: 1px solid transparent;
|
||||
|
||||
&[data-variant='default'] {
|
||||
color: var(--theme-colors-surface-foreground);
|
||||
background: var(--theme-colors-surface);
|
||||
}
|
||||
|
||||
&[data-variant='filled'] {
|
||||
color: var(--theme-colors-foreground);
|
||||
background: var(--theme-colors-background);
|
||||
}
|
||||
}
|
||||
|
||||
.input:focus,
|
||||
.input:focus-visible {
|
||||
border-color: lighten(var(--theme-colors-border), 10%);
|
||||
}
|
||||
|
||||
.label {
|
||||
margin-bottom: var(--theme-spacing-sm);
|
||||
}
|
||||
|
||||
.section {
|
||||
color: var(--theme-colors-foreground-muted);
|
||||
}
|
||||
|
||||
.dropdown {
|
||||
padding: var(--theme-spacing-xs);
|
||||
color: var(--theme-colors-surface-foreground);
|
||||
background: var(--theme-colors-surface);
|
||||
border: 1px solid var(--theme-colors-border);
|
||||
}
|
||||
|
||||
.option {
|
||||
position: relative;
|
||||
padding: var(--theme-spacing-sm) var(--theme-spacing-md);
|
||||
}
|
||||
|
||||
.option:hover {
|
||||
background: lighten(var(--theme-colors-surface), 5%);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { AutocompleteProps as MantineAutocompleteProps } from '@mantine/core';
|
||||
|
||||
import { Autocomplete as MantineAutocomplete } from '@mantine/core';
|
||||
import { CSSProperties, forwardRef } from 'react';
|
||||
|
||||
import styles from './autocomplete.module.css';
|
||||
|
||||
export interface AutocompleteProps extends MantineAutocompleteProps {
|
||||
maxWidth?: CSSProperties['maxWidth'];
|
||||
width?: CSSProperties['width'];
|
||||
}
|
||||
|
||||
export const Autocomplete = forwardRef<HTMLInputElement, AutocompleteProps>(
|
||||
(
|
||||
{
|
||||
classNames,
|
||||
maxWidth,
|
||||
size = 'sm',
|
||||
style,
|
||||
variant = 'default',
|
||||
width,
|
||||
...props
|
||||
}: AutocompleteProps,
|
||||
ref,
|
||||
) => {
|
||||
return (
|
||||
<MantineAutocomplete
|
||||
classNames={{
|
||||
dropdown: styles.dropdown,
|
||||
input: styles.input,
|
||||
label: styles.label,
|
||||
option: styles.option,
|
||||
root: styles.root,
|
||||
section: styles.section,
|
||||
...classNames,
|
||||
}}
|
||||
ref={ref}
|
||||
size={size}
|
||||
spellCheck={false}
|
||||
style={{ maxWidth, width, ...style }}
|
||||
variant={variant}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -125,6 +125,7 @@ import {
|
||||
LuTimerOff,
|
||||
LuTrash,
|
||||
LuTriangleAlert,
|
||||
LuUndo2,
|
||||
LuUpload,
|
||||
LuUser,
|
||||
LuUserPen,
|
||||
@@ -361,6 +362,7 @@ export const AppIcon = {
|
||||
themeDark: LuMoon,
|
||||
themeLight: LuSun,
|
||||
track: LuMusic2,
|
||||
undo: LuUndo2,
|
||||
unfavorite: LuHeartCrack,
|
||||
unpin: LuPinOff,
|
||||
upload: LuUpload,
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
.root {
|
||||
& [data-disabled='true'] {
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
.label {
|
||||
margin-bottom: var(--theme-spacing-sm);
|
||||
}
|
||||
|
||||
.dropdown {
|
||||
padding: var(--theme-spacing-xs);
|
||||
color: var(--theme-colors-surface-foreground);
|
||||
background: var(--theme-colors-surface);
|
||||
border: 1px solid var(--theme-colors-border);
|
||||
}
|
||||
|
||||
.input {
|
||||
width: 100%;
|
||||
border: 1px solid transparent;
|
||||
|
||||
&[data-variant='default'] {
|
||||
color: var(--theme-colors-surface-foreground);
|
||||
background: var(--theme-colors-surface);
|
||||
}
|
||||
|
||||
&[data-variant='filled'] {
|
||||
color: var(--theme-colors-foreground);
|
||||
background: var(--theme-colors-background);
|
||||
|
||||
& .pill {
|
||||
background: var(--theme-colors-surface);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.input:focus-within {
|
||||
border-color: lighten(var(--theme-colors-border), 10%);
|
||||
}
|
||||
|
||||
.input-field {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.option {
|
||||
position: relative;
|
||||
padding: var(--theme-spacing-sm) var(--theme-spacing-md);
|
||||
}
|
||||
|
||||
.option:hover {
|
||||
background: lighten(var(--theme-colors-surface), 5%);
|
||||
}
|
||||
|
||||
.pill {
|
||||
font-size: var(--theme-font-size-sm);
|
||||
background: var(--theme-colors-background);
|
||||
border-radius: var(--theme-radius-sm);
|
||||
}
|
||||
|
||||
.pills-list {
|
||||
padding-right: var(--theme-spacing-lg);
|
||||
}
|
||||
|
||||
.clear-button {
|
||||
background: transparent !important;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import {
|
||||
TagsInput as MantineTagsInput,
|
||||
TagsInputProps as MantineTagsInputProps,
|
||||
} from '@mantine/core';
|
||||
import { CSSProperties, useMemo } from 'react';
|
||||
|
||||
import styles from './tags-input.module.css';
|
||||
|
||||
export interface TagsInputProps extends MantineTagsInputProps {
|
||||
maxWidth?: CSSProperties['maxWidth'];
|
||||
width?: CSSProperties['width'];
|
||||
}
|
||||
|
||||
const defaultClassNames = {
|
||||
dropdown: styles.dropdown,
|
||||
input: styles.input,
|
||||
inputField: styles.inputField,
|
||||
label: styles.label,
|
||||
option: styles.option,
|
||||
pill: styles.pill,
|
||||
pillsList: styles.pillsList,
|
||||
root: styles.root,
|
||||
};
|
||||
|
||||
const defaultClearButtonProps = {
|
||||
classNames: {
|
||||
root: styles.clearButton,
|
||||
},
|
||||
variant: 'transparent' as const,
|
||||
};
|
||||
|
||||
export const TagsInput = ({
|
||||
classNames,
|
||||
maxWidth,
|
||||
variant = 'default',
|
||||
width,
|
||||
...props
|
||||
}: TagsInputProps) => {
|
||||
const mergedClassNames = useMemo(
|
||||
() => (classNames ? { ...defaultClassNames, ...classNames } : defaultClassNames),
|
||||
[classNames],
|
||||
);
|
||||
|
||||
const style = useMemo(
|
||||
() => (maxWidth || width ? { maxWidth, width } : undefined),
|
||||
[maxWidth, width],
|
||||
);
|
||||
|
||||
return (
|
||||
<MantineTagsInput
|
||||
classNames={mergedClassNames}
|
||||
clearButtonProps={defaultClearButtonProps}
|
||||
style={style}
|
||||
variant={variant}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1,15 +1,20 @@
|
||||
.root {
|
||||
transition: width 0.3s ease-in-out;
|
||||
|
||||
&[data-disabled='true'] {
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
.input {
|
||||
color: var(--theme-colors-surface-foreground);
|
||||
background: var(--theme-colors-surface);
|
||||
width: 100%;
|
||||
border: 1px solid transparent;
|
||||
|
||||
&[data-variant='default'] {
|
||||
color: var(--theme-colors-surface-foreground);
|
||||
background: var(--theme-colors-surface);
|
||||
}
|
||||
|
||||
&[data-variant='filled'] {
|
||||
color: var(--theme-colors-foreground);
|
||||
background: var(--theme-colors-background);
|
||||
}
|
||||
}
|
||||
|
||||
.input:focus,
|
||||
@@ -20,3 +25,7 @@
|
||||
.label {
|
||||
margin-bottom: var(--theme-spacing-sm);
|
||||
}
|
||||
|
||||
.required {
|
||||
color: var(--theme-colors-state-error);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,19 @@ export interface TextareaProps extends MantineTextareaProps {
|
||||
}
|
||||
|
||||
export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
({ children, classNames, maxWidth, style, width, ...props }: TextareaProps, ref) => {
|
||||
(
|
||||
{
|
||||
children,
|
||||
classNames,
|
||||
maxWidth,
|
||||
size = 'sm',
|
||||
style,
|
||||
variant = 'default',
|
||||
width,
|
||||
...props
|
||||
}: TextareaProps,
|
||||
ref,
|
||||
) => {
|
||||
return (
|
||||
<MantineTextarea
|
||||
classNames={{
|
||||
@@ -21,7 +33,10 @@ export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
...classNames,
|
||||
}}
|
||||
ref={ref}
|
||||
size={size}
|
||||
spellCheck={false}
|
||||
style={{ maxWidth, width, ...style }}
|
||||
variant={variant}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -1384,6 +1384,10 @@ export type RandomSongListQuery = {
|
||||
|
||||
export type RandomSongListResponse = SongListResponse;
|
||||
|
||||
export type RefreshItemsArgs = BaseEndpointArgs & { query: { ids: string[] } };
|
||||
|
||||
export type RefreshItemsResponse = null;
|
||||
|
||||
export type ScrobbleArgs = BaseEndpointArgs & {
|
||||
query: ScrobbleQuery;
|
||||
};
|
||||
@@ -1577,6 +1581,7 @@ export type ControllerEndpoint = {
|
||||
getUserList?: (args: UserListArgs) => Promise<UserListResponse>;
|
||||
jukeboxControl?: (args: JukeboxControlArgs) => Promise<JukeboxControlResponse>;
|
||||
movePlaylistItem?: (args: MoveItemArgs) => Promise<void>;
|
||||
refreshItems: (args: RefreshItemsArgs) => Promise<RefreshItemsResponse>;
|
||||
removeFromPlaylist: (args: RemoveFromPlaylistArgs) => Promise<RemoveFromPlaylistResponse>;
|
||||
replacePlaylist: (args: ReplacePlaylistArgs) => Promise<ReplacePlaylistResponse>;
|
||||
savePlayQueue: (args: SaveQueueArgs) => Promise<void>;
|
||||
@@ -1745,6 +1750,7 @@ export type InternalControllerEndpoint = {
|
||||
args: ReplaceApiClientProps<JukeboxControlArgs>,
|
||||
) => Promise<JukeboxControlResponse>;
|
||||
movePlaylistItem?: (args: ReplaceApiClientProps<MoveItemArgs>) => Promise<void>;
|
||||
refreshItems: (args: ReplaceApiClientProps<RefreshItemsArgs>) => Promise<RefreshItemsResponse>;
|
||||
removeFromPlaylist: (
|
||||
args: ReplaceApiClientProps<RemoveFromPlaylistArgs>,
|
||||
) => Promise<RemoveFromPlaylistResponse>;
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
export type ArtworkKind = 'common' | 'mixed' | 'none';
|
||||
|
||||
export type ArtworkOp = { bytes: Uint8Array; mimeType: string; type: 'set' } | { type: 'clear' };
|
||||
|
||||
export interface BatchFileError {
|
||||
error: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface BatchProgress {
|
||||
processed: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface ReadLocalImageResult extends IpcResult {
|
||||
data?: string;
|
||||
mimeType?: string;
|
||||
}
|
||||
|
||||
export interface ReadSongMetadataBatchResult extends IpcResult {
|
||||
artworkData?: string;
|
||||
artworkKind: ArtworkKind;
|
||||
artworkMimeType?: string;
|
||||
failedFiles?: BatchFileError[];
|
||||
multiValueKeys?: string[];
|
||||
readCount?: number;
|
||||
tagSummary?: Record<string, null | TagValue>;
|
||||
totalCount?: number;
|
||||
}
|
||||
|
||||
/** Subset of `window.api.utils` consumed by the metadata editor. */
|
||||
export interface TagEditorUtils {
|
||||
cancelReadSongMetadata: () => void;
|
||||
offBatchProgress: (cb: (event: unknown, data: BatchProgress) => void) => void;
|
||||
onBatchProgress: (cb: (event: unknown, data: BatchProgress) => void) => void;
|
||||
readLocalImage: (filePath: string) => Promise<ReadLocalImageResult>;
|
||||
readSongMetadataBatch: (filePaths: string[]) => Promise<ReadSongMetadataBatchResult>;
|
||||
writeSongTagsBatch: (
|
||||
filePaths: string[],
|
||||
edits: Record<string, TagValue>,
|
||||
removed: string[],
|
||||
artworkOp?: ArtworkOp,
|
||||
) => Promise<WriteSongTagsBatchResult>;
|
||||
}
|
||||
|
||||
export type TagValue = string | string[];
|
||||
|
||||
export interface WriteSongTagsBatchResult extends IpcResult {
|
||||
failedFiles?: BatchFileError[];
|
||||
}
|
||||
|
||||
interface IpcResult {
|
||||
error?: string;
|
||||
success: boolean;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/** Guesses image MIME type from a file path or filename extension. */
|
||||
export const getImageMimeTypeFromPath = (filePath: string): string => {
|
||||
if (/\.png$/i.test(filePath)) return 'image/png';
|
||||
if (/\.gif$/i.test(filePath)) return 'image/gif';
|
||||
if (/\.webp$/i.test(filePath)) return 'image/webp';
|
||||
return 'image/jpeg';
|
||||
};
|
||||
Vendored
+6
-1
@@ -1,7 +1,8 @@
|
||||
import { ActionIconSize, PillVariant } from '@mantine/core';
|
||||
import { ActionIconSize, PillVariant, TagsInputVariant } from '@mantine/core';
|
||||
|
||||
type ExtendedActionIconSize = 'compact-md' | 'compact-sm' | 'compact-xs' | ActionIconSize;
|
||||
type ExtendedPillVariant = 'outline' | PillVariant;
|
||||
type ExtendedTagsInputVariant = 'default' | 'filled' | TagsInputVariant;
|
||||
|
||||
declare module '@mantine/core' {
|
||||
export interface ActionIconProps {
|
||||
@@ -11,4 +12,8 @@ declare module '@mantine/core' {
|
||||
export interface PillProps {
|
||||
variant?: ExtendedPillVariant;
|
||||
}
|
||||
|
||||
export interface TagsInputProps {
|
||||
variant?: ExtendedTagsInputVariant;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user