@trtc/calls-uikit-react 4.5.10 → 4.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/package.json +2 -2
  2. package/src/Components/TUICallKit/TUICallKit.tsx +75 -3
  3. package/src/Components/components/common/ButtonPanel/ButtonPanel.tsx +3 -3
  4. package/src/Components/components/common/ButtonPanel/ButtonPanelH5/ButtonPanelH5.tsx +5 -5
  5. package/src/Components/components/common/ButtonPanel/button/Camera.tsx +49 -6
  6. package/src/Components/components/common/ButtonPanel/button/Microphone.tsx +48 -6
  7. package/src/Components/components/common/FloatingWindow/FloatingWindow.tsx +2 -2
  8. package/src/Components/components/common/PermissionDialog/DevicePermissionBanner.module.scss +55 -0
  9. package/src/Components/components/common/PermissionDialog/DevicePermissionBanner.tsx +78 -0
  10. package/src/Components/components/common/PermissionDialog/index.ts +3 -0
  11. package/src/Components/components/common/StreamInfo/StreamInfoH5/StreamInfoH5.tsx +4 -4
  12. package/src/Components/components/common/TopBar/h5/TopBarH5.tsx +2 -2
  13. package/src/Components/components/common/TopBar/pc/TopBarPC.tsx +2 -2
  14. package/src/Components/context/CallUserInfoContext.ts +3 -0
  15. package/src/TUICallService/CallService/index.ts +153 -9
  16. package/src/TUICallService/CallService/utils.ts +35 -23
  17. package/src/TUICallService/TUIStore/callStore.ts +1 -0
  18. package/src/TUICallService/const/index.ts +27 -0
  19. package/src/TUICallService/index.ts +2 -1
  20. package/src/TUICallService/interface/ICallStore.ts +2 -1
  21. package/src/TUICallService/locales/en.ts +26 -0
  22. package/src/TUICallService/locales/index.ts +66 -0
  23. package/src/TUICallService/locales/ja_JP.ts +26 -0
  24. package/src/TUICallService/locales/zh-cn.ts +35 -0
  25. package/src/index.ts +1 -1
  26. package/tuicall-uikit-react.es.js +3658 -3285
  27. package/tuicall-uikit-react.umd.js +4 -4
  28. package/types/Components/components/common/PermissionDialog/DevicePermissionBanner.d.ts +1 -0
  29. package/types/Components/components/common/PermissionDialog/index.d.ts +2 -0
  30. package/types/Components/context/CallUserInfoContext.d.ts +1 -0
  31. package/types/TUICallService/CallService/index.d.ts +30 -0
  32. package/types/TUICallService/const/index.d.ts +13 -0
  33. package/types/TUICallService/index.d.ts +2 -2
  34. package/types/TUICallService/interface/ICallStore.d.ts +2 -1
  35. package/types/TUICallService/locales/en.d.ts +25 -0
  36. package/types/TUICallService/locales/index.d.ts +14 -0
  37. package/types/TUICallService/locales/ja_JP.d.ts +25 -0
  38. package/types/TUICallService/locales/zh-cn.d.ts +25 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trtc/calls-uikit-react",
3
- "version": "4.5.10",
3
+ "version": "4.6.0",
4
4
  "main": "./tuicall-uikit-react.umd.js",
5
5
  "module": "./tuicall-uikit-react.es.js",
6
6
  "types": "./types/index.d.ts",
@@ -14,7 +14,7 @@
14
14
  },
15
15
  "dependencies": {
16
16
  "@tencentcloud/tui-core-lite": "~1.0.1",
17
- "@trtc/call-engine-lite-js": "~4.0.10",
17
+ "@trtc/call-engine-lite-js": "~4.1.0",
18
18
  "@tencentcloud/lite-chat": "^1.6.3",
19
19
  "@trtc/call-engine-lite-wx": "~5.0.6"
20
20
  },
@@ -16,6 +16,7 @@ import SingleCall from '../components/SingleCall/SingleCall';
16
16
  import GroupCall from '../components/GroupCall/GroupCall';
17
17
  import { AISubtitle } from '../components/common/AIAssistant';
18
18
  import AutoPlayDialog from '../components/common/AutoPlayDialog';
19
+ import DevicePermissionBanner from '../components/common/PermissionDialog';
19
20
  import { classNames } from '../util/classnames';
