@trtc/calls-uikit-react 4.5.11 → 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 +2 -0
- 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/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/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 +3442 -3110
- 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/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,
|
|
@@ -338,6 +339,7 @@ export default function TUICallKit(props: ITUICallKitProps) {
|
|
|
338
339
|
{!isMultiUserLayout && <SingleCall />}
|
|
339
340
|
{isMultiUserLayout && <GroupCall />}
|
|
340
341
|
<AutoPlayDialog />
|
|
342
|
+
<DevicePermissionBanner />
|
|
341
343
|
<AISubtitle />
|
|
342
344
|
</div>
|
|
343
345
|
<Portal attach="body">
|
|
@@ -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>
|
|
@@ -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
|
+
}
|
|
@@ -4,13 +4,13 @@ import {
|
|
|
4
4
|
import {
|
|
5
5
|
StoreName, CallStatus, CallMediaType, NAME, CALL_DATA_KEY, LanguageType, CallRole, LOG_LEVEL, VideoDisplayMode,
|
|
6
6
|
VideoResolution, StatusChange, AudioPlayBackDevice, CameraPosition, COMPONENT, FeatureButton, ButtonState,
|
|
7
|
-
LayoutMode, DEFAULT_BLUR_LEVEL,
|
|
7
|
+
LayoutMode, DEFAULT_BLUR_LEVEL, DEVICE_ERROR_CODE, DeviceErrorReason, DeviceUnavailableReasons,
|
|
8
8
|
} from '../const/index';
|
|
9
9
|
// @ts-ignore
|
|
10
10
|
import { TUICallEngine } from '@trtc/call-engine-lite-js';
|
|
11
11
|
import { checkLocalMP3FileExists } from '../utils/index';
|
|
12
12
|
import { handleModalError } from './UIKitModal';
|
|
13
|
-
import { CallTips, t } from '../locales/index';
|
|
13
|
+
import { CallTips, t, buildDeviceUnavailableText } from '../locales/index';
|
|
14
14
|
import { BellContext } from './bellContext';
|
|
15
15
|
import { avoidRepeatedCall } from '../utils/validate/index';
|
|
16
16
|
import { handleRepeatedCallError, formatTime, performanceNow, initBrowserCloseDetection } from '../utils/common-utils';
|
|
@@ -27,7 +27,7 @@ const TUIGlobal: ITUIGlobal = TuiGlobal.getInstance();
|
|
|
27
27
|
const TUIStore: ITUIStore = TuiStore.getInstance();
|
|
28
28
|
const uiDesign = UIDesign.getInstance();
|
|
29
29
|
uiDesign.setTUIStore(TUIStore);
|
|
30
|
-
const version = '4.
|
|
30
|
+
const version = '4.6.0';
|
|
31
31
|
import AIAssistant from './AIAssistant'; // 仅 web 支持 AI 实时字幕
|
|
32
32
|
const frameWork = 'react';
|
|
33
33
|
export { TUIGlobal, TUIStore, uiDesign };
|
|
@@ -45,6 +45,11 @@ export default class TUICallService {
|
|
|
45
45
|
private _currentGroupId: string = ''; // The currentGroupId of the group chat that the user is currently in
|
|
46
46
|
private _offlinePushInfo = null;
|
|
47
47
|
private _permissionCheckTimer: any = null;
|
|
48
|
+
// Device permission error codes maintained by the service layer (0 = no error).
|
|
49
|
+
// Populated by catching the error thrown from engine's device permission check,
|
|
50
|
+
// replacing the removed engine-side audioErrorName/videoErrorName fields.
|
|
51
|
+
private _deviceErrorCodes = { audio: DEVICE_ERROR_CODE.NONE, video: DEVICE_ERROR_CODE.NONE };
|
|
52
|
+
|
|
48
53
|
private _chatCombine: any = null;
|
|
49
54
|
private _engineEventHandler: any = null;
|
|
50
55
|
// wasm ready
|
|
@@ -217,6 +222,10 @@ export default class TUICallService {
|
|
|
217
222
|
const { userIDList, type, chatGroupID, offlinePushInfo } = callsParams;
|
|
218
223
|
if (TUIStore.getData(StoreName.CALL, NAME.CALL_STATUS) !== CallStatus.IDLE) return;
|
|
219
224
|
const remoteUserInfoList = userIDList.map(userId => ({ userId }));
|
|
225
|
+
// Engine no longer probes device permission internally, so the service layer
|
|
226
|
+
// must do it before the call. This keeps updateDeviceList and the post-call
|
|
227
|
+
// permission dialog working against the real device state.
|
|
228
|
+
await this.probeDevicePermission({ audio: true, video: type === CallMediaType.VIDEO });
|
|
220
229
|
await this._updateCallStoreBeforeCall(type, remoteUserInfoList, chatGroupID);
|
|
221
230
|
this.executeExternalBeforeCalling();
|
|
222
231
|
callsParams.offlinePushInfo = { ...this.getDefaultOfflinePushInfo(), ...offlinePushInfo };
|
|
@@ -240,18 +249,25 @@ export default class TUICallService {
|
|
|
240
249
|
};
|
|
241
250
|
TUIStore.updateStore(updateStoreParams, StoreName.CALL);
|
|
242
251
|
|
|
252
|
+
// Engine no longer probes device permission internally; probe here so the
|
|
253
|
+
// following updateDeviceList and audio availability read the real state.
|
|
254
|
+
await this.probeDevicePermission({
|
|
255
|
+
audio: true,
|
|
256
|
+
video: updateStoreParams[NAME.CALL_MEDIA_TYPE] === CallMediaType.VIDEO,
|
|
257
|
+
});
|
|
243
258
|
const response = await this._tuiCallEngine.join(params);
|
|
244
259
|
TUIStore.update(StoreName.CALL, NAME.IS_CLICKABLE, true);
|
|
245
260
|
this.startTimer();
|
|
246
261
|
|
|
247
|
-
updateDeviceList(this._tuiCallEngine)
|
|
262
|
+
updateDeviceList(this._tuiCallEngine);
|
|
248
263
|
await this._tuiCallEngine.setVideoQuality(TUIStore.getData(StoreName.CALL, NAME.VIDEO_RESOLUTION));
|
|
249
264
|
const localUserInfo = TUIStore.getData(StoreName.CALL, NAME.LOCAL_USER_INFO);
|
|
250
265
|
TUIStore.update(StoreName.CALL, NAME.LOCAL_USER_INFO, { ...localUserInfo, isEnter: true });
|
|
251
266
|
TUIStore.update(StoreName.CALL, NAME.LOCAL_USER_INFO_EXCLUDE_VOLUMN, { ...localUserInfo, isEnter: true });
|
|
252
267
|
|
|
253
268
|
await this.openMicrophone();
|
|
254
|
-
|
|
269
|
+
const joinPermission = this._tuiCallEngine?.getDevicePermission();
|
|
270
|
+
setLocalUserInfoAudioVideoAvailable(!!joinPermission?.audio, NAME.AUDIO);
|
|
255
271
|
|
|
256
272
|
// // Current policy: By default, the camera remains off when joining GroupCall
|
|
257
273
|
// if (TUIStore.getData(StoreName.CALL, NAME.CALL_MEDIA_TYPE) === CallMediaType.VIDEO) {
|
|
@@ -377,11 +393,29 @@ export default class TUICallService {
|
|
|
377
393
|
data: { callStatus },
|
|
378
394
|
});
|
|
379
395
|
if (callStatus === CallStatus.CONNECTED) return; // avoid double click when application stuck, especially for miniProgram
|
|
396
|
+
|
|
397
|
+
// Probe device permission so the following accept flow (updateDeviceList /
|
|
398
|
+
// openMicrophone / unavailable hints) runs against the real device state.
|
|
399
|
+
// Unavailable devices no longer block accept: we connect directly and surface
|
|
400
|
+
// a toast + banner afterwards instead of popping a confirm dialog.
|
|
401
|
+
const acceptMediaType = TUIStore.getData(StoreName.CALL, NAME.CALL_MEDIA_TYPE);
|
|
402
|
+
await this.probeDevicePermission({
|
|
403
|
+
audio: true,
|
|
404
|
+
video: acceptMediaType === CallMediaType.VIDEO,
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
await this._doAccept();
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// Perform the actual accept (called directly or after the accept-permission dialog)
|
|
411
|
+
public async _doAccept() {
|
|
380
412
|
try {
|
|
381
413
|
TUIStore.update(StoreName.CALL, NAME.CALL_STATUS, CallStatus.CONNECTED);
|
|
382
|
-
updateDeviceList(this._tuiCallEngine)
|
|
414
|
+
updateDeviceList(this._tuiCallEngine);
|
|
383
415
|
const response = await this._tuiCallEngine.accept();
|
|
384
416
|
await this._handleAcceptResponse(response);
|
|
417
|
+
const acceptCallMediaType = TUIStore.getData(StoreName.CALL, NAME.CALL_MEDIA_TYPE);
|
|
418
|
+
this._updateDeviceUnavailableHints(acceptCallMediaType);
|
|
385
419
|
} catch (error) {
|
|
386
420
|
this._tuiCallEngine?.reportLog?.({
|
|
387
421
|
name: 'TUICallKit.accept.fail',
|
|
@@ -406,7 +440,8 @@ export default class TUICallService {
|
|
|
406
440
|
const localUserInfo = TUIStore.getData(StoreName.CALL, NAME.LOCAL_USER_INFO);
|
|
407
441
|
TUIStore.update(StoreName.CALL, NAME.LOCAL_USER_INFO, { ...localUserInfo, isEnter: true });
|
|
408
442
|
TUIStore.update(StoreName.CALL, NAME.LOCAL_USER_INFO_EXCLUDE_VOLUMN, { ...localUserInfo, isEnter: true });
|
|
409
|
-
|
|
443
|
+
const acceptPermission = this._tuiCallEngine?.getDevicePermission();
|
|
444
|
+
setLocalUserInfoAudioVideoAvailable(!!acceptPermission?.audio, NAME.AUDIO);
|
|
410
445
|
}
|
|
411
446
|
}
|
|
412
447
|
@avoidRepeatedCall()
|
|
@@ -441,6 +476,11 @@ export default class TUICallService {
|
|
|
441
476
|
}
|
|
442
477
|
@avoidRepeatedCall()
|
|
443
478
|
public async openCamera(videoViewDomID: string) {
|
|
479
|
+
// Skip opening camera if video permission is already known to be unavailable
|
|
480
|
+
if (this.isDeviceKnownUnavailable(NAME.VIDEO as 'video')) {
|
|
481
|
+
console.warn(`${NAME.PREFIX}openCamera skipped: video device unavailable (code ${this._deviceErrorCodes.video}).`);
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
444
484
|
try {
|
|
445
485
|
if (TUIGlobal.isH5 || TUIGlobal.isWeChat) {
|
|
446
486
|
const currentPosition = TUIStore.getData(StoreName.CALL, NAME.CAMERA_POSITION);
|
|
@@ -466,6 +506,11 @@ export default class TUICallService {
|
|
|
466
506
|
}
|
|
467
507
|
@avoidRepeatedCall()
|
|
468
508
|
public async openMicrophone() {
|
|
509
|
+
// Skip opening microphone if audio permission is already known to be unavailable
|
|
510
|
+
if (this.isDeviceKnownUnavailable(NAME.AUDIO as 'audio')) {
|
|
511
|
+
console.warn(`${NAME.PREFIX}openMicrophone skipped: audio device unavailable (code ${this._deviceErrorCodes.audio}).`);
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
469
514
|
try {
|
|
470
515
|
await this._tuiCallEngine.openMicrophone();
|
|
471
516
|
setLocalUserInfoAudioVideoAvailable(true, NAME.AUDIO);
|
|
@@ -659,6 +704,102 @@ export default class TUICallService {
|
|
|
659
704
|
}
|
|
660
705
|
}
|
|
661
706
|
// =========================【private methods for service use】=========================
|
|
707
|
+
/**
|
|
708
|
+
* Check device permissions after call/accept succeeds.
|
|
709
|
+
* 1. Show an error toast summarizing unavailable devices.
|
|
710
|
+
* 2. Store the unavailable-device reasons so the banner bar and button badges
|
|
711
|
+
* can render accordingly (aligned with interaction design).
|
|
712
|
+
*/
|
|
713
|
+
private _updateDeviceUnavailableHints(callMediaType: CallMediaType): void {
|
|
714
|
+
const deviceUnavailableReasons = this._resolveUnavailableDevices(callMediaType);
|
|
715
|
+
if (!deviceUnavailableReasons) {
|
|
716
|
+
TUIStore.update(StoreName.CALL, NAME.DEVICE_UNAVAILABLE_REASONS, null);
|
|
717
|
+
return;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
// Build toast message by grouping devices per reason so mixed cases
|
|
721
|
+
// (e.g. mic no-permission + camera no-device) are described accurately.
|
|
722
|
+
const toastText = buildDeviceUnavailableText(deviceUnavailableReasons.microphone, deviceUnavailableReasons.camera, 'toast');
|
|
723
|
+
TUIStore.update(StoreName.CALL, NAME.TOAST_INFO, {
|
|
724
|
+
content: { key: 'permission-toast-raw', options: { text: toastText } },
|
|
725
|
+
type: NAME.ERROR,
|
|
726
|
+
});
|
|
727
|
+
|
|
728
|
+
// Store the unavailable-device reasons for banner bar + button badges
|
|
729
|
+
TUIStore.update(StoreName.CALL, NAME.DEVICE_UNAVAILABLE_REASONS, deviceUnavailableReasons);
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
/**
|
|
733
|
+
* Resolve the classified reasons for currently unavailable devices from the engine's
|
|
734
|
+
* cached permission. Returns null when all required devices are available; otherwise
|
|
735
|
+
* returns the per-device reason. Derived values (whether it is a permission issue,
|
|
736
|
+
* device-name text) are computed by the UI from these reasons.
|
|
737
|
+
*/
|
|
738
|
+
private _resolveUnavailableDevices(callMediaType: CallMediaType): DeviceUnavailableReasons | null {
|
|
739
|
+
const permission = this._tuiCallEngine?.getDevicePermission();
|
|
740
|
+
if (!permission) return null;
|
|
741
|
+
|
|
742
|
+
const noMicrophone = !permission.audio;
|
|
743
|
+
const noCamera = callMediaType === CallMediaType.VIDEO && !permission.video;
|
|
744
|
+
if (!noMicrophone && !noCamera) return null;
|
|
745
|
+
|
|
746
|
+
// Map the engine's audio/video permission to device-named reasons for the UI.
|
|
747
|
+
return {
|
|
748
|
+
microphone: noMicrophone ? this._classifyDeviceErrorReason(this._deviceErrorCodes.audio) : null,
|
|
749
|
+
camera: noCamera ? this._classifyDeviceErrorReason(this._deviceErrorCodes.video) : null,
|
|
750
|
+
};
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
private _classifyDeviceErrorReason(errorCode: number): DeviceErrorReason {
|
|
754
|
+
switch (errorCode) {
|
|
755
|
+
case DEVICE_ERROR_CODE.PERMISSION_DENIED: return 'no-permission';
|
|
756
|
+
case DEVICE_ERROR_CODE.NO_MICROPHONE:
|
|
757
|
+
case DEVICE_ERROR_CODE.NO_CAMERA: return 'no-device';
|
|
758
|
+
case DEVICE_ERROR_CODE.MICROPHONE_NOT_READABLE:
|
|
759
|
+
case DEVICE_ERROR_CODE.CAMERA_NOT_READABLE: return 'device-occupied';
|
|
760
|
+
default: return 'unknown';
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
/**
|
|
765
|
+
* Probe device permission by reusing engine's device permission check, which
|
|
766
|
+
* triggers getUserMedia and classifies the failure (incl. iOS repeated-call and
|
|
767
|
+
* http protocol handling). The thrown error's code is cached per device so the
|
|
768
|
+
* service layer can decide device availability without engine-side errorName.
|
|
769
|
+
* Audio and video are probed separately because engine aborts on the first failure.
|
|
770
|
+
*/
|
|
771
|
+
public async probeDevicePermission(opts: { audio?: boolean; video?: boolean }): Promise<void> {
|
|
772
|
+
if (opts.audio) {
|
|
773
|
+
try {
|
|
774
|
+
await this._tuiCallEngine?._checkDevicePermission({ audio: true });
|
|
775
|
+
this._deviceErrorCodes.audio = DEVICE_ERROR_CODE.NONE;
|
|
776
|
+
} catch (error: any) {
|
|
777
|
+
this._deviceErrorCodes.audio = error?.code ?? DEVICE_ERROR_CODE.NONE;
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
if (opts.video) {
|
|
781
|
+
try {
|
|
782
|
+
// isThrowVideoError is required, otherwise engine swallows the video error.
|
|
783
|
+
await this._tuiCallEngine?._checkDevicePermission({ video: true, isThrowVideoError: true });
|
|
784
|
+
this._deviceErrorCodes.video = DEVICE_ERROR_CODE.NONE;
|
|
785
|
+
} catch (error: any) {
|
|
786
|
+
this._deviceErrorCodes.video = error?.code ?? DEVICE_ERROR_CODE.NONE;
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
// Clear the cached device error so the next probe re-triggers the browser prompt.
|
|
792
|
+
public resetDeviceError(type: 'audio' | 'video'): void {
|
|
793
|
+
if (type === NAME.AUDIO) this._deviceErrorCodes.audio = DEVICE_ERROR_CODE.NONE;
|
|
794
|
+
if (type === NAME.VIDEO) this._deviceErrorCodes.video = DEVICE_ERROR_CODE.NONE;
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
// Whether a device is known to be unavailable (permission off / no device / occupied).
|
|
798
|
+
public isDeviceKnownUnavailable(type: 'audio' | 'video'): boolean {
|
|
799
|
+
const permission = this._tuiCallEngine?.getDevicePermission();
|
|
800
|
+
if (type === NAME.AUDIO) return !permission?.audio && this._deviceErrorCodes.audio !== DEVICE_ERROR_CODE.NONE;
|
|
801
|
+
return !permission?.video && this._deviceErrorCodes.video !== DEVICE_ERROR_CODE.NONE;
|
|
802
|
+
}
|
|
662
803
|
// 处理 "呼叫" 抛出的异常
|
|
663
804
|
private _handleCallError(error: any, methodName?: string) {
|
|
664
805
|
this._permissionCheckTimer && clearInterval(this._permissionCheckTimer);
|
|
@@ -691,7 +832,7 @@ export default class TUICallService {
|
|
|
691
832
|
oldStatus: StatusChange.IDLE,
|
|
692
833
|
newStatus: (groupID || remoteUserInfoList.length > 1) ? StatusChange.DIALING_GROUP : StatusChange.DIALING_C2C,
|
|
693
834
|
});
|
|
694
|
-
updateDeviceList(this._tuiCallEngine)
|
|
835
|
+
updateDeviceList(this._tuiCallEngine);
|
|
695
836
|
const remoteUserInfoLists = await getRemoteUserProfile(remoteUserInfoList.map(obj => obj.userId), this.getTim());
|
|
696
837
|
|
|
697
838
|
if (remoteUserInfoLists.length > 0) {
|
|
@@ -721,7 +862,10 @@ export default class TUICallService {
|
|
|
721
862
|
const localUserInfo = TUIStore.getData(StoreName.CALL, NAME.LOCAL_USER_INFO);
|
|
722
863
|
TUIStore.update(StoreName.CALL, NAME.LOCAL_USER_INFO, { ...localUserInfo, isEnter: true });
|
|
723
864
|
TUIStore.update(StoreName.CALL, NAME.LOCAL_USER_INFO_EXCLUDE_VOLUMN, { ...localUserInfo, isEnter: true });
|
|
724
|
-
|
|
865
|
+
const callPermission = this._tuiCallEngine?.getDevicePermission();
|
|
866
|
+
setLocalUserInfoAudioVideoAvailable(!!callPermission?.audio, NAME.AUDIO);
|
|
867
|
+
// Show device-unavailable hints (banner / button badges / toast) once the call is established.
|
|
868
|
+
this._updateDeviceUnavailableHints(callMediaType);
|
|
725
869
|
} else {
|
|
726
870
|
this._permissionCheckTimer && clearInterval(this._permissionCheckTimer);
|
|
727
871
|
this._permissionCheckTimer = null;
|