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

View File

@@ -1,12 +1,12 @@
{
"name": "ripster-frontend",
"version": "0.10.0-4",
"version": "0.10.0-5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ripster-frontend",
"version": "0.10.0-4",
"version": "0.10.0-5",
"dependencies": {
"primeicons": "^7.0.0",
"primereact": "^10.9.2",

View File

@@ -1,6 +1,6 @@
{
"name": "ripster-frontend",
"version": "0.10.0-4",
"version": "0.10.0-5",
"private": true,
"type": "module",
"scripts": {

View File

@@ -321,6 +321,14 @@ export const api = {
afterMutationInvalidate(['/history', '/pipeline/queue']);
return result;
},
async startAudiobook(jobId, payload = {}) {
const result = await request(`/pipeline/audiobook/start/${jobId}`, {
method: 'POST',
body: JSON.stringify(payload || {})
});
afterMutationInvalidate(['/history', '/pipeline/queue']);
return result;
},
async selectMetadata(payload) {
const result = await request('/pipeline/select-metadata', {
method: 'POST',

View File

@@ -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>
);
}

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>
</>
) : (
<>

View File

@@ -0,0 +1,80 @@
export const AUDIOBOOK_FORMATS = [
{ label: 'M4B (Original-Audio)', value: 'm4b' },
{ label: 'MP3', value: 'mp3' },
{ label: 'FLAC (verlustlos)', value: 'flac' }
];
export const AUDIOBOOK_FORMAT_SCHEMAS = {
m4b: {
fields: []
},
flac: {
fields: [
{
key: 'flacCompression',
label: 'Kompressionsstufe',
description: '0 = schnell / wenig Kompression, 8 = maximale Kompression',
type: 'slider',
min: 0,
max: 8,
step: 1,
default: 5
}
]
},
mp3: {
fields: [
{
key: 'mp3Mode',
label: 'Modus',
type: 'select',
options: [
{ label: 'CBR (Konstante Bitrate)', value: 'cbr' },
{ label: 'VBR (Variable Bitrate)', value: 'vbr' }
],
default: 'cbr'
},
{
key: 'mp3Bitrate',
label: 'Bitrate (kbps)',
type: 'select',
showWhen: { field: 'mp3Mode', value: 'cbr' },
options: [
{ label: '128 kbps', value: 128 },
{ label: '160 kbps', value: 160 },
{ label: '192 kbps', value: 192 },
{ label: '256 kbps', value: 256 },
{ label: '320 kbps', value: 320 }
],
default: 192
},
{
key: 'mp3Quality',
label: 'VBR Qualität (V0-V9)',
description: '0 = beste Qualität, 9 = kleinste Datei',
type: 'slider',
min: 0,
max: 9,
step: 1,
showWhen: { field: 'mp3Mode', value: 'vbr' },
default: 4
}
]
}
};
export function getDefaultAudiobookFormatOptions(format) {
const schema = AUDIOBOOK_FORMAT_SCHEMAS[format];
if (!schema) {
return {};
}
const defaults = {};
for (const field of schema.fields) {
if (field.default !== undefined) {
defaults[field.key] = field.default;
}
}
return defaults;
}

View File

@@ -11,6 +11,7 @@ import PipelineStatusCard from '../components/PipelineStatusCard';
import MetadataSelectionDialog from '../components/MetadataSelectionDialog';
import CdMetadataDialog from '../components/CdMetadataDialog';
import CdRipConfigPanel from '../components/CdRipConfigPanel';
import AudiobookConfigPanel from '../components/AudiobookConfigPanel';
import blurayIndicatorIcon from '../assets/media-bluray.svg';
import discIndicatorIcon from '../assets/media-disc.svg';
import otherIndicatorIcon from '../assets/media-other.svg';
@@ -596,7 +597,11 @@ function buildPipelineFromJob(job, currentPipeline, currentPipelineJobId) {
title: audiobookSelectedMeta?.title || job?.title || job?.detected_title || null,
author: audiobookSelectedMeta?.author || audiobookSelectedMeta?.artist || null,
narrator: audiobookSelectedMeta?.narrator || null,
series: audiobookSelectedMeta?.series || null,
part: audiobookSelectedMeta?.part || null,
year: audiobookSelectedMeta?.year ?? job?.year ?? null,
chapters: Array.isArray(audiobookSelectedMeta?.chapters) ? audiobookSelectedMeta.chapters : [],
durationMs: audiobookSelectedMeta?.durationMs || 0,
poster: job?.poster_url || null
}
: {
@@ -667,6 +672,14 @@ function buildPipelineFromJob(job, currentPipeline, currentPipelineJobId) {
mode,
sourceJobId: encodePlan?.sourceJobId || null,
selectedMetadata,
audiobookConfig: resolvedMediaType === 'audiobook'
? {
format: String(encodePlan?.format || '').trim().toLowerCase() || 'mp3',
formatOptions: encodePlan?.formatOptions && typeof encodePlan.formatOptions === 'object'
? encodePlan.formatOptions
: {}
}
: null,
mediaInfoReview: encodePlan,
playlistAnalysis: analyzeContext.playlistAnalysis || null,
playlistDecisionRequired: Boolean(analyzeContext.playlistDecisionRequired),
@@ -1324,7 +1337,7 @@ export default function DashboardPage({
}
setAudiobookUploadBusy(true);
try {
const response = await api.uploadAudiobook(audiobookUploadFile, { startImmediately: true });
const response = await api.uploadAudiobook(audiobookUploadFile, { startImmediately: false });
const result = getQueueActionResult(response);
const uploadedJobId = normalizeJobId(response?.result?.jobId);
await refreshPipeline();
@@ -1350,6 +1363,29 @@ export default function DashboardPage({
}
};
const handleAudiobookStart = async (jobId, audiobookConfig) => {
const normalizedJobId = normalizeJobId(jobId);
if (!normalizedJobId) {
return;
}
setJobBusy(normalizedJobId, true);
try {
const response = await api.startAudiobook(normalizedJobId, audiobookConfig || {});
const result = getQueueActionResult(response);
await refreshPipeline();
await loadDashboardJobs();
if (result.queued) {
showQueuedToast(toastRef, 'Audiobook', result);
} else {
setExpandedJobId(normalizedJobId);
}
} catch (error) {
showError(error);
} finally {
setJobBusy(normalizedJobId, false);
}
};
const handleConfirmReview = async (
jobId,
selectedEncodeTitleId = null,
@@ -2008,7 +2044,7 @@ export default function DashboardPage({
)}
</Card>
<Card title="Audiobook Upload" subTitle="AAX-Datei hochladen, ins RAW-Verzeichnis übernehmen und direkt mit dem in den Settings gewählten Zielformat starten.">
<Card title="Audiobook Upload" subTitle="AAX-Datei hochladen, analysieren und danach Format/Qualität vor dem Start auswählen.">
<div className="actions-row">
<input
key={audiobookUploadFile ? `${audiobookUploadFile.name}-${audiobookUploadFile.size}` : 'audiobook-upload-input'}
@@ -2033,7 +2069,7 @@ export default function DashboardPage({
<small>
{audiobookUploadFile
? `Ausgewählt: ${audiobookUploadFile.name}`
: 'Unterstützt im MVP: AAX-Upload. Das Ausgabeformat wird aus den Audiobook-Settings gelesen.'}
: 'Unterstützt im MVP: AAX-Upload. Danach erscheint ein eigener Audiobook-Startschritt mit Format- und Qualitätswahl.'}
</small>
</Card>
@@ -2374,11 +2410,14 @@ export default function DashboardPage({
const statusBadgeSeverity = getStatusSeverity(normalizedStatus, { queued: isQueued });
const isExpanded = normalizeJobId(expandedJobId) === jobId;
const isCurrentSession = currentPipelineJobId === jobId && state !== 'IDLE';
const isResumable = normalizedStatus === 'READY_TO_ENCODE' && !isCurrentSession;
const reviewConfirmed = Boolean(Number(job?.encode_review_confirmed || 0));
const pipelineForJob = pipelineByJobId.get(jobId) || pipeline;
const jobTitle = job?.title || job?.detected_title || `Job #${jobId}`;
const mediaIndicator = mediaIndicatorMeta(job);
const isResumable = (
normalizedStatus === 'READY_TO_ENCODE'
|| (mediaIndicator.mediaType === 'audiobook' && normalizedStatus === 'READY_TO_START')
) && !isCurrentSession;
const mediaProfile = String(pipelineForJob?.context?.mediaProfile || '').trim().toLowerCase();
const jobState = String(pipelineForJob?.state || normalizedStatus).trim().toUpperCase();
const pipelineStage = String(pipelineForJob?.context?.stage || '').trim().toUpperCase();
@@ -2388,6 +2427,9 @@ export default function DashboardPage({
|| mediaProfile === 'cd'
|| mediaIndicator.mediaType === 'cd'
|| pipelineStatusText.includes('CD_');
const isAudiobookJob = mediaProfile === 'audiobook'
|| mediaIndicator.mediaType === 'audiobook'
|| String(pipelineForJob?.context?.mode || '').trim().toLowerCase() === 'audiobook';
const rawProgress = Number(pipelineForJob?.progress ?? 0);
const clampedProgress = Number.isFinite(rawProgress)
? Math.max(0, Math.min(100, rawProgress))
@@ -2395,14 +2437,19 @@ export default function DashboardPage({
const progressLabel = `${Math.round(clampedProgress)}%`;
const etaLabel = String(pipelineForJob?.eta || '').trim();
const audiobookMeta = pipelineForJob?.context?.selectedMetadata && typeof pipelineForJob.context.selectedMetadata === 'object'
? pipelineForJob.context.selectedMetadata
: {};
const audiobookChapterCount = Array.isArray(audiobookMeta?.chapters) ? audiobookMeta.chapters.length : 0;
if (isExpanded) {
return (
<div key={jobId} className="dashboard-job-expanded">
<div className="dashboard-job-expanded-head">
{job?.poster_url && job.poster_url !== 'N/A' ? (
{(job?.poster_url && job.poster_url !== 'N/A') ? (
<img src={job.poster_url} alt={jobTitle} className="poster-thumb" />
) : (
<div className="poster-thumb dashboard-job-poster-fallback">Kein Poster</div>
<div className="poster-thumb dashboard-job-poster-fallback">{isAudiobookJob ? 'Kein Cover' : 'Kein Poster'}</div>
)}
<div className="dashboard-job-expanded-title">
<strong className="dashboard-job-title-line">
@@ -2456,9 +2503,20 @@ export default function DashboardPage({
</>
);
}
if (isAudiobookJob) {
return (
<AudiobookConfigPanel
pipeline={pipelineForJob}
onStart={(config) => handleAudiobookStart(jobId, config)}
onCancel={() => handleCancel(jobId, jobState)}
onRetry={() => handleRetry(jobId)}
busy={busyJobIds.has(jobId)}
/>
);
}
return null;
})()}
{!isCdJob ? (
{!isCdJob && !isAudiobookJob ? (
<PipelineStatusCard
pipeline={pipelineForJob}
onAnalyze={handleAnalyze}
@@ -2492,7 +2550,7 @@ export default function DashboardPage({
{job?.poster_url && job.poster_url !== 'N/A' ? (
<img src={job.poster_url} alt={jobTitle} className="poster-thumb" />
) : (
<div className="poster-thumb dashboard-job-poster-fallback">Kein Poster</div>
<div className="poster-thumb dashboard-job-poster-fallback">{isAudiobookJob ? 'Kein Cover' : 'Kein Poster'}</div>
)}
<div className="dashboard-job-row-content">
<div className="dashboard-job-row-main">
@@ -2507,8 +2565,16 @@ export default function DashboardPage({
</strong>
<small>
#{jobId}
{job?.year ? ` | ${job.year}` : ''}
{job?.imdb_id ? ` | ${job.imdb_id}` : ''}
{isAudiobookJob
? (
`${audiobookMeta?.author ? ` | ${audiobookMeta.author}` : ''}`
+ `${audiobookMeta?.narrator ? ` | ${audiobookMeta.narrator}` : ''}`
+ `${audiobookChapterCount > 0 ? ` | ${audiobookChapterCount} Kapitel` : ''}`
)
: (
`${job?.year ? ` | ${job.year}` : ''}`
+ `${job?.imdb_id ? ` | ${job.imdb_id}` : ''}`
)}
</small>
</div>
<div className="dashboard-job-badges">

View File

@@ -3405,3 +3405,74 @@ body {
grid-template-columns: 1fr;
}
}
.audiobook-config-panel {
display: grid;
gap: 1rem;
}
.audiobook-config-head {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: flex-start;
flex-wrap: wrap;
}
.audiobook-config-tags {
display: flex;
flex-wrap: wrap;
gap: 0.45rem;
align-items: flex-start;
}
.audiobook-config-grid {
display: grid;
grid-template-columns: minmax(260px, 340px) minmax(0, 1fr);
gap: 1rem;
}
.audiobook-config-settings,
.audiobook-config-chapters {
display: grid;
gap: 0.85rem;
}
.audiobook-config-chapters h4 {
margin: 0;
}
.audiobook-chapter-list {
display: grid;
gap: 0.55rem;
max-height: 18rem;
overflow: auto;
padding-right: 0.25rem;
}
.audiobook-chapter-row {
display: grid;
gap: 0.15rem;
padding: 0.65rem 0.75rem;
border: 1px solid var(--surface-border, #d8d3c6);
border-radius: 10px;
background: var(--surface-card, #fff);
}
.audiobook-chapter-row small {
color: var(--rip-muted, #666);
}
.audiobook-output-path {
padding: 0.75rem 0.85rem;
border-radius: 10px;
border: 1px solid var(--surface-border, #d8d3c6);
background: var(--surface-ground, #f7f7f7);
word-break: break-word;
}
@media (max-width: 980px) {
.audiobook-config-grid {
grid-template-columns: 1fr;
}
}