Files
ripster/frontend/src/components/AudiobookConfigPanel.jsx
T

407 lines
15 KiB
React

import { useEffect, useMemo, useState } from 'react';
import { Dialog } from 'primereact/dialog';
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 { InputText } from 'primereact/inputtext';
import { AUDIOBOOK_FORMATS, AUDIOBOOK_FORMAT_SCHEMAS, getDefaultAudiobookFormatOptions } from '../config/audiobookFormatSchemas';
import { getStatusLabel, getStatusSeverity } from '../utils/statusPresentation';
function normalizeJobId(value) {
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed <= 0) {
return null;
}
return Math.trunc(parsed);
}
function normalizeFormat(value) {
const raw = String(value || '').trim().toLowerCase();
return AUDIOBOOK_FORMATS.some((entry) => entry.value === raw) ? raw : 'mp3';
}
function isFieldVisible(field, values) {
if (!field?.showWhen) {
return true;
}
return values?.[field.showWhen.field] === field.showWhen.value;
}
function buildFormatOptions(format, existingOptions = {}) {
return {
...getDefaultAudiobookFormatOptions(format),
...(existingOptions && typeof existingOptions === 'object' ? existingOptions : {})
};
}
function formatChapterTime(secondsValue) {
const totalSeconds = Number(secondsValue || 0);
if (!Number.isFinite(totalSeconds) || totalSeconds < 0) {
return '-';
}
const rounded = Math.max(0, Math.round(totalSeconds));
const hours = Math.floor(rounded / 3600);
const minutes = Math.floor((rounded % 3600) / 60);
const seconds = rounded % 60;
if (hours > 0) {
return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
}
return `${minutes}:${String(seconds).padStart(2, '0')}`;
}
function stripHtml(value) {
return String(value || '').replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
}
function truncateDescription(value, maxLength = 220) {
const normalized = stripHtml(value);
if (!normalized || normalized.length <= maxLength) {
return normalized;
}
return `${normalized.slice(0, maxLength).trim()}...`;
}
function normalizeChapterTitle(value, index) {
const normalized = String(value || '').replace(/\s+/g, ' ').trim();
return normalized || `Kapitel ${index}`;
}
function normalizeEditableChapters(chapters = []) {
const source = Array.isArray(chapters) ? chapters : [];
return source.map((chapter, index) => {
const safeIndex = Number(chapter?.index);
const resolvedIndex = Number.isFinite(safeIndex) && safeIndex > 0 ? Math.trunc(safeIndex) : index + 1;
return {
index: resolvedIndex,
title: normalizeChapterTitle(chapter?.title, resolvedIndex),
startSeconds: Number(chapter?.startSeconds || 0),
endSeconds: Number(chapter?.endSeconds || 0),
startMs: Number(chapter?.startMs || 0),
endMs: Number(chapter?.endMs || 0)
};
});
}
function FormatField({ field, value, onChange, disabled }) {
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={(event) => onChange(field.key, event.value)}
min={field.min}
max={field.max}
step={field.step || 1}
disabled={disabled}
/>
</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={(event) => onChange(field.key, event.value)}
disabled={disabled}
/>
</div>
);
}
return null;
}
export default function AudiobookConfigPanel({
pipeline,
onStart,
onCancel,
onRetry,
onDeleteJob,
busy
}) {
const context = pipeline?.context && typeof pipeline.context === 'object' ? pipeline.context : {};
const state = String(pipeline?.state || 'IDLE').trim().toUpperCase() || 'IDLE';
const jobId = normalizeJobId(context?.jobId);
const metadata = context?.selectedMetadata && typeof context.selectedMetadata === 'object'
? context.selectedMetadata
: {};
const audiobookConfig = context?.audiobookConfig && typeof context.audiobookConfig === 'object'
? context.audiobookConfig
: (context?.mediaInfoReview && typeof context.mediaInfoReview === 'object' ? context.mediaInfoReview : {});
const initialFormat = normalizeFormat(audiobookConfig?.format);
const chapters = Array.isArray(metadata?.chapters)
? metadata.chapters
: (Array.isArray(context?.chapters) ? context.chapters : []);
const [format, setFormat] = useState(initialFormat);
const [formatOptions, setFormatOptions] = useState(() => buildFormatOptions(initialFormat, audiobookConfig?.formatOptions));
const [editableChapters, setEditableChapters] = useState(() => normalizeEditableChapters(chapters));
const [descriptionDialogVisible, setDescriptionDialogVisible] = useState(false);
useEffect(() => {
const nextFormat = normalizeFormat(audiobookConfig?.format);
setFormat(nextFormat);
setFormatOptions(buildFormatOptions(nextFormat, audiobookConfig?.formatOptions));
}, [jobId, audiobookConfig?.format, JSON.stringify(audiobookConfig?.formatOptions || {})]);
useEffect(() => {
setEditableChapters(normalizeEditableChapters(chapters));
}, [jobId, JSON.stringify(chapters || [])]);
const schema = AUDIOBOOK_FORMAT_SCHEMAS[format] || AUDIOBOOK_FORMAT_SCHEMAS.mp3;
const canStart = Boolean(jobId) && (state === 'READY_TO_START' || state === 'ERROR' || state === 'CANCELLED');
const isRunning = state === 'ENCODING';
const isFinished = state === 'FINISHED';
const progress = Number.isFinite(Number(pipeline?.progress)) ? Math.max(0, Math.min(100, Number(pipeline.progress))) : 0;
const outputPath = String(context?.outputPath || '').trim() || null;
const isSplitOutput = format === 'mp3' || format === 'flac';
const currentChapter = context?.currentChapter && typeof context.currentChapter === 'object' ? context.currentChapter : null;
const completedChapterCount = Number(context?.completedChapterCount ?? -1);
const chapterTotal = Number(currentChapter?.total || editableChapters.length || 0);
const statusLabel = getStatusLabel(state);
const statusSeverity = getStatusSeverity(state);
const description = String(metadata?.description || '').trim();
const descriptionStripped = stripHtml(description);
const descriptionPreview = truncateDescription(description);
const posterUrl = String(metadata?.poster || '').trim() || null;
const visibleFields = useMemo(
() => (Array.isArray(schema?.fields) ? schema.fields.filter((field) => isFieldVisible(field, formatOptions)) : []),
[schema, formatOptions]
);
return (
<div className="audiobook-config-panel">
<div className="audiobook-config-head">
<div className="audiobook-config-summary">
{posterUrl ? (
<div className="audiobook-config-cover">
<img src={posterUrl} alt={metadata?.title || 'Audiobook Cover'} />
</div>
) : null}
<div className="device-meta">
<div><strong>Titel:</strong> {metadata?.title || '-'}</div>
<div><strong>Autor:</strong> {metadata?.author || '-'}</div>
<div><strong>Sprecher:</strong> {metadata?.narrator || '-'}</div>
<div><strong>Jahr:</strong> {metadata?.year || '-'}</div>
<div><strong>Kapitel:</strong> {editableChapters.length || '-'}</div>
{descriptionPreview ? (
<div className="audiobook-description-preview">
<strong>Beschreibung:</strong>
<span>{descriptionPreview}</span>
{descriptionStripped.length > descriptionPreview.length ? (
<Button
type="button"
label="Vollständig anzeigen"
icon="pi pi-external-link"
text
size="small"
onClick={() => setDescriptionDialogVisible(true)}
/>
) : null}
</div>
) : null}
</div>
</div>
<div className="audiobook-config-tags">
<Tag value={statusLabel} severity={statusSeverity} />
<Tag value={`Format: ${format.toUpperCase()}`} severity="info" />
{metadata?.durationMs ? <Tag value={`Dauer: ${Math.round(Number(metadata.durationMs) / 60000)} min`} severity="secondary" /> : null}
{posterUrl ? <Tag value="Cover erkannt" severity="success" /> : null}
</div>
</div>
<div className="audiobook-config-grid">
<div className="audiobook-config-settings">
<div className="cd-format-field">
<label>Ausgabeformat</label>
<Dropdown
value={format}
options={AUDIOBOOK_FORMATS}
optionLabel="label"
optionValue="value"
onChange={(event) => {
const nextFormat = normalizeFormat(event.value);
setFormat(nextFormat);
setFormatOptions(buildFormatOptions(nextFormat, {}));
}}
disabled={busy || isRunning}
/>
</div>
{visibleFields.map((field) => (
<FormatField
key={`${format}-${field.key}`}
field={field}
value={formatOptions?.[field.key] ?? field.default ?? null}
onChange={(key, nextValue) => {
setFormatOptions((prev) => ({
...prev,
[key]: nextValue
}));
}}
disabled={busy || isRunning}
/>
))}
<small>
<code>m4b</code> erzeugt eine Datei mit bearbeitbaren Kapiteln. <code>mp3</code> und <code>flac</code> werden kapitelweise als einzelne Dateien erzeugt.
</small>
</div>
<div className="audiobook-config-chapters">
<h4>Kapitel</h4>
{editableChapters.length === 0 ? (
<small>Keine Kapitel in der Quelle erkannt.</small>
) : (
<div className="audiobook-chapter-list">
{editableChapters.map((chapter, index) => (
<div key={`${chapter.index}-${index}`} className="audiobook-chapter-row audiobook-chapter-row-editable">
<div className="audiobook-chapter-row-head">
<strong>#{chapter.index || index + 1}</strong>
<small>
{formatChapterTime(chapter.startSeconds)} - {formatChapterTime(chapter.endSeconds)}
</small>
</div>
<InputText
value={chapter.title}
onChange={(event) => {
const nextTitle = event.target.value;
setEditableChapters((prev) => prev.map((entry, entryIndex) => (
entryIndex === index
? { ...entry, title: nextTitle }
: entry
)));
}}
disabled={busy || isRunning}
/>
</div>
))}
</div>
)}
</div>
</div>
{isRunning ? (
<div className="dashboard-job-row-progress" aria-label={`Audiobook Fortschritt ${Math.round(progress)}%`}>
<ProgressBar value={progress} showValue={false} />
<small>{Math.round(progress)}%{currentChapter ? ` | Kapitel ${currentChapter.index}/${currentChapter.total}: ${currentChapter.title}` : ''}</small>
</div>
) : null}
{isSplitOutput && (isRunning || isFinished) && editableChapters.length > 0 ? (
<div className="audiobook-chapter-status">
<strong>Kapitel-Status ({completedChapterCount >= 0 ? completedChapterCount : (isFinished ? editableChapters.length : 0)}/{chapterTotal || editableChapters.length} fertig)</strong>
<div className="audiobook-chapter-status-list">
{editableChapters.map((chapter, idx) => {
const chIdx = chapter.index || idx + 1;
const isDone = isFinished || (completedChapterCount >= 0 && chIdx <= completedChapterCount);
const isActive = !isDone && currentChapter?.index === chIdx;
const statusIcon = isDone ? 'pi pi-check-circle' : isActive ? 'pi pi-spin pi-spinner' : 'pi pi-circle';
const statusClass = isDone ? 'chapter-status-done' : isActive ? 'chapter-status-active' : 'chapter-status-pending';
return (
<div key={chIdx} className={`audiobook-chapter-status-row ${statusClass}`}>
<i className={statusIcon} />
<span className="chapter-status-nr">#{String(chIdx).padStart(2, '0')}</span>
<span className="chapter-status-title">{chapter.title}</span>
</div>
);
})}
</div>
</div>
) : null}
{outputPath ? (
<div className="audiobook-output-path">
<strong>Ausgabe:</strong> <code>{outputPath}</code>
</div>
) : null}
<div className="actions-row">
{canStart ? (
<Button
label={state === 'READY_TO_START' ? 'Encoding starten' : 'Mit diesen Einstellungen starten'}
icon="pi pi-play"
severity="success"
onClick={() => onStart?.({
format,
formatOptions,
chapters: editableChapters.map((chapter, index) => ({
index: chapter.index || index + 1,
title: normalizeChapterTitle(chapter.title, chapter.index || index + 1),
startSeconds: chapter.startSeconds,
endSeconds: chapter.endSeconds,
startMs: chapter.startMs,
endMs: chapter.endMs
}))
})}
loading={busy}
disabled={!jobId}
/>
) : null}
{jobId ? (
<Button
label="Job löschen"
icon="pi pi-trash"
severity="danger"
outlined
onClick={() => onDeleteJob?.(jobId)}
loading={busy}
/>
) : null}
{isRunning ? (
<Button
label="Abbrechen"
icon="pi pi-stop"
severity="danger"
onClick={() => onCancel?.()}
loading={busy}
disabled={!jobId}
/>
) : null}
{(state === 'ERROR' || state === 'CANCELLED') ? (
<Button
label="Retry-Job anlegen"
icon="pi pi-refresh"
severity="warning"
outlined
onClick={() => onRetry?.()}
loading={busy}
disabled={!jobId}
/>
) : null}
</div>
<Dialog
header="Beschreibung"
visible={descriptionDialogVisible}
style={{ width: 'min(48rem, 92vw)' }}
onHide={() => setDescriptionDialogVisible(false)}
>
<div
className="audiobook-description-dialog"
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{ __html: description || '<p>Keine Beschreibung vorhanden.</p>' }}
/>
</Dialog>
</div>
);
}