From a20b7552702f3ee4d590b8ac72b0ec0d2bc43fb6 Mon Sep 17 00:00:00 2001 From: BookDock Dev Date: Fri, 12 Jun 2026 23:26:28 +0800 Subject: [PATCH 1/6] =?UTF-8?q?feat:=20=E7=A7=BB=E5=8A=A8=E7=AB=AF?= =?UTF-8?q?=E4=BF=AE=E6=94=B9=20tts=20=E9=A1=B5=E9=9D=A2=E5=B8=83=E5=B1=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../.expo/prebuild/cached-packages.json | 2 +- .../ios/BookDock.xcodeproj/project.pbxproj | 4 +- apps/mobile/ios/Podfile.lock | 18 +- apps/mobile/src/screens/TTSScreen.tsx | 476 ++++++++++++++---- 4 files changed, 400 insertions(+), 100 deletions(-) diff --git a/apps/mobile/.expo/prebuild/cached-packages.json b/apps/mobile/.expo/prebuild/cached-packages.json index 3d1e9e6e..7a7b03ab 100644 --- a/apps/mobile/.expo/prebuild/cached-packages.json +++ b/apps/mobile/.expo/prebuild/cached-packages.json @@ -1,4 +1,4 @@ { - "dependencies": "4c6c46f85ef3aae30206750b8003384fc449d9be", + "dependencies": "8a1c22423cc3ef699863e28c02845e645a1c345e", "devDependencies": "bf2b9e763f70fb9f3e3cd3badd4d6b47189ccdd5" } diff --git a/apps/mobile/ios/BookDock.xcodeproj/project.pbxproj b/apps/mobile/ios/BookDock.xcodeproj/project.pbxproj index 446a2f77..05f73248 100644 --- a/apps/mobile/ios/BookDock.xcodeproj/project.pbxproj +++ b/apps/mobile/ios/BookDock.xcodeproj/project.pbxproj @@ -371,7 +371,7 @@ ); OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG"; PRODUCT_BUNDLE_IDENTIFIER = com.bookdock.app; - PRODUCT_NAME = "BookDock"; + PRODUCT_NAME = BookDock; SWIFT_OBJC_BRIDGING_HEADER = "BookDock/BookDock-Bridging-Header.h"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; @@ -403,7 +403,7 @@ ); OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE"; PRODUCT_BUNDLE_IDENTIFIER = com.bookdock.app; - PRODUCT_NAME = "BookDock"; + PRODUCT_NAME = BookDock; SWIFT_OBJC_BRIDGING_HEADER = "BookDock/BookDock-Bridging-Header.h"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; diff --git a/apps/mobile/ios/Podfile.lock b/apps/mobile/ios/Podfile.lock index 9480491d..acc3300e 100644 --- a/apps/mobile/ios/Podfile.lock +++ b/apps/mobile/ios/Podfile.lock @@ -1516,6 +1516,9 @@ PODS: - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - Yoga + - react-native-track-player (3.2.0): + - React-Core + - SwiftAudioEx (= 0.15.3) - react-native-webview (13.12.5): - DoubleConversion - glog @@ -1876,6 +1879,7 @@ PODS: - ReactCommon/turbomodule/core - Yoga - SocketRocket (0.7.1) + - SwiftAudioEx (0.15.3) - Yoga (0.0.0) - ZXingObjC/Core (3.6.9) - ZXingObjC/OneD (3.6.9): @@ -1887,12 +1891,12 @@ DEPENDENCIES: - "boost (from `../../../node_modules/.pnpm/react-native@0.76.9_@babel+core@7.29.0_@babel+preset-env@7.29.2_@babel+core@7.29.0__@ty_5b47f4191fccc6326c7433113200ce36/node_modules/react-native/third-party-podspecs/boost.podspec`)" - "DoubleConversion (from `../../../node_modules/.pnpm/react-native@0.76.9_@babel+core@7.29.0_@babel+preset-env@7.29.2_@babel+core@7.29.0__@ty_5b47f4191fccc6326c7433113200ce36/node_modules/react-native/third-party-podspecs/DoubleConversion.podspec`)" - "EXApplication (from `../../../node_modules/.pnpm/expo-application@6.0.2_expo@52.0.49_@babel+core@7.29.0_@babel+preset-env@7.29.2_@babel+_8aada838badbbb9b9fc0db20947b91ec/node_modules/expo-application/ios`)" - - "EXAV (from `../../../node_modules/.pnpm/expo-av@15.0.2_expo@52.0.49_@babel+core@7.29.0_@babel+preset-env@7.29.2_@babel+core@7.2_523bd471ae0ea5a1ff9ffbd66fa60126/node_modules/expo-av/ios`)" + - "EXAV (from `../../../node_modules/.pnpm/expo-av@15.0.2_expo@52.0.49_@babel+core@7.29.0_@babel+preset-env@7.29.2_@babel+core@7.2_ec04850fe5d65ebc0b21084fa9d0198e/node_modules/expo-av/ios`)" - "EXConstants (from `../../../node_modules/.pnpm/expo-constants@17.0.8_expo@52.0.49_@babel+core@7.29.0_@babel+preset-env@7.29.2_@babel+c_827742c1a7387a8d561caf738fb8f26d/node_modules/expo-constants/ios`)" - "EXNotifications (from `../../../node_modules/.pnpm/expo-notifications@0.29.14_expo@52.0.49_@babel+core@7.29.0_@babel+preset-env@7.29.2_@ba_897aec8a6574faddb4568e8feebce4dc/node_modules/expo-notifications/ios`)" - "Expo (from `../../../node_modules/.pnpm/expo@52.0.49_@babel+core@7.29.0_@babel+preset-env@7.29.2_@babel+core@7.29.0__@expo+metr_dfe6d2a5b8d690dd262b65d9b96bf258/node_modules/expo`)" - "ExpoAsset (from `../../../node_modules/.pnpm/expo-asset@11.0.5_expo@52.0.49_@babel+core@7.29.0_@babel+preset-env@7.29.2_@babel+core@_6a1c7d3533930fa99087cea314242ee7/node_modules/expo-asset/ios`)" - - "ExpoCamera (from `../../../node_modules/.pnpm/expo-camera@16.0.18_expo@52.0.49_@babel+core@7.29.0_@babel+preset-env@7.29.2_@babel+cor_0d37fed885492298cff92477878ee31e/node_modules/expo-camera/ios`)" + - "ExpoCamera (from `../../../node_modules/.pnpm/expo-camera@16.0.18_expo@52.0.49_@babel+core@7.29.0_@babel+preset-env@7.29.2_@babel+cor_cf62aa9939b7cff329a990cdec18e6e8/node_modules/expo-camera/ios`)" - "ExpoFileSystem (from `../../../node_modules/.pnpm/expo-file-system@18.0.12_expo@52.0.49_@babel+core@7.29.0_@babel+preset-env@7.29.2_@babe_fb4bb0a638b1739f514d5cede251db5c/node_modules/expo-file-system/ios`)" - "ExpoFont (from `../../../node_modules/.pnpm/expo-font@13.0.4_expo@52.0.49_@babel+core@7.29.0_@babel+preset-env@7.29.2_@babel+core@7_62c91e13165fe4e7305c20cdf48274da/node_modules/expo-font/ios`)" - "ExpoKeepAwake (from `../../../node_modules/.pnpm/expo-keep-awake@14.0.3_expo@52.0.49_@babel+core@7.29.0_@babel+preset-env@7.29.2_@babel+_db919d6e9cb9acbff9755fad0b5ebe61/node_modules/expo-keep-awake/ios`)" @@ -1944,6 +1948,7 @@ DEPENDENCIES: - react-native-pdf (from `../node_modules/react-native-pdf`) - react-native-safe-area-context (from `../node_modules/react-native-safe-area-context`) - "react-native-slider (from `../node_modules/@react-native-community/slider`)" + - react-native-track-player (from `../node_modules/react-native-track-player`) - react-native-webview (from `../node_modules/react-native-webview`) - "React-nativeconfig (from `../../../node_modules/.pnpm/react-native@0.76.9_@babel+core@7.29.0_@babel+preset-env@7.29.2_@babel+core@7.29.0__@ty_5b47f4191fccc6326c7433113200ce36/node_modules/react-native/ReactCommon`)" - "React-NativeModulesApple (from `../../../node_modules/.pnpm/react-native@0.76.9_@babel+core@7.29.0_@babel+preset-env@7.29.2_@babel+core@7.29.0__@ty_5b47f4191fccc6326c7433113200ce36/node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios`)" @@ -1979,6 +1984,7 @@ DEPENDENCIES: SPEC REPOS: trunk: - SocketRocket + - SwiftAudioEx - ZXingObjC EXTERNAL SOURCES: @@ -1989,7 +1995,7 @@ EXTERNAL SOURCES: EXApplication: :path: "../../../node_modules/.pnpm/expo-application@6.0.2_expo@52.0.49_@babel+core@7.29.0_@babel+preset-env@7.29.2_@babel+_8aada838badbbb9b9fc0db20947b91ec/node_modules/expo-application/ios" EXAV: - :path: "../../../node_modules/.pnpm/expo-av@15.0.2_expo@52.0.49_@babel+core@7.29.0_@babel+preset-env@7.29.2_@babel+core@7.2_523bd471ae0ea5a1ff9ffbd66fa60126/node_modules/expo-av/ios" + :path: "../../../node_modules/.pnpm/expo-av@15.0.2_expo@52.0.49_@babel+core@7.29.0_@babel+preset-env@7.29.2_@babel+core@7.2_ec04850fe5d65ebc0b21084fa9d0198e/node_modules/expo-av/ios" EXConstants: :path: "../../../node_modules/.pnpm/expo-constants@17.0.8_expo@52.0.49_@babel+core@7.29.0_@babel+preset-env@7.29.2_@babel+c_827742c1a7387a8d561caf738fb8f26d/node_modules/expo-constants/ios" EXNotifications: @@ -1999,7 +2005,7 @@ EXTERNAL SOURCES: ExpoAsset: :path: "../../../node_modules/.pnpm/expo-asset@11.0.5_expo@52.0.49_@babel+core@7.29.0_@babel+preset-env@7.29.2_@babel+core@_6a1c7d3533930fa99087cea314242ee7/node_modules/expo-asset/ios" ExpoCamera: - :path: "../../../node_modules/.pnpm/expo-camera@16.0.18_expo@52.0.49_@babel+core@7.29.0_@babel+preset-env@7.29.2_@babel+cor_0d37fed885492298cff92477878ee31e/node_modules/expo-camera/ios" + :path: "../../../node_modules/.pnpm/expo-camera@16.0.18_expo@52.0.49_@babel+core@7.29.0_@babel+preset-env@7.29.2_@babel+cor_cf62aa9939b7cff329a990cdec18e6e8/node_modules/expo-camera/ios" ExpoFileSystem: :path: "../../../node_modules/.pnpm/expo-file-system@18.0.12_expo@52.0.49_@babel+core@7.29.0_@babel+preset-env@7.29.2_@babe_fb4bb0a638b1739f514d5cede251db5c/node_modules/expo-file-system/ios" ExpoFont: @@ -2099,6 +2105,8 @@ EXTERNAL SOURCES: :path: "../node_modules/react-native-safe-area-context" react-native-slider: :path: "../node_modules/@react-native-community/slider" + react-native-track-player: + :path: "../node_modules/react-native-track-player" react-native-webview: :path: "../node_modules/react-native-webview" React-nativeconfig: @@ -2221,6 +2229,7 @@ SPEC CHECKSUMS: react-native-pdf: 1f4d418f1408001468263a7638f668593dad144d react-native-safe-area-context: cd916088cac5300c3266876218377518987b995e react-native-slider: 18a34929dbeb86db5375b56ef28551299a3fa89c + react-native-track-player: 12bd55f56b5557bfd9aa59d5284954529874fb57 react-native-webview: 6b9fc65c1951203a3e958ff3cc0a858d4b6be901 React-nativeconfig: 8efdb1ef1e9158c77098a93085438f7e7b463678 React-NativeModulesApple: cebca2e5320a3d66e123cade23bd90a167ffce5e @@ -2252,6 +2261,7 @@ SPEC CHECKSUMS: RNCAsyncStorage: 66991bb6672ed988a8c56289a08bf4fc3a29d4b3 RNScreens: 362f4c861dd155f898908d5035d97b07a3f1a9d1 SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748 + SwiftAudioEx: 83eabba2940924fc1c0d5cb0896049921365229c Yoga: feb4910aba9742cfedc059e2b2902e22ffe9954a ZXingObjC: 8898711ab495761b2dbbdec76d90164a6d7e14c5 diff --git a/apps/mobile/src/screens/TTSScreen.tsx b/apps/mobile/src/screens/TTSScreen.tsx index 8515363b..3257570c 100644 --- a/apps/mobile/src/screens/TTSScreen.tsx +++ b/apps/mobile/src/screens/TTSScreen.tsx @@ -22,6 +22,8 @@ import { Alert, AppState, Image, + Modal, + Pressable, SafeAreaView, ScrollView, StyleSheet, @@ -49,6 +51,72 @@ const providerLabel = (name: string) => { return name; }; +/** + * Format voice name for display. + * Edge TTS names are long like "Microsoft Server Speech Text to Speech Voice (zh-CN, XiaoxiaoNeural)" + * We extract just the Chinese name and region tag. + */ +function formatVoiceName(voice: TTSVoice, providerName: string): string { + const name = voice.name || ''; + + // 小米 TTS: return name as-is, no region tag + if (providerName === 'mi') { + return name; + } + + // Edge TTS: extract name from parentheses, e.g. "(zh-CN, XiaoxiaoNeural)" + const match = name.match(/\(([a-z]{2}-[A-Z]{2}),\s*([^)]+)\)/); + if (match) { + const region = match[1].toLowerCase(); // e.g. "zh-cn", "zh-hk", "zh-tw" + const voiceId = match[2]; // e.g. "XiaoxiaoNeural" + + // Map voice IDs to Chinese names + const nameMap: Record = { + 'XiaoxiaoNeural': '晓晓', + 'XiaoyiNeural': '晓伊', + 'YunjianNeural': '云健', + 'YunxiNeural': '云希', + 'YunxiaNeural': '云夏', + 'YunyangNeural': '云扬', + 'XiaochenNeural': '晓辰', + 'XiaohanNeural': '晓涵', + 'XiaomengNeural': '晓梦', + 'XiaomoNeural': '晓墨', + 'XiaoqiuNeural': '晓秋', + 'XiaoruiNeural': '晓睿', + 'XiaoshuangNeural': '晓双', + 'XiaoyanNeural': '晓颜', + 'XiaoyouNeural': '晓悠', + 'XiaozhenNeural': '晓甄', + 'YunfengNeural': '云枫', + 'YunhaoNeural': '云浩', + 'YunyeNeural': '云野', + 'YunzeNeural': '云泽', + 'HiuMaanNeural': '晓曼', + 'WanLungNeural': '云龙', + 'HsiaoChenNeural': '晓臻', + 'HsiaoYuNeural': '晓雨', + 'YunJheNeural': '云哲', + }; + + const chineseName = nameMap[voiceId] || voiceId.replace('Neural', ''); + + // Region tag for Edge TTS only + let regionTag = ''; + if (region === 'zh-hk' || region === 'zh-hant-hk') regionTag = 'HK'; + else if (region === 'zh-tw' || region === 'zh-hant-tw') regionTag = 'TW'; + else if (region === 'zh-cn' || region === 'zh-hans') regionTag = 'CN'; + + return regionTag ? `${chineseName} (${regionTag})` : chineseName; + } + + // For other providers or fallback, return name as-is but truncate if too long + if (name.length > 20) { + return name.slice(0, 18) + '...'; + } + return name; +} + const TTS_CHUNK_MAX = 2500; function splitForTts(text: string, maxLen: number): string[] { @@ -118,6 +186,7 @@ export function TTSScreen() { ttsStore.selectedVoice?.id || "", ); const [showSettings, setShowSettings] = useState(false); + const [showTTSConfig, setShowTTSConfig] = useState(false); const [showChapterPicker, setShowChapterPicker] = useState(false); const [showSpeedPicker, setShowSpeedPicker] = useState(false); const [showTimerPicker, setShowTimerPicker] = useState(false); @@ -817,6 +886,19 @@ export function TTSScreen() { {book.author} + {/* Book description */} + {book.description && ( + + 简介 + + {book.description} + + + )} + + + {/* Bottom fixed: Progress + Controls */} + {/* Progress */} @@ -845,19 +927,6 @@ export function TTSScreen() { - {/* Speed */} - setShowSpeedPicker(!showSpeedPicker)} - > - - {ttsStore.playbackRate !== 1.0 && ( - - {ttsStore.playbackRate.toFixed(1)}x - - )} - - {/* Skip back */} setShowChapterPicker(true)} > - - - - {/* Sleep timer */} - setShowTimerPicker(!showTimerPicker)} - > - - {sleepMinutes > 0 && ( - - {Math.floor(sleepRemaining / 60)}m - - )} + - - {/* Book description */} - {book.description && ( - - 简介 - - {book.description} - - - )} - + {/* Chapter Picker Modal */} - {showChapterPicker && ( - - setShowChapterPicker(false)} /> - + setShowChapterPicker(false)} + > + + setShowChapterPicker(false)} + /> + + 章节列表 setShowChapterPicker(false)}> - + {chapters.map((c) => { const active = chapterIndex === c.index; return ( @@ -962,13 +1017,22 @@ export function TTSScreen() { - )} + {/* Speed Picker Modal */} - {showSpeedPicker && ( - - setShowSpeedPicker(false)} /> - + setShowSpeedPicker(false)} + > + + setShowSpeedPicker(false)} + /> + + 倍速 setShowSpeedPicker(false)}> @@ -996,13 +1060,22 @@ export function TTSScreen() { - )} + {/* Timer Picker Modal */} - {showTimerPicker && ( - - setShowTimerPicker(false)} /> - + setShowTimerPicker(false)} + > + + setShowTimerPicker(false)} + /> + + 定时关闭 setShowTimerPicker(false)}> @@ -1030,22 +1103,105 @@ export function TTSScreen() { - )} + + + {/* Settings Bottom Sheet - More Options */} + setShowSettings(false)} + > + + {/* Backdrop - click to close */} + setShowSettings(false)} + /> + {/* Sheet content - positioned at bottom */} + + + + + 更多选项 + - {/* Settings Bottom Sheet */} - {showSettings && ( - - setShowSettings(false)} /> - - - 播放设置 - setShowSettings(false)}> - + + { + setShowSettings(false); + setShowSpeedPicker(true); + }} + > + + 播放倍速 + + {ttsStore.playbackRate.toFixed(1)}x + + - - - {/* Provider section */} - TTS 服务商 + + { + setShowSettings(false); + setShowTimerPicker(true); + }} + > + + 定时关闭 + + {sleepMinutes > 0 ? `${sleepMinutes}分钟` : '关闭'} + + + + + { + setShowSettings(false); + setShowTTSConfig(true); + }} + > + + TTS 设置 + + {providerLabel(provider)} · {(() => { + const v = voices.find((v) => v.id === voiceId); + return v ? formatVoiceName(v, provider) : '默认'; + })()} + + + + + + + + + + + {/* TTS Config Modal */} + setShowTTSConfig(false)} + > + + setShowTTSConfig(false)} + /> + + + + TTS 设置 + + + + TTS 服务商 + {providers.map((p) => { const active = provider === p.name; @@ -1069,35 +1225,48 @@ export function TTSScreen() { ); })} - - {/* 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} - - - ); - })} - + + 音色 + + + {voices + .filter((v) => { + const lang = (v.language || v.lang || '').toLowerCase(); + return lang.startsWith('zh'); + }) + .map((v) => { + const active = voiceId === v.id; + return ( + { + setVoiceId(v.id); + ttsStore.setSelectedVoice(v); + }} + style={[ + styles.voiceListItem, + active && { backgroundColor: theme.colors.primary + '20', borderColor: theme.colors.primary }, + ]} + > + + + {formatVoiceName(v, provider)} + + + {v.language || v.lang || 'zh-CN'} + + + {active && ( + + )} + + ); + })} + - )} + ); @@ -1387,6 +1556,13 @@ function createStyles(theme: ReturnType) { color: theme.colors.text, lineHeight: fontSizes.sm * 1.6, }, + bottomBar: { + paddingHorizontal: spacing.lg, + paddingTop: spacing.sm, + paddingBottom: 4, + borderTopWidth: 1, + borderTopColor: theme.colors.border + '30', + }, // Content view paragraphsScroll: { flex: 1, @@ -1462,5 +1638,119 @@ function createStyles(theme: ReturnType) { fontSize: fontSizes.md, color: theme.colors.text, }, + // Voice list (vertical) + voiceList: { + gap: spacing.xs, + marginTop: spacing.xs, + }, + voiceListItem: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingVertical: spacing.sm, + paddingHorizontal: spacing.md, + borderRadius: borderRadius.md, + borderWidth: 1, + borderColor: theme.colors.border + '40', + backgroundColor: theme.colors.background, + }, + voiceListLeft: { + flex: 1, + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + }, + voiceListName: { + fontSize: fontSizes.md, + fontWeight: '500', + }, + voiceListLang: { + fontSize: fontSizes.sm, + color: theme.colors.textSecondary, + }, + // Bottom Sheet (More Options) + sheetWrapper: { + position: 'absolute', + bottom: 0, + left: '50%', + transform: [{ translateX: -300 }], + width: '100%', + maxHeight: '60%', + maxWidth: 600, + borderTopLeftRadius: 20, + borderTopRightRadius: 20, + paddingTop: 8, + paddingHorizontal: spacing.lg, + paddingBottom: spacing.xl, + }, + sheetHandle: { + width: 40, + height: 4, + backgroundColor: 'rgba(150,150,150,0.3)', + borderRadius: 2, + alignSelf: 'center', + marginBottom: spacing.md, + marginTop: spacing.xs, + }, + sheetTitle: { + fontSize: fontSizes.xl, + fontWeight: '600', + textAlign: 'center', + marginBottom: spacing.lg, + }, + sheetQuickControls: { + flexDirection: 'row', + justifyContent: 'space-around', + paddingVertical: spacing.md, + marginBottom: spacing.sm, + }, + sheetQuickItem: { + alignItems: 'center', + gap: spacing.xs, + flex: 1, + }, + sheetQuickIcon: { + width: 52, + height: 52, + borderRadius: 26, + justifyContent: 'center', + alignItems: 'center', + marginBottom: spacing.xs, + }, + sheetQuickLabel: { + fontSize: fontSizes.sm, + fontWeight: '500', + }, + sheetQuickValue: { + fontSize: fontSizes.xs, + fontWeight: '600', + }, + sheetDivider: { + height: 1, + marginVertical: spacing.sm, + }, + sheetSectionTitle: { + fontSize: fontSizes.sm, + fontWeight: '600', + marginTop: spacing.md, + marginBottom: spacing.xs, + textTransform: 'uppercase', + letterSpacing: 0.5, + }, + sheetOption: { + flexDirection: 'row', + alignItems: 'center', + paddingVertical: spacing.md, + gap: spacing.md, + }, + sheetOptionText: { + flex: 1, + fontSize: fontSizes.md, + fontWeight: '500', + }, + sheetOptionValue: { + fontSize: fontSizes.sm, + marginRight: spacing.xs, + }, }); } From 3620425f2df2f6c357af59650ad42573bffa72c5 Mon Sep 17 00:00:00 2001 From: BookDock Dev Date: Sat, 13 Jun 2026 09:16:42 +0800 Subject: [PATCH 2/6] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=E5=B8=83?= =?UTF-8?q?=E5=B1=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/mobile/src/screens/TTSScreen.tsx | 1226 +++++++++++++------------ 1 file changed, 638 insertions(+), 588 deletions(-) diff --git a/apps/mobile/src/screens/TTSScreen.tsx b/apps/mobile/src/screens/TTSScreen.tsx index 3257570c..5da6d55d 100644 --- a/apps/mobile/src/screens/TTSScreen.tsx +++ b/apps/mobile/src/screens/TTSScreen.tsx @@ -21,6 +21,8 @@ import { ActivityIndicator, Alert, AppState, + Dimensions, + FlatList, Image, Modal, Pressable, @@ -168,6 +170,11 @@ export function TTSScreen() { const chapterIndex = ttsStore.chapterIndex; const currentParagraph = ttsStore.currentParagraph; + // Local setters for chapter data (since these come from API, not store) + const setParagraphs = useCallback((p: Paragraph[]) => ttsStore.setParagraphs(p), [ttsStore]); + const setChapterTitle = useCallback((t: string) => ttsStore.setChapterTitle(t), [ttsStore]); + const setChapterIndex = useCallback((i: number) => ttsStore.setChapterIndex(i), [ttsStore]); + // ── Local UI state ────────────────────────────────────────────────── const [chapters, setChapters] = useState<{ title: string; index: number }[]>( [], @@ -196,6 +203,17 @@ export function TTSScreen() { // View mode: 'controls' = cover + controls, 'content' = paragraph list const [viewMode, setViewMode] = useState<'controls' | 'content'>('controls'); + // Screen width for responsive layout + const [screenWidth, setScreenWidth] = useState(Dimensions.get('window').width); + const isWideScreen = screenWidth > 600; + + useEffect(() => { + const subscription = Dimensions.addEventListener('change', ({ window }) => { + setScreenWidth(window.width); + }); + return () => subscription?.remove(); + }, []); + // RNTP progress const playbackState = usePlaybackState(); const progress = useProgress(); @@ -211,7 +229,7 @@ export function TTSScreen() { const prefetchedRef = useRef>(new Map()); const cancelledRef = useRef(false); const sleepTimerRef = useRef | null>(null); - const paragraphsScrollRef = useRef(null); + const flatListRef = useRef(null); const paragraphRefs = useRef>(new Map()); const styles = useMemo(() => createStyles(theme), [theme]); @@ -403,49 +421,36 @@ export function TTSScreen() { }, [currentParagraph, paragraphs.length, chapterIndex, voiceId, provider, ttsStore]); - // Auto-scroll to current paragraph in content view + // Auto-scroll to current paragraph when it changes useEffect(() => { - if (viewMode === 'content' && currentParagraph >= 0) { - const ref = paragraphRefs.current.get(currentParagraph); - if (ref && paragraphsScrollRef.current) { - ref.measureLayout( - paragraphsScrollRef.current as any, - (_x, y) => { - paragraphsScrollRef.current?.scrollTo({ y: Math.max(0, y - 100), animated: true }); - }, - () => {} - ); - } + if (flatListRef.current && currentParagraph >= 0 && paragraphs.length > 0) { + const timeout = setTimeout(() => { + try { + flatListRef.current?.scrollToIndex({ + index: currentParagraph, + animated: true, + viewPosition: 0.5, + }); + } catch (e) { + // Fallback to scrollToOffset if scrollToIndex fails + console.warn('scrollToIndex failed, falling back'); + } + }, 100); + return () => clearTimeout(timeout); } - }, [currentParagraph, viewMode]); + }, [currentParagraph, paragraphs.length]); const loadChapter = async (ci: number, vid: string, prov: string) => { - console.log('[TTSScreen] loadChapter called, ci:', ci, 'vid:', vid, 'prov:', prov); - ttsStore.setChapterIndex(ci); + 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 < chaptersRef.current.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); setParagraphProgress(0); setResumeOffsetMs(0); prefetchedRef.current.clear(); @@ -453,7 +458,6 @@ export function TTSScreen() { ttsStore.setTotalParagraphs(pRes.data.paragraphs.length); ttsStore.setChapterTitle(pRes.data.title); ttsStore.setChapterIndex(ci); - ttsStore.setCurrentParagraph(0); // Resume from saved cloud progress try { @@ -520,21 +524,53 @@ export function TTSScreen() { // ── Persist latest position when user leaves the screen ───────────── useEffect(() => { const unsubscribe = navigation.addListener("beforeRemove", async () => { - await persistProgress(ttsStore.currentParagraph, Math.round(progress.position * 1000)); + await persistProgress(currentParagraph, Math.round(progress.position * 1000)); ttsStore.setMiniPlayerVisible(true); }); return unsubscribe; - }, [navigation, persistProgress, progress.position, ttsStore]); + }, [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 persistProgress(ttsStore.currentParagraph, Math.round(progress.position * 1000)); + void persistProgress(currentParagraph, Math.round(progress.position * 1000)); } }); return () => subscription.remove(); - }, [persistProgress, progress.position, ttsStore]); + }, [currentParagraph, persistProgress, progress.position]); + + const prefetchParagraph = useCallback( + async (idx: number) => { + if (idx >= paragraphs.length) return; + const para = paragraphs[idx]; + if (prefetchedRef.current.has(para.id)) return; + const chunks = splitForTts(para.text, TTS_CHUNK_MAX); + const apiClient = getApiClient(); + try { + for (let i = 0; i < chunks.length; i++) { + const chunk = chunks[i]; + const chunkParaId = chunks.length > 1 ? `${para.id}#${i}` : para.id; + const r = await apiClient.synthesizeParagraph({ + bookId: book.id, + paragraphId: chunkParaId, + text: chunk, + provider, + voice: voiceId, + }); + if (r.success && r.data) { + prefetchedRef.current.set( + chunkParaId, + resolveAudioUrl(r.data.url, apiClient), + ); + } + } + } catch { + /* ignore */ + } + }, + [book.id, paragraphs, provider, voiceId], + ); const synthesizeParagraph = useCallback( async (idx: number): Promise => { @@ -567,47 +603,6 @@ export function TTSScreen() { [book.id, paragraphs, provider, voiceId], ); - // ── TrackPlayer event: active track changed → update current paragraph ─ - useEffect(() => { - const sub = TrackPlayer.addEventListener(Event.PlaybackTrackChanged, async (event: any) => { - if (event.nextTrack === undefined) return; - - // Get current track to determine which paragraph we're on - const track = await TrackPlayer.getTrack(event.nextTrack); - if (!track?.id) return; - - // Extract paragraph index from track ID (format: "paraId-chunkIdx") - const paraId = track.id.split('-')[0]; - const paraIdx = paragraphs.findIndex(p => p.id === paraId); - - if (paraIdx >= 0 && paraIdx !== ttsStore.currentParagraph) { - ttsStore.setCurrentParagraph(paraIdx); - - // Add next paragraph to queue for seamless playback - try { - const nextIdx = paraIdx + 1; - if (nextIdx < paragraphs.length) { - const nextUris = await synthesizeParagraph(nextIdx); - if (nextUris.length > 0) { - const nextTracks = nextUris.map((uri, i) => ({ - id: `${paragraphs[nextIdx].id}-${i}`, - url: uri, - title: `${book.title || '未知书籍'} - ${chapterTitle}`, - artist: book.author || "未知作者", - artwork: undefined, - duration: 0, - })); - await TrackPlayer.add(nextTracks); - } - } - } catch { - // Ignore prefetch errors - } - } - }); - return () => sub.remove(); - }, [ttsStore, paragraphs, book, chapterTitle, synthesizeParagraph]); - const playParagraph = useCallback( async (idx: number) => { if (idx >= paragraphs.length) { @@ -620,52 +615,30 @@ export function TTSScreen() { setParagraphProgress(0); const startOffsetMs = resumeOffsetMs; setResumeOffsetMs(0); + prefetchParagraph(idx + 1); try { - // Synthesize current and next paragraph const uris = await synthesizeParagraph(idx); if (uris.length === 0) return; - // Build tracks for current paragraph + // Build tracks for RNTP const tracks = uris.map((uri, i) => ({ id: `${paragraphs[idx].id}-${i}`, url: uri, - title: `${book.title || '未知书籍'} - ${chapterTitle}`, + title: `${book.title} - ${chapterTitle}`, artist: book.author || "未知作者", artwork: undefined, duration: 0, })); - // Reset and add current paragraph await TrackPlayer.reset(); await TrackPlayer.add(tracks); - - // Try to add next paragraph to queue for seamless playback - try { - if (idx + 1 < paragraphs.length) { - const nextUris = await synthesizeParagraph(idx + 1); - if (nextUris.length > 0) { - const nextTracks = nextUris.map((uri, i) => ({ - id: `${paragraphs[idx + 1].id}-${i}`, - url: uri, - title: `${book.title || '未知书籍'} - ${chapterTitle}`, - artist: book.author || "未知作者", - artwork: undefined, - duration: 0, - })); - await TrackPlayer.add(nextTracks); - } - } - } catch { - // Ignore prefetch errors - } - if (startOffsetMs > 0) { await TrackPlayer.seekTo(startOffsetMs / 1000); } await TrackPlayer.play(); - ttsStore.setCurrentBook(book.id, idx, paragraphs.length); ttsStore.setState("playing"); + ttsStore.setCurrentBook(book.id, idx, paragraphs.length); } catch (e) { console.error("TTS paragraph error", e); ttsStore.setState("idle"); @@ -678,6 +651,7 @@ export function TTSScreen() { book.author, chapterTitle, paragraphs, + prefetchParagraph, synthesizeParagraph, resumeOffsetMs, ttsStore, @@ -704,6 +678,7 @@ export function TTSScreen() { }, [isPaused, isPlaying, paragraphs.length, playParagraph, currentParagraph, ttsStore, persistProgress, progress.position]); const handleStop = useCallback(async () => { + await TrackPlayer.pause(); await TrackPlayer.reset(); ttsStore.setState("idle"); ttsStore.setCurrentParagraph(0); @@ -735,21 +710,12 @@ export function TTSScreen() { const handleChapterChange = useCallback( async (ci: number) => { - console.log('[TTSScreen] handleChapterChange called, ci:', ci, 'chapterIndex:', chapterIndex); - if (ci === chapterIndex) { - console.log('[TTSScreen] Same chapter, skipping'); - return; - } - try { - await TrackPlayer.reset(); - ttsStore.setState("idle"); - console.log('[TTSScreen] Loading chapter:', ci); - await loadChapter(ci, voiceId, provider); - console.log('[TTSScreen] Chapter loaded, closing picker'); - setShowChapterPicker(false); - } catch (e) { - console.error('[TTSScreen] handleChapterChange error:', e); - } + if (ci === chapterIndex) return; + await TrackPlayer.pause(); + await TrackPlayer.reset(); + ttsStore.setState("idle"); + await loadChapter(ci, voiceId, provider); + setShowChapterPicker(false); }, // eslint-disable-next-line react-hooks/exhaustive-deps [chapterIndex, voiceId, provider], @@ -796,6 +762,489 @@ export function TTSScreen() { ? (currentParagraph + paragraphProgress) / paragraphs.length : 0; + // ── Render helpers ───────────────────────────────────────────────── + const renderHeader = () => ( + + + + + + {chapterTitle || '未知章节'} + + setShowSettings(true)}> + + + + ); + + const renderCover = () => ( + + + {book.coverUrl ? ( + + ) : ( + {(book.title || '?').charAt(0)} + )} + + {sleepMinutes > 0 && ( + + + + {Math.floor(sleepRemaining / 60)}m + + + )} + + ); + + const renderChapterInfo = () => ( + + {book.title || '未知书籍'} + + ); + + const renderDescription = () => ( + book.description && ( + + 简介 + + {book.description} + + + ) + ); + + const renderProgress = () => ( + + + + 第 {currentParagraph + 1} 段 / 共 {paragraphs.length} 段 + + {Math.round(overallProgress * 100)}% + + + + + + ); + + const handleReadBook = useCallback(() => { + navigation.navigate('Reader', { book }); + }, [navigation, book]); + + const renderControls = () => ( + + + + + + + + + + + {isLoadingAudio ? ( + + ) : ( + + )} + + + + + + + setShowChapterPicker(true)} + > + + + + ); + + const renderBottomBar = () => ( + + {renderProgress()} + {renderControls()} + + ); + + const renderParagraphList = () => ( + item.id} + style={styles.paragraphsScroll} + renderItem={({ item, index }) => { + const isCurrent = index === currentParagraph; + const isPast = index < currentParagraph; + return ( + handleJumpToParagraph(index)} + style={[ + styles.paragraphItem, + isCurrent && { backgroundColor: theme.colors.primary + '25' }, + ]} + > + + {item.text} + + + ); + }} + maintainVisibleContentPosition={{ + minIndexForVisible: 0, + }} + onScrollToIndexFailed={(info) => { + console.warn('Scroll to index failed:', info); + }} + /> + ); + + const renderModals = () => ( + <> + {/* Speed Picker Modal */} + setShowSpeedPicker(false)} + > + + setShowSpeedPicker(false)} + /> + + + + 倍速 + setShowSpeedPicker(false)}> + + + + + {[0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0].map((r) => ( + { + handleRateChange(r); + setShowSpeedPicker(false); + }} + style={[ + styles.sleepOption, + ttsStore.playbackRate === r && { backgroundColor: theme.colors.primary }, + ]} + > + + {r}x + + + ))} + + + + + + {/* Timer Picker Modal */} + setShowTimerPicker(false)} + > + + setShowTimerPicker(false)} + /> + + + + 定时关闭 + setShowTimerPicker(false)}> + + + + + {[0, 5, 10, 15, 30, 45, 60].map((minutes) => ( + { + handleSleepTimerSet(minutes); + setShowTimerPicker(false); + }} + style={[ + styles.sleepOption, + sleepMinutes === minutes && { backgroundColor: theme.colors.primary }, + ]} + > + + {minutes === 0 ? '关闭' : `${minutes} 分钟`} + + + ))} + + + + + + {/* Settings Bottom Sheet - More Options */} + setShowSettings(false)} + > + + setShowSettings(false)} + /> + + + + + 更多选项 + + + + { + setShowSettings(false); + setShowSpeedPicker(true); + }} + > + + 播放倍速 + + {ttsStore.playbackRate.toFixed(1)}x + + + + + { + setShowSettings(false); + setShowTimerPicker(true); + }} + > + + 定时关闭 + + {sleepMinutes > 0 ? `${sleepMinutes}分钟` : '关闭'} + + + + + { + setShowSettings(false); + setShowTTSConfig(true); + }} + > + + TTS 设置 + + {providerLabel(provider)} · {(() => { + const v = voices.find((v) => v.id === voiceId); + return v ? formatVoiceName(v, provider) : '默认'; + })()} + + + + + + + + + + + {/* TTS Config Modal */} + setShowTTSConfig(false)} + > + + setShowTTSConfig(false)} + /> + + + + TTS 设置 + + + + TTS 服务商 + + + {providers.map((p) => { + const active = provider === p.name; + return ( + setProvider(p.name)} + style={[ + styles.chip, + { + backgroundColor: active ? theme.colors.primary : theme.colors.background, + opacity: p.enabled ? 1 : 0.4, + }, + ]} + > + + {providerLabel(p.name)} + + + ); + })} + + + 音色 + + + {voices + .filter((v) => { + const lang = (v.language || v.lang || '').toLowerCase(); + return lang.startsWith('zh'); + }) + .map((v) => { + const active = voiceId === v.id; + return ( + { + setVoiceId(v.id); + ttsStore.setSelectedVoice(v); + }} + style={[ + styles.voiceListItem, + active && { backgroundColor: theme.colors.primary + '20', borderColor: theme.colors.primary }, + ]} + > + + + {formatVoiceName(v, provider)} + + + {v.language || v.lang || 'zh-CN'} + + + {active && ( + + )} + + ); + })} + + + + + + + {/* Chapter Picker Modal */} + setShowChapterPicker(false)} + > + + setShowChapterPicker(false)} + /> + + + + 章节列表 + setShowChapterPicker(false)}> + + + + + {chapters.map((c) => { + const active = chapterIndex === c.index; + return ( + handleChapterChange(c.index)} + style={[ + styles.chapterListItem, + active && { backgroundColor: theme.colors.primary + '20' }, + ]} + > + + {c.index + 1}. + {c.title} + + + ); + })} + + + + + + ); + // ── Render ─────────────────────────────────────────────────────────── if (loading) { return ( @@ -833,446 +1282,54 @@ export function TTSScreen() { ); } - // ── Controls View ──────────────────────────────────────────────────── - if (viewMode === 'controls') { + // ── Wide Screen Layout (tablet/desktop) ────────────────────────────── + if (isWideScreen) { return ( - {/* Header */} - - - - - - {book.title || '未知书籍'} + {renderHeader()} + + {/* Left Panel: Cover + Info + Controls */} + + + {renderCover()} + {renderChapterInfo()} + {renderDescription()} + + {renderBottomBar()} - setShowSettings(true)}> - - - - - {/* Book Cover - tap to switch to content view */} - - - {book.coverUrl ? ( - - ) : ( - {(book.title || '?').charAt(0)} - )} - - {sleepMinutes > 0 && ( - - - - {Math.floor(sleepRemaining / 60)}m - - - )} - - - {/* Chapter Info */} - - {chapterTitle} - - {book.author} - - {/* Book description */} - {book.description && ( - - 简介 - - {book.description} - - - )} - - - {/* Bottom fixed: Progress + Controls */} - - {/* Progress */} - - - - 第 {currentParagraph + 1} 段 / 共 {paragraphs.length} 段 - - {Math.round(overallProgress * 100)}% - - - - - - - {/* Playback Controls - compact row */} - - {/* Settings */} - setShowSettings(true)} - > - - - - {/* Skip back */} - - - - - {/* Play/Pause */} - - {isLoadingAudio ? ( - - ) : ( - - )} - - - {/* Skip forward */} - - - - - {/* Chapter picker */} - setShowChapterPicker(true)} - > - - + {/* Right Panel: Paragraph List */} + + {chapterTitle} + {renderParagraphList()} + {renderModals()} + + ); + } - {/* Chapter Picker Modal */} - setShowChapterPicker(false)} - > - - setShowChapterPicker(false)} - /> - - - - 章节列表 - setShowChapterPicker(false)}> - - - - - {chapters.map((c) => { - const active = chapterIndex === c.index; - return ( - handleChapterChange(c.index)} - style={[ - styles.chapterListItem, - active && { backgroundColor: theme.colors.primary + '20' }, - ]} - > - - {c.index + 1}. - {c.title} - - - ); - })} - - - - - - {/* Speed Picker Modal */} - setShowSpeedPicker(false)} - > - - setShowSpeedPicker(false)} - /> - - - - 倍速 - setShowSpeedPicker(false)}> - - - - - {[0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0].map((r) => ( - { - handleRateChange(r); - setShowSpeedPicker(false); - }} - style={[ - styles.sleepOption, - ttsStore.playbackRate === r && { backgroundColor: theme.colors.primary }, - ]} - > - - {r}x - - - ))} - - - - - - {/* Timer Picker Modal */} - setShowTimerPicker(false)} - > - - setShowTimerPicker(false)} - /> - - - - 定时关闭 - setShowTimerPicker(false)}> - - - - - {[0, 5, 10, 15, 30, 45, 60].map((minutes) => ( - { - handleSleepTimerSet(minutes); - setShowTimerPicker(false); - }} - style={[ - styles.sleepOption, - sleepMinutes === minutes && { backgroundColor: theme.colors.primary }, - ]} - > - - {minutes === 0 ? '关闭' : `${minutes} 分钟`} - - - ))} - - - - - - {/* Settings Bottom Sheet - More Options */} - setShowSettings(false)} - > - - {/* Backdrop - click to close */} - setShowSettings(false)} - /> - {/* Sheet content - positioned at bottom */} - - - - - 更多选项 - - - - { - setShowSettings(false); - setShowSpeedPicker(true); - }} - > - - 播放倍速 - - {ttsStore.playbackRate.toFixed(1)}x - - - - - { - setShowSettings(false); - setShowTimerPicker(true); - }} - > - - 定时关闭 - - {sleepMinutes > 0 ? `${sleepMinutes}分钟` : '关闭'} - - - - - { - setShowSettings(false); - setShowTTSConfig(true); - }} - > - - TTS 设置 - - {providerLabel(provider)} · {(() => { - const v = voices.find((v) => v.id === voiceId); - return v ? formatVoiceName(v, provider) : '默认'; - })()} - - - - - - - - - - - {/* TTS Config Modal */} - setShowTTSConfig(false)} - > - - setShowTTSConfig(false)} - /> - - - - TTS 设置 - - - - TTS 服务商 - - - {providers.map((p) => { - const active = provider === p.name; - return ( - setProvider(p.name)} - style={[ - styles.chip, - { - backgroundColor: active ? theme.colors.primary : theme.colors.background, - opacity: p.enabled ? 1 : 0.4, - }, - ]} - > - - {providerLabel(p.name)} - - - ); - })} - - - 音色 - - - {voices - .filter((v) => { - const lang = (v.language || v.lang || '').toLowerCase(); - return lang.startsWith('zh'); - }) - .map((v) => { - const active = voiceId === v.id; - return ( - { - setVoiceId(v.id); - ttsStore.setSelectedVoice(v); - }} - style={[ - styles.voiceListItem, - active && { backgroundColor: theme.colors.primary + '20', borderColor: theme.colors.primary }, - ]} - > - - - {formatVoiceName(v, provider)} - - - {v.language || v.lang || 'zh-CN'} - - - {active && ( - - )} - - ); - })} - - - - - - + // ── Mobile Layout: Controls View ─────────────────────────────────── + if (viewMode === 'controls') { + return ( + + {renderHeader()} + + {renderCover()} + {renderChapterInfo()} + {renderDescription()} + + {renderBottomBar()} + {renderModals()} ); } - // ── Content View (Paragraph List) ──────────────────────────────────── + // ── Mobile Layout: Content View ──────────────────────────────────── return ( - - {chapterTitle} - + + {chapterTitle} + - - {/* Paragraph list */} - - {paragraphs.map((p, idx) => { - const isCurrent = idx === currentParagraph; - const isPast = idx < currentParagraph; - return ( - handleJumpToParagraph(idx)} - ref={(ref) => { if (ref) paragraphRefs.current.set(idx, ref as any); }} - style={[ - styles.paragraphItem, - isCurrent && { backgroundColor: theme.colors.primary + '25' }, - ]} - > - - {p.text} - - - ); - })} - + {renderParagraphList()} + {renderModals()} ); } @@ -1383,6 +1406,33 @@ function createStyles(theme: ReturnType) { fontWeight: '600', color: theme.colors.text, }, + // Wide screen layout + wideContainer: { + flex: 1, + flexDirection: 'row', + }, + wideLeftPanel: { + width: 360, + borderRightWidth: 1, + borderRightColor: theme.colors.border + '30', + }, + wideLeftContent: { + padding: spacing.lg, + paddingTop: spacing.xl, + paddingBottom: spacing.xxl, + alignItems: 'center', + }, + wideRightPanel: { + flex: 1, + }, + wideRightHeader: { + fontSize: fontSizes.lg, + fontWeight: '600', + color: theme.colors.text, + paddingHorizontal: spacing.lg, + paddingTop: spacing.lg, + paddingBottom: spacing.sm, + }, // Controls view controlsContent: { padding: spacing.lg, @@ -1476,7 +1526,6 @@ function createStyles(theme: ReturnType) { alignItems: 'center', justifyContent: 'center', gap: spacing.sm, - marginBottom: spacing.lg, }, iconButton: { width: 44, @@ -1556,10 +1605,11 @@ function createStyles(theme: ReturnType) { color: theme.colors.text, lineHeight: fontSizes.sm * 1.6, }, + // Bottom fixed bar bottomBar: { paddingHorizontal: spacing.lg, paddingTop: spacing.sm, - paddingBottom: 4, + paddingBottom: 20, borderTopWidth: 1, borderTopColor: theme.colors.border + '30', }, From 5efcfa32cbb67b721bee394065b173fbb9790cc9 Mon Sep 17 00:00:00 2001 From: BookDock Dev Date: Sat, 13 Jun 2026 21:59:02 +0800 Subject: [PATCH 3/6] =?UTF-8?q?feat:=20=E4=BF=AE=E6=94=B9=E5=B8=83?= =?UTF-8?q?=E5=B1=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/mobile/src/components/TTSMiniPlayer.tsx | 4 ++-- apps/mobile/src/screens/TTSScreen.tsx | 18 +++++++----------- 2 files changed, 9 insertions(+), 13 deletions(-) diff --git a/apps/mobile/src/components/TTSMiniPlayer.tsx b/apps/mobile/src/components/TTSMiniPlayer.tsx index d6530269..90236513 100644 --- a/apps/mobile/src/components/TTSMiniPlayer.tsx +++ b/apps/mobile/src/components/TTSMiniPlayer.tsx @@ -159,8 +159,8 @@ function createStyles(theme: ReturnType) { gap: spacing.sm, }, cover: { - width: 40, - height: 40, + width: 32, + height: 44, borderRadius: borderRadius.sm, overflow: 'hidden', }, diff --git a/apps/mobile/src/screens/TTSScreen.tsx b/apps/mobile/src/screens/TTSScreen.tsx index 5da6d55d..9cefd4b8 100644 --- a/apps/mobile/src/screens/TTSScreen.tsx +++ b/apps/mobile/src/screens/TTSScreen.tsx @@ -1334,17 +1334,11 @@ export function TTSScreen() { - {/* Header */} - - - - - - {chapterTitle} - - + {renderHeader()} + + {renderParagraphList()} + {renderBottomBar()} - {renderParagraphList()} {renderModals()} ); @@ -1613,7 +1607,9 @@ function createStyles(theme: ReturnType) { borderTopWidth: 1, borderTopColor: theme.colors.border + '30', }, - // Content view + contentViewContainer: { + flex: 1, + }, paragraphsScroll: { flex: 1, padding: spacing.md, From 98cd7fd8360cbb846d2ebb8e9855c199455b56b3 Mon Sep 17 00:00:00 2001 From: BookDock Dev Date: Sat, 13 Jun 2026 22:02:28 +0800 Subject: [PATCH 4/6] =?UTF-8?q?feat:=20=E4=BF=AE=E6=94=B9=E5=B8=83?= =?UTF-8?q?=E5=B1=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/mobile/src/screens/TTSScreen.tsx | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/apps/mobile/src/screens/TTSScreen.tsx b/apps/mobile/src/screens/TTSScreen.tsx index 9cefd4b8..7a39bc72 100644 --- a/apps/mobile/src/screens/TTSScreen.tsx +++ b/apps/mobile/src/screens/TTSScreen.tsx @@ -768,9 +768,18 @@ export function TTSScreen() { - - {chapterTitle || '未知章节'} - + {!isWideScreen && ( + + + + )} setShowSettings(true)}> @@ -825,8 +834,8 @@ export function TTSScreen() { const renderProgress = () => ( - - 第 {currentParagraph + 1} 段 / 共 {paragraphs.length} 段 + + {chapterTitle} {Math.round(overallProgress * 100)}% From cb7cb4e6e1e8196a8c8ac6e62ba20722852fb667 Mon Sep 17 00:00:00 2001 From: BookDock Dev Date: Sat, 13 Jun 2026 23:14:59 +0800 Subject: [PATCH 5/6] =?UTF-8?q?feat:=20=E4=BF=AE=E6=94=B9=E5=B8=83?= =?UTF-8?q?=E5=B1=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/mobile/src/components/TTSMiniPlayer.tsx | 345 +++++++++++++++---- apps/mobile/src/navigation/types.ts | 2 +- apps/mobile/src/screens/TTSScreen.tsx | 39 +++ apps/mobile/src/services/ttsAudio.ts | 141 ++++++++ apps/mobile/src/stores/ttsStore.ts | 7 + 5 files changed, 470 insertions(+), 64 deletions(-) create mode 100644 apps/mobile/src/services/ttsAudio.ts diff --git a/apps/mobile/src/components/TTSMiniPlayer.tsx b/apps/mobile/src/components/TTSMiniPlayer.tsx index 90236513..31afbc73 100644 --- a/apps/mobile/src/components/TTSMiniPlayer.tsx +++ b/apps/mobile/src/components/TTSMiniPlayer.tsx @@ -6,25 +6,32 @@ 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 { useCallback, useEffect, useMemo, useState } from 'react'; import { View, Text, TouchableOpacity, StyleSheet, Image, + Modal, + Pressable, + FlatList, + useWindowDimensions, } from 'react-native'; -import TrackPlayer, { State, usePlaybackState, useProgress } from 'react-native-track-player'; +import TrackPlayer, { Event, usePlaybackState, useProgress } from 'react-native-track-player'; import { useTTSStore, useThemeStore } from '../stores'; import { getTheme, spacing, fontSizes, borderRadius } from '../utils/theme'; import { getCoverImageUrl } from '../services/api'; import type { RootStackParamList } from '../navigation/types'; +import { synthesizeParagraphAudio, playParagraphAudio } from '../services/ttsAudio'; export function TTSMiniPlayer() { const navigation = useNavigation>(); const ttsStore = useTTSStore(); const actualTheme = useThemeStore((state) => state.actualTheme); const theme = getTheme(actualTheme === 'dark'); + const { width: screenWidth } = useWindowDimensions(); + const isWide = screenWidth > 600; const playbackState = usePlaybackState(); const progress = useProgress(); @@ -32,6 +39,8 @@ export function TTSMiniPlayer() { const isPlaying = ttsStore.state === 'playing'; const isPaused = ttsStore.state === 'paused'; + const [showChapterPicker, setShowChapterPicker] = useState(false); + const paragraphProgress = progress.duration > 0 ? progress.position / progress.duration : 0; const overallProgress = ttsStore.totalParagraphs > 0 ? (ttsStore.currentParagraph + paragraphProgress) / ttsStore.totalParagraphs @@ -39,6 +48,40 @@ export function TTSMiniPlayer() { const styles = useMemo(() => createStyles(theme), [theme]); + // Auto-advance paragraph when queue ends (mini player mode) + useEffect(() => { + const sub = TrackPlayer.addEventListener(Event.PlaybackQueueEnded, async () => { + console.log('[TTSMiniPlayer] Queue ended, auto-advancing'); + const nextIdx = ttsStore.currentParagraph + 1; + if (nextIdx < ttsStore.totalParagraphs) { + ttsStore.setCurrentParagraph(nextIdx); + // Synthesize and play next paragraph + const paragraphs = ttsStore.paragraphs; + const book = ttsStore.currentBook; + const provider = ttsStore.selectedProvider || 'edge'; + const voiceId = ttsStore.selectedVoice?.id || ''; + if (paragraphs[nextIdx] && book) { + try { + const uris = await synthesizeParagraphAudio(book.id, paragraphs[nextIdx], provider, voiceId); + await playParagraphAudio(uris, { + id: paragraphs[nextIdx].id, + title: `${book.title} - ${ttsStore.chapterTitle || ''}`, + artist: book.author || '未知作者', + }); + ttsStore.setState('playing'); + ttsStore.setCurrentBook(book.id, nextIdx, paragraphs.length); + } catch (e) { + console.error('[TTSMiniPlayer] Auto-advance error:', e); + ttsStore.setState('idle'); + } + } + } else { + ttsStore.setState('idle'); + } + }); + return () => sub.remove(); + }, [ttsStore]); + const handlePlayPause = useCallback(async () => { if (isPaused) { await TrackPlayer.play(); @@ -50,7 +93,6 @@ export function TTSMiniPlayer() { }, [isPaused, isPlaying, ttsStore]); const handleExpand = useCallback(() => { - // Navigate back to TTSScreen with full book data if (ttsStore.currentBook) { navigation.navigate('TTSScreen', { book: ttsStore.currentBook }); } else if (ttsStore.currentBookId) { @@ -59,77 +101,209 @@ export function TTSMiniPlayer() { ttsStore.setMiniPlayerVisible(false); }, [navigation, ttsStore]); - const handleClose = useCallback(async () => { - await TrackPlayer.reset(); - ttsStore.setState('idle'); - ttsStore.setMiniPlayerVisible(false); - ttsStore.reset(); + const handleSkipBack = useCallback(async () => { + const prevIdx = Math.max(0, ttsStore.currentParagraph - 1); + ttsStore.setCurrentParagraph(prevIdx); + + // Synthesize and play the previous paragraph audio + const paragraphs = ttsStore.paragraphs; + const book = ttsStore.currentBook; + const provider = ttsStore.selectedProvider || 'edge'; + const voiceId = ttsStore.selectedVoice?.id || ''; + if (paragraphs[prevIdx] && book) { + try { + ttsStore.setState('loading'); + const uris = await synthesizeParagraphAudio(book.id, paragraphs[prevIdx], provider, voiceId); + await playParagraphAudio(uris, { + id: paragraphs[prevIdx].id, + title: `${book.title} - ${ttsStore.chapterTitle || ''}`, + artist: book.author || '未知作者', + }); + ttsStore.setState('playing'); + ttsStore.setCurrentBook(book.id, prevIdx, paragraphs.length); + } catch (e) { + console.error('[TTSMiniPlayer] Skip back error:', e); + ttsStore.setState('idle'); + } + } + }, [ttsStore]); + + const handleSkipForward = useCallback(async () => { + const nextIdx = Math.min(ttsStore.totalParagraphs - 1, ttsStore.currentParagraph + 1); + ttsStore.setCurrentParagraph(nextIdx); + + // Synthesize and play the next paragraph audio + const paragraphs = ttsStore.paragraphs; + const book = ttsStore.currentBook; + const provider = ttsStore.selectedProvider || 'edge'; + const voiceId = ttsStore.selectedVoice?.id || ''; + if (paragraphs[nextIdx] && book) { + try { + ttsStore.setState('loading'); + const uris = await synthesizeParagraphAudio(book.id, paragraphs[nextIdx], provider, voiceId); + await playParagraphAudio(uris, { + id: paragraphs[nextIdx].id, + title: `${book.title} - ${ttsStore.chapterTitle || ''}`, + artist: book.author || '未知作者', + }); + ttsStore.setState('playing'); + ttsStore.setCurrentBook(book.id, nextIdx, paragraphs.length); + } catch (e) { + console.error('[TTSMiniPlayer] Skip forward error:', e); + ttsStore.setState('idle'); + } + } }, [ttsStore]); + const handleChapterList = useCallback(() => { + setShowChapterPicker(true); + }, []); + + const handleChapterChange = useCallback(async (ci: number) => { + setShowChapterPicker(false); + // Navigate to TTSScreen to load the selected chapter + if (ttsStore.currentBook) { + navigation.navigate('TTSScreen', { book: ttsStore.currentBook, showChapterPicker: false }); + } else if (ttsStore.currentBookId) { + navigation.navigate('TTSScreen', { book: { id: ttsStore.currentBookId } as any, showChapterPicker: false }); + } + ttsStore.setMiniPlayerVisible(false); + }, [ttsStore, navigation]); + // Don't show if no book is loaded or explicitly hidden if (!ttsStore.currentBookId || !ttsStore.isMiniPlayerVisible) { return null; } return ( - - {/* Progress bar at top */} - - - + <> + + {/* Progress bar at top - overall chapter progress */} + + + - - {/* Cover thumbnail */} - - {ttsStore.currentBook?.coverUrl ? ( - - ) : ( - - - {(ttsStore.currentBook?.title || 'T').charAt(0)} - - - )} - - - {/* Info */} - - - {ttsStore.currentBook?.title || ttsStore.chapterTitle || '正在朗读'} - - - {ttsStore.chapterTitle} · 第 {ttsStore.currentParagraph + 1}/{ttsStore.totalParagraphs} 段 - - - - {/* Controls */} - - - + + {/* Cover thumbnail - click to expand */} + + {ttsStore.currentBook?.coverUrl ? ( + + ) : ( + + + {(ttsStore.currentBook?.title || 'T').charAt(0)} + + + )} - - + {/* Info - click to expand */} + + + {ttsStore.chapterTitle || '未知章节'} + + + {ttsStore.currentBook?.title}{ttsStore.currentBook?.author ? ` · ${ttsStore.currentBook.author}` : ''} + + + {/* Controls */} + + + + + + + + + + + + + + + + + - + + {/* Chapter Picker Modal */} + setShowChapterPicker(false)} + > + setShowChapterPicker(false)} + > + + {/* Handle bar */} + + + + + + 选择章节 + + + String(item.index)} + renderItem={({ item: c }) => { + const active = c.index === ttsStore.chapterIndex; + return ( + handleChapterChange(c.index)} + style={[ + styles.chapterListItem, + active && { backgroundColor: theme.colors.primary + '20' }, + ]} + > + + {c.index + 1}. + {c.title} + + + ); + }} + style={styles.chapterList} + /> + + + + ); } @@ -182,12 +356,12 @@ function createStyles(theme: ReturnType) { flex: 1, justifyContent: 'center', }, - title: { + chapterTitle: { fontSize: fontSizes.sm, - fontWeight: '600', + fontWeight: '700', color: theme.colors.text, }, - subtitle: { + bookMeta: { fontSize: fontSizes.xs, color: theme.colors.textSecondary, marginTop: 2, @@ -200,5 +374,50 @@ function createStyles(theme: ReturnType) { controlButton: { padding: spacing.sm, }, + // Modal styles + modalOverlay: { + flex: 1, + justifyContent: 'flex-end', + alignItems: 'center', + backgroundColor: 'rgba(0,0,0,0.5)', + }, + modalContent: { + width: '100%', + borderTopLeftRadius: borderRadius.xl, + borderTopRightRadius: borderRadius.xl, + paddingHorizontal: spacing.md, + paddingBottom: spacing.xl, + maxHeight: '70%', + }, + handleBar: { + alignItems: 'center', + paddingVertical: spacing.md, + }, + handle: { + width: 40, + height: 4, + borderRadius: borderRadius.sm, + }, + modalTitle: { + fontSize: fontSizes.lg, + fontWeight: '700', + marginBottom: spacing.md, + }, + chapterList: { + maxHeight: 400, + }, + 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, + }, }); } diff --git a/apps/mobile/src/navigation/types.ts b/apps/mobile/src/navigation/types.ts index 5bed9b2a..1ae31b9e 100644 --- a/apps/mobile/src/navigation/types.ts +++ b/apps/mobile/src/navigation/types.ts @@ -5,7 +5,7 @@ import type { EbookSource } from '../services/api'; export type RootStackParamList = { Main: undefined; Reader: { book: Book }; - TTSScreen: { book: Book }; + TTSScreen: { book: Book; showChapterPicker?: boolean }; TTSReader: { book: Book }; BookDetails: { book: Book }; AuthorDetail: { author: Author }; diff --git a/apps/mobile/src/screens/TTSScreen.tsx b/apps/mobile/src/screens/TTSScreen.tsx index 7a39bc72..126923e8 100644 --- a/apps/mobile/src/screens/TTSScreen.tsx +++ b/apps/mobile/src/screens/TTSScreen.tsx @@ -326,6 +326,7 @@ export function TTSScreen() { } setChapters(chRes.data); chaptersRef.current = chRes.data; + ttsStore.setChapters(chRes.data); // Load first chapter (loadChapter will skip empty ones) console.log('[TTSScreen] Loading chapter 0, voice:', vs[0]?.id || voiceId, 'provider:', finalProvider); @@ -343,6 +344,13 @@ export function TTSScreen() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [book.id]); + // ── Auto-open chapter picker if requested via route params ────────── + useEffect(() => { + if (route.params?.showChapterPicker && chapters.length > 0 && !loading) { + setShowChapterPicker(true); + } + }, [route.params?.showChapterPicker, chapters.length, loading]); + // ── Reload voices when provider changes ───────────────────────────── useEffect(() => { if (!provider) return; @@ -440,6 +448,25 @@ export function TTSScreen() { } }, [currentParagraph, paragraphs.length]); + // Scroll to current paragraph when entering content view (initial mount) + useEffect(() => { + if (viewMode === 'content' && flatListRef.current && currentParagraph >= 0 && paragraphs.length > 0) { + const timeout = setTimeout(() => { + try { + flatListRef.current?.scrollToIndex({ + index: currentParagraph, + animated: false, + viewPosition: 0.5, + }); + } catch (e) { + console.warn('Initial scrollToIndex failed:', e); + } + }, 300); + return () => clearTimeout(timeout); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [viewMode]); + const loadChapter = async (ci: number, vid: string, prov: string) => { setChapterIndex(ci); try { @@ -945,11 +972,23 @@ export function TTSScreen() { ); }} + getItemLayout={(data, index) => { + // Estimate item height based on text length (approx 40 chars per line, 24px per line + padding) + const text = data?.[index]?.text || ''; + const lines = Math.max(1, Math.ceil(text.length / 40)); + const height = lines * 24 + 16; // 16px for padding (8 top + 8 bottom) + return { length: height, offset: height * index, index }; + }} maintainVisibleContentPosition={{ minIndexForVisible: 0, }} onScrollToIndexFailed={(info) => { console.warn('Scroll to index failed:', info); + // Fallback: scroll to approximate offset + flatListRef.current?.scrollToOffset({ + offset: info.averageItemLength * info.index, + animated: true, + }); }} /> ); diff --git a/apps/mobile/src/services/ttsAudio.ts b/apps/mobile/src/services/ttsAudio.ts new file mode 100644 index 00000000..c9914157 --- /dev/null +++ b/apps/mobile/src/services/ttsAudio.ts @@ -0,0 +1,141 @@ +/** + * TTS Audio Service — Shared audio synthesis logic for TTSScreen and MiniPlayer. + */ + +import { getApiClient, Paragraph } from "@bookdock/api-client"; +import TrackPlayer from "react-native-track-player"; + +const TTS_CHUNK_MAX = 2500; + +function splitForTts(text: string, maxLen: number): string[] { + if (!text) return []; + if (text.length <= maxLen) return [text]; + const chunks: string[] = []; + let start = 0; + while (start < text.length) { + let end = Math.min(start + maxLen, text.length); + if (end < text.length) { + const breakChars = [",", "。", "!", "?", ";", "、", " ", "\n"]; + let found = false; + for (let i = end - 1; i > start; i--) { + if (breakChars.includes(text[i])) { + end = i + 1; + found = true; + break; + } + } + if (!found) { + for (let i = end - 1; i > start; i--) { + if (/[a-zA-Z0-9]/.test(text[i])) { + end = i + 1; + found = true; + break; + } + } + } + if (!found) end = start + maxLen; + } + chunks.push(text.slice(start, end).trim()); + start = end; + } + return chunks; +} + +function resolveAudioUrl( + url: string, + apiClient: ReturnType, +) { + if (url.startsWith("http")) return url; + return `${apiClient.serverBaseURL}${url}`; +} + +const prefetchedCache = new Map(); + +export function clearPrefetchCache() { + prefetchedCache.clear(); +} + +export async function prefetchParagraph( + bookId: string, + paragraph: Paragraph, + provider: string, + voiceId: string, +) { + if (prefetchedCache.has(paragraph.id)) return; + const chunks = splitForTts(paragraph.text, TTS_CHUNK_MAX); + const apiClient = getApiClient(); + try { + for (let i = 0; i < chunks.length; i++) { + const chunk = chunks[i]; + const chunkParaId = chunks.length > 1 ? `${paragraph.id}#${i}` : paragraph.id; + const r = await apiClient.synthesizeParagraph({ + bookId, + paragraphId: chunkParaId, + text: chunk, + provider, + voice: voiceId, + }); + if (r.success && r.data) { + prefetchedCache.set( + chunkParaId, + resolveAudioUrl(r.data.url, apiClient), + ); + } + } + } catch { + /* ignore prefetch errors */ + } +} + +export async function synthesizeParagraphAudio( + bookId: string, + paragraph: Paragraph, + provider: string, + voiceId: string, +): Promise { + const chunks = splitForTts(paragraph.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 ? `${paragraph.id}#${i}` : paragraph.id; + let uri = prefetchedCache.get(chunkParaId); + if (!uri) { + const r = await apiClient.synthesizeParagraph({ + bookId, + 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); + prefetchedCache.set(chunkParaId, uri); + } + uris.push(uri); + } + return uris; +} + +export async function playParagraphAudio( + uris: string[], + trackMeta: { id: string; title: string; artist: string }, + startOffsetMs = 0, +) { + const tracks = uris.map((uri, i) => ({ + id: `${trackMeta.id}-${i}`, + url: uri, + title: trackMeta.title, + artist: trackMeta.artist, + artwork: undefined, + duration: 0, + })); + + await TrackPlayer.reset(); + await TrackPlayer.add(tracks); + if (startOffsetMs > 0) { + await TrackPlayer.seekTo(startOffsetMs / 1000); + } + await TrackPlayer.play(); +} diff --git a/apps/mobile/src/stores/ttsStore.ts b/apps/mobile/src/stores/ttsStore.ts index c54e1168..c4bea8e1 100644 --- a/apps/mobile/src/stores/ttsStore.ts +++ b/apps/mobile/src/stores/ttsStore.ts @@ -29,6 +29,8 @@ interface TTSStoreState { paragraphs: Paragraph[]; chapterTitle: string; chapterIndex: number; + // Chapters list (for mini player chapter picker) + chapters: { title: string; index: number }[]; // Actions setState: (state: TTSState) => void; @@ -48,6 +50,7 @@ interface TTSStoreState { setParagraphs: (paragraphs: Paragraph[]) => void; setChapterTitle: (title: string) => void; setChapterIndex: (index: number) => void; + setChapters: (chapters: { title: string; index: number }[]) => void; reset: () => void; } @@ -72,6 +75,7 @@ export const useTTSStore = create()( paragraphs: [], chapterTitle: '', chapterIndex: 0, + chapters: [], setState: (state) => set({ state }), @@ -111,6 +115,8 @@ export const useTTSStore = create()( setChapterIndex: (index) => set({ chapterIndex: index }), + setChapters: (chapters) => set({ chapters }), + reset: () => set({ state: 'idle', currentBookId: null, @@ -122,6 +128,7 @@ export const useTTSStore = create()( paragraphs: [], chapterTitle: '', chapterIndex: 0, + chapters: [], isMiniPlayerVisible: false, viewMode: 'controls', }), From ffdf63e4de436e3e54d43b26d9ffb5ca8fbbc32e Mon Sep 17 00:00:00 2001 From: BookDock Dev Date: Sat, 13 Jun 2026 23:22:52 +0800 Subject: [PATCH 6/6] =?UTF-8?q?feat:=E4=BF=AE=E6=94=B9=E5=B8=83=E5=B1=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/mobile/src/components/TTSMiniPlayer.tsx | 5 +- apps/mobile/src/screens/TTSScreen.tsx | 416 ++++++++++--------- 2 files changed, 217 insertions(+), 204 deletions(-) diff --git a/apps/mobile/src/components/TTSMiniPlayer.tsx b/apps/mobile/src/components/TTSMiniPlayer.tsx index 31afbc73..fd06e834 100644 --- a/apps/mobile/src/components/TTSMiniPlayer.tsx +++ b/apps/mobile/src/components/TTSMiniPlayer.tsx @@ -260,7 +260,7 @@ export function TTSMiniPlayer() { styles.modalContent, { backgroundColor: theme.colors.background, - maxWidth: isWide ? 600 : screenWidth - 32, + width: isWide ? 600 : screenWidth - 32, }, ]} > @@ -378,11 +378,10 @@ function createStyles(theme: ReturnType) { modalOverlay: { flex: 1, justifyContent: 'flex-end', - alignItems: 'center', backgroundColor: 'rgba(0,0,0,0.5)', }, modalContent: { - width: '100%', + alignSelf: 'center', borderTopLeftRadius: borderRadius.xl, borderTopRightRadius: borderRadius.xl, paddingHorizontal: spacing.md, diff --git a/apps/mobile/src/screens/TTSScreen.tsx b/apps/mobile/src/screens/TTSScreen.tsx index 126923e8..4636ebf0 100644 --- a/apps/mobile/src/screens/TTSScreen.tsx +++ b/apps/mobile/src/screens/TTSScreen.tsx @@ -103,13 +103,13 @@ function formatVoiceName(voice: TTSVoice, providerName: string): string { const chineseName = nameMap[voiceId] || voiceId.replace('Neural', ''); - // Region tag for Edge TTS only + // Region tag for Edge TTS only - use shorter tags let regionTag = ''; - if (region === 'zh-hk' || region === 'zh-hant-hk') regionTag = 'HK'; - else if (region === 'zh-tw' || region === 'zh-hant-tw') regionTag = 'TW'; - else if (region === 'zh-cn' || region === 'zh-hans') regionTag = 'CN'; + if (region === 'zh-hk' || region === 'zh-hant-hk') regionTag = '港'; + else if (region === 'zh-tw' || region === 'zh-hant-tw') regionTag = '台'; + else if (region === 'zh-cn' || region === 'zh-hans') regionTag = '陆'; - return regionTag ? `${chineseName} (${regionTag})` : chineseName; + return regionTag ? `${chineseName}·${regionTag}` : chineseName; } // For other providers or fallback, return name as-is but truncate if too long @@ -1008,31 +1008,33 @@ export function TTSScreen() { onPress={() => setShowSpeedPicker(false)} /> - - - 倍速 - setShowSpeedPicker(false)}> - - - - - {[0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0].map((r) => ( - { - handleRateChange(r); - setShowSpeedPicker(false); - }} - style={[ - styles.sleepOption, - ttsStore.playbackRate === r && { backgroundColor: theme.colors.primary }, - ]} - > - - {r}x - + + + + 倍速 + setShowSpeedPicker(false)}> + - ))} + + + {[0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0].map((r) => ( + { + handleRateChange(r); + setShowSpeedPicker(false); + }} + style={[ + styles.sleepOption, + ttsStore.playbackRate === r && { backgroundColor: theme.colors.primary }, + ]} + > + + {r}x + + + ))} + @@ -1051,31 +1053,33 @@ export function TTSScreen() { onPress={() => setShowTimerPicker(false)} /> - - - 定时关闭 - setShowTimerPicker(false)}> - - - - - {[0, 5, 10, 15, 30, 45, 60].map((minutes) => ( - { - handleSleepTimerSet(minutes); - setShowTimerPicker(false); - }} - style={[ - styles.sleepOption, - sleepMinutes === minutes && { backgroundColor: theme.colors.primary }, - ]} - > - - {minutes === 0 ? '关闭' : `${minutes} 分钟`} - + + + + 定时关闭 + setShowTimerPicker(false)}> + - ))} + + + {[0, 5, 10, 15, 30, 45, 60].map((minutes) => ( + { + handleSleepTimerSet(minutes); + setShowTimerPicker(false); + }} + style={[ + styles.sleepOption, + sleepMinutes === minutes && { backgroundColor: theme.colors.primary }, + ]} + > + + {minutes === 0 ? '关闭' : `${minutes} 分钟`} + + + ))} + @@ -1094,63 +1098,65 @@ export function TTSScreen() { onPress={() => setShowSettings(false)} /> - + + - - 更多选项 - + + 更多选项 + - - { - setShowSettings(false); - setShowSpeedPicker(true); - }} - > - - 播放倍速 - - {ttsStore.playbackRate.toFixed(1)}x - - - - - { - setShowSettings(false); - setShowTimerPicker(true); - }} - > - - 定时关闭 - - {sleepMinutes > 0 ? `${sleepMinutes}分钟` : '关闭'} - - - - - { - setShowSettings(false); - setShowTTSConfig(true); - }} - > - - TTS 设置 - - {providerLabel(provider)} · {(() => { - const v = voices.find((v) => v.id === voiceId); - return v ? formatVoiceName(v, provider) : '默认'; - })()} - - - + + { + setShowSettings(false); + setShowSpeedPicker(true); + }} + > + + 播放倍速 + + {ttsStore.playbackRate.toFixed(1)}x + + + - - + { + setShowSettings(false); + setShowTimerPicker(true); + }} + > + + 定时关闭 + + {sleepMinutes > 0 ? `${sleepMinutes}分钟` : '关闭'} + + + + + { + setShowSettings(false); + setShowTTSConfig(true); + }} + > + + TTS 设置 + + {providerLabel(provider)} · {(() => { + const v = voices.find((v) => v.id === voiceId); + return v ? formatVoiceName(v, provider) : '默认'; + })()} + + + + + + + @@ -1168,76 +1174,78 @@ export function TTSScreen() { onPress={() => setShowTTSConfig(false)} /> - - - TTS 设置 - - - - TTS 服务商 + + + + TTS 设置 - - {providers.map((p) => { - const active = provider === p.name; - return ( - setProvider(p.name)} - style={[ - styles.chip, - { - backgroundColor: active ? theme.colors.primary : theme.colors.background, - opacity: p.enabled ? 1 : 0.4, - }, - ]} - > - - {providerLabel(p.name)} - - - ); - })} - - - 音色 - - - {voices - .filter((v) => { - const lang = (v.language || v.lang || '').toLowerCase(); - return lang.startsWith('zh'); - }) - .map((v) => { - const active = voiceId === v.id; + + + TTS 服务商 + + + {providers.map((p) => { + const active = provider === p.name; return ( { - setVoiceId(v.id); - ttsStore.setSelectedVoice(v); - }} + key={p.name} + disabled={!p.enabled} + onPress={() => setProvider(p.name)} style={[ - styles.voiceListItem, - active && { backgroundColor: theme.colors.primary + '20', borderColor: theme.colors.primary }, + styles.chip, + { + backgroundColor: active ? theme.colors.primary : theme.colors.background, + opacity: p.enabled ? 1 : 0.4, + }, ]} > - - - {formatVoiceName(v, provider)} - - - {v.language || v.lang || 'zh-CN'} - - - {active && ( - - )} + + {providerLabel(p.name)} + ); })} - - + + + 音色 + + + {voices + .filter((v) => { + const lang = (v.language || v.lang || '').toLowerCase(); + return lang.startsWith('zh'); + }) + .map((v) => { + const active = voiceId === v.id; + return ( + { + setVoiceId(v.id); + ttsStore.setSelectedVoice(v); + }} + style={[ + styles.voiceListItem, + active && { backgroundColor: theme.colors.primary + '20', borderColor: theme.colors.primary }, + ]} + > + + + {formatVoiceName(v, provider)} + + + {v.language || v.lang || 'zh-CN'} + + + {active && ( + + )} + + ); + })} + + + @@ -1255,38 +1263,40 @@ export function TTSScreen() { onPress={() => setShowChapterPicker(false)} /> - - - 章节列表 - setShowChapterPicker(false)}> - - - - - {chapters.map((c) => { - const active = chapterIndex === c.index; - return ( - handleChapterChange(c.index)} - style={[ - styles.chapterListItem, - active && { backgroundColor: theme.colors.primary + '20' }, - ]} - > - + + + 章节列表 + setShowChapterPicker(false)}> + + + + + {chapters.map((c) => { + const active = chapterIndex === c.index; + return ( + handleChapterChange(c.index)} style={[ - styles.chapterListText, - { color: active ? theme.colors.primary : theme.colors.text }, + styles.chapterListItem, + active && { backgroundColor: theme.colors.primary + '20' }, ]} > - {c.index + 1}. - {c.title} - - - ); - })} - + + {c.index + 1}. + {c.title} + + + ); + })} + + @@ -1766,17 +1776,20 @@ function createStyles(theme: ReturnType) { sheetWrapper: { position: 'absolute', bottom: 0, - left: '50%', - transform: [{ translateX: -300 }], - width: '100%', + left: 0, + right: 0, + alignItems: 'center', maxHeight: '60%', - maxWidth: 600, borderTopLeftRadius: 20, borderTopRightRadius: 20, paddingTop: 8, paddingHorizontal: spacing.lg, paddingBottom: spacing.xl, }, + sheetInner: { + width: '100%', + maxWidth: 600, + }, sheetHandle: { width: 40, height: 4, @@ -1845,6 +1858,7 @@ function createStyles(theme: ReturnType) { sheetOptionValue: { fontSize: fontSizes.sm, marginRight: spacing.xs, + maxWidth: 120, }, }); }