feat: migrate from NativeWind to react-native-unistyles and add Speechmatics audio
🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -13,3 +13,11 @@ This is an npm workspace monorepo:
|
|||||||
- **Root**: Workspace configuration and shared TypeScript config
|
- **Root**: Workspace configuration and shared TypeScript config
|
||||||
- **packages/server**: Backend server (Express + WebSocket API)
|
- **packages/server**: Backend server (Express + WebSocket API)
|
||||||
- **packages/app**: Cross-platform app (Expo - iOS, Android, Web)
|
- **packages/app**: Cross-platform app (Expo - iOS, Android, Web)
|
||||||
|
|
||||||
|
## Running and checking logs
|
||||||
|
|
||||||
|
We run the mobile app and server in the tmux session `voice-dev`:
|
||||||
|
|
||||||
|
tmux capture-pane -t voice-dev:mobile -p
|
||||||
|
|
||||||
|
tmux capture-pane -t voice-dev:server -p
|
||||||
|
|||||||
Generated
+93
-361
@@ -101,19 +101,6 @@
|
|||||||
"zod": "^3.25.76 || ^4.1.8"
|
"zod": "^3.25.76 || ^4.1.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@alloc/quick-lru": {
|
|
||||||
"version": "5.2.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
|
|
||||||
"integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@babel/code-frame": {
|
"node_modules/@babel/code-frame": {
|
||||||
"version": "7.10.4",
|
"version": "7.10.4",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.10.4.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.10.4.tgz",
|
||||||
@@ -6384,7 +6371,6 @@
|
|||||||
"version": "0.76.2",
|
"version": "0.76.2",
|
||||||
"resolved": "https://registry.npmjs.org/@react-native/normalize-colors/-/normalize-colors-0.76.2.tgz",
|
"resolved": "https://registry.npmjs.org/@react-native/normalize-colors/-/normalize-colors-0.76.2.tgz",
|
||||||
"integrity": "sha512-ICoOpaTLPsFQjNLSM00NgQr6wal300cZZonHVSDXKntX+BfkLeuCHRtr/Mn+klTtW+/1v2/2FRm9dXjvyGf9Dw==",
|
"integrity": "sha512-ICoOpaTLPsFQjNLSM00NgQr6wal300cZZonHVSDXKntX+BfkLeuCHRtr/Mn+klTtW+/1v2/2FRm9dXjvyGf9Dw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@react-native/virtualized-lists": {
|
"node_modules/@react-native/virtualized-lists": {
|
||||||
@@ -6906,283 +6892,23 @@
|
|||||||
"react-native": "*"
|
"react-native": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@speechmatics/expo-two-way-audio": {
|
||||||
|
"version": "0.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@speechmatics/expo-two-way-audio/-/expo-two-way-audio-0.1.2.tgz",
|
||||||
|
"integrity": "sha512-ecg4y/fkd2v1oWH2BTNyG5GBRqDuL2j9b86xNXGXHX87Xx87pw9CIPmIUIJWkk2aApXsWdkATz2txEcV41JMkg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"expo": "*",
|
||||||
|
"react": "*",
|
||||||
|
"react-native": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@standard-schema/spec": {
|
"node_modules/@standard-schema/spec": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz",
|
||||||
"integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==",
|
"integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@tailwindcss/node": {
|
|
||||||
"version": "4.1.15",
|
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.15.tgz",
|
|
||||||
"integrity": "sha512-HF4+7QxATZWY3Jr8OlZrBSXmwT3Watj0OogeDvdUY/ByXJHQ+LBtqA2brDb3sBxYslIFx6UP94BJ4X6a4L9Bmw==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@jridgewell/remapping": "^2.3.4",
|
|
||||||
"enhanced-resolve": "^5.18.3",
|
|
||||||
"jiti": "^2.6.0",
|
|
||||||
"lightningcss": "1.30.2",
|
|
||||||
"magic-string": "^0.30.19",
|
|
||||||
"source-map-js": "^1.2.1",
|
|
||||||
"tailwindcss": "4.1.15"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@tailwindcss/oxide": {
|
|
||||||
"version": "4.1.15",
|
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.15.tgz",
|
|
||||||
"integrity": "sha512-krhX+UOOgnsUuks2SR7hFafXmLQrKxB4YyRTERuCE59JlYL+FawgaAlSkOYmDRJdf1Q+IFNDMl9iRnBW7QBDfQ==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 10"
|
|
||||||
},
|
|
||||||
"optionalDependencies": {
|
|
||||||
"@tailwindcss/oxide-android-arm64": "4.1.15",
|
|
||||||
"@tailwindcss/oxide-darwin-arm64": "4.1.15",
|
|
||||||
"@tailwindcss/oxide-darwin-x64": "4.1.15",
|
|
||||||
"@tailwindcss/oxide-freebsd-x64": "4.1.15",
|
|
||||||
"@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.15",
|
|
||||||
"@tailwindcss/oxide-linux-arm64-gnu": "4.1.15",
|
|
||||||
"@tailwindcss/oxide-linux-arm64-musl": "4.1.15",
|
|
||||||
"@tailwindcss/oxide-linux-x64-gnu": "4.1.15",
|
|
||||||
"@tailwindcss/oxide-linux-x64-musl": "4.1.15",
|
|
||||||
"@tailwindcss/oxide-wasm32-wasi": "4.1.15",
|
|
||||||
"@tailwindcss/oxide-win32-arm64-msvc": "4.1.15",
|
|
||||||
"@tailwindcss/oxide-win32-x64-msvc": "4.1.15"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@tailwindcss/oxide-android-arm64": {
|
|
||||||
"version": "4.1.15",
|
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.15.tgz",
|
|
||||||
"integrity": "sha512-TkUkUgAw8At4cBjCeVCRMc/guVLKOU1D+sBPrHt5uVcGhlbVKxrCaCW9OKUIBv1oWkjh4GbunD/u/Mf0ql6kEA==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"android"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 10"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@tailwindcss/oxide-darwin-arm64": {
|
|
||||||
"version": "4.1.15",
|
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.15.tgz",
|
|
||||||
"integrity": "sha512-xt5XEJpn2piMSfvd1UFN6jrWXyaKCwikP4Pidcf+yfHTSzSpYhG3dcMktjNkQO3JiLCp+0bG0HoWGvz97K162w==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"darwin"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 10"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@tailwindcss/oxide-darwin-x64": {
|
|
||||||
"version": "4.1.15",
|
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.15.tgz",
|
|
||||||
"integrity": "sha512-TnWaxP6Bx2CojZEXAV2M01Yl13nYPpp0EtGpUrY+LMciKfIXiLL2r/SiSRpagE5Fp2gX+rflp/Os1VJDAyqymg==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"darwin"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 10"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@tailwindcss/oxide-freebsd-x64": {
|
|
||||||
"version": "4.1.15",
|
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.15.tgz",
|
|
||||||
"integrity": "sha512-quISQDWqiB6Cqhjc3iWptXVZHNVENsWoI77L1qgGEHNIdLDLFnw3/AfY7DidAiiCIkGX/MjIdB3bbBZR/G2aJg==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"freebsd"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 10"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
|
|
||||||
"version": "4.1.15",
|
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.15.tgz",
|
|
||||||
"integrity": "sha512-ObG76+vPlab65xzVUQbExmDU9FIeYLQ5k2LrQdR2Ud6hboR+ZobXpDoKEYXf/uOezOfIYmy2Ta3w0ejkTg9yxg==",
|
|
||||||
"cpu": [
|
|
||||||
"arm"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 10"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
|
|
||||||
"version": "4.1.15",
|
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.15.tgz",
|
|
||||||
"integrity": "sha512-4WbBacRmk43pkb8/xts3wnOZMDKsPFyEH/oisCm2q3aLZND25ufvJKcDUpAu0cS+CBOL05dYa8D4U5OWECuH/Q==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 10"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@tailwindcss/oxide-linux-arm64-musl": {
|
|
||||||
"version": "4.1.15",
|
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.15.tgz",
|
|
||||||
"integrity": "sha512-AbvmEiteEj1nf42nE8skdHv73NoR+EwXVSgPY6l39X12Ex8pzOwwfi3Kc8GAmjsnsaDEbk+aj9NyL3UeyHcTLg==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 10"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@tailwindcss/oxide-linux-x64-gnu": {
|
|
||||||
"version": "4.1.15",
|
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.15.tgz",
|
|
||||||
"integrity": "sha512-+rzMVlvVgrXtFiS+ES78yWgKqpThgV19ISKD58Ck+YO5pO5KjyxLt7AWKsWMbY0R9yBDC82w6QVGz837AKQcHg==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 10"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@tailwindcss/oxide-linux-x64-musl": {
|
|
||||||
"version": "4.1.15",
|
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.15.tgz",
|
|
||||||
"integrity": "sha512-fPdEy7a8eQN9qOIK3Em9D3TO1z41JScJn8yxl/76mp4sAXFDfV4YXxsiptJcOwy6bGR+70ZSwFIZhTXzQeqwQg==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 10"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@tailwindcss/oxide-wasm32-wasi": {
|
|
||||||
"version": "4.1.15",
|
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.15.tgz",
|
|
||||||
"integrity": "sha512-sJ4yd6iXXdlgIMfIBXuVGp/NvmviEoMVWMOAGxtxhzLPp9LOj5k0pMEMZdjeMCl4C6Up+RM8T3Zgk+BMQ0bGcQ==",
|
|
||||||
"bundleDependencies": [
|
|
||||||
"@napi-rs/wasm-runtime",
|
|
||||||
"@emnapi/core",
|
|
||||||
"@emnapi/runtime",
|
|
||||||
"@tybys/wasm-util",
|
|
||||||
"@emnapi/wasi-threads",
|
|
||||||
"tslib"
|
|
||||||
],
|
|
||||||
"cpu": [
|
|
||||||
"wasm32"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"dependencies": {
|
|
||||||
"@emnapi/core": "^1.5.0",
|
|
||||||
"@emnapi/runtime": "^1.5.0",
|
|
||||||
"@emnapi/wasi-threads": "^1.1.0",
|
|
||||||
"@napi-rs/wasm-runtime": "^1.0.7",
|
|
||||||
"@tybys/wasm-util": "^0.10.1",
|
|
||||||
"tslib": "^2.4.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=14.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
|
|
||||||
"version": "4.1.15",
|
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.15.tgz",
|
|
||||||
"integrity": "sha512-sJGE5faXnNQ1iXeqmRin7Ds/ru2fgCiaQZQQz3ZGIDtvbkeV85rAZ0QJFMDg0FrqsffZG96H1U9AQlNBRLsHVg==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"win32"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 10"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@tailwindcss/oxide-win32-x64-msvc": {
|
|
||||||
"version": "4.1.15",
|
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.15.tgz",
|
|
||||||
"integrity": "sha512-NLeHE7jUV6HcFKS504bpOohyi01zPXi2PXmjFfkzTph8xRxDdxkRsXm/xDO5uV5K3brrE1cCwbUYmFUSHR3u1w==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"win32"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 10"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@tailwindcss/postcss": {
|
|
||||||
"version": "4.1.15",
|
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.1.15.tgz",
|
|
||||||
"integrity": "sha512-IZh8IT76KujRz6d15wZw4eoeViT4TqmzVWNNfpuNCTKiaZUwgr5vtPqO4HjuYDyx3MgGR5qgPt1HMzTeLJyA3g==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@alloc/quick-lru": "^5.2.0",
|
|
||||||
"@tailwindcss/node": "4.1.15",
|
|
||||||
"@tailwindcss/oxide": "4.1.15",
|
|
||||||
"postcss": "^8.4.41",
|
|
||||||
"tailwindcss": "4.1.15"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@tsconfig/node10": {
|
"node_modules/@tsconfig/node10": {
|
||||||
"version": "1.0.11",
|
"version": "1.0.11",
|
||||||
"resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz",
|
"resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz",
|
||||||
@@ -7459,7 +7185,7 @@
|
|||||||
"version": "19.2.2",
|
"version": "19.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz",
|
||||||
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
|
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"csstype": "^3.0.2"
|
"csstype": "^3.0.2"
|
||||||
@@ -10054,7 +9780,7 @@
|
|||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
|
||||||
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
|
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/data-view-buffer": {
|
"node_modules/data-view-buffer": {
|
||||||
@@ -10712,20 +10438,6 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/enhanced-resolve": {
|
|
||||||
"version": "5.18.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz",
|
|
||||||
"integrity": "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"graceful-fs": "^4.2.4",
|
|
||||||
"tapable": "^2.2.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10.13.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/entities": {
|
"node_modules/entities": {
|
||||||
"version": "2.0.3",
|
"version": "2.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/entities/-/entities-2.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/entities/-/entities-2.0.3.tgz",
|
||||||
@@ -13751,7 +13463,7 @@
|
|||||||
"version": "16.8.1",
|
"version": "16.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/graphql/-/graphql-16.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/graphql/-/graphql-16.8.1.tgz",
|
||||||
"integrity": "sha512-59LZHPdGZVh695Ud9lRzPBVTtlX9ZCV150Er2W43ro37wVof0ctenSaskPPjN7lVTIN8mSZt8PHUNKZuNQUuxw==",
|
"integrity": "sha512-59LZHPdGZVh695Ud9lRzPBVTtlX9ZCV150Er2W43ro37wVof0ctenSaskPPjN7lVTIN8mSZt8PHUNKZuNQUuxw==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0"
|
"node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0"
|
||||||
@@ -14955,6 +14667,8 @@
|
|||||||
"integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==",
|
"integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"jiti": "lib/jiti-cli.mjs"
|
"jiti": "lib/jiti-cli.mjs"
|
||||||
}
|
}
|
||||||
@@ -16442,25 +16156,6 @@
|
|||||||
"url": "https://opencollective.com/napi-postinstall"
|
"url": "https://opencollective.com/napi-postinstall"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/nativewind": {
|
|
||||||
"version": "5.0.0-preview.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/nativewind/-/nativewind-5.0.0-preview.2.tgz",
|
|
||||||
"integrity": "sha512-rTNrwFIwl/n2VH7KPvsZj/NdvKf+uGHF4NYtPamr5qG2eTYGT8B8VeyCPfYf/xUskpWOLJVqVEXaFO/vuIDEdw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"workspaces": [
|
|
||||||
"example"
|
|
||||||
],
|
|
||||||
"dependencies": {
|
|
||||||
"tailwindcss-safe-area": "^1.1.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=20"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"react-native-css": "^3.0.1",
|
|
||||||
"tailwindcss": ">4.1.11"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/natural-compare": {
|
"node_modules/natural-compare": {
|
||||||
"version": "1.4.0",
|
"version": "1.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
|
||||||
@@ -17819,6 +17514,17 @@
|
|||||||
"@babel/types": "^7.26.0"
|
"@babel/types": "^7.26.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-native-edge-to-edge": {
|
||||||
|
"version": "1.7.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-native-edge-to-edge/-/react-native-edge-to-edge-1.7.0.tgz",
|
||||||
|
"integrity": "sha512-ERegbsq28yoMndn/Uq49i4h6aAhMvTEjOfkFh50yX9H/dMjjCr/Tix/es/9JcPRvC+q7VzCMWfxWDUb6Jrq1OQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "*",
|
||||||
|
"react-native": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-native-fit-image": {
|
"node_modules/react-native-fit-image": {
|
||||||
"version": "1.5.5",
|
"version": "1.5.5",
|
||||||
"resolved": "https://registry.npmjs.org/react-native-fit-image/-/react-native-fit-image-1.5.5.tgz",
|
"resolved": "https://registry.npmjs.org/react-native-fit-image/-/react-native-fit-image-1.5.5.tgz",
|
||||||
@@ -17869,6 +17575,18 @@
|
|||||||
"react-native": ">=0.50.4"
|
"react-native": ">=0.50.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-native-nitro-modules": {
|
||||||
|
"version": "0.31.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-native-nitro-modules/-/react-native-nitro-modules-0.31.1.tgz",
|
||||||
|
"integrity": "sha512-ecr3bTWvLj5/Ce3BO54XRHy4uNHfFR2iVFLf9g/zDRkaD4iOg6kF9hatkIXwUPZi6apIf9P91BS+bco0lSIcVA==",
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "*",
|
||||||
|
"react-native": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-native-permissions": {
|
"node_modules/react-native-permissions": {
|
||||||
"version": "5.4.2",
|
"version": "5.4.2",
|
||||||
"resolved": "https://registry.npmjs.org/react-native-permissions/-/react-native-permissions-5.4.2.tgz",
|
"resolved": "https://registry.npmjs.org/react-native-permissions/-/react-native-permissions-5.4.2.tgz",
|
||||||
@@ -17953,6 +17671,33 @@
|
|||||||
"react-native": "*"
|
"react-native": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-native-unistyles": {
|
||||||
|
"version": "3.0.15",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-native-unistyles/-/react-native-unistyles-3.0.15.tgz",
|
||||||
|
"integrity": "sha512-mbALO/lTc1oCWrCww79Zzipr1uVoePE2VXXgJO7zOKApK/hjBas03zpe3C4H6w7gBnD/z6TJYwTwQZt/yAWBeQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"workspaces": [
|
||||||
|
"example",
|
||||||
|
"docs",
|
||||||
|
"expo-example"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 18.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@react-native/normalize-colors": "*",
|
||||||
|
"react": "*",
|
||||||
|
"react-native": ">=0.76.0",
|
||||||
|
"react-native-edge-to-edge": "*",
|
||||||
|
"react-native-nitro-modules": "*",
|
||||||
|
"react-native-reanimated": "*"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"react-native-reanimated": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-native-web": {
|
"node_modules/react-native-web": {
|
||||||
"version": "0.21.2",
|
"version": "0.21.2",
|
||||||
"resolved": "https://registry.npmjs.org/react-native-web/-/react-native-web-0.21.2.tgz",
|
"resolved": "https://registry.npmjs.org/react-native-web/-/react-native-web-0.21.2.tgz",
|
||||||
@@ -19720,42 +19465,6 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/tailwindcss": {
|
|
||||||
"version": "4.1.15",
|
|
||||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.15.tgz",
|
|
||||||
"integrity": "sha512-k2WLnWkYFkdpRv+Oby3EBXIyQC8/s1HOFMBUViwtAh6Z5uAozeUSMQlIsn/c6Q2iJzqG6aJT3wdPaRNj70iYxQ==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/tailwindcss-safe-area": {
|
|
||||||
"version": "1.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/tailwindcss-safe-area/-/tailwindcss-safe-area-1.1.0.tgz",
|
|
||||||
"integrity": "sha512-wuPUeW5BhWNv9yr3OzaGgpqImQG9FBM4mQIQh2C6yjHmOOZsJ3gh5RfNHt+TM16TMtpQs/8k2TWx8yQTFG7Fcw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=22"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/mvllow"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"tailwindcss": "^4.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/tapable": {
|
|
||||||
"version": "2.3.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz",
|
|
||||||
"integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=6"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/webpack"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/tar": {
|
"node_modules/tar": {
|
||||||
"version": "6.2.1",
|
"version": "6.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz",
|
||||||
@@ -21467,6 +21176,8 @@
|
|||||||
"@react-navigation/elements": "^2.6.3",
|
"@react-navigation/elements": "^2.6.3",
|
||||||
"@react-navigation/native": "^7.1.8",
|
"@react-navigation/native": "^7.1.8",
|
||||||
"@siteed/expo-audio-studio": "^2.18.1",
|
"@siteed/expo-audio-studio": "^2.18.1",
|
||||||
|
"@speechmatics/expo-two-way-audio": "^0.1.2",
|
||||||
|
"buffer": "^6.0.3",
|
||||||
"expo": "^54.0.18",
|
"expo": "^54.0.18",
|
||||||
"expo-audio": "~1.0.13",
|
"expo-audio": "~1.0.13",
|
||||||
"expo-av": "^16.0.7",
|
"expo-av": "^16.0.7",
|
||||||
@@ -21487,7 +21198,6 @@
|
|||||||
"expo-updates": "~29.0.12",
|
"expo-updates": "~29.0.12",
|
||||||
"expo-web-browser": "~15.0.8",
|
"expo-web-browser": "~15.0.8",
|
||||||
"lucide-react-native": "^0.546.0",
|
"lucide-react-native": "^0.546.0",
|
||||||
"nativewind": "^5.0.0-preview.2",
|
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-dom": "19.1.0",
|
"react-dom": "19.1.0",
|
||||||
"react-native": "0.81.4",
|
"react-native": "0.81.4",
|
||||||
@@ -21499,21 +21209,43 @@
|
|||||||
"react-native-safe-area-context": "~5.6.0",
|
"react-native-safe-area-context": "~5.6.0",
|
||||||
"react-native-screens": "~4.16.0",
|
"react-native-screens": "~4.16.0",
|
||||||
"react-native-svg": "^15.14.0",
|
"react-native-svg": "^15.14.0",
|
||||||
|
"react-native-unistyles": "^3.0.15",
|
||||||
"react-native-web": "~0.21.0",
|
"react-native-web": "~0.21.0",
|
||||||
"react-native-webrtc": "^124.0.7",
|
"react-native-webrtc": "^124.0.7",
|
||||||
"react-native-worklets": "0.5.1"
|
"react-native-worklets": "0.5.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4.1.15",
|
|
||||||
"@types/react": "~19.2.0",
|
"@types/react": "~19.2.0",
|
||||||
"eas-cli": "^16.24.1",
|
"eas-cli": "^16.24.1",
|
||||||
"eslint": "^9.25.0",
|
"eslint": "^9.25.0",
|
||||||
"eslint-config-expo": "~10.0.0",
|
"eslint-config-expo": "~10.0.0",
|
||||||
"postcss": "^8.4.49",
|
|
||||||
"tailwindcss": "^4.1.15",
|
|
||||||
"typescript": "~5.9.2"
|
"typescript": "~5.9.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"packages/app/node_modules/buffer": {
|
||||||
|
"version": "6.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
|
||||||
|
"integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "patreon",
|
||||||
|
"url": "https://www.patreon.com/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "consulting",
|
||||||
|
"url": "https://feross.org/support"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"base64-js": "^1.3.1",
|
||||||
|
"ieee754": "^1.2.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"packages/server": {
|
"packages/server": {
|
||||||
"name": "@voice-dev/server",
|
"name": "@voice-dev/server",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import '../global.css';
|
import '@/styles/unistyles';
|
||||||
import { Stack } from 'expo-router';
|
import { Stack } from 'expo-router';
|
||||||
import { StatusBar } from 'expo-status-bar';
|
import { StatusBar } from 'expo-status-bar';
|
||||||
import { SafeAreaProvider } from 'react-native-safe-area-context';
|
import { SafeAreaProvider } from 'react-native-safe-area-context';
|
||||||
|
|||||||
+291
-62
@@ -1,8 +1,231 @@
|
|||||||
import { View, Text, Pressable, ScrollView, Alert } from 'react-native';
|
import { View, Text, Pressable, ScrollView, Alert } from 'react-native';
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
|
import { StyleSheet } from 'react-native-unistyles';
|
||||||
import { useAudioRecorder } from '../hooks/use-audio-recorder';
|
import { useAudioRecorder } from '../hooks/use-audio-recorder';
|
||||||
import { useAudioPlayer } from '../hooks/use-audio-player';
|
import { useAudioPlayer } from '../hooks/use-audio-player';
|
||||||
import { getRecordingPermissionsAsync, requestRecordingPermissionsAsync } from 'expo-audio';
|
import { getRecordingPermissionsAsync, requestRecordingPermissionsAsync } from 'expo-audio';
|
||||||
|
import type { Theme } from '../styles/theme';
|
||||||
|
|
||||||
|
const styles = StyleSheet.create((theme: Theme) => ({
|
||||||
|
scrollView: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: theme.colors.white,
|
||||||
|
},
|
||||||
|
scrollViewDark: {
|
||||||
|
backgroundColor: theme.colors.black,
|
||||||
|
},
|
||||||
|
container: {
|
||||||
|
padding: theme.spacing[6],
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: theme.fontSize['3xl'],
|
||||||
|
fontWeight: theme.fontWeight.bold,
|
||||||
|
color: theme.colors.gray[900],
|
||||||
|
marginBottom: theme.spacing[2],
|
||||||
|
},
|
||||||
|
titleDark: {
|
||||||
|
color: theme.colors.white,
|
||||||
|
},
|
||||||
|
subtitle: {
|
||||||
|
fontSize: theme.fontSize.base,
|
||||||
|
color: theme.colors.gray[600],
|
||||||
|
marginBottom: theme.spacing[8],
|
||||||
|
},
|
||||||
|
subtitleDark: {
|
||||||
|
color: theme.colors.gray[400],
|
||||||
|
},
|
||||||
|
section: {
|
||||||
|
marginBottom: theme.spacing[8],
|
||||||
|
},
|
||||||
|
sectionTitle: {
|
||||||
|
fontSize: theme.fontSize.lg,
|
||||||
|
fontWeight: theme.fontWeight.semibold,
|
||||||
|
color: theme.colors.gray[900],
|
||||||
|
marginBottom: theme.spacing[2],
|
||||||
|
},
|
||||||
|
sectionTitleDark: {
|
||||||
|
color: theme.colors.white,
|
||||||
|
},
|
||||||
|
permissionCard: {
|
||||||
|
padding: theme.spacing[4],
|
||||||
|
borderRadius: theme.borderRadius.lg,
|
||||||
|
},
|
||||||
|
permissionGranted: {
|
||||||
|
backgroundColor: theme.colors.green[100],
|
||||||
|
},
|
||||||
|
permissionGrantedDark: {
|
||||||
|
backgroundColor: theme.colors.green[900],
|
||||||
|
},
|
||||||
|
permissionDenied: {
|
||||||
|
backgroundColor: theme.colors.red[100],
|
||||||
|
},
|
||||||
|
permissionDeniedDark: {
|
||||||
|
backgroundColor: theme.colors.red[900],
|
||||||
|
},
|
||||||
|
permissionText: {
|
||||||
|
fontSize: theme.fontSize.base,
|
||||||
|
},
|
||||||
|
permissionTextGranted: {
|
||||||
|
color: theme.colors.green[800],
|
||||||
|
},
|
||||||
|
permissionTextGrantedDark: {
|
||||||
|
color: theme.colors.green[200],
|
||||||
|
},
|
||||||
|
permissionTextDenied: {
|
||||||
|
color: theme.colors.red[800],
|
||||||
|
},
|
||||||
|
permissionTextDeniedDark: {
|
||||||
|
color: theme.colors.red[200],
|
||||||
|
},
|
||||||
|
statusCard: {
|
||||||
|
marginBottom: theme.spacing[4],
|
||||||
|
padding: theme.spacing[4],
|
||||||
|
backgroundColor: theme.colors.gray[100],
|
||||||
|
borderRadius: theme.borderRadius.lg,
|
||||||
|
},
|
||||||
|
statusCardDark: {
|
||||||
|
backgroundColor: theme.colors.gray[800],
|
||||||
|
},
|
||||||
|
statusLabel: {
|
||||||
|
fontSize: theme.fontSize.sm,
|
||||||
|
fontWeight: theme.fontWeight.semibold,
|
||||||
|
color: theme.colors.gray[700],
|
||||||
|
marginBottom: theme.spacing[1],
|
||||||
|
},
|
||||||
|
statusLabelDark: {
|
||||||
|
color: theme.colors.gray[300],
|
||||||
|
},
|
||||||
|
statusValue: {
|
||||||
|
fontSize: theme.fontSize.base,
|
||||||
|
color: theme.colors.gray[900],
|
||||||
|
},
|
||||||
|
statusValueDark: {
|
||||||
|
color: theme.colors.white,
|
||||||
|
},
|
||||||
|
controlsRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
gap: theme.spacing[3],
|
||||||
|
marginBottom: theme.spacing[4],
|
||||||
|
},
|
||||||
|
button: {
|
||||||
|
flex: 1,
|
||||||
|
padding: theme.spacing[4],
|
||||||
|
borderRadius: theme.borderRadius.lg,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
buttonBlue: {
|
||||||
|
backgroundColor: theme.colors.blue[500],
|
||||||
|
},
|
||||||
|
buttonBlueDark: {
|
||||||
|
backgroundColor: theme.colors.blue[600],
|
||||||
|
},
|
||||||
|
buttonRed: {
|
||||||
|
backgroundColor: theme.colors.red[500],
|
||||||
|
},
|
||||||
|
buttonRedDark: {
|
||||||
|
backgroundColor: theme.colors.red[600],
|
||||||
|
},
|
||||||
|
buttonGreen: {
|
||||||
|
backgroundColor: theme.colors.green[500],
|
||||||
|
},
|
||||||
|
buttonGreenDark: {
|
||||||
|
backgroundColor: theme.colors.green[600],
|
||||||
|
},
|
||||||
|
buttonPurple: {
|
||||||
|
backgroundColor: theme.colors.purple[500],
|
||||||
|
},
|
||||||
|
buttonPurpleDark: {
|
||||||
|
backgroundColor: theme.colors.purple[600],
|
||||||
|
},
|
||||||
|
buttonOrange: {
|
||||||
|
backgroundColor: theme.colors.orange[500],
|
||||||
|
},
|
||||||
|
buttonOrangeDark: {
|
||||||
|
backgroundColor: theme.colors.orange[600],
|
||||||
|
},
|
||||||
|
buttonDisabled: {
|
||||||
|
backgroundColor: theme.colors.gray[300],
|
||||||
|
},
|
||||||
|
buttonDisabledDark: {
|
||||||
|
backgroundColor: theme.colors.gray[700],
|
||||||
|
},
|
||||||
|
buttonText: {
|
||||||
|
fontSize: theme.fontSize.base,
|
||||||
|
fontWeight: theme.fontWeight.semibold,
|
||||||
|
color: theme.colors.white,
|
||||||
|
},
|
||||||
|
buttonTextDisabled: {
|
||||||
|
color: theme.colors.gray[500],
|
||||||
|
},
|
||||||
|
buttonTextDisabledDark: {
|
||||||
|
color: theme.colors.gray[400],
|
||||||
|
},
|
||||||
|
fullWidthButton: {
|
||||||
|
padding: theme.spacing[4],
|
||||||
|
borderRadius: theme.borderRadius.lg,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
infoCard: {
|
||||||
|
padding: theme.spacing[4],
|
||||||
|
borderRadius: theme.borderRadius.lg,
|
||||||
|
backgroundColor: theme.colors.blue[50],
|
||||||
|
},
|
||||||
|
infoCardDark: {
|
||||||
|
backgroundColor: theme.colors.blue[950],
|
||||||
|
},
|
||||||
|
infoCardLabel: {
|
||||||
|
fontSize: theme.fontSize.sm,
|
||||||
|
fontWeight: theme.fontWeight.semibold,
|
||||||
|
color: theme.colors.blue[700],
|
||||||
|
marginBottom: theme.spacing[1],
|
||||||
|
},
|
||||||
|
infoCardLabelDark: {
|
||||||
|
color: theme.colors.blue[300],
|
||||||
|
},
|
||||||
|
infoCardValue: {
|
||||||
|
fontSize: theme.fontSize.base,
|
||||||
|
color: theme.colors.blue[900],
|
||||||
|
},
|
||||||
|
infoCardValueDark: {
|
||||||
|
color: theme.colors.blue[100],
|
||||||
|
},
|
||||||
|
infoCardSubtext: {
|
||||||
|
fontSize: theme.fontSize.sm,
|
||||||
|
color: theme.colors.blue[600],
|
||||||
|
marginTop: theme.spacing[1],
|
||||||
|
},
|
||||||
|
infoCardSubtextDark: {
|
||||||
|
color: theme.colors.blue[400],
|
||||||
|
},
|
||||||
|
configCard: {
|
||||||
|
padding: theme.spacing[4],
|
||||||
|
backgroundColor: theme.colors.gray[50],
|
||||||
|
borderRadius: theme.borderRadius.lg,
|
||||||
|
},
|
||||||
|
configCardDark: {
|
||||||
|
backgroundColor: theme.colors.gray[900],
|
||||||
|
},
|
||||||
|
configTitle: {
|
||||||
|
fontSize: theme.fontSize.sm,
|
||||||
|
fontWeight: theme.fontWeight.semibold,
|
||||||
|
color: theme.colors.gray[700],
|
||||||
|
marginBottom: theme.spacing[2],
|
||||||
|
},
|
||||||
|
configTitleDark: {
|
||||||
|
color: theme.colors.gray[300],
|
||||||
|
},
|
||||||
|
configText: {
|
||||||
|
fontSize: theme.fontSize.sm,
|
||||||
|
color: theme.colors.gray[600],
|
||||||
|
marginBottom: theme.spacing[1],
|
||||||
|
},
|
||||||
|
configTextDark: {
|
||||||
|
color: theme.colors.gray[400],
|
||||||
|
},
|
||||||
|
gap3: {
|
||||||
|
gap: theme.spacing[3],
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
export default function AudioTestScreen() {
|
export default function AudioTestScreen() {
|
||||||
const [permissionGranted, setPermissionGranted] = useState(false);
|
const [permissionGranted, setPermissionGranted] = useState(false);
|
||||||
@@ -124,40 +347,46 @@ export default function AudioTestScreen() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollView className="flex-1 bg-white dark:bg-black">
|
<ScrollView style={[styles.scrollView, styles.scrollViewDark]}>
|
||||||
<View className="p-6">
|
<View style={styles.container}>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<Text className="text-3xl font-bold text-gray-900 dark:text-white mb-2">
|
<Text style={[styles.title, styles.titleDark]}>
|
||||||
Audio Test
|
Audio Test
|
||||||
</Text>
|
</Text>
|
||||||
<Text className="text-base text-gray-600 dark:text-gray-400 mb-8">
|
<Text style={[styles.subtitle, styles.subtitleDark]}>
|
||||||
Test audio recording and playback functionality
|
Test audio recording and playback functionality
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
{/* Permission Status */}
|
{/* Permission Status */}
|
||||||
<View className="mb-8">
|
<View style={styles.section}>
|
||||||
<Text className="text-lg font-semibold text-gray-900 dark:text-white mb-2">
|
<Text style={[styles.sectionTitle, styles.sectionTitleDark]}>
|
||||||
Permission Status
|
Permission Status
|
||||||
</Text>
|
</Text>
|
||||||
<View className={`p-4 rounded-lg ${permissionGranted ? 'bg-green-100 dark:bg-green-900' : 'bg-red-100 dark:bg-red-900'}`}>
|
<View style={[
|
||||||
<Text className={`text-base ${permissionGranted ? 'text-green-800 dark:text-green-200' : 'text-red-800 dark:text-red-200'}`}>
|
styles.permissionCard,
|
||||||
|
permissionGranted ? styles.permissionGrantedDark : styles.permissionDeniedDark
|
||||||
|
]}>
|
||||||
|
<Text style={[
|
||||||
|
styles.permissionText,
|
||||||
|
permissionGranted ? styles.permissionTextGrantedDark : styles.permissionTextDeniedDark
|
||||||
|
]}>
|
||||||
{permissionGranted ? '✓ Microphone permission granted' : '✗ Microphone permission denied'}
|
{permissionGranted ? '✓ Microphone permission granted' : '✗ Microphone permission denied'}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Recording Section */}
|
{/* Recording Section */}
|
||||||
<View className="mb-8">
|
<View style={styles.section}>
|
||||||
<Text className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
<Text style={[styles.sectionTitle, styles.sectionTitleDark]}>
|
||||||
Audio Recording
|
Audio Recording
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
{/* Recording Status */}
|
{/* Recording Status */}
|
||||||
<View className="mb-4 p-4 bg-gray-100 dark:bg-gray-800 rounded-lg">
|
<View style={[styles.statusCard, styles.statusCardDark]}>
|
||||||
<Text className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
<Text style={[styles.statusLabel, styles.statusLabelDark]}>
|
||||||
Status
|
Status
|
||||||
</Text>
|
</Text>
|
||||||
<Text className="text-base text-gray-900 dark:text-white">
|
<Text style={[styles.statusValue, styles.statusValueDark]}>
|
||||||
{recordingStatus === 'idle' && 'Ready to record'}
|
{recordingStatus === 'idle' && 'Ready to record'}
|
||||||
{recordingStatus === 'recording' && '🔴 Recording...'}
|
{recordingStatus === 'recording' && '🔴 Recording...'}
|
||||||
{recordingStatus === 'processing' && 'Processing...'}
|
{recordingStatus === 'processing' && 'Processing...'}
|
||||||
@@ -165,21 +394,21 @@ export default function AudioTestScreen() {
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Recording Controls */}
|
{/* Recording Controls */}
|
||||||
<View className="flex-row gap-3 mb-4">
|
<View style={styles.controlsRow}>
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={handleStartRecording}
|
onPress={handleStartRecording}
|
||||||
disabled={recordingStatus !== 'idle' || !permissionGranted}
|
disabled={recordingStatus !== 'idle' || !permissionGranted}
|
||||||
className={`flex-1 p-4 rounded-lg items-center ${
|
style={[
|
||||||
recordingStatus !== 'idle' || !permissionGranted
|
styles.button,
|
||||||
? 'bg-gray-300 dark:bg-gray-700'
|
(recordingStatus !== 'idle' || !permissionGranted)
|
||||||
: 'bg-blue-500 dark:bg-blue-600'
|
? styles.buttonDisabledDark
|
||||||
}`}
|
: styles.buttonBlueDark
|
||||||
|
]}
|
||||||
>
|
>
|
||||||
<Text className={`text-base font-semibold ${
|
<Text style={[
|
||||||
recordingStatus !== 'idle' || !permissionGranted
|
styles.buttonText,
|
||||||
? 'text-gray-500 dark:text-gray-400'
|
(recordingStatus !== 'idle' || !permissionGranted) && styles.buttonTextDisabledDark
|
||||||
: 'text-white'
|
]}>
|
||||||
}`}>
|
|
||||||
Start Recording
|
Start Recording
|
||||||
</Text>
|
</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
@@ -187,17 +416,17 @@ export default function AudioTestScreen() {
|
|||||||
<Pressable
|
<Pressable
|
||||||
onPress={handleStopRecording}
|
onPress={handleStopRecording}
|
||||||
disabled={recordingStatus !== 'recording'}
|
disabled={recordingStatus !== 'recording'}
|
||||||
className={`flex-1 p-4 rounded-lg items-center ${
|
style={[
|
||||||
|
styles.button,
|
||||||
recordingStatus !== 'recording'
|
recordingStatus !== 'recording'
|
||||||
? 'bg-gray-300 dark:bg-gray-700'
|
? styles.buttonDisabledDark
|
||||||
: 'bg-red-500 dark:bg-red-600'
|
: styles.buttonRedDark
|
||||||
}`}
|
]}
|
||||||
>
|
>
|
||||||
<Text className={`text-base font-semibold ${
|
<Text style={[
|
||||||
recordingStatus !== 'recording'
|
styles.buttonText,
|
||||||
? 'text-gray-500 dark:text-gray-400'
|
recordingStatus !== 'recording' && styles.buttonTextDisabledDark
|
||||||
: 'text-white'
|
]}>
|
||||||
}`}>
|
|
||||||
Stop Recording
|
Stop Recording
|
||||||
</Text>
|
</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
@@ -205,14 +434,14 @@ export default function AudioTestScreen() {
|
|||||||
|
|
||||||
{/* Last Recording Info */}
|
{/* Last Recording Info */}
|
||||||
{lastRecordingSize !== null && (
|
{lastRecordingSize !== null && (
|
||||||
<View className="p-4 bg-blue-50 dark:bg-blue-950 rounded-lg">
|
<View style={[styles.infoCard, styles.infoCardDark]}>
|
||||||
<Text className="text-sm font-medium text-blue-700 dark:text-blue-300 mb-1">
|
<Text style={[styles.infoCardLabel, styles.infoCardLabelDark]}>
|
||||||
Last Recording
|
Last Recording
|
||||||
</Text>
|
</Text>
|
||||||
<Text className="text-base text-blue-900 dark:text-blue-100">
|
<Text style={[styles.infoCardValue, styles.infoCardValueDark]}>
|
||||||
Size: {lastRecordingSize} bytes
|
Size: {lastRecordingSize} bytes
|
||||||
</Text>
|
</Text>
|
||||||
<Text className="text-sm text-blue-600 dark:text-blue-400 mt-1">
|
<Text style={[styles.infoCardSubtext, styles.infoCardSubtextDark]}>
|
||||||
Type: audio/m4a
|
Type: audio/m4a
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
@@ -220,55 +449,55 @@ export default function AudioTestScreen() {
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Playback Section */}
|
{/* Playback Section */}
|
||||||
<View className="mb-8">
|
<View style={styles.section}>
|
||||||
<Text className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
<Text style={[styles.sectionTitle, styles.sectionTitleDark]}>
|
||||||
Audio Playback
|
Audio Playback
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
{/* Playback Status */}
|
{/* Playback Status */}
|
||||||
<View className="mb-4 p-4 bg-gray-100 dark:bg-gray-800 rounded-lg">
|
<View style={[styles.statusCard, styles.statusCardDark]}>
|
||||||
<Text className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
<Text style={[styles.statusLabel, styles.statusLabelDark]}>
|
||||||
Status
|
Status
|
||||||
</Text>
|
</Text>
|
||||||
<Text className="text-base text-gray-900 dark:text-white">
|
<Text style={[styles.statusValue, styles.statusValueDark]}>
|
||||||
{playbackStatus}
|
{playbackStatus}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Playback Controls */}
|
{/* Playback Controls */}
|
||||||
<View className="gap-3">
|
<View style={styles.gap3}>
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={handlePlayLastRecording}
|
onPress={handlePlayLastRecording}
|
||||||
disabled={!lastRecordedBlob}
|
disabled={!lastRecordedBlob}
|
||||||
className={`p-4 rounded-lg items-center ${
|
style={[
|
||||||
|
styles.fullWidthButton,
|
||||||
!lastRecordedBlob
|
!lastRecordedBlob
|
||||||
? 'bg-gray-300 dark:bg-gray-700'
|
? styles.buttonDisabledDark
|
||||||
: 'bg-green-500 dark:bg-green-600'
|
: styles.buttonGreenDark
|
||||||
}`}
|
]}
|
||||||
>
|
>
|
||||||
<Text className={`text-base font-semibold ${
|
<Text style={[
|
||||||
!lastRecordedBlob
|
styles.buttonText,
|
||||||
? 'text-gray-500 dark:text-gray-400'
|
!lastRecordedBlob && styles.buttonTextDisabledDark
|
||||||
: 'text-white'
|
]}>
|
||||||
}`}>
|
|
||||||
Play Last Recording
|
Play Last Recording
|
||||||
</Text>
|
</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={handlePlayTestAudio}
|
onPress={handlePlayTestAudio}
|
||||||
className="p-4 rounded-lg items-center bg-purple-500 dark:bg-purple-600"
|
style={[styles.fullWidthButton, styles.buttonPurpleDark]}
|
||||||
>
|
>
|
||||||
<Text className="text-base font-semibold text-white">
|
<Text style={styles.buttonText}>
|
||||||
Play Test Audio
|
Play Test Audio
|
||||||
</Text>
|
</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={handleStopPlayback}
|
onPress={handleStopPlayback}
|
||||||
className="p-4 rounded-lg items-center bg-orange-500 dark:bg-orange-600"
|
style={[styles.fullWidthButton, styles.buttonOrangeDark]}
|
||||||
>
|
>
|
||||||
<Text className="text-base font-semibold text-white">
|
<Text style={styles.buttonText}>
|
||||||
Stop Playback
|
Stop Playback
|
||||||
</Text>
|
</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
@@ -276,20 +505,20 @@ export default function AudioTestScreen() {
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Audio Settings Info */}
|
{/* Audio Settings Info */}
|
||||||
<View className="p-4 bg-gray-50 dark:bg-gray-900 rounded-lg">
|
<View style={[styles.configCard, styles.configCardDark]}>
|
||||||
<Text className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
<Text style={[styles.configTitle, styles.configTitleDark]}>
|
||||||
Audio Configuration
|
Audio Configuration
|
||||||
</Text>
|
</Text>
|
||||||
<Text className="text-sm text-gray-600 dark:text-gray-400 mb-1">
|
<Text style={[styles.configText, styles.configTextDark]}>
|
||||||
• Sample Rate: 16000 Hz
|
• Sample Rate: 16000 Hz
|
||||||
</Text>
|
</Text>
|
||||||
<Text className="text-sm text-gray-600 dark:text-gray-400 mb-1">
|
<Text style={[styles.configText, styles.configTextDark]}>
|
||||||
• Channels: 1 (Mono)
|
• Channels: 1 (Mono)
|
||||||
</Text>
|
</Text>
|
||||||
<Text className="text-sm text-gray-600 dark:text-gray-400 mb-1">
|
<Text style={[styles.configText, styles.configTextDark]}>
|
||||||
• Format: M4A/AAC
|
• Format: M4A/AAC
|
||||||
</Text>
|
</Text>
|
||||||
<Text className="text-sm text-gray-600 dark:text-gray-400">
|
<Text style={[styles.configText, styles.configTextDark]}>
|
||||||
• Optimized for voice/speech
|
• Optimized for voice/speech
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
+461
-289
File diff suppressed because it is too large
Load Diff
+347
-118
@@ -1,8 +1,235 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from "react";
|
||||||
import { View, Text, ScrollView, TextInput, Switch, Pressable, Alert, ActivityIndicator } from 'react-native';
|
import {
|
||||||
import { router } from 'expo-router';
|
View,
|
||||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
Text,
|
||||||
import { useSettings } from '@/hooks/use-settings';
|
ScrollView,
|
||||||
|
TextInput,
|
||||||
|
Switch,
|
||||||
|
Pressable,
|
||||||
|
Alert,
|
||||||
|
ActivityIndicator,
|
||||||
|
} from "react-native";
|
||||||
|
import { router } from "expo-router";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { StyleSheet } from "react-native-unistyles";
|
||||||
|
import { useSettings } from "@/hooks/use-settings";
|
||||||
|
import type { Theme } from "@/styles/theme";
|
||||||
|
|
||||||
|
const styles = StyleSheet.create((theme: Theme) => ({
|
||||||
|
loadingContainer: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: theme.colors.black,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
},
|
||||||
|
loadingText: {
|
||||||
|
color: theme.colors.white,
|
||||||
|
fontSize: theme.fontSize.lg,
|
||||||
|
},
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: theme.colors.black,
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
paddingHorizontal: theme.spacing[6],
|
||||||
|
paddingBottom: theme.spacing[4],
|
||||||
|
borderBottomWidth: theme.borderWidth[1],
|
||||||
|
borderBottomColor: theme.colors.zinc[800],
|
||||||
|
},
|
||||||
|
headerRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
},
|
||||||
|
headerTitle: {
|
||||||
|
color: theme.colors.white,
|
||||||
|
fontSize: theme.fontSize["3xl"],
|
||||||
|
fontWeight: theme.fontWeight.bold,
|
||||||
|
},
|
||||||
|
cancelButton: {
|
||||||
|
color: theme.colors.blue[500],
|
||||||
|
fontSize: theme.fontSize.base,
|
||||||
|
fontWeight: theme.fontWeight.semibold,
|
||||||
|
},
|
||||||
|
scrollView: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
padding: theme.spacing[6],
|
||||||
|
},
|
||||||
|
section: {
|
||||||
|
marginBottom: theme.spacing[8],
|
||||||
|
},
|
||||||
|
sectionTitle: {
|
||||||
|
color: theme.colors.white,
|
||||||
|
fontSize: theme.fontSize.lg,
|
||||||
|
fontWeight: theme.fontWeight.semibold,
|
||||||
|
marginBottom: theme.spacing[4],
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
color: theme.colors.zinc[400],
|
||||||
|
fontSize: theme.fontSize.sm,
|
||||||
|
marginBottom: theme.spacing[2],
|
||||||
|
},
|
||||||
|
input: {
|
||||||
|
backgroundColor: theme.colors.zinc[900],
|
||||||
|
color: theme.colors.white,
|
||||||
|
padding: theme.spacing[4],
|
||||||
|
borderRadius: theme.borderRadius.lg,
|
||||||
|
marginBottom: theme.spacing[2],
|
||||||
|
},
|
||||||
|
helperText: {
|
||||||
|
color: theme.colors.zinc[500],
|
||||||
|
fontSize: theme.fontSize.xs,
|
||||||
|
marginBottom: theme.spacing[3],
|
||||||
|
},
|
||||||
|
testButton: {
|
||||||
|
padding: theme.spacing[3],
|
||||||
|
borderRadius: theme.borderRadius.lg,
|
||||||
|
marginBottom: theme.spacing[3],
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
backgroundColor: theme.colors.blue[600],
|
||||||
|
},
|
||||||
|
testButtonDisabled: {
|
||||||
|
backgroundColor: theme.colors.zinc[800],
|
||||||
|
},
|
||||||
|
testButtonText: {
|
||||||
|
color: theme.colors.white,
|
||||||
|
fontWeight: theme.fontWeight.semibold,
|
||||||
|
marginLeft: theme.spacing[2],
|
||||||
|
},
|
||||||
|
testResultSuccess: {
|
||||||
|
padding: theme.spacing[3],
|
||||||
|
borderRadius: theme.borderRadius.lg,
|
||||||
|
backgroundColor: "#14532d",
|
||||||
|
borderWidth: theme.borderWidth[1],
|
||||||
|
borderColor: "#15803d",
|
||||||
|
},
|
||||||
|
testResultError: {
|
||||||
|
padding: theme.spacing[3],
|
||||||
|
borderRadius: theme.borderRadius.lg,
|
||||||
|
backgroundColor: "#7f1d1d",
|
||||||
|
borderWidth: theme.borderWidth[1],
|
||||||
|
borderColor: "#b91c1c",
|
||||||
|
},
|
||||||
|
testResultTextSuccess: {
|
||||||
|
color: "#4ade80",
|
||||||
|
},
|
||||||
|
testResultTextError: {
|
||||||
|
color: "#f87171",
|
||||||
|
},
|
||||||
|
settingCard: {
|
||||||
|
backgroundColor: theme.colors.zinc[900],
|
||||||
|
borderRadius: theme.borderRadius.lg,
|
||||||
|
padding: theme.spacing[4],
|
||||||
|
marginBottom: theme.spacing[3],
|
||||||
|
},
|
||||||
|
settingRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
settingContent: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
settingTitle: {
|
||||||
|
color: theme.colors.white,
|
||||||
|
fontSize: theme.fontSize.base,
|
||||||
|
marginBottom: theme.spacing[1],
|
||||||
|
},
|
||||||
|
settingDescription: {
|
||||||
|
color: theme.colors.zinc[400],
|
||||||
|
fontSize: theme.fontSize.sm,
|
||||||
|
},
|
||||||
|
themeCardDisabled: {
|
||||||
|
backgroundColor: theme.colors.zinc[900],
|
||||||
|
borderRadius: theme.borderRadius.lg,
|
||||||
|
padding: theme.spacing[4],
|
||||||
|
opacity: theme.opacity[50],
|
||||||
|
},
|
||||||
|
themeHelpText: {
|
||||||
|
color: theme.colors.zinc[400],
|
||||||
|
fontSize: theme.fontSize.sm,
|
||||||
|
marginBottom: theme.spacing[3],
|
||||||
|
},
|
||||||
|
themeOption: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
paddingVertical: theme.spacing[2],
|
||||||
|
},
|
||||||
|
radioOuter: {
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
borderRadius: theme.borderRadius.full,
|
||||||
|
borderWidth: theme.borderWidth[2],
|
||||||
|
marginRight: theme.spacing[3],
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
},
|
||||||
|
radioOuterSelected: {
|
||||||
|
borderColor: theme.colors.blue[500],
|
||||||
|
},
|
||||||
|
radioOuterUnselected: {
|
||||||
|
borderColor: theme.colors.zinc[600],
|
||||||
|
},
|
||||||
|
radioInner: {
|
||||||
|
width: 12,
|
||||||
|
height: 12,
|
||||||
|
borderRadius: theme.borderRadius.full,
|
||||||
|
backgroundColor: theme.colors.blue[500],
|
||||||
|
},
|
||||||
|
themeOptionText: {
|
||||||
|
color: theme.colors.zinc[400],
|
||||||
|
fontSize: theme.fontSize.base,
|
||||||
|
textTransform: "capitalize",
|
||||||
|
},
|
||||||
|
saveButton: {
|
||||||
|
padding: theme.spacing[4],
|
||||||
|
borderRadius: theme.borderRadius.lg,
|
||||||
|
marginBottom: theme.spacing[3],
|
||||||
|
backgroundColor: theme.colors.blue[500],
|
||||||
|
},
|
||||||
|
saveButtonDisabled: {
|
||||||
|
backgroundColor: "#1e3a8a",
|
||||||
|
opacity: theme.opacity[50],
|
||||||
|
},
|
||||||
|
saveButtonText: {
|
||||||
|
color: theme.colors.white,
|
||||||
|
textAlign: "center",
|
||||||
|
fontSize: theme.fontSize.base,
|
||||||
|
fontWeight: theme.fontWeight.semibold,
|
||||||
|
},
|
||||||
|
resetButton: {
|
||||||
|
padding: theme.spacing[4],
|
||||||
|
borderRadius: theme.borderRadius.lg,
|
||||||
|
borderWidth: theme.borderWidth[1],
|
||||||
|
borderColor: "#7f1d1d",
|
||||||
|
},
|
||||||
|
resetButtonText: {
|
||||||
|
color: theme.colors.red[500],
|
||||||
|
textAlign: "center",
|
||||||
|
fontSize: theme.fontSize.base,
|
||||||
|
fontWeight: theme.fontWeight.semibold,
|
||||||
|
},
|
||||||
|
footer: {
|
||||||
|
borderTopWidth: theme.borderWidth[1],
|
||||||
|
borderTopColor: theme.colors.zinc[800],
|
||||||
|
paddingTop: theme.spacing[6],
|
||||||
|
},
|
||||||
|
footerText: {
|
||||||
|
color: theme.colors.zinc[500],
|
||||||
|
fontSize: theme.fontSize.sm,
|
||||||
|
textAlign: "center",
|
||||||
|
},
|
||||||
|
footerVersion: {
|
||||||
|
color: theme.colors.zinc[600],
|
||||||
|
fontSize: theme.fontSize.xs,
|
||||||
|
textAlign: "center",
|
||||||
|
marginTop: theme.spacing[1],
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
export default function SettingsScreen() {
|
export default function SettingsScreen() {
|
||||||
const { settings, isLoading, updateSettings, resetSettings } = useSettings();
|
const { settings, isLoading, updateSettings, resetSettings } = useSettings();
|
||||||
@@ -11,10 +238,13 @@ export default function SettingsScreen() {
|
|||||||
const [serverUrl, setServerUrl] = useState(settings.serverUrl);
|
const [serverUrl, setServerUrl] = useState(settings.serverUrl);
|
||||||
const [useSpeaker, setUseSpeaker] = useState(settings.useSpeaker);
|
const [useSpeaker, setUseSpeaker] = useState(settings.useSpeaker);
|
||||||
const [keepScreenOn, setKeepScreenOn] = useState(settings.keepScreenOn);
|
const [keepScreenOn, setKeepScreenOn] = useState(settings.keepScreenOn);
|
||||||
const [theme, setTheme] = useState<'dark' | 'light' | 'auto'>(settings.theme);
|
const [theme, setTheme] = useState<"dark" | "light" | "auto">(settings.theme);
|
||||||
const [hasChanges, setHasChanges] = useState(false);
|
const [hasChanges, setHasChanges] = useState(false);
|
||||||
const [isTesting, setIsTesting] = useState(false);
|
const [isTesting, setIsTesting] = useState(false);
|
||||||
const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null);
|
const [testResult, setTestResult] = useState<{
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
// Update local state when settings load
|
// Update local state when settings load
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -37,7 +267,7 @@ export default function SettingsScreen() {
|
|||||||
function validateServerUrl(url: string): boolean {
|
function validateServerUrl(url: string): boolean {
|
||||||
try {
|
try {
|
||||||
const urlObj = new URL(url);
|
const urlObj = new URL(url);
|
||||||
return urlObj.protocol === 'ws:' || urlObj.protocol === 'wss:';
|
return urlObj.protocol === "ws:" || urlObj.protocol === "wss:";
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -47,9 +277,9 @@ export default function SettingsScreen() {
|
|||||||
// Validate server URL
|
// Validate server URL
|
||||||
if (!validateServerUrl(serverUrl)) {
|
if (!validateServerUrl(serverUrl)) {
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
'Invalid URL',
|
"Invalid URL",
|
||||||
'Server URL must be a valid WebSocket URL (ws:// or wss://)',
|
"Server URL must be a valid WebSocket URL (ws:// or wss://)",
|
||||||
[{ text: 'OK' }]
|
[{ text: "OK" }]
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -63,39 +293,43 @@ export default function SettingsScreen() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
'Settings Saved',
|
"Settings Saved",
|
||||||
'Your settings have been saved successfully.',
|
"Your settings have been saved successfully.",
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
text: 'OK',
|
text: "OK",
|
||||||
onPress: () => router.back(),
|
onPress: () => router.back(),
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Alert.alert(
|
Alert.alert("Error", "Failed to save settings. Please try again.", [
|
||||||
'Error',
|
{ text: "OK" },
|
||||||
'Failed to save settings. Please try again.',
|
]);
|
||||||
[{ text: 'OK' }]
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleReset() {
|
async function handleReset() {
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
'Reset Settings',
|
"Reset Settings",
|
||||||
'Are you sure you want to reset all settings to defaults?',
|
"Are you sure you want to reset all settings to defaults?",
|
||||||
[
|
[
|
||||||
{ text: 'Cancel', style: 'cancel' },
|
{ text: "Cancel", style: "cancel" },
|
||||||
{
|
{
|
||||||
text: 'Reset',
|
text: "Reset",
|
||||||
style: 'destructive',
|
style: "destructive",
|
||||||
onPress: async () => {
|
onPress: async () => {
|
||||||
try {
|
try {
|
||||||
await resetSettings();
|
await resetSettings();
|
||||||
Alert.alert('Settings Reset', 'All settings have been reset to defaults.');
|
Alert.alert(
|
||||||
|
"Settings Reset",
|
||||||
|
"All settings have been reset to defaults."
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Alert.alert('Error', 'Failed to reset settings. Please try again.');
|
Alert.alert(
|
||||||
|
"Error",
|
||||||
|
"Failed to reset settings. Please try again."
|
||||||
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -106,13 +340,13 @@ export default function SettingsScreen() {
|
|||||||
function handleCancel() {
|
function handleCancel() {
|
||||||
if (hasChanges) {
|
if (hasChanges) {
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
'Discard Changes',
|
"Discard Changes",
|
||||||
'You have unsaved changes. Are you sure you want to go back?',
|
"You have unsaved changes. Are you sure you want to go back?",
|
||||||
[
|
[
|
||||||
{ text: 'Stay', style: 'cancel' },
|
{ text: "Stay", style: "cancel" },
|
||||||
{
|
{
|
||||||
text: 'Discard',
|
text: "Discard",
|
||||||
style: 'destructive',
|
style: "destructive",
|
||||||
onPress: () => router.back(),
|
onPress: () => router.back(),
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
@@ -125,9 +359,9 @@ export default function SettingsScreen() {
|
|||||||
async function handleTestConnection() {
|
async function handleTestConnection() {
|
||||||
if (!validateServerUrl(serverUrl)) {
|
if (!validateServerUrl(serverUrl)) {
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
'Invalid URL',
|
"Invalid URL",
|
||||||
'Server URL must be a valid WebSocket URL (ws:// or wss://)',
|
"Server URL must be a valid WebSocket URL (ws:// or wss://)",
|
||||||
[{ text: 'OK' }]
|
[{ text: "OK" }]
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -142,7 +376,7 @@ export default function SettingsScreen() {
|
|||||||
ws.close();
|
ws.close();
|
||||||
setTestResult({
|
setTestResult({
|
||||||
success: false,
|
success: false,
|
||||||
message: 'Connection timeout - server did not respond',
|
message: "Connection timeout - server did not respond",
|
||||||
});
|
});
|
||||||
setIsTesting(false);
|
setIsTesting(false);
|
||||||
}, 5000);
|
}, 5000);
|
||||||
@@ -152,7 +386,7 @@ export default function SettingsScreen() {
|
|||||||
ws.close();
|
ws.close();
|
||||||
setTestResult({
|
setTestResult({
|
||||||
success: true,
|
success: true,
|
||||||
message: 'Connection successful',
|
message: "Connection successful",
|
||||||
});
|
});
|
||||||
setIsTesting(false);
|
setIsTesting(false);
|
||||||
};
|
};
|
||||||
@@ -161,14 +395,14 @@ export default function SettingsScreen() {
|
|||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
setTestResult({
|
setTestResult({
|
||||||
success: false,
|
success: false,
|
||||||
message: 'Connection failed - check URL and network',
|
message: "Connection failed - check URL and network",
|
||||||
});
|
});
|
||||||
setIsTesting(false);
|
setIsTesting(false);
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setTestResult({
|
setTestResult({
|
||||||
success: false,
|
success: false,
|
||||||
message: 'Failed to create connection',
|
message: "Failed to create connection",
|
||||||
});
|
});
|
||||||
setIsTesting(false);
|
setIsTesting(false);
|
||||||
}
|
}
|
||||||
@@ -176,35 +410,33 @@ export default function SettingsScreen() {
|
|||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<View className="flex-1 bg-black items-center justify-center">
|
<View style={styles.loadingContainer}>
|
||||||
<Text className="text-white text-lg">Loading settings...</Text>
|
<Text style={styles.loadingText}>Loading settings...</Text>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className="flex-1 bg-black">
|
<View style={styles.container}>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<View className="px-6 pb-4 border-b border-gray-800" style={{ paddingTop: insets.top + 16 }}>
|
<View style={[styles.header, { paddingTop: insets.top + 16 }]}>
|
||||||
<View className="flex-row items-center justify-between">
|
<View style={styles.headerRow}>
|
||||||
<Text className="text-white text-3xl font-bold">Settings</Text>
|
<Text style={styles.headerTitle}>Settings</Text>
|
||||||
<Pressable onPress={handleCancel}>
|
<Pressable onPress={handleCancel}>
|
||||||
<Text className="text-blue-500 text-base font-semibold">Cancel</Text>
|
<Text style={styles.cancelButton}>Cancel</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<ScrollView className="flex-1">
|
<ScrollView style={styles.scrollView}>
|
||||||
<View className="p-6">
|
<View style={styles.content}>
|
||||||
{/* Server Configuration */}
|
{/* Server Configuration */}
|
||||||
<View className="mb-8">
|
<View style={styles.section}>
|
||||||
<Text className="text-white text-lg font-semibold mb-4">
|
<Text style={styles.sectionTitle}>Server Configuration</Text>
|
||||||
Server Configuration
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
<Text className="text-gray-400 text-sm mb-2">WebSocket URL</Text>
|
<Text style={styles.label}>WebSocket URL</Text>
|
||||||
<TextInput
|
<TextInput
|
||||||
className="bg-zinc-900 text-white p-4 rounded-lg mb-2"
|
style={styles.input}
|
||||||
placeholder="wss://example.com/ws"
|
placeholder="wss://example.com/ws"
|
||||||
placeholderTextColor="#6b7280"
|
placeholderTextColor="#6b7280"
|
||||||
value={serverUrl}
|
value={serverUrl}
|
||||||
@@ -216,7 +448,7 @@ export default function SettingsScreen() {
|
|||||||
autoCorrect={false}
|
autoCorrect={false}
|
||||||
keyboardType="url"
|
keyboardType="url"
|
||||||
/>
|
/>
|
||||||
<Text className="text-gray-500 text-xs mb-3">
|
<Text style={styles.helperText}>
|
||||||
Must be a valid WebSocket URL (ws:// or wss://)
|
Must be a valid WebSocket URL (ws:// or wss://)
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
@@ -224,30 +456,38 @@ export default function SettingsScreen() {
|
|||||||
<Pressable
|
<Pressable
|
||||||
onPress={handleTestConnection}
|
onPress={handleTestConnection}
|
||||||
disabled={isTesting || !validateServerUrl(serverUrl)}
|
disabled={isTesting || !validateServerUrl(serverUrl)}
|
||||||
className={`p-3 rounded-lg mb-3 flex-row items-center justify-center ${
|
style={[
|
||||||
isTesting || !validateServerUrl(serverUrl) ? 'bg-zinc-800' : 'bg-blue-600 active:bg-blue-700'
|
styles.testButton,
|
||||||
}`}
|
(isTesting || !validateServerUrl(serverUrl)) &&
|
||||||
|
styles.testButtonDisabled,
|
||||||
|
]}
|
||||||
>
|
>
|
||||||
{isTesting ? (
|
{isTesting ? (
|
||||||
<>
|
<>
|
||||||
<ActivityIndicator size="small" color="#fff" />
|
<ActivityIndicator size="small" color="#fff" />
|
||||||
<Text className="text-white font-semibold ml-2">Testing...</Text>
|
<Text style={styles.testButtonText}>Testing...</Text>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<Text className="text-white font-semibold">Test Connection</Text>
|
<Text style={styles.testButtonText}>Test Connection</Text>
|
||||||
)}
|
)}
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
|
||||||
{/* Test Result */}
|
{/* Test Result */}
|
||||||
{testResult && (
|
{testResult && (
|
||||||
<View
|
<View
|
||||||
className={`p-3 rounded-lg ${
|
style={
|
||||||
testResult.success
|
testResult.success
|
||||||
? 'bg-green-900/30 border border-green-700'
|
? styles.testResultSuccess
|
||||||
: 'bg-red-900/30 border border-red-700'
|
: styles.testResultError
|
||||||
}`}
|
}
|
||||||
>
|
>
|
||||||
<Text className={testResult.success ? 'text-green-400' : 'text-red-400'}>
|
<Text
|
||||||
|
style={
|
||||||
|
testResult.success
|
||||||
|
? styles.testResultTextSuccess
|
||||||
|
: styles.testResultTextError
|
||||||
|
}
|
||||||
|
>
|
||||||
{testResult.message}
|
{testResult.message}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
@@ -255,110 +495,99 @@ export default function SettingsScreen() {
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Audio Settings */}
|
{/* Audio Settings */}
|
||||||
<View className="mb-8">
|
<View style={styles.section}>
|
||||||
<Text className="text-white text-lg font-semibold mb-4">
|
<Text style={styles.sectionTitle}>Audio</Text>
|
||||||
Audio
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
<View className="bg-zinc-900 rounded-lg p-4 mb-3">
|
<View style={styles.settingCard}>
|
||||||
<View className="flex-row justify-between items-center">
|
<View style={styles.settingRow}>
|
||||||
<View className="flex-1">
|
<View style={styles.settingContent}>
|
||||||
<Text className="text-white text-base mb-1">Use Speaker</Text>
|
<Text style={styles.settingTitle}>Use Speaker</Text>
|
||||||
<Text className="text-gray-400 text-sm">
|
<Text style={styles.settingDescription}>
|
||||||
Play audio through speaker instead of earpiece
|
Play audio through speaker instead of earpiece
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<Switch
|
<Switch
|
||||||
value={useSpeaker}
|
value={useSpeaker}
|
||||||
onValueChange={setUseSpeaker}
|
onValueChange={setUseSpeaker}
|
||||||
trackColor={{ false: '#374151', true: '#3b82f6' }}
|
trackColor={{ false: "#374151", true: "#3b82f6" }}
|
||||||
thumbColor={useSpeaker ? '#60a5fa' : '#d1d5db'}
|
thumbColor={useSpeaker ? "#60a5fa" : "#d1d5db"}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View className="bg-zinc-900 rounded-lg p-4">
|
<View style={styles.settingCard}>
|
||||||
<View className="flex-row justify-between items-center">
|
<View style={styles.settingRow}>
|
||||||
<View className="flex-1">
|
<View style={styles.settingContent}>
|
||||||
<Text className="text-white text-base mb-1">Keep Screen On</Text>
|
<Text style={styles.settingTitle}>Keep Screen On</Text>
|
||||||
<Text className="text-gray-400 text-sm">
|
<Text style={styles.settingDescription}>
|
||||||
Prevent screen from sleeping during voice sessions
|
Prevent screen from sleeping during voice sessions
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<Switch
|
<Switch
|
||||||
value={keepScreenOn}
|
value={keepScreenOn}
|
||||||
onValueChange={setKeepScreenOn}
|
onValueChange={setKeepScreenOn}
|
||||||
trackColor={{ false: '#374151', true: '#3b82f6' }}
|
trackColor={{ false: "#374151", true: "#3b82f6" }}
|
||||||
thumbColor={keepScreenOn ? '#60a5fa' : '#d1d5db'}
|
thumbColor={keepScreenOn ? "#60a5fa" : "#d1d5db"}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Theme Settings */}
|
{/* Theme Settings */}
|
||||||
<View className="mb-8">
|
<View style={styles.section}>
|
||||||
<Text className="text-white text-lg font-semibold mb-4">
|
<Text style={styles.sectionTitle}>Theme</Text>
|
||||||
Theme
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
<View className="bg-zinc-900 rounded-lg p-4 opacity-50">
|
<View style={styles.themeCardDisabled}>
|
||||||
<Text className="text-gray-400 text-sm mb-3">
|
<Text style={styles.themeHelpText}>
|
||||||
Theme selection (coming soon)
|
Theme selection (coming soon)
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
{(['dark', 'light', 'auto'] as const).map((themeOption) => (
|
{(["dark", "light", "auto"] as const).map((themeOption) => (
|
||||||
<Pressable
|
<Pressable
|
||||||
key={themeOption}
|
key={themeOption}
|
||||||
disabled
|
disabled
|
||||||
className="flex-row items-center py-2"
|
style={styles.themeOption}
|
||||||
>
|
>
|
||||||
<View className={`w-5 h-5 rounded-full border-2 ${
|
<View
|
||||||
theme === themeOption ? 'border-blue-500' : 'border-gray-600'
|
style={[
|
||||||
} mr-3 items-center justify-center`}>
|
styles.radioOuter,
|
||||||
|
theme === themeOption
|
||||||
|
? styles.radioOuterSelected
|
||||||
|
: styles.radioOuterUnselected,
|
||||||
|
]}
|
||||||
|
>
|
||||||
{theme === themeOption && (
|
{theme === themeOption && (
|
||||||
<View className="w-3 h-3 rounded-full bg-blue-500" />
|
<View style={styles.radioInner} />
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
<Text className="text-gray-400 text-base capitalize">
|
<Text style={styles.themeOptionText}>{themeOption}</Text>
|
||||||
{themeOption}
|
|
||||||
</Text>
|
|
||||||
</Pressable>
|
</Pressable>
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Action Buttons */}
|
{/* Action Buttons */}
|
||||||
<View className="mb-8">
|
<View style={styles.section}>
|
||||||
<Pressable
|
<Pressable
|
||||||
className={`p-4 rounded-lg mb-3 ${
|
style={[
|
||||||
hasChanges ? 'bg-blue-500' : 'bg-blue-500/50'
|
styles.saveButton,
|
||||||
}`}
|
!hasChanges && styles.saveButtonDisabled,
|
||||||
|
]}
|
||||||
onPress={handleSave}
|
onPress={handleSave}
|
||||||
disabled={!hasChanges}
|
disabled={!hasChanges}
|
||||||
>
|
>
|
||||||
<Text className="text-white text-center text-base font-semibold">
|
<Text style={styles.saveButtonText}>Save Settings</Text>
|
||||||
Save Settings
|
|
||||||
</Text>
|
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
|
||||||
<Pressable
|
<Pressable style={styles.resetButton} onPress={handleReset}>
|
||||||
className="p-4 rounded-lg border border-red-500/30"
|
<Text style={styles.resetButtonText}>Reset to Defaults</Text>
|
||||||
onPress={handleReset}
|
|
||||||
>
|
|
||||||
<Text className="text-red-500 text-center text-base font-semibold">
|
|
||||||
Reset to Defaults
|
|
||||||
</Text>
|
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* App Info */}
|
{/* App Info */}
|
||||||
<View className="border-t border-gray-800 pt-6">
|
<View style={styles.footer}>
|
||||||
<Text className="text-gray-500 text-sm text-center">
|
<Text style={styles.footerText}>Voice Assistant Mobile</Text>
|
||||||
Voice Assistant Mobile
|
<Text style={styles.footerVersion}>Version 1.0.0</Text>
|
||||||
</Text>
|
|
||||||
<Text className="text-gray-600 text-xs text-center mt-1">
|
|
||||||
Version 1.0.0
|
|
||||||
</Text>
|
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { View, Text, Pressable, ScrollView } from 'react-native';
|
import { View, Text, Pressable, ScrollView } from 'react-native';
|
||||||
|
import { StyleSheet } from 'react-native-unistyles';
|
||||||
import type { AgentStatus } from '@server/server/acp/types';
|
import type { AgentStatus } from '@server/server/acp/types';
|
||||||
|
|
||||||
export interface ActiveProcessesProps {
|
export interface ActiveProcessesProps {
|
||||||
@@ -59,6 +60,81 @@ function getModeColor(modeId?: string): string {
|
|||||||
return '#9ca3af'; // gray - unknown
|
return '#9ca3af'; // gray - unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create((theme: import('../styles/theme').Theme) => ({
|
||||||
|
container: {
|
||||||
|
backgroundColor: theme.colors.zinc[900],
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: theme.colors.zinc[800],
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
paddingHorizontal: theme.spacing[4],
|
||||||
|
paddingVertical: theme.spacing[3],
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: theme.colors.zinc[800],
|
||||||
|
},
|
||||||
|
backButton: {
|
||||||
|
backgroundColor: theme.colors.zinc[800],
|
||||||
|
paddingHorizontal: theme.spacing[4],
|
||||||
|
paddingVertical: theme.spacing[2],
|
||||||
|
borderRadius: theme.borderRadius.lg,
|
||||||
|
},
|
||||||
|
backButtonActive: {
|
||||||
|
backgroundColor: theme.colors.zinc[700],
|
||||||
|
},
|
||||||
|
backButtonText: {
|
||||||
|
color: '#fff',
|
||||||
|
fontSize: theme.fontSize.sm,
|
||||||
|
fontWeight: '600',
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
scrollView: {
|
||||||
|
paddingHorizontal: theme.spacing[4],
|
||||||
|
paddingVertical: theme.spacing[3],
|
||||||
|
},
|
||||||
|
processItem: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: theme.spacing[2],
|
||||||
|
paddingHorizontal: theme.spacing[3],
|
||||||
|
paddingVertical: theme.spacing[2],
|
||||||
|
borderRadius: theme.borderRadius.lg,
|
||||||
|
},
|
||||||
|
processItemActive: {
|
||||||
|
backgroundColor: theme.colors.blue[600],
|
||||||
|
},
|
||||||
|
processItemInactive: {
|
||||||
|
backgroundColor: theme.colors.zinc[800],
|
||||||
|
},
|
||||||
|
agentIcon: {
|
||||||
|
width: 12,
|
||||||
|
height: 12,
|
||||||
|
borderRadius: theme.borderRadius.full,
|
||||||
|
backgroundColor: theme.colors.blue[500],
|
||||||
|
},
|
||||||
|
commandIcon: {
|
||||||
|
width: 12,
|
||||||
|
height: 12,
|
||||||
|
borderRadius: theme.borderRadius.sm,
|
||||||
|
backgroundColor: theme.colors.purple[500],
|
||||||
|
},
|
||||||
|
processText: {
|
||||||
|
color: '#fff',
|
||||||
|
fontSize: theme.fontSize.xs,
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
statusDot: {
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
borderRadius: theme.borderRadius.full,
|
||||||
|
},
|
||||||
|
modeIndicator: {
|
||||||
|
width: 6,
|
||||||
|
height: 6,
|
||||||
|
borderRadius: theme.borderRadius.full,
|
||||||
|
opacity: 0.3,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
export function ActiveProcesses({
|
export function ActiveProcesses({
|
||||||
agents,
|
agents,
|
||||||
commands,
|
commands,
|
||||||
@@ -74,29 +150,24 @@ export function ActiveProcesses({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className="bg-zinc-900 border-b border-zinc-800">
|
<View style={styles.container}>
|
||||||
{/* Header with Back button */}
|
|
||||||
{hasActiveProcess && (
|
{hasActiveProcess && (
|
||||||
<View className="px-4 py-3 border-b border-zinc-800">
|
<View style={styles.header}>
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={onBackToOrchestrator}
|
onPress={onBackToOrchestrator}
|
||||||
className="bg-zinc-800 px-4 py-2 rounded-lg active:bg-zinc-700"
|
style={({ pressed }) => [styles.backButton, pressed && styles.backButtonActive]}
|
||||||
>
|
>
|
||||||
<Text className="text-white text-sm font-semibold text-center">
|
<Text style={styles.backButtonText}>Back to Chat</Text>
|
||||||
Back to Chat
|
|
||||||
</Text>
|
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Scrollable process list */}
|
|
||||||
<ScrollView
|
<ScrollView
|
||||||
horizontal
|
horizontal
|
||||||
showsHorizontalScrollIndicator={false}
|
showsHorizontalScrollIndicator={false}
|
||||||
className="px-4 py-3"
|
style={styles.scrollView}
|
||||||
contentContainerStyle={{ gap: 8 }}
|
contentContainerStyle={{ gap: 8 }}
|
||||||
>
|
>
|
||||||
{/* Agents */}
|
|
||||||
{agents.map((agent) => {
|
{agents.map((agent) => {
|
||||||
const modeName = getModeName(agent.currentModeId, agent.availableModes);
|
const modeName = getModeName(agent.currentModeId, agent.availableModes);
|
||||||
const isActive = activeProcessType === 'agent' && activeProcessId === agent.id;
|
const isActive = activeProcessType === 'agent' && activeProcessId === agent.id;
|
||||||
@@ -105,36 +176,25 @@ export function ActiveProcesses({
|
|||||||
<Pressable
|
<Pressable
|
||||||
key={`agent-${agent.id}`}
|
key={`agent-${agent.id}`}
|
||||||
onPress={() => onSelectAgent(agent.id)}
|
onPress={() => onSelectAgent(agent.id)}
|
||||||
className={`flex-row items-center gap-2 px-3 py-2 rounded-lg ${
|
style={({ pressed }) => [
|
||||||
isActive ? 'bg-blue-600' : 'bg-zinc-800'
|
styles.processItem,
|
||||||
} active:opacity-70`}
|
isActive ? styles.processItemActive : styles.processItemInactive,
|
||||||
|
pressed && { opacity: 0.7 },
|
||||||
|
]}
|
||||||
>
|
>
|
||||||
{/* Agent icon */}
|
<View style={styles.agentIcon} />
|
||||||
<View className="w-3 h-3 rounded-full bg-blue-500" />
|
|
||||||
|
|
||||||
{/* Agent ID (shortened) */}
|
<Text style={styles.processText}>{agent.id.substring(0, 8)}</Text>
|
||||||
<Text className="text-white text-xs font-medium">
|
|
||||||
{agent.id.substring(0, 8)}
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
{/* Status indicator */}
|
<View style={[styles.statusDot, { backgroundColor: getAgentStatusColor(agent.status) }]} />
|
||||||
<View
|
|
||||||
className="w-2 h-2 rounded-full"
|
|
||||||
style={{ backgroundColor: getAgentStatusColor(agent.status) }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Mode indicator */}
|
|
||||||
{agent.currentModeId && (
|
{agent.currentModeId && (
|
||||||
<View
|
<View style={[styles.modeIndicator, { backgroundColor: getModeColor(agent.currentModeId) }]} />
|
||||||
className="w-1.5 h-1.5 rounded-full opacity-30"
|
|
||||||
style={{ backgroundColor: getModeColor(agent.currentModeId) }}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</Pressable>
|
</Pressable>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{/* Commands */}
|
|
||||||
{commands.map((command) => {
|
{commands.map((command) => {
|
||||||
const statusColor = command.isDead
|
const statusColor = command.isDead
|
||||||
? command.exitCode === 0
|
? command.exitCode === 0
|
||||||
@@ -143,23 +203,14 @@ export function ActiveProcesses({
|
|||||||
: '#3b82f6';
|
: '#3b82f6';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View key={`command-${command.id}`} style={[styles.processItem, styles.processItemInactive]}>
|
||||||
key={`command-${command.id}`}
|
<View style={styles.commandIcon} />
|
||||||
className="flex-row items-center gap-2 px-3 py-2 rounded-lg bg-zinc-800"
|
|
||||||
>
|
|
||||||
{/* Command icon */}
|
|
||||||
<View className="w-3 h-3 rounded-sm bg-purple-500" />
|
|
||||||
|
|
||||||
{/* Command name */}
|
<Text style={styles.processText} numberOfLines={1}>
|
||||||
<Text className="text-white text-xs font-medium" numberOfLines={1}>
|
|
||||||
{command.name || command.currentCommand.substring(0, 20)}
|
{command.name || command.currentCommand.substring(0, 20)}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
{/* Status indicator */}
|
<View style={[styles.statusDot, { backgroundColor: statusColor }]} />
|
||||||
<View
|
|
||||||
className="w-2 h-2 rounded-full"
|
|
||||||
style={{ backgroundColor: statusColor }}
|
|
||||||
/>
|
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import { View, Text, ScrollView, Pressable } from 'react-native';
|
import { View, Text, ScrollView, Pressable } from 'react-native';
|
||||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
import { StyleSheet } from 'react-native-unistyles';
|
||||||
import type { AgentStatus } from '@server/server/acp/types';
|
import type { AgentStatus } from '@server/server/acp/types';
|
||||||
import type { SessionNotification } from '@agentclientprotocol/sdk';
|
import type { SessionNotification } from '@agentclientprotocol/sdk';
|
||||||
|
|
||||||
@@ -80,11 +81,11 @@ function AgentUpdate({
|
|||||||
// Handle message chunks
|
// Handle message chunks
|
||||||
if (sessionUpdate.kind === 'agent_message_chunk') {
|
if (sessionUpdate.kind === 'agent_message_chunk') {
|
||||||
return (
|
return (
|
||||||
<View className="bg-zinc-800 rounded-lg p-3 mb-2">
|
<View style={stylesheet.updateCard}>
|
||||||
<Text className="text-zinc-500 text-xs mb-1">
|
<Text style={stylesheet.timestampText}>
|
||||||
{formatTimestamp(update.timestamp)}
|
{formatTimestamp(update.timestamp)}
|
||||||
</Text>
|
</Text>
|
||||||
<Text className="text-white text-sm">{sessionUpdate.chunk || ''}</Text>
|
<Text style={stylesheet.messageText}>{sessionUpdate.chunk || ''}</Text>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -92,21 +93,21 @@ function AgentUpdate({
|
|||||||
// Handle tool calls
|
// Handle tool calls
|
||||||
if (sessionUpdate.kind === 'tool_call') {
|
if (sessionUpdate.kind === 'tool_call') {
|
||||||
return (
|
return (
|
||||||
<View className="bg-zinc-800 rounded-lg mb-2 overflow-hidden">
|
<View style={stylesheet.collapsibleCard}>
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={() => setIsExpanded(!isExpanded)}
|
onPress={() => setIsExpanded(!isExpanded)}
|
||||||
className="px-3 py-2 active:bg-zinc-700"
|
style={stylesheet.collapsibleHeader}
|
||||||
>
|
>
|
||||||
<Text className="text-zinc-500 text-xs mb-1">
|
<Text style={stylesheet.timestampText}>
|
||||||
{formatTimestamp(update.timestamp)}
|
{formatTimestamp(update.timestamp)}
|
||||||
</Text>
|
</Text>
|
||||||
<Text className="text-blue-400 text-sm font-medium">
|
<Text style={stylesheet.toolCallText}>
|
||||||
Tool Call: {sessionUpdate.toolName || 'unknown'}
|
Tool Call: {sessionUpdate.toolName || 'unknown'}
|
||||||
</Text>
|
</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
{isExpanded && sessionUpdate.arguments && (
|
{isExpanded && sessionUpdate.arguments && (
|
||||||
<View className="px-3 pb-3 border-t border-zinc-700">
|
<View style={stylesheet.collapsibleContent}>
|
||||||
<Text className="text-zinc-400 text-xs font-mono mt-2">
|
<Text style={stylesheet.codeText}>
|
||||||
{JSON.stringify(sessionUpdate.arguments, null, 2)}
|
{JSON.stringify(sessionUpdate.arguments, null, 2)}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
@@ -118,11 +119,11 @@ function AgentUpdate({
|
|||||||
// Handle tool call updates
|
// Handle tool call updates
|
||||||
if (sessionUpdate.kind === 'tool_call_update') {
|
if (sessionUpdate.kind === 'tool_call_update') {
|
||||||
return (
|
return (
|
||||||
<View className="bg-zinc-800 rounded-lg p-3 mb-2">
|
<View style={stylesheet.updateCard}>
|
||||||
<Text className="text-zinc-500 text-xs mb-1">
|
<Text style={stylesheet.timestampText}>
|
||||||
{formatTimestamp(update.timestamp)}
|
{formatTimestamp(update.timestamp)}
|
||||||
</Text>
|
</Text>
|
||||||
<Text className="text-yellow-400 text-sm">
|
<Text style={stylesheet.updateStatusText}>
|
||||||
{sessionUpdate.status || 'updating'}
|
{sessionUpdate.status || 'updating'}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
@@ -133,22 +134,22 @@ function AgentUpdate({
|
|||||||
if (sessionUpdate.kind === 'available_commands_update') {
|
if (sessionUpdate.kind === 'available_commands_update') {
|
||||||
const commands = sessionUpdate.commands || [];
|
const commands = sessionUpdate.commands || [];
|
||||||
return (
|
return (
|
||||||
<View className="bg-zinc-800 rounded-lg mb-2 overflow-hidden">
|
<View style={stylesheet.collapsibleCard}>
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={() => setIsExpanded(!isExpanded)}
|
onPress={() => setIsExpanded(!isExpanded)}
|
||||||
className="px-3 py-2 active:bg-zinc-700"
|
style={stylesheet.collapsibleHeader}
|
||||||
>
|
>
|
||||||
<Text className="text-zinc-500 text-xs mb-1">
|
<Text style={stylesheet.timestampText}>
|
||||||
{formatTimestamp(update.timestamp)}
|
{formatTimestamp(update.timestamp)}
|
||||||
</Text>
|
</Text>
|
||||||
<Text className="text-green-400 text-sm font-medium">
|
<Text style={stylesheet.commandsText}>
|
||||||
Available Commands ({commands.length})
|
Available Commands ({commands.length})
|
||||||
</Text>
|
</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
{isExpanded && (
|
{isExpanded && (
|
||||||
<View className="px-3 pb-3 border-t border-zinc-700">
|
<View style={stylesheet.collapsibleContent}>
|
||||||
{commands.map((cmd: any, idx: number) => (
|
{commands.map((cmd: any, idx: number) => (
|
||||||
<Text key={idx} className="text-zinc-400 text-xs mt-1">
|
<Text key={idx} style={stylesheet.commandItem}>
|
||||||
• {cmd.name || cmd}
|
• {cmd.name || cmd}
|
||||||
</Text>
|
</Text>
|
||||||
))}
|
))}
|
||||||
@@ -160,11 +161,11 @@ function AgentUpdate({
|
|||||||
|
|
||||||
// Generic session update
|
// Generic session update
|
||||||
return (
|
return (
|
||||||
<View className="bg-zinc-800 rounded-lg p-3 mb-2">
|
<View style={stylesheet.updateCard}>
|
||||||
<Text className="text-zinc-500 text-xs mb-1">
|
<Text style={stylesheet.timestampText}>
|
||||||
{formatTimestamp(update.timestamp)}
|
{formatTimestamp(update.timestamp)}
|
||||||
</Text>
|
</Text>
|
||||||
<Text className="text-zinc-400 text-xs font-mono">
|
<Text style={stylesheet.codeText}>
|
||||||
{JSON.stringify(sessionUpdate, null, 2)}
|
{JSON.stringify(sessionUpdate, null, 2)}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
@@ -173,11 +174,11 @@ function AgentUpdate({
|
|||||||
|
|
||||||
// Fallback for unknown notification types
|
// Fallback for unknown notification types
|
||||||
return (
|
return (
|
||||||
<View className="bg-zinc-800 rounded-lg p-3 mb-2">
|
<View style={stylesheet.updateCard}>
|
||||||
<Text className="text-zinc-500 text-xs mb-1">
|
<Text style={stylesheet.timestampText}>
|
||||||
{formatTimestamp(update.timestamp)}
|
{formatTimestamp(update.timestamp)}
|
||||||
</Text>
|
</Text>
|
||||||
<Text className="text-zinc-400 text-xs font-mono">
|
<Text style={stylesheet.codeText}>
|
||||||
{JSON.stringify(notification, null, 2)}
|
{JSON.stringify(notification, null, 2)}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
@@ -204,49 +205,51 @@ export function AgentStreamView({
|
|||||||
const canKill = agent.status !== 'killed' && agent.status !== 'completed';
|
const canKill = agent.status !== 'killed' && agent.status !== 'completed';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className="flex-1 bg-black">
|
<View style={stylesheet.container}>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<View className="bg-zinc-900 border-b border-zinc-800 px-4 pb-4" style={{ paddingTop: insets.top + 16 }}>
|
<View style={[stylesheet.header, { paddingTop: insets.top + 16 }]}>
|
||||||
{/* Back button */}
|
{/* Back button */}
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={onBack}
|
onPress={onBack}
|
||||||
className="bg-zinc-800 px-4 py-2 rounded-lg mb-4 active:bg-zinc-700"
|
style={stylesheet.backButton}
|
||||||
>
|
>
|
||||||
<Text className="text-white text-sm font-semibold text-center">
|
<Text style={stylesheet.backButtonText}>
|
||||||
← Back to Chat
|
← Back to Chat
|
||||||
</Text>
|
</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
|
||||||
{/* Agent info */}
|
{/* Agent info */}
|
||||||
<View className="flex-row items-center justify-between mb-3">
|
<View style={stylesheet.agentInfoRow}>
|
||||||
<View className="flex-row items-center gap-2">
|
<View style={stylesheet.agentIdRow}>
|
||||||
<Text className="text-zinc-400 text-sm">Agent:</Text>
|
<Text style={stylesheet.agentLabel}>Agent:</Text>
|
||||||
<Text className="text-white text-sm font-mono">
|
<Text style={stylesheet.agentId}>
|
||||||
{agentId.substring(0, 8)}
|
{agentId.substring(0, 8)}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<View
|
<View
|
||||||
className="flex-row items-center gap-2 px-3 py-1 rounded-full"
|
style={[
|
||||||
style={{ backgroundColor: getStatusColor(agent.status) }}
|
stylesheet.statusBadge,
|
||||||
|
{ backgroundColor: getStatusColor(agent.status) }
|
||||||
|
]}
|
||||||
>
|
>
|
||||||
<Text className="text-white text-xs">{getStatusIcon(agent.status)}</Text>
|
<Text style={stylesheet.statusIcon}>{getStatusIcon(agent.status)}</Text>
|
||||||
<Text className="text-white text-xs font-medium">{agent.status}</Text>
|
<Text style={stylesheet.statusText}>{agent.status}</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<Text className="text-zinc-500 text-xs">
|
<Text style={stylesheet.createdText}>
|
||||||
Created: {formatTimestamp(agent.createdAt)}
|
Created: {formatTimestamp(agent.createdAt)}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
{/* Control buttons */}
|
{/* Control buttons */}
|
||||||
{(canCancel || canKill) && (
|
{(canCancel || canKill) && (
|
||||||
<View className="flex-row gap-2 mt-4">
|
<View style={stylesheet.controlButtons}>
|
||||||
{canCancel && (
|
{canCancel && (
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={() => onCancelAgent(agentId)}
|
onPress={() => onCancelAgent(agentId)}
|
||||||
className="flex-1 bg-orange-600 px-4 py-2 rounded-lg active:bg-orange-700"
|
style={stylesheet.cancelButton}
|
||||||
>
|
>
|
||||||
<Text className="text-white text-sm font-semibold text-center">
|
<Text style={stylesheet.controlButtonText}>
|
||||||
Cancel
|
Cancel
|
||||||
</Text>
|
</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
@@ -254,9 +257,9 @@ export function AgentStreamView({
|
|||||||
{canKill && (
|
{canKill && (
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={() => onKillAgent(agentId)}
|
onPress={() => onKillAgent(agentId)}
|
||||||
className="flex-1 bg-red-600 px-4 py-2 rounded-lg active:bg-red-700"
|
style={stylesheet.killButton}
|
||||||
>
|
>
|
||||||
<Text className="text-white text-sm font-semibold text-center">
|
<Text style={stylesheet.controlButtonText}>
|
||||||
Kill
|
Kill
|
||||||
</Text>
|
</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
@@ -268,12 +271,12 @@ export function AgentStreamView({
|
|||||||
{/* Updates list */}
|
{/* Updates list */}
|
||||||
<ScrollView
|
<ScrollView
|
||||||
ref={scrollViewRef}
|
ref={scrollViewRef}
|
||||||
className="flex-1 px-4"
|
style={stylesheet.scrollView}
|
||||||
contentContainerStyle={{ paddingTop: 16, paddingBottom: Math.max(insets.bottom, 32) }}
|
contentContainerStyle={{ paddingTop: 16, paddingBottom: Math.max(insets.bottom, 32) }}
|
||||||
>
|
>
|
||||||
{updates.length === 0 ? (
|
{updates.length === 0 ? (
|
||||||
<View className="flex-1 items-center justify-center py-12">
|
<View style={stylesheet.emptyState}>
|
||||||
<Text className="text-zinc-500 text-sm text-center">
|
<Text style={stylesheet.emptyStateText}>
|
||||||
No updates yet.{'\n'}Waiting for agent activity...
|
No updates yet.{'\n'}Waiting for agent activity...
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
@@ -284,3 +287,167 @@ export function AgentStreamView({
|
|||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const stylesheet = StyleSheet.create((theme: import('../styles/theme').Theme) => ({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: theme.colors.black,
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
backgroundColor: theme.colors.zinc[900],
|
||||||
|
borderBottomWidth: theme.borderWidth[1],
|
||||||
|
borderBottomColor: theme.colors.zinc[800],
|
||||||
|
paddingHorizontal: theme.spacing[4],
|
||||||
|
paddingBottom: theme.spacing[4],
|
||||||
|
},
|
||||||
|
backButton: {
|
||||||
|
backgroundColor: theme.colors.zinc[800],
|
||||||
|
paddingHorizontal: theme.spacing[4],
|
||||||
|
paddingVertical: theme.spacing[2],
|
||||||
|
borderRadius: theme.borderRadius.lg,
|
||||||
|
marginBottom: theme.spacing[4],
|
||||||
|
},
|
||||||
|
backButtonText: {
|
||||||
|
color: theme.colors.white,
|
||||||
|
fontSize: theme.fontSize.sm,
|
||||||
|
fontWeight: theme.fontWeight.semibold,
|
||||||
|
textAlign: "center",
|
||||||
|
},
|
||||||
|
agentInfoRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
marginBottom: theme.spacing[3],
|
||||||
|
},
|
||||||
|
agentIdRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: theme.spacing[2],
|
||||||
|
},
|
||||||
|
agentLabel: {
|
||||||
|
color: theme.colors.zinc[400],
|
||||||
|
fontSize: theme.fontSize.sm,
|
||||||
|
},
|
||||||
|
agentId: {
|
||||||
|
color: theme.colors.white,
|
||||||
|
fontSize: theme.fontSize.sm,
|
||||||
|
fontFamily: "monospace",
|
||||||
|
},
|
||||||
|
statusBadge: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: theme.spacing[2],
|
||||||
|
paddingHorizontal: theme.spacing[3],
|
||||||
|
paddingVertical: theme.spacing[1],
|
||||||
|
borderRadius: theme.borderRadius.full,
|
||||||
|
},
|
||||||
|
statusIcon: {
|
||||||
|
color: theme.colors.white,
|
||||||
|
fontSize: theme.fontSize.xs,
|
||||||
|
},
|
||||||
|
statusText: {
|
||||||
|
color: theme.colors.white,
|
||||||
|
fontSize: theme.fontSize.xs,
|
||||||
|
fontWeight: theme.fontWeight.semibold,
|
||||||
|
},
|
||||||
|
createdText: {
|
||||||
|
color: theme.colors.zinc[500],
|
||||||
|
fontSize: theme.fontSize.xs,
|
||||||
|
},
|
||||||
|
controlButtons: {
|
||||||
|
flexDirection: "row",
|
||||||
|
gap: theme.spacing[2],
|
||||||
|
marginTop: theme.spacing[4],
|
||||||
|
},
|
||||||
|
cancelButton: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: theme.colors.orange[600],
|
||||||
|
paddingHorizontal: theme.spacing[4],
|
||||||
|
paddingVertical: theme.spacing[2],
|
||||||
|
borderRadius: theme.borderRadius.lg,
|
||||||
|
},
|
||||||
|
killButton: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: theme.colors.red[600],
|
||||||
|
paddingHorizontal: theme.spacing[4],
|
||||||
|
paddingVertical: theme.spacing[2],
|
||||||
|
borderRadius: theme.borderRadius.lg,
|
||||||
|
},
|
||||||
|
controlButtonText: {
|
||||||
|
color: theme.colors.white,
|
||||||
|
fontSize: theme.fontSize.sm,
|
||||||
|
fontWeight: theme.fontWeight.semibold,
|
||||||
|
textAlign: "center",
|
||||||
|
},
|
||||||
|
scrollView: {
|
||||||
|
flex: 1,
|
||||||
|
paddingHorizontal: theme.spacing[4],
|
||||||
|
},
|
||||||
|
emptyState: {
|
||||||
|
flex: 1,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
paddingVertical: theme.spacing[12],
|
||||||
|
},
|
||||||
|
emptyStateText: {
|
||||||
|
color: theme.colors.zinc[500],
|
||||||
|
fontSize: theme.fontSize.sm,
|
||||||
|
textAlign: "center",
|
||||||
|
},
|
||||||
|
updateCard: {
|
||||||
|
backgroundColor: theme.colors.zinc[800],
|
||||||
|
borderRadius: theme.borderRadius.lg,
|
||||||
|
padding: theme.spacing[3],
|
||||||
|
marginBottom: theme.spacing[2],
|
||||||
|
},
|
||||||
|
collapsibleCard: {
|
||||||
|
backgroundColor: theme.colors.zinc[800],
|
||||||
|
borderRadius: theme.borderRadius.lg,
|
||||||
|
marginBottom: theme.spacing[2],
|
||||||
|
overflow: "hidden",
|
||||||
|
},
|
||||||
|
collapsibleHeader: {
|
||||||
|
paddingHorizontal: theme.spacing[3],
|
||||||
|
paddingVertical: theme.spacing[2],
|
||||||
|
},
|
||||||
|
collapsibleContent: {
|
||||||
|
paddingHorizontal: theme.spacing[3],
|
||||||
|
paddingBottom: theme.spacing[3],
|
||||||
|
borderTopWidth: theme.borderWidth[1],
|
||||||
|
borderTopColor: theme.colors.zinc[700],
|
||||||
|
},
|
||||||
|
timestampText: {
|
||||||
|
color: theme.colors.zinc[500],
|
||||||
|
fontSize: theme.fontSize.xs,
|
||||||
|
marginBottom: theme.spacing[1],
|
||||||
|
},
|
||||||
|
messageText: {
|
||||||
|
color: theme.colors.white,
|
||||||
|
fontSize: theme.fontSize.sm,
|
||||||
|
},
|
||||||
|
toolCallText: {
|
||||||
|
color: "#60a5fa",
|
||||||
|
fontSize: theme.fontSize.sm,
|
||||||
|
fontWeight: theme.fontWeight.semibold,
|
||||||
|
},
|
||||||
|
updateStatusText: {
|
||||||
|
color: "#fbbf24",
|
||||||
|
fontSize: theme.fontSize.sm,
|
||||||
|
},
|
||||||
|
commandsText: {
|
||||||
|
color: "#4ade80",
|
||||||
|
fontSize: theme.fontSize.sm,
|
||||||
|
fontWeight: theme.fontWeight.semibold,
|
||||||
|
},
|
||||||
|
commandItem: {
|
||||||
|
color: theme.colors.zinc[400],
|
||||||
|
fontSize: theme.fontSize.xs,
|
||||||
|
marginTop: theme.spacing[1],
|
||||||
|
},
|
||||||
|
codeText: {
|
||||||
|
color: theme.colors.zinc[400],
|
||||||
|
fontSize: theme.fontSize.xs,
|
||||||
|
fontFamily: "monospace",
|
||||||
|
marginTop: theme.spacing[2],
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|||||||
@@ -1,10 +1,17 @@
|
|||||||
import { View, Text, ScrollView, Pressable, Modal } from 'react-native';
|
import {
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
View,
|
||||||
import { useEffect } from 'react';
|
Text,
|
||||||
|
ScrollView,
|
||||||
|
Pressable,
|
||||||
|
Modal,
|
||||||
|
} from "react-native";
|
||||||
|
import { SafeAreaView } from "react-native-safe-area-context";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { StyleSheet } from "react-native-unistyles";
|
||||||
|
|
||||||
export interface Artifact {
|
export interface Artifact {
|
||||||
id: string;
|
id: string;
|
||||||
type: 'markdown' | 'diff' | 'image' | 'code';
|
type: "markdown" | "diff" | "image" | "code";
|
||||||
title: string;
|
title: string;
|
||||||
content: string;
|
content: string;
|
||||||
isBase64: boolean;
|
isBase64: boolean;
|
||||||
@@ -15,10 +22,145 @@ interface ArtifactDrawerProps {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create((theme: import('../styles/theme').Theme) => ({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: theme.colors.black,
|
||||||
|
flexDirection: "column",
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
paddingBottom: theme.spacing[4],
|
||||||
|
paddingHorizontal: theme.spacing[4],
|
||||||
|
borderBottomWidth: theme.borderWidth[1],
|
||||||
|
borderBottomColor: theme.colors.zinc[800],
|
||||||
|
},
|
||||||
|
headerRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
},
|
||||||
|
titleContainer: {
|
||||||
|
flex: 1,
|
||||||
|
marginRight: theme.spacing[4],
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
color: theme.colors.white,
|
||||||
|
fontSize: theme.fontSize["2xl"],
|
||||||
|
fontWeight: theme.fontWeight.bold,
|
||||||
|
},
|
||||||
|
headerActions: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: theme.spacing[2],
|
||||||
|
},
|
||||||
|
badge: {
|
||||||
|
paddingHorizontal: theme.spacing[3],
|
||||||
|
paddingVertical: theme.spacing[1],
|
||||||
|
borderRadius: theme.borderRadius.full,
|
||||||
|
},
|
||||||
|
badgeMarkdown: {
|
||||||
|
backgroundColor: theme.colors.blue[600],
|
||||||
|
},
|
||||||
|
badgeDiff: {
|
||||||
|
backgroundColor: theme.colors.purple[600],
|
||||||
|
},
|
||||||
|
badgeImage: {
|
||||||
|
backgroundColor: theme.colors.green[600],
|
||||||
|
},
|
||||||
|
badgeCode: {
|
||||||
|
backgroundColor: theme.colors.orange[600],
|
||||||
|
},
|
||||||
|
badgeText: {
|
||||||
|
color: theme.colors.white,
|
||||||
|
fontSize: theme.fontSize.xs,
|
||||||
|
fontWeight: theme.fontWeight.semibold,
|
||||||
|
},
|
||||||
|
closeButton: {
|
||||||
|
backgroundColor: theme.colors.zinc[800],
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
borderRadius: 20,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
},
|
||||||
|
closeButtonText: {
|
||||||
|
color: theme.colors.white,
|
||||||
|
fontSize: theme.fontSize["2xl"],
|
||||||
|
fontWeight: theme.fontWeight.bold,
|
||||||
|
},
|
||||||
|
contentScroll: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: theme.colors.black,
|
||||||
|
},
|
||||||
|
contentScrollContainer: {
|
||||||
|
padding: theme.spacing[4],
|
||||||
|
flexGrow: 1,
|
||||||
|
},
|
||||||
|
imagePlaceholder: {
|
||||||
|
flex: 1,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
},
|
||||||
|
imagePlaceholderText: {
|
||||||
|
color: theme.colors.zinc[400],
|
||||||
|
fontSize: theme.fontSize.sm,
|
||||||
|
},
|
||||||
|
imagePlaceholderSubtext: {
|
||||||
|
color: theme.colors.zinc[600],
|
||||||
|
fontSize: theme.fontSize.xs,
|
||||||
|
marginTop: theme.spacing[2],
|
||||||
|
},
|
||||||
|
codeContainer: {
|
||||||
|
backgroundColor: theme.colors.zinc[900],
|
||||||
|
borderRadius: theme.borderRadius.lg,
|
||||||
|
padding: theme.spacing[4],
|
||||||
|
borderWidth: theme.borderWidth[1],
|
||||||
|
borderColor: theme.colors.zinc[800],
|
||||||
|
},
|
||||||
|
codeText: {
|
||||||
|
color: theme.colors.zinc[300],
|
||||||
|
fontSize: theme.fontSize.sm,
|
||||||
|
fontFamily: "monospace",
|
||||||
|
},
|
||||||
|
metadataContainer: {
|
||||||
|
backgroundColor: theme.colors.zinc[900],
|
||||||
|
padding: theme.spacing[3],
|
||||||
|
borderTopWidth: theme.borderWidth[1],
|
||||||
|
borderTopColor: theme.colors.zinc[800],
|
||||||
|
marginTop: theme.spacing[2],
|
||||||
|
},
|
||||||
|
metadataTitle: {
|
||||||
|
color: theme.colors.zinc[500],
|
||||||
|
fontSize: theme.fontSize.xs,
|
||||||
|
fontWeight: theme.fontWeight.semibold,
|
||||||
|
marginBottom: theme.spacing[2],
|
||||||
|
},
|
||||||
|
metadataRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
marginBottom: theme.spacing[1],
|
||||||
|
},
|
||||||
|
metadataLabel: {
|
||||||
|
color: theme.colors.zinc[500],
|
||||||
|
fontSize: theme.fontSize.xs,
|
||||||
|
width: 80,
|
||||||
|
},
|
||||||
|
metadataValue: {
|
||||||
|
color: theme.colors.zinc[400],
|
||||||
|
fontSize: theme.fontSize.xs,
|
||||||
|
flex: 1,
|
||||||
|
fontFamily: "monospace",
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
export function ArtifactDrawer({ artifact, onClose }: ArtifactDrawerProps) {
|
export function ArtifactDrawer({ artifact, onClose }: ArtifactDrawerProps) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!artifact) return;
|
if (!artifact) return;
|
||||||
console.log('[ArtifactDrawer] Showing artifact:', artifact.id, artifact.type, artifact.title);
|
console.log(
|
||||||
|
"[ArtifactDrawer] Showing artifact:",
|
||||||
|
artifact.id,
|
||||||
|
artifact.type,
|
||||||
|
artifact.title
|
||||||
|
);
|
||||||
}, [artifact]);
|
}, [artifact]);
|
||||||
|
|
||||||
if (!artifact) {
|
if (!artifact) {
|
||||||
@@ -28,12 +170,12 @@ export function ArtifactDrawer({ artifact, onClose }: ArtifactDrawerProps) {
|
|||||||
// Decode content if base64
|
// Decode content if base64
|
||||||
const content = artifact.isBase64 ? atob(artifact.content) : artifact.content;
|
const content = artifact.isBase64 ? atob(artifact.content) : artifact.content;
|
||||||
|
|
||||||
// Type badge colors
|
// Type badge style mapping
|
||||||
const typeColors = {
|
const typeBadgeStyles = {
|
||||||
markdown: 'bg-blue-600',
|
markdown: styles.badgeMarkdown,
|
||||||
diff: 'bg-purple-600',
|
diff: styles.badgeDiff,
|
||||||
image: 'bg-green-600',
|
image: styles.badgeImage,
|
||||||
code: 'bg-orange-600',
|
code: styles.badgeCode,
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -43,84 +185,81 @@ export function ArtifactDrawer({ artifact, onClose }: ArtifactDrawerProps) {
|
|||||||
presentationStyle="pageSheet"
|
presentationStyle="pageSheet"
|
||||||
onRequestClose={onClose}
|
onRequestClose={onClose}
|
||||||
>
|
>
|
||||||
<SafeAreaView edges={['top', 'bottom']} className="flex-1 bg-black">
|
<SafeAreaView edges={["top", "bottom"]} style={styles.container}>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<View className="pb-4 px-4 border-b border-zinc-800">
|
<View style={styles.header}>
|
||||||
<View className="flex-row items-center justify-between">
|
<View style={styles.headerRow}>
|
||||||
<View className="flex-1 mr-4">
|
<View style={styles.titleContainer}>
|
||||||
<Text className="text-white text-xl font-bold" numberOfLines={2}>
|
<Text style={styles.title} numberOfLines={2}>
|
||||||
{artifact.title}
|
{artifact.title}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<View className="flex-row items-center gap-2">
|
<View style={styles.headerActions}>
|
||||||
<View className={`${typeColors[artifact.type]} px-3 py-1 rounded-full`}>
|
<View
|
||||||
<Text className="text-white text-xs font-semibold uppercase">
|
style={[
|
||||||
{artifact.type}
|
styles.badge,
|
||||||
|
typeBadgeStyles[artifact.type],
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text style={styles.badgeText}>
|
||||||
|
{artifact.type.toUpperCase()}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<Pressable
|
<Pressable onPress={onClose} style={styles.closeButton}>
|
||||||
onPress={onClose}
|
<Text style={styles.closeButtonText}>×</Text>
|
||||||
className="bg-zinc-800 w-10 h-10 rounded-full items-center justify-center"
|
|
||||||
>
|
|
||||||
<Text className="text-white text-xl font-bold">×</Text>
|
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<ScrollView className="flex-1" contentContainerClassName="p-4">
|
<ScrollView
|
||||||
{artifact.type === 'image' ? (
|
style={styles.contentScroll}
|
||||||
<View className="items-center justify-center">
|
contentContainerStyle={styles.contentScrollContainer}
|
||||||
<Text className="text-zinc-400 text-sm">
|
>
|
||||||
|
{artifact.type === "image" ? (
|
||||||
|
<View style={styles.imagePlaceholder}>
|
||||||
|
<Text style={styles.imagePlaceholderText}>
|
||||||
Image viewing not yet implemented
|
Image viewing not yet implemented
|
||||||
</Text>
|
</Text>
|
||||||
<Text className="text-zinc-600 text-xs mt-2">
|
<Text style={styles.imagePlaceholderSubtext}>
|
||||||
Base64 image data received
|
Base64 image data received
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
<View className="bg-zinc-900 rounded-lg p-4 border border-zinc-800">
|
<View style={styles.codeContainer}>
|
||||||
<ScrollView horizontal showsHorizontalScrollIndicator={true}>
|
<ScrollView horizontal showsHorizontalScrollIndicator={true}>
|
||||||
<Text
|
<Text style={styles.codeText}>{content}</Text>
|
||||||
className="text-zinc-300 text-sm font-mono"
|
|
||||||
style={{ fontFamily: 'monospace' }}
|
|
||||||
>
|
|
||||||
{content}
|
|
||||||
</Text>
|
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
{/* Metadata */}
|
{/* Metadata - Fixed at bottom */}
|
||||||
<View className="mt-4 bg-zinc-900 rounded-lg p-3 border border-zinc-800">
|
<View style={styles.metadataContainer}>
|
||||||
<Text className="text-zinc-500 text-xs font-semibold mb-2">METADATA</Text>
|
<Text style={styles.metadataTitle}>METADATA</Text>
|
||||||
<View className="space-y-1">
|
<View>
|
||||||
<View className="flex-row">
|
<View style={styles.metadataRow}>
|
||||||
<Text className="text-zinc-500 text-xs w-20">ID:</Text>
|
<Text style={styles.metadataLabel}>ID:</Text>
|
||||||
<Text className="text-zinc-400 text-xs flex-1 font-mono">
|
<Text style={styles.metadataValue}>{artifact.id}</Text>
|
||||||
{artifact.id}
|
</View>
|
||||||
</Text>
|
<View style={styles.metadataRow}>
|
||||||
</View>
|
<Text style={styles.metadataLabel}>Type:</Text>
|
||||||
<View className="flex-row">
|
<Text style={styles.metadataValue}>{artifact.type}</Text>
|
||||||
<Text className="text-zinc-500 text-xs w-20">Type:</Text>
|
</View>
|
||||||
<Text className="text-zinc-400 text-xs">{artifact.type}</Text>
|
<View style={styles.metadataRow}>
|
||||||
</View>
|
<Text style={styles.metadataLabel}>Encoding:</Text>
|
||||||
<View className="flex-row">
|
<Text style={styles.metadataValue}>
|
||||||
<Text className="text-zinc-500 text-xs w-20">Encoding:</Text>
|
{artifact.isBase64 ? "Base64" : "Plain text"}
|
||||||
<Text className="text-zinc-400 text-xs">
|
</Text>
|
||||||
{artifact.isBase64 ? 'Base64' : 'Plain text'}
|
</View>
|
||||||
</Text>
|
<View style={styles.metadataRow}>
|
||||||
</View>
|
<Text style={styles.metadataLabel}>Size:</Text>
|
||||||
<View className="flex-row">
|
<Text style={styles.metadataValue}>
|
||||||
<Text className="text-zinc-500 text-xs w-20">Size:</Text>
|
{content.length.toLocaleString()} characters
|
||||||
<Text className="text-zinc-400 text-xs">
|
</Text>
|
||||||
{content.length.toLocaleString()} characters
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
</View>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,15 +1,50 @@
|
|||||||
import { View, Text } from 'react-native';
|
import { View, Text } from 'react-native';
|
||||||
|
import { StyleSheet } from 'react-native-unistyles';
|
||||||
|
|
||||||
interface ConnectionStatusProps {
|
interface ConnectionStatusProps {
|
||||||
isConnected: boolean;
|
isConnected: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create((theme: import('../styles/theme').Theme) => ({
|
||||||
|
container: {
|
||||||
|
paddingHorizontal: theme.spacing[4],
|
||||||
|
paddingVertical: theme.spacing[3],
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: theme.colors.zinc[800],
|
||||||
|
},
|
||||||
|
row: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
dot: {
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
borderRadius: theme.borderRadius.full,
|
||||||
|
marginRight: theme.spacing[2],
|
||||||
|
},
|
||||||
|
dotConnected: {
|
||||||
|
backgroundColor: theme.colors.green[500],
|
||||||
|
},
|
||||||
|
dotDisconnected: {
|
||||||
|
backgroundColor: theme.colors.red[500],
|
||||||
|
},
|
||||||
|
text: {
|
||||||
|
fontSize: theme.fontSize.sm,
|
||||||
|
},
|
||||||
|
textConnected: {
|
||||||
|
color: theme.colors.green[500],
|
||||||
|
},
|
||||||
|
textDisconnected: {
|
||||||
|
color: theme.colors.red[500],
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
export function ConnectionStatus({ isConnected }: ConnectionStatusProps) {
|
export function ConnectionStatus({ isConnected }: ConnectionStatusProps) {
|
||||||
return (
|
return (
|
||||||
<View className="px-4 py-3 border-b border-zinc-800">
|
<View style={styles.container}>
|
||||||
<View className="flex-row items-center">
|
<View style={styles.row}>
|
||||||
<View className={`w-2 h-2 rounded-full mr-2 ${isConnected ? 'bg-green-500' : 'bg-red-500'}`} />
|
<View style={[styles.dot, isConnected ? styles.dotConnected : styles.dotDisconnected]} />
|
||||||
<Text className={isConnected ? 'text-green-500 text-sm' : 'text-red-500 text-sm'}>
|
<Text style={[styles.text, isConnected ? styles.textConnected : styles.textDisconnected]}>
|
||||||
{isConnected ? 'Connected' : 'Disconnected'}
|
{isConnected ? 'Connected' : 'Disconnected'}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Modal, View, Text, Pressable, ScrollView, Alert, KeyboardAvoidingView,
|
|||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
import { MessageSquare, X, Plus, Trash2 } from 'lucide-react-native';
|
import { MessageSquare, X, Plus, Trash2 } from 'lucide-react-native';
|
||||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
|
import { StyleSheet } from 'react-native-unistyles';
|
||||||
import type { UseWebSocketReturn } from '../hooks/use-websocket';
|
import type { UseWebSocketReturn } from '../hooks/use-websocket';
|
||||||
|
|
||||||
const STORAGE_KEY = '@voice-dev:conversation-id';
|
const STORAGE_KEY = '@voice-dev:conversation-id';
|
||||||
@@ -177,7 +178,7 @@ export function ConversationSelector({
|
|||||||
<View>
|
<View>
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={() => setIsOpen(true)}
|
onPress={() => setIsOpen(true)}
|
||||||
className="bg-zinc-800 px-4 py-2 rounded-lg"
|
style={styles.triggerButton}
|
||||||
>
|
>
|
||||||
<MessageSquare size={20} color="white" />
|
<MessageSquare size={20} color="white" />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
@@ -188,99 +189,94 @@ export function ConversationSelector({
|
|||||||
transparent={true}
|
transparent={true}
|
||||||
onRequestClose={() => setIsOpen(false)}
|
onRequestClose={() => setIsOpen(false)}
|
||||||
>
|
>
|
||||||
<View style={{ flex: 1, backgroundColor: 'rgba(0,0,0,0.5)' }}>
|
<View style={styles.modalOverlay}>
|
||||||
<Pressable
|
<Pressable
|
||||||
style={{ flex: 1 }}
|
style={styles.modalBackdrop}
|
||||||
onPress={() => setIsOpen(false)}
|
onPress={() => setIsOpen(false)}
|
||||||
/>
|
/>
|
||||||
<View style={{ backgroundColor: '#18181b', borderTopLeftRadius: 24, borderTopRightRadius: 24, height: '80%' }}>
|
<View style={styles.modalContent}>
|
||||||
<View style={{ flex: 1 }}>
|
<View style={styles.modalInner}>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<View className="flex-row items-center justify-between p-6 border-b border-zinc-800">
|
<View style={styles.header}>
|
||||||
<Text className="text-white text-lg font-semibold">
|
<Text style={styles.headerTitle}>
|
||||||
Conversations {conversations.length > 0 && `(${conversations.length})`}
|
Conversations {conversations.length > 0 && `(${conversations.length})`}
|
||||||
</Text>
|
</Text>
|
||||||
<Pressable onPress={() => setIsOpen(false)}>
|
<Pressable onPress={() => setIsOpen(false)}>
|
||||||
<X size={24} color="white" />
|
<X size={24} color="white" />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* New Conversation Button */}
|
{/* New Conversation Button */}
|
||||||
<View className="p-4 border-b border-zinc-800">
|
<View style={styles.newButtonContainer}>
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={handleNewConversation}
|
onPress={handleNewConversation}
|
||||||
className="flex-row items-center justify-center gap-2 bg-zinc-800 py-3 rounded-lg"
|
style={styles.newButton}
|
||||||
>
|
|
||||||
<Plus size={20} color="white" />
|
|
||||||
<Text className="text-white font-semibold">New Conversation</Text>
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Conversations List */}
|
|
||||||
<ScrollView
|
|
||||||
style={{ flex: 1 }}
|
|
||||||
contentContainerStyle={{ paddingBottom: 20 }}
|
|
||||||
keyboardShouldPersistTaps="handled"
|
|
||||||
keyboardDismissMode="on-drag"
|
|
||||||
>
|
|
||||||
{isLoading && (
|
|
||||||
<View className="p-8 items-center">
|
|
||||||
<Text className="text-zinc-400">Loading...</Text>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!isLoading && conversations.length === 0 && (
|
|
||||||
<View className="p-8 items-center">
|
|
||||||
<Text className="text-zinc-400">No saved conversations</Text>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!isLoading && conversations.map((conversation) => (
|
|
||||||
<View
|
|
||||||
key={conversation.id}
|
|
||||||
style={{
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
padding: 16,
|
|
||||||
borderBottomWidth: 1,
|
|
||||||
borderBottomColor: '#27272a',
|
|
||||||
backgroundColor: conversation.id === currentConversationId ? '#27272a' : 'transparent'
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Pressable
|
<Plus size={20} color="white" />
|
||||||
style={{ flex: 1 }}
|
<Text style={styles.newButtonText}>New Conversation</Text>
|
||||||
onPress={() => handleSelectConversation(conversation.id)}
|
</Pressable>
|
||||||
>
|
</View>
|
||||||
<Text style={{ color: 'white', fontWeight: '600', fontSize: 16 }}>
|
|
||||||
{conversation.messageCount} messages
|
|
||||||
</Text>
|
|
||||||
<Text style={{ color: '#a1a1aa', fontSize: 14, marginTop: 4 }}>
|
|
||||||
{formatDate(conversation.lastUpdated)}
|
|
||||||
</Text>
|
|
||||||
</Pressable>
|
|
||||||
<Pressable
|
|
||||||
onPress={() => handleDeleteConversation(conversation.id)}
|
|
||||||
style={{ padding: 8 }}
|
|
||||||
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
|
||||||
>
|
|
||||||
<Trash2 size={20} color="#ef4444" />
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{!isLoading && conversations.length > 0 && (
|
{/* Conversations List */}
|
||||||
<View className="p-4">
|
<ScrollView
|
||||||
<Pressable
|
style={styles.scrollView}
|
||||||
onPress={handleClearAll}
|
contentContainerStyle={styles.scrollContent}
|
||||||
className="flex-row items-center justify-center gap-2 bg-red-900/20 py-3 rounded-lg border border-red-900"
|
keyboardShouldPersistTaps="handled"
|
||||||
|
keyboardDismissMode="on-drag"
|
||||||
|
>
|
||||||
|
{isLoading && (
|
||||||
|
<View style={styles.loadingContainer}>
|
||||||
|
<Text style={styles.loadingText}>Loading...</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isLoading && conversations.length === 0 && (
|
||||||
|
<View style={styles.emptyContainer}>
|
||||||
|
<Text style={styles.emptyText}>No saved conversations</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isLoading && conversations.map((conversation) => (
|
||||||
|
<View
|
||||||
|
key={conversation.id}
|
||||||
|
style={[
|
||||||
|
styles.conversationItem,
|
||||||
|
conversation.id === currentConversationId && styles.conversationItemActive
|
||||||
|
]}
|
||||||
>
|
>
|
||||||
<Trash2 size={18} color="#ef4444" />
|
<Pressable
|
||||||
<Text className="text-red-500 font-semibold">Clear All</Text>
|
style={styles.conversationContent}
|
||||||
</Pressable>
|
onPress={() => handleSelectConversation(conversation.id)}
|
||||||
</View>
|
>
|
||||||
)}
|
<Text style={styles.conversationTitle}>
|
||||||
</ScrollView>
|
{conversation.messageCount} messages
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.conversationDate}>
|
||||||
|
{formatDate(conversation.lastUpdated)}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => handleDeleteConversation(conversation.id)}
|
||||||
|
style={styles.deleteButton}
|
||||||
|
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
||||||
|
>
|
||||||
|
<Trash2 size={20} color="#ef4444" />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{!isLoading && conversations.length > 0 && (
|
||||||
|
<View style={styles.clearAllContainer}>
|
||||||
|
<Pressable
|
||||||
|
onPress={handleClearAll}
|
||||||
|
style={styles.clearAllButton}
|
||||||
|
>
|
||||||
|
<Trash2 size={18} color="#ef4444" />
|
||||||
|
<Text style={styles.clearAllText}>Clear All</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
@@ -288,3 +284,125 @@ export function ConversationSelector({
|
|||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create((theme: import('../styles/theme').Theme) => ({
|
||||||
|
triggerButton: {
|
||||||
|
backgroundColor: theme.colors.zinc[800],
|
||||||
|
paddingHorizontal: theme.spacing[4],
|
||||||
|
paddingVertical: theme.spacing[2],
|
||||||
|
borderRadius: theme.borderRadius.lg,
|
||||||
|
},
|
||||||
|
modalOverlay: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||||
|
},
|
||||||
|
modalBackdrop: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
modalContent: {
|
||||||
|
backgroundColor: theme.colors.zinc[900],
|
||||||
|
borderTopLeftRadius: theme.spacing[6],
|
||||||
|
borderTopRightRadius: theme.spacing[6],
|
||||||
|
height: '80%',
|
||||||
|
},
|
||||||
|
modalInner: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
padding: theme.spacing[6],
|
||||||
|
borderBottomWidth: theme.borderWidth[1],
|
||||||
|
borderBottomColor: theme.colors.zinc[800],
|
||||||
|
},
|
||||||
|
headerTitle: {
|
||||||
|
color: theme.colors.white,
|
||||||
|
fontSize: theme.fontSize.lg,
|
||||||
|
fontWeight: theme.fontWeight.semibold,
|
||||||
|
},
|
||||||
|
newButtonContainer: {
|
||||||
|
padding: theme.spacing[4],
|
||||||
|
borderBottomWidth: theme.borderWidth[1],
|
||||||
|
borderBottomColor: theme.colors.zinc[800],
|
||||||
|
},
|
||||||
|
newButton: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: theme.spacing[2],
|
||||||
|
backgroundColor: theme.colors.zinc[800],
|
||||||
|
paddingVertical: theme.spacing[3],
|
||||||
|
borderRadius: theme.borderRadius.lg,
|
||||||
|
},
|
||||||
|
newButtonText: {
|
||||||
|
color: theme.colors.white,
|
||||||
|
fontWeight: theme.fontWeight.semibold,
|
||||||
|
},
|
||||||
|
scrollView: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
scrollContent: {
|
||||||
|
paddingBottom: theme.spacing[4] * 5,
|
||||||
|
},
|
||||||
|
loadingContainer: {
|
||||||
|
padding: theme.spacing[8],
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
loadingText: {
|
||||||
|
color: theme.colors.zinc[400],
|
||||||
|
},
|
||||||
|
emptyContainer: {
|
||||||
|
padding: theme.spacing[8],
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
emptyText: {
|
||||||
|
color: theme.colors.zinc[400],
|
||||||
|
},
|
||||||
|
conversationItem: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
padding: theme.spacing[4],
|
||||||
|
borderBottomWidth: theme.borderWidth[1],
|
||||||
|
borderBottomColor: theme.colors.zinc[800],
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
},
|
||||||
|
conversationItemActive: {
|
||||||
|
backgroundColor: theme.colors.zinc[800],
|
||||||
|
},
|
||||||
|
conversationContent: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
conversationTitle: {
|
||||||
|
color: theme.colors.white,
|
||||||
|
fontWeight: theme.fontWeight.semibold,
|
||||||
|
fontSize: theme.fontSize.base,
|
||||||
|
},
|
||||||
|
conversationDate: {
|
||||||
|
color: theme.colors.zinc[400],
|
||||||
|
fontSize: theme.fontSize.sm,
|
||||||
|
marginTop: theme.spacing[1],
|
||||||
|
},
|
||||||
|
deleteButton: {
|
||||||
|
padding: theme.spacing[2],
|
||||||
|
},
|
||||||
|
clearAllContainer: {
|
||||||
|
padding: theme.spacing[4],
|
||||||
|
},
|
||||||
|
clearAllButton: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: theme.spacing[2],
|
||||||
|
backgroundColor: 'rgba(127, 29, 29, 0.2)',
|
||||||
|
paddingVertical: theme.spacing[3],
|
||||||
|
borderRadius: theme.borderRadius.lg,
|
||||||
|
borderWidth: theme.borderWidth[1],
|
||||||
|
borderColor: theme.colors.red[900],
|
||||||
|
},
|
||||||
|
clearAllText: {
|
||||||
|
color: theme.colors.red[500],
|
||||||
|
fontWeight: theme.fontWeight.semibold,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|||||||
@@ -2,17 +2,40 @@ import { View, Text, Pressable, Animated } from 'react-native';
|
|||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import Markdown from 'react-native-markdown-display';
|
import Markdown from 'react-native-markdown-display';
|
||||||
import { Circle, Info, CheckCircle, XCircle, FileText, ChevronRight, ChevronDown, RefreshCw } from 'lucide-react-native';
|
import { Circle, Info, CheckCircle, XCircle, FileText, ChevronRight, ChevronDown, RefreshCw } from 'lucide-react-native';
|
||||||
|
import { StyleSheet } from 'react-native-unistyles';
|
||||||
|
|
||||||
interface UserMessageProps {
|
interface UserMessageProps {
|
||||||
message: string;
|
message: string;
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const userMessageStylesheet = StyleSheet.create((theme: import('../styles/theme').Theme) => ({
|
||||||
|
container: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
marginBottom: theme.spacing[3],
|
||||||
|
paddingHorizontal: theme.spacing[4],
|
||||||
|
},
|
||||||
|
bubble: {
|
||||||
|
backgroundColor: theme.colors.blue[600],
|
||||||
|
borderRadius: theme.borderRadius['2xl'],
|
||||||
|
borderTopRightRadius: theme.borderRadius.sm,
|
||||||
|
paddingHorizontal: theme.spacing[4],
|
||||||
|
paddingVertical: theme.spacing[3],
|
||||||
|
maxWidth: '80%',
|
||||||
|
},
|
||||||
|
text: {
|
||||||
|
color: theme.colors.white,
|
||||||
|
fontSize: theme.fontSize.lg,
|
||||||
|
lineHeight: 24,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
export function UserMessage({ message, timestamp }: UserMessageProps) {
|
export function UserMessage({ message, timestamp }: UserMessageProps) {
|
||||||
return (
|
return (
|
||||||
<View className="flex-row justify-end mb-3 px-4">
|
<View style={userMessageStylesheet.container}>
|
||||||
<View className="bg-blue-600 rounded-2xl rounded-tr-sm px-4 py-3 max-w-[80%]">
|
<View style={userMessageStylesheet.bubble}>
|
||||||
<Text className="text-white text-base leading-6">{message}</Text>
|
<Text style={userMessageStylesheet.text}>{message}</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
@@ -24,6 +47,22 @@ interface AssistantMessageProps {
|
|||||||
isStreaming?: boolean;
|
isStreaming?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const assistantMessageStylesheet = StyleSheet.create((theme: import('../styles/theme').Theme) => ({
|
||||||
|
container: {
|
||||||
|
marginBottom: theme.spacing[3],
|
||||||
|
paddingHorizontal: theme.spacing[4],
|
||||||
|
paddingVertical: theme.spacing[3],
|
||||||
|
},
|
||||||
|
streamingIndicator: {
|
||||||
|
marginTop: theme.spacing[1],
|
||||||
|
},
|
||||||
|
streamingText: {
|
||||||
|
color: theme.colors.teal[200],
|
||||||
|
fontSize: theme.fontSize.xs,
|
||||||
|
fontWeight: theme.fontWeight.bold,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
export function AssistantMessage({ message, timestamp, isStreaming = false }: AssistantMessageProps) {
|
export function AssistantMessage({ message, timestamp, isStreaming = false }: AssistantMessageProps) {
|
||||||
const fadeAnim = useRef(new Animated.Value(0.3)).current;
|
const fadeAnim = useRef(new Animated.Value(0.3)).current;
|
||||||
|
|
||||||
@@ -60,10 +99,10 @@ export function AssistantMessage({ message, timestamp, isStreaming = false }: As
|
|||||||
marginBottom: 8,
|
marginBottom: 8,
|
||||||
},
|
},
|
||||||
strong: {
|
strong: {
|
||||||
fontWeight: '700',
|
fontWeight: '700' as const,
|
||||||
},
|
},
|
||||||
em: {
|
em: {
|
||||||
fontStyle: 'italic',
|
fontStyle: 'italic' as const,
|
||||||
},
|
},
|
||||||
code_inline: {
|
code_inline: {
|
||||||
backgroundColor: '#134e4a',
|
backgroundColor: '#134e4a',
|
||||||
@@ -91,7 +130,7 @@ export function AssistantMessage({ message, timestamp, isStreaming = false }: As
|
|||||||
},
|
},
|
||||||
link: {
|
link: {
|
||||||
color: '#5eead4',
|
color: '#5eead4',
|
||||||
textDecorationLine: 'underline',
|
textDecorationLine: 'underline' as const,
|
||||||
},
|
},
|
||||||
bullet_list: {
|
bullet_list: {
|
||||||
marginBottom: 8,
|
marginBottom: 8,
|
||||||
@@ -105,11 +144,11 @@ export function AssistantMessage({ message, timestamp, isStreaming = false }: As
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className="mb-3 px-4 py-3">
|
<View style={assistantMessageStylesheet.container}>
|
||||||
<Markdown style={markdownStyles}>{message}</Markdown>
|
<Markdown style={markdownStyles}>{message}</Markdown>
|
||||||
{isStreaming && (
|
{isStreaming && (
|
||||||
<Animated.View style={{ opacity: fadeAnim }} className="mt-1">
|
<Animated.View style={[assistantMessageStylesheet.streamingIndicator, { opacity: fadeAnim }]}>
|
||||||
<Text className="text-teal-200 text-xs font-bold">...</Text>
|
<Text style={assistantMessageStylesheet.streamingText}>...</Text>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
@@ -127,6 +166,76 @@ interface ActivityLogProps {
|
|||||||
onArtifactClick?: (artifactId: string) => void;
|
onArtifactClick?: (artifactId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const activityLogStylesheet = StyleSheet.create((theme: import('../styles/theme').Theme) => ({
|
||||||
|
pressable: {
|
||||||
|
marginHorizontal: theme.spacing[2],
|
||||||
|
marginBottom: theme.spacing[1],
|
||||||
|
borderRadius: theme.borderRadius.md,
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
pressableActive: {
|
||||||
|
opacity: 0.7,
|
||||||
|
},
|
||||||
|
systemBg: {
|
||||||
|
backgroundColor: 'rgba(39, 39, 42, 0.5)', // zinc-800/50
|
||||||
|
},
|
||||||
|
infoBg: {
|
||||||
|
backgroundColor: 'rgba(30, 58, 138, 0.3)', // blue-900/30
|
||||||
|
},
|
||||||
|
successBg: {
|
||||||
|
backgroundColor: 'rgba(20, 83, 45, 0.3)', // green-900/30
|
||||||
|
},
|
||||||
|
errorBg: {
|
||||||
|
backgroundColor: 'rgba(127, 29, 29, 0.3)', // red-900/30
|
||||||
|
},
|
||||||
|
artifactBg: {
|
||||||
|
backgroundColor: 'rgba(30, 58, 138, 0.4)', // blue-900/40
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
paddingHorizontal: theme.spacing[3],
|
||||||
|
paddingVertical: 10,
|
||||||
|
},
|
||||||
|
row: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
gap: theme.spacing[2],
|
||||||
|
},
|
||||||
|
iconContainer: {
|
||||||
|
flexShrink: 0,
|
||||||
|
},
|
||||||
|
textContainer: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
messageText: {
|
||||||
|
fontSize: theme.fontSize.sm,
|
||||||
|
lineHeight: 20,
|
||||||
|
},
|
||||||
|
detailsRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginTop: theme.spacing[1],
|
||||||
|
},
|
||||||
|
detailsText: {
|
||||||
|
color: theme.colors.zinc[500],
|
||||||
|
fontSize: theme.fontSize.xs,
|
||||||
|
marginRight: theme.spacing[1],
|
||||||
|
},
|
||||||
|
metadataContainer: {
|
||||||
|
marginTop: theme.spacing[2],
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||||
|
borderRadius: theme.borderRadius.base,
|
||||||
|
padding: theme.spacing[2],
|
||||||
|
borderWidth: theme.borderWidth[1],
|
||||||
|
borderColor: theme.colors.zinc[700],
|
||||||
|
},
|
||||||
|
metadataText: {
|
||||||
|
color: theme.colors.zinc[300],
|
||||||
|
fontSize: theme.fontSize.xs,
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
lineHeight: 16,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
export function ActivityLog({
|
export function ActivityLog({
|
||||||
type,
|
type,
|
||||||
message,
|
message,
|
||||||
@@ -140,11 +249,11 @@ export function ActivityLog({
|
|||||||
const [isExpanded, setIsExpanded] = useState(false);
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
|
|
||||||
const typeConfig = {
|
const typeConfig = {
|
||||||
system: { bg: 'bg-zinc-800/50', color: '#a1a1aa', Icon: Circle },
|
system: { bg: activityLogStylesheet.systemBg, color: '#a1a1aa', Icon: Circle },
|
||||||
info: { bg: 'bg-blue-900/30', color: '#60a5fa', Icon: Info },
|
info: { bg: activityLogStylesheet.infoBg, color: '#60a5fa', Icon: Info },
|
||||||
success: { bg: 'bg-green-900/30', color: '#4ade80', Icon: CheckCircle },
|
success: { bg: activityLogStylesheet.successBg, color: '#4ade80', Icon: CheckCircle },
|
||||||
error: { bg: 'bg-red-900/30', color: '#f87171', Icon: XCircle },
|
error: { bg: activityLogStylesheet.errorBg, color: '#f87171', Icon: XCircle },
|
||||||
artifact: { bg: 'bg-blue-900/40', color: '#93c5fd', Icon: FileText },
|
artifact: { bg: activityLogStylesheet.artifactBg, color: '#93c5fd', Icon: FileText },
|
||||||
};
|
};
|
||||||
|
|
||||||
const config = typeConfig[type];
|
const config = typeConfig[type];
|
||||||
@@ -162,24 +271,30 @@ export function ActivityLog({
|
|||||||
? `${artifactType}: ${title}`
|
? `${artifactType}: ${title}`
|
||||||
: message;
|
: message;
|
||||||
|
|
||||||
|
const isInteractive = type === 'artifact' || metadata;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={handlePress}
|
onPress={handlePress}
|
||||||
disabled={type !== 'artifact' && !metadata}
|
disabled={!isInteractive}
|
||||||
className={`mx-2 mb-1 rounded-md overflow-hidden ${config.bg} ${
|
style={[
|
||||||
(type === 'artifact' || metadata) ? 'active:opacity-70' : ''
|
activityLogStylesheet.pressable,
|
||||||
}`}
|
config.bg,
|
||||||
|
isInteractive && activityLogStylesheet.pressableActive,
|
||||||
|
]}
|
||||||
>
|
>
|
||||||
<View className="px-3 py-2.5">
|
<View style={activityLogStylesheet.content}>
|
||||||
<View className="flex-row items-start gap-2">
|
<View style={activityLogStylesheet.row}>
|
||||||
<IconComponent size={16} color={config.color} />
|
<View style={activityLogStylesheet.iconContainer}>
|
||||||
<View className="flex-1">
|
<IconComponent size={16} color={config.color} />
|
||||||
<Text style={{ color: config.color }} className="text-sm leading-5">
|
</View>
|
||||||
|
<View style={activityLogStylesheet.textContainer}>
|
||||||
|
<Text style={[activityLogStylesheet.messageText, { color: config.color }]}>
|
||||||
{displayMessage}
|
{displayMessage}
|
||||||
</Text>
|
</Text>
|
||||||
{metadata && (
|
{metadata && (
|
||||||
<View className="flex-row items-center mt-1">
|
<View style={activityLogStylesheet.detailsRow}>
|
||||||
<Text className="text-zinc-500 text-xs mr-1">Details</Text>
|
<Text style={activityLogStylesheet.detailsText}>Details</Text>
|
||||||
{isExpanded ? (
|
{isExpanded ? (
|
||||||
<ChevronDown size={12} color="#71717a" />
|
<ChevronDown size={12} color="#71717a" />
|
||||||
) : (
|
) : (
|
||||||
@@ -190,8 +305,8 @@ export function ActivityLog({
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
{isExpanded && metadata && (
|
{isExpanded && metadata && (
|
||||||
<View className="mt-2 bg-black/50 rounded p-2 border border-zinc-700">
|
<View style={activityLogStylesheet.metadataContainer}>
|
||||||
<Text className="text-zinc-300 text-xs font-mono leading-4">
|
<Text style={activityLogStylesheet.metadataText}>
|
||||||
{JSON.stringify(metadata, null, 2)}
|
{JSON.stringify(metadata, null, 2)}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
@@ -209,6 +324,102 @@ interface ToolCallProps {
|
|||||||
status: 'executing' | 'completed' | 'failed';
|
status: 'executing' | 'completed' | 'failed';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const toolCallStylesheet = StyleSheet.create((theme: import('../styles/theme').Theme) => ({
|
||||||
|
pressable: {
|
||||||
|
marginHorizontal: theme.spacing[2],
|
||||||
|
marginBottom: theme.spacing[2],
|
||||||
|
backgroundColor: theme.colors.zinc[900],
|
||||||
|
borderRadius: theme.borderRadius.lg,
|
||||||
|
borderWidth: theme.borderWidth[1],
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
pressableActive: {
|
||||||
|
opacity: 0.8,
|
||||||
|
},
|
||||||
|
executingBorder: {
|
||||||
|
borderColor: theme.colors.amber[500],
|
||||||
|
},
|
||||||
|
completedBorder: {
|
||||||
|
borderColor: theme.colors.green[500],
|
||||||
|
},
|
||||||
|
failedBorder: {
|
||||||
|
borderColor: theme.colors.red[500],
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
padding: theme.spacing[3],
|
||||||
|
},
|
||||||
|
headerRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
chevronContainer: {
|
||||||
|
marginRight: theme.spacing[2],
|
||||||
|
},
|
||||||
|
toolName: {
|
||||||
|
color: theme.colors.slate[200],
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontWeight: theme.fontWeight.semibold,
|
||||||
|
fontSize: theme.fontSize.sm,
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
statusBadge: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 6,
|
||||||
|
paddingHorizontal: theme.spacing[2],
|
||||||
|
paddingVertical: theme.spacing[1],
|
||||||
|
borderRadius: theme.borderRadius.base,
|
||||||
|
},
|
||||||
|
executingBadgeBg: {
|
||||||
|
backgroundColor: 'rgba(245, 158, 11, 0.2)', // amber-500/20
|
||||||
|
},
|
||||||
|
completedBadgeBg: {
|
||||||
|
backgroundColor: 'rgba(34, 197, 94, 0.2)', // green-500/20
|
||||||
|
},
|
||||||
|
failedBadgeBg: {
|
||||||
|
backgroundColor: 'rgba(239, 68, 68, 0.2)', // red-500/20
|
||||||
|
},
|
||||||
|
statusText: {
|
||||||
|
fontSize: theme.fontSize.xs,
|
||||||
|
fontWeight: theme.fontWeight.semibold,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
},
|
||||||
|
expandedContent: {
|
||||||
|
marginTop: theme.spacing[3],
|
||||||
|
gap: theme.spacing[2],
|
||||||
|
},
|
||||||
|
section: {
|
||||||
|
// empty - just for grouping
|
||||||
|
},
|
||||||
|
sectionTitle: {
|
||||||
|
color: theme.colors.zinc[400],
|
||||||
|
fontSize: theme.fontSize.xs,
|
||||||
|
fontWeight: theme.fontWeight.semibold,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
marginBottom: 6,
|
||||||
|
},
|
||||||
|
errorSectionTitle: {
|
||||||
|
color: '#fca5a5', // red-300
|
||||||
|
},
|
||||||
|
sectionContent: {
|
||||||
|
backgroundColor: theme.colors.black,
|
||||||
|
borderRadius: theme.borderRadius.base,
|
||||||
|
borderWidth: theme.borderWidth[1],
|
||||||
|
borderColor: theme.colors.zinc[700],
|
||||||
|
padding: theme.spacing[2],
|
||||||
|
},
|
||||||
|
errorSectionContent: {
|
||||||
|
borderColor: '#991b1b', // red-800
|
||||||
|
},
|
||||||
|
sectionText: {
|
||||||
|
color: theme.colors.slate[200],
|
||||||
|
fontSize: theme.fontSize.xs,
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
lineHeight: 16,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
export function ToolCall({ toolName, args, result, error, status }: ToolCallProps) {
|
export function ToolCall({ toolName, args, result, error, status }: ToolCallProps) {
|
||||||
const [isExpanded, setIsExpanded] = useState(false);
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
const spinAnim = useRef(new Animated.Value(0)).current;
|
const spinAnim = useRef(new Animated.Value(0)).current;
|
||||||
@@ -235,20 +446,20 @@ export function ToolCall({ toolName, args, result, error, status }: ToolCallProp
|
|||||||
|
|
||||||
const statusConfig = {
|
const statusConfig = {
|
||||||
executing: {
|
executing: {
|
||||||
border: 'border-amber-500',
|
border: toolCallStylesheet.executingBorder,
|
||||||
bg: 'bg-amber-500/20',
|
badgeBg: toolCallStylesheet.executingBadgeBg,
|
||||||
color: '#fcd34d',
|
color: '#fcd34d',
|
||||||
label: 'executing',
|
label: 'executing',
|
||||||
},
|
},
|
||||||
completed: {
|
completed: {
|
||||||
border: 'border-green-500',
|
border: toolCallStylesheet.completedBorder,
|
||||||
bg: 'bg-green-500/20',
|
badgeBg: toolCallStylesheet.completedBadgeBg,
|
||||||
color: '#86efac',
|
color: '#86efac',
|
||||||
label: 'completed',
|
label: 'completed',
|
||||||
},
|
},
|
||||||
failed: {
|
failed: {
|
||||||
border: 'border-red-500',
|
border: toolCallStylesheet.failedBorder,
|
||||||
bg: 'bg-red-500/20',
|
badgeBg: toolCallStylesheet.failedBadgeBg,
|
||||||
color: '#fca5a5',
|
color: '#fca5a5',
|
||||||
label: 'failed',
|
label: 'failed',
|
||||||
},
|
},
|
||||||
@@ -259,19 +470,21 @@ export function ToolCall({ toolName, args, result, error, status }: ToolCallProp
|
|||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={() => setIsExpanded(!isExpanded)}
|
onPress={() => setIsExpanded(!isExpanded)}
|
||||||
className={`mx-2 mb-2 bg-zinc-900 rounded-lg border ${config.border} overflow-hidden active:opacity-80`}
|
style={[toolCallStylesheet.pressable, toolCallStylesheet.pressableActive, config.border]}
|
||||||
>
|
>
|
||||||
<View className="p-3">
|
<View style={toolCallStylesheet.content}>
|
||||||
<View className="flex-row items-center">
|
<View style={toolCallStylesheet.headerRow}>
|
||||||
{isExpanded ? (
|
<View style={toolCallStylesheet.chevronContainer}>
|
||||||
<ChevronDown size={16} color="#9ca3af" className="mr-2" />
|
{isExpanded ? (
|
||||||
) : (
|
<ChevronDown size={16} color="#9ca3af" />
|
||||||
<ChevronRight size={16} color="#9ca3af" className="mr-2" />
|
) : (
|
||||||
)}
|
<ChevronRight size={16} color="#9ca3af" />
|
||||||
<Text className="text-slate-200 font-mono font-medium text-sm flex-1">
|
)}
|
||||||
|
</View>
|
||||||
|
<Text style={toolCallStylesheet.toolName}>
|
||||||
{toolName}
|
{toolName}
|
||||||
</Text>
|
</Text>
|
||||||
<View className={`flex-row items-center gap-1.5 px-2 py-1 rounded ${config.bg}`}>
|
<View style={[toolCallStylesheet.statusBadge, config.badgeBg]}>
|
||||||
{status === 'executing' ? (
|
{status === 'executing' ? (
|
||||||
<Animated.View style={{ transform: [{ rotate: spin }] }}>
|
<Animated.View style={{ transform: [{ rotate: spin }] }}>
|
||||||
<RefreshCw size={14} color={config.color} />
|
<RefreshCw size={14} color={config.color} />
|
||||||
@@ -281,32 +494,32 @@ export function ToolCall({ toolName, args, result, error, status }: ToolCallProp
|
|||||||
) : (
|
) : (
|
||||||
<XCircle size={14} color={config.color} />
|
<XCircle size={14} color={config.color} />
|
||||||
)}
|
)}
|
||||||
<Text style={{ color: config.color }} className="text-xs font-medium uppercase">
|
<Text style={[toolCallStylesheet.statusText, { color: config.color }]}>
|
||||||
{config.label}
|
{config.label}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{isExpanded && (
|
{isExpanded && (
|
||||||
<View className="mt-3 gap-2">
|
<View style={toolCallStylesheet.expandedContent}>
|
||||||
<View>
|
<View style={toolCallStylesheet.section}>
|
||||||
<Text className="text-zinc-400 text-xs font-semibold uppercase tracking-wide mb-1.5">
|
<Text style={toolCallStylesheet.sectionTitle}>
|
||||||
Arguments
|
Arguments
|
||||||
</Text>
|
</Text>
|
||||||
<View className="bg-black rounded border border-zinc-700 p-2">
|
<View style={toolCallStylesheet.sectionContent}>
|
||||||
<Text className="text-slate-200 text-xs font-mono leading-4">
|
<Text style={toolCallStylesheet.sectionText}>
|
||||||
{JSON.stringify(args, null, 2)}
|
{JSON.stringify(args, null, 2)}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{result !== undefined && (
|
{result !== undefined && (
|
||||||
<View>
|
<View style={toolCallStylesheet.section}>
|
||||||
<Text className="text-zinc-400 text-xs font-semibold uppercase tracking-wide mb-1.5">
|
<Text style={toolCallStylesheet.sectionTitle}>
|
||||||
Result
|
Result
|
||||||
</Text>
|
</Text>
|
||||||
<View className="bg-black rounded border border-zinc-700 p-2">
|
<View style={toolCallStylesheet.sectionContent}>
|
||||||
<Text className="text-slate-200 text-xs font-mono leading-4">
|
<Text style={toolCallStylesheet.sectionText}>
|
||||||
{JSON.stringify(result, null, 2)}
|
{JSON.stringify(result, null, 2)}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
@@ -314,12 +527,12 @@ export function ToolCall({ toolName, args, result, error, status }: ToolCallProp
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{error !== undefined && (
|
{error !== undefined && (
|
||||||
<View>
|
<View style={toolCallStylesheet.section}>
|
||||||
<Text className="text-red-400 text-xs font-semibold uppercase tracking-wide mb-1.5">
|
<Text style={[toolCallStylesheet.sectionTitle, toolCallStylesheet.errorSectionTitle]}>
|
||||||
Error
|
Error
|
||||||
</Text>
|
</Text>
|
||||||
<View className="bg-black rounded border border-red-800 p-2">
|
<View style={[toolCallStylesheet.sectionContent, toolCallStylesheet.errorSectionContent]}>
|
||||||
<Text className="text-slate-200 text-xs font-mono leading-4">
|
<Text style={toolCallStylesheet.sectionText}>
|
||||||
{JSON.stringify(error, null, 2)}
|
{JSON.stringify(error, null, 2)}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Pressable, View, Text, Animated } from 'react-native';
|
import { Pressable, View, Text, Animated } from 'react-native';
|
||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
|
import { StyleSheet } from 'react-native-unistyles';
|
||||||
|
|
||||||
interface VoiceButtonProps {
|
interface VoiceButtonProps {
|
||||||
state: 'idle' | 'recording' | 'processing' | 'playing';
|
state: 'idle' | 'recording' | 'processing' | 'playing';
|
||||||
@@ -7,6 +8,125 @@ interface VoiceButtonProps {
|
|||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create((theme: import('../styles/theme').Theme) => ({
|
||||||
|
container: {
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: theme.spacing[4],
|
||||||
|
},
|
||||||
|
pressable: {
|
||||||
|
opacity: 1,
|
||||||
|
},
|
||||||
|
pressableDisabled: {
|
||||||
|
opacity: 0.5,
|
||||||
|
},
|
||||||
|
button: {
|
||||||
|
width: 80,
|
||||||
|
height: 80,
|
||||||
|
borderRadius: theme.borderRadius.full,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 4 },
|
||||||
|
shadowOpacity: 0.3,
|
||||||
|
shadowRadius: 8,
|
||||||
|
elevation: 8,
|
||||||
|
},
|
||||||
|
buttonIdle: {
|
||||||
|
backgroundColor: theme.colors.zinc[700],
|
||||||
|
},
|
||||||
|
buttonRecording: {
|
||||||
|
backgroundColor: theme.colors.red[500],
|
||||||
|
},
|
||||||
|
buttonProcessing: {
|
||||||
|
backgroundColor: theme.colors.blue[500],
|
||||||
|
},
|
||||||
|
buttonPlaying: {
|
||||||
|
backgroundColor: theme.colors.green[500],
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
color: theme.colors.zinc[400],
|
||||||
|
fontSize: theme.fontSize.sm,
|
||||||
|
},
|
||||||
|
// Recording icon
|
||||||
|
recordingIcon: {
|
||||||
|
width: 24,
|
||||||
|
height: 24,
|
||||||
|
backgroundColor: '#fff',
|
||||||
|
borderRadius: theme.borderRadius.md,
|
||||||
|
},
|
||||||
|
// Processing icon
|
||||||
|
processingIconContainer: {
|
||||||
|
width: 24,
|
||||||
|
height: 24,
|
||||||
|
},
|
||||||
|
processingDot: {
|
||||||
|
width: 6,
|
||||||
|
height: 6,
|
||||||
|
backgroundColor: '#fff',
|
||||||
|
borderRadius: theme.borderRadius.full,
|
||||||
|
position: 'absolute',
|
||||||
|
},
|
||||||
|
processingDotTop: {
|
||||||
|
top: 0,
|
||||||
|
left: 12,
|
||||||
|
},
|
||||||
|
processingDotRight: {
|
||||||
|
top: 12,
|
||||||
|
right: 0,
|
||||||
|
},
|
||||||
|
processingDotBottom: {
|
||||||
|
bottom: 0,
|
||||||
|
left: 12,
|
||||||
|
},
|
||||||
|
// Playing icon
|
||||||
|
playingIconContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: theme.spacing[1],
|
||||||
|
},
|
||||||
|
playingBar: {
|
||||||
|
backgroundColor: '#fff',
|
||||||
|
borderRadius: theme.borderRadius.sm,
|
||||||
|
},
|
||||||
|
playingBar1: {
|
||||||
|
width: 4,
|
||||||
|
height: 16,
|
||||||
|
},
|
||||||
|
playingBar2: {
|
||||||
|
width: 4,
|
||||||
|
height: 24,
|
||||||
|
},
|
||||||
|
playingBar3: {
|
||||||
|
width: 4,
|
||||||
|
height: 12,
|
||||||
|
},
|
||||||
|
// Idle icon (microphone)
|
||||||
|
micContainer: {
|
||||||
|
width: 24,
|
||||||
|
height: 32,
|
||||||
|
position: 'relative',
|
||||||
|
},
|
||||||
|
micCapsule: {
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: 0,
|
||||||
|
left: 4,
|
||||||
|
width: 16,
|
||||||
|
height: 24,
|
||||||
|
backgroundColor: '#fff',
|
||||||
|
borderTopLeftRadius: 999,
|
||||||
|
borderTopRightRadius: 999,
|
||||||
|
},
|
||||||
|
micBase: {
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
width: 24,
|
||||||
|
height: 6,
|
||||||
|
backgroundColor: '#fff',
|
||||||
|
borderRadius: theme.borderRadius.full,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
export function VoiceButton({ state, onPress, disabled = false }: VoiceButtonProps) {
|
export function VoiceButton({ state, onPress, disabled = false }: VoiceButtonProps) {
|
||||||
const pulseAnim = useRef(new Animated.Value(1)).current;
|
const pulseAnim = useRef(new Animated.Value(1)).current;
|
||||||
|
|
||||||
@@ -34,43 +154,41 @@ export function VoiceButton({ state, onPress, disabled = false }: VoiceButtonPro
|
|||||||
const getButtonStyle = () => {
|
const getButtonStyle = () => {
|
||||||
switch (state) {
|
switch (state) {
|
||||||
case 'recording':
|
case 'recording':
|
||||||
return 'bg-red-500';
|
return styles.buttonRecording;
|
||||||
case 'processing':
|
case 'processing':
|
||||||
return 'bg-blue-500';
|
return styles.buttonProcessing;
|
||||||
case 'playing':
|
case 'playing':
|
||||||
return 'bg-green-500';
|
return styles.buttonPlaying;
|
||||||
default:
|
default:
|
||||||
return 'bg-zinc-700';
|
return styles.buttonIdle;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const getIcon = () => {
|
const getIcon = () => {
|
||||||
switch (state) {
|
switch (state) {
|
||||||
case 'recording':
|
case 'recording':
|
||||||
return (
|
return <View style={styles.recordingIcon} />;
|
||||||
<View className="w-6 h-6 bg-white rounded" />
|
|
||||||
);
|
|
||||||
case 'processing':
|
case 'processing':
|
||||||
return (
|
return (
|
||||||
<View className="w-6 h-6">
|
<View style={styles.processingIconContainer}>
|
||||||
<View className="w-1.5 h-1.5 bg-white rounded-full absolute top-0 left-3" />
|
<View style={[styles.processingDot, styles.processingDotTop]} />
|
||||||
<View className="w-1.5 h-1.5 bg-white rounded-full absolute top-3 right-0" />
|
<View style={[styles.processingDot, styles.processingDotRight]} />
|
||||||
<View className="w-1.5 h-1.5 bg-white rounded-full absolute bottom-0 left-3" />
|
<View style={[styles.processingDot, styles.processingDotBottom]} />
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
case 'playing':
|
case 'playing':
|
||||||
return (
|
return (
|
||||||
<View className="flex-row items-center gap-1">
|
<View style={styles.playingIconContainer}>
|
||||||
<View className="w-1 h-4 bg-white rounded" />
|
<View style={[styles.playingBar, styles.playingBar1]} />
|
||||||
<View className="w-1 h-6 bg-white rounded" />
|
<View style={[styles.playingBar, styles.playingBar2]} />
|
||||||
<View className="w-1 h-3 bg-white rounded" />
|
<View style={[styles.playingBar, styles.playingBar3]} />
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
default:
|
default:
|
||||||
return (
|
return (
|
||||||
<View className="w-6 h-8 relative">
|
<View style={styles.micContainer}>
|
||||||
<View className="absolute bottom-0 left-1/2 -translate-x-1/2 w-4 h-6 bg-white rounded-t-full" />
|
<View style={styles.micCapsule} />
|
||||||
<View className="absolute bottom-0 left-1/2 -translate-x-1/2 w-6 h-1.5 bg-white rounded-full" />
|
<View style={styles.micBase} />
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -90,20 +208,17 @@ export function VoiceButton({ state, onPress, disabled = false }: VoiceButtonPro
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className="items-center gap-4">
|
<View style={styles.container}>
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className={`${disabled ? 'opacity-50' : 'opacity-100'}`}
|
style={disabled ? styles.pressableDisabled : styles.pressable}
|
||||||
>
|
>
|
||||||
<Animated.View
|
<Animated.View style={[styles.button, getButtonStyle(), { transform: [{ scale: pulseAnim }] }]}>
|
||||||
style={{ transform: [{ scale: pulseAnim }] }}
|
|
||||||
className={`w-20 h-20 rounded-full ${getButtonStyle()} items-center justify-center shadow-lg`}
|
|
||||||
>
|
|
||||||
{getIcon()}
|
{getIcon()}
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
<Text className="text-zinc-400 text-sm">{getLabel()}</Text>
|
<Text style={styles.label}>{getLabel()}</Text>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
@import "tailwindcss/theme.css" layer(theme);
|
|
||||||
@import "tailwindcss/preflight.css" layer(base);
|
|
||||||
@import "tailwindcss/utilities.css";
|
|
||||||
@import "nativewind/theme";
|
|
||||||
@@ -1,7 +1,5 @@
|
|||||||
import { useAudioPlayer as useExpoAudioPlayer, setAudioModeAsync } from 'expo-audio';
|
|
||||||
import { Paths, File } from 'expo-file-system';
|
|
||||||
import { useState, useRef, useEffect } from 'react';
|
import { useState, useRef, useEffect } from 'react';
|
||||||
import { Platform } from 'react-native';
|
import { initialize, playPCMData } from '@speechmatics/expo-two-way-audio';
|
||||||
|
|
||||||
interface QueuedAudio {
|
interface QueuedAudio {
|
||||||
audioData: Blob;
|
audioData: Blob;
|
||||||
@@ -9,75 +7,57 @@ interface QueuedAudio {
|
|||||||
reject: (error: Error) => void;
|
reject: (error: Error) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AudioPlayerOptions {
|
/**
|
||||||
useSpeaker?: boolean;
|
* Resample PCM16 audio from 24kHz to 16kHz
|
||||||
|
* OpenAI returns 24kHz, Speechmatics expects 16kHz
|
||||||
|
*/
|
||||||
|
function resamplePcm24kTo16k(pcm24k: Uint8Array): Uint8Array {
|
||||||
|
// PCM16 = 2 bytes per sample
|
||||||
|
const samples24k = pcm24k.length / 2;
|
||||||
|
const samples16k = Math.floor(samples24k * 16000 / 24000);
|
||||||
|
|
||||||
|
const pcm16k = new Uint8Array(samples16k * 2);
|
||||||
|
const ratio = 24000 / 16000; // 1.5
|
||||||
|
|
||||||
|
for (let i = 0; i < samples16k; i++) {
|
||||||
|
const srcIndex = Math.floor(i * ratio) * 2;
|
||||||
|
if (srcIndex + 1 < pcm24k.length) {
|
||||||
|
pcm16k[i * 2] = pcm24k[srcIndex];
|
||||||
|
pcm16k[i * 2 + 1] = pcm24k[srcIndex + 1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[AudioPlayer] Resampled PCM:', {
|
||||||
|
input24k: pcm24k.length,
|
||||||
|
output16k: pcm16k.length,
|
||||||
|
durationMs: (samples16k / 16),
|
||||||
|
});
|
||||||
|
|
||||||
|
return pcm16k;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert Blob to file URI for expo-audio playback
|
* Hook for audio playback using Speechmatics two-way audio with echo cancellation
|
||||||
*/
|
*/
|
||||||
async function blobToFile(blob: Blob): Promise<File> {
|
export function useAudioPlayer() {
|
||||||
// Read blob as array buffer
|
|
||||||
const arrayBuffer = await blob.arrayBuffer();
|
|
||||||
|
|
||||||
// Create temporary file in cache directory
|
|
||||||
const fileName = `audio_${Date.now()}.mp3`;
|
|
||||||
const file = new File(Paths.cache, fileName);
|
|
||||||
|
|
||||||
// Write array buffer to file
|
|
||||||
const uint8Array = new Uint8Array(arrayBuffer);
|
|
||||||
file.create();
|
|
||||||
const stream = file.writableStream();
|
|
||||||
const writer = stream.getWriter();
|
|
||||||
await writer.write(uint8Array);
|
|
||||||
await writer.close();
|
|
||||||
|
|
||||||
return file;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook for audio playback with queue system matching web version functionality
|
|
||||||
*/
|
|
||||||
export function useAudioPlayer(options?: AudioPlayerOptions) {
|
|
||||||
const player = useExpoAudioPlayer();
|
|
||||||
const [isPlaying, setIsPlaying] = useState(false);
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
const [isPaused, setIsPaused] = useState(false);
|
const [audioInitialized, setAudioInitialized] = useState(false);
|
||||||
const queueRef = useRef<QueuedAudio[]>([]);
|
const queueRef = useRef<QueuedAudio[]>([]);
|
||||||
const isProcessingQueueRef = useRef(false);
|
const isProcessingQueueRef = useRef(false);
|
||||||
const currentRejectRef = useRef<((error: Error) => void) | null>(null);
|
|
||||||
const currentFileRef = useRef<File | null>(null);
|
|
||||||
|
|
||||||
// Configure audio mode based on speaker/earpiece preference
|
// Initialize Speechmatics audio on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function configureAudioMode() {
|
const initializeAudio = async () => {
|
||||||
try {
|
try {
|
||||||
if (Platform.OS === 'android') {
|
await initialize();
|
||||||
// On Android, use shouldRouteThroughEarpiece
|
setAudioInitialized(true);
|
||||||
await setAudioModeAsync({
|
console.log('[AudioPlayer] ✅ Initialized (Speechmatics two-way audio)');
|
||||||
shouldRouteThroughEarpiece: !options?.useSpeaker,
|
|
||||||
});
|
|
||||||
} else if (Platform.OS === 'ios') {
|
|
||||||
// On iOS, the audio routing is controlled by the AVAudioSession category
|
|
||||||
// The default category should work, but we can configure it if needed
|
|
||||||
// For now, we rely on the iOS default behavior which routes to the receiver
|
|
||||||
// unless the user has changed the route (e.g., via speaker button in Control Center)
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('[AudioPlayer] Failed to configure audio mode:', error);
|
console.error('[AudioPlayer] Failed to initialize audio:', error);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
initializeAudio();
|
||||||
configureAudioMode();
|
}, []);
|
||||||
}, [options?.useSpeaker]);
|
|
||||||
|
|
||||||
// Monitor playback status
|
|
||||||
useEffect(() => {
|
|
||||||
if (!player.playing && isPlaying && !isPaused) {
|
|
||||||
// Playback finished
|
|
||||||
setIsPlaying(false);
|
|
||||||
processNextInQueue();
|
|
||||||
}
|
|
||||||
}, [player.playing, isPlaying, isPaused]);
|
|
||||||
|
|
||||||
async function play(audioData: Blob): Promise<number> {
|
async function play(audioData: Blob): Promise<number> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
@@ -127,92 +107,55 @@ export function useAudioPlayer(options?: AudioPlayerOptions) {
|
|||||||
|
|
||||||
async function playAudio(audioData: Blob): Promise<number> {
|
async function playAudio(audioData: Blob): Promise<number> {
|
||||||
return new Promise(async (resolve, reject) => {
|
return new Promise(async (resolve, reject) => {
|
||||||
currentRejectRef.current = reject;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log(
|
console.log(
|
||||||
`[AudioPlayer] Playing audio (${audioData.size} bytes, type: ${audioData.type})`
|
`[AudioPlayer] Playing audio (${audioData.size} bytes, type: ${audioData.type})`
|
||||||
);
|
);
|
||||||
|
|
||||||
// Convert blob to file
|
if (!audioInitialized) {
|
||||||
const file = await blobToFile(audioData);
|
throw new Error('Audio not initialized');
|
||||||
currentFileRef.current = file;
|
}
|
||||||
|
|
||||||
|
// Get PCM data from blob (server now sends PCM format)
|
||||||
|
const arrayBuffer = await audioData.arrayBuffer();
|
||||||
|
let pcm24k = new Uint8Array(arrayBuffer);
|
||||||
|
|
||||||
|
// Resample from 24kHz (OpenAI) to 16kHz (Speechmatics)
|
||||||
|
const pcm16k = resamplePcm24kTo16k(pcm24k);
|
||||||
|
|
||||||
|
// Calculate total duration
|
||||||
|
const samples = pcm16k.length / 2; // 16-bit = 2 bytes per sample
|
||||||
|
const durationSec = samples / 16000; // 16kHz sample rate
|
||||||
|
|
||||||
|
const audioSizeKb = (pcm16k.length / 1024).toFixed(2);
|
||||||
|
console.log('[AudioPlayer] 🔊 Playing audio:', audioSizeKb, 'KB, duration:', durationSec.toFixed(2), 's');
|
||||||
|
|
||||||
setIsPlaying(true);
|
setIsPlaying(true);
|
||||||
setIsPaused(false);
|
|
||||||
|
|
||||||
// Load and play audio
|
// Play entire PCM data at once through Speechmatics
|
||||||
await player.replace({ uri: file.uri });
|
playPCMData(pcm16k);
|
||||||
player.play();
|
|
||||||
|
|
||||||
// Wait for playback to finish
|
// Wait for playback to finish (estimate based on duration)
|
||||||
const checkInterval = setInterval(() => {
|
setTimeout(() => {
|
||||||
if (!player.playing && player.duration > 0) {
|
console.log('[AudioPlayer] ✅ Playback finished');
|
||||||
clearInterval(checkInterval);
|
setIsPlaying(false);
|
||||||
|
resolve(durationSec);
|
||||||
const duration = player.duration;
|
}, durationSec * 1000);
|
||||||
console.log(`[AudioPlayer] Playback finished (duration: ${duration}s)`);
|
|
||||||
|
|
||||||
setIsPlaying(false);
|
|
||||||
currentRejectRef.current = null;
|
|
||||||
|
|
||||||
// Clean up temporary file
|
|
||||||
if (currentFileRef.current) {
|
|
||||||
try {
|
|
||||||
currentFileRef.current.delete();
|
|
||||||
} catch (err: any) {
|
|
||||||
console.warn('[AudioPlayer] Failed to delete temp file:', err);
|
|
||||||
}
|
|
||||||
currentFileRef.current = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
resolve(duration);
|
|
||||||
}
|
|
||||||
}, 100);
|
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[AudioPlayer] Error playing audio:', error);
|
console.error('[AudioPlayer] Error playing audio:', error);
|
||||||
setIsPlaying(false);
|
setIsPlaying(false);
|
||||||
currentRejectRef.current = null;
|
|
||||||
|
|
||||||
// Clean up temporary file
|
|
||||||
if (currentFileRef.current) {
|
|
||||||
try {
|
|
||||||
currentFileRef.current.delete();
|
|
||||||
} catch (err: any) {
|
|
||||||
console.warn('[AudioPlayer] Failed to delete temp file:', err);
|
|
||||||
}
|
|
||||||
currentFileRef.current = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
reject(error);
|
reject(error);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function stop(): void {
|
function stop(): void {
|
||||||
// Stop currently playing audio
|
if (isPlaying) {
|
||||||
if (player) {
|
console.log('[AudioPlayer] 🛑 Stopping playback (interrupted)');
|
||||||
player.pause();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsPlaying(false);
|
setIsPlaying(false);
|
||||||
setIsPaused(false);
|
|
||||||
|
|
||||||
// Clean up temporary file
|
|
||||||
if (currentFileRef.current) {
|
|
||||||
try {
|
|
||||||
currentFileRef.current.delete();
|
|
||||||
} catch (err: any) {
|
|
||||||
console.warn('[AudioPlayer] Failed to delete temp file:', err);
|
|
||||||
}
|
|
||||||
currentFileRef.current = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reject the current playing promise if it exists
|
|
||||||
if (currentRejectRef.current) {
|
|
||||||
currentRejectRef.current(new Error('Playback stopped'));
|
|
||||||
currentRejectRef.current = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reject all pending promises in the queue
|
// Reject all pending promises in the queue
|
||||||
while (queueRef.current.length > 0) {
|
while (queueRef.current.length > 0) {
|
||||||
@@ -223,22 +166,6 @@ export function useAudioPlayer(options?: AudioPlayerOptions) {
|
|||||||
isProcessingQueueRef.current = false;
|
isProcessingQueueRef.current = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function pause(): void {
|
|
||||||
if (player && isPlaying && !isPaused) {
|
|
||||||
player.pause();
|
|
||||||
setIsPaused(true);
|
|
||||||
console.log('[AudioPlayer] Playback paused');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function resume(): void {
|
|
||||||
if (player && isPaused) {
|
|
||||||
player.play();
|
|
||||||
setIsPaused(false);
|
|
||||||
console.log('[AudioPlayer] Playback resumed');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearQueue(): void {
|
function clearQueue(): void {
|
||||||
// Reject all pending promises in the queue
|
// Reject all pending promises in the queue
|
||||||
while (queueRef.current.length > 0) {
|
while (queueRef.current.length > 0) {
|
||||||
@@ -250,10 +177,7 @@ export function useAudioPlayer(options?: AudioPlayerOptions) {
|
|||||||
return {
|
return {
|
||||||
play,
|
play,
|
||||||
stop,
|
stop,
|
||||||
pause,
|
|
||||||
resume,
|
|
||||||
isPlaying: () => isPlaying,
|
isPlaying: () => isPlaying,
|
||||||
isPaused: () => isPaused,
|
|
||||||
clearQueue,
|
clearQueue,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useAudioRecorder as useExpoAudioRecorder, RecordingOptions, RecordingPresets, requestRecordingPermissionsAsync, setAudioModeAsync } from 'expo-audio';
|
import { useAudioRecorder as useExpoAudioRecorder, useAudioRecorderState, RecordingOptions, RecordingPresets, requestRecordingPermissionsAsync, setAudioModeAsync } from 'expo-audio';
|
||||||
import { Paths, File, Directory, FileInfo } from 'expo-file-system';
|
import { Paths, File, Directory, FileInfo } from 'expo-file-system';
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
|
|
||||||
@@ -159,24 +159,25 @@ export function useAudioRecorder(config?: AudioCaptureConfig) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const audioRecorder = useExpoAudioRecorder(recordingOptions);
|
const audioRecorder = useExpoAudioRecorder(recordingOptions);
|
||||||
|
const recorderState = useAudioRecorderState(audioRecorder, 100);
|
||||||
|
|
||||||
// Monitor audio levels if metering is enabled
|
// Monitor audio levels if metering is enabled
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!config?.onAudioLevel || !isRecording) return;
|
if (!config?.onAudioLevel || !isRecording) return;
|
||||||
|
|
||||||
const interval = setInterval(() => {
|
const interval = setInterval(() => {
|
||||||
const metering = audioRecorder.state?.metering;
|
const metering = recorderState.metering;
|
||||||
if (metering !== undefined && metering !== null) {
|
if (metering !== undefined && metering !== null) {
|
||||||
// Normalize metering value (typically ranges from -160 to 0 dB)
|
// Normalize metering value (typically ranges from -160 to 0 dB)
|
||||||
// Convert to 0-1 range where 0 is silence and 1 is loud
|
// Convert to 0-1 range where 0 is silence and 1 is loud
|
||||||
// We'll use -40 dB as the threshold for "loud"
|
// We'll use -40 dB as the threshold for "loud"
|
||||||
const normalized = Math.max(0, Math.min(1, (metering + 40) / 40));
|
const normalized = Math.max(0, Math.min(1, (metering + 40) / 40));
|
||||||
config.onAudioLevel(normalized);
|
config.onAudioLevel?.(normalized);
|
||||||
}
|
}
|
||||||
}, 100); // Check every 100ms
|
}, 100); // Check every 100ms
|
||||||
|
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [isRecording, config, audioRecorder.state?.metering]);
|
}, [isRecording, config, recorderState.metering]);
|
||||||
|
|
||||||
async function start(): Promise<void> {
|
async function start(): Promise<void> {
|
||||||
if (isRecording) {
|
if (isRecording) {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export interface RealtimeAudioConfig {
|
|||||||
onError?: (error: Error) => void;
|
onError?: (error: Error) => void;
|
||||||
volumeThreshold?: number; // RMS threshold for speech detection (0-1)
|
volumeThreshold?: number; // RMS threshold for speech detection (0-1)
|
||||||
silenceDuration?: number; // ms of silence before ending segment
|
silenceDuration?: number; // ms of silence before ending segment
|
||||||
|
speechStartDuration?: number; // ms of sustained volume before starting segment
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RealtimeAudio {
|
export interface RealtimeAudio {
|
||||||
@@ -19,6 +20,115 @@ export interface RealtimeAudio {
|
|||||||
isSpeaking: boolean;
|
isSpeaking: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert raw PCM data to WAV format by adding WAV headers
|
||||||
|
* @param pcmBase64 Base64-encoded PCM data
|
||||||
|
* @param sampleRate Sample rate (default: 16000)
|
||||||
|
* @param channels Number of channels (default: 1 for mono)
|
||||||
|
* @param bitsPerSample Bits per sample (default: 16)
|
||||||
|
* @returns Base64-encoded WAV data
|
||||||
|
*/
|
||||||
|
function pcmToWav(
|
||||||
|
pcmBase64: string,
|
||||||
|
sampleRate: number = 16000,
|
||||||
|
channels: number = 1,
|
||||||
|
bitsPerSample: number = 16
|
||||||
|
): string {
|
||||||
|
// Decode base64 PCM data
|
||||||
|
const pcmBinary = atob(pcmBase64);
|
||||||
|
const pcmLength = pcmBinary.length;
|
||||||
|
|
||||||
|
// Calculate WAV parameters
|
||||||
|
const byteRate = sampleRate * channels * (bitsPerSample / 8);
|
||||||
|
const blockAlign = channels * (bitsPerSample / 8);
|
||||||
|
const dataSize = pcmLength;
|
||||||
|
const fileSize = 44 + dataSize; // WAV header is 44 bytes
|
||||||
|
|
||||||
|
// Create WAV buffer
|
||||||
|
const wavBuffer = new Uint8Array(fileSize);
|
||||||
|
const view = new DataView(wavBuffer.buffer);
|
||||||
|
|
||||||
|
// Write WAV header
|
||||||
|
let offset = 0;
|
||||||
|
|
||||||
|
// RIFF chunk descriptor
|
||||||
|
writeString(view, offset, 'RIFF'); offset += 4;
|
||||||
|
view.setUint32(offset, fileSize - 8, true); offset += 4; // File size - 8
|
||||||
|
writeString(view, offset, 'WAVE'); offset += 4;
|
||||||
|
|
||||||
|
// fmt sub-chunk
|
||||||
|
writeString(view, offset, 'fmt '); offset += 4;
|
||||||
|
view.setUint32(offset, 16, true); offset += 4; // Subchunk size (16 for PCM)
|
||||||
|
view.setUint16(offset, 1, true); offset += 2; // Audio format (1 = PCM)
|
||||||
|
view.setUint16(offset, channels, true); offset += 2;
|
||||||
|
view.setUint32(offset, sampleRate, true); offset += 4;
|
||||||
|
view.setUint32(offset, byteRate, true); offset += 4;
|
||||||
|
view.setUint16(offset, blockAlign, true); offset += 2;
|
||||||
|
view.setUint16(offset, bitsPerSample, true); offset += 2;
|
||||||
|
|
||||||
|
// data sub-chunk
|
||||||
|
writeString(view, offset, 'data'); offset += 4;
|
||||||
|
view.setUint32(offset, dataSize, true); offset += 4;
|
||||||
|
|
||||||
|
// Copy PCM data
|
||||||
|
for (let i = 0; i < pcmLength; i++) {
|
||||||
|
wavBuffer[offset + i] = pcmBinary.charCodeAt(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to base64
|
||||||
|
let binary = '';
|
||||||
|
for (let i = 0; i < wavBuffer.length; i++) {
|
||||||
|
binary += String.fromCharCode(wavBuffer[i]);
|
||||||
|
}
|
||||||
|
return btoa(binary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to write string to DataView
|
||||||
|
*/
|
||||||
|
function writeString(view: DataView, offset: number, str: string): void {
|
||||||
|
for (let i = 0; i < str.length; i++) {
|
||||||
|
view.setUint8(offset + i, str.charCodeAt(i));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode base64 string to Uint8Array
|
||||||
|
*/
|
||||||
|
function decodeBase64ToUint8Array(base64: string): Uint8Array {
|
||||||
|
const binary = atob(base64);
|
||||||
|
const bytes = new Uint8Array(binary.length);
|
||||||
|
for (let i = 0; i < binary.length; i++) {
|
||||||
|
bytes[i] = binary.charCodeAt(i);
|
||||||
|
}
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encode Uint8Array to base64 string
|
||||||
|
*/
|
||||||
|
function uint8ArrayToBase64(bytes: Uint8Array): string {
|
||||||
|
let binary = '';
|
||||||
|
for (let i = 0; i < bytes.length; i++) {
|
||||||
|
binary += String.fromCharCode(bytes[i]);
|
||||||
|
}
|
||||||
|
return btoa(binary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Concatenate multiple Uint8Arrays into one
|
||||||
|
*/
|
||||||
|
function concatenateUint8Arrays(arrays: Uint8Array[]): Uint8Array {
|
||||||
|
const totalLength = arrays.reduce((sum, arr) => sum + arr.length, 0);
|
||||||
|
const result = new Uint8Array(totalLength);
|
||||||
|
let offset = 0;
|
||||||
|
for (const arr of arrays) {
|
||||||
|
result.set(arr, offset);
|
||||||
|
offset += arr.length;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hook for realtime audio streaming with volume-based VAD
|
* Hook for realtime audio streaming with volume-based VAD
|
||||||
* Uses expo-audio-studio for recording with echo cancellation and noise suppression
|
* Uses expo-audio-studio for recording with echo cancellation and noise suppression
|
||||||
@@ -28,13 +138,17 @@ export function useRealtimeAudio(config: RealtimeAudioConfig): RealtimeAudio {
|
|||||||
const [isSpeaking, setIsSpeaking] = useState(false);
|
const [isSpeaking, setIsSpeaking] = useState(false);
|
||||||
|
|
||||||
const audioRecorder = useAudioRecorder();
|
const audioRecorder = useAudioRecorder();
|
||||||
const audioBufferRef = useRef<string[]>([]);
|
const audioBufferRef = useRef<Uint8Array[]>([]);
|
||||||
const silenceStartRef = useRef<number | null>(null);
|
const silenceStartRef = useRef<number | null>(null);
|
||||||
const isSpeakingRef = useRef(false);
|
const isSpeakingRef = useRef(false);
|
||||||
|
const speechStartRef = useRef<number | null>(null); // Track when volume first exceeded threshold
|
||||||
|
const speechConfirmedRef = useRef(false); // Track if speech has been confirmed
|
||||||
const lastVolumeCheckRef = useRef<number>(Date.now());
|
const lastVolumeCheckRef = useRef<number>(Date.now());
|
||||||
|
const lastVolumeLogRef = useRef<number>(Date.now());
|
||||||
|
|
||||||
const VOLUME_THRESHOLD = config.volumeThreshold ?? 0.02;
|
const VOLUME_THRESHOLD = config.volumeThreshold ?? 0.2;
|
||||||
const SILENCE_DURATION_MS = config.silenceDuration ?? 1000;
|
const SILENCE_DURATION_MS = config.silenceDuration ?? 1000;
|
||||||
|
const SPEECH_START_DURATION_MS = config.speechStartDuration ?? 1000;
|
||||||
const VOLUME_CHECK_INTERVAL = 200; // Check volume every 200ms
|
const VOLUME_CHECK_INTERVAL = 200; // Check volume every 200ms
|
||||||
|
|
||||||
async function requestMicrophonePermission(): Promise<boolean> {
|
async function requestMicrophonePermission(): Promise<boolean> {
|
||||||
@@ -84,7 +198,6 @@ export function useRealtimeAudio(config: RealtimeAudioConfig): RealtimeAudio {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const rms = Math.sqrt(sum / sampleCount);
|
const rms = Math.sqrt(sum / sampleCount);
|
||||||
console.log('[RealtimeAudio] Volume:', rms.toFixed(6));
|
|
||||||
return rms;
|
return rms;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[RealtimeAudio] Volume calculation error:', error);
|
console.error('[RealtimeAudio] Volume calculation error:', error);
|
||||||
@@ -126,38 +239,84 @@ export function useRealtimeAudio(config: RealtimeAudioConfig): RealtimeAudio {
|
|||||||
const volume = calculateVolume(audioData);
|
const volume = calculateVolume(audioData);
|
||||||
const speechDetected = volume > VOLUME_THRESHOLD;
|
const speechDetected = volume > VOLUME_THRESHOLD;
|
||||||
|
|
||||||
if (speechDetected && !isSpeakingRef.current) {
|
// Decode base64 chunk to binary for buffering
|
||||||
// Speech START
|
const decodedChunk = decodeBase64ToUint8Array(audioData);
|
||||||
console.log('[RealtimeAudio] Speech started (volume:', volume.toFixed(4), ')');
|
|
||||||
isSpeakingRef.current = true;
|
// Check if we're in detection phase (volume detected but not yet confirmed)
|
||||||
|
const inDetectionPhase = speechStartRef.current !== null && !isSpeakingRef.current;
|
||||||
|
|
||||||
|
if (inDetectionPhase) {
|
||||||
|
// We're in detection phase - check timer regardless of current volume
|
||||||
|
const sustainedDuration = Date.now() - speechStartRef.current!;
|
||||||
|
|
||||||
|
if (sustainedDuration >= SPEECH_START_DURATION_MS) {
|
||||||
|
// Timer elapsed - confirm speech
|
||||||
|
console.log('[RealtimeAudio] ✅ Speech confirmed after', sustainedDuration, 'ms (volume:', volume.toFixed(4), 'threshold:', VOLUME_THRESHOLD, ')');
|
||||||
|
isSpeakingRef.current = true;
|
||||||
|
speechConfirmedRef.current = true;
|
||||||
|
setIsSpeaking(true);
|
||||||
|
config.onSpeechStart?.();
|
||||||
|
audioBufferRef.current.push(decodedChunk);
|
||||||
|
silenceStartRef.current = null;
|
||||||
|
} else if (speechDetected) {
|
||||||
|
// Still in detection phase, volume still above threshold, keep buffering
|
||||||
|
audioBufferRef.current.push(decodedChunk);
|
||||||
|
|
||||||
|
// Log progress every 500ms during detection
|
||||||
|
const logNow = Date.now();
|
||||||
|
if (logNow - lastVolumeLogRef.current >= 500) {
|
||||||
|
console.log('[RealtimeAudio] 🔍 Detecting...', sustainedDuration, '/', SPEECH_START_DURATION_MS, 'ms (volume:', volume.toFixed(4), ')');
|
||||||
|
lastVolumeLogRef.current = logNow;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Volume dropped before timer elapsed - false start
|
||||||
|
console.log('[RealtimeAudio] ❌ False start (volume dropped after', sustainedDuration, 'ms, needed', SPEECH_START_DURATION_MS, 'ms)');
|
||||||
|
speechStartRef.current = null;
|
||||||
|
speechConfirmedRef.current = false;
|
||||||
|
audioBufferRef.current = [];
|
||||||
|
}
|
||||||
|
} else if (speechDetected && !isSpeakingRef.current) {
|
||||||
|
// First volume detection - start timer and buffering
|
||||||
|
console.log('[RealtimeAudio] 🎤 Volume spike detected! Starting detection... (volume:', volume.toFixed(4), 'threshold:', VOLUME_THRESHOLD, ')');
|
||||||
|
speechStartRef.current = Date.now();
|
||||||
|
speechConfirmedRef.current = false;
|
||||||
|
audioBufferRef.current = [decodedChunk];
|
||||||
silenceStartRef.current = null;
|
silenceStartRef.current = null;
|
||||||
audioBufferRef.current = [audioData];
|
|
||||||
setIsSpeaking(true);
|
|
||||||
config.onSpeechStart?.();
|
|
||||||
} else if (speechDetected && isSpeakingRef.current) {
|
} else if (speechDetected && isSpeakingRef.current) {
|
||||||
// Continuing speech
|
// Continuing confirmed speech - just buffer, no logs
|
||||||
audioBufferRef.current.push(audioData);
|
audioBufferRef.current.push(decodedChunk);
|
||||||
silenceStartRef.current = null;
|
silenceStartRef.current = null;
|
||||||
} else if (!speechDetected && isSpeakingRef.current) {
|
} else if (!speechDetected && isSpeakingRef.current) {
|
||||||
// Potential speech END
|
// Potential speech END (after confirmed speech)
|
||||||
audioBufferRef.current.push(audioData);
|
audioBufferRef.current.push(decodedChunk);
|
||||||
|
|
||||||
if (silenceStartRef.current === null) {
|
if (silenceStartRef.current === null) {
|
||||||
silenceStartRef.current = Date.now();
|
silenceStartRef.current = Date.now();
|
||||||
|
console.log('[RealtimeAudio] 🔇 Silence detected, waiting', SILENCE_DURATION_MS, 'ms to confirm end...');
|
||||||
} else {
|
} else {
|
||||||
const silenceDuration = Date.now() - silenceStartRef.current;
|
const silenceDuration = Date.now() - silenceStartRef.current;
|
||||||
if (silenceDuration >= SILENCE_DURATION_MS) {
|
if (silenceDuration >= SILENCE_DURATION_MS) {
|
||||||
// Speech END confirmed
|
// Speech END confirmed
|
||||||
console.log('[RealtimeAudio] Speech ended after', silenceDuration, 'ms silence');
|
const totalChunks = audioBufferRef.current.length;
|
||||||
|
console.log('[RealtimeAudio] 🛑 Speech ended after', silenceDuration, 'ms silence (captured', totalChunks, 'chunks)');
|
||||||
isSpeakingRef.current = false;
|
isSpeakingRef.current = false;
|
||||||
|
speechStartRef.current = null;
|
||||||
|
speechConfirmedRef.current = false;
|
||||||
silenceStartRef.current = null;
|
silenceStartRef.current = null;
|
||||||
setIsSpeaking(false);
|
setIsSpeaking(false);
|
||||||
config.onSpeechEnd?.();
|
config.onSpeechEnd?.();
|
||||||
|
|
||||||
// Send buffered audio segment
|
// Send buffered audio segment
|
||||||
if (audioBufferRef.current.length > 0) {
|
if (audioBufferRef.current.length > 0) {
|
||||||
const combinedAudio = audioBufferRef.current.join('');
|
// Concatenate all binary chunks
|
||||||
config.onAudioSegment?.(combinedAudio);
|
const combinedBinary = concatenateUint8Arrays(audioBufferRef.current);
|
||||||
|
const audioSizeKb = (combinedBinary.length / 1024).toFixed(2);
|
||||||
|
console.log('[RealtimeAudio] 📤 Sending audio segment:', audioSizeKb, 'KB');
|
||||||
|
// Convert to base64
|
||||||
|
const combinedPcmBase64 = uint8ArrayToBase64(combinedBinary);
|
||||||
|
// Convert PCM to WAV format
|
||||||
|
const wavAudio = pcmToWav(combinedPcmBase64, 16000, 1, 16);
|
||||||
|
config.onAudioSegment?.(wavAudio);
|
||||||
audioBufferRef.current = [];
|
audioBufferRef.current = [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -165,13 +324,14 @@ export function useRealtimeAudio(config: RealtimeAudioConfig): RealtimeAudio {
|
|||||||
}
|
}
|
||||||
} else if (isSpeakingRef.current) {
|
} else if (isSpeakingRef.current) {
|
||||||
// Just buffer if we're speaking but not checking volume yet
|
// Just buffer if we're speaking but not checking volume yet
|
||||||
audioBufferRef.current.push(audioData);
|
const decodedChunk = decodeBase64ToUint8Array(audioData);
|
||||||
|
audioBufferRef.current.push(decodedChunk);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
setIsActive(true);
|
setIsActive(true);
|
||||||
console.log('[RealtimeAudio] Realtime audio started successfully');
|
console.log('[RealtimeAudio] 🎙️ Audio capture started (threshold:', VOLUME_THRESHOLD, 'duration:', SPEECH_START_DURATION_MS, 'ms)');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[RealtimeAudio] Start error:', error);
|
console.error('[RealtimeAudio] Start error:', error);
|
||||||
const err = error instanceof Error ? error : new Error(String(error));
|
const err = error instanceof Error ? error : new Error(String(error));
|
||||||
@@ -198,6 +358,8 @@ export function useRealtimeAudio(config: RealtimeAudioConfig): RealtimeAudio {
|
|||||||
// Reset state
|
// Reset state
|
||||||
audioBufferRef.current = [];
|
audioBufferRef.current = [];
|
||||||
isSpeakingRef.current = false;
|
isSpeakingRef.current = false;
|
||||||
|
speechStartRef.current = null;
|
||||||
|
speechConfirmedRef.current = false;
|
||||||
silenceStartRef.current = null;
|
silenceStartRef.current = null;
|
||||||
setIsActive(false);
|
setIsActive(false);
|
||||||
setIsSpeaking(false);
|
setIsSpeaking(false);
|
||||||
|
|||||||
@@ -0,0 +1,352 @@
|
|||||||
|
import { useState, useEffect, useRef, useCallback } from "react";
|
||||||
|
import {
|
||||||
|
initialize,
|
||||||
|
toggleRecording,
|
||||||
|
tearDown,
|
||||||
|
useExpoTwoWayAudioEventListener,
|
||||||
|
type MicrophoneDataCallback,
|
||||||
|
type VolumeLevelCallback,
|
||||||
|
} from "@speechmatics/expo-two-way-audio";
|
||||||
|
|
||||||
|
export interface SpeechmaticsAudioConfig {
|
||||||
|
onAudioSegment?: (audioData: string) => void;
|
||||||
|
onSpeechStart?: () => void;
|
||||||
|
onSpeechEnd?: () => void;
|
||||||
|
onError?: (error: Error) => void;
|
||||||
|
volumeThreshold: number; // Volume threshold for speech detection (0-1)
|
||||||
|
silenceDuration: number; // ms of silence before ending segment
|
||||||
|
speechConfirmationDuration: number; // ms of sustained speech before confirming
|
||||||
|
detectionGracePeriod: number; // ms grace period for volume dips during detection
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SpeechmaticsAudio {
|
||||||
|
start: () => Promise<void>;
|
||||||
|
stop: () => Promise<void>;
|
||||||
|
isActive: boolean;
|
||||||
|
isSpeaking: boolean;
|
||||||
|
isDetecting: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert raw PCM data to WAV format by adding WAV headers
|
||||||
|
*/
|
||||||
|
function pcmToWav(
|
||||||
|
pcmBase64: string,
|
||||||
|
sampleRate: number = 16000,
|
||||||
|
channels: number = 1,
|
||||||
|
bitsPerSample: number = 16
|
||||||
|
): string {
|
||||||
|
const pcmBinary = atob(pcmBase64);
|
||||||
|
const pcmLength = pcmBinary.length;
|
||||||
|
|
||||||
|
const byteRate = sampleRate * channels * (bitsPerSample / 8);
|
||||||
|
const blockAlign = channels * (bitsPerSample / 8);
|
||||||
|
const dataSize = pcmLength;
|
||||||
|
const fileSize = 44 + dataSize;
|
||||||
|
|
||||||
|
const wavBuffer = new Uint8Array(fileSize);
|
||||||
|
const view = new DataView(wavBuffer.buffer);
|
||||||
|
|
||||||
|
let offset = 0;
|
||||||
|
|
||||||
|
// RIFF chunk descriptor
|
||||||
|
writeString(view, offset, "RIFF");
|
||||||
|
offset += 4;
|
||||||
|
view.setUint32(offset, fileSize - 8, true);
|
||||||
|
offset += 4;
|
||||||
|
writeString(view, offset, "WAVE");
|
||||||
|
offset += 4;
|
||||||
|
|
||||||
|
// fmt sub-chunk
|
||||||
|
writeString(view, offset, "fmt ");
|
||||||
|
offset += 4;
|
||||||
|
view.setUint32(offset, 16, true);
|
||||||
|
offset += 4;
|
||||||
|
view.setUint16(offset, 1, true);
|
||||||
|
offset += 2;
|
||||||
|
view.setUint16(offset, channels, true);
|
||||||
|
offset += 2;
|
||||||
|
view.setUint32(offset, sampleRate, true);
|
||||||
|
offset += 4;
|
||||||
|
view.setUint32(offset, byteRate, true);
|
||||||
|
offset += 4;
|
||||||
|
view.setUint16(offset, blockAlign, true);
|
||||||
|
offset += 2;
|
||||||
|
view.setUint16(offset, bitsPerSample, true);
|
||||||
|
offset += 2;
|
||||||
|
|
||||||
|
// data sub-chunk
|
||||||
|
writeString(view, offset, "data");
|
||||||
|
offset += 4;
|
||||||
|
view.setUint32(offset, dataSize, true);
|
||||||
|
offset += 4;
|
||||||
|
|
||||||
|
// Copy PCM data
|
||||||
|
for (let i = 0; i < pcmLength; i++) {
|
||||||
|
wavBuffer[offset + i] = pcmBinary.charCodeAt(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to base64
|
||||||
|
let binary = "";
|
||||||
|
for (let i = 0; i < wavBuffer.length; i++) {
|
||||||
|
binary += String.fromCharCode(wavBuffer[i]);
|
||||||
|
}
|
||||||
|
return btoa(binary);
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeString(view: DataView, offset: number, str: string): void {
|
||||||
|
for (let i = 0; i < str.length; i++) {
|
||||||
|
view.setUint8(offset + i, str.charCodeAt(i));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function uint8ArrayToBase64(bytes: Uint8Array): string {
|
||||||
|
let binary = "";
|
||||||
|
for (let i = 0; i < bytes.length; i++) {
|
||||||
|
binary += String.fromCharCode(bytes[i]);
|
||||||
|
}
|
||||||
|
return btoa(binary);
|
||||||
|
}
|
||||||
|
|
||||||
|
function concatenateUint8Arrays(arrays: Uint8Array[]): Uint8Array {
|
||||||
|
const totalLength = arrays.reduce((sum, arr) => sum + arr.length, 0);
|
||||||
|
const result = new Uint8Array(totalLength);
|
||||||
|
let offset = 0;
|
||||||
|
for (const arr of arrays) {
|
||||||
|
result.set(arr, offset);
|
||||||
|
offset += arr.length;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook for audio capture with echo cancellation using Speechmatics expo-two-way-audio
|
||||||
|
*/
|
||||||
|
export function useSpeechmaticsAudio(
|
||||||
|
config: SpeechmaticsAudioConfig
|
||||||
|
): SpeechmaticsAudio {
|
||||||
|
const [isActive, setIsActive] = useState(false);
|
||||||
|
const [isSpeaking, setIsSpeaking] = useState(false);
|
||||||
|
const [isDetecting, setIsDetecting] = useState(false);
|
||||||
|
const [audioInitialized, setAudioInitialized] = useState(false);
|
||||||
|
|
||||||
|
const audioBufferRef = useRef<Uint8Array[]>([]);
|
||||||
|
const silenceStartRef = useRef<number | null>(null);
|
||||||
|
const isSpeakingRef = useRef(false);
|
||||||
|
const speechDetectionStartRef = useRef<number | null>(null);
|
||||||
|
const speechConfirmedRef = useRef(false);
|
||||||
|
const detectionSilenceStartRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
const VOLUME_THRESHOLD = config.volumeThreshold;
|
||||||
|
const SILENCE_DURATION_MS = config.silenceDuration;
|
||||||
|
const SPEECH_CONFIRMATION_MS = config.speechConfirmationDuration;
|
||||||
|
const DETECTION_GRACE_PERIOD_MS = config.detectionGracePeriod;
|
||||||
|
|
||||||
|
// Initialize audio on mount
|
||||||
|
useEffect(() => {
|
||||||
|
const initializeAudio = async () => {
|
||||||
|
try {
|
||||||
|
await initialize();
|
||||||
|
setAudioInitialized(true);
|
||||||
|
console.log("[SpeechmaticsAudio] Audio initialized");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[SpeechmaticsAudio] Initialization error:", error);
|
||||||
|
const err = error instanceof Error ? error : new Error(String(error));
|
||||||
|
config.onError?.(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
initializeAudio();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
tearDown();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Listen to microphone data
|
||||||
|
useExpoTwoWayAudioEventListener(
|
||||||
|
"onMicrophoneData",
|
||||||
|
useCallback<MicrophoneDataCallback>(
|
||||||
|
(event) => {
|
||||||
|
if (!isActive) return;
|
||||||
|
|
||||||
|
const pcmData: Uint8Array = event.data;
|
||||||
|
|
||||||
|
// Buffer the audio chunk if we're detecting or speaking
|
||||||
|
// Start buffering from first spike to capture beginning of speech
|
||||||
|
if (speechDetectionStartRef.current !== null || isSpeakingRef.current) {
|
||||||
|
audioBufferRef.current.push(pcmData);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[isActive]
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Listen to volume level for VAD
|
||||||
|
useExpoTwoWayAudioEventListener(
|
||||||
|
"onInputVolumeLevelData",
|
||||||
|
useCallback<VolumeLevelCallback>(
|
||||||
|
(event) => {
|
||||||
|
if (!isActive) return;
|
||||||
|
|
||||||
|
const volume: number = event.data;
|
||||||
|
const speechDetected = volume > VOLUME_THRESHOLD;
|
||||||
|
|
||||||
|
// console.log('[SpeechmaticsAudio] Volume:', volume.toFixed(6));
|
||||||
|
|
||||||
|
if (speechDetected && !isSpeakingRef.current && !speechConfirmedRef.current) {
|
||||||
|
// Initial speech detection - start tracking
|
||||||
|
if (speechDetectionStartRef.current === null) {
|
||||||
|
console.log(
|
||||||
|
"[SpeechmaticsAudio] Speech started (volume:",
|
||||||
|
volume.toFixed(4),
|
||||||
|
")"
|
||||||
|
);
|
||||||
|
speechDetectionStartRef.current = Date.now();
|
||||||
|
detectionSilenceStartRef.current = null;
|
||||||
|
audioBufferRef.current = [];
|
||||||
|
setIsDetecting(true);
|
||||||
|
} else {
|
||||||
|
// Volume is back above threshold - reset grace period
|
||||||
|
detectionSilenceStartRef.current = null;
|
||||||
|
|
||||||
|
// Check if speech has been sustained long enough
|
||||||
|
const speechDuration = Date.now() - speechDetectionStartRef.current;
|
||||||
|
if (speechDuration >= SPEECH_CONFIRMATION_MS) {
|
||||||
|
// Speech CONFIRMED
|
||||||
|
console.log(
|
||||||
|
"[SpeechmaticsAudio] Speech confirmed after",
|
||||||
|
speechDuration,
|
||||||
|
"ms"
|
||||||
|
);
|
||||||
|
isSpeakingRef.current = true;
|
||||||
|
speechConfirmedRef.current = true;
|
||||||
|
silenceStartRef.current = null;
|
||||||
|
setIsDetecting(false);
|
||||||
|
setIsSpeaking(true);
|
||||||
|
config.onSpeechStart?.();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (speechDetected && isSpeakingRef.current && speechConfirmedRef.current) {
|
||||||
|
// Continuing confirmed speech
|
||||||
|
silenceStartRef.current = null;
|
||||||
|
} else if (!speechDetected && !speechConfirmedRef.current && speechDetectionStartRef.current !== null) {
|
||||||
|
// Volume dropped during detection phase - apply grace period
|
||||||
|
if (detectionSilenceStartRef.current === null) {
|
||||||
|
detectionSilenceStartRef.current = Date.now();
|
||||||
|
} else {
|
||||||
|
const graceDuration = Date.now() - detectionSilenceStartRef.current;
|
||||||
|
if (graceDuration >= DETECTION_GRACE_PERIOD_MS) {
|
||||||
|
// Grace period expired - cancel detection
|
||||||
|
console.log(
|
||||||
|
"[SpeechmaticsAudio] Speech detection cancelled after",
|
||||||
|
graceDuration,
|
||||||
|
"ms grace period"
|
||||||
|
);
|
||||||
|
speechDetectionStartRef.current = null;
|
||||||
|
detectionSilenceStartRef.current = null;
|
||||||
|
audioBufferRef.current = [];
|
||||||
|
setIsDetecting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (!speechDetected && isSpeakingRef.current && speechConfirmedRef.current) {
|
||||||
|
// Potential speech END
|
||||||
|
if (silenceStartRef.current === null) {
|
||||||
|
silenceStartRef.current = Date.now();
|
||||||
|
} else {
|
||||||
|
const silenceDuration = Date.now() - silenceStartRef.current;
|
||||||
|
if (silenceDuration >= SILENCE_DURATION_MS) {
|
||||||
|
// Speech END confirmed
|
||||||
|
console.log(
|
||||||
|
"[SpeechmaticsAudio] Speech ended after",
|
||||||
|
silenceDuration,
|
||||||
|
"ms silence"
|
||||||
|
);
|
||||||
|
isSpeakingRef.current = false;
|
||||||
|
speechConfirmedRef.current = false;
|
||||||
|
speechDetectionStartRef.current = null;
|
||||||
|
silenceStartRef.current = null;
|
||||||
|
setIsSpeaking(false);
|
||||||
|
config.onSpeechEnd?.();
|
||||||
|
|
||||||
|
// Send buffered audio segment
|
||||||
|
if (audioBufferRef.current.length > 0) {
|
||||||
|
const combinedBinary = concatenateUint8Arrays(
|
||||||
|
audioBufferRef.current
|
||||||
|
);
|
||||||
|
const combinedPcmBase64 = uint8ArrayToBase64(combinedBinary);
|
||||||
|
const wavAudio = pcmToWav(combinedPcmBase64, 16000, 1, 16);
|
||||||
|
config.onAudioSegment?.(wavAudio);
|
||||||
|
audioBufferRef.current = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[isActive, VOLUME_THRESHOLD, SILENCE_DURATION_MS, SPEECH_CONFIRMATION_MS, DETECTION_GRACE_PERIOD_MS, config]
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
async function start(): Promise<void> {
|
||||||
|
if (!audioInitialized) {
|
||||||
|
throw new Error("Audio not initialized");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isActive) {
|
||||||
|
console.log("[SpeechmaticsAudio] Already active");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log("[SpeechmaticsAudio] Starting audio capture...");
|
||||||
|
|
||||||
|
// Start recording
|
||||||
|
toggleRecording(true);
|
||||||
|
|
||||||
|
setIsActive(true);
|
||||||
|
console.log("[SpeechmaticsAudio] Audio capture started successfully");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[SpeechmaticsAudio] Start error:", error);
|
||||||
|
const err = error instanceof Error ? error : new Error(String(error));
|
||||||
|
config.onError?.(err);
|
||||||
|
await stop();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stop(): Promise<void> {
|
||||||
|
console.log("[SpeechmaticsAudio] Stopping audio capture...");
|
||||||
|
|
||||||
|
// Stop recording
|
||||||
|
toggleRecording(false);
|
||||||
|
|
||||||
|
// Reset state
|
||||||
|
audioBufferRef.current = [];
|
||||||
|
isSpeakingRef.current = false;
|
||||||
|
speechConfirmedRef.current = false;
|
||||||
|
speechDetectionStartRef.current = null;
|
||||||
|
detectionSilenceStartRef.current = null;
|
||||||
|
silenceStartRef.current = null;
|
||||||
|
setIsActive(false);
|
||||||
|
setIsSpeaking(false);
|
||||||
|
setIsDetecting(false);
|
||||||
|
|
||||||
|
console.log("[SpeechmaticsAudio] Audio capture stopped");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (isActive) {
|
||||||
|
stop();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [isActive]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
start,
|
||||||
|
stop,
|
||||||
|
isActive,
|
||||||
|
isSpeaking,
|
||||||
|
isDetecting,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -58,7 +58,7 @@ export function useWebSocket(url: string, conversationId?: string | null): UseWe
|
|||||||
// Only session messages trigger handlers
|
// Only session messages trigger handlers
|
||||||
if (wsMessage.type === 'session') {
|
if (wsMessage.type === 'session') {
|
||||||
const sessionMessage = wsMessage.message;
|
const sessionMessage = wsMessage.message;
|
||||||
console.log(`[WS] Received session message type: ${sessionMessage.type}`);
|
// console.log(`[WS] Received session message type: ${sessionMessage.type}`);
|
||||||
|
|
||||||
// Track conversation ID when loaded
|
// Track conversation ID when loaded
|
||||||
if (sessionMessage.type === 'conversation_loaded') {
|
if (sessionMessage.type === 'conversation_loaded') {
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
const { getDefaultConfig } = require("expo/metro-config");
|
const { getDefaultConfig } = require("expo/metro-config");
|
||||||
const { withNativewind } = require("nativewind/metro");
|
|
||||||
|
|
||||||
const config = getDefaultConfig(__dirname);
|
const config = getDefaultConfig(__dirname);
|
||||||
|
|
||||||
module.exports = withNativewind(config);
|
module.exports = config;
|
||||||
|
|||||||
@@ -17,6 +17,8 @@
|
|||||||
"@react-navigation/elements": "^2.6.3",
|
"@react-navigation/elements": "^2.6.3",
|
||||||
"@react-navigation/native": "^7.1.8",
|
"@react-navigation/native": "^7.1.8",
|
||||||
"@siteed/expo-audio-studio": "^2.18.1",
|
"@siteed/expo-audio-studio": "^2.18.1",
|
||||||
|
"@speechmatics/expo-two-way-audio": "^0.1.2",
|
||||||
|
"buffer": "^6.0.3",
|
||||||
"expo": "^54.0.18",
|
"expo": "^54.0.18",
|
||||||
"expo-audio": "~1.0.13",
|
"expo-audio": "~1.0.13",
|
||||||
"expo-av": "^16.0.7",
|
"expo-av": "^16.0.7",
|
||||||
@@ -37,7 +39,6 @@
|
|||||||
"expo-updates": "~29.0.12",
|
"expo-updates": "~29.0.12",
|
||||||
"expo-web-browser": "~15.0.8",
|
"expo-web-browser": "~15.0.8",
|
||||||
"lucide-react-native": "^0.546.0",
|
"lucide-react-native": "^0.546.0",
|
||||||
"nativewind": "^5.0.0-preview.2",
|
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-dom": "19.1.0",
|
"react-dom": "19.1.0",
|
||||||
"react-native": "0.81.4",
|
"react-native": "0.81.4",
|
||||||
@@ -49,18 +50,16 @@
|
|||||||
"react-native-safe-area-context": "~5.6.0",
|
"react-native-safe-area-context": "~5.6.0",
|
||||||
"react-native-screens": "~4.16.0",
|
"react-native-screens": "~4.16.0",
|
||||||
"react-native-svg": "^15.14.0",
|
"react-native-svg": "^15.14.0",
|
||||||
|
"react-native-unistyles": "^3.0.15",
|
||||||
"react-native-web": "~0.21.0",
|
"react-native-web": "~0.21.0",
|
||||||
"react-native-webrtc": "^124.0.7",
|
"react-native-webrtc": "^124.0.7",
|
||||||
"react-native-worklets": "0.5.1"
|
"react-native-worklets": "0.5.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4.1.15",
|
|
||||||
"@types/react": "~19.2.0",
|
"@types/react": "~19.2.0",
|
||||||
"eas-cli": "^16.24.1",
|
"eas-cli": "^16.24.1",
|
||||||
"eslint": "^9.25.0",
|
"eslint": "^9.25.0",
|
||||||
"eslint-config-expo": "~10.0.0",
|
"eslint-config-expo": "~10.0.0",
|
||||||
"postcss": "^8.4.49",
|
|
||||||
"tailwindcss": "^4.1.15",
|
|
||||||
"typescript": "~5.9.2"
|
"typescript": "~5.9.2"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
export default {
|
|
||||||
plugins: {
|
|
||||||
"@tailwindcss/postcss": {},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
export const theme = {
|
||||||
|
colors: {
|
||||||
|
// Base colors
|
||||||
|
white: "#ffffff",
|
||||||
|
black: "#000000",
|
||||||
|
|
||||||
|
// Zinc scale (primary gray palette)
|
||||||
|
zinc: {
|
||||||
|
50: "#fafafa",
|
||||||
|
100: "#f4f4f5",
|
||||||
|
200: "#e4e4e7",
|
||||||
|
300: "#d4d4d8",
|
||||||
|
400: "#a1a1aa",
|
||||||
|
500: "#71717a",
|
||||||
|
600: "#52525b",
|
||||||
|
700: "#3f3f46",
|
||||||
|
800: "#27272a",
|
||||||
|
900: "#18181b",
|
||||||
|
},
|
||||||
|
|
||||||
|
// Gray scale
|
||||||
|
gray: {
|
||||||
|
50: "#f9fafb",
|
||||||
|
100: "#f3f4f6",
|
||||||
|
200: "#e5e7eb",
|
||||||
|
300: "#d1d5db",
|
||||||
|
400: "#9ca3af",
|
||||||
|
500: "#6b7280",
|
||||||
|
600: "#4b5563",
|
||||||
|
700: "#374151",
|
||||||
|
800: "#1f2937",
|
||||||
|
900: "#111827",
|
||||||
|
},
|
||||||
|
|
||||||
|
// Slate scale
|
||||||
|
slate: {
|
||||||
|
200: "#e2e8f0",
|
||||||
|
},
|
||||||
|
|
||||||
|
// Blue scale
|
||||||
|
blue: {
|
||||||
|
50: "#eff6ff",
|
||||||
|
100: "#dbeafe",
|
||||||
|
200: "#bfdbfe",
|
||||||
|
300: "#93c5fd",
|
||||||
|
400: "#60a5fa",
|
||||||
|
500: "#3b82f6",
|
||||||
|
600: "#2563eb",
|
||||||
|
700: "#1d4ed8",
|
||||||
|
800: "#1e40af",
|
||||||
|
900: "#1e3a8a",
|
||||||
|
950: "#172554",
|
||||||
|
},
|
||||||
|
|
||||||
|
// Green scale
|
||||||
|
green: {
|
||||||
|
100: "#dcfce7",
|
||||||
|
200: "#bbf7d0",
|
||||||
|
500: "#22c55e",
|
||||||
|
600: "#16a34a",
|
||||||
|
800: "#166534",
|
||||||
|
900: "#14532d",
|
||||||
|
},
|
||||||
|
|
||||||
|
// Red scale
|
||||||
|
red: {
|
||||||
|
100: "#fee2e2",
|
||||||
|
200: "#fecaca",
|
||||||
|
500: "#ef4444",
|
||||||
|
600: "#dc2626",
|
||||||
|
800: "#991b1b",
|
||||||
|
900: "#7f1d1d",
|
||||||
|
},
|
||||||
|
|
||||||
|
// Teal scale
|
||||||
|
teal: {
|
||||||
|
200: "#99f6e4",
|
||||||
|
},
|
||||||
|
|
||||||
|
// Amber scale
|
||||||
|
amber: {
|
||||||
|
500: "#f59e0b",
|
||||||
|
},
|
||||||
|
|
||||||
|
// Purple scale
|
||||||
|
purple: {
|
||||||
|
500: "#a855f7",
|
||||||
|
600: "#9333ea",
|
||||||
|
},
|
||||||
|
|
||||||
|
// Orange scale
|
||||||
|
orange: {
|
||||||
|
500: "#f97316",
|
||||||
|
600: "#ea580c",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
spacing: {
|
||||||
|
0: 0,
|
||||||
|
1: 4,
|
||||||
|
2: 8,
|
||||||
|
3: 12,
|
||||||
|
4: 16,
|
||||||
|
6: 24,
|
||||||
|
8: 32,
|
||||||
|
12: 48,
|
||||||
|
16: 64,
|
||||||
|
20: 80,
|
||||||
|
24: 96,
|
||||||
|
32: 128,
|
||||||
|
},
|
||||||
|
|
||||||
|
fontSize: {
|
||||||
|
xs: 10,
|
||||||
|
sm: 12,
|
||||||
|
base: 14,
|
||||||
|
lg: 16,
|
||||||
|
xl: 18,
|
||||||
|
"2xl": 20,
|
||||||
|
"3xl": 24,
|
||||||
|
},
|
||||||
|
|
||||||
|
fontWeight: {
|
||||||
|
normal: "normal" as const,
|
||||||
|
semibold: "600" as const,
|
||||||
|
bold: "bold" as const,
|
||||||
|
},
|
||||||
|
|
||||||
|
borderRadius: {
|
||||||
|
none: 0,
|
||||||
|
sm: 2,
|
||||||
|
base: 4,
|
||||||
|
md: 6,
|
||||||
|
lg: 8,
|
||||||
|
xl: 12,
|
||||||
|
"2xl": 16,
|
||||||
|
full: 9999,
|
||||||
|
},
|
||||||
|
|
||||||
|
borderWidth: {
|
||||||
|
0: 0,
|
||||||
|
1: 1,
|
||||||
|
2: 2,
|
||||||
|
},
|
||||||
|
|
||||||
|
opacity: {
|
||||||
|
0: 0,
|
||||||
|
50: 0.5,
|
||||||
|
100: 1,
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type Theme = typeof theme;
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { StyleSheet } from "react-native-unistyles";
|
||||||
|
import { theme } from "./theme";
|
||||||
|
|
||||||
|
// Configure Unistyles with our theme
|
||||||
|
StyleSheet.configure({
|
||||||
|
themes: {
|
||||||
|
dark: theme,
|
||||||
|
},
|
||||||
|
settings: {
|
||||||
|
initialTheme: "dark",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Type augmentation for TypeScript
|
||||||
|
type AppThemes = {
|
||||||
|
dark: typeof theme;
|
||||||
|
};
|
||||||
|
|
||||||
|
declare module "react-native-unistyles" {
|
||||||
|
export interface UnistylesThemes extends AppThemes {}
|
||||||
|
}
|
||||||
@@ -20,14 +20,14 @@ export function initializeTTS(ttsConfig: TTSConfig): void {
|
|||||||
config = {
|
config = {
|
||||||
model: "tts-1",
|
model: "tts-1",
|
||||||
voice: "alloy",
|
voice: "alloy",
|
||||||
responseFormat: "mp3",
|
responseFormat: "pcm",
|
||||||
...ttsConfig,
|
...ttsConfig,
|
||||||
};
|
};
|
||||||
openaiClient = new OpenAI({
|
openaiClient = new OpenAI({
|
||||||
apiKey: ttsConfig.apiKey,
|
apiKey: ttsConfig.apiKey,
|
||||||
});
|
});
|
||||||
console.log(
|
console.log(
|
||||||
`✓ TTS (OpenAI) initialized with voice: ${config.voice}, model: ${config.model}`
|
`✓ TTS (OpenAI) initialized with voice: ${config.voice}, model: ${config.model}, format: ${config.responseFormat}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,7 +55,7 @@ export async function synthesizeSpeech(text: string): Promise<SpeechResult> {
|
|||||||
voice: config.voice!,
|
voice: config.voice!,
|
||||||
input: text,
|
input: text,
|
||||||
// speed: 1.2,
|
// speed: 1.2,
|
||||||
response_format: config.responseFormat as "mp3" | "opus" | "aac" | "flac",
|
response_format: config.responseFormat as "mp3" | "opus" | "aac" | "flac" | "wav" | "pcm",
|
||||||
});
|
});
|
||||||
|
|
||||||
// Response is a stream, convert to buffer
|
// Response is a stream, convert to buffer
|
||||||
|
|||||||
@@ -13,7 +13,10 @@ import {
|
|||||||
function createServer() {
|
function createServer() {
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
// Basic authentication
|
// Serve static files from public directory (no auth required for APK downloads)
|
||||||
|
app.use("/public", express.static("public"));
|
||||||
|
|
||||||
|
// Basic authentication (skip for /public routes)
|
||||||
app.use(
|
app.use(
|
||||||
basicAuth({
|
basicAuth({
|
||||||
users: { mo: "bo" },
|
users: { mo: "bo" },
|
||||||
@@ -85,7 +88,7 @@ function main() {
|
|||||||
apiKey,
|
apiKey,
|
||||||
voice: ttsVoice,
|
voice: ttsVoice,
|
||||||
model: ttsModel,
|
model: ttsModel,
|
||||||
responseFormat: "mp3",
|
responseFormat: "pcm",
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
console.warn(
|
console.warn(
|
||||||
|
|||||||
Reference in New Issue
Block a user