snapshot of https://github.com/jitsi/jitsi-meet.git @ fd1749dd28f4138aa80234fc7f311b19aaffa0e7 (2026-09-15)
This commit is contained in:
265
react/features/external-api/DocumentPiPController.web.ts
Normal file
265
react/features/external-api/DocumentPiPController.web.ts
Normal file
@@ -0,0 +1,265 @@
|
||||
import type { MediaCastSignal } from '../base/media-cast/types.web';
|
||||
import { DEFAULT_DOCUMENT_PIP_HEIGHT, DEFAULT_DOCUMENT_PIP_WIDTH } from '../pip/constants';
|
||||
import type { IDocumentPictureInPictureOptions } from '../pip/types';
|
||||
|
||||
/**
|
||||
* Renderer bundle loaded into the host-owned Document PiP window.
|
||||
*/
|
||||
const DOCUMENT_PIP_SCRIPT = 'libs/documentpip.min.js';
|
||||
|
||||
/**
|
||||
* Application stylesheet loaded into the host-owned Document PiP window.
|
||||
*/
|
||||
const DOCUMENT_PIP_STYLESHEET = 'css/all.css';
|
||||
|
||||
/**
|
||||
* Options for constructing a {@link DocumentPiPController}.
|
||||
*/
|
||||
interface IDocumentPiPControllerOptions {
|
||||
/**
|
||||
* The JitsiMeetExternalAPI instance; exposed to the renderer bundle through window.alwaysOnTop.
|
||||
*/
|
||||
api: any;
|
||||
|
||||
/**
|
||||
* The iframe hosting the meeting; used to derive the renderer bundle base URL.
|
||||
*/
|
||||
frame: HTMLIFrameElement;
|
||||
|
||||
/**
|
||||
* The URL of the meeting iframe, used as the fallback base URL for the renderer bundle.
|
||||
*/
|
||||
meetingUrl: string;
|
||||
|
||||
/**
|
||||
* The transport used to send host commands back to the meeting iframe.
|
||||
*/
|
||||
transport: any;
|
||||
|
||||
/**
|
||||
* The Document PiP window options supplied by the embedder, if any.
|
||||
*/
|
||||
windowOptions?: IDocumentPictureInPictureOptions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Owns the top-level portion of embedded Document Picture-in-Picture.
|
||||
*
|
||||
* The iframe learns the outcome of every open request exclusively through the 'opened' or
|
||||
* 'open-failed' host commands, so every exit path of {@link open} must answer with one of them.
|
||||
*/
|
||||
export default class DocumentPiPController {
|
||||
/**
|
||||
* The currently resolving Document PiP window request, if any.
|
||||
*/
|
||||
private _pendingRequest?: Promise<Window>;
|
||||
|
||||
/**
|
||||
* The currently open Document PiP window, if any.
|
||||
*/
|
||||
private _pipWindow?: Window;
|
||||
|
||||
/**
|
||||
* Whether the renderer bundle has loaded in the active Document PiP window.
|
||||
*/
|
||||
private _rendererReady = false;
|
||||
|
||||
/**
|
||||
* Immutable dependencies and configuration supplied by the owning External API instance.
|
||||
*/
|
||||
private _options: IDocumentPiPControllerOptions;
|
||||
|
||||
/**
|
||||
* Creates a controller with private per-API lifecycle state.
|
||||
*
|
||||
* @param {IDocumentPiPControllerOptions} options - Configuration for the controller.
|
||||
*/
|
||||
constructor(options: IDocumentPiPControllerOptions) {
|
||||
this._options = options;
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens and initializes a Document PiP window. Every exit path answers the iframe with
|
||||
* either the 'opened' or the 'open-failed' command so a request never goes unanswered.
|
||||
*
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async open(): Promise<void> {
|
||||
if (this._pendingRequest || this._pipWindow) {
|
||||
// A session is already owned. Answer so the iframe never waits forever: a live
|
||||
// ready window is 'opened'; an unresolved or initializing request is 'open-failed'.
|
||||
this._sendCommand(this._pipWindow && this._rendererReady ? 'opened' : 'open-failed');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const documentPiP = window.documentPictureInPicture;
|
||||
|
||||
if (!documentPiP) {
|
||||
this._sendCommand('open-failed');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
let newPiPWindow: Window | undefined;
|
||||
let request: Promise<Window> | undefined;
|
||||
|
||||
try {
|
||||
// Toolbar clicks and Chrome's automatic PiP callback preserve activation across the
|
||||
// iframe transport hop. Visibility-only IntersectionObserver requests may be rejected
|
||||
// because scrolling does not create transient user activation; that failure is reported below.
|
||||
request = documentPiP.requestWindow({
|
||||
height: DEFAULT_DOCUMENT_PIP_HEIGHT,
|
||||
width: DEFAULT_DOCUMENT_PIP_WIDTH,
|
||||
...this._options.windowOptions
|
||||
});
|
||||
this._pendingRequest = request;
|
||||
|
||||
newPiPWindow = await request;
|
||||
|
||||
if (this._pendingRequest !== request) {
|
||||
// A close was requested while the window was resolving; closeSession() already
|
||||
// acknowledged it with the 'closed' command, so do not double-acknowledge here.
|
||||
newPiPWindow.close();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this._pendingRequest = undefined;
|
||||
this._pipWindow = newPiPWindow;
|
||||
this._initializeWindow(newPiPWindow);
|
||||
} catch (error) {
|
||||
// A cleared pending request with no matching window means close() already cancelled
|
||||
// and acknowledged this operation. Otherwise this request still owns the failure.
|
||||
const ownsPendingRequest = !request || this._pendingRequest === request;
|
||||
const ownsPiPWindow = Boolean(newPiPWindow && this._pipWindow === newPiPWindow);
|
||||
|
||||
if (ownsPendingRequest || ownsPiPWindow) {
|
||||
this._pendingRequest = undefined;
|
||||
this._closeSession(false);
|
||||
this._sendCommand('open-failed');
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes an active or pending PiP session and acknowledges the close once.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
close(): void {
|
||||
this._closeSession(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Forwards a media-cast signal from the Document PiP renderer to the meeting iframe.
|
||||
*
|
||||
* @param {MediaCastSignal} signal - Answer, candidate, restart, or stop signal.
|
||||
* @returns {void}
|
||||
*/
|
||||
sendSignal(signal: MediaCastSignal): void {
|
||||
this._sendCommand('signal', signal);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends an internal host command to the embedded meeting iframe.
|
||||
*
|
||||
* @param {string} name - Command name, e.g. 'opened', 'open-failed', 'closed' or 'signal'.
|
||||
* @param {any} [data] - Optional command payload.
|
||||
* @returns {void}
|
||||
*/
|
||||
private _sendCommand(name: string, data?: any): void {
|
||||
this._options.transport.sendEvent({
|
||||
data: data === undefined ? [] : [ data ],
|
||||
name: `document-pip-${name}`
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Tears down the active or pending session, closing any live window and optionally
|
||||
* acknowledging the close to the iframe.
|
||||
*
|
||||
* @param {boolean} notify - Whether to send the 'closed' command when a session existed.
|
||||
* @returns {void}
|
||||
*/
|
||||
private _closeSession(notify: boolean): void {
|
||||
const hadSession = Boolean(this._pendingRequest || this._pipWindow);
|
||||
const activeWindow = this._pipWindow;
|
||||
|
||||
this._pendingRequest = undefined;
|
||||
this._pipWindow = undefined;
|
||||
this._rendererReady = false;
|
||||
|
||||
if (activeWindow && !activeWindow.closed) {
|
||||
activeWindow.close();
|
||||
}
|
||||
|
||||
if (notify && hadSession) {
|
||||
this._sendCommand('closed');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes a freshly opened PiP window: copies the stylesheet, seeds the container markup,
|
||||
* exposes the embedder API to the renderer bundle and loads libs/documentpip.min.js. The
|
||||
* renderer's load success or failure is reported back as the 'opened' or 'open-failed' command.
|
||||
*
|
||||
* @param {Window} newPiPWindow - The Document PiP window to initialize.
|
||||
* @returns {void}
|
||||
*/
|
||||
private _initializeWindow(newPiPWindow: Window): void {
|
||||
let baseURL = new URL('/', this._options.meetingUrl).href;
|
||||
|
||||
try {
|
||||
// For a cross-origin meeting frame contentDocument is always null, so the URL-derived
|
||||
// fallback is what we use in practice; the <base> lookup only matters for same-origin
|
||||
// deployments. Deployments that serve files from a versioned CDN therefore load the
|
||||
// fallback URL, so it must resolve to the same release as the iframe — otherwise the
|
||||
// PiP window loads a 404 or a bundle from a different release.
|
||||
const base = this._options.frame.contentDocument?.querySelector('base');
|
||||
|
||||
if (base) {
|
||||
baseURL = base.href;
|
||||
}
|
||||
} catch {
|
||||
// Cross-origin meeting iframes use the URL-derived fallback.
|
||||
}
|
||||
|
||||
const pipDocument = newPiPWindow.document;
|
||||
const link = pipDocument.createElement('link');
|
||||
const script = pipDocument.createElement('script');
|
||||
|
||||
link.href = new URL(DOCUMENT_PIP_STYLESHEET, baseURL).href;
|
||||
link.rel = 'stylesheet';
|
||||
pipDocument.head.appendChild(link);
|
||||
|
||||
pipDocument.body.innerHTML
|
||||
= '<div class="videocontainer" id="react" style="height:100%"></div>';
|
||||
|
||||
newPiPWindow.alwaysOnTop = { api: this._options.api };
|
||||
|
||||
newPiPWindow.addEventListener('pagehide', () => {
|
||||
if (this._pipWindow === newPiPWindow) {
|
||||
this._closeSession(true);
|
||||
}
|
||||
}, { once: true });
|
||||
|
||||
script.addEventListener('load', () => {
|
||||
if (this._pipWindow === newPiPWindow) {
|
||||
this._rendererReady = true;
|
||||
this._sendCommand('opened');
|
||||
}
|
||||
}, { once: true });
|
||||
script.addEventListener('error', () => {
|
||||
if (this._pipWindow === newPiPWindow) {
|
||||
this._closeSession(false);
|
||||
this._sendCommand('open-failed');
|
||||
console.error('Document PiP renderer failed to load.');
|
||||
}
|
||||
}, { once: true });
|
||||
script.src = new URL(DOCUMENT_PIP_SCRIPT, baseURL).href;
|
||||
pipDocument.body.appendChild(script);
|
||||
}
|
||||
}
|
||||
279
react/features/external-api/middleware.ts
Normal file
279
react/features/external-api/middleware.ts
Normal file
@@ -0,0 +1,279 @@
|
||||
// @ts-expect-error
|
||||
import { getJitsiMeetTransport } from '../../../modules/transport';
|
||||
import {
|
||||
CONFERENCE_FAILED,
|
||||
CONFERENCE_JOINED,
|
||||
DATA_CHANNEL_CLOSED,
|
||||
DATA_CHANNEL_OPENED,
|
||||
KICKED_OUT
|
||||
} from '../base/conference/actionTypes';
|
||||
import { SET_CONFIG } from '../base/config/actionTypes';
|
||||
import { NOTIFY_CAMERA_ERROR, NOTIFY_MIC_ERROR } from '../base/devices/actionTypes';
|
||||
import { JitsiConferenceErrors } from '../base/lib-jitsi-meet';
|
||||
import {
|
||||
DOMINANT_SPEAKER_CHANGED,
|
||||
PARTICIPANT_JOINED,
|
||||
PARTICIPANT_KICKED,
|
||||
PARTICIPANT_LEFT,
|
||||
PARTICIPANT_ROLE_CHANGED,
|
||||
SET_LOADABLE_AVATAR_URL
|
||||
} from '../base/participants/actionTypes';
|
||||
import {
|
||||
getDominantSpeakerParticipant,
|
||||
getLocalParticipant,
|
||||
getParticipantById,
|
||||
getParticipantDisplayName
|
||||
} from '../base/participants/functions';
|
||||
import MiddlewareRegistry from '../base/redux/MiddlewareRegistry';
|
||||
import { getBaseUrl } from '../base/util/helpers';
|
||||
import { appendSuffix } from '../display-name/functions';
|
||||
import { SUBMIT_FEEDBACK_ERROR, SUBMIT_FEEDBACK_SUCCESS } from '../feedback/actionTypes';
|
||||
import { SET_FILMSTRIP_VISIBLE } from '../filmstrip/actionTypes';
|
||||
import { iAmVisitor } from '../visitors/functions';
|
||||
|
||||
import './subscriber';
|
||||
|
||||
/**
|
||||
* The middleware of the feature {@code external-api}.
|
||||
*
|
||||
* @returns {Function}
|
||||
*/
|
||||
MiddlewareRegistry.register(store => next => action => {
|
||||
// We need to do these before executing the rest of the middelware chain
|
||||
switch (action.type) {
|
||||
case DOMINANT_SPEAKER_CHANGED: {
|
||||
const dominantSpeaker = getDominantSpeakerParticipant(store.getState());
|
||||
|
||||
if (dominantSpeaker?.id !== action.participant.id) {
|
||||
const result = next(action);
|
||||
|
||||
APP.API.notifyDominantSpeakerChanged(action.participant.id);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case SET_LOADABLE_AVATAR_URL: {
|
||||
const { id, loadableAvatarUrl } = action.participant;
|
||||
const participant = getParticipantById(
|
||||
store.getState(),
|
||||
id
|
||||
);
|
||||
|
||||
const result = next(action);
|
||||
|
||||
if (participant) {
|
||||
if (loadableAvatarUrl) {
|
||||
participant.loadableAvatarUrl !== loadableAvatarUrl && APP.API.notifyAvatarChanged(
|
||||
id,
|
||||
loadableAvatarUrl
|
||||
);
|
||||
} else {
|
||||
// There is no loadable explicit URL. In this case the Avatar component would
|
||||
// decide to render initials or the default avatar, but the external API needs
|
||||
// a URL when it needs to be rendered, so if there is no initials, we return the default
|
||||
// Avatar URL as if it was a usual avatar URL. If there are (or may be) initials
|
||||
// we send undefined to signal the api user that it's not an URL that needs to be rendered.
|
||||
//
|
||||
// NOTE: we may implement a special URL format later to signal that the avatar is based
|
||||
// on initials, that API consumers can handle as they want, e.g. initials://jm
|
||||
APP.API.notifyAvatarChanged(
|
||||
id,
|
||||
participant.name ? undefined : _getDefaultAvatarUrl()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
const result = next(action);
|
||||
|
||||
// These should happen after the rest of the middleware chain ran
|
||||
switch (action.type) {
|
||||
case CONFERENCE_FAILED: {
|
||||
if (action.conference
|
||||
&& action.error.name === JitsiConferenceErrors.PASSWORD_REQUIRED) {
|
||||
APP.API.notifyOnPasswordRequired();
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case CONFERENCE_JOINED: {
|
||||
const state = store.getState();
|
||||
const { defaultLocalDisplayName } = state['features/base/config'];
|
||||
const { room } = state['features/base/conference'];
|
||||
const { loadableAvatarUrl, name, id, email } = getLocalParticipant(state) ?? {};
|
||||
const breakoutRoom = APP.conference.roomName.toString() !== room?.toLowerCase();
|
||||
|
||||
// we use APP.conference.roomName as we do not update state['features/base/conference'].room when
|
||||
// moving between rooms in case of breakout rooms and it stays always with the name of the main room
|
||||
APP.API.notifyConferenceJoined(
|
||||
APP.conference.roomName,
|
||||
id,
|
||||
{
|
||||
displayName: name,
|
||||
formattedDisplayName: appendSuffix(
|
||||
name ?? '',
|
||||
defaultLocalDisplayName
|
||||
),
|
||||
avatarURL: loadableAvatarUrl,
|
||||
breakoutRoom,
|
||||
email,
|
||||
visitor: iAmVisitor(state)
|
||||
}
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
case DATA_CHANNEL_CLOSED:
|
||||
APP.API.notifyDataChannelClosed(action.code, action.reason);
|
||||
break;
|
||||
|
||||
case DATA_CHANNEL_OPENED:
|
||||
APP.API.notifyDataChannelOpened();
|
||||
break;
|
||||
|
||||
case KICKED_OUT: {
|
||||
const state = store.getState();
|
||||
const localParticipant = getLocalParticipant(state);
|
||||
|
||||
if (!localParticipant) {
|
||||
break;
|
||||
}
|
||||
|
||||
const actor = action.participant;
|
||||
|
||||
APP.API.notifyKickedOut(
|
||||
{
|
||||
id: localParticipant.id,
|
||||
name: getParticipantDisplayName(state, localParticipant.id),
|
||||
local: true
|
||||
},
|
||||
{
|
||||
id: actor?.getId(),
|
||||
name: actor?.getDisplayName()
|
||||
}
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
case NOTIFY_CAMERA_ERROR:
|
||||
if (action.error) {
|
||||
APP.API.notifyOnCameraError(
|
||||
action.error.name, action.error.message);
|
||||
}
|
||||
break;
|
||||
|
||||
case NOTIFY_MIC_ERROR:
|
||||
if (action.error) {
|
||||
APP.API.notifyOnMicError(action.error.name, action.error.message);
|
||||
}
|
||||
break;
|
||||
|
||||
case PARTICIPANT_KICKED: {
|
||||
const state = store.getState();
|
||||
const kickedParticipant = getParticipantById(state, action.kicked);
|
||||
const kickerParticipant = getParticipantById(state, action.kicker);
|
||||
|
||||
if (!kickerParticipant || !kickedParticipant) {
|
||||
break;
|
||||
}
|
||||
|
||||
APP.API.notifyKickedOut(
|
||||
{
|
||||
id: kickedParticipant.id,
|
||||
local: kickedParticipant.local,
|
||||
name: getParticipantDisplayName(state, kickedParticipant.id)
|
||||
},
|
||||
{
|
||||
id: kickerParticipant.id,
|
||||
local: kickerParticipant.local,
|
||||
name: getParticipantDisplayName(state, kickerParticipant.id)
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case PARTICIPANT_LEFT: {
|
||||
const { participant } = action;
|
||||
const { fakeParticipant } = participant;
|
||||
|
||||
// Skip sending participant left event for fake participants.
|
||||
if (fakeParticipant) {
|
||||
break;
|
||||
}
|
||||
|
||||
APP.API.notifyUserLeft(action.participant.id);
|
||||
break;
|
||||
}
|
||||
case PARTICIPANT_JOINED: {
|
||||
const state = store.getState();
|
||||
const { defaultRemoteDisplayName } = state['features/base/config'];
|
||||
const { participant } = action;
|
||||
const { fakeParticipant, id, local, name, userContext } = participant;
|
||||
|
||||
// The version of external api outside of middleware did not emit
|
||||
// the local participant being created.
|
||||
if (!local) {
|
||||
// Skip sending participant joined event for fake participants.
|
||||
if (fakeParticipant) {
|
||||
break;
|
||||
}
|
||||
|
||||
APP.API.notifyUserJoined(id, {
|
||||
displayName: name,
|
||||
formattedDisplayName: appendSuffix(
|
||||
name || defaultRemoteDisplayName),
|
||||
userContext
|
||||
});
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case PARTICIPANT_ROLE_CHANGED:
|
||||
APP.API.notifyUserRoleChanged(action.participant.id, action.participant.role);
|
||||
break;
|
||||
|
||||
case SET_CONFIG: {
|
||||
const state = store.getState();
|
||||
const { disableBeforeUnloadHandlers = false } = state['features/base/config'];
|
||||
|
||||
/**
|
||||
* Disposing the API when the user closes the page.
|
||||
*/
|
||||
window.addEventListener(disableBeforeUnloadHandlers ? 'unload' : 'beforeunload', () => {
|
||||
APP.API.notifyConferenceLeft(APP.conference.roomName);
|
||||
APP.API.dispose();
|
||||
getJitsiMeetTransport().dispose();
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case SET_FILMSTRIP_VISIBLE:
|
||||
APP.API.notifyFilmstripDisplayChanged(action.visible);
|
||||
break;
|
||||
|
||||
case SUBMIT_FEEDBACK_ERROR:
|
||||
APP.API.notifyFeedbackSubmitted(action.error || 'Unknown error');
|
||||
break;
|
||||
|
||||
case SUBMIT_FEEDBACK_SUCCESS:
|
||||
APP.API.notifyFeedbackSubmitted();
|
||||
break;
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
/**
|
||||
* Returns the absolute URL of the default avatar.
|
||||
*
|
||||
* @returns {string}
|
||||
*/
|
||||
function _getDefaultAvatarUrl() {
|
||||
return new URL('images/avatar.png', getBaseUrl()).href;
|
||||
}
|
||||
73
react/features/external-api/subscriber.ts
Normal file
73
react/features/external-api/subscriber.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { getLocalParticipant } from '../base/participants/functions';
|
||||
import StateListenerRegistry from '../base/redux/StateListenerRegistry';
|
||||
import { appendSuffix } from '../display-name/functions';
|
||||
import { shouldDisplayTileView } from '../video-layout/functions';
|
||||
|
||||
/**
|
||||
* StateListenerRegistry provides a reliable way of detecting changes to
|
||||
* preferred layout state and dispatching additional actions.
|
||||
*/
|
||||
StateListenerRegistry.register(
|
||||
/* selector */ state => shouldDisplayTileView(state),
|
||||
/* listener */ displayTileView => {
|
||||
APP.API.notifyTileViewChanged(displayTileView);
|
||||
});
|
||||
|
||||
StateListenerRegistry.register(
|
||||
/* selector */ state => state['features/base/settings'].displayName,
|
||||
/* listener */ (displayName, store) => {
|
||||
const localParticipant = getLocalParticipant(store.getState());
|
||||
const { defaultLocalDisplayName } = store.getState()['features/base/config'];
|
||||
|
||||
// Initial setting of the display name happens on app
|
||||
// initialization, before the local participant is ready. The initial
|
||||
// settings is not desired to be fired anyways, only changes.
|
||||
if (localParticipant) {
|
||||
const { id } = localParticipant;
|
||||
|
||||
APP.API.notifyDisplayNameChanged(id, {
|
||||
displayName,
|
||||
formattedDisplayName: appendSuffix(
|
||||
displayName,
|
||||
defaultLocalDisplayName
|
||||
)
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
StateListenerRegistry.register(
|
||||
/* selector */ state => state['features/base/settings'].email,
|
||||
/* listener */ (email, store) => {
|
||||
const localParticipant = getLocalParticipant(store.getState());
|
||||
|
||||
// Initial setting of the email happens on app
|
||||
// initialization, before the local participant is ready. The initial
|
||||
// settings is not desired to be fired anyways, only changes.
|
||||
if (localParticipant) {
|
||||
const { id } = localParticipant;
|
||||
|
||||
APP.API.notifyEmailChanged(id, {
|
||||
email
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Updates the on stage participant value.
|
||||
*/
|
||||
StateListenerRegistry.register(
|
||||
/* selector */ state => state['features/large-video'].participantId,
|
||||
/* listener */ participantId => {
|
||||
APP.API.notifyOnStageParticipantChanged(participantId);
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Updates the on audio only value.
|
||||
*/
|
||||
StateListenerRegistry.register(
|
||||
/* selector */ state => state['features/base/low-bandwidth-mode'].enabled,
|
||||
/* listener */ enabled => {
|
||||
APP.API.notifyLowBandwidthModeChanged(enabled);
|
||||
}
|
||||
);
|
||||
Reference in New Issue
Block a user