@trtc/calls-uikit-react 4.5.10 → 4.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/package.json +2 -2
  2. package/src/Components/TUICallKit/TUICallKit.tsx +75 -3
  3. package/src/Components/components/common/ButtonPanel/ButtonPanel.tsx +3 -3
  4. package/src/Components/components/common/ButtonPanel/ButtonPanelH5/ButtonPanelH5.tsx +5 -5
  5. package/src/Components/components/common/ButtonPanel/button/Camera.tsx +49 -6
  6. package/src/Components/components/common/ButtonPanel/button/Microphone.tsx +48 -6
  7. package/src/Components/components/common/FloatingWindow/FloatingWindow.tsx +2 -2
  8. package/src/Components/components/common/PermissionDialog/DevicePermissionBanner.module.scss +55 -0
  9. package/src/Components/components/common/PermissionDialog/DevicePermissionBanner.tsx +78 -0
  10. package/src/Components/components/common/PermissionDialog/index.ts +3 -0
  11. package/src/Components/components/common/StreamInfo/StreamInfoH5/StreamInfoH5.tsx +4 -4
  12. package/src/Components/components/common/TopBar/h5/TopBarH5.tsx +2 -2
  13. package/src/Components/components/common/TopBar/pc/TopBarPC.tsx +2 -2
  14. package/src/Components/context/CallUserInfoContext.ts +3 -0
  15. package/src/TUICallService/CallService/index.ts +153 -9
  16. package/src/TUICallService/CallService/utils.ts +35 -23
  17. package/src/TUICallService/TUIStore/callStore.ts +1 -0
  18. package/src/TUICallService/const/index.ts +27 -0
  19. package/src/TUICallService/index.ts +2 -1
  20. package/src/TUICallService/interface/ICallStore.ts +2 -1
  21. package/src/TUICallService/locales/en.ts +26 -0
  22. package/src/TUICallService/locales/index.ts +66 -0
  23. package/src/TUICallService/locales/ja_JP.ts +26 -0
  24. package/src/TUICallService/locales/zh-cn.ts +35 -0
  25. package/src/index.ts +1 -1
  26. package/tuicall-uikit-react.es.js +3658 -3285
  27. package/tuicall-uikit-react.umd.js +4 -4
  28. package/types/Components/components/common/PermissionDialog/DevicePermissionBanner.d.ts +1 -0
  29. package/types/Components/components/common/PermissionDialog/index.d.ts +2 -0
  30. package/types/Components/context/CallUserInfoContext.d.ts +1 -0
  31. package/types/TUICallService/CallService/index.d.ts +30 -0
  32. package/types/TUICallService/const/index.d.ts +13 -0
  33. package/types/TUICallService/index.d.ts +2 -2
  34. package/types/TUICallService/interface/ICallStore.d.ts +2 -1
  35. package/types/TUICallService/locales/en.d.ts +25 -0
  36. package/types/TUICallService/locales/index.d.ts +14 -0
  37. package/types/TUICallService/locales/ja_JP.d.ts +25 -0
  38. package/types/TUICallService/locales/zh-cn.d.ts +25 -0
@@ -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.10';
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;
@@ -200,19 +200,29 @@ export function deleteRemoteUser(userIdList: string[]): void {
200
200
  TUIStore.update(StoreName.CALL, NAME.REMOTE_USER_INFO_EXCLUDE_VOLUMN_LIST, remoteUserInfoList);
201
201
  }
