First test
This commit is contained in:
@@ -0,0 +1,282 @@
|
||||
import { useEffect, 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 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')}`;
|
||||
}
|
||||
|
||||
export default function CdMetadataDialog({
|
||||
visible,
|
||||
context,
|
||||
onHide,
|
||||
onSubmit,
|
||||
onSearch,
|
||||
busy
|
||||
}) {
|
||||
const [selected, setSelected] = useState(null);
|
||||
const [query, setQuery] = useState('');
|
||||
const [extraResults, setExtraResults] = useState([]);
|
||||
|
||||
// Manual metadata inputs
|
||||
const [manualTitle, setManualTitle] = useState('');
|
||||
const [manualArtist, setManualArtist] = useState('');
|
||||
const [manualYear, setManualYear] = useState(null);
|
||||
|
||||
// Per-track title editing
|
||||
const [trackTitles, setTrackTitles] = useState({});
|
||||
const [selectedTrackPositions, setSelectedTrackPositions] = useState(new Set());
|
||||
|
||||
const tocTracks = Array.isArray(context?.tracks) ? context.tracks : [];
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) {
|
||||
return;
|
||||
}
|
||||
setSelected(null);
|
||||
setQuery(context?.detectedTitle || '');
|
||||
setManualTitle(context?.detectedTitle || '');
|
||||
setManualArtist('');
|
||||
setManualYear(null);
|
||||
setExtraResults([]);
|
||||
|
||||
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(() => {
|
||||
if (!selected) {
|
||||
return;
|
||||
}
|
||||
setManualTitle(selected.title || '');
|
||||
setManualArtist(selected.artist || '');
|
||||
setManualYear(selected.year || null);
|
||||
|
||||
// Pre-fill track titles from the MusicBrainz result
|
||||
if (Array.isArray(selected.tracks) && selected.tracks.length > 0) {
|
||||
const titles = {};
|
||||
for (const t of selected.tracks) {
|
||||
if (t.position <= tocTracks.length) {
|
||||
titles[t.position] = t.title || `Track ${t.position}`;
|
||||
}
|
||||
}
|
||||
// Fill any remaining tracks not in MB result
|
||||
for (const t of tocTracks) {
|
||||
if (!titles[t.position]) {
|
||||
titles[t.position] = t.title || `Track ${t.position}`;
|
||||
}
|
||||
}
|
||||
setTrackTitles(titles);
|
||||
}
|
||||
}, [selected]);
|
||||
|
||||
const allMbRows = [
|
||||
...(Array.isArray(context?.mbCandidates) ? context.mbCandidates : []),
|
||||
...extraResults
|
||||
].filter(Boolean);
|
||||
|
||||
// Deduplicate by mbId
|
||||
const mbRows = [];
|
||||
const seen = new Set();
|
||||
for (const r of allMbRows) {
|
||||
if (r.mbId && !seen.has(r.mbId)) {
|
||||
seen.add(r.mbId);
|
||||
mbRows.push(r);
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearch = async () => {
|
||||
if (!query.trim()) {
|
||||
return;
|
||||
}
|
||||
const results = await onSearch(query.trim());
|
||||
setExtraResults(results || []);
|
||||
};
|
||||
|
||||
const handleToggleTrack = (position) => {
|
||||
setSelectedTrackPositions((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(position)) {
|
||||
next.delete(position);
|
||||
} else {
|
||||
next.add(position);
|
||||
}
|
||||
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 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,
|
||||
tracks
|
||||
};
|
||||
|
||||
await onSubmit(payload);
|
||||
};
|
||||
|
||||
const mbTitleBody = (row) => (
|
||||
<div className="mb-result-row">
|
||||
{row.coverArtUrl ? (
|
||||
<img src={row.coverArtUrl} alt={row.title} className="poster-thumb-lg" />
|
||||
) : (
|
||||
<div className="poster-thumb-lg poster-fallback">-</div>
|
||||
)}
|
||||
<div>
|
||||
<div><strong>{row.title}</strong></div>
|
||||
<small>{row.artist}{row.year ? ` | ${row.year}` : ''}</small>
|
||||
{row.label ? <small> | {row.label}</small> : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const allSelected = tocTracks.length > 0 && selectedTrackPositions.size === tocTracks.length;
|
||||
const noneSelected = selectedTrackPositions.size === 0;
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
header="CD-Metadaten auswählen"
|
||||
visible={visible}
|
||||
onHide={onHide}
|
||||
style={{ width: '58rem', maxWidth: '97vw' }}
|
||||
className="cd-metadata-dialog"
|
||||
breakpoints={{ '1200px': '92vw', '768px': '96vw', '560px': '98vw' }}
|
||||
modal
|
||||
>
|
||||
{/* MusicBrainz search */}
|
||||
<div className="search-row">
|
||||
<InputText
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||||
placeholder="Album / Interpret suchen"
|
||||
/>
|
||||
<Button label="MusicBrainz Suche" icon="pi pi-search" onClick={handleSearch} loading={busy} />
|
||||
</div>
|
||||
|
||||
{mbRows.length > 0 ? (
|
||||
<div className="table-scroll-wrap table-scroll-medium">
|
||||
<DataTable
|
||||
value={mbRows}
|
||||
selectionMode="single"
|
||||
selection={selected}
|
||||
onSelectionChange={(e) => setSelected(e.value)}
|
||||
dataKey="mbId"
|
||||
size="small"
|
||||
scrollable
|
||||
scrollHeight="16rem"
|
||||
emptyMessage="Keine Treffer"
|
||||
>
|
||||
<Column header="Album" body={mbTitleBody} />
|
||||
<Column field="year" header="Jahr" style={{ width: '6rem' }} />
|
||||
<Column field="country" header="Land" style={{ width: '6rem' }} />
|
||||
</DataTable>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Manual metadata */}
|
||||
<h4 style={{ marginTop: '1rem', marginBottom: '0.5rem' }}>Metadaten</h4>
|
||||
<div className="metadata-grid">
|
||||
<InputText
|
||||
value={manualTitle}
|
||||
onChange={(e) => setManualTitle(e.target.value)}
|
||||
placeholder="Album-Titel"
|
||||
/>
|
||||
<InputText
|
||||
value={manualArtist}
|
||||
onChange={(e) => setManualArtist(e.target.value)}
|
||||
placeholder="Interpret / Band"
|
||||
/>
|
||||
<InputNumber
|
||||
value={manualYear}
|
||||
onValueChange={(e) => setManualYear(e.value)}
|
||||
placeholder="Jahr"
|
||||
useGrouping={false}
|
||||
min={1900}
|
||||
max={2100}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Track selection */}
|
||||
{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>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<div className="dialog-actions" style={{ marginTop: '1rem' }}>
|
||||
<Button label="Abbrechen" severity="secondary" text onClick={onHide} />
|
||||
<Button
|
||||
label="Weiter"
|
||||
icon="pi pi-arrow-right"
|
||||
onClick={handleSubmit}
|
||||
loading={busy}
|
||||
disabled={noneSelected || (!manualTitle.trim() && !context?.detectedTitle)}
|
||||
/>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Dropdown } from 'primereact/dropdown';
|
||||
import { Slider } from 'primereact/slider';
|
||||
import { Button } from 'primereact/button';
|
||||
import { ProgressBar } from 'primereact/progressbar';
|
||||
import { Tag } from 'primereact/tag';
|
||||
import { CD_FORMATS, CD_FORMAT_SCHEMAS, getDefaultFormatOptions } from '../config/cdFormatSchemas';
|
||||
|
||||
function isFieldVisible(field, values) {
|
||||
if (!field.showWhen) {
|
||||
return true;
|
||||
}
|
||||
return values[field.showWhen.field] === field.showWhen.value;
|
||||
}
|
||||
|
||||
function FormatField({ field, value, onChange }) {
|
||||
if (field.type === 'slider') {
|
||||
return (
|
||||
<div className="cd-format-field">
|
||||
<label>
|
||||
{field.label}: <strong>{value}</strong>
|
||||
</label>
|
||||
{field.description ? <small>{field.description}</small> : null}
|
||||
<Slider
|
||||
value={value}
|
||||
onChange={(e) => onChange(field.key, e.value)}
|
||||
min={field.min}
|
||||
max={field.max}
|
||||
step={field.step || 1}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (field.type === 'select') {
|
||||
return (
|
||||
<div className="cd-format-field">
|
||||
<label>{field.label}</label>
|
||||
{field.description ? <small>{field.description}</small> : null}
|
||||
<Dropdown
|
||||
value={value}
|
||||
options={field.options}
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
onChange={(e) => onChange(field.key, e.value)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function CdRipConfigPanel({
|
||||
pipeline,
|
||||
onStart,
|
||||
onCancel,
|
||||
busy
|
||||
}) {
|
||||
const context = pipeline?.context && typeof pipeline.context === 'object' ? pipeline.context : {};
|
||||
const tracks = Array.isArray(context.tracks) ? context.tracks : [];
|
||||
const selectedMeta = context.selectedMetadata || {};
|
||||
const state = String(pipeline?.state || '').trim().toUpperCase();
|
||||
|
||||
const isRipping = state === 'CD_RIPPING' || state === 'CD_ENCODING';
|
||||
const isFinished = state === 'FINISHED';
|
||||
|
||||
const [format, setFormat] = useState('flac');
|
||||
const [formatOptions, setFormatOptions] = useState(() => getDefaultFormatOptions('flac'));
|
||||
|
||||
// Track selection: position → boolean
|
||||
const [selectedTracks, setSelectedTracks] = useState(() => {
|
||||
const map = {};
|
||||
for (const t of tracks) {
|
||||
map[t.position] = true;
|
||||
}
|
||||
return map;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setFormatOptions(getDefaultFormatOptions(format));
|
||||
}, [format]);
|
||||
|
||||
useEffect(() => {
|
||||
const map = {};
|
||||
for (const t of tracks) {
|
||||
map[t.position] = selectedTracks[t.position] !== false;
|
||||
}
|
||||
setSelectedTracks(map);
|
||||
}, [tracks.length]);
|
||||
|
||||
const handleFormatOptionChange = (key, value) => {
|
||||
setFormatOptions((prev) => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
const handleToggleTrack = (position) => {
|
||||
setSelectedTracks((prev) => ({ ...prev, [position]: !prev[position] }));
|
||||
};
|
||||
|
||||
const handleToggleAll = () => {
|
||||
const allSelected = tracks.every((t) => selectedTracks[t.position] !== false);
|
||||
const map = {};
|
||||
for (const t of tracks) {
|
||||
map[t.position] = !allSelected;
|
||||
}
|
||||
setSelectedTracks(map);
|
||||
};
|
||||
|
||||
const handleStart = () => {
|
||||
const selected = tracks
|
||||
.filter((t) => selectedTracks[t.position] !== false)
|
||||
.map((t) => t.position);
|
||||
|
||||
if (selected.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
onStart && onStart({
|
||||
format,
|
||||
formatOptions,
|
||||
selectedTracks: selected
|
||||
});
|
||||
};
|
||||
|
||||
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 progress = Number(pipeline?.progress ?? 0);
|
||||
const clampedProgress = Math.max(0, Math.min(100, progress));
|
||||
const eta = String(pipeline?.eta || '').trim();
|
||||
const statusText = String(pipeline?.statusText || '').trim();
|
||||
|
||||
if (isRipping || isFinished) {
|
||||
return (
|
||||
<div className="cd-rip-config-panel">
|
||||
<div className="cd-rip-status">
|
||||
<Tag
|
||||
value={state === 'CD_RIPPING' ? 'Ripping' : state === 'CD_ENCODING' ? 'Encodierung' : 'Fertig'}
|
||||
severity={isFinished ? 'success' : 'info'}
|
||||
/>
|
||||
{statusText ? <small>{statusText}</small> : null}
|
||||
{!isFinished ? (
|
||||
<>
|
||||
<ProgressBar value={clampedProgress} />
|
||||
<small>{Math.round(clampedProgress)}%{eta ? ` | ETA ${eta}` : ''}</small>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
{!isFinished ? (
|
||||
<Button
|
||||
label="Abbrechen"
|
||||
icon="pi pi-times"
|
||||
severity="danger"
|
||||
outlined
|
||||
onClick={() => onCancel && onCancel()}
|
||||
disabled={busy}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<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>
|
||||
) : null}
|
||||
|
||||
{/* Format selection */}
|
||||
<div className="cd-format-field">
|
||||
<label>Ausgabeformat</label>
|
||||
<Dropdown
|
||||
value={format}
|
||||
options={CD_FORMATS}
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
onChange={(e) => setFormat(e.value)}
|
||||
disabled={busy}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Format-specific options */}
|
||||
{visibleFields.map((field) => (
|
||||
<FormatField
|
||||
key={field.key}
|
||||
field={field}
|
||||
value={formatOptions[field.key] ?? field.default}
|
||||
onChange={handleFormatOptionChange}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Track selection */}
|
||||
{tracks.length > 0 ? (
|
||||
<div className="cd-track-selection">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', marginBottom: '0.5rem' }}>
|
||||
<strong>Tracks ({selectedCount} / {tracks.length} ausgewählt)</strong>
|
||||
<Button
|
||||
label={selectedCount === tracks.length ? 'Alle abwählen' : 'Alle auswählen'}
|
||||
size="small"
|
||||
severity="secondary"
|
||||
outlined
|
||||
onClick={handleToggleAll}
|
||||
disabled={busy}
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="actions-row" style={{ marginTop: '1rem' }}>
|
||||
<Button
|
||||
label="Abbrechen"
|
||||
severity="secondary"
|
||||
outlined
|
||||
onClick={() => onCancel && onCancel()}
|
||||
disabled={busy}
|
||||
/>
|
||||
<Button
|
||||
label="Rip starten"
|
||||
icon="pi pi-play"
|
||||
onClick={handleStart}
|
||||
loading={busy}
|
||||
disabled={selectedCount === 0}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user