From 1781f9573a97456adb271bda2caad8ecb75f395e Mon Sep 17 00:00:00 2001 From: mmdctjj <984808285@qq.com> Date: Fri, 12 Jun 2026 15:11:36 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20=E4=BF=AE=E6=94=B9=E7=A7=BB?= =?UTF-8?q?=E5=8A=A8=E7=AB=AF=E9=9F=B3=E9=A2=91=E6=92=AD=E6=94=BE=E9=97=AE?= =?UTF-8?q?=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/mobile/android/build.gradle | 2 +- apps/mobile/src/components/TTSMiniPlayer.tsx | 5 ++- apps/mobile/src/screens/TTSScreen.tsx | 37 ++++++++++++-------- apps/mobile/src/services/playbackService.ts | 2 +- 4 files changed, 27 insertions(+), 19 deletions(-) diff --git a/apps/mobile/android/build.gradle b/apps/mobile/android/build.gradle index abbcb8ec..e724c422 100644 --- a/apps/mobile/android/build.gradle +++ b/apps/mobile/android/build.gradle @@ -5,7 +5,7 @@ buildscript { buildToolsVersion = findProperty('android.buildToolsVersion') ?: '35.0.0' minSdkVersion = Integer.parseInt(findProperty('android.minSdkVersion') ?: '24') compileSdkVersion = Integer.parseInt(findProperty('android.compileSdkVersion') ?: '35') - targetSdkVersion = Integer.parseInt(findProperty('android.targetSdkVersion') ?: '34') + targetSdkVersion = Integer.parseInt(findProperty('android.targetSdkVersion') ?: '33') kotlinVersion = findProperty('android.kotlinVersion') ?: '1.9.25' ndkVersion = "26.1.10909125" diff --git a/apps/mobile/src/components/TTSMiniPlayer.tsx b/apps/mobile/src/components/TTSMiniPlayer.tsx index 18587f26..bc9a6d16 100644 --- a/apps/mobile/src/components/TTSMiniPlayer.tsx +++ b/apps/mobile/src/components/TTSMiniPlayer.tsx @@ -27,8 +27,8 @@ export function TTSMiniPlayer() { const playbackState = usePlaybackState(); const progress = useProgress(); - const isPlaying = playbackState.state === State.Playing; - const isPaused = playbackState.state === State.Paused; + const isPlaying = playbackState === State.Playing; + const isPaused = playbackState === State.Paused; const styles = useMemo(() => createStyles(theme), [theme]); @@ -54,7 +54,6 @@ export function TTSMiniPlayer() { }, [navigation, ttsStore]); const handleClose = useCallback(async () => { - await TrackPlayer.stop(); await TrackPlayer.reset(); ttsStore.setState('idle'); ttsStore.setMiniPlayerVisible(false); diff --git a/apps/mobile/src/screens/TTSScreen.tsx b/apps/mobile/src/screens/TTSScreen.tsx index 3986cdd9..14dbeaff 100644 --- a/apps/mobile/src/screens/TTSScreen.tsx +++ b/apps/mobile/src/screens/TTSScreen.tsx @@ -101,6 +101,7 @@ export function TTSScreen() { const [chapters, setChapters] = useState<{ title: string; index: number }[]>( [], ); + const chaptersRef = useRef<{ title: string; index: number }[]>([]); const [loading, setLoading] = useState(true); const [loadError, setLoadError] = useState(null); @@ -127,10 +128,9 @@ export function TTSScreen() { const playbackState = usePlaybackState(); const progress = useProgress(); - const currentState = playbackState?.state; - const isPlaying = currentState === State.Playing; - const isPaused = currentState === State.Paused; - const isLoadingAudio = currentState === State.Loading || currentState === State.Buffering; + const isPlaying = playbackState === State.Playing; + const isPaused = playbackState === State.Paused; + const isLoadingAudio = playbackState === State.Loading || playbackState === State.Buffering; const [currentParagraph, setCurrentParagraph] = useState(0); const [paragraphProgress, setParagraphProgress] = useState(0); @@ -222,6 +222,7 @@ export function TTSScreen() { return; } setChapters(chRes.data); + chaptersRef.current = chRes.data; // Load first chapter (loadChapter will skip empty ones) console.log('[TTSScreen] Loading chapter 0, voice:', vs[0]?.id || voiceId, 'provider:', finalProvider); @@ -295,14 +296,14 @@ export function TTSScreen() { } else { // Chapter ended, try next chapter const nextChapter = chapterIndex + 1; - if (nextChapter < chapters.length) { + if (nextChapter < chaptersRef.current.length) { await loadChapter(nextChapter, voiceId, provider); await playParagraph(0); } } }); return () => sub.remove(); - }, [currentParagraph, paragraphs.length, chapterIndex, chapters.length, voiceId, provider]); + }, [currentParagraph, paragraphs.length, chapterIndex, voiceId, provider]); // ── TrackPlayer event: active track changed → update current paragraph ─ useEffect(() => { @@ -333,7 +334,7 @@ export function TTSScreen() { // If chapter has no paragraphs, try next chapter if (!pRes.data.paragraphs || pRes.data.paragraphs.length === 0) { const nextChapter = ci + 1; - if (nextChapter < chapters.length) { + if (nextChapter < chaptersRef.current.length) { console.log('[TTSScreen] Chapter empty, trying next:', nextChapter); return loadChapter(nextChapter, vid, prov); } else { @@ -573,7 +574,6 @@ export function TTSScreen() { }, [isPaused, isPlaying, paragraphs.length, playParagraph, currentParagraph, ttsStore, persistProgress, progress.position]); const handleStop = useCallback(async () => { - await TrackPlayer.stop(); await TrackPlayer.reset(); ttsStore.setState("idle"); setCurrentParagraph(0); @@ -606,12 +606,21 @@ export function TTSScreen() { const handleChapterChange = useCallback( async (ci: number) => { - if (ci === chapterIndex) return; - await TrackPlayer.stop(); - await TrackPlayer.reset(); - ttsStore.setState("idle"); - await loadChapter(ci, voiceId, provider); - setShowChapterPicker(false); + console.log('[TTSScreen] handleChapterChange called, ci:', ci, 'chapterIndex:', chapterIndex); + if (ci === chapterIndex) { + console.log('[TTSScreen] Same chapter, skipping'); + return; + } + try { + await TrackPlayer.reset(); + ttsStore.setState("idle"); + console.log('[TTSScreen] Loading chapter:', ci); + await loadChapter(ci, voiceId, provider); + console.log('[TTSScreen] Chapter loaded, closing picker'); + setShowChapterPicker(false); + } catch (e) { + console.error('[TTSScreen] handleChapterChange error:', e); + } }, // eslint-disable-next-line react-hooks/exhaustive-deps [chapterIndex, voiceId, provider], diff --git a/apps/mobile/src/services/playbackService.ts b/apps/mobile/src/services/playbackService.ts index 90565560..809117db 100644 --- a/apps/mobile/src/services/playbackService.ts +++ b/apps/mobile/src/services/playbackService.ts @@ -29,7 +29,7 @@ export const PlaybackService = async function () { TrackPlayer.addEventListener(Event.RemoteStop, async () => { console.log('[PlaybackService] RemoteStop'); - await TrackPlayer.stop(); + await TrackPlayer.reset(); }); TrackPlayer.addEventListener(Event.RemoteSeek, async (event) => { From 3ef25b4fedb4c6364b5def66d1431286cef606ce Mon Sep 17 00:00:00 2001 From: mmdctjj <984808285@qq.com> Date: Fri, 12 Jun 2026 17:44:34 +0800 Subject: [PATCH 2/3] =?UTF-8?q?feat:=20=E8=AE=B0=E5=BD=95=E4=B8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/mobile/src/components/TTSMiniPlayer.tsx | 53 +++-- apps/mobile/src/screens/TTSScreen.tsx | 206 +++++++++++++------ apps/mobile/src/stores/ttsStore.ts | 9 +- 3 files changed, 187 insertions(+), 81 deletions(-) diff --git a/apps/mobile/src/components/TTSMiniPlayer.tsx b/apps/mobile/src/components/TTSMiniPlayer.tsx index bc9a6d16..d6530269 100644 --- a/apps/mobile/src/components/TTSMiniPlayer.tsx +++ b/apps/mobile/src/components/TTSMiniPlayer.tsx @@ -12,10 +12,12 @@ import { Text, TouchableOpacity, StyleSheet, + Image, } from 'react-native'; import TrackPlayer, { State, usePlaybackState, useProgress } from 'react-native-track-player'; import { useTTSStore, useThemeStore } from '../stores'; import { getTheme, spacing, fontSizes, borderRadius } from '../utils/theme'; +import { getCoverImageUrl } from '../services/api'; import type { RootStackParamList } from '../navigation/types'; export function TTSMiniPlayer() { @@ -27,8 +29,13 @@ export function TTSMiniPlayer() { const playbackState = usePlaybackState(); const progress = useProgress(); - const isPlaying = playbackState === State.Playing; - const isPaused = playbackState === State.Paused; + const isPlaying = ttsStore.state === 'playing'; + const isPaused = ttsStore.state === 'paused'; + + const paragraphProgress = progress.duration > 0 ? progress.position / progress.duration : 0; + const overallProgress = ttsStore.totalParagraphs > 0 + ? (ttsStore.currentParagraph + paragraphProgress) / ttsStore.totalParagraphs + : 0; const styles = useMemo(() => createStyles(theme), [theme]); @@ -43,11 +50,10 @@ export function TTSMiniPlayer() { }, [isPaused, isPlaying, ttsStore]); const handleExpand = useCallback(() => { - // Navigate back to TTSScreen - if (ttsStore.currentBookId) { - // We need the book object to navigate - this is a limitation - // In practice, the mini player should be shown only when - // the TTSScreen is in the navigation stack + // Navigate back to TTSScreen with full book data + if (ttsStore.currentBook) { + navigation.navigate('TTSScreen', { book: ttsStore.currentBook }); + } else if (ttsStore.currentBookId) { navigation.navigate('TTSScreen', { book: { id: ttsStore.currentBookId } as any }); } ttsStore.setMiniPlayerVisible(false); @@ -83,20 +89,28 @@ export function TTSMiniPlayer() { {/* Cover thumbnail */} - - - {ttsStore.chapterTitle?.charAt(0) || 'T'} - - + {ttsStore.currentBook?.coverUrl ? ( + + ) : ( + + + {(ttsStore.currentBook?.title || 'T').charAt(0)} + + + )} {/* Info */} - {ttsStore.chapterTitle || '正在朗读'} + {ttsStore.currentBook?.title || ttsStore.chapterTitle || '正在朗读'} - 第 {ttsStore.currentParagraph + 1} 段 / 共 {ttsStore.totalParagraphs} 段 + {ttsStore.chapterTitle} · 第 {ttsStore.currentParagraph + 1}/{ttsStore.totalParagraphs} 段 @@ -123,16 +137,11 @@ function createStyles(theme: ReturnType) { return StyleSheet.create({ container: { position: 'absolute', - bottom: 0, + bottom: 48, left: 0, right: 0, borderTopLeftRadius: borderRadius.lg, borderTopRightRadius: borderRadius.lg, - shadowColor: '#000', - shadowOffset: { width: 0, height: -2 }, - shadowOpacity: 0.1, - shadowRadius: 4, - elevation: 8, zIndex: 100, }, progressBar: { @@ -160,6 +169,10 @@ function createStyles(theme: ReturnType) { justifyContent: 'center', alignItems: 'center', }, + coverImage: { + width: '100%', + height: '100%', + }, coverText: { fontSize: fontSizes.lg, fontWeight: 'bold', diff --git a/apps/mobile/src/screens/TTSScreen.tsx b/apps/mobile/src/screens/TTSScreen.tsx index 14dbeaff..f1948435 100644 --- a/apps/mobile/src/screens/TTSScreen.tsx +++ b/apps/mobile/src/screens/TTSScreen.tsx @@ -94,6 +94,19 @@ export function TTSScreen() { const theme = getTheme(actualTheme === "dark"); const ttsStore = useTTSStore(); + // ── Sync state from store when expanding from mini player ────────── + useEffect(() => { + if (ttsStore.currentBookId === book.id && ttsStore.paragraphs.length > 0) { + setParagraphs(ttsStore.paragraphs); + setChapterTitle(ttsStore.chapterTitle); + setChapterIndex(ttsStore.chapterIndex); + setCurrentParagraph(ttsStore.currentParagraph); + if (ttsStore.selectedProvider) setProvider(ttsStore.selectedProvider); + if (ttsStore.selectedVoice?.id) setVoiceId(ttsStore.selectedVoice.id); + setLoading(false); + } + }, []); + // ── Book + chapter state ───────────────────────────────────────────── const [paragraphs, setParagraphs] = useState([]); const [chapterTitle, setChapterTitle] = useState(""); @@ -128,9 +141,10 @@ export function TTSScreen() { const playbackState = usePlaybackState(); const progress = useProgress(); - const isPlaying = playbackState === State.Playing; - const isPaused = playbackState === State.Paused; - const isLoadingAudio = playbackState === State.Loading || playbackState === State.Buffering; + // Use ttsStore state for UI to avoid flicker during paragraph transitions + const isPlaying = ttsStore.state === 'playing'; + const isPaused = ttsStore.state === 'paused'; + const isLoadingAudio = ttsStore.state === 'loading' || playbackState === State.Connecting || playbackState === State.Buffering; const [currentParagraph, setCurrentParagraph] = useState(0); const [paragraphProgress, setParagraphProgress] = useState(0); @@ -139,6 +153,8 @@ export function TTSScreen() { const prefetchedRef = useRef>(new Map()); const cancelledRef = useRef(false); const sleepTimerRef = useRef | null>(null); + const paragraphsScrollRef = useRef(null); + const paragraphRefs = useRef>(new Map()); const styles = useMemo(() => createStyles(theme), [theme]); @@ -168,11 +184,13 @@ export function TTSScreen() { ], progressUpdateEventInterval: 1, }); - // Reset player to clear any stuck loading/buffering state - await TrackPlayer.reset(); + // Only reset if nothing is playing (avoid interrupting playback from mini player) + const state = await TrackPlayer.getPlaybackState(); + if (state.state !== State.Playing && state.state !== State.Buffering) { + await TrackPlayer.reset(); + } } catch (e) { - // Player may already be set up, try reset anyway - try { await TrackPlayer.reset(); } catch {} + // Player may already be set up } })(); return () => { @@ -184,11 +202,21 @@ export function TTSScreen() { useEffect(() => { let cancelled = false; (async () => { + // If already playing this book (expanding from mini player), skip reload + if (ttsStore.currentBookId === book.id && ttsStore.paragraphs.length > 0 && ttsStore.state === 'playing') { + console.log('[TTSScreen] Already playing this book, skipping reload'); + setLoading(false); + return; + } + setLoading(true); setLoadError(null); try { const apiClient = getApiClient(); console.log('[TTSScreen] Starting load, book.id:', book.id); + + // Save book data to store for mini player + ttsStore.setCurrentBookData(book); // Providers const provRes = await apiClient.getTtsProviders(); @@ -265,6 +293,14 @@ export function TTSScreen() { }, [provider]); // ── Sleep timer ────────────────────────────────────────────────────── + + // Update paragraph progress from RNTP progress + useEffect(() => { + if (progress.duration > 0) { + setParagraphProgress(progress.position / progress.duration); + } + }, [progress.position, progress.duration]); + useEffect(() => { if (sleepMinutes <= 0) { setSleepRemaining(0); @@ -289,9 +325,10 @@ export function TTSScreen() { // ── TrackPlayer event: track ended → auto advance ─────────────────── useEffect(() => { const sub = TrackPlayer.addEventListener(Event.PlaybackQueueEnded, async () => { - console.log('[TTSScreen] Queue ended, auto-advancing paragraph'); + console.log('[TTSScreen] Queue ended'); const nextIdx = currentParagraph + 1; if (nextIdx < paragraphs.length) { + // More paragraphs in chapter, continue playing await playParagraph(nextIdx); } else { // Chapter ended, try next chapter @@ -299,22 +336,72 @@ export function TTSScreen() { if (nextChapter < chaptersRef.current.length) { await loadChapter(nextChapter, voiceId, provider); await playParagraph(0); + } else { + // Truly done - no more chapters + ttsStore.setState("idle"); } } }); return () => sub.remove(); - }, [currentParagraph, paragraphs.length, chapterIndex, voiceId, provider]); + }, [currentParagraph, paragraphs.length, chapterIndex, voiceId, provider, ttsStore]); // ── TrackPlayer event: active track changed → update current paragraph ─ useEffect(() => { const sub = TrackPlayer.addEventListener(Event.PlaybackActiveTrackChanged, async (event) => { - if (event.index !== undefined) { - setCurrentParagraph(event.index); - ttsStore.setCurrentParagraph(event.index); + if (event.index === undefined) return; + + // Get current track to determine which paragraph we're on + const track = await TrackPlayer.getTrack(event.index); + if (!track?.id) return; + + // Extract paragraph index from track ID (format: "paraId-chunkIdx") + const paraId = track.id.split('-')[0]; + const paraIdx = paragraphs.findIndex(p => p.id === paraId); + + if (paraIdx >= 0 && paraIdx !== currentParagraph) { + setCurrentParagraph(paraIdx); + ttsStore.setCurrentParagraph(paraIdx); + + // Add next paragraph to queue for seamless playback + try { + const nextIdx = paraIdx + 1; + if (nextIdx < paragraphs.length) { + const nextUris = await synthesizeParagraph(nextIdx); + if (nextUris.length > 0) { + const nextTracks = nextUris.map((uri, i) => ({ + id: `${paragraphs[nextIdx].id}-${i}`, + url: uri, + title: `${book.title || '未知书籍'} - ${chapterTitle}`, + artist: book.author || "未知作者", + artwork: undefined, + duration: 0, + })); + await TrackPlayer.add(nextTracks); + } + } + } catch { + // Ignore prefetch errors + } } }); return () => sub.remove(); - }, [ttsStore]); + }, [ttsStore, paragraphs, currentParagraph, book, chapterTitle, synthesizeParagraph]); + + // Auto-scroll to current paragraph in content view + useEffect(() => { + if (viewMode === 'content' && currentParagraph >= 0) { + const ref = paragraphRefs.current.get(currentParagraph); + if (ref && paragraphsScrollRef.current) { + ref.measureLayout( + paragraphsScrollRef.current as any, + (_x, y) => { + paragraphsScrollRef.current?.scrollTo({ y: Math.max(0, y - 100), animated: true }); + }, + () => {} + ); + } + } + }, [currentParagraph, viewMode]); const loadChapter = async (ci: number, vid: string, prov: string) => { console.log('[TTSScreen] loadChapter called, ci:', ci, 'vid:', vid, 'prov:', prov); @@ -420,10 +507,16 @@ export function TTSScreen() { useEffect(() => { const unsubscribe = navigation.addListener("beforeRemove", async () => { await persistProgress(currentParagraph, Math.round(progress.position * 1000)); + // Sync state to store + ttsStore.setParagraphs(paragraphs); + ttsStore.setTotalParagraphs(paragraphs.length); + ttsStore.setChapterTitle(chapterTitle); + ttsStore.setChapterIndex(chapterIndex); + ttsStore.setCurrentParagraph(currentParagraph); ttsStore.setMiniPlayerVisible(true); }); return unsubscribe; - }, [currentParagraph, navigation, persistProgress, progress.position, ttsStore]); + }, [currentParagraph, navigation, persistProgress, progress.position, ttsStore, paragraphs, chapterTitle, chapterIndex]); // ── Persist progress when app goes to background ──────────────────── useEffect(() => { @@ -435,38 +528,6 @@ export function TTSScreen() { return () => subscription.remove(); }, [currentParagraph, persistProgress, progress.position]); - const prefetchParagraph = useCallback( - async (idx: number) => { - if (idx >= paragraphs.length) return; - const para = paragraphs[idx]; - if (prefetchedRef.current.has(para.id)) return; - const chunks = splitForTts(para.text, TTS_CHUNK_MAX); - const apiClient = getApiClient(); - try { - for (let i = 0; i < chunks.length; i++) { - const chunk = chunks[i]; - const chunkParaId = chunks.length > 1 ? `${para.id}#${i}` : para.id; - const r = await apiClient.synthesizeParagraph({ - bookId: book.id, - paragraphId: chunkParaId, - text: chunk, - provider, - voice: voiceId, - }); - if (r.success && r.data) { - prefetchedRef.current.set( - chunkParaId, - resolveAudioUrl(r.data.url, apiClient), - ); - } - } - } catch { - /* ignore */ - } - }, - [book.id, paragraphs, provider, voiceId], - ); - const synthesizeParagraph = useCallback( async (idx: number): Promise => { if (idx >= paragraphs.length) return []; @@ -511,30 +572,52 @@ export function TTSScreen() { ttsStore.setCurrentParagraph(idx); const startOffsetMs = resumeOffsetMs; setResumeOffsetMs(0); - prefetchParagraph(idx + 1); try { + // Synthesize current and next paragraph const uris = await synthesizeParagraph(idx); if (uris.length === 0) return; - // Build tracks for RNTP + // Build tracks for current paragraph const tracks = uris.map((uri, i) => ({ id: `${paragraphs[idx].id}-${i}`, url: uri, - title: `${book.title} - ${chapterTitle}`, + title: `${book.title || '未知书籍'} - ${chapterTitle}`, artist: book.author || "未知作者", artwork: undefined, duration: 0, })); + // Reset and add current paragraph await TrackPlayer.reset(); await TrackPlayer.add(tracks); + + // Try to add next paragraph to queue for seamless playback + try { + if (idx + 1 < paragraphs.length) { + const nextUris = await synthesizeParagraph(idx + 1); + if (nextUris.length > 0) { + const nextTracks = nextUris.map((uri, i) => ({ + id: `${paragraphs[idx + 1].id}-${i}`, + url: uri, + title: `${book.title || '未知书籍'} - ${chapterTitle}`, + artist: book.author || "未知作者", + artwork: undefined, + duration: 0, + })); + await TrackPlayer.add(nextTracks); + } + } + } catch { + // Ignore prefetch errors + } + if (startOffsetMs > 0) { await TrackPlayer.seekTo(startOffsetMs / 1000); } await TrackPlayer.play(); - ttsStore.setState("playing"); ttsStore.setCurrentBook(book.id, idx, paragraphs.length); + ttsStore.setState("playing"); } catch (e) { console.error("TTS paragraph error", e); ttsStore.setState("idle"); @@ -547,7 +630,6 @@ export function TTSScreen() { book.author, chapterTitle, paragraphs, - prefetchParagraph, synthesizeParagraph, resumeOffsetMs, ttsStore, @@ -658,9 +740,15 @@ export function TTSScreen() { }, []); const handleMinimize = useCallback(() => { + // Sync current state to store before minimizing + ttsStore.setParagraphs(paragraphs); + ttsStore.setTotalParagraphs(paragraphs.length); + ttsStore.setChapterTitle(chapterTitle); + ttsStore.setChapterIndex(chapterIndex); + ttsStore.setCurrentParagraph(currentParagraph); ttsStore.setMiniPlayerVisible(true); navigation.goBack(); - }, [ttsStore, navigation]); + }, [ttsStore, navigation, paragraphs, chapterTitle, chapterIndex, currentParagraph]); // ── Progress calculation ───────────────────────────────────────────── const overallProgress = paragraphs.length @@ -716,7 +804,7 @@ export function TTSScreen() { - {book.title} + {book.title || '未知书籍'} setShowSettings(true)}> @@ -738,7 +826,7 @@ export function TTSScreen() { resizeMode="cover" /> ) : ( - {book.title.charAt(0)} + {(book.title || '?').charAt(0)} )} {sleepMinutes > 0 && ( @@ -1053,15 +1141,14 @@ export function TTSScreen() { - - 朗读内容 - {chapterTitle} - + + {chapterTitle} + {/* Paragraph list */} - + {paragraphs.map((p, idx) => { const isCurrent = idx === currentParagraph; const isPast = idx < currentParagraph; @@ -1069,6 +1156,7 @@ export function TTSScreen() { handleJumpToParagraph(idx)} + ref={(ref) => { if (ref) paragraphRefs.current.set(idx, ref as any); }} style={[ styles.paragraphItem, isCurrent && { backgroundColor: theme.colors.primary + '25' }, diff --git a/apps/mobile/src/stores/ttsStore.ts b/apps/mobile/src/stores/ttsStore.ts index bea4938a..c54e1168 100644 --- a/apps/mobile/src/stores/ttsStore.ts +++ b/apps/mobile/src/stores/ttsStore.ts @@ -1,4 +1,4 @@ -import type { Paragraph, TTSVoice } from '@bookdock/api-client'; +import type { Book, Paragraph, TTSVoice } from '@bookdock/api-client'; import AsyncStorage from '@react-native-async-storage/async-storage'; import { create } from 'zustand'; import { createJSONStorage, persist } from 'zustand/middleware'; @@ -9,6 +9,7 @@ type ViewMode = 'controls' | 'content'; interface TTSStoreState { state: TTSState; currentBookId: string | null; + currentBook: Book | null; currentPosition: number; totalLength: number; selectedProvider: string | null; @@ -32,6 +33,7 @@ interface TTSStoreState { // Actions setState: (state: TTSState) => void; setCurrentBook: (bookId: string | null, position?: number, totalLength?: number) => void; + setCurrentBookData: (book: Book) => void; setPosition: (position: number) => void; setSelectedProvider: (provider: string) => void; setSelectedVoice: (voice: TTSVoice | null) => void; @@ -54,6 +56,7 @@ export const useTTSStore = create()( (set) => ({ state: 'idle', currentBookId: null, + currentBook: null, currentPosition: 0, totalLength: 0, selectedProvider: null, @@ -76,9 +79,10 @@ export const useTTSStore = create()( currentBookId: bookId, currentPosition: position, totalLength, - state: bookId ? 'paused' : 'idle', }), + setCurrentBookData: (book) => set({ currentBook: book }), + setPosition: (position) => set({ currentPosition: position }), setSelectedProvider: (provider) => set({ selectedProvider: provider }), @@ -110,6 +114,7 @@ export const useTTSStore = create()( reset: () => set({ state: 'idle', currentBookId: null, + currentBook: null, currentPosition: 0, totalLength: 0, currentParagraph: 0, From d7109d4a549f64a109f9951305b89477daf3d392 Mon Sep 17 00:00:00 2001 From: mmdctjj <984808285@qq.com> Date: Fri, 12 Jun 2026 17:55:33 +0800 Subject: [PATCH 3/3] =?UTF-8?q?feat:=20=E4=BF=AE=E6=94=B9=E5=90=8C?= =?UTF-8?q?=E6=AD=A5=E6=96=B9=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/mobile/src/screens/TTSScreen.tsx | 146 +++++++++++--------------- 1 file changed, 59 insertions(+), 87 deletions(-) diff --git a/apps/mobile/src/screens/TTSScreen.tsx b/apps/mobile/src/screens/TTSScreen.tsx index f1948435..8515363b 100644 --- a/apps/mobile/src/screens/TTSScreen.tsx +++ b/apps/mobile/src/screens/TTSScreen.tsx @@ -94,23 +94,13 @@ export function TTSScreen() { const theme = getTheme(actualTheme === "dark"); const ttsStore = useTTSStore(); - // ── Sync state from store when expanding from mini player ────────── - useEffect(() => { - if (ttsStore.currentBookId === book.id && ttsStore.paragraphs.length > 0) { - setParagraphs(ttsStore.paragraphs); - setChapterTitle(ttsStore.chapterTitle); - setChapterIndex(ttsStore.chapterIndex); - setCurrentParagraph(ttsStore.currentParagraph); - if (ttsStore.selectedProvider) setProvider(ttsStore.selectedProvider); - if (ttsStore.selectedVoice?.id) setVoiceId(ttsStore.selectedVoice.id); - setLoading(false); - } - }, []); + // Derived state from store (single source of truth) + const paragraphs = ttsStore.paragraphs; + const chapterTitle = ttsStore.chapterTitle; + const chapterIndex = ttsStore.chapterIndex; + const currentParagraph = ttsStore.currentParagraph; - // ── Book + chapter state ───────────────────────────────────────────── - const [paragraphs, setParagraphs] = useState([]); - const [chapterTitle, setChapterTitle] = useState(""); - const [chapterIndex, setChapterIndex] = useState(0); + // ── Local UI state ────────────────────────────────────────────────── const [chapters, setChapters] = useState<{ title: string; index: number }[]>( [], ); @@ -146,7 +136,6 @@ export function TTSScreen() { const isPaused = ttsStore.state === 'paused'; const isLoadingAudio = ttsStore.state === 'loading' || playbackState === State.Connecting || playbackState === State.Buffering; - const [currentParagraph, setCurrentParagraph] = useState(0); const [paragraphProgress, setParagraphProgress] = useState(0); const [resumeOffsetMs, setResumeOffsetMs] = useState(0); @@ -185,8 +174,7 @@ export function TTSScreen() { progressUpdateEventInterval: 1, }); // Only reset if nothing is playing (avoid interrupting playback from mini player) - const state = await TrackPlayer.getPlaybackState(); - if (state.state !== State.Playing && state.state !== State.Buffering) { + if (ttsStore.state !== 'playing') { await TrackPlayer.reset(); } } catch (e) { @@ -345,47 +333,6 @@ export function TTSScreen() { return () => sub.remove(); }, [currentParagraph, paragraphs.length, chapterIndex, voiceId, provider, ttsStore]); - // ── TrackPlayer event: active track changed → update current paragraph ─ - useEffect(() => { - const sub = TrackPlayer.addEventListener(Event.PlaybackActiveTrackChanged, async (event) => { - if (event.index === undefined) return; - - // Get current track to determine which paragraph we're on - const track = await TrackPlayer.getTrack(event.index); - if (!track?.id) return; - - // Extract paragraph index from track ID (format: "paraId-chunkIdx") - const paraId = track.id.split('-')[0]; - const paraIdx = paragraphs.findIndex(p => p.id === paraId); - - if (paraIdx >= 0 && paraIdx !== currentParagraph) { - setCurrentParagraph(paraIdx); - ttsStore.setCurrentParagraph(paraIdx); - - // Add next paragraph to queue for seamless playback - try { - const nextIdx = paraIdx + 1; - if (nextIdx < paragraphs.length) { - const nextUris = await synthesizeParagraph(nextIdx); - if (nextUris.length > 0) { - const nextTracks = nextUris.map((uri, i) => ({ - id: `${paragraphs[nextIdx].id}-${i}`, - url: uri, - title: `${book.title || '未知书籍'} - ${chapterTitle}`, - artist: book.author || "未知作者", - artwork: undefined, - duration: 0, - })); - await TrackPlayer.add(nextTracks); - } - } - } catch { - // Ignore prefetch errors - } - } - }); - return () => sub.remove(); - }, [ttsStore, paragraphs, currentParagraph, book, chapterTitle, synthesizeParagraph]); // Auto-scroll to current paragraph in content view useEffect(() => { @@ -405,7 +352,7 @@ export function TTSScreen() { const loadChapter = async (ci: number, vid: string, prov: string) => { console.log('[TTSScreen] loadChapter called, ci:', ci, 'vid:', vid, 'prov:', prov); - setChapterIndex(ci); + ttsStore.setChapterIndex(ci); try { const apiClient = getApiClient(); console.log('[TTSScreen] Fetching paragraphs for book:', book.id, 'chapter:', ci); @@ -430,9 +377,6 @@ export function TTSScreen() { } } - setChapterTitle(pRes.data.title); - setParagraphs(pRes.data.paragraphs); - setCurrentParagraph(0); setParagraphProgress(0); setResumeOffsetMs(0); prefetchedRef.current.clear(); @@ -440,6 +384,7 @@ export function TTSScreen() { ttsStore.setTotalParagraphs(pRes.data.paragraphs.length); ttsStore.setChapterTitle(pRes.data.title); ttsStore.setChapterIndex(ci); + ttsStore.setCurrentParagraph(0); // Resume from saved cloud progress try { @@ -452,7 +397,7 @@ export function TTSScreen() { } if (rec.voice) setVoiceId(rec.voice); setResumeOffsetMs(Math.max(0, rec.audioOffsetMs || 0)); - setCurrentParagraph( + ttsStore.setCurrentParagraph( Math.min(rec.paragraphIndex, pRes.data.paragraphs.length - 1), ); } @@ -506,27 +451,21 @@ export function TTSScreen() { // ── Persist latest position when user leaves the screen ───────────── useEffect(() => { const unsubscribe = navigation.addListener("beforeRemove", async () => { - await persistProgress(currentParagraph, Math.round(progress.position * 1000)); - // Sync state to store - ttsStore.setParagraphs(paragraphs); - ttsStore.setTotalParagraphs(paragraphs.length); - ttsStore.setChapterTitle(chapterTitle); - ttsStore.setChapterIndex(chapterIndex); - ttsStore.setCurrentParagraph(currentParagraph); + await persistProgress(ttsStore.currentParagraph, Math.round(progress.position * 1000)); ttsStore.setMiniPlayerVisible(true); }); return unsubscribe; - }, [currentParagraph, navigation, persistProgress, progress.position, ttsStore, paragraphs, chapterTitle, chapterIndex]); + }, [navigation, persistProgress, progress.position, ttsStore]); // ── Persist progress when app goes to background ──────────────────── useEffect(() => { const subscription = AppState.addEventListener("change", (nextAppState) => { if (nextAppState === "background" || nextAppState === "inactive") { - void persistProgress(currentParagraph, Math.round(progress.position * 1000)); + void persistProgress(ttsStore.currentParagraph, Math.round(progress.position * 1000)); } }); return () => subscription.remove(); - }, [currentParagraph, persistProgress, progress.position]); + }, [persistProgress, progress.position, ttsStore]); const synthesizeParagraph = useCallback( async (idx: number): Promise => { @@ -559,17 +498,57 @@ export function TTSScreen() { [book.id, paragraphs, provider, voiceId], ); + // ── TrackPlayer event: active track changed → update current paragraph ─ + useEffect(() => { + const sub = TrackPlayer.addEventListener(Event.PlaybackTrackChanged, async (event: any) => { + if (event.nextTrack === undefined) return; + + // Get current track to determine which paragraph we're on + const track = await TrackPlayer.getTrack(event.nextTrack); + if (!track?.id) return; + + // Extract paragraph index from track ID (format: "paraId-chunkIdx") + const paraId = track.id.split('-')[0]; + const paraIdx = paragraphs.findIndex(p => p.id === paraId); + + if (paraIdx >= 0 && paraIdx !== ttsStore.currentParagraph) { + ttsStore.setCurrentParagraph(paraIdx); + + // Add next paragraph to queue for seamless playback + try { + const nextIdx = paraIdx + 1; + if (nextIdx < paragraphs.length) { + const nextUris = await synthesizeParagraph(nextIdx); + if (nextUris.length > 0) { + const nextTracks = nextUris.map((uri, i) => ({ + id: `${paragraphs[nextIdx].id}-${i}`, + url: uri, + title: `${book.title || '未知书籍'} - ${chapterTitle}`, + artist: book.author || "未知作者", + artwork: undefined, + duration: 0, + })); + await TrackPlayer.add(nextTracks); + } + } + } catch { + // Ignore prefetch errors + } + } + }); + return () => sub.remove(); + }, [ttsStore, paragraphs, book, chapterTitle, synthesizeParagraph]); + const playParagraph = useCallback( async (idx: number) => { if (idx >= paragraphs.length) { - setCurrentParagraph(0); + ttsStore.setCurrentParagraph(0); setParagraphProgress(0); ttsStore.setState("idle"); return; } - setCurrentParagraph(idx); - setParagraphProgress(0); ttsStore.setCurrentParagraph(idx); + setParagraphProgress(0); const startOffsetMs = resumeOffsetMs; setResumeOffsetMs(0); @@ -658,7 +637,7 @@ export function TTSScreen() { const handleStop = useCallback(async () => { await TrackPlayer.reset(); ttsStore.setState("idle"); - setCurrentParagraph(0); + ttsStore.setCurrentParagraph(0); setParagraphProgress(0); }, [ttsStore]); @@ -668,7 +647,6 @@ export function TTSScreen() { if (isPlaying || isPaused) { await playParagraph(idx); } else { - setCurrentParagraph(idx); ttsStore.setCurrentParagraph(idx); void persistProgress(idx); } @@ -740,15 +718,9 @@ export function TTSScreen() { }, []); const handleMinimize = useCallback(() => { - // Sync current state to store before minimizing - ttsStore.setParagraphs(paragraphs); - ttsStore.setTotalParagraphs(paragraphs.length); - ttsStore.setChapterTitle(chapterTitle); - ttsStore.setChapterIndex(chapterIndex); - ttsStore.setCurrentParagraph(currentParagraph); ttsStore.setMiniPlayerVisible(true); navigation.goBack(); - }, [ttsStore, navigation, paragraphs, chapterTitle, chapterIndex, currentParagraph]); + }, [ttsStore, navigation]); // ── Progress calculation ───────────────────────────────────────────── const overallProgress = paragraphs.length