202
202
  export function updateDeviceList(tuiCallEngine) {
203
- tuiCallEngine?.getDeviceList('speaker').then((result)=>{
204
- const deviceList = TUIStore.getData(StoreName.CALL, NAME.DEVICE_LIST);
205
- const currentSpeaker = result?.[0] || {};
206
- TUIStore.update(
207
- StoreName.CALL,
208
- NAME.DEVICE_LIST,
209
- { ...deviceList, speakerList: result, currentSpeaker },
210
- );
211
- }).catch(error =>{
212
- console.error(`${NAME.PREFIX}updateSpeakerList failed, error: ${JSON.stringify(error)}.`);
213
- });
203
+ // Check current permission state to avoid triggering getUserMedia prompts.
204
+ // getDeviceList internally calls _checkDevicePermission which fires getUserMedia
205
+ // when the permission has not been checked yet (permission.audio/video is false).
206
+ // This races with Engine.calls() and causes duplicate browser prompts.
207
+ const permission = tuiCallEngine?.getDevicePermission?.();
208
+ const hasAudioPermission = permission?.audio === true;
209
+ const hasVideoPermission = permission?.video === true;
210
+
211
+ if (hasAudioPermission) {
212
+ tuiCallEngine?.getDeviceList('speaker').then((result)=>{
213
+ const deviceList = TUIStore.getData(StoreName.CALL, NAME.DEVICE_LIST);
214
+ const currentSpeaker = result?.[0] || {};
215
+ TUIStore.update(
216
+ StoreName.CALL,
217
+ NAME.DEVICE_LIST,
218
+ { ...deviceList, speakerList: result, currentSpeaker },
219
+ );
220
+ }).catch(error =>{
221
+ console.error(`${NAME.PREFIX}updateSpeakerList failed, error: ${JSON.stringify(error)}.`);
222
+ });
223
+ }
214
224
  const callMediaType = TUIStore.getData(StoreName.CALL, NAME.CALL_MEDIA_TYPE);
215
- if (callMediaType === CallMediaType.VIDEO) {
225
+ if (callMediaType === CallMediaType.VIDEO && hasVideoPermission) {
216
226
  tuiCallEngine?.getDeviceList('camera').then((result) => {
217
227
  const deviceList = TUIStore.getData(StoreName.CALL, NAME.DEVICE_LIST);
218
228
  const currentCamera = result?.[0] || {};
@@ -225,17 +235,19 @@ export function updateDeviceList(tuiCallEngine) {
225
235
  console.error(`${NAME.PREFIX}updateCameraList failed, error: ${error}.`);
226
236
  });
227
237
  }
228
- tuiCallEngine?.getDeviceList('microphones').then((result) => {
229
- const deviceList = TUIStore.getData(StoreName.CALL, NAME.DEVICE_LIST);
230
- const currentMicrophone = result?.[0] || {};
231
- TUIStore.update(
232
- StoreName.CALL,
233
- NAME.DEVICE_LIST,
234
- { ...deviceList, microphoneList: result, currentMicrophone },
235
- );
236
- }).catch(error => {
237
- console.error(`${NAME.PREFIX}updateMicrophoneList failed, error: ${error}.`);
238
- });
238
+ if (hasAudioPermission) {
239
+ tuiCallEngine?.getDeviceList('microphones').then((result) => {
240
+ const deviceList = TUIStore.getData(StoreName.CALL, NAME.DEVICE_LIST);
241
+ const currentMicrophone = result?.[0] || {};
242
+ TUIStore.update(
243
+ StoreName.CALL,
244
+ NAME.DEVICE_LIST,
245
+ { ...deviceList, microphoneList: result, currentMicrophone },
246
+ );
247
+ }).catch(error => {
248
+ console.error(`${NAME.PREFIX}updateMicrophoneList failed, error: ${error}.`);
249
+ });
250
+ }
239
251
  }
240
252
  /**
241
253
  * update the no device permission toast
@@ -60,6 +60,7 @@ export default class CallStore {
60
60
  realtimeMessageList: [],
61
61
  isAITranscriberEnabled: false,
62
62
  isAITranscriberRunning: false,
63
+ deviceUnavailableReasons: null, // per-device unavailable reasons (null = available)
63
64
  };
64
65
  public store: ICallStore = deepClone(this.defaultStore);
65
66
  public prevStore: ICallStore = deepClone(this.defaultStore);
@@ -40,6 +40,7 @@ export const CALL_DATA_KEY: any = {
40
40
  PUSHER_ID: 'pusherId',
41
41
  IS_FORCE_USE_V2_API: 'isForceUseV2API',
42
42
  MODAL_ERROR: 'modalError',
43
+ DEVICE_UNAVAILABLE_REASONS: 'deviceUnavailableReasons',
43
44
  TRANSCRIBER_ROBOT_ID: 'transcriberRobotId',
44
45
  REALTIME_MESSAGE_LIST: 'realtimeMessageList',
45
46
  IS_AI_TRANSCRIBER_ENABLED: 'isAITranscriberEnabled', // Controls UI visibility
@@ -84,6 +85,32 @@ export const NAME = {
84
85
  ...CHAT_DATA_KEY,
85
86
  };
86
87
 
88
+ // Stable device error codes exposed by TUICallEngine (kept in sync with the
89
+ // engine's public ERROR_CODE). Used by the service layer to classify device
90
+ // failures caught from engine's device permission check, instead of relying on
91
+ // engine-internal errorName fields.
92
+ // Classified reason why a device is unavailable, shared by the service layer and UI.
93
+ export type DeviceErrorReason = 'no-permission' | 'no-device' | 'device-occupied' | 'unknown' | null;
94
+
95
+ // Per-device reason why a device is currently unavailable. The value is the classified
96
+ // reason (e.g. 'no-permission' / 'no-device' / 'device-occupied'); null means that device
97
+ // is available. The keys follow the engine's audio/video wording:
98
+ // - microphone (audio input)
99
+ // - camera (video input)
100
+ export interface DeviceUnavailableReasons {
101
+ microphone: DeviceErrorReason;
102
+ camera: DeviceErrorReason;
103
+ }
104
+
105
+ export const DEVICE_ERROR_CODE = {
106
+ NONE: 0, // no error
107
+ PERMISSION_DENIED: -1101, // NotAllowedError, permission refused
108
+ NO_MICROPHONE: 60003, // NotFoundError, no microphone device
109
+ NO_CAMERA: 60004, // NotFoundError, no camera device
110
+ MICROPHONE_NOT_READABLE: 60007, // NotReadableError, mic occupied / hardware error
111
+ CAMERA_NOT_READABLE: 60008, // NotReadableError, camera occupied / hardware error
112
+ };
113
+
87
114
  export const AudioCallIcon = 'https://web.sdk.qcloud.com/component/TUIKit/assets/call.png';
88
115
  export const VideoCallIcon = 'https://web.sdk.qcloud.com/component/TUIKit/assets/call-video-reverse.svg';
89
116
  export const MAX_NUMBER_ROOM_ID = 2147483647;
@@ -13,7 +13,7 @@ import {
13
13
  LayoutMode,
14
14
  CloudRecordPolicy,
15
15
  } from './const/index';
16
- import { t } from './locales/index';
16
+ import { t, buildDeviceUnavailableText } from './locales/index';
17
17
 
18
18
  // 实例化
19
19
  const TUICallKitAPI = TUICallService.getInstance();
@@ -32,6 +32,7 @@ export {
32
32
  VideoDisplayMode,
33
33
  AudioPlayBackDevice,
34
34
  t,
35
+ buildDeviceUnavailableText,
35
36
  uiDesign,
36
37
  FeatureButton,
37
38
  LayoutMode,
@@ -1,4 +1,4 @@
1
- import { CallStatus, CallRole, CallMediaType, VideoDisplayMode, VideoResolution, TDeviceList, CameraPosition, ICustomUIConfig } from '../const/index';
1
+ import { CallStatus, CallRole, CallMediaType, VideoDisplayMode, VideoResolution, TDeviceList, CameraPosition, ICustomUIConfig, DeviceUnavailableReasons } from '../const/index';
2
2
  import { IUserInfo, INetWorkQuality } from './index';
3
3
 
4
4
  export interface IToastInfo {
@@ -58,4 +58,5 @@ export interface ICallStore {
58
58
  realtimeMessageList: [], // 实时转写消息列表
59
59
  isAITranscriberEnabled: boolean, // AI 转写UI是否显示
60
60
  isAITranscriberRunning: boolean, // AI transcriber running state
61
+ deviceUnavailableReasons: DeviceUnavailableReasons | null, // Per-device unavailable reason (null = available); drives banner/button badges & toast
61
62
  }
@@ -49,6 +49,10 @@ export const en = {
49
49
  'camera disabled': 'Camera Off',
50
50
  'microphone enabled': 'Unmuted',
51
51
  'microphone disabled': 'Muted',
52
+ 'no mic permission': 'No Mic Access',
53
+ 'no mic device': 'No Mic Found',
54
+ 'no cam permission': 'No Cam Access',
55
+ 'no cam device': 'No Cam Found',
52
56
  'speaker enabled': 'Speaker On',
53
57
  'speaker disabled': 'Speaker Off',
54
58
  'open speaker': 'Turn on speaker',
@@ -152,6 +156,28 @@ export const en = {
152
156
  'error.10017': "Failed to start the call: the callee userID cannot be the current logged-in user. Please check the callee userID you passed in.",
153
157
  'error.10018': "Failed to start the call: the number of callees in a single call must be between 1 and 8. Please check the userIDList you passed in.",
154
158
  'error': 'Error',
159
+ // Permission / device unavailability texts
160
+ 'permission-device-separator': ' and ',
161
+ 'permission-phrase-separator': '; ',
162
+ 'permission-sentence-separator': '. ',
163
+ 'permission-phrase-perm': '{{ devices }} access denied',
164
+ 'permission-phrase-no-device': '{{ devices }} not detected',
165
+ 'permission-phrase-occupied': '{{ devices }} is in use by another app',
166
+ 'permission-phrase-unknown': '{{ devices }} unavailable',
167
+ 'permission-toast-suffix': ', cannot be used during the call.',
168
+ 'permission-toast-raw': '{{ text }}',
169
+ 'permission-banner-guide-perm': 'Please allow access in the browser address bar.',
170
+ 'permission-banner-guide-perm-h5': 'Please enable in system settings.',
171
+ 'permission-banner-guide-no-device': 'Please connect device and try again.',
172
+ 'permission-banner-guide-no-device-h5': 'Please connect device.',
173
+ 'permission-btn-mic-no-perm': 'Microphone access denied. Please click the lock icon in the address bar to allow.',
174
+ 'permission-btn-mic-no-device': 'No microphone detected. Please connect a microphone.',
175
+ 'permission-btn-mic-occupied': 'Microphone is in use by another app. Please close that app.',
176
+ 'permission-btn-cam-no-perm': 'Camera access denied. Please click the lock icon in the address bar to allow.',
177
+ 'permission-btn-cam-no-device': 'No camera detected. Please connect a camera.',
178
+ 'permission-btn-cam-occupied': 'Camera is in use by another app. Please close that app.',
179
+ 'permission-btn-mic-no-perm-h5': 'Microphone access denied. Please allow browser microphone access in system settings.',
180
+ 'permission-btn-cam-no-perm-h5': 'Camera access denied. Please allow browser camera access in system settings.',
155
181
  // AI Subtitle Settings
156
182
  'ai-subtitle': 'AI Subtitle',
157
183
  'ai-subtitle-settings': 'AI Subtitle Settings',
@@ -59,3 +59,69 @@ interface languageItemType {
59
59
  interface languageDataType {
60
60
  [key: string]: languageItemType;
61
61
  }
62
+
63
+ // Map a classified device error reason to its phrase translation key.
64
+ const DEVICE_REASON_PHRASE_KEY: { [reason: string]: string } = {
65
+ 'no-permission': 'permission-phrase-perm',
66
+ 'no-device': 'permission-phrase-no-device',
67
+ 'device-occupied': 'permission-phrase-occupied',
68
+ };
69
+
70
+ /**
71
+ * Build a device-unavailability message by grouping devices by their error reason,
72
+ * so each reason is described accurately. For example, when the microphone has no
73
+ * permission and the camera has no device, it produces
74
+ * "麦克风权限未开启,未检测到摄像头" instead of wrongly saying both lack permission.
75
+ *
76
+ * @param audioReason classified reason for the microphone, or null if available
77
+ * @param videoReason classified reason for the camera, or null if available
78
+ * @param scene 'plain' returns the grouped description only; 'toast' appends the
79
+ * in-call suffix; 'banner' appends the guidance sentence
80
+ * @param isMobile selects PC vs H5 guidance text (banner only)
81
+ * @returns the composed localized text, or '' when no device is unavailable
82
+ */
83
+ export function buildDeviceUnavailableText(
84
+ audioReason: string | null,
85
+ videoReason: string | null,
86
+ scene: 'plain' | 'toast' | 'banner',
87
+ isMobile = false,
88
+ ): string {
89
+ const reasonToDevices: { [reason: string]: string[] } = {};
90
+ const collect = (reason: string | null, deviceName: string) => {
91
+ if (!reason) return;
92
+ (reasonToDevices[reason] = reasonToDevices[reason] || []).push(deviceName);
93
+ };
94
+ collect(audioReason, t('microphone'));
95
+ collect(videoReason, t('camera'));
96
+
97
+ const reasons = Object.keys(reasonToDevices);
98
+ if (reasons.length === 0) return '';
99
+
100
+ const deviceSep = t('permission-device-separator');
101
+ const phraseSep = t('permission-phrase-separator');
102
+ const phrases = reasons.map((reason) => {
103
+ const deviceNameText = reasonToDevices[reason].join(deviceSep);
104
+ const phraseKey = DEVICE_REASON_PHRASE_KEY[reason] || 'permission-phrase-unknown';
105
+ return t({ key: phraseKey, options: { devices: deviceNameText } });
106
+ });
107
+ const description = phrases.join(phraseSep);
108
+
109
+ if (scene === 'plain') {
110
+ return description;
111
+ }
112
+
113
+ if (scene === 'toast') {
114
+ return `${description}${t('permission-toast-suffix')}`;
115
+ }
116
+
117
+ // Banner: permission issues take priority since the user must actively grant
118
+ // access; otherwise guide the user to connect a device.
119
+ const hasPermIssue = reasons.includes('no-permission');
120
+ let guideKey: string;
121
+ if (hasPermIssue) {
122
+ guideKey = isMobile ? 'permission-banner-guide-perm-h5' : 'permission-banner-guide-perm';
123
+ } else {
124
+ guideKey = isMobile ? 'permission-banner-guide-no-device-h5' : 'permission-banner-guide-no-device';
125
+ }
126
+ return `${description}${t('permission-sentence-separator')}${t(guideKey)}`;
127
+ }
@@ -60,6 +60,10 @@ export const ja_JP = {
60
60
  'camera disabled': 'カメラオフ',
61
61
  'microphone enabled': 'マイクオン',
62
62
  'microphone disabled': 'マイクオフ',
63
+ 'no mic permission': 'マイク権限なし',
64
+ 'no mic device': 'マイク未検出',
65
+ 'no cam permission': 'カメラ権限なし',
66
+ 'no cam device': 'カメラ未検出',
63
67
  'speaker enabled': 'スピーカーオン',
64
68
  'speaker disabled': 'スピーカーオフ',
65
69
  'open speaker': 'スピーカーをオンにする',
@@ -150,6 +154,28 @@ export const ja_JP = {
150
154
  'error.10017': "通話の発信に失敗しました:着信側 userID を現在ログイン中のユーザー自身に指定することはできません。渡した着信側 userID を確認してください。",
151
155
  'error.10018': "通話の発信に失敗しました:1 回の通話で指定できる着信側ユーザー数は 1〜8 人である必要があります。渡した userIDList を確認してください。",
152
156
  'error': 'エラー',
157
+ // Permission / device unavailability texts
158
+ 'permission-device-separator': '、',
159
+ 'permission-phrase-separator': '、',
160
+ 'permission-sentence-separator': '。',
161
+ 'permission-phrase-perm': '{{ devices }}のアクセス許可が未設定です',
162
+ 'permission-phrase-no-device': '{{ devices }}が検出されません',
163
+ 'permission-phrase-occupied': '{{ devices }}が他のアプリで使用中です',
164
+ 'permission-phrase-unknown': '{{ devices }}は使用できません',
165
+ 'permission-toast-suffix': '。通話中は使用できません。',
166
+ 'permission-toast-raw': '{{ text }}',
167
+ 'permission-banner-guide-perm': 'ブラウザのアドレスバーでアクセスを許可してください。',
168
+ 'permission-banner-guide-perm-h5': 'システム設定で有効にしてください。',
169
+ 'permission-banner-guide-no-device': 'デバイスを接続して再試行してください。',
170
+ 'permission-banner-guide-no-device-h5': 'デバイスを接続してください。',
171
+ 'permission-btn-mic-no-perm': 'マイクのアクセス許可が未設定です。アドレスバーのロックアイコンをクリックして許可してください。',
172
+ 'permission-btn-mic-no-device': 'マイクが検出されません。マイクを接続してください。',
173
+ 'permission-btn-mic-occupied': 'マイクが他のアプリで使用中です。他のアプリを閉じてください。',
174
+ 'permission-btn-cam-no-perm': 'カメラのアクセス許可が未設定です。アドレスバーのロックアイコンをクリックして許可してください。',
175
+ 'permission-btn-cam-no-device': 'カメラが検出されません。カメラを接続してください。',
176
+ 'permission-btn-cam-occupied': 'カメラが他のアプリで使用中です。他のアプリを閉じてください。',
177
+ 'permission-btn-mic-no-perm-h5': 'マイクのアクセス許可が未設定です。システム設定でブラウザのマイクを許可してください。',
178
+ 'permission-btn-cam-no-perm-h5': 'カメラのアクセス許可が未設定です。システム設定でブラウザのカメラを許可してください。',
153
179
  // AI Subtitle Settings
154
180
  'ai-subtitle': 'AI字幕',
155
181
  'ai-subtitle-settings': 'AI字幕設定',
@@ -49,6 +49,10 @@ export const zh = {
49
49
  'camera disabled': '摄像头已关',
50
50
  'microphone enabled': '麦克风已开',
51
51
  'microphone disabled': '麦克风已关',
52
+ 'no mic permission': '无麦克风权限',
53
+ 'no mic device': '无麦克风设备',
54
+ 'no cam permission': '无摄像头权限',
55
+ 'no cam device': '无摄像头设备',
52
56
  'speaker enabled': '扬声器已开',
53
57
  'speaker disabled': '扬声器已关',
54
58
  'open speaker': '开启扬声器',
@@ -147,6 +151,37 @@ export const zh = {
147
151
  'error.10017': "发起通话失败:被叫 userID 不能是当前登录用户自己,请检查传入的被叫 userID。",
148
152
  'error.10018': "发起通话失败:单次呼叫的被叫用户数量需在 1 到 8 之间,请检查传入的 userIDList。",
149
153
  'error': '错误',
154
+ // Permission / device unavailability texts
155
+ // Separator used when joining multiple device names sharing the same reason (e.g. microphone、camera)
156
+ 'permission-device-separator': '、',
157
+ // Separator used when joining phrases of different reasons
158
+ 'permission-phrase-separator': ',',
159
+ // Separator between the device description and the guidance sentence (banner)
160
+ 'permission-sentence-separator': ',',
161
+ // Device unavailability phrases (grouped by reason, combined dynamically)
162
+ 'permission-phrase-perm': '{{ devices }}权限未开启',
163
+ 'permission-phrase-no-device': '未检测到{{ devices }}',
164
+ 'permission-phrase-occupied': '{{ devices }}被其他应用占用',
165
+ 'permission-phrase-unknown': '{{ devices }}不可用',
166
+ // Toast: suffix appended after the combined device phrases
167
+ 'permission-toast-suffix': ',通话中将无法使用',
168
+ // Toast: raw passthrough template for pre-composed text
169
+ 'permission-toast-raw': '{{ text }}',
170
+ // Banner: guidance appended after the device phrases (PC / H5)
171
+ 'permission-banner-guide-perm': '请在浏览器地址栏允许访问',
172
+ 'permission-banner-guide-perm-h5': '请前往系统设置开启',
173
+ 'permission-banner-guide-no-device': '请连接设备后重试',
174
+ 'permission-banner-guide-no-device-h5': '请连接设备',
175
+ // Button click toast (PC)
176
+ 'permission-btn-mic-no-perm': '麦克风权限未开启,请在浏览器地址栏点击锁定图标允许使用',
177
+ 'permission-btn-mic-no-device': '未检测到麦克风设备,请连接麦克风后重试',
178
+ 'permission-btn-mic-occupied': '麦克风被其他应用占用,请关闭占用应用后重试',
179
+ 'permission-btn-cam-no-perm': '摄像头权限未开启,请在浏览器地址栏点击锁定图标允许使用',
180
+ 'permission-btn-cam-no-device': '未检测到摄像头设备,请连接摄像头后重试',
181
+ 'permission-btn-cam-occupied': '摄像头被其他应用占用,请关闭占用应用后重试',
182
+ // Button click toast (H5)
183
+ 'permission-btn-mic-no-perm-h5': '麦克风权限未开启,请在手机系统设置中允许浏览器使用麦克风',
184
+ 'permission-btn-cam-no-perm-h5': '摄像头权限未开启,请在手机系统设置中允许浏览器使用摄像头',
150
185
  // AI Subtitle Settings
151
186
  'ai-subtitle': 'AI 字幕',
152
187
  'ai-subtitle-settings': 'AI 字幕设置',
package/src/index.ts CHANGED
@@ -35,7 +35,7 @@ const TUICallType = {
35
35
  AUDIO_CALL: 1,
36
36
  VIDEO_CALL: 2,
37
37
  };
38
- const Version = '4.5.10'; // basic-demo 原来上报使用
38
+ const Version = '4.6.0'; // basic-demo 原来上报使用
39
39
 
40
40
  // 输出产物
41
41
  export {