@100mslive/hms-video-store
Version:
@100mslive Core SDK which abstracts the complexities of webRTC while providing a reactive store for data management with a unidirectional data flow
1,423 lines (1,282 loc) • 59.8 kB
text/typescript
import HMSRoom from './models/HMSRoom';
import { HMSLocalPeer } from './models/peer';
import { HMSPeerListIterator } from './HMSPeerListIterator';
import { LocalTrackManager } from './LocalTrackManager';
import { NetworkTestManager } from './NetworkTestManager';
import RoleChangeManager from './RoleChangeManager';
import { Store } from './store';
import { WakeLockManager } from './WakeLockManager';
import AnalyticsEvent from '../analytics/AnalyticsEvent';
import AnalyticsEventFactory from '../analytics/AnalyticsEventFactory';
import { HMSAnalyticsLevel } from '../analytics/AnalyticsEventLevel';
import { AnalyticsEventsService } from '../analytics/AnalyticsEventsService';
import { AnalyticsTimer, TimedEvent } from '../analytics/AnalyticsTimer';
import { AudioSinkManager } from '../audio-sink-manager';
import { PluginUsageTracker } from '../common/PluginUsageTracker';
import { DeviceManager } from '../device-manager';
import { AudioOutputManager } from '../device-manager/AudioOutputManager';
import { DeviceStorageManager } from '../device-manager/DeviceStorage';
import { HMSDiagnosticsConnectivityListener } from '../diagnostics/interfaces';
import { FeedbackService, HMSSessionFeedback, HMSSessionInfo } from '../end-call-feedback';
import { ErrorCodes } from '../error/ErrorCodes';
import { ErrorFactory } from '../error/ErrorFactory';
import { HMSAction } from '../error/HMSAction';
import { HMSException } from '../error/HMSException';
import { EventBus } from '../events/EventBus';
import {
HMSAudioCodec,
HMSChangeMultiTrackStateParams,
HMSConfig,
HMSConnectionQualityListener,
HMSDeviceChangeEvent,
HMSFrameworkInfo,
HMSMessageInput,
HMSPeerType,
HMSPlaylistSettings,
HMSPlaylistType,
HMSPreviewConfig,
HMSRole,
HMSRoleChangeRequest,
HMSScreenShareConfig,
HMSVideoCodec,
TokenRequest,
TokenRequestOptions,
} from '../interfaces';
import { DeviceChangeListener } from '../interfaces/devices';
import { IErrorListener } from '../interfaces/error-listener';
import { HLSConfig, HLSTimedMetadata, StopHLSConfig } from '../interfaces/hls-config';
import { HMSInterface } from '../interfaces/hms';
import { HMSLeaveRoomRequest } from '../interfaces/leave-room-request';
import { HMSPeerListIteratorOptions } from '../interfaces/peer-list-iterator';
import { HMSPreviewListener } from '../interfaces/preview-listener';
import { RTMPRecordingConfig } from '../interfaces/rtmp-recording-config';
import InitialSettings from '../interfaces/settings';
import { HMSAudioListener, HMSPeerUpdate, HMSTrackUpdate, HMSUpdateListener } from '../interfaces/update-listener';
import { PlaylistManager, TranscriptionConfig } from '../internal';
import { HMSAudioTrackSettingsBuilder, HMSVideoTrackSettingsBuilder } from '../media/settings';
import { HMSLocalStream } from '../media/streams/HMSLocalStream';
import {
HMSLocalAudioTrack,
HMSLocalTrack,
HMSLocalVideoTrack,
HMSRemoteTrack,
HMSTrackSource,
HMSTrackType,
HMSVideoTrack,
} from '../media/tracks';
import {
HMSNotificationMethod,
PeerLeaveRequestNotification,
PeerNotificationInfo,
SendMessage,
} from '../notification-manager';
import { createRemotePeer } from '../notification-manager/managers/utils';
import { NotificationManager } from '../notification-manager/NotificationManager';
import { DebugInfo } from '../schema';
import { SessionStore } from '../session-store';
import { InteractivityCenter } from '../session-store/interactivity-center';
import { InitConfig, InitFlags } from '../signal/init/models';
import {
FindPeerByNameRequestParams,
HLSRequestParams,
HLSTimedMetadataParams,
HLSVariant,
StartRTMPOrRecordingRequestParams,
StartTranscriptionRequestParams,
} from '../signal/interfaces';
import HMSTransport from '../transport';
import ITransportObserver from '../transport/ITransportObserver';
import { TransportState } from '../transport/models/TransportState';
import { getAnalyticsDeviceId } from '../utils/analytics-deviceId';
import {
DEFAULT_PLAYLIST_AUDIO_BITRATE,
DEFAULT_PLAYLIST_VIDEO_BITRATE,
HAND_RAISE_GROUP_NAME,
LEAVE_REASON,
} from '../utils/constants';
import { fetchWithRetry } from '../utils/fetch';
import decodeJWT from '../utils/jwt';
import HMSLogger, { HMSLogLevel } from '../utils/logger';
import { HMSAudioContextHandler } from '../utils/media';
import { isNode } from '../utils/support';
import { workerSleep } from '../utils/timer-utils';
import { validateMediaDevicesExistence, validatePublishParams, validateRTCPeerConnection } from '../utils/validations';
const INITIAL_STATE = {
published: false,
isInitialised: false,
isReconnecting: false,
isPreviewInProgress: false,
isPreviewCalled: false,
isJoinInProgress: false,
deviceManagersInitialised: false,
};
export class HMSSdk implements HMSInterface {
private transport!: HMSTransport;
private readonly TAG = '[HMSSdk]:';
public listener?: HMSUpdateListener;
private errorListener?: IErrorListener;
private deviceChangeListener?: DeviceChangeListener;
private audioListener?: HMSAudioListener;
public store!: Store;
private notificationManager?: NotificationManager;
/** @internal */
public deviceManager!: DeviceManager;
private audioSinkManager!: AudioSinkManager;
private playlistManager!: PlaylistManager;
private audioOutput!: AudioOutputManager;
private transportState: TransportState = TransportState.Disconnected;
private roleChangeManager?: RoleChangeManager;
/** @internal */
public localTrackManager!: LocalTrackManager;
private analyticsEventsService!: AnalyticsEventsService;
private analyticsTimer = new AnalyticsTimer();
private eventBus!: EventBus;
private networkTestManager!: NetworkTestManager;
private wakeLockManager!: WakeLockManager;
private sessionStore!: SessionStore;
private interactivityCenter!: InteractivityCenter;
private pluginUsageTracker!: PluginUsageTracker;
private sdkState = { ...INITIAL_STATE };
private frameworkInfo?: HMSFrameworkInfo;
private isDiagnostics = false;
/**
* will be set post join
* this will not be reset on leave but after feedback success
* we will just clean token after successful submit feedback
* will be replaced when a newer join happens.
*/
private sessionPeerInfo?: HMSSessionInfo;
private playlistSettings: HMSPlaylistSettings = {
video: {
bitrate: DEFAULT_PLAYLIST_VIDEO_BITRATE,
},
audio: {
bitrate: DEFAULT_PLAYLIST_AUDIO_BITRATE,
},
};
private setSessionPeerInfo(websocketURL: string, peer?: HMSLocalPeer) {
const room = this.store.getRoom();
if (!peer || !room) {
HMSLogger.e(this.TAG, 'setSessionPeerInfo> Local peer or room is undefined');
return;
}
this.sessionPeerInfo = {
peer: {
peer_id: peer.peerId,
role: peer.role?.name,
joined_at: peer.joinedAt?.valueOf() || 0,
room_name: room.name,
session_started_at: room.startedAt?.valueOf() || 0,
user_data: peer.customerUserId,
user_name: peer.name,
template_id: room.templateId,
session_id: room.sessionId,
token: this.store.getConfig()?.authToken,
},
agent: this.store.getUserAgent(),
device_id: getAnalyticsDeviceId(),
cluster: {
websocket_url: websocketURL,
},
timestamp: Date.now(),
};
}
private initNotificationManager() {
if (!this.notificationManager) {
this.notificationManager = new NotificationManager(
this.store,
this.eventBus,
this.transport!,
this.listener,
this.audioListener,
);
}
}
/** @internal */
initStoreAndManagers(listener: HMSPreviewListener | HMSUpdateListener | HMSDiagnosticsConnectivityListener) {
this.listener = listener as unknown as HMSUpdateListener;
this.errorListener = listener;
this.deviceChangeListener = listener;
this.store?.setErrorListener(this.errorListener);
if (this.sdkState.isInitialised) {
/**
* Set listener after both join and preview, since they can have different listeners
*/
this.notificationManager?.setListener(this.listener);
this.audioSinkManager.setListener(this.listener);
this.interactivityCenter.setListener(this.listener);
this.transport.setListener(this.listener);
return;
}
this.sdkState.isInitialised = true;
this.store = new Store();
this.store.setErrorListener(this.errorListener);
this.eventBus = new EventBus();
this.pluginUsageTracker = new PluginUsageTracker(this.eventBus);
this.wakeLockManager = new WakeLockManager();
this.networkTestManager = new NetworkTestManager(this.eventBus, this.listener);
this.playlistManager = new PlaylistManager(this, this.eventBus);
this.deviceManager = new DeviceManager(this.store, this.eventBus);
this.audioSinkManager = new AudioSinkManager(this.store, this.deviceManager, this.eventBus);
this.audioOutput = new AudioOutputManager(this.deviceManager, this.audioSinkManager);
this.audioSinkManager.setListener(this.listener);
this.eventBus.autoplayError.subscribe(this.handleAutoplayError);
this.localTrackManager = new LocalTrackManager(
this.store,
this.observer,
this.deviceManager,
this.eventBus,
this.analyticsTimer,
);
this.analyticsEventsService = new AnalyticsEventsService(this.store);
this.transport = new HMSTransport(
this.observer,
this.deviceManager,
this.store,
this.eventBus,
this.analyticsEventsService,
this.analyticsTimer,
this.pluginUsageTracker,
);
// add diagnostics callbacks if present
if ('onInitSuccess' in listener) {
this.transport.setConnectivityListener(listener);
}
this.sessionStore = new SessionStore(this.transport);
this.interactivityCenter = new InteractivityCenter(this.transport, this.store, this.listener);
/**
* Note: Subscribe to events here right after creating stores and managers
* to not miss events that are published before the handlers are subscribed.
*/
this.eventBus.analytics.subscribe(this.sendAnalyticsEvent);
this.eventBus.deviceChange.subscribe(this.handleDeviceChange);
this.eventBus.localVideoUnmutedNatively.subscribe(this.unpauseRemoteVideoTracks);
this.eventBus.localAudioUnmutedNatively.subscribe(this.unpauseRemoteVideoTracks);
this.eventBus.audioPluginFailed.subscribe(this.handleAudioPluginError);
this.eventBus.error.subscribe(this.handleError);
}
private validateJoined(name: string) {
if (!this.localPeer) {
throw ErrorFactory.GenericErrors.NotConnected(HMSAction.VALIDATION, `Not connected - ${name}`);
}
}
// @ts-ignore
private sendHLSAnalytics(error: HMSException) {
this.sendAnalyticsEvent(AnalyticsEventFactory.hlsPlayerError(error));
}
async refreshDevices() {
this.validateJoined('refreshDevices');
await this.deviceManager.init(true);
}
getWebrtcInternals() {
return this.transport?.getWebrtcInternals();
}
getDebugInfo(): DebugInfo | undefined {
if (!this.transport) {
HMSLogger.e(this.TAG, `Transport is not defined`);
throw new Error('getDebugInfo can only be called after join');
}
const websocketURL = this.transport.getWebsocketEndpoint();
const enabledFlags = Object.values(InitFlags).filter(flag => this.transport.isFlagEnabled(flag));
const initEndpoint = this.store.getConfig()?.initEndpoint;
return {
websocketURL,
enabledFlags,
initEndpoint,
};
}
getSessionStore() {
return this.sessionStore;
}
getPlaylistManager(): PlaylistManager {
return this.playlistManager;
}
getRecordingState() {
return this.store.getRoom()?.recording;
}
getRTMPState() {
return this.store.getRoom()?.rtmp;
}
getHLSState() {
return this.store.getRoom()?.hls;
}
getTranscriptionState() {
return this.store.getRoom()?.transcriptions;
}
getTemplateAppData() {
return this.store.getTemplateAppData();
}
getInteractivityCenter() {
return this.interactivityCenter;
}
getPeerListIterator(options?: HMSPeerListIteratorOptions) {
return new HMSPeerListIterator(this.transport, this.store, options);
}
updatePlaylistSettings(options: HMSPlaylistSettings) {
if (options.video) {
Object.assign(this.playlistSettings.video, options.video);
}
if (options.audio) {
Object.assign(this.playlistSettings.audio, options.audio);
}
}
private handleAutoplayError = (error: HMSException) => {
this.errorListener?.onError?.(error);
};
private get localPeer(): HMSLocalPeer | undefined {
return this.store?.getLocalPeer();
}
private observer: ITransportObserver = {
onNotification: (message: any) => {
if (message.method === HMSNotificationMethod.PEER_LEAVE_REQUEST) {
this.handlePeerLeaveRequest(message.params as PeerLeaveRequestNotification);
return;
}
switch (message.method) {
case HMSNotificationMethod.POLICY_CHANGE:
this.analyticsTimer.end(TimedEvent.ON_POLICY_CHANGE);
break;
case HMSNotificationMethod.PEER_LIST:
this.analyticsTimer.end(TimedEvent.PEER_LIST);
this.sendJoinAnalyticsEvent(this.sdkState.isPreviewCalled);
break;
case HMSNotificationMethod.ROOM_STATE:
this.analyticsTimer.end(TimedEvent.ROOM_STATE);
break;
default:
}
this.notificationManager?.handleNotification(message, this.sdkState.isReconnecting);
},
onConnected: () => {
this.initNotificationManager();
},
onTrackAdd: (track: HMSRemoteTrack) => {
this.notificationManager?.handleTrackAdd(track);
},
onTrackRemove: (track: HMSRemoteTrack) => {
this.notificationManager?.handleTrackRemove(track);
},
onFailure: (exception: HMSException) => {
this.errorListener?.onError(exception);
},
onStateChange: async (state: TransportState, error?: HMSException) => {
const handleFailedState = async (error?: HMSException) => {
await this.internalLeave(true, error);
/**
* no need to call onError here when preview/join is in progress
* since preview/join will call onError when they receive leave event from the above call
*/
if (!this.sdkState.isPreviewInProgress && !this.sdkState.isJoinInProgress) {
this.errorListener?.onError?.(error!);
}
this.sdkState.isReconnecting = false;
};
switch (state) {
case TransportState.Preview:
case TransportState.Joined:
this.initNotificationManager();
if (this.transportState === TransportState.Reconnecting) {
this.listener?.onReconnected();
}
break;
case TransportState.Failed:
await handleFailedState(error);
break;
case TransportState.Reconnecting:
this.sdkState.isReconnecting = true;
this.listener?.onReconnecting(error!);
break;
}
this.transportState = state;
HMSLogger.d(this.TAG, 'Transport State Change', this.transportState);
},
};
private handlePeerLeaveRequest = (message: PeerLeaveRequestNotification) => {
const peer = message.requested_by ? this.store.getPeerById(message.requested_by) : undefined;
const request: HMSLeaveRoomRequest = {
roomEnded: message.room_end,
reason: message.reason,
requestedBy: peer,
};
this.listener?.onRemovedFromRoom(request);
this.internalLeave(false);
};
async preview(config: HMSPreviewConfig, listener: HMSPreviewListener) {
validateMediaDevicesExistence();
validateRTCPeerConnection();
if (this.sdkState.isPreviewInProgress) {
return Promise.reject(
ErrorFactory.GenericErrors.PreviewAlreadyInProgress(HMSAction.PREVIEW, 'Preview already called'),
);
}
if ([TransportState.Joined, TransportState.Reconnecting].includes(this.transportState)) {
return this.midCallPreview(config.asRole, config.settings);
}
this.analyticsTimer.start(TimedEvent.PREVIEW);
this.setUpPreview(config, listener);
let initSuccessful = false;
let networkTestFinished = false;
const timerId = setTimeout(() => {
// If init or network is not done by 3s send -1
if (!initSuccessful || !networkTestFinished) {
this.listener?.onNetworkQuality?.(-1);
}
}, 3000);
return new Promise<void>((resolve, reject) => {
const policyHandler = async () => {
if (this.localPeer) {
const newRole = config.asRole && this.store.getPolicyForRole(config.asRole);
this.localPeer.asRole = newRole || this.localPeer.role;
}
const tracks = await this.localTrackManager.getTracksToPublish(config.settings);
tracks.forEach(track => {
this.setLocalPeerTrack(track);
if (track.isTrackNotPublishing()) {
const error = ErrorFactory.TracksErrors.NoDataInTrack(
`${track.type} track has no data. muted: ${track.nativeTrack.muted}, readyState: ${track.nativeTrack.readyState}`,
);
HMSLogger.e(this.TAG, error);
this.sendAnalyticsEvent(
AnalyticsEventFactory.publish({
devices: this.deviceManager.getDevices(),
error: error,
}),
);
this.listener?.onError(error);
}
});
this.localPeer?.audioTrack && this.initPreviewTrackAudioLevelMonitor();
await this.initDeviceManagers();
this.sdkState.isPreviewInProgress = false;
this.analyticsTimer.end(TimedEvent.PREVIEW);
const room = this.store.getRoom();
if (room) {
listener.onPreview(room, tracks);
}
this.sendPreviewAnalyticsEvent();
resolve();
};
this.eventBus.policyChange.subscribeOnce(policyHandler);
this.eventBus.leave.subscribeOnce(this.handlePreviewError);
this.eventBus.leave.subscribeOnce(ex => reject(ex as HMSException));
this.transport
.preview(
config.authToken,
config.initEndpoint!,
this.localPeer!.peerId,
{ name: config.userName, metaData: config.metaData || '' },
config.autoVideoSubscribe,
config.iceServers,
)
.then((initConfig: InitConfig | void) => {
initSuccessful = true;
clearTimeout(timerId);
if (initConfig && config.captureNetworkQualityInPreview) {
this.networkTestManager.start(initConfig.config?.networkHealth).then(() => {
networkTestFinished = true;
});
}
})
.catch(ex => {
this.handlePreviewError(ex);
reject(ex);
});
});
}
private handlePreviewError = (ex?: HMSException) => {
this.analyticsTimer.end(TimedEvent.PREVIEW);
ex && this.errorListener?.onError(ex);
this.sendPreviewAnalyticsEvent(ex);
this.sdkState.isPreviewInProgress = false;
};
private async midCallPreview(asRole?: string, settings?: InitialSettings): Promise<void> {
if (!this.localPeer || this.transportState !== TransportState.Joined) {
throw ErrorFactory.GenericErrors.NotConnected(HMSAction.VALIDATION, 'Not connected - midCallPreview');
}
const newRole = asRole && this.store.getPolicyForRole(asRole);
if (!newRole) {
throw ErrorFactory.GenericErrors.InvalidRole(HMSAction.PREVIEW, `role ${asRole} does not exist in policy`);
}
this.localPeer.asRole = newRole;
const tracks = await this.localTrackManager.getTracksToPublish(settings);
tracks.forEach(track => this.setLocalPeerTrack(track));
this.localPeer?.audioTrack && this.initPreviewTrackAudioLevelMonitor();
await this.initDeviceManagers();
this.listener?.onPreview(this.store.getRoom()!, tracks);
}
async cancelMidCallPreview() {
if (!this.localPeer || !this.localPeer.isInPreview()) {
HMSLogger.w(this.TAG, 'Cannot cancel mid call preview as preview is not in progress');
}
if (this.localPeer?.asRole && this.localPeer.role) {
const oldRole = this.localPeer.asRole;
const newRole = this.localPeer.role;
delete this.localPeer.asRole;
await this.roleChangeManager?.diffRolesAndPublishTracks({
oldRole,
newRole,
});
this.listener?.onPeerUpdate(HMSPeerUpdate.ROLE_UPDATED, this.localPeer);
}
}
private handleDeviceChange = (event: HMSDeviceChangeEvent) => {
if (event.isUserSelection) {
return;
}
HMSLogger.d(this.TAG, 'Device Change event', event);
this.deviceChangeListener?.onDeviceChange?.(event);
const disableTrackOnError = () => {
if (event.error && event.type) {
const track = event.type.includes('audio') ? this.localPeer?.audioTrack : this.localPeer?.videoTrack;
this.errorListener?.onError(event.error);
if (
[
ErrorCodes.TracksErrors.CANT_ACCESS_CAPTURE_DEVICE,
ErrorCodes.TracksErrors.DEVICE_IN_USE,
ErrorCodes.TracksErrors.DEVICE_NOT_AVAILABLE,
].includes(event.error.code) &&
track
) {
track.setEnabled(false);
this.listener?.onTrackUpdate(HMSTrackUpdate.TRACK_MUTED, track, this.localPeer!);
}
}
};
disableTrackOnError();
};
private handleAudioPluginError = (error: HMSException) => {
HMSLogger.e(this.TAG, 'Audio Plugin Error event', error);
this.errorListener?.onError(error);
};
/**
* This is to handle errors thrown from internal handling of audio video track changes
* For example, handling visibility change and making a new gum can throw an error which is currently
* unhandled. This will notify the app of the error.
* @param {HMSException} error
*/
private handleError = (error: HMSException) => {
HMSLogger.e(this.TAG, error);
this.errorListener?.onError(error);
};
// eslint-disable-next-line complexity
async join(config: HMSConfig, listener: HMSUpdateListener) {
validateMediaDevicesExistence();
validateRTCPeerConnection();
if (this.sdkState.isPreviewInProgress) {
throw ErrorFactory.GenericErrors.NotReady(HMSAction.JOIN, "Preview is in progress, can't join");
}
// remove terminal error handling from preview(do not send preview.failed after join on disconnect)
this.eventBus?.leave?.unsubscribe(this.handlePreviewError);
this.analyticsTimer.start(TimedEvent.JOIN);
this.sdkState.isJoinInProgress = true;
const { roomId, userId, role } = decodeJWT(config.authToken);
const previewRole = this.localPeer?.asRole?.name || this.localPeer?.role?.name;
this.networkTestManager?.stop();
this.commonSetup(config, roomId, listener);
this.removeDevicesFromConfig(config);
this.store.setConfig(config);
/** set after config since we need config to get env for user agent */
this.store.createAndSetUserAgent(this.frameworkInfo);
HMSAudioContextHandler.resumeContext();
// acquire screen lock to stay awake while in call
const storeConfig = this.store.getConfig();
if (storeConfig?.autoManageWakeLock) {
this.wakeLockManager.acquireLock();
}
if (!this.localPeer) {
this.createAndAddLocalPeerToStore(config, role, userId);
} else {
this.localPeer.name = config.userName;
this.localPeer.role = this.store.getPolicyForRole(role);
this.localPeer.customerUserId = userId;
this.localPeer.metadata = config.metaData;
delete this.localPeer.asRole;
}
this.roleChangeManager = new RoleChangeManager(
this.store,
this.transport,
this.deviceManager,
this.getAndPublishTracks.bind(this),
this.removeTrack.bind(this),
this.listener,
);
this.eventBus.localRoleUpdate.subscribe(this.handleLocalRoleUpdate);
HMSLogger.d(this.TAG, `⏳ Joining room ${roomId}`);
HMSLogger.time(`join-room-${roomId}`);
try {
await this.transport.join(
config.authToken,
this.localPeer!.peerId,
{ name: config.userName, metaData: config.metaData! },
config.initEndpoint!,
config.autoVideoSubscribe,
config.iceServers,
);
HMSLogger.d(this.TAG, `✅ Joined room ${roomId}`);
this.analyticsTimer.start(TimedEvent.PEER_LIST);
await this.notifyJoin();
this.sdkState.isJoinInProgress = false;
await this.publish(config.settings, previewRole);
} catch (error) {
this.analyticsTimer.end(TimedEvent.JOIN);
this.sdkState.isJoinInProgress = false;
this.listener?.onError(error as HMSException);
this.sendJoinAnalyticsEvent(this.sdkState.isPreviewCalled, error as HMSException);
HMSLogger.e(this.TAG, 'Unable to join room', error);
throw error;
}
HMSLogger.timeEnd(`join-room-${roomId}`);
}
private stringifyMetadata(config: HMSConfig) {
if (config.metaData && typeof config.metaData !== 'string') {
config.metaData = JSON.stringify(config.metaData);
} else if (!config.metaData) {
config.metaData = '';
}
}
private cleanup() {
this.cleanDeviceManagers();
this.eventBus.analytics.unsubscribe(this.sendAnalyticsEvent);
this.eventBus.localVideoUnmutedNatively.unsubscribe(this.unpauseRemoteVideoTracks);
this.eventBus.localAudioUnmutedNatively.unsubscribe(this.unpauseRemoteVideoTracks);
this.eventBus.error.unsubscribe(this.handleError);
this.analyticsTimer.cleanup();
DeviceStorageManager.cleanup();
this.playlistManager.cleanup();
this.wakeLockManager?.cleanup();
LocalTrackManager.cleanup();
this.notificationManager = undefined;
HMSLogger.cleanup();
this.sdkState = { ...INITIAL_STATE };
/**
* when leave is called after preview itself without join.
* Store won't have the tracks in this case
*/
if (this.localPeer) {
this.localPeer.audioTrack?.cleanup();
this.localPeer.audioTrack = undefined;
this.localPeer.videoTrack?.cleanup();
this.localPeer.videoTrack = undefined;
}
this.store.cleanup();
this.listener = undefined;
if (this.roleChangeManager) {
this.eventBus.localRoleUpdate.unsubscribe(this.handleLocalRoleUpdate);
}
}
leave(notifyServer?: boolean) {
return this.internalLeave(notifyServer);
}
// eslint-disable-next-line complexity
private async internalLeave(notifyServer = true, error?: HMSException) {
const room = this.store?.getRoom();
if (room) {
// Wait for preview or join to finish to prevent any race conditions happening because preview/join are called multiple times
// This can happen when useEffects are not properly handled in case of react apps
// when error is terminal this will go into infinite loop so error?.isTerminal check is needed
while ((this.sdkState.isPreviewInProgress || this.sdkState.isJoinInProgress) && !error?.isTerminal) {
await workerSleep(100);
}
const roomId = room.id;
// setSessionJoin
this.setSessionPeerInfo(this.transport.getWebsocketEndpoint() || '', this.localPeer);
this.networkTestManager?.stop();
this.eventBus.leave.publish(error);
const peerId = this.localPeer?.peerId;
HMSLogger.d(this.TAG, `⏳ Leaving room ${roomId}, peerId=${peerId}`);
// browsers often put limitation on amount of time a function set on window onBeforeUnload can take in case of
// tab refresh or close. Therefore prioritise the leave action over anything else, if tab is closed/refreshed
// we would want leave to succeed to stop stucked peer for others. The followup cleanup however is important
// for cases where uses stays on the page post leave.
await this.transport?.leave(notifyServer, error ? LEAVE_REASON.SDK_REQUEST : LEAVE_REASON.USER_REQUEST);
this.cleanup();
HMSLogger.d(this.TAG, `✅ Left room ${roomId}, peerId=${peerId}`);
}
}
async getAuthTokenByRoomCode(tokenRequest: TokenRequest, tokenRequestOptions?: TokenRequestOptions): Promise<string> {
const tokenAPIURL = (tokenRequestOptions || {}).endpoint || 'https://auth.100ms.live/v2/token';
this.analyticsTimer.start(TimedEvent.GET_TOKEN);
const response = await fetchWithRetry(
tokenAPIURL,
{
method: 'POST',
body: JSON.stringify({ code: tokenRequest.roomCode, user_id: tokenRequest.userId }),
},
[429, 500, 501, 502, 503, 504, 505, 506, 507, 508, 509, 510, 511],
);
const data = await response.json();
this.analyticsTimer.end(TimedEvent.GET_TOKEN);
if (!response.ok) {
throw ErrorFactory.APIErrors.ServerErrors(data.code, HMSAction.GET_TOKEN, data.message, false);
}
const { token } = data;
if (!token) {
throw Error(data.message);
}
return token;
}
getLocalPeer() {
return this.store.getLocalPeer();
}
getPeers() {
return this.store.getPeers();
}
getPeerMap() {
return this.store.getPeerMap();
}
getAudioOutput() {
return this.audioOutput;
}
sendMessage(type: string, message: string) {
this.sendMessageInternal({ message, type });
}
async sendBroadcastMessage(message: string, type?: string) {
return await this.sendMessageInternal({ message, type });
}
async sendGroupMessage(message: string, roles: HMSRole[], type?: string) {
const knownRoles = this.store.getKnownRoles();
const recipientRoles =
roles.filter(role => {
return knownRoles[role.name];
}) || [];
if (recipientRoles.length === 0) {
throw ErrorFactory.GenericErrors.ValidationFailed('No valid role is present', roles);
}
return await this.sendMessageInternal({ message, recipientRoles: roles, type });
}
async sendDirectMessage(message: string, peerId: string, type?: string) {
if (this.localPeer?.peerId === peerId) {
throw ErrorFactory.GenericErrors.ValidationFailed('Cannot send message to self');
}
const isLargeRoom = !!this.store.getRoom()?.large_room_optimization;
let recipientPeer = this.store.getPeerById(peerId);
if (!recipientPeer) {
if (isLargeRoom) {
const peer = await this.transport.signal.getPeer({ peer_id: peerId });
if (!peer) {
throw ErrorFactory.GenericErrors.ValidationFailed('Invalid peer - peer not present in the room', peerId);
}
recipientPeer = createRemotePeer(peer, this.store);
} else {
throw ErrorFactory.GenericErrors.ValidationFailed('Invalid peer - peer not present in the room', peerId);
}
}
return await this.sendMessageInternal({ message, recipientPeer, type });
}
async submitSessionFeedback(feedback: HMSSessionFeedback, eventEndpoint?: string) {
if (!this.sessionPeerInfo) {
HMSLogger.e(this.TAG, 'submitSessionFeedback> session is undefined');
throw new Error('session is undefined');
}
const token = this.sessionPeerInfo.peer.token;
if (!token) {
HMSLogger.e(this.TAG, 'submitSessionFeedback> token is undefined');
throw new Error('Internal error, token is not present');
}
try {
await FeedbackService.sendFeedback({
token: token,
info: this.sessionPeerInfo,
feedback,
eventEndpoint,
});
HMSLogger.i(this.TAG, 'submitSessionFeedback> submitted feedback');
this.sessionPeerInfo = undefined;
} catch (e) {
HMSLogger.e(this.TAG, 'submitSessionFeedback> error occured ', e);
throw new Error('Unable to submit feedback');
}
}
async getPeer(peerId: string) {
const response = await this.transport.signal.getPeer({ peer_id: peerId });
if (response) {
return createRemotePeer(response, this.store);
}
return undefined;
}
async findPeerByName({ query, limit = 10, offset }: FindPeerByNameRequestParams) {
const {
peers,
offset: responseOffset,
eof,
} = await this.transport.signal.findPeerByName({ query: query?.toLowerCase(), limit, offset });
if (peers.length > 0) {
return {
offset: responseOffset,
eof,
peers: peers.map(peerInfo => {
return createRemotePeer(
{
peer_id: peerInfo.peer_id,
role: peerInfo.role,
groups: [],
info: {
name: peerInfo.name,
data: '',
user_id: '',
type: peerInfo.type,
},
} as PeerNotificationInfo,
this.store,
);
}),
};
}
return { offset: responseOffset, peers: [] };
}
private async sendMessageInternal({ recipientRoles, recipientPeer, type = 'chat', message }: HMSMessageInput) {
if (message.replace(/\u200b/g, ' ').trim() === '') {
HMSLogger.w(this.TAG, 'sendMessage', 'Ignoring empty message send');
throw ErrorFactory.GenericErrors.ValidationFailed('Empty message not allowed');
}
const sendParams: SendMessage = {
info: {
message,
type,
},
};
if (recipientRoles?.length) {
sendParams.roles = recipientRoles.map(role => role.name);
}
if (recipientPeer?.peerId) {
sendParams.peer_id = recipientPeer.peerId;
}
HMSLogger.d(this.TAG, 'Sending Message: ', sendParams);
return await this.transport.signal.broadcast(sendParams);
}
async startScreenShare(onStop: () => void, config?: HMSScreenShareConfig) {
const publishParams = this.store.getPublishParams();
if (!publishParams) {
return;
}
const { allowed } = publishParams;
const canPublishScreen = allowed && allowed.includes('screen');
if (!canPublishScreen) {
HMSLogger.e(this.TAG, `Role ${this.localPeer?.role} cannot share screen`);
return;
}
if (this.localPeer?.auxiliaryTracks?.find(track => track.source === 'screen')) {
throw Error('Cannot share multiple screens');
}
const tracks = await this.getScreenshareTracks(onStop, config);
if (!this.localPeer) {
HMSLogger.d(this.TAG, 'Screenshared when not connected');
tracks.forEach(track => {
track.cleanup();
});
return;
}
this.transport.setOnScreenshareStop(() => {
this.stopEndedScreenshare(onStop);
});
await this.transport.publish(tracks);
tracks.forEach(track => {
track.peerId = this.localPeer?.peerId;
this.localPeer?.auxiliaryTracks.push(track);
this.listener?.onTrackUpdate(HMSTrackUpdate.TRACK_ADDED, track, this.localPeer!);
});
}
private async stopEndedScreenshare(onStop: () => void) {
HMSLogger.d(this.TAG, `✅ Screenshare ended natively`);
await this.stopScreenShare();
onStop();
}
async stopScreenShare() {
HMSLogger.d(this.TAG, `✅ Screenshare ended from app`);
const screenTracks = this.localPeer?.auxiliaryTracks.filter(t => t.source === 'screen');
if (screenTracks) {
for (const track of screenTracks) {
await this.removeTrack(track.trackId);
}
}
}
async addTrack(track: MediaStreamTrack, source: HMSTrackSource = 'regular'): Promise<void> {
if (!track) {
HMSLogger.w(this.TAG, 'Please pass a valid MediaStreamTrack');
return;
}
if (!this.localPeer) {
throw ErrorFactory.GenericErrors.NotConnected(HMSAction.VALIDATION, 'No local peer present, cannot addTrack');
}
const isTrackPresent = this.localPeer.auxiliaryTracks.find(t => t.trackId === track.id);
if (isTrackPresent) {
return;
}
const type = track.kind;
const nativeStream = new MediaStream([track]);
const stream = new HMSLocalStream(nativeStream);
const TrackKlass = type === 'audio' ? HMSLocalAudioTrack : HMSLocalVideoTrack;
const hmsTrack = new TrackKlass(stream, track, source, this.eventBus);
await this.applySettings(hmsTrack);
await this.setPlaylistSettings({
track,
hmsTrack,
source,
});
await this.transport?.publish([hmsTrack]);
hmsTrack.peerId = this.localPeer?.peerId;
this.localPeer?.auxiliaryTracks.push(hmsTrack);
this.listener?.onTrackUpdate(HMSTrackUpdate.TRACK_ADDED, hmsTrack, this.localPeer!);
}
async removeTrack(trackId: string, internal = false) {
if (!this.localPeer) {
throw ErrorFactory.GenericErrors.NotConnected(HMSAction.VALIDATION, 'No local peer present, cannot removeTrack');
}
const trackIndex = this.localPeer.auxiliaryTracks.findIndex(t => t.trackId === trackId);
if (trackIndex > -1) {
const track = this.localPeer.auxiliaryTracks[trackIndex];
if (track.isPublished) {
await this.transport!.unpublish([track]);
} else {
await track.cleanup();
}
// Stop local playback when playlist track is removed
if (!internal) {
this.stopPlaylist(track);
}
this.localPeer.auxiliaryTracks.splice(trackIndex, 1);
this.listener?.onTrackUpdate(HMSTrackUpdate.TRACK_REMOVED, track, this.localPeer);
} else {
HMSLogger.w(this.TAG, `No track found for ${trackId}`);
}
}
setAnalyticsLevel(level: HMSAnalyticsLevel) {
this.analyticsEventsService.level = level;
}
setLogLevel(level: HMSLogLevel) {
HMSLogger.level = level;
}
autoSelectAudioOutput(delay?: number) {
this.deviceManager?.autoSelectAudioOutput(delay);
}
addAudioListener(audioListener: HMSAudioListener) {
this.audioListener = audioListener;
this.notificationManager?.setAudioListener(audioListener);
}
addConnectionQualityListener(qualityListener: HMSConnectionQualityListener) {
this.notificationManager?.setConnectionQualityListener(qualityListener);
}
/** @internal */
setIsDiagnostics(isDiagnostics: boolean) {
this.isDiagnostics = isDiagnostics;
}
async changeRole(forPeerId: string, toRole: string, force = false) {
await this.transport?.signal.requestRoleChange({
requested_for: forPeerId,
role: toRole,
force,
});
}
async changeRoleOfPeer(forPeerId: string, toRole: string, force = false) {
await this.transport?.signal.requestRoleChange({
requested_for: forPeerId,
role: toRole,
force,
});
}
async changeRoleOfPeersWithRoles(roles: HMSRole[], toRole: string) {
if (roles.length <= 0 || !toRole) {
return;
}
await this.transport?.signal.requestBulkRoleChange({
roles: roles.map((role: HMSRole) => role.name),
role: toRole,
force: true,
});
}
async acceptChangeRole(request: HMSRoleChangeRequest) {
await this.transport?.signal.acceptRoleChangeRequest({
requested_by: request.requestedBy?.peerId,
role: request.role.name,
token: request.token,
});
}
async endRoom(lock: boolean, reason: string) {
if (!this.localPeer) {
throw ErrorFactory.GenericErrors.NotConnected(HMSAction.VALIDATION, 'No local peer present, cannot end room');
}
await this.transport?.signal.endRoom(lock, reason);
await this.leave();
}
async removePeer(peerId: string, reason: string) {
if (!this.localPeer) {
throw ErrorFactory.GenericErrors.NotConnected(HMSAction.VALIDATION, 'No local peer present, cannot remove peer');
}
await this.transport?.signal.removePeer({ requested_for: peerId, reason });
}
async startRTMPOrRecording(params: RTMPRecordingConfig) {
if (!this.localPeer) {
throw ErrorFactory.GenericErrors.NotConnected(
HMSAction.VALIDATION,
'No local peer present, cannot start streaming or recording',
);
}
const signalParams: StartRTMPOrRecordingRequestParams = {
meeting_url: params.meetingURL,
record: params.record,
};
if (params.rtmpURLs?.length) {
signalParams.rtmp_urls = params.rtmpURLs;
}
if (params.resolution) {
signalParams.resolution = params.resolution;
}
await this.transport?.signal.startRTMPOrRecording(signalParams);
}
async stopRTMPAndRecording() {
if (!this.localPeer) {
throw ErrorFactory.GenericErrors.NotConnected(
HMSAction.VALIDATION,
'No local peer present, cannot stop streaming or recording',
);
}
await this.transport?.signal.stopRTMPAndRecording();
}
async startHLSStreaming(params?: HLSConfig) {
if (!this.localPeer) {
throw ErrorFactory.GenericErrors.NotConnected(
HMSAction.VALIDATION,
'No local peer present, cannot start HLS streaming',
);
}
const hlsParams: HLSRequestParams = {};
if (params && params.variants && params.variants.length > 0) {
hlsParams.variants = params.variants.map(variant => {
const hlsVariant: HLSVariant = { meeting_url: variant.meetingURL };
if (variant.metadata) {
hlsVariant.metadata = variant.metadata;
}
return hlsVariant;
});
}
if (params?.recording) {
hlsParams.hls_recording = {
single_file_per_layer: params.recording.singleFilePerLayer,
hls_vod: params.recording.hlsVod,
};
}
await this.transport?.signal.startHLSStreaming(hlsParams);
}
async stopHLSStreaming(params?: StopHLSConfig) {
if (!this.localPeer) {
throw ErrorFactory.GenericErrors.NotConnected(
HMSAction.VALIDATION,
'No local peer present, cannot stop HLS streaming',
);
}
if (params) {
const hlsParams: HLSRequestParams = {
variants: params?.variants?.map(variant => {
const hlsVariant: HLSVariant = { meeting_url: variant.meetingURL };
if (variant.metadata) {
hlsVariant.metadata = variant.metadata;
}
return hlsVariant;
}),
stop_reason: params.stop_reason,
};
await this.transport?.signal.stopHLSStreaming(hlsParams);
} else {
await this.transport?.signal.stopHLSStreaming();
}
}
async startTranscription(params: TranscriptionConfig) {
if (!this.localPeer) {
throw ErrorFactory.GenericErrors.NotConnected(
HMSAction.VALIDATION,
'No local peer present, cannot start transcriptions',
);
}
const transcriptionParams: StartTranscriptionRequestParams = {
mode: params.mode,
};
await this.transport?.signal.startTranscription(transcriptionParams);
}
async stopTranscription(params: TranscriptionConfig) {
if (!this.localPeer) {
throw ErrorFactory.GenericErrors.NotConnected(
HMSAction.VALIDATION,
'No local peer present, cannot stop transcriptions',
);
}
if (!params) {
throw ErrorFactory.GenericErrors.Signalling(HMSAction.VALIDATION, 'No mode is passed to stop the transcription');
}
const transcriptionParams: StartTranscriptionRequestParams = {
mode: params.mode,
};
await this.transport?.signal.stopTranscription(transcriptionParams);
}
async sendHLSTimedMetadata(metadataList: HLSTimedMetadata[]) {
this.validateJoined('sendHLSTimedMetadata');
if (metadataList.length > 0) {
const hlsMtParams: HLSTimedMetadataParams = {
metadata_objs: metadataList,
};
await this.transport?.signal.sendHLSTimedMetadata(hlsMtParams);
}
}
async changeName(name: string) {
this.validateJoined('changeName');
const peer = this.store.getLocalPeer();
if (peer && peer.name !== name) {
await this.transport?.signal.updatePeer({
name: name,
});
this.notificationManager?.updateLocalPeer({ name });
}
}
async changeMetadata(metadata: string) {
this.validateJoined('changeMetadata');
await this.transport?.signal.updatePeer({
data: metadata,
});
this.notificationManager?.updateLocalPeer({ metadata });
}
async setSessionMetadata(metadata: any) {
await this.transport?.signal.setSessionMetadata({ key: 'default', data: metadata });
}
async getSessionMetadata() {
const response = await this.transport?.signal.getSessionMetadata('default');
return response.data;
}
getRoles(): HMSRole[] {
return Object.values(this.store.getKnownRoles());
}
async changeTrackState(forRemoteTrack: HMSRemoteTrack, enabled: boolean) {
if (forRemoteTrack.type === HMSTrackType.VIDEO && forRemoteTrack.source !== 'regular') {
HMSLogger.w(this.TAG, `Muting non-regular video tracks is currently not supported`);
return;
}
if (forRemoteTrack.enabled === enabled) {
HMSLogger.w(this.TAG, `Aborting change track state, track already has enabled - ${enabled}`, forRemoteTrack);
return;
}
if (!this.store.getTrackById(forRemoteTrack.trackId)) {
throw ErrorFactory.GenericErrors.ValidationFailed('No track found for change track state', forRemoteTrack);
}
const peer = this.store.getPeerByTrackId(forRemoteTrack.trackId);
if (!peer) {
throw ErrorFactory.GenericErrors.ValidationFailed('No peer found for change track state', forRemoteTrack);
}
await this.transport?.signal.requestTrackStateChange({
requested_for: peer.peerId,
track_id: forRemoteTrack.trackId,
stream_id: forRemoteTrack.stream.id,
mute: !enabled,
});
}
async changeMultiTrackState(params: HMSChangeMultiTrackStateParams) {
if (typeof params.enabled !== 'boolean') {
throw ErrorFactory.GenericErrors.ValidationFailed('Pass a boolean for enabled');
}
const { enabled, roles, type, source } = params;
await this.transport?.signal.requestMultiTrackStateChange({
value: !enabled,
type,
source,
roles: roles?.map(role => role?.name),
});
}
async raiseLocalPeerHand() {
this.validateJoined('raiseLocalPeerHand');
await this.transport?.signal.joinGroup(HAND_RAISE_GROUP_NAME);
}
async lowerLocalPeerHand() {
this.validateJoined('lowerLocalPeerHand');
await this.transport?.signal.leaveGroup(HAND_RAISE_GROUP_NAME);
}
async raiseRemotePeerHand(peerId: string) {
await this.transport?.signal.addToGroup(peerId, HAND_RAISE_GROUP_NAME);
}
async lowerRemotePeerHand(peerId: string) {
await this.transport?.signal.removeFromGroup(peerId, HAND_RAISE_GROUP_NAME);
}
setFrameworkInfo(frameworkInfo: HMSFrameworkInfo) {
this.frameworkInfo = { ...this.frameworkInfo, ...frameworkInfo };
}
async attachVideo(track: HMSVideoTrack, videoElement: HTMLVideoElement) {
const config = this.store.getConfig();
if (config?.autoManageVideo) {
track.attach(videoElement);
} else {
await track.addSink(videoElement);
}
}
async detachVideo(track: HMSVideoTrack, videoElement: HTMLVideoElement) {
const config = this.store.getConfig();
if (config?.autoManageVideo) {
track.detach(videoElement);
} else {
await track.removeSink(videoElement);
}
}
private async publish(initialSettings?: InitialSettings, oldRole?: string) {
if ([this.store.getPublishParams(), !this.sdkState.published, !isNode].every(value => !!value)) {
// if preview asRole(oldRole) is used, use roleChangeManager to diff policy and publish, else do normal publish
const publishAction =
oldRole && oldRole !== this.localPeer?.role?.name
? () =>
this.roleChangeManager?.diffRolesAndPublishTracks({
oldRole: this.store.getPolicyForRole(oldRole),
newRole: this.localPeer!.role!,
})
: () => this.getAndPublishTracks(initialSettings);
await publishAction?.()?.catch(error => {
HMSLogger.e(this.TAG, 'Error in publish', error);
this.listener?.onError(error);
});
}
}
private async getAndPublishTracks(initialSettings?: InitialSettings) {
const tracks = await this.localTrackManager.getTracksToPublish(initialSettings);
await this.initDeviceManagers();
await this.setAndPublishTracks(tracks);
this.localPeer?.audioTrack?.initAudioLevelMonitor();
this.sdkState.published = true;
}
private handleLocalRoleUpdate = async ({ oldRole, newRole }: { oldRole: HMSRole; newRole: HMSRole }) => {
this.deviceManager.currentSelection = this.deviceManager.getCurrentSelection();
await this.transport.handleLocalRoleUpdate({ oldRole, newRole });
await this.roleChangeManager?.handleLocalPeerRoleUpdate({ oldRole, newRole });
await this.interactivityCenter.whiteboard.handleLocalRoleUpdate();
};
private async setAndPublishTracks(tracks: HMSLocalTrack[]) {
for (const track of tracks) {
await this.transport.publish([track]);
if (track.isTrackNotPublishing()) {
const error = ErrorFactory.TracksErrors.NoDataInTrack(
`${track.type} track has no data. muted: ${track.nativeTrack.muted}, readyState: ${track.nativeTrack.readyState}`,
);
HMSLogger.e(this.TAG, error);
this.sendAnalyticsEvent(
AnalyticsEventFactory.publish({
devices: this.deviceManager.getDevices(),
error: error,
}),
);
this.listener?.onError(error);
}
await this.setLocalPeerTrack(track);
this.listener?.onTrackUpdate(HMSTrackUpdate.TRACK_ADDED, track, this.localPeer!);
}
}
private async setLocalPeerTrack(track: HMSLocalTrack) {
track.peerId = this.localPeer?.peerId;
switch (track.type) {
case HMSTrackType.AUDIO:
this.localPeer!.audioTrack = track as HMSLocalAudioTrack;
await this.deviceManager.autoSelectAudioOutput();
break;
case HMSTrackType.VIDEO:
this.localPeer!.videoTrack = track as HMSLocalVideoTrack;
break;
}
}
private async initDeviceManagers() {
// No need to initialise and add listeners if already initialised in preview
if (this.sdkState.deviceManagersInitialised) {
return;
}
this.sdkState.deviceManagersInitialised = true;
await this.deviceMana