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..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 === State.Playing;
- const isPaused = playbackState.state === 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,18 +50,16 @@ 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);
}, [navigation, ttsStore]);
const handleClose = useCallback(async () => {
- await TrackPlayer.stop();
await TrackPlayer.reset();
ttsStore.setState('idle');
ttsStore.setMiniPlayerVisible(false);
@@ -84,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} 段
@@ -124,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: {
@@ -161,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 3986cdd9..8515363b 100644
--- a/apps/mobile/src/screens/TTSScreen.tsx
+++ b/apps/mobile/src/screens/TTSScreen.tsx
@@ -94,13 +94,17 @@ export function TTSScreen() {
const theme = getTheme(actualTheme === "dark");
const ttsStore = useTTSStore();
- // ── Book + chapter state ─────────────────────────────────────────────
- const [paragraphs, setParagraphs] = useState([]);
- const [chapterTitle, setChapterTitle] = useState("");
- const [chapterIndex, setChapterIndex] = useState(0);
+ // Derived state from store (single source of truth)
+ const paragraphs = ttsStore.paragraphs;
+ const chapterTitle = ttsStore.chapterTitle;
+ const chapterIndex = ttsStore.chapterIndex;
+ const currentParagraph = ttsStore.currentParagraph;
+
+ // ── Local UI state ──────────────────────────────────────────────────
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,18 +131,19 @@ 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;
+ // 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);
const [resumeOffsetMs, setResumeOffsetMs] = useState(0);
const prefetchedRef = useRef
{sleepMinutes > 0 && (
@@ -1044,15 +1113,14 @@ export function TTSScreen() {
-
- 朗读内容
- {chapterTitle}
-
+
+ {chapterTitle}
+
{/* Paragraph list */}
-
+
{paragraphs.map((p, idx) => {
const isCurrent = idx === currentParagraph;
const isPast = idx < currentParagraph;
@@ -1060,6 +1128,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/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) => {
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,