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
+
+ {/* 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