feat(audiobook): persist book metadata/script/prefs via LongformProject store (#31b) (#444)

Audiobook's script, default voice, output format, loudness, book metadata
(title/author/narrator/genre/year/description) and pronunciation lexicon now
bind to the unified store (#31a) instead of component useState — so they
**survive a tab switch / reload** (previously all lost). The headline #31 win.

- text→script, defaultVoice, format→outputFormat, loudness, meta→setProjectMeta,
  bound to store selectors. `meta` is default-filled so an empty record never
  flips a controlled input to uncontrolled.
- Lexicon rows stay LOCAL (half-typed rows aren't junk-persisted); the filtered
  dict flushes to the store on change and hydrates back into rows on mount.
- Transient state (plan, generating, progress, output, chapter previews) stays
  component-local — correctly NOT persisted.

Deferred (noted): coverRef persistence (a File/blob can't go to localStorage);
the "Save as named project" affordance + Projects-list card + App `onOpenStory`
mode-aware routing (criterion 4 — re-open from Projects). This slice lands the
working-state persistence (criterion 3); save/reopen is the next slice.

Full frontend vitest green (357); typecheck:ci clean; CJK guard green.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Palash Debnath
2026-06-14 12:18:20 +05:30
committed by GitHub
co-authored by Claude Opus 4.8
parent 0a72a75ed2
commit b6f0c73f7a
+41 -10
View File
@@ -7,6 +7,7 @@ import {
} from '../api/audiobook';
import { audioUrl } from '../api/generate';
import { consumeLongformStream } from '../utils/longformStream';
import { useAppStore } from '../store';
import './AudiobookTab.css';
/**
@@ -18,8 +19,17 @@ import './AudiobookTab.css';
*/
export default function AudiobookTab({ profiles = [] }) {
const { t } = useTranslation();
const [text, setText] = useState('');
const [defaultVoice, setDefaultVoice] = useState('');
// Persisted via the unified LongformProject store (#31b) — book identity,
// script, voice, and output prefs now survive a tab switch / reload (they
// used to live in component useState and evaporate).
const text = useAppStore((s) => s.script);
const setText = useAppStore((s) => s.setScript);
const defaultVoice = useAppStore((s) => s.defaultVoice) ?? ''; // select coerces null→''
const setOutputPrefs = useAppStore((s) => s.setOutputPrefs);
const setProjectMeta = useAppStore((s) => s.setProjectMeta);
const setLexiconStore = useAppStore((s) => s.setLexicon);
const storeLexicon = useAppStore((s) => s.lexicon);
const setDefaultVoice = (v) => setOutputPrefs({ defaultVoice: v || null });
const [plan, setPlan] = useState(null);
const [planLoading, setPlanLoading] = useState(false);
const [generating, setGenerating] = useState(false);
@@ -30,23 +40,44 @@ export default function AudiobookTab({ profiles = [] }) {
const [chapterPrev, setChapterPrev] = useState({}); // index → {url, loading}
const abortRef = useRef(false);
// Output + metadata (embedded in the file; players show these).
const [format, setFormat] = useState('m4b'); // 'm4b' | 'mp3'
const [loudness, setLoudness] = useState('off'); // 'off' | 'acx' | 'podcast'
const [meta, setMeta] = useState({
title: '', author: '', narrator: '', year: '', genre: '', description: '',
});
// Output prefs + metadata (embedded in the file; players show these) — now
// store-backed. `meta` is default-filled so every controlled input gets a
// defined string (an empty store record never flips a controlled→uncontrolled).
const format = useAppStore((s) => s.outputFormat); // 'm4b' | 'mp3'
const setFormat = (v) => setOutputPrefs({ outputFormat: v });
const loudness = useAppStore((s) => s.loudness); // 'off' | 'acx' | 'podcast'
const setLoudness = (v) => setOutputPrefs({ loudness: v });
const metaStore = useAppStore((s) => s.meta);
const meta = { title: '', author: '', narrator: '', year: '', genre: '', description: '', ...metaStore };
const setMetaField = (k) => (e) => setProjectMeta({ [k]: e.target.value });
// Cover stays component-local (a File/blob can't persist to localStorage;
// coverRef persistence is a noted follow-up).
const [coverFile, setCoverFile] = useState(null);
const [coverPreview, setCoverPreview] = useState('');
// Pronunciation lexicon: editable {word → respelling} rows.
// Pronunciation lexicon: editable {word → respelling} rows. Rows stay LOCAL
// (half-typed rows aren't junk-persisted); the filtered dict flushes to the
// store so it survives a reload, and hydrates back into rows on mount.
const [lex, setLex] = useState([]); // [{ word, say }]
const lexHydrated = useRef(false);
useEffect(() => {
if (lexHydrated.current) return;
lexHydrated.current = true;
const rows = Object.entries(storeLexicon || {}).map(([word, say]) => ({ word, say }));
if (rows.length) setLex(rows);
}, [storeLexicon]);
const lexDict = () => Object.fromEntries(
lex.filter((r) => r.word.trim() && r.say.trim()).map((r) => [r.word.trim(), r.say.trim()]),
);
// Flush the filtered dict to the store whenever rows change (after hydration).
useEffect(() => {
if (!lexHydrated.current) return;
setLexiconStore(lexDict());
}, [lex]); // eslint-disable-line react-hooks/exhaustive-deps
const setLexRow = (i, k) => (e) => setLex((rows) => rows.map((r, j) => (j === i ? { ...r, [k]: e.target.value } : r)));
const addLexRow = () => setLex((rows) => [...rows, { word: '', say: '' }]);
const removeLexRow = (i) => setLex((rows) => rows.filter((_, j) => j !== i));
const setMetaField = (k) => (e) => setMeta((m) => ({ ...m, [k]: e.target.value }));
const onCoverPick = useCallback((e) => {
const f = e.target.files?.[0];