diff --git a/.github/workflows/release-manager.yml b/.github/workflows/release-manager.yml new file mode 100644 index 0000000..9f532d8 --- /dev/null +++ b/.github/workflows/release-manager.yml @@ -0,0 +1,51 @@ +name: Release Manager + +permissions: + contents: write + issues: write + pull-requests: write + actions: read + checks: read + +on: + workflow_dispatch: + inputs: + version: + description: Release version, for example 1.3.1 + required: true + type: string + action: + description: Release action + required: true + default: full + type: choice + options: + - full + - prepare + - promote + - verify + +concurrency: + group: release-manager + cancel-in-progress: false + +jobs: + release: + name: Release Manager + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ secrets.RELEASE_MANAGER_TOKEN || github.token }} + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 24 + + - name: Run release manager + env: + GH_TOKEN: ${{ secrets.RELEASE_MANAGER_TOKEN || github.token }} + run: node scripts/release-manager.mjs "${{ inputs.action }}" --version "${{ inputs.version }}" diff --git a/.gitignore b/.gitignore index c53e8a4..1892435 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,8 @@ # testing /coverage +/test-results +/playwright-report # next.js /.next/ diff --git a/README.md b/README.md index 63bfe75..1be4ac3 100644 --- a/README.md +++ b/README.md @@ -192,8 +192,9 @@ ASR/TTS provider keys (all optional — mock/native fallback works without them) ASR_PROVIDER=native TTS_PROVIDER=mock XUNFEI_APP_ID= +XUNFEI_API_PASSWORD= # preferred; sent as the server-side x-api-key header XUNFEI_API_KEY= -XUNFEI_API_SECRET= +XUNFEI_API_SECRET= # APIKey + APISecret remain supported as a fallback XUNFEI_ASR_PRODUCT=zh_iat XUNFEI_TTS_VOICE= # default fallback vcn; coach voice is selectable in Settings VOLCENGINE_ACCESS_KEY= @@ -255,6 +256,18 @@ Set the API base URL in `apps/mobile/app.json`: } ``` +## Release Management + +Production releases are managed by the GitHub Actions `Release Manager` workflow: + +```text +GitHub -> Actions -> Release Manager -> Run workflow +``` + +Use `action=full` with a semantic version such as `1.3.1`. The workflow creates the release issue and PRs, waits for checks, promotes `main` to `release`, waits for Tencent deployment, verifies public URLs, and creates the GitHub Release. + +Detailed operation and recovery steps live in `docs/release-manager.md`. + ## TTS Providers Domestic providers are the recommended real voice path: diff --git a/README.zh-CN.md b/README.zh-CN.md index 0ca61ef..d4b2cb7 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -192,8 +192,9 @@ ASR/TTS 服务商密钥(均可选,不填则使用 mock/native 兜底): ASR_PROVIDER=native TTS_PROVIDER=mock XUNFEI_APP_ID= +XUNFEI_API_PASSWORD= # 推荐;仅由服务端通过 x-api-key 请求头发送 XUNFEI_API_KEY= -XUNFEI_API_SECRET= +XUNFEI_API_SECRET= # 仍兼容 APIKey + APISecret 作为兜底鉴权 XUNFEI_ASR_PRODUCT=zh_iat XUNFEI_TTS_VOICE= # 默认兜底 vcn;具体教练声音在设置页选择 VOLCENGINE_ACCESS_KEY= @@ -255,6 +256,18 @@ eas build --platform ios --profile preview } ``` +## 发布管理 + +生产发布通过 GitHub Actions 的 `Release Manager` workflow 管理: + +```text +GitHub -> Actions -> Release Manager -> Run workflow +``` + +选择 `action=full`,输入类似 `1.3.1` 的语义化版本号。workflow 会自动创建 release issue 和 PR,等待检查通过,将 `main` 提升到 `release`,等待腾讯云部署,验证公网 URL,并创建 GitHub Release。 + +详细操作和中断恢复步骤见 `docs/release-manager.md`。 + ## TTS 服务商 国内用户推荐使用以下服务商: diff --git a/apps/mobile/app.json b/apps/mobile/app.json index 4c3f677..eb1b6a8 100644 --- a/apps/mobile/app.json +++ b/apps/mobile/app.json @@ -3,15 +3,15 @@ "name": "MeteorVoice", "slug": "meteorvoice-mobile", "owner": "junchenmeteors-team", - "version": "1.3.0", + "version": "1.4.0", "orientation": "portrait", "scheme": "meteorvoice", "userInterfaceStyle": "automatic", "icon": "./assets/icon.png", "ios": { "supportsTablet": true, - "bundleIdentifier": "com.tzcpa.meteorvoice", - "buildNumber": "2026060601", + "bundleIdentifier": "com.jcmeteor.meteorvoice", + "buildNumber": "2026071101", "deploymentTarget": "18.0", "infoPlist": { "NSMicrophoneUsageDescription": "MeteorVoice uses the microphone for voice practice sessions.", @@ -26,7 +26,7 @@ }, "android": { "package": "com.jcmeteor.meteorvoice", - "versionCode": 2026060601, + "versionCode": 2026071101, "permissions": [ "android.permission.RECORD_AUDIO", "android.permission.MODIFY_AUDIO_SETTINGS", @@ -37,6 +37,7 @@ ] }, "plugins": [ + "expo-router", [ "expo-speech-recognition", { diff --git a/apps/mobile/app/(tabs)/_layout.tsx b/apps/mobile/app/(tabs)/_layout.tsx new file mode 100644 index 0000000..e1415de --- /dev/null +++ b/apps/mobile/app/(tabs)/_layout.tsx @@ -0,0 +1,169 @@ +/** + * Four-tab layout using the original MeteorVoice visual language. + * The tab bar stays opaque and participates in layout so scroll content never + * shows through or underneath it. + */ +import { Tabs } from 'expo-router' +import { + Pressable, + StyleSheet, + Text, + View, +} from 'react-native' +import { SafeAreaView } from 'react-native-safe-area-context' + +import { useSession } from '../../src/SessionContext' +import { useTheme } from '../../src/ThemeProvider' + +const TAB_LABELS: Record = { + home: 'nav.home', + session: 'nav.practice', + history: 'nav.history', + settings: 'nav.settings', +} + +function TabIcon({ tab, color }: { color: string; tab: string }) { + if (tab === 'home') return ( + + + + + ) + + if (tab === 'session') return ( + + + + + ) + + if (tab === 'history') return ( + + {[18, 14, 10].map(width => ( + + ))} + + ) + + return ( + + + + {[0, 45, 90, 135].map(deg => ( + + ))} + + + ) +} + +export default function TabLayout() { + const { C } = useTheme() + const { tr } = useSession() + + return ( + + ( + + + {state.routes.map((route, index) => { + const focused = state.index === index + const labelKey = TAB_LABELS[route.name] + + return ( + navigation.emit({ type: 'tabLongPress', target: route.key })} + onPress={() => { + const event = navigation.emit({ + type: 'tabPress', + target: route.key, + canPreventDefault: true, + }) + if (!focused && !event.defaultPrevented) navigation.navigate(route.name) + }} + style={[styles.tabItem, focused && { backgroundColor: C.accent }]} + > + + + {labelKey ? tr(labelKey) : route.name} + + + ) + })} + + + )} + > + + + + + + + ) +} + +const styles = StyleSheet.create({ + shell: { flex: 1 }, + tabBarWrapper: { + paddingHorizontal: 16, + paddingTop: 6, + paddingBottom: 8, + }, + tabBar: { + flexDirection: 'row', + borderRadius: 24, + borderWidth: 1, + paddingVertical: 4, + paddingHorizontal: 4, + overflow: 'hidden', + }, + tabItem: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + paddingVertical: 8, + gap: 2, + borderRadius: 20, + }, + tabLabel: { fontSize: 10, fontWeight: '600' }, + homeIcon: { width: 18, height: 18, alignItems: 'center', justifyContent: 'flex-end' }, + homeRoof: { + width: 0, + height: 0, + borderLeftWidth: 9, + borderRightWidth: 9, + borderBottomWidth: 8, + borderLeftColor: 'transparent', + borderRightColor: 'transparent', + marginBottom: 1, + }, + homeBody: { width: 12, height: 8, borderRadius: 1 }, + sessionIcon: { width: 18, height: 18, alignItems: 'center', justifyContent: 'center', gap: 1 }, + microphone: { width: 8, height: 11, borderRadius: 4, borderWidth: 2 }, + microphoneBase: { width: 12, height: 2, borderRadius: 1 }, + historyIcon: { width: 18, height: 18, justifyContent: 'center', gap: 3 }, + historyLine: { height: 2, borderRadius: 1 }, + settingsIcon: { width: 18, height: 18, alignItems: 'center', justifyContent: 'center' }, + settingsCenter: { width: 10, height: 10, borderRadius: 5, borderWidth: 2 }, + settingsDots: { + position: 'absolute', + width: 18, + height: 18, + alignItems: 'center', + justifyContent: 'center', + }, + settingsDot: { position: 'absolute', width: 3, height: 3, borderRadius: 1.5 }, +}) diff --git a/apps/mobile/app/(tabs)/history.tsx b/apps/mobile/app/(tabs)/history.tsx new file mode 100644 index 0000000..9e8a7a1 --- /dev/null +++ b/apps/mobile/app/(tabs)/history.tsx @@ -0,0 +1,24 @@ +/** + * History tab — session history and review screen. + * 历史标签页 — 会话历史与回顾界面。 + */ + +import { useMobileAuth } from '../../src/mobileAuth' +import { HistoryScreen } from '../../src/screens/HistoryScreen' +import { useSession } from '../../src/SessionContext' + +export default function HistoryTab() { + const { tr, locale, api } = useSession() + const auth = useMobileAuth() + + return ( + auth.signOut(null)} + defaultApiBaseUrl="" + /> + ) +} diff --git a/apps/mobile/app/(tabs)/home.tsx b/apps/mobile/app/(tabs)/home.tsx new file mode 100644 index 0000000..0f226cd --- /dev/null +++ b/apps/mobile/app/(tabs)/home.tsx @@ -0,0 +1,26 @@ +/** + * Home tab — scenario selection screen. + * 首页标签页 — 场景选择界面。 + */ + +import { useRouter } from 'expo-router' + +import { scenarios } from '@meteorvoice/shared' + +import { HomeScreen } from '../../src/screens/HomeScreen' +import { useSession } from '../../src/SessionContext' + +export default function HomeTab() { + const { tr, locale } = useSession() + const router = useRouter() + + return ( + router.replace('/(tabs)/session')} + /> + ) +} diff --git a/apps/mobile/app/(tabs)/session.tsx b/apps/mobile/app/(tabs)/session.tsx new file mode 100644 index 0000000..b2c1e6b --- /dev/null +++ b/apps/mobile/app/(tabs)/session.tsx @@ -0,0 +1,41 @@ +/** + * Session tab — voice practice screen. + * 会话标签页 — 语音练习界面。 + */ + +import { useMemo } from 'react' + +import { + accentProfiles, + getAccentLabel, + getAccentRegion, + getDifficultyLabel, + getScenarioDescription, + getScenarioLabel, + scenarios, +} from '@meteorvoice/shared' + +import { SessionScreen } from '../../src/screens/SessionScreen' +import { useSession } from '../../src/SessionContext' + +export default function SessionTab() { + const { tr, locale, selectedScenarioKey, selectedAccentKey, voiceProfileAccentLabel, voiceProfileAccentRegion } = useSession() + + const { scenario, accent } = useMemo(() => { + const s = scenarios.find(x => x.key === selectedScenarioKey) ?? scenarios[0] + const a = accentProfiles.find(x => x.key === selectedAccentKey) ?? accentProfiles[0] + return { scenario: s, accent: a } + }, [selectedScenarioKey, selectedAccentKey]) + + return ( + + ) +} diff --git a/apps/mobile/app/(tabs)/settings.tsx b/apps/mobile/app/(tabs)/settings.tsx new file mode 100644 index 0000000..1fbaf5f --- /dev/null +++ b/apps/mobile/app/(tabs)/settings.tsx @@ -0,0 +1,35 @@ +/** + * Settings tab — app settings and preferences screen. + * 设置标签页 — 应用设置与偏好界面。 + */ + +import { SettingsScreen } from '../../src/screens/SettingsScreen' +import { useSession } from '../../src/SessionContext' + +export default function SettingsTab() { + const { + appVersion, + auth, + defaultApiBaseUrl, + getAuthHeaders, + handleUnauthorized, + locale, + setLocale, + signOut, + tr, + } = useSession() + + return ( + + ) +} diff --git a/apps/mobile/app/_layout.tsx b/apps/mobile/app/_layout.tsx new file mode 100644 index 0000000..c483df7 --- /dev/null +++ b/apps/mobile/app/_layout.tsx @@ -0,0 +1,19 @@ +/** + * Root Stack layout — back stack, deep links, slide transitions. + * 根 Stack 布局 — 返回栈、深链接 (meteorvoice://)、平台原生滑动转场。 + * + * ThemeProvider, LogProvider, and SessionContext are provided by App.tsx. + */ +import { Stack } from 'expo-router' + +import App from '../src/App' + +export default function RootLayout() { + return ( + + + + + + ) +} diff --git a/apps/mobile/app/index.tsx b/apps/mobile/app/index.tsx new file mode 100644 index 0000000..c7a3277 --- /dev/null +++ b/apps/mobile/app/index.tsx @@ -0,0 +1,8 @@ +/** + * Root entry route — always open the practice session tab. + */ +import { Redirect } from 'expo-router' + +export default function Index() { + return +} diff --git a/apps/mobile/index.ts b/apps/mobile/index.ts index bfa0404..67000cc 100644 --- a/apps/mobile/index.ts +++ b/apps/mobile/index.ts @@ -1,4 +1 @@ -import { registerRootComponent } from 'expo' -import App from './src/App' - -registerRootComponent(App) +import 'expo-router/entry' diff --git a/apps/mobile/ios/MeteorVoice.xcodeproj/project.pbxproj b/apps/mobile/ios/MeteorVoice.xcodeproj/project.pbxproj index 3ae5a9a..94d714e 100644 --- a/apps/mobile/ios/MeteorVoice.xcodeproj/project.pbxproj +++ b/apps/mobile/ios/MeteorVoice.xcodeproj/project.pbxproj @@ -24,7 +24,7 @@ AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = SplashScreen.storyboard; path = MeteorVoice/SplashScreen.storyboard; sourceTree = ""; }; AE4BE393335FE25D8B585A41 /* ExpoModulesProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ExpoModulesProvider.swift; path = "Pods/Target Support Files/Pods-MeteorVoice/ExpoModulesProvider.swift"; sourceTree = ""; }; BB2F792C24A3F905000567C9 /* Expo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Expo.plist; sourceTree = ""; }; - D9E55C21D76F8D91B23EB544 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; name = PrivacyInfo.xcprivacy; path = MeteorVoice/PrivacyInfo.xcprivacy; sourceTree = ""; }; + D9E55C21D76F8D91B23EB544 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xml; name = PrivacyInfo.xcprivacy; path = MeteorVoice/PrivacyInfo.xcprivacy; sourceTree = ""; }; EB270F574178BA552D004F75 /* libPods-MeteorVoice.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-MeteorVoice.a"; sourceTree = BUILT_PRODUCTS_DIR; }; ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; }; F11748412D0307B40044C1D9 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppDelegate.swift; path = MeteorVoice/AppDelegate.swift; sourceTree = ""; }; @@ -109,7 +109,6 @@ 950FCE77984CFD863035DF52 /* Pods-MeteorVoice.debug.xcconfig */, FDDC0101904C6B5FC73E4CC5 /* Pods-MeteorVoice.release.xcconfig */, ); - name = Pods; path = Pods; sourceTree = ""; }; @@ -164,7 +163,6 @@ LastUpgradeCheck = 1130; TargetAttributes = { 13B07F861A680F5B00A75B9A = { - DevelopmentTeam = LGGMBJG4VB; LastSwiftMigration = 1250; ProvisioningStyle = Automatic; }; @@ -256,6 +254,7 @@ "${PODS_CONFIGURATION_BUILD_DIR}/ExpoFileSystem/ExpoFileSystem_privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/React-Core/React-Core_privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/React-cxxreact/React-cxxreact_privacy.bundle", + "${PODS_CONFIGURATION_BUILD_DIR}/SDWebImage/SDWebImage.bundle", ); name = "[CP] Copy Pods Resources"; outputPaths = ( @@ -264,6 +263,7 @@ "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoFileSystem_privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/React-Core_privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/React-cxxreact_privacy.bundle", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/SDWebImage.bundle", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; @@ -339,7 +339,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 2026060601; - DEVELOPMENT_TEAM = LGGMBJG4VB; + DEVELOPMENT_TEAM = VY69DPYH3T; ENABLE_BITCODE = NO; GCC_PREPROCESSOR_DEFINITIONS = ( "$(inherited)", @@ -358,7 +358,7 @@ "-lc++", ); OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG"; - PRODUCT_BUNDLE_IDENTIFIER = com.tzcpa.meteorvoice; + PRODUCT_BUNDLE_IDENTIFIER = com.jcmeteor.meteorvoice; PRODUCT_NAME = MeteorVoice; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; @@ -377,7 +377,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 2026060601; - DEVELOPMENT_TEAM = LGGMBJG4VB; + DEVELOPMENT_TEAM = VY69DPYH3T; INFOPLIST_FILE = MeteorVoice/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 18.0; LD_RUNPATH_SEARCH_PATHS = ( @@ -391,7 +391,7 @@ "-lc++", ); OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE"; - PRODUCT_BUNDLE_IDENTIFIER = com.tzcpa.meteorvoice; + PRODUCT_BUNDLE_IDENTIFIER = com.jcmeteor.meteorvoice; PRODUCT_NAME = MeteorVoice; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; @@ -456,7 +456,7 @@ ONLY_ACTIVE_ARCH = YES; OTHER_CFLAGS = "$(inherited)"; OTHER_CPLUSPLUSFLAGS = "$(inherited)"; - REACT_NATIVE_PATH = "${PODS_ROOT}/../../../../node_modules/react-native"; + REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native"; SDKROOT = iphoneos; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG"; SWIFT_ENABLE_EXPLICIT_MODULES = NO; @@ -513,7 +513,7 @@ MTL_ENABLE_DEBUG_INFO = NO; OTHER_CFLAGS = "$(inherited)"; OTHER_CPLUSPLUSFLAGS = "$(inherited)"; - REACT_NATIVE_PATH = "${PODS_ROOT}/../../../../node_modules/react-native"; + REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native"; SDKROOT = iphoneos; SWIFT_ENABLE_EXPLICIT_MODULES = NO; USE_HERMES = true; diff --git a/apps/mobile/ios/MeteorVoice/Info.plist b/apps/mobile/ios/MeteorVoice/Info.plist index fe9f4b3..ae29f46 100644 --- a/apps/mobile/ios/MeteorVoice/Info.plist +++ b/apps/mobile/ios/MeteorVoice/Info.plist @@ -1,85 +1,85 @@ - - CADisableMinimumFrameDurationOnPhone - - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleDisplayName - MeteorVoice - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - $(PRODUCT_NAME) - CFBundlePackageType - $(PRODUCT_BUNDLE_PACKAGE_TYPE) - CFBundleShortVersionString - $(MARKETING_VERSION) - CFBundleSignature - ???? - CFBundleURLTypes - - - CFBundleURLSchemes - - meteorvoice - com.tzcpa.meteorvoice - - - - CFBundleVersion - $(CURRENT_PROJECT_VERSION) - LSMinimumSystemVersion - 12.0 - LSRequiresIPhoneOS - + + CADisableMinimumFrameDurationOnPhone + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + MeteorVoice + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleSignature + ???? + CFBundleURLTypes + + + CFBundleURLSchemes + + meteorvoice + com.jcmeteor.meteorvoice + + + + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + LSMinimumSystemVersion + 12.0 + LSRequiresIPhoneOS + NSAppTransportSecurity NSAllowsArbitraryLoads - NSFaceIDUsageDescription - Allow $(PRODUCT_NAME) to access your Face ID biometric data. - NSMicrophoneUsageDescription - MeteorVoice uses the microphone for voice practice sessions. - NSSpeechRecognitionUsageDescription - MeteorVoice uses speech recognition to turn your practice speech into text. - RCTNewArchEnabled - - UIBackgroundModes - - audio - - UILaunchStoryboardName - SplashScreen - UIRequiredDeviceCapabilities - - arm64 - - UIRequiresFullScreen - - UIStatusBarStyle - UIStatusBarStyleDefault - UISupportedInterfaceOrientations - - UIInterfaceOrientationPortrait - UIInterfaceOrientationPortraitUpsideDown - - UISupportedInterfaceOrientations~ipad - - UIInterfaceOrientationPortrait - UIInterfaceOrientationPortraitUpsideDown - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - UIUserInterfaceStyle - Automatic - UIViewControllerBasedStatusBarAppearance - - + NSFaceIDUsageDescription + Allow $(PRODUCT_NAME) to access your Face ID biometric data. + NSMicrophoneUsageDescription + MeteorVoice uses the microphone for voice practice sessions. + NSSpeechRecognitionUsageDescription + MeteorVoice uses speech recognition to turn your practice speech into text. + RCTNewArchEnabled + + UIBackgroundModes + + audio + + UILaunchStoryboardName + SplashScreen + UIRequiredDeviceCapabilities + + arm64 + + UIRequiresFullScreen + + UIStatusBarStyle + UIStatusBarStyleDefault + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UIUserInterfaceStyle + Automatic + UIViewControllerBasedStatusBarAppearance + + diff --git a/apps/mobile/ios/Podfile.lock b/apps/mobile/ios/Podfile.lock index bafa939..74e92e1 100644 --- a/apps/mobile/ios/Podfile.lock +++ b/apps/mobile/ios/Podfile.lock @@ -1,7 +1,7 @@ PODS: - EXConstants (55.0.16): - ExpoModulesCore - - Expo (55.0.26): + - Expo (55.0.27): - ExpoModulesCore - hermes-engine - RCTRequired @@ -28,16 +28,27 @@ PODS: - Yoga - ExpoAsset (55.0.17): - ExpoModulesCore - - ExpoAudio (55.0.14): + - ExpoAudio (55.0.15): - ExpoModulesCore - ExpoDomWebView (55.0.6): - ExpoModulesCore - - ExpoFileSystem (55.0.22): + - ExpoFileSystem (55.0.23): - ExpoModulesCore - ExpoFont (55.0.8): - ExpoModulesCore + - ExpoGlassEffect (55.0.11): + - ExpoModulesCore + - ExpoImage (55.0.11): + - ExpoModulesCore + - libavif/libdav1d + - SDWebImage (~> 5.21.0) + - SDWebImageAVIFCoder (~> 0.11.0) + - SDWebImageSVGCoder (~> 1.7.0) + - SDWebImageWebPCoder (~> 0.14.6) - ExpoKeepAwake (55.0.8): - ExpoModulesCore + - ExpoLinking (55.0.16): + - ExpoModulesCore - ExpoLogBox (55.0.12): - React-Core - ExpoModulesCore (55.0.25): @@ -69,42 +80,64 @@ PODS: - React-Core - React-runtimescheduler - ReactCommon - - ExpoSecureStore (55.0.14): + - ExpoRouter (55.0.16): + - ExpoModulesCore + - RNScreens + - ExpoSecureStore (55.0.15): - ExpoModulesCore - ExpoSpeechRecognition (56.0.0): - ExpoModulesCore - - FBLazyVector (0.83.0) - - hermes-engine (0.14.0): - - hermes-engine/Pre-built (= 0.14.0) - - hermes-engine/Pre-built (0.14.0) - - RCTDeprecation (0.83.0) - - RCTRequired (0.83.0) - - RCTSwiftUI (0.83.0) - - RCTSwiftUIWrapper (0.83.0): + - ExpoSymbols (55.0.9): + - ExpoModulesCore + - FBLazyVector (0.83.6) + - hermes-engine (0.14.1): + - hermes-engine/Pre-built (= 0.14.1) + - hermes-engine/Pre-built (0.14.1) + - libavif/core (1.0.0) + - libavif/libdav1d (1.0.0): + - libavif/core + - libdav1d (>= 0.6.0) + - libdav1d (1.2.0) + - libwebp (1.5.0): + - libwebp/demux (= 1.5.0) + - libwebp/mux (= 1.5.0) + - libwebp/sharpyuv (= 1.5.0) + - libwebp/webp (= 1.5.0) + - libwebp/demux (1.5.0): + - libwebp/webp + - libwebp/mux (1.5.0): + - libwebp/demux + - libwebp/sharpyuv (1.5.0) + - libwebp/webp (1.5.0): + - libwebp/sharpyuv + - RCTDeprecation (0.83.6) + - RCTRequired (0.83.6) + - RCTSwiftUI (0.83.6) + - RCTSwiftUIWrapper (0.83.6): - RCTSwiftUI - - RCTTypeSafety (0.83.0): - - FBLazyVector (= 0.83.0) - - RCTRequired (= 0.83.0) - - React-Core (= 0.83.0) - - React (0.83.0): - - React-Core (= 0.83.0) - - React-Core/DevSupport (= 0.83.0) - - React-Core/RCTWebSocket (= 0.83.0) - - React-RCTActionSheet (= 0.83.0) - - React-RCTAnimation (= 0.83.0) - - React-RCTBlob (= 0.83.0) - - React-RCTImage (= 0.83.0) - - React-RCTLinking (= 0.83.0) - - React-RCTNetwork (= 0.83.0) - - React-RCTSettings (= 0.83.0) - - React-RCTText (= 0.83.0) - - React-RCTVibration (= 0.83.0) - - React-callinvoker (0.83.0) - - React-Core (0.83.0): + - RCTTypeSafety (0.83.6): + - FBLazyVector (= 0.83.6) + - RCTRequired (= 0.83.6) + - React-Core (= 0.83.6) + - React (0.83.6): + - React-Core (= 0.83.6) + - React-Core/DevSupport (= 0.83.6) + - React-Core/RCTWebSocket (= 0.83.6) + - React-RCTActionSheet (= 0.83.6) + - React-RCTAnimation (= 0.83.6) + - React-RCTBlob (= 0.83.6) + - React-RCTImage (= 0.83.6) + - React-RCTLinking (= 0.83.6) + - React-RCTNetwork (= 0.83.6) + - React-RCTSettings (= 0.83.6) + - React-RCTText (= 0.83.6) + - React-RCTVibration (= 0.83.6) + - React-callinvoker (0.83.6) + - React-Core (0.83.6): - hermes-engine - RCTDeprecation - React-Core-prebuilt - - React-Core/Default (= 0.83.0) + - React-Core/Default (= 0.83.6) - React-cxxreact - React-featureflags - React-hermes @@ -119,9 +152,9 @@ PODS: - React-utils - ReactNativeDependencies - Yoga - - React-Core-prebuilt (0.83.0): + - React-Core-prebuilt (0.83.6): - ReactNativeDependencies - - React-Core/CoreModulesHeaders (0.83.0): + - React-Core/CoreModulesHeaders (0.83.6): - hermes-engine - RCTDeprecation - React-Core-prebuilt @@ -140,7 +173,7 @@ PODS: - React-utils - ReactNativeDependencies - Yoga - - React-Core/Default (0.83.0): + - React-Core/Default (0.83.6): - hermes-engine - RCTDeprecation - React-Core-prebuilt @@ -158,12 +191,12 @@ PODS: - React-utils - ReactNativeDependencies - Yoga - - React-Core/DevSupport (0.83.0): + - React-Core/DevSupport (0.83.6): - hermes-engine - RCTDeprecation - React-Core-prebuilt - - React-Core/Default (= 0.83.0) - - React-Core/RCTWebSocket (= 0.83.0) + - React-Core/Default (= 0.83.6) + - React-Core/RCTWebSocket (= 0.83.6) - React-cxxreact - React-featureflags - React-hermes @@ -178,7 +211,7 @@ PODS: - React-utils - ReactNativeDependencies - Yoga - - React-Core/RCTActionSheetHeaders (0.83.0): + - React-Core/RCTActionSheetHeaders (0.83.6): - hermes-engine - RCTDeprecation - React-Core-prebuilt @@ -197,7 +230,7 @@ PODS: - React-utils - ReactNativeDependencies - Yoga - - React-Core/RCTAnimationHeaders (0.83.0): + - React-Core/RCTAnimationHeaders (0.83.6): - hermes-engine - RCTDeprecation - React-Core-prebuilt @@ -216,7 +249,7 @@ PODS: - React-utils - ReactNativeDependencies - Yoga - - React-Core/RCTBlobHeaders (0.83.0): + - React-Core/RCTBlobHeaders (0.83.6): - hermes-engine - RCTDeprecation - React-Core-prebuilt @@ -235,7 +268,7 @@ PODS: - React-utils - ReactNativeDependencies - Yoga - - React-Core/RCTImageHeaders (0.83.0): + - React-Core/RCTImageHeaders (0.83.6): - hermes-engine - RCTDeprecation - React-Core-prebuilt @@ -254,7 +287,7 @@ PODS: - React-utils - ReactNativeDependencies - Yoga - - React-Core/RCTLinkingHeaders (0.83.0): + - React-Core/RCTLinkingHeaders (0.83.6): - hermes-engine - RCTDeprecation - React-Core-prebuilt @@ -273,7 +306,7 @@ PODS: - React-utils - ReactNativeDependencies - Yoga - - React-Core/RCTNetworkHeaders (0.83.0): + - React-Core/RCTNetworkHeaders (0.83.6): - hermes-engine - RCTDeprecation - React-Core-prebuilt @@ -292,7 +325,7 @@ PODS: - React-utils - ReactNativeDependencies - Yoga - - React-Core/RCTSettingsHeaders (0.83.0): + - React-Core/RCTSettingsHeaders (0.83.6): - hermes-engine - RCTDeprecation - React-Core-prebuilt @@ -311,7 +344,7 @@ PODS: - React-utils - ReactNativeDependencies - Yoga - - React-Core/RCTTextHeaders (0.83.0): + - React-Core/RCTTextHeaders (0.83.6): - hermes-engine - RCTDeprecation - React-Core-prebuilt @@ -330,7 +363,7 @@ PODS: - React-utils - ReactNativeDependencies - Yoga - - React-Core/RCTVibrationHeaders (0.83.0): + - React-Core/RCTVibrationHeaders (0.83.6): - hermes-engine - RCTDeprecation - React-Core-prebuilt @@ -349,11 +382,11 @@ PODS: - React-utils - ReactNativeDependencies - Yoga - - React-Core/RCTWebSocket (0.83.0): + - React-Core/RCTWebSocket (0.83.6): - hermes-engine - RCTDeprecation - React-Core-prebuilt - - React-Core/Default (= 0.83.0) + - React-Core/Default (= 0.83.6) - React-cxxreact - React-featureflags - React-hermes @@ -368,40 +401,40 @@ PODS: - React-utils - ReactNativeDependencies - Yoga - - React-CoreModules (0.83.0): - - RCTTypeSafety (= 0.83.0) + - React-CoreModules (0.83.6): + - RCTTypeSafety (= 0.83.6) - React-Core-prebuilt - - React-Core/CoreModulesHeaders (= 0.83.0) + - React-Core/CoreModulesHeaders (= 0.83.6) - React-debug - - React-jsi (= 0.83.0) + - React-jsi (= 0.83.6) - React-jsinspector - React-jsinspectorcdp - React-jsinspectortracing - React-NativeModulesApple - React-RCTBlob - React-RCTFBReactNativeSpec - - React-RCTImage (= 0.83.0) + - React-RCTImage (= 0.83.6) - React-runtimeexecutor - React-utils - ReactCommon - ReactNativeDependencies - - React-cxxreact (0.83.0): + - React-cxxreact (0.83.6): - hermes-engine - - React-callinvoker (= 0.83.0) + - React-callinvoker (= 0.83.6) - React-Core-prebuilt - - React-debug (= 0.83.0) - - React-jsi (= 0.83.0) + - React-debug (= 0.83.6) + - React-jsi (= 0.83.6) - React-jsinspector - React-jsinspectorcdp - React-jsinspectortracing - - React-logger (= 0.83.0) - - React-perflogger (= 0.83.0) + - React-logger (= 0.83.6) + - React-perflogger (= 0.83.6) - React-runtimeexecutor - - React-timing (= 0.83.0) + - React-timing (= 0.83.6) - React-utils - ReactNativeDependencies - - React-debug (0.83.0) - - React-defaultsnativemodule (0.83.0): + - React-debug (0.83.6) + - React-defaultsnativemodule (0.83.6): - hermes-engine - React-Core-prebuilt - React-domnativemodule @@ -416,7 +449,7 @@ PODS: - React-webperformancenativemodule - ReactNativeDependencies - Yoga - - React-domnativemodule (0.83.0): + - React-domnativemodule (0.83.6): - hermes-engine - React-Core-prebuilt - React-Fabric @@ -430,7 +463,7 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - React-Fabric (0.83.0): + - React-Fabric (0.83.6): - hermes-engine - RCTRequired - RCTTypeSafety @@ -438,25 +471,25 @@ PODS: - React-Core-prebuilt - React-cxxreact - React-debug - - React-Fabric/animated (= 0.83.0) - - React-Fabric/animationbackend (= 0.83.0) - - React-Fabric/animations (= 0.83.0) - - React-Fabric/attributedstring (= 0.83.0) - - React-Fabric/bridging (= 0.83.0) - - React-Fabric/componentregistry (= 0.83.0) - - React-Fabric/componentregistrynative (= 0.83.0) - - React-Fabric/components (= 0.83.0) - - React-Fabric/consistency (= 0.83.0) - - React-Fabric/core (= 0.83.0) - - React-Fabric/dom (= 0.83.0) - - React-Fabric/imagemanager (= 0.83.0) - - React-Fabric/leakchecker (= 0.83.0) - - React-Fabric/mounting (= 0.83.0) - - React-Fabric/observers (= 0.83.0) - - React-Fabric/scheduler (= 0.83.0) - - React-Fabric/telemetry (= 0.83.0) - - React-Fabric/templateprocessor (= 0.83.0) - - React-Fabric/uimanager (= 0.83.0) + - React-Fabric/animated (= 0.83.6) + - React-Fabric/animationbackend (= 0.83.6) + - React-Fabric/animations (= 0.83.6) + - React-Fabric/attributedstring (= 0.83.6) + - React-Fabric/bridging (= 0.83.6) + - React-Fabric/componentregistry (= 0.83.6) + - React-Fabric/componentregistrynative (= 0.83.6) + - React-Fabric/components (= 0.83.6) + - React-Fabric/consistency (= 0.83.6) + - React-Fabric/core (= 0.83.6) + - React-Fabric/dom (= 0.83.6) + - React-Fabric/imagemanager (= 0.83.6) + - React-Fabric/leakchecker (= 0.83.6) + - React-Fabric/mounting (= 0.83.6) + - React-Fabric/observers (= 0.83.6) + - React-Fabric/scheduler (= 0.83.6) + - React-Fabric/telemetry (= 0.83.6) + - React-Fabric/templateprocessor (= 0.83.6) + - React-Fabric/uimanager (= 0.83.6) - React-featureflags - React-graphics - React-jsi @@ -468,7 +501,7 @@ PODS: - React-utils - ReactCommon/turbomodule/core - ReactNativeDependencies - - React-Fabric/animated (0.83.0): + - React-Fabric/animated (0.83.6): - hermes-engine - RCTRequired - RCTTypeSafety @@ -487,7 +520,7 @@ PODS: - React-utils - ReactCommon/turbomodule/core - ReactNativeDependencies - - React-Fabric/animationbackend (0.83.0): + - React-Fabric/animationbackend (0.83.6): - hermes-engine - RCTRequired - RCTTypeSafety @@ -506,7 +539,7 @@ PODS: - React-utils - ReactCommon/turbomodule/core - ReactNativeDependencies - - React-Fabric/animations (0.83.0): + - React-Fabric/animations (0.83.6): - hermes-engine - RCTRequired - RCTTypeSafety @@ -525,7 +558,7 @@ PODS: - React-utils - ReactCommon/turbomodule/core - ReactNativeDependencies - - React-Fabric/attributedstring (0.83.0): + - React-Fabric/attributedstring (0.83.6): - hermes-engine - RCTRequired - RCTTypeSafety @@ -544,7 +577,7 @@ PODS: - React-utils - ReactCommon/turbomodule/core - ReactNativeDependencies - - React-Fabric/bridging (0.83.0): + - React-Fabric/bridging (0.83.6): - hermes-engine - RCTRequired - RCTTypeSafety @@ -563,7 +596,7 @@ PODS: - React-utils - ReactCommon/turbomodule/core - ReactNativeDependencies - - React-Fabric/componentregistry (0.83.0): + - React-Fabric/componentregistry (0.83.6): - hermes-engine - RCTRequired - RCTTypeSafety @@ -582,7 +615,7 @@ PODS: - React-utils - ReactCommon/turbomodule/core - ReactNativeDependencies - - React-Fabric/componentregistrynative (0.83.0): + - React-Fabric/componentregistrynative (0.83.6): - hermes-engine - RCTRequired - RCTTypeSafety @@ -601,7 +634,7 @@ PODS: - React-utils - ReactCommon/turbomodule/core - ReactNativeDependencies - - React-Fabric/components (0.83.0): + - React-Fabric/components (0.83.6): - hermes-engine - RCTRequired - RCTTypeSafety @@ -609,10 +642,10 @@ PODS: - React-Core-prebuilt - React-cxxreact - React-debug - - React-Fabric/components/legacyviewmanagerinterop (= 0.83.0) - - React-Fabric/components/root (= 0.83.0) - - React-Fabric/components/scrollview (= 0.83.0) - - React-Fabric/components/view (= 0.83.0) + - React-Fabric/components/legacyviewmanagerinterop (= 0.83.6) + - React-Fabric/components/root (= 0.83.6) + - React-Fabric/components/scrollview (= 0.83.6) + - React-Fabric/components/view (= 0.83.6) - React-featureflags - React-graphics - React-jsi @@ -624,7 +657,7 @@ PODS: - React-utils - ReactCommon/turbomodule/core - ReactNativeDependencies - - React-Fabric/components/legacyviewmanagerinterop (0.83.0): + - React-Fabric/components/legacyviewmanagerinterop (0.83.6): - hermes-engine - RCTRequired - RCTTypeSafety @@ -643,7 +676,7 @@ PODS: - React-utils - ReactCommon/turbomodule/core - ReactNativeDependencies - - React-Fabric/components/root (0.83.0): + - React-Fabric/components/root (0.83.6): - hermes-engine - RCTRequired - RCTTypeSafety @@ -662,7 +695,7 @@ PODS: - React-utils - ReactCommon/turbomodule/core - ReactNativeDependencies - - React-Fabric/components/scrollview (0.83.0): + - React-Fabric/components/scrollview (0.83.6): - hermes-engine - RCTRequired - RCTTypeSafety @@ -681,7 +714,7 @@ PODS: - React-utils - ReactCommon/turbomodule/core - ReactNativeDependencies - - React-Fabric/components/view (0.83.0): + - React-Fabric/components/view (0.83.6): - hermes-engine - RCTRequired - RCTTypeSafety @@ -702,7 +735,7 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - React-Fabric/consistency (0.83.0): + - React-Fabric/consistency (0.83.6): - hermes-engine - RCTRequired - RCTTypeSafety @@ -721,7 +754,7 @@ PODS: - React-utils - ReactCommon/turbomodule/core - ReactNativeDependencies - - React-Fabric/core (0.83.0): + - React-Fabric/core (0.83.6): - hermes-engine - RCTRequired - RCTTypeSafety @@ -740,7 +773,7 @@ PODS: - React-utils - ReactCommon/turbomodule/core - ReactNativeDependencies - - React-Fabric/dom (0.83.0): + - React-Fabric/dom (0.83.6): - hermes-engine - RCTRequired - RCTTypeSafety @@ -759,7 +792,7 @@ PODS: - React-utils - ReactCommon/turbomodule/core - ReactNativeDependencies - - React-Fabric/imagemanager (0.83.0): + - React-Fabric/imagemanager (0.83.6): - hermes-engine - RCTRequired - RCTTypeSafety @@ -778,7 +811,7 @@ PODS: - React-utils - ReactCommon/turbomodule/core - ReactNativeDependencies - - React-Fabric/leakchecker (0.83.0): + - React-Fabric/leakchecker (0.83.6): - hermes-engine - RCTRequired - RCTTypeSafety @@ -797,7 +830,7 @@ PODS: - React-utils - ReactCommon/turbomodule/core - ReactNativeDependencies - - React-Fabric/mounting (0.83.0): + - React-Fabric/mounting (0.83.6): - hermes-engine - RCTRequired - RCTTypeSafety @@ -816,7 +849,7 @@ PODS: - React-utils - ReactCommon/turbomodule/core - ReactNativeDependencies - - React-Fabric/observers (0.83.0): + - React-Fabric/observers (0.83.6): - hermes-engine - RCTRequired - RCTTypeSafety @@ -824,8 +857,8 @@ PODS: - React-Core-prebuilt - React-cxxreact - React-debug - - React-Fabric/observers/events (= 0.83.0) - - React-Fabric/observers/intersection (= 0.83.0) + - React-Fabric/observers/events (= 0.83.6) + - React-Fabric/observers/intersection (= 0.83.6) - React-featureflags - React-graphics - React-jsi @@ -837,7 +870,7 @@ PODS: - React-utils - ReactCommon/turbomodule/core - ReactNativeDependencies - - React-Fabric/observers/events (0.83.0): + - React-Fabric/observers/events (0.83.6): - hermes-engine - RCTRequired - RCTTypeSafety @@ -856,7 +889,7 @@ PODS: - React-utils - ReactCommon/turbomodule/core - ReactNativeDependencies - - React-Fabric/observers/intersection (0.83.0): + - React-Fabric/observers/intersection (0.83.6): - hermes-engine - RCTRequired - RCTTypeSafety @@ -875,7 +908,7 @@ PODS: - React-utils - ReactCommon/turbomodule/core - ReactNativeDependencies - - React-Fabric/scheduler (0.83.0): + - React-Fabric/scheduler (0.83.6): - hermes-engine - RCTRequired - RCTTypeSafety @@ -897,7 +930,7 @@ PODS: - React-utils - ReactCommon/turbomodule/core - ReactNativeDependencies - - React-Fabric/telemetry (0.83.0): + - React-Fabric/telemetry (0.83.6): - hermes-engine - RCTRequired - RCTTypeSafety @@ -916,7 +949,7 @@ PODS: - React-utils - ReactCommon/turbomodule/core - ReactNativeDependencies - - React-Fabric/templateprocessor (0.83.0): + - React-Fabric/templateprocessor (0.83.6): - hermes-engine - RCTRequired - RCTTypeSafety @@ -935,7 +968,7 @@ PODS: - React-utils - ReactCommon/turbomodule/core - ReactNativeDependencies - - React-Fabric/uimanager (0.83.0): + - React-Fabric/uimanager (0.83.6): - hermes-engine - RCTRequired - RCTTypeSafety @@ -943,7 +976,7 @@ PODS: - React-Core-prebuilt - React-cxxreact - React-debug - - React-Fabric/uimanager/consistency (= 0.83.0) + - React-Fabric/uimanager/consistency (= 0.83.6) - React-featureflags - React-graphics - React-jsi @@ -956,7 +989,7 @@ PODS: - React-utils - ReactCommon/turbomodule/core - ReactNativeDependencies - - React-Fabric/uimanager/consistency (0.83.0): + - React-Fabric/uimanager/consistency (0.83.6): - hermes-engine - RCTRequired - RCTTypeSafety @@ -976,7 +1009,7 @@ PODS: - React-utils - ReactCommon/turbomodule/core - ReactNativeDependencies - - React-FabricComponents (0.83.0): + - React-FabricComponents (0.83.6): - hermes-engine - RCTRequired - RCTTypeSafety @@ -985,8 +1018,8 @@ PODS: - React-cxxreact - React-debug - React-Fabric - - React-FabricComponents/components (= 0.83.0) - - React-FabricComponents/textlayoutmanager (= 0.83.0) + - React-FabricComponents/components (= 0.83.6) + - React-FabricComponents/textlayoutmanager (= 0.83.6) - React-featureflags - React-graphics - React-jsi @@ -999,7 +1032,7 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - React-FabricComponents/components (0.83.0): + - React-FabricComponents/components (0.83.6): - hermes-engine - RCTRequired - RCTTypeSafety @@ -1008,18 +1041,18 @@ PODS: - React-cxxreact - React-debug - React-Fabric - - React-FabricComponents/components/inputaccessory (= 0.83.0) - - React-FabricComponents/components/iostextinput (= 0.83.0) - - React-FabricComponents/components/modal (= 0.83.0) - - React-FabricComponents/components/rncore (= 0.83.0) - - React-FabricComponents/components/safeareaview (= 0.83.0) - - React-FabricComponents/components/scrollview (= 0.83.0) - - React-FabricComponents/components/switch (= 0.83.0) - - React-FabricComponents/components/text (= 0.83.0) - - React-FabricComponents/components/textinput (= 0.83.0) - - React-FabricComponents/components/unimplementedview (= 0.83.0) - - React-FabricComponents/components/virtualview (= 0.83.0) - - React-FabricComponents/components/virtualviewexperimental (= 0.83.0) + - React-FabricComponents/components/inputaccessory (= 0.83.6) + - React-FabricComponents/components/iostextinput (= 0.83.6) + - React-FabricComponents/components/modal (= 0.83.6) + - React-FabricComponents/components/rncore (= 0.83.6) + - React-FabricComponents/components/safeareaview (= 0.83.6) + - React-FabricComponents/components/scrollview (= 0.83.6) + - React-FabricComponents/components/switch (= 0.83.6) + - React-FabricComponents/components/text (= 0.83.6) + - React-FabricComponents/components/textinput (= 0.83.6) + - React-FabricComponents/components/unimplementedview (= 0.83.6) + - React-FabricComponents/components/virtualview (= 0.83.6) + - React-FabricComponents/components/virtualviewexperimental (= 0.83.6) - React-featureflags - React-graphics - React-jsi @@ -1032,7 +1065,7 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - React-FabricComponents/components/inputaccessory (0.83.0): + - React-FabricComponents/components/inputaccessory (0.83.6): - hermes-engine - RCTRequired - RCTTypeSafety @@ -1053,7 +1086,7 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - React-FabricComponents/components/iostextinput (0.83.0): + - React-FabricComponents/components/iostextinput (0.83.6): - hermes-engine - RCTRequired - RCTTypeSafety @@ -1074,7 +1107,7 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - React-FabricComponents/components/modal (0.83.0): + - React-FabricComponents/components/modal (0.83.6): - hermes-engine - RCTRequired - RCTTypeSafety @@ -1095,7 +1128,7 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - React-FabricComponents/components/rncore (0.83.0): + - React-FabricComponents/components/rncore (0.83.6): - hermes-engine - RCTRequired - RCTTypeSafety @@ -1116,7 +1149,7 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - React-FabricComponents/components/safeareaview (0.83.0): + - React-FabricComponents/components/safeareaview (0.83.6): - hermes-engine - RCTRequired - RCTTypeSafety @@ -1137,7 +1170,7 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - React-FabricComponents/components/scrollview (0.83.0): + - React-FabricComponents/components/scrollview (0.83.6): - hermes-engine - RCTRequired - RCTTypeSafety @@ -1158,7 +1191,7 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - React-FabricComponents/components/switch (0.83.0): + - React-FabricComponents/components/switch (0.83.6): - hermes-engine - RCTRequired - RCTTypeSafety @@ -1179,7 +1212,7 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - React-FabricComponents/components/text (0.83.0): + - React-FabricComponents/components/text (0.83.6): - hermes-engine - RCTRequired - RCTTypeSafety @@ -1200,7 +1233,7 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - React-FabricComponents/components/textinput (0.83.0): + - React-FabricComponents/components/textinput (0.83.6): - hermes-engine - RCTRequired - RCTTypeSafety @@ -1221,7 +1254,7 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - React-FabricComponents/components/unimplementedview (0.83.0): + - React-FabricComponents/components/unimplementedview (0.83.6): - hermes-engine - RCTRequired - RCTTypeSafety @@ -1242,7 +1275,7 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - React-FabricComponents/components/virtualview (0.83.0): + - React-FabricComponents/components/virtualview (0.83.6): - hermes-engine - RCTRequired - RCTTypeSafety @@ -1263,7 +1296,7 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - React-FabricComponents/components/virtualviewexperimental (0.83.0): + - React-FabricComponents/components/virtualviewexperimental (0.83.6): - hermes-engine - RCTRequired - RCTTypeSafety @@ -1284,7 +1317,7 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - React-FabricComponents/textlayoutmanager (0.83.0): + - React-FabricComponents/textlayoutmanager (0.83.6): - hermes-engine - RCTRequired - RCTTypeSafety @@ -1305,27 +1338,27 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - React-FabricImage (0.83.0): + - React-FabricImage (0.83.6): - hermes-engine - - RCTRequired (= 0.83.0) - - RCTTypeSafety (= 0.83.0) + - RCTRequired (= 0.83.6) + - RCTTypeSafety (= 0.83.6) - React-Core-prebuilt - React-Fabric - React-featureflags - React-graphics - React-ImageManager - React-jsi - - React-jsiexecutor (= 0.83.0) + - React-jsiexecutor (= 0.83.6) - React-logger - React-rendererdebug - React-utils - ReactCommon - ReactNativeDependencies - Yoga - - React-featureflags (0.83.0): + - React-featureflags (0.83.6): - React-Core-prebuilt - ReactNativeDependencies - - React-featureflagsnativemodule (0.83.0): + - React-featureflagsnativemodule (0.83.6): - hermes-engine - React-Core-prebuilt - React-featureflags @@ -1334,27 +1367,27 @@ PODS: - React-RCTFBReactNativeSpec - ReactCommon/turbomodule/core - ReactNativeDependencies - - React-graphics (0.83.0): + - React-graphics (0.83.6): - hermes-engine - React-Core-prebuilt - React-jsi - React-jsiexecutor - React-utils - ReactNativeDependencies - - React-hermes (0.83.0): + - React-hermes (0.83.6): - hermes-engine - React-Core-prebuilt - - React-cxxreact (= 0.83.0) + - React-cxxreact (= 0.83.6) - React-jsi - - React-jsiexecutor (= 0.83.0) + - React-jsiexecutor (= 0.83.6) - React-jsinspector - React-jsinspectorcdp - React-jsinspectortracing - React-oscompat - - React-perflogger (= 0.83.0) + - React-perflogger (= 0.83.6) - React-runtimeexecutor - ReactNativeDependencies - - React-idlecallbacksnativemodule (0.83.0): + - React-idlecallbacksnativemodule (0.83.6): - hermes-engine - React-Core-prebuilt - React-jsi @@ -1364,7 +1397,7 @@ PODS: - React-runtimescheduler - ReactCommon/turbomodule/core - ReactNativeDependencies - - React-ImageManager (0.83.0): + - React-ImageManager (0.83.6): - React-Core-prebuilt - React-Core/Default - React-debug @@ -1373,7 +1406,7 @@ PODS: - React-rendererdebug - React-utils - ReactNativeDependencies - - React-intersectionobservernativemodule (0.83.0): + - React-intersectionobservernativemodule (0.83.6): - hermes-engine - React-Core-prebuilt - React-cxxreact @@ -1388,7 +1421,7 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - React-jserrorhandler (0.83.0): + - React-jserrorhandler (0.83.6): - hermes-engine - React-Core-prebuilt - React-cxxreact @@ -1397,11 +1430,11 @@ PODS: - React-jsi - ReactCommon/turbomodule/bridging - ReactNativeDependencies - - React-jsi (0.83.0): + - React-jsi (0.83.6): - hermes-engine - React-Core-prebuilt - ReactNativeDependencies - - React-jsiexecutor (0.83.0): + - React-jsiexecutor (0.83.6): - hermes-engine - React-Core-prebuilt - React-cxxreact @@ -1414,7 +1447,7 @@ PODS: - React-runtimeexecutor - React-utils - ReactNativeDependencies - - React-jsinspector (0.83.0): + - React-jsinspector (0.83.6): - hermes-engine - React-Core-prebuilt - React-featureflags @@ -1423,46 +1456,47 @@ PODS: - React-jsinspectornetwork - React-jsinspectortracing - React-oscompat - - React-perflogger (= 0.83.0) + - React-perflogger (= 0.83.6) - React-runtimeexecutor - React-utils - ReactNativeDependencies - - React-jsinspectorcdp (0.83.0): + - React-jsinspectorcdp (0.83.6): - React-Core-prebuilt - ReactNativeDependencies - - React-jsinspectornetwork (0.83.0): + - React-jsinspectornetwork (0.83.6): - React-Core-prebuilt - React-jsinspectorcdp - ReactNativeDependencies - - React-jsinspectortracing (0.83.0): + - React-jsinspectortracing (0.83.6): - hermes-engine - React-Core-prebuilt - React-jsi - React-jsinspectornetwork - React-oscompat - React-timing + - React-utils - ReactNativeDependencies - - React-jsitooling (0.83.0): + - React-jsitooling (0.83.6): - React-Core-prebuilt - - React-cxxreact (= 0.83.0) + - React-cxxreact (= 0.83.6) - React-debug - - React-jsi (= 0.83.0) + - React-jsi (= 0.83.6) - React-jsinspector - React-jsinspectorcdp - React-jsinspectortracing - React-runtimeexecutor - React-utils - ReactNativeDependencies - - React-jsitracing (0.83.0): + - React-jsitracing (0.83.6): - React-jsi - - React-logger (0.83.0): + - React-logger (0.83.6): - React-Core-prebuilt - ReactNativeDependencies - - React-Mapbuffer (0.83.0): + - React-Mapbuffer (0.83.6): - React-Core-prebuilt - React-debug - ReactNativeDependencies - - React-microtasksnativemodule (0.83.0): + - React-microtasksnativemodule (0.83.6): - hermes-engine - React-Core-prebuilt - React-jsi @@ -1470,7 +1504,76 @@ PODS: - React-RCTFBReactNativeSpec - ReactCommon/turbomodule/core - ReactNativeDependencies - - React-NativeModulesApple (0.83.0): + - react-native-safe-area-context (5.6.2): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-jsi + - react-native-safe-area-context/common (= 5.6.2) + - react-native-safe-area-context/fabric (= 5.6.2) + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - react-native-safe-area-context/common (5.6.2): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - react-native-safe-area-context/fabric (5.6.2): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-jsi + - react-native-safe-area-context/common + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - React-NativeModulesApple (0.83.6): - hermes-engine - React-callinvoker - React-Core @@ -1485,7 +1588,7 @@ PODS: - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - ReactNativeDependencies - - React-networking (0.83.0): + - React-networking (0.83.6): - React-Core-prebuilt - React-featureflags - React-jsinspectornetwork @@ -1493,11 +1596,11 @@ PODS: - React-performancetimeline - React-timing - ReactNativeDependencies - - React-oscompat (0.83.0) - - React-perflogger (0.83.0): + - React-oscompat (0.83.6) + - React-perflogger (0.83.6): - React-Core-prebuilt - ReactNativeDependencies - - React-performancecdpmetrics (0.83.0): + - React-performancecdpmetrics (0.83.6): - hermes-engine - React-Core-prebuilt - React-jsi @@ -1505,16 +1608,16 @@ PODS: - React-runtimeexecutor - React-timing - ReactNativeDependencies - - React-performancetimeline (0.83.0): + - React-performancetimeline (0.83.6): - React-Core-prebuilt - React-featureflags - React-jsinspectortracing - React-perflogger - React-timing - ReactNativeDependencies - - React-RCTActionSheet (0.83.0): - - React-Core/RCTActionSheetHeaders (= 0.83.0) - - React-RCTAnimation (0.83.0): + - React-RCTActionSheet (0.83.6): + - React-Core/RCTActionSheetHeaders (= 0.83.6) + - React-RCTAnimation (0.83.6): - RCTTypeSafety - React-Core-prebuilt - React-Core/RCTAnimationHeaders @@ -1524,7 +1627,7 @@ PODS: - React-RCTFBReactNativeSpec - ReactCommon - ReactNativeDependencies - - React-RCTAppDelegate (0.83.0): + - React-RCTAppDelegate (0.83.6): - hermes-engine - RCTRequired - RCTTypeSafety @@ -1552,7 +1655,7 @@ PODS: - React-utils - ReactCommon - ReactNativeDependencies - - React-RCTBlob (0.83.0): + - React-RCTBlob (0.83.6): - hermes-engine - React-Core-prebuilt - React-Core/RCTBlobHeaders @@ -1565,7 +1668,7 @@ PODS: - React-RCTNetwork - ReactCommon - ReactNativeDependencies - - React-RCTFabric (0.83.0): + - React-RCTFabric (0.83.6): - hermes-engine - RCTSwiftUIWrapper - React-Core @@ -1596,7 +1699,7 @@ PODS: - React-utils - ReactNativeDependencies - Yoga - - React-RCTFBReactNativeSpec (0.83.0): + - React-RCTFBReactNativeSpec (0.83.6): - hermes-engine - RCTRequired - RCTTypeSafety @@ -1604,10 +1707,10 @@ PODS: - React-Core-prebuilt - React-jsi - React-NativeModulesApple - - React-RCTFBReactNativeSpec/components (= 0.83.0) + - React-RCTFBReactNativeSpec/components (= 0.83.6) - ReactCommon - ReactNativeDependencies - - React-RCTFBReactNativeSpec/components (0.83.0): + - React-RCTFBReactNativeSpec/components (0.83.6): - hermes-engine - RCTRequired - RCTTypeSafety @@ -1624,7 +1727,7 @@ PODS: - ReactCommon - ReactNativeDependencies - Yoga - - React-RCTImage (0.83.0): + - React-RCTImage (0.83.6): - RCTTypeSafety - React-Core-prebuilt - React-Core/RCTImageHeaders @@ -1634,14 +1737,14 @@ PODS: - React-RCTNetwork - ReactCommon - ReactNativeDependencies - - React-RCTLinking (0.83.0): - - React-Core/RCTLinkingHeaders (= 0.83.0) - - React-jsi (= 0.83.0) + - React-RCTLinking (0.83.6): + - React-Core/RCTLinkingHeaders (= 0.83.6) + - React-jsi (= 0.83.6) - React-NativeModulesApple - React-RCTFBReactNativeSpec - ReactCommon - - ReactCommon/turbomodule/core (= 0.83.0) - - React-RCTNetwork (0.83.0): + - ReactCommon/turbomodule/core (= 0.83.6) + - React-RCTNetwork (0.83.6): - RCTTypeSafety - React-Core-prebuilt - React-Core/RCTNetworkHeaders @@ -1655,7 +1758,7 @@ PODS: - React-RCTFBReactNativeSpec - ReactCommon - ReactNativeDependencies - - React-RCTRuntime (0.83.0): + - React-RCTRuntime (0.83.6): - hermes-engine - React-Core - React-Core-prebuilt @@ -1671,7 +1774,7 @@ PODS: - React-RuntimeHermes - React-utils - ReactNativeDependencies - - React-RCTSettings (0.83.0): + - React-RCTSettings (0.83.6): - RCTTypeSafety - React-Core-prebuilt - React-Core/RCTSettingsHeaders @@ -1680,10 +1783,10 @@ PODS: - React-RCTFBReactNativeSpec - ReactCommon - ReactNativeDependencies - - React-RCTText (0.83.0): - - React-Core/RCTTextHeaders (= 0.83.0) + - React-RCTText (0.83.6): + - React-Core/RCTTextHeaders (= 0.83.6) - Yoga - - React-RCTVibration (0.83.0): + - React-RCTVibration (0.83.6): - React-Core-prebuilt - React-Core/RCTVibrationHeaders - React-jsi @@ -1691,15 +1794,15 @@ PODS: - React-RCTFBReactNativeSpec - ReactCommon - ReactNativeDependencies - - React-rendererconsistency (0.83.0) - - React-renderercss (0.83.0): + - React-rendererconsistency (0.83.6) + - React-renderercss (0.83.6): - React-debug - React-utils - - React-rendererdebug (0.83.0): + - React-rendererdebug (0.83.6): - React-Core-prebuilt - React-debug - ReactNativeDependencies - - React-RuntimeApple (0.83.0): + - React-RuntimeApple (0.83.6): - hermes-engine - React-callinvoker - React-Core-prebuilt @@ -1722,7 +1825,7 @@ PODS: - React-runtimescheduler - React-utils - ReactNativeDependencies - - React-RuntimeCore (0.83.0): + - React-RuntimeCore (0.83.6): - hermes-engine - React-Core-prebuilt - React-cxxreact @@ -1738,14 +1841,14 @@ PODS: - React-runtimescheduler - React-utils - ReactNativeDependencies - - React-runtimeexecutor (0.83.0): + - React-runtimeexecutor (0.83.6): - React-Core-prebuilt - React-debug - React-featureflags - - React-jsi (= 0.83.0) + - React-jsi (= 0.83.6) - React-utils - ReactNativeDependencies - - React-RuntimeHermes (0.83.0): + - React-RuntimeHermes (0.83.6): - hermes-engine - React-Core-prebuilt - React-featureflags @@ -1760,7 +1863,7 @@ PODS: - React-runtimeexecutor - React-utils - ReactNativeDependencies - - React-runtimescheduler (0.83.0): + - React-runtimescheduler (0.83.6): - hermes-engine - React-callinvoker - React-Core-prebuilt @@ -1776,15 +1879,15 @@ PODS: - React-timing - React-utils - ReactNativeDependencies - - React-timing (0.83.0): + - React-timing (0.83.6): - React-debug - - React-utils (0.83.0): + - React-utils (0.83.6): - hermes-engine - React-Core-prebuilt - React-debug - - React-jsi (= 0.83.0) + - React-jsi (= 0.83.6) - ReactNativeDependencies - - React-webperformancenativemodule (0.83.0): + - React-webperformancenativemodule (0.83.6): - hermes-engine - React-Core-prebuilt - React-cxxreact @@ -1795,9 +1898,9 @@ PODS: - React-runtimeexecutor - ReactCommon/turbomodule/core - ReactNativeDependencies - - ReactAppDependencyProvider (0.83.0): + - ReactAppDependencyProvider (0.83.6): - ReactCodegen - - ReactCodegen (0.83.0): + - ReactCodegen (0.83.6): - hermes-engine - RCTRequired - RCTTypeSafety @@ -1817,43 +1920,101 @@ PODS: - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - ReactNativeDependencies - - ReactCommon (0.83.0): + - ReactCommon (0.83.6): - React-Core-prebuilt - - ReactCommon/turbomodule (= 0.83.0) + - ReactCommon/turbomodule (= 0.83.6) - ReactNativeDependencies - - ReactCommon/turbomodule (0.83.0): + - ReactCommon/turbomodule (0.83.6): - hermes-engine - - React-callinvoker (= 0.83.0) + - React-callinvoker (= 0.83.6) - React-Core-prebuilt - - React-cxxreact (= 0.83.0) - - React-jsi (= 0.83.0) - - React-logger (= 0.83.0) - - React-perflogger (= 0.83.0) - - ReactCommon/turbomodule/bridging (= 0.83.0) - - ReactCommon/turbomodule/core (= 0.83.0) + - React-cxxreact (= 0.83.6) + - React-jsi (= 0.83.6) + - React-logger (= 0.83.6) + - React-perflogger (= 0.83.6) + - ReactCommon/turbomodule/bridging (= 0.83.6) + - ReactCommon/turbomodule/core (= 0.83.6) - ReactNativeDependencies - - ReactCommon/turbomodule/bridging (0.83.0): + - ReactCommon/turbomodule/bridging (0.83.6): - hermes-engine - - React-callinvoker (= 0.83.0) + - React-callinvoker (= 0.83.6) - React-Core-prebuilt - - React-cxxreact (= 0.83.0) - - React-jsi (= 0.83.0) - - React-logger (= 0.83.0) - - React-perflogger (= 0.83.0) + - React-cxxreact (= 0.83.6) + - React-jsi (= 0.83.6) + - React-logger (= 0.83.6) + - React-perflogger (= 0.83.6) - ReactNativeDependencies - - ReactCommon/turbomodule/core (0.83.0): + - ReactCommon/turbomodule/core (0.83.6): - hermes-engine - - React-callinvoker (= 0.83.0) + - React-callinvoker (= 0.83.6) - React-Core-prebuilt - - React-cxxreact (= 0.83.0) - - React-debug (= 0.83.0) - - React-featureflags (= 0.83.0) - - React-jsi (= 0.83.0) - - React-logger (= 0.83.0) - - React-perflogger (= 0.83.0) - - React-utils (= 0.83.0) + - React-cxxreact (= 0.83.6) + - React-debug (= 0.83.6) + - React-featureflags (= 0.83.6) + - React-jsi (= 0.83.6) + - React-logger (= 0.83.6) + - React-perflogger (= 0.83.6) + - React-utils (= 0.83.6) - ReactNativeDependencies - - ReactNativeDependencies (0.83.0) + - ReactNativeDependencies (0.83.6) + - RNScreens (4.23.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-RCTImage + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - RNScreens/common (= 4.23.0) + - Yoga + - RNScreens/common (4.23.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-RCTImage + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - SDWebImage (5.21.7): + - SDWebImage/Core (= 5.21.7) + - SDWebImage/Core (5.21.7) + - SDWebImageAVIFCoder (0.11.1): + - libavif/core (>= 0.11.0) + - SDWebImage (~> 5.10) + - SDWebImageSVGCoder (1.7.0): + - SDWebImage/Core (~> 5.6) + - SDWebImageWebPCoder (0.14.6): + - libwebp (~> 1.0) + - SDWebImage/Core (~> 5.17) - VoiceAudioSession (0.1.0): - ExpoModulesCore - VoicePcmCapture (0.1.0): @@ -1862,100 +2023,117 @@ PODS: DEPENDENCIES: - EXConstants (from `../../../node_modules/expo-constants/ios`) - - Expo (from `../../../node_modules/expo`) + - Expo (from `../node_modules/expo`) - ExpoAsset (from `../../../node_modules/expo-asset/ios`) - ExpoAudio (from `../../../node_modules/expo-audio/ios`) - "ExpoDomWebView (from `../../../node_modules/@expo/dom-webview/ios`)" - - ExpoFileSystem (from `../../../node_modules/expo-file-system/ios`) + - ExpoFileSystem (from `../node_modules/expo-file-system/ios`) - ExpoFont (from `../../../node_modules/expo-font/ios`) + - ExpoGlassEffect (from `../../../node_modules/expo-glass-effect/ios`) + - ExpoImage (from `../../../node_modules/expo-image/ios`) - ExpoKeepAwake (from `../../../node_modules/expo-keep-awake/ios`) + - ExpoLinking (from `../node_modules/expo-linking/ios`) - "ExpoLogBox (from `../../../node_modules/@expo/log-box`)" - ExpoModulesCore (from `../../../node_modules/expo-modules-core`) - ExpoModulesJSI (from `../../../node_modules/expo-modules-core`) - - ExpoSecureStore (from `../node_modules/expo-secure-store/ios`) + - ExpoRouter (from `../../../node_modules/expo-router/ios`) + - ExpoSecureStore (from `../../../node_modules/expo-secure-store/ios`) - ExpoSpeechRecognition (from `../../../node_modules/expo-speech-recognition/ios`) - - FBLazyVector (from `../../../node_modules/react-native/Libraries/FBLazyVector`) - - hermes-engine (from `../../../node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec`) - - RCTDeprecation (from `../../../node_modules/react-native/ReactApple/Libraries/RCTFoundation/RCTDeprecation`) - - RCTRequired (from `../../../node_modules/react-native/Libraries/Required`) - - RCTSwiftUI (from `../../../node_modules/react-native/ReactApple/RCTSwiftUI`) - - RCTSwiftUIWrapper (from `../../../node_modules/react-native/ReactApple/RCTSwiftUIWrapper`) - - RCTTypeSafety (from `../../../node_modules/react-native/Libraries/TypeSafety`) - - React (from `../../../node_modules/react-native/`) - - React-callinvoker (from `../../../node_modules/react-native/ReactCommon/callinvoker`) - - React-Core (from `../../../node_modules/react-native/`) - - React-Core-prebuilt (from `../../../node_modules/react-native/React-Core-prebuilt.podspec`) - - React-Core/RCTWebSocket (from `../../../node_modules/react-native/`) - - React-CoreModules (from `../../../node_modules/react-native/React/CoreModules`) - - React-cxxreact (from `../../../node_modules/react-native/ReactCommon/cxxreact`) - - React-debug (from `../../../node_modules/react-native/ReactCommon/react/debug`) - - React-defaultsnativemodule (from `../../../node_modules/react-native/ReactCommon/react/nativemodule/defaults`) - - React-domnativemodule (from `../../../node_modules/react-native/ReactCommon/react/nativemodule/dom`) - - React-Fabric (from `../../../node_modules/react-native/ReactCommon`) - - React-FabricComponents (from `../../../node_modules/react-native/ReactCommon`) - - React-FabricImage (from `../../../node_modules/react-native/ReactCommon`) - - React-featureflags (from `../../../node_modules/react-native/ReactCommon/react/featureflags`) - - React-featureflagsnativemodule (from `../../../node_modules/react-native/ReactCommon/react/nativemodule/featureflags`) - - React-graphics (from `../../../node_modules/react-native/ReactCommon/react/renderer/graphics`) - - React-hermes (from `../../../node_modules/react-native/ReactCommon/hermes`) - - React-idlecallbacksnativemodule (from `../../../node_modules/react-native/ReactCommon/react/nativemodule/idlecallbacks`) - - React-ImageManager (from `../../../node_modules/react-native/ReactCommon/react/renderer/imagemanager/platform/ios`) - - React-intersectionobservernativemodule (from `../../../node_modules/react-native/ReactCommon/react/nativemodule/intersectionobserver`) - - React-jserrorhandler (from `../../../node_modules/react-native/ReactCommon/jserrorhandler`) - - React-jsi (from `../../../node_modules/react-native/ReactCommon/jsi`) - - React-jsiexecutor (from `../../../node_modules/react-native/ReactCommon/jsiexecutor`) - - React-jsinspector (from `../../../node_modules/react-native/ReactCommon/jsinspector-modern`) - - React-jsinspectorcdp (from `../../../node_modules/react-native/ReactCommon/jsinspector-modern/cdp`) - - React-jsinspectornetwork (from `../../../node_modules/react-native/ReactCommon/jsinspector-modern/network`) - - React-jsinspectortracing (from `../../../node_modules/react-native/ReactCommon/jsinspector-modern/tracing`) - - React-jsitooling (from `../../../node_modules/react-native/ReactCommon/jsitooling`) - - React-jsitracing (from `../../../node_modules/react-native/ReactCommon/hermes/executor/`) - - React-logger (from `../../../node_modules/react-native/ReactCommon/logger`) - - React-Mapbuffer (from `../../../node_modules/react-native/ReactCommon`) - - React-microtasksnativemodule (from `../../../node_modules/react-native/ReactCommon/react/nativemodule/microtasks`) - - React-NativeModulesApple (from `../../../node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios`) - - React-networking (from `../../../node_modules/react-native/ReactCommon/react/networking`) - - React-oscompat (from `../../../node_modules/react-native/ReactCommon/oscompat`) - - React-perflogger (from `../../../node_modules/react-native/ReactCommon/reactperflogger`) - - React-performancecdpmetrics (from `../../../node_modules/react-native/ReactCommon/react/performance/cdpmetrics`) - - React-performancetimeline (from `../../../node_modules/react-native/ReactCommon/react/performance/timeline`) - - React-RCTActionSheet (from `../../../node_modules/react-native/Libraries/ActionSheetIOS`) - - React-RCTAnimation (from `../../../node_modules/react-native/Libraries/NativeAnimation`) - - React-RCTAppDelegate (from `../../../node_modules/react-native/Libraries/AppDelegate`) - - React-RCTBlob (from `../../../node_modules/react-native/Libraries/Blob`) - - React-RCTFabric (from `../../../node_modules/react-native/React`) - - React-RCTFBReactNativeSpec (from `../../../node_modules/react-native/React`) - - React-RCTImage (from `../../../node_modules/react-native/Libraries/Image`) - - React-RCTLinking (from `../../../node_modules/react-native/Libraries/LinkingIOS`) - - React-RCTNetwork (from `../../../node_modules/react-native/Libraries/Network`) - - React-RCTRuntime (from `../../../node_modules/react-native/React/Runtime`) - - React-RCTSettings (from `../../../node_modules/react-native/Libraries/Settings`) - - React-RCTText (from `../../../node_modules/react-native/Libraries/Text`) - - React-RCTVibration (from `../../../node_modules/react-native/Libraries/Vibration`) - - React-rendererconsistency (from `../../../node_modules/react-native/ReactCommon/react/renderer/consistency`) - - React-renderercss (from `../../../node_modules/react-native/ReactCommon/react/renderer/css`) - - React-rendererdebug (from `../../../node_modules/react-native/ReactCommon/react/renderer/debug`) - - React-RuntimeApple (from `../../../node_modules/react-native/ReactCommon/react/runtime/platform/ios`) - - React-RuntimeCore (from `../../../node_modules/react-native/ReactCommon/react/runtime`) - - React-runtimeexecutor (from `../../../node_modules/react-native/ReactCommon/runtimeexecutor`) - - React-RuntimeHermes (from `../../../node_modules/react-native/ReactCommon/react/runtime`) - - React-runtimescheduler (from `../../../node_modules/react-native/ReactCommon/react/renderer/runtimescheduler`) - - React-timing (from `../../../node_modules/react-native/ReactCommon/react/timing`) - - React-utils (from `../../../node_modules/react-native/ReactCommon/react/utils`) - - React-webperformancenativemodule (from `../../../node_modules/react-native/ReactCommon/react/nativemodule/webperformance`) + - ExpoSymbols (from `../../../node_modules/expo-symbols/ios`) + - FBLazyVector (from `../node_modules/react-native/Libraries/FBLazyVector`) + - hermes-engine (from `../node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec`) + - RCTDeprecation (from `../node_modules/react-native/ReactApple/Libraries/RCTFoundation/RCTDeprecation`) + - RCTRequired (from `../node_modules/react-native/Libraries/Required`) + - RCTSwiftUI (from `../node_modules/react-native/ReactApple/RCTSwiftUI`) + - RCTSwiftUIWrapper (from `../node_modules/react-native/ReactApple/RCTSwiftUIWrapper`) + - RCTTypeSafety (from `../node_modules/react-native/Libraries/TypeSafety`) + - React (from `../node_modules/react-native/`) + - React-callinvoker (from `../node_modules/react-native/ReactCommon/callinvoker`) + - React-Core (from `../node_modules/react-native/`) + - React-Core-prebuilt (from `../node_modules/react-native/React-Core-prebuilt.podspec`) + - React-Core/RCTWebSocket (from `../node_modules/react-native/`) + - React-CoreModules (from `../node_modules/react-native/React/CoreModules`) + - React-cxxreact (from `../node_modules/react-native/ReactCommon/cxxreact`) + - React-debug (from `../node_modules/react-native/ReactCommon/react/debug`) + - React-defaultsnativemodule (from `../node_modules/react-native/ReactCommon/react/nativemodule/defaults`) + - React-domnativemodule (from `../node_modules/react-native/ReactCommon/react/nativemodule/dom`) + - React-Fabric (from `../node_modules/react-native/ReactCommon`) + - React-FabricComponents (from `../node_modules/react-native/ReactCommon`) + - React-FabricImage (from `../node_modules/react-native/ReactCommon`) + - React-featureflags (from `../node_modules/react-native/ReactCommon/react/featureflags`) + - React-featureflagsnativemodule (from `../node_modules/react-native/ReactCommon/react/nativemodule/featureflags`) + - React-graphics (from `../node_modules/react-native/ReactCommon/react/renderer/graphics`) + - React-hermes (from `../node_modules/react-native/ReactCommon/hermes`) + - React-idlecallbacksnativemodule (from `../node_modules/react-native/ReactCommon/react/nativemodule/idlecallbacks`) + - React-ImageManager (from `../node_modules/react-native/ReactCommon/react/renderer/imagemanager/platform/ios`) + - React-intersectionobservernativemodule (from `../node_modules/react-native/ReactCommon/react/nativemodule/intersectionobserver`) + - React-jserrorhandler (from `../node_modules/react-native/ReactCommon/jserrorhandler`) + - React-jsi (from `../node_modules/react-native/ReactCommon/jsi`) + - React-jsiexecutor (from `../node_modules/react-native/ReactCommon/jsiexecutor`) + - React-jsinspector (from `../node_modules/react-native/ReactCommon/jsinspector-modern`) + - React-jsinspectorcdp (from `../node_modules/react-native/ReactCommon/jsinspector-modern/cdp`) + - React-jsinspectornetwork (from `../node_modules/react-native/ReactCommon/jsinspector-modern/network`) + - React-jsinspectortracing (from `../node_modules/react-native/ReactCommon/jsinspector-modern/tracing`) + - React-jsitooling (from `../node_modules/react-native/ReactCommon/jsitooling`) + - React-jsitracing (from `../node_modules/react-native/ReactCommon/hermes/executor/`) + - React-logger (from `../node_modules/react-native/ReactCommon/logger`) + - React-Mapbuffer (from `../node_modules/react-native/ReactCommon`) + - React-microtasksnativemodule (from `../node_modules/react-native/ReactCommon/react/nativemodule/microtasks`) + - react-native-safe-area-context (from `../node_modules/react-native-safe-area-context`) + - React-NativeModulesApple (from `../node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios`) + - React-networking (from `../node_modules/react-native/ReactCommon/react/networking`) + - React-oscompat (from `../node_modules/react-native/ReactCommon/oscompat`) + - React-perflogger (from `../node_modules/react-native/ReactCommon/reactperflogger`) + - React-performancecdpmetrics (from `../node_modules/react-native/ReactCommon/react/performance/cdpmetrics`) + - React-performancetimeline (from `../node_modules/react-native/ReactCommon/react/performance/timeline`) + - React-RCTActionSheet (from `../node_modules/react-native/Libraries/ActionSheetIOS`) + - React-RCTAnimation (from `../node_modules/react-native/Libraries/NativeAnimation`) + - React-RCTAppDelegate (from `../node_modules/react-native/Libraries/AppDelegate`) + - React-RCTBlob (from `../node_modules/react-native/Libraries/Blob`) + - React-RCTFabric (from `../node_modules/react-native/React`) + - React-RCTFBReactNativeSpec (from `../node_modules/react-native/React`) + - React-RCTImage (from `../node_modules/react-native/Libraries/Image`) + - React-RCTLinking (from `../node_modules/react-native/Libraries/LinkingIOS`) + - React-RCTNetwork (from `../node_modules/react-native/Libraries/Network`) + - React-RCTRuntime (from `../node_modules/react-native/React/Runtime`) + - React-RCTSettings (from `../node_modules/react-native/Libraries/Settings`) + - React-RCTText (from `../node_modules/react-native/Libraries/Text`) + - React-RCTVibration (from `../node_modules/react-native/Libraries/Vibration`) + - React-rendererconsistency (from `../node_modules/react-native/ReactCommon/react/renderer/consistency`) + - React-renderercss (from `../node_modules/react-native/ReactCommon/react/renderer/css`) + - React-rendererdebug (from `../node_modules/react-native/ReactCommon/react/renderer/debug`) + - React-RuntimeApple (from `../node_modules/react-native/ReactCommon/react/runtime/platform/ios`) + - React-RuntimeCore (from `../node_modules/react-native/ReactCommon/react/runtime`) + - React-runtimeexecutor (from `../node_modules/react-native/ReactCommon/runtimeexecutor`) + - React-RuntimeHermes (from `../node_modules/react-native/ReactCommon/react/runtime`) + - React-runtimescheduler (from `../node_modules/react-native/ReactCommon/react/renderer/runtimescheduler`) + - React-timing (from `../node_modules/react-native/ReactCommon/react/timing`) + - React-utils (from `../node_modules/react-native/ReactCommon/react/utils`) + - React-webperformancenativemodule (from `../node_modules/react-native/ReactCommon/react/nativemodule/webperformance`) - ReactAppDependencyProvider (from `build/generated/ios/ReactAppDependencyProvider`) - ReactCodegen (from `build/generated/ios/ReactCodegen`) - - ReactCommon/turbomodule/core (from `../../../node_modules/react-native/ReactCommon`) - - ReactNativeDependencies (from `../../../node_modules/react-native/third-party-podspecs/ReactNativeDependencies.podspec`) + - ReactCommon/turbomodule/core (from `../node_modules/react-native/ReactCommon`) + - ReactNativeDependencies (from `../node_modules/react-native/third-party-podspecs/ReactNativeDependencies.podspec`) + - RNScreens (from `../node_modules/react-native-screens`) - VoiceAudioSession (from `../modules/voice-audio-session/ios`) - VoicePcmCapture (from `../modules/voice-pcm-capture/ios`) - - Yoga (from `../../../node_modules/react-native/ReactCommon/yoga`) + - Yoga (from `../node_modules/react-native/ReactCommon/yoga`) + +SPEC REPOS: + trunk: + - libavif + - libdav1d + - libwebp + - SDWebImage + - SDWebImageAVIFCoder + - SDWebImageSVGCoder + - SDWebImageWebPCoder EXTERNAL SOURCES: EXConstants: :path: "../../../node_modules/expo-constants/ios" Expo: - :path: "../../../node_modules/expo" + :path: "../node_modules/expo" ExpoAsset: :path: "../../../node_modules/expo-asset/ios" ExpoAudio: @@ -1963,262 +2141,290 @@ EXTERNAL SOURCES: ExpoDomWebView: :path: "../../../node_modules/@expo/dom-webview/ios" ExpoFileSystem: - :path: "../../../node_modules/expo-file-system/ios" + :path: "../node_modules/expo-file-system/ios" ExpoFont: :path: "../../../node_modules/expo-font/ios" + ExpoGlassEffect: + :path: "../../../node_modules/expo-glass-effect/ios" + ExpoImage: + :path: "../../../node_modules/expo-image/ios" ExpoKeepAwake: :path: "../../../node_modules/expo-keep-awake/ios" + ExpoLinking: + :path: "../node_modules/expo-linking/ios" ExpoLogBox: :path: "../../../node_modules/@expo/log-box" ExpoModulesCore: :path: "../../../node_modules/expo-modules-core" ExpoModulesJSI: :path: "../../../node_modules/expo-modules-core" + ExpoRouter: + :path: "../../../node_modules/expo-router/ios" ExpoSecureStore: - :path: "../node_modules/expo-secure-store/ios" + :path: "../../../node_modules/expo-secure-store/ios" ExpoSpeechRecognition: :path: "../../../node_modules/expo-speech-recognition/ios" + ExpoSymbols: + :path: "../../../node_modules/expo-symbols/ios" FBLazyVector: - :path: "../../../node_modules/react-native/Libraries/FBLazyVector" + :path: "../node_modules/react-native/Libraries/FBLazyVector" hermes-engine: - :podspec: "../../../node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec" - :tag: hermes-v0.14.0 + :podspec: "../node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec" + :tag: hermes-v0.14.1 RCTDeprecation: - :path: "../../../node_modules/react-native/ReactApple/Libraries/RCTFoundation/RCTDeprecation" + :path: "../node_modules/react-native/ReactApple/Libraries/RCTFoundation/RCTDeprecation" RCTRequired: - :path: "../../../node_modules/react-native/Libraries/Required" + :path: "../node_modules/react-native/Libraries/Required" RCTSwiftUI: - :path: "../../../node_modules/react-native/ReactApple/RCTSwiftUI" + :path: "../node_modules/react-native/ReactApple/RCTSwiftUI" RCTSwiftUIWrapper: - :path: "../../../node_modules/react-native/ReactApple/RCTSwiftUIWrapper" + :path: "../node_modules/react-native/ReactApple/RCTSwiftUIWrapper" RCTTypeSafety: - :path: "../../../node_modules/react-native/Libraries/TypeSafety" + :path: "../node_modules/react-native/Libraries/TypeSafety" React: - :path: "../../../node_modules/react-native/" + :path: "../node_modules/react-native/" React-callinvoker: - :path: "../../../node_modules/react-native/ReactCommon/callinvoker" + :path: "../node_modules/react-native/ReactCommon/callinvoker" React-Core: - :path: "../../../node_modules/react-native/" + :path: "../node_modules/react-native/" React-Core-prebuilt: - :podspec: "../../../node_modules/react-native/React-Core-prebuilt.podspec" + :podspec: "../node_modules/react-native/React-Core-prebuilt.podspec" React-CoreModules: - :path: "../../../node_modules/react-native/React/CoreModules" + :path: "../node_modules/react-native/React/CoreModules" React-cxxreact: - :path: "../../../node_modules/react-native/ReactCommon/cxxreact" + :path: "../node_modules/react-native/ReactCommon/cxxreact" React-debug: - :path: "../../../node_modules/react-native/ReactCommon/react/debug" + :path: "../node_modules/react-native/ReactCommon/react/debug" React-defaultsnativemodule: - :path: "../../../node_modules/react-native/ReactCommon/react/nativemodule/defaults" + :path: "../node_modules/react-native/ReactCommon/react/nativemodule/defaults" React-domnativemodule: - :path: "../../../node_modules/react-native/ReactCommon/react/nativemodule/dom" + :path: "../node_modules/react-native/ReactCommon/react/nativemodule/dom" React-Fabric: - :path: "../../../node_modules/react-native/ReactCommon" + :path: "../node_modules/react-native/ReactCommon" React-FabricComponents: - :path: "../../../node_modules/react-native/ReactCommon" + :path: "../node_modules/react-native/ReactCommon" React-FabricImage: - :path: "../../../node_modules/react-native/ReactCommon" + :path: "../node_modules/react-native/ReactCommon" React-featureflags: - :path: "../../../node_modules/react-native/ReactCommon/react/featureflags" + :path: "../node_modules/react-native/ReactCommon/react/featureflags" React-featureflagsnativemodule: - :path: "../../../node_modules/react-native/ReactCommon/react/nativemodule/featureflags" + :path: "../node_modules/react-native/ReactCommon/react/nativemodule/featureflags" React-graphics: - :path: "../../../node_modules/react-native/ReactCommon/react/renderer/graphics" + :path: "../node_modules/react-native/ReactCommon/react/renderer/graphics" React-hermes: - :path: "../../../node_modules/react-native/ReactCommon/hermes" + :path: "../node_modules/react-native/ReactCommon/hermes" React-idlecallbacksnativemodule: - :path: "../../../node_modules/react-native/ReactCommon/react/nativemodule/idlecallbacks" + :path: "../node_modules/react-native/ReactCommon/react/nativemodule/idlecallbacks" React-ImageManager: - :path: "../../../node_modules/react-native/ReactCommon/react/renderer/imagemanager/platform/ios" + :path: "../node_modules/react-native/ReactCommon/react/renderer/imagemanager/platform/ios" React-intersectionobservernativemodule: - :path: "../../../node_modules/react-native/ReactCommon/react/nativemodule/intersectionobserver" + :path: "../node_modules/react-native/ReactCommon/react/nativemodule/intersectionobserver" React-jserrorhandler: - :path: "../../../node_modules/react-native/ReactCommon/jserrorhandler" + :path: "../node_modules/react-native/ReactCommon/jserrorhandler" React-jsi: - :path: "../../../node_modules/react-native/ReactCommon/jsi" + :path: "../node_modules/react-native/ReactCommon/jsi" React-jsiexecutor: - :path: "../../../node_modules/react-native/ReactCommon/jsiexecutor" + :path: "../node_modules/react-native/ReactCommon/jsiexecutor" React-jsinspector: - :path: "../../../node_modules/react-native/ReactCommon/jsinspector-modern" + :path: "../node_modules/react-native/ReactCommon/jsinspector-modern" React-jsinspectorcdp: - :path: "../../../node_modules/react-native/ReactCommon/jsinspector-modern/cdp" + :path: "../node_modules/react-native/ReactCommon/jsinspector-modern/cdp" React-jsinspectornetwork: - :path: "../../../node_modules/react-native/ReactCommon/jsinspector-modern/network" + :path: "../node_modules/react-native/ReactCommon/jsinspector-modern/network" React-jsinspectortracing: - :path: "../../../node_modules/react-native/ReactCommon/jsinspector-modern/tracing" + :path: "../node_modules/react-native/ReactCommon/jsinspector-modern/tracing" React-jsitooling: - :path: "../../../node_modules/react-native/ReactCommon/jsitooling" + :path: "../node_modules/react-native/ReactCommon/jsitooling" React-jsitracing: - :path: "../../../node_modules/react-native/ReactCommon/hermes/executor/" + :path: "../node_modules/react-native/ReactCommon/hermes/executor/" React-logger: - :path: "../../../node_modules/react-native/ReactCommon/logger" + :path: "../node_modules/react-native/ReactCommon/logger" React-Mapbuffer: - :path: "../../../node_modules/react-native/ReactCommon" + :path: "../node_modules/react-native/ReactCommon" React-microtasksnativemodule: - :path: "../../../node_modules/react-native/ReactCommon/react/nativemodule/microtasks" + :path: "../node_modules/react-native/ReactCommon/react/nativemodule/microtasks" + react-native-safe-area-context: + :path: "../node_modules/react-native-safe-area-context" React-NativeModulesApple: - :path: "../../../node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios" + :path: "../node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios" React-networking: - :path: "../../../node_modules/react-native/ReactCommon/react/networking" + :path: "../node_modules/react-native/ReactCommon/react/networking" React-oscompat: - :path: "../../../node_modules/react-native/ReactCommon/oscompat" + :path: "../node_modules/react-native/ReactCommon/oscompat" React-perflogger: - :path: "../../../node_modules/react-native/ReactCommon/reactperflogger" + :path: "../node_modules/react-native/ReactCommon/reactperflogger" React-performancecdpmetrics: - :path: "../../../node_modules/react-native/ReactCommon/react/performance/cdpmetrics" + :path: "../node_modules/react-native/ReactCommon/react/performance/cdpmetrics" React-performancetimeline: - :path: "../../../node_modules/react-native/ReactCommon/react/performance/timeline" + :path: "../node_modules/react-native/ReactCommon/react/performance/timeline" React-RCTActionSheet: - :path: "../../../node_modules/react-native/Libraries/ActionSheetIOS" + :path: "../node_modules/react-native/Libraries/ActionSheetIOS" React-RCTAnimation: - :path: "../../../node_modules/react-native/Libraries/NativeAnimation" + :path: "../node_modules/react-native/Libraries/NativeAnimation" React-RCTAppDelegate: - :path: "../../../node_modules/react-native/Libraries/AppDelegate" + :path: "../node_modules/react-native/Libraries/AppDelegate" React-RCTBlob: - :path: "../../../node_modules/react-native/Libraries/Blob" + :path: "../node_modules/react-native/Libraries/Blob" React-RCTFabric: - :path: "../../../node_modules/react-native/React" + :path: "../node_modules/react-native/React" React-RCTFBReactNativeSpec: - :path: "../../../node_modules/react-native/React" + :path: "../node_modules/react-native/React" React-RCTImage: - :path: "../../../node_modules/react-native/Libraries/Image" + :path: "../node_modules/react-native/Libraries/Image" React-RCTLinking: - :path: "../../../node_modules/react-native/Libraries/LinkingIOS" + :path: "../node_modules/react-native/Libraries/LinkingIOS" React-RCTNetwork: - :path: "../../../node_modules/react-native/Libraries/Network" + :path: "../node_modules/react-native/Libraries/Network" React-RCTRuntime: - :path: "../../../node_modules/react-native/React/Runtime" + :path: "../node_modules/react-native/React/Runtime" React-RCTSettings: - :path: "../../../node_modules/react-native/Libraries/Settings" + :path: "../node_modules/react-native/Libraries/Settings" React-RCTText: - :path: "../../../node_modules/react-native/Libraries/Text" + :path: "../node_modules/react-native/Libraries/Text" React-RCTVibration: - :path: "../../../node_modules/react-native/Libraries/Vibration" + :path: "../node_modules/react-native/Libraries/Vibration" React-rendererconsistency: - :path: "../../../node_modules/react-native/ReactCommon/react/renderer/consistency" + :path: "../node_modules/react-native/ReactCommon/react/renderer/consistency" React-renderercss: - :path: "../../../node_modules/react-native/ReactCommon/react/renderer/css" + :path: "../node_modules/react-native/ReactCommon/react/renderer/css" React-rendererdebug: - :path: "../../../node_modules/react-native/ReactCommon/react/renderer/debug" + :path: "../node_modules/react-native/ReactCommon/react/renderer/debug" React-RuntimeApple: - :path: "../../../node_modules/react-native/ReactCommon/react/runtime/platform/ios" + :path: "../node_modules/react-native/ReactCommon/react/runtime/platform/ios" React-RuntimeCore: - :path: "../../../node_modules/react-native/ReactCommon/react/runtime" + :path: "../node_modules/react-native/ReactCommon/react/runtime" React-runtimeexecutor: - :path: "../../../node_modules/react-native/ReactCommon/runtimeexecutor" + :path: "../node_modules/react-native/ReactCommon/runtimeexecutor" React-RuntimeHermes: - :path: "../../../node_modules/react-native/ReactCommon/react/runtime" + :path: "../node_modules/react-native/ReactCommon/react/runtime" React-runtimescheduler: - :path: "../../../node_modules/react-native/ReactCommon/react/renderer/runtimescheduler" + :path: "../node_modules/react-native/ReactCommon/react/renderer/runtimescheduler" React-timing: - :path: "../../../node_modules/react-native/ReactCommon/react/timing" + :path: "../node_modules/react-native/ReactCommon/react/timing" React-utils: - :path: "../../../node_modules/react-native/ReactCommon/react/utils" + :path: "../node_modules/react-native/ReactCommon/react/utils" React-webperformancenativemodule: - :path: "../../../node_modules/react-native/ReactCommon/react/nativemodule/webperformance" + :path: "../node_modules/react-native/ReactCommon/react/nativemodule/webperformance" ReactAppDependencyProvider: :path: build/generated/ios/ReactAppDependencyProvider ReactCodegen: :path: build/generated/ios/ReactCodegen ReactCommon: - :path: "../../../node_modules/react-native/ReactCommon" + :path: "../node_modules/react-native/ReactCommon" ReactNativeDependencies: - :podspec: "../../../node_modules/react-native/third-party-podspecs/ReactNativeDependencies.podspec" + :podspec: "../node_modules/react-native/third-party-podspecs/ReactNativeDependencies.podspec" + RNScreens: + :path: "../node_modules/react-native-screens" VoiceAudioSession: :path: "../modules/voice-audio-session/ios" VoicePcmCapture: :path: "../modules/voice-pcm-capture/ios" Yoga: - :path: "../../../node_modules/react-native/ReactCommon/yoga" + :path: "../node_modules/react-native/ReactCommon/yoga" SPEC CHECKSUMS: EXConstants: dadbeba983acc30f855a919658a2b34fbd86615d - Expo: a74260e921728e06b81d3811a508acd5858812d6 + Expo: b0f96de93d2e9ab7c6eabcf88597b13f6fe3f67e ExpoAsset: 7721c5c4ae2a7c3c8147a046727ac6c020b05648 - ExpoAudio: a826903abdeba843cde8602bd1c077d808b31f1d + ExpoAudio: 7314dc660c5749134f75767b371da210e4a2692f ExpoDomWebView: b2e9d601f9cb77d3c97da73234e175b64be91928 - ExpoFileSystem: 61e663d0bbd1a973d324530d1d6ee559a5e5192c + ExpoFileSystem: 49ab894dee0818cb8b83d489fc51eafade73efb9 ExpoFont: a7e34a75bdfb703fa07a0e87807f836236608c76 + ExpoGlassEffect: 2dedac2fe817f9b02d0dad29ce0aef7afebc5cd4 + ExpoImage: 5854bd51edd7c0a775755473598f9daeb3fde4e3 ExpoKeepAwake: d0eb7a0719500d2a43fbf29b6f79e84d70c75ebf + ExpoLinking: 221ab136d976b632e04ff4023660cc9c24233d07 ExpoLogBox: a678d36477ab9544fe63e0b5328a0716539b6774 ExpoModulesCore: 998d0fbf7f687b1ff715b87cec779fc92be628ac ExpoModulesJSI: 964334271fc77832736f66ee1785761cdc778dbe - ExpoSecureStore: 0e7959ad3a9fa59306d55f26a37854640dd334c2 + ExpoRouter: e0e2aa4b03843e7abfa3e63e0cdd6c642d74780f + ExpoSecureStore: f495a3e0ab65683be8427a96f694726a88048f46 ExpoSpeechRecognition: 255445556dbf20df46cc7585d0f5827e4825df8a - FBLazyVector: 7c1d69992204c5ec452eeefa4a24b0ff311709c8 - hermes-engine: 930b26374dff7c5a8f59510fc0f77195222f5a1e - RCTDeprecation: dbbb85eae640e3b43cc16462d0476b00ca5959ae - RCTRequired: 7047b18af29a76069818fd3fa0f4df423483abab - RCTSwiftUI: 5928f7ca7e9e2f1a82d85d4c79ea3065137ad81c - RCTSwiftUIWrapper: 8ff2f9da84b47db66d11ece1589d8e5515c0ab8b - RCTTypeSafety: 0416f31c41f9a792a9287543c6c30bd605c2eed0 - React: f6f8fc5c01e77349cdfaf49102bcb928ac31d8ed - React-callinvoker: 3e62a849bda1522c6422309c02f5dc3210bc5359 - React-Core: 107092273c9178bb015aee2af302ed1340e59da3 - React-Core-prebuilt: 86f55da5a94bc67a7da313a510074988ac4603a5 - React-CoreModules: 0b3b55b0b3954812ee0d67d85960bd7b42268336 - React-cxxreact: a76c6a3b36b20e777542946c20bebf67acbb631c - React-debug: f9dda2791d3ebe2078bc6102641edab77917efb7 - React-defaultsnativemodule: 23c3714471bebf777a6adfeeb0862010d6c3dd41 - React-domnativemodule: ba499975e32cec21aa364cbd9589d4cf5ffdcc02 - React-Fabric: 805d412fd56fd688491b58780fb4434a8f7d51fa - React-FabricComponents: 0b5df5e7de2b944f888f2ada793e47c940a7082c - React-FabricImage: 9382e3b2be7d0445ea38bd72b0cffedd701973ac - React-featureflags: f5fc732f8511f90f531e59a1177356f1e9bdde67 - React-featureflagsnativemodule: d1c9fba207214180ef6e9fa26b92d7b71909dbf0 - React-graphics: 69211395982fa1a3fc0b22be8abc032e53f58bd5 - React-hermes: ed6172652de7168bcc1a3ad1b7ee73694910ef83 - React-idlecallbacksnativemodule: 2ccc9360969e239cc150dd69cd09537c388bc36d - React-ImageManager: 705e657487ceeb63a184a65f20422c5f597133c5 - React-intersectionobservernativemodule: 3807d7631efd891e5ac1b1d7c0265622d8735348 - React-jserrorhandler: a3d99c0c15376e6de71e33ccab0cb1bd8b99eb22 - React-jsi: 78c5e479d87a3c037865e8977b8c5d99a248f245 - React-jsiexecutor: a054806b8ea1ad1dc6ad65234b60c9d790c7773c - React-jsinspector: a796aadf3fa7efddf65141bae9955d1b3b5d38ad - React-jsinspectorcdp: 2add8d4116b31f3c844f9f882698e79a9308c4f4 - React-jsinspectornetwork: 99643cec64fcb448ade11450e540798d625dea87 - React-jsinspectortracing: 007b7cd9d8fc46c4a1601346e6002cbaf99aabd9 - React-jsitooling: d6c400dfc2155de637718b9eee44c7776473b06e - React-jsitracing: 3f211ad2131ae6bf599b8de219f97e6fa971d27f - React-logger: d6b4e3af2a51d5c1c8ebb7914637b86bbec93564 - React-Mapbuffer: 019786f44ee5f1556493e01a72eca223a2d46356 - React-microtasksnativemodule: 5855ca396a2748042ea1b856e628d9c6a740f6c6 - React-NativeModulesApple: de8b90a262c3e13e950b645186a47c3ff667052d - React-networking: 70fca7678ef2673d9ff1f8cc0e1f809e1be954ce - React-oscompat: f3eb52b3be9b08bf8ca8ba0c616fd9905f4c7391 - React-perflogger: 21748e5dc4ba3d3d5157db1ec1c026b3ca2671f6 - React-performancecdpmetrics: add513368e6f997ba0d78820d7a848000c68fc69 - React-performancetimeline: e52281d68535985313f9d5a2b3830756cc4ebc4b - React-RCTActionSheet: a8bff6e75c7a18f653297fb14571043ae79431f6 - React-RCTAnimation: 5a25c557763c321028856b0b0d35e12280759783 - React-RCTAppDelegate: 5a3cdb389f8f1e4c2bb88459b6027c087964301d - React-RCTBlob: f0f6305c164356f61ce444af0d12857b855e2645 - React-RCTFabric: ef9c6a3ca6edf8f8e829a27f4b063fa71fe5759b - React-RCTFBReactNativeSpec: dd05f61b5283ad14c1dc10fe466a36530dcbcab1 - React-RCTImage: ec2705a7eb15cf09d8d4978956159d95f6a8abf5 - React-RCTLinking: dd9aef1181fbdc286c50bb96c61f52e725d267d3 - React-RCTNetwork: 654f40ca7ea62a00592eda5c94155bb322cb22e1 - React-RCTRuntime: 270c5022ae7820302fd8bbac529780be1c99ce71 - React-RCTSettings: f3a808b3d707abef5e7f7308e93405068b6535ca - React-RCTText: e0e652329e2bede240664c1e75476037cb778b85 - React-RCTVibration: 65e1183edfd95293f769bf8486bbd7b0e82e5b4b - React-rendererconsistency: ff227146777b3733c2f4c601ec68267955447e27 - React-renderercss: 28bc4de38c9f9d3f70113c6dcb4f859dab726c3d - React-rendererdebug: ca2d43838d5210c9625f3a8fe4caef34e1240375 - React-RuntimeApple: c79c05a004c1cb95476ad0ef01d4fddb1b083122 - React-RuntimeCore: 7eb439050ec80ac61fdcf193edce77ab75e5399c - React-runtimeexecutor: f801ae52e36376b637dad78aaea5e2564a3c655e - React-RuntimeHermes: 4bb4a0a392e16351fe44257460f8aa693ba5f057 - React-runtimescheduler: 4a94d1c2fc29384beac270ba331a8b2c95fcf058 - React-timing: 9ad4ede53c5d42fc6cd2334ed76b73d374619f8f - React-utils: 8d6d63b3ab6c08fd730d737798ce10da4be604eb - React-webperformancenativemodule: 02c3929320f3f839467a55b1d2e202206c8e3196 - ReactAppDependencyProvider: ebcf3a78dc1bcdf054c9e8d309244bade6b31568 - ReactCodegen: 4245ecd1c87997f9e85d19ddf44e69cbd0dae3b3 - ReactCommon: e22f6e537e566de4c61121da2139a1980caff948 - ReactNativeDependencies: d4bf16fb4f11558ffe141c42146791892f58666f + ExpoSymbols: 896159288ad708e1a32dc12c92fe5f90bff01126 + FBLazyVector: 427fa1856a481856091061293ce865a75aa4f20f + hermes-engine: c8b880604e39149d11bb3dde57bdfdcd3fbab6c6 + libavif: 5f8e715bea24debec477006f21ef9e95432e254d + libdav1d: 23581a4d8ec811ff171ed5e2e05cd27bad64c39f + libwebp: 02b23773aedb6ff1fd38cec7a77b81414c6842a8 + RCTDeprecation: 9502b5eb8e3c9eb75ff5a333b2c591bab9d91e28 + RCTRequired: 0268a5d86aef1defea2e04289d74fdf3570197c1 + RCTSwiftUI: cff211c5e4d94e491dda240d663b8effd25296d7 + RCTSwiftUIWrapper: b731e4d3257895dfcc4bbe2dc3ae24249cd55f6e + RCTTypeSafety: c6ed564d0d54c9a01be7db724fbc753eb77b3dc1 + React: 9b033c7a99e1c8eec40960b6b2e6844e011e8bd1 + React-callinvoker: f64c8d57f45173835b6b9891cda27f1625551972 + React-Core: 341863a2597c4a2320f916b4232d280cb8a28e4b + React-Core-prebuilt: e4498053e2740e98c1febfddaa5d8b9c1746d68f + React-CoreModules: 9370769c9b2b1732c58960687f4d50d9d3510efc + React-cxxreact: 41ae74bd9eab80af9c5da6a9b4934eb97e2c7569 + React-debug: eb0f19ff879ad3870dc760b6c21ad56cbb3e4b98 + React-defaultsnativemodule: aa25696c7d18115bae062629c2183c267d96e017 + React-domnativemodule: 016da08b63d25b2131eda8a3bb4836929a062dd7 + React-Fabric: 179061816251452fa8ec7dfe7a16df1d06e12a59 + React-FabricComponents: 3ea07086a38f4ff29aac97d2a99815e33ac71ffa + React-FabricImage: a55a5318d125f25ac9828e0465275ad081303720 + React-featureflags: b0327e47f6e97ae98bd844ba6ebf97d62f07f5ff + React-featureflagsnativemodule: d94042d8fa774a4e162627d77eadd79861771865 + React-graphics: 7eed2e1c4736871456c2b05926bc334e533f89eb + React-hermes: feafb2b23ca8a4c5a1892681ca12970c0861b2f5 + React-idlecallbacksnativemodule: 05aeb9baf9b55d3a61dd0650f17ac5a609107578 + React-ImageManager: 8dfcfe118282f2a0b3d279350d0f97a63e6b4034 + React-intersectionobservernativemodule: 2fdf4760cf45f6303427d54a006adb0983c6719d + React-jserrorhandler: 375f46da481dea08283cc395627545e113217efa + React-jsi: 98e544352782dad976e1bb9e428e572f5e618978 + React-jsiexecutor: cd242143c5973359a28edd6accfdfe8b281fd07d + React-jsinspector: bada2d53d66ca027cedab80327262df3e4686ef4 + React-jsinspectorcdp: 1f345e95c747c4abc0786193294b5d4ec8deae68 + React-jsinspectornetwork: f1191804c726ca36fd1d32a6b4806a6e6994183a + React-jsinspectortracing: 298ed7d8fb5fa6f0d78c5d13d0566db71847a5fd + React-jsitooling: d108a612810309154369fc74b89caa8793506485 + React-jsitracing: 80caa0cbcff315e795d7f6cc013530367871a649 + React-logger: 702a49dadb10a6bd63d56b297c3350b5ea552141 + React-Mapbuffer: bcd9f00a498b2981c7b390f00fd2b7cd6e0ead29 + React-microtasksnativemodule: 203b41906d627dac7d697bccba6137b630951fae + react-native-safe-area-context: 37e680fc4cace3c0030ee46e8987d24f5d3bdab2 + React-NativeModulesApple: 9068613f7650828eed29ead6fc0d0a1620117e6a + React-networking: 233dd85e1b890ca191f64f0896153165571b3039 + React-oscompat: 05afae2c7689405dbb4f6458110d501d55357980 + React-perflogger: e3b6c4e302c1db7ef30ea2484d22350cb91ed209 + React-performancecdpmetrics: 78d0eea925c56b851ffc62377ed2c55be985a89b + React-performancetimeline: 0dd54019ec9fe7de84bfb1fc8a9ede032af8a7e9 + React-RCTActionSheet: 6905f5568a98e1466071904ecce56f412477276b + React-RCTAnimation: 2296d52d6ea82e69c5019b8def1e7fd6c2adbeed + React-RCTAppDelegate: 1fe1d13018c2f556b583819219f7de73d5b70a68 + React-RCTBlob: fa1130cb4e2f880ddc6cd910543d1e634330ee72 + React-RCTFabric: 12abb4c93fac4af6a9de3fc6676a6263720b7f9e + React-RCTFBReactNativeSpec: 6e025ccbdb0d36a7ee57d79863da9a5b0d4a1c6d + React-RCTImage: cdae5d3d50e30ff8eb587134a87f742d37bc795a + React-RCTLinking: eeb33d8033a41c0ebab879110063a25f1a282f45 + React-RCTNetwork: 066703b5cc088bd682f748a0d6b0fe7fbabc898f + React-RCTRuntime: ec68db9d8ab02e031053be5a6624be8018f79876 + React-RCTSettings: 5fc7087bc8fd37c43f727ad7840d9014fb14625b + React-RCTText: 3e79e3df3a37e7ecf75b0a19815b8031531bfb06 + React-RCTVibration: bfbc37851c3fb413ca206d768c1745fb9fce7cc4 + React-rendererconsistency: ecc195511eaba84d5c9f3a9ef92f1c3942b2c825 + React-renderercss: c72c11962925fbcad46ee6dc30ea04667f4ed089 + React-rendererdebug: ea9f3d3c2bc5bdd5ee2d8e3ebc2abc72c27698ab + React-RuntimeApple: 1413c7dad511cea861046a1e5150bf0ae9f0c5c7 + React-RuntimeCore: 337347732af37d78e93bbcc619ce973224a98eb9 + React-runtimeexecutor: 096e0b91e643b6ed51c206dcfa4b9657eeadf7dc + React-RuntimeHermes: 44accf583d9d3aba9be9b9dbeef3e80504e215a3 + React-runtimescheduler: c4b30a7b110b377f3b59b78ef1b78dba997614c6 + React-timing: 84de84d0ad5b053a07d58be44a6f4913882759c2 + React-utils: f1ec2d55598341eae23adcc8d946e9de5d6bd789 + React-webperformancenativemodule: a9bba1ed256de5fb6607a038f37e2466e0f01fbf + ReactAppDependencyProvider: 3ec8fc5d32ad422f5b805523a5a5ea5556d8b3a2 + ReactCodegen: 169fa6c4ad3c3065f98fa2364bf95e9a21c3afec + ReactCommon: ba28cd4e7d9e24014d2772a80e32da020c732b00 + ReactNativeDependencies: f6b3d08460bcb59c1c86d91eabd9d6b00f6a89c2 + RNScreens: 14243fa0d9842ffa7f8bb2d00b6c3cfd3ca817e8 + SDWebImage: e9fc87c1aab89a8ab1bbd74eba378c6f53be8abf + SDWebImageAVIFCoder: afe194a084e851f70228e4be35ef651df0fc5c57 + SDWebImageSVGCoder: 15a300a97ec1c8ac958f009c02220ac0402e936c + SDWebImageWebPCoder: e38c0a70396191361d60c092933e22c20d5b1380 VoiceAudioSession: 480d1183a4f27f9f0253116e067343f1a5067af0 VoicePcmCapture: 341b0ed696cea24b337066c8ce56738c9a89de61 - Yoga: 90687fcd1ebd927341caed917696d692813c0b24 + Yoga: e41d64347f52d634dd8c2f6bac33f95081959fd3 PODFILE CHECKSUM: 09905f1f2c1d6ab15e8eda814d07fdc5f2141069 diff --git a/apps/mobile/modules/voice-pcm-capture/ios/VoicePcmCaptureModule.swift b/apps/mobile/modules/voice-pcm-capture/ios/VoicePcmCaptureModule.swift index 5974f11..37653c0 100644 --- a/apps/mobile/modules/voice-pcm-capture/ios/VoicePcmCaptureModule.swift +++ b/apps/mobile/modules/voice-pcm-capture/ios/VoicePcmCaptureModule.swift @@ -41,6 +41,13 @@ public class VoicePcmCaptureModule: Module { return currentStatus() } + // Defensive: clean up any stale engine reference from a previous incomplete stop + if engine != nil { + engine?.inputNode.removeTap(onBus: 0) + engine?.stop() + engine = nil + } + sampleRate = options["sampleRate"] as? Double ?? 16000.0 frameDurationMs = options["frameDurationMs"] as? Int ?? 40 frameSizeBytes = Int(sampleRate * Double(frameDurationMs) / 1000.0) * 2 @@ -107,6 +114,13 @@ public class VoicePcmCaptureModule: Module { pendingPcm.removeAll(keepingCapacity: false) removeAudioSessionObservers() + // Deactivate audio session so other audio sources can claim hardware + do { + try AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation) + } catch { + // Best effort — audio session may already be deactivated + } + let status = currentStatus(reason: reason) sendState("stopped", message: reason) return status diff --git a/apps/mobile/package.json b/apps/mobile/package.json index dd98185..9dd0131 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -1,6 +1,6 @@ { "name": "@meteorvoice/mobile", - "version": "1.3.0", + "version": "1.4.0", "private": true, "main": "index.ts", "scripts": { @@ -22,13 +22,19 @@ "@meteorvoice/session-core": "file:../../packages/session-core", "@meteorvoice/shared": "file:../../packages/shared", "@supabase/supabase-js": "^2.106.1", - "expo": "~55.0.0", - "expo-audio": "~55.0.14", + "expo": "~55.0.27", + "expo-audio": "~55.0.15", "expo-constants": "~55.0.16", + "expo-file-system": "~55.0.23", + "expo-linking": "~55.0.16", "expo-modules-core": "~55.0.25", - "expo-secure-store": "~55.0.14", + "expo-router": "~55.0.16", + "expo-secure-store": "~55.0.15", "expo-speech-recognition": "^56.0.0", + "expo-status-bar": "~55.0.6", "react": "19.2.0", - "react-native": "0.83.0" + "react-native": "0.83.6", + "react-native-safe-area-context": "~5.6.2", + "react-native-screens": "~4.23.0" } } diff --git a/apps/mobile/src/App.tsx b/apps/mobile/src/App.tsx index 73728c9..838991a 100644 --- a/apps/mobile/src/App.tsx +++ b/apps/mobile/src/App.tsx @@ -1,453 +1,252 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +/** + * App entry point — ThemeProvider, LogProvider, SessionContext orchestration. + * 应用入口 — 主题、日志、会话编排。 + */ + +import type { AppStateStatus } from 'react-native' +import * as SecureStore from 'expo-secure-store' +import { + File, + Paths, +} from 'expo-file-system' +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react' import { + Alert, AppState, - Pressable, - SafeAreaView, - Share, - StyleSheet, - Text, - View, - type AppStateStatus, } from 'react-native' + +import type { + PlaybackQueueSnapshot, + WorkflowSnapshot, +} from '@meteorvoice/session-core' +import type { + AppFeedbackState, + ConversationMessage, + ConversationResponse, + Locale, + TranslateFn, +} from '@meteorvoice/shared' +import type { SyncSessionRequest } from '@meteorvoice/api-client' +import type { PreferencesResponse } from '@meteorvoice/api-client' import { createMeteorVoiceApiClient, fetchWithTimeout, formatApiRequestError, - type CreateASRSessionResponse, - type HistorySession, - type PreferencesResponse, - type SessionTurnDto, } from '@meteorvoice/api-client' +import { + accentProfiles, + appFeedback, + displayErrorFeedback, + getAccentLabel, + getAccentRegion, + getDifficultyLabel, + getScenarioDescription, + getScenarioLabel, + getTTSSpeedRouting, + scenarios, + translate, +} from '@meteorvoice/shared' import { acceptTranscriptTurn, - advancePlaybackQueue, canAcceptUserTranscript, canEndSession, - createPlaybackQueueSnapshot, + completeCoachPlayback, createInitialSnapshot, + createPlaybackQueueSnapshot, DEFAULT_PLAYBACK_COOLDOWN_MS, endActiveSession, gateUserTranscript, - getPlaybackCompletionEffects, judgeEndpoint, receiveCoachReply, recoverSessionError, requestCoachReply, - completeCoachPlayback, shouldIgnoreLikelyPlaybackEcho, startListeningSession, startPlaybackQueue, - type PlaybackQueueSnapshot, - type WorkflowSnapshot, } from '@meteorvoice/session-core' -import { - accentProfiles, - getAccentLabel, - getAccentRegion, - getDifficultyLabel, - getScenarioDescription, - getScenarioLabel, - getTTSSpeedRouting, - runAppOperationGroup, - scenarios, - t, - appFeedback, - displayErrorFeedback, - type AppFeedbackState, - type ConversationMessage, - type ConversationResponse, - type Locale, - type VoiceProfile, -} from '@meteorvoice/shared' -import * as SecureStore from 'expo-secure-store' +import { + SessionContext, + type SessionContextValue, +} from './SessionContext' +import type { + SessionRoutePresence, + SessionSttProvider, + Tab, +} from './sessionRuntime' +import { AppShell } from './AppShell' +import { useXunfeiStt } from './hooks/useXunfeiStt' import { useMobileAuth } from './mobileAuth' import { useNativeSessionAudio } from './nativeAudio' import { useNativeSpeech } from './nativeSpeech' -import { syncMobilePreferences, type XunfeiVoice } from './mobilePreferences' -import { getDefaultApiBaseUrl, getDisplayAppVersion } from './mobileConfig' +import { useHandlerBridge } from './utils/handlerBridge' +import { + LogProvider, + useLog, +} from './LogContext' import { - addPcmFrameListener, - addPcmStateListener, - isPcmCaptureAvailable, - startPcmCapture, - stopPcmCapture, - type PcmCaptureFrameEvent, -} from './voicePcmCapture' -import { ThemeProvider, useTheme } from './ThemeProvider' -import { SessionScreen } from './screens/SessionScreen' -import { HomeScreen } from './screens/HomeScreen' -import { HistoryScreen } from './screens/HistoryScreen' -import { SettingsScreen } from './screens/SettingsScreen' + getDefaultApiBaseUrl, + getDisplayAppVersion, +} from './mobileConfig' +import { ThemeProvider } from './ThemeProvider' +import { + canApplyEndpointResult, + classifyRequestTerminalStage, + isTurnStale, +} from './sessionTurnRuntime' import { AppFeedbackOverlay } from './components/AppFeedbackOverlay' +import { + enqueueSessionSync, + flushSessionSyncOutbox, +} from './sessionSyncOutbox' +import { + canStartListening, + enqueueRuntimeOperation, + getPlaybackTailPrewarmDecision, + routePresenceForTab, + shouldResumeListening, + STT_MAX_CONSECUTIVE_RESTARTS, + withTimeout, +} from './sessionRuntime' const defaultApiBaseUrl = getDefaultApiBaseUrl() const appVersion = getDisplayAppVersion() -const apiBaseUrlStorageKey = 'api_base_url' const sessionSttProviderStorageKey = 'session_stt_provider' -type Tab = 'session' | 'home' | 'history' | 'settings' -type ApiBaseUrlSource = 'default' | 'user' -type SessionSttProvider = 'native' | 'xunfei' -type VoiceMetricEntry = { - ts: number - stage: string - data: Record -} - -type ASREvaluationRun = { - startedAt?: number - firstPartialMs?: number | null - finalMs?: number | null - chars?: number - source?: string - frameCount?: number - totalBytes?: number - error?: string -} - -const TAB_LABELS: Record = { - home: 'nav.home', - session: 'nav.practice', - history: 'nav.history', - settings: 'nav.settings', -} - - -function TabIcon({ tab, color }: { tab: Tab; color: string }) { - if (tab === 'home') return ( - - - - - ) - if (tab === 'session') return ( - - - - - ) - if (tab === 'history') return ( - - {[0, 1, 2].map(i => )} - - ) - return ( - - - - {[0, 45, 90, 135].map(deg => ( - - ))} - - - ) -} - -function createASREvaluationReport(entries: VoiceMetricEntry[]) { - const nativeRuns: ASREvaluationRun[] = [] - const remoteRuns: ASREvaluationRun[] = [] - let currentNative: ASREvaluationRun | null = null - let currentRemote: ASREvaluationRun | null = null - - for (const entry of entries) { - if (entry.stage === 'stt_start') { - currentNative = { startedAt: entry.ts } - nativeRuns.push(currentNative) - } else if (entry.stage === 'stt_first_partial' && currentNative) { - currentNative.firstPartialMs = readMetricNumber(entry.data.elapsedMs) - currentNative.chars = readMetricNumber(entry.data.chars) ?? currentNative.chars - } else if (entry.stage === 'stt_submit' && currentNative) { - currentNative.finalMs = readMetricNumber(entry.data.elapsedMs) - currentNative.chars = readMetricNumber(entry.data.chars) ?? currentNative.chars - currentNative.source = typeof entry.data.source === 'string' ? entry.data.source : undefined - } else if (entry.stage === 'stt_end' && currentNative && currentNative.finalMs == null) { - currentNative.finalMs = readMetricNumber(entry.data.elapsedMs) - } - - if (entry.stage === 'asr_stream_start') { - currentRemote = { startedAt: entry.ts } - remoteRuns.push(currentRemote) - } else if (entry.stage === 'asr_first_partial' && currentRemote) { - currentRemote.firstPartialMs = readMetricNumber(entry.data.elapsedMs) - currentRemote.chars = readMetricNumber(entry.data.chars) ?? currentRemote.chars - } else if (entry.stage === 'asr_stream_done' && currentRemote) { - currentRemote.finalMs = readMetricNumber(entry.data.streamElapsedMs) ?? readMetricNumber(entry.data.elapsedMs) - currentRemote.chars = readMetricNumber(entry.data.transcriptChars) ?? currentRemote.chars - currentRemote.frameCount = readMetricNumber(entry.data.frameCount) ?? undefined - currentRemote.totalBytes = readMetricNumber(entry.data.totalBytes) ?? undefined - } else if (entry.stage === 'asr_stream_provider_error' && currentRemote) { - currentRemote.error = typeof entry.data.message === 'string' ? entry.data.message : 'Provider error' - } else if (entry.stage === 'asr_diagnostic_error' && currentRemote) { - currentRemote.error = typeof entry.data.message === 'string' ? entry.data.message : 'Diagnostic error' - } - } - - const latestNative = nativeRuns.at(-1) - const latestRemote = remoteRuns.at(-1) - return [ - 'ASR P4 evaluation report', - `Generated: ${new Date().toISOString()}`, - '', - `Native runs: ${nativeRuns.length}`, - formatASRRun('Latest native', latestNative), - '', - `Remote Xunfei runs: ${remoteRuns.length}`, - formatASRRun('Latest remote', latestRemote), - '', - 'Acceptance checks:', - '- Compare first partial latency between native and remote.', - '- Compare final latency between native and remote.', - '- Compare transcript chars and exported raw metrics against the spoken script.', - '- Do not switch production STT until remote accuracy and latency are better on device.', - ].join('\n') -} - -function formatASRRun(label: string, run: ASREvaluationRun | undefined) { - if (!run) return `${label}: no run captured` - return [ - `${label}:`, - ` startedAt: ${run.startedAt ? new Date(run.startedAt).toLocaleString() : 'unknown'}`, - ` firstPartialMs: ${formatMetricValue(run.firstPartialMs)}`, - ` finalMs: ${formatMetricValue(run.finalMs)}`, - ` chars: ${formatMetricValue(run.chars)}`, - run.source ? ` source: ${run.source}` : null, - run.frameCount != null ? ` frameCount: ${run.frameCount}` : null, - run.totalBytes != null ? ` totalBytes: ${run.totalBytes}` : null, - run.error ? ` error: ${run.error}` : null, - ].filter(Boolean).join('\n') -} - -function readMetricNumber(value: unknown) { - return typeof value === 'number' && Number.isFinite(value) ? value : null +const sessionSyncOutboxStorage = { + getItem: async (key: string) => { + const file = sessionSyncOutboxFile(key) + return file.exists ? file.text() : null + }, + setItem: async (key: string, value: string) => { + const file = sessionSyncOutboxFile(key) + if (!file.exists) file.create({ intermediates: true }) + file.write(value) + }, + removeItem: async (key: string) => { + const file = sessionSyncOutboxFile(key) + if (file.exists) file.delete() + }, } -function formatMetricValue(value: number | null | undefined) { - return value == null ? 'n/a' : String(value) +function sessionSyncOutboxFile(key: string) { + return new File(Paths.document, `${key.replace(/[^a-z0-9._-]/gi, '_')}.json`) } -function withTimeout(promise: Promise, timeoutMs: number, message: string) { - return new Promise((resolve, reject) => { - const timer = setTimeout(() => reject(new Error(message)), timeoutMs) - promise - .then(resolve, reject) - .finally(() => clearTimeout(timer)) - }) -} - -function createXunfeiASRFrame(session: CreateASRSessionResponse, status: 0 | 1 | 2, audioBase64: string, sequence: number) { - const providerConfig = session.providerConfig - const header = { - app_id: providerConfig?.appId, - status, - } - const audio = { - encoding: providerConfig?.audioEncoding ?? 'raw', - sample_rate: providerConfig?.sampleRate ?? 16000, - channels: providerConfig?.channels ?? 1, - bit_depth: providerConfig?.bitDepth ?? 16, - seq: sequence, - status, - audio: audioBase64, - } - if (status !== 0) { - return { - header, - payload: { audio }, - } - } - - return { - header, - parameter: { - iat: { - domain: providerConfig?.domain ?? 'slm', - language: providerConfig?.language ?? 'zh_cn', - accent: providerConfig?.accent ?? 'mandarin', - eos: providerConfig?.eosMs ?? 900, - dwa: 'wpgs', - result: { - encoding: 'utf8', - compress: 'raw', - format: 'json', - }, - }, - }, - payload: { audio }, - } -} - -function parseJsonObject(value: unknown): Record | null { - if (typeof value !== 'string') return null - try { - const parsed = JSON.parse(value) - return parsed && typeof parsed === 'object' ? parsed : null - } catch { - return null - } -} - -function getObject(value: unknown): Record | null { - return value && typeof value === 'object' && !Array.isArray(value) ? value as Record : null -} +// ─── Main Entry / 入口 ─── -function extractXunfeiRecognitionResult(payload: Record | null) { - const payloadObject = getObject(payload?.payload) - const payloadResult = getObject(payloadObject?.result) - const encodedText = typeof payloadResult?.text === 'string' ? payloadResult.text : null - if (encodedText) { - const decoded = decodeBase64Utf8(encodedText) - const decodedPayload = parseJsonObject(decoded) - const decodedWords = extractXunfeiWords(decodedPayload?.ws) - if (decodedWords) { - const rg = Array.isArray(decodedPayload?.rg) && - typeof decodedPayload.rg[0] === 'number' && - typeof decodedPayload.rg[1] === 'number' - ? [decodedPayload.rg[0], decodedPayload.rg[1]] as [number, number] - : null - return { - text: decodedWords, - sn: typeof decodedPayload?.sn === 'number' ? decodedPayload.sn : null, - pgs: typeof decodedPayload?.pgs === 'string' ? decodedPayload.pgs : null, - rg, - } - } - } - - const data = getObject(payload?.data) - const result = getObject(data?.result) - const fallbackWords = extractXunfeiWords(result?.ws) - return fallbackWords - ? { text: fallbackWords, sn: null, pgs: null, rg: null } - : null -} - -function extractXunfeiWords(words: unknown) { - if (!Array.isArray(words)) return '' - return words.map(item => { - const word = getObject(item) - const candidates = word?.cw - if (!Array.isArray(candidates)) return '' - return candidates.map(candidate => { - const candidateObject = getObject(candidate) - return typeof candidateObject?.w === 'string' ? candidateObject.w : '' - }).join('') - }).join('') -} - -function decodeBase64Utf8(value: string) { - try { - const decoder = globalThis.atob - if (!decoder) return '' - const binary = decoder(value) - const escaped = Array.from(binary) - .map(char => `%${char.charCodeAt(0).toString(16).padStart(2, '0')}`) - .join('') - return decodeURIComponent(escaped) - } catch { - return '' - } -} - -export default function App() { +export default function App({ children }: { children?: React.ReactNode }) { return ( - + + {children} + ) } -function AppInner() { - const { C, setTheme: setThemeLocal } = useTheme() +// ─── AppInner / 编排层 ─── + +function AppInner({ children }: { children?: React.ReactNode }) { + /* eslint-disable react-hooks/exhaustive-deps */ + const { logMetric, logUserAction, setEnrichment } = useLog() + + // ─── Navigation / 导航 ─── const [activeTab, setActiveTab] = useState('session') - const [apiBaseUrl, setApiBaseUrl] = useState(defaultApiBaseUrl) - const [apiBaseUrlSource, setApiBaseUrlSource] = useState('default') + + // ─── Session State / 会话状态 ─── const [messages, setMessages] = useState([]) const [correctionHistory, setCorrectionHistory] = useState([]) const [audioUrl, setAudioUrl] = useState(null) const [playbackQueue, setPlaybackQueue] = useState(() => createPlaybackQueueSnapshot()) - const [status, setStatus] = useState('session.ready') + const [status, setStatusState] = useState('session.ready') + const [isSessionActive, setIsSessionActive] = useState(false) + const [snapshot, setSnapshot] = useState(() => createInitialSnapshot('mobile-session')) + const [summary, setSummary] = useState(null) + const [busy, setBusyState] = useState(false) + + // ─── Scenario & Accent / 场景与口音 ─── + const [selectedScenarioKey, setSelectedScenarioKey] = useState('small-talk') + const [selectedAccentKey, setSelectedAccentKey] = useState('american') + const [scenarioSwitching, setScenarioSwitching] = useState(false) + const [apiSessionId] = useState(null) + + // ─── Language / 语言 ─── const [locale, setLocaleState] = useState('en') useEffect(() => { SecureStore.getItemAsync('app_locale').then(v => { if (v === 'zh' || v === 'en') setLocaleState(v) }) }, []) - useEffect(() => { - SecureStore.getItemAsync(apiBaseUrlStorageKey).then(value => { - const stored = value?.trim() - if (stored) { - setApiBaseUrl(stored) - setApiBaseUrlSource('user') - } else { - setApiBaseUrl(defaultApiBaseUrl) - setApiBaseUrlSource('default') - } - }) - }, []) const setLocale = useCallback((l: Locale) => { setLocaleState(l) void SecureStore.setItemAsync('app_locale', l) }, []) - const [summary, setSummary] = useState(null) - const [voiceMetrics, setVoiceMetrics] = useState([]) - const [busy, setBusy] = useState(false) - const [historyLoading, setHistoryLoading] = useState(false) - const [historyError, setHistoryError] = useState(null) - const [historySessions, setHistorySessions] = useState([]) - const [selectedHistory, setSelectedHistory] = useState(null) - const [selectedHistoryTurns, setSelectedHistoryTurns] = useState([]) - const [settingsLoading, setSettingsLoading] = useState(false) - const [settingsMessage, setSettingsMessage] = useState(null) - const [authSubmitting, setAuthSubmitting] = useState(false) - const [scenarioSwitching, setScenarioSwitching] = useState(false) - const [activeFeedback, setActiveFeedback] = useState(() => appFeedback.getFeedback()) + + // ─── Provider State / 提供者状态 ─── const [ttsProvider, setTtsProvider] = useState('mock') - const [availableProviders, setAvailableProviders] = useState(['mock']) - const [sessionSttProvider, setSessionSttProviderState] = useState('native') - const [availableSessionSttProviders, setAvailableSessionSttProviders] = useState(['native']) const [ttsVoiceId, setTtsVoiceId] = useState(null) - const [voiceProfiles, setVoiceProfiles] = useState([]) - const [selectedVoiceProfileId, setSelectedVoiceProfileId] = useState(null) - const [xunfeiVoices, setXunfeiVoices] = useState([]) const [ttsSpeed, setTtsSpeed] = useState(1) - const [isSessionActive, setIsSessionActive] = useState(false) - const [snapshot, setSnapshot] = useState(() => createInitialSnapshot('mobile-session')) - const [authMode, setAuthMode] = useState<'sign-in' | 'sign-up'>('sign-in') - const [email, setEmail] = useState('') - const [password, setPassword] = useState('') - const [apiSessionId] = useState(null) - const [selectedScenarioKey, setSelectedScenarioKey] = useState('small-talk') - const [selectedAccentKey, setSelectedAccentKey] = useState('american') + const [sessionSttProvider, setSessionSttProviderState] = useState('native') + const [activeFeedback, setActiveFeedback] = useState(() => appFeedback.getFeedback()) + + // ─── TTS & Audio / 语音合成与音频 ─── const ttsSpeedRouting = getTTSSpeedRouting(ttsProvider, ttsSpeed) const audio = useNativeSessionAudio(audioUrl, ttsSpeedRouting.playbackRate) const auth = useMobileAuth() const getAuthHeaders = auth.getAuthHeaders const signOut = auth.signOut + + const tr: TranslateFn = useCallback((key, values) => translate(locale, key, values), [locale]) + + const handleUnauthorized = useCallback(() => { + if (auth.state !== 'signed-in') return signOut(null) + return signOut(tr('settings.auth_expired')) + }, [auth.state, signOut, tr]) + + const api = useMemo(() => createMeteorVoiceApiClient({ + baseUrl: defaultApiBaseUrl.trim(), + headers: getAuthHeaders, + onUnauthorized: handleUnauthorized, + }), [getAuthHeaders, handleUnauthorized]) + + const applyTtsPreferences = useCallback((preferences: PreferencesResponse) => { + if (preferences.tts_provider) setTtsProvider(preferences.tts_provider) + if (typeof preferences.tts_speed === 'number') setTtsSpeed(preferences.tts_speed) + if (preferences.tts_voice_id !== undefined) setTtsVoiceId(preferences.tts_voice_id) + }, []) + + useEffect(() => { + if (auth.state !== 'signed-in') return + let cancelled = false + void api.getPreferences() + .then(preferences => { if (!cancelled) applyTtsPreferences(preferences) }) + .catch(error => { + logMetric('mobile_preferences_startup_error', { + message: error instanceof Error ? error.message : 'unknown', + }) + }) + return () => { cancelled = true } + }, [api, applyTtsPreferences, auth.state, logMetric]) + + // ─── Derived Values / 派生值 ─── + const scenario = useMemo(() => scenarios.find(s => s.key === selectedScenarioKey) ?? scenarios[0], [selectedScenarioKey]) + const accent = useMemo(() => accentProfiles.find(a => a.key === selectedAccentKey) ?? accentProfiles[0], [selectedAccentKey]) + + // Voice profile accent override (for SettingsScreen voice selection) + const voiceProfileAccentLabel = null // TODO: wire from SettingsScreen voice profile selection + const voiceProfileAccentRegion = null + + // ─── Refs / 可变引用 ─── const snapshotRef = useRef(snapshot) const messagesRef = useRef(messages) - const localeRef = useRef(locale) - const sessionSttProviderRef = useRef(sessionSttProvider) - const sessionSttProviderHydratedRef = useRef(false) - const sessionSttProvidersLoadedRef = useRef(false) - const startListeningWithProviderRef = useRef<(provider: SessionSttProvider, lang?: string) => Promise>(() => Promise.resolve(false)) - const prefSyncTimerRef = useRef | null>(null) - const themeInitializedRef = useRef(false) - const listeningStartMsRef = useRef(0) - const speechStartListeningRef = useRef<(lang?: string) => Promise>(() => Promise.resolve(false)) - const speechCancelListeningRef = useRef<() => void | Promise>(() => undefined) - const nativeSpeechStartListeningRef = useRef<(lang?: string) => Promise>(() => Promise.resolve(false)) - const nativeSpeechCancelListeningRef = useRef<() => void | Promise>(() => undefined) - const xunfeiSessionSttRef = useRef<{ - socket: WebSocket | null - frameSubscription: { remove: () => void } | null - stateSubscription: { remove: () => void } | null - finalizeTimer: ReturnType | null - hardTimer: ReturnType | null - noFrameTimer: ReturnType | null - settled: boolean - } | null>(null) - const endpointRequestRef = useRef(0) - const turnRequestRef = useRef(0) + const statusRef = useRef(status) const sessionActiveRef = useRef(false) const canListenOnRouteRef = useRef(true) + const routePresenceRef = useRef('inSession') const playbackActiveRef = useRef(false) const audioPlayingRef = useRef(false) const busyRef = useRef(false) @@ -456,1886 +255,688 @@ function AppInner() { const pendingNativeTranscriptRef = useRef('') const isCorrectionPlayingRef = useRef(false) const resumeListeningTimerRef = useRef | null>(null) - const historyAutoLoadRef = useRef(false) - const settingsAutoLoadRef = useRef(false) - const settingsRequestRef = useRef(0) - const settingsLoadingRef = useRef(false) - const activeTabRef = useRef(activeTab) + const listeningStartMsRef = useRef(0) const listeningTeardownRef = useRef | null>(null) - - const scenario = scenarios.find(item => item.key === selectedScenarioKey) ?? scenarios[0] - const accent = accentProfiles.find(item => item.key === selectedAccentKey) ?? accentProfiles[0] - const providerVoiceProfiles = voiceProfiles.filter(profile => profile.provider === ttsProvider) - const selectedVoiceProfile = voiceProfiles.find(profile => profile.id === selectedVoiceProfileId) - ?? providerVoiceProfiles.find(profile => profile.providerVoiceId === ttsVoiceId) - ?? providerVoiceProfiles.find(profile => profile.status === 'active') - const sessionAccentName = selectedVoiceProfile?.accentLabel ?? getAccentLabel(accent, locale) - const sessionAccentRegion = selectedVoiceProfile?.accentRegion ?? getAccentRegion(accent, locale) - const tr = useCallback((key: string) => t[locale]?.[key] ?? t.en[key] ?? key, [locale]) - - useEffect(() => { - SecureStore.getItemAsync(sessionSttProviderStorageKey).then(value => { - if (value === 'xunfei' || value === 'native') { - sessionSttProviderRef.current = value - setSessionSttProviderState(value) - } - sessionSttProviderHydratedRef.current = true - }) + const endpointRequestRef = useRef(0) + const turnRequestRef = useRef(0) + const sessionGenerationRef = useRef(0) + const sttRestartCountRef = useRef(0) + const sttRestartStartMsRef = useRef(0) + const sttStreamIdRef = useRef(0) + const sttOperationQueueRef = useRef>(Promise.resolve()) + const sttPrewarmAudioUrlRef = useRef(null) + const sessionSyncFlushRef = useRef | null>(null) + + const clearAudio = useCallback(() => { + setAudioUrl(null) + playbackEndedAtMsRef.current = null }, []) - useEffect(() => { - snapshotRef.current = snapshot - }, [snapshot]) + // STT provider refs + const sessionSttProviderRef = useRef(sessionSttProvider) + const sessionSttProviderHydratedRef = useRef(false) + const speechStartListeningRef = useRef<(lang?: string) => Promise>(() => Promise.resolve(false)) + const speechCancelListeningRef = useRef<() => void | Promise>(() => undefined) + const nativeSpeechStartListeningRef = useRef<(lang?: string) => Promise>(() => Promise.resolve(false)) + const nativeSpeechCancelListeningRef = useRef<() => void | Promise>(() => undefined) + const startListeningWithProviderRef = useRef<(provider: SessionSttProvider, lang?: string) => Promise>(() => Promise.resolve(false)) - useEffect(() => { - messagesRef.current = messages - }, [messages]) + // Ref syncs: state → ref + useEffect(() => { snapshotRef.current = snapshot }, [snapshot]) + useEffect(() => { messagesRef.current = messages }, [messages]) + useEffect(() => { statusRef.current = status }, [status]) + useEffect(() => { sessionSttProviderRef.current = sessionSttProvider }, [sessionSttProvider]) + useEffect(() => { busyRef.current = busy }, [busy]) + useEffect(() => { sessionActiveRef.current = isSessionActive }, [isSessionActive]) + + const flushPendingSessionSyncs = useCallback(() => { + if (auth.state !== 'signed-in') return Promise.resolve() + if (sessionSyncFlushRef.current) return sessionSyncFlushRef.current + const run = flushSessionSyncOutbox(sessionSyncOutboxStorage, payload => api.syncSession(payload)) + .then(result => { + if (result.synced > 0) logMetric('session_sync_outbox_flushed', result) + if (result.remaining > 0) logMetric('session_sync_outbox_pending', result) + if (result.synced > 0 && result.remaining === 0 && !sessionActiveRef.current && statusRef.current === 'session.sync_pending') { + statusRef.current = 'session.ended' + setStatusState('session.ended') + } + }) + .catch(error => { + logMetric('session_sync_outbox_error', { + message: error instanceof Error ? error.message : 'unknown', + }) + }) + .finally(() => { sessionSyncFlushRef.current = null }) + sessionSyncFlushRef.current = run + return run + }, [api, auth.state, logMetric]) useEffect(() => { - localeRef.current = locale - }, [locale]) + if (auth.state === 'signed-in') void flushPendingSessionSyncs() + }, [auth.state, flushPendingSessionSyncs]) - useEffect(() => { - sessionSttProviderRef.current = sessionSttProvider - }, [sessionSttProvider]) + // ─── STT Provider / 语音识别提供者 ─── + const setSessionSttProvider = useCallback((provider: SessionSttProvider) => { + sessionSttProviderRef.current = provider + setSessionSttProviderState(provider) + void SecureStore.setItemAsync(sessionSttProviderStorageKey, provider) + }, []) - useEffect(() => { - activeTabRef.current = activeTab - }, [activeTab]) + // ─── Voice Metrics / 语音指标 ─── + const setStatus = useCallback((nextStatus: string) => { + const previous = statusRef.current + statusRef.current = nextStatus + if (previous !== nextStatus) logMetric('ui_status_changed', { from: previous, to: nextStatus }) + setStatusState(nextStatus) + }, [logMetric]) + + const setBusy = useCallback((nextBusy: boolean) => { + busyRef.current = nextBusy + if (busyRef.current !== nextBusy) logMetric('ui_busy_changed', { from: !nextBusy, to: nextBusy }) + setBusyState(nextBusy) + }, [logMetric]) + + const setRoutePresence = useCallback((next: SessionRoutePresence, reason: string) => { + const previous = routePresenceRef.current + routePresenceRef.current = next + canListenOnRouteRef.current = next === 'inSession' + if (previous !== next) logMetric('route_presence_changed', { from: previous, to: next, reason }) + }, [logMetric]) + + const canStartSessionListening = useCallback((context: string, generation?: number) => { + const gen = generation ?? sessionGenerationRef.current + const gate = { + sessionActive: sessionActiveRef.current, routePresence: routePresenceRef.current, + canListenOnRoute: canListenOnRouteRef.current, busy: busyRef.current, + playbackActive: playbackActiveRef.current, audioPlaying: audioPlayingRef.current, + generation: gen, currentGeneration: sessionGenerationRef.current, + } + const allowed = canStartListening(gate) + if (!allowed) logMetric('stt_start_aborted', { context, ...gate }) + return allowed + }, [logMetric]) - useEffect(() => { - busyRef.current = busy - }, [busy]) - - const listeningStartupStatus = useCallback((provider = sessionSttProviderRef.current) => ( - provider === 'xunfei' - ? 'session.status.preparing_listening' - : 'session.status.listening' - ), []) - const handleUnauthorized = useCallback(() => { - if (auth.state !== 'signed-in') return signOut(null) - return signOut(tr('settings.auth_expired')) - }, [auth.state, signOut, tr]) - const api = useMemo(() => createMeteorVoiceApiClient({ - baseUrl: apiBaseUrl.trim(), - headers: getAuthHeaders, - onUnauthorized: handleUnauthorized, - }), [apiBaseUrl, getAuthHeaders, handleUnauthorized]) - const setSettingsLoadingFlag = useCallback((loading: boolean) => { - settingsLoadingRef.current = loading - setSettingsLoading(loading) - }, []) - const applyThemeLocal = useCallback((k: Parameters[0]) => { - setThemeLocal(k) - }, [setThemeLocal]) - const setTheme = useCallback((k: Parameters[0]) => { - themeInitializedRef.current = true - setThemeLocal(k) - const now = new Date().toISOString() - void SecureStore.setItemAsync('theme_set_at', now) - void api.updatePreferences({ ui_theme: k }).catch(() => {}) - }, [setThemeLocal, api]) const clearResumeListeningTimer = useCallback(() => { if (!resumeListeningTimerRef.current) return clearTimeout(resumeListeningTimerRef.current) resumeListeningTimerRef.current = null }, []) - const logVoiceMetric = useCallback((stage: string, data: Record = {}) => { - const sanitizedData = Object.fromEntries( - Object.entries(data).map(([key, value]) => [ - key, - key.toLowerCase().includes('audiourl') && typeof value === 'string' ? '' : value, - ]), - ) - const entry = { ts: Date.now(), stage, data: sanitizedData } - console.info('[voice-metrics]', JSON.stringify(entry)) - setVoiceMetrics(previous => [...previous.slice(-239), entry]) - }, []) - - const logUserAction = useCallback((action: string, data: Record = {}) => { - logVoiceMetric('user_action', { - action, - activeTab: activeTabRef.current, - scenario: selectedScenarioKey, - sessionActive: sessionActiveRef.current, - canListenOnRoute: canListenOnRouteRef.current, - busy, - playbackActive: playbackActiveRef.current, - audioPlaying: audioPlayingRef.current, - sttProvider: sessionSttProviderRef.current, - pendingTeardown: Boolean(listeningTeardownRef.current), - ...data, - }) - }, [busy, logVoiceMetric, selectedScenarioKey]) - - const runListeningTeardown = useCallback((reason: string, action: () => void | Promise) => { - const startedAt = Date.now() - logVoiceMetric('listening_teardown_start', { - reason, - provider: sessionSttProviderRef.current, - activeTab: activeTabRef.current, - sessionActive: sessionActiveRef.current, - canListenOnRoute: canListenOnRouteRef.current, - }) - const task = Promise.resolve() - .then(action) - .catch(error => { - logVoiceMetric('listening_teardown_error', { - reason, - message: error instanceof Error ? error.message : 'Listening teardown failed', - }) - }) - .finally(() => { - if (listeningTeardownRef.current === task) { - listeningTeardownRef.current = null - } - logVoiceMetric('listening_teardown_done', { - reason, - elapsedMs: Date.now() - startedAt, - }) - }) - listeningTeardownRef.current = task - return task - }, [logVoiceMetric]) - - const waitForListeningTeardown = useCallback(async (context: string) => { - const pending = listeningTeardownRef.current - if (!pending) return - const startedAt = Date.now() - logVoiceMetric('listening_teardown_wait', { context }) - await pending - logVoiceMetric('listening_teardown_wait_done', { - context, - elapsedMs: Date.now() - startedAt, - }) - }, [logVoiceMetric]) - - const cancelListeningForReason = useCallback((reason: string) => ( - runListeningTeardown(reason, async () => { - await speechCancelListeningRef.current() - }) - ), [runListeningTeardown]) - - const voiceMetricsText = useMemo(() => { - return voiceMetrics - .map(entry => `${new Date(entry.ts).toLocaleTimeString()} ${entry.stage} ${JSON.stringify(entry.data)}`) - .join('\n') - }, [voiceMetrics]) - const asrEvaluationText = useMemo(() => createASREvaluationReport(voiceMetrics), [voiceMetrics]) - const scheduleResumeListening = useCallback((delayMs = DEFAULT_PLAYBACK_COOLDOWN_MS, updateStatus = true) => { clearResumeListeningTimer() resumeListeningTimerRef.current = setTimeout(() => { resumeListeningTimerRef.current = null - if (!sessionActiveRef.current || !canListenOnRouteRef.current || playbackActiveRef.current || audioPlayingRef.current) { - logVoiceMetric('resume_listening_skipped', { - playbackActive: playbackActiveRef.current, - audioPlaying: audioPlayingRef.current, - }) + if (!canStartSessionListening('resume_timer')) { + logMetric('resume_listening_skipped', {}) return } listeningStartMsRef.current = Date.now() - if (updateStatus) setStatus(listeningStartupStatus()) + if (updateStatus) { + setStatus(sessionSttProviderRef.current === 'xunfei' ? 'session.status.preparing_listening' : 'session.status.listening') + } void speechStartListeningRef.current('en-US') }, delayMs) - }, [clearResumeListeningTimer, listeningStartupStatus, logVoiceMetric]) + }, [canStartSessionListening, clearResumeListeningTimer, logMetric, setStatus]) - useEffect(() => clearResumeListeningTimer, [clearResumeListeningTimer]) - - const updateApiBaseUrl = useCallback((value: string) => { - setApiBaseUrl(value) - const normalized = value.trim() - if (!normalized || normalized === defaultApiBaseUrl) { - setApiBaseUrlSource('default') - void SecureStore.deleteItemAsync(apiBaseUrlStorageKey) - return - } - setApiBaseUrlSource('user') - void SecureStore.setItemAsync(apiBaseUrlStorageKey, normalized) - }, []) + const enqueueSttOperation = useCallback((label: string, operation: () => Promise) => { + const { task, queue } = enqueueRuntimeOperation({ + queue: sttOperationQueueRef.current, label, log: logMetric, operation, + }) + sttOperationQueueRef.current = queue + return task + }, [logMetric]) - const resetApiBaseUrl = useCallback(() => { - setApiBaseUrl(defaultApiBaseUrl) - setApiBaseUrlSource('default') - void SecureStore.deleteItemAsync(apiBaseUrlStorageKey) - }, []) + const cancelListeningForReason = useCallback((reason: string) => { + const task = Promise.resolve() + .then(() => speechCancelListeningRef.current()) + .catch(error => { logMetric('listening_teardown_error', { reason, message: error instanceof Error ? error.message : 'teardown failed' }) }) + .finally(() => { + if (listeningTeardownRef.current === task) listeningTeardownRef.current = null + logMetric('listening_teardown_done', { reason }) + }) + listeningTeardownRef.current = task + return task + }, [logMetric]) - const setSessionSttProvider = useCallback((provider: SessionSttProvider) => { - sessionSttProviderRef.current = provider - setSessionSttProviderState(provider) - void SecureStore.setItemAsync(sessionSttProviderStorageKey, provider) - logVoiceMetric('stt_provider_selected', { provider }) - }, [logVoiceMetric, setSessionSttProviderState]) + // ─── Native Speech / 原生语音 ─── + const nativeFinalTranscriptRef = useHandlerBridge<(t: string) => Promise>() + const nativeEndedWithoutTranscriptRef = useHandlerBridge<() => void>() + const xunfeiFinalTranscriptRef = useHandlerBridge<(t: string) => Promise>() + const xunfeiEndedWithoutTranscriptRef = useHandlerBridge<() => void>() - async function ensureSessionSttProviderForStart() { - let provider = sessionSttProviderRef.current + const speech = useNativeSpeech({ + onFinalTranscript: useCallback((t: string) => { void nativeFinalTranscriptRef.current(t) }, []), + onListeningEndedWithoutTranscript: useCallback(() => { nativeEndedWithoutTranscriptRef.current() }, []), + onMetric: useCallback((stage: string, data?: Record) => { logMetric(stage, data) }, [logMetric]), + }) - if (!sessionSttProviderHydratedRef.current) { - const stored = await SecureStore.getItemAsync(sessionSttProviderStorageKey) - if (stored === 'xunfei' || stored === 'native') { - provider = stored - sessionSttProviderRef.current = stored - setSessionSttProviderState(stored) - } - sessionSttProviderHydratedRef.current = true - } + useEffect(() => { + nativeSpeechStartListeningRef.current = speech.startListening + nativeSpeechCancelListeningRef.current = speech.cancelListening + }, [speech.cancelListening, speech.startListening]) - if (auth.state === 'signed-in' && !sessionSttProvidersLoadedRef.current) { - try { - const result = await api.listASRProviders() - const providers: SessionSttProvider[] = ['native'] - if (result.providers.some(item => item.key === 'xunfei' && item.enabled)) { - providers.push('xunfei') - } - setAvailableSessionSttProviders(providers) - sessionSttProvidersLoadedRef.current = true - if (!providers.includes(provider)) { - provider = 'native' - sessionSttProviderRef.current = 'native' - setSessionSttProviderState('native') - void SecureStore.setItemAsync(sessionSttProviderStorageKey, 'native') - } - } catch (error) { - const requestError = formatApiRequestError(error, { - context: 'mobile_asr_providers_load', - presentation: 'silent', - }) - logVoiceMetric('mobile_silent_request_error', requestError.logData) - } - } - - return provider - } - - async function startSession() { - logUserAction('session_start_tap', { scenario: scenario.key }) - if (scenarioSwitching) { - logVoiceMetric('session_start_blocked', { reason: 'scenario_switching' }) - return - } - if (auth.state !== 'signed-in') { - setActiveTab('settings') - setStatus('login.signin') - return - } - - setStatus('session.status.preparing_listening') - logVoiceMetric('session_start_requested', { - scenario: scenario.key, - activeTab: activeTabRef.current, - pendingTeardown: Boolean(listeningTeardownRef.current), - }) - await waitForListeningTeardown('session_start') - await cancelListeningForReason('session_start_reset') - const listeningProvider = await ensureSessionSttProviderForStart() - logVoiceMetric('session_start', { - scenario: scenario.key, - accent: accent.key, - provider: ttsProvider, - sttProvider: listeningProvider, - }) - endpointRequestRef.current += 1 - clearResumeListeningTimer() - playbackActiveRef.current = false - playbackStartedRef.current = false - playbackEndedAtMsRef.current = null - listeningStartMsRef.current = Date.now() - pendingNativeTranscriptRef.current = '' - sessionActiveRef.current = true - canListenOnRouteRef.current = true - const nextSessionId = apiSessionId ?? `mobile-${Date.now()}` - const nextSnapshot = startListeningSession(nextSessionId) - snapshotRef.current = nextSnapshot - messagesRef.current = [] - setSnapshot(nextSnapshot) - setMessages([]) - setCorrectionHistory([]) - setAudioUrl(null) - playbackEndedAtMsRef.current = null - setPlaybackQueue(createPlaybackQueueSnapshot()) - setSummary(null) - setIsSessionActive(true) - setStatus(listeningStartupStatus(listeningProvider)) - logVoiceMetric('session_listening_start_requested', { - sttProvider: listeningProvider, - sessionId: nextSessionId, - canListenOnRoute: canListenOnRouteRef.current, - }) - void startListeningWithProviderRef.current(listeningProvider, 'en-US') - } - - const synthesizeCoachSpeech = useCallback(async (text: string) => { - return api.synthesizeSpeech({ - text, - accent: accent.name, - provider: ttsProvider, - speed: ttsSpeedRouting.serverSpeed, - voiceId: ttsVoiceId ?? undefined, - }) - }, [accent.name, api, ttsProvider, ttsSpeedRouting.serverSpeed, ttsVoiceId]) + // ─── Xunfei STT Engine / 讯飞语音识别引擎 ─── + const xunfeiStt = useXunfeiStt({ + network: { api, auth }, + context: { locale, selectedScenarioKey }, + refs: { + snapshot: snapshotRef, sessionGeneration: sessionGenerationRef, + sttStreamId: sttStreamIdRef, sttRestartCount: sttRestartCountRef, + sttRestartStartMs: sttRestartStartMsRef, listeningStartMs: listeningStartMsRef, + }, + session: { + sessionActive: sessionActiveRef, routePresence: routePresenceRef, + canListenOnRoute: canListenOnRouteRef, playbackActive: playbackActiveRef, + audioPlaying: audioPlayingRef, + }, + callbacks: { logMetric, setStatus, enqueueSttOperation, canStartSessionListening }, + bridge: { + nativeSpeechStart: nativeSpeechStartListeningRef, + nativeSpeechCancel: nativeSpeechCancelListeningRef, + finalTranscript: xunfeiFinalTranscriptRef, + endedWithoutTranscript: xunfeiEndedWithoutTranscriptRef, + }, + }) + const { startXunfeiSessionListening, cancelXunfeiSessionListening } = xunfeiStt + // Synced from hook — used by prewarm/ref wiring effects + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const xunfeiSessionSttRef = xunfeiStt.xunfeiSessionSttRef + // ─── STT Ref Wiring / 语音识别引用接线 ─── useEffect(() => { - audioPlayingRef.current = audio.isPlaying - if (audio.isPlaying && audioUrl && playbackActiveRef.current && !playbackStartedRef.current) { - playbackStartedRef.current = true - logVoiceMetric('playback_started', { audioUrl }) - void cancelListeningForReason('playback_started') - } - }, [audio.isPlaying, audioUrl, cancelListeningForReason, logVoiceMetric]) + speechStartListeningRef.current = sessionSttProvider === 'xunfei' + ? () => startXunfeiSessionListening(false).then(() => true) + : speech.startListening + speechCancelListeningRef.current = sessionSttProvider === 'xunfei' + ? cancelXunfeiSessionListening + : speech.cancelListening + startListeningWithProviderRef.current = (provider, lang) => + provider === 'xunfei' ? startXunfeiSessionListening().then(() => true) : speech.startListening(lang) + }, [sessionSttProvider, speech, startXunfeiSessionListening, cancelXunfeiSessionListening]) + // ─── Prewarm / 预热 ─── useEffect(() => { - if (!audioUrl || !audio.didJustFinish || audio.isPlaying) return - if (!playbackStartedRef.current) { - logVoiceMetric('playback_finish_ignored', { reason: 'not_started', audioUrl }) - return - } - - let cancelled = false - const advanceQueue = () => { - if (cancelled) return - - if (isCorrectionPlayingRef.current) { - isCorrectionPlayingRef.current = false - playbackActiveRef.current = false - playbackStartedRef.current = false - playbackEndedAtMsRef.current = Date.now() - setStatus(sessionActiveRef.current && canListenOnRouteRef.current - ? 'session.status.listening' - : 'session.status.reply_played') - if (sessionActiveRef.current && canListenOnRouteRef.current) { - scheduleResumeListening(900, false) - } - return - } - - const nextQueue = advancePlaybackQueue({ - queue: playbackQueue, - finishedAudioUrl: audioUrl, - didJustFinish: audio.didJustFinish, - isPlaying: audio.isPlaying, - }) - - if (nextQueue === playbackQueue) return - - setPlaybackQueue(nextQueue) - const effects = getPlaybackCompletionEffects(nextQueue) - if (effects.includes('play_next_audio') && nextQueue.currentAudioUrl && nextQueue.currentAudioUrl !== audioUrl) { - playbackActiveRef.current = true - playbackStartedRef.current = false - playbackEndedAtMsRef.current = null - clearResumeListeningTimer() - void cancelListeningForReason('play_next_audio') - setStatus('session.status.playing_reply') - setAudioUrl(nextQueue.currentAudioUrl) - return - } + if (!audioUrl || sessionSttProvider !== 'xunfei') { sttPrewarmAudioUrlRef.current = null; return } + const decision = getPlaybackTailPrewarmDecision({ + provider: sessionSttProvider, isPlaying: audio.isPlaying, playbackActive: playbackActiveRef.current, + audioUrl, prewarmedAudioUrl: sttPrewarmAudioUrlRef.current, + playbackDurationSeconds: audio.playbackDurationSeconds, playbackRemainingMs: audio.playbackRemainingMs, + }) + if (!decision.shouldPrewarm || decision.remainingMs == null) return + sttPrewarmAudioUrlRef.current = audioUrl + const timer = setTimeout(() => { void startXunfeiSessionListening(true) }, 0) + return () => clearTimeout(timer) + }, [audio.isPlaying, audio.playbackDurationSeconds, audio.playbackRemainingMs, audioUrl, sessionSttProvider, startXunfeiSessionListening]) - playbackActiveRef.current = false - playbackStartedRef.current = false - playbackEndedAtMsRef.current = Date.now() - logVoiceMetric('playback_finished', { audioUrl }) - setStatus('session.status.reply_played') - if (sessionActiveRef.current && canListenOnRouteRef.current) { - scheduleResumeListening() - } - } + // ─── Orchestration / 编排函数 ─── - const timeout = setTimeout(advanceQueue, 0) - return () => { - cancelled = true - clearTimeout(timeout) - } - }, [ - audio.didJustFinish, audio.isPlaying, audioUrl, playbackQueue, cancelListeningForReason, - clearResumeListeningTimer, logVoiceMetric, scheduleResumeListening, - ]) + const synthesizeCoachSpeech = useCallback(async (text: string) => { + return api.synthesizeSpeech({ text, accent: accent.name, provider: ttsProvider, speed: ttsSpeedRouting.serverSpeed, voiceId: ttsVoiceId ?? undefined }) + }, [accent.name, api, ttsProvider, ttsSpeedRouting.serverSpeed, ttsVoiceId]) const submitTurn = useCallback(async (sourceTranscript: string) => { const submitStartedAt = Date.now() + const submitGeneration = sessionGenerationRef.current const transcript = sourceTranscript.trim() - const currentSnapshot = snapshotRef.current - const currentMessages = messagesRef.current - if ( - busy || - audio.isRecording || - playbackActiveRef.current || - !canAcceptUserTranscript({ - activeSession: isSessionActive, - canListenOnRoute: canListenOnRouteRef.current, - workflowState: currentSnapshot.state, - transcript, - }) - ) return - - const acceptedTurn = acceptTranscriptTurn({ snapshot: currentSnapshot, transcript, messages: currentMessages }) - const nextMessages = acceptedTurn.messages - let nextSnapshot = acceptedTurn.snapshot - snapshotRef.current = nextSnapshot - messagesRef.current = nextMessages - setSnapshot(nextSnapshot) - setMessages(nextMessages) - setAudioUrl(null) - setPlaybackQueue(createPlaybackQueueSnapshot()) - listeningStartMsRef.current = 0 - playbackEndedAtMsRef.current = null - pendingNativeTranscriptRef.current = '' + if (busyRef.current || audio.isRecording || playbackActiveRef.current || + !canAcceptUserTranscript({ activeSession: isSessionActive, canListenOnRoute: canListenOnRouteRef.current, workflowState: snapshotRef.current.state, transcript })) return + + const acceptedTurn = acceptTranscriptTurn({ snapshot: snapshotRef.current, transcript, messages: messagesRef.current }) + snapshotRef.current = acceptedTurn.snapshot + messagesRef.current = acceptedTurn.messages + setSnapshot(acceptedTurn.snapshot) + setMessages(acceptedTurn.messages) + setAudioUrl(null); setPlaybackQueue(createPlaybackQueueSnapshot()) + listeningStartMsRef.current = 0; playbackEndedAtMsRef.current = null; pendingNativeTranscriptRef.current = '' clearResumeListeningTimer() await cancelListeningForReason('submit_turn') setBusy(true) const turnRequestId = ++turnRequestRef.current + let terminalLogged = false + let nextSnapshot = acceptedTurn.snapshot + + const logTerminal = (stage: string, data: Record = {}) => { + terminalLogged = true + logMetric(stage, { turnRequestId, generation: submitGeneration, elapsedMs: Date.now() - submitStartedAt, ...data }) + } try { setStatus('session.status.requesting_reply') nextSnapshot = requestCoachReply(nextSnapshot) - snapshotRef.current = nextSnapshot - setSnapshot(nextSnapshot) - const coachReply = await withTimeout(api.generateCoachReply({ - messages: nextMessages, - context: { - scenario: { name: scenario.name, description: scenario.description }, - accentProfile: { name: accent.name, region: accent.region }, - sessionId: nextSnapshot.sessionId, - turnNumber: nextMessages.filter(message => message.role === 'user').length, - responseLocale: localeRef.current, - }, + snapshotRef.current = nextSnapshot; setSnapshot(nextSnapshot) + const reply = await withTimeout(api.generateCoachReply({ + messages: messagesRef.current, + context: { scenario: { name: scenario.name, description: scenario.description }, accentProfile: { name: accent.name, region: accent.region }, sessionId: nextSnapshot.sessionId, turnNumber: messagesRef.current.filter(m => m.role === 'user').length, responseLocale: locale }, }), 20_000, 'Coach reply request timed out.') - if (turnRequestRef.current !== turnRequestId || !sessionActiveRef.current) { - logVoiceMetric('coach_reply_ignored', { reason: 'session_inactive' }) - return + if (isTurnStale({ turnRequestId, currentTurnRequestId: turnRequestRef.current, generation: submitGeneration, currentGeneration: sessionGenerationRef.current, sessionActive: sessionActiveRef.current })) { + logTerminal('submit_turn_ignored_stale', { reason: 'coach_reply_stale' }); return } - logVoiceMetric('coach_reply_ready', { - elapsedMs: Date.now() - submitStartedAt, - chars: coachReply.text.length, - }) - setCorrectionHistory(previous => [...previous, ...coachReply.corrections]) - const coachTurn = receiveCoachReply({ - snapshot: nextSnapshot, - messages: nextMessages, - responseText: coachReply.text, - corrections: coachReply.corrections, - }) - nextSnapshot = coachTurn.snapshot - snapshotRef.current = nextSnapshot - messagesRef.current = coachTurn.messages - setMessages(coachTurn.messages) - setSnapshot(nextSnapshot) + setCorrectionHistory(prev => [...prev, ...reply.corrections]) + const coachTurn = receiveCoachReply({ snapshot: nextSnapshot, messages: messagesRef.current, responseText: reply.text, corrections: reply.corrections }) + nextSnapshot = coachTurn.snapshot; snapshotRef.current = nextSnapshot; messagesRef.current = coachTurn.messages + setMessages(coachTurn.messages); setSnapshot(nextSnapshot) setStatus('session.status.requesting_voice') - if (!coachReply.text.trim()) { + if (!reply.text.trim()) { setStatus('session.status.reply_without_text') - if (sessionActiveRef.current && canListenOnRouteRef.current) { - scheduleResumeListening(500) - } - return + const gate = { sessionActive: sessionActiveRef.current, routePresence: routePresenceRef.current, canListenOnRoute: canListenOnRouteRef.current, busy: false, playbackActive: playbackActiveRef.current, audioPlaying: audioPlayingRef.current, generation: submitGeneration, currentGeneration: sessionGenerationRef.current } + if (shouldResumeListening(gate)) scheduleResumeListening(500) + logTerminal('submit_turn_done', { reason: 'reply_without_text' }); return } - const voice = await withTimeout(synthesizeCoachSpeech(coachReply.text), 20_000, 'Coach voice request timed out.') - if (turnRequestRef.current !== turnRequestId || !sessionActiveRef.current) { - logVoiceMetric('tts_ignored', { reason: 'session_inactive' }) - return + const voice = await withTimeout(synthesizeCoachSpeech(reply.text), 20_000, 'Coach voice request timed out.') + if (isTurnStale({ turnRequestId, currentTurnRequestId: turnRequestRef.current, generation: submitGeneration, currentGeneration: sessionGenerationRef.current, sessionActive: sessionActiveRef.current })) { + logTerminal('submit_turn_ignored_stale', { reason: 'tts_stale' }); return } - logVoiceMetric('tts_ready', { - elapsedMs: Date.now() - submitStartedAt, - hasAudio: Boolean(voice.audioUrl), - }) - if (voice.audioUrl) { - playbackActiveRef.current = true - playbackStartedRef.current = false - playbackEndedAtMsRef.current = null - clearResumeListeningTimer() - await cancelListeningForReason('playback_enqueue') - setStatus('session.status.playing_reply') - setPlaybackQueue(startPlaybackQueue(voice.audioUrl)) - setAudioUrl(voice.audioUrl) - logVoiceMetric('playback_enqueued', { elapsedMs: Date.now() - submitStartedAt }) + playbackActiveRef.current = true; playbackStartedRef.current = false; playbackEndedAtMsRef.current = null + clearResumeListeningTimer(); await cancelListeningForReason('playback_enqueue') + setStatus('session.status.playing_reply'); setPlaybackQueue(startPlaybackQueue(voice.audioUrl)); setAudioUrl(voice.audioUrl) } else { - playbackActiveRef.current = false - setStatus('session.status.reply_without_audio') - if (sessionActiveRef.current && canListenOnRouteRef.current) { - scheduleResumeListening(500) - } + playbackActiveRef.current = false; setStatus('session.status.reply_without_audio') + const gate = { sessionActive: sessionActiveRef.current, routePresence: routePresenceRef.current, canListenOnRoute: canListenOnRouteRef.current, busy: false, playbackActive: playbackActiveRef.current, audioPlaying: audioPlayingRef.current, generation: submitGeneration, currentGeneration: sessionGenerationRef.current } + if (shouldResumeListening(gate)) scheduleResumeListening(500) } - const completedTurn = completeCoachPlayback({ - snapshot: nextSnapshot, - corrections: coachReply.corrections, - }) - snapshotRef.current = completedTurn.snapshot - setSnapshot(completedTurn.snapshot) + const completed = completeCoachPlayback({ snapshot: nextSnapshot, corrections: reply.corrections }) + snapshotRef.current = completed.snapshot; setSnapshot(completed.snapshot) + logTerminal('submit_turn_done', { hasAudio: Boolean(voice.audioUrl) }) } catch (error) { - const recovery = recoverSessionError({ - snapshot: nextSnapshot, - reason: 'coach_reply_failed', - activeSession: isSessionActive, - canListenOnRoute: canListenOnRouteRef.current, - }) - snapshotRef.current = recovery.snapshot - setSnapshot(recovery.snapshot) - const requestError = formatApiRequestError(error, { - context: 'mobile_session_submit', - presentation: 'banner', - }) - logVoiceMetric('mobile_session_request_error', requestError.logData) + const terminal = classifyRequestTerminalStage(error) + logTerminal(terminal.stage, { message: terminal.message }) + const recovery = recoverSessionError({ snapshot: nextSnapshot!, reason: 'coach_reply_failed', activeSession: isSessionActive, canListenOnRoute: canListenOnRouteRef.current }) + snapshotRef.current = recovery.snapshot; setSnapshot(recovery.snapshot) + const requestError = formatApiRequestError(error, { context: 'mobile_session_submit', presentation: 'banner' }) + logMetric('mobile_session_request_error', requestError.logData) displayErrorFeedback(requestError, 'mobile_session_submit') setStatus(requestError.displayMessage) - if (sessionActiveRef.current && canListenOnRouteRef.current) { - scheduleResumeListening(900) - } + const gate = { sessionActive: sessionActiveRef.current, routePresence: routePresenceRef.current, canListenOnRoute: canListenOnRouteRef.current, busy: false, playbackActive: playbackActiveRef.current, audioPlaying: audioPlayingRef.current, generation: submitGeneration, currentGeneration: sessionGenerationRef.current } + if (shouldResumeListening(gate)) scheduleResumeListening(900) } finally { if (turnRequestRef.current === turnRequestId) setBusy(false) + if (!terminalLogged) logMetric('submit_turn_finally_without_terminal', { turnRequestId, generation: submitGeneration, elapsedMs: Date.now() - submitStartedAt }) } - }, [ - accent.name, accent.region, api, audio.isRecording, busy, cancelListeningForReason, - clearResumeListeningTimer, isSessionActive, logVoiceMetric, scenario.description, scenario.name, - scheduleResumeListening, synthesizeCoachSpeech, - ]) + }, [accent, api, audio.isRecording, cancelListeningForReason, clearResumeListeningTimer, isSessionActive, logMetric, scenario, scheduleResumeListening, setBusy, setStatus, synthesizeCoachSpeech, locale]) const handleNativeFinalTranscript = useCallback(async (finalTranscript: string) => { - const finalReceivedAt = Date.now() const transcript = finalTranscript.trim() if (!transcript) return - const endpointTranscript = [pendingNativeTranscriptRef.current, transcript] - .map(part => part.trim()) - .filter(Boolean) - .join(' ') - - if (!sessionActiveRef.current) { - logVoiceMetric('transcript_ignored_inactive', { chars: transcript.length }) - setStatus('session.status.speech_captured') - return - } - - const currentSnapshot = snapshotRef.current - const currentMessages = messagesRef.current - const transcriptGate = gateUserTranscript({ - activeSession: sessionActiveRef.current, - canListenOnRoute: canListenOnRouteRef.current, - workflowState: currentSnapshot.state, - transcript: endpointTranscript, - playbackActive: playbackActiveRef.current, - audioPlaying: audio.isPlaying, - playbackEndedAtMs: playbackEndedAtMsRef.current, - nowMs: Date.now(), - cooldownMs: DEFAULT_PLAYBACK_COOLDOWN_MS, + const endpointTranscript = [pendingNativeTranscriptRef.current, transcript].map(p => p.trim()).filter(Boolean).join(' ') + if (!sessionActiveRef.current) { setStatus('session.status.speech_captured'); return } + + const gate = gateUserTranscript({ + activeSession: sessionActiveRef.current, canListenOnRoute: canListenOnRouteRef.current, + workflowState: snapshotRef.current.state, transcript: endpointTranscript, + playbackActive: playbackActiveRef.current, audioPlaying: audio.isPlaying, + playbackEndedAtMs: playbackEndedAtMsRef.current, nowMs: Date.now(), cooldownMs: DEFAULT_PLAYBACK_COOLDOWN_MS, }) - if (!transcriptGate.accepted) { - logVoiceMetric('transcript_gate_rejected', { - reason: transcriptGate.reason, - chars: endpointTranscript.length, - }) - if (transcriptGate.reason === 'playback_active') { - void cancelListeningForReason('transcript_gate_playback_active') - } + if (!gate.accepted) { + if (gate.reason === 'playback_active') void cancelListeningForReason('transcript_gate_playback_active') + pendingNativeTranscriptRef.current = ''; return + } + const echo = shouldIgnoreLikelyPlaybackEcho({ transcript: endpointTranscript, lastAssistantResponse: snapshotRef.current.lastResponse, playbackEndedAtMs: playbackEndedAtMsRef.current, nowMs: Date.now() }) + if (echo.shouldIgnore) { pendingNativeTranscriptRef.current = '' + setStatus(sessionSttProviderRef.current === 'xunfei' ? 'session.status.preparing_listening' : 'session.status.listening') + const gate2 = { sessionActive: sessionActiveRef.current, routePresence: routePresenceRef.current, canListenOnRoute: canListenOnRouteRef.current, busy: busyRef.current, playbackActive: playbackActiveRef.current, audioPlaying: audioPlayingRef.current } + if (shouldResumeListening(gate2)) void speechStartListeningRef.current('en-US') return } - - const echoGuard = shouldIgnoreLikelyPlaybackEcho({ - transcript: endpointTranscript, - lastAssistantResponse: currentSnapshot.lastResponse, - playbackEndedAtMs: playbackEndedAtMsRef.current, - nowMs: Date.now(), - }) - if (echoGuard.shouldIgnore) { - logVoiceMetric('transcript_echo_ignored', { - overlapRatio: echoGuard.overlapRatio, - chars: endpointTranscript.length, + const endpointRequestId = ++endpointRequestRef.current + let endpointResult: Awaited> + try { + endpointResult = await judgeEndpoint({ + transcript: endpointTranscript, listeningDurationMs: Date.now() - listeningStartMsRef.current, + messages: messagesRef.current, scenario: scenario.key, + semanticCheck: auth.state === 'signed-in' ? async (t, ctx) => { + const authHeaders = await getAuthHeaders() + const res = await fetchWithTimeout(fetch, `${defaultApiBaseUrl.trim()}/api/semantic-endpoint`, { + method: 'POST', headers: { 'Content-Type': 'application/json', 'X-MeteorVoice-Client': 'meteorvoice-mobile', ...authHeaders }, + body: JSON.stringify({ transcript: t, messages: ctx.messages, scenario: ctx.scenario }), + }) + if (res.status === 401) handleUnauthorized() + if (!res.ok) throw new Error('Semantic check failed') + const data = await res.json() as { judgment: 'done' | 'thinking' } + return data.judgment + } : undefined, }) - pendingNativeTranscriptRef.current = '' - setStatus(listeningStartupStatus()) - if (!playbackActiveRef.current && !audioPlayingRef.current) { - void speechStartListeningRef.current('en-US') - } + } catch { + pendingNativeTranscriptRef.current = endpointTranscript + setStatus(sessionSttProviderRef.current === 'xunfei' ? 'session.status.preparing_listening' : 'session.status.listening') + const gate2 = { sessionActive: sessionActiveRef.current, routePresence: routePresenceRef.current, canListenOnRoute: canListenOnRouteRef.current, busy: busyRef.current, playbackActive: playbackActiveRef.current, audioPlaying: audioPlayingRef.current } + if (shouldResumeListening(gate2)) void speechStartListeningRef.current('en-US') return } - - const baseUrl = apiBaseUrl.trim() - const endpointRequestId = ++endpointRequestRef.current - logVoiceMetric('endpoint_start', { chars: endpointTranscript.length }) - const endpointResult = await judgeEndpoint({ - transcript: endpointTranscript, - listeningDurationMs: Date.now() - listeningStartMsRef.current, - messages: currentMessages, - scenario: scenario.key, - semanticCheck: auth.state === 'signed-in' ? async (t, ctx) => { - const authHeaders = await getAuthHeaders() - const res = await fetchWithTimeout(fetch, `${baseUrl}/api/semantic-endpoint`, { - method: 'POST', - headers: { 'Content-Type': 'application/json', 'X-MeteorVoice-Client': 'meteorvoice-mobile', ...authHeaders }, - body: JSON.stringify({ transcript: t, messages: ctx.messages, scenario: ctx.scenario }), - }) - if (res.status === 401) await handleUnauthorized() - if (!res.ok) throw new Error('Semantic check failed') - const data = await res.json() as { judgment: 'done' | 'thinking' } - return data.judgment - } : undefined, - }) - if (endpointRequestId !== endpointRequestRef.current || !sessionActiveRef.current || !canListenOnRouteRef.current || playbackActiveRef.current) return - logVoiceMetric('endpoint_done', { - judgment: endpointResult.judgment, - reason: endpointResult.reason, - elapsedMs: Date.now() - finalReceivedAt, - }) - + if (!canApplyEndpointResult({ endpointRequestId, currentEndpointRequestId: endpointRequestRef.current, sessionActive: sessionActiveRef.current, canListenOnRoute: canListenOnRouteRef.current, playbackActive: playbackActiveRef.current })) return if (endpointResult.judgment === 'continue') { pendingNativeTranscriptRef.current = endpointTranscript - setStatus(listeningStartupStatus()) - if (!playbackActiveRef.current && !audioPlayingRef.current) { - void speechStartListeningRef.current('en-US') - } + setStatus(sessionSttProviderRef.current === 'xunfei' ? 'session.status.preparing_listening' : 'session.status.listening') + const gate2 = { sessionActive: sessionActiveRef.current, routePresence: routePresenceRef.current, canListenOnRoute: canListenOnRouteRef.current, busy: busyRef.current, playbackActive: playbackActiveRef.current, audioPlaying: audioPlayingRef.current } + if (shouldResumeListening(gate2)) void speechStartListeningRef.current('en-US') return } - pendingNativeTranscriptRef.current = '' - logVoiceMetric('submit_turn_start', { chars: endpointTranscript.length }) void submitTurn(endpointTranscript) - }, [ - apiBaseUrl, audio.isPlaying, auth.state, cancelListeningForReason, getAuthHeaders, handleUnauthorized, - listeningStartupStatus, logVoiceMetric, scenario.key, submitTurn, - ]) + }, [audio.isPlaying, auth.state, cancelListeningForReason, defaultApiBaseUrl, getAuthHeaders, handleUnauthorized, logMetric, scenario.key, setStatus, submitTurn]) const handleListeningEndedWithoutTranscript = useCallback(() => { - if (!sessionActiveRef.current || !canListenOnRouteRef.current || busy || playbackActiveRef.current || audioPlayingRef.current) { - logVoiceMetric('stt_end_restart_skipped', { - sessionActive: sessionActiveRef.current, - canListenOnRoute: canListenOnRouteRef.current, - activeTab: activeTabRef.current, - busy, - playbackActive: playbackActiveRef.current, - audioPlaying: audioPlayingRef.current, - provider: sessionSttProviderRef.current, - }) + const gate = { sessionActive: sessionActiveRef.current, routePresence: routePresenceRef.current, canListenOnRoute: canListenOnRouteRef.current, busy: busyRef.current, playbackActive: playbackActiveRef.current, audioPlaying: audioPlayingRef.current } + if (!shouldResumeListening(gate)) return + sttRestartCountRef.current += 1 + if (sttRestartCountRef.current > STT_MAX_CONSECUTIVE_RESTARTS) { + sttRestartCountRef.current = 0; sttRestartStartMsRef.current = 0 + void nativeSpeechStartListeningRef.current('en-US') return } - logVoiceMetric('stt_end_restart_scheduled') - scheduleResumeListening(250, false) - }, [busy, logVoiceMetric, scheduleResumeListening]) - - const cancelXunfeiSessionListening = useCallback(async (reason = 'cancel') => { - const current = xunfeiSessionSttRef.current - if (!current || current.settled) return - current.settled = true - if (current.finalizeTimer) clearTimeout(current.finalizeTimer) - if (current.hardTimer) clearTimeout(current.hardTimer) - if (current.noFrameTimer) clearTimeout(current.noFrameTimer) - current.frameSubscription?.remove() - current.stateSubscription?.remove() - if (current.socket && current.socket.readyState === WebSocket.OPEN) { - current.socket.close() - } - await stopPcmCapture(`session_${reason}`).catch(() => undefined) - xunfeiSessionSttRef.current = null - logVoiceMetric('stt_end', { provider: 'xunfei', cancelled: true, reason }) - }, [logVoiceMetric]) - - const startXunfeiSessionListening = useCallback(async () => { - const canUseXunfeiRoute = (context: string) => { - const allowed = sessionActiveRef.current && - canListenOnRouteRef.current && - !busyRef.current && - !playbackActiveRef.current && - !audioPlayingRef.current && - activeTabRef.current === 'session' - if (!allowed) { - logVoiceMetric('stt_start_aborted', { - provider: 'xunfei', - context, - sessionActive: sessionActiveRef.current, - canListenOnRoute: canListenOnRouteRef.current, - activeTab: activeTabRef.current, - busy: busyRef.current, - playbackActive: playbackActiveRef.current, - audioPlaying: audioPlayingRef.current, - }) - } - return allowed - } - - if (!canUseXunfeiRoute('entry')) return false - if (xunfeiSessionSttRef.current && !xunfeiSessionSttRef.current.settled) return true - if (!availableSessionSttProviders.includes('xunfei') || !isPcmCaptureAvailable()) { - logVoiceMetric('stt_provider_fallback', { - requested: 'xunfei', - reason: !isPcmCaptureAvailable() ? 'pcm_unavailable' : 'provider_unavailable', - }) - return nativeSpeechStartListeningRef.current('en-US') - } - if (auth.state !== 'signed-in') return nativeSpeechStartListeningRef.current('en-US') - - const streamStartedAt = Date.now() - let socket: WebSocket | null = null - let frameSubscription: { remove: () => void } | null = null - let stateSubscription: { remove: () => void } | null = null - let finalizeTimer: ReturnType | null = null - let hardTimer: ReturnType | null = null - let noFrameTimer: ReturnType | null = null - let settled = false - let firstFrame = true - let finalFrameSent = false - let finalReceived = false - let audioSequence = 0 - let frameCount = 0 - let totalBytes = 0 - let transcript = '' - let firstPartialAt: number | null = null - const transcriptSegments: string[] = [] - - const updateCurrent = () => { - xunfeiSessionSttRef.current = { - socket, - frameSubscription, - stateSubscription, - finalizeTimer, - hardTimer, - noFrameTimer, - settled, - } - } - - const clearNoFrameTimer = () => { - if (!noFrameTimer) return - clearTimeout(noFrameTimer) - noFrameTimer = null - updateCurrent() - } - - const settle = (reason: string, submitted: boolean) => { - if (settled) return - settled = true - updateCurrent() - if (finalizeTimer) clearTimeout(finalizeTimer) - if (hardTimer) clearTimeout(hardTimer) - if (noFrameTimer) clearTimeout(noFrameTimer) - frameSubscription?.remove() - void stopPcmCapture(`session_${reason}`).catch(() => undefined).finally(() => stateSubscription?.remove()) - if (socket && socket.readyState === WebSocket.OPEN) socket.close() - xunfeiSessionSttRef.current = null - logVoiceMetric('stt_end', { - provider: 'xunfei', - cancelled: reason !== 'final', - reason, - hadTranscript: Boolean(transcript.trim()), - submitted, - elapsedMs: Date.now() - streamStartedAt, - frameCount, - totalBytes, - }) - } - - const sendAudioFrame = (status: 0 | 1 | 2, audioBase64: string, session: CreateASRSessionResponse) => { - if (!socket || socket.readyState !== WebSocket.OPEN || finalFrameSent) return - if (status === 2) finalFrameSent = true - audioSequence += 1 - socket.send(JSON.stringify(createXunfeiASRFrame(session, status, audioBase64, audioSequence))) - } - - try { - const authReady = await auth.refreshSession() - if (!canUseXunfeiRoute('after_auth_refresh')) return false - if (!authReady) return nativeSpeechStartListeningRef.current('en-US') - - const session = await api.createASRSession({ - provider: 'xunfei', - mode: 'streaming', - languageMode: 'mixed_zh_en', - scenarioKey: scenario.key, - sessionId: snapshotRef.current.sessionId, - endpointSilenceMs: 900, - clientTraceId: `mobile-session-${Date.now()}`, - }) - if (!canUseXunfeiRoute('after_session_create')) { - logVoiceMetric('stt_start_stale_after_bootstrap', { provider: 'xunfei' }) - return false - } - if (session.provider !== 'xunfei' || session.status !== 'created' || session.transport !== 'websocket' || !session.endpointUrl) { - logVoiceMetric('stt_provider_fallback', { requested: 'xunfei', reason: 'session_not_ready' }) - return nativeSpeechStartListeningRef.current('en-US') - } - - logVoiceMetric('stt_bootstrap_start', { provider: 'xunfei' }) - setStatus('session.status.preparing_listening') - await nativeSpeechCancelListeningRef.current() - - socket = new WebSocket(session.endpointUrl) - updateCurrent() - - const finishAudio = () => { - if (!canUseXunfeiRoute('finish_audio')) { - settle('route_inactive', false) - return - } - if (finalFrameSent) return - sendAudioFrame(2, '', session) - void stopPcmCapture('session_endpoint').catch(() => undefined) - } - - const scheduleFinalize = () => { - if (finalizeTimer) clearTimeout(finalizeTimer) - finalizeTimer = setTimeout(finishAudio, session.providerConfig?.eosMs ?? 900) - updateCurrent() - } - - stateSubscription = addPcmStateListener(event => { - logVoiceMetric('stt_pcm_state', { - provider: 'xunfei', - state: event.state, - frameCount: event.frameCount, - totalBytes: event.totalBytes, - message: event.message, - }) - }) - - frameSubscription = addPcmFrameListener((event: PcmCaptureFrameEvent) => { - if (!canUseXunfeiRoute('pcm_frame')) { - settle('route_inactive', false) - return - } - if (!socket || socket.readyState !== WebSocket.OPEN || finalFrameSent) return - frameCount += 1 - totalBytes += event.byteCount - if (frameCount === 1) clearNoFrameTimer() - sendAudioFrame(firstFrame ? 0 : 1, event.audioBase64, session) - firstFrame = false - if (frameCount === 1 || frameCount % 50 === 0) { - logVoiceMetric('stt_pcm_frame', { - provider: 'xunfei', - frameCount, - totalBytes, - elapsedMs: event.elapsedMs, - }) - } - }) - - socket.onopen = () => { - if (!canUseXunfeiRoute('socket_open')) { - settle('route_inactive', false) - return - } - void startPcmCapture({ - sampleRate: session.providerConfig?.sampleRate ?? 16000, - frameDurationMs: session.providerConfig?.frameIntervalMs ?? 40, - }).then(status => { - if (!canUseXunfeiRoute('after_pcm_start')) { - void stopPcmCapture('session_route_inactive').catch(() => undefined) - settle('route_inactive', false) - return - } - listeningStartMsRef.current = Date.now() - logVoiceMetric('stt_start', { provider: 'xunfei' }) - logVoiceMetric('stt_ready', { - provider: 'xunfei', - elapsedMs: Date.now() - streamStartedAt, - sampleRate: status.sampleRate, - frameSizeBytes: status.frameSizeBytes, - }) - setStatus('session.status.listening') - noFrameTimer = setTimeout(() => { - if (settled || frameCount > 0) return - logVoiceMetric('stt_pcm_no_frame', { - provider: 'xunfei', - elapsedMs: Date.now() - streamStartedAt, - pcmStatusFrameCount: status.frameCount, - pcmStatusTotalBytes: status.totalBytes, - }) - settle('pcm_no_frame', false) - handleListeningEndedWithoutTranscript() - }, 1800) - updateCurrent() - }).catch(error => { - logVoiceMetric('stt_provider_error', { - provider: 'xunfei', - message: error instanceof Error ? error.message : 'PCM capture failed', - }) - settle('pcm_error', false) - handleListeningEndedWithoutTranscript() - }) - hardTimer = setTimeout(finishAudio, 15_000) - updateCurrent() - } - - socket.onmessage = event => { - if (!canUseXunfeiRoute('socket_message')) { - settle('route_inactive', false) - return - } - const payload = parseJsonObject(event.data) - const header = getObject(payload?.header) - const code = typeof header?.code === 'number' - ? header.code - : typeof payload?.code === 'number' - ? payload.code - : 0 - if (code !== 0) { - const message = typeof header?.message === 'string' - ? header.message - : typeof payload?.message === 'string' - ? payload.message - : `Xunfei ASR error ${code}` - logVoiceMetric('stt_provider_error', { provider: 'xunfei', code, message }) - settle('provider_error', false) - handleListeningEndedWithoutTranscript() - return - } - - const recognitionResult = extractXunfeiRecognitionResult(payload) - if (recognitionResult?.text) { - if (recognitionResult.pgs === 'rpl' && recognitionResult.rg) { - const [start, end] = recognitionResult.rg - for (let index = start; index <= end; index += 1) transcriptSegments[index] = '' - } - if (recognitionResult.sn != null) { - transcriptSegments[recognitionResult.sn] = recognitionResult.text - } else { - transcriptSegments.push(recognitionResult.text) - } - transcript = transcriptSegments.filter(Boolean).join('').trim() - if (!firstPartialAt) { - firstPartialAt = Date.now() - logVoiceMetric('stt_first_partial', { - provider: 'xunfei', - elapsedMs: firstPartialAt - streamStartedAt, - chars: transcript.length, - }) - } - logVoiceMetric('stt_partial', { provider: 'xunfei', chars: transcript.length }) - scheduleFinalize() - } - - const data = getObject(payload?.data) - const status = typeof header?.status === 'number' - ? header.status - : typeof data?.status === 'number' - ? data.status - : undefined - if (status === 2) { - finalReceived = true - const normalized = transcript.trim() - if (normalized) { - logVoiceMetric('stt_submit', { - provider: 'xunfei', - source: 'xunfei_final', - chars: normalized.length, - elapsedMs: Date.now() - streamStartedAt, - }) - void handleNativeFinalTranscript(normalized) - settle('final', true) - } else { - settle('final', false) - handleListeningEndedWithoutTranscript() - } - } - } - - socket.onerror = () => { - logVoiceMetric('stt_provider_error', { provider: 'xunfei', message: 'WebSocket error' }) - settle('socket_error', false) - handleListeningEndedWithoutTranscript() - } + if (sttRestartCountRef.current === 1) sttRestartStartMsRef.current = Date.now() + scheduleResumeListening(Math.min(250 * Math.pow(2, sttRestartCountRef.current - 1), 8000), false) + }, [scheduleResumeListening]) - socket.onclose = event => { - logVoiceMetric('stt_socket_close', { - provider: 'xunfei', - code: typeof event?.code === 'number' ? event.code : null, - reason: typeof event?.reason === 'string' ? event.reason : '', - wasClean: Boolean(event?.wasClean), - finalReceived, - finalFrameSent, - frameCount, - totalBytes, - }) - if (!finalReceived && !settled) { - settle('socket_closed', false) - handleListeningEndedWithoutTranscript() - } - } - - updateCurrent() - return true - } catch (error) { - logVoiceMetric('stt_provider_error', { - provider: 'xunfei', - message: error instanceof Error ? error.message : 'Xunfei STT failed to start', - }) - settle('start_error', false) - return nativeSpeechStartListeningRef.current('en-US') - } - }, [ - api, auth, availableSessionSttProviders, handleListeningEndedWithoutTranscript, - handleNativeFinalTranscript, logVoiceMetric, scenario.key, - ]) - - const speech = useNativeSpeech({ - onFinalTranscript: handleNativeFinalTranscript, - onListeningEndedWithoutTranscript: handleListeningEndedWithoutTranscript, - onMetric: logVoiceMetric, - }) - - useEffect(() => { - nativeSpeechStartListeningRef.current = speech.startListening - nativeSpeechCancelListeningRef.current = speech.cancelListening - }, [speech.cancelListening, speech.startListening]) - - useEffect(() => { - speechStartListeningRef.current = sessionSttProvider === 'xunfei' - ? startXunfeiSessionListening - : speech.startListening - speechCancelListeningRef.current = sessionSttProvider === 'xunfei' - ? cancelXunfeiSessionListening - : speech.cancelListening - startListeningWithProviderRef.current = (provider, lang) => ( - provider === 'xunfei' - ? startXunfeiSessionListening() - : speech.startListening(lang) - ) - }, [ - cancelXunfeiSessionListening, sessionSttProvider, speech, speech.cancelListening, speech.startListening, - startXunfeiSessionListening, - ]) - - useEffect(() => { - sessionActiveRef.current = isSessionActive - }, [isSessionActive]) + const startSession = useCallback(async () => { + logUserAction('session_start_tap', { scenario: scenario.key }) + if (scenarioSwitching) return + if (auth.state !== 'signed-in') { setActiveTab('settings'); setStatus('login.signin'); return } + setStatus('session.status.preparing_listening') + // Wait for any pending teardown to complete before starting + const pendingTeardown = listeningTeardownRef.current + if (pendingTeardown) await pendingTeardown + await cancelListeningForReason('session_start_reset') - const selectTab = useCallback((tab: Tab) => { - logUserAction('tab_tap', { to: tab, from: activeTabRef.current }) - logVoiceMetric('tab_change', { - from: activeTabRef.current, - to: tab, - sessionActive: sessionActiveRef.current, - canListenOnRoute: canListenOnRouteRef.current, - busy, - playbackActive: playbackActiveRef.current, - audioPlaying: audioPlayingRef.current, - }) - setActiveTab(tab) - if (tab !== 'session') { - canListenOnRouteRef.current = false - playbackEndedAtMsRef.current = null - clearResumeListeningTimer() - endpointRequestRef.current += 1 - pendingNativeTranscriptRef.current = '' - void cancelListeningForReason(`tab:${tab}`) - if (sessionActiveRef.current) setStatus('session.paused') - return + // Hydrate STT provider from SecureStore + if (!sessionSttProviderHydratedRef.current) { + const stored = await SecureStore.getItemAsync(sessionSttProviderStorageKey) + if (stored === 'xunfei' || stored === 'native') { setSessionSttProvider(stored); sessionSttProviderRef.current = stored } + sessionSttProviderHydratedRef.current = true } - canListenOnRouteRef.current = true - if (sessionActiveRef.current && !busy && !playbackActiveRef.current && !audioPlayingRef.current) { - listeningStartMsRef.current = Date.now() - setStatus(listeningStartupStatus()) - void speechStartListeningRef.current('en-US') - } - }, [busy, cancelListeningForReason, clearResumeListeningTimer, listeningStartupStatus, logUserAction, logVoiceMetric]) + const provider = sessionSttProviderRef.current + endpointRequestRef.current += 1; sessionGenerationRef.current += 1 + sttRestartCountRef.current = 0; sttRestartStartMsRef.current = 0 + clearResumeListeningTimer() + playbackActiveRef.current = false; playbackStartedRef.current = false; playbackEndedAtMsRef.current = null + listeningStartMsRef.current = Date.now(); pendingNativeTranscriptRef.current = '' + sessionActiveRef.current = true; setRoutePresence('inSession', 'session_start') + const nextId = apiSessionId ?? `mobile-${Date.now()}` + const nextSnap = startListeningSession(nextId) + snapshotRef.current = nextSnap; messagesRef.current = [] + setSnapshot(nextSnap); setMessages([]); setCorrectionHistory([]) + setAudioUrl(null); playbackEndedAtMsRef.current = null + setPlaybackQueue(createPlaybackQueueSnapshot()); setSummary(null) + setIsSessionActive(true) + setStatus(provider === 'xunfei' ? 'session.status.preparing_listening' : 'session.status.listening') + void startListeningWithProviderRef.current(provider, 'en-US') + }, [logUserAction, scenario, scenarioSwitching, auth.state, setActiveTab, setStatus, cancelListeningForReason, clearResumeListeningTimer, setRoutePresence, apiSessionId]) - async function endSession() { + const endSession = useCallback(async () => { logUserAction('session_stop_tap') if (!canEndSession({ activeSession: isSessionActive, workflowState: snapshot.state })) return - logVoiceMetric('session_end_requested', { - sessionId: snapshot.sessionId, - state: snapshot.state, - messages: messages.length, - activeTab: activeTabRef.current, - pendingTeardown: Boolean(listeningTeardownRef.current), - }) - - // 立即结束 session,不等 API - turnRequestRef.current += 1 - sessionActiveRef.current = false - canListenOnRouteRef.current = false - playbackActiveRef.current = false - audioPlayingRef.current = false - playbackStartedRef.current = false - playbackEndedAtMsRef.current = null - clearResumeListeningTimer() - endpointRequestRef.current += 1 - pendingNativeTranscriptRef.current = '' - audio.stopPlayback() - setAudioUrl(null) - setPlaybackQueue(createPlaybackQueueSnapshot()) + turnRequestRef.current += 1; sessionGenerationRef.current += 1 + sttRestartCountRef.current = 0; sttRestartStartMsRef.current = 0 + sessionActiveRef.current = false; setRoutePresence('outSession', 'session_end') + playbackActiveRef.current = false; audioPlayingRef.current = false + playbackStartedRef.current = false; playbackEndedAtMsRef.current = null + clearResumeListeningTimer(); endpointRequestRef.current += 1; pendingNativeTranscriptRef.current = '' + audio.stopPlayback(); setAudioUrl(null); setPlaybackQueue(createPlaybackQueueSnapshot()) void cancelListeningForReason('session_end') - const endedSnapshot = endActiveSession(snapshot).snapshot - setSnapshot(endedSnapshot) - setIsSessionActive(false) - setStatus('session.ended') - setBusy(false) - - const userTurns = messages.filter(m => m.role === 'user').length - try { - const result = await api.generateSummary({ - sessionId: snapshot.sessionId, - scenario: scenario.name, - messages, - turnNumber: userTurns, - }) - setSummary(result.summary) - await api.syncSession({ - session_id: snapshot.sessionId, - scenario: scenario.name, - accent: accent.name, - turns: userTurns, - messages, - corrections: correctionHistory, - }).catch(() => undefined) - } catch { - // summary 失败不影响 session 已结束的状态 - } - } - - async function selectScenario(key: string) { - if (scenarioSwitching) { - logUserAction('scenario_tap_ignored_switching', { to: key }) - return - } - logUserAction('scenario_tap', { to: key, from: selectedScenarioKey }) - logVoiceMetric('scenario_select_requested', { - from: selectedScenarioKey, - to: key, - activeTab: activeTabRef.current, - sessionActive: sessionActiveRef.current, - canListenOnRoute: canListenOnRouteRef.current, - pendingTeardown: Boolean(listeningTeardownRef.current), - }) - setScenarioSwitching(true) - setStatus('session.status.switching_session') - try { - turnRequestRef.current += 1 - endpointRequestRef.current += 1 - sessionActiveRef.current = false - canListenOnRouteRef.current = false - playbackActiveRef.current = false - audioPlayingRef.current = false - playbackStartedRef.current = false - playbackEndedAtMsRef.current = null - clearResumeListeningTimer() - pendingNativeTranscriptRef.current = '' - audio.stopPlayback() - setBusy(false) - await cancelListeningForReason('scenario_change') - setSelectedScenarioKey(key) - setMessages([]) - setCorrectionHistory([]) - setAudioUrl(null) - setPlaybackQueue(createPlaybackQueueSnapshot()) - setSummary(null) - setSnapshot(createInitialSnapshot('mobile-session')) - setIsSessionActive(false) - setStatus('session.status.scenario_selected') - logVoiceMetric('scenario_selected', { key }) - } finally { - setScenarioSwitching(false) - } - } - - const loadHistory = useCallback(async () => { - if (historyLoading) return - if (auth.state !== 'signed-in') { - setHistoryError(tr('history.auth_required')) - return - } - setHistoryLoading(true) - setHistoryError(null) - try { - const result = await api.listHistory() - setHistorySessions(result.sessions) - setSelectedHistory(result.sessions[0] ?? null) - setSelectedHistoryTurns([]) - } catch (error) { - const requestError = formatApiRequestError(error, { - context: 'mobile_history_list', - presentation: 'inline', - }) - setHistoryError(requestError.displayMessage) - } finally { - setHistoryLoading(false) - } - }, [api, auth.state, historyLoading, tr]) - - useEffect(() => { - if (activeTab !== 'history') return - if (auth.state !== 'signed-in') { - historyAutoLoadRef.current = false - return + const ended = endActiveSession(snapshot).snapshot + setSnapshot(ended); setIsSessionActive(false); setStatus('session.ended'); setBusy(false) + const userTurns = messagesRef.current.filter(m => m.role === 'user').length + const syncPayload: SyncSessionRequest = { + session_id: snapshot.sessionId, + scenario: scenario.name, + accent: accent.name, + turns: userTurns, + messages: messagesRef.current, + corrections: correctionHistory, } - if (historyAutoLoadRef.current) return - historyAutoLoadRef.current = true - void loadHistory() - }, [activeTab, auth.state, loadHistory]) - - async function deleteSession(id: string) { - setHistorySessions(prev => prev.map(s => s.id === id ? { ...s, status: 'deleted' } : s)) try { - const authHeaders = await getAuthHeaders() - await fetchWithTimeout(fetch, `${apiBaseUrl.trim()}/api/session?id=${encodeURIComponent(id)}`, { - method: 'DELETE', - headers: authHeaders as Record, - }).then(res => { - if (res.status === 401) return handleUnauthorized() - return undefined - }) + const result = await api.generateSummary({ sessionId: snapshot.sessionId, scenario: scenario.name, messages: messagesRef.current, turnNumber: userTurns }) + setSummary(result.summary) } catch { - // 静默失败 + logMetric('session_summary_failed') } - } - - async function selectHistorySession(item: HistorySession) { - setSelectedHistory(item) - setSelectedHistoryTurns([]) try { - const result = await api.listSessionTurns(item.id) - setSelectedHistoryTurns(result.turns) + await api.syncSession(syncPayload) + logMetric('session_sync_done', { sessionId: snapshot.sessionId }) } catch (error) { - const requestError = formatApiRequestError(error, { - context: 'mobile_history_turns', - presentation: 'inline', - }) - setHistoryError(requestError.displayMessage) + try { + await enqueueSessionSync(sessionSyncOutboxStorage, syncPayload) + setStatus('session.sync_pending') + logMetric('session_sync_queued', { + sessionId: snapshot.sessionId, + message: error instanceof Error ? error.message : 'unknown', + }) + } catch (storageError) { + setStatus('session.sync_failed') + logMetric('session_sync_outbox_error', { + sessionId: snapshot.sessionId, + message: storageError instanceof Error ? storageError.message : 'unknown', + }) + } } - } + }, [accent.name, api, audio, cancelListeningForReason, clearResumeListeningTimer, correctionHistory, isSessionActive, logMetric, logUserAction, scenario, setBusy, setRoutePresence, setStatus, snapshot]) - const applyPreferences = useCallback((preferences: PreferencesResponse, successMessage?: string) => { - setLocale(preferences.locale === 'zh' ? 'zh' : 'en') - setTtsProvider(preferences.tts_provider ?? 'mock') - setAvailableProviders(preferences.available_providers?.length ? preferences.available_providers : ['mock']) - setTtsSpeed(preferences.tts_speed ?? 1) - if (preferences.tts_voice_id !== undefined) setTtsVoiceId(preferences.tts_voice_id) - if (preferences.voice_profiles) setVoiceProfiles(preferences.voice_profiles) - if (preferences.selected_voice_profile_id !== undefined) setSelectedVoiceProfileId(preferences.selected_voice_profile_id) - if (preferences.xunfei_voices?.configured) setXunfeiVoices(preferences.xunfei_voices.configured) - if (preferences.default_scenario_key) setSelectedScenarioKey(preferences.default_scenario_key) - const profile = preferences.voice_profiles?.find(item => item.id === preferences.selected_voice_profile_id) - if (profile) setSelectedAccentKey(profile.accentKey) - if (preferences.ui_theme && !themeInitializedRef.current) { - themeInitializedRef.current = true - void SecureStore.getItemAsync('theme_set_at').then(localSetAt => { - const serverTs = new Date(preferences.ui_theme_updated_at ?? new Date(0).toISOString()).getTime() - const localTs = localSetAt ? new Date(localSetAt).getTime() : 0 - if (serverTs >= localTs) { - applyThemeLocal(preferences.ui_theme as Parameters[0]) - } + const selectScenario = useCallback(async (key: string) => { + if (scenarioSwitching) return false + if (key === selectedScenarioKey) return true + // Confirmation dialog when switching scenarios during an active session + if (isSessionActive) { + const confirmed = await new Promise(resolve => { + Alert.alert(tr('session.switch_scenario_title'), tr('session.switch_scenario_message'), [ + { text: tr('common.cancel'), style: 'cancel', onPress: () => resolve(false) }, + { text: tr('common.confirm'), style: 'destructive', onPress: () => resolve(true) }, + ]) }) + if (!confirmed) return false } - setSettingsMessage(successMessage ?? tr('session.status.preferences_loaded')) - }, [applyThemeLocal, setLocale, tr]) - - const applyTtsPreferences = useCallback((preferences: PreferencesResponse, successMessage = tr('session.status.preferences_saved')) => { - setTtsProvider(preferences.tts_provider ?? 'mock') - setTtsSpeed(preferences.tts_speed ?? 1) - if (preferences.tts_voice_id !== undefined) setTtsVoiceId(preferences.tts_voice_id) - if (preferences.selected_voice_profile_id !== undefined) setSelectedVoiceProfileId(preferences.selected_voice_profile_id) - const profiles = preferences.voice_profiles ?? voiceProfiles - const profile = profiles.find(item => item.id === preferences.selected_voice_profile_id) - if (profile) setSelectedAccentKey(profile.accentKey) - setSettingsMessage(successMessage) - }, [tr, voiceProfiles]) - - const applyPracticePreferences = useCallback((preferences: PreferencesResponse, successMessage = tr('session.status.practice_defaults_saved')) => { - setTtsProvider(preferences.tts_provider ?? 'mock') - setTtsSpeed(preferences.tts_speed ?? 1) - if (preferences.default_scenario_key) setSelectedScenarioKey(preferences.default_scenario_key) - setSettingsMessage(successMessage) - }, [tr]) - - const applyVoiceProfilePreferences = useCallback((preferences: PreferencesResponse, successMessage = tr('session.status.preferences_saved')) => { - setTtsProvider(preferences.tts_provider ?? 'mock') - if (preferences.tts_voice_id !== undefined) setTtsVoiceId(preferences.tts_voice_id) - if (preferences.selected_voice_profile_id !== undefined) setSelectedVoiceProfileId(preferences.selected_voice_profile_id) - const profiles = preferences.voice_profiles ?? voiceProfiles - const profile = profiles.find(item => item.id === preferences.selected_voice_profile_id) - if (profile) setSelectedAccentKey(profile.accentKey) - setSettingsMessage(successMessage) - }, [tr, voiceProfiles]) - - const applyLocalePreferences = useCallback((preferences: PreferencesResponse, successMessage = tr('session.status.preferences_saved')) => { - setLocale(preferences.locale === 'zh' ? 'zh' : 'en') - setSettingsMessage(successMessage) - }, [setLocale, tr]) - - const fetchSessionSttProviders = useCallback(async () => { - const result = await api.listASRProviders() - const providers: SessionSttProvider[] = ['native'] - if (result.providers.some(provider => provider.key === 'xunfei' && provider.enabled)) { - providers.push('xunfei') - } - return providers - }, [api]) - - const applySessionSttProviders = useCallback((providers: SessionSttProvider[]) => { - setAvailableSessionSttProviders(providers) - if (!providers.includes(sessionSttProvider)) { - sessionSttProviderRef.current = 'native' - setSessionSttProviderState('native') - void SecureStore.setItemAsync(sessionSttProviderStorageKey, 'native') - } - }, [sessionSttProvider]) - - const loadSessionSttProviders = useCallback(async () => { + setScenarioSwitching(true); setStatus('session.status.switching_session') try { - const providers = await fetchSessionSttProviders() - applySessionSttProviders(providers) - sessionSttProvidersLoadedRef.current = true - } catch (error) { - const requestError = formatApiRequestError(error, { - context: 'mobile_asr_providers_load', - presentation: 'silent', - }) - logVoiceMetric('mobile_silent_request_error', requestError.logData) - } - }, [applySessionSttProviders, fetchSessionSttProviders, logVoiceMetric]) + turnRequestRef.current += 1; endpointRequestRef.current += 1; sessionGenerationRef.current += 1 + sttRestartCountRef.current = 0; sttRestartStartMsRef.current = 0 + sessionActiveRef.current = false; setRoutePresence('outSession', 'scenario_change') + playbackActiveRef.current = false; audioPlayingRef.current = false + playbackStartedRef.current = false; playbackEndedAtMsRef.current = null + clearResumeListeningTimer(); pendingNativeTranscriptRef.current = '' + audio.stopPlayback(); setBusy(false) + await cancelListeningForReason('scenario_change') + setSelectedScenarioKey(key); setMessages([]); setCorrectionHistory([]) + setAudioUrl(null); setPlaybackQueue(createPlaybackQueueSnapshot()) + setSummary(null); setSnapshot(createInitialSnapshot('mobile-session')) + setIsSessionActive(false); setStatus('session.status.scenario_selected') + return true + } finally { setScenarioSwitching(false) } + }, [scenarioSwitching, selectedScenarioKey, setStatus, audio, cancelListeningForReason, clearResumeListeningTimer, setBusy, setRoutePresence]) - useEffect(() => appFeedback.subscribe(setActiveFeedback), []) + const playCorrection = useCallback((text: string) => { + logUserAction('play_correction_tap', { chars: text.length }) + clearResumeListeningTimer() + void cancelListeningForReason('play_correction') + void synthesizeCoachSpeech(text).then(voice => { + if (voice.audioUrl) { + isCorrectionPlayingRef.current = true + playbackActiveRef.current = true; playbackStartedRef.current = false; playbackEndedAtMsRef.current = null + setStatus('session.status.playing_reply'); setAudioUrl(voice.audioUrl) + } + }).catch(() => {}) + }, [logUserAction, clearResumeListeningTimer, cancelListeningForReason, synthesizeCoachSpeech, setStatus]) + // ─── Handler Wiring / 回调接线 ─── useEffect(() => { - if (settingsLoading && activeTab === 'settings') { - appFeedback.show({ - message: tr('settings.syncing'), - variant: 'hud', - source: 'settings', - }) - return - } - appFeedback.hide('settings') - }, [activeTab, settingsLoading, tr]) + nativeFinalTranscriptRef.current = handleNativeFinalTranscript + nativeEndedWithoutTranscriptRef.current = handleListeningEndedWithoutTranscript + xunfeiFinalTranscriptRef.current = handleNativeFinalTranscript + xunfeiEndedWithoutTranscriptRef.current = handleListeningEndedWithoutTranscript + }) + // ─── Playback Effects / 播放效果 ─── useEffect(() => { - if (authSubmitting && activeTab === 'settings') { - appFeedback.show({ - message: tr('login.loading'), - variant: 'hud', - source: 'auth', - }) - return + audioPlayingRef.current = audio.isPlaying + if (audio.isPlaying && audioUrl && playbackActiveRef.current && !playbackStartedRef.current) { + playbackStartedRef.current = true; sttPrewarmAudioUrlRef.current = null + void cancelListeningForReason('playback_started') } - appFeedback.hide('auth') - }, [activeTab, authSubmitting, tr]) + }, [audio.isPlaying, audioUrl, cancelListeningForReason]) useEffect(() => { - if (scenarioSwitching) { - appFeedback.show({ - message: tr('session.status.switching_session'), - variant: 'hud', - source: 'session-transition', - }) - return - } - appFeedback.hide('session-transition') - }, [scenarioSwitching, tr]) - - const loadPreferences = useCallback(async (options: { force?: boolean; successMessage?: string } = {}) => { - if (settingsLoadingRef.current && !options.force) return - if (auth.state !== 'signed-in') { - setSettingsMessage(tr('settings.auth_required')) - return - } - const requestId = ++settingsRequestRef.current - setSettingsLoadingFlag(true) - setSettingsMessage(null) - try { - const preferences = await api.getPreferences() - if (requestId !== settingsRequestRef.current) return - applyPreferences(preferences, options.successMessage) - } catch (error) { - const requestError = formatApiRequestError(error, { - context: 'mobile_preferences_load', - presentation: 'inline', - }) - setSettingsMessage(requestError.displayMessage) - } finally { - if (requestId === settingsRequestRef.current) { - setSettingsLoadingFlag(false) - } - } - }, [api, applyPreferences, auth.state, setSettingsLoadingFlag, tr]) - - const loadSettingsDataGroup = useCallback(() => { - if (settingsLoadingRef.current) return () => undefined - if (auth.state !== 'signed-in') { - setSettingsMessage(tr('settings.auth_required')) - return () => undefined - } - + if (!audioUrl || !audio.didJustFinish || audio.isPlaying || !playbackStartedRef.current) return let cancelled = false - const requestId = ++settingsRequestRef.current - setSettingsLoadingFlag(true) - setSettingsMessage(null) - - void runAppOperationGroup({ - source: 'mobile_settings_data', - tasks: { - preferences: () => api.getPreferences(), - providers: fetchSessionSttProviders, - }, - }).then(({ preferences: preferencesResult, providers: providersResult }) => { - if (cancelled || requestId !== settingsRequestRef.current) return - - if (preferencesResult.status === 'fulfilled') { - applyPreferences(preferencesResult.value) - } else { - const requestError = formatApiRequestError(preferencesResult.reason, { - context: 'mobile_preferences_load', - presentation: 'inline', - }) - setSettingsMessage(requestError.displayMessage) + const advance = () => { + if (cancelled) return + if (isCorrectionPlayingRef.current) { + isCorrectionPlayingRef.current = false + playbackActiveRef.current = false; playbackStartedRef.current = false; playbackEndedAtMsRef.current = Date.now() + const gate = { sessionActive: sessionActiveRef.current, routePresence: routePresenceRef.current, canListenOnRoute: canListenOnRouteRef.current, busy: busyRef.current, playbackActive: playbackActiveRef.current, audioPlaying: audioPlayingRef.current, generation: sessionGenerationRef.current, currentGeneration: sessionGenerationRef.current } + setStatus(shouldResumeListening(gate) ? 'session.status.listening' : 'session.status.reply_played') + if (shouldResumeListening(gate)) scheduleResumeListening(900, false) + return } + // Simple: just finish playback and resume listening + playbackActiveRef.current = false; playbackStartedRef.current = false; playbackEndedAtMsRef.current = Date.now() + setStatus('session.status.reply_played') + const gate = { sessionActive: sessionActiveRef.current, routePresence: routePresenceRef.current, canListenOnRoute: canListenOnRouteRef.current, busy: busyRef.current, playbackActive: playbackActiveRef.current, audioPlaying: audioPlayingRef.current, generation: sessionGenerationRef.current, currentGeneration: sessionGenerationRef.current } + if (shouldResumeListening(gate)) scheduleResumeListening() + } + const t = setTimeout(advance, 0) + return () => { cancelled = true; clearTimeout(t) } + }, [audio.didJustFinish, audio.isPlaying, audioUrl, cancelListeningForReason, clearResumeListeningTimer, scheduleResumeListening, setStatus]) - if (providersResult.status === 'fulfilled') { - applySessionSttProviders(providersResult.value) - } else { - const requestError = formatApiRequestError(providersResult.reason, { - context: 'mobile_asr_providers_load', - presentation: 'silent', - }) - logVoiceMetric('mobile_silent_request_error', requestError.logData) + // ─── AppState Listener / 应用状态监听 ─── + useEffect(() => { + const sub = AppState.addEventListener('change', (nextState: AppStateStatus) => { + if (nextState !== 'active') { + setRoutePresence('outSession', `app_state:${nextState}`) + playbackEndedAtMsRef.current = null; clearResumeListeningTimer() + endpointRequestRef.current += 1; pendingNativeTranscriptRef.current = '' + void cancelListeningForReason(`app_state:${nextState}`) + if (sessionActiveRef.current) setStatus('session.paused') + return } - }).finally(() => { - if (!cancelled && requestId === settingsRequestRef.current) { - setSettingsLoadingFlag(false) + setRoutePresence(routePresenceForTab(activeTab), 'app_state:active') + void flushPendingSessionSyncs() + if (canStartSessionListening('app_state_active')) { + listeningStartMsRef.current = Date.now() + if (sessionSttProviderRef.current === 'xunfei') setStatus('session.status.preparing_listening') + else setStatus('session.status.listening') + void speechStartListeningRef.current('en-US') } }) + return () => sub.remove() + }, [activeTab, canStartSessionListening, cancelListeningForReason, clearResumeListeningTimer, flushPendingSessionSyncs, setRoutePresence, setStatus]) - return () => { - cancelled = true - } - }, [ - api, applyPreferences, applySessionSttProviders, auth.state, - fetchSessionSttProviders, logVoiceMetric, setSettingsLoadingFlag, tr, - ]) - - const reloadSettingsData = useCallback(() => loadSettingsDataGroup(), [loadSettingsDataGroup]) - + // ─── Log Enrichment / 日志上下文注入 ─── useEffect(() => { - if (auth.state !== 'signed-in') { - settingsAutoLoadRef.current = false - return - } - if (activeTab !== 'settings' || settingsAutoLoadRef.current) return - settingsAutoLoadRef.current = true - return reloadSettingsData() - }, [activeTab, auth.state, reloadSettingsData]) - - async function saveProvider(provider: string) { - settingsRequestRef.current += 1 - setTtsProvider(provider) - setAudioUrl(null) - playbackEndedAtMsRef.current = null - setSettingsLoadingFlag(true) - setSettingsMessage(null) - if (auth.state !== 'signed-in') { - setSettingsMessage(tr('session.status.preferences_saved')) - setSettingsLoadingFlag(false) - return - } + setEnrichment({ + activeTab, scenarioKey: selectedScenarioKey, sessionActive: isSessionActive, + busy, ttsProvider, sttProvider: sessionSttProvider, + }) + }, [activeTab, selectedScenarioKey, isSessionActive, busy, ttsProvider, sessionSttProvider, setEnrichment]) - try { - const preferences = await api.updatePreferences({ - tts_provider: provider, - default_scenario_key: selectedScenarioKey, - tts_speed: ttsSpeed, - }) - applyTtsPreferences(preferences) - } catch (error) { - const requestError = formatApiRequestError(error, { - context: 'mobile_preferences_save_provider', - presentation: 'inline', - }) - setSettingsMessage(requestError.displayMessage) - } finally { - setSettingsLoadingFlag(false) - } - } + // ─── Cleanup / 清理 ─── + useEffect(() => () => clearResumeListeningTimer(), [clearResumeListeningTimer]) + useEffect(() => appFeedback.subscribe(setActiveFeedback), []) - async function savePracticePreferences() { - settingsRequestRef.current += 1 - setSettingsLoadingFlag(true) - setSettingsMessage(null) - if (auth.state !== 'signed-in') { - setSettingsMessage(tr('session.status.practice_defaults_saved')) - setSettingsLoadingFlag(false) - return - } + // HUD feedback overlay effects + // Placeholder states kept for HUD overlay feedbaack compatibility - try { - const preferences = await api.updatePreferences({ - tts_provider: ttsProvider, - default_scenario_key: selectedScenarioKey, - tts_speed: ttsSpeed, - }) - applyPracticePreferences(preferences) - } catch (error) { - const requestError = formatApiRequestError(error, { - context: 'mobile_preferences_save_practice', - presentation: 'inline', - }) - setSettingsMessage(requestError.displayMessage) - } finally { - setSettingsLoadingFlag(false) + useEffect(() => { + if (scenarioSwitching) { + appFeedback.show({ message: tr('session.status.switching_session'), variant: 'hud', source: 'session-transition' }) + } else { + appFeedback.hide('session-transition') } - } + }, [scenarioSwitching, tr]) - function adjustSpeed(delta: number) { - setTtsSpeed(previous => { - const next = Math.min(1.3, Math.max(0.7, Number((previous + delta).toFixed(1)))) - if (prefSyncTimerRef.current) clearTimeout(prefSyncTimerRef.current) - prefSyncTimerRef.current = setTimeout(() => { - void syncMobilePreferences({ - apiBaseUrl: apiBaseUrl.trim(), - getAuthHeaders: auth.getAuthHeaders, - onUnauthorized: handleUnauthorized, - ttsSpeed: next, - ttsProvider, - defaultScenarioKey: selectedScenarioKey, - }).then(preferences => { - if (preferences) { - applyTtsPreferences(preferences) - } - }) - }, 600) - return next + // ─── STT Provider Preflight / 语音识别提供者预检 ─── + useEffect(() => { + SecureStore.getItemAsync(sessionSttProviderStorageKey).then(value => { + if (value === 'xunfei' || value === 'native') { setSessionSttProvider(value); sessionSttProviderRef.current = value } + sessionSttProviderHydratedRef.current = true }) - } + }, [setSessionSttProvider]) - async function selectVoiceProfile(profile: VoiceProfile) { - if (profile.status !== 'active') return - settingsRequestRef.current += 1 - setAudioUrl(null) - playbackEndedAtMsRef.current = null - setSelectedVoiceProfileId(profile.id) - setTtsProvider(profile.provider) - setTtsVoiceId(profile.providerVoiceId) - setSelectedAccentKey(profile.accentKey) - setSettingsMessage(null) + useEffect(() => { if (auth.state !== 'signed-in') return - - try { - const preferences = await api.updatePreferences({ selected_voice_profile_id: profile.id }) - applyVoiceProfilePreferences(preferences) - } catch (error) { - const requestError = formatApiRequestError(error, { - context: 'mobile_preferences_select_voice_profile', - presentation: 'silent', - }) - logVoiceMetric('mobile_silent_request_error', requestError.logData) - } - } - - async function saveLocalePreference(nextLocale: Locale) { - if (nextLocale === locale) return - setLocale(nextLocale) - - if (auth.state !== 'signed-in') { - setSettingsMessage(tr('settings.auth_required')) + // Fetch available ASR providers from server + api.listASRProviders().then(result => { + const providers: SessionSttProvider[] = ['native'] + if (result.providers.some(p => p.key === 'xunfei' && p.enabled)) providers.push('xunfei') + if (!providers.includes(sessionSttProviderRef.current)) { + sessionSttProviderRef.current = 'native' + setSessionSttProvider('native') + } + }).catch(() => { /* silent — provider list is best-effort */ }) + }, [auth.state, api, setSessionSttProvider]) + + // ─── SessionContext Value / 会话上下文值 ─── + const sessionContext = useMemo(() => ({ + appVersion, applyTtsPreferences, auth, defaultApiBaseUrl, getAuthHeaders, handleUnauthorized, signOut, + snapshot, messages, corrections: correctionHistory, summary, + isSessionActive, status, busy, scenarioSwitching, + locale, tr, + ttsProvider, ttsVoiceId, + selectedScenarioKey, selectedAccentKey, + voiceProfileAccentLabel, voiceProfileAccentRegion, + audioUrl, api, + startSession, endSession, playCorrection, selectScenario, setLocale, submitText: submitTurn, + clearAudio, + }), [applyTtsPreferences, auth, clearAudio, getAuthHeaders, handleUnauthorized, signOut, snapshot, messages, correctionHistory, summary, isSessionActive, status, busy, scenarioSwitching, locale, tr, ttsProvider, ttsVoiceId, selectedScenarioKey, selectedAccentKey, voiceProfileAccentLabel, voiceProfileAccentRegion, audioUrl, api, startSession, endSession, playCorrection, selectScenario, setLocale, submitTurn, playbackQueue, setSelectedAccentKey, setTtsProvider, setTtsVoiceId, setTtsSpeed]) + + // ─── Tab Selection / 标签选择 ─── + const selectTab = useCallback((tab: Tab) => { + logUserAction('tab_tap', { to: tab }) + setActiveTab(tab) + const presence = routePresenceForTab(tab) + setRoutePresence(presence, `tab:${tab}`) + if (presence === 'outSession') { + playbackEndedAtMsRef.current = null; clearResumeListeningTimer() + endpointRequestRef.current += 1; pendingNativeTranscriptRef.current = '' + void cancelListeningForReason(`tab:${tab}`) + if (sessionActiveRef.current) setStatus('session.paused') return } - - const requestId = ++settingsRequestRef.current - setSettingsLoadingFlag(true) - setSettingsMessage(null) - try { - const preferences = await api.updatePreferences({ locale: nextLocale }) - if (requestId !== settingsRequestRef.current) return - applyLocalePreferences(preferences) - } catch (error) { - const requestError = formatApiRequestError(error, { - context: 'mobile_preferences_save_locale', - presentation: 'banner', - }) - setSettingsMessage(requestError.displayMessage) - displayErrorFeedback(requestError, 'mobile_preferences_save_locale') - } finally { - if (requestId === settingsRequestRef.current) { - setSettingsLoadingFlag(false) - } - } - } - - async function submitAuth() { - const normalizedEmail = email.trim() - if (!normalizedEmail || !password || auth.state === 'loading' || authSubmitting) return - logUserAction('auth_submit_tap', { - mode: authMode, - hasEmail: Boolean(normalizedEmail), - passwordLength: password.length, - }) - setAuthSubmitting(true) - try { - const success = await auth.submit(authMode, normalizedEmail, password) - logVoiceMetric('auth_submit_done', { mode: authMode, success }) - if (success) setPassword('') - } catch (error) { - logVoiceMetric('auth_submit_error', { - mode: authMode, - message: error instanceof Error ? error.message : 'Auth submit failed', - }) - } finally { - setAuthSubmitting(false) + // Resume listening when switching to session tab + if (canStartSessionListening('tab_session')) { + listeningStartMsRef.current = Date.now() + setStatus(sessionSttProviderRef.current === 'xunfei' ? 'session.status.preparing_listening' : 'session.status.listening') + void speechStartListeningRef.current('en-US') } - } - - useEffect(() => { - const timer = setTimeout(() => { - void loadSessionSttProviders() - }, 0) - return () => clearTimeout(timer) - }, [loadSessionSttProviders]) - - // 登录后自动拉取偏好 - useEffect(() => { - if (auth.state !== 'signed-in') return - const timer = setTimeout(() => { - loadSettingsDataGroup() - }, 0) - return () => clearTimeout(timer) - }, [auth.state, loadSettingsDataGroup]) - - useEffect(() => { - const subscription = AppState.addEventListener('change', (nextState: AppStateStatus) => { - if (nextState !== 'active') { - logVoiceMetric('app_state_inactive', { - nextState, - sessionActive: sessionActiveRef.current, - activeTab: activeTabRef.current, - }) - canListenOnRouteRef.current = false - playbackEndedAtMsRef.current = null - clearResumeListeningTimer() - endpointRequestRef.current += 1 - pendingNativeTranscriptRef.current = '' - void cancelListeningForReason(`app_state:${nextState}`) - if (sessionActiveRef.current) setStatus('session.paused') - return - } - - canListenOnRouteRef.current = true - logVoiceMetric('app_state_active', { - sessionActive: sessionActiveRef.current, - activeTab: activeTabRef.current, - busy, - playbackActive: playbackActiveRef.current, - audioPlaying: audioPlayingRef.current, - }) - if (auth.state === 'signed-in') { - void loadPreferences() - } + }, [logUserAction, clearResumeListeningTimer, cancelListeningForReason, setRoutePresence, setStatus, canStartSessionListening]) - if (sessionActiveRef.current && !busy && !playbackActiveRef.current && !audioPlayingRef.current) { - listeningStartMsRef.current = Date.now() - setStatus(listeningStartupStatus()) - void speechStartListeningRef.current('en-US') - } - }) - return () => subscription.remove() - }, [auth.state, busy, cancelListeningForReason, clearResumeListeningTimer, listeningStartupStatus, loadPreferences, logVoiceMetric]) - - function playCorrection(text: string) { - logUserAction('play_correction_tap', { chars: text.length }) - clearResumeListeningTimer() - void cancelListeningForReason('play_correction') - void synthesizeCoachSpeech(text).then(voice => { - if (voice.audioUrl) { - isCorrectionPlayingRef.current = true - playbackActiveRef.current = true - playbackStartedRef.current = false - playbackEndedAtMsRef.current = null - setStatus('session.status.playing_reply') - setAudioUrl(voice.audioUrl) - } - }).catch(() => {}) - } + // ─── Render / 渲染 ─── + const accentName = voiceProfileAccentLabel ?? getAccentLabel(accent, locale) + const accentRegion = voiceProfileAccentRegion ?? getAccentRegion(accent, locale) - function renderScreen() { - switch (activeTab) { - case 'session': - return ( - void endSession()} - onPlayCorrection={playCorrection} - onSubmitText={text => { - logUserAction('manual_text_submit', { chars: text.trim().length }) - void submitTurn(text) - }} - /> - ) - case 'home': - return ( - selectTab('session')} - /> - ) - case 'history': - return ( - void loadHistory()} - onSelect={item => void selectHistorySession(item)} - onDelete={id => void deleteSession(id)} - /> - ) - case 'settings': - return ( - { - logUserAction('settings_locale_tap', { locale: localeValue }) - void saveLocalePreference(localeValue as Locale) - }} - onSetTheme={key => { - logUserAction('settings_theme_tap', { theme: key }) - setTheme(key) - }} - onSaveProvider={provider => { - logUserAction('settings_tts_provider_tap', { provider }) - void saveProvider(provider) - }} - onSetSessionSttProvider={provider => { - logUserAction('settings_stt_provider_tap', { provider }) - setSessionSttProvider(provider) - }} - onAdjustSpeed={delta => { - logUserAction('settings_tts_speed_tap', { delta }) - adjustSpeed(delta) - }} - onSavePracticePreferences={() => { - logUserAction('settings_save_preferences_tap') - void savePracticePreferences() - }} - onLoadPreferences={() => { - logUserAction('settings_reload_tap') - reloadSettingsData() - }} - onSelectVoiceProfile={profile => { - logUserAction('settings_voice_profile_tap', { profileId: profile.id, provider: profile.provider }) - void selectVoiceProfile(profile) - }} - onSetEmail={setEmail} - onSetPassword={setPassword} - onSetAuthMode={mode => { - logUserAction('auth_mode_tap', { mode }) - setAuthMode(mode) - }} - onSubmitAuth={() => void submitAuth()} - onSignOut={() => { - logUserAction('auth_sign_out_tap') - void auth.signOut() - }} - onSetApiBaseUrl={value => { - logUserAction('settings_api_base_url_edit', { hasValue: Boolean(value.trim()) }) - updateApiBaseUrl(value) - }} - onResetApiBaseUrl={() => { - logUserAction('settings_api_base_url_reset_tap') - resetApiBaseUrl() - }} - onClearVoiceMetrics={() => { - logUserAction('diagnostics_clear_tap') - setVoiceMetrics([]) - }} - onShareVoiceMetrics={() => { - logUserAction('diagnostics_share_tap', { entries: voiceMetrics.length }) - void Share.share({ - title: 'MeteorVoice voice diagnostics', - message: voiceMetricsText || 'No voice metrics yet.', - }) - }} - onShareASREvaluation={() => { - logUserAction('diagnostics_asr_share_tap', { entries: voiceMetrics.length }) - void Share.share({ - title: 'MeteorVoice ASR P4 evaluation', - message: asrEvaluationText, - }) - }} - /> - ) - } + if (children !== undefined) { + return ( + + {children} + + + ) } - const styles = makeStyles() - return ( - - - {renderScreen()} - - - - - {(['home', 'session', 'history', 'settings'] as Tab[]).map(tab => ( - selectTab(tab)} style={[styles.tabItem, activeTab === tab && styles.tabItemActive]}> - - - {tr(TAB_LABELS[tab])} - - - ))} - - - + ) - - function makeStyles() { - return StyleSheet.create({ - shell: { flex: 1, backgroundColor: C.bg }, - content: { flex: 1 }, - tabBarWrapper: { paddingHorizontal: 16, paddingBottom: 8, paddingTop: 6, backgroundColor: C.bg }, - tabBar: { - flexDirection: 'row', - backgroundColor: C.surface, - borderRadius: 24, borderWidth: 1, borderColor: C.border, - paddingVertical: 4, paddingHorizontal: 4, - }, - tabItem: { flex: 1, alignItems: 'center', paddingVertical: 8, gap: 2, borderRadius: 20 }, - tabItemActive: { backgroundColor: C.accent }, - tabLabel: { fontSize: 10, color: C.textMuted, fontWeight: '600' }, - tabLabelActive: { color: C.cream }, - }) - } + /* eslint-enable react-hooks/exhaustive-deps */ } diff --git a/apps/mobile/src/AppShell.tsx b/apps/mobile/src/AppShell.tsx new file mode 100644 index 0000000..7f32d46 --- /dev/null +++ b/apps/mobile/src/AppShell.tsx @@ -0,0 +1,154 @@ +/** + * Mobile app shell — tab navigation and screen composition. + * 移动端外壳 — 标签导航与页面组合。 + */ + +import { + Pressable, + SafeAreaView, + StyleSheet, + Text, + View, +} from 'react-native' + +import type { MeteorVoiceApiClient } from '@meteorvoice/api-client' +import type { + AppFeedbackState, + Locale, + TranslateFn, +} from '@meteorvoice/shared' +import { + scenarios, +} from '@meteorvoice/shared' + +import type { SessionContextValue } from './SessionContext' +import type { MobileAuthState } from './mobileAuth' +import type { Tab } from './sessionRuntime' +import { AppFeedbackOverlay } from './components/AppFeedbackOverlay' +import { HistoryScreen } from './screens/HistoryScreen' +import { HomeScreen } from './screens/HomeScreen' +import { SessionScreen } from './screens/SessionScreen' +import { SettingsScreen } from './screens/SettingsScreen' +import { SessionContext } from './SessionContext' +import { useTheme } from './ThemeProvider' + +const TAB_LABELS: Record = { + history: 'nav.history', + home: 'nav.home', + session: 'nav.practice', + settings: 'nav.settings', +} + +interface AppShellProps { + accentName: string + accentRegion: string + activeFeedback: AppFeedbackState | null + activeTab: Tab + api: MeteorVoiceApiClient + appVersion: string + auth: MobileAuthState + defaultApiBaseUrl: string + getAuthHeaders: () => Promise + handleUnauthorized: () => void + locale: Locale + scenarioDescription: string + scenarioDifficulty: string + scenarioIcon: string + scenarioName: string + selectTab: (tab: Tab) => void + sessionContext: SessionContextValue + setActiveTab: (tab: Tab) => void + setLocale: (locale: Locale) => void + signOut: (nextMessage?: string | null) => Promise + tr: TranslateFn +} + +function TabIcon({ tab, color }: { color: string; tab: Tab }) { + if (tab === 'home') return ( + + + + + ) + if (tab === 'session') return ( + + + + + ) + if (tab === 'history') return ( + + {[0, 1, 2].map(i => )} + + ) + return ( + + + + {[0, 45, 90, 135].map(deg => ( + + ))} + + + ) +} + +export function AppShell({ + accentName, + accentRegion, + activeFeedback, + activeTab, + api, + appVersion, + auth, + defaultApiBaseUrl, + getAuthHeaders, + handleUnauthorized, + locale, + scenarioDescription, + scenarioDifficulty, + scenarioIcon, + scenarioName, + selectTab, + sessionContext, + setActiveTab, + setLocale, + signOut, + tr, +}: AppShellProps) { + const { C } = useTheme() + const styles = StyleSheet.create({ + content: { flex: 1 }, + shell: { flex: 1, backgroundColor: C.bg }, + tabBar: { flexDirection: 'row', backgroundColor: C.surface, borderRadius: 24, borderWidth: 1, borderColor: C.border, paddingVertical: 4, paddingHorizontal: 4 }, + tabBarWrapper: { paddingHorizontal: 16, paddingBottom: 8, paddingTop: 6, backgroundColor: C.bg }, + tabItem: { flex: 1, alignItems: 'center', paddingVertical: 8, gap: 2, borderRadius: 20 }, + tabItemActive: { backgroundColor: C.accent }, + tabLabel: { fontSize: 10, color: C.textMuted, fontWeight: '600' }, + tabLabelActive: { color: C.cream }, + }) + + return ( + + + + {activeTab === 'home' && setActiveTab('session')} />} + {activeTab === 'session' && } + {activeTab === 'history' && } + {activeTab === 'settings' && } + + + + + + {(['home', 'session', 'history', 'settings'] as Tab[]).map(tab => ( + selectTab(tab)} style={[styles.tabItem, activeTab === tab && styles.tabItemActive]}> + + {tr(TAB_LABELS[tab])} + + ))} + + + + ) +} diff --git a/apps/mobile/src/LogContext.tsx b/apps/mobile/src/LogContext.tsx new file mode 100644 index 0000000..34f8365 --- /dev/null +++ b/apps/mobile/src/LogContext.tsx @@ -0,0 +1,82 @@ +/** + * LogProvider — global logging and metrics system. + * 全局日志和指标系统。 + * + * Independent of session lifecycle. Any component can useLog() to log metrics + * or user actions. AppInner injects session enrichment via setEnrichment(). + * 独立于会话生命周期。任何组件都可以通过 useLog() 记录指标或用户操作。 + * AppInner 通过 setEnrichment() 注入会话上下文。 + */ +import { + createContext, + useCallback, + useContext, + useMemo, + useRef, + useState, +} from 'react' + +import type { VoiceMetricEntry } from './sessionRuntime' +import { createASREvaluationReport } from './sessionRuntime' + +export interface LogContextValue { + asrEvaluationText: string + clearVoiceMetrics: () => void + logMetric: (stage: string, data?: Record) => void + logUserAction: (action: string, data?: Record) => void + setEnrichment: (data: Record | null) => void + voiceMetrics: VoiceMetricEntry[] + voiceMetricsText: string +} + +const LogContext = createContext(null) + +export function useLog(): LogContextValue { + const ctx = useContext(LogContext) + if (!ctx) { + throw new Error('useLog must be used within LogProvider') + } + return ctx +} + +export function LogProvider({ children }: { children: React.ReactNode }) { + const [voiceMetrics, setVoiceMetrics] = useState([]) + const voiceMetricSeqRef = useRef(0) + const enrichmentRef = useRef | null>(null) + + const setEnrichment = useCallback((data: Record | null) => { + enrichmentRef.current = data + }, []) + + const logMetric = useCallback((stage: string, data: Record = {}) => { + const seq = ++voiceMetricSeqRef.current + const enriched = { ...enrichmentRef.current, metricSeq: seq, ...data } + const entry = { ts: Date.now(), stage, data: enriched } + console.info('[voice-metrics]', JSON.stringify(entry)) + setVoiceMetrics(previous => [...previous.slice(-239), entry]) + }, []) + + const logUserAction = useCallback((action: string, data: Record = {}) => { + logMetric('user_action', { action, ...data }) + }, [logMetric]) + + const clearVoiceMetrics = useCallback(() => setVoiceMetrics([]), []) + + const voiceMetricsText = useMemo(() => + voiceMetrics.map(e => `${new Date(e.ts).toLocaleTimeString()} ${e.stage} ${JSON.stringify(e.data)}`).join('\n'), + [voiceMetrics]) + + const asrEvaluationText = useMemo(() => createASREvaluationReport(voiceMetrics), [voiceMetrics]) + + const value = useMemo(() => ({ + asrEvaluationText, + clearVoiceMetrics, + logMetric, + logUserAction, + setEnrichment, + voiceMetrics, + voiceMetricsText, + }), [asrEvaluationText, clearVoiceMetrics, logMetric, logUserAction, setEnrichment, voiceMetrics, voiceMetricsText]) + + return {children} +} diff --git a/apps/mobile/src/SessionContext.tsx b/apps/mobile/src/SessionContext.tsx new file mode 100644 index 0000000..5302649 --- /dev/null +++ b/apps/mobile/src/SessionContext.tsx @@ -0,0 +1,72 @@ +/** + * SessionContext — session state and operations shared across screens. + * 会话上下文 — 跨 Screen 共享的会话状态和操作。 + * + * AppInner acts as the provider, populating all fields from orchestration state. + * Screen components consume via useSession() instead of prop drilling. + * Only data keys (not display labels) live here — consumers compute their own labels. + * AppInner 作为 provider,编排状态填充所有字段。Screen 组件通过 useSession() 消费, + * 无需 prop drilling。Context 只放数据 key,显示标签由消费者自己计算。 + */ +import { + createContext, + useContext, +} from 'react' + +import type { + MeteorVoiceApiClient, + PreferencesResponse, +} from '@meteorvoice/api-client' +import type { WorkflowSnapshot } from '@meteorvoice/session-core' +import type { + ConversationMessage, + ConversationResponse, + Locale, + TranslateFn, +} from '@meteorvoice/shared' +import type { MobileAuthState } from './mobileAuth' + +export interface SessionContextValue { + api: MeteorVoiceApiClient + appVersion: string + applyTtsPreferences: (preferences: PreferencesResponse) => void + auth: MobileAuthState + audioUrl: string | null + busy: boolean + clearAudio: () => void + corrections: ConversationResponse['corrections'] + endSession: () => Promise + defaultApiBaseUrl: string + getAuthHeaders: () => Promise + handleUnauthorized: () => void + isSessionActive: boolean + locale: Locale + messages: ConversationMessage[] + playCorrection: (text: string) => void + scenarioSwitching: boolean + selectScenario: (key: string) => Promise + selectedAccentKey: string + selectedScenarioKey: string + setLocale: (l: Locale) => void + signOut: (nextMessage?: string | null) => Promise + snapshot: WorkflowSnapshot + startSession: () => Promise + status: string + submitText: (text: string) => void + summary: string | null + tr: TranslateFn + ttsProvider: string + ttsVoiceId: string | null + voiceProfileAccentLabel: string | null + voiceProfileAccentRegion: string | null +} + +const SessionContext = createContext(null) + +export function useSession(): SessionContextValue { + const ctx = useContext(SessionContext) + if (!ctx) throw new Error('useSession must be used within AppInner (SessionContext.Provider)') + return ctx +} + +export { SessionContext } diff --git a/apps/mobile/src/ThemeProvider.tsx b/apps/mobile/src/ThemeProvider.tsx index 570eabd..430c4d3 100644 --- a/apps/mobile/src/ThemeProvider.tsx +++ b/apps/mobile/src/ThemeProvider.tsx @@ -1,6 +1,22 @@ -import { createContext, useContext, useState, useEffect, type ReactNode } from 'react' +/** + * Theme context provider. + * 主题上下文提供者。 + */ + +import type { ReactNode } from 'react' import * as SecureStore from 'expo-secure-store' -import { themes, type ThemeKey, type ThemeColors } from './theme' +import { + createContext, + useContext, + useEffect, + useState, +} from 'react' + +import type { + ThemeColors, + ThemeKey, +} from './theme' +import { themes } from './theme' const THEME_KEY = 'app_theme' diff --git a/apps/mobile/src/components/AppFeedbackOverlay.tsx b/apps/mobile/src/components/AppFeedbackOverlay.tsx index f7b9769..feaf2fd 100644 --- a/apps/mobile/src/components/AppFeedbackOverlay.tsx +++ b/apps/mobile/src/components/AppFeedbackOverlay.tsx @@ -1,6 +1,19 @@ +/** + * Global feedback overlay (loading/error/toast). + * 全局反馈遮罩。 + */ + import { useEffect } from 'react' -import { ActivityIndicator, StyleSheet, Text, View } from 'react-native' -import { hideAppFeedback, type AppFeedbackState } from '@meteorvoice/shared' +import { + ActivityIndicator, + StyleSheet, + Text, + View, +} from 'react-native' + +import type { AppFeedbackState } from '@meteorvoice/shared' +import { hideAppFeedback } from '@meteorvoice/shared' + import { useTheme } from '../ThemeProvider' type Props = { diff --git a/apps/mobile/src/components/BottomSheet.tsx b/apps/mobile/src/components/BottomSheet.tsx index bea652d..7153269 100644 --- a/apps/mobile/src/components/BottomSheet.tsx +++ b/apps/mobile/src/components/BottomSheet.tsx @@ -1,12 +1,30 @@ +/** + * Bottom sheet with tabs for corrections and transcript. + * 底部弹窗(纠错/转录标签页)。 + */ + import { useMemo } from 'react' -import { Modal, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native' +import { + Modal, + Pressable, + ScrollView, + StyleSheet, + Text, + View, +} from 'react-native' + +import type { + ConversationMessage, + ConversationResponse, + TranslateFn, +} from '@meteorvoice/shared' + import { useTheme } from '../ThemeProvider' -import type { ConversationMessage, ConversationResponse } from '@meteorvoice/shared' type Tab = 'corrections' | 'transcript' interface Props { - tr: (key: string) => string + tr: TranslateFn visible: boolean onClose: () => void activeTab: Tab @@ -112,4 +130,3 @@ export function BottomSheet({ tr, visible, onClose, activeTab, onTabChange, corr ) } - diff --git a/apps/mobile/src/components/DiagnosticsSection.tsx b/apps/mobile/src/components/DiagnosticsSection.tsx new file mode 100644 index 0000000..6577670 --- /dev/null +++ b/apps/mobile/src/components/DiagnosticsSection.tsx @@ -0,0 +1,69 @@ +/** + * Voice diagnostics settings section. + * 语音诊断设置区块。 + */ + +import { + Pressable, + ScrollView, + StyleProp, + Text, + TextStyle, + View, + ViewStyle, +} from 'react-native' + +type DiagnosticsSectionStyles = { + card: StyleProp + cardHeader: StyleProp + cardTitle: StyleProp + chipRow: StyleProp + diagnosticsBox: StyleProp + diagnosticsText: StyleProp + smallBtn: StyleProp + smallBtnTxt: StyleProp +} + +interface DiagnosticsSectionProps { + asrEvaluationText: string + onClearVoiceMetrics: () => void + onShareASREvaluation: () => void + onShareVoiceMetrics: () => void + styles: DiagnosticsSectionStyles + voiceMetricsText: string +} + +export function DiagnosticsSection({ + asrEvaluationText, + onClearVoiceMetrics, + onShareASREvaluation, + onShareVoiceMetrics, + styles, + voiceMetricsText, +}: DiagnosticsSectionProps) { + return ( + + + Voice diagnostics + + + Logs + + + ASR + + + Clear + + + + + + + {voiceMetricsText || asrEvaluationText || 'No voice metrics yet.'} + + + + + ) +} diff --git a/apps/mobile/src/components/VoiceWaveform.tsx b/apps/mobile/src/components/VoiceWaveform.tsx index 2bdebb8..3d73284 100644 --- a/apps/mobile/src/components/VoiceWaveform.tsx +++ b/apps/mobile/src/components/VoiceWaveform.tsx @@ -1,5 +1,18 @@ -import { useEffect, useMemo } from 'react' -import { Animated, StyleSheet, View } from 'react-native' +/** + * Animated voice waveform visualization. + * 语音波形动画组件。 + */ + +import { + useEffect, + useMemo, +} from 'react' +import { + Animated, + StyleSheet, + View, +} from 'react-native' + import { useTheme } from '../ThemeProvider' export type WaveformMode = 'idle' | 'listening' | 'transcribing' | 'thinking' | 'speaking' | 'paused' | 'ended' diff --git a/apps/mobile/src/hooks/useAuthFormState.ts b/apps/mobile/src/hooks/useAuthFormState.ts new file mode 100644 index 0000000..1d8c22d --- /dev/null +++ b/apps/mobile/src/hooks/useAuthFormState.ts @@ -0,0 +1,54 @@ +/** + * Mobile account form state. + * 移动端账号表单状态。 + */ + +import { + useCallback, + useState, +} from 'react' + +import type { TranslateFn } from '@meteorvoice/shared' +import { appFeedback } from '@meteorvoice/shared' + +import type { MobileAuthState } from '../mobileAuth' + +interface UseAuthFormStateInput { + auth: MobileAuthState + tr: TranslateFn +} + +export function useAuthFormState({ + auth, + tr, +}: UseAuthFormStateInput) { + const [authMode, setAuthMode] = useState<'sign-in' | 'sign-up'>('sign-in') + const [authSubmitting, setAuthSubmitting] = useState(false) + const [email, setEmail] = useState('') + const [password, setPassword] = useState('') + + const submitAuth = useCallback(async () => { + const normalized = email.trim() + if (!normalized || !password || auth.state === 'loading' || authSubmitting) return + setAuthSubmitting(true) + appFeedback.show({ message: tr('login.loading'), variant: 'hud', source: 'auth' }) + try { + const success = await auth.submit(authMode, normalized, password) + if (success) setPassword('') + } finally { + setAuthSubmitting(false) + appFeedback.hide('auth') + } + }, [auth, authMode, authSubmitting, email, password, tr]) + + return { + authMode, + authSubmitting, + email, + password, + setAuthMode, + setEmail, + setPassword, + submitAuth, + } +} diff --git a/apps/mobile/src/hooks/useHistoryScreenState.ts b/apps/mobile/src/hooks/useHistoryScreenState.ts new file mode 100644 index 0000000..b7c8439 --- /dev/null +++ b/apps/mobile/src/hooks/useHistoryScreenState.ts @@ -0,0 +1,118 @@ +/** + * History screen state and data operations. + * 历史页面状态与数据操作。 + */ + +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react' + +import type { + HistorySession, + MeteorVoiceApiClient, + SessionTurnDto, +} from '@meteorvoice/api-client' +import { + formatApiRequestError, + MeteorVoiceApiError, +} from '@meteorvoice/api-client' + +interface UseHistoryScreenStateInput { + api: MeteorVoiceApiClient + handleUnauthorized: () => void +} + +export function useHistoryScreenState({ + api, + handleUnauthorized, +}: UseHistoryScreenStateInput) { + const [expandedId, setExpandedId] = useState(null) + const [filterScenario, setFilterScenario] = useState(null) + const [sessions, setSessions] = useState([]) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const [selectedHistory, setSelectedHistory] = useState(null) + const [selectedTurns, setSelectedTurns] = useState([]) + const autoLoadRef = useRef(false) + + const loadHistory = useCallback(async () => { + if (loading) return + setLoading(true) + setError(null) + try { + const result = await api.listHistory() + setSessions(result.sessions) + setSelectedHistory(result.sessions[0] ?? null) + setSelectedTurns([]) + } catch (error) { + const requestError = formatApiRequestError(error, { context: 'mobile_history_list', presentation: 'inline' }) + setError(requestError.displayMessage) + } finally { + setLoading(false) + } + }, [api, loading]) + + useEffect(() => { + if (autoLoadRef.current) return + autoLoadRef.current = true + void loadHistory() + }, [loadHistory]) + + const deleteSession = useCallback(async (id: string) => { + setSessions(prev => prev.map(session => session.id === id ? { ...session, status: 'deleted' as const } : session)) + try { + await api.deleteSession(id) + } catch (error) { + if (error instanceof MeteorVoiceApiError && error.status === 401) { + handleUnauthorized() + } + } + }, [api, handleUnauthorized]) + + const selectHistory = useCallback(async (item: HistorySession) => { + setSelectedHistory(item) + setSelectedTurns([]) + try { + const result = await api.listSessionTurns(item.id) + setSelectedTurns(result.turns) + } catch (error) { + const requestError = formatApiRequestError(error, { context: 'mobile_history_turns', presentation: 'inline' }) + setError(requestError.displayMessage) + } + }, [api]) + + const toggle = useCallback((id: string | number) => { + if (expandedId === id) { + setExpandedId(null) + return + } + const nextSession = sessions.find(session => session.id === id) + if (!nextSession) return + setExpandedId(id) + void selectHistory(nextSession) + }, [expandedId, selectHistory, sessions]) + + const filtered = useMemo(() => ( + filterScenario + ? sessions.filter(session => session.scenario_key === filterScenario || session.scenario === filterScenario) + : sessions + ), [filterScenario, sessions]) + + return { + deleteSession, + error, + expandedId, + filtered, + filterScenario, + loadHistory, + loading, + selectedHistory, + selectedTurns, + setFilterScenario, + toggle, + } +} diff --git a/apps/mobile/src/hooks/useSettingsPreferencesState.ts b/apps/mobile/src/hooks/useSettingsPreferencesState.ts new file mode 100644 index 0000000..505024e --- /dev/null +++ b/apps/mobile/src/hooks/useSettingsPreferencesState.ts @@ -0,0 +1,409 @@ +/** + * Settings preferences state and side-effect operations. + * 设置偏好状态与副作用操作。 + */ + +import * as SecureStore from 'expo-secure-store' +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react' +import type { PreferencesResponse } from '@meteorvoice/api-client' +import type { + Locale, + TranslateFn, + VoiceProfile, +} from '@meteorvoice/shared' +import { + createMeteorVoiceApiClient, + formatApiRequestError, +} from '@meteorvoice/api-client' +import { + appFeedback, + displayErrorFeedback, + runAppOperationGroup, +} from '@meteorvoice/shared' + +import type { MobileAuthState } from '../mobileAuth' +import type { XunfeiVoice } from '../mobilePreferences' +import type { ThemeKey } from '../theme' +import { syncMobilePreferences } from '../mobilePreferences' + +interface UseSettingsPreferencesStateInput { + auth: MobileAuthState + clearAudio: () => void + ctxAccentKey: string + ctxScenarioKey: string + ctxTtsProvider: string + ctxTtsVoiceId: string | null + defaultApiBaseUrl: string + getAuthHeaders: () => Promise + handleUnauthorized: () => void + locale: Locale + logMetric: (name: string, data?: Record) => void + onLocaleChange: (locale: Locale) => void + onTtsPreferencesChange: (preferences: PreferencesResponse) => void + setThemeLocal: (theme: ThemeKey) => void + tr: TranslateFn +} + +export function useSettingsPreferencesState({ + auth, + clearAudio, + ctxAccentKey, + ctxScenarioKey, + ctxTtsProvider, + ctxTtsVoiceId, + defaultApiBaseUrl, + getAuthHeaders, + handleUnauthorized, + locale, + logMetric, + onLocaleChange, + onTtsPreferencesChange, + setThemeLocal, + tr, +}: UseSettingsPreferencesStateInput) { + // ─── State / 状态 ─── + const [settingsLoading, setSettingsLoading] = useState(false) + const [settingsMessage, setSettingsMessage] = useState(null) + const settingsRequestRef = useRef(0) + const settingsLoadingRef = useRef(false) + const settingsAutoLoadRef = useRef(false) + const prefSyncTimerRef = useRef | null>(null) + const themeInitializedRef = useRef(false) + + // Preferences state + const [ttsProvider, setTtsProvider] = useState(ctxTtsProvider ?? 'mock') + const [availableProviders, setAvailableProviders] = useState(['mock']) + const [ttsSpeed, setTtsSpeedLocal] = useState(1) + const [ttsVoiceId, setTtsVoiceId] = useState(ctxTtsVoiceId) + const [voiceProfiles, setVoiceProfiles] = useState([]) + const [selectedVoiceProfileId, setSelectedVoiceProfileId] = useState(null) + const [xunfeiVoices, setXunfeiVoices] = useState([]) + const [sessionSttProvider, setSessionSttProviderLocal] = useState<'native' | 'xunfei'>('native') + const [availableSessionSttProviders, setAvailableSessionSttProviders] = useState<('native' | 'xunfei')[]>(['native']) + const [apiBaseUrl, setApiBaseUrlRaw] = useState(defaultApiBaseUrl) + const [apiBaseUrlSource, setApiBaseUrlSource] = useState<'default' | 'user'>('default') + + // Hydrate API URL from SecureStore on mount + useEffect(() => { + SecureStore.getItemAsync('api_base_url').then(v => { + const stored = v?.trim() + if (stored && stored !== defaultApiBaseUrl) { setApiBaseUrlRaw(stored); setApiBaseUrlSource('user') } + }) + }, [defaultApiBaseUrl]) + + const setApiBaseUrl = useCallback((value: string) => { + setApiBaseUrlRaw(value) + const normalized = value.trim() + if (!normalized || normalized === defaultApiBaseUrl) { + setApiBaseUrlSource('default') + void SecureStore.deleteItemAsync('api_base_url') + } else { + setApiBaseUrlSource('user') + void SecureStore.setItemAsync('api_base_url', normalized) + } + }, [defaultApiBaseUrl]) + + const api = useMemo(() => createMeteorVoiceApiClient({ + baseUrl: apiBaseUrl.trim(), + headers: getAuthHeaders, + onUnauthorized: handleUnauthorized, + }), [apiBaseUrl, getAuthHeaders, handleUnauthorized]) + + const setSettingsLoadingFlag = useCallback((loading: boolean) => { + settingsLoadingRef.current = loading + setSettingsLoading(loading) + if (loading) { + appFeedback.show({ message: tr('settings.syncing'), variant: 'hud', source: 'settings' }) + } else { + appFeedback.hide('settings') + } + }, [tr]) + + const setLocale = useCallback((l: Locale) => { + onLocaleChange(l) + }, [onLocaleChange]) + const setTheme = useCallback((k: ThemeKey) => { setThemeLocal(k) }, [setThemeLocal]) + + // ─── Preference Helpers / 偏好辅助 ─── + const applyTtsPreferences = useCallback((prefs: PreferencesResponse, msg = tr('session.status.preferences_saved')) => { + setTtsProvider(prefs.tts_provider ?? 'mock') + setTtsSpeedLocal(prefs.tts_speed ?? 1) + if (prefs.tts_voice_id !== undefined) setTtsVoiceId(prefs.tts_voice_id) + if (prefs.selected_voice_profile_id !== undefined) setSelectedVoiceProfileId(prefs.selected_voice_profile_id) + const profiles = prefs.voice_profiles ?? voiceProfiles + const profile = profiles.find(p => p.id === prefs.selected_voice_profile_id) + if (profile && !ctxAccentKey) { /* accentKey managed by SessionContext */ } + onTtsPreferencesChange(prefs) + setSettingsMessage(msg) + }, [tr, voiceProfiles, ctxAccentKey, onTtsPreferencesChange]) + + const applyPracticePreferences = useCallback((prefs: PreferencesResponse, msg = tr('session.status.practice_defaults_saved')) => { + setTtsProvider(prefs.tts_provider ?? 'mock') + setTtsSpeedLocal(prefs.tts_speed ?? 1) + onTtsPreferencesChange(prefs) + setSettingsMessage(msg) + }, [tr, onTtsPreferencesChange]) + + const applyVoiceProfilePreferences = useCallback((prefs: PreferencesResponse, msg = tr('session.status.preferences_saved')) => { + setTtsProvider(prefs.tts_provider ?? 'mock') + if (prefs.tts_voice_id !== undefined) setTtsVoiceId(prefs.tts_voice_id) + if (prefs.selected_voice_profile_id !== undefined) setSelectedVoiceProfileId(prefs.selected_voice_profile_id) + onTtsPreferencesChange(prefs) + setSettingsMessage(msg) + }, [tr, onTtsPreferencesChange]) + + const applySessionSttProviders = useCallback((providers: ('native' | 'xunfei')[]) => { + setAvailableSessionSttProviders(providers) + if (!providers.includes(sessionSttProvider)) { + setSessionSttProviderLocal('native') + void SecureStore.setItemAsync('session_stt_provider', 'native') + } + }, [sessionSttProvider]) + + // ─── Load Operations / 加载操作 ─── + const loadPreferences = useCallback(async (options: { force?: boolean; successMessage?: string } = {}) => { + if (settingsLoadingRef.current && !options.force) return + if (auth.state !== 'signed-in') { setSettingsMessage(tr('settings.auth_required')); return } + const requestId = ++settingsRequestRef.current + setSettingsLoadingFlag(true) + setSettingsMessage(null) + try { + const prefs = await api.getPreferences() + if (requestId !== settingsRequestRef.current) return + setLocale(prefs.locale === 'zh' ? 'zh' : 'en') + setTtsProvider(prefs.tts_provider ?? 'mock') + setAvailableProviders(prefs.available_providers?.length ? prefs.available_providers : ['mock']) + setTtsSpeedLocal(prefs.tts_speed ?? 1) + if (prefs.tts_voice_id !== undefined) setTtsVoiceId(prefs.tts_voice_id) + if (prefs.voice_profiles) setVoiceProfiles(prefs.voice_profiles) + if (prefs.selected_voice_profile_id !== undefined) setSelectedVoiceProfileId(prefs.selected_voice_profile_id) + onTtsPreferencesChange(prefs) + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const ext = prefs as Record + if (ext.xunfei_voices?.configured) setXunfeiVoices(ext.xunfei_voices.configured) + if (prefs.ui_theme && !themeInitializedRef.current) { + themeInitializedRef.current = true + void SecureStore.getItemAsync('theme_set_at').then(localSetAt => { + const serverTs = new Date(ext.ui_theme_updated_at ?? new Date(0).toISOString()).getTime() + const localTs = localSetAt ? new Date(localSetAt).getTime() : 0 + if (serverTs >= localTs) setThemeLocal(prefs.ui_theme as ThemeKey) + }) + } + setSettingsMessage(options.successMessage ?? tr('session.status.preferences_loaded')) + } catch (error) { + const reqErr = formatApiRequestError(error, { context: 'mobile_preferences_load', presentation: 'inline' }) + setSettingsMessage(reqErr.displayMessage) + } finally { + if (requestId === settingsRequestRef.current) setSettingsLoadingFlag(false) + } + }, [api, auth.state, onTtsPreferencesChange, setSettingsLoadingFlag, setLocale, setThemeLocal, tr]) + + const loadSettingsDataGroup = useCallback(() => { + if (settingsLoadingRef.current) return () => undefined + if (auth.state !== 'signed-in') { setSettingsMessage(tr('settings.auth_required')); return () => undefined } + let cancelled = false + const requestId = ++settingsRequestRef.current + setSettingsLoadingFlag(true) + void runAppOperationGroup({ + source: 'mobile_settings_data', + tasks: { preferences: () => api.getPreferences(), providers: () => api.listASRProviders() }, + }).then(({ preferences: prefR, providers: provR }) => { + if (cancelled || requestId !== settingsRequestRef.current) return + if (prefR.status === 'fulfilled') { + const prefs = prefR.value + setLocale(prefs.locale === 'zh' ? 'zh' : 'en') + setTtsProvider(prefs.tts_provider ?? 'mock') + setAvailableProviders(prefs.available_providers?.length ? prefs.available_providers : ['mock']) + setTtsSpeedLocal(prefs.tts_speed ?? 1) + if (prefs.tts_voice_id !== undefined) setTtsVoiceId(prefs.tts_voice_id) + if (prefs.voice_profiles) setVoiceProfiles(prefs.voice_profiles) + if (prefs.selected_voice_profile_id !== undefined) setSelectedVoiceProfileId(prefs.selected_voice_profile_id) + onTtsPreferencesChange(prefs) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const extPrefs = prefs as Record + if (extPrefs.xunfei_voices?.configured) setXunfeiVoices(extPrefs.xunfei_voices.configured) + if (prefs.ui_theme && !themeInitializedRef.current) { + themeInitializedRef.current = true + void SecureStore.getItemAsync('theme_set_at').then(localSetAt => { + const srvTs = new Date(extPrefs.ui_theme_updated_at ?? new Date(0).toISOString()).getTime() + const localTs = localSetAt ? new Date(localSetAt).getTime() : 0 + if (srvTs >= localTs) setThemeLocal(prefs.ui_theme as ThemeKey) + }) + } + } else { + const reqErr = formatApiRequestError(prefR.reason, { context: 'mobile_preferences_load', presentation: 'inline' }) + setSettingsMessage(reqErr.displayMessage) + } + if (provR.status === 'fulfilled') { + const providers: ('native' | 'xunfei')[] = ['native'] + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const provList = (provR.value as Record).providers as Array<{ key: string; enabled: boolean }> | undefined + if (provList?.some(p => p.key === 'xunfei' && p.enabled)) providers.push('xunfei') + applySessionSttProviders(providers) + } + }).finally(() => { if (!cancelled && requestId === settingsRequestRef.current) setSettingsLoadingFlag(false) }) + return () => { cancelled = true } + }, [api, auth.state, onTtsPreferencesChange, setSettingsLoadingFlag, setLocale, setThemeLocal, applySessionSttProviders, tr]) + + // ─── Save Operations / 保存操作 ─── + const saveProvider = useCallback(async (provider: string) => { + settingsRequestRef.current += 1 + setTtsProvider(provider) + onTtsPreferencesChange({ tts_provider: provider, tts_speed: ttsSpeed }) + clearAudio() + setSettingsLoadingFlag(true); setSettingsMessage(null) + if (auth.state !== 'signed-in') { setSettingsMessage(tr('session.status.preferences_saved')); setSettingsLoadingFlag(false); return } + try { + const prefs = await api.updatePreferences({ tts_provider: provider, tts_speed: ttsSpeed }) + applyTtsPreferences(prefs) + } catch (error) { + const reqErr = formatApiRequestError(error, { context: 'mobile_preferences_save_provider', presentation: 'inline' }) + setSettingsMessage(reqErr.displayMessage) + } finally { setSettingsLoadingFlag(false) } + }, [api, auth.state, clearAudio, ttsSpeed, applyTtsPreferences, onTtsPreferencesChange, setSettingsLoadingFlag, tr]) + + const savePracticePreferences = useCallback(async () => { + settingsRequestRef.current += 1 + setSettingsLoadingFlag(true); setSettingsMessage(null) + if (auth.state !== 'signed-in') { setSettingsMessage(tr('session.status.practice_defaults_saved')); setSettingsLoadingFlag(false); return } + try { + const prefs = await api.updatePreferences({ tts_provider: ttsProvider, tts_speed: ttsSpeed }) + applyPracticePreferences(prefs) + } catch (error) { + const reqErr = formatApiRequestError(error, { context: 'mobile_preferences_save_practice', presentation: 'inline' }) + setSettingsMessage(reqErr.displayMessage) + } finally { setSettingsLoadingFlag(false) } + }, [api, auth.state, ttsProvider, ttsSpeed, applyPracticePreferences, setSettingsLoadingFlag, tr]) + + const adjustSpeed = useCallback((delta: number) => { + setTtsSpeedLocal(prev => { + const next = Math.min(1.3, Math.max(0.7, Number((prev + delta).toFixed(1)))) + if (prefSyncTimerRef.current) clearTimeout(prefSyncTimerRef.current) + prefSyncTimerRef.current = setTimeout(() => { + void syncMobilePreferences({ + apiBaseUrl: apiBaseUrl.trim(), getAuthHeaders, onUnauthorized: handleUnauthorized, + ttsSpeed: next, ttsProvider, defaultScenarioKey: ctxScenarioKey, + }).then(prefs => { if (prefs) applyTtsPreferences(prefs) }) + }, 600) + return next + }) + }, [apiBaseUrl, ctxScenarioKey, getAuthHeaders, handleUnauthorized, ttsProvider, applyTtsPreferences]) + + const selectVoiceProfile = useCallback(async (profile: VoiceProfile) => { + if (profile.status !== 'active') return + settingsRequestRef.current += 1 + clearAudio() + setSelectedVoiceProfileId(profile.id) + setTtsProvider(profile.provider) + setTtsVoiceId(profile.providerVoiceId) + onTtsPreferencesChange({ + selected_voice_profile_id: profile.id, + tts_provider: profile.provider, + tts_voice_id: profile.providerVoiceId, + }) + setSettingsMessage(null) + if (auth.state !== 'signed-in') return + try { + const prefs = await api.updatePreferences({ selected_voice_profile_id: profile.id }) + applyVoiceProfilePreferences(prefs) + } catch (error) { + const reqErr = formatApiRequestError(error, { context: 'mobile_preferences_select_voice_profile', presentation: 'silent' }) + logMetric('mobile_silent_request_error', reqErr.logData) + } + }, [api, auth.state, applyVoiceProfilePreferences, clearAudio, logMetric, onTtsPreferencesChange]) + + const saveLocalePreference = useCallback(async (nextLocale: Locale) => { + if (nextLocale === locale) return + setLocale(nextLocale) + if (auth.state !== 'signed-in') { setSettingsMessage(tr('settings.auth_required')); return } + const requestId = ++settingsRequestRef.current + setSettingsLoadingFlag(true); setSettingsMessage(null) + try { + await api.updatePreferences({ locale: nextLocale }) + if (requestId !== settingsRequestRef.current) return + setSettingsMessage(tr('session.status.preferences_saved')) + } catch (error) { + const reqErr = formatApiRequestError(error, { context: 'mobile_preferences_save_locale', presentation: 'banner' }) + setSettingsMessage(reqErr.displayMessage) + displayErrorFeedback(reqErr, 'mobile_preferences_save_locale') + } finally { if (requestId === settingsRequestRef.current) setSettingsLoadingFlag(false) } + }, [api, auth.state, locale, setLocale, setSettingsLoadingFlag, tr]) + + // ─── Auto-load / 自动加载 ─── + useEffect(() => { + if (auth.state !== 'signed-in') { settingsAutoLoadRef.current = false; return } + if (settingsAutoLoadRef.current) return + settingsAutoLoadRef.current = true + const cleanup = loadSettingsDataGroup() + return cleanup + }, [auth.state, loadSettingsDataGroup]) + + const onSetLocale = (l: string) => { void saveLocalePreference(l as Locale) } + const onSetTheme = setTheme + const onSaveProvider = saveProvider + const onSetSessionSttProvider = (p: 'native' | 'xunfei') => { + setSessionSttProviderLocal(p) + void SecureStore.setItemAsync('session_stt_provider', p) + } + const onAdjustSpeed = adjustSpeed + const onSavePracticePreferences = savePracticePreferences + const onLoadPreferences = () => { void loadPreferences() } + const onSelectVoiceProfile = selectVoiceProfile + const onSetApiBaseUrl = setApiBaseUrl + const onResetApiBaseUrl = () => setApiBaseUrl(defaultApiBaseUrl) + const speedFill = Math.max(0, Math.min(1, (ttsSpeed - 0.7) / 0.6)) + const providerVoiceProfiles = voiceProfiles.filter(profile => profile.provider === ttsProvider) + const selectedVoiceProfile = voiceProfiles.find(profile => profile.id === selectedVoiceProfileId) + ?? providerVoiceProfiles.find(profile => profile.providerVoiceId === ttsVoiceId) + ?? providerVoiceProfiles.find(profile => profile.status === 'active') + + function voiceProfileMeta(profile: VoiceProfile) { + const providerLabel = tr(`settings.tts_provider_${profile.provider}`) !== `settings.tts_provider_${profile.provider}` + ? tr(`settings.tts_provider_${profile.provider}`) + : profile.provider + const gender = profile.gender ? tr(`settings.xunfei_voice_gender_${profile.gender}`) : null + const language = profile.locale === 'zh' ? tr('settings.xunfei_voice_language_zh') : tr('settings.xunfei_voice_language_en') + const tier = profile.qualityTier ? tr(`settings.xunfei_voice_tier_${profile.qualityTier}`) : null + return [providerLabel, language, gender, tier, profile.accentLabel, profile.accentRegion, profile.style].filter(Boolean).join(' · ') + } + + function voiceProfileName(profile: VoiceProfile) { + return locale === 'zh' ? profile.displayNameZh ?? profile.displayName : profile.displayName + } + + + return { + apiBaseUrl, + apiBaseUrlSource, + availableProviders, + availableSessionSttProviders, + onAdjustSpeed, + onLoadPreferences, + onResetApiBaseUrl, + onSavePracticePreferences, + onSaveProvider, + onSelectVoiceProfile, + onSetApiBaseUrl, + onSetLocale, + onSetSessionSttProvider, + onSetTheme, + providerVoiceProfiles, + selectedVoiceProfile, + sessionSttProvider, + settingsLoading, + settingsMessage, + speedFill, + ttsProvider, + ttsSpeed, + voiceProfileMeta, + voiceProfileName, + xunfeiVoices, + } +} diff --git a/apps/mobile/src/hooks/useXunfeiStt.ts b/apps/mobile/src/hooks/useXunfeiStt.ts new file mode 100644 index 0000000..87b1175 --- /dev/null +++ b/apps/mobile/src/hooks/useXunfeiStt.ts @@ -0,0 +1,418 @@ +/** + * useXunfeiStt — Xunfei ASR WebSocket Engine / 讯飞流式语音识别 WebSocket 引擎 + * + * Manages the complete lifecycle of a Xunfei streaming ASR session: + * create session → open WebSocket → start PCM capture → stream frames + * → receive partial/final results → cleanup + * 管理讯飞流式 ASR 会话的完整生命周期: + * 创建会话 → 打开 WebSocket → 启动 PCM 采集 → 流式发送帧 + * → 接收部分/最终结果 → 清理 + * + * @deps + * api, auth — network / auth / 网络与鉴权 + * logMetric, setStatus — logging / UI / 日志与 UI 状态 + * enqueueSttOperation, canStartSessionListening — STT queue / gate / STT 队列与门控 + * locale, selectedScenarioKey — session context / 会话上下文 + * snapshotRef, sessionGeneration, sttStreamId, ... — mutable refs / 可变引用 + * sessionActive, routePresence, ... — session state refs / 会话状态引用 + * nativeSpeechStart, nativeSpeechCancelListeningRef — fallback / 降级回退 + * finalTranscript, endedWithoutTranscript — ref bridges to orchestration / 引用桥接到编排层 + * + * @returns + * xunfeiSessionSttRef — current session state (socket, timers, subscriptions) / 当前会话状态 + * startXunfeiSessionListening — start or prewarm a Xunfei session / 启动或预热讯飞会话 + * cancelXunfeiSessionListening — cleanly tear down / 清理并关闭会话 + */ +import { + useCallback, + useRef, +} from 'react' + +import type { + CreateASRSessionResponse, + MeteorVoiceApiClient, +} from '@meteorvoice/api-client' + +import type { PcmCaptureFrameEvent } from '../voicePcmCapture' +import type { XunfeiSessionSttState } from '../sessionRuntime' +import { + createXunfeiASRFrame, + extractXunfeiRecognitionResult, + getObject, + parseJsonObject, +} from '../xunfeiAsrWire' +import { + addPcmFrameListener, + addPcmStateListener, + isPcmCaptureAvailable, + startPcmCapture, + stopPcmCapture, +} from '../voicePcmCapture' +import { + createStoppedSignal, + delay, + settleWithTimeout, + STT_PREWARM_STALE_TIMEOUT_MS, + STT_RESTART_DEBOUNCE_MS, + STT_STOP_SETTLE_TIMEOUT_MS, +} from '../sessionRuntime' + +interface XunfeiSttDeps { + network: { + api: MeteorVoiceApiClient + auth: { state: string; refreshSession: () => Promise } + } + context: { + locale: string + selectedScenarioKey: string + } + refs: { + snapshot: React.MutableRefObject<{ sessionId: string }> + sessionGeneration: React.MutableRefObject + sttStreamId: React.MutableRefObject + sttRestartCount: React.MutableRefObject + sttRestartStartMs: React.MutableRefObject + listeningStartMs: React.MutableRefObject + } + session: { + sessionActive: React.MutableRefObject + routePresence: React.MutableRefObject + canListenOnRoute: React.MutableRefObject + playbackActive: React.MutableRefObject + audioPlaying: React.MutableRefObject + } + callbacks: { + logMetric: (stage: string, data?: Record) => void + setStatus: (status: string) => void + enqueueSttOperation: (label: string, op: () => Promise) => Promise + canStartSessionListening: (context: string, generation?: number) => boolean + } + bridge: { + nativeSpeechStart: React.MutableRefObject<(lang?: string) => Promise> + nativeSpeechCancel: React.MutableRefObject<() => void | Promise> + finalTranscript: React.MutableRefObject<(t: string) => Promise> + endedWithoutTranscript: React.MutableRefObject<() => void> + } +} + +function clearXunfeiTimers(timers: XunfeiSessionSttState['timers']) { + Object.values(timers).forEach(timer => { + if (timer) clearTimeout(timer) + }) +} + +export function useXunfeiStt(deps: XunfeiSttDeps) { + const { network, context, refs, session, callbacks, bridge } = deps + const { api, auth } = network + const { locale, selectedScenarioKey } = context + const { logMetric, setStatus, enqueueSttOperation, canStartSessionListening } = callbacks + const { snapshot, sessionGeneration, sttStreamId, sttRestartCount, sttRestartStartMs, listeningStartMs } = refs + const { sessionActive, routePresence, canListenOnRoute, playbackActive, audioPlaying } = session + const { nativeSpeechStart, nativeSpeechCancel, finalTranscript, endedWithoutTranscript } = bridge + + const xunfeiSessionSttRef = useRef(null) + + const cancelXunfeiSessionListening = useCallback(async (reason = 'cancel') => { + await enqueueSttOperation(`stop:${reason}`, async () => { + const current = xunfeiSessionSttRef.current + if (!current) return + if (current.settled) { + await settleWithTimeout(current.stopped, STT_STOP_SETTLE_TIMEOUT_MS) + return + } + current.settled = true + clearXunfeiTimers(current.timers) + current.subscriptions.frame?.remove() + current.subscriptions.state?.remove() + if (current.socket && current.socket.readyState === WebSocket.OPEN) current.socket.close() + await stopPcmCapture(`session_${reason}`).catch(() => undefined) + current.resolveStopped?.() + if (xunfeiSessionSttRef.current === current) xunfeiSessionSttRef.current = null + await settleWithTimeout(current.stopped, STT_STOP_SETTLE_TIMEOUT_MS) + }) + }, [enqueueSttOperation]) + + const startXunfeiSessionListening = useCallback(async (prewarm = false) => { + return enqueueSttOperation(prewarm ? 'prewarm:xunfei' : 'start:xunfei', async () => { + const generation = sessionGeneration.current + const streamId = ++sttStreamId.current + let recordingStarted = false + + const canUseXunfeiRoute = (context: string) => { + const allowed = prewarm && !recordingStarted + ? sessionActive.current && routePresence.current === 'inSession' && canListenOnRoute.current && + generation === sessionGeneration.current && playbackActive.current && audioPlaying.current + : canStartSessionListening(context, generation) + if (!allowed) logMetric('stt_stream_aborted', { provider: 'xunfei', context, streamId, generation, prewarm }) + return allowed + } + + if (!prewarm && !canUseXunfeiRoute('entry')) return false + if (prewarm && (!sessionActive.current || routePresence.current !== 'inSession' || !playbackActive.current)) return false + + const existing = xunfeiSessionSttRef.current + if (existing && !existing.settled && !prewarm && existing.prewarmed && !existing.recordingStarted && existing.startRecording) { + await existing.startRecording('consume_prewarm') + return true + } + if (existing && !existing.settled) return true + if (existing) await settleWithTimeout(existing.stopped, STT_STOP_SETTLE_TIMEOUT_MS) + await delay(STT_RESTART_DEBOUNCE_MS) + if (!canUseXunfeiRoute('after_restart_debounce')) return false + if (!isPcmCaptureAvailable()) return nativeSpeechStart.current('en-US') + if (auth.state !== 'signed-in') return nativeSpeechStart.current('en-US') + + let socket: WebSocket | null = null + let frameSubscription: { remove: () => void } | null = null as { remove: () => void } | null + let stateSubscription: { remove: () => void } | null = null as { remove: () => void } | null + let finalizeTimer: ReturnType | null = null + let hardTimer: ReturnType | null = null + let noFrameTimer: ReturnType | null = null + let stoppedTimer: ReturnType | null = null + let prewarmStaleTimer: ReturnType | null = null + let bootstrapTimer: ReturnType | null = null + let settled = false + let firstFrame = true + let finalFrameSent = false + let finalReceived = false + let audioSequence = 0 + let frameCount = 0 + let totalBytes = 0 + let transcript = '' + let firstPartialAt: number | null = null + const transcriptSegments: string[] = [] + const stoppedSignal = createStoppedSignal() + let bootstrappedSession: CreateASRSessionResponse | null = null + let finishAudio: (() => void) | null = null + let startRecording: ((context: string) => Promise) | undefined + + const isCurrentStream = () => { + const current = xunfeiSessionSttRef.current + return current && current.streamId === streamId && generation === sessionGeneration.current + } + + const updateCurrent = () => { + xunfeiSessionSttRef.current = { + streamId, + socket, + subscriptions: { + frame: frameSubscription, + state: stateSubscription, + }, + timers: { + bootstrap: bootstrapTimer, + finalize: finalizeTimer, + hard: hardTimer, + noFrame: noFrameTimer, + prewarmStale: prewarmStaleTimer, + stopped: stoppedTimer, + }, + generation, prewarmed: prewarm, recordingStarted, settled, + stopped: stoppedSignal.stopped, resolveStopped: stoppedSignal.resolveStopped, + startRecording, + } + } + + const settle = (reason: string) => { + if (settled) return + settled = true; updateCurrent() + clearXunfeiTimers({ + bootstrap: bootstrapTimer, + finalize: finalizeTimer, + hard: hardTimer, + noFrame: noFrameTimer, + prewarmStale: prewarmStaleTimer, + stopped: stoppedTimer, + }) + bootstrapTimer = null + finalizeTimer = null + hardTimer = null + noFrameTimer = null + prewarmStaleTimer = null + stoppedTimer = null + frameSubscription?.remove() + void stopPcmCapture(`session_${reason}`).catch(() => undefined).finally(() => { + stateSubscription?.remove(); stoppedSignal.resolveStopped() + }) + if (socket && socket.readyState === WebSocket.OPEN) socket.close() + stoppedTimer = setTimeout(() => stoppedSignal.resolveStopped(), STT_STOP_SETTLE_TIMEOUT_MS) + if (xunfeiSessionSttRef.current && xunfeiSessionSttRef.current.streamId === streamId) { + xunfeiSessionSttRef.current = null + } + logMetric('stt_end', { provider: 'xunfei', cancelled: reason !== 'final', reason, streamId, generation, frameCount, totalBytes }) + } + + try { + const authReady = await auth.refreshSession() + if (!canUseXunfeiRoute('after_auth_refresh')) return false + if (!authReady) return nativeSpeechStart.current('en-US') + + const session = await api.createASRSession({ + provider: 'xunfei', mode: 'streaming', + languageMode: locale === 'zh' ? 'mixed_zh_en' : 'english', + scenarioKey: selectedScenarioKey, + sessionId: snapshot.current.sessionId, + endpointSilenceMs: 900, + clientTraceId: `mobile-session-${Date.now()}`, + }) + + if (!canUseXunfeiRoute('after_session_create')) return false + if (session.provider !== 'xunfei' || session.status !== 'created' || session.transport !== 'websocket' || !session.endpointUrl) { + return nativeSpeechStart.current('en-US') + } + bootstrappedSession = session + setStatus('session.status.preparing_listening') + await nativeSpeechCancel.current() + + bootstrapTimer = setTimeout(() => { + if (!settled) { settle('bootstrap_timeout'); void nativeSpeechStart.current('en-US') } + }, 10_000) + + socket = new WebSocket(session.endpointUrl) + updateCurrent() + + // PCM state listener — diagnostic logging + stateSubscription = addPcmStateListener(event => { + if (!isCurrentStream()) return + logMetric('stt_pcm_state', { + provider: 'xunfei', state: event.state, + frameCount: event.frameCount, totalBytes: event.totalBytes, + message: event.message, + }) + }) + + // PCM frame listener — streams microphone audio to Xunfei WebSocket + frameSubscription = addPcmFrameListener((event: PcmCaptureFrameEvent) => { + if (!isCurrentStream()) return + if (!canUseXunfeiRoute('pcm_frame')) { settle('route_inactive'); return } + if (!socket || socket.readyState !== WebSocket.OPEN || finalFrameSent) return + frameCount += 1 + totalBytes += event.byteCount + if (frameCount === 1) { + if (noFrameTimer) { clearTimeout(noFrameTimer); noFrameTimer = null } + } + sendAudioFrame(firstFrame ? 0 : 1, event.audioBase64) + firstFrame = false + if (frameCount === 1 || frameCount % 50 === 0) { + logMetric('stt_pcm_frame', { + provider: 'xunfei', frameCount, totalBytes, elapsedMs: event.elapsedMs, + }) + } + }) + + const sendAudioFrame = (status: 0 | 1 | 2, audioBase64: string) => { + if (!socket || socket.readyState !== WebSocket.OPEN || finalFrameSent) return + if (status === 2) finalFrameSent = true + audioSequence += 1 + socket.send(JSON.stringify(createXunfeiASRFrame(session, status, audioBase64, audioSequence))) + } + + finishAudio = () => { + if (!isCurrentStream()) { settle('route_inactive'); return } + if (finalFrameSent) return + sendAudioFrame(2, '') + void stopPcmCapture('session_endpoint').catch(() => undefined) + } + + socket.onopen = () => { + if (!isCurrentStream()) return + if (!canUseXunfeiRoute('socket_open')) { settle('route_inactive'); return } + startRecording = async (context: string) => { + if (recordingStarted || settled) return + if (!bootstrappedSession) { settle('missing_session'); return } + if (!isCurrentStream()) return + if (!canUseXunfeiRoute(`start_pcm:${context}`)) { settle('route_inactive'); return } + if (prewarmStaleTimer) { clearTimeout(prewarmStaleTimer); prewarmStaleTimer = null } + recordingStarted = true; updateCurrent() + try { + await startPcmCapture({ sampleRate: bootstrappedSession.providerConfig?.sampleRate ?? 16000, frameDurationMs: bootstrappedSession.providerConfig?.frameIntervalMs ?? 40 }) + if (!isCurrentStream()) return + if (!canUseXunfeiRoute(`after_pcm_start:${context}`)) { void stopPcmCapture('session_route_inactive').catch(() => undefined); settle('route_inactive'); return } + listeningStartMs.current = Date.now() + if (bootstrapTimer) { clearTimeout(bootstrapTimer); bootstrapTimer = null } + setStatus('session.status.listening') + if (!hardTimer && finishAudio) hardTimer = setTimeout(finishAudio, 15_000) + noFrameTimer = setTimeout(() => { + if (settled || frameCount > 0) return + settle('pcm_no_frame') + endedWithoutTranscript.current() + }, 1800) + updateCurrent() + } catch { settle('pcm_error'); endedWithoutTranscript.current() } + } + if (prewarm) { + prewarmStaleTimer = setTimeout(() => { + if (settled || recordingStarted) return + settle('prewarm_expired') + }, STT_PREWARM_STALE_TIMEOUT_MS) + updateCurrent() + return + } + updateCurrent() + void startRecording('socket_open') + if (!hardTimer && finishAudio) hardTimer = setTimeout(finishAudio, 15_000) + updateCurrent() + } + + socket.onmessage = event => { + if (!isCurrentStream()) return + const payload = parseJsonObject(event.data) + const header = getObject(payload?.header) + const code = typeof header?.code === 'number' ? header.code : typeof payload?.code === 'number' ? payload.code : 0 + if (code !== 0) { settle('provider_error'); endedWithoutTranscript.current(); return } + + const recognitionResult = extractXunfeiRecognitionResult(payload) + if (recognitionResult?.text) { + if (recognitionResult.pgs === 'rpl' && recognitionResult.rg) { + const [start, end] = recognitionResult.rg + for (let i = start; i <= end; i++) transcriptSegments[i] = '' + } + if (recognitionResult.sn != null) transcriptSegments[recognitionResult.sn] = recognitionResult.text + else transcriptSegments.push(recognitionResult.text) + transcript = transcriptSegments.filter(Boolean).join('').trim() + if (!firstPartialAt) firstPartialAt = Date.now() + if (finalizeTimer) clearTimeout(finalizeTimer) + finalizeTimer = setTimeout(finishAudio!, session.providerConfig?.eosMs ?? 900) + updateCurrent() + } + + const data = getObject(payload?.data) + const wsStatus = typeof header?.status === 'number' ? header.status : typeof data?.status === 'number' ? data.status : undefined + if (wsStatus === 2) { + finalReceived = true + const normalized = transcript.trim() + if (normalized) { + sttRestartCount.current = 0; sttRestartStartMs.current = 0 + void finalTranscript.current(normalized) + settle('final') + } else { settle('final'); endedWithoutTranscript.current() } + } + } + + socket.onerror = () => { if (isCurrentStream()) { settle('socket_error'); endedWithoutTranscript.current() } } + + socket.onclose = () => { + if (!isCurrentStream()) return + if (!finalReceived && !settled) { settle('socket_closed'); endedWithoutTranscript.current() } + } + updateCurrent() + return true + } catch (error) { + logMetric('stt_provider_error', { provider: 'xunfei', message: error instanceof Error ? error.message : 'Xunfei STT failed to start' }) + settle('start_error') + return nativeSpeechStart.current('en-US') + } + }) + }, [ + api, auth, canStartSessionListening, enqueueSttOperation, locale, logMetric, + selectedScenarioKey, setStatus, snapshot, + sessionGeneration, sttStreamId, sttRestartCount, sttRestartStartMs, + listeningStartMs, + sessionActive, routePresence, canListenOnRoute, + playbackActive, audioPlaying, + nativeSpeechStart, nativeSpeechCancel, + finalTranscript, endedWithoutTranscript, + ]) + + return { xunfeiSessionSttRef, startXunfeiSessionListening, cancelXunfeiSessionListening } +} diff --git a/apps/mobile/src/mobileAuth.ts b/apps/mobile/src/mobileAuth.ts index 819b9d0..527cdd5 100644 --- a/apps/mobile/src/mobileAuth.ts +++ b/apps/mobile/src/mobileAuth.ts @@ -1,7 +1,26 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { Platform, Settings } from 'react-native' -import { createClient, type Session, type SupabaseClient, type User } from '@supabase/supabase-js' +/** + * Mobile authentication hook (Supabase). + * 移动端登录鉴权 Hook。 + */ + +import type { + Session, + SupabaseClient, + User, +} from '@supabase/supabase-js' import * as SecureStore from 'expo-secure-store' +import { createClient } from '@supabase/supabase-js' +import { + Platform, + Settings, +} from 'react-native' +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react' function resolveEmail(input: string): string { const trimmed = input.trim().toLowerCase() diff --git a/apps/mobile/src/mobileConfig.ts b/apps/mobile/src/mobileConfig.ts index 02dc81d..ccf68e8 100644 --- a/apps/mobile/src/mobileConfig.ts +++ b/apps/mobile/src/mobileConfig.ts @@ -1,3 +1,8 @@ +/** + * Mobile app configuration (API URL, version). + * 移动端应用配置。 + */ + import Constants from 'expo-constants' import { Platform } from 'react-native' diff --git a/apps/mobile/src/mobilePreferences.ts b/apps/mobile/src/mobilePreferences.ts index 8e17470..d427d80 100644 --- a/apps/mobile/src/mobilePreferences.ts +++ b/apps/mobile/src/mobilePreferences.ts @@ -1,5 +1,11 @@ -import { createMeteorVoiceApiClient, type PreferencesResponse } from '@meteorvoice/api-client' +/** + * Mobile preferences sync utilities. + * 移动端偏好同步工具。 + */ + +import type { PreferencesResponse } from '@meteorvoice/api-client' import type { VoiceProfile } from '@meteorvoice/shared' +import { createMeteorVoiceApiClient } from '@meteorvoice/api-client' export type XunfeiVoice = { id: string diff --git a/apps/mobile/src/nativeAudio.ts b/apps/mobile/src/nativeAudio.ts index b28da05..33734da 100644 --- a/apps/mobile/src/nativeAudio.ts +++ b/apps/mobile/src/nativeAudio.ts @@ -1,5 +1,17 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { AppState, type AppStateStatus } from 'react-native' +/** + * Native audio playback and recording hook. + * 原生音频播放与录音 Hook。 + */ + +import type { AppStateStatus } from 'react-native' +import { AppState } from 'react-native' +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react' import { getRecordingPermissionsAsync, RecordingPresets, @@ -10,6 +22,7 @@ import { useAudioRecorder, useAudioRecorderState, } from 'expo-audio' + import { configureVoiceAudioSession } from './voiceAudioSession' type NativeAudioPermission = 'unknown' | 'granted' | 'denied' @@ -65,9 +78,10 @@ export function useNativeSessionAudio(audioUrl: string | null, playbackRateValue const [interrupted, setInterrupted] = useState(false) const operationRef = useRef | null>(null) const interruptedPhaseRef = useRef(null) + const autoplayedUrlRef = useRef(null) const playbackRate = normalizePlaybackRate(playbackRateValue) - const player = useAudioPlayer(audioUrl, { downloadFirst: true, updateInterval: 250 }) + const player = useAudioPlayer(audioUrl, { updateInterval: 250 }) const playerStatus = useAudioPlayerStatus(player) const recorder = useAudioRecorder( audioExperimentFlags.useAndroidVoiceCommunicationRecorder @@ -253,19 +267,30 @@ export function useNativeSessionAudio(audioUrl: string | null, playbackRateValue }, [applyPlaybackRate]) useEffect(() => { - if (!audioUrl) return + if (!audioUrl) { + autoplayedUrlRef.current = null + return + } + if (!playerStatus.isLoaded || autoplayedUrlRef.current === audioUrl) return + let cancelled = false void configurePlayback() .then(() => { + if (cancelled) return applyPlaybackRate() player.seekTo(0) player.play() + autoplayedUrlRef.current = audioUrl + setInterrupted(false) + setPhase('playing') }) .catch(error => { + if (cancelled) return const message = error instanceof Error ? error.message : 'Coach voice failed to play' setErrorMessage(message) setPhase('error') }) - }, [applyPlaybackRate, audioUrl, configurePlayback, player]) + return () => { cancelled = true } + }, [applyPlaybackRate, audioUrl, configurePlayback, player, playerStatus.isLoaded]) // 前后台切换:后台时暂停音频,前台时检查权限恢复 useEffect(() => { @@ -310,7 +335,9 @@ export function useNativeSessionAudio(audioUrl: string | null, playbackRateValue }, [player, playerStatus.playing, recorder, stopRecording]) return useMemo(() => ({ + currentTimeSeconds: playerStatus.currentTime, didJustFinish: playerStatus.didJustFinish, + playbackDurationSeconds: playerStatus.duration, durationMillis: recorderState.durationMillis, errorMessage, interrupted, @@ -318,6 +345,9 @@ export function useNativeSessionAudio(audioUrl: string | null, playbackRateValue isRecording, lastRecordingUri, permission, + playbackRemainingMs: playerStatus.playing && playerStatus.duration > 0 + ? Math.max(0, (playerStatus.duration - playerStatus.currentTime) * 1000) + : null, phase: displayPhase, playReply, resumeAfterInterruption, @@ -332,6 +362,9 @@ export function useNativeSessionAudio(audioUrl: string | null, playbackRateValue lastRecordingUri, permission, playerStatus.didJustFinish, + playerStatus.currentTime, + playerStatus.duration, + playerStatus.playing, displayPhase, playReply, resumeAfterInterruption, diff --git a/apps/mobile/src/nativeSpeech.ts b/apps/mobile/src/nativeSpeech.ts index 16ce54c..b541a8a 100644 --- a/apps/mobile/src/nativeSpeech.ts +++ b/apps/mobile/src/nativeSpeech.ts @@ -1,16 +1,28 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +/** + * Native speech recognition hook. + * 原生语音识别 Hook。 + */ + +import { + ExpoSpeechRecognitionModule, + type ExpoSpeechRecognitionErrorEvent, + useSpeechRecognitionEvent, +} from 'expo-speech-recognition' +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react' + import { createVoiceActivitySnapshot, FINAL_RESULT_SILENCE_FINALIZE_MS, getSpeechEndpointDelay, - updateVoiceActivitySnapshot, type VoiceActivitySnapshot, + updateVoiceActivitySnapshot, } from '@meteorvoice/session-core' -import { - ExpoSpeechRecognitionModule, - useSpeechRecognitionEvent, - type ExpoSpeechRecognitionErrorEvent, -} from 'expo-speech-recognition' export type NativeSpeechPhase = | 'idle' diff --git a/apps/mobile/src/screens/HistoryScreen.tsx b/apps/mobile/src/screens/HistoryScreen.tsx index c4b39d2..af57606 100644 --- a/apps/mobile/src/screens/HistoryScreen.tsx +++ b/apps/mobile/src/screens/HistoryScreen.tsx @@ -1,21 +1,45 @@ -import { useMemo, useState } from 'react' -import { FlatList, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native' +/** + * Session history and review screen. + * 会话历史记录界面。 + */ + +import { + useMemo, +} from 'react' +import { + FlatList, + Pressable, + ScrollView, + StyleSheet, + Text, + View, +} from 'react-native' + +import type { + Locale, + TranslateFn, +} from '@meteorvoice/shared' +import type { + HistorySession, + MeteorVoiceApiClient, +} from '@meteorvoice/api-client' +import { + accentProfiles, + getAccentLabel, + getScenarioLabel, + scenarios, +} from '@meteorvoice/shared' + +import { useHistoryScreenState } from '../hooks/useHistoryScreenState' import { useTheme } from '../ThemeProvider' -import type { HistorySession, SessionTurnDto } from '@meteorvoice/api-client' -import { scenarios, accentProfiles, getScenarioLabel, getAccentLabel } from '@meteorvoice/shared' -import type { Locale } from '@meteorvoice/shared' interface Props { - tr: (key: string) => string + tr: TranslateFn locale: Locale - sessions: HistorySession[] - loading: boolean - error: string | null - selectedHistory: HistorySession | null - selectedTurns: SessionTurnDto[] - onLoad: () => void - onSelect: (item: HistorySession) => void - onDelete: (id: string) => void + api: MeteorVoiceApiClient + getAuthHeaders: () => Promise + handleUnauthorized: () => void + defaultApiBaseUrl: string } function scenarioLabel(entry: HistorySession, locale: Locale) { @@ -28,29 +52,23 @@ function accentLabel(entry: HistorySession, locale: Locale) { return a ? getAccentLabel(a, locale) : entry.accent } -export function HistoryScreen({ tr, locale, sessions, loading, error, selectedHistory, selectedTurns, onLoad, onSelect, onDelete }: Props) { +export function HistoryScreen({ tr, locale, api, handleUnauthorized }: Props) { const { C } = useTheme() - const [expandedId, setExpandedId] = useState(null) - const [filterScenario, setFilterScenario] = useState(null) - - function toggle(id: string | number) { - if (expandedId === id) { - setExpandedId(null) - } else { - setExpandedId(id) - onSelect(sessions.find(s => s.id === id)!) - } - } - - function handleDelete(id: string) { - onDelete(id) - } - - const filtered = filterScenario - ? sessions.filter(s => s.scenario_key === filterScenario || s.scenario === filterScenario) - : sessions - + const { + deleteSession, + error, + expandedId, + filtered, + filterScenario, + loadHistory, + loading, + selectedHistory, + selectedTurns, + setFilterScenario, + toggle, + } = useHistoryScreenState({ api, handleUnauthorized }) + // ─── Styles / 样式 ─── const styles = useMemo(() => StyleSheet.create({ shell: { flex: 1, backgroundColor: C.bg }, header: { @@ -114,11 +132,13 @@ export function HistoryScreen({ tr, locale, sessions, loading, error, selectedHi correctionSuggested: { color: C.success }, correctionExplanation: { color: C.textMuted, fontSize: 11, lineHeight: 16 }, }), [C]) + + // ─── Render / 渲染 ─── return ( {tr('history.title')} - + {loading ? tr('history.loading') : tr('nav.history') || 'Refresh'} @@ -165,7 +185,7 @@ export function HistoryScreen({ tr, locale, sessions, loading, error, selectedHi {tr(`history.status.${item.status}`) || item.status} {!isDeleted && ( - handleDelete(item.id)} style={styles.deleteBtn} hitSlop={8}> + deleteSession(item.id)} style={styles.deleteBtn} hitSlop={8}> )} diff --git a/apps/mobile/src/screens/HomeScreen.tsx b/apps/mobile/src/screens/HomeScreen.tsx index 5ffe5c1..dec3e14 100644 --- a/apps/mobile/src/screens/HomeScreen.tsx +++ b/apps/mobile/src/screens/HomeScreen.tsx @@ -1,31 +1,50 @@ +/** + * Home screen with scenario selection. + * 场景选择主界面。 + */ + import { useMemo } from 'react' -import { FlatList, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native' +import { + FlatList, + Pressable, + ScrollView, + StyleSheet, + Text, + View, +} from 'react-native' + +import type { + Locale, + TranslateFn, +} from '@meteorvoice/shared' +import type { scenarios as ScenariosType } from '@meteorvoice/shared' +import { + getDifficultyLabel, + getScenarioDescription, + getScenarioLabel, +} from '@meteorvoice/shared' + +import { useSession } from '../SessionContext' import { useTheme } from '../ThemeProvider' -import { getScenarioLabel, getScenarioDescription, getDifficultyLabel, type scenarios as ScenariosType } from '@meteorvoice/shared' -import type { Locale } from '@meteorvoice/shared' type Scenario = (typeof ScenariosType)[number] interface Props { - tr: (key: string) => string + tr: TranslateFn locale: Locale scenarios: Scenario[] - selectedScenarioKey: string - isSessionActive: boolean - scenarioSwitching: boolean - onSelectScenario: (key: string) => void | Promise onGoToSession: () => void } export function HomeScreen({ tr, locale, scenarios, - selectedScenarioKey, isSessionActive, scenarioSwitching, - onSelectScenario, onGoToSession, + onGoToSession, }: Props) { + const { selectedScenarioKey, isSessionActive, selectScenario, scenarioSwitching } = useSession() const { C } = useTheme() async function handleScenario(key: string) { - await onSelectScenario(key) - onGoToSession() + const shouldNavigate = await selectScenario(key) + if (shouldNavigate) onGoToSession() } @@ -58,6 +77,8 @@ export function HomeScreen({ }, resumeTxt: { color: C.cream, fontSize: 14, fontWeight: '700' }, }), [C]) + + // ─── Render / 渲染 ─── return ( string - snapshot: WorkflowSnapshot - messages: ConversationMessage[] - corrections: ConversationResponse['corrections'] - isSessionActive: boolean - status: string - summary: string | null - busy: boolean + tr: TranslateFn scenarioName: string scenarioIcon: string scenarioDifficulty: string scenarioDescription: string accentName: string accentRegion: string - onStart: () => void - onEnd: () => void - onPlayCorrection: (text: string) => void - onSubmitText: (text: string) => void } export function SessionScreen({ - tr, snapshot, messages, corrections, isSessionActive, status, summary, busy, + tr, scenarioName, scenarioIcon, scenarioDifficulty, scenarioDescription, accentName, accentRegion, - onStart, onEnd, onPlayCorrection, onSubmitText, }: Props) { + const { + snapshot, messages, corrections, isSessionActive, status, summary, busy, + startSession, endSession, playCorrection, submitText, + } = useSession() const { C } = useTheme() + + // ─── State / 状态 ─── const [sheetOpen, setSheetOpen] = useState(false) const [activeTab, setActiveTab] = useState<'corrections' | 'transcript'>('corrections') const [textDraft, setTextDraft] = useState('') @@ -59,6 +74,7 @@ export function SessionScreen({ const statusColor = isSessionActive ? C.success : C.textMuted + // ─── Callbacks / 回调 ─── function openSheet(tab: 'corrections' | 'transcript') { setActiveTab(tab) setSheetOpen(true) @@ -68,10 +84,11 @@ export function SessionScreen({ const text = textDraft.trim() if (!text || busy || !isSessionActive) return setTextDraft('') - onSubmitText(text) + submitText(text) } + // ─── Styles / 样式 ─── const styles = useMemo(() => StyleSheet.create({ shell: { flex: 1, backgroundColor: C.bg }, gradientCircle: { @@ -160,6 +177,8 @@ export function SessionScreen({ panelCardTitle: { color: C.textPrimary, fontSize: 14, fontWeight: '700' }, panelCardMeta: { color: C.textMuted, fontSize: 12 }, }), [C]) + + // ─── Render / 渲染 ─── return ( @@ -225,7 +244,7 @@ export function SessionScreen({ {!isSessionActive ? ( @@ -257,7 +276,7 @@ export function SessionScreen({ - + @@ -268,13 +287,13 @@ export function SessionScreen({ openSheet('corrections')}> {tr('session.corrections_tab')} - {corrections.length === 0 ? tr('session.corrections_empty') : tr('session.corrections_count').replace('{count}', String(corrections.length))} + {corrections.length === 0 ? tr('session.corrections_empty') : tr('session.corrections_count', { count: corrections.length })} openSheet('transcript')}> {tr('session.transcript_tab')} - {messages.length === 0 ? tr('session.transcript_empty') : tr('session.transcript_count').replace('{count}', String(messages.length))} + {messages.length === 0 ? tr('session.transcript_empty') : tr('session.transcript_count', { count: messages.length })} @@ -288,9 +307,8 @@ export function SessionScreen({ onTabChange={setActiveTab} corrections={corrections} messages={messages} - onPlayCorrection={onPlayCorrection} + onPlayCorrection={playCorrection} /> ) } - diff --git a/apps/mobile/src/screens/SettingsScreen.tsx b/apps/mobile/src/screens/SettingsScreen.tsx index d64bfff..e3cae87 100644 --- a/apps/mobile/src/screens/SettingsScreen.tsx +++ b/apps/mobile/src/screens/SettingsScreen.tsx @@ -1,87 +1,125 @@ -import { useMemo } from 'react' -import { KeyboardAvoidingView, Platform, Pressable, ScrollView, StyleSheet, Text, TextInput, View } from 'react-native' +/** + * App settings and preferences screen. + * 应用设置与偏好界面。 + */ + +import { + useMemo, +} from 'react' +import { + KeyboardAvoidingView, + Platform, + Pressable, + ScrollView, + Share, + StyleSheet, + Text, + TextInput, + View, +} from 'react-native' + +import type { + Locale, + TranslateFn, +} from '@meteorvoice/shared' + +import type { ThemeKey } from '../theme' +import { DiagnosticsSection } from '../components/DiagnosticsSection' +import { useAuthFormState } from '../hooks/useAuthFormState' +import { useSettingsPreferencesState } from '../hooks/useSettingsPreferencesState' +import { useLog } from '../LogContext' +import { useSession } from '../SessionContext' +import { themeLabels } from '../theme' import { useTheme } from '../ThemeProvider' -import type { Locale, VoiceProfile } from '@meteorvoice/shared' -import type { MobileAuthState } from '../mobileAuth' -import type { XunfeiVoice } from '../mobilePreferences' -import { themeLabels, type ThemeKey } from '../theme' interface Props { - tr: (key: string) => string + tr: TranslateFn locale: Locale - ttsProvider: string - availableProviders: string[] - sessionSttProvider: 'native' | 'xunfei' - availableSessionSttProviders: Array<'native' | 'xunfei'> - ttsSpeed: number - ttsVoiceId: string | null - voiceProfiles: VoiceProfile[] - selectedVoiceProfileId: string | null - xunfeiVoices: XunfeiVoice[] - settingsLoading: boolean - authSubmitting: boolean - settingsMessage: string | null - auth: MobileAuthState - email: string - password: string - authMode: 'sign-in' | 'sign-up' - apiBaseUrl: string - apiBaseUrlSource: 'default' | 'user' - defaultApiBaseUrl: string appVersion: string - voiceMetricsText: string - asrEvaluationText: string - onSetLocale: (l: string) => void - onSetTheme: (k: ThemeKey) => void - onSaveProvider: (p: string) => void - onSetSessionSttProvider: (p: 'native' | 'xunfei') => void - onAdjustSpeed: (delta: number) => void - onSavePracticePreferences: () => void - onLoadPreferences: () => void - onSelectVoiceProfile: (profile: VoiceProfile) => void - onSetEmail: (v: string) => void - onSetPassword: (v: string) => void - onSetAuthMode: (m: 'sign-in' | 'sign-up') => void - onSubmitAuth: () => void - onSignOut: () => void - onSetApiBaseUrl: (v: string) => void - onResetApiBaseUrl: () => void - onClearVoiceMetrics: () => void - onShareVoiceMetrics: () => void - onShareASREvaluation: () => void + defaultApiBaseUrl: string + auth: import('../mobileAuth').MobileAuthState + signOut: (nextMessage?: string | null) => Promise + handleUnauthorized: () => void + getAuthHeaders: () => Promise + onLocaleChange: (l: Locale) => void } export function SettingsScreen({ - tr, locale, ttsProvider, availableProviders, sessionSttProvider, availableSessionSttProviders, ttsSpeed, - ttsVoiceId, voiceProfiles, selectedVoiceProfileId, xunfeiVoices, - settingsLoading, authSubmitting, settingsMessage, - auth, email, password, authMode, apiBaseUrl, apiBaseUrlSource, defaultApiBaseUrl, appVersion, voiceMetricsText, asrEvaluationText, - onSetLocale, onSetTheme, onSaveProvider, onSetSessionSttProvider, onAdjustSpeed, onSavePracticePreferences, - onLoadPreferences, onSelectVoiceProfile, - onSetEmail, onSetPassword, onSetAuthMode, onSubmitAuth, onSignOut, onSetApiBaseUrl, - onResetApiBaseUrl, onClearVoiceMetrics, onShareVoiceMetrics, onShareASREvaluation, + tr, locale, appVersion, defaultApiBaseUrl, + auth, signOut, handleUnauthorized, getAuthHeaders, + onLocaleChange, }: Props) { - const { C, themeKey } = useTheme() - const speedFill = Math.max(0, Math.min(1, (ttsSpeed - 0.7) / 0.6)) - const providerVoiceProfiles = voiceProfiles.filter(profile => profile.provider === ttsProvider) - const selectedVoiceProfile = voiceProfiles.find(profile => profile.id === selectedVoiceProfileId) - ?? providerVoiceProfiles.find(profile => profile.providerVoiceId === ttsVoiceId) - ?? providerVoiceProfiles.find(profile => profile.status === 'active') - - function voiceProfileMeta(profile: VoiceProfile) { - const providerLabel = tr(`settings.tts_provider_${profile.provider}`) !== `settings.tts_provider_${profile.provider}` - ? tr(`settings.tts_provider_${profile.provider}`) - : profile.provider - const gender = profile.gender ? tr(`settings.xunfei_voice_gender_${profile.gender}`) : null - const language = profile.locale === 'zh' ? tr('settings.xunfei_voice_language_zh') : tr('settings.xunfei_voice_language_en') - const tier = profile.qualityTier ? tr(`settings.xunfei_voice_tier_${profile.qualityTier}`) : null - return [providerLabel, language, gender, tier, profile.accentLabel, profile.accentRegion, profile.style].filter(Boolean).join(' · ') - } + const { voiceMetricsText, asrEvaluationText, clearVoiceMetrics, logMetric } = useLog() + const { + applyTtsPreferences, + clearAudio, + selectedAccentKey: ctxAccentKey, + selectedScenarioKey: ctxScenarioKey, + ttsProvider: ctxTtsProvider, + ttsVoiceId: ctxTtsVoiceId, + } = useSession() + const { C, setTheme: setThemeLocal, themeKey } = useTheme() - function voiceProfileName(profile: VoiceProfile) { - return locale === 'zh' ? profile.displayNameZh ?? profile.displayName : profile.displayName - } + const preferences = useSettingsPreferencesState({ + auth, + clearAudio, + ctxAccentKey, + ctxScenarioKey, + ctxTtsProvider, + ctxTtsVoiceId, + defaultApiBaseUrl, + getAuthHeaders, + handleUnauthorized, + locale, + logMetric, + onTtsPreferencesChange: applyTtsPreferences, + onLocaleChange, + setThemeLocal, + tr, + }) + const { + apiBaseUrl, + apiBaseUrlSource, + availableProviders, + availableSessionSttProviders, + onAdjustSpeed, + onLoadPreferences, + onResetApiBaseUrl, + onSavePracticePreferences, + onSaveProvider, + onSelectVoiceProfile, + onSetApiBaseUrl, + onSetLocale, + onSetSessionSttProvider, + onSetTheme, + providerVoiceProfiles, + selectedVoiceProfile, + sessionSttProvider, + settingsLoading, + settingsMessage, + speedFill, + ttsProvider, + ttsSpeed, + voiceProfileMeta, + voiceProfileName, + xunfeiVoices, + } = preferences + const { + authMode, + authSubmitting, + email, + password, + setAuthMode: onSetAuthMode, + setEmail: onSetEmail, + setPassword: onSetPassword, + submitAuth: onSubmitAuth, + } = useAuthFormState({ auth, tr }) + const onSignOut = () => { void signOut() } + const onClearVoiceMetrics = clearVoiceMetrics + const onShareVoiceMetrics = () => Share.share({ title: 'MeteorVoice voice diagnostics', message: voiceMetricsText }) + const onShareASREvaluation = () => Share.share({ title: 'MeteorVoice ASR evaluation', message: asrEvaluationText }) + // ─── Styles / 样式 ─── const styles = useMemo(() => StyleSheet.create({ shell: { flex: 1, backgroundColor: C.bg }, scrollView: { flex: 1 }, @@ -162,6 +200,8 @@ export function SettingsScreen({ diagnosticsText: { color: C.textSecondary, fontSize: 11, lineHeight: 16 }, appVersion: { color: C.textMuted, fontSize: 11, textAlign: 'center', paddingBottom: 16 }, }), [C]) + + // ─── Render / 渲染 ─── return ( - {/* Diagnostics */} - - - Voice diagnostics - - - Logs - - - ASR - - - Clear - - - - - - - {voiceMetricsText || asrEvaluationText || 'No voice metrics yet.'} - - - - + {/* Auth */} diff --git a/apps/mobile/src/sessionRuntime.ts b/apps/mobile/src/sessionRuntime.ts new file mode 100644 index 0000000..9654e3d --- /dev/null +++ b/apps/mobile/src/sessionRuntime.ts @@ -0,0 +1,295 @@ +/** + * Session runtime utilities and constants. + * 会话运行时工具与常量。 + */ + +export type Tab = 'session' | 'home' | 'history' | 'settings' +export type ApiBaseUrlSource = 'default' | 'user' +export type SessionSttProvider = 'native' | 'xunfei' +export type SessionRoutePresence = 'inSession' | 'outSession' + +export type VoiceMetricEntry = { + ts: number + stage: string + data: Record +} + +export type ASREvaluationRun = { + startedAt?: number + firstPartialMs?: number | null + finalMs?: number | null + chars?: number + source?: string + frameCount?: number + totalBytes?: number + error?: string +} + +export type XunfeiSessionSttState = { + socket: WebSocket | null + subscriptions: { + frame: { remove: () => void } | null + state: { remove: () => void } | null + } + timers: { + bootstrap: ReturnType | null + finalize: ReturnType | null + hard: ReturnType | null + noFrame: ReturnType | null + prewarmStale: ReturnType | null + stopped: ReturnType | null + } + streamId: number + generation: number + prewarmed: boolean + recordingStarted: boolean + settled: boolean + stopped: Promise + resolveStopped: () => void + startRecording?: (context: string) => Promise +} + +export type ListeningGateSnapshot = { + sessionActive: boolean + routePresence: SessionRoutePresence + canListenOnRoute: boolean + busy: boolean + playbackActive: boolean + audioPlaying: boolean + generation: number + currentGeneration: number +} + +export type PlaybackTailPrewarmSnapshot = { + provider: SessionSttProvider + isPlaying: boolean + playbackActive: boolean + audioUrl: string | null + prewarmedAudioUrl: string | null + playbackDurationSeconds: number | null | undefined + playbackRemainingMs: number | null +} + +export const STT_STOP_SETTLE_TIMEOUT_MS = 800 +export const STT_MAX_CONSECUTIVE_RESTARTS = 5 +export const STT_RESTART_DEBOUNCE_MS = 200 +export const STT_PLAYBACK_PREWARM_MIN_DURATION_MS = 1800 +export const STT_PLAYBACK_PREWARM_MIN_WINDOW_MS = 320 +export const STT_PLAYBACK_PREWARM_MAX_WINDOW_MS = 900 +export const STT_PREWARM_STALE_TIMEOUT_MS = 3500 + +export function delay(ms: number) { + return new Promise(resolve => setTimeout(resolve, ms)) +} + +export async function settleWithTimeout(promise: Promise, timeoutMs: number) { + let timeout: ReturnType | null = null + try { + await Promise.race([ + promise, + new Promise(resolve => { + timeout = setTimeout(resolve, timeoutMs) + }), + ]) + } finally { + if (timeout) clearTimeout(timeout) + } +} + +export function createStoppedSignal() { + let resolveStopped: () => void = () => undefined + const stopped = new Promise(resolve => { + resolveStopped = resolve + }) + return { stopped, resolveStopped } +} + +export function getPlaybackPrewarmWindowMs(durationSeconds: number | null | undefined) { + if (!durationSeconds || !Number.isFinite(durationSeconds)) return 0 + const durationMs = durationSeconds * 1000 + if (durationMs < STT_PLAYBACK_PREWARM_MIN_DURATION_MS) return 0 + return Math.min( + STT_PLAYBACK_PREWARM_MAX_WINDOW_MS, + Math.max(STT_PLAYBACK_PREWARM_MIN_WINDOW_MS, Math.round(durationMs * 0.18)), + ) +} + +export function canStartListening(snapshot: ListeningGateSnapshot) { + return snapshot.sessionActive && + snapshot.routePresence === 'inSession' && + snapshot.canListenOnRoute && + !snapshot.busy && + !snapshot.playbackActive && + !snapshot.audioPlaying && + snapshot.generation === snapshot.currentGeneration +} + +export function routePresenceForTab(tab: Tab): SessionRoutePresence { + return tab === 'session' ? 'inSession' : 'outSession' +} + +export function shouldConfirmScenarioSwitch(input: { + currentScenarioKey: string + nextScenarioKey: string + sessionActive: boolean +}) { + return input.sessionActive && input.currentScenarioKey !== input.nextScenarioKey +} + +export function shouldResumeListening(input: { + sessionActive: boolean + routePresence?: SessionRoutePresence + canListenOnRoute: boolean + busy?: boolean + playbackActive: boolean + audioPlaying: boolean + generation?: number + currentGeneration?: number +}) { + return input.sessionActive && + (input.routePresence == null || input.routePresence === 'inSession') && + input.canListenOnRoute && + !input.busy && + !input.playbackActive && + !input.audioPlaying && + (input.generation == null || input.currentGeneration == null || input.generation === input.currentGeneration) +} + +export function getPlaybackTailPrewarmDecision(snapshot: PlaybackTailPrewarmSnapshot) { + const windowMs = getPlaybackPrewarmWindowMs(snapshot.playbackDurationSeconds) + const shouldPrewarm = snapshot.provider === 'xunfei' && + snapshot.isPlaying && + snapshot.playbackActive && + Boolean(snapshot.audioUrl) && + snapshot.prewarmedAudioUrl !== snapshot.audioUrl && + Boolean(windowMs) && + snapshot.playbackRemainingMs != null && + snapshot.playbackRemainingMs <= windowMs + + return { + shouldPrewarm, + windowMs, + remainingMs: snapshot.playbackRemainingMs, + durationMs: snapshot.playbackDurationSeconds && Number.isFinite(snapshot.playbackDurationSeconds) + ? snapshot.playbackDurationSeconds * 1000 + : null, + } +} + +export function withTimeout(promise: Promise, timeoutMs: number, message: string) { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error(message)), timeoutMs) + promise + .then(resolve, reject) + .finally(() => clearTimeout(timer)) + }) +} + +export function enqueueRuntimeOperation(options: { + queue: Promise + label: string + log: (stage: string, data?: Record) => void + operation: () => Promise +}) { + const startedAt = Date.now() + const task = options.queue + .catch(() => undefined) + .then(async () => { + options.log('stt_operation_start', { label: options.label }) + try { + return await options.operation() + } finally { + options.log('stt_operation_done', { + label: options.label, + elapsedMs: Date.now() - startedAt, + }) + } + }) + + return { + task, + queue: task.then(() => undefined, () => undefined), + } +} + +export function createASREvaluationReport(entries: VoiceMetricEntry[]) { + const nativeRuns: ASREvaluationRun[] = [] + const remoteRuns: ASREvaluationRun[] = [] + let currentNative: ASREvaluationRun | null = null + let currentRemote: ASREvaluationRun | null = null + + for (const entry of entries) { + if (entry.stage === 'stt_start') { + currentNative = { startedAt: entry.ts } + nativeRuns.push(currentNative) + } else if (entry.stage === 'stt_first_partial' && currentNative) { + currentNative.firstPartialMs = readMetricNumber(entry.data.elapsedMs) + currentNative.chars = readMetricNumber(entry.data.chars) ?? currentNative.chars + } else if (entry.stage === 'stt_submit' && currentNative) { + currentNative.finalMs = readMetricNumber(entry.data.elapsedMs) + currentNative.chars = readMetricNumber(entry.data.chars) ?? currentNative.chars + currentNative.source = typeof entry.data.source === 'string' ? entry.data.source : undefined + } else if (entry.stage === 'stt_end' && currentNative && currentNative.finalMs == null) { + currentNative.finalMs = readMetricNumber(entry.data.elapsedMs) + } + + if (entry.stage === 'asr_stream_start') { + currentRemote = { startedAt: entry.ts } + remoteRuns.push(currentRemote) + } else if (entry.stage === 'asr_first_partial' && currentRemote) { + currentRemote.firstPartialMs = readMetricNumber(entry.data.elapsedMs) + currentRemote.chars = readMetricNumber(entry.data.chars) ?? currentRemote.chars + } else if (entry.stage === 'asr_stream_done' && currentRemote) { + currentRemote.finalMs = readMetricNumber(entry.data.streamElapsedMs) ?? readMetricNumber(entry.data.elapsedMs) + currentRemote.chars = readMetricNumber(entry.data.transcriptChars) ?? currentRemote.chars + currentRemote.frameCount = readMetricNumber(entry.data.frameCount) ?? undefined + currentRemote.totalBytes = readMetricNumber(entry.data.totalBytes) ?? undefined + } else if (entry.stage === 'asr_stream_provider_error' && currentRemote) { + currentRemote.error = typeof entry.data.message === 'string' ? entry.data.message : 'Provider error' + } else if (entry.stage === 'asr_diagnostic_error' && currentRemote) { + currentRemote.error = typeof entry.data.message === 'string' ? entry.data.message : 'Diagnostic error' + } + } + + const latestNative = nativeRuns.at(-1) + const latestRemote = remoteRuns.at(-1) + return [ + 'ASR P4 evaluation report', + `Generated: ${new Date().toISOString()}`, + '', + `Native runs: ${nativeRuns.length}`, + formatASRRun('Latest native', latestNative), + '', + `Remote Xunfei runs: ${remoteRuns.length}`, + formatASRRun('Latest remote', latestRemote), + '', + 'Acceptance checks:', + '- Compare first partial latency between native and remote.', + '- Compare final latency between native and remote.', + '- Compare transcript chars and exported raw metrics against the spoken script.', + '- Do not switch production STT until remote accuracy and latency are better on device.', + ].join('\n') +} + +function formatASRRun(label: string, run: ASREvaluationRun | undefined) { + if (!run) return `${label}: no run captured` + return [ + `${label}:`, + ` startedAt: ${run.startedAt ? new Date(run.startedAt).toLocaleString() : 'unknown'}`, + ` firstPartialMs: ${formatMetricValue(run.firstPartialMs)}`, + ` finalMs: ${formatMetricValue(run.finalMs)}`, + ` chars: ${formatMetricValue(run.chars)}`, + run.source ? ` source: ${run.source}` : null, + run.frameCount != null ? ` frameCount: ${run.frameCount}` : null, + run.totalBytes != null ? ` totalBytes: ${run.totalBytes}` : null, + run.error ? ` error: ${run.error}` : null, + ].filter(Boolean).join('\n') +} + +function readMetricNumber(value: unknown) { + return typeof value === 'number' && Number.isFinite(value) ? value : null +} + +function formatMetricValue(value: number | null | undefined) { + return value == null ? 'n/a' : String(value) +} diff --git a/apps/mobile/src/sessionSyncOutbox.ts b/apps/mobile/src/sessionSyncOutbox.ts new file mode 100644 index 0000000..641bf3f --- /dev/null +++ b/apps/mobile/src/sessionSyncOutbox.ts @@ -0,0 +1,94 @@ +/** + * Persistent queue for completed sessions that could not reach the API. + */ +import type { SyncSessionRequest } from '@meteorvoice/api-client' + +export interface SessionSyncOutboxStorage { + getItem(key: string): Promise + setItem(key: string, value: string): Promise + removeItem(key: string): Promise +} + +export type PendingSessionSync = { + payload: SyncSessionRequest + queuedAt: number + attempts: number + lastAttemptAt?: number +} + +const storageKey = 'meteorvoice.session-sync-outbox.v1' +const maxPendingSessions = 20 + +export async function loadSessionSyncOutbox(storage: SessionSyncOutboxStorage): Promise { + const raw = await storage.getItem(storageKey) + if (!raw) return [] + try { + const parsed = JSON.parse(raw) as { version?: unknown; items?: unknown } + if (parsed.version !== 1 || !Array.isArray(parsed.items)) return [] + return parsed.items.filter(isPendingSessionSync).slice(-maxPendingSessions) + } catch { + return [] + } +} + +export async function enqueueSessionSync( + storage: SessionSyncOutboxStorage, + payload: SyncSessionRequest, + now = Date.now(), +) { + const current = await loadSessionSyncOutbox(storage) + const next = current.filter(item => item.payload.session_id !== payload.session_id) + next.push({ payload, queuedAt: now, attempts: 0 }) + await saveSessionSyncOutbox(storage, next.slice(-maxPendingSessions)) +} + +export async function flushSessionSyncOutbox( + storage: SessionSyncOutboxStorage, + sync: (payload: SyncSessionRequest) => Promise, + now = Date.now(), +) { + const pending = await loadSessionSyncOutbox(storage) + let attempted = 0 + let synced = 0 + + while (pending.length > 0) { + const item = pending[0] + attempted += 1 + try { + await sync(item.payload) + pending.shift() + synced += 1 + await saveSessionSyncOutbox(storage, pending) + } catch { + pending[0] = { + ...item, + attempts: item.attempts + 1, + lastAttemptAt: now, + } + await saveSessionSyncOutbox(storage, pending) + break + } + } + + return { attempted, synced, remaining: pending.length } +} + +async function saveSessionSyncOutbox(storage: SessionSyncOutboxStorage, items: PendingSessionSync[]) { + if (items.length === 0) { + await storage.removeItem(storageKey) + return + } + await storage.setItem(storageKey, JSON.stringify({ version: 1, items })) +} + +function isPendingSessionSync(value: unknown): value is PendingSessionSync { + if (!value || typeof value !== 'object') return false + const item = value as Partial + return Boolean( + item.payload && + typeof item.payload === 'object' && + typeof item.payload.session_id === 'string' && + typeof item.queuedAt === 'number' && + typeof item.attempts === 'number', + ) +} diff --git a/apps/mobile/src/sessionTurnRuntime.ts b/apps/mobile/src/sessionTurnRuntime.ts new file mode 100644 index 0000000..7adbf5d --- /dev/null +++ b/apps/mobile/src/sessionTurnRuntime.ts @@ -0,0 +1,41 @@ +/** + * Turn runtime utilities (staleness, terminal classification). + * 轮次运行时工具。 + */ + +export type TurnRuntimeGuardSnapshot = { + turnRequestId: number + currentTurnRequestId: number + generation: number + currentGeneration: number + sessionActive: boolean +} + +export type EndpointRuntimeGuardSnapshot = { + endpointRequestId: number + currentEndpointRequestId: number + sessionActive: boolean + canListenOnRoute: boolean + playbackActive: boolean +} + +export function isTurnStale(snapshot: TurnRuntimeGuardSnapshot) { + return snapshot.turnRequestId !== snapshot.currentTurnRequestId || + !snapshot.sessionActive || + snapshot.generation !== snapshot.currentGeneration +} + +export function canApplyEndpointResult(snapshot: EndpointRuntimeGuardSnapshot) { + return snapshot.endpointRequestId === snapshot.currentEndpointRequestId && + snapshot.sessionActive && + snapshot.canListenOnRoute && + !snapshot.playbackActive +} + +export function classifyRequestTerminalStage(error: unknown) { + const message = error instanceof Error ? error.message : 'Coach request failed' + return { + stage: message.toLowerCase().includes('timed out') ? 'submit_turn_timeout' : 'submit_turn_error', + message, + } +} diff --git a/apps/mobile/src/theme.ts b/apps/mobile/src/theme.ts index c962b61..046739f 100644 --- a/apps/mobile/src/theme.ts +++ b/apps/mobile/src/theme.ts @@ -1,3 +1,8 @@ +/** + * Theme configuration and types. + * 主题配置与类型。 + */ + export type ThemeKey = 'forest' | 'night' | 'conversation' | 'learning' export type ThemeColors = { diff --git a/apps/mobile/src/utils/handlerBridge.ts b/apps/mobile/src/utils/handlerBridge.ts new file mode 100644 index 0000000..32667d3 --- /dev/null +++ b/apps/mobile/src/utils/handlerBridge.ts @@ -0,0 +1,22 @@ +/** + * HandlerBridge — ref-based callback forwarding between hooks. + * 回调转发器 — 基于 ref 的跨 Hook 回调桥接。 + * + * When Hook A is created before Hook B but needs B's callback, + * create a bridge ref, pass to A, then wire to B after creation. + * 当 Hook A 在 Hook B 之前创建,但需要调用 B 的回调时使用。 + * + * @example + * const onResult = useHandlerBridge<(text: string) => void>() + * useHookA({ onResult: useCallback((t) => onResult.current(t), []) }) + * const hookB = useHookB(...) + * useEffect(() => { onResult.current = hookB.handleResult }) + */ +import type { MutableRefObject } from 'react' +import { useRef } from 'react' + +export function useHandlerBridge unknown>( + stub?: T, +): MutableRefObject { + return useRef((stub ?? (() => {})) as unknown as T) +} diff --git a/apps/mobile/src/voiceAudioSession.ts b/apps/mobile/src/voiceAudioSession.ts index 26dcf82..e1665ee 100644 --- a/apps/mobile/src/voiceAudioSession.ts +++ b/apps/mobile/src/voiceAudioSession.ts @@ -1,3 +1,8 @@ +/** + * iOS AVAudioSession management (Expo Module). + * iOS 音频会话管理。 + */ + import { requireOptionalNativeModule } from 'expo-modules-core' import { Platform } from 'react-native' diff --git a/apps/mobile/src/voicePcmCapture.ts b/apps/mobile/src/voicePcmCapture.ts index 3c36b2f..9d36664 100644 --- a/apps/mobile/src/voicePcmCapture.ts +++ b/apps/mobile/src/voicePcmCapture.ts @@ -1,4 +1,10 @@ -import { requireOptionalNativeModule, type EventSubscription } from 'expo-modules-core' +/** + * iOS PCM audio capture (Expo Module). + * iOS PCM 音频采集。 + */ + +import type { EventSubscription } from 'expo-modules-core' +import { requireOptionalNativeModule } from 'expo-modules-core' import { Platform } from 'react-native' export type PcmCaptureOptions = { diff --git a/apps/mobile/src/xunfeiAsrWire.ts b/apps/mobile/src/xunfeiAsrWire.ts new file mode 100644 index 0000000..9b94495 --- /dev/null +++ b/apps/mobile/src/xunfeiAsrWire.ts @@ -0,0 +1,120 @@ +/** + * Xunfei ASR WebSocket frame parsing. + * 讯飞 ASR WebSocket 帧解析。 + */ + +import type { CreateASRSessionResponse } from '@meteorvoice/api-client' + +export function createXunfeiASRFrame(session: CreateASRSessionResponse, status: 0 | 1 | 2, audioBase64: string, sequence: number) { + const providerConfig = session.providerConfig + const header = { + app_id: providerConfig?.appId, + status, + } + const audio = { + encoding: providerConfig?.audioEncoding ?? 'raw', + sample_rate: providerConfig?.sampleRate ?? 16000, + channels: providerConfig?.channels ?? 1, + bit_depth: providerConfig?.bitDepth ?? 16, + seq: sequence, + status, + audio: audioBase64, + } + if (status !== 0) { + return { + header, + payload: { audio }, + } + } + + return { + header, + parameter: { + iat: { + domain: providerConfig?.domain ?? 'iat', + language: providerConfig?.language ?? 'zh_cn', + accent: providerConfig?.accent ?? 'mandarin', + eos: providerConfig?.eosMs ?? 900, + dwa: 'wpgs', + result: { + encoding: 'utf8', + compress: 'raw', + format: 'json', + }, + }, + }, + payload: { audio }, + } +} + +export function extractXunfeiRecognitionResult(payload: Record | null) { + const payloadObject = getObject(payload?.payload) + const payloadResult = getObject(payloadObject?.result) + const encodedText = typeof payloadResult?.text === 'string' ? payloadResult.text : null + if (encodedText) { + const decoded = decodeBase64Utf8(encodedText) + const decodedPayload = parseJsonObject(decoded) + const decodedWords = extractXunfeiWords(decodedPayload?.ws) + if (decodedWords) { + const rg = Array.isArray(decodedPayload?.rg) && + typeof decodedPayload.rg[0] === 'number' && + typeof decodedPayload.rg[1] === 'number' + ? [decodedPayload.rg[0], decodedPayload.rg[1]] as [number, number] + : null + return { + text: decodedWords, + sn: typeof decodedPayload?.sn === 'number' ? decodedPayload.sn : null, + pgs: typeof decodedPayload?.pgs === 'string' ? decodedPayload.pgs : null, + rg, + } + } + } + + const data = getObject(payload?.data) + const result = getObject(data?.result) + const fallbackWords = extractXunfeiWords(result?.ws) + return fallbackWords + ? { text: fallbackWords, sn: null, pgs: null, rg: null } + : null +} + +export function parseJsonObject(value: unknown): Record | null { + if (typeof value !== 'string') return null + try { + const parsed = JSON.parse(value) + return parsed && typeof parsed === 'object' ? parsed : null + } catch { + return null + } +} + +export function getObject(value: unknown): Record | null { + return value && typeof value === 'object' && !Array.isArray(value) ? value as Record : null +} + +function extractXunfeiWords(words: unknown) { + if (!Array.isArray(words)) return '' + return words.map(item => { + const word = getObject(item) + const candidates = word?.cw + if (!Array.isArray(candidates)) return '' + return candidates.map(candidate => { + const candidateObject = getObject(candidate) + return typeof candidateObject?.w === 'string' ? candidateObject.w : '' + }).join('') + }).join('') +} + +function decodeBase64Utf8(value: string) { + try { + const decoder = globalThis.atob + if (!decoder) return '' + const binary = decoder(value) + const escaped = Array.from(binary) + .map(char => `%${char.charCodeAt(0).toString(16).padStart(2, '0')}`) + .join('') + return decodeURIComponent(escaped) + } catch { + return '' + } +} diff --git a/apps/mobile/tsconfig.json b/apps/mobile/tsconfig.json index 6c0a9f6..bc63171 100644 --- a/apps/mobile/tsconfig.json +++ b/apps/mobile/tsconfig.json @@ -18,6 +18,8 @@ }, "include": [ "index.ts", + "app/**/*.ts", + "app/**/*.tsx", "src/**/*.ts", "src/**/*.tsx", "../../packages/**/*.ts" diff --git a/apps/web/.env.local.example b/apps/web/.env.local.example index 9f2ce0c..0682607 100644 --- a/apps/web/.env.local.example +++ b/apps/web/.env.local.example @@ -10,6 +10,14 @@ DEEPSEEK_BASE_URL=https://api.deepseek.com # Optional: Production ASR/TTS providers ASR_PROVIDER=native # TTS_PROVIDER=mock|xunfei|volcengine|tencent|azure +# API rate limit store. Use supabase for multi-instance deployments; default is in-memory. +API_RATE_LIMIT_STORE=memory +# TTS audio delivery. Keep base64 on Vercel/serverless; use local-cache on single-server deployments. +NEXT_PUBLIC_APP_URL=http://localhost:3000 +TTS_AUDIO_DELIVERY=base64 +TTS_AUDIO_CACHE_DIR=/var/lib/meteorvoice/tts-cache +TTS_AUDIO_CACHE_MAX_BYTES=10737418240 +TTS_AUDIO_CACHE_TTL_DAYS=7 # Xunfei ASR/TTS (recommended first provider in China) XUNFEI_APP_ID= diff --git a/apps/web/app/api/accents/route.ts b/apps/web/app/api/accents/route.ts index f7da0a8..47827a5 100644 --- a/apps/web/app/api/accents/route.ts +++ b/apps/web/app/api/accents/route.ts @@ -1,17 +1,30 @@ -import { jsonApiResult, jsonServerError } from '@/lib/server/http' -import { getAvailableProviders, getTTSProviderPreference } from '@/lib/server/preferences' +/** + * Accent profile and voice listing. / 口音配置和音色列表。 + */ import { accentProfiles, getAccentDescription, getAccentLabel, getAccentRegion, + normalizeLocale, supportsAccent, ttsProviderCapabilities, - type Locale, } from '@meteorvoice/shared' +import { + getAvailableProviders, + getTTSProviderPreference, +} from '@/lib/server/preferences' +import { + guardApiRequest, + jsonApiResult, + jsonServerError, +} from '@/lib/server/http' + export async function GET(request: Request) { try { + const guard = await guardApiRequest(request, { name: 'accents', windowMs: 60_000, maxRequests: 120, requireClientHeader: true }) + if (guard) return jsonApiResult(guard) const url = new URL(request.url) const locale = normalizeLocale(url.searchParams.get('locale')) const selectedProvider = url.searchParams.get('provider') ?? await getTTSProviderPreference() @@ -42,6 +55,3 @@ export async function GET(request: Request) { } } -function normalizeLocale(value: string | null): Locale { - return value === 'zh' ? 'zh' : 'en' -} diff --git a/apps/web/app/api/asr/providers/route.ts b/apps/web/app/api/asr/providers/route.ts index e4316c2..f544184 100644 --- a/apps/web/app/api/asr/providers/route.ts +++ b/apps/web/app/api/asr/providers/route.ts @@ -1,10 +1,22 @@ -import { jsonApiResult, jsonServerError } from '@/lib/server/http' -import { getASRProviders, getDefaultASRProvider } from '@/lib/server/asr' +/** + * ASR provider listing. / 语音识别提供商列表。 + */ +import { + getASRProviders, + getDefaultASRProvider, +} from '@/lib/server/asr' +import { + guardApiRequest, + jsonApiResult, + jsonServerError, +} from '@/lib/server/http' export const runtime = 'nodejs' -export async function GET() { +export async function GET(request: Request) { try { + const guard = await guardApiRequest(request, { name: 'asr-providers', windowMs: 60_000, maxRequests: 120, requireClientHeader: true }) + if (guard) return jsonApiResult(guard) return jsonApiResult({ providers: getASRProviders(), default_provider: getDefaultASRProvider(), diff --git a/apps/web/app/api/asr/session/route.ts b/apps/web/app/api/asr/session/route.ts index 099d0de..8bc4c6b 100644 --- a/apps/web/app/api/asr/session/route.ts +++ b/apps/web/app/api/asr/session/route.ts @@ -1,12 +1,21 @@ -import { guardApiRequest, jsonApiResult, jsonServerError, requireApiUser } from '@/lib/server/http' -import { createASRSessionFromRequest } from '@/lib/server/asr' +/** + * ASR session creation and renewal. / 语音识别会话创建和续期。 + */ import type { ASRSessionBootstrapRequest } from '@meteorvoice/shared' +import { createASRSessionFromRequest } from '@/lib/server/asr' +import { + guardApiRequest, + jsonApiResult, + jsonServerError, + requireApiUser, +} from '@/lib/server/http' + export const runtime = 'nodejs' export async function POST(request: Request) { try { - const guard = guardApiRequest(request, { name: 'asr_session', windowMs: 60_000, maxRequests: 30, requireClientHeader: true }) + const guard = await guardApiRequest(request, { name: 'asr_session', windowMs: 60_000, maxRequests: 30, requireClientHeader: true }) if (guard) return jsonApiResult(guard) const auth = await requireApiUser() if (auth) return jsonApiResult(auth) diff --git a/apps/web/app/api/chat/route.ts b/apps/web/app/api/chat/route.ts index bcf1bca..ff4946f 100644 --- a/apps/web/app/api/chat/route.ts +++ b/apps/web/app/api/chat/route.ts @@ -1,20 +1,35 @@ -import type { ConversationMessage, ConversationContext } from '@/lib/providers/types' +/** + * AI coach chat endpoint. / AI 教练对话端点。 + */ +import { AIProviderUnavailableError } from '@/lib/providers/ai-provider' +import { + parseChatRequest, + readJsonRequest, +} from '@/lib/server/api-input' import { generateCoachReply } from '@/lib/server/chat' -import { guardApiRequest, jsonApiResult, jsonServerError, requireApiUser } from '@/lib/server/http' +import { + guardApiRequest, + jsonApiResult, + jsonServerError, + requireApiUser, +} from '@/lib/server/http' export async function POST(request: Request) { try { - const guard = guardApiRequest(request, { name: 'chat', windowMs: 60_000, maxRequests: 30, requireClientHeader: true }) + const guard = await guardApiRequest(request, { name: 'chat', windowMs: 60_000, maxRequests: 30, requireClientHeader: true }) if (guard) return jsonApiResult(guard) const auth = await requireApiUser() if (auth) return jsonApiResult(auth) - const body = await request.json() as { - messages: ConversationMessage[] - context: ConversationContext - } - const response = await generateCoachReply(body.messages, body.context) + const json = await readJsonRequest(request, 64 * 1024) + if ('error' in json) return jsonApiResult(json) + const body = parseChatRequest(json.value) + if ('error' in body) return jsonApiResult(body) + const response = await generateCoachReply(body.value.messages, body.value.context) return jsonApiResult(response) } catch (e) { + if (e instanceof AIProviderUnavailableError) { + return jsonApiResult({ error: 'AI coach is temporarily unavailable', status: 503 }) + } return jsonServerError(e) } } diff --git a/apps/web/app/api/history/route.ts b/apps/web/app/api/history/route.ts index 1701fd6..767c5ad 100644 --- a/apps/web/app/api/history/route.ts +++ b/apps/web/app/api/history/route.ts @@ -1,8 +1,20 @@ -import { jsonApiResult, jsonServerError } from '@/lib/server/http' +/** + * Session history listing. / 会话历史列表。 + */ import { listSessions } from '@/lib/server/session' +import { + guardApiRequest, + jsonApiResult, + jsonServerError, + requireApiUser, +} from '@/lib/server/http' export async function GET(request: Request) { try { + const guard = await guardApiRequest(request, { name: 'history', windowMs: 60_000, maxRequests: 60, requireClientHeader: true }) + if (guard) return jsonApiResult(guard) + const auth = await requireApiUser() + if (auth) return jsonApiResult(auth) const { searchParams } = new URL(request.url) const offset = parseInt(searchParams.get('offset') ?? '0', 10) const limit = Math.min(parseInt(searchParams.get('limit') ?? '20', 10), 50) diff --git a/apps/web/app/api/preferences/route.ts b/apps/web/app/api/preferences/route.ts index a824ae7..3b35c8c 100644 --- a/apps/web/app/api/preferences/route.ts +++ b/apps/web/app/api/preferences/route.ts @@ -1,9 +1,20 @@ -import { guardApiRequest, jsonApiResult, jsonServerError, requireApiUser } from '@/lib/server/http' -import { getPreferences, setPreferences } from '@/lib/server/preferences' +/** + * User preferences read and update. / 用户偏好读写。 + */ +import { + getPreferences, + setPreferences, +} from '@/lib/server/preferences' +import { + guardApiRequest, + jsonApiResult, + jsonServerError, + requireApiUser, +} from '@/lib/server/http' export async function GET(request: Request) { try { - const guard = guardApiRequest(request, { name: 'preferences_get', windowMs: 60_000, maxRequests: 30, requireClientHeader: true }) + const guard = await guardApiRequest(request, { name: 'preferences_get', windowMs: 60_000, maxRequests: 30, requireClientHeader: true }) if (guard) return jsonApiResult(guard) const auth = await requireApiUser() if (auth) return jsonApiResult(auth) @@ -15,7 +26,7 @@ export async function GET(request: Request) { export async function PATCH(request: Request) { try { - const guard = guardApiRequest(request, { name: 'preferences_patch', windowMs: 60_000, maxRequests: 30, requireClientHeader: true }) + const guard = await guardApiRequest(request, { name: 'preferences_patch', windowMs: 60_000, maxRequests: 30, requireClientHeader: true }) if (guard) return jsonApiResult(guard) const auth = await requireApiUser() if (auth) return jsonApiResult(auth) diff --git a/apps/web/app/api/scenarios/route.ts b/apps/web/app/api/scenarios/route.ts index c3f98d8..c14d74e 100644 --- a/apps/web/app/api/scenarios/route.ts +++ b/apps/web/app/api/scenarios/route.ts @@ -1,13 +1,23 @@ -import { jsonApiResult, jsonServerError } from '@/lib/server/http' +/** + * Practice scenario listing. / 练习场景列表。 + */ import { getScenarioDescription, getScenarioLabel, + normalizeLocale, scenarios, - type Locale, } from '@meteorvoice/shared' +import { + guardApiRequest, + jsonApiResult, + jsonServerError, +} from '@/lib/server/http' + export async function GET(request: Request) { try { + const guard = await guardApiRequest(request, { name: 'scenarios', windowMs: 60_000, maxRequests: 120, requireClientHeader: true }) + if (guard) return jsonApiResult(guard) const url = new URL(request.url) const locale = normalizeLocale(url.searchParams.get('locale')) @@ -23,6 +33,3 @@ export async function GET(request: Request) { } } -function normalizeLocale(value: string | null): Locale { - return value === 'zh' ? 'zh' : 'en' -} diff --git a/apps/web/app/api/semantic-endpoint/route.ts b/apps/web/app/api/semantic-endpoint/route.ts index 169f415..259ef39 100644 --- a/apps/web/app/api/semantic-endpoint/route.ts +++ b/apps/web/app/api/semantic-endpoint/route.ts @@ -1,5 +1,13 @@ -import { guardApiRequest, jsonApiResult, jsonServerError, requireApiUser } from '@/lib/server/http' +/** + * Semantic endpoint health check. / 语义端点健康检查。 + */ import { createSemanticEndpointCheck } from '@/lib/server/semantic-endpoint' +import { + guardApiRequest, + jsonApiResult, + jsonServerError, + requireApiUser, +} from '@/lib/server/http' const MAX_TRANSCRIPT_LENGTH = 2000 const MAX_MESSAGES = 8 @@ -28,7 +36,7 @@ async function getCheck() { export async function POST(request: Request) { try { - const guard = guardApiRequest(request, { name: 'semantic_endpoint', windowMs: 60_000, maxRequests: 120, requireClientHeader: true }) + const guard = await guardApiRequest(request, { name: 'semantic_endpoint', windowMs: 60_000, maxRequests: 120, requireClientHeader: true }) if (guard) return jsonApiResult(guard) const auth = await requireApiUser() if (auth) return jsonApiResult(auth) diff --git a/apps/web/app/api/session/route.ts b/apps/web/app/api/session/route.ts index d8a3040..ef6ba5b 100644 --- a/apps/web/app/api/session/route.ts +++ b/apps/web/app/api/session/route.ts @@ -1,8 +1,28 @@ -import { jsonApiResult, jsonServerError } from '@/lib/server/http' -import { createSession, deleteSession, updateSessionStatus } from '@/lib/server/session' +/** + * Session create, delete, and status update. / 会话创建、删除和状态更新。 + */ +import { + createSession, + deleteSession, + updateSessionStatus, +} from '@/lib/server/session' +import { + guardApiRequest, + jsonApiResult, + jsonServerError, + requireApiUser, +} from '@/lib/server/http' + +async function guardSessionRequest(request: Request) { + const guard = await guardApiRequest(request, { name: 'session', windowMs: 60_000, maxRequests: 60, requireClientHeader: true }) + if (guard) return guard + return requireApiUser() +} export async function POST(request: Request) { try { + const auth = await guardSessionRequest(request) + if (auth) return jsonApiResult(auth) const body = await request.json() as { scenario_id?: string accent_profile_id?: string @@ -17,6 +37,8 @@ export async function POST(request: Request) { export async function PATCH(request: Request) { try { + const auth = await guardSessionRequest(request) + if (auth) return jsonApiResult(auth) const body = await request.json() as { id: string; status: string } const result = await updateSessionStatus(body) return jsonApiResult(result) @@ -27,6 +49,8 @@ export async function PATCH(request: Request) { export async function DELETE(request: Request) { try { + const auth = await guardSessionRequest(request) + if (auth) return jsonApiResult(auth) const { searchParams } = new URL(request.url) const id = searchParams.get('id') if (!id) return jsonApiResult({ error: 'Missing session id' }) diff --git a/apps/web/app/api/session/sync/route.ts b/apps/web/app/api/session/sync/route.ts index f23fe48..ee4afff 100644 --- a/apps/web/app/api/session/sync/route.ts +++ b/apps/web/app/api/session/sync/route.ts @@ -1,23 +1,29 @@ -import { jsonApiResult, jsonServerError } from '@/lib/server/http' +/** + * Session finalization and data sync. / 会话结束和数据同步。 + */ import { finalizeSession } from '@/lib/server/turns' +import { + parseSessionSyncRequest, + readJsonRequest, +} from '@/lib/server/api-input' +import { + guardApiRequest, + jsonApiResult, + jsonServerError, + requireApiUser, +} from '@/lib/server/http' export async function POST(request: Request) { try { - const body = await request.json() as { - session_id: string - scenario: string - accent: string - turns: number - messages: { role: string; content: string }[] - corrections: { - type: string - originalText: string - suggestedText: string - explanation: string - severity: string - }[] - } - const result = await finalizeSession(body) + const guard = await guardApiRequest(request, { name: 'session-sync', windowMs: 60_000, maxRequests: 30, requireClientHeader: true }) + if (guard) return jsonApiResult(guard) + const auth = await requireApiUser() + if (auth) return jsonApiResult(auth) + const json = await readJsonRequest(request, 512 * 1024) + if ('error' in json) return jsonApiResult(json) + const body = parseSessionSyncRequest(json.value) + if ('error' in body) return jsonApiResult(body) + const result = await finalizeSession(body.value) return jsonApiResult(result) } catch (e) { return jsonServerError(e) diff --git a/apps/web/app/api/sessions/[sessionId]/route.ts b/apps/web/app/api/sessions/[sessionId]/route.ts new file mode 100644 index 0000000..0f85096 --- /dev/null +++ b/apps/web/app/api/sessions/[sessionId]/route.ts @@ -0,0 +1,30 @@ +/** + * Session resource operations. / 会话资源操作。 + */ +import { deleteSession } from '@/lib/server/session' +import { + guardApiRequest, + jsonApiResult, + jsonServerError, + requireApiUser, +} from '@/lib/server/http' + +async function guardSessionResourceRequest(request: Request) { + const guard = await guardApiRequest(request, { name: 'session-resource', windowMs: 60_000, maxRequests: 60, requireClientHeader: true }) + if (guard) return guard + return requireApiUser() +} + +export async function DELETE( + request: Request, + context: { params: Promise<{ sessionId: string }> }, +) { + try { + const auth = await guardSessionResourceRequest(request) + if (auth) return jsonApiResult(auth) + const { sessionId } = await context.params + return jsonApiResult(await deleteSession(sessionId)) + } catch (error) { + return jsonServerError(error, 'Failed to delete session') + } +} diff --git a/apps/web/app/api/sessions/[sessionId]/turns/route.ts b/apps/web/app/api/sessions/[sessionId]/turns/route.ts index f42122d..13a50b0 100644 --- a/apps/web/app/api/sessions/[sessionId]/turns/route.ts +++ b/apps/web/app/api/sessions/[sessionId]/turns/route.ts @@ -1,11 +1,23 @@ -import { jsonApiResult, jsonServerError } from '@/lib/server/http' +/** + * Session turn history. / 会话对话轮次历史。 + */ import { listSessionTurns } from '@/lib/server/session' +import { + guardApiRequest, + jsonApiResult, + jsonServerError, + requireApiUser, +} from '@/lib/server/http' export async function GET( - _request: Request, + request: Request, context: { params: Promise<{ sessionId: string }> }, ) { try { + const guard = await guardApiRequest(request, { name: 'session-turns', windowMs: 60_000, maxRequests: 60, requireClientHeader: true }) + if (guard) return jsonApiResult(guard) + const auth = await requireApiUser() + if (auth) return jsonApiResult(auth) const { sessionId } = await context.params return jsonApiResult(await listSessionTurns(sessionId)) } catch (error) { diff --git a/apps/web/app/api/summary/route.ts b/apps/web/app/api/summary/route.ts index 3a3c59f..1b43860 100644 --- a/apps/web/app/api/summary/route.ts +++ b/apps/web/app/api/summary/route.ts @@ -1,15 +1,31 @@ -import { jsonApiResult } from '@/lib/server/http' -import { FALLBACK_SESSION_SUMMARY, generateSessionSummary } from '@/lib/server/summary' +/** + * Session summary generation. / 会话总结生成。 + */ +import { + FALLBACK_SESSION_SUMMARY, + generateSessionSummary, +} from '@/lib/server/summary' +import { + parseSummaryRequest, + readJsonRequest, +} from '@/lib/server/api-input' +import { + guardApiRequest, + jsonApiResult, + requireApiUser, +} from '@/lib/server/http' export async function POST(request: Request) { try { - const body = await request.json() as { - sessionId: string - scenario: string - messages: { role: string; content: string }[] - turnNumber: number - } - const summary = await generateSessionSummary(body) + const guard = await guardApiRequest(request, { name: 'summary', windowMs: 60_000, maxRequests: 30, requireClientHeader: true }) + if (guard) return jsonApiResult(guard) + const auth = await requireApiUser() + if (auth) return jsonApiResult(auth) + const json = await readJsonRequest(request, 256 * 1024) + if ('error' in json) return jsonApiResult(json) + const body = parseSummaryRequest(json.value) + if ('error' in body) return jsonApiResult(body) + const summary = await generateSessionSummary(body.value) return jsonApiResult({ summary }) } catch { return jsonApiResult({ summary: FALLBACK_SESSION_SUMMARY }) diff --git a/apps/web/app/api/tts/audio/[audioId]/route.ts b/apps/web/app/api/tts/audio/[audioId]/route.ts new file mode 100644 index 0000000..6c93184 --- /dev/null +++ b/apps/web/app/api/tts/audio/[audioId]/route.ts @@ -0,0 +1,96 @@ +/** + * Authenticated TTS audio delivery. / 已鉴权 TTS 音频交付。 + */ +import { + createReadStream, + statSync, +} from 'node:fs' + +import { + getCachedTTSAudioByToken, + getCachedTTSAudioForUser, +} from '@/lib/server/tts-audio-cache' +import { + getApiUser, + guardApiRequest, + isApiErrorResult, + jsonApiResult, + jsonServerError, +} from '@/lib/server/http' + +export const runtime = 'nodejs' + +export async function GET( + request: Request, + context: { params: Promise<{ audioId: string }> }, +) { + try { + const guard = await guardApiRequest(request, { name: 'tts_audio', windowMs: 60_000, maxRequests: 240 }) + if (guard) return jsonApiResult(guard) + + const { audioId } = await context.params + const token = new URL(request.url).searchParams.get('token')?.trim() + const cachedAudio = token + ? await getCachedTTSAudioByToken(audioId, token) + : await getCachedTTSAudioForAuthenticatedUser(audioId) + if (!cachedAudio) { + return Response.json({ error: 'Audio not found' }, { status: 404 }) + } + + const range = request.headers.get('range') + if (range) return rangeResponse(cachedAudio.filePath, cachedAudio.contentType, range) + + const stream = createReadStream(cachedAudio.filePath) + return new Response(stream as unknown as BodyInit, { + headers: { + 'Accept-Ranges': 'bytes', + 'Cache-Control': 'private, max-age=604800', + 'Content-Length': String(cachedAudio.bytes), + 'Content-Type': cachedAudio.contentType, + }, + }) + } catch (error) { + return jsonServerError(error, 'TTS audio delivery failed') + } +} + +async function getCachedTTSAudioForAuthenticatedUser(audioId: string) { + const auth = await getApiUser() + if (isApiErrorResult(auth)) return null + return getCachedTTSAudioForUser(audioId, auth.user.id) +} + +function rangeResponse(filePath: string, contentType: string, range: string) { + const fileSize = statSync(filePath).size + const match = /^bytes=(\d*)-(\d*)$/.exec(range) + if (!match) { + return new Response(null, { + status: 416, + headers: { 'Content-Range': `bytes */${fileSize}` }, + }) + } + + const requestedStart = match[1] ? Number(match[1]) : 0 + const requestedEnd = match[2] ? Number(match[2]) : fileSize - 1 + const start = Math.max(0, requestedStart) + const end = Math.min(fileSize - 1, requestedEnd) + + if (!Number.isFinite(start) || !Number.isFinite(end) || start > end) { + return new Response(null, { + status: 416, + headers: { 'Content-Range': `bytes */${fileSize}` }, + }) + } + + const stream = createReadStream(filePath, { start, end }) + return new Response(stream as unknown as BodyInit, { + status: 206, + headers: { + 'Accept-Ranges': 'bytes', + 'Cache-Control': 'private, max-age=604800', + 'Content-Length': String(end - start + 1), + 'Content-Range': `bytes ${start}-${end}/${fileSize}`, + 'Content-Type': contentType, + }, + }) +} diff --git a/apps/web/app/api/tts/route.ts b/apps/web/app/api/tts/route.ts index 39d1630..e3e2faf 100644 --- a/apps/web/app/api/tts/route.ts +++ b/apps/web/app/api/tts/route.ts @@ -1,25 +1,45 @@ -import { guardApiRequest, jsonApiResult, jsonServerError, requireApiUser } from '@/lib/server/http' +/** + * Text-to-speech synthesis. / 文本转语音合成。 + */ import { synthesizeSpeechFromRequest } from '@/lib/server/tts' +import { + parseTTSRequest, + readJsonRequest, +} from '@/lib/server/api-input' +import { + getApiUser, + guardApiRequest, + isApiErrorResult, + jsonApiResult, + jsonServerError, +} from '@/lib/server/http' export const runtime = 'nodejs' export async function POST(request: Request) { try { - const guard = guardApiRequest(request, { name: 'tts', windowMs: 60_000, maxRequests: 60, requireClientHeader: true }) + const guard = await guardApiRequest(request, { name: 'tts', windowMs: 60_000, maxRequests: 60, requireClientHeader: true }) if (guard) return jsonApiResult(guard) - const auth = await requireApiUser() - if (auth) return jsonApiResult(auth) - const body = await request.json() as { - text?: string - accent?: string - speed?: number - provider?: string - voiceId?: string - } + const auth = await getApiUser() + if (isApiErrorResult(auth)) return jsonApiResult(auth) + const json = await readJsonRequest(request, 16 * 1024) + if ('error' in json) return jsonApiResult(json) + const body = parseTTSRequest(json.value) + if ('error' in body) return jsonApiResult(body) - const result = await synthesizeSpeechFromRequest(body) + const result = await synthesizeSpeechFromRequest({ + ...body.value, + userId: auth.user.id, + audioBaseUrl: getRequestBaseUrl(request), + }) return jsonApiResult(result) } catch (error) { return jsonServerError(error, 'TTS failed') } } + +function getRequestBaseUrl(request: Request) { + const host = request.headers.get('x-forwarded-host') || request.headers.get('host') + const protocol = request.headers.get('x-forwarded-proto') || new URL(request.url).protocol.replace(':', '') + return host ? `${protocol}://${host}` : new URL(request.url).origin +} diff --git a/apps/web/app/api/turns/route.ts b/apps/web/app/api/turns/route.ts index d678a39..729e8f8 100644 --- a/apps/web/app/api/turns/route.ts +++ b/apps/web/app/api/turns/route.ts @@ -1,21 +1,29 @@ -import { jsonApiResult, jsonServerError } from '@/lib/server/http' +/** + * Conversation turn creation. / 对话轮次创建。 + */ import { createTurn } from '@/lib/server/turns' +import { + parseTurnRequest, + readJsonRequest, +} from '@/lib/server/api-input' +import { + guardApiRequest, + jsonApiResult, + jsonServerError, + requireApiUser, +} from '@/lib/server/http' export async function POST(request: Request) { try { - const body = await request.json() as { - session_id: string - speaker: string - transcript: string - corrections?: { - type: string - originalText: string - suggestedText: string - explanation: string - severity: string - }[] - } - const result = await createTurn(body) + const guard = await guardApiRequest(request, { name: 'turns', windowMs: 60_000, maxRequests: 60, requireClientHeader: true }) + if (guard) return jsonApiResult(guard) + const auth = await requireApiUser() + if (auth) return jsonApiResult(auth) + const json = await readJsonRequest(request, 64 * 1024) + if ('error' in json) return jsonApiResult(json) + const body = parseTurnRequest(json.value) + if ('error' in body) return jsonApiResult(body) + const result = await createTurn(body.value) return jsonApiResult(result) } catch (e) { return jsonServerError(e) diff --git a/apps/web/app/history/page.tsx b/apps/web/app/history/page.tsx index 3c4dee1..a88ecb4 100644 --- a/apps/web/app/history/page.tsx +++ b/apps/web/app/history/page.tsx @@ -1,12 +1,42 @@ +/** + * Session history page (web). + * 会话历史页面(Web 端)。 + */ + 'use client' -import { useCallback, useEffect, useState } from 'react' -import { useLocale, useT } from '@/components/LanguageProvider' -import { Card, CardContent } from '@/components/ui/card' -import { scenarios, findAccentByKeyOrName, findScenarioByKeyOrName, getAccentLabel, getScenarioLabel } from '@/lib/scenarios' +import { + useCallback, + useEffect, + useState, +} from 'react' + +import { + formatApiRequestError, + readApiJsonResponse, +} from '@meteorvoice/api-client' +import { + displayErrorFeedback, + hideAppFeedback, + runAppOperationGroup, +} from '@meteorvoice/shared' + import { flushPendingPreferences } from '@/lib/tts-speed' -import { formatApiRequestError, readApiJsonResponse } from '@meteorvoice/api-client' -import { displayErrorFeedback, hideAppFeedback, runAppOperationGroup } from '@meteorvoice/shared' +import { + useLocale, + useT, +} from '@/components/LanguageProvider' +import { + Card, + CardContent, +} from '@/components/ui/card' +import { + findAccentByKeyOrName, + findScenarioByKeyOrName, + getAccentLabel, + getScenarioLabel, + scenarios, +} from '@/lib/scenarios' interface HistorySession { id: string @@ -203,7 +233,7 @@ export default function HistoryPage() { }, tasks: { deleted: async () => { - const res = await fetch(`/api/session?id=${encodeURIComponent(id)}`, { method: 'DELETE' }) + const res = await fetch(`/api/sessions/${encodeURIComponent(id)}`, { method: 'DELETE' }) if (!res.ok) throw new Error(`Delete failed: ${res.status}`) return true }, @@ -291,6 +321,7 @@ export default function HistoryPage() { onClick={() => void handleDelete(s.id)} disabled={deletingId === s.id} className="text-xs text-[var(--theme-text-muted)] hover:text-[var(--theme-danger)] transition-colors" + aria-label={`${t('history.delete')}: ${scenarioLabel(s)}`} title={t('history.delete')} > {deletingId === s.id ? '...' : '✕'} @@ -319,7 +350,7 @@ export default function HistoryPage() { ) : ( <>

- {t('history.turns_count').replace('{count}', String(turns.length))} + {t('history.turns_count', { count: turns.length })}

{turns.map(turn => (
startSession(s.key)} className="data-panel p-5 text-left hover:ring-2 hover:ring-[var(--theme-accent)] transition-all cursor-pointer" > -
{s.icon}
+

{getScenarioLabel(s, locale)}

{getScenarioDescription(s, locale)}

{getDifficultyLabel(s.difficulty, locale)} @@ -69,8 +84,14 @@ export default function HomePage() { {pendingScenarioKey && (
-
-

{t('home.active_session_dialog_title')}

+
+

{t('home.active_session_dialog_title')}

{t('home.active_session_dialog_desc')}

diff --git a/apps/web/app/review/page.tsx b/apps/web/app/review/page.tsx index 6d31c11..11a9bf1 100644 --- a/apps/web/app/review/page.tsx +++ b/apps/web/app/review/page.tsx @@ -1,10 +1,25 @@ +/** + * Review corrections page. + * 纠错复习页面。 + */ + 'use client' import { useState } from 'react' -import { useLocale, useT } from '@/components/LanguageProvider' -import { Card, CardContent } from '@/components/ui/card' + import type { ConversationResponse } from '@/lib/providers/types' -import { findScenarioByKeyOrName, getScenarioLabel } from '@/lib/scenarios' +import { + useLocale, + useT, +} from '@/components/LanguageProvider' +import { + Card, + CardContent, +} from '@/components/ui/card' +import { + findScenarioByKeyOrName, + getScenarioLabel, +} from '@/lib/scenarios' interface ReviewItem { id: string @@ -155,6 +170,7 @@ export default function ReviewPage() { onClick={() => setRevealed(true)} className="px-6 py-3 rounded-xl text-sm font-semibold text-white" style={{ background: 'var(--theme-accent)' }} + aria-expanded={revealed} > {t('review.reveal')} diff --git a/apps/web/app/session/SessionPage.tsx b/apps/web/app/session/SessionPage.tsx index f53980d..f8bec07 100644 --- a/apps/web/app/session/SessionPage.tsx +++ b/apps/web/app/session/SessionPage.tsx @@ -1,7 +1,26 @@ 'use client' +/** + * Session page client component. + * 会话页面客户端组件。 + */ + import { useSearchParams } from 'next/navigation' -import { useEffect, useRef, useState } from 'react' +import { + useEffect, + useRef, + useState, +} from 'react' + +import type { VoiceWaveformMode } from './VoiceWaveform' +import type { ConversationResponse } from '@/lib/providers/types' +import { VoiceWaveform } from './VoiceWaveform' +import { Button } from '@/components/ui/button' +import { useVoiceSession } from '@/components/VoiceSessionProvider' +import { + useLocale, + useT, +} from '@/components/LanguageProvider' import { getAccentLabel, getAccentRegion, @@ -9,11 +28,6 @@ import { getScenarioDescription, getScenarioLabel, } from '@/lib/scenarios' -import type { ConversationResponse } from '@/lib/providers/types' -import { useLocale, useT } from '@/components/LanguageProvider' -import { useVoiceSession } from '@/components/VoiceSessionProvider' -import { Button } from '@/components/ui/button' -import { VoiceWaveform, type VoiceWaveformMode } from './VoiceWaveform' type SidePanelTab = 'corrections' | 'transcript' @@ -109,10 +123,10 @@ export function SessionPageClient() { }` const correctionSummary = corrections.length === 0 ? tr('session.corrections_empty') - : tr('session.corrections_count').replace('{count}', String(corrections.length)) + : tr('session.corrections_count', { count: corrections.length }) const transcriptSummary = messages.length === 0 ? tr('session.transcript_empty') - : tr('session.transcript_count').replace('{count}', String(messages.length)) + : tr('session.transcript_count', { count: messages.length }) function openMobilePanel(tab: SidePanelTab) { setActiveTab(tab) @@ -220,7 +234,7 @@ export function SessionPageClient() {
- {statusText} + {statusText} {interrupted && ( {tr('session.interrupted')} )} @@ -343,13 +357,16 @@ export function SessionPageClient() { aria-label={tr('session.close_panel')} />
-
+