Prototype
This commit is contained in:
@@ -1,14 +1,16 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Dialog } from 'primereact/dialog';
|
||||
import { Button } from 'primereact/button';
|
||||
import { DataTable } from 'primereact/datatable';
|
||||
import { Column } from 'primereact/column';
|
||||
import { InputText } from 'primereact/inputtext';
|
||||
import { InputNumber } from 'primereact/inputnumber';
|
||||
import { Checkbox } from 'primereact/checkbox';
|
||||
|
||||
function CoverThumb({ url, alt }) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
useEffect(() => {
|
||||
setFailed(false);
|
||||
}, [url]);
|
||||
if (!url || failed) {
|
||||
return <div className="poster-thumb-lg poster-fallback">-</div>;
|
||||
}
|
||||
@@ -17,16 +19,46 @@ function CoverThumb({ url, alt }) {
|
||||
src={url}
|
||||
alt={alt}
|
||||
className="poster-thumb-lg"
|
||||
loading="eager"
|
||||
decoding="sync"
|
||||
onError={() => setFailed(true)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function formatDurationMs(ms) {
|
||||
const totalSec = Math.round((ms || 0) / 1000);
|
||||
const min = Math.floor(totalSec / 60);
|
||||
const sec = totalSec % 60;
|
||||
return `${min}:${String(sec).padStart(2, '0')}`;
|
||||
const COVER_PRELOAD_TIMEOUT_MS = 3000;
|
||||
|
||||
function preloadCoverImage(url) {
|
||||
const src = String(url || '').trim();
|
||||
if (!src) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
const image = new Image();
|
||||
let settled = false;
|
||||
const cleanup = () => {
|
||||
image.onload = null;
|
||||
image.onerror = null;
|
||||
};
|
||||
const done = () => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
cleanup();
|
||||
resolve();
|
||||
};
|
||||
const timer = window.setTimeout(done, COVER_PRELOAD_TIMEOUT_MS);
|
||||
image.onload = () => {
|
||||
window.clearTimeout(timer);
|
||||
done();
|
||||
};
|
||||
image.onerror = () => {
|
||||
window.clearTimeout(timer);
|
||||
done();
|
||||
};
|
||||
image.src = src;
|
||||
});
|
||||
}
|
||||
|
||||
export default function CdMetadataDialog({
|
||||
@@ -35,20 +67,22 @@ export default function CdMetadataDialog({
|
||||
onHide,
|
||||
onSubmit,
|
||||
onSearch,
|
||||
onFetchRelease,
|
||||
busy
|
||||
}) {
|
||||
const [selected, setSelected] = useState(null);
|
||||
const [query, setQuery] = useState('');
|
||||
const [results, setResults] = useState([]);
|
||||
const [searchBusy, setSearchBusy] = useState(false);
|
||||
const searchRunRef = useRef(0);
|
||||
|
||||
// Manual metadata inputs
|
||||
const [manualTitle, setManualTitle] = useState('');
|
||||
const [manualArtist, setManualArtist] = useState('');
|
||||
const [manualYear, setManualYear] = useState(null);
|
||||
|
||||
// Per-track title editing
|
||||
// Track titles are pre-filled from MusicBrainz and edited in the next step.
|
||||
const [trackTitles, setTrackTitles] = useState({});
|
||||
const [selectedTrackPositions, setSelectedTrackPositions] = useState(new Set());
|
||||
|
||||
const tocTracks = Array.isArray(context?.tracks) ? context.tracks : [];
|
||||
|
||||
@@ -57,20 +91,18 @@ export default function CdMetadataDialog({
|
||||
return;
|
||||
}
|
||||
setSelected(null);
|
||||
setQuery(context?.detectedTitle || '');
|
||||
setQuery('');
|
||||
setManualTitle(context?.detectedTitle || '');
|
||||
setManualArtist('');
|
||||
setManualYear(null);
|
||||
setResults([]);
|
||||
setSearchBusy(false);
|
||||
|
||||
const titles = {};
|
||||
const positions = new Set();
|
||||
for (const t of tocTracks) {
|
||||
titles[t.position] = t.title || `Track ${t.position}`;
|
||||
positions.add(t.position);
|
||||
}
|
||||
setTrackTitles(titles);
|
||||
setSelectedTrackPositions(positions);
|
||||
}, [visible, context]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -100,48 +132,79 @@ export default function CdMetadataDialog({
|
||||
}, [selected]);
|
||||
|
||||
const handleSearch = async () => {
|
||||
if (!query.trim()) {
|
||||
const trimmedQuery = query.trim();
|
||||
if (!trimmedQuery) {
|
||||
return;
|
||||
}
|
||||
const searchResults = await onSearch(query.trim());
|
||||
setResults(searchResults || []);
|
||||
setSelected(null);
|
||||
};
|
||||
|
||||
const handleToggleTrack = (position) => {
|
||||
setSelectedTrackPositions((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(position)) {
|
||||
next.delete(position);
|
||||
} else {
|
||||
next.add(position);
|
||||
setSearchBusy(true);
|
||||
const searchRunId = searchRunRef.current + 1;
|
||||
searchRunRef.current = searchRunId;
|
||||
try {
|
||||
const searchResults = await onSearch(trimmedQuery);
|
||||
const normalizedResults = Array.isArray(searchResults) ? searchResults : [];
|
||||
await Promise.all(normalizedResults.map((item) => preloadCoverImage(item?.coverArtUrl)));
|
||||
if (searchRunRef.current !== searchRunId) {
|
||||
return;
|
||||
}
|
||||
setResults(normalizedResults);
|
||||
setSelected(null);
|
||||
} finally {
|
||||
if (searchRunRef.current === searchRunId) {
|
||||
setSearchBusy(false);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleToggleAll = () => {
|
||||
if (selectedTrackPositions.size === tocTracks.length) {
|
||||
setSelectedTrackPositions(new Set());
|
||||
} else {
|
||||
setSelectedTrackPositions(new Set(tocTracks.map((t) => t.position)));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const tracks = tocTracks.map((t) => ({
|
||||
position: t.position,
|
||||
title: trackTitles[t.position] || `Track ${t.position}`,
|
||||
selected: selectedTrackPositions.has(t.position)
|
||||
}));
|
||||
const normalizeTrackText = (value) => String(value || '').replace(/\s+/g, ' ').trim();
|
||||
let releaseDetails = selected;
|
||||
if (selected?.mbId && (!Array.isArray(selected?.tracks) || selected.tracks.length === 0) && typeof onFetchRelease === 'function') {
|
||||
const fetched = await onFetchRelease(selected.mbId);
|
||||
if (fetched && typeof fetched === 'object') {
|
||||
releaseDetails = fetched;
|
||||
}
|
||||
}
|
||||
|
||||
const releaseTracks = Array.isArray(releaseDetails?.tracks) ? releaseDetails.tracks : [];
|
||||
const releaseTracksByPosition = new Map();
|
||||
releaseTracks.forEach((track, index) => {
|
||||
const parsedPosition = Number(track?.position);
|
||||
const normalizedPosition = Number.isFinite(parsedPosition) && parsedPosition > 0
|
||||
? Math.trunc(parsedPosition)
|
||||
: index + 1;
|
||||
if (!releaseTracksByPosition.has(normalizedPosition)) {
|
||||
releaseTracksByPosition.set(normalizedPosition, track);
|
||||
}
|
||||
});
|
||||
|
||||
const tracks = tocTracks.map((t, index) => {
|
||||
const position = Number(t.position);
|
||||
const byPosition = releaseTracksByPosition.get(position);
|
||||
const byIndex = releaseTracks[index];
|
||||
return {
|
||||
position,
|
||||
title: normalizeTrackText(
|
||||
byPosition?.title
|
||||
|| byIndex?.title
|
||||
|| trackTitles[t.position]
|
||||
) || `Track ${t.position}`,
|
||||
artist: normalizeTrackText(
|
||||
byPosition?.artist
|
||||
|| byIndex?.artist
|
||||
|| manualArtist.trim()
|
||||
|| releaseDetails?.artist
|
||||
) || null,
|
||||
selected: true
|
||||
};
|
||||
});
|
||||
|
||||
const payload = {
|
||||
jobId: context.jobId,
|
||||
title: manualTitle.trim() || context?.detectedTitle || 'Audio CD',
|
||||
artist: manualArtist.trim() || null,
|
||||
year: manualYear || null,
|
||||
mbId: selected?.mbId || null,
|
||||
coverUrl: selected?.coverArtUrl || null,
|
||||
mbId: releaseDetails?.mbId || selected?.mbId || null,
|
||||
coverUrl: releaseDetails?.coverArtUrl || selected?.coverArtUrl || null,
|
||||
tracks
|
||||
};
|
||||
|
||||
@@ -159,9 +222,6 @@ export default function CdMetadataDialog({
|
||||
</div>
|
||||
);
|
||||
|
||||
const allSelected = tocTracks.length > 0 && selectedTrackPositions.size === tocTracks.length;
|
||||
const tracksBlocking = tocTracks.length > 0 && selectedTrackPositions.size === 0;
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
header="CD-Metadaten auswählen"
|
||||
@@ -180,7 +240,12 @@ export default function CdMetadataDialog({
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||||
placeholder="Album / Interpret suchen"
|
||||
/>
|
||||
<Button label="MusicBrainz Suche" icon="pi pi-search" onClick={handleSearch} loading={busy} />
|
||||
<Button
|
||||
label="MusicBrainz Suche"
|
||||
icon="pi pi-search"
|
||||
onClick={handleSearch}
|
||||
loading={busy || searchBusy}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{results.length > 0 ? (
|
||||
@@ -226,42 +291,11 @@ export default function CdMetadataDialog({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Track selection */}
|
||||
{/* Track selection/editing moved to CD-Rip configuration panel */}
|
||||
{tocTracks.length > 0 ? (
|
||||
<>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', marginTop: '1rem', marginBottom: '0.25rem' }}>
|
||||
<h4 style={{ margin: 0 }}>Tracks ({tocTracks.length})</h4>
|
||||
<Button
|
||||
label={allSelected ? 'Alle abwählen' : 'Alle auswählen'}
|
||||
size="small"
|
||||
severity="secondary"
|
||||
outlined
|
||||
onClick={handleToggleAll}
|
||||
/>
|
||||
</div>
|
||||
<div className="cd-track-list">
|
||||
{tocTracks.map((track) => (
|
||||
<div key={track.position} className="cd-track-row">
|
||||
<Checkbox
|
||||
checked={selectedTrackPositions.has(track.position)}
|
||||
onChange={() => handleToggleTrack(track.position)}
|
||||
inputId={`track-${track.position}`}
|
||||
/>
|
||||
<span className="cd-track-num">{String(track.position).padStart(2, '0')}</span>
|
||||
<InputText
|
||||
value={trackTitles[track.position] ?? `Track ${track.position}`}
|
||||
onChange={(e) => setTrackTitles((prev) => ({ ...prev, [track.position]: e.target.value }))}
|
||||
className="cd-track-title-input"
|
||||
placeholder={`Track ${track.position}`}
|
||||
disabled={!selectedTrackPositions.has(track.position)}
|
||||
/>
|
||||
<span className="cd-track-duration">
|
||||
{track.durationMs ? formatDurationMs(track.durationMs) : '-'}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
<small style={{ display: 'block', marginTop: '0.9rem' }}>
|
||||
{tocTracks.length} Tracks erkannt. Auswahl/Feinschliff (Checkboxen, Interpret, Titel, Länge) erfolgt im nächsten Schritt in der Job-Übersicht.
|
||||
</small>
|
||||
) : null}
|
||||
|
||||
<div className="dialog-actions" style={{ marginTop: '1rem' }}>
|
||||
@@ -271,7 +305,7 @@ export default function CdMetadataDialog({
|
||||
icon="pi pi-arrow-right"
|
||||
onClick={handleSubmit}
|
||||
loading={busy}
|
||||
disabled={tracksBlocking || (!manualTitle.trim() && !context?.detectedTitle)}
|
||||
disabled={!manualTitle.trim() && !context?.detectedTitle}
|
||||
/>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
@@ -4,7 +4,10 @@ import { Slider } from 'primereact/slider';
|
||||
import { Button } from 'primereact/button';
|
||||
import { ProgressBar } from 'primereact/progressbar';
|
||||
import { Tag } from 'primereact/tag';
|
||||
import { InputText } from 'primereact/inputtext';
|
||||
import { InputNumber } from 'primereact/inputnumber';
|
||||
import { CD_FORMATS, CD_FORMAT_SCHEMAS, getDefaultFormatOptions } from '../config/cdFormatSchemas';
|
||||
import { api } from '../api/client';
|
||||
|
||||
function isFieldVisible(field, values) {
|
||||
if (!field.showWhen) {
|
||||
@@ -51,6 +54,147 @@ function FormatField({ field, value, onChange }) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function quoteShellArg(value) {
|
||||
const text = String(value || '');
|
||||
if (!text) {
|
||||
return "''";
|
||||
}
|
||||
if (/^[a-zA-Z0-9_./:-]+$/.test(text)) {
|
||||
return text;
|
||||
}
|
||||
return `'${text.replace(/'/g, "'\\''")}'`;
|
||||
}
|
||||
|
||||
function buildCommandLine(cmd, args = []) {
|
||||
const normalizedArgs = Array.isArray(args) ? args : [];
|
||||
return [quoteShellArg(cmd), ...normalizedArgs.map((arg) => quoteShellArg(arg))].join(' ');
|
||||
}
|
||||
|
||||
function buildEncodeCommandPreview({
|
||||
format,
|
||||
formatOptions,
|
||||
wavFile,
|
||||
outFile,
|
||||
trackTitle,
|
||||
trackArtist,
|
||||
albumTitle,
|
||||
year,
|
||||
trackNo
|
||||
}) {
|
||||
const normalizedFormat = String(format || '').trim().toLowerCase();
|
||||
const title = String(trackTitle || `Track ${trackNo || 1}`).trim() || `Track ${trackNo || 1}`;
|
||||
const artist = String(trackArtist || '').trim();
|
||||
const album = String(albumTitle || '').trim();
|
||||
const releaseYear = year == null ? '' : String(year).trim();
|
||||
const number = String(trackNo || 1);
|
||||
|
||||
if (normalizedFormat === 'wav') {
|
||||
return buildCommandLine('mv', [wavFile, outFile]);
|
||||
}
|
||||
|
||||
if (normalizedFormat === 'flac') {
|
||||
const level = Math.max(0, Math.min(8, Number(formatOptions?.flacCompression ?? 5)));
|
||||
return buildCommandLine('flac', [
|
||||
`--compression-level-${level}`,
|
||||
'--tag', `TITLE=${title}`,
|
||||
'--tag', `ARTIST=${artist}`,
|
||||
'--tag', `ALBUM=${album}`,
|
||||
'--tag', `DATE=${releaseYear}`,
|
||||
'--tag', `TRACKNUMBER=${number}`,
|
||||
wavFile,
|
||||
'-o', outFile
|
||||
]);
|
||||
}
|
||||
|
||||
if (normalizedFormat === 'mp3') {
|
||||
const mode = String(formatOptions?.mp3Mode || 'cbr').trim().toLowerCase();
|
||||
const args = ['--id3v2-only', '--noreplaygain'];
|
||||
if (mode === 'vbr') {
|
||||
const quality = Math.max(0, Math.min(9, Number(formatOptions?.mp3Quality ?? 4)));
|
||||
args.push('-V', String(quality));
|
||||
} else {
|
||||
const bitrate = Number(formatOptions?.mp3Bitrate ?? 192);
|
||||
args.push('-b', String(bitrate));
|
||||
}
|
||||
args.push(
|
||||
'--tt', title,
|
||||
'--ta', artist,
|
||||
'--tl', album,
|
||||
'--ty', releaseYear,
|
||||
'--tn', number,
|
||||
wavFile,
|
||||
outFile
|
||||
);
|
||||
return buildCommandLine('lame', args);
|
||||
}
|
||||
|
||||
if (normalizedFormat === 'opus') {
|
||||
const bitrate = Math.max(32, Math.min(512, Number(formatOptions?.opusBitrate ?? 160)));
|
||||
const complexity = Math.max(0, Math.min(10, Number(formatOptions?.opusComplexity ?? 10)));
|
||||
return buildCommandLine('opusenc', [
|
||||
'--bitrate', String(bitrate),
|
||||
'--comp', String(complexity),
|
||||
'--title', title,
|
||||
'--artist', artist,
|
||||
'--album', album,
|
||||
'--date', releaseYear,
|
||||
'--tracknumber', number,
|
||||
wavFile,
|
||||
outFile
|
||||
]);
|
||||
}
|
||||
|
||||
if (normalizedFormat === 'ogg') {
|
||||
const quality = Math.max(-1, Math.min(10, Number(formatOptions?.oggQuality ?? 6)));
|
||||
return buildCommandLine('oggenc', [
|
||||
'-q', String(quality),
|
||||
'-t', title,
|
||||
'-a', artist,
|
||||
'-l', album,
|
||||
'-d', releaseYear,
|
||||
'-N', number,
|
||||
'-o', outFile,
|
||||
wavFile
|
||||
]);
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
function normalizePosition(value) {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
return null;
|
||||
}
|
||||
return Math.trunc(parsed);
|
||||
}
|
||||
|
||||
function normalizeTrackText(value) {
|
||||
return String(value || '').replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
function normalizeYear(value) {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
return null;
|
||||
}
|
||||
return Math.trunc(parsed);
|
||||
}
|
||||
|
||||
function formatTrackDuration(track) {
|
||||
const durationMs = Number(track?.durationMs);
|
||||
const durationSec = Number(track?.durationSec);
|
||||
const totalSec = Number.isFinite(durationMs) && durationMs > 0
|
||||
? Math.round(durationMs / 1000)
|
||||
: (Number.isFinite(durationSec) && durationSec > 0 ? Math.round(durationSec) : 0);
|
||||
if (totalSec <= 0) {
|
||||
return '-';
|
||||
}
|
||||
const min = Math.floor(totalSec / 60);
|
||||
const sec = totalSec % 60;
|
||||
return `${min}:${String(sec).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export default function CdRipConfigPanel({
|
||||
pipeline,
|
||||
onStart,
|
||||
@@ -67,27 +211,117 @@ export default function CdRipConfigPanel({
|
||||
|
||||
const [format, setFormat] = useState('flac');
|
||||
const [formatOptions, setFormatOptions] = useState(() => getDefaultFormatOptions('flac'));
|
||||
const [settingsCdparanoiaCmd, setSettingsCdparanoiaCmd] = useState('');
|
||||
|
||||
// Track selection: position → boolean
|
||||
const [selectedTracks, setSelectedTracks] = useState(() => {
|
||||
const map = {};
|
||||
for (const t of tracks) {
|
||||
map[t.position] = true;
|
||||
const position = normalizePosition(t?.position);
|
||||
if (!position) {
|
||||
continue;
|
||||
}
|
||||
map[position] = t?.selected !== false;
|
||||
}
|
||||
return map;
|
||||
});
|
||||
// Editable track metadata in job overview (artist/title).
|
||||
const [trackFields, setTrackFields] = useState(() => {
|
||||
const map = {};
|
||||
const defaultArtist = normalizeTrackText(selectedMeta?.artist);
|
||||
for (const t of tracks) {
|
||||
const position = normalizePosition(t?.position);
|
||||
if (!position) {
|
||||
continue;
|
||||
}
|
||||
const fallbackTitle = `Track ${position}`;
|
||||
map[position] = {
|
||||
title: normalizeTrackText(t?.title) || fallbackTitle,
|
||||
artist: normalizeTrackText(t?.artist) || defaultArtist
|
||||
};
|
||||
}
|
||||
return map;
|
||||
});
|
||||
const [metaFields, setMetaFields] = useState(() => ({
|
||||
title: normalizeTrackText(selectedMeta?.title) || normalizeTrackText(context?.detectedTitle) || '',
|
||||
artist: normalizeTrackText(selectedMeta?.artist) || '',
|
||||
year: normalizeYear(selectedMeta?.year)
|
||||
}));
|
||||
|
||||
useEffect(() => {
|
||||
setFormatOptions(getDefaultFormatOptions(format));
|
||||
}, [format]);
|
||||
|
||||
useEffect(() => {
|
||||
const map = {};
|
||||
for (const t of tracks) {
|
||||
map[t.position] = selectedTracks[t.position] !== false;
|
||||
}
|
||||
setSelectedTracks(map);
|
||||
}, [tracks.length]);
|
||||
setMetaFields({
|
||||
title: normalizeTrackText(selectedMeta?.title) || normalizeTrackText(context?.detectedTitle) || '',
|
||||
artist: normalizeTrackText(selectedMeta?.artist) || '',
|
||||
year: normalizeYear(selectedMeta?.year)
|
||||
});
|
||||
}, [context?.jobId, selectedMeta?.title, selectedMeta?.artist, selectedMeta?.year, context?.detectedTitle]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const refreshSettings = async () => {
|
||||
try {
|
||||
const response = await api.getSettings({ forceRefresh: true });
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
const value = String(response?.settings?.cdparanoia_command || '').trim();
|
||||
setSettingsCdparanoiaCmd(value);
|
||||
} catch (_error) {
|
||||
if (!cancelled) {
|
||||
setSettingsCdparanoiaCmd('');
|
||||
}
|
||||
}
|
||||
};
|
||||
refreshSettings();
|
||||
const intervalId = setInterval(refreshSettings, 5000);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(intervalId);
|
||||
};
|
||||
}, [context?.jobId]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedTracks((prev) => {
|
||||
const next = {};
|
||||
for (const t of tracks) {
|
||||
const normalized = normalizePosition(t?.position);
|
||||
if (!normalized) {
|
||||
continue;
|
||||
}
|
||||
if (prev[normalized] !== undefined) {
|
||||
next[normalized] = prev[normalized];
|
||||
} else {
|
||||
next[normalized] = t?.selected !== false;
|
||||
}
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, [tracks]);
|
||||
|
||||
useEffect(() => {
|
||||
const defaultArtist = normalizeTrackText(selectedMeta?.artist);
|
||||
setTrackFields((prev) => {
|
||||
const next = {};
|
||||
for (const t of tracks) {
|
||||
const position = normalizePosition(t?.position);
|
||||
if (!position) {
|
||||
continue;
|
||||
}
|
||||
const previous = prev[position] || {};
|
||||
const fallbackTitle = normalizeTrackText(t?.title) || `Track ${position}`;
|
||||
const fallbackArtist = normalizeTrackText(t?.artist) || defaultArtist;
|
||||
next[position] = {
|
||||
title: normalizeTrackText(previous.title) || fallbackTitle,
|
||||
artist: normalizeTrackText(previous.artist) || fallbackArtist
|
||||
};
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, [tracks, selectedMeta?.artist]);
|
||||
|
||||
const handleFormatOptionChange = (key, value) => {
|
||||
setFormatOptions((prev) => ({ ...prev, [key]: value }));
|
||||
@@ -98,17 +332,73 @@ export default function CdRipConfigPanel({
|
||||
};
|
||||
|
||||
const handleToggleAll = () => {
|
||||
const allSelected = tracks.every((t) => selectedTracks[t.position] !== false);
|
||||
const allSelected = tracks.every((t) => {
|
||||
const position = normalizePosition(t?.position);
|
||||
return position ? selectedTracks[position] !== false : false;
|
||||
});
|
||||
const map = {};
|
||||
for (const t of tracks) {
|
||||
map[t.position] = !allSelected;
|
||||
const position = normalizePosition(t?.position);
|
||||
if (!position) {
|
||||
continue;
|
||||
}
|
||||
map[position] = !allSelected;
|
||||
}
|
||||
setSelectedTracks(map);
|
||||
};
|
||||
|
||||
const handleTrackFieldChange = (position, key, value) => {
|
||||
if (!position) {
|
||||
return;
|
||||
}
|
||||
setTrackFields((prev) => ({
|
||||
...prev,
|
||||
[position]: {
|
||||
...(prev[position] || {}),
|
||||
[key]: value
|
||||
}
|
||||
}));
|
||||
};
|
||||
|
||||
const handleMetaFieldChange = (key, value) => {
|
||||
setMetaFields((prev) => ({
|
||||
...prev,
|
||||
[key]: value
|
||||
}));
|
||||
};
|
||||
|
||||
const handleStart = () => {
|
||||
const selected = tracks
|
||||
.filter((t) => selectedTracks[t.position] !== false)
|
||||
const albumTitle = normalizeTrackText(metaFields?.title)
|
||||
|| normalizeTrackText(selectedMeta?.title)
|
||||
|| normalizeTrackText(context?.detectedTitle)
|
||||
|| 'Audio CD';
|
||||
const albumArtist = normalizeTrackText(metaFields?.artist)
|
||||
|| normalizeTrackText(selectedMeta?.artist)
|
||||
|| null;
|
||||
const albumYear = normalizeYear(metaFields?.year);
|
||||
|
||||
const normalizedTracks = tracks
|
||||
.map((t) => {
|
||||
const position = normalizePosition(t?.position);
|
||||
if (!position) {
|
||||
return null;
|
||||
}
|
||||
const baseTitle = normalizeTrackText(t?.title) || `Track ${position}`;
|
||||
const baseArtist = normalizeTrackText(t?.artist) || normalizeTrackText(selectedMeta?.artist);
|
||||
const edited = trackFields[position] || {};
|
||||
const title = normalizeTrackText(edited.title) || baseTitle;
|
||||
const artist = normalizeTrackText(edited.artist) || baseArtist || null;
|
||||
return {
|
||||
position,
|
||||
title,
|
||||
artist,
|
||||
selected: selectedTracks[position] !== false
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
const selected = normalizedTracks
|
||||
.filter((t) => t.selected)
|
||||
.map((t) => t.position);
|
||||
|
||||
if (selected.length === 0) {
|
||||
@@ -118,14 +408,74 @@ export default function CdRipConfigPanel({
|
||||
onStart && onStart({
|
||||
format,
|
||||
formatOptions,
|
||||
selectedTracks: selected
|
||||
selectedTracks: selected,
|
||||
tracks: normalizedTracks,
|
||||
metadata: {
|
||||
title: albumTitle,
|
||||
artist: albumArtist,
|
||||
year: albumYear
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const schema = CD_FORMAT_SCHEMAS[format] || { fields: [] };
|
||||
const visibleFields = schema.fields.filter((f) => isFieldVisible(f, formatOptions));
|
||||
|
||||
const selectedCount = tracks.filter((t) => selectedTracks[t.position] !== false).length;
|
||||
const selectedCount = tracks.filter((t) => {
|
||||
const position = normalizePosition(t?.position);
|
||||
return position ? selectedTracks[position] !== false : false;
|
||||
}).length;
|
||||
const firstSelectedTrack = tracks.find((t) => {
|
||||
const position = normalizePosition(t?.position);
|
||||
return position ? selectedTracks[position] !== false : false;
|
||||
}) || null;
|
||||
const devicePath = String(context?.devicePath || context?.device?.path || '').trim();
|
||||
const cdparanoiaCmd = settingsCdparanoiaCmd
|
||||
|| String(context?.cdparanoiaCmd || '').trim()
|
||||
|| 'cdparanoia';
|
||||
const rawWavDir = String(context?.rawWavDir || '').trim();
|
||||
const commandTrackNumber = firstSelectedTrack ? Math.trunc(Number(firstSelectedTrack.position)) : null;
|
||||
const commandWavTarget = commandTrackNumber
|
||||
? (
|
||||
rawWavDir
|
||||
? `${rawWavDir}/track${String(commandTrackNumber).padStart(2, '0')}.cdda.wav`
|
||||
: `<temp>/track${String(commandTrackNumber).padStart(2, '0')}.cdda.wav`
|
||||
)
|
||||
: '<temp>/trackNN.cdda.wav';
|
||||
const cdparanoiaCommandPreview = [
|
||||
quoteShellArg(cdparanoiaCmd),
|
||||
'-d',
|
||||
quoteShellArg(devicePath || '<device>'),
|
||||
String(commandTrackNumber || '<trackNr>'),
|
||||
quoteShellArg(commandWavTarget)
|
||||
].join(' ');
|
||||
const commandTrackNo = commandTrackNumber || 1;
|
||||
const commandTrackFields = trackFields[commandTrackNo] || {};
|
||||
const commandTrackTitle = normalizeTrackText(commandTrackFields.title)
|
||||
|| normalizeTrackText(firstSelectedTrack?.title)
|
||||
|| `Track ${commandTrackNo}`;
|
||||
const commandTrackArtist = normalizeTrackText(commandTrackFields.artist)
|
||||
|| normalizeTrackText(firstSelectedTrack?.artist)
|
||||
|| normalizeTrackText(metaFields?.artist)
|
||||
|| normalizeTrackText(selectedMeta?.artist)
|
||||
|| 'Unknown Artist';
|
||||
const commandAlbumTitle = normalizeTrackText(metaFields?.title)
|
||||
|| normalizeTrackText(selectedMeta?.title)
|
||||
|| normalizeTrackText(context?.detectedTitle)
|
||||
|| 'Audio CD';
|
||||
const commandYear = normalizeYear(metaFields?.year) ?? normalizeYear(selectedMeta?.year);
|
||||
const commandOutputFile = `<output>/track${String(commandTrackNo).padStart(2, '0')}.${format}`;
|
||||
const encodeCommandPreview = buildEncodeCommandPreview({
|
||||
format,
|
||||
formatOptions,
|
||||
wavFile: commandWavTarget,
|
||||
outFile: commandOutputFile,
|
||||
trackTitle: commandTrackTitle,
|
||||
trackArtist: commandTrackArtist,
|
||||
albumTitle: commandAlbumTitle,
|
||||
year: commandYear,
|
||||
trackNo: commandTrackNo
|
||||
});
|
||||
const progress = Number(pipeline?.progress ?? 0);
|
||||
const clampedProgress = Math.max(0, Math.min(100, progress));
|
||||
const eta = String(pipeline?.eta || '').trim();
|
||||
@@ -140,6 +490,8 @@ export default function CdRipConfigPanel({
|
||||
severity={isFinished ? 'success' : 'info'}
|
||||
/>
|
||||
{statusText ? <small>{statusText}</small> : null}
|
||||
<small>1) {cdparanoiaCommandPreview}</small>
|
||||
{encodeCommandPreview ? <small>2) {encodeCommandPreview}</small> : null}
|
||||
{!isFinished ? (
|
||||
<>
|
||||
<ProgressBar value={clampedProgress} />
|
||||
@@ -165,12 +517,32 @@ export default function CdRipConfigPanel({
|
||||
<div className="cd-rip-config-panel">
|
||||
<h4 style={{ marginTop: 0, marginBottom: '0.75rem' }}>CD-Rip Konfiguration</h4>
|
||||
|
||||
{selectedMeta.title ? (
|
||||
<div className="cd-meta-summary">
|
||||
<strong>{selectedMeta.artist ? `${selectedMeta.artist} – ` : ''}{selectedMeta.title}</strong>
|
||||
{selectedMeta.year ? <span> ({selectedMeta.year})</span> : null}
|
||||
<div className="cd-meta-summary">
|
||||
<strong>Album-Metadaten</strong>
|
||||
<div className="metadata-grid" style={{ marginTop: '0.55rem' }}>
|
||||
<InputText
|
||||
value={metaFields.title}
|
||||
onChange={(e) => handleMetaFieldChange('title', e.target.value)}
|
||||
placeholder="Album"
|
||||
disabled={busy}
|
||||
/>
|
||||
<InputText
|
||||
value={metaFields.artist}
|
||||
onChange={(e) => handleMetaFieldChange('artist', e.target.value)}
|
||||
placeholder="Interpret"
|
||||
disabled={busy}
|
||||
/>
|
||||
<InputNumber
|
||||
value={metaFields.year}
|
||||
onValueChange={(e) => handleMetaFieldChange('year', normalizeYear(e.value))}
|
||||
placeholder="Jahr"
|
||||
useGrouping={false}
|
||||
min={1900}
|
||||
max={2100}
|
||||
disabled={busy}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Format selection */}
|
||||
<div className="cd-format-field">
|
||||
@@ -210,30 +582,74 @@ export default function CdRipConfigPanel({
|
||||
/>
|
||||
</div>
|
||||
<div className="cd-track-list">
|
||||
{tracks.map((track) => {
|
||||
const isSelected = selectedTracks[track.position] !== false;
|
||||
const totalSec = Math.round((track.durationMs || track.durationSec * 1000 || 0) / 1000);
|
||||
const min = Math.floor(totalSec / 60);
|
||||
const sec = totalSec % 60;
|
||||
const duration = totalSec > 0 ? `${min}:${String(sec).padStart(2, '0')}` : '-';
|
||||
return (
|
||||
<button
|
||||
key={track.position}
|
||||
type="button"
|
||||
className={`cd-track-row selectable${isSelected ? ' selected' : ''}`}
|
||||
onClick={() => !busy && handleToggleTrack(track.position)}
|
||||
disabled={busy}
|
||||
>
|
||||
<span className="cd-track-num">{String(track.position).padStart(2, '0')}</span>
|
||||
<span className="cd-track-title">{track.title || `Track ${track.position}`}</span>
|
||||
<span className="cd-track-duration">{duration}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<table className="cd-track-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="check">Auswahl</th>
|
||||
<th className="num">Nr</th>
|
||||
<th className="artist">Interpret</th>
|
||||
<th className="title">Titel</th>
|
||||
<th className="duration">Länge</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tracks.map((track) => {
|
||||
const position = normalizePosition(track?.position);
|
||||
if (!position) {
|
||||
return null;
|
||||
}
|
||||
const isSelected = selectedTracks[position] !== false;
|
||||
const fields = trackFields[position] || {};
|
||||
const titleValue = normalizeTrackText(fields.title)
|
||||
|| normalizeTrackText(track?.title)
|
||||
|| `Track ${position}`;
|
||||
const artistValue = normalizeTrackText(fields.artist)
|
||||
|| normalizeTrackText(track?.artist)
|
||||
|| normalizeTrackText(selectedMeta?.artist);
|
||||
const duration = formatTrackDuration(track);
|
||||
return (
|
||||
<tr key={position} className={isSelected ? 'selected' : ''}>
|
||||
<td className="check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={() => handleToggleTrack(position)}
|
||||
disabled={busy}
|
||||
/>
|
||||
</td>
|
||||
<td className="num">{String(position).padStart(2, '0')}</td>
|
||||
<td className="artist">
|
||||
<InputText
|
||||
value={artistValue}
|
||||
onChange={(e) => handleTrackFieldChange(position, 'artist', e.target.value)}
|
||||
placeholder="Interpret"
|
||||
disabled={busy || !isSelected}
|
||||
/>
|
||||
</td>
|
||||
<td className="title">
|
||||
<InputText
|
||||
value={titleValue}
|
||||
onChange={(e) => handleTrackFieldChange(position, 'title', e.target.value)}
|
||||
placeholder={`Track ${position}`}
|
||||
disabled={busy || !isSelected}
|
||||
/>
|
||||
</td>
|
||||
<td className="duration">{duration}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="cd-format-field">
|
||||
<label>Prompt-/Befehlskette (Preview)</label>
|
||||
<small>1) {cdparanoiaCommandPreview}</small>
|
||||
{encodeCommandPreview ? <small>2) {encodeCommandPreview}</small> : null}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="actions-row" style={{ marginTop: '1rem' }}>
|
||||
<Button
|
||||
|
||||
Reference in New Issue
Block a user