0.10.0-5 AudioBooks Frontend

This commit is contained in:
2026-03-14 19:36:45 +00:00
parent 9d789f302a
commit a471de6422
15 changed files with 718 additions and 33 deletions
@@ -0,0 +1,261 @@
import { useEffect, useMemo, useState } 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 { 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 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,
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));
useEffect(() => {
const nextFormat = normalizeFormat(audiobookConfig?.format);
setFormat(nextFormat);
setFormatOptions(buildFormatOptions(nextFormat, audiobookConfig?.formatOptions));
}, [jobId, audiobookConfig?.format, JSON.stringify(audiobookConfig?.formatOptions || {})]);
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 progress = Number.isFinite(Number(pipeline?.progress)) ? Math.max(0, Math.min(100, Number(pipeline.progress))) : 0;
const outputPath = String(context?.outputPath || '').trim() || null;
const statusLabel = getStatusLabel(state);
const statusSeverity = getStatusSeverity(state);
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="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>Serie:</strong> {metadata?.series || '-'}</div>
<div><strong>Teil:</strong> {metadata?.part || '-'}</div>
<div><strong>Jahr:</strong> {metadata?.year || '-'}</div>
<div><strong>Kapitel:</strong> {chapters.length || '-'}</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}
</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>
Metadaten und Kapitel werden aus der AAX-Datei gelesen. Erst nach Klick auf Start wird `ffmpeg` ausgeführt.
</small>
</div>
<div className="audiobook-config-chapters">
<h4>Kapitelvorschau</h4>
{chapters.length === 0 ? (
<small>Keine Kapitel in der Quelle erkannt.</small>
) : (
<div className="audiobook-chapter-list">
{chapters.map((chapter, index) => (
<div key={`${chapter?.index || index}-${chapter?.title || ''}`} className="audiobook-chapter-row">
<strong>#{chapter?.index || index + 1}</strong>
<span>{chapter?.title || `Kapitel ${index + 1}`}</span>
<small>
{formatChapterTime(chapter?.startSeconds)} - {formatChapterTime(chapter?.endSeconds)}
</small>
</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)}%</small>
</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 })}
loading={busy}
disabled={!jobId}
/>
) : 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>
</div>
);
}
+19 -2
View File
@@ -327,13 +327,26 @@ function resolveAudiobookDetails(job) {
? selectedMetadata.chapters
: (Array.isArray(makemkvInfo?.chapters) ? makemkvInfo.chapters : []);
const format = String(job?.handbrakeInfo?.format || encodePlan?.format || '').trim().toLowerCase() || null;
const formatOptions = job?.handbrakeInfo?.formatOptions && typeof job.handbrakeInfo.formatOptions === 'object'
? job.handbrakeInfo.formatOptions
: (encodePlan?.formatOptions && typeof encodePlan.formatOptions === 'object' ? encodePlan.formatOptions : {});
const qualityLabel = format === 'mp3'
? (
String(formatOptions?.mp3Mode || '').trim().toLowerCase() === 'vbr'
? `VBR V${Number(formatOptions?.mp3Quality ?? 4)}`
: `CBR ${Number(formatOptions?.mp3Bitrate ?? 192)} kbps`
)
: (format === 'flac'
? `Kompression ${Number(formatOptions?.flacCompression ?? 5)}`
: (format === 'm4b' ? 'Original-Audio' : null));
return {
author: String(selectedMetadata?.author || selectedMetadata?.artist || '').trim() || null,
narrator: String(selectedMetadata?.narrator || '').trim() || null,
series: String(selectedMetadata?.series || '').trim() || null,
part: String(selectedMetadata?.part || '').trim() || null,
chapterCount: chapters.length,
formatLabel: format ? format.toUpperCase() : null
formatLabel: format ? format.toUpperCase() : null,
qualityLabel
};
}
@@ -513,7 +526,7 @@ export default function JobDetailDialog({
{job.poster_url && job.poster_url !== 'N/A' ? (
<img src={job.poster_url} alt={job.title || 'Poster'} className="poster-large" />
) : (
<div className="poster-large poster-fallback">{isCd ? 'Kein Cover' : 'Kein Poster'}</div>
<div className="poster-large poster-fallback">{isCd || isAudiobook ? 'Kein Cover' : 'Kein Poster'}</div>
)}
<div className="job-film-info-grid">
@@ -603,6 +616,10 @@ export default function JobDetailDialog({
<strong>Format:</strong>
<span>{audiobookDetails?.formatLabel || '-'}</span>
</div>
<div className="job-meta-item">
<strong>Qualität:</strong>
<span>{audiobookDetails?.qualityLabel || '-'}</span>
</div>
</>
) : (
<>