mirror of
https://github.com/jeffvli/feishin.git
synced 2026-07-23 02:46:40 +02:00
Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bd328657d8 | |||
| b0e66fcf45 | |||
| aa5309c54f | |||
| b11ad3e069 | |||
| 2777a33f9a | |||
| 9ff705a483 | |||
| 82d64489ce | |||
| 48ff8ed6ff | |||
| 38ad3e5d5a | |||
| 45c660dcc5 | |||
| 4b15bcb7d7 | |||
| 107727c65d | |||
| 030ecca898 | |||
| dd0b645ee4 | |||
| 5ed4397f8f | |||
| 17dba85baf | |||
| 149c7c40b2 | |||
| c594545658 | |||
| cde2b85148 | |||
| 8917a5d076 | |||
| 56f05ddafc | |||
| aa12585b20 | |||
| f3966e3211 | |||
| 651fe46519 | |||
| 7e82ab146e |
@@ -5,6 +5,9 @@ WORKDIR /app
|
|||||||
# Copy package.json first to cache node_modules
|
# Copy package.json first to cache node_modules
|
||||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .
|
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .
|
||||||
|
|
||||||
|
# Match packageManager in package.json (pnpm is not bundled in the Node image)
|
||||||
|
RUN corepack enable && corepack prepare pnpm@11.5.2 --activate
|
||||||
|
|
||||||
RUN pnpm install
|
RUN pnpm install
|
||||||
|
|
||||||
# Copy code and build with cached modules
|
# Copy code and build with cached modules
|
||||||
|
|||||||
@@ -120,6 +120,7 @@
|
|||||||
"md5": "^2.3.0",
|
"md5": "^2.3.0",
|
||||||
"motion": "^12.40.0",
|
"motion": "^12.40.0",
|
||||||
"mpris-service": "^2.1.2",
|
"mpris-service": "^2.1.2",
|
||||||
|
"music-metadata": "^11.12.3",
|
||||||
"nanoid": "^3.3.12",
|
"nanoid": "^3.3.12",
|
||||||
"node-mpv": "github:jeffvli/Node-MPV#32b4d64395289ad710c41d481d2707a7acfc228f",
|
"node-mpv": "github:jeffvli/Node-MPV#32b4d64395289ad710c41d481d2707a7acfc228f",
|
||||||
"overlayscrollbars": "^2.16.0",
|
"overlayscrollbars": "^2.16.0",
|
||||||
@@ -139,6 +140,7 @@
|
|||||||
"react-window-v2": "npm:react-window@^2.2.7",
|
"react-window-v2": "npm:react-window@^2.2.7",
|
||||||
"semver": "^7.8.2",
|
"semver": "^7.8.2",
|
||||||
"string-to-color": "^2.2.2",
|
"string-to-color": "^2.2.2",
|
||||||
|
"taglib-wasm": "^1.5.0",
|
||||||
"wavesurfer.js": "^7.12.7",
|
"wavesurfer.js": "^7.12.7",
|
||||||
"ws": "^8.21.0",
|
"ws": "^8.21.0",
|
||||||
"zod": "^3.25.76",
|
"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
|
electron-winstaller: true
|
||||||
esbuild: true
|
esbuild: true
|
||||||
minimumReleaseAge: 1440
|
minimumReleaseAge: 1440
|
||||||
|
minimumReleaseAgeExclude:
|
||||||
|
- 'taglib-wasm@1.3.0'
|
||||||
overrides:
|
overrides:
|
||||||
'xml2js': '0.5.0'
|
'xml2js': '0.5.0'
|
||||||
'react-router': '7.14.0'
|
'react-router': '7.14.0'
|
||||||
|
|||||||
@@ -161,6 +161,7 @@
|
|||||||
"trackGain": "Track gain",
|
"trackGain": "Track gain",
|
||||||
"trackPeak": "Track peak",
|
"trackPeak": "Track peak",
|
||||||
"translation": "Translation",
|
"translation": "Translation",
|
||||||
|
"undo": "Undo",
|
||||||
"unknown": "Unknown",
|
"unknown": "Unknown",
|
||||||
"version": "Version",
|
"version": "Version",
|
||||||
"year": "Year",
|
"year": "Year",
|
||||||
@@ -541,7 +542,8 @@
|
|||||||
"goTo": "Go to",
|
"goTo": "Go to",
|
||||||
"goToAlbum": "Go to $t(entity.album, {\"count\": 1})",
|
"goToAlbum": "Go to $t(entity.album, {\"count\": 1})",
|
||||||
"goToAlbumArtist": "Go to $t(entity.albumArtist, {\"count\": 1})",
|
"goToAlbumArtist": "Go to $t(entity.albumArtist, {\"count\": 1})",
|
||||||
"showDetails": "Get info"
|
"showDetails": "Get info",
|
||||||
|
"editMetadata": "Edit Metadata"
|
||||||
},
|
},
|
||||||
"fullscreenPlayer": {
|
"fullscreenPlayer": {
|
||||||
"config": {
|
"config": {
|
||||||
@@ -602,9 +604,27 @@
|
|||||||
"title": "$t(common.home)"
|
"title": "$t(common.home)"
|
||||||
},
|
},
|
||||||
"itemDetail": {
|
"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",
|
"copyPath": "Copy path to clipboard",
|
||||||
"copiedPath": "Path copied successfully",
|
"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": {
|
"playlist": {
|
||||||
"reorder": "Reordering only enabled when sorting by ID"
|
"reorder": "Reordering only enabled when sorting by ID"
|
||||||
|
|||||||
@@ -4,4 +4,5 @@ import './player';
|
|||||||
import './remote';
|
import './remote';
|
||||||
import './settings';
|
import './settings';
|
||||||
import './discord-rpc';
|
import './discord-rpc';
|
||||||
|
import './tag-editor';
|
||||||
import './visualizer';
|
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 log from 'electron-log/main';
|
||||||
import { AppImageUpdater, autoUpdater, MacUpdater, NsisUpdater } from 'electron-updater';
|
import { AppImageUpdater, autoUpdater, MacUpdater, NsisUpdater } from 'electron-updater';
|
||||||
import { access, constants } from 'fs';
|
import { access, constants } from 'fs';
|
||||||
|
import { createRequire } from 'module';
|
||||||
import path, { join } from 'path';
|
import path, { join } from 'path';
|
||||||
import semver from 'semver';
|
import semver from 'semver';
|
||||||
|
|
||||||
@@ -39,6 +40,10 @@ import { autoUpdaterLogInterface, createLog, hotkeyToElectronAccelerator } from
|
|||||||
import { disableAutoUpdates, isLinux, isMacOS, isWindows } from '/@/main/env';
|
import { disableAutoUpdates, isLinux, isMacOS, isWindows } from '/@/main/env';
|
||||||
import { PlayerRepeat, PlayerStatus, PlayerType, TitleTheme } from '/@/shared/types/types';
|
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: {
|
const ALPHA_UPDATER_CONFIG: {
|
||||||
bucket: string;
|
bucket: string;
|
||||||
channel: 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';
|
import { disableAutoUpdates, isLinux, isMacOS, isWindows } from '../main/env';
|
||||||
|
|
||||||
@@ -6,6 +15,35 @@ const openItem = async (path: string) => {
|
|||||||
return ipcRenderer.invoke('open-item', path);
|
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 () => {
|
const openApplicationDirectory = async () => {
|
||||||
return ipcRenderer.invoke('open-application-directory');
|
return ipcRenderer.invoke('open-application-directory');
|
||||||
};
|
};
|
||||||
@@ -117,6 +155,7 @@ const rendererUpdateAvailable = (cb: (version: string) => void) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const utils = {
|
export const utils = {
|
||||||
|
cancelReadSongMetadata,
|
||||||
checkForUpdates,
|
checkForUpdates,
|
||||||
customCssUpdatedListener,
|
customCssUpdatedListener,
|
||||||
disableAutoUpdates,
|
disableAutoUpdates,
|
||||||
@@ -127,10 +166,14 @@ export const utils = {
|
|||||||
isMacOS,
|
isMacOS,
|
||||||
isWindows,
|
isWindows,
|
||||||
mainMessageListener,
|
mainMessageListener,
|
||||||
|
offBatchProgress,
|
||||||
|
onBatchProgress,
|
||||||
openApplicationDirectory,
|
openApplicationDirectory,
|
||||||
openCustomCssFolder,
|
openCustomCssFolder,
|
||||||
openItem,
|
openItem,
|
||||||
playerErrorListener,
|
playerErrorListener,
|
||||||
|
readLocalImage,
|
||||||
|
readSongMetadataBatch,
|
||||||
rendererOpenCommandPalette,
|
rendererOpenCommandPalette,
|
||||||
rendererOpenManageServers,
|
rendererOpenManageServers,
|
||||||
rendererOpenReleaseNotes,
|
rendererOpenReleaseNotes,
|
||||||
@@ -143,6 +186,7 @@ export const utils = {
|
|||||||
setInputFocused,
|
setInputFocused,
|
||||||
startPowerSaveBlocker,
|
startPowerSaveBlocker,
|
||||||
stopPowerSaveBlocker,
|
stopPowerSaveBlocker,
|
||||||
|
writeSongTagsBatch,
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Utils = typeof utils;
|
export type Utils = typeof utils;
|
||||||
|
|||||||
@@ -786,6 +786,18 @@ export const controller: GeneralController = {
|
|||||||
server.type,
|
server.type,
|
||||||
)?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } }));
|
)?.(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) {
|
removeFromPlaylist(args) {
|
||||||
const server = getServerById(args.apiClientProps.serverId);
|
const server = getServerById(args.apiClientProps.serverId);
|
||||||
|
|
||||||
|
|||||||
@@ -301,6 +301,18 @@ export const contract = c.router({
|
|||||||
400: jfType._response.error,
|
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: {
|
removeFavorite: {
|
||||||
body: jfType._parameters.favorite,
|
body: jfType._parameters.favorite,
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
|
|||||||
@@ -1556,6 +1556,21 @@ export const JellyfinController: InternalControllerEndpoint = {
|
|||||||
throw new Error('Failed to move item in playlist');
|
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) => {
|
removeFromPlaylist: async (args) => {
|
||||||
const { apiClientProps, query } = args;
|
const { apiClientProps, query } = args;
|
||||||
|
|
||||||
|
|||||||
@@ -1027,6 +1027,7 @@ export const NavidromeController: InternalControllerEndpoint = {
|
|||||||
throw new Error('Failed to move item in playlist');
|
throw new Error('Failed to move item in playlist');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
refreshItems: SubsonicController.refreshItems,
|
||||||
removeFromPlaylist: async (args) => {
|
removeFromPlaylist: async (args) => {
|
||||||
const { apiClientProps, query } = args;
|
const { apiClientProps, query } = args;
|
||||||
|
|
||||||
|
|||||||
@@ -186,6 +186,14 @@ export const contract = c.router({
|
|||||||
200: ssType._response.randomSongList,
|
200: ssType._response.randomSongList,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
getScanStatus: {
|
||||||
|
method: 'GET',
|
||||||
|
path: 'getScanStatus.view',
|
||||||
|
query: ssType._parameters.getScanStatus,
|
||||||
|
responses: {
|
||||||
|
200: ssType._response.getScanStatus,
|
||||||
|
},
|
||||||
|
},
|
||||||
getServerInfo: {
|
getServerInfo: {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
path: 'getOpenSubsonicExtensions.view',
|
path: 'getOpenSubsonicExtensions.view',
|
||||||
@@ -345,6 +353,14 @@ export const contract = c.router({
|
|||||||
200: ssType._response.setRating,
|
200: ssType._response.setRating,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
startScan: {
|
||||||
|
method: 'GET',
|
||||||
|
path: 'startScan.view',
|
||||||
|
query: ssType._parameters.startScan,
|
||||||
|
responses: {
|
||||||
|
200: ssType._response.startScan,
|
||||||
|
},
|
||||||
|
},
|
||||||
updateInternetRadioStation: {
|
updateInternetRadioStation: {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
path: 'updateInternetRadioStation.view',
|
path: 'updateInternetRadioStation.view',
|
||||||
|
|||||||
@@ -2082,6 +2082,17 @@ export const SubsonicController: InternalControllerEndpoint = {
|
|||||||
|
|
||||||
return res.body;
|
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 }) => {
|
removeFromPlaylist: async ({ apiClientProps, query }) => {
|
||||||
const res = await ssApiClient(apiClientProps).updatePlaylist({
|
const res = await ssApiClient(apiClientProps).updatePlaylist({
|
||||||
query: {
|
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 { AddToPlaylistAction } from '/@/renderer/features/context-menu/actions/add-to-playlist-action';
|
||||||
import { DownloadAction } from '/@/renderer/features/context-menu/actions/download-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 { GetInfoAction } from '/@/renderer/features/context-menu/actions/get-info-action';
|
||||||
import { GoToAction } from '/@/renderer/features/context-menu/actions/go-to-action';
|
import { GoToAction } from '/@/renderer/features/context-menu/actions/go-to-action';
|
||||||
import { PlayAction } from '/@/renderer/features/context-menu/actions/play-action';
|
import { PlayAction } from '/@/renderer/features/context-menu/actions/play-action';
|
||||||
@@ -41,6 +42,7 @@ export const AlbumContextMenu = ({ items, type }: AlbumContextMenuProps) => {
|
|||||||
<ContextMenu.Divider />
|
<ContextMenu.Divider />
|
||||||
<GoToAction items={items} />
|
<GoToAction items={items} />
|
||||||
<ContextMenu.Divider />
|
<ContextMenu.Divider />
|
||||||
|
<EditMetadataAction albumIds={ids} />
|
||||||
<GetInfoAction disabled={items.length === 0} items={items} />
|
<GetInfoAction disabled={items.length === 0} items={items} />
|
||||||
</ContextMenu.Content>
|
</ContextMenu.Content>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useMemo } from 'react';
|
|||||||
|
|
||||||
import { AddToPlaylistAction } from '/@/renderer/features/context-menu/actions/add-to-playlist-action';
|
import { AddToPlaylistAction } from '/@/renderer/features/context-menu/actions/add-to-playlist-action';
|
||||||
import { DownloadAction } from '/@/renderer/features/context-menu/actions/download-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 { GetInfoAction } from '/@/renderer/features/context-menu/actions/get-info-action';
|
||||||
import { GoToAction } from '/@/renderer/features/context-menu/actions/go-to-action';
|
import { GoToAction } from '/@/renderer/features/context-menu/actions/go-to-action';
|
||||||
import { PlayAction } from '/@/renderer/features/context-menu/actions/play-action';
|
import { PlayAction } from '/@/renderer/features/context-menu/actions/play-action';
|
||||||
@@ -46,6 +47,7 @@ export const PlaylistSongContextMenu = ({ items, type }: PlaylistSongContextMenu
|
|||||||
<GoToAction items={items} />
|
<GoToAction items={items} />
|
||||||
<ShowInFileExplorerAction items={items} />
|
<ShowInFileExplorerAction items={items} />
|
||||||
<ContextMenu.Divider />
|
<ContextMenu.Divider />
|
||||||
|
<EditMetadataAction songs={items} />
|
||||||
<GetInfoAction disabled={items.length === 0} items={items} />
|
<GetInfoAction disabled={items.length === 0} items={items} />
|
||||||
</ContextMenu.Content>
|
</ContextMenu.Content>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useMemo } from 'react';
|
|||||||
|
|
||||||
import { AddToPlaylistAction } from '/@/renderer/features/context-menu/actions/add-to-playlist-action';
|
import { AddToPlaylistAction } from '/@/renderer/features/context-menu/actions/add-to-playlist-action';
|
||||||
import { DownloadAction } from '/@/renderer/features/context-menu/actions/download-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 { GetInfoAction } from '/@/renderer/features/context-menu/actions/get-info-action';
|
||||||
import { GoToAction } from '/@/renderer/features/context-menu/actions/go-to-action';
|
import { GoToAction } from '/@/renderer/features/context-menu/actions/go-to-action';
|
||||||
import { PlayAction } from '/@/renderer/features/context-menu/actions/play-action';
|
import { PlayAction } from '/@/renderer/features/context-menu/actions/play-action';
|
||||||
@@ -43,6 +44,7 @@ export const SongContextMenu = ({ items, type }: SongContextMenuProps) => {
|
|||||||
<GoToAction items={items} />
|
<GoToAction items={items} />
|
||||||
<ShowInFileExplorerAction items={items} />
|
<ShowInFileExplorerAction items={items} />
|
||||||
<ContextMenu.Divider />
|
<ContextMenu.Divider />
|
||||||
|
<EditMetadataAction songs={items} />
|
||||||
<GetInfoAction disabled={items.length === 0} items={items} />
|
<GetInfoAction disabled={items.length === 0} items={items} />
|
||||||
</ContextMenu.Content>
|
</ContextMenu.Content>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -448,7 +448,7 @@ export const PitchControls = () => {
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Text size="xs" style={{ fontFamily: 'monospace' }} ta="center" w="60px">
|
<Text size="xs" style={{ fontFamily: 'monospace' }} ta="center" w="60px">
|
||||||
{speed.toFixed(2)}% {speedToPitch(speed) > 0 && '+'}
|
{speed.toFixed(2)}x {speedToPitch(speed) > 0 && '+'}
|
||||||
{speedToPitch(speed) == 0 && '±'}
|
{speedToPitch(speed) == 0 && '±'}
|
||||||
{speedToPitch(speed).toFixed(2)}st
|
{speedToPitch(speed).toFixed(2)}st
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@@ -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>
|
</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 = {
|
const appRouterModals = {
|
||||||
addToPlaylist: AddToPlaylistContextModal,
|
addToPlaylist: AddToPlaylistContextModal,
|
||||||
base: BaseContextModal,
|
base: BaseContextModal,
|
||||||
|
editMetadata: SongEditContextModal,
|
||||||
lyricsSettings: LyricsSettingsContextModal,
|
lyricsSettings: LyricsSettingsContextModal,
|
||||||
saveAndReplace: SaveAndReplaceContextModal,
|
saveAndReplace: SaveAndReplaceContextModal,
|
||||||
settings: SettingsContextModal,
|
settings: SettingsContextModal,
|
||||||
|
|||||||
@@ -744,6 +744,12 @@ const AutoDJSettingsSchema = z.object({
|
|||||||
timing: z.number(),
|
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
|
* This schema is used for validation of the imported settings json
|
||||||
*/
|
*/
|
||||||
@@ -767,6 +773,7 @@ export const ValidationSettingsStateSchema = z.object({
|
|||||||
z.literal('window'),
|
z.literal('window'),
|
||||||
z.string(),
|
z.string(),
|
||||||
]),
|
]),
|
||||||
|
tagEditor: TagEditorSettingsSchema,
|
||||||
visualizer: VisualizerSettingsSchema,
|
visualizer: VisualizerSettingsSchema,
|
||||||
window: WindowSettingsSchema,
|
window: WindowSettingsSchema,
|
||||||
});
|
});
|
||||||
@@ -1988,6 +1995,11 @@ const initialState: SettingsState = {
|
|||||||
username: 'feishin',
|
username: 'feishin',
|
||||||
},
|
},
|
||||||
tab: 'general',
|
tab: 'general',
|
||||||
|
tagEditor: {
|
||||||
|
favoriteValues: {},
|
||||||
|
multiValueFields: ['artist', 'albumArtist', 'genre', 'composer', 'lyricist'],
|
||||||
|
triggerRescan: true,
|
||||||
|
},
|
||||||
visualizer: {
|
visualizer: {
|
||||||
audiomotionanalyzer: {
|
audiomotionanalyzer: {
|
||||||
alphaBars: false,
|
alphaBars: false,
|
||||||
@@ -2677,6 +2689,8 @@ export const useCssSettings = () => useSettingsStore((state) => state.css, shall
|
|||||||
export const useQueryBuilderSettings = () =>
|
export const useQueryBuilderSettings = () =>
|
||||||
useSettingsStore((state) => state.queryBuilder, shallow);
|
useSettingsStore((state) => state.queryBuilder, shallow);
|
||||||
|
|
||||||
|
export const useTagEditorSettings = () => useSettingsStore((state) => state.tagEditor, shallow);
|
||||||
|
|
||||||
const getSettingsStoreVersion = () => useSettingsStore.persist.getOptions().version!;
|
const getSettingsStoreVersion = () => useSettingsStore.persist.getOptions().version!;
|
||||||
|
|
||||||
export const useSettingsForExport = (): SettingsState & { version: number } =>
|
export const useSettingsForExport = (): SettingsState & { version: number } =>
|
||||||
|
|||||||
@@ -347,6 +347,19 @@ const scrobbleParameters = z.object({
|
|||||||
|
|
||||||
const scrobble = z.null();
|
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({
|
const search3 = z.object({
|
||||||
searchResult3: z
|
searchResult3: z
|
||||||
.object({
|
.object({
|
||||||
@@ -907,6 +920,7 @@ export const ssType = {
|
|||||||
getMusicDirectory: getMusicDirectoryParameters,
|
getMusicDirectory: getMusicDirectoryParameters,
|
||||||
getPlaylist: getPlaylistParameters,
|
getPlaylist: getPlaylistParameters,
|
||||||
getPlaylists: getPlaylistsParameters,
|
getPlaylists: getPlaylistsParameters,
|
||||||
|
getScanStatus: getScanStatusParameters,
|
||||||
getSong: getSongParameters,
|
getSong: getSongParameters,
|
||||||
getSongsByGenre: getSongsByGenreParameters,
|
getSongsByGenre: getSongsByGenreParameters,
|
||||||
getStarred: getStarredParameters,
|
getStarred: getStarredParameters,
|
||||||
@@ -923,6 +937,7 @@ export const ssType = {
|
|||||||
setRating: setRatingParameters,
|
setRating: setRatingParameters,
|
||||||
similarSongs: similarSongsParameters,
|
similarSongs: similarSongsParameters,
|
||||||
similarSongs2: similarSongs2Parameters,
|
similarSongs2: similarSongs2Parameters,
|
||||||
|
startScan: startScanParameters,
|
||||||
structuredLyrics: structuredLyricsParameters,
|
structuredLyrics: structuredLyricsParameters,
|
||||||
topSongsList: topSongsListParameters,
|
topSongsList: topSongsListParameters,
|
||||||
updateInternetRadioStation: updateInternetRadioStationParameters,
|
updateInternetRadioStation: updateInternetRadioStationParameters,
|
||||||
@@ -956,6 +971,7 @@ export const ssType = {
|
|||||||
getMusicDirectory,
|
getMusicDirectory,
|
||||||
getPlaylist,
|
getPlaylist,
|
||||||
getPlaylists,
|
getPlaylists,
|
||||||
|
getScanStatus,
|
||||||
getSong,
|
getSong,
|
||||||
getSongsByGenre,
|
getSongsByGenre,
|
||||||
getStarred,
|
getStarred,
|
||||||
@@ -981,6 +997,7 @@ export const ssType = {
|
|||||||
similarSongs,
|
similarSongs,
|
||||||
similarSongs2,
|
similarSongs2,
|
||||||
song,
|
song,
|
||||||
|
startScan,
|
||||||
structuredLyrics,
|
structuredLyrics,
|
||||||
topSongsList,
|
topSongsList,
|
||||||
updateInternetRadioStation,
|
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,
|
LuTimerOff,
|
||||||
LuTrash,
|
LuTrash,
|
||||||
LuTriangleAlert,
|
LuTriangleAlert,
|
||||||
|
LuUndo2,
|
||||||
LuUpload,
|
LuUpload,
|
||||||
LuUser,
|
LuUser,
|
||||||
LuUserPen,
|
LuUserPen,
|
||||||
@@ -361,6 +362,7 @@ export const AppIcon = {
|
|||||||
themeDark: LuMoon,
|
themeDark: LuMoon,
|
||||||
themeLight: LuSun,
|
themeLight: LuSun,
|
||||||
track: LuMusic2,
|
track: LuMusic2,
|
||||||
|
undo: LuUndo2,
|
||||||
unfavorite: LuHeartCrack,
|
unfavorite: LuHeartCrack,
|
||||||
unpin: LuPinOff,
|
unpin: LuPinOff,
|
||||||
upload: LuUpload,
|
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 {
|
.root {
|
||||||
transition: width 0.3s ease-in-out;
|
transition: width 0.3s ease-in-out;
|
||||||
|
|
||||||
&[data-disabled='true'] {
|
|
||||||
opacity: 0.6;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.input {
|
.input {
|
||||||
color: var(--theme-colors-surface-foreground);
|
width: 100%;
|
||||||
background: var(--theme-colors-surface);
|
|
||||||
border: 1px solid transparent;
|
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,
|
||||||
@@ -20,3 +25,7 @@
|
|||||||
.label {
|
.label {
|
||||||
margin-bottom: var(--theme-spacing-sm);
|
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>(
|
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 (
|
return (
|
||||||
<MantineTextarea
|
<MantineTextarea
|
||||||
classNames={{
|
classNames={{
|
||||||
@@ -21,7 +33,10 @@ export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
|
|||||||
...classNames,
|
...classNames,
|
||||||
}}
|
}}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
size={size}
|
||||||
|
spellCheck={false}
|
||||||
style={{ maxWidth, width, ...style }}
|
style={{ maxWidth, width, ...style }}
|
||||||
|
variant={variant}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -1384,6 +1384,10 @@ export type RandomSongListQuery = {
|
|||||||
|
|
||||||
export type RandomSongListResponse = SongListResponse;
|
export type RandomSongListResponse = SongListResponse;
|
||||||
|
|
||||||
|
export type RefreshItemsArgs = BaseEndpointArgs & { query: { ids: string[] } };
|
||||||
|
|
||||||
|
export type RefreshItemsResponse = null;
|
||||||
|
|
||||||
export type ScrobbleArgs = BaseEndpointArgs & {
|
export type ScrobbleArgs = BaseEndpointArgs & {
|
||||||
query: ScrobbleQuery;
|
query: ScrobbleQuery;
|
||||||
};
|
};
|
||||||
@@ -1577,6 +1581,7 @@ export type ControllerEndpoint = {
|
|||||||
getUserList?: (args: UserListArgs) => Promise<UserListResponse>;
|
getUserList?: (args: UserListArgs) => Promise<UserListResponse>;
|
||||||
jukeboxControl?: (args: JukeboxControlArgs) => Promise<JukeboxControlResponse>;
|
jukeboxControl?: (args: JukeboxControlArgs) => Promise<JukeboxControlResponse>;
|
||||||
movePlaylistItem?: (args: MoveItemArgs) => Promise<void>;
|
movePlaylistItem?: (args: MoveItemArgs) => Promise<void>;
|
||||||
|
refreshItems: (args: RefreshItemsArgs) => Promise<RefreshItemsResponse>;
|
||||||
removeFromPlaylist: (args: RemoveFromPlaylistArgs) => Promise<RemoveFromPlaylistResponse>;
|
removeFromPlaylist: (args: RemoveFromPlaylistArgs) => Promise<RemoveFromPlaylistResponse>;
|
||||||
replacePlaylist: (args: ReplacePlaylistArgs) => Promise<ReplacePlaylistResponse>;
|
replacePlaylist: (args: ReplacePlaylistArgs) => Promise<ReplacePlaylistResponse>;
|
||||||
savePlayQueue: (args: SaveQueueArgs) => Promise<void>;
|
savePlayQueue: (args: SaveQueueArgs) => Promise<void>;
|
||||||
@@ -1745,6 +1750,7 @@ export type InternalControllerEndpoint = {
|
|||||||
args: ReplaceApiClientProps<JukeboxControlArgs>,
|
args: ReplaceApiClientProps<JukeboxControlArgs>,
|
||||||
) => Promise<JukeboxControlResponse>;
|
) => Promise<JukeboxControlResponse>;
|
||||||
movePlaylistItem?: (args: ReplaceApiClientProps<MoveItemArgs>) => Promise<void>;
|
movePlaylistItem?: (args: ReplaceApiClientProps<MoveItemArgs>) => Promise<void>;
|
||||||
|
refreshItems: (args: ReplaceApiClientProps<RefreshItemsArgs>) => Promise<RefreshItemsResponse>;
|
||||||
removeFromPlaylist: (
|
removeFromPlaylist: (
|
||||||
args: ReplaceApiClientProps<RemoveFromPlaylistArgs>,
|
args: ReplaceApiClientProps<RemoveFromPlaylistArgs>,
|
||||||
) => Promise<RemoveFromPlaylistResponse>;
|
) => 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 ExtendedActionIconSize = 'compact-md' | 'compact-sm' | 'compact-xs' | ActionIconSize;
|
||||||
type ExtendedPillVariant = 'outline' | PillVariant;
|
type ExtendedPillVariant = 'outline' | PillVariant;
|
||||||
|
type ExtendedTagsInputVariant = 'default' | 'filled' | TagsInputVariant;
|
||||||
|
|
||||||
declare module '@mantine/core' {
|
declare module '@mantine/core' {
|
||||||
export interface ActionIconProps {
|
export interface ActionIconProps {
|
||||||
@@ -11,4 +12,8 @@ declare module '@mantine/core' {
|
|||||||
export interface PillProps {
|
export interface PillProps {
|
||||||
variant?: ExtendedPillVariant;
|
variant?: ExtendedPillVariant;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TagsInputProps {
|
||||||
|
variant?: ExtendedTagsInputVariant;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user