新增功能

This commit is contained in:
Your Name
2026-03-04 15:32:30 +08:00
parent a07e844c47
commit ab77f5488d
2266 changed files with 177942 additions and 3444 deletions
@@ -0,0 +1,174 @@
import { ButtonState, FeatureButton, LayoutMode, NAME, StoreName } from "../const/index";
import { deepClone } from "../utils/index";
import isEmpty from "../utils/is-empty";
export interface IUIDesign {
updateViewBackgroundUserId: (viewType: 'local' | 'remote') => void;
hideFeatureButton: (buttonName: FeatureButton) => void;
setLocalViewBackgroundImage: (url: string) => void;
setRemoteViewBackgroundImage: (userId: string, url: string) => void;
setLayoutMode: (layoutMode: LayoutMode) => void;
setCameraDefaultState: (isOpen: boolean) => void;
setEngineInstance: (engineInstance: any) => void;
setTUIStore: (tuiStore: any) => void;
}
const DEFAULT_LOCAL_USER_ID = '_local_user_id';
export class UIDesign implements IUIDesign {
static instance: IUIDesign;
static getInstance() {
if (!UIDesign.instance) {
UIDesign.instance = new UIDesign();
}
return UIDesign.instance;
}
private _viewConfig = {
viewBackground: {
local: {},
remote: {},
}
};
private _isSetViewBackgroundConfig = { remote: false, local: false };
private _tuiCallEngine = null;
private _tuiStore = null;
private _updateViewBackground() {
const customUIConfig = this._tuiStore?.getData(StoreName.CALL, NAME.CUSTOM_UI_CONFIG);
const { userId } = this._tuiStore?.getData(StoreName.CALL, NAME.LOCAL_USER_INFO);
if (Object.keys(this._viewConfig.viewBackground.remote).includes(userId)) {
delete this._viewConfig.viewBackground.remote[userId];
}
this._tuiStore?.update(
StoreName.CALL,
NAME.CUSTOM_UI_CONFIG,
{
...customUIConfig,
viewBackground: {
...this._viewConfig.viewBackground.remote,
...this._viewConfig.viewBackground.local,
},
},
);
}
setEngineInstance(engineInstance) {
this._tuiCallEngine = engineInstance;
}
setTUIStore(tuiStore) {
this._tuiStore = tuiStore;
}
public updateViewBackgroundUserId(name) {
if (name === 'local') {
const { userId } = this._tuiStore?.getData(StoreName.CALL, NAME.LOCAL_USER_INFO);
if (Object.keys(this._viewConfig.viewBackground.remote).includes(userId)) {
delete this._viewConfig.viewBackground.remote[userId];
this._updateViewBackground();
}
if (!this._isSetViewBackgroundConfig.local) {
return;
}
const localViewBackgroundConfig = this._viewConfig.viewBackground.local;
const url = localViewBackgroundConfig[userId] || localViewBackgroundConfig[DEFAULT_LOCAL_USER_ID];
localViewBackgroundConfig[userId] = localViewBackgroundConfig[DEFAULT_LOCAL_USER_ID];
this._viewConfig.viewBackground.local = { [userId]: url };
this._updateViewBackground();
} else {
let remoteViewBackgroundConfig = this._viewConfig.viewBackground.remote;
if (this._isSetViewBackgroundConfig.remote && Object.keys(remoteViewBackgroundConfig).includes('*')) {
const remoteUserInfoList = this._tuiStore?.getData(StoreName.CALL, NAME.REMOTE_USER_INFO_LIST);
const remoteUserIdList = remoteUserInfoList.map((item) => item.userId);
remoteUserIdList.forEach((userId) => {
if (!Object.keys(remoteViewBackgroundConfig).includes(userId)) {
remoteViewBackgroundConfig[userId] = remoteViewBackgroundConfig['*'];
}
});
this._viewConfig.viewBackground.remote = remoteViewBackgroundConfig;
this._updateViewBackground();
}
}
}
public hideFeatureButton(buttonName: FeatureButton) {
this._tuiCallEngine?.reportLog?.({
name: 'TUICallKit.hideFeatureButton.start',
data: { buttonName },
});
const customUIConfig = this._tuiStore?.getData(StoreName.CALL, NAME.CUSTOM_UI_CONFIG);
this._tuiStore?.update(
StoreName.CALL,
NAME.CUSTOM_UI_CONFIG,
{
...customUIConfig,
button: {
...customUIConfig.button,
[buttonName]: { ...(customUIConfig.button?.[buttonName] || {}), show: false, },
},
}
);
}
public setLocalViewBackgroundImage(url: string) {
this._tuiCallEngine?.reportLog?.({
name: 'TUICallKit.setLocalViewBackgroundImage.start',
data: { url },
});
this._isSetViewBackgroundConfig.local = true;
let { userId } = this._tuiStore?.getData(StoreName.CALL, NAME.LOCAL_USER_INFO);
if (isEmpty(userId)) {
userId = DEFAULT_LOCAL_USER_ID;
}
this._viewConfig.viewBackground.local = { [userId]: url };
this._updateViewBackground();
}
public setRemoteViewBackgroundImage(userId: string, url:string) {
this._tuiCallEngine?.reportLog?.({
name: 'TUICallKit.setRemoteViewBackgroundImage.start',
data: { userId, url },
});
this._isSetViewBackgroundConfig.remote = true;
if (userId === '*') {
this._viewConfig.viewBackground.remote = {};
}
this._viewConfig.viewBackground.remote[userId] = url;
this._updateViewBackground();
}
public setLayoutMode(layoutMode: LayoutMode) {
this._tuiCallEngine?.reportLog?.({
name: 'TUICallKit.setLayoutMode.start',
data: { layoutMode },
});
const customUIConfig = this._tuiStore.getData(StoreName.CALL, NAME.CUSTOM_UI_CONFIG);
this._tuiStore.update(
StoreName.CALL,
NAME.CUSTOM_UI_CONFIG,
{
...customUIConfig,
layoutMode,
}
);
}
public setCameraDefaultState(isOpen: boolean) {
this._tuiCallEngine?.reportLog?.({
name: 'TUICallKit.setCameraDefaultState.start',
data: { isOpen },
});
const customUIConfig = deepClone(this._tuiStore.getData(StoreName.CALL, NAME.CUSTOM_UI_CONFIG));
if (!Object.keys(customUIConfig.button).includes(FeatureButton.Camera)) {
customUIConfig.button[FeatureButton.Camera] = {};
}
customUIConfig.button[FeatureButton.Camera].state = isOpen ? ButtonState.Open : ButtonState.Close;
this._tuiStore.update(StoreName.CALL, NAME.CUSTOM_UI_CONFIG, customUIConfig);
}
}
@@ -0,0 +1,169 @@
import { CallStatus, NAME, CallRole } from '../const/index';
import { IBellParams } from '../interface/index';
import { isUndefined } from '../utils/common-utils';
import DEFAULT_CALLER_BELL_FILEPATH from '../assets/phone_dialing.mp3';
import DEFAULT_CALLEE_BELL_FILEPATH from '../assets/phone_ringing.mp3';
export class BellContext {
private _bellContext: any = null;
private _isMuteBell: boolean = false;
private _calleeBellFilePath: string = DEFAULT_CALLEE_BELL_FILEPATH;
private _callRole: string = CallRole.UNKNOWN;
private _callStatus: string = CallStatus.IDLE;
private _isPlaying: boolean = false;
constructor() {
// @ts-ignore
this._bellContext = wx.createInnerAudioContext({ useWebAudioImplement: false });
this._addListenBellContextEvent();
this._bellContext.loop = true;
}
setBellSrc() {
// @ts-ignore
const fs = wx.getFileSystemManager();
try {
let playBellFilePath = DEFAULT_CALLER_BELL_FILEPATH;
if (this._callRole === CallRole.CALLEE) {
playBellFilePath = this._calleeBellFilePath || DEFAULT_CALLEE_BELL_FILEPATH;
}
fs.readFileSync(playBellFilePath, 'utf8', 0);
this._bellContext.src = playBellFilePath;
} catch (error) {
console.warn(`${NAME.PREFIX}Failed to setBellSrc, ${error}`);
}
}
setBellProperties(bellParams: IBellParams) {
this._callRole = bellParams.callRole || this._callRole;
this._callStatus = bellParams.callStatus || this._callStatus;
this._calleeBellFilePath = bellParams.calleeBellFilePath || this._calleeBellFilePath;
// undefined/false || isMuteBell => isMuteBell (不符合预期)
this._isMuteBell = isUndefined(bellParams.isMuteBell) ? this._isMuteBell : bellParams.isMuteBell;
}
async play() {
try {
if (this._callStatus !== CallStatus.CALLING) {
return;
}
// iOS 优化:增加更严格的状态检查
if (this._callStatus !== CallStatus.CALLING || this._isPlaying) {
console.warn(`${NAME.PREFIX}play skipped, callStatus: ${this._callStatus}, isPlaying: ${this._isPlaying}`);
return;
}
this._isPlaying = true;
this.setBellSrc();
if (this._callRole === CallRole.CALLEE && !this._isMuteBell) {
// 再次检查状态,避免在设置过程中状态已变更
if (!this._isPlaying || this._callStatus !== CallStatus.CALLING) return;
await this._bellContext.play();
return;
}
if (this._callRole === CallRole.CALLER) {
// 再次检查状态,避免在设置过程中状态已变更
if (!this._isPlaying || this._callStatus !== CallStatus.CALLING) return;
await this._bellContext.play();
return;
}
} catch (error) {
console.warn(`${NAME.PREFIX}Failed to play audio file, ${error}`);
}
}
async stop() {
try {
this._isPlaying = false;
// iOS 小程序音频停止优化:先暂停再停止,并添加延时确保操作完成
if (this._bellContext) {
// 先暂停音频
this._bellContext.pause();
// iOS 需要短暂延时确保暂停操作完成
await this._delay(100);
// 再停止音频
this._bellContext.stop();
// 重置音频源,确保彻底停止
this._bellContext.src = '';
// 再次延时确保所有操作完成
await this._delay(100);
}
} catch (error) {
console.warn(`${NAME.PREFIX}Failed to stop audio file, ${error}`);
}
}
async setBellMute(enable: boolean) {
if (this._callStatus !== CallStatus.CALLING && this._callRole !== CallRole.CALLEE) {
return;
}
if (enable) {
await this.stop();
} else {
await this.play();
}
}
destroy() {
try {
this._isMuteBell = false;
this._calleeBellFilePath = '';
this._callRole = CallRole.UNKNOWN;
this._callStatus = CallStatus.IDLE;
this._isPlaying = false;
this?._removeListenBellContextEvent();
this._bellContext.destroy();
this._bellContext = null;
} catch (error) {
console.warn(`${NAME.PREFIX}Failed to destroy, ${error}`);
}
}
private _delay(ms: number): any {
return new Promise((resolve: any) => setTimeout(resolve, ms));
}
private _delayCallback(callback: () => void, ms: number) {
setTimeout(callback, ms);
}
private _handleAudioInterruptionBegin = async () => {
await this.stop();
};
private _handleAudioInterruptionEnd = async () => {
if (this._callStatus !== CallStatus.CALLING) {
await this.stop();
} else {
// 音频中断结束后,延时一下再重新播放,确保系统音频资源释放完成
this._delayCallback(async () => {
if (this._callStatus === CallStatus.CALLING && !this._isPlaying) {
await this.play();
}
}, 100);
}
};
private _addListenBellContextEvent() {
this._bellContext.onPlay(() => {
if (!this._isPlaying) {
this._bellContext?.pause();
this._bellContext?.stop();
}
});
// @ts-ignore
wx.onAudioInterruptionBegin(this._handleAudioInterruptionBegin);
// @ts-ignore
wx.onAudioInterruptionEnd(this._handleAudioInterruptionEnd);
}
private _removeListenBellContextEvent() {
// @ts-ignore
wx.offAudioInterruptionBegin(this._handleAudioInterruptionBegin);
// @ts-ignore
wx.offAudioInterruptionEnd(this._handleAudioInterruptionEnd);
}
}
@@ -0,0 +1,299 @@
import { TUICore, TUILogin, TUIConstants, ExtensionInfo } from '@tencentcloud/tui-core-lite';
import { CallMediaType, AudioCallIcon, VideoCallIcon, LOG_LEVEL, COMPONENT, StoreName, NAME, CallStatus, CallType, ACTION_TYPE } from '../const/index';
import { isUndefined, formatTime, JSONToObject } from '../utils/common-utils';
import { getRemoteUserProfile } from './utils';
import { ITUIStore } from '../interface/ITUIStore';
import TuiStore from '../TUIStore/tuiStore';
// @ts-ignore
import TencentCloudChat from '@tencentcloud/lite-chat/basic'; // 仅支持 calls 接口
import { t } from '../locales/index';
const TUIStore: ITUIStore = TuiStore.getInstance();
const cmd2messageCardContentMap = {
audioCall: () => 'Voice call',
videoCall: () => 'Video call',
switchToAudio: () => 'Switch audio call',
switchToVideo: () => 'Switch video call',
hangup: ({ callDuration }) => `${t('Call duration')}${callDuration}`,
};
export default class ChatCombine {
static instance: ChatCombine;
private _callService: any;
constructor(options) {
this._callService = options.callService;
// 下面:TUICore注册事件,注册组件服务,注册界面拓展
TUICore.registerEvent(TUIConstants.TUILogin.EVENT.LOGIN_STATE_CHANGED, TUIConstants.TUILogin.EVENT_SUB_KEY.USER_LOGIN_SUCCESS, this); // onNotifyEvent
// @ts-ignore
if (TUIConstants.TUIChat?.EVENT) {
// @ts-ignore
TUICore.registerEvent(TUIConstants.TUIChat.EVENT?.CHAT_STATE_CHANGED, TUIConstants.TUIChat.EVENT_SUB_KEY?.CHAT_OPENED, this); // onNotifyEvent
}
TUICore.registerService(TUIConstants.TUICalling.SERVICE.NAME, this); // onCall
TUICore.registerExtension(TUIConstants.TUIChat.EXTENSION.INPUT_MORE.EXT_ID, this); // onGetExtension
}
static getInstance(options) {
if (!ChatCombine.instance) {
ChatCombine.instance = new ChatCombine(options);
}
return ChatCombine.instance;
}
// ================ 【】 ================
/**
* message on screen
* @param {Any} params Parameters for message up-screening
*/
public callTUIService(params) {
const { message } = params || {};
TUICore.callService({
serviceName: TUIConstants.TUIChat.SERVICE.NAME,
method: TUIConstants.TUIChat.SERVICE.METHOD.UPDATE_MESSAGE_LIST,
params: { message },
});
}
/**
* tuicore getExtension
* @param {String} extensionID extension id
* @param {Any} params tuicore pass parameters
* @returns {Any[]} return extension
*/
public onGetExtension(extensionID: string, params: any) {
if (extensionID === TUIConstants.TUIChat.EXTENSION.INPUT_MORE.EXT_ID) {
this._callService.getTUICallEngineInstance()?.reportLog?.({ name: 'TUICallKit.onGetExtension', data: { extensionID, params } });
if (isUndefined(params)) return [];
// room and customer_service ChatType not show audio and video icon.
// @ts-ignore
if ([TUIConstants.TUIChat.TYPE.ROOM, TUIConstants.TUIChat.TYPE.CUSTOMER_SERVICE].includes(params.chatType)) return [];
let list = [];
const audioCallExtension: ExtensionInfo = {
weight: 1000,
text: '语音通话',
icon: AudioCallIcon,
data: {
name: 'voiceCall',
},
listener: {
onClicked: async options => await this._handleTUICoreOnClick(options, options.type || CallMediaType.AUDIO),
},
};
const videoCallExtension: ExtensionInfo = {
weight: 900,
text: '视频通话',
icon: VideoCallIcon,
data: {
name: 'videoCall',
},
listener: {
onClicked: async options => await this._handleTUICoreOnClick(options, options.type || CallMediaType.VIDEO),
},
};
if (params?.chatType) {
list = [audioCallExtension, videoCallExtension];
} else {
!params?.filterVoice && list.push(audioCallExtension);
!params?.filterVideo && list.push(videoCallExtension);
}
return list;
}
}
public async onCall(method: String, params: any) {
if (method === TUIConstants.TUICalling.SERVICE.METHOD.START_CALL) {
await this._handleTUICoreOnClick(params, params.type);
}
}
/**
* tuicore notify event manager
* @param {String} eventName event name
* @param {String} subKey sub key
* @param {Any} options tuicore event parameters
*/
public async onNotifyEvent(eventName: string, subKey: string, options?: any) {
try {
if (eventName === TUIConstants.TUILogin.EVENT.LOGIN_STATE_CHANGED) {
// TUICallkit executes its own business logic when it receives a successful login.
if (subKey === TUIConstants.TUILogin.EVENT_SUB_KEY.USER_LOGIN_SUCCESS) {
// @ts-ignore
const { chat, userID, userSig, SDKAppID } = TUILogin.getContext();
await this._callService?.init({ tim: chat, userID, userSig, sdkAppID: SDKAppID, isFromChat: true, component: COMPONENT.TIM_CALL_KIT });
this._callService?.setIsFromChat(true);
this._callService?.setLogLevel(LOG_LEVEL.NORMAL); // setLogLevel to 0 in tuikit. easy to find out problem via logs.
this._addListenChatEvent();
} else if (subKey === TUIConstants.TUILogin.EVENT_SUB_KEY.USER_LOGOUT_SUCCESS) {
this._removeListenChatEvent();
await this._callService?.destroyed();
}
}
// @ts-ignore
if (TUIConstants.TUIChat?.EVENT && eventName === TUIConstants.TUIChat.EVENT.CHAT_STATE_CHANGED) {
// @ts-ignore
if (subKey === TUIConstants.TUIChat.EVENT_SUB_KEY.CHAT_OPENED) {
this._callService?.setCurrentGroupId(options?.groupID || '');
if (TUIStore.getData(StoreName.CALL, NAME.CALL_STATUS) !== CallStatus.IDLE) return;
const currentGroupId = this._callService?.getCurrentGroupId();
const groupAttributes = currentGroupId ? await this.getGroupAttributes(this._callService?.getTim(), currentGroupId) : {};
await this.updateStoreBasedOnGroupAttributes(groupAttributes);
}
}
} catch (error) {
console.error(`${NAME.PREFIX}TUICore onNotifyEvent failed, error: ${error}.`);
}
}
// Handling the chat+call scenario, data required for the joinInGroupCall API: update store / clear relevant store data
public async updateStoreBasedOnGroupAttributes(groupAttributes: any) {
this._callService?.getTUICallEngineInstance()?.reportLog?.({
name: 'TUICallKit.getJoinGroupCallInfo.success',
data: { groupAttributes },
});
try {
const {
call_id: callId = '', // callEngine v3.1 support
group_id: groupId = '',
room_id: roomId = 0,
room_id_type: roomIdType = 0,
call_media_type: callType = NAME.UNKNOWN,
// @ts-ignore
user_list: userList, // The default value of the user list returned by the background is null
} = groupAttributes[NAME.INNER_ATTR_KIT_INFO] ? JSON.parse(groupAttributes[NAME.INNER_ATTR_KIT_INFO]) : {};
let userListInfo = (userList || []).map(user => user.userid);
if (userListInfo.length > 0) {
userListInfo = await getRemoteUserProfile(userListInfo, this._callService?.getTim());
}
const updateStoreParams = {
[NAME.CALL_ID]: callId,
[NAME.GROUP_ID]: groupId,
[NAME.GROUP_CALL_MEMBERS]: userListInfo,
[NAME.ROOM_ID]: roomId,
[NAME.CALL_MEDIA_TYPE]: CallType[callType],
[NAME.ROOM_ID_TYPE]: roomIdType,
};
TUIStore.updateStore(updateStoreParams, StoreName.CALL);
} catch (error) {
console.warn(`${NAME.PREFIX}updateStoreBasedOnGroupAttributes fail, error: ${error}`);
}
}
// Get group attribute
public async getGroupAttributes(tim: any, groupId: string) {
if (!groupId) return {};
try {
const { data } = await tim.getGroupAttributes({
groupID: groupId,
keyList: []
});
return data?.groupAttributes || {};
} catch (error) {
console.warn(`${NAME.PREFIX}getGroupAttributes fail: ${error}`);
return {};
}
}
isLineBusy(message) {
const callMessage: any = JSONToObject(message.payload.data);
const objectData = JSONToObject(callMessage?.data);
return objectData?.line_busy === 'line_busy' || objectData?.line_busy === '' || objectData?.data?.message === 'lineBusy';
}
async getCallKitMessage(message: any, tim: any) {
const callMessage: any = JSONToObject(message.payload.data);
if (callMessage?.businessID !== 1) {
return {};
}
let messageCardContent = '';
const objectData = JSONToObject(callMessage?.data);
const callMediaType = objectData.call_type;
const inviteeList = callMessage.inviteeList;
const inviter = objectData?.data?.inviter;
const localUserId = TUIStore.getData(StoreName.CALL, NAME.LOCAL_USER_INFO).userId;
const isInviter = inviter === localUserId;
const cmd = objectData?.data?.cmd;
switch (callMessage?.actionType) {
case ACTION_TYPE.INVITE: {
messageCardContent = cmd2messageCardContentMap[cmd]({ callDuration: formatTime(objectData?.call_end) });
break;
}
case ACTION_TYPE.CANCEL_INVITE:
messageCardContent = isInviter ? 'Call Cancel' : 'Other Side Cancel';
break;
case ACTION_TYPE.ACCEPT_INVITE:
if (['switchToAudio', 'switchToVideo'].includes(cmd)) {
messageCardContent = cmd2messageCardContentMap?.[cmd]?.();
} else {
messageCardContent = t('Answered');
}
break;
case ACTION_TYPE.REJECT_INVITE:
if (this.isLineBusy(message)) {
messageCardContent = isInviter ? 'Line Busy' : 'Other Side Line Busy';
} else {
messageCardContent = isInviter ? 'Other Side Decline' : 'Decline';
}
break;
case ACTION_TYPE.INVITE_TIMEOUT:
if (['switchToAudio', 'switchToVideo'].includes(cmd)) {
messageCardContent = cmd2messageCardContentMap?.[cmd]?.();
} else {
messageCardContent = isInviter ? 'Other Side No Answer' : 'No answer';
}
break;
}
return { messageCardContent, callMediaType, inviteeList };
}
// =========================【chat: event listening】=========================
private _addListenChatEvent() {
if (!this._callService?.getTim()) {
console.warn(`${NAME.PREFIX}add tim event listener failed, tim is empty.`);
return;
}
this._callService?.getTim().on(TencentCloudChat.EVENT.GROUP_ATTRIBUTES_UPDATED, this._handleGroupAttributesUpdated, this);
}
private _removeListenChatEvent() {
if (!this._callService?.getTim()) {
console.warn(`${NAME.PREFIX}remove tim event listener failed, tim is empty.`);
return;
}
this._callService?.getTim().off(TencentCloudChat.EVENT.GROUP_ATTRIBUTES_UPDATED, this._handleGroupAttributesUpdated, this);
}
/**
* chat start audio/video call via click
* @param {Any} options Parameters passed in when clicking on an audio/video call from chat
* @param {CallMediaType} type call media type. 0 - audio; 1 - video.
*
* priority: isForceUseV2API > version
*/
private async _handleTUICoreOnClick(options, type: CallMediaType) {
try {
const { groupID, userIDList = [], version = '', ...rest } = options;
await this._callService?.calls({ chatGroupID: groupID, userIDList, type, ...rest });
} catch (error: any) {
// call update to lite-chat only support calls interface
if (!options?.version) {
console.warn('Please upgrade to the latest Chat UIKit components.');
}
console.debug(error);
}
}
private async _handleGroupAttributesUpdated(event) {
if (TUIStore.getData(StoreName.CALL, NAME.CALL_STATUS) !== CallStatus.IDLE) return;
const data = event?.data || {};
const { groupID: groupId = '', groupAttributes = {} } = data;
if (groupId !== this._callService?.getCurrentGroupId()) return;
await this.updateStoreBasedOnGroupAttributes(groupAttributes);
}
}
@@ -0,0 +1,438 @@
import { ITUIStore } from '../interface/ITUIStore';
// @ts-ignore
import { TUICallEvent } from '@trtc/call-engine-lite-wx';
import { IUserInfo } from '../interface/ICallService';
import { StoreName, CallStatus, CallMediaType, NAME, CallRole, StatusChange, ErrorCode, ErrorMessage, AudioPlayBackDevice,
NETWORK_QUALITY_THRESHOLD } from '../const/index';
import { CallTips, t } from '../locales/index';
import { initAndCheckRunEnv } from './miniProgram';
import { getMyProfile, getRemoteUserProfile, updateRoomIdAndRoomIdType, analyzeEventData, deleteRemoteUser } from './utils';
import promiseRetryDecorator from '../utils/decorators/promise-retry';
import { UIDesign } from './UIDesign';
import TuiStore from '../TUIStore/tuiStore';
const TUIStore: ITUIStore = TuiStore.getInstance();
const uiDesign = UIDesign.getInstance();
export default class EngineEventHandler {
static instance: EngineEventHandler;
private _callService: any;
constructor(options) {
this._callService = options.callService;
}
static getInstance(options) {
if (!EngineEventHandler.instance) {
EngineEventHandler.instance = new EngineEventHandler(options);
}
return EngineEventHandler.instance;
}
public addListenTuiCallEngineEvent() {
const callEngine = this._callService?.getTUICallEngineInstance();
if (!callEngine) {
console.warn(`${NAME.PREFIX}add engine event listener failed, engine is empty.`);
return;
}
callEngine.on(TUICallEvent.ERROR, this._handleError, this);
callEngine.on(TUICallEvent.ON_CALL_RECEIVED, this._handleNewInvitationReceived, this); // 收到邀请事件
TUICallEvent?.ON_CALL_BEGIN && callEngine.on(TUICallEvent.ON_CALL_BEGIN, this._handleOnCallBegin, this); // 主叫收到被叫接通事件
callEngine.on(TUICallEvent.USER_ENTER, this._handleUserEnter, this); // 有用户进房事件
callEngine.on(TUICallEvent.USER_LEAVE, this._handleUserLeave, this); // 有用户离开通话事件
callEngine.on(TUICallEvent.REJECT, this._handleInviteeReject, this); // 主叫收到被叫的拒绝通话事件
callEngine.on(TUICallEvent.NO_RESP, this._handleNoResponse, this); // 主叫收到被叫的无应答事件
callEngine.on(TUICallEvent.LINE_BUSY, this._handleLineBusy, this); // 主叫收到被叫的忙线事件
callEngine.on(TUICallEvent.ON_CALL_NOT_CONNECTED, this._handleCallNotConnected, this); // 主被叫在通话未建立时, 收到的取消事件
callEngine.on(TUICallEvent.ON_USER_INVITING, this._handleOnUserInviting, this); // 通话存在邀请他人时, 通话里的所有人都会抛出
callEngine.on(TUICallEvent.SDK_READY, this._handleSDKReady, this); // SDK Ready 回调
callEngine.on(TUICallEvent.KICKED_OUT, this._handleKickedOut, this); // 未开启多端登录时, 多端登录收到的被踢事件
callEngine.on(TUICallEvent.MESSAGE_SENT_BY_ME, this._messageSentByMe, this);
// @ts-ignore
TUICallEvent.CALL_MESSAGE && callEngine.on(TUICallEvent.CALL_MESSAGE, this._handleCallMessage, this); // call message card display event
// @ts-ignore
TUICallEvent.ON_USER_NETWORK_QUALITY_CHANGED && callEngine.on(TUICallEvent.ON_USER_NETWORK_QUALITY_CHANGED, this._handleNetworkQuality, this); // 用户网络质量
callEngine.on(TUICallEvent.USER_VIDEO_AVAILABLE, this._handleUserVideoAvailable, this);
callEngine.on(TUICallEvent.USER_AUDIO_AVAILABLE, this._handleUserAudioAvailable, this);
callEngine.on(TUICallEvent.ON_CALL_END, this._handleCallingEnd, this); // 主被叫在通话结束时, 收到的通话结束事件
// @ts-ignore
callEngine.on(TUICallEvent.CALL_MODE, this._handleCallTypeChange, this);
// @ts-ignore
callEngine.on(TUICallEvent.USER_UPDATE, this._handleUserUpdate, this); // mini: user data update
}
public removeListenTuiCallEngineEvent() {
const callEngine = this._callService?.getTUICallEngineInstance();
callEngine.off(TUICallEvent.ERROR, this._handleError, this);
callEngine.off(TUICallEvent.ON_CALL_RECEIVED, this._handleNewInvitationReceived, this);
TUICallEvent?.ON_CALL_BEGIN && callEngine.off(TUICallEvent.ON_CALL_BEGIN, this._handleOnCallBegin, this);
callEngine.off(TUICallEvent.USER_ENTER, this._handleUserEnter, this);
callEngine.off(TUICallEvent.USER_LEAVE, this._handleUserLeave, this);
callEngine.off(TUICallEvent.REJECT, this._handleInviteeReject, this);
callEngine.off(TUICallEvent.NO_RESP, this._handleNoResponse, this);
callEngine.off(TUICallEvent.LINE_BUSY, this._handleLineBusy, this);
callEngine.off(TUICallEvent.ON_CALL_NOT_CONNECTED, this._handleCallNotConnected, this);
callEngine.off(TUICallEvent.ON_USER_INVITING, this._handleOnUserInviting, this);
callEngine.off(TUICallEvent.SDK_READY, this._handleSDKReady, this);
callEngine.off(TUICallEvent.KICKED_OUT, this._handleKickedOut, this);
callEngine.off(TUICallEvent.MESSAGE_SENT_BY_ME, this._messageSentByMe, this);
// @ts-ignore
TUICallEvent.ON_USER_NETWORK_QUALITY_CHANGED && callEngine.off(TUICallEvent.ON_USER_NETWORK_QUALITY_CHANGED, this._handleNetworkQuality, this);
callEngine.off(TUICallEvent.USER_VIDEO_AVAILABLE, this._handleUserVideoAvailable, this);
callEngine.off(TUICallEvent.USER_AUDIO_AVAILABLE, this._handleUserAudioAvailable, this);
callEngine.off(TUICallEvent.CALL_END, this._handleCallingEnd, this);
// @ts-ignore
callEngine.off(TUICallEvent.CALL_MODE, this._handleCallTypeChange, this); // 切换通话事件 miniProgram CALL_MODE
// @ts-ignore
callEngine.off(TUICallEvent.USER_UPDATE, this._handleUserUpdate, this); // mini: user data update
}
private _callerChangeToConnected() {
const callRole = TUIStore.getData(StoreName.CALL, NAME.CALL_ROLE);
const callStatus = TUIStore.getData(StoreName.CALL, NAME.CALL_STATUS);
if (callStatus === CallStatus.CALLING && callRole === CallRole.CALLER) {
// iOS 优化:主叫状态变更时立即停止铃声
this._callService?._bellContext?.stop();
TUIStore.update(StoreName.CALL, NAME.CALL_STATUS, CallStatus.CONNECTED);
this._callService?.startTimer();
}
}
private _unNormalEventsManager(event: any, eventName: TUICallEvent): void {
console.log(`${NAME.PREFIX}${eventName} event data: ${JSON.stringify(event)}.`);
const isGroup = TUIStore.getData(StoreName.CALL, NAME.IS_GROUP);
const remoteUserInfoList = TUIStore.getData(StoreName.CALL, NAME.REMOTE_USER_INFO_LIST);
switch (eventName) {
case TUICallEvent.REJECT:
case TUICallEvent.LINE_BUSY: {
const { userID: userId } = analyzeEventData(event);
let callTipsKey = eventName === TUICallEvent.REJECT ? CallTips.OTHER_SIDE_REJECT_CALL : CallTips.OTHER_SIDE_LINE_BUSY;
let userListNeedToShow = '';
if (isGroup) {
userListNeedToShow = (remoteUserInfoList.find(obj => obj.userId === userId) || {}).displayUserInfo || userId;
callTipsKey = eventName === TUICallEvent.REJECT ? CallTips.REJECT_CALL : CallTips.IN_BUSY;
}
TUIStore.update(StoreName.CALL, NAME.TOAST_INFO, { content: { key: callTipsKey, options: { userList: userListNeedToShow } } });
userId && deleteRemoteUser([userId]);
if (TUIStore.getData(StoreName.CALL, NAME.REMOTE_USER_INFO_LIST).length === 0) {
this._callService?._resetCallStore();
}
break;
}
case TUICallEvent.NO_RESP: {
const { userIDList = [] } = analyzeEventData(event);
const callTipsKey = isGroup ? CallTips.TIMEOUT : CallTips.CALL_TIMEOUT;
const userInfoList: string[] = userIDList.map(userId => {
const userInfo: IUserInfo = remoteUserInfoList.find(obj => obj.userId === userId) || {};
return userInfo.displayUserInfo || userId;
});
TUIStore.update(StoreName.CALL, NAME.TOAST_INFO, { content: { key: callTipsKey, options: { userList: userInfoList.join() } } });
userIDList.length > 0 && deleteRemoteUser(userIDList);
break;
}
case TUICallEvent.ON_CALL_NOT_CONNECTED: {
this._callService?._resetCallStore();
break;
}
}
}
private _handleError(event: any): void {
const { code, message } = event || {};
const index = Object.values(ErrorCode).indexOf(code);
let callTips = '';
if (index !== -1) {
const key = Object.keys(ErrorCode)[index];
callTips = t(ErrorMessage[key]);
callTips && TUIStore.update(StoreName.CALL, NAME.TOAST_INFO, { content: ErrorMessage[key], type: NAME.ERROR });
}
this._callService?.executeExternalAfterCalling();
console.error(`${NAME.PREFIX}_handleError, errorCode: ${code}; errorMessage: ${callTips || message}.`);
}
private async _handleNewInvitationReceived(event: any) {
console.log(`${NAME.PREFIX}onCallReceived event data: ${JSON.stringify(event)}.`);
const { callerId = '', callMediaType, inviteData = {}, calleeIdList = [], chatGroupID: groupID = '', roomID, strRoomID } = analyzeEventData(event);
const currentUserInfo: IUserInfo = TUIStore.getData(StoreName.CALL, NAME.LOCAL_USER_INFO);
const remoteUserIdList: string[] = [callerId, ...calleeIdList.filter((userId: string) => userId !== currentUserInfo.userId)];
const type = callMediaType || inviteData.callType;
const callTipsKey = type === CallMediaType.AUDIO ? CallTips.CALLEE_CALLING_AUDIO_MSG : CallTips.CALLEE_CALLING_VIDEO_MSG;
let updateStoreParams = {
[NAME.CALL_ROLE]: CallRole.CALLEE,
[NAME.IS_GROUP]: (!!groupID || calleeIdList.length > 1),
[NAME.CALL_STATUS]: CallStatus.CALLING,
[NAME.CALL_MEDIA_TYPE]: type,
[NAME.CALL_TIPS]: callTipsKey,
[NAME.CALLER_USER_INFO]: { userId: callerId },
[NAME.GROUP_ID]: groupID,
};
initAndCheckRunEnv();
const pusher = { enableCamera: type === CallMediaType.VIDEO, enableMic: true }; // mini 默认打开麦克风
updateStoreParams = { ...updateStoreParams, [NAME.PUSHER]: pusher };
(type === CallMediaType.VIDEO) && this._callService.openCamera(NAME.LOCAL_VIDEO);
this._callService.openMicrophone()
const deviceMap = {
microphone: true,
camera: type === CallMediaType.VIDEO,
};
this._callService._preDevicePermission = await this._callService._tuiCallEngine.deviceCheck(deviceMap);
updateRoomIdAndRoomIdType(roomID, strRoomID);
TUIStore.updateStore(updateStoreParams, StoreName.CALL);
this._callService?.executeExternalBeforeCalling();
this._callService?.statusChanged && this._callService?.statusChanged({ oldStatus: StatusChange.IDLE, newStatus: StatusChange.BE_INVITED });
const remoteUserInfoList = await getRemoteUserProfile(remoteUserIdList, this._callService?.getTim());
const [userInfo] = remoteUserInfoList.filter((userInfo: IUserInfo) => userInfo.userId === callerId);
remoteUserInfoList.length > 0 && TUIStore.updateStore({
[NAME.REMOTE_USER_INFO_LIST]: remoteUserInfoList,
[NAME.REMOTE_USER_INFO_EXCLUDE_VOLUMN_LIST]: remoteUserInfoList,
[NAME.CALLER_USER_INFO]: {
userId: callerId,
nick: userInfo?.nick || '',
avatar: userInfo?.avatar || '',
displayUserInfo: userInfo?.remark || userInfo?.nick || callerId,
},
}, StoreName.CALL);
}
private async _handleOnCallBegin(event: any): Promise<void> {
// iOS 优化:ON_CALL_BEGIN 事件时立即停止铃声
this._callService?._bellContext?.stop();
this._callerChangeToConnected();
TUIStore.update(StoreName.CALL, NAME.CALL_TIPS, { text: 'answered', duration: 2000 });
await this._callService.openMicrophone();
console.log(`${NAME.PREFIX}accept event data: ${JSON.stringify(event)}.`);
}
private async _handleUserEnter(event: any): Promise<void> {
const { userID: userId, data } = analyzeEventData(event);
if (userId && TUIStore.getData(StoreName.CALL, NAME.CALL_ROLE) === CallRole.CALLEE) {
if (TUIStore.getData(StoreName.CALL, NAME.CALL_STATUS) === CallStatus.CALLING) {
this._callService._handleAcceptResponse({});
this._callService._cleanupAvoidRepeatCallState('accept');
}
}
this._callerChangeToConnected();
data?.playerList && TUIStore.update(StoreName.CALL, NAME.PLAYER, data.playerList);
await this._addUserToRemoteUserInfoList(userId);
let remoteUserInfoList = TUIStore.getData(StoreName.CALL, NAME.REMOTE_USER_INFO_LIST);
remoteUserInfoList = remoteUserInfoList.map((obj: IUserInfo) => {
if (obj.userId === userId) obj.isEnter = true;
return obj;
});
if (remoteUserInfoList.length > 0) {
TUIStore.update(StoreName.CALL, NAME.REMOTE_USER_INFO_LIST, remoteUserInfoList);
TUIStore.update(StoreName.CALL, NAME.REMOTE_USER_INFO_EXCLUDE_VOLUMN_LIST, remoteUserInfoList);
uiDesign.updateViewBackgroundUserId('remote');
}
console.log(`${NAME.PREFIX}userEnter event data: ${JSON.stringify(event)}.`);
}
private _handleUserLeave(event: any): void {
console.log(`${NAME.PREFIX}userLeave event data: ${JSON.stringify(event)}.`);
const { data, userID: userId } = analyzeEventData(event);
data?.playerList && TUIStore.update(StoreName.CALL, NAME.PLAYER, data.playerList);
if (TUIStore.getData(StoreName.CALL, NAME.IS_GROUP)) {
const remoteUserInfoList = TUIStore.getData(StoreName.CALL, NAME.REMOTE_USER_INFO_LIST);
const userListNeedToShow: string = (remoteUserInfoList.find(obj => obj.userId === userId) || {}).displayUserInfo || userId;
TUIStore.update(StoreName.CALL, NAME.TOAST_INFO, { content: { key: CallTips.END_CALL, options: { userList: userListNeedToShow } } });
}
userId && deleteRemoteUser([userId]);
}
private _handleInviteeReject(event: any): void {
this._unNormalEventsManager(event, TUICallEvent.REJECT);
}
private _handleNoResponse(event: any): void {
this._unNormalEventsManager(event, TUICallEvent.NO_RESP);
}
private _handleLineBusy(event: any): void {
this._unNormalEventsManager(event, TUICallEvent.LINE_BUSY);
}
private _handleCallNotConnected(event: any): void {
this?._callService?._cleanupAvoidRepeatCallState?.();
this._callService?.executeExternalAfterCalling();
this._unNormalEventsManager(event, TUICallEvent.ON_CALL_NOT_CONNECTED);
}
private async _handleOnUserInviting(event: any) {
const { userID: userId } = analyzeEventData(event);
if (!userId) return;
if (userId !== TUIStore.getData(StoreName.CALL, NAME.LOCAL_USER_INFO).userId) {
await this._addUserToRemoteUserInfoList(userId);
}
}
private _handleCallingEnd(event: any): void {
console.log(`${NAME.PREFIX}callEnd event data: ${JSON.stringify(event)}.`);
this._callService?.executeExternalAfterCalling();
this._callService?._resetCallStore();
}
// SDK_READY 后才能调用 tim 接口, 否则登录后立刻获取导致调用接口失败. v2.27.4+、v3 接口 login 后会抛出 SDK_READY
private async _handleSDKReady(event: any): Promise<void> {
let localUserInfo: IUserInfo = TUIStore.getData(StoreName.CALL, NAME.LOCAL_USER_INFO);
localUserInfo = await getMyProfile(localUserInfo.userId, this._callService?.getTim());
TUIStore.update(StoreName.CALL, NAME.LOCAL_USER_INFO, localUserInfo);
TUIStore.update(StoreName.CALL, NAME.LOCAL_USER_INFO_EXCLUDE_VOLUMN, localUserInfo);
}
private _handleKickedOut(event: any): void {
console.log(`${NAME.PREFIX}kickOut event data: ${JSON.stringify(event)}.`);
this._callService?.kickedOut && this._callService?.kickedOut(event);
TUIStore.update(StoreName.CALL, NAME.CALL_TIPS, CallTips.KICK_OUT);
this._callService?._resetCallStore();
}
private _messageSentByMe(event: any): void {
const message = event?.data;
this._callService?.onMessageSentByMe && this._callService?.onMessageSentByMe(message);
}
private _handleCallMessage(event: any) {
const message = analyzeEventData(event);
this._callService._chatCombine.callTUIService({ message });
}
private _handleCallTypeChange(event: any): void {
const { newCallType, type } = analyzeEventData(event);
TUIStore.update(StoreName.CALL, NAME.CALL_MEDIA_TYPE, newCallType || type);
this._callService?.setSoundMode(AudioPlayBackDevice.EAR);
}
private _handleNetworkQuality(event) {
const { networkQualityList = [] } = analyzeEventData(event);
TUIStore.update(StoreName.CALL, NAME.NETWORK_STATUS, networkQualityList);
const isGroup = TUIStore.getData(StoreName.CALL, NAME.IS_GROUP);
const localUserInfo = TUIStore.getData(StoreName.CALL, NAME.LOCAL_USER_INFO);
const remoteUserInfoList = TUIStore.getData(StoreName.CALL, NAME.REMOTE_USER_INFO_LIST);
if(!isGroup) {
const isRemoteNetworkPoor = networkQualityList.find(user => remoteUserInfoList[0]?.userId === user?.userId && user?.quality >= NETWORK_QUALITY_THRESHOLD);
if(isRemoteNetworkPoor) {
TUIStore.update(StoreName.CALL, NAME.CALL_TIPS, CallTips.REMOTE_NETWORK_IS_POOR);
return;
};
const isLocalNetworkPoor = networkQualityList.find(user => localUserInfo?.userId === user?.userId && user?.quality >= NETWORK_QUALITY_THRESHOLD);
if(isLocalNetworkPoor) {
TUIStore.update(StoreName.CALL, NAME.CALL_TIPS, CallTips.LOCAL_NETWORK_IS_POOR);
return;
}
}
}
// =============================【 WEB 私有事件】==============================
@promiseRetryDecorator({
retries: 5,
timeout: 200,
onRetrying(retryCount) {
console.warn(`${NAME.PREFIX}_startRemoteView, retrying [${retryCount}]`);
},
})
private async _startRemoteView(userId: string) {
if (!userId) {
console.warn(`${NAME.PREFIX}_startRemoteView userID is empty`);
return;
}
try {
const displayMode = TUIStore.getData(StoreName.CALL, NAME.DISPLAY_MODE);
await this._callService?.getTUICallEngineInstance().startRemoteView({ userID: userId, videoViewDomID: `${userId}_0`, options: { objectFit: displayMode } });
} catch (error: any) {
console.error(`${NAME.PREFIX}_startRemoteView error: ${error}.`);
return Promise.reject(error);
}
}
private _setRemoteUserInfoAudioVideoAvailable(isAvailable: boolean, type: string, userId: string) {
let remoteUserInfoList = TUIStore.getData(StoreName.CALL, NAME.REMOTE_USER_INFO_LIST);
remoteUserInfoList = remoteUserInfoList.map((obj: IUserInfo) => {
if (obj.userId === userId) {
if (type === NAME.AUDIO) {
return { ...obj, isAudioAvailable: isAvailable };
}
if (type === NAME.VIDEO) {
return { ...obj, isVideoAvailable: isAvailable };
}
}
return obj;
});
if (remoteUserInfoList.length > 0) {
TUIStore.update(StoreName.CALL, NAME.REMOTE_USER_INFO_LIST, remoteUserInfoList);
TUIStore.update(StoreName.CALL, NAME.REMOTE_USER_INFO_EXCLUDE_VOLUMN_LIST, remoteUserInfoList);
}
}
private async _handleUserVideoAvailable(event: any): Promise<any> {
const { userID: userId, isVideoAvailable } = analyzeEventData(event);
console.log(`${NAME.PREFIX}_handleUserVideoAvailable event data: ${JSON.stringify(event)}.`);
this._setRemoteUserInfoAudioVideoAvailable(isVideoAvailable, NAME.VIDEO, userId);
try {
isVideoAvailable && await this._startRemoteView(userId);
} catch (error) {
console.error(`${NAME.PREFIX}_startRemoteView failed, error: ${error}.`);
}
}
private _handleUserAudioAvailable(event: any): void {
const { userID: userId, isAudioAvailable } = analyzeEventData(event);
console.log(`${NAME.PREFIX}_handleUserAudioAvailable event data: ${JSON.stringify(event)}.`);
this._setRemoteUserInfoAudioVideoAvailable(isAudioAvailable, NAME.AUDIO, userId);
}
private _handleUserVoiceVolume(event: any): void {
try {
const { volumeMap: volumeList } = analyzeEventData(event);
if ((volumeList || []).length === 0) return; // 减少不必要的更新
const localUserInfo: IUserInfo = TUIStore.getData(StoreName.CALL, NAME.LOCAL_USER_INFO);
let remoteUserInfoList: IUserInfo[] = TUIStore.getData(StoreName.CALL, NAME.REMOTE_USER_INFO_LIST);
const [localUserVolumeObj] = volumeList.filter((obj: any) => obj.userId === localUserInfo.userId);
const remoteUserVolumeObj = volumeList.reduce((acc: any, obj: any) => {
if (obj.userId !== localUserInfo.userId) {
return { ...acc, [obj.userId]: obj.audioVolume };
}
return acc;
}, {});
localUserInfo.volume = localUserVolumeObj.audioVolume;
remoteUserInfoList = remoteUserInfoList.map((obj: any) => ({ ...obj, volume: remoteUserVolumeObj[obj.userId] }));
const updateStoreParams = {
[NAME.LOCAL_USER_INFO]: localUserInfo,
[NAME.REMOTE_USER_INFO_LIST]: remoteUserInfoList,
};
TUIStore.updateStore(updateStoreParams, StoreName.CALL);
} catch (error) {
console.debug(error);
}
}
private _handleDeviceUpdate(event: any): void {
const { cameraList, microphoneList, speakerList, currentCamera, currentMicrophone, currentSpeaker } = event;
TUIStore.update(StoreName.CALL, NAME.DEVICE_LIST, { cameraList, microphoneList, speakerList, currentCamera, currentMicrophone, currentSpeaker });
}
// ==========================【 miniProgram 私有事件】==========================
private _handleUserUpdate(event: any): void {
const data = analyzeEventData(event);
data?.pusher && TUIStore.update(StoreName.CALL, NAME.PUSHER, data.pusher);
data?.playerList && TUIStore.update(StoreName.CALL, NAME.PLAYER, data.playerList);
}
private async _addUserToRemoteUserInfoList(userId: string) {
let remoteUserInfoList = TUIStore.getData(StoreName.CALL, NAME.REMOTE_USER_INFO_LIST);
const isInRemoteUserList = remoteUserInfoList.find(item => item?.userId === userId);
if (!isInRemoteUserList) {
remoteUserInfoList.push({ userId });
TUIStore.update(StoreName.CALL, NAME.REMOTE_USER_INFO_LIST, remoteUserInfoList);
TUIStore.update(StoreName.CALL, NAME.REMOTE_USER_INFO_EXCLUDE_VOLUMN_LIST, remoteUserInfoList);
const [userInfo] = await getRemoteUserProfile([userId], this._callService?.getTim());
remoteUserInfoList = TUIStore.getData(StoreName.CALL, NAME.REMOTE_USER_INFO_LIST);
remoteUserInfoList.forEach((obj) => {
if (obj?.userId === userId) {
obj = Object.assign(obj, userInfo);
}
});
TUIStore.update(StoreName.CALL, NAME.REMOTE_USER_INFO_LIST, remoteUserInfoList);
TUIStore.update(StoreName.CALL, NAME.REMOTE_USER_INFO_EXCLUDE_VOLUMN_LIST, remoteUserInfoList);
}
}
}
@@ -0,0 +1,860 @@
import {
IUserInfo, ICallbackParam, ISelfInfoParams, IBellParams, IInviteUserParams, IInitParams, ICallsParams,
} from '../interface/ICallService';
import {
StoreName, CallStatus, CallMediaType, NAME, CALL_DATA_KEY, LanguageType, CallRole, LOG_LEVEL, VideoDisplayMode,
VideoResolution, StatusChange, AudioPlayBackDevice, CameraPosition, COMPONENT, FeatureButton, ButtonState,
LayoutMode, DEFAULT_BLUR_LEVEL,
} from '../const/index';
// @ts-ignore
import { TUICallEngine, devicePermissions } from '@trtc/call-engine-lite-wx';
import { beforeCall, handlePackageError, handleNoPusherCapabilityError } from './miniProgram';
import { CallTips, t } from '../locales/index';
import { BellContext } from './bellContext';
import { avoidRepeatedCall } from '../utils/validate/index';
import { handleRepeatedCallError, formatTime, performanceNow, initBrowserCloseDetection } from '../utils/common-utils';
import { getRemoteUserProfile, generateStatusChangeText, noDevicePermissionToast, setLocalUserInfoAudioVideoAvailable,
getGroupMemberList, getGroupProfile, updateRoomIdAndRoomIdType, updateDeviceList } from './utils';
import timer from '../utils/timer';
import { ITUIGlobal, ITUIStore } from '../interface/index';
import TuiGlobal from '../TUIGlobal/tuiGlobal';
import TuiStore from '../TUIStore/tuiStore';
import { UIDesign } from './UIDesign';
import ChatCombine from './chatCombine';
import EngineEventHandler from './engineEventHandler';
const TUIGlobal: ITUIGlobal = TuiGlobal.getInstance();
const TUIStore: ITUIStore = TuiStore.getInstance();
const uiDesign = UIDesign.getInstance();
uiDesign.setTUIStore(TUIStore);
const version = '4.2.7';
export { TUIGlobal, TUIStore, uiDesign };
export default class TUICallService {
static instance: TUICallService;
public _tuiCallEngine: any;
private _tim: any = null;
private _TUICore: any = null;
private _timerId: number = -1;
private _startTimeStamp: number = performanceNow();
private _bellContext: any = null;
private _isFromChat: boolean = false;
private _currentGroupId: string = ''; // The currentGroupId of the group chat that the user is currently in
private _offlinePushInfo = null;
private _permissionCheckTimer: any = null;
private _chatCombine: any = null;
private _engineEventHandler: any = null;
// wasm ready
private _wasmReadyPromise;
private _wasmReadyResolve;
private _isInitialized = false;
constructor() {
console.log(`${NAME.PREFIX}version: ${version}`);
this._wasmReadyPromise = new Promise(resolve => {
this._wasmReadyResolve = resolve;
});
this._loadWasm();
this._watchTUIStore();
this._engineEventHandler = EngineEventHandler.getInstance({ callService: this });
this._chatCombine = ChatCombine.getInstance({ callService: this });
}
static getInstance() {
if (!TUICallService.instance) {
TUICallService.instance = new TUICallService();
}
return TUICallService.instance;
}
private _loadWasm() {
TUICallEngine.once('ready', () => this._wasmReadyResolve());
}
@avoidRepeatedCall()
public async init(params: IInitParams) {
if (this._isInitialized) {
console.warn('TUICallKit has already been initialized.');
return;
}
await this._wasmReadyPromise;
if (!this._isInitialized) {
this._doInit(params);
this._isInitialized = true;
}
}
private async _doInit(params: IInitParams) {
try {
if (this._tuiCallEngine) return;
// @ts-ignore
let { userID, tim, userSig, sdkAppID, SDKAppID, isFromChat, component = COMPONENT.TUI_CALL_KIT } = params;
if (this._TUICore) {
sdkAppID = this._TUICore.SDKAppID;
tim = this._TUICore.tim;
}
this._tim = tim;
console.log(`${NAME.PREFIX}init sdkAppId: ${sdkAppID || SDKAppID}, userId: ${userID}`);
let scene = `web`;
// To distinguish whether the UniApp mini-program chooses Vue 2 or Vue 3
let vueVersion = 2;
// #ifdef VUE3
vueVersion = 3;
// #endif
scene = `wx-uniapp-vue${vueVersion}`;
this._tuiCallEngine = TUICallEngine.createInstance({
tim,
// @ts-ignore
sdkAppID: sdkAppID || SDKAppID, // 兼容传入 SDKAppID 的问题
callkitVersion: version,
isFromChat: isFromChat || false,
component,
scene,
});
uiDesign.setEngineInstance(this._tuiCallEngine);
this._addListenTuiCallEngineEvent();
if (this._bellContext) {
this._bellContext?.destroy();
}
this._bellContext = new BellContext();
TUIStore.update(StoreName.CALL, NAME.LOCAL_USER_INFO, { userId: userID });
TUIStore.update(StoreName.CALL, NAME.LOCAL_USER_INFO_EXCLUDE_VOLUMN, { userId: userID });
uiDesign.updateViewBackgroundUserId('local');
await this._tuiCallEngine.login({ userID, userSig, assetsPath: '' }); // web && mini
const uiConfig = TUIStore.getData(StoreName.CALL, NAME.CUSTOM_UI_CONFIG);
this._tuiCallEngine?.reportLog?.({
name: 'TUICallkit.init',
data: {
uiConfig,
}
});
} catch (error) {
console.error(`${NAME.PREFIX}init failed, error: ${error}.`);
throw error;
}
}
// component destroy
public async destroyed() {
try {
this._isInitialized = false;
const currentCallStatus = TUIStore.getData(StoreName.CALL, NAME.CALL_STATUS);
if (currentCallStatus !== CallStatus.IDLE) {
throw new Error(`please destroyed when status is idle, current status: ${currentCallStatus}`);
}
if (this._tuiCallEngine) {
this._removeListenTuiCallEngineEvent();
await this._tuiCallEngine.destroyInstance();
this._tuiCallEngine = null;
}
this._bellContext?.destroy();
this._bellContext = null;
} catch (error) {
console.error(`${NAME.PREFIX}destroyed failed, error: ${error}.`);
throw error;
}
}
// ===============================【通话操作】===============================
@avoidRepeatedCall()
public async inviteUser(params: IInviteUserParams) {
if (TUIStore.getData(StoreName.CALL, NAME.CALL_STATUS) === CallStatus.IDLE) return; // avoid double click when application stuck
try {
const { userIDList } = params;
let inviteUserInfoList = await getRemoteUserProfile(userIDList, this.getTim());
const remoteUserInfoList = TUIStore.getData(StoreName.CALL, NAME.REMOTE_USER_INFO_LIST);
const userIDListNotInRemoteUserInfoList = userIDList.filter(userId => {
return !remoteUserInfoList.some(remoteUserInfo => remoteUserInfo.userId === userId);
});
if (userIDListNotInRemoteUserInfoList.length === 0) {
return;
}
TUIStore.update(StoreName.CALL, NAME.REMOTE_USER_INFO_LIST, [...remoteUserInfoList, ...inviteUserInfoList]);
TUIStore.update(StoreName.CALL, NAME.REMOTE_USER_INFO_EXCLUDE_VOLUMN_LIST, [...remoteUserInfoList, ...inviteUserInfoList]);
this._tuiCallEngine && await this._tuiCallEngine.inviteUser(params);
} catch (error: any) {
console.error(`${NAME.PREFIX}inviteUser failed, error: ${error}.`);
}
}
@avoidRepeatedCall()
public async calls(callsParams: ICallsParams) {
if (TUIStore.getData(StoreName.CALL, NAME.CALL_STATUS) !== CallStatus.IDLE) return; // avoid double click when application stuck
try {
TUIStore.update(StoreName.CALL, NAME.PUSHER_ID, NAME.NEW_PUSHER);
const { userIDList, type, chatGroupID, offlinePushInfo } = callsParams;
if (TUIStore.getData(StoreName.CALL, NAME.CALL_STATUS) !== CallStatus.IDLE) return;
const remoteUserInfoList = userIDList.map(userId => ({ userId }));
await this._updateCallStoreBeforeCall(type, remoteUserInfoList, chatGroupID);
this.executeExternalBeforeCalling();
callsParams.offlinePushInfo = { ...this.getDefaultOfflinePushInfo(), ...offlinePushInfo };
const response = await this._tuiCallEngine.calls(callsParams);
await this._updateCallStoreAfterCall(userIDList, response);
} catch (error: any) {
this._handleCallError(error, 'calls');
}
}
@avoidRepeatedCall()
public async join(params) {
if (TUIStore.getData(StoreName.CALL, NAME.CALL_STATUS) === CallStatus.CONNECTED) return; // avoid double click when application stuck
try {
TUIStore.update(StoreName.CALL, NAME.PUSHER_ID, NAME.NEW_PUSHER);
const updateStoreParams = {
[NAME.CALL_ROLE]: CallRole.CALLEE,
[NAME.IS_GROUP]: true,
[NAME.CALL_STATUS]: CallStatus.CONNECTED,
[NAME.CALL_MEDIA_TYPE]: TUIStore.getData(StoreName.CALL, NAME.CALL_MEDIA_TYPE) || CallMediaType.AUDIO, // default audio callMediaType
};
TUIStore.updateStore(updateStoreParams, StoreName.CALL);
const response = await this._tuiCallEngine.join(params);
TUIStore.update(StoreName.CALL, NAME.IS_CLICKABLE, true);
this.startTimer();
TUIStore.update(StoreName.CALL, NAME.PUSHER, response);
this.setSoundMode(params.type === CallMediaType.AUDIO ? AudioPlayBackDevice.EAR : AudioPlayBackDevice.SPEAKER);
const localUserInfo = TUIStore.getData(StoreName.CALL, NAME.LOCAL_USER_INFO);
TUIStore.update(StoreName.CALL, NAME.LOCAL_USER_INFO, { ...localUserInfo, isEnter: true });
TUIStore.update(StoreName.CALL, NAME.LOCAL_USER_INFO_EXCLUDE_VOLUMN, { ...localUserInfo, isEnter: true });
await this.openMicrophone();
setLocalUserInfoAudioVideoAvailable(true, NAME.AUDIO);
// // Current policy: By default, the camera remains off when joining GroupCall
// if (TUIStore.getData(StoreName.CALL, NAME.CALL_MEDIA_TYPE) === CallMediaType.VIDEO) {
// await this.openCamera(NAME.LOCAL_VIDEO);
// setLocalUserInfoAudioVideoAvailable(true, NAME.VIDEO);
// }
} catch (error) {
this._handleCallError(error, 'join');
}
}
// ===============================【其它对外接口】===============================
public getTUICallEngineInstance(): any {
return this?._tuiCallEngine || null;
}
public setLogLevel(level: LOG_LEVEL) {
this?._tuiCallEngine?.setLogLevel(level);
}
public setLanguage(language: string) {
console.warn(`${NAME.PREFIX}The miniProgram does not support setLanguage`);
}
public enableFloatWindow(enable: boolean) {
TUIStore.update(StoreName.CALL, NAME.ENABLE_FLOAT_WINDOW, enable);
}
public async setSelfInfo(params: ISelfInfoParams) {
const { nickName, avatar } = params;
try {
await this._tuiCallEngine.setSelfInfo(nickName, avatar);
} catch (error) {
console.error(`${NAME.PREFIX}setSelfInfo failed, error: ${error}.`);
}
}
public async enableVirtualBackground(enable: boolean) {
TUIStore.update(StoreName.CALL, NAME.IS_SHOW_ENABLE_VIRTUAL_BACKGROUND, enable);
}
// 修改默认铃声:只支持本地铃声文件,不支持在线铃声文件;修改铃声修改的是被叫的铃声
public async setCallingBell(filePath?: string) {
let isCheckFileExist: boolean = true;
if (!isCheckFileExist) {
console.warn(`${NAME.PREFIX}setCallingBell failed, filePath: ${filePath}.`);
return ;
}
const bellParams: IBellParams = { calleeBellFilePath: filePath };
this._bellContext.setBellProperties(bellParams);
}
public async enableMuteMode(enable: boolean) {
try {
const bellParams: IBellParams = { isMuteBell: enable };
this._bellContext.setBellProperties(bellParams);
await this._bellContext.setBellMute(enable);
} catch (error) {
console.warn(`${NAME.PREFIX}enableMuteMode failed, error: ${error}.`);
}
}
public hideFeatureButton(buttonName: FeatureButton) {
uiDesign.hideFeatureButton(buttonName);
}
public setLocalViewBackgroundImage(url: string) {
uiDesign.setLocalViewBackgroundImage(url);
}
public setRemoteViewBackgroundImage(userId: string, url: string) {
uiDesign.setRemoteViewBackgroundImage(userId, url);
}
public setLayoutMode(layoutMode: LayoutMode) {
uiDesign.setLayoutMode(layoutMode);
}
public setCameraDefaultState(isOpen: boolean) {
uiDesign.setCameraDefaultState(isOpen);
}
// =============================【实验性接口】=============================
public callExperimentalAPI(jsonStr: string) {
const jsonObj = JSON.parse(jsonStr);
if (jsonObj === jsonStr) return;
const { api, params } = jsonObj;
if (!api || !params) return;
try {
switch(api) {
case 'forceUseV2API':
const { enable } = params;
TUIStore.update(StoreName.CALL, NAME.IS_FORCE_USE_V2_API, !!enable);
break;
default:
break;
}
} catch (error) {
this._tuiCallEngine?.reportLog?.({ name: 'TUICallKit.callExperimentalAPI.fail', data: { error } });
}
}
// =============================【内部按钮操作方法】=============================
@avoidRepeatedCall()
public async accept() {
const callStatus = TUIStore.getData(StoreName.CALL, NAME.CALL_STATUS);
this._tuiCallEngine?.reportLog?.({
name: 'TUICallKit.accept.start',
data: { callStatus },
});
if (callStatus === CallStatus.CONNECTED) return; // avoid double click when application stuck, especially for miniProgram
try {
const callMediaType = TUIStore.getData(StoreName.CALL, NAME.CALL_MEDIA_TYPE);
const deviceMap = {
microphone: true,
camera: callMediaType === CallMediaType.VIDEO,
};
const currentDevicePermission = await this._tuiCallEngine.deviceCheck(deviceMap);
if (!currentDevicePermission) {
const callMediaType = TUIStore.getData(StoreName.CALL, NAME.CALL_MEDIA_TYPE);
if (typeof devicePermissions === 'function') {
await devicePermissions(callMediaType);
}
}
TUIStore.update(StoreName.CALL, NAME.PUSHER_ID, NAME.NEW_PUSHER);
const response = await this._tuiCallEngine.accept();
await this._handleAcceptResponse(response);
} catch (error) {
this._tuiCallEngine?.reportLog?.({
name: 'TUICallKit.accept.fail',
level: 'error',
error,
});
if (handleRepeatedCallError(error)) return;
noDevicePermissionToast(error, CallMediaType.AUDIO, this._tuiCallEngine);
this._resetCallStore();
}
}
private async _handleAcceptResponse(response) {
if (response) {
// 小程序接通时会进行授权弹框, 状态需要放在 accept 后, 否则先接通后再拉起权限设置
if (TUIStore.getData(StoreName.CALL, NAME.CALL_STATUS) === CallStatus.CONNECTED) return;
// C++ 回调异常时, 此时需要 callkit 主动打开音频, 否则出现小程序没有采集推音频流
this.openMicrophone();
// iOS 优化:立即停止铃声,避免状态变更延迟导致的铃声持续
this._bellContext?.stop();
TUIStore.update(StoreName.CALL, NAME.CALL_STATUS, CallStatus.CONNECTED);
this._chatCombine?.callTUIService({ message: response?.data?.message });
TUIStore.update(StoreName.CALL, NAME.IS_CLICKABLE, true);
this.startTimer();
const callMediaType = TUIStore.getData(StoreName.CALL, NAME.CALL_MEDIA_TYPE);
const isCameraDefaultStateClose = this._getFeatureButtonDefaultState(FeatureButton.Camera) === ButtonState.Close;
(callMediaType === CallMediaType.VIDEO) && !isCameraDefaultStateClose && await this.openCamera(NAME.LOCAL_VIDEO);
response.pusher && TUIStore.update(StoreName.CALL, NAME.PUSHER, response.pusher);
this.setSoundMode(callMediaType === CallMediaType.AUDIO ? AudioPlayBackDevice.EAR : AudioPlayBackDevice.SPEAKER);
const localUserInfo = TUIStore.getData(StoreName.CALL, NAME.LOCAL_USER_INFO);
TUIStore.update(StoreName.CALL, NAME.LOCAL_USER_INFO, { ...localUserInfo, isEnter: true });
TUIStore.update(StoreName.CALL, NAME.LOCAL_USER_INFO_EXCLUDE_VOLUMN, { ...localUserInfo, isEnter: true });
setLocalUserInfoAudioVideoAvailable(true, NAME.AUDIO); // web && mini default open audio
}
}
@avoidRepeatedCall()
public async hangup() {
if (TUIStore.getData(StoreName.CALL, NAME.CALL_STATUS) === CallStatus.IDLE) return; // avoid double click when application stuck
try {
const response = await this._tuiCallEngine.hangup();
response?.forEach((item) => {
if (item?.code === 0) {
this._chatCombine?.callTUIService({ message: item?.data?.message });
}
});
} catch (error) {
console.debug(error);
}
this._resetCallStore();
}
@avoidRepeatedCall()
public async reject() {
if (TUIStore.getData(StoreName.CALL, NAME.CALL_STATUS) === CallStatus.IDLE) return; // avoid double click when application stuck
try {
const response = await this._tuiCallEngine.reject();
if (response?.code === 0) {
this._chatCombine?.callTUIService({ message: response?.data?.message });
}
} catch (error) {
console.debug(error);
}
this._resetCallStore();
}
@avoidRepeatedCall()
public async openCamera(videoViewDomID: string) {
try {
if (TUIGlobal.isH5 || TUIGlobal.isWeChat) {
const currentPosition = TUIStore.getData(StoreName.CALL, NAME.CAMERA_POSITION);
const isFrontCamera = currentPosition === CameraPosition.FRONT ? true : false;
this._tuiCallEngine.openCamera(videoViewDomID, isFrontCamera);
} else {
await this._tuiCallEngine.openCamera(videoViewDomID);
}
setLocalUserInfoAudioVideoAvailable(true, NAME.VIDEO);
} catch (error: any) {
noDevicePermissionToast(error, CallMediaType.VIDEO, this._tuiCallEngine);
console.error(`${NAME.PREFIX}openCamera error: ${error}.`);
}
}
@avoidRepeatedCall()
public async closeCamera() {
try {
await this._tuiCallEngine.closeCamera();
setLocalUserInfoAudioVideoAvailable(false, NAME.VIDEO);
} catch (error: any) {
console.error(`${NAME.PREFIX}closeCamera error: ${error}.`);
}
}
@avoidRepeatedCall()
public async openMicrophone() {
try {
await this._tuiCallEngine.openMicrophone();
setLocalUserInfoAudioVideoAvailable(true, NAME.AUDIO);
} catch (error: any) {
console.error(`${NAME.PREFIX}openMicrophone failed, error: ${error}.`);
}
}
@avoidRepeatedCall()
public async closeMicrophone() {
try {
await this._tuiCallEngine.closeMicrophone();
setLocalUserInfoAudioVideoAvailable(false, NAME.AUDIO);
} catch (error: any) {
console.error(`${NAME.PREFIX}closeMicrophone failed, error: ${error}.`);
}
}
@avoidRepeatedCall()
public switchScreen(userId: string) {
if(!userId) return;
TUIStore.update(StoreName.CALL, NAME.BIG_SCREEN_USER_ID, userId);
}
// support video to audio; not support audio to video
@avoidRepeatedCall()
public async switchCallMediaType() {
try {
const callMediaType = TUIStore.getData(StoreName.CALL, NAME.CALL_MEDIA_TYPE);
if (callMediaType === CallMediaType.AUDIO) {
console.warn(`${NAME.PREFIX}switchCallMediaType failed, ${callMediaType} not support.`);
return;
}
const response = await this._tuiCallEngine.switchCallMediaType(CallMediaType.AUDIO);
if (response?.code === 0) {
this._chatCombine?.callTUIService({ message: response?.data?.message });
}
TUIStore.update(StoreName.CALL, NAME.CALL_MEDIA_TYPE, CallMediaType.AUDIO);
const isGroup = TUIStore.getData(StoreName.CALL, NAME.IS_GROUP);
const oldStatus = isGroup ? StatusChange.CALLING_GROUP_VIDEO : StatusChange.CALLING_C2C_VIDEO;
const newStatus = generateStatusChangeText();
this.statusChanged && this.statusChanged({ oldStatus, newStatus });
this.setSoundMode(AudioPlayBackDevice.EAR);
} catch (error: any) {
console.error(`${NAME.PREFIX}switchCallMediaType failed, error: ${error}.`);
}
}
@avoidRepeatedCall()
public async switchCamera() {
const currentPosition = TUIStore.getData(StoreName.CALL, NAME.CAMERA_POSITION);
const targetPosition = currentPosition === CameraPosition.BACK ? CameraPosition.FRONT : CameraPosition.BACK;
try {
await this._tuiCallEngine.switchCamera(targetPosition);
TUIStore.update(StoreName.CALL, NAME.CAMERA_POSITION, targetPosition);
} catch (error) {
console.error(`${NAME.PREFIX}_switchCamera failed, error: ${error}.`);
}
}
@avoidRepeatedCall()
public setSoundMode(type?: string): void {
try {
let isEarPhone = TUIStore.getData(StoreName.CALL, NAME.IS_EAR_PHONE);
const soundMode = type || (isEarPhone ? AudioPlayBackDevice.SPEAKER : AudioPlayBackDevice.EAR); // UI 层切换时传参数
this._tuiCallEngine?.selectAudioPlaybackDevice(soundMode);
if (type) {
isEarPhone = type === AudioPlayBackDevice.EAR;
} else {
isEarPhone = !isEarPhone;
}
TUIStore.update(StoreName.CALL, NAME.IS_EAR_PHONE, isEarPhone);
} catch (error) {
console.error(`${NAME.PREFIX}setSoundMode failed, error: ${error}.`);
}
}
@avoidRepeatedCall()
public async setBlurBackground(enable: boolean) {
try {
TUIStore.update(StoreName.CALL, NAME.ENABLE_VIRTUAL_BACKGROUND, enable);
} catch (error) {
console.error(`${NAME.PREFIX}_setBlurBackground failed, error: ${error}.`);
}
}
// ==========================【TUICallEngine 事件处理】==========================
private _addListenTuiCallEngineEvent() {
this._engineEventHandler.addListenTuiCallEngineEvent();
}
private _removeListenTuiCallEngineEvent() {
this._engineEventHandler.removeListenTuiCallEngineEvent();
}
// ========================【原 Web CallKit 提供的方法】========================
public beforeCalling: ((...args: any[]) => void) | undefined; // 原来
public afterCalling: ((...args: any[]) => void) | undefined;
public onMinimized: ((...args: any[]) => void) | undefined;
public onMessageSentByMe: ((...args: any[]) => void) | undefined;
public kickedOut: ((...args: any[]) => void) | undefined;
public statusChanged: ((...args: any[]) => void) | undefined;
public setCallback(params: ICallbackParam) {
const { beforeCalling, afterCalling, onMinimized, onMessageSentByMe, kickedOut, statusChanged } = params;
beforeCalling && (this.beforeCalling = beforeCalling);
afterCalling && (this.afterCalling = afterCalling);
onMinimized && (this.onMinimized = onMinimized);
onMessageSentByMe && (this.onMessageSentByMe = onMessageSentByMe);
kickedOut && (this.kickedOut = kickedOut);
statusChanged && (this.statusChanged = statusChanged);
}
public toggleMinimize() {
const isMinimized = TUIStore.getData(StoreName.CALL, NAME.IS_MINIMIZED);
TUIStore.update(StoreName.CALL, NAME.IS_MINIMIZED, !isMinimized);
console.log(`${NAME.PREFIX}toggleMinimize: ${isMinimized} -> ${!isMinimized}.`);
this.onMinimized && this.onMinimized(isMinimized, !isMinimized);
}
public executeExternalBeforeCalling(): void {
this.beforeCalling && this.beforeCalling();
}
public executeExternalAfterCalling(): void {
this.afterCalling && this.afterCalling();
}
// 处理用户异常退出的情况, 小程序 ”右滑“、"左上角退出"; web 页面关闭浏览器或关闭 tab 页面
public async handleExceptionExit(event?: any) {
try {
const callStatus = TUIStore.getData(StoreName.CALL, NAME.CALL_STATUS);
const callRole = TUIStore.getData(StoreName.CALL, NAME.CALL_ROLE);
this._tuiCallEngine?.reportLog?.({ name: 'TUICallkit.handleExceptionExit', data: { callStatus, callRole } });
if (callStatus === CallStatus.IDLE) return;
// 在呼叫状态下,被叫调用 reject,主叫调用 hangup
if (callStatus === CallStatus.CALLING) {
if (callRole === CallRole.CALLER) {
await this?.hangup();
} else {
await this?.reject();
}
}
if (callStatus === CallStatus.CONNECTED) {
await this?.hangup();
}
this?._resetCallStore();
} catch (error) {
console.error(`${NAME.PREFIX} handleExceptionExit failed, error: ${error}.`);
}
if (event) {
event.returnValue = '';
}
}
// 处理 pusher 内部错误,没有 live-pusher 能力时做出弹窗提示。
public handlePusherError(event) {
if(event?.detail?.errMsg === 'fail:access denied') {
handleNoPusherCapabilityError();
}
}
// ========================【TUICallKit 组件属性设置方法】========================
public setVideoDisplayMode(displayMode: VideoDisplayMode) {
TUIStore.update(StoreName.CALL, NAME.DISPLAY_MODE, displayMode);
}
public async setVideoResolution(resolution: VideoResolution) {
try {
if (!resolution) return;
TUIStore.update(StoreName.CALL, NAME.VIDEO_RESOLUTION, resolution);
await this._tuiCallEngine?.setVideoQuality(resolution);
} catch (error) {
console.warn(`${NAME.PREFIX}setVideoResolution failed, error: ${error}.`);
}
}
// 通话时长更新
public startTimer(): void {
if (this._timerId === -1) {
this._startTimeStamp = performanceNow();
this._timerId = timer.run(NAME.TIMEOUT, this._updateCallDuration.bind(this), { delay: 1000 });
}
}
// =========================【private methods for service use】=========================
// 处理 “呼叫” 抛出的异常
private _handleCallError(error: any, methodName?: string) {
this._permissionCheckTimer && clearInterval(this._permissionCheckTimer);
if (handleRepeatedCallError(error)) return;
handlePackageError(error); // 无套餐提示, 小程序 engine 不抛出 onError
noDevicePermissionToast(error, CallMediaType.AUDIO, this._tuiCallEngine);
console.error(`${NAME.PREFIX}${methodName} failed, error: ${error}.`);
this._resetCallStore();
throw error;
}
private async _updateCallStoreBeforeCall(type: number, remoteUserInfoList: IUserInfo[], groupID?: string): Promise<void> {
let callTips = CallTips.CALLER_CALLING_MSG;
if (groupID || TUIStore.getData(StoreName.CALL, NAME.IS_MINIMIZED) || remoteUserInfoList.length > 1) {
callTips = CallTips.CALLER_GROUP_CALLING_MSG;
}
console.warn(`呼叫前更新 call status: ${(!!groupID || remoteUserInfoList.length > 1)}, ${groupID}, ${JSON.stringify(remoteUserInfoList)}`)
let updateStoreParams: any = {
[NAME.CALL_MEDIA_TYPE]: type,
[NAME.CALL_ROLE]: CallRole.CALLER,
[NAME.REMOTE_USER_INFO_LIST]: remoteUserInfoList,
[NAME.REMOTE_USER_INFO_EXCLUDE_VOLUMN_LIST]: remoteUserInfoList,
[NAME.IS_GROUP]: (!!groupID || remoteUserInfoList.length > 1),
[NAME.CALL_TIPS]: callTips,
[NAME.GROUP_ID]: groupID
};
const pusher = { enableCamera: type === CallMediaType.VIDEO, enableMic: true }; // mini 默认打开麦克风
updateStoreParams = { ...updateStoreParams, [NAME.PUSHER]: pusher };
TUIStore.updateStore(updateStoreParams, StoreName.CALL);
const callStatus = await beforeCall(type, this); // 如果没有权限, 此时为 false. 因此需要在 call 后设置为 calling. 和 web 存在差异
console.log(`${NAME.PREFIX}mini beforeCall return callStatus: ${callStatus}.`);
TUIStore.update(StoreName.CALL, NAME.CALL_STATUS, callStatus);
const remoteUserInfoLists = await getRemoteUserProfile(remoteUserInfoList.map(obj => obj.userId), this.getTim());
if (remoteUserInfoLists.length > 0) {
TUIStore.update(StoreName.CALL, NAME.REMOTE_USER_INFO_LIST, remoteUserInfoLists);
TUIStore.update(StoreName.CALL, NAME.REMOTE_USER_INFO_EXCLUDE_VOLUMN_LIST, remoteUserInfoLists);
}
const deviceMap = {
microphone: true,
camera: type === CallMediaType.VIDEO,
};
let hasDevicePermission = await this._tuiCallEngine.deviceCheck(deviceMap);
if (!hasDevicePermission) {
this._permissionCheckTimer && clearInterval(this._permissionCheckTimer);
this._permissionCheckTimer = setInterval(async () => {
hasDevicePermission = await this._tuiCallEngine.deviceCheck(deviceMap);
if (hasDevicePermission && this._permissionCheckTimer) {
clearInterval(this._permissionCheckTimer);
TUIStore.update(StoreName.CALL, NAME.CALL_STATUS, CallStatus.CALLING);
}
}, 500);
}
}
private async _updateCallStoreAfterCall(userIdList: string[], response: any) {
if (response) {
TUIStore.update(StoreName.CALL, NAME.IS_CLICKABLE, true);
updateRoomIdAndRoomIdType(response?.roomID, response?.strRoomID);
const callMediaType = TUIStore.getData(StoreName.CALL, NAME.CALL_MEDIA_TYPE);
this._chatCombine?.callTUIService({ message: response?.data?.message });
response.pusher && TUIStore.update(StoreName.CALL, NAME.PUSHER, response.pusher);
this.setSoundMode(callMediaType === CallMediaType.AUDIO ? AudioPlayBackDevice.EAR : AudioPlayBackDevice.SPEAKER);
TUIStore.update(StoreName.CALL, NAME.CALL_STATUS, CallStatus.CALLING); // 小程序未授权时, 此时状态为 idle; web 直接设置为 calling
const isCameraDefaultStateClose = this._getFeatureButtonDefaultState(FeatureButton.Camera) === ButtonState.Close;
(callMediaType === CallMediaType.VIDEO) && !isCameraDefaultStateClose && await this.openCamera(NAME.LOCAL_VIDEO);
const localUserInfo = TUIStore.getData(StoreName.CALL, NAME.LOCAL_USER_INFO);
TUIStore.update(StoreName.CALL, NAME.LOCAL_USER_INFO, { ...localUserInfo, isEnter: true });
TUIStore.update(StoreName.CALL, NAME.LOCAL_USER_INFO_EXCLUDE_VOLUMN, { ...localUserInfo, isEnter: true });
setLocalUserInfoAudioVideoAvailable(true, NAME.AUDIO); // web && mini, default open audio
} else {
this._permissionCheckTimer && clearInterval(this._permissionCheckTimer);
this._permissionCheckTimer = null;
this._resetCallStore();
}
}
private _getFeatureButtonDefaultState(buttonName: FeatureButton) {
const { button: buttonConfig } = TUIStore.getData(StoreName.CALL, NAME.CUSTOM_UI_CONFIG);
return buttonConfig?.[buttonName]?.state;
}
private _updateCallDuration(): void {
const callDurationNum = Math.round((performanceNow() - this._startTimeStamp) / 1000); // miniProgram stop timer when background
const callDurationStr = formatTime(callDurationNum);
TUIStore.update(StoreName.CALL, NAME.CALL_DURATION, callDurationStr);
}
private _stopTimer(): void {
if (this._timerId !== -1) {
timer.clearTask(this._timerId);
this._timerId = -1;
}
}
// clear all use avoidRepeatCall decorator state
private _cleanupAvoidRepeatCallState(methodName?: string) {
this._tuiCallEngine?.reportLog?.({ name: 'TUICallkit._cleanupAvoidRepeatCallState', data: { } });
let methodsToClean = [];
if (methodName) {
methodsToClean = [(this as any)[methodName]];
} else {
methodsToClean = [
(this as any).calls,
(this as any).accept,
(this as any).hangup,
(this as any).reject,
];
}
methodsToClean?.forEach(method => {
method?.clearCallState?.(this);
});
}
private _resetCallStore() {
this._cleanupAvoidRepeatCallState();
this._bellContext?.stop();
const oldStatusStr = generateStatusChangeText();
this._stopTimer();
// localUserInfo, language 在通话结束后不需要清除
// callStatus 清除需要通知; isMinimized 也需要通知(basic-vue3 中切小窗关闭后, 再呼叫还是小窗, 因此需要通知到组件侧)
// isGroup 也不清除(engine 先抛 cancel 事件, 再抛 reject 事件)
// displayMode、videoResolution 也不能清除, 组件不卸载, 这些属性也需保留, 否则采用默认值.
// enableFloatWindow 不清除:开启/关闭悬浮窗功能。
let notResetOrNotifyKeys = Object.keys(CALL_DATA_KEY).filter((key) => {
switch (CALL_DATA_KEY[key]) {
case NAME.CALL_STATUS:
case NAME.LANGUAGE:
case NAME.IS_GROUP:
case NAME.DISPLAY_MODE:
case NAME.VIDEO_RESOLUTION:
case NAME.ENABLE_FLOAT_WINDOW:
case NAME.LOCAL_USER_INFO:
case NAME.IS_SHOW_ENABLE_VIRTUAL_BACKGROUND:
case NAME.IS_FORCE_USE_V2_API:
case NAME.LOCAL_USER_INFO_EXCLUDE_VOLUMN: {
return false;
}
default: {
return true;
}
}
});
notResetOrNotifyKeys = notResetOrNotifyKeys.map(key => CALL_DATA_KEY[key]);
TUIStore.reset(StoreName.CALL, notResetOrNotifyKeys);
const callStatus = TUIStore.getData(StoreName.CALL, NAME.CALL_STATUS);
callStatus !== CallStatus.IDLE && TUIStore.reset(StoreName.CALL, [NAME.CALL_STATUS], true); // callStatus reset need notify
TUIStore.reset(StoreName.CALL, [NAME.IS_MINIMIZED], true); // isMinimized reset need notify
TUIStore.reset(StoreName.CALL, [NAME.IS_EAR_PHONE], true); // isEarPhone reset need notify
TUIStore.reset(StoreName.CALL, [NAME.PUSHER_ID], true); // pusher unload reset need notify
TUIStore.reset(StoreName.CALL, [NAME.ENABLE_VIRTUAL_BACKGROUND], true); // ENABLE_VIRTUAL_BACKGROUND reset need notify
TUIStore.reset(StoreName.CALL, [NAME.IS_MUTE_SPEAKER], true); // IS_MUTE_SPEAKER reset need notify
TUIStore.update(StoreName.CALL, NAME.LOCAL_USER_INFO, {
...TUIStore.getData(StoreName.CALL, NAME.LOCAL_USER_INFO),
isVideoAvailable: false,
isAudioAvailable: false,
});
TUIStore.update(StoreName.CALL, NAME.LOCAL_USER_INFO_EXCLUDE_VOLUMN, {
...TUIStore.getData(StoreName.CALL, NAME.LOCAL_USER_INFO_EXCLUDE_VOLUMN),
isVideoAvailable: false,
isAudioAvailable: false,
});
TUIStore.update(StoreName.CALL, NAME.REMOTE_USER_INFO_LIST, []);
TUIStore.update(StoreName.CALL, NAME.REMOTE_USER_INFO_EXCLUDE_VOLUMN_LIST, []);
TUIStore.update(StoreName.CALL, NAME.CAMERA_POSITION, CameraPosition.FRONT);
const newStatusStr = generateStatusChangeText();
if (oldStatusStr !== newStatusStr) {
this.statusChanged && this.statusChanged({ oldStatus: oldStatusStr, newStatus: newStatusStr });
}
}
// =========================【Calling the Chat SDK APi】=========================
// 获取群成员
public async getGroupMemberList(count: number, offset: number) {
const groupID = TUIStore.getData(StoreName.CALL, NAME.GROUP_ID);
let groupMemberList = await getGroupMemberList(groupID, this.getTim(), count, offset);
return groupMemberList;
}
// 获取群信息
public async getGroupProfile() {
const groupID: string = TUIStore.getData(StoreName.CALL, NAME.GROUP_ID);
return await getGroupProfile(groupID, this.getTim());
}
// =========================【监听 TUIStore 中的状态及处理】=========================
private _handleCallStatusChange = async (value: CallStatus) => {
try {
const bellParams: IBellParams = {
callRole: TUIStore.getData(StoreName.CALL, NAME.CALL_ROLE),
callStatus: TUIStore.getData(StoreName.CALL, NAME.CALL_STATUS),
};
this._tuiCallEngine?.reportLog?.({
name: 'TUICallkit._handleCallStatusChange.start',
data: { bellParams }
});
this._bellContext.setBellProperties(bellParams);
if (value === CallStatus.CALLING) {
this?._bellContext?.play();
} else {
this._tuiCallEngine?.reportLog?.({
name: 'TUICallkit._bellContext.stop',
});
this?._bellContext?.stop();
// 状态变更通知
if (value === CallStatus.CONNECTED) {
const isGroup = TUIStore.getData(StoreName.CALL, NAME.IS_GROUP);
const callMediaType = TUIStore.getData(StoreName.CALL, NAME.CALL_MEDIA_TYPE);
const remoteUserInfoList = TUIStore.getData(StoreName.CALL, NAME.REMOTE_USER_INFO_LIST);
const oldStatus = isGroup ? StatusChange.DIALING_GROUP : StatusChange.DIALING_C2C;
TUIStore.update(StoreName.CALL, NAME.CALL_TIPS, '');
this.statusChanged && this.statusChanged({ oldStatus, newStatus: generateStatusChangeText() });
if (!isGroup && callMediaType === CallMediaType.VIDEO) {
this.switchScreen(remoteUserInfoList[0].domId);
}
}
}
} catch (error) {
console.warn(`${NAME.PREFIX}handleCallStatusChange, ${error}.`);
}
};
private _watchTUIStore() {
TUIStore?.watch(StoreName.CALL, {
[NAME.CALL_STATUS]: this._handleCallStatusChange,
});
}
private _unwatchTUIStore() {
TUIStore?.unwatch(StoreName.CALL, {
[NAME.CALL_STATUS]: this._handleCallStatusChange,
});
}
// =========================【融合 chat 】=========================
public bindTUICore(TUICore: any) {
this._TUICore = TUICore;
}
// =========================【set、get methods】=========================
public getTim() {
if (this._tim) return this._tim;
if (!this._tuiCallEngine) {
console.warn(`${NAME.PREFIX}getTim warning: _tuiCallEngine Instance is not available.`);
return null;
}
return this._tuiCallEngine?.tim || this._tuiCallEngine?.getTim(); // mini support getTim interface
}
public setIsFromChat(isFromChat: boolean) {
this._isFromChat = isFromChat;
}
public setCurrentGroupId(groupId: string) {
this._currentGroupId = groupId;
}
public getCurrentGroupId() {
return this._currentGroupId;
}
public setDefaultOfflinePushInfo(offlinePushInfo) {
this._offlinePushInfo = offlinePushInfo;
}
public getDefaultOfflinePushInfo() {
const localUserInfo: IUserInfo = TUIStore.getData(StoreName.CALL, NAME.LOCAL_USER_INFO);
if (this._offlinePushInfo) {
return this._offlinePushInfo;
}
return {
title: localUserInfo?.displayUserInfo || '',
description: t('you have a new call'),
};
}
public async getCallMessage(message) {
return await this._chatCombine.getCallKitMessage(message, this.getTim());
}
}
@@ -0,0 +1,65 @@
import { CallMediaType, CallStatus } from '../const/index';
export function initialUI() {
// 收起键盘
// @ts-ignore
wx.hideKeyboard && wx.hideKeyboard({
complete: () => {},
});
};
// 检测运行时环境, 当是微信开发者工具时, 提示用户需要手机调试
export function checkRunPlatform() {
// @ts-ignore
const systemInfo = wx.getSystemInfoSync();
if (systemInfo.platform === 'devtools') {
// 当前运行在微信开发者工具里
// @ts-ignore
wx.showModal({
icon: 'none',
title: '运行环境提醒',
content: '微信开发者工具不支持原生推拉流组件(即 <live-pusher> 和 <live-player> 标签),请使用真机调试或者扫码预览。',
showCancel: false,
});
}
};
export function initAndCheckRunEnv() {
initialUI(); // miniProgram 收起键盘, 隐藏 tabBar
checkRunPlatform(); // miniProgram 检测运行时环境
}
export async function beforeCall(type: CallMediaType, that: any) {
try {
initAndCheckRunEnv();
// 检查设备权限
const deviceMap = {
microphone: true,
camera: type === CallMediaType.VIDEO,
};
const hasDevicePermission = await that._tuiCallEngine.deviceCheck(deviceMap); // miniProgram 检查设备权限
return hasDevicePermission ? CallStatus.CALLING : CallStatus.IDLE;
} catch (error) {
console.debug(error);
return CallStatus.IDLE;
}
}
// 套餐问题提示, 小程序最低需要群组通话版, 1v1 通话版本使用 TRTC 就会报错
export function handlePackageError(error) {
if (error?.code === -1002) {
// @ts-ignore
wx.showModal({
icon: 'none',
title: 'error',
content: error?.message || '',
showCancel: false,
});
}
}
export function handleNoPusherCapabilityError(){
// @ts-ignore
wx.showModal({
icon: 'none',
title: '权限提示',
content: '当前小程序 appid 不具备 <live-pusher> 和 <live-player> 的使用权限,您将无法正常使用实时通话能力,请使用企业小程序账号申请权限后再进行开发体验',
showCancel: false,
});
}
@@ -0,0 +1,277 @@
import { NAME, StoreName, CallStatus, StatusChange, CallMediaType, ROOM_ID_TYPE } from '../const/index';
import { handleNoDevicePermissionError } from '../utils/common-utils';
import { IUserInfo } from '../interface/ICallService';
import { ITUIStore } from '../interface/ITUIStore';
import { CallTips, t } from '../locales/index';
import TuiStore from '../TUIStore/tuiStore';
// @ts-ignore
const TUIStore: ITUIStore = TuiStore.getInstance();
// 设置默认的 UserInfo 信息
export function setDefaultUserInfo(userId: string, domId?: string): IUserInfo {
const userInfo: IUserInfo = {
userId,
nick: '',
avatar: '',
remark: '',
displayUserInfo: '',
isAudioAvailable: false,
isVideoAvailable: false,
isEnter: false,
domId: domId || userId,
};
return domId ? userInfo : { ...userInfo, isEnter: false }; // localUserInfo 没有 isEnter, remoteUserInfoList 有 isEnter
}
// 获取个人用户信息
export async function getMyProfile(myselfUserId: string, tim: any): Promise<IUserInfo> {
let localUserInfo: IUserInfo = setDefaultUserInfo(myselfUserId, NAME.LOCAL_VIDEO);
try {
if (!tim) return localUserInfo;
const res = await tim.getMyProfile();
const currentLocalUserInfo = TUIStore?.getData(StoreName.CALL, NAME.LOCAL_USER_INFO); // localUserInfo may have been updated
if (res?.code === 0) {
localUserInfo = {
...localUserInfo,
...currentLocalUserInfo,
userId: res?.data?.userID,
nick: res?.data?.nick,
avatar: res?.data?.avatar,
displayUserInfo: res?.data?.nick || res?.data?.userID,
};
}
return localUserInfo;
} catch (error) {
console.error(`${NAME.PREFIX}getMyProfile failed, error: ${error}.`);
return localUserInfo;
}
}
// 获取远端用户列表信息
export async function getRemoteUserProfile(userIdList: Array<string>, tim: any): Promise<any> {
let remoteUserInfoList: IUserInfo[] = userIdList.map((userId: string) => setDefaultUserInfo(userId));
try {
if (!tim) return remoteUserInfoList;
if (tim?.getFriendProfile) {
const res = await tim.getFriendProfile({ userIDList: userIdList });
if (res.code === 0) {
const { friendList = [], failureUserIDList = [] } = res.data;
let unFriendList: IUserInfo[] = failureUserIDList.map((obj: any) => obj.userID);
if (failureUserIDList.length > 0) {
const res = await tim.getUserProfile({ userIDList: failureUserIDList.map((obj: any) => obj.userID) });
if (res?.code === 0) {
unFriendList = res?.data || [];
}
}
const currentRemoteUserInfoList = TUIStore?.getData(StoreName.CALL, NAME.REMOTE_USER_INFO_LIST); // remoteUserInfoList may have been updated
const tempFriendIdList: string[] = friendList.map((obj: any) => obj.userID);
const tempUnFriendIdList: string[] = unFriendList.map((obj: any) => obj.userID);
remoteUserInfoList = userIdList.map((userId: string) => {
const defaultUserInfo: IUserInfo = setDefaultUserInfo(userId);
const friendListIndex: number = tempFriendIdList.indexOf(userId);
const unFriendListIndex: number = tempUnFriendIdList.indexOf(userId);
let remark = '';
let nick = '';
let displayUserInfo = '' ;
let avatar = '';
if (friendListIndex !== -1) {
remark = friendList[friendListIndex]?.remark || '';
nick = friendList[friendListIndex]?.profile?.nick || '';
displayUserInfo = remark || nick || defaultUserInfo.userId || '';
avatar = friendList[friendListIndex]?.profile?.avatar || '';
}
if (unFriendListIndex !== -1) {
nick = unFriendList[unFriendListIndex]?.nick || '';
displayUserInfo = nick || defaultUserInfo.userId || '';
avatar = unFriendList[unFriendListIndex]?.avatar || '';
}
const userInfo = currentRemoteUserInfoList.find(subObj => subObj.userId === userId) || {};
return { ...defaultUserInfo, ...userInfo, remark, nick, displayUserInfo, avatar };
});
}
return remoteUserInfoList;
} else {
const res = await tim.getUserProfile({ userIDList: userIdList });
const currentRemoteUserInfoList = TUIStore?.getData(StoreName.CALL, NAME.REMOTE_USER_INFO_LIST); // remoteUserInfoList may have been updated
remoteUserInfoList = userIdList.map((userId: string) => {
const defaultUserInfo: IUserInfo = setDefaultUserInfo(userId);
const userInfo = currentRemoteUserInfoList.find(subObj => subObj.userId === userId) || {};
const userData = (res.data || []).find(obj => obj.userID === userId) || {};
return {
...defaultUserInfo,
...userInfo,
nick: userData?.nick || '',
displayUserInfo: userData?.nick || userId,
avatar: userData?.avatar || '',
};
});
return remoteUserInfoList;
}
} catch (error) {
console.error(`${NAME.PREFIX}getRemoteUserProfile failed, error: ${error}.`);
return remoteUserInfoList;
}
}
// 生成弹框提示文案
export function generateText(key: string, prefix?: string, suffix?: string): string {
const isGroup = TUIStore.getData(StoreName.CALL, NAME.IS_GROUP);
let callTips = `${t(key)}`;
if (isGroup) {
callTips = prefix ? `${prefix} ${callTips}` : callTips;
callTips = suffix ? `${callTips} ${suffix}` : callTips;
}
return callTips;
}
// 生成 statusChange 抛出的字符串
export function generateStatusChangeText(): string {
const callStatus = TUIStore.getData(StoreName.CALL, NAME.CALL_STATUS);
if (callStatus === CallStatus.IDLE) {
return StatusChange.IDLE;
}
const isGroup = TUIStore.getData(StoreName.CALL, NAME.IS_GROUP);
if (callStatus === CallStatus.CALLING) {
return isGroup ? StatusChange.DIALING_GROUP : StatusChange.DIALING_C2C;
}
const callMediaType = TUIStore.getData(StoreName.CALL, NAME.CALL_MEDIA_TYPE);
if (isGroup) {
return callMediaType === CallMediaType.AUDIO ? StatusChange.CALLING_GROUP_AUDIO : StatusChange.CALLING_GROUP_VIDEO;
}
return callMediaType === CallMediaType.AUDIO ? StatusChange.CALLING_C2C_AUDIO : StatusChange.CALLING_C2C_VIDEO;
}
// 获取群组[offset, count + offset]区间成员
export async function getGroupMemberList(groupID: string, tim: any, count, offset) {
let groupMemberList = [];
try {
const res = await tim.getGroupMemberList({ groupID, count, offset });
if (res.code === 0) {
return res.data.memberList || groupMemberList;
}
} catch(error) {
console.error(`${NAME.PREFIX}getGroupMember failed, error: ${error}.`);
return groupMemberList;
}
}
// 获取 IM 群信息
export async function getGroupProfile(groupID: string, tim: any): Promise<any> {
let groupProfile = {};
try {
const res = await tim.getGroupProfile({ groupID });
return res.data.group || groupProfile;
} catch(error) {
console.warn(`${NAME.PREFIX}getGroupProfile failed, error: ${error}.`);
return groupProfile;
}
}
/**
* update roomId and roomIdType
* @param {number} roomId number roomId
* @param {string} strRoomId string roomId
*/
export function updateRoomIdAndRoomIdType(roomId, strRoomId) {
if (roomId === 0 && strRoomId) { // use strRoomID
TUIStore.update(StoreName.CALL, NAME.ROOM_ID, strRoomId);
TUIStore.update(StoreName.CALL, NAME.ROOM_ID_TYPE, ROOM_ID_TYPE.STRING_ROOM_ID);
} else {
TUIStore.update(StoreName.CALL, NAME.ROOM_ID, roomId);
TUIStore.update(StoreName.CALL, NAME.ROOM_ID_TYPE, ROOM_ID_TYPE.NUMBER_ROOM_ID);
}
}
/**
* web and miniProgram call engine throw event data structure are different
* @param {any} event call engine throw out data
* @returns {any} data
*/
export function analyzeEventData(event: any): any {
return event || {};
}
/**
* delete user from remoteUserInfoList
* @param {string[]} userIdList to be deleted userIdList
* @param {ITUIStore} TUIStore TUIStore instance
*/
export function deleteRemoteUser(userIdList: string[]): void {
if (userIdList.length === 0) return;
let remoteUserInfoList = TUIStore.getData(StoreName.CALL, NAME.REMOTE_USER_INFO_LIST);
userIdList.forEach((userId) => {
remoteUserInfoList = remoteUserInfoList.filter((obj: IUserInfo) => obj.userId !== userId);
});
TUIStore.update(StoreName.CALL, NAME.REMOTE_USER_INFO_LIST, remoteUserInfoList);
TUIStore.update(StoreName.CALL, NAME.REMOTE_USER_INFO_EXCLUDE_VOLUMN_LIST, remoteUserInfoList);
}
export function updateDeviceList(tuiCallEngine) {
tuiCallEngine?.getDeviceList('speaker').then((result)=>{
const deviceList = TUIStore.getData(StoreName.CALL, NAME.DEVICE_LIST);
const currentSpeaker = result?.[0] || {};
TUIStore.update(
StoreName.CALL,
NAME.DEVICE_LIST,
{ ...deviceList, speakerList: result, currentSpeaker },
);
}).catch(error =>{
console.error(`${NAME.PREFIX}updateSpeakerList failed, error: ${JSON.stringify(error)}.`);
});
const callMediaType = TUIStore.getData(StoreName.CALL, NAME.CALL_MEDIA_TYPE);
if (callMediaType === CallMediaType.VIDEO) {
tuiCallEngine?.getDeviceList('camera').then((result) => {
const deviceList = TUIStore.getData(StoreName.CALL, NAME.DEVICE_LIST);
const currentCamera = result?.[0] || {};
TUIStore.update(
StoreName.CALL,
NAME.DEVICE_LIST,
{ ...deviceList, cameraList: result, currentCamera },
);
}).catch(error => {
console.error(`${NAME.PREFIX}updateCameraList failed, error: ${error}.`);
});
}
tuiCallEngine?.getDeviceList('microphones').then((result) => {
const deviceList = TUIStore.getData(StoreName.CALL, NAME.DEVICE_LIST);
const currentMicrophone = result?.[0] || {};
TUIStore.update(
StoreName.CALL,
NAME.DEVICE_LIST,
{ ...deviceList, microphoneList: result, currentMicrophone },
);
}).catch(error => {
console.error(`${NAME.PREFIX}updateMicrophoneList failed, error: ${error}.`);
});
}
/**
* update the no device permission toast
* @param {any} error error
* @param {CallMediaType} type call midia type
* @param {any} tuiCallEngine TUICallEngine instance
*/
export function noDevicePermissionToast(error, type: CallMediaType, tuiCallEngine: any) {
let toastInfoKey = '';
if (handleNoDevicePermissionError(error)) {
if (type === CallMediaType.AUDIO) {
toastInfoKey = CallTips.NO_MICROPHONE_DEVICE_PERMISSION;
}
if (type === CallMediaType.VIDEO) {
toastInfoKey = CallTips.NO_CAMERA_DEVICE_PERMISSION;
}
toastInfoKey && TUIStore.update(StoreName.CALL, NAME.TOAST_INFO, { content: toastInfoKey, type: NAME.ERROR });
console.error(`${NAME.PREFIX}call failed, error: ${error.message}.`);
}
}
/**
* set localUserInfo audio/video available
* @param {boolean} isAvailable is available
* @param {string} type callMediaType 'audio' | 'video'
* @param {ITUIStore} TUIStore TUIStore instance
*/
export function setLocalUserInfoAudioVideoAvailable(isAvailable: boolean, type: string) {
let localUserInfo = TUIStore.getData(StoreName.CALL, NAME.LOCAL_USER_INFO);
if (type === NAME.AUDIO) {
localUserInfo = { ...localUserInfo, isAudioAvailable: isAvailable };
}
if (type === NAME.VIDEO) {
localUserInfo = { ...localUserInfo, isVideoAvailable: isAvailable };
}
TUIStore.update(StoreName.CALL, NAME.LOCAL_USER_INFO, localUserInfo);
TUIStore.update(StoreName.CALL, NAME.LOCAL_USER_INFO_EXCLUDE_VOLUMN, localUserInfo);
}
@@ -0,0 +1,43 @@
import { APP_NAMESPACE, IS_PC, IS_H5, IN_WX_MINI_APP, IN_UNI_NATIVE_APP, IN_UNI_APP, IS_MAC, IS_WIN } from '../utils/env';
import { ITUIGlobal } from '../interface/ITUIGlobal';
export default class TUIGlobal implements ITUIGlobal {
static instance: TUIGlobal;
public global: any = APP_NAMESPACE;
public isPC: boolean = false;
public isH5: boolean = false;
public isWeChat: boolean = false;
public isApp: boolean = false;
public isUniPlatform: boolean = false;
public isOfficial: boolean = false;
public isWIN: boolean = false;
public isMAC: boolean = false;
constructor() {
this.initEnv();
}
/**
* 获取 TUIGlobal 实例
* @returns {TUIGlobal}
*/
static getInstance() {
if (!TUIGlobal.instance) {
TUIGlobal.instance = new TUIGlobal();
}
return TUIGlobal.instance;
}
initEnv() {
this.isPC = IS_PC;
this.isH5 = IS_H5;
this.isWeChat = IN_WX_MINI_APP;
this.isApp = IN_UNI_NATIVE_APP && !IN_WX_MINI_APP; // uniApp 打包小程序时 IN_UNI_NATIVE_APP 为 true,所以此处需要增加条件
this.isUniPlatform = IN_UNI_APP;
this.isWIN = IS_WIN;
this.isMAC = IS_MAC;
}
initOfficial(SDKAppID: number) {
this.isOfficial = (SDKAppID === 1400187352 || SDKAppID === 1400188366);
}
}
@@ -0,0 +1,93 @@
import { CallStatus, CallRole, CallMediaType, VideoDisplayMode, VideoResolution, CameraPosition, LayoutMode, NAME } from '../const/index';
import { ICallStore } from '../interface/ICallStore';
import { t } from '../locales/index';
import { getLanguage } from '../utils/common-utils';
import { deepClone } from "../utils/index";
export default class CallStore {
public defaultStore: ICallStore = {
callStatus: CallStatus.IDLE,
callRole: CallRole.UNKNOWN,
callMediaType: CallMediaType.UNKNOWN,
localUserInfo: { userId: '' },
localUserInfoExcludeVolume: { userId: '' },
remoteUserInfoList: [],
remoteUserInfoExcludeVolumeList: [],
callerUserInfo: { userId: '' },
isGroup: false,
callDuration: '00:00:00', // 通话时长
callTips: '', // 通话提示的信息. 例如: '等待谁接听', 'xxx 拒绝通话', 'xxx 挂断通话'
toastInfo: { text: '' }, // 远端用户挂断、拒绝、超时、忙线等的 toast 提示信息
isMinimized: false, // 用来记录当前是否悬浮窗模式
enableFloatWindow: false, // 开启/关闭悬浮窗功能,设置为false,通话界面左上角的悬浮窗按钮会隐藏
bigScreenUserId: '', // 当前大屏幕显示的 userID 用户
language: getLanguage(), // en, zh-cn
isClickable: false, // 是否可点击, 用于按钮增加 loading 效果,不可点击
deviceList: { cameraList: [], microphoneList: [], currentCamera: {}, currentMicrophone: {} },
showPermissionTip: false,
netWorkQualityList: [], // 显示网络状态差的提示
isMuteSpeaker: false,
callID: '', // callEngine 3.1 support
groupID: '',
roomID: 0,
roomIdType: 0,
cameraPosition: CameraPosition.FRONT, // 前置或后置,值为front, back
groupCallMembers: [], // chat 群会话在的通话中的成员
// TUICallKit 组件上的属性
displayMode: VideoDisplayMode.COVER, // 设置预览远端的画面显示模式
videoResolution: VideoResolution.RESOLUTION_720P,
showSelectUser: false,
// 小程序相关属性
pusher: {},
player: [],
isEarPhone: false, // 是否是听筒, 默认: false
pusherId: '', // 重新渲染 live-Pusher 的标识位
// 是否开启虚拟背景, 目前仅 web 支持
isShowEnableVirtualBackground: false, // 是否显示虚拟背景图标, 默认: false
enableVirtualBackground: false, // 是否开启虚拟背景, 默认: false
// customUIConfig
customUIConfig: {
button: {},
viewBackground: {},
layoutMode: LayoutMode.RemoteInLargeView,
},
// translate function
translate: t,
isForceUseV2API: false,
};
public store: ICallStore = deepClone(this.defaultStore);
public prevStore: ICallStore = deepClone(this.defaultStore);
public update(key: keyof ICallStore, data: any): void {
switch (key) {
case NAME.CALL_TIPS:
const preData = this.getData(key);
(this.prevStore[key] as any) = preData;
default:
// resolve "Type 'any' is not assignable to type 'never'.ts", ref: https://github.com/microsoft/TypeScript/issues/31663
(this.store[key] as any) = data as any;
}
}
public getPrevData(key: string | undefined): any {
if (!key) return this.prevStore;
return this.prevStore[key as keyof ICallStore];
}
public getData(key: string | undefined): any {
if (!key) return this.store;
return this.store[key as keyof ICallStore];
}
// reset call store
public reset(keyList: Array<string> = []) {
if (keyList.length === 0) {
keyList = Object.keys(this.store);
}
const resetToDefault = keyList.reduce((acc, key) => ({ ...acc, [key]: this.defaultStore[key as keyof ICallStore] }), {});
this.store = {
...this.defaultStore,
...this.store,
...resetToDefault,
};
}
}
@@ -0,0 +1,167 @@
import { ITUIStore, IOptions, Task } from '../interface/ITUIStore';
import { StoreName, NAME } from '../const/index';
import CallStore from './callStore';
import { isString, isNumber, isBoolean } from '../utils/common-utils';
export default class TUIStore implements ITUIStore {
static instance: TUIStore;
public task: Task;
private storeMap: Partial<Record<StoreName, any>>;
private timerId: number = -1;
constructor() {
this.storeMap = {
[StoreName.CALL]: new CallStore(),
};
// todo 此处后续优化结构后调整
this.task = {} as Task; // 保存监听回调列表
}
/**
* 获取 TUIStore 实例
* @returns {TUIStore}
*/
static getInstance() {
if (!TUIStore.instance) {
TUIStore.instance = new TUIStore();
}
return TUIStore.instance;
}
/**
* UI 组件注册监听回调
* @param {StoreName} storeName store 名称
* @param {IOptions} options 监听信息
* @param {Object} params 扩展参数
* @param {String} params.notifyRangeWhenWatch 注册时监听时的通知范围, 'all' - 通知所有注册该 key 的监听; 'myself' - 通知本次注册该 key 的监听; 默认不通知
*/
watch(storeName: StoreName, options: IOptions, params?: any) {
if (!this.task[storeName]) {
this.task[storeName] = {};
}
const watcher = this.task[storeName];
Object.keys(options).forEach((key) => {
const callback = options[key];
if (!watcher[key]) {
watcher[key] = new Map();
}
watcher[key].set(callback, 1);
const { notifyRangeWhenWatch } = params || {};
// 注册监听后, 通知所有注册该 key 的监听,使用 'all' 时要特别注意是否对其他地方的监听产生影响
if (notifyRangeWhenWatch === NAME.ALL) {
this.notify(storeName, key);
}
// 注册监听后, 仅通知自己本次监听该 key 的数据
if (notifyRangeWhenWatch === NAME.MYSELF) {
const data = this.getData(storeName, key);
callback.call(this, data);
}
});
}
/**
* UI 取消组件监听回调
* @param {StoreName} storeName store 名称
* @param {IOptions} options 监听信息,包含需要取消的回掉等
*/
unwatch(storeName: StoreName, options: IOptions) {
// todo 该接口暂未支持,unwatch掉同一类,如仅传入store注销掉该store下的所有callback,同样options仅传入key注销掉该key下的所有callback
// options的callback function为必填参数,后续修改
if (!this.task[storeName]) {
return;
};
// if (isString(options)) {
// // 移除所有的监听
// if (options === '*') {
// const watcher = this.task[storeName];
// Object.keys(watcher).forEach(key => {
// watcher[key].clear();
// });
// } else {
// console.warn(`${NAME.PREFIX}unwatch warning: options is ${options}`);
// }
// return;
// }
const watcher = this.task[storeName];
Object.keys(options).forEach((key: string) => {
watcher[key].delete(options[key]);
});
}
/**
* 通用 store 数据更新,messageList 的变更需要单独处理
* @param {StoreName} storeName store 名称
* @param {string} key 变更的 key
* @param {unknown} data 变更的数据
*/
update(storeName: StoreName, key: string, data: unknown) {
// 基本数据类型时, 如果相等, 就不进行更新, 减少不必要的 notify
if (isString(data) || isNumber(data) || isBoolean(data)) {
const currentData = this.storeMap[storeName]['store'][key]; // eslint-disable-line
if (currentData === data) return;
}
this.storeMap[storeName]?.update(key, data);
this.notify(storeName, key);
}
/**
* 获取 Store 的上一个状态值
* @param {StoreName} storeName store 名称
* @param {string} key 待获取的 key
* @returns {Any}
*/
getPrevData(storeName: StoreName, key: string) {
return this.storeMap[storeName]?.getPrevData(key);
}
/**
* 获取 Store 数据
* @param {StoreName} storeName store 名称
* @param {string} key 待获取的 key
* @returns {Any}
*/
getData(storeName: StoreName, key: string) {
return this.storeMap[storeName]?.getData(key);
}
/**
* UI 组件注册监听回调
* @param {StoreName} storeName store 名称
* @param {string} key 变更的 key
*/
private notify(storeName: StoreName, key: string) {
if (!this.task[storeName]) {
return;
}
const watcher = this.task[storeName];
if (watcher[key]) {
const callbackMap = watcher[key];
const data = this.getData(storeName, key);
for (const [callback] of callbackMap.entries()) {
callback.call(this, data);
}
}
}
public reset(storeName: StoreName, keyList: Array<string> = [], isNotificationNeeded = false) {
if (storeName in this.storeMap) {
const store = this.storeMap[storeName];
// reset all
if (keyList.length === 0) {
keyList = Object.keys(store?.store);
}
store.reset(keyList);
if (isNotificationNeeded) {
keyList.forEach((key) => {
this.notify(storeName, key);
});
}
}
}
// 批量修改多个 key-value
public updateStore(params: any, name?: StoreName): void {
const storeName = name ? name : StoreName.CALL;
Object.keys(params).forEach((key) => {
this.update(storeName, key, params[key]);
});
}
}
@@ -0,0 +1,157 @@
/**
* @property {String} call 1v1 通话 + 群组通话
* @property {String} CUSTOM 自定义 Store
*/
export enum StoreName {
CALL = 'call',
CUSTOM = 'custom'
}
/**
* @property {String} idle 空闲
* @property {String} connecting 呼叫等待中
* @property {String} connected 通话中
*/
export enum CallMediaType {
UNKNOWN,
AUDIO,
VIDEO,
}
/**
* @property {String} caller 主叫
* @property {String} callee 被叫
*/
export enum CallRole {
UNKNOWN = 'unknown',
CALLEE = 'callee',
CALLER = 'caller',
}
/**
* @property {String} idle 空闲
* @property {String} calling 呼叫等待中
* @property {String} connected 通话中
*/
export enum CallStatus {
IDLE = 'idle',
CALLING = 'calling',
CONNECTED = 'connected',
}
/**
* 视频画面显示模式
* 播放视频流默认使用 cover 模式; 播放屏幕共享流默认使用 contain 模式。
* @property {String} contain 优先保证视频内容全部显示。视频尺寸等比缩放,直至视频窗口的一边与视窗边框对齐。如果视频尺寸与显示视窗尺寸不一致,在保持长宽比的前提下,将视频进行缩放后填满视窗,缩放后的视频四周会有一圈黑边。
* @property {String} cover 优先保证视窗被填满。视频尺寸等比缩放,直至整个视窗被视频填满。如果视频长宽与显示窗口不同,则视频流会按照显示视窗的比例进行周边裁剪或图像拉伸后填满视窗
* @property {String} fill 保证视窗被填满的同时保证视频内容全部显示,但是不保证视频尺寸比例不变。视频的宽高会被拉伸至和视窗尺寸一致。(该选项值自 v4.12.1 开始支持)
*/
export enum VideoDisplayMode {
CONTAIN = 'contain',
COVER = 'cover',
FILL = 'fill',
}
/**
* 视频分辨率
* @property {String} 480p
* @property {String} 720p
* @property {String} 1080p
*/
export enum VideoResolution {
RESOLUTION_480P = '480p',
RESOLUTION_720P = '720p',
RESOLUTION_1080P = '1080p',
}
// 支持的语言
export enum LanguageType {
EN = 'en',
'ZH-CN' = 'zh-cn',
JA_JP = 'ja_JP',
}
export type TDeviceList = {
cameraList: any[],
microphoneList: any[],
currentCamera: any,
currentMicrophone: any,
};
/* === 【原来 TUICallKit 对外暴露】=== */
// 原来 web callKit 定义通知外部状态变更的变量, 对外暴露
export const StatusChange = {
IDLE: "idle",
BE_INVITED: "be-invited",
DIALING_C2C: "dialing-c2c",
DIALING_GROUP: "dialing-group",
CALLING_C2C_AUDIO: "calling-c2c-audio",
CALLING_C2C_VIDEO: "calling-c2c-video",
CALLING_GROUP_AUDIO: "calling-group-audio",
CALLING_GROUP_VIDEO: "calling-group-video",
} as const;
export const CallType = {
'unknown': CallMediaType.UNKNOWN,
'audio': CallMediaType.AUDIO,
'video': CallMediaType.VIDEO,
} as const;
/* === 【小程序使用】=== */
/**
* @property {String} ear 听筒
* @property {String} speaker 免提
*/
export enum AudioPlayBackDevice {
EAR = 'ear',
SPEAKER = 'speaker',
};
export enum DeviceType {
MICROPHONE = 'microphone',
CAMERA = 'camera',
SPEAKER = 'speaker',
}
export enum CameraPosition {
FRONT = 0,
BACK = 1,
}
export enum FeatureButton {
Camera = 'camera',
Microphone = 'microphone',
SwitchCamera = 'switchCamera',
InviteUser = 'inviteUser',
}
export enum ButtonState {
Open = 'open',
Close = 'close',
}
export interface IViewBackgroundImage {
[userId: string]: string,
}
export enum ViewName {
LOCAL = 'local',
REMOTE = 'remote',
}
export enum LayoutMode {
LocalInLargeView = ViewName.LOCAL,
RemoteInLargeView = ViewName.REMOTE,
}
export interface ICustomUIConfig {
button?: {
[buttonName in FeatureButton]?: {
show: boolean;
state: ButtonState;
};
}
viewBackground?: IViewBackgroundImage;
layoutMode?: LayoutMode,
}
export enum ACTION_TYPE {
INVITE = 1,
CANCEL_INVITE = 2,
ACCEPT_INVITE = 3,
REJECT_INVITE = 4,
INVITE_TIMEOUT = 5,
}
@@ -0,0 +1,20 @@
// 错误码
export const ErrorCode: any = {
SWITCH_TO_AUDIO_CALL_FAILED: 60001,
SWITCH_TO_VIDEO_CALL_FAILED: 60002,
MICROPHONE_UNAVAILABLE: 60003,
CAMERA_UNAVAILABLE: 60004,
BAN_DEVICE: 60005,
NOT_SUPPORTED_WEBRTC: 60006,
ERROR_BLACKLIST: 20007,
} as const;
export const ErrorMessage: any = {
SWITCH_TO_AUDIO_CALL_FAILED: 'switchToAudioCall-call-failed',
SWITCH_TO_VIDEO_CALL_FAILED: 'switchToVideoCall-call-failed',
MICROPHONE_UNAVAILABLE: 'microphone-unavailable',
CAMERA_UNAVAILABLE: 'camera-unavailable',
BAN_DEVICE: 'ban-device',
NOT_SUPPORTED_WEBRTC: 'not-supported-webrtc',
ERROR_BLACKLIST: 'blacklist-user-tips'
} as const;
@@ -0,0 +1,103 @@
export * from './call';
export * from './error';
export * from './log';
export const CALL_DATA_KEY: any = {
CALL_STATUS: 'callStatus',
CALL_ROLE: 'callRole',
CALL_MEDIA_TYPE: 'callMediaType',
LOCAL_USER_INFO: 'localUserInfo',
LOCAL_USER_INFO_EXCLUDE_VOLUMN: 'localUserInfoExcludeVolume',
REMOTE_USER_INFO_LIST: 'remoteUserInfoList',
REMOTE_USER_INFO_EXCLUDE_VOLUMN_LIST: 'remoteUserInfoExcludeVolumeList',
CALLER_USER_INFO: 'callerUserInfo',
IS_GROUP: 'isGroup',
CALL_DURATION: 'callDuration',
CALL_TIPS: 'callTips',
TOAST_INFO: 'toastInfo',
IS_MINIMIZED: 'isMinimized',
ENABLE_FLOAT_WINDOW: 'enableFloatWindow',
BIG_SCREEN_USER_ID: 'bigScreenUserId',
LANGUAGE: 'language',
IS_CLICKABLE: 'isClickable',
DISPLAY_MODE: 'displayMode',
VIDEO_RESOLUTION: 'videoResolution',
PUSHER: 'pusher',
PLAYER: 'player',
IS_EAR_PHONE: 'isEarPhone',
IS_MUTE_SPEAKER: 'isMuteSpeaker',
SHOW_PERMISSION_TIP: 'SHOW_PERMISSION_TIP',
NETWORK_STATUS: 'NetWorkStatus',
CALL_ID: 'callID',
GROUP_ID: 'groupID',
ROOM_ID: 'roomID',
ROOM_ID_TYPE: 'roomIdType',
SHOW_SELECT_USER: 'showSelectUser',
IS_SHOW_ENABLE_VIRTUAL_BACKGROUND: 'isShowEnableVirtualBackground',
ENABLE_VIRTUAL_BACKGROUND: 'enableVirtualBackground',
GROUP_CALL_MEMBERS: 'groupCallMembers',
PUSHER_ID: 'pusherId',
IS_FORCE_USE_V2_API: 'isForceUseV2API',
};
export const CHAT_DATA_KEY: any = {
"INNER_ATTR_KIT_INFO": "inner_attr_kit_info",
};
export const PUSHER_ID = {
INITIAL_PUSHER: 'initialPusher',
NEW_PUSHER: 'newPusher'
};
export const NAME = {
PREFIX: '【CallService】',
AUDIO: 'audio',
VIDEO: 'video',
LOCAL_VIDEO: 'localVideo',
ERROR: 'error',
TIMEOUT: 'timeout',
RAF: 'raf',
INTERVAL: 'interval',
DEFAULT: 'default',
BOOLEAN: 'boolean',
STRING: 'string',
NUMBER: 'number',
OBJECT: 'object',
ARRAY: 'array',
FUNCTION: 'function',
UNDEFINED: "undefined",
UNKNOWN: 'unknown',
ALL: 'all',
MYSELF: 'myself',
DEVICE_LIST: 'deviceList',
CAMERA_POSITION: 'cameraPosition',
CUSTOM_UI_CONFIG: 'customUIConfig',
TRANSLATE: 'translate',
...PUSHER_ID,
...CALL_DATA_KEY,
...CHAT_DATA_KEY,
};
export const AudioCallIcon = 'https://web.sdk.qcloud.com/component/TUIKit/assets/call.png';
export const VideoCallIcon = 'https://web.sdk.qcloud.com/component/TUIKit/assets/call-video-reverse.svg';
export const MAX_NUMBER_ROOM_ID = 2147483647;
export const DEFAULT_BLUR_LEVEL = 3;
export const NETWORK_QUALITY_THRESHOLD = 4;
export enum PLATFORM {
// eslint-disable-next-line no-unused-vars
MAC = 'mac',
// eslint-disable-next-line no-unused-vars
WIN = 'win',
};
export enum COMPONENT {
// eslint-disable-next-line no-unused-vars
TUI_CALL_KIT = 14,
// eslint-disable-next-line no-unused-vars
TIM_CALL_KIT = 15,
};
export enum ROOM_ID_TYPE {
// eslint-disable-next-line no-unused-vars
NUMBER_ROOM_ID = 1,
// eslint-disable-next-line no-unused-vars
STRING_ROOM_ID = 2,
};
@@ -0,0 +1,9 @@
/* eslint-disable */
// 唯一一个变量格式有问题的, 但是为了和原来 TUICallKit 对外暴露的保持一致
export enum LOG_LEVEL {
NORMAL = 0, // 普通级别,日志量较多,接入时建议使用
RELEASE = 1, // release级别,SDK 输出关键信息,生产环境时建议使用
WARNING = 2, // 告警级别,SDK 只输出告警和错误级别的日志
ERROR = 3, // 错误级别,SDK 只输出错误级别的日志
NONE = 4, // 无日志级别,SDK 将不打印任何日志
}
+37
View File
@@ -0,0 +1,37 @@
import TUICallService, { TUIGlobal, TUIStore, uiDesign } from './CallService/index';
import {
StoreName,
NAME,
CallRole,
CallMediaType,
CallStatus,
StatusChange,
VideoResolution,
VideoDisplayMode,
AudioPlayBackDevice,
FeatureButton,
LayoutMode,
} from './const/index';
import { t } from './locales/index';
// 实例化
const TUICallKitAPI = TUICallService.getInstance();
// 输出产物
export {
TUIGlobal,
TUIStore,
StoreName,
TUICallKitAPI,
NAME,
CallStatus,
CallRole,
CallMediaType,
StatusChange,
VideoResolution,
VideoDisplayMode,
AudioPlayBackDevice,
t,
uiDesign,
FeatureButton,
LayoutMode,
};
@@ -0,0 +1,70 @@
import { CallStatus, CallRole } from '../const/index';
type SDKAppID = { SDKAppID: number; } | { sdkAppID: number; };
export interface IInitParamsBase {
userID: string;
userSig: string;
tim?: any;
isFromChat?: boolean;
component?: number;
}
export type IInitParams = IInitParamsBase & SDKAppID;
// calls params interface
export interface ICallsParams {
userIDList: Array<string>;
type: number;
chatGroupID?: string;
roomID?: number;
strRoomID?: string;
userData?: string;
timeout?: number;
offlinePushInfo?: IOfflinePushInfo;
}
// userInfo interface
export interface IUserInfo {
userId: string;
nick?: string;
avatar?: string;
remark?: string;
displayUserInfo?: string; // 远端用户信息展示: remark -> nick -> userId, 简化 UI 组件; 本地用户信息展示: nick -> userId
isAudioAvailable?: boolean; // 用来设置: 麦克风是否打开
isVideoAvailable?: boolean; // 用来设置: 摄像头是否打开
volume?: number;
isEnter?: boolean; // 远端用户, 用来控制预览远端是否显示 loading 效果; 本地用户, 用来控制 "呼叫"、"接通" 接通后显示的 loading 效果
domId?: string; // 播放流 dom 节点, localUserInfo 的 domId = 'localVideo'; remoteUserInfo 的 domId 就是 userId
}
export interface IOfflinePushInfo {
title?: string,
description?: string,
androidOPPOChannelID?: string,
extension: String
}
export interface ICallbackParam {
beforeCalling?: (...args: any[]) => void;
afterCalling?: (...args: any[]) => void;
onMinimized?: (...args: any[]) => void;
onMessageSentByMe?: (...args: any[]) => void;
kickedOut?: (...args: any[]) => void;
statusChanged?: (...args: any[]) => void;
}
export interface ISelfInfoParams {
nickName: string;
avatar: string;
}
export interface IBellParams {
callRole?: CallRole;
callStatus?: CallStatus;
isMuteBell?: boolean;
calleeBellFilePath?: string;
}
export interface IInviteUserParams {
userIDList: string[];
offlinePushInfo?: IOfflinePushInfo;
}
export interface INetWorkQuality {
userId: string;
quality: number
}
@@ -0,0 +1,55 @@
import { CallStatus, CallRole, CallMediaType, VideoDisplayMode, VideoResolution, TDeviceList, CameraPosition, ICustomUIConfig } from '../const/index';
import { IUserInfo, INetWorkQuality } from './index';
export interface IToastInfo {
text: string;
type?: string; // 默认 info 通知, 取值: 'info' | 'warn' | 'success' | 'error'
}
export interface ICallStore {
callStatus: CallStatus; // 当前的通话状态, 默认: 'idle'
callRole: CallRole; // 通话角色, 默认: 'callee'
callMediaType: CallMediaType; // 通话类型
localUserInfo: IUserInfo; // 自己的信息, 默认: { userId: '' }
localUserInfoExcludeVolume: IUserInfo; // 不包含音量的当前用户信息
remoteUserInfoList: Array<IUserInfo>; // 远端用户信息列表, 默认: []
remoteUserInfoExcludeVolumeList: Array<IUserInfo>; // 不包含音量的远端用户信息列表
// 被叫在未接通时,展示主叫的 userId、头像。但是如果主叫进入通话后再挂断,此时被叫无法知道主叫的信息了。
// 因为目前 store 中仅提供了 remoteUserInfoList 数据,主叫离开后,被叫就没有主叫的信息了。因此考虑在 store 中增加 callerUserInfo 字段。
callerUserInfo: IUserInfo;
isGroup: boolean; // 是否是群组通话, 默认: false
callDuration: string; // 通话时长, 默认: '00:00:00'
callTips: string; // 通话提示的信息. 例如: '等待谁接听', 'xxx 拒绝通话', 'xxx 挂断通话'
toastInfo: IToastInfo; // 远端用户挂断、拒绝、超时、忙线等的 toast 提示信息
isMinimized: boolean; // 当前是否悬浮窗模式, 默认: false
enableFloatWindow: boolean, // 开启/关闭悬浮窗功能,默认: false
bigScreenUserId: string, // 当前大屏幕显示的 userID 用户
language: string; // 当前语言
isClickable: boolean; // 按钮是否可点击(呼叫后, '挂断' 按钮不可点击, 发送信令后才可以点击)
showPermissionTip: boolean; // 设备权限弹窗是否展示(如果有麦克风权限为 false,如果没有麦克风也没有摄像头权限为 true)
netWorkQualityList: Array<INetWorkQuality>; // 通话中用户的网络状态
deviceList: TDeviceList;
callID: string; // callEngine v3.1 support
groupID: string;
roomID: number | string;
roomIdType: number;
cameraPosition: CameraPosition; // 前置或后置,值为front, back
isMuteSpeaker: boolean;
groupCallMembers: IUserInfo[]; // chat 群会话中在通话的成员
// TUICallKit 组件上的属性
displayMode: VideoDisplayMode; // 设置预览远端的画面显示模式, 默认: VideoDisplayMode.COVER
videoResolution: VideoResolution; // 设置视频分辨率, 默认: VideoResolution.RESOLUTION_720P
// 小程序相关属性
pusher: any;
player: any[];
isEarPhone: boolean; // 是否是听筒, 默认: false
showSelectUser: boolean;
// 是否开启虚拟背景, 目前仅 web 支持
isShowEnableVirtualBackground: boolean, // 是否显示虚拟背景图标, 默认: false
enableVirtualBackground: boolean, // 是否开启虚拟背景, 默认: false
// customUIConfig
customUIConfig: ICustomUIConfig,
pusherId: string, // 重新渲染 live-Pusher 的标识位
// translate function
translate: Function,
isForceUseV2API: boolean, // 是否使用 call/groupCall 接口, 默认: false
}
@@ -0,0 +1,36 @@
/**
* @interface TUIGlobal
* @property {Object} global 根据运行环境代理 wx、uni、window
* @property {Boolean} isPC true 标识是 pc 网页
* @property {Boolean} isH5 true 标识是 手机 H5
* @property {Boolean} isWeChat true 标识是 微信小程序
* @property {Boolean} isApp true 标识是 uniapp 打包的 native app
* @property {Boolean} isUniPlatform true 标识当前应用是通过 uniapp 平台打包的产物
* @property {Boolean} isOfficial true 标识是腾讯云官网 Demo 应用
* @property {Boolean} isWIN true 标识是window系统pc
* @property {Boolean} isMAC true 标识是mac os系统pc
*/
export interface ITUIGlobal {
global: any; // 挂在 wx、uni、window 对象
isPC: boolean;
isH5: boolean;
isWeChat: boolean;
isApp: boolean;
isUniPlatform: boolean;
isOfficial: boolean;
isWIN: boolean;
isMAC: boolean;
/**
* 初始化 TUIGlobal 环境变量
* @function
* @private
*/
initEnv(): void;
/**
* 初始化 isOfficial
* @function
* @param {number} SDKAppID 当前实例的应用 SDKAppID
* @private
*/
initOfficial(SDKAppID: number): void;
}
@@ -0,0 +1,87 @@
import { StoreName } from '../const/index';
// 此处 Map 的 value = 1 为占位作用
export type Task = Record<StoreName, Record<string, Map<(data?: unknown) => void, 1>>>
export interface IOptions {
[key: string]: (newData?: any) => void;
}
/**
* @class TUIStore
* @property {ICustomStore} customStore 自定义 store,可根据业务需要通过以下 API 进行数据操作。
*/
export interface ITUIStore {
task: Task;
/**
* UI 组件注册监听
* @function
* @param {StoreName} storeName store 名称
* @param {IOptions} options UI 组件注册的监听信息
* @param {Object} params 扩展参数
* @param {String} params.notifyRangeWhenWatch 注册时监听时的通知范围
* @example
* // UI 层监听会话列表更新通知
* let onConversationListUpdated = function(conversationList) {
* console.warn(conversationList);
* }
* TUIStore.watch(StoreName.CONV, {
* conversationList: onConversationListUpdated,
* })
*/
watch(storeName: StoreName, options: IOptions, params?: any): void;
/**
* UI 组件取消监听回调
* @function
* @param {StoreName} storeName store 名称
* @param {IOptions} options 监听信息,包含需要取消的回调等
* @example
* // UI 层取消监听会话列表更新通知
* TUIStore.unwatch(StoreName.CONV, {
* conversationList: onConversationListUpdated,
* })
*/
unwatch(storeName: StoreName, options: IOptions | string): void;
/**
* 获取 store 中的上一个状态
* @function
* @param {StoreName} storeName store 名称
* @param {String} key 需要获取的 key
* @private
*/
getPrevData(storeName: StoreName, key: string): any;
/**
* 获取 store 中的数据
* @function
* @param {StoreName} storeName store 名称
* @param {String} key 需要获取的 key
* @private
*/
getData(storeName: StoreName, key: string): any;
/**
* 更新 store
* - 需要使用自定义 store 时可以用此 API 更新自定义数据
* @function
* @param {StoreName} storeName store 名称
* @param {String} key 需要更新的 key
* @example
* // UI 层更新自定义 Store 数据
* TUIStore.update(StoreName.CUSTOM, 'customKey', 'customData')
*/
update(storeName: StoreName, key: string, data: unknown): void;
/**
* 重置 store 内数据
* @function
* @param {StoreName} storeName store 名称
* @param {Array<string>} keyList 需要 reset 的 keyList
* @param {boolean} isNotificationNeeded 是否需要触发更新
* @private
*/
reset: (storeName: StoreName, keyList?: Array<string>, isNotificationNeeded?: boolean) => void;
/**
* 修改多个 key-value
* @param {Object} params 多个 key-value 组成的 object
* @param {StoreName} storeName store 名称
*/
updateStore: (params: any, name?: StoreName) => void;
}
@@ -0,0 +1,4 @@
export * from './ICallService';
export * from './ICallStore';
export * from './ITUIGlobal';
export * from './ITUIStore';
@@ -0,0 +1,53 @@
import { TUIStore } from '../CallService/index';
import { NAME, StoreName } from '../const/index';
import { zh } from './zh-cn';
import { interpolate, isString, isPlainObject } from '../utils/common-utils';
export const CallTips: any = {
OTHER_SIDE: 'other side',
CANCEL: 'cancel',
OTHER_SIDE_REJECT_CALL: 'other side reject call',
REJECT_CALL: 'reject call',
OTHER_SIDE_LINE_BUSY: 'other side line busy',
IN_BUSY: 'in busy',
CALL_TIMEOUT: 'call timeout',
END_CALL: 'end call',
TIMEOUT: 'timeout',
KICK_OUT: 'kick out',
CALLER_CALLING_MSG: 'caller calling message',
CALLER_GROUP_CALLING_MSG: 'wait to be called',
CALLEE_CALLING_VIDEO_MSG: 'callee calling video message',
CALLEE_CALLING_AUDIO_MSG: 'callee calling audio message',
NO_MICROPHONE_DEVICE_PERMISSION: 'no microphone access',
NO_CAMERA_DEVICE_PERMISSION: 'no camera access',
EXIST_GROUP_CALL: 'exist group call',
LOCAL_NETWORK_IS_POOR: 'The network is poor during your current call',
REMOTE_NETWORK_IS_POOR: 'The other user network is poor during the current call',
};
export const languageData: languageDataType = {
'zh-cn': zh,
};
// language translate
export function t(args): string {
const language = TUIStore.getData(StoreName.CALL, NAME.LANGUAGE);
let translationContent = '';
if (isString(args)) {
translationContent = languageData?.[language]?.[args] || '';
} else if (isPlainObject(args)) {
const { key, options } = args;
translationContent = languageData?.[language]?.[key] || '';
translationContent = interpolate(translationContent, options);
}
return translationContent;
}
interface languageItemType {
[key: string]: string;
}
interface languageDataType {
[key: string]: languageItemType;
}
@@ -0,0 +1,130 @@
export const zh = {
// 按钮文案
'hangup': '挂断',
'reject': '拒绝',
'accept': '接受',
'camera': '摄像头',
'microphone': '麦克风',
'speaker': '扬声器',
'open camera': '打开摄像头',
'close camera': '关闭摄像头',
'open microphone': '打开麦克风',
'close microphone': '关闭麦克风',
'video-to-audio': '转语音通话',
'virtual-background': '模糊背景',
// 通话结果
'other side reject call': '对方已拒绝',
'reject call': '{{ userList }} 拒绝通话',
'cancel': '取消通话',
'other side line busy': '对方忙线',
'in busy': '{{ userList }} 正在忙',
'call timeout': '呼叫超时',
'end call': '{{ userList }} 结束通话',
// 通话提示语
'caller calling message': '等待对方接受邀请',
'callee calling video message': '邀请你视频通话',
'callee calling audio message': '邀请你语音通话',
'no microphone access': '没有麦克风权限',
'no camera access': '没有摄像头权限',
'invite member': '邀请成员',
'Invited group call': '邀请你加入多人通话',
'Those involved': '参与通话的有:',
'waiting': '等待接听...',
'me': '(我)',
// 弹出层文案
'browser-authorization': '浏览器授权',
'mac-privacy': '系统偏好设置 -> 安全与隐私 -> 隐私',
'win-privacy': '设置 -> 隐私和安全性 -> 应用权限',
'mac-preferences': '打开系统偏好设置',
'win-preferences': '打开系统设置',
'Please enter userID': '请输入 userID',
'View more': '查看更多',
'people selected': '人已选中',
'Select all': '全选',
'Cancel': '取消',
'Done': '完成',
'exist group call': '当前群组中已经存在群组通话',
// UI3.0 新增
'camera enabled': '摄像头已开',
'camera disabled': '摄像头已关',
'microphone enabled': '麦克风已开',
'microphone disabled': '麦克风已关',
'speaker enabled': '扬声器已开',
'speaker disabled': '扬声器已关',
'open speaker': '开启扬声器',
'close speaker': '关闭扬声器',
'wait to be called': '等待接听',
'answered': '已接通',
'people in the call': '人参与通话',
'failed to obtain speakers': '无法获取扬声器',
'you have a new call': '您有一个新的通话',
'switch camera': '翻转',
'join': '加入',
'people on the call': '人正在通话',
"Supports a maximum of 9 people for simultaneous calls": "最多支持9人同时通话",
'you': '(你)',
"The network is poor during your current call": "当前通话你的网络不佳",
"The other user network is poor during the current call": "当前通话对方网络不佳",
"TUICallKit init is not complete": "TUICallKit 初始化登录未完成,需要在 init 完成后使用此 API",
// combine chat
"Video call": "发起视频通话",
"Voice call": "发起语音通话",
"Call End": "通话结束",
"Switch voice call": "切换语音通话",
"Switch video call": "切换视频通话",
"Call duration": "通话时长",
"Call Cancel": "已取消",
"Other Side Cancel": "对方已取消",
"Decline": "已拒绝",
"Other Side Decline": "对方已拒绝",
"No answer": "超时无应答",
"Other Side No Answer": "对方无应答",
"Answered": "已接听",
"Other Side Line Busy": "对方忙线中",
"Line Busy": "忙线无应答",
// 待废弃文案
'timeout': '{{ userList }} 超时',
'kick out': '被踢',
'call': '通话',
'video-call': '视频通话',
'audio-call': '音频通话',
'search': '搜索',
'search-result': '搜索结果',
'Wechat scan right QR code': '微信扫右二维码',
'Use-phone-and-computer': '用手机与电脑互打体验视频通话',
'Scan the QR code above': '扫描上方二维码',
'no-user': '未搜索到用户',
'member-not-added': '未添加成员',
'not-login': '未登录',
'login-status-expire': '登录状态已失效,请刷新网页重试',
'experience-multi-call': '体验多人通话请下载全功能demo:',
'not-support-multi-call': '多人通话接口未开放',
'input-phone-userID': '请输入手机号/用户ID',
'userID': '用户ID',
'already-enter': '已经进入当前通话',
'image-resolution': '分辨率',
'default-image-resolution': '默认分辨率',
'invited-person': '添加成员',
'be-rejected': '对方已拒绝,',
'be-no-response': '对方无应答,',
'be-line-busy': '对方忙线中,',
'be-canceled': '对方已取消',
'voice-call-end': '语音通话结束',
'video-call-end': '视频通话结束',
'method-call-failed': '同步操作失败',
'failed-to-obtain-permission': '权限获取失败',
'environment-detection-failed': '环境检测失败',
'switchToAudioCall-call-failed': '切语音调用失败',
'switchToVideoCall-call-failed': '切视频调用失败',
'microphone-unavailable': '没有可用的麦克风设备',
'camera-unavailable': '没有可用的摄像头设备',
'ban-device': '用户禁止使用设备',
'not-supported-webrtc': '当前环境不支持 WebRTC',
'blacklist-user-tips': '发起通话失败,被对方拉入黑名单,禁止发起!',
'is-already-calling': 'TUICallKit 已在通话状态',
'need-init': 'TUICallKit 发起通话前需保证 TUICallKitAPI.init() 方法执行成功',
"can't call yourself": '不能呼叫自己', // eslint-disable-line
'accept-error': '接通失败',
'accept-device-error': '接通失败,通话设备获取失败',
'call-error': '发起通话失败',
};
@@ -0,0 +1,109 @@
import { TUICallKitAPI, NAME, TUIStore, StoreName } from '../../index';
import { CallStatus } from '../const/index';
import { avoidRepeatedCall } from '../utils/validate/index';
import { IN_WX_MINI_APP } from '../utils/env';
/**
* @param {Number} sdkAppID 用户的sdkAppID 必传
* @param {String} userID 用户的userID 必传
* @param {String} userSig 用户的userSig 必传
* @param {String} globalCallPagePath 跳转的路径 必传
* @param {ChatSDK} tim tim实例 非必传
*/
const PREFIX = 'callManager';
export class CallManager {
private _globalCallPagePath:string = '';
@avoidRepeatedCall()
public async init(params) {
const { sdkAppID, userID, userSig, globalCallPagePath, tim } = params;
if (!globalCallPagePath) {
console.error(`${PREFIX} globalCallPagePath Can not be empty!`);
return;
};
this._globalCallPagePath = globalCallPagePath;
try {
await TUICallKitAPI.init({
sdkAppID,
userID,
userSig,
tim,
});
this._watchTUIStore();
// uniApp 小程序全局监听下,关闭悬浮窗
if (!IN_WX_MINI_APP) {
TUICallKitAPI.enableFloatWindow(false);
};
console.log(`${PREFIX} init Ready!`);
} catch (error) {
console.error(`${PREFIX} init fail!`, error);
throw error;
}
}
// =========================【监听 TUIStore 中的状态】=========================
private _watchTUIStore() {
TUIStore?.watch(StoreName.CALL, {
[NAME.CALL_STATUS]: this._handleCallStatusChange,
}, {
notifyRangeWhenWatch: NAME.MYSELF,
});
}
private _unwatchTUIStore() {
TUIStore?.unwatch(StoreName.CALL, {
[NAME.CALL_STATUS]: this._handleCallStatusChange,
});
}
private _handleCallStatusChange = async (value: CallStatus) => {
switch (value) {
case CallStatus.CALLING:
case CallStatus.CONNECTED:
this._handleCallStatusToCalling();
break;
case CallStatus.IDLE:
this._handleCallStatusToIdle();
break;
}
};
private _handleCallStatusToCalling() {
if (this.getRoute() === this._globalCallPagePath) return;
// @ts-ignore
wx.navigateTo({
url: `/${this._globalCallPagePath}`,
success: () => {},
fail: () => {
console.error(`${PREFIX} navigateTo fail!`);
},
complete: () => {},
});
}
private _handleCallStatusToIdle() {
if (this.getRoute() !== this._globalCallPagePath) return;
// @ts-ignore
wx.navigateBack({
success: () => {},
fail: () => {
console.error(`${PREFIX} navigateBack fail!`);
},
complete: () => {},
});
}
// 获取当前的页面地址
getRoute() {
// @ts-ignore
const pages = getCurrentPages();
const currentPage = pages[pages.length - 1];
return currentPage.route;
}
// 卸载 callManger
public async destroyed() {
this._globalCallPagePath = '';
this._unwatchTUIStore();
await TUICallKitAPI.destroyed();
}
}
@@ -0,0 +1,269 @@
import { NAME } from '../const/index';
import TUIGlobal from '../TUIGlobal/tuiGlobal';
export const isUndefined = function (input: any) {
return typeof input === NAME.UNDEFINED;
};
export const isPlainObject = function (input: any) {
// 注意不能使用以下方式判断,因为IE9/IE10下,对象的__proto__是 undefined
// return isObject(input) && input.__proto__ === Object.prototype;
if (typeof input !== NAME.OBJECT || input === null) {
return false;
}
const proto = Object.getPrototypeOf(input);
if (proto === null) { // edge case Object.create(null)
return true;
}
let baseProto = proto;
while (Object.getPrototypeOf(baseProto) !== null) {
baseProto = Object.getPrototypeOf(baseProto);
}
// 原型链第一个和最后一个比较
return proto === baseProto;
};
export const isArray = function (input: any) {
if (typeof Array.isArray === NAME.FUNCTION) {
return Array.isArray(input);
}
return (Object as any).prototype.toString.call(input).match(/^\[object (.*)\]$/)[1].toLowerCase() === NAME.ARRAY;
};
export const isPrivateKey = function (key: string) {
return key.startsWith('_');
};
export const isUrl = function (url: string) {
return /^(https?:\/\/(([a-zA-Z0-9]+-?)+[a-zA-Z0-9]+\.)+[a-zA-Z]+)(:\d+)?(\/.*)?(\?.*)?(#.*)?$/.test(url);
};
/**
* 检测input类型是否为string
* @param {*} input 任意类型的输入
* @returns {Boolean} true->string / false->not a string
*/
export const isString = function (input: any) {
return typeof input === NAME.STRING;
};
export const isBoolean = function (input: any) {
return typeof input === NAME.BOOLEAN;
};
export const isNumber = function (input: any) {
return (
// eslint-disable-next-line
input !== null &&
((typeof input === NAME.NUMBER && !isNaN(input - 0)) || (typeof input === NAME.OBJECT && input.constructor === Number))
);
};
export function formatTime(secondTime: number): string {
const hours: number = Math.floor(secondTime / 3600);
const minutes: number = Math.floor((secondTime % 3600) / 60);
const seconds: number = Math.floor(secondTime % 60);
let callDurationStr: string = hours > 9 ? `${hours}` : `0${hours}`;
callDurationStr += minutes > 9 ? `:${minutes}` : `:0${minutes}`;
callDurationStr += seconds > 9 ? `:${seconds}` : `:0${seconds}`;
return callDurationStr;
}
export function formatTimeInverse(stringTime: string): number {
const list = stringTime.split(':');
return parseInt(list[0]) * 3600 + parseInt(list[1]) * 60 + parseInt(list[2]); // eslint-disable-line
}
// Determine if it is a JSON string
export function isJSON(str: string) {
if (typeof str === NAME.STRING) {
try {
const data = JSON.parse(str);
if (data) {
return true;
}
return false;
} catch (error) {
console.debug(error);
return false;
}
}
return false;
}
// Determine if it is a JSON string
export const JSONToObject = function (str: string) {
if (!str || !isJSON(str)) {
return str;
}
return JSON.parse(str);
};
/**
* 重试函数, catch 时,重试
* @param {Promise} promise 需重试的函数
* @param {number} num 需要重试的次数
* @param {number} time 间隔时间(s
* @returns {Promise<any>} im 接口的 response 原样返回
*/
export const retryPromise = (promise: Promise<any>, num: number = 6, time: number = 0.5) => {
let n = num;
const func = () => promise;
return func()
.catch((error: any) => {
if (n === 0) {
throw error;
}
const timer = setTimeout(() => {
func();
clearTimeout(timer);
n = n - 1;
}, time * 1000);
});
};
// /**
// * 节流函数(目前 TUICallKit 增加防重调用装饰器,该方法可删除)
// * @param {Function} func 传入的函数
// * @param {wait} time 间隔时间(ms
// */
// export const throttle = (func: Function, wait: number) => {
// let previousTime = 0;
// return function () {
// const now = Date.now();
// const args = [...arguments];
// if (now - previousTime > wait) {
// func.apply(this, args);
// previousTime = now;
// }
// };
// }
/**
* web call engine 重复调用时的错误, 这种错误在 TUICallKit 应该忽略
* @param {any} error 错误信息
* @returns {Boolean}
*/
export function handleRepeatedCallError(error: any) {
if (error?.message?.indexOf('is ongoing, please avoid repeated calls') !== -1) {
return true;
}
return false;
}
/**
* 设备无权限时的错误处理
* @param {any} error 错误信息
* @returns {Boolean}
*/
export function handleNoDevicePermissionError(error: any) {
const { message } = error;
if (message?.indexOf('NotAllowedError: Permission denied') !== -1) {
return true;
}
return false;
}
/*
* 获取向下取整的 performance.now() 值
* 在不支持 performance.now 的浏览器中,使用 Date.now(). 例如 ie 9ie 10,避免加载 sdk 时报错
* @export
* @return {Number}
*/
export function performanceNow() {
// if (!performance || !performance.now) {
// return Date.now();
// }
// return Math.floor(performance.now()); // uni-app 打包小程序没有 performance, 报错
return Date.now();
}
/**
* 检测input类型是否为function
* @param {*} input 任意类型的输入
* @returns {Boolean} true->input is a function
*/
export const isFunction = function (input: any) {
return typeof input === NAME.FUNCTION;
};
/*
* 获取浏览器语言
* @export
* @return {zh-cn | en}
*/
export const getLanguage = () => {
if (TUIGlobal.getInstance().isWeChat) {
return 'zh-cn';
}
// @ts-ignore
const lang = (navigator?.language || navigator?.userLanguage || '').substr(0, 2);
let language = 'en';
switch (lang) {
case 'zh':
language = 'zh-cn';
break;
case 'ja':
language = 'ja_JP';
break;
default:
language = 'en';
}
return language;
};
export function noop(e: any) {}
/**
* Get the object type string
* @param {*} input 任意类型的输入
* @returns {String} the object type string
*/
export const getType = function(input) {
return Object.prototype.toString
.call(input)
.match(/^\[object (.*)\]$/)[1]
.toLowerCase();
};
// 修改对象键名
export function modifyObjectKey(obj, oldKey, newKey) {
if (!obj.hasOwnProperty(oldKey)) {
return obj;
}
const newObj = {};
Object.keys(obj).forEach(key => {
if (key === oldKey) {
newObj[newKey] = obj[key];
} else {
newObj[key] = obj[key];
}
});
return newObj;
}
/**
* interpolate function
* @param {string} str - 'hello {{name}}'
* @param {object} data - { name: 'sam' }
* @returns {string} 'hello sam'
*
*/
export function interpolate(str, data) {
return str.replace(/{{\s*(\w+)(\s*,\s*[^}]+)?\s*}}/g, (match, p1) => {
const key = p1.trim();
return data[key] !== undefined ? String(data[key]) : match;
});
}
// Execute the callback when detecting browser refresh or close, but skip processing for page navigation
export function initBrowserCloseDetection(callback: Function) {
if (window?.addEventListener) {
// Trigger condition: close tab、refresh、navigate
window.addEventListener('beforeunload', (event) => {
const navigationEntries = performance?.getEntriesByType('navigation') || [];
const navigationEntry = navigationEntries[0];
// @ts-ignore
if (navigationEntry && navigationEntry.type === 'navigate') {
return;
}
callback(event);
});
}
}
@@ -0,0 +1,51 @@
import promiseRetry from '../retry';
/**
* 异步函数重试 Interface
* @param {number} [retries = 5] 重试次数
* @param {number} [timeout = 2000] 重试间隔时间
* @param {Function=} onError 重试错误回调
* @param {Function=} onRetrying 重试回调
* @param {Function=} onRetryFailed 重试失败回调
*/
interface IPromiseRetryDecoratorSettings {
retries?: number;
timeout?: number;
onError?: Function;
onRetrying?: Function;
onRetryFailed?: Function;
};
/**
* 装饰器函数:给异步函数增加重试
* @param {Object} settings 入参
* @returns {Function}
* @example
* class LocalStream {
* @promiseRetryDecorator({
* retries: 10,
* timeout: 3000,
* onRetryFailed: function(error) {
* }
* })
* async recoverCapture(options) {}
* }
*/
export default function promiseRetryDecorator(settings: IPromiseRetryDecoratorSettings) {
return function(target, name, descriptor) {
const { retries = 5, timeout = 2000, onError, onRetrying, onRetryFailed } = settings;
const oldFn = promiseRetry({
retryFunction: descriptor.value,
settings: { retries, timeout },
onError,
onRetrying,
onRetryFailed,
context: null,
});
descriptor.value = function(...args) {
return oldFn.apply(this, args);
};
return descriptor;
};
}
@@ -0,0 +1,51 @@
// eslint-disable-next-line
declare var wx: any;
// eslint-disable-next-line
declare var uni: any;
// eslint-disable-next-line
declare var window: any;
// 在 uniApp 框架下,打包 H5、ios app、android app 时存在 wx/qq/tt/swan/my 等变量会导致引入 web sdk 环境判断失效
// 小程序 getSystemInfoSync 返回的 fontSizeSetting 在 H5 和 app 中为 undefined,所以通过 fontSizeSetting 增强小程序环境判断
// wx 小程序
export const IN_WX_MINI_APP = (typeof wx !== 'undefined' && typeof wx.getSystemInfoSync === 'function' && Boolean(wx.getSystemInfoSync().fontSizeSetting));
// 用 uni-app 打包 native app,此时运行于 js core,无 window 等对象,此时调用 api 都得 uni.xxx,由于风格跟小程序类似,就归为 IN_MINI_APP 的一种
export const IN_UNI_NATIVE_APP = (typeof uni !== 'undefined' && typeof uni === 'undefined');
export const IN_MINI_APP = IN_WX_MINI_APP || IN_UNI_NATIVE_APP;
export const IN_UNI_APP = (typeof uni !== 'undefined');
// 在 uniApp 框架下,由于客户打包 ios app、android app 时 window 不一定存在,所以通过 !IN_MINI_APP 进行判断
// 非 uniApp 框架下,仍然通过 window 结合 IN_MINI_APP 进行判断,可兼容 Taro3.0+ 暴露 window 对象引起的 IN_BROWSER 判断失效问题
export const IN_BROWSER = (function () {
if (typeof uni !== 'undefined') {
return !IN_MINI_APP;
}
return (typeof window !== 'undefined') && !IN_MINI_APP;
}());
// 命名空间
export const APP_NAMESPACE = (function () {
if (IN_WX_MINI_APP) {
return wx;
}
if (IN_UNI_APP) {
return uni;
}
return window;
}());
// eslint-disable-next-line no-mixed-operators
const USER_AGENT = IN_BROWSER && window && window.navigator && window.navigator.userAgent || '';
const IS_ANDROID = /Android/i.test(USER_AGENT);
const IS_WIN_PHONE = /(?:Windows Phone)/.test(USER_AGENT);
const IS_SYMBIAN = /(?:SymbianOS)/.test(USER_AGENT);
const IS_IOS = /iPad/i.test(USER_AGENT) || /iPhone/i.test(USER_AGENT) || /iPod/i.test(USER_AGENT);
export const IS_H5 = IS_ANDROID || IS_WIN_PHONE || IS_SYMBIAN || IS_IOS;
export const IS_PC = IN_BROWSER && !IS_H5;
export const IS_WIN = IS_PC && USER_AGENT.includes('Windows NT');
export const IS_MAC = IS_PC && USER_AGENT.includes('Mac');
@@ -0,0 +1,32 @@
export async function checkLocalMP3FileExists(src: string) {
if (!src) return false;
try {
const response = await new Promise<XMLHttpRequest>((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('HEAD', src, true);
xhr.onload = () => resolve(xhr);
xhr.onerror = () => reject(xhr);
xhr.send();
});
return response.status === 200 && response.getResponseHeader('Content-Type') === 'audio/mpeg';
} catch (error) {
console.warn(error);
return false;
}
}
export function deepClone(obj) {
if (typeof obj !== 'object' || obj === null) {
return obj;
}
let clone = Array.isArray(obj) ? [] : {};
for (let key in obj) {
if (obj.hasOwnProperty(key)) {
clone[key] = deepClone(obj[key]);
}
}
return clone;
}
@@ -0,0 +1,31 @@
import { isPlainObject } from './common-utils';
const isEmpty = function (input: any) {
// Null and Undefined...
if (input === null || typeof (input) === 'undefined') return true;
// Booleans...
if (typeof input === 'boolean') return false;
// Numbers...
if (typeof input === 'number') return input === 0;
// Strings...
if (typeof input === 'string') return input.length === 0;
// Functions...
if (typeof input === 'function') return input.length === 0;
// Arrays...
if (Array.isArray(input)) return input.length === 0;
// Errors...
if (input instanceof Error) return input.message === '';
// plain object
if (isPlainObject(input)) {
// eslint-disable-next-line
for (const key in input) {
if (Object.prototype.hasOwnProperty.call(input, key)) {
return false;
}
}
return true;
}
return false;
};
export default isEmpty;
@@ -0,0 +1,88 @@
import { isFunction, isUndefined } from './common-utils';
const RETRY_STATE_NOT_START = 0;
const RETRY_STATE_STARTED = 1;
const RETRY_STATE_STOPPED = 2;
/**
* 给异步函数封装重试逻辑
* @param {Object} options 重试逻辑入参
* @param {Object} options.retryFunction 需要封装的异步函数
* @param {Object} options.settings 重试属性
* @param {Number} [options.settings.retries = 5] 重试次数
* @param {Number} [options.settings.timeout = 1000] 重试间隔
* @param {onErrorCallback} options.onError 重试错误回调
* @param {onRetryingCallback} [options.onRetrying] 重试后的回调
* @param {Object} options.context 上下文,可选
* @returns {Function} 封装后的函数
* @example
* const getUserMedia = promiseRetry({
* retryFunction: getUserMedia_,
* settings: { retries: 5, timeout: 2000 },
* onError: (error, retry, reject) => {
* if (error.name === 'NotReadableError') {
* retry();
* } else {
* reject(error);
* }
* },
* onRetrying: retryCount => {
* console.warn(`getUserMedia NotReadableError observed, retrying [${retryCount}/5]`);
* }
* });
*/
function promiseRetry({ retryFunction, settings, onError, onRetrying, onRetryFailed, context }) {
return function(...args) {
const retries = settings.retries || 5;
let retryCount = 0;
let timer: any = -1;
let retryState = RETRY_STATE_NOT_START;
const run = async (resolve, reject) => {
const ctx = context || this;
try {
const result = await retryFunction.apply(ctx, args);
// 执行成功,正常返回
retryCount = 0;
resolve(result);
} catch (error) {
// 用于停止重试
const stopRetry = () => {
clearTimeout(timer);
retryCount = 0;
retryState = RETRY_STATE_STOPPED;
reject(error);
};
const retry = () => {
if (retryState !== RETRY_STATE_STOPPED && retryCount < retries) {
retryCount++;
retryState = RETRY_STATE_STARTED;
if (isFunction(onRetrying)) {
onRetrying.call(ctx, retryCount, stopRetry);
}
timer = setTimeout(
() => {
timer = -1;
run(resolve, reject);
},
isUndefined(settings.timeout) ? 1000 : settings.timeout
);
} else {
stopRetry();
if (isFunction(onRetryFailed)) {
onRetryFailed.call(ctx, error);
}
}
};
if (isFunction(onError)) {
onError.call(ctx, error, retry, reject, args);
} else {
retry();
}
}
};
return new Promise(run);
};
}
export default promiseRetry;
@@ -0,0 +1,162 @@
/* eslint-disable */
import { isPlainObject, performanceNow, isFunction } from './common-utils';
import { NAME } from '../const/index';
/**
* 定时器,功能:
* 1. 支持定时执行回调 [1,n] 次,用于常规延迟、定时操作
* @example
* // 默认嵌套执行,count=0 无限次
* timer.run(callback, {delay: 2000});
* // count=1 等同于 原始setTimeout
* timer.run(callback, {delay: 2000, count:0});
* 2. 支持 RAF 执行回调,用于小流渲染,audio音量获取等任务,需要渲染频率稳定,支持页面退后台后,用 setTimeout 接管,最短 1s 执行一次
* @example
* // 默认60fps,可以根据单位时长换算,默认开启后台执行
* timer.run('raf', callback, {fps: 60});
* // 设置执行次数
* timer.run('raf', callback, {fps: 60, count: 300, backgroundTask: false});
* 3. 支持空闲任务执行回调, requestIdleCallback 在帧渲染的空闲时间执行任务,可以用于 storage 写入等低优先级的任务
* @example
* // 支持原生setInterval 但不推荐使用,定时任务推荐用 timeout
* timer.run('interval', callback, {delay:2000, count:10})
*/
class Timer {
static taskMap = new Map();
static currentTaskID = 1;
static generateTaskID() {
return this.currentTaskID++;
}
/**
*
* @param {string} taskName 'interval' 'timeout'
* @param {function} callback
* @param {object} options include:
* @param {number} options.delay millisecond
* @param {number} options.count 定时器回调执行次数,0 无限次 or n次
* @param {boolean} options.backgroundTask 在页面静默后是否继续执行定时器
*/
static run(taskName = NAME.TIMEOUT, callback: any, options: any) {
// default options
if (taskName === NAME.INTERVAL) {
options = { ...{ delay: 2000, count: 0, backgroundTask: true }, ...options };
} else {
options = { ...{ delay: 2000, count: 0, backgroundTask: true }, ...options };
}
// call run(function, {...})
if (isPlainObject(callback)) {
options = { ...options, ...callback };
}
if (isFunction(taskName)) {
callback = taskName;
taskName = NAME.TIMEOUT;
}
// 1. 创建 taskID,作为 timer task 的唯一标识,在本函数执行完后返回,用于在调用的地方实现互斥逻辑
// 2. 根据 taskName 执行相应的函数
const taskItem = {
taskID: this.generateTaskID(),
loopCount: 0,
intervalID: null,
timeoutID: null,
taskName,
callback,
...options
};
this.taskMap.set(taskItem.taskID, taskItem);
// console.log(`timer run task:${taskItem.taskName}, task queue size: ${this.taskMap.size}`);
if (taskName === NAME.INTERNAL) {
this.interval(taskItem);
} else {
this.timeout(taskItem);
}
return taskItem.taskID;
}
/**
* 定时循环执行回调函数
* 可以指定循环的时间间隔
* 可以指定循环次数
* @param {object} taskItem
* @param {function} callback
* @param {*} delay
* @param {*} count
* @returns ID
*/
static interval(taskItem: any) {
// setInterval 缺点,浏览器退后台会降频,循环执行间隔时间不可靠
// 创建进入定时器循环的任务函数,函数内:1. 判断是否满足执行条件,2.执行 callback
// 通过 setInterval 执行任务函数
// 将 intervalID 记录到 taskMap 对应的 item
const task = () => {
taskItem.callback();
taskItem.loopCount += 1;
if (this.isBreakLoop(taskItem)) {
return;
}
};
return taskItem.intervalID = setInterval(task, taskItem.delay);
}
/**
* 延迟执行回调
* count = 0,循环
* count = n, 执行n次
* @param {object} taskItem
*
*/
static timeout(taskItem: any) {
// setTimeout 浏览器退后台,延迟变为至少1s
const task: any = () => {
// 执行回调
taskItem.callback();
taskItem.loopCount += 1;
if (this.isBreakLoop(taskItem)) {
return;
}
// 不修正延迟,每次callback间隔平均
return taskItem.timeoutID = setTimeout(task, taskItem.delay);
};
return taskItem.timeoutID = setTimeout(task, taskItem.delay);
}
static hasTask(taskID: any) {
return this.taskMap.has(taskID);
}
static clearTask(taskID: any) {
// console.log('timer clearTask start', `| taskID:${taskID} | size:${this.taskMap.size}`);
if (!this.taskMap.has(taskID)) {
return true;
}
const { intervalID, timeoutID, onVisibilitychange } = this.taskMap.get(taskID);
if (intervalID) {
clearInterval(intervalID);
}
if (timeoutID) {
clearTimeout(timeoutID);
}
if (onVisibilitychange) {
document.removeEventListener('visibilitychange', onVisibilitychange);
}
this.taskMap.delete(taskID);
// console.log('timer clearTask end ', `| size:${this.taskMap.size}`);
return true;
}
/**
* 1. 如果已移除出定时队列,退出当前任务
* 2. 如果当前任务已满足次数限制,则退出当前任务
* @param {object} taskItem
* @returns
*/
static isBreakLoop(taskItem: any) {
if (!this.taskMap.has(taskItem.taskID)) {
return true;
}
if (taskItem.count !== 0 && taskItem.loopCount >= taskItem.count) {
this.clearTask(taskItem.taskID);
return true;
}
return false;
}
}
export default Timer;
@@ -0,0 +1,44 @@
import { NAME } from '../../const/index';
/**
* 装饰器:阻止函数重复调用
* @export
* @param {Object} options 入参
* @param {Function} options.fn 函数
* @param {Object} options.context 上下文对象
* @param {String} options.name 函数名
* @returns {Function} 封装后的函数
*/
export function avoidRepeatedCall() {
return function (target: any, name: string, descriptor: any) {
const oldFn = descriptor.value;
const isCallingSet = new Set();
descriptor.value = async function (...args: any[]) {
if (isCallingSet.has(this)) {
console.warn((`${NAME.PREFIX}previous ${name}() is ongoing, please avoid repeated calls`));
// throw new Error(`previous ${name}() is ongoing, please avoid repeated calls`);
this?.getTUICallEngineInstance()?.reportLog?.({
name: 'TUICallKit.avoidRepeatedCall.fail',
data: { name },
error: `previous ${name}() is ongoing`,
});
return;
}
try {
isCallingSet.add(this);
const result = await oldFn.apply(this, args);
isCallingSet.delete(this);
return result;
} catch (error) {
isCallingSet.delete(this);
throw error;
}
};
descriptor.value.clearCallState = function(instance: any) {
isCallingSet.delete(instance);
};
return descriptor;
};
}
@@ -0,0 +1,7 @@
import { avoidRepeatedCall } from './avoidRepeatedCall';
// import { apiCallQueue } from "./apiCallQueue";
export {
avoidRepeatedCall,
// apiCallQueue,
};