@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.
- package/package.json +2 -2
- package/src/Components/TUICallKit/TUICallKit.tsx +75 -3
- package/src/Components/components/common/ButtonPanel/ButtonPanel.tsx +3 -3
- package/src/Components/components/common/ButtonPanel/ButtonPanelH5/ButtonPanelH5.tsx +5 -5
- package/src/Components/components/common/ButtonPanel/button/Camera.tsx +49 -6
- package/src/Components/components/common/ButtonPanel/button/Microphone.tsx +48 -6
- package/src/Components/components/common/FloatingWindow/FloatingWindow.tsx +2 -2
- package/src/Components/components/common/PermissionDialog/DevicePermissionBanner.module.scss +55 -0
- package/src/Components/components/common/PermissionDialog/DevicePermissionBanner.tsx +78 -0
- package/src/Components/components/common/PermissionDialog/index.ts +3 -0
- package/src/Components/components/common/StreamInfo/StreamInfoH5/StreamInfoH5.tsx +4 -4
- package/src/Components/components/common/TopBar/h5/TopBarH5.tsx +2 -2
- package/src/Components/components/common/TopBar/pc/TopBarPC.tsx +2 -2
- package/src/Components/context/CallUserInfoContext.ts +3 -0
- package/src/TUICallService/CallService/index.ts +153 -9
- package/src/TUICallService/CallService/utils.ts +35 -23
- package/src/TUICallService/TUIStore/callStore.ts +1 -0
- package/src/TUICallService/const/index.ts +27 -0
- package/src/TUICallService/index.ts +2 -1
- package/src/TUICallService/interface/ICallStore.ts +2 -1
- package/src/TUICallService/locales/en.ts +26 -0
- package/src/TUICallService/locales/index.ts +66 -0
- package/src/TUICallService/locales/ja_JP.ts +26 -0
- package/src/TUICallService/locales/zh-cn.ts +35 -0
- package/src/index.ts +1 -1
- package/tuicall-uikit-react.es.js +3658 -3285
- package/tuicall-uikit-react.umd.js +4 -4
- package/types/Components/components/common/PermissionDialog/DevicePermissionBanner.d.ts +1 -0
- package/types/Components/components/common/PermissionDialog/index.d.ts +2 -0
- package/types/Components/context/CallUserInfoContext.d.ts +1 -0
- package/types/TUICallService/CallService/index.d.ts +30 -0
- package/types/TUICallService/const/index.d.ts +13 -0
- package/types/TUICallService/index.d.ts +2 -2
- package/types/TUICallService/interface/ICallStore.d.ts +2 -1
- package/types/TUICallService/locales/en.d.ts +25 -0
- package/types/TUICallService/locales/index.d.ts +14 -0
- package/types/TUICallService/locales/ja_JP.d.ts +25 -0
- 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.
|
|
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
|
|
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
|
-
{!
|
|
269
|
-
{
|
|
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={
|
|
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,
|
|
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 =
|
|
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
|
-
|
|
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 {
|
|
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]:
|
|
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 (
|
|
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']]:
|
|
144
|
-
[buttonPanelH5Style['singlecall-panel']]: !
|
|
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 {
|
|
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
|
-
|
|
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
|
-
{
|
|
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 {
|
|
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
|
-
|
|
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
|
-
{
|
|
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 {
|
|
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
|
-
|
|
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
|
+
}
|
|
@@ -32,13 +32,13 @@ export default function StreamInfoH5(props: IStreamInfoH5Props) {
|
|
|
32
32
|
showSwitchCamera = false,
|
|
33
33
|
showNetWorkStatus = false,
|
|
34
34
|
} = props;
|
|
35
|
-
const {
|
|
35
|
+
const { isMultiUserLayout } = useContext(CallInfoContext);
|
|
36
36
|
const classnames = classNames(
|
|
37
37
|
[
|
|
38
38
|
streamInfoH5Style['stream-info-container-h5'],
|
|
39
39
|
{
|
|
40
|
-
[streamInfoH5Style.group]:
|
|
41
|
-
[streamInfoH5Style.single]: !
|
|
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
|
-
{
|
|
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
|
-
|
|
20
|
+
isMultiUserLayout, callStatus, callRole, allowedMinimized,
|
|
21
21
|
} = useContext(CallInfoContext);
|
|
22
22
|
|
|
23
23
|
const { isAITranscriberEnabled } = useAIAssistant();
|
|
24
24
|
|
|
25
|
-
const showTip = (
|
|
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
|
-
|
|
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 && !
|
|
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;
|