From 2e6912dddfeb3555c88c3708e6a0586bf832978f Mon Sep 17 00:00:00 2001 From: lan Date: Wed, 3 Jun 2026 01:56:02 +0800 Subject: [PATCH] feat(call): implement CallKeep integration and enhance group call support Integrate `expo-callkit-telecom` to support native system call handling (answering, ending, and muting via system UI). This includes a new `callKeepService` and a `CallKeepBootstrap` component to manage the lifecycle of system call events. Additionally, improves the calling experience by: - Adding support for group calls with participant tracking and UI indicators. - Enhancing LiveKit integration by replacing polling with event-driven video track synchronization. - Updating `WebSocketService` to prevent disconnection when the app enters the background during an active call. - Adding new WebSocket message types for participant join/leave events and group invites. - Refining call UI components (`CallScreen`, `FloatingCallWindow`, `IncomingCallModal`) for better visual feedback and safe area handling. Refactor LiveKit service to use event-driven updates for local and remote tracks, improving performance and reliability. --- app.json | 1 + app/_layout.tsx | 34 ++ package-lock.json | 563 ++++++++++-------- package.json | 39 +- src/components/call/CallScreen.tsx | 49 +- src/components/call/FloatingCallWindow.tsx | 141 +++-- src/components/call/IncomingCallModal.tsx | 273 +++++---- src/data/datasources/CacheDataSource.ts | 2 +- src/infrastructure/cache/MediaCacheManager.ts | 8 +- src/screens/profile/DataStorageScreen.tsx | 4 +- src/services/callkeep/callKeepService.ts | 284 +++++++++ src/services/callkeep/index.ts | 2 + src/services/core/api.ts | 2 +- .../core/datasources/CacheDataSource.ts | 2 +- src/services/core/wsService.ts | 37 +- src/services/livekit/LiveKitService.ts | 153 +++-- src/stores/call/callStore.ts | 546 ++++++++++++++++- src/stores/call/index.ts | 2 +- 18 files changed, 1635 insertions(+), 507 deletions(-) create mode 100644 src/services/callkeep/callKeepService.ts create mode 100644 src/services/callkeep/index.ts diff --git a/app.json b/app.json index dbbc8cb..373c09b 100644 --- a/app.json +++ b/app.json @@ -153,6 +153,7 @@ } ], "./plugins/withHuaweiPush", + "expo-callkit-telecom", "expo-status-bar" ], "extra": { diff --git a/app/_layout.tsx b/app/_layout.tsx index 97c1dad..447dc3f 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -28,6 +28,8 @@ import { useAuthStore } from '../src/stores'; import { checkForAPKUpdate } from '@/services/platform'; import { CallScreen, IncomingCallModal, FloatingCallWindow } from '../src/components/call'; import { jpushService } from '@/services/notification/jpushService'; +import { callKeepService } from '@/services/callkeep'; +import { callStore } from '@/stores/call'; registerNotificationPresentationHandler(); @@ -237,6 +239,37 @@ function APKUpdateBootstrap() { return null; } +function CallKeepBootstrap() { + useEffect(() => { + if (Platform.OS === 'web') return; + + callKeepService.initialize({ + onSystemAnswered: () => { + callStore.getState().handleSystemAnswer(); + }, + onSystemEnded: () => { + callStore.getState().endCall('ended'); + }, + onSystemMuted: (muted: boolean) => { + callStore.getState().handleSystemMuted(muted); + }, + onIncomingCallFromPush: (session) => { + callStore.getState().handleIncomingFromPush(session); + }, + onOutgoingStarted: () => { + // Outgoing call accepted by system — no action needed, + // the WS call_invited / call_accepted flow handles everything + }, + }); + + return () => { + callKeepService.dispose(); + }; + }, []); + + return null; +} + function ThemedStack() { const router = useRouter(); const resolved = useResolvedColorScheme(); @@ -249,6 +282,7 @@ function ThemedStack() { + diff --git a/package-lock.json b/package-lock.json index 111aff9..e1529f1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,13 +11,14 @@ "@expo/vector-icons": "^15.1.1", "@livekit/react-native": "^2.11.0", "@livekit/react-native-webrtc": "^144.1.0", - "@react-native-async-storage/async-storage": "^2.2.0", + "@react-native-async-storage/async-storage": "^3.1.1", "@shopify/flash-list": "^2.3.1", - "@tanstack/react-query": "^5.90.21", - "axios": "^1.13.6", - "date-fns": "^4.1.0", + "@tanstack/react-query": "^5.100.14", + "axios": "^1.16.1", + "date-fns": "^4.4.0", "expo": "^56.0.8", "expo-background-task": "~56.0.16", + "expo-callkit-telecom": "^0.3.8", "expo-camera": "~56.0.7", "expo-constants": "~56.0.16", "expo-dev-client": "~56.0.18", @@ -38,34 +39,34 @@ "expo-task-manager": "~56.0.16", "expo-updates": "~56.0.17", "expo-video": "~56.1.2", - "jcore-react-native": "^2.3.5", - "jpush-react-native": "^3.2.6", - "katex": "^0.16.42", + "jcore-react-native": "^2.3.6", + "jpush-react-native": "^3.2.7", + "katex": "^0.17.0", "livekit-client": "^2.19.0", - "markdown-it": "^14.1.1", + "markdown-it": "^14.2.0", "pako": "^2.1.0", "prismjs": "^1.30.0", "react": "19.2.3", "react-dom": "19.2.3", "react-native": "0.85.3", - "react-native-gesture-handler": "~2.31.1", - "react-native-pager-view": "8.0.1", - "react-native-paper": "^5.15.0", - "react-native-reanimated": "4.3.1", - "react-native-safe-area-context": "~5.7.0", + "react-native-gesture-handler": "^3.0.0", + "react-native-pager-view": "^8.0.2", + "react-native-paper": "^5.15.3", + "react-native-reanimated": "^4.4.0", + "react-native-safe-area-context": "~5.8.0", "react-native-screens": "4.25.2", - "react-native-share": "^12.2.6", + "react-native-share": "^12.3.1", "react-native-sse": "^1.2.1", "react-native-web": "^0.21.0", "react-native-webview": "13.16.1", - "react-native-worklets": "0.8.3", - "zod": "^4.3.6", - "zustand": "^5.0.11" + "react-native-worklets": "^0.9.1", + "zod": "^4.4.3", + "zustand": "^5.0.14" }, "devDependencies": { - "@react-native-community/cli": "^20.1.2", + "@react-native-community/cli": "^20.1.3", "@types/pako": "^2.0.4", - "@types/react": "~19.2.2", + "@types/react": "~19.2.16", "@types/react-native-vector-icons": "^6.4.18", "typescript": "~6.0.3" } @@ -1197,18 +1198,6 @@ "node": ">=0.10.0" } }, - "node_modules/@egjs/hammerjs": { - "version": "2.0.17", - "resolved": "https://registry.npmmirror.com/@egjs/hammerjs/-/hammerjs-2.0.17.tgz", - "integrity": "sha512-XQsZgjm2EcVUiZQf11UBJQfmZeEmOW8DpI1gsFeln6w0ae0ii4dMQEQ0kjl6DspdWX1aGY1/loyXnP0JS06e/A==", - "license": "MIT", - "dependencies": { - "@types/hammerjs": "^2.0.36" - }, - "engines": { - "node": ">=0.8.0" - } - }, "node_modules/@expo-google-fonts/material-symbols": { "version": "0.4.38", "resolved": "https://registry.npmmirror.com/@expo-google-fonts/material-symbols/-/material-symbols-0.4.38.tgz", @@ -2267,6 +2256,19 @@ "win32" ] }, + "node_modules/@nodable/entities": { + "version": "2.1.1", + "resolved": "https://registry.npmmirror.com/@nodable/entities/-/entities-2.1.1.tgz", + "integrity": "sha512-Pig3HxDIoMgjdEH8OCf/dkcTmLFjJRjWuq8jSnklu284/TKOPibSRERmOykiwmyXTtv61mP+44f3GMx0tLAyjg==", + "devOptional": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/nodable" + } + ], + "license": "MIT" + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmmirror.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -2793,30 +2795,31 @@ } }, "node_modules/@react-native-async-storage/async-storage": { - "version": "2.2.0", - "resolved": "https://registry.npmmirror.com/@react-native-async-storage/async-storage/-/async-storage-2.2.0.tgz", - "integrity": "sha512-gvRvjR5JAaUZF8tv2Kcq/Gbt3JHwbKFYfmb445rhOj6NUMx3qPLixmDx5pZAyb9at1bYvJ4/eTUipU5aki45xw==", + "version": "3.1.1", + "resolved": "https://registry.npmmirror.com/@react-native-async-storage/async-storage/-/async-storage-3.1.1.tgz", + "integrity": "sha512-z+PnLz1n6ECKhgoHZHkfc+dijXZEyZnNFSajbtE0NEbsJhmX8x9GlOeiMQIKX2E4DUqPSgfIh4FYBv1M49KgPQ==", "license": "MIT", "dependencies": { - "merge-options": "^3.0.4" + "idb": "8.0.3" }, "peerDependencies": { - "react-native": "^0.0.0-0 || >=0.65 <1.0" + "react": "*", + "react-native": "*" } }, "node_modules/@react-native-community/cli": { - "version": "20.1.2", - "resolved": "https://registry.npmmirror.com/@react-native-community/cli/-/cli-20.1.2.tgz", - "integrity": "sha512-48GRnGfm1+4ueV8ESNJmKAKW01QdbB8H6qrVxCqpHYvuRkeFBaPpwRY2bEjSwqruw3Pn9ppzGfpAxYQDiUWQxQ==", + "version": "20.1.3", + "resolved": "https://registry.npmmirror.com/@react-native-community/cli/-/cli-20.1.3.tgz", + "integrity": "sha512-sLo8cu9JyFNfuuF1C+8NJ4DHE/PEFaXGd4enkcxi/OJjGG8+sOQrdjNQ4i+cVh/2c+ah1mEMwsYjc3z0+/MqSg==", "devOptional": true, "license": "MIT", "dependencies": { - "@react-native-community/cli-clean": "20.1.2", - "@react-native-community/cli-config": "20.1.2", - "@react-native-community/cli-doctor": "20.1.2", - "@react-native-community/cli-server-api": "20.1.2", - "@react-native-community/cli-tools": "20.1.2", - "@react-native-community/cli-types": "20.1.2", + "@react-native-community/cli-clean": "20.1.3", + "@react-native-community/cli-config": "20.1.3", + "@react-native-community/cli-doctor": "20.1.3", + "@react-native-community/cli-server-api": "20.1.3", + "@react-native-community/cli-tools": "20.1.3", + "@react-native-community/cli-types": "20.1.3", "commander": "^9.4.1", "deepmerge": "^4.3.0", "execa": "^5.0.0", @@ -2835,26 +2838,26 @@ } }, "node_modules/@react-native-community/cli-clean": { - "version": "20.1.2", - "resolved": "https://registry.npmmirror.com/@react-native-community/cli-clean/-/cli-clean-20.1.2.tgz", - "integrity": "sha512-XcNlmFnYOVDjvHQQn0qreI4FPLEUx8p43EdOmKbtzqwqc9T/EdBdzUnUc5wWQPO1CN7BdMfxW8fyvosz8NIlrg==", + "version": "20.1.3", + "resolved": "https://registry.npmmirror.com/@react-native-community/cli-clean/-/cli-clean-20.1.3.tgz", + "integrity": "sha512-sFLdLzapfC0scjgzBJJWYDY2RhHPjuuPkA5r6q0gc/UQH/izXpMpLrhh1DW84cMDraNACK0U62tU7ebNaQ1LMQ==", "devOptional": true, "license": "MIT", "dependencies": { - "@react-native-community/cli-tools": "20.1.2", + "@react-native-community/cli-tools": "20.1.3", "execa": "^5.0.0", "fast-glob": "^3.3.2", "picocolors": "^1.1.1" } }, "node_modules/@react-native-community/cli-config": { - "version": "20.1.2", - "resolved": "https://registry.npmmirror.com/@react-native-community/cli-config/-/cli-config-20.1.2.tgz", - "integrity": "sha512-7aPE14QA8aXpfuQ1jmfiBfjC/N6gdbg6PpBTwb3cl8c/KaeVm+OQYoC2kn2b3XS0NPgw5Ix/VxVaX6AAUQRFuA==", + "version": "20.1.3", + "resolved": "https://registry.npmmirror.com/@react-native-community/cli-config/-/cli-config-20.1.3.tgz", + "integrity": "sha512-n73nW0cG92oNF0r994pPqm0DjAShOm3F8LSffDYhJqNAno+h/csmv/37iL4NtSpmKIO8xqsG3uVTXz9X/hzNaQ==", "devOptional": true, "license": "MIT", "dependencies": { - "@react-native-community/cli-tools": "20.1.2", + "@react-native-community/cli-tools": "20.1.3", "cosmiconfig": "^9.0.0", "deepmerge": "^4.3.0", "fast-glob": "^3.3.2", @@ -2863,43 +2866,43 @@ } }, "node_modules/@react-native-community/cli-config-android": { - "version": "20.1.2", - "resolved": "https://registry.npmmirror.com/@react-native-community/cli-config-android/-/cli-config-android-20.1.2.tgz", - "integrity": "sha512-W0Qx+lW8pbQMz8x3Rlf/H7D2j2u8O+u9HnrZnKzDl1DaXgaOQqL484lTZlMEQofjq7eLXdmzWxuZdqS6K1QfmQ==", + "version": "20.1.3", + "resolved": "https://registry.npmmirror.com/@react-native-community/cli-config-android/-/cli-config-android-20.1.3.tgz", + "integrity": "sha512-DNHDP+OWLyhKShGciBqPcxhxfp1Z/7GQcb4F+TGyCeKQAr+JdnUjRXN3X+YCU/v+g2kbYYyRJKlGabzkVvdrAw==", "devOptional": true, "license": "MIT", "dependencies": { - "@react-native-community/cli-tools": "20.1.2", + "@react-native-community/cli-tools": "20.1.3", "fast-glob": "^3.3.2", "fast-xml-parser": "^5.3.6", "picocolors": "^1.1.1" } }, "node_modules/@react-native-community/cli-config-apple": { - "version": "20.1.2", - "resolved": "https://registry.npmmirror.com/@react-native-community/cli-config-apple/-/cli-config-apple-20.1.2.tgz", - "integrity": "sha512-Dhi1N1EoMMmJ4dnDlmNWCrJggfv7X/kl3l8uax72uaxepQI/CfohJP2rBdG2mWis+vzrCIk14z2keY0ixxsN8g==", + "version": "20.1.3", + "resolved": "https://registry.npmmirror.com/@react-native-community/cli-config-apple/-/cli-config-apple-20.1.3.tgz", + "integrity": "sha512-QX9B83nAfCPs0KiaYz61kAEHWr9sttooxzRzNdQwvZTwnsIpvWOT9GvMMj/19OeXiQzMJBzZX0Pgt6+spiUsDQ==", "devOptional": true, "license": "MIT", "dependencies": { - "@react-native-community/cli-tools": "20.1.2", + "@react-native-community/cli-tools": "20.1.3", "execa": "^5.0.0", "fast-glob": "^3.3.2", "picocolors": "^1.1.1" } }, "node_modules/@react-native-community/cli-doctor": { - "version": "20.1.2", - "resolved": "https://registry.npmmirror.com/@react-native-community/cli-doctor/-/cli-doctor-20.1.2.tgz", - "integrity": "sha512-bbT1EhomvHz5ZtzxY2czA4/JMXhP4aIAxRDsqiW6wfZA9A9/HXqA4uv6CxP0wZUUmovmPNRl3kW/LWXrRmdv3A==", + "version": "20.1.3", + "resolved": "https://registry.npmmirror.com/@react-native-community/cli-doctor/-/cli-doctor-20.1.3.tgz", + "integrity": "sha512-EI+mAPWn255/WZ4CQohy1I049yiaxVr41C3BeQ2BCyhxODIDR8XRsLzYb1t9MfqK/C3ZncUN2mPSRXFeKPPI1w==", "devOptional": true, "license": "MIT", "dependencies": { - "@react-native-community/cli-config": "20.1.2", - "@react-native-community/cli-platform-android": "20.1.2", - "@react-native-community/cli-platform-apple": "20.1.2", - "@react-native-community/cli-platform-ios": "20.1.2", - "@react-native-community/cli-tools": "20.1.2", + "@react-native-community/cli-config": "20.1.3", + "@react-native-community/cli-platform-android": "20.1.3", + "@react-native-community/cli-platform-apple": "20.1.3", + "@react-native-community/cli-platform-ios": "20.1.3", + "@react-native-community/cli-tools": "20.1.3", "command-exists": "^1.2.8", "deepmerge": "^4.3.0", "envinfo": "^7.13.0", @@ -2913,9 +2916,9 @@ } }, "node_modules/@react-native-community/cli-doctor/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmmirror.com/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "version": "7.8.1", + "resolved": "https://registry.npmmirror.com/semver/-/semver-7.8.1.tgz", + "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", "devOptional": true, "license": "ISC", "bin": { @@ -2926,51 +2929,51 @@ } }, "node_modules/@react-native-community/cli-platform-android": { - "version": "20.1.2", - "resolved": "https://registry.npmmirror.com/@react-native-community/cli-platform-android/-/cli-platform-android-20.1.2.tgz", - "integrity": "sha512-1iHB8cTTJpMyEKhxWTRYsxhBBsiOq3tVguGX/HtBdHRzhlCeDpanE6U+aKxWfMFetMcFL+HLe5nQPcJXf9EtKg==", + "version": "20.1.3", + "resolved": "https://registry.npmmirror.com/@react-native-community/cli-platform-android/-/cli-platform-android-20.1.3.tgz", + "integrity": "sha512-bzB9ELPOISuqgtDZXFPQlkuxx1YFkNx3cNgslc5ElCrk+5LeCLQLIBh/dmIuK8rwUrPcrramjeBj++Noc+TaAA==", "devOptional": true, "license": "MIT", "dependencies": { - "@react-native-community/cli-config-android": "20.1.2", - "@react-native-community/cli-tools": "20.1.2", + "@react-native-community/cli-config-android": "20.1.3", + "@react-native-community/cli-tools": "20.1.3", "execa": "^5.0.0", "logkitty": "^0.7.1", "picocolors": "^1.1.1" } }, "node_modules/@react-native-community/cli-platform-apple": { - "version": "20.1.2", - "resolved": "https://registry.npmmirror.com/@react-native-community/cli-platform-apple/-/cli-platform-apple-20.1.2.tgz", - "integrity": "sha512-UvzjcRGotO3E2xaty8YWE2XMGkkDDaXRtQtNRjzmtcoNY40C+y4iMHxd0o3xbD0bzYM/PO79tXye9MxTWdyVkg==", + "version": "20.1.3", + "resolved": "https://registry.npmmirror.com/@react-native-community/cli-platform-apple/-/cli-platform-apple-20.1.3.tgz", + "integrity": "sha512-XJ+DqAD4hkplWVXK5AMgN7pP9+4yRSe5KfZ/b42+ofkDBI55ALlUmX+9HWE3fMuRjcotTCoNZqX2ov97cFDXpQ==", "devOptional": true, "license": "MIT", "dependencies": { - "@react-native-community/cli-config-apple": "20.1.2", - "@react-native-community/cli-tools": "20.1.2", + "@react-native-community/cli-config-apple": "20.1.3", + "@react-native-community/cli-tools": "20.1.3", "execa": "^5.0.0", "fast-xml-parser": "^5.3.6", "picocolors": "^1.1.1" } }, "node_modules/@react-native-community/cli-platform-ios": { - "version": "20.1.2", - "resolved": "https://registry.npmmirror.com/@react-native-community/cli-platform-ios/-/cli-platform-ios-20.1.2.tgz", - "integrity": "sha512-ZzdLwJMt7ehjO0iy/rQGPgH6uZqMYXeS5uXzSi1DeLYwurV1wOqFc0SLm4TAz5FKYQmHpwBXlMiI12rUmkZxcg==", + "version": "20.1.3", + "resolved": "https://registry.npmmirror.com/@react-native-community/cli-platform-ios/-/cli-platform-ios-20.1.3.tgz", + "integrity": "sha512-2qL48SINotuHbZO73cgqSwqd/OWNx0xTbFSdujhpogV4p8BNwYYypfjh4vJY5qJEB5PxuoVkMXT+aCADpg9nBg==", "devOptional": true, "license": "MIT", "dependencies": { - "@react-native-community/cli-platform-apple": "20.1.2" + "@react-native-community/cli-platform-apple": "20.1.3" } }, "node_modules/@react-native-community/cli-server-api": { - "version": "20.1.2", - "resolved": "https://registry.npmmirror.com/@react-native-community/cli-server-api/-/cli-server-api-20.1.2.tgz", - "integrity": "sha512-ZlINtIYoDAwSemwTU9OavI1IixCCmAPPw1s3Mp0cOvrddFSZ0hx1N1IR+imLyo4lhFfM8OO3rUe9oVJj1SHUCA==", + "version": "20.1.3", + "resolved": "https://registry.npmmirror.com/@react-native-community/cli-server-api/-/cli-server-api-20.1.3.tgz", + "integrity": "sha512-hsNsdUKZDd2T99OuNuiXz4VuvLa1UN0zcxefmPjXQgI0byrBLzzDr+o7p03sKuODSzKi2h+BMnUxiS07HACQLA==", "devOptional": true, "license": "MIT", "dependencies": { - "@react-native-community/cli-tools": "20.1.2", + "@react-native-community/cli-tools": "20.1.3", "body-parser": "^2.2.2", "compression": "^1.7.1", "connect": "^3.6.5", @@ -2984,9 +2987,9 @@ } }, "node_modules/@react-native-community/cli-tools": { - "version": "20.1.2", - "resolved": "https://registry.npmmirror.com/@react-native-community/cli-tools/-/cli-tools-20.1.2.tgz", - "integrity": "sha512-on2VUBZb68RlMxvIrEdK6+NiOEYu/z+t/cz746yGtxn49fwW6Wafzmh1QNZj8HPAuZ8+Ds61LiXbwoDDkzNSSA==", + "version": "20.1.3", + "resolved": "https://registry.npmmirror.com/@react-native-community/cli-tools/-/cli-tools-20.1.3.tgz", + "integrity": "sha512-EAn0vPCMxtHhfWk2UwLmSUfPfLUnFgC7NjiVJVTKJyVk5qGnkPfoT8te/1IUXFTysUB0F0RIi+NgDB4usFOLeA==", "devOptional": true, "license": "MIT", "dependencies": { @@ -3003,9 +3006,9 @@ } }, "node_modules/@react-native-community/cli-tools/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmmirror.com/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "version": "7.8.1", + "resolved": "https://registry.npmmirror.com/semver/-/semver-7.8.1.tgz", + "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", "devOptional": true, "license": "ISC", "bin": { @@ -3016,9 +3019,9 @@ } }, "node_modules/@react-native-community/cli-types": { - "version": "20.1.2", - "resolved": "https://registry.npmmirror.com/@react-native-community/cli-types/-/cli-types-20.1.2.tgz", - "integrity": "sha512-WYK98VdcJE+lRuyRzigE/GQAbaJZOKkjpaLwhmMMItXVTqMmIccfGu9b4pRoQOVfs1aLq87DuwUOi9sxz6OG1g==", + "version": "20.1.3", + "resolved": "https://registry.npmmirror.com/@react-native-community/cli-types/-/cli-types-20.1.3.tgz", + "integrity": "sha512-IdAcegf0pH1hVraxWTG1ACLkYC0LDQfqtaEf42ESyLIF3Xap70JzL/9tAlxw7lSCPZPFWhrcgU0TBc4SkC/ecw==", "devOptional": true, "license": "MIT", "dependencies": { @@ -3400,9 +3403,9 @@ "license": "MIT" }, "node_modules/@tanstack/query-core": { - "version": "5.91.2", - "resolved": "https://registry.npmmirror.com/@tanstack/query-core/-/query-core-5.91.2.tgz", - "integrity": "sha512-Uz2pTgPC1mhqrrSGg18RKCWT/pkduAYtxbcyIyKBhw7dTWjXZIzqmpzO2lBkyWr4hlImQgpu1m1pei3UnkFRWw==", + "version": "5.100.14", + "resolved": "https://registry.npmmirror.com/@tanstack/query-core/-/query-core-5.100.14.tgz", + "integrity": "sha512-5X41dGpxgeaHISCRW2oYwcSycZeULZzAunaudXT9ov1KOTj9xwt0CH6hbwqP1/z74ZWF7rYFnDpyYH07XFcZew==", "license": "MIT", "funding": { "type": "github", @@ -3410,12 +3413,12 @@ } }, "node_modules/@tanstack/react-query": { - "version": "5.91.2", - "resolved": "https://registry.npmmirror.com/@tanstack/react-query/-/react-query-5.91.2.tgz", - "integrity": "sha512-GClLPzbM57iFXv+FlvOUL56XVe00PxuTaVEyj1zAObhRiKF008J5vedmaq7O6ehs+VmPHe8+PUQhMuEyv8d9wQ==", + "version": "5.100.14", + "resolved": "https://registry.npmmirror.com/@tanstack/react-query/-/react-query-5.100.14.tgz", + "integrity": "sha512-oOr6aRdSFEwWhzxEkD/9ZcItM3+LjBSkeVmadWKwUssAHTsqd/7bOjWrX4AbvEkoEhgAxzN0Xk6H/aYzXiYBAw==", "license": "MIT", "dependencies": { - "@tanstack/query-core": "5.91.2" + "@tanstack/query-core": "5.100.14" }, "funding": { "type": "github", @@ -3538,12 +3541,6 @@ "integrity": "sha512-cMQm7pxu6BxtHyqJ7mQZ2kXWV5SLmugybFdHCBbJ5eHzOo6VhBckEgAT3//rP5FwPHNPeEiq4SmQ5ucBwsOo4Q==", "license": "MIT" }, - "node_modules/@types/hammerjs": { - "version": "2.0.46", - "resolved": "https://registry.npmmirror.com/@types/hammerjs/-/hammerjs-2.0.46.tgz", - "integrity": "sha512-ynRvcq6wvqexJ9brDMS4BnBLzmr0e14d6ZJTEShTBWKymQiHwlAyGu0ZPEFI2Fh1U53F7tN9ufClWM5KvqkKOw==", - "license": "MIT" - }, "node_modules/@types/istanbul-lib-coverage": { "version": "2.0.6", "resolved": "https://registry.npmmirror.com/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", @@ -3585,9 +3582,9 @@ "license": "MIT" }, "node_modules/@types/react": { - "version": "19.2.14", - "resolved": "https://registry.npmmirror.com/@types/react/-/react-19.2.14.tgz", - "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "version": "19.2.16", + "resolved": "https://registry.npmmirror.com/@types/react/-/react-19.2.16.tgz", + "integrity": "sha512-esJiCAnl0kfpNdE69f3So4WJUXy95dLZydX0KwK46riIHDzHM7O9Vtf9xCHW0PXIqvgqNrswl522kA/5yx+F4w==", "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -3961,14 +3958,40 @@ "license": "MIT" }, "node_modules/axios": { - "version": "1.13.6", - "resolved": "https://registry.npmmirror.com/axios/-/axios-1.13.6.tgz", - "integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==", + "version": "1.16.1", + "resolved": "https://registry.npmmirror.com/axios/-/axios-1.16.1.tgz", + "integrity": "sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==", "license": "MIT", "dependencies": { - "follow-redirects": "^1.15.11", + "follow-redirects": "^1.16.0", "form-data": "^4.0.5", - "proxy-from-env": "^1.1.0" + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/axios/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmmirror.com/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/axios/node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmmirror.com/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" } }, "node_modules/babel-plugin-polyfill-corejs2": { @@ -4883,9 +4906,9 @@ } }, "node_modules/date-fns": { - "version": "4.1.0", - "resolved": "https://registry.npmmirror.com/date-fns/-/date-fns-4.1.0.tgz", - "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", + "version": "4.4.0", + "resolved": "https://registry.npmmirror.com/date-fns/-/date-fns-4.4.0.tgz", + "integrity": "sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==", "license": "MIT", "funding": { "type": "github", @@ -4893,9 +4916,9 @@ } }, "node_modules/dayjs": { - "version": "1.11.20", - "resolved": "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.20.tgz", - "integrity": "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==", + "version": "1.11.21", + "resolved": "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.21.tgz", + "integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==", "devOptional": true, "license": "MIT" }, @@ -5512,6 +5535,19 @@ "expo": "*" } }, + "node_modules/expo-callkit-telecom": { + "version": "0.3.8", + "resolved": "https://registry.npmmirror.com/expo-callkit-telecom/-/expo-callkit-telecom-0.3.8.tgz", + "integrity": "sha512-Jh3tOMBD5JHCMF8EumOSqT/bQgWUhmCn9EwMZm9LT1mhWWmNQ7wKwAOYGdF7P4i4dtPDj0HG7u7fuljzt4bmQw==", + "license": "MIT", + "peerDependencies": { + "@livekit/react-native-webrtc": ">=144.0.0", + "expo": "*", + "expo-notifications": "*", + "react": "*", + "react-native": "*" + } + }, "node_modules/expo-camera": { "version": "56.0.7", "resolved": "https://registry.npmmirror.com/expo-camera/-/expo-camera-56.0.7.tgz", @@ -6420,9 +6456,9 @@ } }, "node_modules/fast-xml-builder": { - "version": "1.1.4", - "resolved": "https://registry.npmmirror.com/fast-xml-builder/-/fast-xml-builder-1.1.4.tgz", - "integrity": "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg==", + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/fast-xml-builder/-/fast-xml-builder-1.2.0.tgz", + "integrity": "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==", "devOptional": true, "funding": [ { @@ -6432,13 +6468,14 @@ ], "license": "MIT", "dependencies": { - "path-expression-matcher": "^1.1.3" + "path-expression-matcher": "^1.5.0", + "xml-naming": "^0.1.0" } }, "node_modules/fast-xml-parser": { - "version": "5.5.7", - "resolved": "https://registry.npmmirror.com/fast-xml-parser/-/fast-xml-parser-5.5.7.tgz", - "integrity": "sha512-LteOsISQ2GEiDHZch6L9hB0+MLoYVLToR7xotrzU0opCICBkxOPgHAy1HxAvtxfJNXDJpgAsQN30mkrfpO2Prg==", + "version": "5.8.0", + "resolved": "https://registry.npmmirror.com/fast-xml-parser/-/fast-xml-parser-5.8.0.tgz", + "integrity": "sha512-6bIM7fsJxeo3uXv7OncQYsBAMPJ7V16Slahl/6M98C/i2q+vB1+4a0MtrvYwDFEUrwDSbAmeLDRXsOBwrL7yAg==", "devOptional": true, "funding": [ { @@ -6448,9 +6485,11 @@ ], "license": "MIT", "dependencies": { - "fast-xml-builder": "^1.1.4", - "path-expression-matcher": "^1.1.3", - "strnum": "^2.2.0" + "@nodable/entities": "^2.1.0", + "fast-xml-builder": "^1.2.0", + "path-expression-matcher": "^1.5.0", + "strnum": "^2.3.0", + "xml-naming": "^0.1.0" }, "bin": { "fxparser": "src/cli/cli.js" @@ -6630,9 +6669,9 @@ "license": "MIT" }, "node_modules/follow-redirects": { - "version": "1.15.11", - "resolved": "https://registry.npmmirror.com/follow-redirects/-/follow-redirects-1.15.11.tgz", - "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "version": "1.16.0", + "resolved": "https://registry.npmmirror.com/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", "funding": [ { "type": "individual", @@ -7140,6 +7179,12 @@ "url": "https://opencollective.com/express" } }, + "node_modules/idb": { + "version": "8.0.3", + "resolved": "https://registry.npmmirror.com/idb/-/idb-8.0.3.tgz", + "integrity": "sha512-LtwtVyVYO5BqRvcsKuB2iUMnHwPVByPCXFXOpuU96IZPPoPN6xjOGxZQ74pgSVVLQWtUOYgyeL4GE98BY5D3wg==", + "license": "ISC" + }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmmirror.com/ieee754/-/ieee754-1.2.1.tgz", @@ -7549,15 +7594,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-plain-obj": { - "version": "2.1.0", - "resolved": "https://registry.npmmirror.com/is-plain-obj/-/is-plain-obj-2.1.0.tgz", - "integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/is-regex": { "version": "1.2.1", "resolved": "https://registry.npmmirror.com/is-regex/-/is-regex-1.2.1.tgz", @@ -7765,9 +7801,9 @@ } }, "node_modules/jcore-react-native": { - "version": "2.3.5", - "resolved": "https://registry.npmmirror.com/jcore-react-native/-/jcore-react-native-2.3.5.tgz", - "integrity": "sha512-rxkDPGiSyNVHjNibUGeqFaV07PbMqKZnoHJa0srt002QGUH4/9K4Ux2tK0EkqHLFvGFsJqGWwjaxEqQIPvoPTA==", + "version": "2.3.6", + "resolved": "https://registry.npmmirror.com/jcore-react-native/-/jcore-react-native-2.3.6.tgz", + "integrity": "sha512-ImaqjrVDA36ceYqullW4tR109OBypvBlXu4nozBLifjIqcLuAjAKHF/vxmEehaifSEiAHOpLLcEElzOiY3Ypuw==", "license": "ISC", "peerDependencies": { "react-native": ">= 0.60" @@ -7903,9 +7939,9 @@ } }, "node_modules/jpush-react-native": { - "version": "3.2.6", - "resolved": "https://registry.npmmirror.com/jpush-react-native/-/jpush-react-native-3.2.6.tgz", - "integrity": "sha512-dnrdfwsZcAYsQLknVMRMFml/f5cDUFlVKaqpgN41au+g2lxYvPyY7v15XZbNiMdnAjzzVgPhmUW8kuptkLtDXw==", + "version": "3.2.7", + "resolved": "https://registry.npmmirror.com/jpush-react-native/-/jpush-react-native-3.2.7.tgz", + "integrity": "sha512-oKZXNXnXMeyNBDmFEh70yVBsAhCyOgMmAT5VBiR3pfnk0cRgS/KQrDjng2flG9MPH8W9SKjov4gjQ5k/kCvHkA==", "license": "ISC", "peerDependencies": { "jcore-react-native": ">= 1.9.3", @@ -7978,9 +8014,9 @@ } }, "node_modules/katex": { - "version": "0.16.42", - "resolved": "https://registry.npmmirror.com/katex/-/katex-0.16.42.tgz", - "integrity": "sha512-sZ4jqyEXfHTLEFK+qsFYToa3UZ0rtFcPGwKpyiRYh2NJn8obPWOQ+/u7ux0F6CAU/y78+Mksh1YkxTPXTh47TQ==", + "version": "0.17.0", + "resolved": "https://registry.npmmirror.com/katex/-/katex-0.17.0.tgz", + "integrity": "sha512-Vdw0ATsQ9V+LuegM/BTwQqV/6cTl5lbGcIrU+BCgLxyf6bo38ybOr372tuSIxir3CN720flu1meYR6XzNMwQnw==", "funding": [ "https://opencollective.com/katex", "https://github.com/sponsors/katex" @@ -8021,14 +8057,14 @@ } }, "node_modules/launch-editor": { - "version": "2.13.1", - "resolved": "https://registry.npmmirror.com/launch-editor/-/launch-editor-2.13.1.tgz", - "integrity": "sha512-lPSddlAAluRKJ7/cjRFoXUFzaX7q/YKI7yPHuEvSJVqoXvFnJov1/Ud87Aa4zULIbA9Nja4mSPK8l0z/7eV2wA==", + "version": "2.14.1", + "resolved": "https://registry.npmmirror.com/launch-editor/-/launch-editor-2.14.1.tgz", + "integrity": "sha512-QWBrQsMpH7gPr965dsKD/3cKWiNoTjpATQf++Xq63N6sKRGMwlVXz41O1IZTMfZQgBctD/K5Zt06+/I6pP6+HA==", "devOptional": true, "license": "MIT", "dependencies": { "picocolors": "^1.1.1", - "shell-quote": "^1.8.3" + "shell-quote": "^1.8.4" } }, "node_modules/leven": { @@ -8322,9 +8358,19 @@ "license": "MIT" }, "node_modules/linkify-it": { - "version": "5.0.0", - "resolved": "https://registry.npmmirror.com/linkify-it/-/linkify-it-5.0.0.tgz", - "integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==", + "version": "5.0.1", + "resolved": "https://registry.npmmirror.com/linkify-it/-/linkify-it-5.0.1.tgz", + "integrity": "sha512-wVoTjP4Q6R0NW5hiZkVJaFZPWgtXfoGF+6LucL3/FtiNjmcHhYjEr5f1Kqjirc1nBW07J/ZuRFumqr2oqccEWg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], "license": "MIT", "dependencies": { "uc.micro": "^2.0.0" @@ -8614,14 +8660,24 @@ } }, "node_modules/markdown-it": { - "version": "14.1.1", - "resolved": "https://registry.npmmirror.com/markdown-it/-/markdown-it-14.1.1.tgz", - "integrity": "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==", + "version": "14.2.0", + "resolved": "https://registry.npmmirror.com/markdown-it/-/markdown-it-14.2.0.tgz", + "integrity": "sha512-1TGiQiJVRQ3NPmZH6sx5Cfnmg6GQm9jvC1ch4TK511NjSJvjzKLzn5pPfZRNZkRPZP0HqCioSndqH8v2nRaWVQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], "license": "MIT", "dependencies": { "argparse": "^2.0.1", "entities": "^4.4.0", - "linkify-it": "^5.0.0", + "linkify-it": "^5.0.1", "mdurl": "^2.0.0", "punycode.js": "^2.3.1", "uc.micro": "^2.1.0" @@ -8667,18 +8723,6 @@ "integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==", "license": "MIT" }, - "node_modules/merge-options": { - "version": "3.0.4", - "resolved": "https://registry.npmmirror.com/merge-options/-/merge-options-3.0.4.tgz", - "integrity": "sha512-2Sug1+knBjkaMsMgf1ctR1Ujx+Ayku4EdJN4Z+C2+JzoeF7A3OZ9KM2GY0CpQS51NR61LTurMJrRKPhSs3ZRTQ==", - "license": "MIT", - "dependencies": { - "is-plain-obj": "^2.1.0" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmmirror.com/merge-stream/-/merge-stream-2.0.0.tgz", @@ -9649,9 +9693,9 @@ } }, "node_modules/path-expression-matcher": { - "version": "1.2.0", - "resolved": "https://registry.npmmirror.com/path-expression-matcher/-/path-expression-matcher-1.2.0.tgz", - "integrity": "sha512-DwmPWeFn+tq7TiyJ2CxezCAirXjFxvaiD03npak3cRjlP9+OjTmSy1EpIrEbh+l6JgUundniloMLDQ/6VTdhLQ==", + "version": "1.5.0", + "resolved": "https://registry.npmmirror.com/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz", + "integrity": "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==", "devOptional": true, "funding": [ { @@ -9890,10 +9934,13 @@ } }, "node_modules/proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmmirror.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", - "license": "MIT" + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } }, "node_modules/punycode": { "version": "2.3.1", @@ -9914,9 +9961,9 @@ } }, "node_modules/qs": { - "version": "6.15.0", - "resolved": "https://registry.npmmirror.com/qs/-/qs-6.15.0.tgz", - "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", + "version": "6.15.2", + "resolved": "https://registry.npmmirror.com/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", "devOptional": true, "license": "BSD-3-Clause", "dependencies": { @@ -10154,14 +10201,12 @@ } }, "node_modules/react-native-gesture-handler": { - "version": "2.31.2", - "resolved": "https://registry.npmmirror.com/react-native-gesture-handler/-/react-native-gesture-handler-2.31.2.tgz", - "integrity": "sha512-rw5q74i2AfS7YGYdbxQDhOU7xqgY6WRM1132/CCm3erqjblhECZDZFHIm0tteHoC9ih24wogVBVVzcTBQtZ+5A==", + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/react-native-gesture-handler/-/react-native-gesture-handler-3.0.0.tgz", + "integrity": "sha512-6E8o9D2sHwhFGiU0c4aCweMdJwIbQeBV+dq3IQ3HcqKhVGzg7ccEycap6i0zGCtIYfs3V29Xd4OycwcRj5qxBQ==", "license": "MIT", "dependencies": { - "@egjs/hammerjs": "^2.0.17", "@types/react-test-renderer": "^19.1.0", - "hoist-non-react-statics": "^3.3.0", "invariant": "^2.2.4" }, "peerDependencies": { @@ -10180,9 +10225,9 @@ } }, "node_modules/react-native-pager-view": { - "version": "8.0.1", - "resolved": "https://registry.npmmirror.com/react-native-pager-view/-/react-native-pager-view-8.0.1.tgz", - "integrity": "sha512-pGOne2o0y0HOQLrlTLcGgOE48uJlqSZHRRwdW8nL6JJozMkPGJYi/G9e0EsJoWFpXYONjiDgr8IwxC4F6/r7Lg==", + "version": "8.0.2", + "resolved": "https://registry.npmmirror.com/react-native-pager-view/-/react-native-pager-view-8.0.2.tgz", + "integrity": "sha512-Y8All2BbjidI4ZIF9kBOIIoldkqrY/2FXapHZMdjwD+ByXhiOWTFenPs5rq9KRN5uAH/gOXtQCqHLxQDNBQRiQ==", "license": "MIT", "peerDependencies": { "react": "*", @@ -10190,9 +10235,9 @@ } }, "node_modules/react-native-paper": { - "version": "5.15.0", - "resolved": "https://registry.npmmirror.com/react-native-paper/-/react-native-paper-5.15.0.tgz", - "integrity": "sha512-I/1CQLfW9VM0Oo5I5dQI/hjgf1I6q2S1wwgzAdsv6whAQ3zO97GWHwtgNh9se9j8zBOJ86afPTQKxxUL0IJd9A==", + "version": "5.15.3", + "resolved": "https://registry.npmmirror.com/react-native-paper/-/react-native-paper-5.15.3.tgz", + "integrity": "sha512-GEyNTmWElIZgnYw09AjjCNupRYzCmP79uAAyGSyCEUZz7KBz1wtJcC0wVUkozR1Rn3PK/td/9LlR6+F1hzmYvA==", "license": "MIT", "workspaces": [ "example", @@ -10235,9 +10280,9 @@ "license": "MIT" }, "node_modules/react-native-reanimated": { - "version": "4.3.1", - "resolved": "https://registry.npmmirror.com/react-native-reanimated/-/react-native-reanimated-4.3.1.tgz", - "integrity": "sha512-KhGsS0YkCA+gusgyzlf9hnqzVPIR398KTpqXyqq/+yYJJPAvyEEPKcxlB0xtOOXSMrR2A9uRKVARVQhZwrOh+Q==", + "version": "4.4.0", + "resolved": "https://registry.npmmirror.com/react-native-reanimated/-/react-native-reanimated-4.4.0.tgz", + "integrity": "sha512-0XbC1SpF3JZOz5QfmTEx3vt8VkmkTlS05CBIOKEg5q5ZSNlGtlacntlhj5CrfZlN1ciHAeoliJouTC2cLGKbDA==", "license": "MIT", "dependencies": { "react-native-is-edge-to-edge": "^1.3.1", @@ -10245,8 +10290,8 @@ }, "peerDependencies": { "react": "*", - "react-native": "0.81 - 0.85", - "react-native-worklets": "0.8.x" + "react-native": "0.83 - 0.86", + "react-native-worklets": "0.9.x" } }, "node_modules/react-native-reanimated/node_modules/semver": { @@ -10262,9 +10307,9 @@ } }, "node_modules/react-native-safe-area-context": { - "version": "5.7.0", - "resolved": "https://registry.npmmirror.com/react-native-safe-area-context/-/react-native-safe-area-context-5.7.0.tgz", - "integrity": "sha512-/9/MtQz8ODphjsLdZ+GZAIcC/RtoqW9EeShf7Uvnfgm/pzYrJ75y3PV/J1wuAV1T5Dye5ygq4EAW20RoBq0ABQ==", + "version": "5.8.0", + "resolved": "https://registry.npmmirror.com/react-native-safe-area-context/-/react-native-safe-area-context-5.8.0.tgz", + "integrity": "sha512-t+ZsAVzY/wWzzx34vqGbo3/as9EEESJdbyZNL7Yg5EYX+toYMtMqFoDDCvqZUi35eeGVsXc6pAaEk4edMwbuCQ==", "license": "MIT", "peerDependencies": { "react": "*", @@ -10286,9 +10331,9 @@ } }, "node_modules/react-native-share": { - "version": "12.2.6", - "resolved": "https://registry.npmmirror.com/react-native-share/-/react-native-share-12.2.6.tgz", - "integrity": "sha512-K9jZCQaTIqSNG37kMVygU1rflVMJm2g0ikslnbbmQ7EgsckYpw7ipePyp01E64hG+HrWNl+z9ZnFWLCC6H+Tiw==", + "version": "12.3.1", + "resolved": "https://registry.npmmirror.com/react-native-share/-/react-native-share-12.3.1.tgz", + "integrity": "sha512-mRVRie0qKbtj+jWqJ2POPkeSd8SxnMp6aazFZVmq8ZbkUGtQLENR/L58ky8zH4VUEF/WYRmdBiBHWHVtzOewtQ==", "license": "MIT", "engines": { "node": ">=16" @@ -10359,28 +10404,28 @@ } }, "node_modules/react-native-worklets": { - "version": "0.8.3", - "resolved": "https://registry.npmmirror.com/react-native-worklets/-/react-native-worklets-0.8.3.tgz", - "integrity": "sha512-oCBJROyLU7yG/1R8s0INMflygTH71bx+5XcYkH0CM938TlhSoVbiunE1WVW5FZa51vwYqfLie/IXMX2s1Kh3eg==", + "version": "0.9.1", + "resolved": "https://registry.npmmirror.com/react-native-worklets/-/react-native-worklets-0.9.1.tgz", + "integrity": "sha512-kb6lGtBI5Ap41tvBPM09Np472r2GXuJ+jRApIFy1eXBk699eChG3U+lyqRC2/wz/VDpaJAy6i5XPcceNOoH3mA==", "license": "MIT", "dependencies": { "@babel/plugin-transform-arrow-functions": "^7.27.1", - "@babel/plugin-transform-class-properties": "^7.27.1", - "@babel/plugin-transform-classes": "^7.28.4", - "@babel/plugin-transform-nullish-coalescing-operator": "^7.27.1", - "@babel/plugin-transform-optional-chaining": "^7.27.1", + "@babel/plugin-transform-class-properties": "^7.28.6", + "@babel/plugin-transform-classes": "^7.28.6", + "@babel/plugin-transform-nullish-coalescing-operator": "^7.28.6", + "@babel/plugin-transform-optional-chaining": "^7.28.6", "@babel/plugin-transform-shorthand-properties": "^7.27.1", "@babel/plugin-transform-template-literals": "^7.27.1", "@babel/plugin-transform-unicode-regex": "^7.27.1", - "@babel/preset-typescript": "^7.27.1", + "@babel/preset-typescript": "^7.28.5", "convert-source-map": "^2.0.0", - "semver": "^7.7.3" + "semver": "^7.7.4" }, "peerDependencies": { "@babel/core": "*", "@react-native/metro-config": "*", "react": "*", - "react-native": "0.81 - 0.85" + "react-native": "0.83 - 0.86" } }, "node_modules/react-native-worklets/node_modules/semver": { @@ -11082,9 +11127,9 @@ } }, "node_modules/shell-quote": { - "version": "1.8.3", - "resolved": "https://registry.npmmirror.com/shell-quote/-/shell-quote-1.8.3.tgz", - "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", + "version": "1.8.4", + "resolved": "https://registry.npmmirror.com/shell-quote/-/shell-quote-1.8.4.tgz", + "integrity": "sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==", "license": "MIT", "engines": { "node": ">= 0.4" @@ -11513,9 +11558,9 @@ } }, "node_modules/strnum": { - "version": "2.2.1", - "resolved": "https://registry.npmmirror.com/strnum/-/strnum-2.2.1.tgz", - "integrity": "sha512-BwRvNd5/QoAtyW1na1y1LsJGQNvRlkde6Q/ipqqEaivoMdV+B1OMOTVdwR+N/cwVUcIt9PYyHmV8HyexCZSupg==", + "version": "2.3.0", + "resolved": "https://registry.npmmirror.com/strnum/-/strnum-2.3.0.tgz", + "integrity": "sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q==", "devOptional": true, "funding": [ { @@ -11703,18 +11748,36 @@ } }, "node_modules/type-is": { - "version": "2.0.1", - "resolved": "https://registry.npmmirror.com/type-is/-/type-is-2.0.1.tgz", - "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", "devOptional": true, "license": "MIT", "dependencies": { - "content-type": "^1.0.5", + "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" }, "engines": { - "node": ">= 0.6" + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/type-is/node_modules/mime-types": { @@ -12363,9 +12426,9 @@ } }, "node_modules/ws": { - "version": "6.2.3", - "resolved": "https://registry.npmmirror.com/ws/-/ws-6.2.3.tgz", - "integrity": "sha512-jmTjYU0j60B+vHey6TfR3Z7RD61z/hmxBS3VMSGIrroOWXQEneK1zNuotOUrGyBHQj0yrpsLHPWtigEFd13ndA==", + "version": "6.2.4", + "resolved": "https://registry.npmmirror.com/ws/-/ws-6.2.4.tgz", + "integrity": "sha512-PNIUUyLI5YpkJZj60YBzX1o0ByQ4ovvfmq9N/Kig/PAYbVlGyz4R6G0SEWrD0O9acc0sT2+IdMBVLFv8FSi0Nw==", "devOptional": true, "license": "MIT", "dependencies": { @@ -12385,6 +12448,22 @@ "node": ">=10.0.0" } }, + "node_modules/xml-naming": { + "version": "0.1.0", + "resolved": "https://registry.npmmirror.com/xml-naming/-/xml-naming-0.1.0.tgz", + "integrity": "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==", + "devOptional": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/xml2js": { "version": "0.6.0", "resolved": "https://registry.npmmirror.com/xml2js/-/xml2js-0.6.0.tgz", @@ -12487,18 +12566,18 @@ } }, "node_modules/zod": { - "version": "4.3.6", - "resolved": "https://registry.npmmirror.com/zod/-/zod-4.3.6.tgz", - "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "version": "4.4.3", + "resolved": "https://registry.npmmirror.com/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" } }, "node_modules/zustand": { - "version": "5.0.12", - "resolved": "https://registry.npmmirror.com/zustand/-/zustand-5.0.12.tgz", - "integrity": "sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g==", + "version": "5.0.14", + "resolved": "https://registry.npmmirror.com/zustand/-/zustand-5.0.14.tgz", + "integrity": "sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==", "license": "MIT", "engines": { "node": ">=12.20.0" diff --git a/package.json b/package.json index 3d618ed..dfd460c 100644 --- a/package.json +++ b/package.json @@ -17,13 +17,14 @@ "@expo/vector-icons": "^15.1.1", "@livekit/react-native": "^2.11.0", "@livekit/react-native-webrtc": "^144.1.0", - "@react-native-async-storage/async-storage": "^2.2.0", + "@react-native-async-storage/async-storage": "^3.1.1", "@shopify/flash-list": "^2.3.1", - "@tanstack/react-query": "^5.90.21", - "axios": "^1.13.6", - "date-fns": "^4.1.0", + "@tanstack/react-query": "^5.100.14", + "axios": "^1.16.1", + "date-fns": "^4.4.0", "expo": "^56.0.8", "expo-background-task": "~56.0.16", + "expo-callkit-telecom": "^0.3.8", "expo-camera": "~56.0.7", "expo-constants": "~56.0.16", "expo-dev-client": "~56.0.18", @@ -44,34 +45,34 @@ "expo-task-manager": "~56.0.16", "expo-updates": "~56.0.17", "expo-video": "~56.1.2", - "jcore-react-native": "^2.3.5", - "jpush-react-native": "^3.2.6", - "katex": "^0.16.42", + "jcore-react-native": "^2.3.6", + "jpush-react-native": "^3.2.7", + "katex": "^0.17.0", "livekit-client": "^2.19.0", - "markdown-it": "^14.1.1", + "markdown-it": "^14.2.0", "pako": "^2.1.0", "prismjs": "^1.30.0", "react": "19.2.3", "react-dom": "19.2.3", "react-native": "0.85.3", - "react-native-gesture-handler": "~2.31.1", - "react-native-pager-view": "8.0.1", - "react-native-paper": "^5.15.0", - "react-native-reanimated": "4.3.1", - "react-native-safe-area-context": "~5.7.0", + "react-native-gesture-handler": "^3.0.0", + "react-native-pager-view": "^8.0.2", + "react-native-paper": "^5.15.3", + "react-native-reanimated": "^4.4.0", + "react-native-safe-area-context": "~5.8.0", "react-native-screens": "4.25.2", - "react-native-share": "^12.2.6", + "react-native-share": "^12.3.1", "react-native-sse": "^1.2.1", "react-native-web": "^0.21.0", "react-native-webview": "13.16.1", - "react-native-worklets": "0.8.3", - "zod": "^4.3.6", - "zustand": "^5.0.11" + "react-native-worklets": "^0.9.1", + "zod": "^4.4.3", + "zustand": "^5.0.14" }, "devDependencies": { - "@react-native-community/cli": "^20.1.2", + "@react-native-community/cli": "^20.1.3", "@types/pako": "^2.0.4", - "@types/react": "~19.2.2", + "@types/react": "~19.2.16", "@types/react-native-vector-icons": "^6.4.18", "typescript": "~6.0.3" }, diff --git a/src/components/call/CallScreen.tsx b/src/components/call/CallScreen.tsx index 17d03ea..7994d85 100644 --- a/src/components/call/CallScreen.tsx +++ b/src/components/call/CallScreen.tsx @@ -40,10 +40,27 @@ const CallScreen: React.FC = () => { const [remoteVideoTrack, setRemoteVideoTrack] = useState(null); const [localVideoTrack, setLocalVideoTrack] = useState(null); - // Subscribe to LiveKit track events + // Event-driven video track subscription (no polling) useEffect(() => { const unsubs: (() => void)[] = []; + // Sync current tracks immediately + const syncTracks = () => { + const lp = liveKitService.localParticipant; + if (lp) { + const videoPub = lp.getTrackPublication(Track.Source.Camera); + setLocalVideoTrack((videoPub?.track as VideoTrack | undefined) ?? null); + } + liveKitService.remoteParticipants.forEach((participant) => { + const videoPub = participant.getTrackPublication(Track.Source.Camera); + if (videoPub?.track) { + setRemoteVideoTrack(videoPub.track as VideoTrack); + } + }); + }; + syncTracks(); + + // Remote video track events unsubs.push( liveKitService.on('trackSubscribed', ({ track }) => { if (track.kind === Track.Kind.Video) { @@ -69,28 +86,32 @@ const CallScreen: React.FC = () => { ); unsubs.push( - liveKitService.on('trackUnmuted', ({ publication, participant }) => { + liveKitService.on('trackUnmuted', ({ publication }) => { if (publication.source === Track.Source.Camera && publication.track) { setRemoteVideoTrack(publication.track as VideoTrack); } }) ); - // Poll local video track from localParticipant - const localPollInterval = setInterval(() => { - const lp = liveKitService.localParticipant; - if (lp) { - const videoPub = lp.getTrackPublication(Track.Source.Camera); - const track = (videoPub?.track ?? null) as VideoTrack | null; - setLocalVideoTrack((prev) => (prev === track ? prev : track)); - } else { - setLocalVideoTrack((prev) => (prev === null ? prev : null)); - } - }, 500); + // Local video track events (replace 500ms polling) + unsubs.push( + liveKitService.on('localTrackPublished', ({ publication }) => { + if (publication.source === Track.Source.Camera && publication.track) { + setLocalVideoTrack(publication.track as VideoTrack); + } + }) + ); + + unsubs.push( + liveKitService.on('localTrackUnpublished', ({ publication }) => { + if (publication.source === Track.Source.Camera) { + setLocalVideoTrack(null); + } + }) + ); return () => { unsubs.forEach((unsub) => unsub()); - clearInterval(localPollInterval); }; }, []); diff --git a/src/components/call/FloatingCallWindow.tsx b/src/components/call/FloatingCallWindow.tsx index ba43fbc..4c19494 100644 --- a/src/components/call/FloatingCallWindow.tsx +++ b/src/components/call/FloatingCallWindow.tsx @@ -6,9 +6,12 @@ import { TouchableOpacity, Image, } from 'react-native'; -import { MaterialCommunityIcons } from '@expo/vector-icons'; +import { MaterialCommunityIcons, Ionicons } from '@expo/vector-icons'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { callStore } from '../../stores/call'; +const BUTTON_SIZE = 40; + const formatDuration = (seconds: number): string => { const mins = Math.floor(seconds / 60); const secs = seconds % 60; @@ -22,69 +25,86 @@ const FloatingCallWindow: React.FC = () => { const toggleMinimize = callStore((s) => s.toggleMinimize); const endCall = callStore((s) => s.endCall); - // Only show when there's an active call and it's minimized + const insets = useSafeAreaInsets(); + if (!currentCall || !isMinimized) return null; const getStatusText = (): string => { switch (currentCall.status) { - case 'calling': - return '等待接听...'; - case 'ringing': - return '来电响铃...'; - case 'connecting': - return '连接中...'; case 'connected': return formatDuration(callDuration); + case 'connecting': + case 'calling': + case 'ringing': + return '连接中...'; case 'reconnecting': return '重连中...'; - case 'ended': - return '已结束'; - case 'failed': - return '连接失败'; default: return ''; } }; + const isConnected = currentCall.status === 'connected'; + const participantCount = currentCall.participants.size; + const isVideoCall = currentCall.mediaType === 'video'; + const isGroupCall = participantCount > 2; + const displayName = isGroupCall + ? `群组通话 (${participantCount})` + : currentCall.peerName || '未知用户'; + return ( - + - {/* Avatar */} - - {currentCall.peerAvatar ? ( - + {/* Call type indicator */} + + + + + {/* Avatar + status indicator ring */} + + {currentCall.peerAvatar && !isGroupCall ? ( + ) : ( - - - {currentCall.peerName?.charAt(0).toUpperCase() || '?'} - + + {isGroupCall ? ( + + ) : ( + + {currentCall.peerName?.charAt(0).toUpperCase() || '?'} + + )} )} + {isConnected && } {/* Info */} - {currentCall.peerName || '未知用户'} + {displayName} + + + {getStatusText()} - {getStatusText()} {/* End call button */} { e.stopPropagation(); endCall('hangup'); }} activeOpacity={0.7} + hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }} > @@ -96,25 +116,34 @@ const FloatingCallWindow: React.FC = () => { const styles = StyleSheet.create({ container: { position: 'absolute', - top: 100, - left: 16, - right: 16, + left: 0, + right: 0, zIndex: 10000, + paddingHorizontal: 16, }, - window: { + bar: { flexDirection: 'row', alignItems: 'center', - backgroundColor: '#2A2A4E', - borderRadius: 16, - padding: 12, + backgroundColor: 'rgba(20, 20, 40, 0.92)', + borderRadius: 18, + paddingVertical: 12, + paddingHorizontal: 14, shadowColor: '#000', shadowOffset: { width: 0, height: 4 }, - shadowOpacity: 0.3, - shadowRadius: 8, - elevation: 8, + shadowOpacity: 0.35, + shadowRadius: 12, + elevation: 10, + borderWidth: 0.5, + borderColor: 'rgba(255,255,255,0.08)', }, - avatarContainer: { - marginRight: 12, + callTypeIcon: { + marginRight: 8, + width: 20, + alignItems: 'center', + }, + avatarWrap: { + position: 'relative', + marginRight: 14, }, avatar: { width: 44, @@ -122,7 +151,7 @@ const styles = StyleSheet.create({ borderRadius: 22, backgroundColor: '#3A3A5C', }, - avatarPlaceholder: { + avatarFallback: { justifyContent: 'center', alignItems: 'center', }, @@ -131,28 +160,44 @@ const styles = StyleSheet.create({ color: '#fff', fontWeight: '600', }, + statusDot: { + position: 'absolute', + bottom: 0, + right: 0, + width: 12, + height: 12, + borderRadius: 6, + backgroundColor: '#34C759', + borderWidth: 2, + borderColor: 'rgba(20, 20, 40, 0.92)', + }, info: { flex: 1, justifyContent: 'center', }, name: { - fontSize: 15, + fontSize: 16, fontWeight: '600', color: '#FFFFFF', marginBottom: 2, }, status: { fontSize: 12, - color: 'rgba(255, 255, 255, 0.6)', + color: 'rgba(255, 255, 255, 0.45)', }, - endButton: { - width: 36, - height: 36, - borderRadius: 18, + statusTimer: { + color: '#34C759', + fontWeight: '500', + fontVariant: ['tabular-nums'], + }, + endBtn: { + width: BUTTON_SIZE, + height: BUTTON_SIZE, + borderRadius: BUTTON_SIZE / 2, backgroundColor: '#E54D42', justifyContent: 'center', alignItems: 'center', - marginLeft: 8, + marginLeft: 10, }, }); diff --git a/src/components/call/IncomingCallModal.tsx b/src/components/call/IncomingCallModal.tsx index 6de9320..4d934cf 100644 --- a/src/components/call/IncomingCallModal.tsx +++ b/src/components/call/IncomingCallModal.tsx @@ -8,160 +8,190 @@ import { Animated, Modal, StatusBar, - Dimensions, - Platform, } from 'react-native'; import { MaterialCommunityIcons } from '@expo/vector-icons'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { callStore } from '../../stores/call'; import { blurActiveElement } from '../../infrastructure/platform'; -const { height: SCREEN_HEIGHT } = Dimensions.get('window'); +const AVATAR_SIZE = 104; +const BTN_SIZE = 68; +const BTN_ICON = 30; const IncomingCallModal: React.FC = () => { const incomingCall = callStore((s) => s.incomingCall); const acceptCall = callStore((s) => s.acceptCall); const rejectCall = callStore((s) => s.rejectCall); + const insets = useSafeAreaInsets(); const pulseAnim = useRef(new Animated.Value(1)).current; - const rippleAnim = useRef(new Animated.Value(0)).current; + const rippleAnim1 = useRef(new Animated.Value(0)).current; + const rippleAnim2 = useRef(new Animated.Value(0)).current; useEffect(() => { if (incomingCall) { blurActiveElement(); + const pulse = Animated.loop( Animated.sequence([ Animated.timing(pulseAnim, { - toValue: 1.06, - duration: 1000, + toValue: 1.05, + duration: 1200, useNativeDriver: true, }), Animated.timing(pulseAnim, { toValue: 1, - duration: 1000, + duration: 1200, useNativeDriver: true, }), - ]) + ]), ); - const ripple = Animated.loop( + const ripple1 = Animated.loop( Animated.sequence([ - Animated.timing(rippleAnim, { + Animated.timing(rippleAnim1, { toValue: 1, - duration: 2000, + duration: 2200, useNativeDriver: true, }), - Animated.timing(rippleAnim, { + Animated.timing(rippleAnim1, { toValue: 0, duration: 0, useNativeDriver: true, }), - ]) + ]), ); - Animated.parallel([pulse, ripple]).start(); + const ripple2 = Animated.loop( + Animated.sequence([ + Animated.delay(1100), + Animated.timing(rippleAnim2, { + toValue: 1, + duration: 2200, + useNativeDriver: true, + }), + Animated.timing(rippleAnim2, { + toValue: 0, + duration: 0, + useNativeDriver: true, + }), + ]), + ); + + Animated.parallel([pulse, ripple1, ripple2]).start(); return () => { pulse.stop(); - ripple.stop(); + ripple1.stop(); + ripple2.stop(); }; } - }, [incomingCall, pulseAnim, rippleAnim]); - - const handleAccept = () => { - acceptCall(); - }; - - const handleReject = () => { - rejectCall(); - }; + }, [incomingCall, pulseAnim, rippleAnim1, rippleAnim2]); if (!incomingCall) return null; + const callerName = incomingCall.callerName || '未知用户'; + const callerInitial = callerName.charAt(0).toUpperCase(); + const isVideoCall = incomingCall.callType === 'video'; + return ( - - + + - {/* Avatar with ripple effect - positioned in upper area */} + {/* Top safe area + label */} + + + {isVideoCall ? '视频通话邀请' : '语音通话邀请'} + + + + {/* Avatar section with ripples */} - {/* Ripple rings */} - {[0, 1, 2].map((i) => ( + {/* Ripple rings — staggered */} + {[ + { anim: rippleAnim1, scaleRange: [1, 1.6], opacityRange: [0.2, 0] as const }, + { anim: rippleAnim2, scaleRange: [1, 1.4], opacityRange: [0.15, 0] as const }, + ].map(({ anim, scaleRange, opacityRange }, i) => ( ))} - - {incomingCall.callerAvatar ? ( - - ) : ( - - - {incomingCall.callerName?.charAt(0).toUpperCase() || '?'} - - - )} + + + {incomingCall.callerAvatar ? ( + + ) : ( + + {callerInitial} + + )} + {/* Caller info */} - {incomingCall.callerName || '未知用户'} + {callerName} - {incomingCall.callType === 'video' ? '视频通话' : '语音通话'} + {isVideoCall ? '邀请你进行视频通话' : '邀请你进行语音通话'} - {/* Bottom controls */} - + {/* Bottom action buttons */} + {/* Decline */} - + 拒绝 {/* Accept */} - + 接听 @@ -171,36 +201,45 @@ const IncomingCallModal: React.FC = () => { ); }; -const AVATAR_SIZE = 100; -const ACTION_CIRCLE_SIZE = 60; +export default IncomingCallModal; const styles = StyleSheet.create({ - overlay: { + root: { flex: 1, - backgroundColor: '#0D0D0D', + backgroundColor: '#0A0A14', }, - avatarSection: { - position: 'absolute', - top: '18%', - left: 0, - right: 0, - height: AVATAR_SIZE + 60, - justifyContent: 'center', + topLabel: { alignItems: 'center', }, - rippleRing: { + topLabelText: { + fontSize: 13, + fontWeight: '500', + color: 'rgba(255,255,255,0.35)', + letterSpacing: 0.5, + textTransform: 'uppercase', + }, + + // ---- Avatar + ripples ---- + avatarSection: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + maxHeight: 260, + }, + ripple: { position: 'absolute', - width: AVATAR_SIZE + 16, - height: AVATAR_SIZE + 16, - borderRadius: (AVATAR_SIZE + 16) / 2, + width: AVATAR_SIZE + 32, + height: AVATAR_SIZE + 32, + borderRadius: (AVATAR_SIZE + 32) / 2, borderWidth: 2, borderColor: '#4CD964', }, - avatarContainer: { - width: AVATAR_SIZE + 8, - height: AVATAR_SIZE + 8, - borderRadius: (AVATAR_SIZE + 8) / 2, - backgroundColor: '#4CD964', + avatarBorder: { + width: AVATAR_SIZE + 10, + height: AVATAR_SIZE + 10, + borderRadius: (AVATAR_SIZE + 10) / 2, + borderWidth: 2.5, + borderColor: 'rgba(76, 217, 100, 0.4)', justifyContent: 'center', alignItems: 'center', }, @@ -208,72 +247,80 @@ const styles = StyleSheet.create({ width: AVATAR_SIZE, height: AVATAR_SIZE, borderRadius: AVATAR_SIZE / 2, - backgroundColor: '#3A3A5C', + backgroundColor: '#2A2A4E', }, - avatarPlaceholder: { + avatarFallback: { justifyContent: 'center', alignItems: 'center', }, - avatarText: { - fontSize: 36, + avatarInitial: { + fontSize: 42, color: '#fff', fontWeight: '600', }, + + // ---- Info ---- infoSection: { - position: 'absolute', - top: '42%', - left: 0, - right: 0, alignItems: 'center', paddingHorizontal: 40, + marginTop: -20, + marginBottom: 40, }, callerName: { - fontSize: 24, + fontSize: 28, fontWeight: '600', color: '#FFFFFF', - marginBottom: 6, textAlign: 'center', - maxWidth: 280, + maxWidth: 300, }, callType: { - fontSize: 14, - color: 'rgba(255, 255, 255, 0.5)', - letterSpacing: 0.3, + fontSize: 15, + fontWeight: '400', + color: 'rgba(255,255,255,0.45)', + marginTop: 8, }, - controls: { - position: 'absolute', - bottom: '12%', - left: 0, - right: 0, + + // ---- Actions ---- + actions: { flexDirection: 'row', justifyContent: 'center', - gap: 60, + gap: 72, + paddingHorizontal: 40, }, - actionButton: { + actionTouchable: { alignItems: 'center', width: 80, }, declineCircle: { - width: ACTION_CIRCLE_SIZE, - height: ACTION_CIRCLE_SIZE, - borderRadius: ACTION_CIRCLE_SIZE / 2, + width: BTN_SIZE, + height: BTN_SIZE, + borderRadius: BTN_SIZE / 2, backgroundColor: '#E54D42', justifyContent: 'center', alignItems: 'center', + shadowColor: '#E54D42', + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.45, + shadowRadius: 10, + elevation: 8, }, acceptCircle: { - width: ACTION_CIRCLE_SIZE, - height: ACTION_CIRCLE_SIZE, - borderRadius: ACTION_CIRCLE_SIZE / 2, + width: BTN_SIZE, + height: BTN_SIZE, + borderRadius: BTN_SIZE / 2, backgroundColor: '#4CD964', justifyContent: 'center', alignItems: 'center', + shadowColor: '#4CD964', + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.45, + shadowRadius: 10, + elevation: 8, }, actionLabel: { - fontSize: 12, - color: 'rgba(255, 255, 255, 0.6)', - marginTop: 10, + fontSize: 13, + fontWeight: '500', + color: 'rgba(255,255,255,0.5)', + marginTop: 12, }, }); - -export default IncomingCallModal; diff --git a/src/data/datasources/CacheDataSource.ts b/src/data/datasources/CacheDataSource.ts index 921e767..83e70d4 100644 --- a/src/data/datasources/CacheDataSource.ts +++ b/src/data/datasources/CacheDataSource.ts @@ -152,7 +152,7 @@ export class CacheDataSource implements ICacheDataSource { const keys = await AsyncStorage.getAllKeys(); const cacheKeys = keys.filter(k => k.startsWith(this.persistPrefix)); if (cacheKeys.length > 0) { - await AsyncStorage.multiRemove(cacheKeys); + await AsyncStorage.removeMany(cacheKeys); } } catch (error) { throw new DataSourceError( diff --git a/src/infrastructure/cache/MediaCacheManager.ts b/src/infrastructure/cache/MediaCacheManager.ts index 0fdc61e..ce94d43 100644 --- a/src/infrastructure/cache/MediaCacheManager.ts +++ b/src/infrastructure/cache/MediaCacheManager.ts @@ -154,9 +154,9 @@ export class MediaCacheManager { if (recordKeys.length === 0) return; - const records = await AsyncStorage.multiGet(recordKeys); - - for (const [key, value] of records) { + const records = await AsyncStorage.getMany(recordKeys); + + for (const [key, value] of Object.entries(records)) { if (value) { const entry: CacheEntry = JSON.parse(value); // 验证文件是否存在 @@ -614,7 +614,7 @@ export class MediaCacheManager { const keys = await AsyncStorage.getAllKeys(); const recordKeys = keys.filter(k => k.startsWith(CACHE_RECORD_PREFIX)); if (recordKeys.length > 0) { - await AsyncStorage.multiRemove(recordKeys); + await AsyncStorage.removeMany(recordKeys); } } diff --git a/src/screens/profile/DataStorageScreen.tsx b/src/screens/profile/DataStorageScreen.tsx index 6224e53..258b1ee 100644 --- a/src/screens/profile/DataStorageScreen.tsx +++ b/src/screens/profile/DataStorageScreen.tsx @@ -107,9 +107,9 @@ export const DataStorageScreen: React.FC = () => { const keys = await AsyncStorage.getAllKeys(); if (keys.length > 0) { - const items = await AsyncStorage.multiGet(keys); + const items = await AsyncStorage.getMany(keys); let totalSize = 0; - for (const [, value] of items) { + for (const value of Object.values(items)) { if (value) { totalSize += value.length * 2; } diff --git a/src/services/callkeep/callKeepService.ts b/src/services/callkeep/callKeepService.ts new file mode 100644 index 0000000..14cc52c --- /dev/null +++ b/src/services/callkeep/callKeepService.ts @@ -0,0 +1,284 @@ +import { Platform } from 'react-native'; +import { + startOutgoingCall, + reportIncomingCall, + answerCall, + endCall, + reportCallEnded, + reportOutgoingCallConnected, + fulfillIncomingCallConnected, + failIncomingCallConnected, + setMuted, + reportVideo, + setAudioSessionPortOverride, + setRTCAudioSessionConfiguration, + addCallSessionAddedListener, + addCallAnsweredListener, + addCallEndedListener, + addOutgoingCallStartedListener, + addSetMutedActionListener, + addVideoChangedListener, + addAudioSessionActivatedListener, + addAudioSessionDeactivatedListener, + addAudioRouteChangedListener, + type CallParticipant, + type IncomingCallEvent, + type CallEndedReason, + type CallSession, + type CallAnsweredEvent, + type CallEndedEvent, + type SetMutedActionEvent, +} from 'expo-callkit-telecom'; + +const isWeb = Platform.OS === 'web'; + +export interface CallKeepCallbacks { + onIncomingCallFromPush: (session: CallSession) => void; + onSystemAnswered: () => void; + onSystemEnded: () => void; + onSystemMuted: (muted: boolean) => void; + onOutgoingStarted: () => void; +} + +/** + * Thin state-machine adapter between callStore (Zustand) and expo-callkit-telecom. + * + * callStore remains the source of truth for call state. + * This service delegates to the system call API and translates system UI events + * back into callStore actions via registered callbacks. + */ +class CallKeepServiceImpl { + private _activeSystemCallId: string | null = null; + private _pendingRequestId: string | null = null; + private _callbacks: CallKeepCallbacks | null = null; + private _subscriptions: Array<{ remove: () => void }> = []; + private _initialized = false; + + // ---- Initialization ---- + + initialize(callbacks: CallKeepCallbacks): void { + if (isWeb) return; + if (this._initialized) return; + + this._callbacks = callbacks; + this._initialized = true; + + this._setupListeners(); + } + + dispose(): void { + this._subscriptions.forEach((s) => s.remove()); + this._subscriptions = []; + this._callbacks = null; + this._initialized = false; + } + + private _setupListeners(): void { + // Outgoing call accepted by the OS — media should start connecting + this._subscriptions.push( + addOutgoingCallStartedListener(() => { + this._callbacks?.onOutgoingStarted(); + }), + ); + + // Incoming call — a new session was created (from push or reportIncomingCall) + this._subscriptions.push( + addCallSessionAddedListener((event) => { + if (event.session.origin === 'incoming') { + this._activeSystemCallId = event.session.id; + if (!this._pendingRequestId) { + this._callbacks?.onIncomingCallFromPush(event.session); + } + } + }), + ); + + // User answered incoming call (from system UI or via answerCall) + this._subscriptions.push( + addCallAnsweredListener((event: CallAnsweredEvent) => { + this._activeSystemCallId = event.id; + this._pendingRequestId = event.requestId; + this._callbacks?.onSystemAnswered(); + }), + ); + + // Call ended from system UI (user pressed end in CallKit UI) + this._subscriptions.push( + addCallEndedListener((_event: CallEndedEvent) => { + this._activeSystemCallId = null; + this._pendingRequestId = null; + this._callbacks?.onSystemEnded(); + }), + ); + + // System mute toggle (CallKit mute button) + this._subscriptions.push( + addSetMutedActionListener((event: SetMutedActionEvent) => { + this._callbacks?.onSystemMuted(event.isMuted); + }), + ); + + // Video changed from system + this._subscriptions.push( + addVideoChangedListener((event) => { + if (this._callbacks && event.hasVideo !== undefined) { + // could add onSystemVideoChanged callback if needed + } + }), + ); + + // Audio session lifecycle — keep for debugging + this._subscriptions.push( + addAudioSessionActivatedListener(() => { + console.log('[CallKeep] Audio session activated'); + }), + ); + + this._subscriptions.push( + addAudioSessionDeactivatedListener(() => { + console.log('[CallKeep] Audio session deactivated'); + }), + ); + + this._subscriptions.push( + addAudioRouteChangedListener((event) => { + console.log('[CallKeep] Audio route changed:', event.currentRoute); + }), + ); + } + + // ---- Outgoing Call ---- + + async startOutgoingCall( + calleeId: string, + calleeName: string, + hasVideo: boolean, + ): Promise { + if (isWeb) return null; + + try { + // End any stale system call before starting a new one + if (this._activeSystemCallId) { + console.warn('[CallKeep] Cleaning up stale system call before new outgoing call'); + this.endActiveCall(); + } + + const recipient: CallParticipant = { + id: calleeId, + displayName: calleeName, + }; + const id = await startOutgoingCall(recipient, { hasVideo }); + this._activeSystemCallId = id; + console.log('[CallKeep] Outgoing call started:', id); + return id; + } catch (err) { + console.error('[CallKeep] Failed to start outgoing call:', err); + return null; + } + } + + reportOutgoingCallConnected(): void { + if (isWeb || !this._activeSystemCallId) return; + reportOutgoingCallConnected(this._activeSystemCallId); + } + + // ---- Incoming Call ---- + + reportIncomingCallEvent(event: IncomingCallEvent): void { + if (isWeb) return; + try { + reportIncomingCall(event); + } catch (err) { + console.error('[CallKeep] Failed to report incoming call:', err); + } + } + + answerCallFromApp(): void { + if (isWeb || !this._activeSystemCallId) return; + answerCall(this._activeSystemCallId); + } + + fulfillIncomingCallConnected(): void { + if (isWeb || !this._pendingRequestId) return; + try { + fulfillIncomingCallConnected(this._pendingRequestId); + this._pendingRequestId = null; + } catch (err) { + console.error('[CallKeep] Failed to fulfill incoming call:', err); + } + } + + failIncomingCallConnected(): void { + if (isWeb || !this._activeSystemCallId || !this._pendingRequestId) return; + try { + failIncomingCallConnected(this._activeSystemCallId, this._pendingRequestId); + this._pendingRequestId = null; + } catch (err) { + console.error('[CallKeep] Failed to fail incoming call:', err); + } + } + + // ---- End Call ---- + + endActiveCall(): void { + if (isWeb || !this._activeSystemCallId) return; + try { + endCall(this._activeSystemCallId); + } catch (err) { + console.error('[CallKeep] Failed to end call:', err); + } + this._activeSystemCallId = null; + this._pendingRequestId = null; + } + + reportCallEndedExternal(reason: CallEndedReason): void { + if (isWeb || !this._activeSystemCallId) return; + try { + reportCallEnded(this._activeSystemCallId, reason); + } catch (err) { + console.error('[CallKeep] Failed to report call ended:', err); + } + this._activeSystemCallId = null; + this._pendingRequestId = null; + } + + // ---- Audio Session ---- + + configureAudioForCall(hasVideo: boolean): void { + if (isWeb) return; + setRTCAudioSessionConfiguration(hasVideo); + } + + // ---- Controls ---- + + setMuted(muted: boolean): void { + if (isWeb || !this._activeSystemCallId) return; + setMuted(this._activeSystemCallId, muted); + } + + reportVideoEnabled(enabled: boolean): void { + if (isWeb || !this._activeSystemCallId) return; + reportVideo(this._activeSystemCallId, enabled); + } + + setAudioOutputToSpeaker(enabled: boolean): void { + if (isWeb) return; + setAudioSessionPortOverride(enabled); + } + + // ---- Getters ---- + + get activeSystemCallId(): string | null { + return this._activeSystemCallId; + } + + get pendingRequestId(): string | null { + return this._pendingRequestId; + } + + get isWebPlatform(): boolean { + return isWeb; + } +} + +export const callKeepService = new CallKeepServiceImpl(); diff --git a/src/services/callkeep/index.ts b/src/services/callkeep/index.ts new file mode 100644 index 0000000..4db808d --- /dev/null +++ b/src/services/callkeep/index.ts @@ -0,0 +1,2 @@ +export { callKeepService } from './callKeepService'; +export type { CallKeepCallbacks } from './callKeepService'; diff --git a/src/services/core/api.ts b/src/services/core/api.ts index da83732..732d0db 100644 --- a/src/services/core/api.ts +++ b/src/services/core/api.ts @@ -164,7 +164,7 @@ class ApiClient { // 清除 token async clearToken(): Promise { try { - await AsyncStorage.multiRemove([TOKEN_KEY, REFRESH_TOKEN_KEY]); + await AsyncStorage.removeMany([TOKEN_KEY, REFRESH_TOKEN_KEY]); } catch (error) { console.error('清除token失败:', error); } diff --git a/src/services/core/datasources/CacheDataSource.ts b/src/services/core/datasources/CacheDataSource.ts index 6169cc7..3fb05bb 100644 --- a/src/services/core/datasources/CacheDataSource.ts +++ b/src/services/core/datasources/CacheDataSource.ts @@ -152,7 +152,7 @@ export class CacheDataSource implements ICacheDataSource { const keys = await AsyncStorage.getAllKeys(); const cacheKeys = keys.filter(k => k.startsWith(this.persistPrefix)); if (cacheKeys.length > 0) { - await AsyncStorage.multiRemove(cacheKeys); + await AsyncStorage.removeMany(cacheKeys); } } catch (error) { throw new DataSourceError( diff --git a/src/services/core/wsService.ts b/src/services/core/wsService.ts index 006062c..04392e4 100644 --- a/src/services/core/wsService.ts +++ b/src/services/core/wsService.ts @@ -44,6 +44,8 @@ export type WSMessageType = | 'call_peer_muted' | 'call_invited' | 'call_answered_elsewhere' + | 'call_participant_joined' + | 'call_participant_left' | 'sync_required'; export interface WSCallIncomingMessage { @@ -103,6 +105,19 @@ export interface WSCallAnsweredElsewhereMessage { reason?: string; } +export interface WSCallParticipantJoinedMessage { + type: 'call_participant_joined'; + call_id: string; + user_id: string; +} + +export interface WSCallParticipantLeftMessage { + type: 'call_participant_left'; + call_id: string; + user_id: string; + reason?: string; +} + export interface WSErrorMessage { type: 'error'; code: string; @@ -257,6 +272,8 @@ export type WSMessage = | WSCallPeerMutedMessage | WSCallInvitedMessage | WSCallAnsweredElsewhereMessage + | WSCallParticipantJoinedMessage + | WSCallParticipantLeftMessage | WSErrorMessage | WSSyncRequiredMessage; @@ -300,6 +317,9 @@ class WebSocketService { private isFallbackMode = false; private fallbackCheckTimer: ReturnType | null = null; + // 通话中阻止后台断开(对方请求权限等场景会导致 AppState 切换,不应断开 WebSocket) + preventDisconnectOnBackground = false; + private getWSUrl(token: string | null): string { return `${WS_URL}?token=${encodeURIComponent(token || '')}&compress=1`; } @@ -747,6 +767,14 @@ class WebSocketService { this.sendFireAndForget('call_ready', { call_id: callId }); } + sendCallGroupInvite(groupId: string, conversationId: string, callType: string): void { + this.sendFireAndForget('call_group_invite', { + group_id: groupId, + conversation_id: conversationId, + call_type: callType, + }); + } + private handleMessageSent(payload: any): void { // 找到对应的发送请求并resolve const pendingMsg = this.pendingMessages.find(p => @@ -1059,9 +1087,14 @@ class WebSocketService { this.lastAppState = AppState.currentState; this.appStateSubscription = AppState.addEventListener('change', (nextState: AppStateStatus) => { // 进入后台时断开 WebSocket,由 JPush 负责后台推送通知 + // 但如果正在通话中则不断开,避免对方请求权限等场景导致通话中断 if (this.lastAppState === 'active' && nextState.match(/inactive|background/)) { - console.log('[WebSocket] App entering background, disconnecting'); - this.disconnect(); + if (this.preventDisconnectOnBackground) { + console.log('[WebSocket] App entering background but call is active, skipping disconnect'); + } else { + console.log('[WebSocket] App entering background, disconnecting'); + this.disconnect(); + } } // 回到前台时重连 WebSocket if (this.lastAppState.match(/inactive|background/) && nextState === 'active' && !this.isConnected()) { diff --git a/src/services/livekit/LiveKitService.ts b/src/services/livekit/LiveKitService.ts index 1dfc6d7..dc5cfec 100644 --- a/src/services/livekit/LiveKitService.ts +++ b/src/services/livekit/LiveKitService.ts @@ -1,6 +1,16 @@ import '../../polyfills'; -import { Platform } from 'react-native'; -import { Room, RoomEvent, Track, ConnectionState, LocalParticipant, RemoteParticipant, RemoteTrackPublication, TrackPublication, Participant as LKParticipant } from 'livekit-client'; +import { + Room, + RoomEvent, + Track, + ConnectionState, + LocalParticipant, + RemoteParticipant, + RemoteTrackPublication, + LocalTrackPublication, + TrackPublication, + Participant as LKParticipant, +} from 'livekit-client'; export interface LiveKitServiceConfig { url: string; @@ -20,6 +30,11 @@ export interface LiveKitEventMap { trackUnmuted: { participant: LKParticipant; publication: TrackPublication }; connectionStatusChanged: ConnectionStatus; error: Error; + activeSpeakersChanged: { speakers: LKParticipant[] }; + participantConnected: { participant: RemoteParticipant }; + participantDisconnected: { participant: RemoteParticipant }; + localTrackPublished: { publication: LocalTrackPublication; participant: LocalParticipant }; + localTrackUnpublished: { publication: LocalTrackPublication; participant: LocalParticipant }; } type EventHandler = (data: LiveKitEventMap[K]) => void; @@ -45,26 +60,11 @@ class LiveKitServiceImpl { return this.room?.remoteParticipants ?? new Map(); } - async connect(url: string, token: string, isVideoCall: boolean = false): Promise { + async connect(url: string, token: string): Promise { if (this.room) { await this.disconnect(); } - // Configure native audio session before connecting (iOS only) - if (Platform.OS === 'ios') { - try { - const { AudioSession } = require('@livekit/react-native'); - await AudioSession.configureAudio({ - ios: { - defaultOutput: isVideoCall ? 'speaker' : 'default', - }, - }); - await AudioSession.startAudioSession(); - } catch (err) { - console.warn('[LiveKit] Audio session configuration failed:', err); - } - } - this.room = new Room({ adaptiveStream: true, dynacast: true, @@ -99,18 +99,16 @@ class LiveKitServiceImpl { this.room.off(RoomEvent.Reconnecting, this.onReconnecting); this.room.off(RoomEvent.Reconnected, this.onReconnected); this.room.off(RoomEvent.ConnectionStateChanged, this.onConnectionStateChanged); + this.room.off(RoomEvent.ActiveSpeakersChanged, this.onActiveSpeakersChanged); + this.room.off(RoomEvent.ParticipantConnected, this.onParticipantConnected); + this.room.off(RoomEvent.ParticipantDisconnected, this.onParticipantDisconnected); + this.room.off(RoomEvent.LocalTrackPublished, this.onLocalTrackPublished); + this.room.off(RoomEvent.LocalTrackUnpublished, this.onLocalTrackUnpublished); await this.room.disconnect(); this.room = null; this._connectionStatus = 'disconnected'; - if (Platform.OS === 'ios') { - try { - const { AudioSession } = require('@livekit/react-native'); - await AudioSession.stopAudioSession(); - } catch {} - } - this.emit('connectionStatusChanged', 'disconnected'); this.emit('disconnected', undefined); } @@ -122,20 +120,60 @@ class LiveKitServiceImpl { async setVideoEnabled(enabled: boolean): Promise { if (!this.room?.localParticipant) return; - console.log('[LiveKit] setVideoEnabled called:', enabled); - try { - await this.room.localParticipant.setCameraEnabled(enabled); - console.log('[LiveKit] setVideoEnabled succeeded:', enabled); - } catch (err) { - console.error('[LiveKit] setVideoEnabled failed:', err); - throw err; + await this.room.localParticipant.setCameraEnabled(enabled); + } + + async setSpeakerOn(_speakerOn: boolean): Promise { + // Audio routing is handled by expo-callkit-telecom + } + + async flipCamera(): Promise { + if (!this.room?.localParticipant) return; + // Camera flip via LiveKit's native support (platform-specific) + const videoTrack = this.room.localParticipant.getTrackPublication(Track.Source.Camera); + if (videoTrack?.track && 'switchCamera' in videoTrack.track) { + await (videoTrack.track as any).switchCamera(); } } - async setSpeakerOn(speakerOn: boolean): Promise { - if (Platform.OS === 'web' || Platform.OS === 'android') return; - const AudioSession = require('@livekit/react-native').AudioSession; - await AudioSession.selectAudioOutput(speakerOn ? 'force_speaker' : 'default'); + async switchMicrophone(deviceId: string): Promise { + if (!this.room) return; + await this.room.switchActiveDevice('audioinput', deviceId); + } + + enumerateAudioInputDevices(): Promise { + // Platform-specific: delegates to native livekit SDK where available + if (typeof navigator !== 'undefined' && navigator.mediaDevices?.enumerateDevices) { + return navigator.mediaDevices.enumerateDevices().then(d => d.filter(x => x.kind === 'audioinput')); + } + return Promise.resolve([]); + } + + enumerateVideoInputDevices(): Promise { + if (typeof navigator !== 'undefined' && navigator.mediaDevices?.enumerateDevices) { + return navigator.mediaDevices.enumerateDevices().then(d => d.filter(x => x.kind === 'videoinput')); + } + return Promise.resolve([]); + } + + getActiveSpeakers(): LKParticipant[] { + return this.room?.activeSpeakers ?? []; + } + + getParticipantTracks(identity: string): { + audio: RemoteTrackPublication | null; + video: RemoteTrackPublication | null; + } { + const participant = this.room?.remoteParticipants.get(identity); + if (!participant) return { audio: null, video: null }; + return { + audio: participant.getTrackPublication(Track.Source.Microphone) as RemoteTrackPublication | null, + video: participant.getTrackPublication(Track.Source.Camera) as RemoteTrackPublication | null, + }; + } + + getParticipantCount(): number { + return (this.room?.remoteParticipants.size ?? 0) + 1; // +1 for local } isMuted(): boolean { @@ -150,22 +188,6 @@ class LiveKitServiceImpl { return videoTrack?.isSubscribed ?? false; } - getRemoteAudioTrack(): Track | null { - for (const [, participant] of this.remoteParticipants) { - const audioPub = participant.getTrackPublication(Track.Source.Microphone); - if (audioPub?.track) return audioPub.track; - } - return null; - } - - getRemoteVideoTrack(): Track | null { - for (const [, participant] of this.remoteParticipants) { - const videoPub = participant.getTrackPublication(Track.Source.Camera); - if (videoPub?.track) return videoPub.track; - } - return null; - } - on(event: K, handler: EventHandler): () => void { if (!this.handlers.has(event)) { this.handlers.set(event, new Set()); @@ -192,6 +214,11 @@ class LiveKitServiceImpl { this.room.on(RoomEvent.Reconnecting, this.onReconnecting); this.room.on(RoomEvent.Reconnected, this.onReconnected); this.room.on(RoomEvent.ConnectionStateChanged, this.onConnectionStateChanged); + this.room.on(RoomEvent.ActiveSpeakersChanged, this.onActiveSpeakersChanged); + this.room.on(RoomEvent.ParticipantConnected, this.onParticipantConnected); + this.room.on(RoomEvent.ParticipantDisconnected, this.onParticipantDisconnected); + this.room.on(RoomEvent.LocalTrackPublished, this.onLocalTrackPublished); + this.room.on(RoomEvent.LocalTrackUnpublished, this.onLocalTrackUnpublished); } private onTrackSubscribed = (track: Track, publication: RemoteTrackPublication, participant: RemoteParticipant): void => { @@ -240,6 +267,26 @@ class LiveKitServiceImpl { this.emit('connectionStatusChanged', status); }; + private onActiveSpeakersChanged = (speakers: LKParticipant[]): void => { + this.emit('activeSpeakersChanged', { speakers }); + }; + + private onParticipantConnected = (participant: RemoteParticipant): void => { + this.emit('participantConnected', { participant }); + }; + + private onParticipantDisconnected = (participant: RemoteParticipant): void => { + this.emit('participantDisconnected', { participant }); + }; + + private onLocalTrackPublished = (publication: LocalTrackPublication, participant: LocalParticipant): void => { + this.emit('localTrackPublished', { publication, participant }); + }; + + private onLocalTrackUnpublished = (publication: LocalTrackPublication, participant: LocalParticipant): void => { + this.emit('localTrackUnpublished', { publication, participant }); + }; + private emit(event: K, data: LiveKitEventMap[K]): void { const handlers = this.handlers.get(event); if (handlers) { @@ -255,4 +302,4 @@ class LiveKitServiceImpl { } export const liveKitService = new LiveKitServiceImpl(); -export default liveKitService; \ No newline at end of file +export default liveKitService; diff --git a/src/stores/call/callStore.ts b/src/stores/call/callStore.ts index ece2cf6..bab51af 100644 --- a/src/stores/call/callStore.ts +++ b/src/stores/call/callStore.ts @@ -6,6 +6,8 @@ import { api, } from '@/services/core'; import { liveKitService } from '@/services/livekit'; +import { callKeepService } from '@/services/callkeep'; +import type { IncomingCallEvent, CallSession as SystemCallSession } from 'expo-callkit-telecom'; import { getCurrentUserId } from '../auth/sessionStore'; import { useUserStore } from '../userStore'; import { userManager } from '../user/UserManager'; @@ -22,14 +24,27 @@ export type CallStatus = export type CallType = 'voice' | 'video'; +export interface CallParticipant { + id: string; + name: string; + avatar?: string | null; + isMuted: boolean; + isVideoEnabled: boolean; + isLocal: boolean; + isReady: boolean; + joinedAt: number; +} + export interface CallSession { id: string; conversationId: string; + groupId?: string; peerId: string; peerName?: string; peerAvatar?: string | null; status: CallStatus; callType: CallType; + mediaType: 'voice' | 'video'; startedAt?: number; duration: number; isMuted: boolean; @@ -38,6 +53,9 @@ export interface CallSession { isPeerVideoEnabled: boolean; isInitiator: boolean; isPeerReady: boolean; + participants: Map; + activeSpeakerId: string | null; + pinnedParticipantId: string | null; } export interface IncomingCallInfo { @@ -72,6 +90,19 @@ interface CallState { toggleMinimize: () => void; toggleVideo: () => Promise; setVideoEnabled: (enabled: boolean) => Promise; + handleSystemAnswer: () => Promise; + handleIncomingFromPush: (session: SystemCallSession) => void; + handleSystemMuted: (muted: boolean) => void; + startGroupCall: ( + groupId: string, + conversationId: string, + callType: CallType + ) => Promise; + addParticipant: (userId: string, info: { name: string; avatar?: string | null }) => void; + removeParticipant: (userId: string) => void; + setActiveSpeaker: (userId: string | null) => void; + pinParticipant: (userId: string | null) => void; + updateParticipantState: (userId: string, updates: Partial) => void; reset: () => void; } @@ -99,6 +130,41 @@ function cleanupProcessedCallIds() { } } +/** + * Build initial participants map for a new call session. + */ +function buildInitialParticipants( + localUserId: string, + localName: string, + calleeId: string, + calleeName: string, + calleeAvatar?: string | null, +): Map { + const m = new Map(); + const now = Date.now(); + m.set(localUserId, { + id: localUserId, + name: localName, + avatar: null, + isMuted: false, + isVideoEnabled: false, + isLocal: true, + isReady: true, + joinedAt: now, + }); + m.set(calleeId, { + id: calleeId, + name: calleeName, + avatar: calleeAvatar ?? null, + isMuted: false, + isVideoEnabled: false, + isLocal: false, + isReady: false, + joinedAt: 0, + }); + return m; +} + /** * Fetch LiveKit token from the backend and connect to the room. * Connects audio-only first, then enables video separately. @@ -116,10 +182,13 @@ async function joinLiveKitRoom(callId: string, videoEnabled: boolean): Promise { + const activeId = speakers.length > 0 ? speakers[0].identity : null; + callStore.getState().setActiveSpeaker(activeId); + }) + ); + + liveKitUnsubs.push( + liveKitService.on('participantConnected', ({ participant }) => { + console.log('[CallStore] Remote participant connected:', participant.identity); + callStore.setState((s) => { + if (!s.currentCall) return {}; + const newParticipants = new Map(s.currentCall.participants); + if (!newParticipants.has(participant.identity)) { + newParticipants.set(participant.identity, { + id: participant.identity, + name: participant.name || participant.identity, + avatar: null, + isMuted: false, + isVideoEnabled: false, + isLocal: false, + isReady: true, + joinedAt: Date.now(), + }); + } + return { currentCall: { ...s.currentCall, participants: newParticipants } }; + }); + }) + ); + + liveKitUnsubs.push( + liveKitService.on('participantDisconnected', ({ participant }) => { + console.log('[CallStore] Remote participant disconnected:', participant.identity); + callStore.getState().removeParticipant(participant.identity); + }) + ); + + liveKitUnsubs.push( + liveKitService.on('localTrackPublished', ({ publication }) => { + if (publication.source === 'camera') { + callStore.setState((s) => ({ + currentCall: s.currentCall + ? { ...s.currentCall, isVideoEnabled: true } + : null, + })); + } + }) + ); + + liveKitUnsubs.push( + liveKitService.on('localTrackUnpublished', ({ publication }) => { + if (publication.source === 'camera') { + callStore.setState((s) => ({ + currentCall: s.currentCall + ? { ...s.currentCall, isVideoEnabled: false } + : null, + })); + } + }) + ); } /** @@ -308,6 +442,28 @@ export const callStore = create((set, get) => ({ initCallUnsub = null; } + // Initialize CallKit/Telecom service with system UI callbacks + callKeepService.initialize({ + onOutgoingStarted: () => { + console.log('[CallStore] System outgoing call started'); + }, + onSystemAnswered: () => { + console.log('[CallStore] System answered'); + get().acceptCall(); + }, + onSystemEnded: () => { + console.log('[CallStore] System ended call'); + get().endCall('system_ended'); + }, + onSystemMuted: (muted: boolean) => { + get().toggleMute(); + }, + onIncomingCallFromPush: (_session) => { + // Incoming call from push when app was killed — already handled by WS event + console.log('[CallStore] Incoming call from push (already handled by WS)'); + }, + }); + const unsubs: Array<() => void> = []; unsubs.push( @@ -371,14 +527,30 @@ export const callStore = create((set, get) => ({ }, }); + wsService.preventDisconnectOnBackground = true; processedCallIds.set(msg.call_id, Date.now()); + // Report incoming call to system CallKit/Telecom UI + callKeepService.reportIncomingCallEvent({ + eventId: `${msg.call_id}-${Date.now()}`, + serverCallId: msg.call_id, + hasVideo: msg.call_type === 'video', + startedAt: new Date(msg.created_at).toISOString(), + caller: { + id: msg.caller_id, + displayName: callerName, + avatarUrl: caller?.avatar || undefined, + }, + metadata: { conversationId: msg.conversation_id }, + }); + if (callTimeoutTimer) clearTimeout(callTimeoutTimer); const remainingTime = Math.max(lifetime - callAge - 1000, 5000); callTimeoutTimer = setTimeout(() => { const { incomingCall: ic } = get(); if (ic?.callId === msg.call_id) { console.log('[CallStore] Incoming call timeout'); + wsService.preventDisconnectOnBackground = false; wsService.sendCallReject(msg.call_id); processedCallIds.set(msg.call_id, Date.now()); set({ incomingCall: null }); @@ -450,6 +622,7 @@ export const callStore = create((set, get) => ({ if (incomingCall?.callId === msg.call_id) { console.log('[CallStore] Incoming call cancelled by caller'); + wsService.preventDisconnectOnBackground = false; if (callTimeoutTimer) { clearTimeout(callTimeoutTimer); callTimeoutTimer = null; @@ -464,6 +637,7 @@ export const callStore = create((set, get) => ({ const { incomingCall } = get(); if (incomingCall?.callId === msg.call_id) { console.log('[CallStore] Call answered on another device'); + wsService.preventDisconnectOnBackground = false; processedCallIds.set(msg.call_id, Date.now()); if (callTimeoutTimer) { clearTimeout(callTimeoutTimer); @@ -506,6 +680,24 @@ export const callStore = create((set, get) => ({ }) ); + unsubs.push( + wsService.on('call_participant_joined', (msg) => { + console.log('[CallStore] Participant joined:', msg.user_id); + const { currentCall } = get(); + if (!currentCall || currentCall.id !== msg.call_id) return; + get().addParticipant(msg.user_id, { name: msg.user_id }); + }) + ); + + unsubs.push( + wsService.on('call_participant_left', (msg) => { + console.log('[CallStore] Participant left:', msg.user_id, 'reason:', msg.reason); + const { currentCall } = get(); + if (!currentCall || currentCall.id !== msg.call_id) return; + get().removeParticipant(msg.user_id); + }) + ); + const cleanup = () => { cleanupResources(); if (unsubInvited) { @@ -537,10 +729,17 @@ export const callStore = create((set, get) => ({ return; } + wsService.preventDisconnectOnBackground = true; + const cachedCallee = useUserStore.getState().userCache[calleeId]; const callee = calleeInfo || cachedCallee; const calleeName = callee?.nickname || callee?.username || calleeId; + const localUser = useUserStore.getState().userCache[myUserId]; + const localName = localUser?.nickname || localUser?.username || myUserId; + + const initialParticipants = buildInitialParticipants(myUserId, localName, calleeId, calleeName, callee?.avatar); + set({ currentCall: { id: '', @@ -549,6 +748,7 @@ export const callStore = create((set, get) => ({ peerName: calleeName, peerAvatar: callee?.avatar, callType, + mediaType: callType, status: 'calling', duration: 0, isMuted: false, @@ -557,20 +757,38 @@ export const callStore = create((set, get) => ({ isPeerVideoEnabled: false, isInitiator: true, isPeerReady: false, + participants: initialParticipants, + activeSpeakerId: null, + pinnedParticipantId: null, }, }); wsService.sendCallInvite(conversationId, calleeId, callType); + callKeepService.startOutgoingCall(calleeId, calleeName, callType === 'video').then((systemCallId) => { + if (systemCallId) { + // Sync system call ID with store, but don't overwrite if WS already set it + const { currentCall: cc } = get(); + if (cc && !cc.id) { + set((s) => ({ + currentCall: s.currentCall ? { ...s.currentCall, id: systemCallId } : null, + })); + } + } + }); + if (unsubInvited) { unsubInvited(); } unsubInvited = wsService.on('call_invited', (msg) => { - set((s) => ({ - currentCall: s.currentCall - ? { ...s.currentCall, id: msg.call_id } - : null, - })); + set((s) => { + if (!s.currentCall) return {}; + // WS call_id is the authoritative one; sync system call ID too + if (s.currentCall.id !== msg.call_id) { + callKeepService.endActiveCall(); // end old system session with wrong ID + } + return { currentCall: { ...s.currentCall, id: msg.call_id } }; + }); }); if (callTimeoutTimer) clearTimeout(callTimeoutTimer); @@ -587,6 +805,8 @@ export const callStore = create((set, get) => ({ const { incomingCall } = get(); if (!incomingCall) return; + wsService.preventDisconnectOnBackground = true; + console.log('[CallStore] acceptCall, incomingCall.callType:', incomingCall.callType); if (callTimeoutTimer) { @@ -597,6 +817,33 @@ export const callStore = create((set, get) => ({ const isVideoCall = incomingCall.callType === 'video'; console.log('[CallStore] acceptCall, isVideoCall:', isVideoCall); + const myUserId = getCurrentUserId(); + const localUser = myUserId ? useUserStore.getState().userCache[myUserId] : null; + const localName = localUser?.nickname || localUser?.username || myUserId || 'me'; + + const participants = new Map(); + const now = Date.now(); + participants.set(myUserId || 'me', { + id: myUserId || 'me', + name: localName, + avatar: null, + isMuted: false, + isVideoEnabled: false, + isLocal: true, + isReady: true, + joinedAt: now, + }); + participants.set(incomingCall.callerId, { + id: incomingCall.callerId, + name: incomingCall.callerName || incomingCall.callerId, + avatar: incomingCall.callerAvatar ?? null, + isMuted: false, + isVideoEnabled: false, + isLocal: false, + isReady: false, + joinedAt: 0, + }); + set({ currentCall: { id: incomingCall.callId, @@ -605,6 +852,7 @@ export const callStore = create((set, get) => ({ peerName: incomingCall.callerName, peerAvatar: incomingCall.callerAvatar, callType: incomingCall.callType as CallType, + mediaType: incomingCall.callType as CallType, status: 'connecting', duration: 0, isMuted: false, @@ -613,6 +861,9 @@ export const callStore = create((set, get) => ({ isPeerVideoEnabled: false, isInitiator: false, isPeerReady: false, + participants, + activeSpeakerId: null, + pinnedParticipantId: null, }, incomingCall: null, }); @@ -623,6 +874,7 @@ export const callStore = create((set, get) => ({ await joinLiveKitRoom(incomingCall.callId, isVideoCall); } catch (err) { console.error('[CallStore] Failed to accept call:', err); + callKeepService.failIncomingCallConnected(); get().endCall('connection_failed'); } }, @@ -631,12 +883,15 @@ export const callStore = create((set, get) => ({ const { incomingCall } = get(); if (!incomingCall) return; + wsService.preventDisconnectOnBackground = false; + if (callTimeoutTimer) { clearTimeout(callTimeoutTimer); callTimeoutTimer = null; } wsService.sendCallReject(incomingCall.callId); + callKeepService.endActiveCall(); set({ incomingCall: null }); }, @@ -644,6 +899,8 @@ export const callStore = create((set, get) => ({ const { currentCall } = get(); if (!currentCall) return; + wsService.preventDisconnectOnBackground = false; + cleanupResources(); if (unsubInvited) { @@ -664,6 +921,13 @@ export const callStore = create((set, get) => ({ console.error('[CallStore] Error disconnecting LiveKit:', err); } + // Sync end with system CallKit/Telecom UI + if (reason === 'ended' || !reason) { + callKeepService.endActiveCall(); + } else { + callKeepService.reportCallEndedExternal(reason as any); + } + if (callId && reason !== 'ended') { wsService.sendCallEnd(callId, reason); } @@ -675,6 +939,7 @@ export const callStore = create((set, get) => ({ const newMuted = !currentCall.isMuted; liveKitService.setMuted(newMuted); + callKeepService.setMuted(newMuted); if (currentCall.id) { wsService.sendCallMute(currentCall.id, newMuted); } @@ -692,6 +957,7 @@ export const callStore = create((set, get) => ({ ? { ...s.currentCall, isSpeakerOn: newSpeakerOn } : null, })); + callKeepService.setAudioOutputToSpeaker(newSpeakerOn); await liveKitService.setSpeakerOn(newSpeakerOn); }, @@ -713,6 +979,7 @@ export const callStore = create((set, get) => ({ try { await liveKitService.setVideoEnabled(enabled); + callKeepService.reportVideoEnabled(enabled); set((s) => ({ currentCall: s.currentCall @@ -724,6 +991,272 @@ export const callStore = create((set, get) => ({ } }, + handleSystemAnswer: async () => { + const { incomingCall, currentCall } = get(); + // If already handling a call from the in-app flow, skip + if (currentCall && currentCall.status !== 'idle') return; + if (!incomingCall) return; + + wsService.preventDisconnectOnBackground = true; + + if (callTimeoutTimer) { + clearTimeout(callTimeoutTimer); + callTimeoutTimer = null; + } + + const isVideoCall = incomingCall.callType === 'video'; + + const myUserId = getCurrentUserId(); + const localUser = myUserId ? useUserStore.getState().userCache[myUserId] : null; + const localName = localUser?.nickname || localUser?.username || myUserId || 'me'; + + const sysParticipants = new Map(); + const sysNow = Date.now(); + sysParticipants.set(myUserId || 'me', { + id: myUserId || 'me', + name: localName, + avatar: null, + isMuted: false, + isVideoEnabled: false, + isLocal: true, + isReady: true, + joinedAt: sysNow, + }); + sysParticipants.set(incomingCall.callerId, { + id: incomingCall.callerId, + name: incomingCall.callerName || incomingCall.callerId, + avatar: incomingCall.callerAvatar ?? null, + isMuted: false, + isVideoEnabled: false, + isLocal: false, + isReady: false, + joinedAt: 0, + }); + + set({ + currentCall: { + id: incomingCall.callId, + conversationId: incomingCall.conversationId, + peerId: incomingCall.callerId, + peerName: incomingCall.callerName, + peerAvatar: incomingCall.callerAvatar, + callType: incomingCall.callType as CallType, + mediaType: incomingCall.callType as CallType, + status: 'connecting', + duration: 0, + isMuted: false, + isSpeakerOn: isVideoCall, + isVideoEnabled: isVideoCall, + isPeerVideoEnabled: false, + isInitiator: false, + isPeerReady: false, + participants: sysParticipants, + activeSpeakerId: null, + pinnedParticipantId: null, + }, + incomingCall: null, + }); + + wsService.sendCallAnswer(incomingCall.callId); + + try { + await joinLiveKitRoom(incomingCall.callId, isVideoCall); + } catch (err) { + console.error('[CallStore] handleSystemAnswer failed:', err); + callKeepService.failIncomingCallConnected(); + get().endCall('connection_failed'); + } + }, + + handleIncomingFromPush: (session: SystemCallSession) => { + const ie = session.incomingCallEvent; + if (!ie) return; + + const { currentCall, incomingCall } = get(); + if (currentCall || incomingCall) return; + + wsService.preventDisconnectOnBackground = true; + + const metadata = ie.metadata ?? {}; + const conversationId = (metadata.conversationId as string) || ''; + + set({ + incomingCall: { + callId: ie.serverCallId, + conversationId, + callerId: ie.caller.id, + callerName: ie.caller.displayName || ie.caller.id, + callerAvatar: ie.caller.avatarUrl ?? null, + callType: ie.hasVideo ? 'video' : 'voice', + receivedAt: Date.now(), + lifetime: 55000, + }, + }); + + if (callTimeoutTimer) clearTimeout(callTimeoutTimer); + callTimeoutTimer = setTimeout(() => { + const { incomingCall: ic } = get(); + if (ic?.callId === ie.serverCallId) { + console.log('[CallStore] Incoming push call timeout'); + wsService.preventDisconnectOnBackground = false; + callKeepService.endActiveCall(); + set({ incomingCall: null }); + } + }, 55000); + }, + + handleSystemMuted: (muted: boolean) => { + const { currentCall } = get(); + if (!currentCall) return; + + liveKitService.setMuted(muted); + if (currentCall.id) { + wsService.sendCallMute(currentCall.id, muted); + } + set((s) => ({ + currentCall: s.currentCall ? { ...s.currentCall, isMuted: muted } : null, + })); + }, + + startGroupCall: async (groupId: string, conversationId: string, callType: CallType) => { + const { currentCall } = get(); + if (currentCall && currentCall.status !== 'idle') { + console.warn('[CallStore] Already in a call'); + return; + } + + const myUserId = getCurrentUserId(); + if (!myUserId) { + console.error('[CallStore] Not logged in'); + return; + } + + wsService.preventDisconnectOnBackground = true; + + const localUser = useUserStore.getState().userCache[myUserId]; + const localName = localUser?.nickname || localUser?.username || myUserId; + + const participants = new Map(); + const now = Date.now(); + participants.set(myUserId, { + id: myUserId, + name: localName, + avatar: null, + isMuted: false, + isVideoEnabled: false, + isLocal: true, + isReady: true, + joinedAt: now, + }); + + set({ + currentCall: { + id: '', + conversationId, + groupId, + peerId: '', + peerName: undefined, + peerAvatar: null, + callType, + mediaType: callType, + status: 'calling', + duration: 0, + isMuted: false, + isSpeakerOn: callType === 'video', + isVideoEnabled: callType === 'video', + isPeerVideoEnabled: false, + isInitiator: true, + isPeerReady: false, + participants, + activeSpeakerId: null, + pinnedParticipantId: null, + }, + }); + + wsService.sendCallGroupInvite(groupId, conversationId, callType); + + if (unsubInvited) unsubInvited(); + unsubInvited = wsService.on('call_invited', (msg: any) => { + set((s) => { + if (!s.currentCall) return {}; + return { currentCall: { ...s.currentCall, id: msg.call_id } }; + }); + }); + + if (callTimeoutTimer) clearTimeout(callTimeoutTimer); + callTimeoutTimer = setTimeout(() => { + const { currentCall: cc } = get(); + if (cc && cc.status === 'calling') { + console.warn('[CallStore] Group call timeout'); + get().endCall('timeout'); + } + }, CALL_TIMEOUT_MS); + }, + + addParticipant: (userId: string, info: { name: string; avatar?: string | null }) => { + const { currentCall } = get(); + if (!currentCall) return; + + const newParticipants = new Map(currentCall.participants); + if (!newParticipants.has(userId)) { + newParticipants.set(userId, { + id: userId, + name: info.name || userId, + avatar: info.avatar ?? null, + isMuted: false, + isVideoEnabled: false, + isLocal: false, + isReady: true, + joinedAt: Date.now(), + }); + } else { + const existing = newParticipants.get(userId)!; + newParticipants.set(userId, { + ...existing, + isReady: true, + joinedAt: existing.joinedAt || Date.now(), + }); + } + set({ currentCall: { ...currentCall, participants: newParticipants } }); + }, + + removeParticipant: (userId: string) => { + const { currentCall } = get(); + if (!currentCall) return; + + const newParticipants = new Map(currentCall.participants); + newParticipants.delete(userId); + set({ currentCall: { ...currentCall, participants: newParticipants } }); + }, + + setActiveSpeaker: (userId: string | null) => { + set((s) => ({ + currentCall: s.currentCall + ? { ...s.currentCall, activeSpeakerId: userId } + : null, + })); + }, + + pinParticipant: (userId: string | null) => { + set((s) => ({ + currentCall: s.currentCall + ? { ...s.currentCall, pinnedParticipantId: userId } + : null, + })); + }, + + updateParticipantState: (userId: string, updates: Partial) => { + const { currentCall } = get(); + if (!currentCall) return; + + const p = currentCall.participants.get(userId); + if (!p) return; + + const newParticipants = new Map(currentCall.participants); + newParticipants.set(userId, { ...p, ...updates }); + set({ currentCall: { ...currentCall, participants: newParticipants } }); + }, + reset: () => { cleanupResources(); @@ -738,6 +1271,7 @@ export const callStore = create((set, get) => ({ } liveKitService.dispose(); + callKeepService.endActiveCall(); processedCallIds.clear(); diff --git a/src/stores/call/index.ts b/src/stores/call/index.ts index 5d762d2..70b0e96 100644 --- a/src/stores/call/index.ts +++ b/src/stores/call/index.ts @@ -3,4 +3,4 @@ */ export { callStore } from './callStore'; -export type { CallType, CallSession, CallStatus, IncomingCallInfo } from './callStore'; \ No newline at end of file +export type { CallType, CallSession, CallStatus, IncomingCallInfo, CallParticipant } from './callStore'; \ No newline at end of file