@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.
Files changed (30) hide show
  1. package/package.json +2 -2
  2. package/src/Components/TUICallKit/TUICallKit.tsx +2 -0
  3. package/src/Components/components/common/ButtonPanel/button/Camera.tsx +49 -6
  4. package/src/Components/components/common/ButtonPanel/button/Microphone.tsx +48 -6
  5. package/src/Components/components/common/PermissionDialog/DevicePermissionBanner.module.scss +55 -0
  6. package/src/Components/components/common/PermissionDialog/DevicePermissionBanner.tsx +78 -0
  7. package/src/Components/components/common/PermissionDialog/index.ts +3 -0
  8. package/src/TUICallService/CallService/index.ts +153 -9
  9. package/src/TUICallService/CallService/utils.ts +35 -23
  10. package/src/TUICallService/TUIStore/callStore.ts +1 -0
  11. package/src/TUICallService/const/index.ts +27 -0
  12. package/src/TUICallService/index.ts +2 -1
  13. package/src/TUICallService/interface/ICallStore.ts +2 -1
  14. package/src/TUICallService/locales/en.ts +26 -0
  15. package/src/TUICallService/locales/index.ts +66 -0
  16. package/src/TUICallService/locales/ja_JP.ts +26 -0
  17. package/src/TUICallService/locales/zh-cn.ts +35 -0
  18. package/src/index.ts +1 -1
  19. package/tuicall-uikit-react.es.js +3442 -3110
  20. package/tuicall-uikit-react.umd.js +4 -4
  21. package/types/Components/components/common/PermissionDialog/DevicePermissionBanner.d.ts +1 -0
  22. package/types/Components/components/common/PermissionDialog/index.d.ts +2 -0
  23. package/types/TUICallService/CallService/index.d.ts +30 -0
  24. package/types/TUICallService/const/index.d.ts +13 -0
  25. package/types/TUICallService/index.d.ts +2 -2
  26. package/types/TUICallService/interface/ICallStore.d.ts +2 -1
  27. package/types/TUICallService/locales/en.d.ts +25 -0
  28. package/types/TUICallService/locales/index.d.ts +14 -0
  29. package/types/TUICallService/locales/ja_JP.d.ts +25 -0
  30. 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.11",
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,
@@ -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 { 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>
@@ -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;
@@ -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.5.11';
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
- setLocalUserInfoAudioVideoAvailable(true, NAME.AUDIO);
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
- setLocalUserInfoAudioVideoAvailable(true, NAME.AUDIO); // web && mini default open audio
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
- setLocalUserInfoAudioVideoAvailable(true, NAME.AUDIO); // web && mini, default open audio
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;