props.streamInfo.streamType === TUIVideoStreamType.kScreenStream
);
+const isLocalPusher = computed(
+ () =>
+ basicStore.userId === props.streamInfo.userId && canMountLocalPusher.value
+);
+
const userInfo = computed(() => roomStore.userInfoObj[props.streamInfo.userId]);
const displayName = computed(() => {
const user = roomStore.userInfoObj[props.streamInfo.userId];
+ if (!user) {
+ return props.streamInfo.userId;
+ }
if (isInnerScene) {
return `${user.nameCard || user.userName} | ${user.userId}`;
}
return user.nameCard || user.userName || user.userId;
});
+async function bindLocalVideoView() {
+ if (basicStore.userId !== props.streamInfo.userId) {
+ return;
+ }
+ // WeChat live-pusher does not use a DOM view. setLocalVideoView(non-null)
+ // maps to startLocalPreview; calling it before enterRoom makes enterRoom
+ // turn off videoPreview while keeping enableCamera, so the stream publishes
+ // but the local UI stays in the camera-off state.
+ if (isWeChat) {
+ return;
+ }
+ await nextTick();
+ if (!canMountLocalPusher.value) {
+ return;
+ }
+ await roomEngine.instance?.setLocalVideoView({
+ view: `${playRegionDomId.value}`,
+ });
+}
+
+async function bindRemoteVideoView() {
+ await nextTick();
+ if (!player.value) {
+ return;
+ }
+ logger.debug(
+ `${logPrefix}watch isVideoStreamAvailable:`,
+ props.streamInfo.userId,
+ player.value
+ );
+ await player.value.setTRTCStreamId(playRegionDomId.value);
+ roomEngine.instance?.setRemoteVideoView({
+ userId: props.streamInfo.userId,
+ streamType: props.streamInfo.streamType,
+ view: `${playRegionDomId.value}`,
+ });
+ await roomEngine.instance?.startPlayRemoteVideo({
+ userId: props.streamInfo.userId,
+ streamType: props.streamInfo.streamType,
+ });
+ const trtcCloud = roomEngine.instance?.getTRTCCloud();
+ await trtcCloud?.setRemoteRenderParams(
+ props.streamInfo.userId,
+ props.streamInfo.streamType === TUIVideoStreamType.kScreenStream
+ ? TRTCVideoStreamType.TRTCVideoStreamTypeSub
+ : TRTCVideoStreamType.TRTCVideoStreamTypeBig,
+ {
+ mirrorType: TRTCVideoMirrorType.TRTCVideoMirrorType_Disable,
+ rotation: TRTCVideoRotation.TRTCVideoRotation0,
+ fillMode:
+ props.streamInfo.streamType === TUIVideoStreamType.kScreenStream
+ ? TRTCVideoFillMode.TRTCVideoFillMode_Fit
+ : TRTCVideoFillMode.TRTCVideoFillMode_Fill,
+ }
+ );
+}
+
onMounted(() => {
watch(
() => props.streamInfo.hasVideoStream,
async val => {
if (val) {
- await nextTick();
- if (player.value) {
- logger.debug(
- `${logPrefix}watch isVideoStreamAvailable:`,
- props.streamInfo.userId,
- player.value
- );
- if (basicStore.userId === props.streamInfo.userId) {
- if (props.streamInfo.hasVideoStream) {
- await roomEngine.instance?.setLocalVideoView({
- view: `${playRegionDomId.value}`,
- });
- }
- } else {
- await player.value.setTRTCStreamId(playRegionDomId.value);
- roomEngine.instance?.setRemoteVideoView({
- userId: props.streamInfo.userId,
- streamType: props.streamInfo.streamType,
- view: `${playRegionDomId.value}`,
- });
- await roomEngine.instance?.startPlayRemoteVideo({
- userId: props.streamInfo.userId,
- streamType: props.streamInfo.streamType,
- });
- const trtcCloud = roomEngine.instance?.getTRTCCloud();
- await trtcCloud?.setRemoteRenderParams(
- props.streamInfo.userId,
- props.streamInfo.streamType === TUIVideoStreamType.kScreenStream
- ? TRTCVideoStreamType.TRTCVideoStreamTypeSub
- : TRTCVideoStreamType.TRTCVideoStreamTypeBig,
- {
- mirrorType: TRTCVideoMirrorType.TRTCVideoMirrorType_Disable,
- rotation: TRTCVideoRotation.TRTCVideoRotation0,
- fillMode:
- props.streamInfo.streamType ===
- TUIVideoStreamType.kScreenStream
- ? TRTCVideoFillMode.TRTCVideoFillMode_Fit
- : TRTCVideoFillMode.TRTCVideoFillMode_Fill,
- }
- );
- }
+ if (basicStore.userId === props.streamInfo.userId) {
+ await bindLocalVideoView();
+ } else {
+ await bindRemoteVideoView();
}
- } else {
- if (
- basicStore.userId === props.streamInfo.userId &&
- props.streamInfo.streamType === TUIVideoStreamType.kCameraStream
- ) {
+ } else if (
+ basicStore.userId === props.streamInfo.userId &&
+ props.streamInfo.streamType === TUIVideoStreamType.kCameraStream
+ ) {
+ // On WeChat, view=null maps to stopLocalPreview and leaves
+ // openLocalCamera waiting for onCameraDidReady. Turning the camera
+ // off must go through closeLocalCamera, not unbinding the view.
+ if (!isWeChat) {
await roomEngine.instance?.setLocalVideoView({
view: null,
});
@@ -206,6 +245,20 @@ onMounted(() => {
},
{ immediate: true }
);
+ watch(localPusherEpoch, async () => {
+ if (basicStore.userId === props.streamInfo.userId) {
+ await bindLocalVideoView();
+ }
+ });
+ watch(
+ canMountLocalPusher,
+ async val => {
+ if (val && basicStore.userId === props.streamInfo.userId) {
+ await bindLocalVideoView();
+ }
+ },
+ { immediate: true }
+ );
});
/**
@@ -222,9 +275,7 @@ onMounted(() => {
* Replay local video streams only when they are open
**/
if (props.streamInfo.hasVideoStream) {
- await roomEngine.instance?.setLocalVideoView({
- view: `${playRegionDomId.value}`,
- });
+ await bindLocalVideoView();
}
} else {
await nextTick();
@@ -277,6 +328,7 @@ function handleScaleChange(event: {
position: absolute;
top: 0;
left: 0;
+ z-index: 2;
display: flex;
align-items: center;
justify-content: center;
@@ -295,6 +347,7 @@ function handleScaleChange(event: {
position: absolute;
bottom: 4px;
left: 0;
+ z-index: 2;
display: flex;
align-content: center;
align-items: center;
diff --git a/MiniProgram/src/roomkit/TUIRoom/components/common/Logo.vue b/MiniProgram/src/roomkit/TUIRoom/components/common/Logo.vue
index e2af8a923..54fc3180e 100644
--- a/MiniProgram/src/roomkit/TUIRoom/components/common/Logo.vue
+++ b/MiniProgram/src/roomkit/TUIRoom/components/common/Logo.vue
@@ -1,14 +1,6 @@
-
-
-
-
-
-
-
-
@@ -37,8 +29,6 @@ import { computed } from 'vue';
import SvgIcon from './base/SvgIcon.vue';
import { useBasicStore } from '../../stores/basic';
import { storeToRefs } from 'pinia';
-import LogoOfPCInChineseBlackIcon from '../../assets/icons/LogoOfPCInChineseBlackIcon.svg';
-import LogoOfPCInChineseWhiteIcon from '../../assets/icons/LogoOfPCInChineseWhiteIcon.svg';
import LogoOfMobileInChinese from '../../assets/icons/LogoOfMobileInChinese.svg';
import LogoTitleOfMobileInChinese from '../../assets/icons/LogoTitleOfMobileInChinese.svg';
import LogoInEnglish from '../../assets/icons/LogoInEnglish.svg';
@@ -51,9 +41,6 @@ const { defaultTheme } = storeToRefs(basicStore);
const isEN = computed(() => i18n.global.locale.value === 'en-US');
const isZH = computed(() => i18n.global.locale.value === 'zh-CN');
-const isDarkTheme = computed(() =>
- theme.value ? theme.value === 'dark' : defaultTheme.value === 'dark'
-);
const isLightTheme = computed(() =>
theme.value ? theme.value === 'light' : defaultTheme.value === 'light'
);
diff --git a/MiniProgram/src/roomkit/TUIRoom/components/common/base/PopUpH5.vue b/MiniProgram/src/roomkit/TUIRoom/components/common/base/PopUpH5.vue
index 3742ed0e6..be2680a81 100644
--- a/MiniProgram/src/roomkit/TUIRoom/components/common/base/PopUpH5.vue
+++ b/MiniProgram/src/roomkit/TUIRoom/components/common/base/PopUpH5.vue
@@ -35,13 +35,16 @@ function handleClose() {
diff --git a/MiniProgram/src/roomkit/TUIRoom/components/common/base/SvgIcon.vue b/MiniProgram/src/roomkit/TUIRoom/components/common/base/SvgIcon.vue
index 2212b22ba..255cc2aad 100644
--- a/MiniProgram/src/roomkit/TUIRoom/components/common/base/SvgIcon.vue
+++ b/MiniProgram/src/roomkit/TUIRoom/components/common/base/SvgIcon.vue
@@ -84,7 +84,8 @@ watch(
customStyle.value.width = addSuffix(val);
customStyle.value.height = addSuffix(val);
}
- }
+ },
+ { immediate: true }
);
onMounted(() => {
@@ -126,7 +127,7 @@ watch(
.replace(/currentColor/g, currentColor || baseColor)
.replace(/var\(--active-color-2\)/g, activeColor);
// 将 svg 数据进行 URL 编码
- customStyle.value.backgroundImage = `url("data:image/svg+xml,${encodeURIComponent(baseStr)}");`;
+ customStyle.value.backgroundImage = `url("data:image/svg+xml,${encodeURIComponent(baseStr)}")`;
customStyle.value.backgroundSize = `100% 100%`;
},
});
diff --git a/MiniProgram/src/roomkit/TUIRoom/conference.vue b/MiniProgram/src/roomkit/TUIRoom/conference.vue
index 229b0f9a8..d0438fc70 100644
--- a/MiniProgram/src/roomkit/TUIRoom/conference.vue
+++ b/MiniProgram/src/roomkit/TUIRoom/conference.vue
@@ -70,6 +70,7 @@ import {
RoomInitData,
} from './services/index';
import useDeviceManager from './hooks/useDeviceManager';
+import { ensureDeviceUsable } from './hooks/useWxMediaGuard';
import { storeToRefs } from 'pinia';
import { useUIKit } from '@tencentcloud/uikit-base-component-uni';
@@ -181,6 +182,11 @@ onMounted(() => {
roomService.on(EventType.ROOM_LEAVE, onLeaveRoom);
roomService.on(EventType.ROOM_DISMISS, onDismissRoom);
roomService.on(EventType.ROOM_NEED_PASSWORD, onRoomNeedPassword);
+ roomService.on(EventType.ROOM_ERROR, onRoomError);
+ roomService.on(
+ EventType.WX_DEVICE_PERMISSION_DENIED,
+ onWxDevicePermissionDenied
+ );
});
onUnmounted(() => {
roomService.off(EventType.ROOM_NOTICE_MESSAGE, showMessage);
@@ -193,6 +199,11 @@ onUnmounted(() => {
roomService.off(EventType.ROOM_LEAVE, onLeaveRoom);
roomService.off(EventType.ROOM_DISMISS, onDismissRoom);
roomService.off(EventType.ROOM_NEED_PASSWORD, onRoomNeedPassword);
+ roomService.off(EventType.ROOM_ERROR, onRoomError);
+ roomService.off(
+ EventType.WX_DEVICE_PERMISSION_DENIED,
+ onWxDevicePermissionDenied
+ );
roomService.resetStore();
});
@@ -299,6 +310,23 @@ async function enterRoom(options: { roomId: string; roomParam?: RoomParam }) {
await roomService.enterRoom(options);
}
+// Entering failed, so ROOM_START / ROOM_JOIN will never arrive to hide the
+// overlay.
+function onRoomError() {
+ isShowLoading.value = false;
+}
+
+// TRTC rejected the device although the room is up. Recreating live-pusher is
+// the only way capture can recover, so force it even if the scope looks granted.
+function onWxDevicePermissionDenied(eventInfo: {
+ device: 'camera' | 'microphone';
+}) {
+ ensureDeviceUsable(eventInfo.device, t, {
+ open: true,
+ forceRecreate: true,
+ });
+}
+
function onRoomNeedPassword(code: TUIErrorCode) {
if (code === TUIErrorCode.ERR_NEED_PASSWORD) {
isShowPasswordContainer.value = true;
diff --git a/MiniProgram/src/roomkit/TUIRoom/hooks/useLocalPusher.ts b/MiniProgram/src/roomkit/TUIRoom/hooks/useLocalPusher.ts
new file mode 100644
index 000000000..8b76acfbb
--- /dev/null
+++ b/MiniProgram/src/roomkit/TUIRoom/hooks/useLocalPusher.ts
@@ -0,0 +1,221 @@
+/**
+ * WeChat live-pusher mount / recreate lifecycle.
+ *
+ * Native live-pusher captures the auth state at creation time. After the user
+ * grants camera or mic permission the component must be destroyed and created
+ * again before openLocalCamera / openLocalMicrophone can take effect.
+ *
+ * Destroying trtc-pusher makes TRTC-WX call exitRoom, so after remounting we
+ * enter the TRTC room again — otherwise preview looks black and the stream is
+ * never published ("skip publish notify, not in room").
+ *
+ * Permission decisions do not belong here; hooks/useWxMediaGuard orchestrates
+ * permission and pusher together.
+ */
+import { nextTick, ref } from 'vue';
+import {
+ TRTCAppScene,
+ TRTCRoleType,
+ TRTCVideoEncParam,
+ TRTCVideoResolution,
+} from '@tencentcloud/tuiroom-engine-wx';
+import { isWeChat } from '../utils/environment';
+import { MediaAuthState } from '../utils/wxPermission';
+import { createSerialRunner } from '../utils/serialTask';
+import { useBasicStore } from '../stores/basic';
+import { useRoomStore } from '../stores/room';
+import useGetRoomEngine from './useRoomEngine';
+import logger from '../utils/common/logger';
+
+const logPrefix = '[useLocalPusher]';
+
+const TRTC_ENTER_ROOM_TIMEOUT = 2000;
+
+export const localPusherEpoch = ref(0);
+export const canMountLocalPusher = ref(!isWeChat);
+
+let pusherCreatedWith: MediaAuthState = {
+ camera: !isWeChat,
+ microphone: !isWeChat,
+};
+
+let trtcNeedsReenter = false;
+
+/**
+ * Pusher operations mutate shared module state and remount a native
+ * component, so they are queued instead of dropped. Dropping a concurrent
+ * call would make the caller believe the pusher was recreated when it was not,
+ * and the user would be left with a black preview.
+ */
+const runExclusive = createSerialRunner();
+
+const smallParam = new TRTCVideoEncParam();
+smallParam.videoResolution = TRTCVideoResolution.TRTCVideoResolution_640_360;
+smallParam.videoFps = 10;
+smallParam.videoBitrate = 550;
+
+export function resetLocalPusherState() {
+ localPusherEpoch.value = 0;
+ canMountLocalPusher.value = !isWeChat;
+ pusherCreatedWith = {
+ camera: !isWeChat,
+ microphone: !isWeChat,
+ };
+ trtcNeedsReenter = false;
+}
+
+export function allowMountLocalPusher(auth: MediaAuthState) {
+ pusherCreatedWith = { ...auth };
+ canMountLocalPusher.value = true;
+}
+
+export function shouldRecreateLocalPusher(auth: MediaAuthState): boolean {
+ if (!isWeChat || !canMountLocalPusher.value) {
+ return false;
+ }
+ // live-pusher cannot start without the record scope. A mid-call mic revoke
+ // must not remount with microphone:false — the guard closes capture instead.
+ if (!auth.microphone && pusherCreatedWith.microphone) {
+ return false;
+ }
+ return (
+ auth.camera !== pusherCreatedWith.camera ||
+ auth.microphone !== pusherCreatedWith.microphone
+ );
+}
+
+/**
+ * Give the renderer a chance to unmount and remount the native component.
+ * Two ticks: one for the v-if teardown, one for the keyed remount.
+ */
+export async function waitForPusherRemount() {
+ await nextTick();
+ await nextTick();
+}
+
+function waitForTrtcEnterRoom(trtcCloud: any): Promise {
+ return new Promise(resolve => {
+ const finish = () => {
+ clearTimeout(timer);
+ trtcCloud.off?.('onEnterRoom', finish);
+ resolve();
+ };
+ const timer = setTimeout(finish, TRTC_ENTER_ROOM_TIMEOUT);
+ trtcCloud.on?.('onEnterRoom', finish);
+ });
+}
+
+async function reenterTrtcRoomIfNeeded() {
+ if (!trtcNeedsReenter) {
+ return;
+ }
+ trtcNeedsReenter = false;
+ const roomEngine = useGetRoomEngine();
+ const basicStore = useBasicStore();
+ if (!basicStore.roomId || !basicStore.userSig) {
+ return;
+ }
+ const trtcCloud = roomEngine.instance?.getTRTCCloud();
+ if (!trtcCloud?.enterRoom) {
+ logger.warn(`${logPrefix}trtcCloud.enterRoom unavailable`);
+ return;
+ }
+ try {
+ await nextTick();
+ const entered = waitForTrtcEnterRoom(trtcCloud);
+ await trtcCloud.enterRoom(
+ {
+ sdkAppId: basicStore.sdkAppId,
+ userId: basicStore.userId,
+ userSig: basicStore.userSig,
+ roomId: 0,
+ strRoomId: String(basicStore.roomId),
+ role: TRTCRoleType.TRTCRoleAnchor,
+ },
+ TRTCAppScene.TRTCAppSceneLIVE
+ );
+ await entered;
+ trtcCloud.switchRole?.(TRTCRoleType.TRTCRoleAnchor);
+ trtcCloud.enableSmallVideoStream?.(true, smallParam);
+ logger.log(`${logPrefix}re-entered TRTC room after pusher recreate`);
+ } catch (error) {
+ logger.warn(`${logPrefix}reenter TRTC room failed:`, error);
+ }
+}
+
+async function doRecreateLocalPusher(auth: MediaAuthState) {
+ if (canMountLocalPusher.value && useBasicStore().roomId) {
+ trtcNeedsReenter = true;
+ }
+ canMountLocalPusher.value = false;
+ await nextTick();
+ localPusherEpoch.value += 1;
+ pusherCreatedWith = { ...auth };
+ canMountLocalPusher.value = true;
+ await waitForPusherRemount();
+ await reenterTrtcRoomIfNeeded();
+ logger.log(`${logPrefix}recreated live-pusher`, auth);
+}
+
+async function closeLocalMedia() {
+ const roomEngine = useGetRoomEngine();
+ try {
+ await roomEngine.instance?.closeLocalCamera();
+ } catch (error) {
+ logger.warn(`${logPrefix}closeLocalCamera failed:`, error);
+ }
+ try {
+ await roomEngine.instance?.closeLocalMicrophone();
+ } catch (error) {
+ logger.warn(`${logPrefix}closeLocalMicrophone failed:`, error);
+ }
+}
+
+async function restoreLocalMedia(
+ auth: MediaAuthState,
+ options: { restoreCamera: boolean; restoreMicrophone: boolean }
+) {
+ const roomEngine = useGetRoomEngine();
+ const basicStore = useBasicStore();
+ if (options.restoreMicrophone && auth.microphone) {
+ try {
+ await roomEngine.instance?.unmuteLocalAudio();
+ await roomEngine.instance?.openLocalMicrophone();
+ basicStore.setIsOpenMic(true);
+ } catch (error) {
+ logger.warn(`${logPrefix}restore microphone failed:`, error);
+ }
+ }
+ if (options.restoreCamera && auth.camera) {
+ try {
+ await roomEngine.instance?.openLocalCamera({
+ isFrontCamera: basicStore.isFrontCamera,
+ });
+ } catch (error) {
+ logger.warn(`${logPrefix}restore camera failed:`, error);
+ }
+ }
+}
+
+export function recreateLocalPusher(auth: MediaAuthState): Promise {
+ return runExclusive(() => doRecreateLocalPusher(auth));
+}
+
+/**
+ * Recreate the pusher and bring back whichever streams were live before, so a
+ * mid-call permission change does not silently mute or blank the user.
+ */
+export function recreateAndRestoreLocalPusher(
+ auth: MediaAuthState
+): Promise {
+ return runExclusive(async () => {
+ const roomStore = useRoomStore();
+ const basicStore = useBasicStore();
+ const restoreCamera = !!roomStore.localUser.hasVideoStream;
+ const restoreMicrophone =
+ !!roomStore.localUser.hasAudioStream || basicStore.isOpenMic;
+ await closeLocalMedia();
+ await doRecreateLocalPusher(auth);
+ await restoreLocalMedia(auth, { restoreCamera, restoreMicrophone });
+ });
+}
diff --git a/MiniProgram/src/roomkit/TUIRoom/hooks/useWxMediaGuard.ts b/MiniProgram/src/roomkit/TUIRoom/hooks/useWxMediaGuard.ts
new file mode 100644
index 000000000..5b7d3fe68
--- /dev/null
+++ b/MiniProgram/src/roomkit/TUIRoom/hooks/useWxMediaGuard.ts
@@ -0,0 +1,285 @@
+/**
+ * Single entry point for "make this device usable on WeChat".
+ *
+ * Every caller — toolbar buttons, post-enter setup, onError recovery and the
+ * page-show resync — goes through ensureDeviceUsable, which owns the whole
+ * sequence: read permission, guide the user, recreate live-pusher, open the
+ * device. Keeping it in one place is what stops the four flows from drifting
+ * apart.
+ */
+import { isWeChat } from '../utils/environment';
+import {
+ MediaAuthState,
+ MediaPermissionDevice,
+ TranslateFn,
+ getCurrentMediaAuth,
+ guideDevicePermission,
+ isDeviceAuthorized,
+} from '../utils/wxPermission';
+import { createSerialRunner } from '../utils/serialTask';
+import {
+ canMountLocalPusher,
+ recreateAndRestoreLocalPusher,
+ recreateLocalPusher,
+ shouldRecreateLocalPusher,
+} from './useLocalPusher';
+import { useBasicStore } from '../stores/basic';
+import { useRoomStore } from '../stores/room';
+import useGetRoomEngine from './useRoomEngine';
+import logger from '../utils/common/logger';
+
+const logPrefix = '[wxMediaGuard]';
+
+/**
+ * A forced recreate that does not fix capture would otherwise loop: opening
+ * the device fails, onError fires, and we recreate again.
+ */
+const FORCE_RECREATE_COOLDOWN = 5000;
+
+export interface EnsureDeviceOptions {
+ /**
+ * The user asked for this device, so go straight to wx.authorize instead of
+ * explaining ourselves in a modal first. wx.authorize itself does not need a
+ * user tap; only wx.openSetting does, and that runs from a modal button.
+ */
+ userGesture?: boolean;
+ /** Open the device once it is usable. Callers that open it themselves omit this. */
+ open?: boolean;
+ /**
+ * Recreate live-pusher even when permission never changed. Used by error
+ * recovery, where capture is broken although the scope is granted.
+ */
+ forceRecreate?: boolean;
+}
+
+const runSerial = createSerialRunner();
+
+let lastForceRecreateAt = 0;
+
+/**
+ * A WeChat modal blocks the JS bridge while it is open. Opening one during
+ * enterRoom can stop TRTC from ever delivering its callbacks, so enterRoom
+ * never settles and the entering overlay stays up forever. TRTC also reports
+ * a denied scope through onError while entering, which is exactly when this
+ * would happen — those prompts are deferred to ensureMediaAfterEnter.
+ */
+let isEnteringRoom = false;
+
+export function setRoomEntering(entering: boolean) {
+ isEnteringRoom = entering;
+}
+
+async function resolvePermission(
+ device: MediaPermissionDevice,
+ t: TranslateFn,
+ userGesture: boolean
+): Promise {
+ const { granted } = await guideDevicePermission(device, t, userGesture);
+ return granted;
+}
+
+/**
+ * Keep walking the user through authorize / settings until the scope is
+ * granted or they cancel. System-level denial is terminal — WeChat cannot
+ * open OS settings for us.
+ */
+async function resolvePermissionUntilGranted(
+ device: MediaPermissionDevice,
+ t: TranslateFn,
+ userGesture: boolean
+): Promise {
+ let gesture = userGesture;
+ while (true) {
+ const { granted, cancelled } = await guideDevicePermission(
+ device,
+ t,
+ gesture
+ );
+ if (granted) {
+ return true;
+ }
+ if (cancelled) {
+ return false;
+ }
+ gesture = false;
+ }
+}
+
+async function syncPusherWithAuth(
+ auth: MediaAuthState,
+ forceRecreate: boolean
+): Promise {
+ if (!canMountLocalPusher.value) {
+ await recreateLocalPusher(auth);
+ return true;
+ }
+ if (shouldRecreateLocalPusher(auth)) {
+ await recreateAndRestoreLocalPusher(auth);
+ return true;
+ }
+ if (!forceRecreate) {
+ return true;
+ }
+ const now = Date.now();
+ if (now - lastForceRecreateAt < FORCE_RECREATE_COOLDOWN) {
+ logger.warn(`${logPrefix}skip forced recreate, still in cooldown`);
+ return false;
+ }
+ lastForceRecreateAt = now;
+ await recreateAndRestoreLocalPusher(auth);
+ return true;
+}
+
+function openDevice(device: MediaPermissionDevice) {
+ const roomEngine = useGetRoomEngine();
+ const basicStore = useBasicStore();
+ // Not awaited: on WeChat openLocalCamera can start the preview and never
+ // resolve, which would stall whoever is waiting on the guard.
+ if (device === 'microphone') {
+ roomEngine.instance
+ ?.unmuteLocalAudio()
+ .then(() => {
+ if (basicStore.isOpenMic) {
+ return;
+ }
+ roomEngine.instance?.openLocalMicrophone();
+ basicStore.setIsOpenMic(true);
+ })
+ .catch((error: unknown) => {
+ logger.error(`${logPrefix}open microphone failed:`, error);
+ });
+ return;
+ }
+ roomEngine.instance
+ ?.openLocalCamera({ isFrontCamera: basicStore.isFrontCamera })
+ .catch((error: unknown) => {
+ logger.error(`${logPrefix}open camera failed:`, error);
+ });
+}
+
+/**
+ * Resolves to whether the device ended up usable. Callers should not open the
+ * device themselves when this returns false.
+ */
+export function ensureDeviceUsable(
+ device: MediaPermissionDevice,
+ t: TranslateFn,
+ options: EnsureDeviceOptions = {}
+): Promise {
+ if (!isWeChat) {
+ return Promise.resolve(true);
+ }
+ return runSerial(async () => {
+ if (isEnteringRoom) {
+ logger.warn(`${logPrefix}skip ${device} prompt while entering room`);
+ return false;
+ }
+ const granted = await resolvePermission(device, t, !!options.userGesture);
+ if (!granted) {
+ return false;
+ }
+ const auth = await getCurrentMediaAuth();
+ const ready = await syncPusherWithAuth(auth, !!options.forceRecreate);
+ if (!ready) {
+ return false;
+ }
+ if (options.open) {
+ openDevice(device);
+ }
+ return true;
+ });
+}
+
+/**
+ * Settle the scopes before live-pusher is created and before we enter.
+ *
+ * live-pusher captures the auth state at creation time and cannot start at all
+ * without the record scope. TRTC then reports "Not allowed to use microphone"
+ * and TUIRoomEngine.enterRoom never settles, so the user is stuck on the
+ * entering overlay with no way out. Asking first is what keeps that from
+ * happening — and it lets the pusher be created once, with the final answer,
+ * instead of being torn down again right after the room is up.
+ *
+ * Resolves to the auth state the caller should create the pusher with. The mic
+ * can still come back denied if the user cancels the guide; entering the room
+ * is the caller's decision.
+ */
+export function ensureMediaBeforeEnter(
+ need: Partial,
+ t: TranslateFn
+): Promise {
+ if (!isWeChat) {
+ return Promise.resolve({ camera: true, microphone: true });
+ }
+ return runSerial(async () => {
+ // Requested even when the user joins muted, because live-pusher needs it.
+ // Loop so "go to settings" can succeed without kicking the user home.
+ await resolvePermissionUntilGranted('microphone', t, true);
+ if (need.camera) {
+ await resolvePermissionUntilGranted('camera', t, true);
+ }
+ return getCurrentMediaAuth();
+ });
+}
+
+/**
+ * Open whatever the room was asked to open. Runs after ROOM_START / ROOM_JOIN,
+ * so the entering overlay is already gone if a scope still needs a prompt.
+ */
+export async function ensureMediaAfterEnter(
+ need: Partial,
+ t: TranslateFn
+) {
+ const devices: MediaPermissionDevice[] = ['microphone', 'camera'];
+ for (const device of devices) {
+ if (need[device]) {
+ await ensureDeviceUsable(device, t, { open: true });
+ }
+ }
+}
+
+/**
+ * The user may have flipped a scope in the WeChat settings page while the room
+ * was in the background. Grants (and camera revoke) remount live-pusher.
+ * Microphone revoke cannot remount: live-pusher cannot start without record,
+ * so capture is closed and the toolbar shows unauthorized instead.
+ */
+export async function syncLocalPusherOnPageShow() {
+ if (!isWeChat) {
+ return;
+ }
+ const auth = await getCurrentMediaAuth();
+ if (!canMountLocalPusher.value) {
+ return;
+ }
+ if (shouldRecreateLocalPusher(auth)) {
+ logger.log(`${logPrefix}auth changed on page show, recreate pusher`);
+ await recreateAndRestoreLocalPusher(auth);
+ return;
+ }
+ await closeRevokedLocalMedia(auth);
+}
+
+async function closeRevokedLocalMedia(auth: MediaAuthState) {
+ const roomEngine = useGetRoomEngine();
+ const basicStore = useBasicStore();
+ const roomStore = useRoomStore();
+ if (!isDeviceAuthorized(auth, 'microphone') && basicStore.isOpenMic) {
+ try {
+ await roomEngine.instance?.muteLocalAudio();
+ basicStore.setIsOpenMic(false);
+ } catch (error) {
+ logger.warn(`${logPrefix}mute revoked microphone failed:`, error);
+ }
+ }
+ if (
+ !isDeviceAuthorized(auth, 'camera') &&
+ roomStore.localUser.hasVideoStream
+ ) {
+ try {
+ await roomEngine.instance?.closeLocalCamera();
+ } catch (error) {
+ logger.warn(`${logPrefix}close revoked camera failed:`, error);
+ }
+ }
+}
diff --git a/MiniProgram/src/roomkit/TUIRoom/locales/en-US.ts b/MiniProgram/src/roomkit/TUIRoom/locales/en-US.ts
index 39b28ad4a..506239d36 100644
--- a/MiniProgram/src/roomkit/TUIRoom/locales/en-US.ts
+++ b/MiniProgram/src/roomkit/TUIRoom/locales/en-US.ts
@@ -1,6 +1,8 @@
export default {
'The room does not exist, please confirm the room number or create a room!':
'The room does not exist, please confirm the room number or create a room!',
+ 'Microphone permission is required to join the meeting, please enable it and try again':
+ 'Microphone permission is required to join the meeting, please enable it and try again',
'Log out': 'Log out',
'Edit profile': 'Edit profile',
'User Name': 'User Name',
@@ -126,7 +128,25 @@ export default {
'Muted by the moderator': 'Muted by the moderator',
'Type a message': 'Type a message',
Send: 'Send',
+ Image: 'Image',
+ File: 'File',
+ Forward: 'Forward',
'Failed to send the message': 'Failed to send the message',
+ 'Failed to send the image': 'Failed to send the image',
+ 'Failed to send the file': 'Failed to send the file',
+ 'Failed to download the file': 'Failed to download the file',
+ 'Failed to open the file': 'Failed to open the file',
+ 'Failed to forward the file': 'Failed to forward the file',
+ 'Failed to save the file': 'Failed to save the file',
+ 'This file type cannot be previewed': 'This file type cannot be previewed',
+ 'File saved': 'File saved',
+ 'WeChat on mobile cannot save this file type to your phone':
+ 'This file type cannot be previewed or saved on mobile. You can forward it to WeChat.',
+ 'The file cannot exceed 100MB': 'The file cannot exceed 100MB',
+ Loading: 'Loading...',
+ 'No more messages': 'No more messages',
+ 'Back to bottom': 'Back to bottom',
+ 'n new messages': ({ named }: any) => `${named('count')} new messages`,
'Applying for the stage': 'Applying for the stage',
'Apply for the stage': 'Apply for the stage',
'Cancel Apply': 'Cancel Apply',
@@ -491,6 +511,26 @@ export default {
({ named }: any) =>
`You can go to "System Preferences - Security & Privacy - ${named('deviceType')}" to enable device permissions.`,
'Go to Settings': 'Go to Settings',
+ 'Files are selected from your WeChat chats':
+ 'Files are selected from your WeChat chats. Continue?',
+ // WeChat showModal buttons allow at most 4 characters.
+ 'Go (short)': 'Go',
+ 'Authorize (short)': 'Allow',
+ 'Go to Settings (short)': 'Open',
+ 'Cancel (short)': 'No',
+ 'I got it (short)': 'OK',
+ Tip: 'Notice',
+ 'Permission prompt': 'Permission prompt',
+ 'The current mini program does not have live-pusher permission':
+ 'This mini program has not enabled real-time audio/video (live-pusher), so the meeting cannot start. Please enable the live-pusher / live-player capability in WeChat Official Account Platform and retry.',
+ 'Please tap to grant device permission': ({ named }: any) =>
+ `This meeting needs the ${named('deviceType')}. Tap to authorize`,
+ 'You have denied device permission, please enable it in mini-program settings':
+ ({ named }: any) =>
+ `You have denied ${named('deviceType')} permission. Please enable it in mini-program settings and retry`,
+ 'WeChat does not have device permission, please enable it in system settings':
+ ({ named }: any) =>
+ `WeChat does not have ${named('deviceType')} permission. Please enable it in system settings > WeChat and retry`,
addMember: 'addMember',
shareRoom: 'shareRoom',
'Invitation sent, waiting for members to join.':
diff --git a/MiniProgram/src/roomkit/TUIRoom/locales/zh-CN.ts b/MiniProgram/src/roomkit/TUIRoom/locales/zh-CN.ts
index 6e1a6d34a..3c64984af 100644
--- a/MiniProgram/src/roomkit/TUIRoom/locales/zh-CN.ts
+++ b/MiniProgram/src/roomkit/TUIRoom/locales/zh-CN.ts
@@ -1,6 +1,8 @@
export default {
'The room does not exist, please confirm the room number or create a room!':
'房间不存在,请确认房间号或创建房间!',
+ 'Microphone permission is required to join the meeting, please enable it and try again':
+ '加入会议需要麦克风权限,请开启后重试',
'Log out': '退出登录',
'Edit profile': '编辑资料',
'User Name': '用户名',
@@ -118,7 +120,25 @@ export default {
'Muted by the moderator': '已被主持人禁言',
'Type a message': '说点什么...',
Send: '发送',
+ Image: '图片',
+ File: '文件',
+ Forward: '转发',
'Failed to send the message': '发送消息失败',
+ 'Failed to send the image': '发送图片失败',
+ 'Failed to send the file': '发送文件失败',
+ 'Failed to download the file': '下载文件失败',
+ 'Failed to open the file': '打开文件失败',
+ 'Failed to forward the file': '转发失败',
+ 'Failed to save the file': '保存文件失败',
+ 'This file type cannot be previewed': '该文件类型暂不支持预览',
+ 'File saved': '已保存到本地',
+ 'WeChat on mobile cannot save this file type to your phone':
+ '该文件类型无法预览或保存到手机,可转发到微信后查看',
+ 'The file cannot exceed 100MB': '文件不能超过 100MB',
+ Loading: '加载中...',
+ 'No more messages': '没有更多消息',
+ 'Back to bottom': '回到底部',
+ 'n new messages': ({ named }: any) => `${named('count')} 条新消息`,
'Applying for the stage': '正在申请上台',
'Apply for the stage': '申请上台',
'Cancel Apply': '取消申请',
@@ -470,6 +490,26 @@ export default {
({ named }: any) =>
`你可前往"系统设置 - 隐私与安全性 - ${named('deviceType')}"开启设备权限。`,
'Go to Settings': '前往设置',
+ 'Files are selected from your WeChat chats':
+ '发送文件需从微信聊天记录中选择,是否前往?',
+ // WeChat showModal buttons allow at most 4 characters.
+ 'Go (short)': '前往',
+ 'Authorize (short)': '去授权',
+ 'Go to Settings (short)': '前往设置',
+ 'Cancel (short)': '取消',
+ 'I got it (short)': '我知道了',
+ Tip: '提示',
+ 'Permission prompt': '权限提示',
+ 'The current mini program does not have live-pusher permission':
+ '当前小程序未开通实时音视频(live-pusher)能力,无法进行音视频会议。请在微信公众平台为小程序开通「实时播放音视频流」类目权限后重试。',
+ 'Please tap to grant device permission': ({ named }: any) =>
+ `会议需要使用${named('deviceType')},请点击授权`,
+ 'You have denied device permission, please enable it in mini-program settings':
+ ({ named }: any) =>
+ `您已拒绝${named('deviceType')}权限,请前往小程序设置页面开启后重试`,
+ 'WeChat does not have device permission, please enable it in system settings':
+ ({ named }: any) =>
+ `您的微信没有${named('deviceType')}权限,请前往手机系统设置 > 微信,开启权限后重试`,
addMember: '添加成员',
shareRoom: '分享房间',
'Invitation sent, waiting for members to join.':
diff --git a/MiniProgram/src/roomkit/TUIRoom/services/function/errorHandler.ts b/MiniProgram/src/roomkit/TUIRoom/services/function/errorHandler.ts
index 7cf0c0b05..8cd3e2c7e 100644
--- a/MiniProgram/src/roomkit/TUIRoom/services/function/errorHandler.ts
+++ b/MiniProgram/src/roomkit/TUIRoom/services/function/errorHandler.ts
@@ -1,6 +1,7 @@
import { TUIErrorCode } from '@tencentcloud/tuiroom-engine-wx';
-import { EventType } from '../types';
-import { isElectron } from '../../utils/environment';
+import { EventType, WX_MICROPHONE_REQUIRED } from '../types';
+import { isElectron, isWeChat } from '../../utils/environment';
+import { handleLivePusherError } from './livePusherError';
type ErrorFunctionName = 'createRoom' | 'enterRoom' | 'onError';
export class ErrorHandler {
@@ -41,6 +42,10 @@ export class ErrorHandler {
message =
'The room does not exist, please confirm the room number or create a room!';
break;
+ case WX_MICROPHONE_REQUIRED:
+ message =
+ 'Microphone permission is required to join the meeting, please enable it and try again';
+ break;
default:
message = 'Failed to enter the meeting';
}
@@ -74,6 +79,14 @@ export class ErrorHandler {
}
private handleOnError(error: any) {
+ if (isWeChat) {
+ const device = getWxDeniedDevice(error);
+ if (device) {
+ this.service.emit(EventType.WX_DEVICE_PERMISSION_DENIED, { device });
+ return;
+ }
+ handleLivePusherError(error);
+ }
if (error.message === 'enter trtc room failed , error code : -1') {
this.service.emit(EventType.ROOM_NOTICE_MESSAGE_BOX, {
type: 'warning',
@@ -99,6 +112,36 @@ export class ErrorHandler {
}
type MediaDeviceType = 'camera' | 'microphone' | 'screenShare';
+
+/**
+ * TRTC-WX surfaces a denied scope as onError { code: -1 } with the reason only
+ * in the message, so it has to be matched by text. Patterns are kept narrow —
+ * a loose "mic" substring would swallow unrelated errors.
+ */
+const WX_DENIED_DEVICE_PATTERNS: Array<{
+ device: 'camera' | 'microphone';
+ pattern: RegExp;
+}> = [
+ {
+ device: 'microphone',
+ pattern:
+ /not allowed to use microphone|scope\.record|microphone (?:permission )?(?:denied|not authorized)/,
+ },
+ {
+ device: 'camera',
+ pattern:
+ /not allowed to use camera|scope\.camera|camera (?:permission )?(?:denied|not authorized)/,
+ },
+];
+
+function getWxDeniedDevice(error: any): 'camera' | 'microphone' | null {
+ const message = String(error?.message || '').toLowerCase();
+ const matched = WX_DENIED_DEVICE_PATTERNS.find(item =>
+ item.pattern.test(message)
+ );
+ return matched ? matched.device : null;
+}
+
class MediaDeviceErrorHandler {
private service: any;
diff --git a/MiniProgram/src/roomkit/TUIRoom/services/function/livePusherError.ts b/MiniProgram/src/roomkit/TUIRoom/services/function/livePusherError.ts
new file mode 100644
index 000000000..5952c67cd
--- /dev/null
+++ b/MiniProgram/src/roomkit/TUIRoom/services/function/livePusherError.ts
@@ -0,0 +1,104 @@
+/**
+ * WeChat live-pusher qualification errors.
+ *
+ * trtc-component-wx handles live-pusher binderror internally and forwards it
+ * as TRTCCloud.on('onError'). CallKit treats errno 103 as "live-pusher
+ * qualification is not enabled".
+ */
+import { TUIRoomEngine } from '@tencentcloud/tuiroom-engine-wx';
+import useGetRoomEngine from '../../hooks/useRoomEngine';
+import i18n from '../../locales';
+import logger from '../../utils/common/logger';
+import { reportTUIKeyFeature, TUIKeyFeature } from '../../utils/tuiKeyFeatures';
+
+declare const uni: any;
+
+const logPrefix = '[livePusherError]';
+
+let hasHandledLivePusherNoPermission = false;
+let hasBoundTrtcCloudError = false;
+
+function collectErrorText(event: any): string {
+ const detail = event?.detail ?? {};
+ return [
+ event?.message,
+ event?.errMsg,
+ event?.code,
+ event?.errCode,
+ detail?.errMsg,
+ detail?.message,
+ detail?.detail,
+ detail?.errCode,
+ detail?.errno,
+ ]
+ .filter(value => value !== undefined && value !== null && value !== '')
+ .join(' ')
+ .toLowerCase();
+}
+
+function isLivePusherNoPermission(event: any): boolean {
+ const errno = Number(
+ event?.detail?.errno ?? event?.errno ?? event?.code ?? event?.errCode
+ );
+ if (errno === 103) {
+ return true;
+ }
+ const text = collectErrorText(event);
+ return (
+ text.includes('jsapi has no permission') ||
+ text.includes('fail:access denied')
+ );
+}
+
+function showLivePusherNoPermissionTip() {
+ const t = i18n.global.t.bind(i18n);
+ uni.showModal({
+ title: t('Tip'),
+ content: t('The current mini program does not have live-pusher permission'),
+ showCancel: false,
+ confirmText: t('I got it (short)'),
+ });
+}
+
+export function handleLivePusherError(event: any) {
+ if (!isLivePusherNoPermission(event) || hasHandledLivePusherNoPermission) {
+ return;
+ }
+ hasHandledLivePusherNoPermission = true;
+ reportTUIKeyFeature(TUIKeyFeature.livePusherNoPermission);
+ logger.warn(
+ `${logPrefix}live-pusher qualification missing`,
+ TUIKeyFeature.livePusherNoPermission.code
+ );
+ showLivePusherNoPermissionTip();
+}
+
+function onTrtcCloudError(code: any, message?: any) {
+ handleLivePusherError({
+ code,
+ message,
+ errMsg: typeof message === 'string' ? message : undefined,
+ errno: code,
+ detail: {
+ errno: code,
+ errCode: code,
+ errMsg: message,
+ },
+ });
+}
+
+export function bindTrtcCloudLivePusherError() {
+ const attach = () => {
+ const trtcCloud = useGetRoomEngine().instance?.getTRTCCloud?.();
+ if (!trtcCloud?.on || hasBoundTrtcCloudError) {
+ return;
+ }
+ hasBoundTrtcCloudError = true;
+ trtcCloud.on('onError', onTrtcCloudError);
+ };
+ if (useGetRoomEngine().instance) {
+ attach();
+ return;
+ }
+ TUIRoomEngine.once('ready', attach);
+}
diff --git a/MiniProgram/src/roomkit/TUIRoom/services/manager/mediaManager.ts b/MiniProgram/src/roomkit/TUIRoom/services/manager/mediaManager.ts
index 67c0668a0..31b6a66e7 100644
--- a/MiniProgram/src/roomkit/TUIRoom/services/manager/mediaManager.ts
+++ b/MiniProgram/src/roomkit/TUIRoom/services/manager/mediaManager.ts
@@ -9,7 +9,7 @@ import {
TRTCVideoRotation,
} from '@tencentcloud/tuiroom-engine-wx';
import { IRoomService, EventType } from '../types';
-import { isMobile } from '../../utils/environment';
+import { isMobile, isWeChat } from '../../utils/environment';
import { MESSAGE_DURATION } from '../../constants/message';
import logger from '../../utils/common/logger';
@@ -270,7 +270,11 @@ export class MediaManager {
userId === this.service.basicStore.userId &&
streamType === TUIVideoStreamType.kCameraStream
) {
- this.service.roomEngine.instance?.setLocalVideoView({ view: null });
+ // WeChat live-pusher does not use a DOM view. Clearing it here would
+ // call stopLocalPreview and hang the next openLocalCamera.
+ if (!isWeChat) {
+ this.service.roomEngine.instance?.setLocalVideoView({ view: null });
+ }
} else {
await this.service.roomEngine.instance?.stopPlayRemoteVideo({
userId,
diff --git a/MiniProgram/src/roomkit/TUIRoom/services/manager/roomActionManager.ts b/MiniProgram/src/roomkit/TUIRoom/services/manager/roomActionManager.ts
index fcee82574..66c6695a2 100644
--- a/MiniProgram/src/roomkit/TUIRoom/services/manager/roomActionManager.ts
+++ b/MiniProgram/src/roomkit/TUIRoom/services/manager/roomActionManager.ts
@@ -9,9 +9,25 @@ import {
TUIVideoStreamType,
TUIUserInfo,
} from '@tencentcloud/tuiroom-engine-wx';
-import { EventType, IRoomService, RoomParam } from '../types';
+import {
+ EventType,
+ IRoomService,
+ RoomParam,
+ WX_MICROPHONE_REQUIRED,
+} from '../types';
import { isMobile, isWeChat } from '../../utils/environment';
import logger from '../../utils/common/logger';
+import { MediaAuthState } from '../../utils/wxPermission';
+import {
+ allowMountLocalPusher,
+ resetLocalPusherState,
+ waitForPusherRemount,
+} from '../../hooks/useLocalPusher';
+import {
+ ensureMediaAfterEnter,
+ ensureMediaBeforeEnter,
+ setRoomEntering,
+} from '../../hooks/useWxMediaGuard';
const logPrefix = '[RoomService:roomActionManager]';
@@ -46,6 +62,12 @@ export type RoomParamsInfo = {
export class RoomActionManager {
private service: IRoomService;
+ /** Devices the room was asked to open, opened once the room is up. */
+ private pendingWxMediaNeed: MediaAuthState = {
+ camera: false,
+ microphone: false,
+ };
+
constructor(service: IRoomService) {
this.service = service;
}
@@ -88,6 +110,7 @@ export class RoomActionManager {
},
});
this.service.emit(EventType.ROOM_START, { roomId });
+ this.openWxMediaAfterEnter();
}
public async join(roomId: string, params: JoinParams = {}) {
@@ -112,6 +135,7 @@ export class RoomActionManager {
},
});
this.service.emit(EventType.ROOM_JOIN, { roomId });
+ this.openWxMediaAfterEnter();
}
public async leaveRoom() {
@@ -178,8 +202,10 @@ export class RoomActionManager {
}
public async enterRoom(options: { roomId: string; roomParam?: RoomParam }) {
+ setRoomEntering(true);
try {
- const { roomId, roomParam } = options;
+ const { roomId } = options;
+ const roomParam = await this.prepareWxMedia(options.roomParam);
const roomInfo = await this.doEnterRoom({
roomId,
roomType: TUIRoomType.kConference,
@@ -199,16 +225,111 @@ export class RoomActionManager {
timeout: 0,
}));
}
- this.setRoomParams(roomParam);
+ // Not awaited: openLocalCamera can start the preview yet never resolve,
+ // which would keep the entering overlay up forever.
+ this.setRoomParams(roomParam).catch((mediaError: unknown) => {
+ logger.error(`${logPrefix}setRoomParams error:`, mediaError);
+ });
} catch (error) {
logger.error(`${logPrefix}enterRoom error:`, error);
this.service.errorHandler.handleError(error, 'enterRoom');
throw error;
+ } finally {
+ setRoomEntering(false);
}
}
- private async setRoomParams(roomParam?: RoomParam) {
+ /**
+ * Settle the WeChat scopes, then mount live-pusher with the answer and strip
+ * the room params we are not allowed to honour.
+ *
+ * The record scope has to be resolved here rather than after the room is up:
+ * live-pusher cannot start without it, and TUIRoomEngine.enterRoom never
+ * settles while the pusher is down, so the user would sit on the entering
+ * overlay forever with no prompt to act on.
+ */
+ private async prepareWxMedia(
+ roomParam?: RoomParam
+ ): Promise {
+ this.pendingWxMediaNeed = {
+ microphone: !!roomParam?.isOpenMicrophone,
+ camera: !!roomParam?.isOpenCamera,
+ };
+ if (!isWeChat) {
+ return roomParam;
+ }
+ resetLocalPusherState();
+ const auth = await ensureMediaBeforeEnter(
+ { camera: !!roomParam?.isOpenCamera },
+ this.service.t.bind(this.service)
+ );
+ this.pendingWxMediaNeed = {
+ microphone: this.pendingWxMediaNeed.microphone && auth.microphone,
+ camera: this.pendingWxMediaNeed.camera && auth.camera,
+ };
+ if (!auth.microphone) {
+ const error = new Error(
+ 'WeChat denied the record scope, live-pusher cannot start'
+ ) as Error & { code: string };
+ error.code = WX_MICROPHONE_REQUIRED;
+ throw error;
+ }
+ // TRTC enterRoom waits for live-pusher to exist on the page.
+ this.restoreLocalUserForPusher();
+ allowMountLocalPusher(auth);
+ await waitForPusherRemount();
if (!roomParam) {
+ return roomParam;
+ }
+ return {
+ ...roomParam,
+ isOpenMicrophone: roomParam.isOpenMicrophone && auth.microphone,
+ isOpenCamera: roomParam.isOpenCamera && auth.camera,
+ };
+ }
+
+ private openWxMediaAfterEnter() {
+ if (!isWeChat) {
+ return;
+ }
+ // Not awaited: start / join should resolve as soon as the room is up,
+ // rather than waiting for the devices to come online.
+ ensureMediaAfterEnter(
+ this.pendingWxMediaNeed,
+ this.service.t.bind(this.service)
+ ).catch((error: unknown) => {
+ logger.error(`${logPrefix}ensureMediaAfterEnter error:`, error);
+ });
+ }
+
+ /**
+ * resetRoomData() clears local user/stream. Restore them so StreamRegion can
+ * render trtc-pusher before enterRoom, which TRTC requires on WeChat.
+ */
+ private restoreLocalUserForPusher() {
+ const { userId, userName, avatarUrl } = this.service.basicStore;
+ if (!userId) {
+ return;
+ }
+ this.service.roomStore.addUserInfo({
+ userId,
+ userName,
+ avatarUrl,
+ });
+ this.service.roomStore.addStreamInfo(
+ userId,
+ TUIVideoStreamType.kCameraStream
+ );
+ }
+
+ /**
+ * Device selection and auto-open for platforms with a device list. WeChat
+ * has neither: live-pusher owns the devices, and opening them before the
+ * user grants a scope makes TRTC report "Not allowed to use microphone".
+ * The WeChat path runs in openWxMediaAfterEnter instead.
+ */
+ private async setRoomParams(roomParam?: RoomParam) {
+ if (!roomParam || isWeChat) {
return;
}
const {
@@ -247,70 +368,70 @@ export class RoomActionManager {
const isCanOpenMicrophone =
isMaster || (!isMicrophoneDisableForAllUser && isFreeSpeakMode);
if (isCanOpenMicrophone) {
- if (isOpenMicrophone) {
- await this.service.roomEngine.instance?.unmuteLocalAudio();
- if (!this.service.basicStore.isOpenMic) {
- this.service.roomEngine.instance?.openLocalMicrophone();
- this.service.basicStore.setIsOpenMic(true);
- }
- if (!isWeChat && !isMobile) {
- const microphoneList =
- await this.service.roomEngine.instance?.getMicDevicesList();
- const speakerList =
- await this.service.roomEngine.instance?.getSpeakerDevicesList();
- if (microphoneList?.length === 0 || speakerList?.length === 0) return;
- if (
- !this.service.roomStore.currentMicrophoneId &&
- microphoneList.length > 0
- ) {
- this.service.roomStore.setCurrentMicrophoneId(
- microphoneList[0].deviceId
- );
+ try {
+ if (isOpenMicrophone) {
+ await this.service.roomEngine.instance?.unmuteLocalAudio();
+ if (!this.service.basicStore.isOpenMic) {
+ await this.service.roomEngine.instance?.openLocalMicrophone();
+ this.service.basicStore.setIsOpenMic(true);
}
- if (
- !this.service.roomStore.currentSpeakerId &&
- speakerList.length > 0
- ) {
- this.service.roomStore.setCurrentSpeakerId(speakerList[0].deviceId);
+ if (!isMobile) {
+ const microphoneList =
+ await this.service.roomEngine.instance?.getMicDevicesList();
+ const speakerList =
+ await this.service.roomEngine.instance?.getSpeakerDevicesList();
+ if (microphoneList?.length > 0 && speakerList?.length > 0) {
+ if (!this.service.roomStore.currentMicrophoneId) {
+ this.service.roomStore.setCurrentMicrophoneId(
+ microphoneList[0].deviceId
+ );
+ }
+ if (!this.service.roomStore.currentSpeakerId) {
+ this.service.roomStore.setCurrentSpeakerId(
+ speakerList[0].deviceId
+ );
+ }
+ await this.service.roomEngine.instance?.setCurrentMicDevice({
+ deviceId: this.service.roomStore.currentMicrophoneId,
+ });
+ }
}
- await this.service.roomEngine.instance?.setCurrentMicDevice({
- deviceId: this.service.roomStore.currentMicrophoneId,
- });
+ } else {
+ await this.service.roomEngine.instance?.muteLocalAudio();
}
- } else {
- await this.service.roomEngine.instance?.muteLocalAudio();
+ } catch (error) {
+ logger.error(`${logPrefix}open microphone error:`, error);
}
}
- // 是否可以自动打开摄像头
const isCanOpenCamera =
isMaster || (!isCameraDisableForAllUser && isFreeSpeakMode);
if (isCanOpenCamera && isOpenCamera) {
- if (isMobile) {
- await this.service.roomEngine.instance?.openLocalCamera({
- isFrontCamera: this.service.basicStore.isFrontCamera,
- });
- return;
- }
- const deviceManager =
- this.service.roomEngine.instance?.getMediaDeviceManager();
- if (!this.service.roomStore.currentCameraId) {
- const cameraList = await deviceManager.getDevicesList({
+ try {
+ if (isMobile) {
+ await this.service.roomEngine.instance?.openLocalCamera({
+ isFrontCamera: this.service.basicStore.isFrontCamera,
+ });
+ return;
+ }
+ const deviceManager =
+ this.service.roomEngine.instance?.getMediaDeviceManager();
+ if (!this.service.roomStore.currentCameraId) {
+ const cameraList = await deviceManager.getDevicesList({
+ type: TUIMediaDeviceType.kMediaDeviceTypeVideoCamera,
+ });
+ if (cameraList && cameraList.length > 0) {
+ this.service.roomStore.setCurrentCameraId(cameraList[0].deviceId);
+ }
+ }
+ await deviceManager.setCurrentDevice({
type: TUIMediaDeviceType.kMediaDeviceTypeVideoCamera,
+ deviceId: this.service.roomStore.currentCameraId,
});
- if (cameraList && cameraList.length > 0) {
- this.service.roomStore.setCurrentCameraId(cameraList[0].deviceId);
- }
+ await this.service.roomEngine.instance?.openLocalCamera();
+ } catch (error) {
+ logger.error(`${logPrefix}open camera error:`, error);
}
- await deviceManager.setCurrentDevice({
- type: TUIMediaDeviceType.kMediaDeviceTypeVideoCamera,
- deviceId: this.service.roomStore.currentCameraId,
- });
- /**
- * Turn on the local camera
- *
- **/
- await this.service.roomEngine.instance?.openLocalCamera();
}
}
@@ -339,7 +460,8 @@ export class RoomActionManager {
trtcCloud?.enableSmallVideoStream(!isH5, smallParam);
roomEngine.instance?.muteLocalAudio();
- if (!roomInfo.isSeatEnabled) {
+ // On WeChat, open the mic only after live-pusher is mounted with auth.
+ if (!roomInfo.isSeatEnabled && !isWeChat) {
roomEngine.instance?.openLocalMicrophone();
this.service.basicStore.setIsOpenMic(true);
}
diff --git a/MiniProgram/src/roomkit/TUIRoom/services/roomService.ts b/MiniProgram/src/roomkit/TUIRoom/services/roomService.ts
index c570d074d..2a253d688 100644
--- a/MiniProgram/src/roomkit/TUIRoom/services/roomService.ts
+++ b/MiniProgram/src/roomkit/TUIRoom/services/roomService.ts
@@ -17,6 +17,8 @@ import { useChatStore } from '../stores/chat';
import useDeviceManager from '../hooks/useDeviceManager';
import logger from '../utils/common/logger';
import { isMobile } from '../utils/environment';
+import { resetLocalPusherState } from '../hooks/useLocalPusher';
+import { bindTrtcCloudLivePusherError } from './function/livePusherError';
import i18n from '../locales';
import { MESSAGE_DURATION } from '../constants/message';
import {
@@ -159,6 +161,7 @@ export class RoomService implements IRoomService {
public bindRoomEngineEvents() {
roomEngine.instance?.on(TUIRoomEvents.onError, this.onError);
+ bindTrtcCloudLivePusherError();
roomEngine.instance?.on(
TUIRoomEvents.onRoomDismissed,
this.onRoomDismissed
@@ -439,6 +442,7 @@ export class RoomService implements IRoomService {
this.basicStore.reset();
this.chatStore.reset();
this.roomStore.reset();
+ resetLocalPusherState();
}
private storeInit(option: RoomInitData) {
diff --git a/MiniProgram/src/roomkit/TUIRoom/services/types.ts b/MiniProgram/src/roomkit/TUIRoom/services/types.ts
index 64f8d3a5d..b53de77ba 100644
--- a/MiniProgram/src/roomkit/TUIRoom/services/types.ts
+++ b/MiniProgram/src/roomkit/TUIRoom/services/types.ts
@@ -53,6 +53,12 @@ export interface RoomParam {
password?: string;
}
+/**
+ * Entering was abandoned because WeChat never granted the record scope, which
+ * live-pusher needs before TRTC can finish entering the room.
+ */
+export const WX_MICROPHONE_REQUIRED = 'WxMicrophoneRequired';
+
export enum EventType {
SERVICE_READY = 'ServiceReady',
ROOM_LOGIN = 'RoomLogin',
@@ -65,6 +71,7 @@ export enum EventType {
ROOM_DISMISS = 'RoomDestroy',
ROOM_ERROR = 'RoomError',
ROOM_NEED_PASSWORD = 'RoomNeedPassword',
+ WX_DEVICE_PERMISSION_DENIED = 'WxDevicePermissionDenied',
KICKED_OUT = 'KickedOut',
KICKED_OFFLINE = 'KickedOffline',
USER_SIG_EXPIRED = 'UserSigExpired',
diff --git a/MiniProgram/src/roomkit/TUIRoom/stores/chat.ts b/MiniProgram/src/roomkit/TUIRoom/stores/chat.ts
index 0a19bf59a..13c5ecfde 100644
--- a/MiniProgram/src/roomkit/TUIRoom/stores/chat.ts
+++ b/MiniProgram/src/roomkit/TUIRoom/stores/chat.ts
@@ -1,15 +1,39 @@
import { defineStore } from 'pinia';
+/**
+ * History messages are kept as they come from the Chat SDK. Image info may
+ * carry the url in either `url` or `imageUrl`, and file info may use either
+ * `fileUrl`/`fileName`/`fileSize` or `url`/`name`/`size`.
+ **/
+export interface MessageImageInfo {
+ imageUrl?: string;
+ url?: string;
+ width?: number;
+ height?: number;
+}
+
+export interface MessageFileInfo {
+ fileName?: string;
+ fileSize?: number;
+ fileUrl?: string;
+}
+
interface MessageItem {
ID: string;
type: string;
payload: {
- text: string;
+ text?: string;
+ imageInfoArray?: MessageImageInfo[];
+ fileName?: string;
+ fileSize?: number;
+ fileUrl?: string;
};
nick: string;
from: string;
flow: string;
sequence: number;
+ // Upload progress of the local image or file message, from 0 to 1
+ progress?: number;
}
interface ChatState {
@@ -19,6 +43,9 @@ interface ChatState {
isCompleted: boolean;
// Is the list of all messages pulled
nextReqMessageId: string;
+ isToolPanelOpen: boolean;
+ // Height of the on-screen keyboard, in px. 0 means the keyboard is hidden.
+ keyboardHeight: number;
}
export const useChatStore = defineStore('chat', {
@@ -28,6 +55,8 @@ export const useChatStore = defineStore('chat', {
unReadCount: 0,
isCompleted: false,
nextReqMessageId: '',
+ isToolPanelOpen: false,
+ keyboardHeight: 0,
}),
getters: {},
actions: {
@@ -37,6 +66,14 @@ export const useChatStore = defineStore('chat', {
this.messageList = this.messageList.concat([message]);
}
},
+ updateMessageItem(ID: string, updates: Partial) {
+ this.messageList = this.messageList.map(message =>
+ message.ID === ID ? { ...message, ...updates } : message
+ );
+ },
+ removeMessage(ID: string) {
+ this.messageList = this.messageList.filter(message => message.ID !== ID);
+ },
setMessageListInfo(
messageList: MessageItem[],
isCompleted: boolean,
@@ -64,10 +101,18 @@ export const useChatStore = defineStore('chat', {
setSendMessageDisableChanged(isDisable: boolean) {
this.isMessageDisabled = isDisable;
},
+ setToolPanelOpen(isOpen: boolean) {
+ this.isToolPanelOpen = isOpen;
+ },
+ setKeyboardHeight(height: number) {
+ this.keyboardHeight = height;
+ },
reset() {
this.messageList = [];
this.unReadCount = 0;
this.isMessageDisabled = false;
+ this.isToolPanelOpen = false;
+ this.keyboardHeight = 0;
},
},
});
diff --git a/MiniProgram/src/roomkit/TUIRoom/stores/room.ts b/MiniProgram/src/roomkit/TUIRoom/stores/room.ts
index 0701599d3..ad0c17b05 100644
--- a/MiniProgram/src/roomkit/TUIRoom/stores/room.ts
+++ b/MiniProgram/src/roomkit/TUIRoom/stores/room.ts
@@ -129,9 +129,9 @@ interface RoomState {
roomName: string;
isOnStateTabActive: boolean;
isLocalUserSharing: boolean;
- isWhiteboardVisiable: boolean;
+ isWhiteboardVisible: boolean;
isSharingScreen: boolean;
- isAnnotationVisiable: boolean;
+ isAnnotationVisible: boolean;
}
export const useRoomStore = defineStore('room', {
@@ -166,9 +166,9 @@ export const useRoomStore = defineStore('room', {
roomName: '',
isOnStateTabActive: true,
isLocalUserSharing: false,
- isWhiteboardVisiable: false,
+ isWhiteboardVisible: false,
isSharingScreen: false,
- isAnnotationVisiable: false,
+ isAnnotationVisible: false,
}),
getters: {
localUser(state: RoomState): UserInfo {
@@ -674,9 +674,9 @@ export const useRoomStore = defineStore('room', {
this.password = '';
this.roomName = '';
this.isLocalUserSharing = false;
- this.isWhiteboardVisiable = false;
+ this.isWhiteboardVisible = false;
this.isSharingScreen = false;
- this.isAnnotationVisiable = false;
+ this.isAnnotationVisible = false;
},
resetDeviceData() {
this.currentCameraId = '';
diff --git a/MiniProgram/src/roomkit/TUIRoom/utils/environment.ts b/MiniProgram/src/roomkit/TUIRoom/utils/environment.ts
index ce6a067a3..ae96ff135 100644
--- a/MiniProgram/src/roomkit/TUIRoom/utils/environment.ts
+++ b/MiniProgram/src/roomkit/TUIRoom/utils/environment.ts
@@ -1,6 +1,7 @@
import { getPlatform } from '@tencentcloud/universal-api';
declare const uni: any;
+declare const wx: any;
export const isPC = getPlatform() === 'pc';
@@ -12,6 +13,44 @@ export const isApp = getPlatform() === 'app';
export const isUniFrameWork = typeof uni !== 'undefined';
+function readSystemInfo(): Record {
+ try {
+ if (
+ typeof uni !== 'undefined' &&
+ typeof uni.getSystemInfoSync === 'function'
+ ) {
+ return uni.getSystemInfoSync() || {};
+ }
+ if (
+ typeof wx !== 'undefined' &&
+ typeof wx.getSystemInfoSync === 'function'
+ ) {
+ return wx.getSystemInfoSync() || {};
+ }
+ } catch {
+ return {};
+ }
+ return {};
+}
+
+/**
+ * WeChat mini program on HarmonyOS. live-pusher swallows parent touch/tap
+ * events, so RoomKit only mounts the gesture cover-view on this platform.
+ */
+export const isHarmonyOS = (() => {
+ const info = readSystemInfo();
+ const platform = String(info.platform || '').toLowerCase();
+ const system = String(info.system || '').toLowerCase();
+ const osName = String(info.osName || '').toLowerCase();
+ return (
+ platform === 'ohos' ||
+ platform === 'harmonyos' ||
+ osName === 'ohos' ||
+ osName === 'harmonyos' ||
+ system.includes('harmony')
+ );
+})();
+
// H5, small programs, apps are considered mobile products, if you need to unify the mobile UI style, you can directly use isMobile to control
export const isMobile = isH5 || isWeChat || isApp;
diff --git a/MiniProgram/src/roomkit/TUIRoom/utils/serialTask.ts b/MiniProgram/src/roomkit/TUIRoom/utils/serialTask.ts
new file mode 100644
index 000000000..ed4bb6f79
--- /dev/null
+++ b/MiniProgram/src/roomkit/TUIRoom/utils/serialTask.ts
@@ -0,0 +1,15 @@
+/**
+ * Run tasks one after another on a shared chain.
+ *
+ * Preferred over an "in flight" boolean for operations whose result the caller
+ * acts on: dropping a concurrent call would hand back a success the task never
+ * actually performed.
+ */
+export function createSerialRunner() {
+ let chain: Promise = Promise.resolve();
+ return function run(task: () => Promise): Promise {
+ const result = chain.then(task, task);
+ chain = result.catch(() => undefined);
+ return result;
+ };
+}
diff --git a/MiniProgram/src/roomkit/TUIRoom/utils/tuiKeyFeatures.ts b/MiniProgram/src/roomkit/TUIRoom/utils/tuiKeyFeatures.ts
new file mode 100644
index 000000000..79d87de21
--- /dev/null
+++ b/MiniProgram/src/roomkit/TUIRoom/utils/tuiKeyFeatures.ts
@@ -0,0 +1,44 @@
+/**
+ * IM `statTUIKeyFeatures` reporting for the WeChat mini program.
+ * TIM must already be logged in; otherwise the experimental API is a no-op.
+ */
+import { TUIRoomEngine } from '@tencentcloud/tuiroom-engine-wx';
+import useGetRoomEngine from '../hooks/useRoomEngine';
+import logger from './common/logger';
+
+const logPrefix = '[tuiKeyFeatures]';
+
+export const TUIKeyFeature = {
+ roomKitWx: {
+ code: 192000,
+ msg: 'TUIRoomKit-wx',
+ },
+ livePusherNoPermission: {
+ code: 192001,
+ msg: 'TUIRoomKit-wx-live-pusher-no-permission',
+ },
+} as const;
+
+type TUIKeyFeatureItem = (typeof TUIKeyFeature)[keyof typeof TUIKeyFeature];
+
+function getTim() {
+ return useGetRoomEngine().instance?.getTIM();
+}
+
+export function reportTUIKeyFeature(feature: TUIKeyFeatureItem) {
+ const send = () => {
+ try {
+ getTim()?.callExperimentalAPI('statTUIKeyFeatures', {
+ code: feature.code,
+ msg: `${feature.code}-${feature.msg}`,
+ });
+ } catch (error) {
+ logger.warn(`${logPrefix}report failed:`, error);
+ }
+ };
+ if (getTim()) {
+ send();
+ return;
+ }
+ TUIRoomEngine.once('ready', send);
+}
diff --git a/MiniProgram/src/roomkit/TUIRoom/utils/wxPermission.ts b/MiniProgram/src/roomkit/TUIRoom/utils/wxPermission.ts
new file mode 100644
index 000000000..3aea5246b
--- /dev/null
+++ b/MiniProgram/src/roomkit/TUIRoom/utils/wxPermission.ts
@@ -0,0 +1,427 @@
+/**
+ * WeChat mini-program camera / microphone permission primitives.
+ *
+ * This is the pure permission layer: it reads scopes, requests scopes and
+ * shows the guidance modal. It must not touch roomEngine or stores —
+ * orchestration lives in hooks/useWxMediaGuard.
+ *
+ * Reading and requesting are separate entry points on purpose: reading never
+ * shows UI, so it is safe on paths that must not interrupt the user.
+ */
+import { ref } from 'vue';
+import { isWeChat } from './environment';
+import logger from './common/logger';
+
+declare const uni: any;
+declare const wx: any;
+
+const logPrefix = '[wxPermission]';
+
+const AUTH_SETTING_TIMEOUT = 2000;
+
+export const PermissionScope = {
+ RECORD: 'scope.record',
+ CAMERA: 'scope.camera',
+} as const;
+
+export type MediaPermissionDevice = 'camera' | 'microphone';
+
+/**
+ * granted already authorized
+ * need-authorize never asked, wx.authorize can still show the native sheet
+ * mini-program user denied it, only openSetting can recover
+ * system WeChat itself has no OS-level permission
+ */
+export type PermissionLevel =
+ | 'granted'
+ | 'need-authorize'
+ | 'mini-program'
+ | 'system';
+
+export type DeniedPermissionLevel = Exclude;
+
+export interface PermissionCheckResult {
+ granted: boolean;
+ level: PermissionLevel;
+ device: MediaPermissionDevice;
+}
+
+export interface MediaAuthState {
+ camera: boolean;
+ microphone: boolean;
+}
+
+export interface PermissionPromptResult {
+ confirmed: boolean;
+}
+
+export interface GuidePermissionResult {
+ granted: boolean;
+ cancelled: boolean;
+}
+
+/**
+ * Last known WeChat media scopes. UI reads this; writers go through
+ * getCurrentMediaAuth / peek / request so the toolbar stays in sync.
+ */
+export const mediaAuthState = ref({
+ camera: !isWeChat,
+ microphone: !isWeChat,
+});
+
+export type TranslateFn = (key: string, params?: Record) => string;
+
+type ModalAction = 'none' | 'authorize' | 'open-setting';
+
+interface DeniedModalConfig {
+ tipKey: string;
+ confirmKey: string;
+ action: ModalAction;
+ showCancel: boolean;
+}
+
+const DENIED_MODAL_CONFIG: Record = {
+ system: {
+ tipKey:
+ 'WeChat does not have device permission, please enable it in system settings',
+ confirmKey: 'I got it (short)',
+ action: 'none',
+ showCancel: false,
+ },
+ 'need-authorize': {
+ tipKey: 'Please tap to grant device permission',
+ confirmKey: 'Authorize (short)',
+ action: 'authorize',
+ showCancel: true,
+ },
+ 'mini-program': {
+ tipKey:
+ 'You have denied device permission, please enable it in mini-program settings',
+ confirmKey: 'Go to Settings (short)',
+ action: 'open-setting',
+ showCancel: true,
+ },
+};
+
+function getWxApi(): any {
+ if (typeof wx !== 'undefined' && wx.getSetting) {
+ return wx;
+ }
+ if (typeof uni !== 'undefined' && uni.getSetting) {
+ return uni;
+ }
+ return null;
+}
+
+function scopeOf(device: MediaPermissionDevice): string {
+ return device === 'camera' ? PermissionScope.CAMERA : PermissionScope.RECORD;
+}
+
+function grantedResult(device: MediaPermissionDevice): PermissionCheckResult {
+ return { granted: true, level: 'granted', device };
+}
+
+function deniedResult(
+ device: MediaPermissionDevice,
+ level: DeniedPermissionLevel
+): PermissionCheckResult {
+ return { granted: false, level, device };
+}
+
+/**
+ * showModal rejects button labels longer than 4 characters. Locales own the
+ * wording through the "(short)" keys; this only guards against an overlong
+ * translation breaking the dialog.
+ */
+function wxModalButtonText(text: string): string {
+ return text.slice(0, 4);
+}
+
+function withTimeout(
+ promise: Promise,
+ ms: number,
+ fallback: T | null
+): Promise {
+ return new Promise(resolve => {
+ const timer = setTimeout(() => resolve(fallback), ms);
+ const settle = (value: T | null) => {
+ clearTimeout(timer);
+ resolve(value);
+ };
+ promise.then(settle).catch(() => settle(fallback));
+ });
+}
+
+function authFromSetting(
+ authSetting: Record
+): MediaAuthState {
+ return {
+ camera: authSetting[PermissionScope.CAMERA] === true,
+ microphone: authSetting[PermissionScope.RECORD] === true,
+ };
+}
+
+function rememberAuth(authSetting: Record) {
+ mediaAuthState.value = authFromSetting(authSetting);
+}
+
+function getAuthSetting(): Promise | null> {
+ const request = new Promise>(resolve => {
+ const api = getWxApi();
+ if (!api?.getSetting) {
+ resolve({});
+ return;
+ }
+ api.getSetting({
+ success(res: { authSetting?: Record }) {
+ resolve(res.authSetting || {});
+ },
+ fail() {
+ resolve({});
+ },
+ });
+ });
+ return withTimeout(request, AUTH_SETTING_TIMEOUT, null).then(authSetting => {
+ if (!authSetting) {
+ logger.warn(`${logPrefix}getSetting timed out`);
+ return null;
+ }
+ rememberAuth(authSetting);
+ return authSetting;
+ });
+}
+
+function authorizeScope(
+ scope: string
+): Promise<{ success: boolean; errMsg: string }> {
+ return new Promise(resolve => {
+ const api = getWxApi();
+ if (!api?.authorize) {
+ resolve({ success: false, errMsg: 'authorize unavailable' });
+ return;
+ }
+ api.authorize({
+ scope,
+ success() {
+ resolve({ success: true, errMsg: '' });
+ },
+ fail(err: { errMsg?: string }) {
+ resolve({ success: false, errMsg: err.errMsg || '' });
+ },
+ });
+ });
+}
+
+/**
+ * WeChat requires the privacy agreement before wx.authorize, otherwise the
+ * first camera / mic prompt can fail silently.
+ */
+function ensurePrivacyAgreed(): Promise {
+ return new Promise(resolve => {
+ const api = getWxApi();
+ if (!api?.requirePrivacyAuthorize) {
+ resolve(true);
+ return;
+ }
+ api.requirePrivacyAuthorize({
+ success() {
+ resolve(true);
+ },
+ fail(err: { errMsg?: string }) {
+ logger.warn(`${logPrefix}requirePrivacyAuthorize failed:`, err?.errMsg);
+ resolve(false);
+ },
+ });
+ });
+}
+
+function isSystemLevelDenial(errMsg: string): boolean {
+ const msg = (errMsg || '').toLowerCase();
+ return (
+ msg.includes('system permission') ||
+ msg.includes('system denied') ||
+ msg.includes('access denied because of system')
+ );
+}
+
+export async function getCurrentMediaAuth(): Promise {
+ if (!isWeChat) {
+ mediaAuthState.value = { camera: true, microphone: true };
+ return mediaAuthState.value;
+ }
+ const authSetting = await getAuthSetting();
+ if (!authSetting) {
+ return mediaAuthState.value;
+ }
+ const auth = authFromSetting(authSetting);
+ mediaAuthState.value = auth;
+ return auth;
+}
+
+export function isDeviceAuthorized(
+ auth: MediaAuthState,
+ device: MediaPermissionDevice
+): boolean {
+ return device === 'camera' ? auth.camera : auth.microphone;
+}
+
+/**
+ * Read the current scope state without triggering any WeChat dialog. Safe to
+ * call outside a user tap.
+ */
+export async function peekDevicePermission(
+ device: MediaPermissionDevice
+): Promise {
+ if (!isWeChat) {
+ return grantedResult(device);
+ }
+ const authSetting = await getAuthSetting();
+ if (!authSetting) {
+ return isDeviceAuthorized(mediaAuthState.value, device)
+ ? grantedResult(device)
+ : deniedResult(device, 'need-authorize');
+ }
+ const scope = scopeOf(device);
+ if (authSetting[scope] === true) {
+ return grantedResult(device);
+ }
+ return deniedResult(
+ device,
+ authSetting[scope] === false ? 'mini-program' : 'need-authorize'
+ );
+}
+
+/**
+ * Ask WeChat for the scope, showing the native sheet on the first ask. Use
+ * peekDevicePermission instead when the caller must not interrupt the user.
+ */
+export async function requestDevicePermission(
+ device: MediaPermissionDevice
+): Promise {
+ if (!isWeChat) {
+ return grantedResult(device);
+ }
+ const peeked = await peekDevicePermission(device);
+ if (peeked.granted || peeked.level === 'mini-program') {
+ return peeked;
+ }
+ if (!(await ensurePrivacyAgreed())) {
+ return deniedResult(device, 'need-authorize');
+ }
+ const scope = scopeOf(device);
+ const result = await authorizeScope(scope);
+ if (result.success) {
+ await getCurrentMediaAuth();
+ return grantedResult(device);
+ }
+ if (isSystemLevelDenial(result.errMsg)) {
+ return deniedResult(device, 'system');
+ }
+ const after = await getAuthSetting();
+ return deniedResult(
+ device,
+ after?.[scope] === false ? 'mini-program' : 'need-authorize'
+ );
+}
+
+/**
+ * The action must be fired synchronously from the modal callback, otherwise
+ * WeChat no longer treats it as a user gesture and silently ignores it.
+ *
+ * wx.authorize itself does not need the tap. Privacy must still run first,
+ * otherwise the native sheet can fail silently — the same rule as
+ * requestDevicePermission.
+ */
+function runModalAction(
+ api: any,
+ action: ModalAction,
+ device: MediaPermissionDevice,
+ done: () => void
+) {
+ if (action === 'authorize' && api.authorize) {
+ void ensurePrivacyAgreed().then(agreed => {
+ if (!agreed) {
+ done();
+ return;
+ }
+ api.authorize({ scope: scopeOf(device), complete: done });
+ });
+ return;
+ }
+ if (action === 'open-setting' && api.openSetting) {
+ api.openSetting({ complete: done });
+ return;
+ }
+ done();
+}
+
+/**
+ * Show a tappable dialog that walks the user to the right place for the given
+ * denial level. Callers re-read the auth state afterwards rather than trusting
+ * the dialog result. `confirmed` is whether the user tapped the action button.
+ */
+export function promptDevicePermission(
+ result: PermissionCheckResult,
+ t: TranslateFn
+): Promise {
+ return new Promise(resolve => {
+ const api = getWxApi();
+ if (result.granted || !api?.showModal) {
+ resolve({ confirmed: false });
+ return;
+ }
+ const config = DENIED_MODAL_CONFIG[result.level as DeniedPermissionLevel];
+ api.showModal({
+ title: t('Permission prompt'),
+ content: t(config.tipKey, { deviceType: t(result.device) }),
+ showCancel: config.showCancel,
+ confirmText: wxModalButtonText(t(config.confirmKey)),
+ cancelText: wxModalButtonText(t('Cancel (short)')),
+ success(modalRes: { confirm?: boolean }) {
+ if (!modalRes.confirm) {
+ resolve({ confirmed: false });
+ return;
+ }
+ runModalAction(api, config.action, result.device, () => {
+ resolve({ confirmed: true });
+ });
+ },
+ fail(err: { errMsg?: string }) {
+ logger.warn(`${logPrefix}showModal failed:`, err?.errMsg);
+ resolve({ confirmed: false });
+ },
+ });
+ });
+}
+
+/**
+ * Read or request the scope, then show the matching guide if it is still
+ * denied. Used by the in-room guard and the pre-room toggles.
+ *
+ * `cancelled` means the user dismissed the guide, or the denial can only be
+ * fixed in system settings. Callers that must keep trying should loop while
+ * `!granted && !cancelled`.
+ */
+export async function guideDevicePermission(
+ device: MediaPermissionDevice,
+ t: TranslateFn,
+ userGesture: boolean
+): Promise {
+ const result = userGesture
+ ? await requestDevicePermission(device)
+ : await peekDevicePermission(device);
+ if (result.granted) {
+ return { granted: true, cancelled: false };
+ }
+ const { confirmed } = await promptDevicePermission(result, t);
+ const auth = await getCurrentMediaAuth();
+ const granted = isDeviceAuthorized(auth, device);
+ if (granted) {
+ return { granted: true, cancelled: false };
+ }
+ const cancelled = !confirmed || result.level === 'system';
+ return { granted: false, cancelled };
+}
diff --git a/MiniProgram/src/roomkit/pages/home.vue b/MiniProgram/src/roomkit/pages/home.vue
index 34e8f66b4..6bf935b02 100644
--- a/MiniProgram/src/roomkit/pages/home.vue
+++ b/MiniProgram/src/roomkit/pages/home.vue
@@ -13,6 +13,10 @@ import PreConferenceView from '../TUIRoom/preConference.vue';
import { conference } from '../TUIRoom/index.ts';
import { getBasicInfo } from '../config/basic-info-config';
import { onMounted } from 'vue';
+import {
+ reportTUIKeyFeature,
+ TUIKeyFeature,
+} from '../TUIRoom/utils/tuiKeyFeatures';
declare const uni: any;
@@ -82,6 +86,7 @@ async function handleInit() {
uni.removeStorageSync('tuiRoom-roomInfo');
const { userId, sdkAppId, userSig, userName, avatarUrl } = userInfo;
await conference.login({ sdkAppId, userId, userSig });
+ reportTUIKeyFeature(TUIKeyFeature.roomKitWx);
await conference.setSelfInfo({ userName, avatarUrl });
}
diff --git a/MiniProgram/src/roomkit/pages/room.vue b/MiniProgram/src/roomkit/pages/room.vue
index 881d9c744..d3f56bfdc 100644
--- a/MiniProgram/src/roomkit/pages/room.vue
+++ b/MiniProgram/src/roomkit/pages/room.vue
@@ -4,14 +4,20 @@