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:
co-authored by
Claude Opus 4.8
parent
0a72a75ed2
commit
b6f0c73f7a
@@ -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];
|
||||
|
||||
Reference in New Issue
Block a user