diff --git a/apps/mobile/android/app/src/main/AndroidManifest.xml b/apps/mobile/android/app/src/main/AndroidManifest.xml index 83dab50f..6f1b17fb 100644 --- a/apps/mobile/android/app/src/main/AndroidManifest.xml +++ b/apps/mobile/android/app/src/main/AndroidManifest.xml @@ -10,6 +10,8 @@ + + diff --git a/apps/mobile/android/gradle.properties b/apps/mobile/android/gradle.properties index 7531e9eb..de47429c 100644 --- a/apps/mobile/android/gradle.properties +++ b/apps/mobile/android/gradle.properties @@ -35,7 +35,7 @@ reactNativeArchitectures=armeabi-v7a,arm64-v8a,x86,x86_64 # your application. You should enable this flag either if you want # to write custom TurboModules/Fabric components OR use libraries that # are providing them. -newArchEnabled=true +newArchEnabled=false # Use this property to enable or disable the Hermes JS engine. # If set to false, you will be using JSC instead. diff --git a/apps/mobile/app.json b/apps/mobile/app.json index 98e6a0f1..171b7eb4 100644 --- a/apps/mobile/app.json +++ b/apps/mobile/app.json @@ -43,7 +43,8 @@ { "sounds": [] } - ] + ], + "expo-font" ], "extra": { "eas": { diff --git a/apps/mobile/index.js b/apps/mobile/index.js index 92f01358..ae29ac31 100644 --- a/apps/mobile/index.js +++ b/apps/mobile/index.js @@ -1,5 +1,10 @@ import { registerRootComponent } from 'expo'; +import TrackPlayer from 'react-native-track-player'; import App from './src/App'; +import { PlaybackService } from './src/services/playbackService'; + +// Register playback service for background audio +TrackPlayer.registerPlaybackService(() => PlaybackService); // registerRootComponent calls AppRegistry.registerComponent('main', ...) // It also ensures that whether you load the app in Expo Go or in a native build, diff --git a/apps/mobile/ios/BookDock/Info.plist b/apps/mobile/ios/BookDock/Info.plist index 06f6b3e0..a16341fd 100644 --- a/apps/mobile/ios/BookDock/Info.plist +++ b/apps/mobile/ios/BookDock/Info.plist @@ -51,6 +51,10 @@ Allow $(PRODUCT_NAME) to access your Face ID biometric data. NSMicrophoneUsageDescription Allow $(PRODUCT_NAME) to access your microphone + UIBackgroundModes + + audio + UILaunchStoryboardName SplashScreen UIRequiredDeviceCapabilities diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 90f235f1..cd75e0b8 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -30,6 +30,7 @@ "expo-av": "~15.0.2", "expo-camera": "~16.0.18", "expo-file-system": "~18.0.0", + "expo-font": "~13.0.4", "expo-intent-launcher": "~12.0.2", "expo-linear-gradient": "~14.0.2", "expo-navigation-bar": "~4.0.9", @@ -47,6 +48,7 @@ "react-native-pdf": "^7.0.4", "react-native-safe-area-context": "4.12.0", "react-native-screens": "~4.4.0", + "react-native-track-player": "3.2.0", "react-native-web": "~0.19.13", "react-native-webview": "13.12.5", "socket.io-client": "^4.8.3", diff --git a/apps/mobile/src/App.tsx b/apps/mobile/src/App.tsx index 22cf27dc..1226f1ce 100644 --- a/apps/mobile/src/App.tsx +++ b/apps/mobile/src/App.tsx @@ -3,6 +3,8 @@ import { StatusBar } from 'expo-status-bar'; import { SafeAreaProvider } from 'react-native-safe-area-context'; import { useColorScheme } from 'react-native'; import * as SplashScreen from 'expo-splash-screen'; +import * as Font from 'expo-font'; +import { Ionicons } from '@expo/vector-icons'; import { RootNavigator } from './navigation'; import { useAuthStore, useThemeStore } from './stores'; import { notificationService } from './services'; @@ -32,6 +34,9 @@ export default function App() { try { setLoading(true); + // Preload icon fonts to prevent glyph map null errors + await Font.loadAsync(Ionicons.font); + // Auto-select best server address const bestAddress = await autoSelectServer(); const activeAddress = bestAddress ? toApiBaseUrl(bestAddress) : await getSavedApiBaseUrl(DEFAULT_API_BASE_URL); diff --git a/apps/mobile/src/components/TTSMiniPlayer.tsx b/apps/mobile/src/components/TTSMiniPlayer.tsx new file mode 100644 index 00000000..18587f26 --- /dev/null +++ b/apps/mobile/src/components/TTSMiniPlayer.tsx @@ -0,0 +1,192 @@ +/** + * TTSMiniPlayer — Bottom-fixed mini player for TTS audiobook. + * Shows when user minimizes the TTSScreen or navigates away while playing. + */ + +import { Ionicons } from '@expo/vector-icons'; +import { useNavigation } from '@react-navigation/native'; +import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; +import { useCallback, useMemo } from 'react'; +import { + View, + Text, + TouchableOpacity, + StyleSheet, +} 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 type { RootStackParamList } from '../navigation/types'; + +export function TTSMiniPlayer() { + const navigation = useNavigation>(); + const ttsStore = useTTSStore(); + const actualTheme = useThemeStore((state) => state.actualTheme); + const theme = getTheme(actualTheme === 'dark'); + + const playbackState = usePlaybackState(); + const progress = useProgress(); + + const isPlaying = playbackState.state === State.Playing; + const isPaused = playbackState.state === State.Paused; + + const styles = useMemo(() => createStyles(theme), [theme]); + + const handlePlayPause = useCallback(async () => { + if (isPaused) { + await TrackPlayer.play(); + ttsStore.setState('playing'); + } else if (isPlaying) { + await TrackPlayer.pause(); + ttsStore.setState('paused'); + } + }, [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 + 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); + ttsStore.reset(); + }, [ttsStore]); + + // Don't show if no book is loaded or explicitly hidden + if (!ttsStore.currentBookId || !ttsStore.isMiniPlayerVisible) { + return null; + } + + return ( + + {/* Progress bar at top */} + + + + + + {/* Cover thumbnail */} + + + + {ttsStore.chapterTitle?.charAt(0) || 'T'} + + + + + {/* Info */} + + + {ttsStore.chapterTitle || '正在朗读'} + + + 第 {ttsStore.currentParagraph + 1} 段 / 共 {ttsStore.totalParagraphs} 段 + + + + {/* Controls */} + + + + + + + + + + + + ); +} + +function createStyles(theme: ReturnType) { + return StyleSheet.create({ + container: { + position: 'absolute', + bottom: 0, + 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: { + height: 2, + overflow: 'hidden', + }, + progressFill: { + height: '100%', + }, + content: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + gap: spacing.sm, + }, + cover: { + width: 40, + height: 40, + borderRadius: borderRadius.sm, + overflow: 'hidden', + }, + coverInner: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + }, + coverText: { + fontSize: fontSizes.lg, + fontWeight: 'bold', + color: theme.colors.primary, + }, + info: { + flex: 1, + justifyContent: 'center', + }, + title: { + fontSize: fontSizes.sm, + fontWeight: '600', + color: theme.colors.text, + }, + subtitle: { + fontSize: fontSizes.xs, + color: theme.colors.textSecondary, + marginTop: 2, + }, + controls: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + }, + controlButton: { + padding: spacing.sm, + }, + }); +} diff --git a/apps/mobile/src/navigation/MainTabNavigator.tsx b/apps/mobile/src/navigation/MainTabNavigator.tsx index 38269151..c098bcd1 100644 --- a/apps/mobile/src/navigation/MainTabNavigator.tsx +++ b/apps/mobile/src/navigation/MainTabNavigator.tsx @@ -1,9 +1,11 @@ import type { JSX } from 'react'; import { createBottomTabNavigator } from '@react-navigation/bottom-tabs'; import { Ionicons } from '@expo/vector-icons'; +import { View } from 'react-native'; import { LibraryScreen } from '../screens/LibraryScreen'; import { RecommendScreen } from '../screens/RecommendScreen'; import { ProfileScreen } from '../screens/ProfileScreen'; +import { TTSMiniPlayer } from '../components/TTSMiniPlayer'; import { useThemeStore } from '../stores'; import { getTheme } from '../utils/theme'; import type { MainTabParamList } from './types'; @@ -15,23 +17,24 @@ export function MainTabNavigator() { const theme = getTheme(actualTheme === 'dark'); return ( - + + - + + + ); } diff --git a/apps/mobile/src/navigation/RootNavigator.tsx b/apps/mobile/src/navigation/RootNavigator.tsx index ef03ed17..a121d891 100644 --- a/apps/mobile/src/navigation/RootNavigator.tsx +++ b/apps/mobile/src/navigation/RootNavigator.tsx @@ -98,10 +98,7 @@ export function RootNavigator() { { - navigation.navigate('TTSReader', { book }); + navigation.navigate('TTSScreen', { book }); }, [navigation, book]); const handleToggleFavorite = useCallback(async () => { diff --git a/apps/mobile/src/screens/SettingsScreen.tsx b/apps/mobile/src/screens/SettingsScreen.tsx index b61cbcda..653c8047 100644 --- a/apps/mobile/src/screens/SettingsScreen.tsx +++ b/apps/mobile/src/screens/SettingsScreen.tsx @@ -20,6 +20,7 @@ import { notificationService, fileSystemService } from '../services'; import { getApiClient } from '@bookdock/api-client'; import type { RootStackParamList } from '../navigation/types'; import AsyncStorage from '@react-native-async-storage/async-storage'; +import { participateInternalTest } from '../services/plus'; export function SettingsScreen() { const navigation = useNavigation>(); @@ -34,6 +35,7 @@ export function SettingsScreen() { const [readingReminder, setReadingReminder] = useState(false); const [isLoading, setIsLoading] = useState(false); const [themeModalVisible, setThemeModalVisible] = useState(false); + const [redeemingInternalTest, setRedeemingInternalTest] = useState(false); const theme = getTheme(actualTheme === 'dark'); const styles = useMemo(() => createStyles(theme), [theme]); @@ -122,6 +124,62 @@ export function SettingsScreen() { ); }, [user]); + const handleJoinInternalTest = useCallback(async () => { + if (isVip) { + Alert.alert('已是内测用户', '您已经参与内测,无需重复申请'); + return; + } + + const plusUserId = await AsyncStorage.getItem('bookdock_plus_user_id'); + if (!plusUserId) { + Alert.alert('需要登录', '请先登录会员账号', [ + { text: '取消', style: 'cancel' }, + { text: '去登录', onPress: () => navigation.navigate('MemberLogin') }, + ]); + return; + } + + try { + setRedeemingInternalTest(true); + const vipStartsAt = new Date(); + const vipEndsAt = new Date(vipStartsAt); + vipEndsAt.setMonth(vipEndsAt.getMonth() + 1); + + const res = await participateInternalTest({ + vipStartsAt: vipStartsAt.toISOString(), + vipEndsAt: vipEndsAt.toISOString(), + }); + + const payload = res.data?.data; + if (res.data?.code !== 200 || !payload?.ok) { + throw new Error(res.data?.message || '参与内测失败'); + } + + await AsyncStorage.setItem('bookdock_vip_status', 'true'); + await AsyncStorage.setItem( + 'bookdock_vip_data', + JSON.stringify({ + ...payload, + vipExpiresAt: payload.vipEndsAt, + }) + ); + await AsyncStorage.setItem('bookdock_vip_updated_at', Date.now().toString()); + + // 更新全局状态 + useAuthStore.setState({ isVip: true, vipTier: payload.vipTier || 'BASIC' }); + + Alert.alert('成功', '恭喜!您已成功参与内测,获得1个月会员体验'); + } catch (error) { + console.error('参与内测失败:', error); + Alert.alert( + '失败', + error instanceof Error ? error.message : '参与内测失败,请稍后重试' + ); + } finally { + setRedeemingInternalTest(false); + } + }, [isVip, navigation]); + const renderSection = (title: string, children: React.ReactNode) => ( {title} @@ -236,6 +294,31 @@ export function SettingsScreen() { )} + {/* 内测参与 */} + {renderSection('内测计划', + <> + {renderRow( + 'flask-outline', + '参与内测', + + + {isVip ? '已参与' : redeemingInternalTest ? '申请中...' : '点击参与'} + + + , + handleJoinInternalTest + )} + + {renderRow( + 'trash-outline', + '删除会员账户', + , + handleDeleteAccount, + theme.colors.error + )} + + )} + {/* Theme Selection Modal */} setThemeModalVisible(false)}> setThemeModalVisible(false)}> @@ -260,6 +343,23 @@ export function SettingsScreen() { + {/* 内测计划 */} + {renderSection('内测计划', + <> + {renderRow( + 'flask-outline', + '参与内测', + + + {isVip ? '已参与' : redeemingInternalTest ? '申请中...' : '点击参与'} + + + , + handleJoinInternalTest + )} + + )} + {/* Logout */} 退出登录 diff --git a/apps/mobile/src/screens/TTSScreen.tsx b/apps/mobile/src/screens/TTSScreen.tsx index e7412844..3986cdd9 100644 --- a/apps/mobile/src/screens/TTSScreen.tsx +++ b/apps/mobile/src/screens/TTSScreen.tsx @@ -1,16 +1,13 @@ /** - * Mobile TTS Screen — paragraph-by-paragraph audio reading. + * TTSScreen — Mobile TTS audiobook reader with background playback. * - * - Loads paragraphs via /books/:id/paragraphs?chapter=N - * - Synthesises one paragraph at a time, plays it through expo-av, - * auto-advances to the next paragraph on completion - * - Real-time paragraph highlight (active paragraph gets a tinted - * background and font-weight bump) - * - Click any paragraph to jump there - * - Provider / voice / rate / volume can be tweaked locally for this - * page; defaults are loaded from ttsStore (set in Settings) - * - Reading position is saved to /tts/progress on each paragraph change + * - Paragraph-by-paragraph audio reading via react-native-track-player + * - Two view modes: "controls" (cover + playback controls) and "content" (paragraph list) + * - Tap cover to toggle between modes (like music player cover/lyrics switch) + * - Background playback with notification controls + * - Mini player mode triggered by down-arrow in header */ + import { getApiClient, Paragraph, @@ -19,12 +16,12 @@ import { } from "@bookdock/api-client"; import { Ionicons } from "@expo/vector-icons"; import { RouteProp, useNavigation, useRoute } from "@react-navigation/native"; -import { Audio } from "expo-av"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { ActivityIndicator, Alert, AppState, + Image, SafeAreaView, ScrollView, StyleSheet, @@ -32,23 +29,28 @@ import { TouchableOpacity, View, } from "react-native"; +import TrackPlayer, { + Capability, + Event, + State, + usePlaybackState, + useProgress, +} from "react-native-track-player"; import type { RootStackParamList } from "../navigation/types"; +import { getCoverImageUrl } from "../services/api"; import { useThemeStore, useTTSStore } from "../stores"; import { borderRadius, fontSizes, getTheme, spacing } from "../utils/theme"; type TTSScreenRouteProp = RouteProp; const providerLabel = (name: string) => { - if (name === "edge") return "Microsoft Edge TTS"; - if (name === "mi") return "小米 TTS"; + if (name === "edge") return "Edge"; + if (name === "mi") return "小米"; return name; }; -/** - * Split a paragraph into ≤ maxLen-character chunks. Breaks on sentence - * boundaries (CJK + Western), then hard-cuts at whitespace as a - * last resort. Mirrors the helper in @bookdock/tts. - */ +const TTS_CHUNK_MAX = 2500; + function splitForTts(text: string, maxLen: number): string[] { if (!text) return []; if (text.length <= maxLen) return [text]; @@ -83,8 +85,6 @@ function splitForTts(text: string, maxLen: number): string[] { return out; } -const TTS_CHUNK_MAX = 2500; - export function TTSScreen() { const navigation = useNavigation(); const route = useRoute(); @@ -114,22 +114,72 @@ export function TTSScreen() { ttsStore.selectedVoice?.id || "", ); const [showSettings, setShowSettings] = useState(false); + const [showChapterPicker, setShowChapterPicker] = useState(false); + const [showSpeedPicker, setShowSpeedPicker] = useState(false); + const [showTimerPicker, setShowTimerPicker] = useState(false); + const [sleepMinutes, setSleepMinutes] = useState(0); + const [sleepRemaining, setSleepRemaining] = useState(0); + + // View mode: 'controls' = cover + controls, 'content' = paragraph list + const [viewMode, setViewMode] = useState<'controls' | 'content'>('controls'); + + // RNTP progress + 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, setIsPlaying] = useState(false); - const [isPaused, setIsPaused] = useState(false); - const [isLoadingAudio, setIsLoadingAudio] = useState(false); const [currentParagraph, setCurrentParagraph] = useState(0); - const [paragraphProgress, setParagraphProgress] = useState(0); // 0..1 within the current paragraph - /** ms offset within the saved paragraph to resume at, populated - * by loadChapter() and consumed on the next play. */ + const [paragraphProgress, setParagraphProgress] = useState(0); const [resumeOffsetMs, setResumeOffsetMs] = useState(0); - const soundRef = useRef(null); - const prefetchedRef = useRef>(new Map()); // paragraphId → audio URI + const prefetchedRef = useRef>(new Map()); const cancelledRef = useRef(false); + const sleepTimerRef = useRef | null>(null); const styles = useMemo(() => createStyles(theme), [theme]); + // ── Setup TrackPlayer on mount ─────────────────────────────────────── + useEffect(() => { + let cancelled = false; + (async () => { + try { + await TrackPlayer.setupPlayer({ + autoHandleInterruptions: true, + }); + await TrackPlayer.updateOptions({ + capabilities: [ + Capability.Play, + Capability.Pause, + Capability.SkipToNext, + Capability.SkipToPrevious, + Capability.SeekTo, + Capability.JumpForward, + Capability.JumpBackward, + ], + compactCapabilities: [ + Capability.Play, + Capability.Pause, + Capability.SkipToNext, + Capability.SkipToPrevious, + ], + progressUpdateEventInterval: 1, + }); + // Reset player to clear any stuck loading/buffering state + await TrackPlayer.reset(); + } catch (e) { + // Player may already be set up, try reset anyway + try { await TrackPlayer.reset(); } catch {} + } + })(); + return () => { + cancelled = true; + }; + }, []); + // ── Load providers + voices + chapter content ────────────────────── useEffect(() => { let cancelled = false; @@ -138,38 +188,46 @@ export function TTSScreen() { setLoadError(null); try { const apiClient = getApiClient(); + console.log('[TTSScreen] Starting load, book.id:', book.id); // Providers const provRes = await apiClient.getTtsProviders(); + console.log('[TTSScreen] Providers response:', JSON.stringify(provRes)); if (cancelled) return; const ps = (provRes.success && provRes.data?.providers) || []; setProviders(ps); - // Default to a known-enabled provider if the stored one isn't in the list const enabledNames = ps.filter((p) => p.enabled).map((p) => p.name); const finalProvider = enabledNames.includes(provider) ? provider : enabledNames[0] || "edge"; + console.log('[TTSScreen] Final provider:', finalProvider); if (finalProvider !== provider) setProvider(finalProvider); // Voices const vRes = await apiClient.getVoices(finalProvider); + console.log('[TTSScreen] Voices response:', JSON.stringify(vRes)); if (cancelled) return; const vs = (vRes.success && vRes.data) || []; setVoices(vs); if (!voiceId && vs[0]) setVoiceId(vs[0].id); // Chapters + console.log('[TTSScreen] Loading chapters for book:', book.id); const chRes = await apiClient.getChapters(book.id); + console.log('[TTSScreen] Chapters response:', JSON.stringify(chRes)); if (cancelled) return; if (!chRes.success || !chRes.data || chRes.data.length === 0) { + console.log('[TTSScreen] No chapters found'); setLoadError("本书暂无章节内容,请先解析章节。"); return; } setChapters(chRes.data); - // Load first chapter paragraphs + // Load first chapter (loadChapter will skip empty ones) + console.log('[TTSScreen] Loading chapter 0, voice:', vs[0]?.id || voiceId, 'provider:', finalProvider); await loadChapter(0, vs[0]?.id || voiceId, finalProvider); } catch (e) { + console.error('[TTSScreen] Load error:', e); setLoadError((e as Error).message || "加载失败"); } finally { if (!cancelled) setLoading(false); @@ -177,7 +235,6 @@ export function TTSScreen() { })(); return () => { cancelled = true; - cleanupAudio(); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [book.id]); @@ -206,35 +263,106 @@ export function TTSScreen() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [provider]); + // ── Sleep timer ────────────────────────────────────────────────────── + useEffect(() => { + if (sleepMinutes <= 0) { + setSleepRemaining(0); + return; + } + setSleepRemaining(sleepMinutes * 60); + sleepTimerRef.current = setInterval(() => { + setSleepRemaining((s) => { + if (s <= 1) { + TrackPlayer.pause(); + setSleepMinutes(0); + return 0; + } + return s - 1; + }); + }, 1000); + return () => { + if (sleepTimerRef.current) clearInterval(sleepTimerRef.current); + }; + }, [sleepMinutes]); + + // ── TrackPlayer event: track ended → auto advance ─────────────────── + useEffect(() => { + const sub = TrackPlayer.addEventListener(Event.PlaybackQueueEnded, async () => { + console.log('[TTSScreen] Queue ended, auto-advancing paragraph'); + const nextIdx = currentParagraph + 1; + if (nextIdx < paragraphs.length) { + await playParagraph(nextIdx); + } else { + // Chapter ended, try next chapter + const nextChapter = chapterIndex + 1; + if (nextChapter < chapters.length) { + await loadChapter(nextChapter, voiceId, provider); + await playParagraph(0); + } + } + }); + return () => sub.remove(); + }, [currentParagraph, paragraphs.length, chapterIndex, chapters.length, voiceId, provider]); + + // ── 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); + } + }); + return () => sub.remove(); + }, [ttsStore]); + const loadChapter = async (ci: number, vid: string, prov: string) => { + console.log('[TTSScreen] loadChapter called, ci:', ci, 'vid:', vid, 'prov:', prov); setChapterIndex(ci); try { const apiClient = getApiClient(); + console.log('[TTSScreen] Fetching paragraphs for book:', book.id, 'chapter:', ci); const pRes = await apiClient.getChapterParagraphs(book.id, ci); + console.log('[TTSScreen] Paragraphs response:', JSON.stringify(pRes).substring(0, 500)); if (!pRes.success || !pRes.data) { + console.log('[TTSScreen] Failed to load paragraphs:', pRes); setLoadError("加载章节失败"); return; } + console.log('[TTSScreen] Paragraphs loaded, count:', pRes.data.paragraphs?.length); + + // 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) { + console.log('[TTSScreen] Chapter empty, trying next:', nextChapter); + return loadChapter(nextChapter, vid, prov); + } else { + setLoadError("没有可朗读的内容"); + return; + } + } + setChapterTitle(pRes.data.title); setParagraphs(pRes.data.paragraphs); setCurrentParagraph(0); setParagraphProgress(0); setResumeOffsetMs(0); prefetchedRef.current.clear(); - // Resume from saved cloud progress (cross-device sync) + ttsStore.setParagraphs(pRes.data.paragraphs); + ttsStore.setTotalParagraphs(pRes.data.paragraphs.length); + ttsStore.setChapterTitle(pRes.data.title); + ttsStore.setChapterIndex(ci); + + // Resume from saved cloud progress try { const prog = await apiClient.getTtsProgress(book.id, ci); if (prog.success && prog.data && !Array.isArray(prog.data)) { const rec = prog.data; - // Apply saved voice/provider so the first synthesis uses them if (rec.provider) { setProvider(rec.provider); ttsStore.setSelectedProvider(rec.provider); } - if (rec.voice) { - setVoiceId(rec.voice); - } - // Stash the byte offset for the next play() call + if (rec.voice) setVoiceId(rec.voice); setResumeOffsetMs(Math.max(0, rec.audioOffsetMs || 0)); setCurrentParagraph( Math.min(rec.paragraphIndex, pRes.data.paragraphs.length - 1), @@ -243,24 +371,19 @@ export function TTSScreen() { } catch { /* ignore */ } - // Save provider/voice in the store so the user only configures once ttsStore.setSelectedProvider(prov); } catch (e) { setLoadError((e as Error).message); } }; - const cleanupAudio = useCallback(async () => { - if (soundRef.current) { - try { - await soundRef.current.stopAsync(); - await soundRef.current.unloadAsync(); - } catch { - /* ignore */ - } - soundRef.current = null; - } - }, []); + const resolveAudioUrl = ( + url: string, + apiClient: ReturnType, + ) => { + if (url.startsWith("http")) return url; + return `${apiClient.serverBaseURL}${url}`; + }; const persistProgress = useCallback( async (idx: number, audioOffsetMs = 0) => { @@ -292,51 +415,24 @@ export function TTSScreen() { [book.id, chapterIndex, paragraphs.length, voiceId, provider], ); - const resolveAudioUrl = ( - url: string, - apiClient: ReturnType, - ) => { - if (url.startsWith("http")) return url; - return `${apiClient.serverBaseURL}${url}`; - }; - // ── Persist latest position when user leaves the screen ───────────── useEffect(() => { const unsubscribe = navigation.addListener("beforeRemove", async () => { - try { - const status = await soundRef.current?.getStatusAsync(); - const offsetMs = - status?.isLoaded && typeof status.positionMillis === "number" - ? status.positionMillis - : 0; - await persistProgress(currentParagraph, offsetMs); - } catch { - await persistProgress(currentParagraph, 0); - } + await persistProgress(currentParagraph, Math.round(progress.position * 1000)); + ttsStore.setMiniPlayerVisible(true); }); return unsubscribe; - }, [currentParagraph, navigation, persistProgress]); + }, [currentParagraph, 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 (async () => { - try { - const status = await soundRef.current?.getStatusAsync(); - const offsetMs = - status?.isLoaded && typeof status.positionMillis === "number" - ? status.positionMillis - : 0; - await persistProgress(currentParagraph, offsetMs); - } catch { - await persistProgress(currentParagraph, 0); - } - })(); + void persistProgress(currentParagraph, Math.round(progress.position * 1000)); } }); return () => subscription.remove(); - }, [currentParagraph, persistProgress]); + }, [currentParagraph, persistProgress, progress.position]); const prefetchParagraph = useCallback( async (idx: number) => { @@ -370,160 +466,103 @@ export function TTSScreen() { [book.id, paragraphs, provider, voiceId], ); + const synthesizeParagraph = useCallback( + async (idx: number): Promise => { + if (idx >= paragraphs.length) return []; + const para = paragraphs[idx]; + const chunks = splitForTts(para.text, TTS_CHUNK_MAX); + const apiClient = getApiClient(); + const uris: string[] = []; + for (let i = 0; i < chunks.length; i++) { + const chunk = chunks[i]; + const chunkParaId = chunks.length > 1 ? `${para.id}#${i}` : para.id; + let uri = prefetchedRef.current.get(chunkParaId); + if (!uri) { + const r = await apiClient.synthesizeParagraph({ + bookId: book.id, + paragraphId: chunkParaId, + text: chunk, + provider, + voice: voiceId, + }); + if (!r.success || !r.data) + throw new Error(r.error || "synthesize failed"); + uri = resolveAudioUrl(r.data.url, apiClient); + prefetchedRef.current.set(chunkParaId, uri); + } + uris.push(uri); + } + return uris; + }, + [book.id, paragraphs, provider, voiceId], + ); + const playParagraph = useCallback( async (idx: number) => { if (idx >= paragraphs.length) { - setIsPlaying(false); - setIsPaused(false); setCurrentParagraph(0); setParagraphProgress(0); ttsStore.setState("idle"); return; } - const para = paragraphs[idx]; setCurrentParagraph(idx); setParagraphProgress(0); - // The first call after a saved cloud resume carries the offset - // we want to skip into; persist progress only once we've actually - // landed at that position. + ttsStore.setCurrentParagraph(idx); const startOffsetMs = resumeOffsetMs; setResumeOffsetMs(0); - // Prefetch the next paragraph in the background prefetchParagraph(idx + 1); - setIsLoadingAudio(true); try { - await cleanupAudio(); - const apiClient = getApiClient(); - const chunks = splitForTts(para.text, TTS_CHUNK_MAX); - // Synthesize all chunks (use prefetched URI where available). - const uris: string[] = []; - for (let i = 0; i < chunks.length; i++) { - const chunk = chunks[i]; - const chunkParaId = chunks.length > 1 ? `${para.id}#${i}` : para.id; - let uri = prefetchedRef.current.get(chunkParaId); - if (!uri) { - const r = await apiClient.synthesizeParagraph({ - bookId: book.id, - paragraphId: chunkParaId, - text: chunk, - provider, - voice: voiceId, - }); - if (!r.success || !r.data) - throw new Error(r.error || "synthesize failed"); - uri = resolveAudioUrl(r.data.url, apiClient); - prefetchedRef.current.set(chunkParaId, uri); - } - uris.push(uri); + const uris = await synthesizeParagraph(idx); + if (uris.length === 0) return; + + // Build tracks for RNTP + const tracks = uris.map((uri, i) => ({ + id: `${paragraphs[idx].id}-${i}`, + url: uri, + title: `${book.title} - ${chapterTitle}`, + artist: book.author || "未知作者", + artwork: undefined, + duration: 0, + })); + + await TrackPlayer.reset(); + await TrackPlayer.add(tracks); + if (startOffsetMs > 0) { + await TrackPlayer.seekTo(startOffsetMs / 1000); } - // Play chunks sequentially. Chained via status callback. - await playChunksSequentially(uris, idx, startOffsetMs); + await TrackPlayer.play(); + ttsStore.setState("playing"); + ttsStore.setCurrentBook(book.id, idx, paragraphs.length); } catch (e) { console.error("TTS paragraph error", e); - setIsLoadingAudio(false); - setIsPlaying(false); ttsStore.setState("idle"); Alert.alert("TTS 错误", (e as Error).message || "语音合成失败"); - return; } }, [ book.id, - cleanupAudio, + book.title, + book.author, + chapterTitle, paragraphs, prefetchParagraph, - provider, + synthesizeParagraph, resumeOffsetMs, ttsStore, - voiceId, - voices, ], ); - /** Play a list of audio URIs sequentially. After the last one finishes, - * auto-advance to the next paragraph. */ - const playChunksSequentially = useCallback( - async (uris: string[], paraIdx: number, startOffsetMs = 0) => { - if (uris.length === 0) return; - const apiClient = getApiClient(); - // Mark progress at chunk 0 - setIsLoadingAudio(false); - setIsPlaying(true); - setIsPaused(false); - ttsStore.setState("playing"); - for (let i = 0; i < uris.length; i++) { - const isLast = i === uris.length - 1; - await new Promise((resolve, reject) => { - Audio.Sound.createAsync( - { uri: uris[i] }, - { - shouldPlay: true, - rate: ttsStore.playbackRate, - volume: ttsStore.volume, - }, - (status) => { - if (!status.isLoaded) return; - const total = status.durationMillis || 1; - // Approximate per-chunk progress within the paragraph - const chunkFrac = status.positionMillis / total; - setParagraphProgress((i + chunkFrac) / uris.length); - if (status.didJustFinish) { - if (isLast) { - // Hand off to next paragraph - playParagraph(paraIdx + 1).catch((e) => - console.error("auto-advance failed", e), - ); - } - resolve(); - } - }, - ) - .then(({ sound }) => { - soundRef.current = sound; - // Seek into the audio when the caller asked to resume at - // a specific offset (set by cloud-progress restore). - // Only the first chunk honors the offset; subsequent - // chunks start from 0. - if (i === 0 && startOffsetMs > 0) { - sound - .setStatusAsync({ positionMillis: startOffsetMs }) - .catch(() => { - /* seek failures are non-fatal */ - }); - } - }) - .catch(reject); - }); - } - // After all chunks are done, the last callback already advanced - // to the next paragraph. Store the chosen voice for persistence - // and overwrite the just-restored paragraph record with the - // current audioOffsetMs so subsequent saves don't collapse to 0. - const v = voices.find((x) => x.id === voiceId); - if (v) ttsStore.setSelectedVoice(v); - try { - const status = await soundRef.current?.getStatusAsync(); - if (status?.isLoaded && typeof status.positionMillis === "number") { - await persistProgress(paraIdx, status.positionMillis); - } - } catch { - /* ignore */ - } - }, - [persistProgress, playParagraph, ttsStore, voiceId, voices], - ); - const handlePlayPause = useCallback(async () => { - if (isPaused && soundRef.current) { - try { - await soundRef.current.playAsync(); - setIsPaused(false); - setIsPlaying(true); - ttsStore.setState("playing"); - } catch { - Alert.alert("错误", "恢复播放失败"); - } + if (isPaused) { + await TrackPlayer.play(); + ttsStore.setState("playing"); + return; + } + if (isPlaying) { + await TrackPlayer.pause(); + ttsStore.setState("paused"); + await persistProgress(currentParagraph, Math.round(progress.position * 1000)); return; } if (!paragraphs.length) { @@ -531,46 +570,28 @@ export function TTSScreen() { return; } await playParagraph(currentParagraph); - }, [isPaused, paragraphs.length, playParagraph, currentParagraph, ttsStore]); - - const handlePause = useCallback(async () => { - if (soundRef.current) { - try { - const status = await soundRef.current.getStatusAsync(); - await soundRef.current.pauseAsync(); - setIsPlaying(false); - setIsPaused(true); - ttsStore.setState("paused"); - if (status?.isLoaded && typeof status.positionMillis === "number") { - await persistProgress(currentParagraph, status.positionMillis); - } - } catch { - /* ignore */ - } - } - }, [currentParagraph, persistProgress, ttsStore]); + }, [isPaused, isPlaying, paragraphs.length, playParagraph, currentParagraph, ttsStore, persistProgress, progress.position]); const handleStop = useCallback(async () => { - await persistProgress(currentParagraph, 0); - await cleanupAudio(); - setIsPlaying(false); - setIsPaused(false); + await TrackPlayer.stop(); + await TrackPlayer.reset(); + ttsStore.setState("idle"); setCurrentParagraph(0); setParagraphProgress(0); - ttsStore.setState("idle"); - }, [cleanupAudio, currentParagraph, persistProgress, ttsStore]); + }, [ttsStore]); const handleJumpToParagraph = useCallback( - (idx: number) => { + async (idx: number) => { if (idx < 0 || idx >= paragraphs.length) return; if (isPlaying || isPaused) { - playParagraph(idx); + await playParagraph(idx); } else { setCurrentParagraph(idx); + ttsStore.setCurrentParagraph(idx); void persistProgress(idx); } }, - [paragraphs.length, isPlaying, isPaused, playParagraph, persistProgress], + [paragraphs.length, isPlaying, isPaused, playParagraph, persistProgress, ttsStore], ); const handleSkipBack = useCallback(() => { @@ -586,26 +607,21 @@ export function TTSScreen() { const handleChapterChange = useCallback( async (ci: number) => { if (ci === chapterIndex) return; - await cleanupAudio(); - setIsPlaying(false); - setIsPaused(false); + await TrackPlayer.stop(); + await TrackPlayer.reset(); + ttsStore.setState("idle"); await loadChapter(ci, voiceId, provider); + setShowChapterPicker(false); }, // eslint-disable-next-line react-hooks/exhaustive-deps - [chapterIndex, voiceId, provider, cleanupAudio], + [chapterIndex, voiceId, provider], ); const handleRateChange = useCallback( async (r: number) => { const newRate = Math.max(0.5, Math.min(2.0, r)); ttsStore.setPlaybackRate(newRate); - if (soundRef.current) { - try { - await soundRef.current.setRateAsync(newRate, true); - } catch { - /* ignore */ - } - } + await TrackPlayer.setRate(newRate); }, [ttsStore], ); @@ -613,17 +629,36 @@ export function TTSScreen() { const handleVolumeChange = useCallback( async (v: number) => { ttsStore.setVolume(v); - if (soundRef.current) { - try { - await soundRef.current.setVolumeAsync(v); - } catch { - /* ignore */ - } - } + await TrackPlayer.setVolume(v); }, [ttsStore], ); + const handleSleepTimerSet = useCallback((minutes: number) => { + setSleepMinutes(minutes); + setShowSettings(false); + if (minutes === 0) { + Alert.alert("睡眠定时", "已取消睡眠定时"); + } else { + Alert.alert("睡眠定时", `${minutes} 分钟后将暂停播放`); + } + }, []); + + const handleToggleViewMode = useCallback(() => { + setViewMode((prev) => (prev === 'controls' ? 'content' : 'controls')); + }, []); + + const handleMinimize = useCallback(() => { + ttsStore.setMiniPlayerVisible(true); + navigation.goBack(); + }, [ttsStore, navigation]); + + // ── Progress calculation ───────────────────────────────────────────── + const overallProgress = paragraphs.length + ? (currentParagraph + paragraphProgress) / paragraphs.length + : 0; + + // ── Render ─────────────────────────────────────────────────────────── if (loading) { return ( + {/* Header */} + + + + + + {book.title} + + setShowSettings(true)}> + + + - return ( - - - {/* Book info */} - - + {/* Book Cover - tap to switch to content view */} + - - {book.title.charAt(0).toUpperCase()} - - - - - {book.title} - - {book.author} - - {chapterTitle} · {currentParagraph + 1}/{paragraphs.length} - + + {book.coverUrl ? ( + + ) : ( + {book.title.charAt(0)} + )} + + {sleepMinutes > 0 && ( + + + + {Math.floor(sleepRemaining / 60)}m + + + )} + + + {/* Chapter Info */} + + {chapterTitle} + + {book.author} + + {/* Progress */} + + + + 第 {currentParagraph + 1} 段 / 共 {paragraphs.length} 段 + + {Math.round(overallProgress * 100)}% + + + + - - {/* Controls */} - - - - + {/* Playback Controls - compact row */} + + {/* Settings */} + setShowSettings(true)} + > + + + + {/* Speed */} + setShowSpeedPicker(!showSpeedPicker)} + > + + {ttsStore.playbackRate !== 1.0 && ( + + {ttsStore.playbackRate.toFixed(1)}x + + )} + {/* Skip back */} - + + {/* Play/Pause */} {isLoadingAudio ? ( ) : ( )} + {/* Skip forward */} - + + {/* Chapter picker */} setShowSettings(!showSettings)} - style={styles.controlButton} + style={[styles.iconButton, { backgroundColor: theme.colors.surface }]} + onPress={() => setShowChapterPicker(true)} > - + - - {/* Overall progress */} - - setShowTimerPicker(!showTimerPicker)} > - - - - - 第 {currentParagraph + 1} 段 / 共 {paragraphs.length} 段 - - - {Math.round(overallProgress * 100)}% - - + + {sleepMinutes > 0 && ( + + {Math.floor(sleepRemaining / 60)}m + + )} + - - {/* Settings panel */} - {showSettings && ( - - 本页语音设置 + {/* Book description */} + {book.description && ( + + 简介 + + {book.description} + + + )} + - 服务商 - - {providers.length === 0 ? ( - {providerLabel(provider)} - ) : ( - providers.map((p) => { - const active = provider === p.name; + {/* Chapter Picker Modal */} + {showChapterPicker && ( + + setShowChapterPicker(false)} /> + + + 章节列表 + setShowChapterPicker(false)}> + + + + + {chapters.map((c) => { + const active = chapterIndex === c.index; return ( setProvider(p.name)} + key={c.index} + onPress={() => handleChapterChange(c.index)} style={[ - styles.chip, - { - backgroundColor: active - ? theme.colors.primary - : theme.colors.background, - opacity: p.enabled ? 1 : 0.4, - }, + styles.chapterListItem, + active && { backgroundColor: theme.colors.primary + '20' }, ]} > - {providerLabel(p.name)} - {p.status === "needs_config" ? " *" : ""} + {c.index + 1}. + {c.title} ); - }) - )} + })} + + + )} - 语音 - - {voices.length === 0 && ( - 加载语音中… - )} - {voices.map((v) => { - const active = voiceId === v.id; - return ( + {/* Speed Picker Modal */} + {showSpeedPicker && ( + + setShowSpeedPicker(false)} /> + + + 倍速 + setShowSpeedPicker(false)}> + + + + + {[0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0].map((r) => ( { - setVoiceId(v.id); - ttsStore.setSelectedVoice(v); + handleRateChange(r); + setShowSpeedPicker(false); }} style={[ - styles.chip, - { - backgroundColor: active - ? theme.colors.primary - : theme.colors.background, - }, + styles.sleepOption, + ttsStore.playbackRate === r && { backgroundColor: theme.colors.primary }, ]} > - - {v.name} · {v.language || v.lang} + + {r}x - ); - })} - - - - 语速 {ttsStore.playbackRate.toFixed(1)}x - - - {[0.75, 1.0, 1.25, 1.5, 2.0].map((r) => ( - handleRateChange(r)} - style={[ - styles.chip, - { - backgroundColor: - ttsStore.playbackRate === r - ? theme.colors.primary - : theme.colors.background, - }, - ]} - > - - {r}x - - - ))} + ))} + + + )} - - 音量 {Math.round(ttsStore.volume * 100)}% - - - {[0, 0.25, 0.5, 0.75, 1.0].map((v) => ( - handleVolumeChange(v)} - style={[ - styles.chip, - { - backgroundColor: - Math.abs(ttsStore.volume - v) < 0.01 - ? theme.colors.primary - : theme.colors.background, - }, - ]} - > - + setShowTimerPicker(false)} /> + + + 定时关闭 + setShowTimerPicker(false)}> + + + + + {[0, 5, 10, 15, 30, 45, 60].map((minutes) => ( + { + handleSleepTimerSet(minutes); + setShowTimerPicker(false); + }} style={[ - styles.chipText, - { - color: - Math.abs(ttsStore.volume - v) < 0.01 - ? "#fff" - : theme.colors.text, - }, + styles.sleepOption, + sleepMinutes === minutes && { backgroundColor: theme.colors.primary }, ]} > - {Math.round(v * 100)}% - - - ))} + + {minutes === 0 ? '关闭' : `${minutes} 分钟`} + + + ))} + + + )} - {chapters.length > 1 && ( - <> - 章节 - - {chapters.map((c) => { - const active = chapterIndex === c.index; + {/* Settings Bottom Sheet */} + {showSettings && ( + + setShowSettings(false)} /> + + + 播放设置 + setShowSettings(false)}> + + + + + {/* Provider section */} + TTS 服务商 + + {providers.map((p) => { + const active = provider === p.name; return ( handleChapterChange(c.index)} + key={p.name} + disabled={!p.enabled} + onPress={() => setProvider(p.name)} style={[ styles.chip, { - backgroundColor: active - ? theme.colors.primary - : theme.colors.background, + backgroundColor: active ? theme.colors.primary : theme.colors.background, + opacity: p.enabled ? 1 : 0.4, }, ]} > - - {c.title} + + {providerLabel(p.name)} + + + ); + })} + + + {/* Voice section */} + 音色 + + {voices.map((v) => { + const active = voiceId === v.id; + return ( + { + setVoiceId(v.id); + ttsStore.setSelectedVoice(v); + }} + style={[ + styles.chip, + { backgroundColor: active ? theme.colors.primary : theme.colors.background }, + ]} + > + + {v.name} ); })} - - )} + + )} - {/* Paragraph list with highlight */} - - 朗读内容 - - {paragraphs.map((p, idx) => { - const isCurrent = idx === currentParagraph; - const isPast = idx < currentParagraph; - return ( - handleJumpToParagraph(idx)} - style={[ - styles.paragraphItem, - isCurrent && { - backgroundColor: theme.colors.primary + "25", - }, - ]} - > - - {p.text} - - - ); - })} - + + ); + } + + // ── Content View (Paragraph List) ──────────────────────────────────── + return ( + + {/* Header */} + + + + + + 朗读内容 + {chapterTitle} + + + {/* Paragraph list */} + + {paragraphs.map((p, idx) => { + const isCurrent = idx === currentParagraph; + const isPast = idx < currentParagraph; + return ( + handleJumpToParagraph(idx)} + style={[ + styles.paragraphItem, + isCurrent && { backgroundColor: theme.colors.primary + '25' }, + ]} + > + + {p.text} + + + ); + })} + ); } @@ -1038,15 +1094,10 @@ function createStyles(theme: ReturnType) { container: { flex: 1, }, - content: { - flex: 1, - padding: spacing.md, - gap: spacing.md, - }, center: { flex: 1, - alignItems: "center", - justifyContent: "center", + alignItems: 'center', + justifyContent: 'center', gap: spacing.md, }, muted: { @@ -1056,7 +1107,7 @@ function createStyles(theme: ReturnType) { errorText: { color: theme.colors.error, fontSize: fontSizes.md, - textAlign: "center", + textAlign: 'center', }, button: { paddingHorizontal: spacing.lg, @@ -1064,92 +1115,178 @@ function createStyles(theme: ReturnType) { borderRadius: borderRadius.md, }, buttonText: { - color: "#fff", + color: '#fff', fontSize: fontSizes.md, - fontWeight: "600", + fontWeight: '600', }, - bookInfo: { - flexDirection: "row", - padding: spacing.md, - borderRadius: borderRadius.lg, - alignItems: "center", - }, - bookCover: { - width: 60, - height: 80, - borderRadius: borderRadius.sm, - justifyContent: "center", - alignItems: "center", + // Header + header: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: spacing.md, + paddingTop: spacing.xl, + paddingBottom: spacing.sm, }, - bookCoverText: { - fontSize: fontSizes.xxxl, - fontWeight: "bold", - color: theme.colors.primary, + headerButton: { + padding: spacing.sm, + width: 48, + alignItems: 'center', }, - bookMeta: { + headerCenter: { flex: 1, - marginLeft: spacing.md, + alignItems: 'center', }, - bookTitle: { - fontSize: fontSizes.lg, - fontWeight: "600", - color: theme.colors.text, + headerTitle: { + fontSize: fontSizes.sm, + color: theme.colors.textSecondary, }, - bookAuthor: { + headerSubtitle: { fontSize: fontSizes.md, - color: theme.colors.textSecondary, - marginTop: spacing.xs, + fontWeight: '600', + color: theme.colors.text, + }, + // Controls view + controlsContent: { + padding: spacing.lg, + paddingTop: spacing.xl, + paddingBottom: spacing.xxl, + alignItems: 'center', + }, + coverSection: { + alignItems: 'center', + marginBottom: spacing.lg, + position: 'relative', + }, + bookCoverLarge: { + width: 160, + height: 224, + borderRadius: borderRadius.xl, + justifyContent: 'center', + alignItems: 'center', + shadowColor: '#000', + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.2, + shadowRadius: 8, + elevation: 8, + overflow: 'hidden', }, - bookType: { + bookCoverImage: { + width: '100%', + height: '100%', + }, + coverInitialLarge: { + fontSize: 52, + fontWeight: 'bold', + color: theme.colors.primary, + }, + tapHint: { fontSize: fontSizes.sm, color: theme.colors.textSecondary, - marginTop: spacing.xs, + marginTop: spacing.sm, }, - controls: { - padding: spacing.md, - borderRadius: borderRadius.lg, - gap: spacing.md, + sleepBadge: { + position: 'absolute', + top: spacing.sm, + right: '20%', + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: spacing.sm, + paddingVertical: spacing.xs, + borderRadius: borderRadius.full, + gap: spacing.xs, }, - mainControls: { - flexDirection: "row", - justifyContent: "center", - alignItems: "center", - gap: spacing.md, + sleepBadgeText: { + fontSize: fontSizes.xs, + color: '#fff', + fontWeight: '600', }, - controlButton: { - padding: spacing.sm, + chapterTitleLarge: { + fontSize: fontSizes.xl, + fontWeight: '600', + color: theme.colors.text, + textAlign: 'center', + marginBottom: spacing.xs, }, - playButton: { - width: 64, - height: 64, - borderRadius: 32, - justifyContent: "center", - alignItems: "center", + bookAuthorLarge: { + fontSize: fontSizes.md, + color: theme.colors.textSecondary, + textAlign: 'center', + marginBottom: spacing.lg, }, - progressContainer: { + progressSection: { + width: '100%', + marginBottom: spacing.lg, gap: spacing.xs, }, + progressLabelsRow: { + flexDirection: 'row', + justifyContent: 'space-between', + paddingHorizontal: spacing.xs, + }, progressBar: { height: 6, borderRadius: 3, - overflow: "hidden", + overflow: 'hidden', }, progressFill: { - height: "100%", + height: '100%', borderRadius: 3, }, - progressLabels: { - flexDirection: "row", - justifyContent: "space-between", + // Controls row + controlsRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: spacing.sm, + marginBottom: spacing.lg, + }, + iconButton: { + width: 44, + height: 44, + borderRadius: 22, + justifyContent: 'center', + alignItems: 'center', + position: 'relative', + }, + playButtonLarge: { + width: 72, + height: 72, + borderRadius: 36, + justifyContent: 'center', + alignItems: 'center', + }, + badge: { + position: 'absolute', + top: -4, + right: -4, + borderRadius: borderRadius.sm, + paddingHorizontal: 4, + paddingVertical: 1, + }, + badgeText: { + fontSize: 10, + color: '#fff', + fontWeight: '600', }, + // Picker dropdown + pickerDropdown: { + width: '100%', + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + borderRadius: borderRadius.lg, + marginBottom: spacing.sm, + }, + // Settings settingsPanel: { + width: '100%', padding: spacing.md, borderRadius: borderRadius.lg, gap: spacing.xs, + marginBottom: spacing.md, }, settingsTitle: { fontSize: fontSizes.md, - fontWeight: "600", + fontWeight: '600', color: theme.colors.text, marginBottom: spacing.xs, }, @@ -1159,8 +1296,8 @@ function createStyles(theme: ReturnType) { marginTop: spacing.sm, }, chipRow: { - flexDirection: "row", - flexWrap: "wrap", + flexDirection: 'row', + flexWrap: 'wrap', gap: spacing.xs, paddingVertical: spacing.xs, }, @@ -1172,19 +1309,19 @@ function createStyles(theme: ReturnType) { chipText: { fontSize: fontSizes.sm, }, - paragraphsPanel: { - flex: 1, - padding: spacing.md, - borderRadius: borderRadius.lg, + // Description + descriptionPanel: { + width: '100%', }, - previewTitle: { - fontSize: fontSizes.md, - fontWeight: "600", + descriptionText: { + fontSize: fontSizes.sm, color: theme.colors.text, - marginBottom: spacing.sm, + lineHeight: fontSizes.sm * 1.6, }, + // Content view paragraphsScroll: { flex: 1, + padding: spacing.md, }, paragraphItem: { paddingVertical: spacing.sm, @@ -1196,5 +1333,65 @@ function createStyles(theme: ReturnType) { fontSize: fontSizes.md, lineHeight: fontSizes.md * 1.5, }, + // Modal + modalOverlay: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + zIndex: 100, + }, + modalBackdrop: { + flex: 1, + backgroundColor: 'rgba(0,0,0,0.4)', + }, + modalContent: { + position: 'absolute', + bottom: 0, + left: 0, + right: 0, + maxHeight: '70%', + borderTopLeftRadius: borderRadius.xl, + borderTopRightRadius: borderRadius.xl, + padding: spacing.lg, + }, + modalHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: spacing.md, + }, + modalTitle: { + fontSize: fontSizes.lg, + fontWeight: '600', + color: theme.colors.text, + }, + chapterListItem: { + paddingVertical: spacing.md, + paddingHorizontal: spacing.sm, + borderBottomWidth: 1, + borderBottomColor: theme.colors.border + '30', + }, + chapterListText: { + fontSize: fontSizes.md, + }, + chapterListNumber: { + color: theme.colors.textSecondary, + fontSize: fontSizes.sm, + }, + sleepOptions: { + gap: spacing.sm, + }, + sleepOption: { + paddingVertical: spacing.md, + borderRadius: borderRadius.md, + backgroundColor: theme.colors.background, + alignItems: 'center', + }, + sleepOptionText: { + fontSize: fontSizes.md, + color: theme.colors.text, + }, }); } diff --git a/apps/mobile/src/services/index.ts b/apps/mobile/src/services/index.ts index 6407f76a..e54b0e25 100644 --- a/apps/mobile/src/services/index.ts +++ b/apps/mobile/src/services/index.ts @@ -252,6 +252,8 @@ export { setPlusToken, getPlusToken, removePlusToken, + participateInternalTest, + deletePlusMe, } from './plus'; export type { ISuccessResponse, @@ -259,4 +261,8 @@ export type { ScanLoginSessionStatus, ScanLoginClaimPayload, ScanLoginConfirmResult, + VipStatusResponse, + ParticipateInternalTestDto, + ParticipateInternalTestResponse, + DeletePlusMeResponse, } from './plus'; diff --git a/apps/mobile/src/services/playbackService.ts b/apps/mobile/src/services/playbackService.ts new file mode 100644 index 00000000..90565560 --- /dev/null +++ b/apps/mobile/src/services/playbackService.ts @@ -0,0 +1,63 @@ +import TrackPlayer, { Event, State } from 'react-native-track-player'; + +export const PlaybackService = async function () { + console.log('[PlaybackService] Registered'); + + const ensurePlaying = async () => { + const playback = await TrackPlayer.getPlaybackState(); + if (playback.state !== State.Playing) { + await TrackPlayer.play(); + } + }; + + const ensurePaused = async () => { + const playback = await TrackPlayer.getPlaybackState(); + if (playback.state !== State.Paused) { + await TrackPlayer.pause(); + } + }; + + TrackPlayer.addEventListener(Event.RemotePlay, async () => { + console.log('[PlaybackService] RemotePlay'); + await ensurePlaying(); + }); + + TrackPlayer.addEventListener(Event.RemotePause, async () => { + console.log('[PlaybackService] RemotePause'); + await ensurePaused(); + }); + + TrackPlayer.addEventListener(Event.RemoteStop, async () => { + console.log('[PlaybackService] RemoteStop'); + await TrackPlayer.stop(); + }); + + TrackPlayer.addEventListener(Event.RemoteSeek, async (event) => { + console.log('[PlaybackService] RemoteSeek:', event.position); + await TrackPlayer.seekTo(event.position); + }); + + TrackPlayer.addEventListener(Event.RemoteJumpForward, async (event) => { + console.log('[PlaybackService] RemoteJumpForward:', event.interval); + await TrackPlayer.seekBy(event.interval || 15); + }); + + TrackPlayer.addEventListener(Event.RemoteJumpBackward, async (event) => { + console.log('[PlaybackService] RemoteJumpBackward:', event.interval); + await TrackPlayer.seekBy(-(event.interval || 15)); + }); + + TrackPlayer.addEventListener(Event.RemoteNext, async () => { + console.log('[PlaybackService] RemoteNext'); + await TrackPlayer.skipToNext(); + }); + + TrackPlayer.addEventListener(Event.RemotePrevious, async () => { + console.log('[PlaybackService] RemotePrevious'); + await TrackPlayer.skipToPrevious(); + }); + + TrackPlayer.addEventListener(Event.PlaybackQueueEnded, async () => { + console.log('[PlaybackService] Queue ended'); + }); +}; diff --git a/apps/mobile/src/services/plus.ts b/apps/mobile/src/services/plus.ts index ad83fca3..a5ad6463 100644 --- a/apps/mobile/src/services/plus.ts +++ b/apps/mobile/src/services/plus.ts @@ -1,330 +1,265 @@ +import axios from 'axios'; + +export const PLUS_API_BASE_URL = 'https://www.bookdock.cn/api'; + +export const plusRequest = axios.create({ + baseURL: PLUS_API_BASE_URL, +}); + +let plusUnauthorizedHandler: (() => void | Promise) | null = null; +let isHandlingPlusUnauthorized = false; + /** - * Plus API Service for BookDock Mobile - * Connects to AudioDock Plus API (https://www.audiodock.cn/api) + * 设置 Plus 服务的验证 Token + * @param token JWT Token */ +export const setPlusToken = (token: string) => { + plusRequest.defaults.headers.common['Authorization'] = `Bearer ${token}`; +}; -import AsyncStorage from "@react-native-async-storage/async-storage"; -import { io, type Socket } from "socket.io-client"; +/** + * 获取 Plus 服务的验证 Token + */ +export const getPlusToken = () => { + return plusRequest.defaults.headers.common['Authorization'] as string | undefined; +}; -const PLUS_API_BASE_URL = "https://www.audiodock.cn/api"; -const PLUS_WS_BASE_URL = "https://www.audiodock.cn/ws"; +/** + * 移除 Plus 服务的验证 Token + */ +export const removePlusToken = () => { + delete plusRequest.defaults.headers.common['Authorization']; +}; -// --- Types --- -export interface ISuccessResponse { - code: number; - data?: T; - message?: string; -} +export const setPlusUnauthorizedHandler = ( + handler: (() => void | Promise) | null, +) => { + plusUnauthorizedHandler = handler; +}; + +const hasPlusAuthHeader = (headers: any) => { + if (!headers) return false; + const authHeader = + headers.Authorization || + headers.authorization || + headers.common?.Authorization || + headers.common?.authorization; + return Boolean(authHeader); +}; + +const isPlusUnauthorizedPayload = (payload: any) => { + if (!payload || typeof payload !== 'object') return false; + if (payload.code !== 401) return false; + const message = String(payload.message || '').toLowerCase(); + return message === 'invalid token' || message === 'missing token'; +}; + +const handlePlusUnauthorized = async () => { + if (isHandlingPlusUnauthorized) return; + isHandlingPlusUnauthorized = true; + + try { + removePlusToken(); + await plusUnauthorizedHandler?.(); + } finally { + setTimeout(() => { + isHandlingPlusUnauthorized = false; + }, 0); + } +}; + +plusRequest.interceptors.response.use( + async (response) => { + if ( + hasPlusAuthHeader(response.config?.headers) && + isPlusUnauthorizedPayload(response.data) + ) { + await handlePlusUnauthorized(); + } + return response; + }, + async (error) => { + const status = error?.response?.status; + if ( + status === 401 && + hasPlusAuthHeader(error?.config?.headers) + ) { + await handlePlusUnauthorized(); + } + return Promise.reject(error); + }, +); + +// --- DTO Types --- export interface SendCodeDto { + /** Phone number in E.164 format, e.g. +8613812345678 */ phone: string; } export interface LoginDto { + /** Phone number in E.164 format */ phone: string; + /** Verification code */ code: string; } - -export interface CreatePaymentDto { - userId: string; - amount: number; - method: "WECHAT" | "ALIPAY" | "STRIPE" | "PAYPAL" | "OTHER"; - forVip: boolean; - forPoints: boolean; - vipTier?: "BASIC" | "PREMIUM" | "LIFETIME"; - clientType?: "app" | "web" | "desktop"; - couponCode?: string; -} - -export interface CreatePaymentResult { - orderId: string; - transactionId?: string | null; - paymentUrl: string; - qrCode: string; - wechatPay?: any | null; - alipayPay?: any | null; - originalAmount: number; - finalAmount: number; - couponDiscount?: any | null; - raw?: any; -} - -export interface PaymentStatusResult { - orderId: string; - status: 'pending' | 'paid' | 'failed' | 'cancelled'; - paidAt?: string | null; - amount?: number; -} -export interface ScanLoginSourceConfig { +export interface ScanLoginSession { id: string; - internal: string; - external: string; - name?: string; + status: ScanLoginSessionStatus; + clientId: string; + createdAt: string; + expiresAt: string; + claimedAt?: string; + confirmedAt?: string; + consumedAt?: string; + userId?: string; + token?: string; } -export interface ScanLoginSourceBundle { - type: string; - configs: ScanLoginSourceConfig[]; -} +export type ScanLoginSessionStatus = 'PENDING' | 'CLAIMED' | 'CONFIRMED' | 'CONSUMED' | 'EXPIRED'; -export interface ScanLoginAuthBundle { - baseUrl: string; - sourceType: string; - token: string; - user: any; - device?: any; +export interface ScanLoginClaimPayload { + deviceName: string; + deviceType: string; } -export interface ScanLoginPlusBundle { - token: string; - userId: string | number; +export interface ScanLoginConfirmResult { + success: boolean; + token?: string; + userId?: string; } -export interface ScanLoginSession { - sessionId: string; - secret: string; - role: "scanner" | "target"; - deviceKind: "mobile" | "desktop"; - expiresAt: number; +export interface VipStatusResponse { + isVip: boolean; + tier: string; + expiresAt: string | null; } -export interface ScanLoginSessionStatus extends Omit { - status: "waiting_scan" | "waiting_confirm" | "confirmed" | "consumed" | "success" | "failed" | "expired"; - deviceName?: string; - sourceBundles: ScanLoginSourceBundle[]; - hasNativeAuth: boolean; - hasPlusAuth: boolean; +export interface ParticipateInternalTestDto { + vipStartsAt: string; + vipEndsAt: string; } -export interface ScanLoginClaimPayload { - nativeAuth?: ScanLoginAuthBundle | null; - plusAuth?: ScanLoginPlusBundle | null; - sourceBundles: ScanLoginSourceBundle[]; - deviceName?: string; +export interface ParticipateInternalTestResponse { + ok: true; + id: string; + batchId: string; + code: string; + vipTier: string; + vipStartsAt: string; + vipEndsAt: string; + usedAt: string | null; + usedByUserId: string | null; + createdAt: string; } -export interface ScanLoginConfirmResult { - nativeAuth: ScanLoginAuthBundle | null; - plusAuth: ScanLoginPlusBundle | null; - sourceBundles: ScanLoginSourceBundle[]; +export interface DeletePlusMeResponse { + ok: boolean; + userId: string; + deletedAt: string; } -// --- Token Management --- - -let plusSocket: Socket | null = null; - -export const getPlusSocket = (): Socket => { - if (!plusSocket) { - plusSocket = io(PLUS_WS_BASE_URL, { - transports: ["websocket"], - }); - } - return plusSocket; -}; - -export const setPlusToken = async (token: string) => { - await AsyncStorage.setItem("bookdock_plus_token", token); -}; - -export const getPlusToken = async (): Promise => { - return AsyncStorage.getItem("bookdock_plus_token"); -}; - -export const removePlusToken = async () => { - await AsyncStorage.removeItem("bookdock_plus_token"); -}; +export interface ISuccessResponse { + code: number; + message: string; + data: T; +} // --- API Functions --- -async function plusFetch(endpoint: string, options: RequestInit = {}): Promise> { - const headers: Record = { - "Content-Type": "application/json", - ...(options.headers as Record || {}), - }; - - const token = await getPlusToken(); - if (token) { - headers.Authorization = `Bearer ${token}`; - } - - const response = await fetch(`${PLUS_API_BASE_URL}${endpoint}`, { - ...options, - headers, - }); - - const data = await response.json(); - return data; -} - -export const plusSendCode = async (data: { phone: string }) => { - return plusFetch("/auth/send-code", { - method: "POST", - body: JSON.stringify(data), - }); +/** + * AuthController_sendCode: Send login code to phone + */ +export const plusSendCode = async (data: SendCodeDto) => { + return plusRequest.post>('/auth/send-code', data); }; -export const plusLogin = async (data: { phone: string; code: string }) => { - return plusFetch<{ token: string; userId: string }>("/auth/login", { - method: "POST", - body: JSON.stringify(data), - }); +/** + * AuthController_login: Login with phone and code + */ +export const plusLogin = async (data: LoginDto) => { + return plusRequest.post>('/auth/login', data); }; +/** + * UserController_getMe: Get current user profile + */ export const plusGetMe = async (userId: string) => { - return plusFetch(`/users/me?userId=${encodeURIComponent(userId)}`); + return plusRequest.get>('/users/me', { params: { userId } }); }; +/** + * VipController_status: Get VIP status + */ export const plusGetVipStatus = async (userId: string) => { - return plusFetch<{ isVip: boolean; tier: string; expiresAt: string | null }>( - `/vip/status?userId=${encodeURIComponent(userId)}` - ); + return plusRequest.get>('/vip/status', { params: { userId } }); }; -export const plusCreateVipPayment = async (data: CreatePaymentDto) => { - return plusFetch("/payment/create", { - method: "POST", - body: JSON.stringify(data), - }); -}; +// --- Scan Login API --- -export const plusQueryPaymentStatus = async (orderId: string) => { - return plusFetch(`/payment/status?orderId=${encodeURIComponent(orderId)}`); +export const createScanLoginSession = async () => { + return plusRequest.post>('/auth/scan-login'); }; -export const plusCancelOrder = async (orderId: string) => { - return plusFetch(`/payment/cancel`, { - method: "POST", - body: JSON.stringify({ orderId }), - }); +export const getScanLoginSession = async (sessionId: string) => { + return plusRequest.get>(`/auth/scan-login/${sessionId}`); }; -export const plusGetCurrentLowestPrice = async () => { - return plusFetch<{ annual?: number; lifetime?: number; annualPrice?: number; lifetimePrice?: number }>("/vip/current-lowest-price"); +export const claimScanLoginSession = async (sessionId: string, payload: ScanLoginClaimPayload) => { + return plusRequest.post>(`/auth/scan-login/${sessionId}/claim`, payload); }; -export interface PlusCoupon { - id: string; - code: string; - discountPercent: number; - expiresAt?: string; -} - -export const plusGetMyCoupons = async () => { - return plusFetch("/coupons/mine"); -}; - -export const plusVerifyCoupon = async (code: string, userId: string) => { - return plusFetch<{ valid: boolean; discountPercent?: number; message?: string }>("/coupons/verify", { - method: "POST", - body: JSON.stringify({ code, userId }), - }); -}; - -// --- Scan Login APIs --- - -export const createScanLoginSession = async (data: { - role: "scanner" | "target"; - deviceKind: "mobile" | "desktop"; -}) => { - return plusFetch("/scan-login/session", { - method: "POST", - body: JSON.stringify(data), - }); -}; - -export const getScanLoginSession = async (sessionId: string, secret: string) => { - return plusFetch( - `/scan-login/session/${sessionId}?secret=${encodeURIComponent(secret)}` - ); +export const confirmScanLoginSession = async (sessionId: string) => { + return plusRequest.post>(`/auth/scan-login/${sessionId}/confirm`); }; -export const claimScanLoginSession = async ( - sessionId: string, - data: { secret: string; payload: ScanLoginClaimPayload }, -) => { - return plusFetch(`/scan-login/session/${sessionId}/claim`, { - method: "POST", - body: JSON.stringify(data), - }); +export const consumeScanLoginSession = async (sessionId: string) => { + return plusRequest.post>(`/auth/scan-login/${sessionId}/consume`); }; -export const confirmScanLoginSession = async ( - sessionId: string, - data: { secret: string; selections?: { type: string; configIds: string[] }[] }, -) => { - return plusFetch(`/scan-login/session/${sessionId}/confirm`, { - method: "POST", - body: JSON.stringify(data), - }); +export const reportScanLoginResult = async (sessionId: string, result: { success: boolean; token?: string; userId?: string }) => { + return plusRequest.post>(`/auth/scan-login/${sessionId}/result`, result); }; -export const consumeScanLoginSession = async ( - sessionId: string, - data: { secret: string; selections?: { type: string; configIds: string[] }[] }, -) => { - return plusFetch(`/scan-login/session/${sessionId}/consume`, { - method: "POST", - body: JSON.stringify(data), - }); +export const subscribeScanLoginSession = (sessionId: string, onUpdate: (session: ScanLoginSession) => void) => { + // 轮询实现 + const interval = setInterval(async () => { + try { + const res = await getScanLoginSession(sessionId); + if (res.data?.data) { + onUpdate(res.data.data); + if (['CONSUMED', 'EXPIRED'].includes(res.data.data.status)) { + clearInterval(interval); + } + } + } catch { + clearInterval(interval); + } + }, 2000); + + return () => clearInterval(interval); }; -export const reportScanLoginResult = async ( - sessionId: string, - data: { secret: string; success: boolean; error?: string }, -) => { - return plusFetch(`/scan-login/session/${sessionId}/report`, { - method: "POST", - body: JSON.stringify(data), - }); +export const reportScanLoginResultViaSocket = async (sessionId: string, result: { success: boolean; token?: string; userId?: string }) => { + return plusRequest.post>(`/auth/scan-login/${sessionId}/result`, result); }; -export const subscribeScanLoginSession = ( - sessionId: string, - secret: string, - listener: (status: ScanLoginSessionStatus) => void, +/** + * 参与内测 - 直接获取内测资格 + */ +export const participateInternalTest = async ( + data: ParticipateInternalTestDto, ) => { - const socket = getPlusSocket(); - const eventName = `scan_login_session_update:${sessionId}`; - const reportEventName = "scan_login_report_result"; - - const handleUpdate = (payload: { - sessionId: string; - secret?: string; - status: ScanLoginSessionStatus; - }) => { - if (payload?.sessionId !== sessionId) return; - if (payload?.secret && payload.secret !== secret) return; - listener(payload.status); - }; - - const handleReport = (payload: { - sessionId: string; - secret?: string; - success: boolean; - error?: string; - }) => { - if (payload?.sessionId !== sessionId) return; - if (payload?.secret && payload.secret !== secret) return; - listener({ status: payload.success ? "success" : "failed", sessionId } as any); - }; - - socket.on(eventName, handleUpdate); - socket.on(reportEventName, handleReport); - socket.emit("scan_login_watch", { sessionId, secret }); - - return () => { - socket.off(eventName, handleUpdate); - socket.off(reportEventName, handleReport); - }; + return plusRequest.post>( + '/users/internal-test-codes/participate', + data, + ); }; -export const reportScanLoginResultViaSocket = ( - sessionId: string, - secret: string, - success: boolean, - error?: string, -) => { - const socket = getPlusSocket(); - socket.emit("scan_login_report_result", { sessionId, secret, success, error }); -}; +/** + * 删除当前会员账户 + */ +export const deletePlusMe = async () => { + return plusRequest.delete>('/users/me'); +}; \ No newline at end of file diff --git a/apps/mobile/src/stores/ttsStore.ts b/apps/mobile/src/stores/ttsStore.ts index e7c05f23..bea4938a 100644 --- a/apps/mobile/src/stores/ttsStore.ts +++ b/apps/mobile/src/stores/ttsStore.ts @@ -1,9 +1,10 @@ -import type { TTSVoice } from '@bookdock/api-client'; +import type { 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'; type TTSState = 'idle' | 'playing' | 'paused' | 'loading'; +type ViewMode = 'controls' | 'content'; interface TTSStoreState { state: TTSState; @@ -17,6 +18,17 @@ interface TTSStoreState { volume: number; isAutoPlay: boolean; + // View mode + viewMode: ViewMode; + // Mini player + isMiniPlayerVisible: boolean; + // Paragraph tracking + currentParagraph: number; + totalParagraphs: number; + paragraphs: Paragraph[]; + chapterTitle: string; + chapterIndex: number; + // Actions setState: (state: TTSState) => void; setCurrentBook: (bookId: string | null, position?: number, totalLength?: number) => void; @@ -27,6 +39,13 @@ interface TTSStoreState { setPlaybackRate: (rate: number) => void; setVolume: (volume: number) => void; setAutoPlay: (autoPlay: boolean) => void; + setViewMode: (mode: ViewMode) => void; + setMiniPlayerVisible: (visible: boolean) => void; + setCurrentParagraph: (index: number) => void; + setTotalParagraphs: (count: number) => void; + setParagraphs: (paragraphs: Paragraph[]) => void; + setChapterTitle: (title: string) => void; + setChapterIndex: (index: number) => void; reset: () => void; } @@ -43,6 +62,13 @@ export const useTTSStore = create()( playbackRate: 1.0, volume: 1.0, isAutoPlay: true, + viewMode: 'controls', + isMiniPlayerVisible: false, + currentParagraph: 0, + totalParagraphs: 0, + paragraphs: [], + chapterTitle: '', + chapterIndex: 0, setState: (state) => set({ state }), @@ -67,11 +93,32 @@ export const useTTSStore = create()( setAutoPlay: (autoPlay) => set({ isAutoPlay: autoPlay }), + setViewMode: (mode) => set({ viewMode: mode }), + + setMiniPlayerVisible: (visible) => set({ isMiniPlayerVisible: visible }), + + setCurrentParagraph: (index) => set({ currentParagraph: index }), + + setTotalParagraphs: (count) => set({ totalParagraphs: count }), + + setParagraphs: (paragraphs) => set({ paragraphs }), + + setChapterTitle: (title) => set({ chapterTitle: title }), + + setChapterIndex: (index) => set({ chapterIndex: index }), + reset: () => set({ state: 'idle', currentBookId: null, currentPosition: 0, totalLength: 0, + currentParagraph: 0, + totalParagraphs: 0, + paragraphs: [], + chapterTitle: '', + chapterIndex: 0, + isMiniPlayerVisible: false, + viewMode: 'controls', }), }), { diff --git a/package.json b/package.json index 7e90d526..d2b1e915 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "workbox-window": "^7.4.0" }, "dependencies": { + "@0no-co/graphql.web": "^1.2.0", "expo": "~52.0.49", "react": "18.3.1", "react-native": "0.76.9" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 75c083a8..220adf50 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -13,6 +13,9 @@ importers: .: dependencies: + '@0no-co/graphql.web': + specifier: ^1.2.0 + version: 1.2.0 expo: specifier: ~52.0.49 version: 52.0.49(@babel/core@7.29.0)(@babel/preset-env@7.29.2(@babel/core@7.29.0))(@expo/metro-runtime@4.0.1(react-native@0.76.9(@babel/core@7.29.0)(@babel/preset-env@7.29.2(@babel/core@7.29.0))(@types/react@18.3.28)(encoding@0.1.13)(react@18.3.1)))(encoding@0.1.13)(react-native-webview@13.12.5(react-native@0.76.9(@babel/core@7.29.0)(@babel/preset-env@7.29.2(@babel/core@7.29.0))(@types/react@18.3.28)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native@0.76.9(@babel/core@7.29.0)(@babel/preset-env@7.29.2(@babel/core@7.29.0))(@types/react@18.3.28)(encoding@0.1.13)(react@18.3.1))(react@18.3.1) @@ -210,6 +213,9 @@ importers: expo-file-system: specifier: ~18.0.0 version: 18.0.12(expo@52.0.49(@babel/core@7.29.0)(@babel/preset-env@7.29.2(@babel/core@7.29.0))(@expo/metro-runtime@4.0.1(react-native@0.76.9(@babel/core@7.29.0)(@babel/preset-env@7.29.2(@babel/core@7.29.0))(@types/react@18.3.28)(encoding@0.1.13)(react@18.3.1)))(encoding@0.1.13)(react-native-webview@13.12.5(react-native@0.76.9(@babel/core@7.29.0)(@babel/preset-env@7.29.2(@babel/core@7.29.0))(@types/react@18.3.28)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native@0.76.9(@babel/core@7.29.0)(@babel/preset-env@7.29.2(@babel/core@7.29.0))(@types/react@18.3.28)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native@0.76.9(@babel/core@7.29.0)(@babel/preset-env@7.29.2(@babel/core@7.29.0))(@types/react@18.3.28)(encoding@0.1.13)(react@18.3.1)) + expo-font: + specifier: ~13.0.4 + version: 13.0.4(expo@52.0.49(@babel/core@7.29.0)(@babel/preset-env@7.29.2(@babel/core@7.29.0))(@expo/metro-runtime@4.0.1(react-native@0.76.9(@babel/core@7.29.0)(@babel/preset-env@7.29.2(@babel/core@7.29.0))(@types/react@18.3.28)(encoding@0.1.13)(react@18.3.1)))(encoding@0.1.13)(react-native-webview@13.12.5(react-native@0.76.9(@babel/core@7.29.0)(@babel/preset-env@7.29.2(@babel/core@7.29.0))(@types/react@18.3.28)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native@0.76.9(@babel/core@7.29.0)(@babel/preset-env@7.29.2(@babel/core@7.29.0))(@types/react@18.3.28)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react@18.3.1) expo-intent-launcher: specifier: ~12.0.2 version: 12.0.2(expo@52.0.49(@babel/core@7.29.0)(@babel/preset-env@7.29.2(@babel/core@7.29.0))(@expo/metro-runtime@4.0.1(react-native@0.76.9(@babel/core@7.29.0)(@babel/preset-env@7.29.2(@babel/core@7.29.0))(@types/react@18.3.28)(encoding@0.1.13)(react@18.3.1)))(encoding@0.1.13)(react-native-webview@13.12.5(react-native@0.76.9(@babel/core@7.29.0)(@babel/preset-env@7.29.2(@babel/core@7.29.0))(@types/react@18.3.28)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native@0.76.9(@babel/core@7.29.0)(@babel/preset-env@7.29.2(@babel/core@7.29.0))(@types/react@18.3.28)(encoding@0.1.13)(react@18.3.1))(react@18.3.1)) @@ -261,6 +267,9 @@ importers: react-native-screens: specifier: ~4.4.0 version: 4.4.0(react-native@0.76.9(@babel/core@7.29.0)(@babel/preset-env@7.29.2(@babel/core@7.29.0))(@types/react@18.3.28)(encoding@0.1.13)(react@18.3.1))(react@18.3.1) + react-native-track-player: + specifier: 3.2.0 + version: 3.2.0(react-native@0.76.9(@babel/core@7.29.0)(@babel/preset-env@7.29.2(@babel/core@7.29.0))(@types/react@18.3.28)(encoding@0.1.13)(react@18.3.1))(react@18.3.1) react-native-web: specifier: ~0.19.13 version: 0.19.13(encoding@0.1.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -4990,6 +4999,7 @@ packages: glob@11.1.0: resolution: {integrity: sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==} engines: {node: 20 || >=22} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true glob@13.0.1: @@ -6737,6 +6747,16 @@ packages: react: '*' react-native: '*' + react-native-track-player@3.2.0: + resolution: {integrity: sha512-svv6TOgU/quFV1aajG5PskVhgFG0wHQtO+aYs6cIH0D27ckzN8WVoC3jI94m1CVaSgLMw48P911csYuD2fIcqA==} + peerDependencies: + react: '>=16.8.6' + react-native: '>=0.60.0-rc.2' + react-native-windows: '>=0.63.0' + peerDependenciesMeta: + react-native-windows: + optional: true + react-native-web@0.19.13: resolution: {integrity: sha512-etv3bN8rJglrRCp/uL4p7l8QvUNUC++QwDbdZ8CB7BvZiMvsxfFIRM1j04vxNldG3uo2puRd6OSWR3ibtmc29A==} peerDependencies: @@ -15551,6 +15571,11 @@ snapshots: react-native: 0.76.9(@babel/core@7.29.0)(@babel/preset-env@7.29.2(@babel/core@7.29.0))(@types/react@18.3.28)(encoding@0.1.13)(react@18.3.1) warn-once: 0.1.1 + react-native-track-player@3.2.0(react-native@0.76.9(@babel/core@7.29.0)(@babel/preset-env@7.29.2(@babel/core@7.29.0))(@types/react@18.3.28)(encoding@0.1.13)(react@18.3.1))(react@18.3.1): + dependencies: + react: 18.3.1 + react-native: 0.76.9(@babel/core@7.29.0)(@babel/preset-env@7.29.2(@babel/core@7.29.0))(@types/react@18.3.28)(encoding@0.1.13)(react@18.3.1) + react-native-web@0.19.13(encoding@0.1.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: '@babel/runtime': 7.29.2