Skip to content

Commit 90a33f6

Browse files
committed
merge: bring camera realtime fixes into main
2 parents 23a47a1 + 1e3db9c commit 90a33f6

9 files changed

Lines changed: 112 additions & 43 deletions

File tree

app/(tabs)/main.tsx

Lines changed: 70 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -314,6 +314,7 @@ const MainUIScreen: React.FC<MainUIScreenProps> = () => {
314314
const sessionStartedResolverRef = useRef<((value: boolean) => void) | null>(null);
315315
const sessionTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
316316
const pendingSessionPromiseRef = useRef<Promise<boolean> | null>(null);
317+
const activeSessionModeRef = useRef<'text' | 'audio' | null>(null);
317318

318319
// Agent Backend 管理(传入 openPanel 以支持动态刷新)
319320
const { agent, onAgentChange, refreshAgentState } = useLive2DAgentBackend({
@@ -392,9 +393,13 @@ const MainUIScreen: React.FC<MainUIScreenProps> = () => {
392393
console.log('✅ 收到 session_started,input_mode:', inputMode);
393394
if (inputMode === 'text') {
394395
setIsTextSessionActive(true);
396+
activeSessionModeRef.current = 'text';
395397
} else if (inputMode === 'audio') {
396398
// audio session 启动意味着 text session 已被替换,重置状态
397399
setIsTextSessionActive(false);
400+
activeSessionModeRef.current = 'audio';
401+
} else {
402+
activeSessionModeRef.current = null;
398403
}
399404
if (sessionTimeoutRef.current) {
400405
clearTimeout(sessionTimeoutRef.current);
@@ -412,6 +417,7 @@ const MainUIScreen: React.FC<MainUIScreenProps> = () => {
412417
if (parsedMsg?.type === 'session_failed') {
413418
console.log('❌ 收到 session_failed,input_mode:', parsedMsg.input_mode);
414419
setIsTextSessionActive(false);
420+
activeSessionModeRef.current = null;
415421
if (sessionTimeoutRef.current) {
416422
clearTimeout(sessionTimeoutRef.current);
417423
sessionTimeoutRef.current = null;
@@ -428,6 +434,7 @@ const MainUIScreen: React.FC<MainUIScreenProps> = () => {
428434
if (parsedMsg?.type === 'session_ended_by_server') {
429435
console.log('⚠️ 收到 session_ended_by_server,input_mode:', parsedMsg.input_mode);
430436
setIsTextSessionActive(false);
437+
activeSessionModeRef.current = null;
431438
return;
432439
}
433440

@@ -570,6 +577,7 @@ const MainUIScreen: React.FC<MainUIScreenProps> = () => {
570577
}
571578
// 连接断开时重置 text session 状态
572579
setIsTextSessionActive(false);
580+
activeSessionModeRef.current = null;
573581
}
574582
}
575583
});
@@ -851,7 +859,39 @@ const MainUIScreen: React.FC<MainUIScreenProps> = () => {
851859
}
852860
}, [mainManager]);
853861

854-
const handleToggleScreen = useCallback(async (next: boolean) => {
862+
const ensureRealtimeVisionSession = useCallback(async (): Promise<boolean> => {
863+
const getActiveSessionMode = (): 'text' | 'audio' | null => activeSessionModeRef.current;
864+
865+
if (!audio.isConnected) {
866+
return false;
867+
}
868+
869+
if (getActiveSessionMode() === 'audio' || audio.isRecording) {
870+
return true;
871+
}
872+
873+
console.log('📤 为实时摄像头准备 audio session');
874+
audio.sendMessage({
875+
action: 'start_session',
876+
input_type: 'audio',
877+
audio_format: 'PCM_48000HZ_MONO_16BIT',
878+
new_session: false,
879+
});
880+
881+
const start = Date.now();
882+
const timeoutMs = 5000;
883+
while (Date.now() - start < timeoutMs) {
884+
if (getActiveSessionMode() === 'audio') {
885+
return true;
886+
}
887+
await new Promise(resolve => setTimeout(resolve, 100));
888+
}
889+
890+
console.warn('⚠️ 实时摄像头切换 audio session 超时');
891+
return false;
892+
}, [audio.isConnected, audio.isRecording, audio.sendMessage]);
893+
894+
const handleToggleCamera = useCallback(async (next: boolean) => {
855895
if (!next) {
856896
// 停止摄像头
857897
cameraStream.stopStreaming();
@@ -864,22 +904,35 @@ const MainUIScreen: React.FC<MainUIScreenProps> = () => {
864904
if (!hasPermission) return;
865905

866906
// 显示选择对话框
907+
const startCameraStream = async (selectedFacing: 'front' | 'back') => {
908+
statusToastRef.current?.show('正在准备实时视觉...', 2000);
909+
const sessionReady = await ensureRealtimeVisionSession();
910+
if (!sessionReady) {
911+
statusToastRef.current?.show('实时视觉会话准备失败', 3000);
912+
return;
913+
}
914+
915+
cameraStream.startStreaming(selectedFacing);
916+
statusToastRef.current?.show(
917+
selectedFacing === 'front' ? '前置摄像头已开启' : '后置摄像头已开启',
918+
2000
919+
);
920+
};
921+
867922
Alert.alert(
868923
'选择摄像头',
869924
'请选择要使用的摄像头',
870925
[
871926
{
872927
text: '前置摄像头',
873928
onPress: () => {
874-
cameraStream.startStreaming('front');
875-
statusToastRef.current?.show('前置摄像头已开启', 2000);
929+
void startCameraStream('front');
876930
},
877931
},
878932
{
879933
text: '后置摄像头',
880934
onPress: () => {
881-
cameraStream.startStreaming('back');
882-
statusToastRef.current?.show('后置摄像头已开启', 2000);
935+
void startCameraStream('back');
883936
},
884937
},
885938
{
@@ -888,7 +941,7 @@ const MainUIScreen: React.FC<MainUIScreenProps> = () => {
888941
},
889942
]
890943
);
891-
}, [cameraStream.startStreaming, cameraStream.stopStreaming, cameraStream.checkAndRequestPermission]);
944+
}, [cameraStream.startStreaming, cameraStream.stopStreaming, cameraStream.checkAndRequestPermission, ensureRealtimeVisionSession]);
892945

893946
const handleGoodbye = useCallback(() => {
894947
// 如果麦克风正在录音,先停止
@@ -1042,6 +1095,13 @@ const MainUIScreen: React.FC<MainUIScreenProps> = () => {
10421095
return false;
10431096
}
10441097

1098+
// 连续摄像头需要 realtime/audio session;切回文本前先暂停它,避免图片堆进文本会话队列。
1099+
if (cameraStream.isStreaming) {
1100+
console.log('📹 切换到文本会话,先暂停实时摄像头');
1101+
cameraStream.stopStreaming();
1102+
statusToastRef.current?.show('发送文本时已暂停实时摄像头', 2500);
1103+
}
1104+
10451105
// 如果当前正在录音(语音模式),先停止录音并等待服务端清理旧 session,
10461106
// 避免 start_session(text) 与正在启动/活跃的 audio session 产生竞态
10471107
if (audio.isRecording) {
@@ -1095,7 +1155,7 @@ const MainUIScreen: React.FC<MainUIScreenProps> = () => {
10951155

10961156
pendingSessionPromiseRef.current = promise;
10971157
return promise;
1098-
}, [isTextSessionActive, audio.isConnected, audio.isRecording, audio.sendMessage, audio.toggleRecording]);
1158+
}, [isTextSessionActive, audio.isConnected, audio.isRecording, audio.sendMessage, audio.toggleRecording, cameraStream.isStreaming, cameraStream.stopStreaming]);
10991159

11001160
// 图片消息服务
11011161
const imageMessageService = useMemo(() => new ImageMessageService(), []);
@@ -1273,7 +1333,7 @@ const MainUIScreen: React.FC<MainUIScreenProps> = () => {
12731333
- 详见:docs/strategy/cross-platform-components.md
12741334
12751335
功能包括:
1276-
- 麦克风/屏幕共享切换
1336+
- 麦克风/摄像头切换
12771337
- Agent 设置面板
12781338
- Settings 面板
12791339
- 设置菜单(Live2D设置、API密钥、角色管理等)
@@ -1285,7 +1345,7 @@ const MainUIScreen: React.FC<MainUIScreenProps> = () => {
12851345
right={isMobile ? 12 : 24}
12861346
top={isMobile ? screenHeight * 0.05 : 24}
12871347
micEnabled={toolbarMicEnabled}
1288-
screenEnabled={cameraStream.isStreaming}
1348+
cameraEnabled={cameraStream.isStreaming}
12891349
goodbyeMode={toolbarGoodbyeMode}
12901350
openPanel={toolbarOpenPanel}
12911351
onOpenPanelChange={setToolbarOpenPanel}
@@ -1294,7 +1354,7 @@ const MainUIScreen: React.FC<MainUIScreenProps> = () => {
12941354
agent={agent}
12951355
onAgentChange={handleToolbarAgentChange}
12961356
onToggleMic={handleToggleMic}
1297-
onToggleScreen={handleToggleScreen}
1357+
onToggleCamera={handleToggleCamera}
12981358
onGoodbye={handleGoodbye}
12991359
onReturn={handleReturn}
13001360
onSettingsMenuClick={handleSettingsMenuClick}

assets/icons/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
请从主项目的 `static/icons/` 目录复制以下图标文件到此目录:
88

99
- `mic_icon_off.png` - 麦克风图标
10-
- `screen_icon_off.png` - 屏幕分享图标
10+
- `screen_icon_off.png` - 摄像头按钮当前复用的历史图标资源
1111
- `Agent_off.png` - Agent 工具图标
1212
- `set_off.png` - 设置图标
1313
- `rest_off.png` - 离开/返回图标

docs/features/realtime-vision-guide.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -172,7 +172,7 @@ export interface CameraStreamConfig {
172172
**关键实现**
173173

174174
- `setCameraRef(ref)` — 绑定 CameraView ref
175-
- `start()`启动 `setInterval` 帧捕获循环,立即捕获第一帧
175+
- `start()`启动帧捕获循环,首帧延迟约 500ms 以提升 CameraView 启动稳定性
176176
- `stop()` — 清除 interval,状态回到 `idle`
177177
- `pause()` / `resume()` — 后台暂停/恢复
178178
- `captureAndSend()` — 内部方法:
@@ -256,7 +256,7 @@ const cameraStream = useCameraStream({
256256
**(c) 替换 toolbarScreenEnabled 状态 (line 220)**
257257

258258
- 删除 `const [toolbarScreenEnabled, setToolbarScreenEnabled] = useState(false);`
259-
- 把 toolbar 的 `screenEnabled` prop 改为 `screenEnabled={cameraStream.isStreaming}`
259+
- 把 toolbar 的 `cameraEnabled` prop 接到 `cameraStream.isStreaming`
260260

261261
**(d) 重写 handleToggleScreen (line 772-775)**
262262

@@ -303,7 +303,7 @@ iOS `NSCameraUsageDescription` 也做同样更新。
303303
| `services/imageCompression.ts` | 直接复用 `compress()` 方法,无需改动 |
304304
| `services/AudioService.ts` | 通过 hook 层的 `sendMessage` 消费 |
305305
| `hooks/useCamera.ts` | 单张拍照 hook,保持独立 |
306-
| `packages/project-neko-components/` | toolbar 已有 `screenEnabled` / `onToggleScreen` props |
306+
| `packages/project-neko-components/` | toolbar 已统一为 `cameraEnabled` / `onToggleCamera` props |
307307
| 后端所有文件 | 协议和限流完全兼容 |
308308

309309
#### 4.2.5 实现顺序
@@ -385,7 +385,7 @@ React 渲染 CameraStreamOverlay + CameraView
385385
386386
onCameraReady 回调触发 → setCameraRef + start()
387387
388-
setInterval 启动 (每 1.5s) + 立即捕获第一帧
388+
定时启动 (每 1.5s) + 首帧延迟约 500ms
389389
390390
takePictureAsync() → ImageCompressionService.compress()
391391

hooks/useCameraStream.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ export function useCameraStream(
7171
setError(err.message);
7272
setStatus('error');
7373
},
74-
frameInterval: 5000, // 5s
74+
frameInterval: 1500, // 1.5s,与后端原生视觉节奏对齐
7575
});
7676

7777
serviceRef.current = service;

packages/project-neko-components/src/Live2DRightToolbar/Live2DRightToolbar.native.tsx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ export function Live2DRightToolbar({
4444
top = 24,
4545
isMobile,
4646
micEnabled,
47-
screenEnabled,
47+
cameraEnabled,
4848
goodbyeMode,
4949
openPanel,
5050
onOpenPanelChange,
@@ -53,7 +53,7 @@ export function Live2DRightToolbar({
5353
agent,
5454
onAgentChange,
5555
onToggleMic,
56-
onToggleScreen,
56+
onToggleCamera,
5757
onGoodbye,
5858
onReturn,
5959
onSettingsMenuClick,
@@ -68,19 +68,19 @@ export function Live2DRightToolbar({
6868
// 使用共享的按钮配置(RN 使用本地 require() 资源)
6969
const buttons = useToolbarButtons<number>({
7070
micEnabled,
71-
screenEnabled,
71+
cameraEnabled,
7272
openPanel,
7373
goodbyeMode,
7474
isMobile,
7575
onToggleMic,
76-
onToggleScreen,
76+
onToggleCamera,
7777
onGoodbye,
7878
togglePanel,
7979
t,
8080
// RN: 使用本地打包的图标资源(确保这些文件存在于 assets/icons/)
8181
icons: {
8282
mic: require('../../../../assets/icons/mic_icon_off.png'),
83-
screen: require('../../../../assets/icons/screen_icon_off.png'),
83+
camera: require('../../../../assets/icons/screen_icon_off.png'),
8484
agent: require('../../../../assets/icons/Agent_off.png'),
8585
settings: require('../../../../assets/icons/set_off.png'),
8686
goodbye: require('../../../../assets/icons/rest_off.png'),

packages/project-neko-components/src/Live2DRightToolbar/Live2DRightToolbar.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ export function Live2DRightToolbar({
2525
top,
2626
isMobile,
2727
micEnabled,
28-
screenEnabled,
28+
cameraEnabled,
2929
goodbyeMode,
3030
openPanel,
3131
onOpenPanelChange,
@@ -34,7 +34,7 @@ export function Live2DRightToolbar({
3434
agent,
3535
onAgentChange,
3636
onToggleMic,
37-
onToggleScreen,
37+
onToggleCamera,
3838
onGoodbye,
3939
onReturn,
4040
onSettingsMenuClick,
@@ -51,12 +51,12 @@ export function Live2DRightToolbar({
5151
// 使用共享的按钮配置
5252
const buttons = useToolbarButtons<string>({
5353
micEnabled,
54-
screenEnabled,
54+
cameraEnabled,
5555
openPanel,
5656
goodbyeMode,
5757
isMobile,
5858
onToggleMic,
59-
onToggleScreen,
59+
onToggleCamera,
6060
onGoodbye,
6161
togglePanel,
6262
t,

packages/project-neko-components/src/Live2DRightToolbar/hooks.ts

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -87,32 +87,32 @@ export function usePanelToggle(
8787
*/
8888
export function useToolbarButtons<TIcon = ToolbarIcon>({
8989
micEnabled,
90-
screenEnabled,
90+
cameraEnabled,
9191
openPanel,
9292
goodbyeMode,
9393
isMobile,
9494
onToggleMic,
95-
onToggleScreen,
95+
onToggleCamera,
9696
onGoodbye,
9797
togglePanel,
9898
t,
9999
iconBasePath = '/static/icons', // Web 使用
100100
icons, // RN 使用 require() 资源
101101
}: {
102102
micEnabled: boolean;
103-
screenEnabled: boolean;
103+
cameraEnabled: boolean;
104104
openPanel: Live2DRightToolbarPanel;
105105
goodbyeMode: boolean;
106106
isMobile?: boolean;
107107
onToggleMic: (next: boolean) => void;
108-
onToggleScreen: (next: boolean) => void;
108+
onToggleCamera: (next: boolean) => void;
109109
onGoodbye: () => void;
110110
togglePanel: (panel: Exclude<Live2DRightToolbarPanel, null>) => void;
111111
t?: TFunction;
112112
iconBasePath?: string;
113113
icons?: {
114114
mic: TIcon;
115-
screen: TIcon;
115+
camera: TIcon;
116116
agent: TIcon;
117117
settings: TIcon;
118118
goodbye: TIcon;
@@ -130,12 +130,12 @@ export function useToolbarButtons<TIcon = ToolbarIcon>({
130130
icon: (icons?.mic ?? `${iconBasePath}/mic_icon_off.png`) as TIcon,
131131
},
132132
{
133-
id: 'screen' as const,
133+
id: 'camera' as const,
134134
title: tOrDefault(t, 'buttons.cameraShare', '摄像头'),
135135
hidden: false,
136-
active: screenEnabled,
137-
onClick: () => onToggleScreen(!screenEnabled),
138-
icon: (icons?.screen ?? `${iconBasePath}/screen_icon_off.png`) as TIcon,
136+
active: cameraEnabled,
137+
onClick: () => onToggleCamera(!cameraEnabled),
138+
icon: (icons?.camera ?? `${iconBasePath}/screen_icon_off.png`) as TIcon,
139139
},
140140
{
141141
id: 'agent' as const,
@@ -165,7 +165,7 @@ export function useToolbarButtons<TIcon = ToolbarIcon>({
165165
hasPanel: false,
166166
},
167167
].filter((b) => !b.hidden),
168-
[goodbyeMode, isMobile, micEnabled, onGoodbye, onToggleMic, onToggleScreen, openPanel, screenEnabled, t, togglePanel, iconBasePath, icons]
168+
[cameraEnabled, goodbyeMode, isMobile, micEnabled, onGoodbye, onToggleCamera, onToggleMic, openPanel, t, togglePanel, iconBasePath, icons]
169169
);
170170
}
171171

0 commit comments

Comments
 (0)