Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/mobile/android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
<uses-permission android:name="android.permission.VIBRATE"/>
<uses-permission android:name="android.permission.WAKE_LOCK"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
<queries>
<intent>
Expand Down
2 changes: 1 addition & 1 deletion apps/mobile/android/gradle.properties
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 2 additions & 1 deletion apps/mobile/app.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,8 @@
{
"sounds": []
}
]
],
"expo-font"
],
"extra": {
"eas": {
Expand Down
5 changes: 5 additions & 0 deletions apps/mobile/index.js
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
4 changes: 4 additions & 0 deletions apps/mobile/ios/BookDock/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,10 @@
<string>Allow $(PRODUCT_NAME) to access your Face ID biometric data.</string>
<key>NSMicrophoneUsageDescription</key>
<string>Allow $(PRODUCT_NAME) to access your microphone</string>
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
</array>
<key>UILaunchStoryboardName</key>
<string>SplashScreen</string>
<key>UIRequiredDeviceCapabilities</key>
Expand Down
2 changes: 2 additions & 0 deletions apps/mobile/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
5 changes: 5 additions & 0 deletions apps/mobile/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Expand Down
192 changes: 192 additions & 0 deletions apps/mobile/src/components/TTSMiniPlayer.tsx
Original file line number Diff line number Diff line change
@@ -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<NativeStackNavigationProp<RootStackParamList>>();
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 (
<View style={[styles.container, { backgroundColor: theme.colors.surface }]}>
{/* Progress bar at top */}
<View style={[styles.progressBar, { backgroundColor: theme.colors.border + '40' }]}>
<View
style={[
styles.progressFill,
{
backgroundColor: theme.colors.primary,
width: `${(progress.position / (progress.duration || 1)) * 100}%`,
},
]}
/>
</View>

<View style={styles.content}>
{/* Cover thumbnail */}
<TouchableOpacity onPress={handleExpand} style={styles.cover}>
<View style={[styles.coverInner, { backgroundColor: theme.colors.primary + '20' }]}>
<Text style={styles.coverText}>
{ttsStore.chapterTitle?.charAt(0) || 'T'}
</Text>
</View>
</TouchableOpacity>

{/* Info */}
<TouchableOpacity onPress={handleExpand} style={styles.info}>
<Text style={styles.title} numberOfLines={1}>
{ttsStore.chapterTitle || '正在朗读'}
</Text>
<Text style={styles.subtitle} numberOfLines={1}>
第 {ttsStore.currentParagraph + 1} 段 / 共 {ttsStore.totalParagraphs} 段
</Text>
</TouchableOpacity>

{/* Controls */}
<View style={styles.controls}>
<TouchableOpacity onPress={handlePlayPause} style={styles.controlButton}>
<Ionicons
name={isPlaying ? 'pause' : 'play'}
size={24}
color={theme.colors.primary}
/>
</TouchableOpacity>

<TouchableOpacity onPress={handleClose} style={styles.controlButton}>
<Ionicons name="close" size={20} color={theme.colors.textSecondary} />
</TouchableOpacity>
</View>
</View>
</View>
);
}

function createStyles(theme: ReturnType<typeof getTheme>) {
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,
},
});
}
41 changes: 23 additions & 18 deletions apps/mobile/src/navigation/MainTabNavigator.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -15,23 +17,24 @@ export function MainTabNavigator() {
const theme = getTheme(actualTheme === 'dark');

return (
<Tab.Navigator
screenOptions={{
tabBarActiveTintColor: theme.colors.primary,
tabBarInactiveTintColor: theme.colors.textSecondary,
tabBarStyle: {
backgroundColor: theme.colors.background,
borderTopColor: theme.colors.border,
},
headerStyle: {
backgroundColor: theme.colors.background,
},
headerTintColor: theme.colors.text,
headerTitleStyle: {
fontWeight: '600',
},
}}
>
<View style={{ flex: 1 }}>
<Tab.Navigator
screenOptions={{
tabBarActiveTintColor: theme.colors.primary,
tabBarInactiveTintColor: theme.colors.textSecondary,
tabBarStyle: {
backgroundColor: theme.colors.background,
borderTopColor: theme.colors.border,
},
headerStyle: {
backgroundColor: theme.colors.background,
},
headerTintColor: theme.colors.text,
headerTitleStyle: {
fontWeight: '600',
},
}}
>
<Tab.Screen
name="Recommend"
component={RecommendScreen}
Expand Down Expand Up @@ -65,6 +68,8 @@ export function MainTabNavigator() {
headerTitle: '',
}}
/>
</Tab.Navigator>
</Tab.Navigator>
<TTSMiniPlayer />
</View>
);
}
5 changes: 1 addition & 4 deletions apps/mobile/src/navigation/RootNavigator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -98,10 +98,7 @@ export function RootNavigator() {
<Stack.Screen
name="TTSScreen"
component={TTSScreen}
options={{
title: "听书",
headerBackTitle: "返回",
}}
options={{ headerShown: false, animation: 'slide_from_bottom' }}
/>
<Stack.Screen
name="Settings"
Expand Down
2 changes: 1 addition & 1 deletion apps/mobile/src/screens/BookDetailScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ export function BookDetailScreen() {
}, [navigation, book]);

const handleTTS = useCallback(() => {
navigation.navigate('TTSReader', { book });
navigation.navigate('TTSScreen', { book });
}, [navigation, book]);

const handleToggleFavorite = useCallback(async () => {
Expand Down
Loading
Loading