snapshot of https://github.com/jitsi/jitsi-meet.git @ fd1749dd28f4138aa80234fc7f311b19aaffa0e7 (2026-09-15)
Some checks failed
Prosody Plugin Tests / test (push) Failing after 10m11s
Close stale issues and PRs / stale (push) Failing after 1s

This commit is contained in:
upstream-mirror
2026-09-15 15:56:32 +05:00
commit 1d2bfcac0c
3159 changed files with 464524 additions and 0 deletions

View 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);
}
}

View 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;
}

View 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);
}
);