20
21
  import {
21
22
  CallInfoContext,
@@ -69,6 +70,15 @@ export default function TUICallKit(props: ITUICallKitProps) {
69
70
  remoteList,
70
71
  setRemoteList,
71
72
  ] = useState(TUIStore.getData(StoreName.CALL, NAME.REMOTE_USER_INFO_LIST));
73
+ // Sticky flag: once the call reaches a multi-user state, it stays multi-user until IDLE.
74
+ // Initialized from the current store snapshot so a re-mount during an in-progress call
75
+ // (e.g. group call, or 1v1 that has already escalated) keeps the correct layout.
76
+ const [hasEverBeenMulti, setHasEverBeenMulti] = useState(() => {
77
+ const initIsGroup = TUIStore.getData(StoreName.CALL, NAME.IS_GROUP);
78
+ const initRemoteList = TUIStore.getData(StoreName.CALL, NAME.REMOTE_USER_INFO_EXCLUDE_VOLUMN_LIST);
79
+ return !!initIsGroup || (Array.isArray(initRemoteList) && initRemoteList.length >= 2);
80
+ });
81
+ const isMultiUserLayout = hasEverBeenMulti;
72
82
  const [callType, setCallType] = useState(TUIStore.getData(StoreName.CALL, NAME.CALL_MEDIA_TYPE));
73
83
  const [callRole, setCallRole] = useState(TUIStore.getData(StoreName.CALL, NAME.CALL_ROLE));
74
84
  const [isEarPhone, setIsEarPhone] = useState(TUIStore.getData(StoreName.CALL, NAME.IS_EAR_PHONE));
@@ -92,6 +102,7 @@ export default function TUICallKit(props: ITUICallKitProps) {
92
102
  callType,
93
103
  isEarPhone,
94
104
  isGroupCall,
105
+ isMultiUserLayout,
95
106
  isFloat,
96
107
  allowedMinimized,
97
108
  allowedFullScreen,
@@ -104,6 +115,7 @@ export default function TUICallKit(props: ITUICallKitProps) {
104
115
  callType,
105
116
  isEarPhone,
106
117
  isGroupCall,
118
+ isMultiUserLayout,
107
119
  isFloat,
108
120
  allowedFullScreen,
109
121
  enableVirtualBackground,
@@ -130,9 +142,18 @@ export default function TUICallKit(props: ITUICallKitProps) {
130
142
 
131
143
  const handleCallStatusChange = (value) => {
132
144
  setCallStatus(value);
145
+ // Reset the sticky multi-user flag when the call fully ends so the next
146
+ // call starts fresh (a subsequent 1v1 will start in single layout).
147
+ if (value === CallStatus.IDLE) {
148
+ setHasEverBeenMulti(false);
149
+ }
133
150
  };
134
151
  const handleIsGroupChange = (value) => {
135
152
  setIsGroupCall(value);
153
+ // Group calls always render in multi-user layout.
154
+ if (value) {
155
+ setHasEverBeenMulti(true);
156
+ }
136
157
  };
137
158
  const handleCallerUserInfoChange = (value) => {
138
159
  setCallerUserInfo(value);
@@ -142,6 +163,11 @@ export default function TUICallKit(props: ITUICallKitProps) {
142
163
  };
143
164
  const handleRemoteUserInfoListChange = (value) => {
144
165
  setRemoteList(value);
166
+ // Escalate to multi-user layout once the remote participant count reaches 2+.
167
+ // The flag is sticky and only reset on IDLE.
168
+ if (Array.isArray(value) && value.length >= 2) {
169
+ setHasEverBeenMulti(true);
170
+ }
145
171
  };
146
172
  const handleCallMediaTypeChange = (value) => {
147
173
  setCallType(value);
@@ -258,6 +284,51 @@ export default function TUICallKit(props: ITUICallKitProps) {
258
284
  onMinimized,
259
285
  ]);
260
286
 
287
+ // When the layout switches from single-user to multi-user mid-call, the old
288
+ // SingleCall unmounts and its <div id={domId}> nodes are destroyed along with
289
+ // the <video> elements the SDK attached inside them. The onUserVideoAvailable /
290
+ // camera-open events do NOT replay, so the new GroupCall would render empty
291
+ // video tiles.
292
+ //
293
+ // Fix: after the new DOM is mounted, re-bind local and remote streams to the
294
+ // new container nodes. The switch is one-way (sticky), so this runs at most
295
+ // once per call. Failures here should never block the UI, only log a warning.
296
+ useEffect(() => {
297
+ if (!isMultiUserLayout) return;
298
+ let cancelled = false;
299
+ (async () => {
300
+ // 1. Re-attach local camera preview to the new local pusher <div>.
301
+ const localSnapshot = TUIStore.getData(StoreName.CALL, NAME.LOCAL_USER_INFO_EXCLUDE_VOLUMN);
302
+ if (localSnapshot?.isVideoAvailable) {
303
+ try {
304
+ await TUICallKitAPI.openCamera(NAME.LOCAL_VIDEO);
305
+ } catch (error) {
306
+ console.warn('[TUICallKit] re-openCamera after layout switch failed:', error);
307
+ }
308
+ }
309
+ if (cancelled) return;
310
+ // 2. Re-attach each active remote video stream to its new player <div>.
311
+ const engine = TUICallKitAPI.getTUICallEngineInstance?.();
312
+ if (!engine?.startRemoteView) return;
313
+ const displayMode = TUIStore.getData(StoreName.CALL, NAME.DISPLAY_MODE);
314
+ const remoteSnapshot = TUIStore.getData(StoreName.CALL, NAME.REMOTE_USER_INFO_EXCLUDE_VOLUMN_LIST) || [];
315
+ for (const remoteUser of remoteSnapshot) {
316
+ if (cancelled) return;
317
+ if (!remoteUser?.isVideoAvailable || !remoteUser?.userId) continue;
318
+ try {
319
+ await engine.startRemoteView({
320
+ userID: remoteUser.userId,
321
+ videoViewDomID: remoteUser.userId,
322
+ options: { objectFit: displayMode },
323
+ });
324
+ } catch (error) {
325
+ console.warn(`[TUICallKit] re-startRemoteView for ${remoteUser.userId} failed:`, error);
326
+ }
327
+ }
328
+ })();
329
+ return () => { cancelled = true; };
330
+ }, [isMultiUserLayout]);
331
+
261
332
  return callStatus !== CallStatus.IDLE && (
262
333
  <UserInfoContext.Provider value={UserInfoContextValue}>
263
334
  <CallerUserInfoContext.Provider value={callerUserInfo}>
@@ -265,15 +336,16 @@ export default function TUICallKit(props: ITUICallKitProps) {
265
336
  <CustomUIConfigContext.Provider value={customUIConfigContextValue}>
266
337
  <TranslateContext.Provider value={translateContextValue}>
267
338
  <div style={{ ...style }} className={classnames} id='tuicallkit-id'>
268
- {!isGroupCall && <SingleCall />}
269
- {isGroupCall && <GroupCall />}
339
+ {!isMultiUserLayout && <SingleCall />}
340
+ {isMultiUserLayout && <GroupCall />}
270
341
  <AutoPlayDialog />
342
+ <DevicePermissionBanner />
271
343
  <AISubtitle />
272
344
  </div>
273
345
  <Portal attach="body">
274
346
  <FloatingWindow
275
347
  show={isFloat}
276
- hasVideo={isGroupCall
348
+ hasVideo={isMultiUserLayout
277
349
  ? speakerIdList.length !== 0
278
350
  : callType === CallMediaType.VIDEO}
279
351
  isAudioAvailable={localUserInfo.isAudioAvailable}
@@ -26,7 +26,7 @@ const isMobile = !TUIGlobal.isPC;
26
26
 
27
27
  export default function ButtonPanel() {
28
28
  const {
29
- callStatus, isGroupCall, callRole, callType, isShowEnableVirtualBackground,
29
+ callStatus, isMultiUserLayout, callRole, callType, isShowEnableVirtualBackground,
30
30
  } = useContext(CallInfoContext);
31
31
  const { localUserInfo } = useContext(UserInfoContext);
32
32
  const customButtonUIConfig = useCustomButtonUIConfig();
@@ -98,7 +98,7 @@ export default function ButtonPanel() {
98
98
  });
99
99
 
100
100
  const platform = TUIGlobal.isPC ? 'pc' : 'h5';
101
- const callScene = isGroupCall ? 'groupCall' : 'singleCall';
101
+ const callScene = isMultiUserLayout ? 'groupCall' : 'singleCall';
102
102
  const mediaType = callType === CallMediaType.AUDIO ? 'audio' : 'video';
103
103
  const status = callStatus === CallStatus.CALLING
104
104
  ? callRole === CallRole.CALLER ? 'calling' : 'accept'
@@ -110,7 +110,7 @@ export default function ButtonPanel() {
110
110
 
111
111
  return rs;
112
112
  }, [
113
- isGroupCall,
113
+ isMultiUserLayout,
114
114
  callType,
115
115
  callStatus,
116
116
  callRole,
@@ -22,7 +22,7 @@ interface IButtonPanelH5Props {
22
22
  }
23
23
  export default function ButtonPanelH5(props: IButtonPanelH5Props) {
24
24
  const { buttons = [] } = props;
25
- const { isGroupCall } = useContext(CallInfoContext);
25
+ const { isMultiUserLayout } = useContext(CallInfoContext);
26
26
  const { focusItem } = useContext(FocusItemContext);
27
27
  const [isPanelOpen, setIsPanelOpen] = useState(true);
28
28
  useEffect(() => {
@@ -38,7 +38,7 @@ export default function ButtonPanelH5(props: IButtonPanelH5Props) {
38
38
  buttonPanelH5Style['button-panel-h5-container'],
39
39
  {
40
40
  [buttonPanelH5Style.open]: isPanelOpen,
41
- [buttonPanelH5Style.groupcall]: isGroupCall,
41
+ [buttonPanelH5Style.groupcall]: isMultiUserLayout,
42
42
  [buttonPanelH5Style['show-panel-background']]: showBtnPanelBackground,
43
43
  },
44
44
  ]);
@@ -127,7 +127,7 @@ export default function ButtonPanelH5(props: IButtonPanelH5Props) {
127
127
  return <Row>{renderButtonPanelCol()}</Row>;
128
128
  };
129
129
  const renderButtonPanel = () => {
130
- if (isGroupCall && !isPanelOpen) {
130
+ if (isMultiUserLayout && !isPanelOpen) {
131
131
  return renderCloseButtonPanel();
132
132
  }
133
133
 
@@ -140,8 +140,8 @@ export default function ButtonPanelH5(props: IButtonPanelH5Props) {
140
140
  <div
141
141
  className={classNames([
142
142
  {
143
- [buttonPanelH5Style['groupcall-panel']]: isGroupCall,
144
- [buttonPanelH5Style['singlecall-panel']]: !isGroupCall,
143
+ [buttonPanelH5Style['groupcall-panel']]: isMultiUserLayout,
144
+ [buttonPanelH5Style['singlecall-panel']]: !isMultiUserLayout,
145
145
  [buttonPanelH5Style.close]: !isPanelOpen,
146
146
  },
147
147
  ])}
@@ -1,6 +1,7 @@
1
1
  import React, {
2
2
  useCallback,
3
3
  useContext,
4
+ useEffect,
4
5
  useMemo,
5
6
  useState,
6
7
  } from 'react';
@@ -9,9 +10,11 @@ import Icon from '../../../base/Icon/Icon';
9
10
  import { classNames } from '../../../../util/classnames';
10
11
  import Popup from '../../../base/Popup/Popup';
11
12
  import DeviceSelector from '../../DeviceSelector/DeviceSelector';
12
- import { DeviceType } from '../../../../../TUICallService/const';
13
+ import { DeviceType, NAME } from '../../../../../TUICallService/const';
13
14
  import { UserInfoContext } from '../../../../context';
14
- import { TUICallKitAPI, TUIGlobal } from '../../../../../TUICallService';
15
+ import {
16
+ TUICallKitAPI, TUIGlobal, TUIStore, StoreName,
17
+ } from '../../../../../TUICallService';
15
18
  import { useTranslate } from '../../../../hooks';
16
19
  import CameraOpenSrc from '../../../../assets/button/camera-open.svg';
17
20
  import CameraCloseSrc from '../../../../assets/button/camera-close.svg';
@@ -32,8 +35,42 @@ export default function Camera(props: ICameraProps) {
32
35
  const [isClickable, setIsClickable] = useState(true);
33
36
  const { t } = useTranslate();
34
37
  const [{ deviceList }] = useDeviceList(DeviceType.CAMERA);
38
+
39
+ // Subscribe to the unavailable-device reasons so the camera button can show a
40
+ // red badge and intercept clicks when the camera is unavailable.
41
+ const [
42
+ deviceUnavailableReasons,
43
+ setDeviceUnavailableReasons,
44
+ ] = useState(TUIStore.getData(StoreName.CALL, NAME.DEVICE_UNAVAILABLE_REASONS));
45
+ useEffect(() => {
46
+ const handleChange = (value) => setDeviceUnavailableReasons(value);
47
+ TUIStore.watch(StoreName.CALL, {
48
+ [NAME.DEVICE_UNAVAILABLE_REASONS]: handleChange,
49
+ }, { notifyRangeWhenWatch: NAME.MYSELF });
50
+ return () => {
51
+ TUIStore.unwatch(StoreName.CALL, {
52
+ [NAME.DEVICE_UNAVAILABLE_REASONS]: handleChange,
53
+ });
54
+ };
55
+ }, []);
56
+ const cameraReason = deviceUnavailableReasons?.camera;
57
+ const isDeviceUnavailable = !!cameraReason;
58
+ const isMobile = !TUIGlobal.isPC;
59
+
35
60
  const handleClick = useCallback(
36
61
  async () => {
62
+ // When the camera is unavailable, intercept the click and show a toast
63
+ // guiding the user instead of toggling the camera.
64
+ if (isDeviceUnavailable) {
65
+ let tipKey = 'permission-btn-cam-no-device';
66
+ if (cameraReason === 'no-permission') {
67
+ tipKey = isMobile ? 'permission-btn-cam-no-perm-h5' : 'permission-btn-cam-no-perm';
68
+ } else if (cameraReason === 'device-occupied') {
69
+ tipKey = 'permission-btn-cam-occupied';
70
+ }
71
+ TUIStore.update(StoreName.CALL, NAME.TOAST_INFO, { content: tipKey, type: NAME.ERROR });
72
+ return;
73
+ }
37
74
  setIsClickable(false);
38
75
 
39
76
  if (isCameraOpen) {
@@ -44,7 +81,7 @@ export default function Camera(props: ICameraProps) {
44
81
 
45
82
  setIsClickable(true);
46
83
  },
47
- [isCameraOpen],
84
+ [isCameraOpen, isDeviceUnavailable, cameraReason, isMobile],
48
85
  );
49
86
  const iconClassName = classNames([
50
87
  commonStyle['btn-icon-container'],
@@ -56,12 +93,18 @@ export default function Camera(props: ICameraProps) {
56
93
  },
57
94
  ]);
58
95
  const CameraIcon = <Icon url={isCameraOpen ? CameraOpenSrc : CameraCloseSrc} className={commonStyle['btn-icon']} />;
59
- const btnText = isCameraOpen ? t('camera enabled') : t('camera disabled');
96
+ let btnText = isCameraOpen ? t('camera enabled') : t('camera disabled');
97
+ if (isDeviceUnavailable) {
98
+ btnText = cameraReason === 'no-permission' ? t('no cam permission') : t('no cam device');
99
+ }
100
+ const btnTextNode = isDeviceUnavailable
101
+ ? <span style={{ color: '#FE3C44' }}>{btnText}</span>
102
+ : btnText;
60
103
 
61
104
  return (
62
105
  <Popup
63
106
  content={<DeviceSelector deviceType={DeviceType.CAMERA} />}
64
- disabled={!TUIGlobal.isPC || !(deviceList?.length > 0)}
107
+ disabled={!TUIGlobal.isPC || isDeviceUnavailable || !(deviceList?.length > 0)}
65
108
  teleported={false}
66
109
  >
67
110
  <div style={{ display: 'flex' }}>
@@ -73,7 +116,7 @@ export default function Camera(props: ICameraProps) {
73
116
  onClick={handleClick}
74
117
  {...props}
75
118
  >
76
- {btnText}
119
+ {btnTextNode}
77
120
  </Button>
78
121
  </div>
79
122
  </Popup>
@@ -1,18 +1,21 @@
1
1
  import React, {
2
2
  useCallback,
3
3
  useContext,
4
+ useEffect,
4
5
  useMemo,
5
6
  useState,
6
7
  } from 'react';
7
8
  import Button from '../../../base/Button';
8
9
  import Icon from '../../../base/Icon/Icon';
9
- import { TUICallKitAPI, TUIGlobal } from '../../../../../TUICallService';
10
+ import {
11
+ TUICallKitAPI, TUIGlobal, TUIStore, StoreName,
12
+ } from '../../../../../TUICallService';
10
13
  import { UserInfoContext } from '../../../../context';
11
14
  import { classNames } from '../../../../util/classnames';
12
15
  import Popup from '../../../base/Popup/Popup';
13
16
  import DeviceSelector from '../../DeviceSelector/DeviceSelector';
14
17
  import { useTranslate, useDeviceList } from '../../../../hooks';
15
- import { DeviceType } from '../../../../../TUICallService/const';
18
+ import { DeviceType, NAME } from '../../../../../TUICallService/const';
16
19
  import MicrophoneOpenSrc from '../../../../assets/button/microphone-open.svg';
17
20
  import MicrophoneCloseSrc from '../../../../assets/button/microphone-close.svg';
18
21
  import { classPrefix } from '../../../base/common';
@@ -29,8 +32,41 @@ export default function Microphone(props: IMicrophoneProps) {
29
32
  const [isClickable, setIsClickable] = useState(true);
30
33
  const { t } = useTranslate();
31
34
 
35
+ // Subscribe to the unavailable-device reasons so the microphone button can show
36
+ // a red badge and intercept clicks when the microphone is unavailable.
37
+ const [
38
+ deviceUnavailableReasons,
39
+ setDeviceUnavailableReasons,
40
+ ] = useState(TUIStore.getData(StoreName.CALL, NAME.DEVICE_UNAVAILABLE_REASONS));
41
+ useEffect(() => {
42
+ const handleChange = (value) => setDeviceUnavailableReasons(value);
43
+ TUIStore.watch(StoreName.CALL, {
44
+ [NAME.DEVICE_UNAVAILABLE_REASONS]: handleChange,
45
+ }, { notifyRangeWhenWatch: NAME.MYSELF });
46
+ return () => {
47
+ TUIStore.unwatch(StoreName.CALL, {
48
+ [NAME.DEVICE_UNAVAILABLE_REASONS]: handleChange,
49
+ });
50
+ };
51
+ }, []);
52
+ const microphoneReason = deviceUnavailableReasons?.microphone;
53
+ const isDeviceUnavailable = !!microphoneReason;
54
+ const isMobile = !TUIGlobal.isPC;
55
+
32
56
  const handleClick = useCallback(
33
57
  async () => {
58
+ // When the microphone is unavailable, intercept the click and show a toast
59
+ // guiding the user instead of toggling the microphone.
60
+ if (isDeviceUnavailable) {
61
+ let tipKey = 'permission-btn-mic-no-device';
62
+ if (microphoneReason === 'no-permission') {
63
+ tipKey = isMobile ? 'permission-btn-mic-no-perm-h5' : 'permission-btn-mic-no-perm';
64
+ } else if (microphoneReason === 'device-occupied') {
65
+ tipKey = 'permission-btn-mic-occupied';
66
+ }
67
+ TUIStore.update(StoreName.CALL, NAME.TOAST_INFO, { content: tipKey, type: NAME.ERROR });
68
+ return;
69
+ }
34
70
  setIsClickable(false);
35
71
 
36
72
  if (isOpen) {
@@ -41,7 +77,7 @@ export default function Microphone(props: IMicrophoneProps) {
41
77
 
42
78
  setIsClickable(true);
43
79
  },
44
- [isOpen, setIsClickable],
80
+ [isOpen, setIsClickable, isDeviceUnavailable, microphoneReason, isMobile],
45
81
  );
46
82
  const iconClassName = classNames([
47
83
  commonStyle['btn-icon-container'],
@@ -52,13 +88,19 @@ export default function Microphone(props: IMicrophoneProps) {
52
88
  disabled: !isClickable,
53
89
  },
54
90
  ]);
55
- const btnText = isOpen ? t('microphone enabled') : t('microphone disabled');
91
+ let btnText = isOpen ? t('microphone enabled') : t('microphone disabled');
92
+ if (isDeviceUnavailable) {
93
+ btnText = microphoneReason === 'no-permission' ? t('no mic permission') : t('no mic device');
94
+ }
95
+ const btnTextNode = isDeviceUnavailable
96
+ ? <span style={{ color: '#FE3C44' }}>{btnText}</span>
97
+ : btnText;
56
98
  const MicrophoneIcon = <Icon className={commonStyle['btn-icon']} url={isOpen ? MicrophoneOpenSrc : MicrophoneCloseSrc}/>;
57
99
 
58
100
  return (
59
101
  <Popup
60
102
  content={<DeviceSelector deviceType={DeviceType.MICROPHONE} />}
61
- disabled={!TUIGlobal.isPC || !(deviceList?.length > 0)}
103
+ disabled={!TUIGlobal.isPC || isDeviceUnavailable || !(deviceList?.length > 0)}
62
104
  teleported={false}
63
105
  >
64
106
  <div style={{ display: 'flex' }}>
@@ -70,7 +112,7 @@ export default function Microphone(props: IMicrophoneProps) {
70
112
  onClick={handleClick}
71
113
  {...props}
72
114
  >
73
- {btnText}
115
+ {btnTextNode}
74
116
  </Button>
75
117
  </div>
76
118
  </Popup>
@@ -13,7 +13,7 @@ interface IFloatingWindowProps {
13
13
  }
14
14
 
15
15
  export default function FloatingWindow(props: IFloatingWindowProps) {
16
- const { isGroupCall } = useContext(CallInfoContext);
16
+ const { isMultiUserLayout } = useContext(CallInfoContext);
17
17
  // React 19's useRef removed the no-argument overload; pass undefined explicitly.
18
18
  const nodeRef = useRef<any>(undefined);
19
19
  const [clickable, setClickable] = useState(true);
@@ -28,7 +28,7 @@ export default function FloatingWindow(props: IFloatingWindowProps) {
28
28
  >
29
29
  <div ref={nodeRef} style={{ visibility }}>
30
30
  {
31
- isGroupCall
31
+ isMultiUserLayout
32
32
  ? <FloatingWindowGroupCall clickable={clickable} {...props} />
33
33
  : <FloatingWindowSingleCall clickable={clickable} {...props} />
34
34
  }
@@ -0,0 +1,55 @@
1
+ .device-permission-banner {
2
+ position: absolute;
3
+ top: 50px;
4
+ left: 50%;
5
+ transform: translateX(-50%);
6
+ z-index: 40;
7
+ display: flex;
8
+ align-items: center;
9
+ gap: 6px;
10
+ background: rgba(0, 0, 0, 0.75);
11
+ backdrop-filter: blur(6px);
12
+ color: #fff;
13
+ border-radius: 6px;
14
+ padding: 6px 28px 6px 10px;
15
+ font-size: 12px;
16
+ line-height: 1.5;
17
+ max-width: 80%;
18
+ pointer-events: auto;
19
+
20
+ .banner-dot {
21
+ width: 5px;
22
+ height: 5px;
23
+ border-radius: 50%;
24
+ background: #e6a23c;
25
+ flex: 0 0 auto;
26
+ }
27
+
28
+ .banner-text {
29
+ flex: 1;
30
+ min-width: 0;
31
+ white-space: nowrap;
32
+ overflow: hidden;
33
+ text-overflow: ellipsis;
34
+ }
35
+
36
+ .banner-close {
37
+ position: absolute;
38
+ top: 50%;
39
+ right: 5px;
40
+ transform: translateY(-50%);
41
+ cursor: pointer;
42
+ color: rgba(255, 255, 255, 0.45);
43
+ font-size: 11px;
44
+ line-height: 1;
45
+ width: 14px;
46
+ height: 14px;
47
+ display: flex;
48
+ align-items: center;
49
+ justify-content: center;
50
+
51
+ &:hover {
52
+ color: #fff;
53
+ }
54
+ }
55
+ }
@@ -0,0 +1,78 @@
1
+ import React, { useEffect, useState } from 'react';
2
+ import {
3
+ TUIStore,
4
+ StoreName,
5
+ NAME,
6
+ TUIGlobal,
7
+ CallStatus,
8
+ buildDeviceUnavailableText,
9
+ } from '../../../../TUICallService';
10
+ import type { DeviceUnavailableReasons } from '../../../../TUICallService/const';
11
+ import bannerStyle from './DevicePermissionBanner.module.scss';
12
+
13
+ // Top banner that surfaces unavailable-device reasons (no permission / no device /
14
+ // occupied) during a call. Driven by the DEVICE_UNAVAILABLE_REASONS store written
15
+ // by the service layer after calls/accept.
16
+ export default function DevicePermissionBanner() {
17
+ const isMobile = !TUIGlobal.isPC;
18
+ const [
19
+ deviceUnavailableReasons,
20
+ setDeviceUnavailableReasons,
21
+ ] = useState<DeviceUnavailableReasons | null>(TUIStore.getData(StoreName.CALL, NAME.DEVICE_UNAVAILABLE_REASONS));
22
+ const [
23
+ visible,
24
+ setVisible,
25
+ ] = useState<boolean>(!!TUIStore.getData(StoreName.CALL, NAME.DEVICE_UNAVAILABLE_REASONS));
26
+
27
+ const handleDeviceUnavailableReasonsChange = (value: DeviceUnavailableReasons | null) => {
28
+ setDeviceUnavailableReasons(value);
29
+ if (value) {
30
+ setVisible(true);
31
+ }
32
+ };
33
+
34
+ // Reset banner when the call ends.
35
+ const handleCallStatusChange = (value) => {
36
+ if (value === CallStatus.IDLE) {
37
+ setVisible(false);
38
+ setDeviceUnavailableReasons(null);
39
+ }
40
+ };
41
+
42
+ useEffect(() => {
43
+ TUIStore.watch(StoreName.CALL, {
44
+ [NAME.DEVICE_UNAVAILABLE_REASONS]: handleDeviceUnavailableReasonsChange,
45
+ [NAME.CALL_STATUS]: handleCallStatusChange,
46
+ }, {
47
+ notifyRangeWhenWatch: NAME.MYSELF,
48
+ });
49
+
50
+ return () => {
51
+ TUIStore.unwatch(StoreName.CALL, {
52
+ [NAME.DEVICE_UNAVAILABLE_REASONS]: handleDeviceUnavailableReasonsChange,
53
+ [NAME.CALL_STATUS]: handleCallStatusChange,
54
+ });
55
+ };
56
+ }, []);
57
+
58
+ if (!visible || !deviceUnavailableReasons) {
59
+ return null;
60
+ }
61
+
62
+ // Group devices per reason so mixed cases (e.g. mic no-permission + camera
63
+ // no-device) describe each device accurately instead of a single wrong sentence.
64
+ const bannerText = buildDeviceUnavailableText(
65
+ deviceUnavailableReasons.microphone,
66
+ deviceUnavailableReasons.camera,
67
+ 'banner',
68
+ isMobile,
69
+ );
70
+
71
+ return (
72
+ <div className={bannerStyle['device-permission-banner']}>
73
+ <span className={bannerStyle['banner-dot']} />
74
+ <span className={bannerStyle['banner-text']}>{bannerText}</span>
75
+ <span className={bannerStyle['banner-close']} onClick={() => setVisible(false)}>✕</span>
76
+ </div>
77
+ );
78
+ }
@@ -0,0 +1,3 @@
1
+ import DevicePermissionBanner from './DevicePermissionBanner';
2
+
3
+ export default DevicePermissionBanner;
@@ -32,13 +32,13 @@ export default function StreamInfoH5(props: IStreamInfoH5Props) {
32
32
  showSwitchCamera = false,
33
33
  showNetWorkStatus = false,
34
34
  } = props;
35
- const { isGroupCall } = useContext(CallInfoContext);
35
+ const { isMultiUserLayout } = useContext(CallInfoContext);
36
36
  const classnames = classNames(
37
37
  [
38
38
  streamInfoH5Style['stream-info-container-h5'],
39
39
  {
40
- [streamInfoH5Style.group]: isGroupCall,
41
- [streamInfoH5Style.single]: !isGroupCall,
40
+ [streamInfoH5Style.group]: isMultiUserLayout,
41
+ [streamInfoH5Style.single]: !isMultiUserLayout,
42
42
  },
43
43
  ],
44
44
  );
@@ -89,7 +89,7 @@ export default function StreamInfoH5(props: IStreamInfoH5Props) {
89
89
 
90
90
  return (
91
91
  <div className={classnames}>
92
- {isGroupCall ? renderGroupCallStreamInfo() : renderSingleCallStreamInfo()}
92
+ {isMultiUserLayout ? renderGroupCallStreamInfo() : renderSingleCallStreamInfo()}
93
93
  </div>
94
94
  );
95
95
  }
@@ -17,12 +17,12 @@ interface ITopBarH5Props {
17
17
  export default function TopBarH5(props: ITopBarH5Props) {
18
18
  const { callDuration } = props;
19
19
  const {
20
- isGroupCall, callStatus, callRole, allowedMinimized,
20
+ isMultiUserLayout, callStatus, callRole, allowedMinimized,
21
21
  } = useContext(CallInfoContext);
22
22
 
23
23
  const { isAITranscriberEnabled } = useAIAssistant();
24
24
 
25
- const showTip = (isGroupCall
25
+ const showTip = (isMultiUserLayout
26
26
  && (callStatus === CallStatus.CONNECTED || callRole === CallRole.CALLER));
27
27
  const showAITranscriberSwitch = callStatus === CallStatus.CONNECTED && isAITranscriberEnabled;
28
28
 
@@ -15,7 +15,7 @@ interface ITopBarPCProps {
15
15
  export default function TopBarPC(props: ITopBarPCProps) {
16
16
  const { callDuration } = props;
17
17
  const {
18
- isGroupCall,
18
+ isMultiUserLayout,
19
19
  callStatus,
20
20
  allowedMinimized,
21
21
  allowedFullScreen,
@@ -23,7 +23,7 @@ export default function TopBarPC(props: ITopBarPCProps) {
23
23
 
24
24
  const { isAITranscriberEnabled } = useAIAssistant();
25
25
 
26
- const showTime = callStatus === CallStatus.CONNECTED && !isGroupCall;
26
+ const showTime = callStatus === CallStatus.CONNECTED && !isMultiUserLayout;
27
27
  const showAITranscriberSwitch = callStatus === CallStatus.CONNECTED && isAITranscriberEnabled;
28
28
 
29
29
  return (
@@ -8,6 +8,9 @@ interface ICallInfoContext {
8
8
  isEarPhone?: boolean;
9
9
  isClickable?: boolean;
10
10
  isGroupCall?: boolean;
11
+ // Whether the UI should render in multi-user layout.
12
+ // Rule: true if isGroupCall, or remoteUserList size >= 2, once true it sticks until call ends.
13
+ isMultiUserLayout?: boolean;
11
14
  isFloat?: boolean;
12
15
  allowedMinimized?: boolean;
13
16
  allowedFullScreen?: boolean;