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,45 @@
/**
* The type of (redux) action which opens or updates a second-screen window with
* a given source descriptor.
*
* {
* type: SET_SECOND_SCREEN,
* id: string,
* source: ISecondScreenSource,
* screenId?: number
* }
*/
export const SET_SECOND_SCREEN = 'SET_SECOND_SCREEN';
/**
* The type of (redux) action which attaches the live window handle (the
* {@code Window}, its {@code <video>} and tracks) to a second-screen entry,
* once the middleware has opened the window.
*
* {
* type: SET_SECOND_SCREEN_WINDOW,
* id: string,
* handle: unknown
* }
*/
export const SET_SECOND_SCREEN_WINDOW = 'SET_SECOND_SCREEN_WINDOW';
/**
* The type of (redux) action which closes a single second-screen window.
*
* {
* type: REMOVE_SECOND_SCREEN,
* id: string
* }
*/
export const REMOVE_SECOND_SCREEN = 'REMOVE_SECOND_SCREEN';
/**
* The type of (redux) action which closes all second-screen windows (e.g. when
* the conference ends).
*
* {
* type: RESET_SECOND_SCREENS
* }
*/
export const RESET_SECOND_SCREENS = 'RESET_SECOND_SCREENS';

View File

@@ -0,0 +1,75 @@
import {
REMOVE_SECOND_SCREEN,
RESET_SECOND_SCREENS,
SET_SECOND_SCREEN,
SET_SECOND_SCREEN_WINDOW
} from './actionTypes';
import { ISecondScreenSource } from './types';
/**
* Opens or updates the second-screen window identified by {@code id} to render
* the given source. Passing an empty/invalid source closes that window instead.
*
* @param {string} id - The window identifier (allows more than one second screen).
* @param {ISecondScreenSource} source - What to render.
* @param {number} screenId - Optional physical screen index to place the window on.
* @returns {Object}
*/
export function setSecondScreen(id: string, source?: ISecondScreenSource, screenId?: number) {
if (!source || (!source.role && !source.participant)) {
return { type: REMOVE_SECOND_SCREEN, id };
}
return {
type: SET_SECOND_SCREEN,
id,
source,
screenId,
// Stamped here rather than in the reducer to keep the reducer pure; it
// orders the in-app windows by how recently they were targeted.
setAt: Date.now()
};
}
/**
* Attaches the live window handle to a second-screen entry once the middleware
* has opened the window. The handle is typed opaquely ({@code unknown}) because
* it holds DOM/media objects the shared/native build cannot type; see
* {@code functions.web.ts}.
*
* @param {string} id - The window identifier.
* @param {unknown} handle - The live window handle.
* @returns {{ type: SET_SECOND_SCREEN_WINDOW, id: string, handle: unknown }}
*/
export function setSecondScreenWindow(id: string, handle: unknown) {
return {
type: SET_SECOND_SCREEN_WINDOW,
id,
handle
};
}
/**
* Closes a single second-screen window.
*
* @param {string} id - The window identifier.
* @returns {{ type: REMOVE_SECOND_SCREEN, id: string }}
*/
export function removeSecondScreen(id: string) {
return {
type: REMOVE_SECOND_SCREEN,
id
};
}
/**
* Closes all second-screen windows.
*
* @returns {{ type: RESET_SECOND_SCREENS }}
*/
export function resetSecondScreens() {
return {
type: RESET_SECOND_SCREENS
};
}

View File

@@ -0,0 +1,203 @@
import React, { useCallback, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { makeStyles } from 'tss-react/mui';
import { getGalleryGridDimensions } from '../functions.web';
import { useDominantSpeakerId, useSecondScreenParticipantIds } from '../hooks.web';
import SecondScreenTile from './SecondScreenTile';
/**
* The type of the React {@code Component} props of {@link SecondScreenGallery}.
*/
interface IProps {
/**
* The second-screen window, needed to render tracks in its own realm.
*/
win: Window;
}
/**
* Maximum number of columns in the gallery grid.
*/
const MAX_GALLERY_COLUMNS = 5;
/**
* Gap between gallery tiles in pixels. Must match the {@code gap} in the styles,
* or the tile-size math mis-counts the gaps and the grid overflows.
*/
const GALLERY_GAP = 8;
/**
* The styles, injected into the second window via its own Emotion cache.
*/
const useStyles = makeStyles()(() => {
return {
gallery: {
boxSizing: 'border-box',
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: GALLERY_GAP,
padding: GALLERY_GAP,
backgroundColor: '#040404',
overflow: 'hidden'
},
row: {
display: 'flex',
justifyContent: 'center',
gap: GALLERY_GAP
},
placeholder: {
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#040404'
},
message: {
fontSize: 16,
color: 'rgba(255, 255, 255, 0.5)'
}
};
});
/**
* The tile-grid (gallery) layout for a second-screen window: every participant in
* a responsive grid sized to the window. Tiles use the realm-safe {@link
* SecondScreenTile}; the grid is measured through the popup window's own
* {@code ResizeObserver} so it reflows to that window (including page-zoom).
*
* @param {IProps} props - The component props.
* @returns {ReactElement}
*/
const SecondScreenGallery = ({ win }: IProps) => {
const { classes } = useStyles();
const { t } = useTranslation();
const participantIds = useSecondScreenParticipantIds();
const dominantSpeakerId = useDominantSpeakerId();
// Container dimensions, tracked via a ResizeObserver bound through a callback
// ref so it (re)attaches when the grid mounts (including when participants
// arrive after the window opened) and tears down when it unmounts. The grid
// lives in the SECONDARY window while this code runs in the main window's
// realm, so the observer, rAF and resize listener are taken from that window;
// otherwise the main window's observer never sees the popup's own reflows
// (most visibly page-zoom).
const [ dimensions, setDimensions ] = useState<{ height: number; width: number; } | null>(null);
const cleanupRef = useRef<(() => void) | null>(null);
const attachGridRef = useCallback((element: HTMLDivElement | null) => {
cleanupRef.current?.();
cleanupRef.current = null;
if (!element) {
return;
}
const ownerWindow = element.ownerDocument.defaultView ?? window;
const ResizeObserverCtor = ownerWindow.ResizeObserver ?? ResizeObserver;
let frame = 0;
const measure = () => {
const width = element.clientWidth;
const height = element.clientHeight;
// Skip a no-op measurement so it never re-enters the observer.
setDimensions(prev =>
((prev && prev.width === width && prev.height === height) ? prev : { height,
width }));
};
// Defer the measurement out of the observer/resize callback: updating
// state there resizes the tiles synchronously, raising the benign
// "ResizeObserver loop" overlay error.
const schedule = () => {
ownerWindow.cancelAnimationFrame(frame);
frame = ownerWindow.requestAnimationFrame(measure);
};
const observer = new ResizeObserverCtor(schedule);
observer.observe(element);
// Page-zoom fires resize on the popup but does not always reach a
// cross-document ResizeObserver, so re-measure on it explicitly too.
ownerWindow.addEventListener('resize', schedule);
cleanupRef.current = () => {
ownerWindow.cancelAnimationFrame(frame);
ownerWindow.removeEventListener('resize', schedule);
observer.disconnect();
};
measure();
}, []);
const count = participantIds.length;
// Defensive: the local participant is normally first in the list, so this is
// only hit in transient pre-join/teardown states. It keeps the second screen
// from flashing blank rather than being a common path.
if (count === 0) {
return (
<div className = { classes.placeholder }>
<div className = { classes.message }>
{ t('multiScreen.noParticipants') }
</div>
</div>
);
}
// The grid is always rendered so attachGridRef can measure it; the tiles wait
// for the first measurement, avoiding a frame of mis-sized tiles.
let tiles: React.ReactNode = null;
if (dimensions) {
const { columns, rows } = getGalleryGridDimensions(count, MAX_GALLERY_COLUMNS);
// The container has GALLERY_GAP padding on every side, so subtract it twice
// (derived from the constant, not hardcoded, so the two stay in sync). Clamp
// so a very small window never yields a negative tile size.
const availableWidth = dimensions.width - ((columns - 1) * GALLERY_GAP) - (2 * GALLERY_GAP);
const availableHeight = dimensions.height - ((rows - 1) * GALLERY_GAP) - (2 * GALLERY_GAP);
const tileWidth = Math.max(0, Math.floor(availableWidth / columns));
const tileHeight = Math.max(0, Math.floor(availableHeight / rows));
const galleryRows: string[][] = [];
for (let i = 0; i < count; i += columns) {
galleryRows.push(participantIds.slice(i, i + columns));
}
tiles = galleryRows.map((row, rowIndex) => (
<div
className = { classes.row }
key = { `row-${rowIndex}` }>
{ row.map(pid => (
<SecondScreenTile
height = { tileHeight }
isActiveSpeaker = { pid === dominantSpeakerId }
key = { pid }
participantId = { pid }
width = { tileWidth }
win = { win } />
)) }
</div>
));
}
return (
<div
className = { classes.gallery }
ref = { attachGridRef }>
{ tiles }
</div>
);
};
export default SecondScreenGallery;

View File

@@ -0,0 +1,60 @@
import { CacheProvider } from '@emotion/react';
import React, { useMemo } from 'react';
import ReactDOM from 'react-dom';
import { useSelector } from 'react-redux';
import { IReduxState } from '../../app/types';
import { ISecondScreenHandle } from '../functions.web';
import SecondScreenView from './SecondScreenView';
/**
* An open second-screen window paired with its portal root.
*/
interface IOpenScreen {
handle: ISecondScreenHandle;
id: string;
}
/**
* Renders every open second-screen window's content into that window via a React
* portal. Mounted once in the conference, it observes the multi-screen redux
* state and (un)mounts a {@link SecondScreenView} per window as windows open and
* close. Because the windows are same-origin, React reconciles their DOM the same
* way it does the main document, so the second screens can host any component
* (avatars now, with layouts and a whiteboard later), not just a {@code <video>}.
* Each window's subtree is wrapped in its own Emotion {@code CacheProvider} (the
* cache's container is that window's head), so MUI/tss-react styles inject into
* the window directly, in dev and prod.
*
* @returns {ReactElement}
*/
const SecondScreenPortals = () => {
const screens = useSelector((state: IReduxState) => state['features/multi-screen'].screens);
const open = useMemo(() => Object.keys(screens).reduce<Array<IOpenScreen>>((acc, id) => {
const handle = screens[id].handle as ISecondScreenHandle | undefined;
if (handle?.root && !handle.win.closed) {
acc.push({ handle, id });
}
return acc;
}, []), [ screens ]);
return (
<>
{ open.map(({ handle, id }) => ReactDOM.createPortal(
<CacheProvider value = { handle.cache }>
<SecondScreenView
id = { id }
win = { handle.win } />
</CacheProvider>,
handle.root,
id
)) }
</>
);
};
export default SecondScreenPortals;

View File

@@ -0,0 +1,130 @@
import React, { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useSelector } from 'react-redux';
import { makeStyles } from 'tss-react/mui';
import { IReduxState } from '../../app/types';
import VideoManager from '../../shared-video/components/web/VideoManager';
import YoutubeVideoManager from '../../shared-video/components/web/YoutubeVideoManager';
import { isSharedVideoEnabled, isVideoPlaying } from '../../shared-video/functions';
import logger from '../logger';
/**
* The type of the React {@code Component} props of {@link SecondScreenSharedVideo}.
*/
interface IProps {
/**
* The id of the second-screen window this view renders into.
*/
id: string;
}
/**
* The styles, injected into the second window via its own Emotion cache.
*/
const useStyles = makeStyles()(() => {
return {
container: {
width: '100%',
height: '100%',
backgroundColor: '#040404',
// The follower player is view-only: a local pause here would not
// be reflected in the meeting and would silently desync until the
// owner's next update.
pointerEvents: 'none',
// react-youtube renders the player inside a plain wrapper div;
// stretch it (and the player itself) to fill the window.
'& > div, & iframe, & video': {
width: '100%',
height: '100%'
}
},
placeholder: {
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#040404'
},
message: {
fontSize: 16,
color: 'rgba(255, 255, 255, 0.5)'
}
};
});
/**
* Renders the video (e.g. YouTube) shared in the meeting on a second screen.
* The player is the same manager the meeting window uses, mounted as a
* follower: it tracks the shared playback state (play/pause/seek) but never
* acts as the controlling player, and plays no audio; the audio stays with
* the main window's player. Like the whiteboard, this is the kind of
* non-video content a plain {@code <video>} second screen cannot host.
* Requires a video to be shared in the meeting; otherwise a hint is shown.
*
* @param {IProps} props - The component props.
* @returns {ReactElement}
*/
const SecondScreenSharedVideo = ({ id }: IProps) => {
const { classes } = useStyles();
const { t } = useTranslation();
const sharedVideoEnabled = useSelector(isSharedVideoEnabled);
const videoShared = useSelector(isVideoPlaying);
const videoUrl = useSelector((state: IReduxState) => state['features/shared-video'].videoUrl);
const [ failed, setFailed ] = useState(false);
// A failure belongs to the video that caused it: a newly shared one gets a
// fresh attempt.
useEffect(() => setFailed(false), [ videoUrl ]);
// The player itself cannot report this: a follower must not stop the
// meeting's shared video, so without this the window just stays black.
const onFollowerError = useCallback((code?: any) => {
logger.error(`Shared video failed on second screen "${id}"`, code);
APP.API?.notifySecondScreenError?.({ id, error: 'shared-video-error' });
setFailed(true);
}, [ id ]);
if (!sharedVideoEnabled || !videoShared || !videoUrl) {
return (
<div className = { classes.placeholder }>
<div className = { classes.message }>
{ t('multiScreen.sharedVideoInactive') }
</div>
</div>
);
}
if (failed) {
return (
<div className = { classes.placeholder }>
<div className = { classes.message }>
{ t('multiScreen.sharedVideoError') }
</div>
</div>
);
}
// The same manager split as the meeting's SharedVideo component: direct
// http(s) links get the plain <video> manager, anything else is a
// YouTube video id.
return (
<div className = { classes.container }>
{ videoUrl.match(/http/)
? <VideoManager
follower = { true }
onFollowerError = { onFollowerError }
videoId = { videoUrl } />
: <YoutubeVideoManager
follower = { true }
onFollowerError = { onFollowerError }
videoId = { videoUrl } /> }
</div>
);
};
export default SecondScreenSharedVideo;

View File

@@ -0,0 +1,99 @@
import React from 'react';
import { useSelector } from 'react-redux';
import { IReduxState } from '../../app/types';
import Avatar from '../../base/avatar/components/Avatar';
import { IParticipant } from '../../base/participants/types';
import { resolveSource } from '../functions.web';
import SecondScreenVideo from './SecondScreenVideo';
/**
* The type of the React {@code Component} props of {@link SecondScreenSingle}.
*/
interface IProps {
/**
* The id of the second-screen window this view renders into.
*/
id: string;
/**
* The second-screen window, needed to render a track in its own realm.
*/
win: Window;
}
/**
* The resolved source: the meeting track to show (or {@code null}) and the
* participant backing it (for the avatar fallback).
*/
interface IResolved {
participant?: IParticipant;
track: MediaStreamTrack | null;
}
/**
* The size of the fallback avatar shown when the source has no live video.
*/
const AVATAR_SIZE = 200;
/**
* Full-bleed, centered style for the avatar fallback on black.
*/
const CONTAINER_STYLE: React.CSSProperties = {
alignItems: 'center',
background: '#000',
display: 'flex',
height: '100%',
inset: 0,
justifyContent: 'center',
position: 'fixed',
width: '100%'
};
/**
* Re-renders only when the resolved track or backing participant actually
* changes, not on every unrelated redux update.
*
* @param {IResolved} a - The previous resolved source.
* @param {IResolved} b - The next resolved source.
* @returns {boolean}
*/
const _resolvedEquals = (a: IResolved, b: IResolved) => a.track === b.track && a.participant === b.participant;
/**
* Renders the single-source second-screen case: the resolved meeting track
* full-bleed (via {@link SecondScreenVideo}), falling back to the backing
* participant's avatar when there is no live video. Kept separate from
* {@link SecondScreenView} so the {@code resolveSource} selector only runs for this
* fall-through case, not for the tile/stage layouts that resolve their own state.
*
* @param {IProps} props - The component props.
* @returns {ReactElement}
*/
const SecondScreenSingle = ({ id, win }: IProps) => {
const source = useSelector((state: IReduxState) => state['features/multi-screen'].screens[id]?.source);
const { participant, track } = useSelector(
(state: IReduxState): IResolved =>
(source ? resolveSource(state, source) : { participant: undefined, track: null }),
_resolvedEquals);
if (track) {
return (
<SecondScreenVideo
track = { track }
win = { win } />
);
}
return (
<div style = { CONTAINER_STYLE }>
<Avatar
participantId = { participant?.id }
size = { AVATAR_SIZE } />
</div>
);
};
export default SecondScreenSingle;

View File

@@ -0,0 +1,211 @@
import React, { useCallback } from 'react';
import { useDispatch, useSelector, useStore } from 'react-redux';
import { makeStyles } from 'tss-react/mui';
import { IReduxState } from '../../app/types';
import Avatar from '../../base/avatar/components/Avatar';
import { getParticipantDisplayName, isScreenShareParticipantById } from '../../base/participants/functions';
import { setSecondScreen } from '../actions.web';
import { resolveSource } from '../functions.web';
import { useDominantSpeakerId, useSecondScreenParticipantIds } from '../hooks.web';
import { ISecondScreenSource } from '../types';
import SecondScreenStageTile from './SecondScreenStageTile';
import SecondScreenVideo from './SecondScreenVideo';
/**
* The type of the React {@code Component} props of {@link SecondScreenStage}.
*/
interface IProps {
/**
* The id of the second-screen window this layout renders into.
*/
id: string;
/**
* The second-screen window, needed to render tracks in its own realm.
*/
win: Window;
}
/**
* The avatar size, in pixels, for a camera-off featured participant.
*/
const FEATURED_AVATAR_SIZE = 160;
/**
* The styles, injected into the second window via its own Emotion cache.
*/
const useStyles = makeStyles()(() => {
return {
stage: {
display: 'flex',
flexDirection: 'row',
width: '100%',
height: '100%',
backgroundColor: '#292929',
overflow: 'hidden'
},
main: {
position: 'relative',
flex: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
minWidth: 0,
minHeight: 0
},
avatar: {
position: 'absolute',
top: '50%',
left: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transform: 'translate(-50%, -50%)'
},
nameOverlay: {
position: 'absolute',
bottom: 16,
left: '50%',
maxWidth: '50%',
padding: '6px 16px',
backgroundColor: 'rgba(0, 0, 0, 0.6)',
borderRadius: 3,
color: '#fff',
fontSize: 13,
lineHeight: '18px',
fontWeight: 600,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
transform: 'translateX(-50%)'
},
filmstrip: {
boxSizing: 'border-box',
width: 188,
flexShrink: 0,
overflow: 'hidden'
},
filmstripInner: {
boxSizing: 'border-box',
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: 8,
padding: 8,
backgroundColor: '#040404',
overflowX: 'hidden',
overflowY: 'auto',
scrollbarWidth: 'thin',
'&::-webkit-scrollbar': {
width: 8
},
'&::-webkit-scrollbar-thumb': {
backgroundColor: 'rgba(255, 255, 255, 0.2)',
borderRadius: 4
}
}
};
});
/**
* Re-renders the featured area only when the resolved track or participant
* actually changes, not on every unrelated redux update.
*
* @param {Object} a - The previous resolved source.
* @param {Object} b - The next resolved source.
* @returns {boolean}
*/
const _featuredEquals = (a: ReturnType<typeof resolveSource>, b: ReturnType<typeof resolveSource>) =>
a.track === b.track && a.participant === b.participant;
/**
* The stage layout for a second-screen window: one participant (or shared screen)
* featured large, with a filmstrip of everyone down the side. The featured
* participant follows the source ({@code role: 'stage'} tracks the active speaker;
* {@code participant: X} pins X). Clicking a filmstrip tile re-pins through the
* external API ({@code setSecondScreen}), so the API stays the single control
* plane. All video is rendered via the realm-safe {@link SecondScreenVideo} clone
* leaf, never {@code VideoTrack} (whose attach would cross window realms).
*
* @param {IProps} props - The component props.
* @returns {ReactElement}
*/
const SecondScreenStage = ({ id, win }: IProps) => {
const { classes } = useStyles();
const dispatch = useDispatch();
const store = useStore<IReduxState>();
const source = useSelector((state: IReduxState) => state['features/multi-screen'].screens[id]?.source);
const screenId = useSelector((state: IReduxState) => state['features/multi-screen'].screens[id]?.screenId);
const { participant: featured, track } = useSelector(
(state: IReduxState) => (source ? resolveSource(state, source) : { participant: undefined, track: null }),
_featuredEquals);
const featuredId = featured?.id;
const name = useSelector((state: IReduxState) => (featuredId ? getParticipantDisplayName(state, featuredId) : ''));
const participantIds = useSecondScreenParticipantIds();
const dominantSpeakerId = useDominantSpeakerId();
const pinnedId = source?.participant ?? null;
const hasFilmstrip = participantIds.length > 1;
const _onSelect = useCallback((participantId: string) => {
let next: ISecondScreenSource;
if (pinnedId === participantId) {
next = { role: 'stage' };
} else if (isScreenShareParticipantById(store.getState(), participantId)) {
next = { media: 'desktop', participant: participantId };
} else {
next = { media: 'camera', participant: participantId };
}
dispatch(setSecondScreen(id, next, screenId));
}, [ dispatch, id, pinnedId, screenId, store ]);
return (
<div className = { classes.stage }>
<div className = { classes.main }>
{ track ? (
<SecondScreenVideo
track = { track }
win = { win } />
) : (
<div className = { classes.avatar }>
<Avatar
participantId = { featuredId }
size = { FEATURED_AVATAR_SIZE } />
</div>
) }
{ name && (
<div className = { classes.nameOverlay }>
{ name }
</div>
) }
</div>
{ hasFilmstrip && (
<div className = { classes.filmstrip }>
<div className = { classes.filmstripInner }>
{ participantIds.map(pid => (
<SecondScreenStageTile
isActiveSpeaker = { pid === dominantSpeakerId }
key = { pid }
onSelect = { _onSelect }
participantId = { pid }
pinned = { pid === pinnedId }
win = { win } />
)) }
</div>
</div>
) }
</div>
);
};
export default SecondScreenStage;

View File

@@ -0,0 +1,135 @@
import React, { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useSelector } from 'react-redux';
import { makeStyles } from 'tss-react/mui';
import { IReduxState } from '../../app/types';
import Icon from '../../base/icons/components/Icon';
import { IconPin } from '../../base/icons/svg';
import { getParticipantDisplayName } from '../../base/participants/functions';
import SecondScreenTile from './SecondScreenTile';
/**
* The type of the React {@code Component} props of {@link SecondScreenStageTile}.
*/
interface IProps {
/**
* Whether this participant is the dominant speaker (drives the speaking ring).
*/
isActiveSpeaker: boolean;
/**
* Called with this tile's participant id when it is clicked, to feature it on
* the stage (or unfeature it when it is already pinned).
*/
onSelect: (participantId: string) => void;
/**
* The id of the participant (a person or a virtual screenshare) this tile
* previews and pins to the stage on click.
*/
participantId: string;
/**
* Whether this participant is explicitly pinned to the stage (shows the pin
* badge; a click then unpins).
*/
pinned: boolean;
/**
* The second-screen window, needed to render a track in its own realm.
*/
win: Window;
}
/**
* The filmstrip tile width and height in pixels (16:9).
*/
const TILE_WIDTH = 160;
const TILE_HEIGHT = 90;
/**
* The styles, injected into the second window via its own Emotion cache.
*/
const useStyles = makeStyles()(() => {
return {
item: {
position: 'relative',
flexShrink: 0,
padding: 0,
background: 'none',
border: '2px solid transparent',
borderRadius: 6,
cursor: 'pointer',
lineHeight: 0,
transition: 'border-color 0.2s ease',
'&:hover': {
borderColor: 'rgba(255, 255, 255, 0.4)'
}
},
pin: {
position: 'absolute',
top: 6,
right: 6,
zIndex: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: 4,
backgroundColor: 'rgba(0, 0, 0, 0.7)',
borderRadius: 4,
color: '#fff',
lineHeight: 0,
pointerEvents: 'none'
}
};
});
/**
* A clickable filmstrip tile in the second-screen stage layout: the shared {@link
* SecondScreenTile} wrapped in a button that pins the participant to the stage by
* dispatching {@code setSecondScreen} through the external-API control plane
* (handled by the parent's {@code onSelect}). A pin badge marks the pinned tile;
* clicking it again unpins. Memoized so a dominant-speaker or pin change only
* re-renders the affected tiles.
*
* @param {IProps} props - The component props.
* @returns {ReactElement}
*/
const SecondScreenStageTile = ({ isActiveSpeaker, onSelect, participantId, pinned, win }: IProps) => {
const { classes } = useStyles();
const { t } = useTranslation();
const name = useSelector((state: IReduxState) => getParticipantDisplayName(state, participantId));
const _onClick = useCallback(() => {
onSelect(participantId);
}, [ onSelect, participantId ]);
return (
<button
aria-label = { t(pinned ? 'multiScreen.unpinFromStage' : 'multiScreen.showOnStage', { name }) }
aria-pressed = { pinned }
className = { classes.item }
onClick = { _onClick }
type = 'button'>
<SecondScreenTile
height = { TILE_HEIGHT }
isActiveSpeaker = { isActiveSpeaker }
participantId = { participantId }
width = { TILE_WIDTH }
win = { win } />
{ pinned && (
<span className = { classes.pin }>
<Icon
color = '#fff'
size = { 14 }
src = { IconPin } />
</span>
) }
</button>
);
};
export default React.memo(SecondScreenStageTile);

View File

@@ -0,0 +1,147 @@
import React from 'react';
import { useSelector } from 'react-redux';
import { makeStyles } from 'tss-react/mui';
import { IReduxState } from '../../app/types';
import Avatar from '../../base/avatar/components/Avatar';
import {
getParticipantById,
getParticipantDisplayName,
isScreenShareParticipantById
} from '../../base/participants/functions';
import { getVideoTrackByParticipant } from '../../base/tracks/functions.any';
import SecondScreenVideo from './SecondScreenVideo';
/**
* The type of the React {@code Component} props of {@link SecondScreenTile}.
*/
interface IProps {
/**
* The tile height in pixels.
*/
height: number;
/**
* Whether this participant is the dominant speaker (drives the speaking ring).
*/
isActiveSpeaker: boolean;
/**
* The id of the participant (a person or a virtual screenshare) this tile
* previews.
*/
participantId: string;
/**
* The tile width in pixels.
*/
width: number;
/**
* The second-screen window, needed to render a track in its own realm.
*/
win: Window;
}
/**
* The styles, injected into the second window via its own Emotion cache (see
* {@code SecondScreenPortals}). The dominant-speaker ring colour matches the main
* window's thumbnail indicator (Jitsi's action01 token).
*/
const useStyles = makeStyles()(() => {
return {
tile: {
position: 'relative',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
boxSizing: 'border-box',
backgroundColor: '#292929',
border: '3px solid transparent',
borderRadius: 4,
overflow: 'hidden',
transition: 'border-color 0.2s ease'
},
speaking: {
borderColor: '#4687ED'
},
avatar: {
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
},
bottom: {
position: 'absolute',
bottom: 6,
left: 6,
display: 'flex',
alignItems: 'center',
gap: 4,
maxWidth: 'calc(100% - 12px)',
padding: '3px 6px',
backgroundColor: 'rgba(0, 0, 0, 0.6)',
borderRadius: 4,
color: '#fff'
},
name: {
minWidth: 0,
fontSize: 12,
lineHeight: '16px',
fontWeight: 600,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis'
}
};
});
/**
* Renders one participant's tile: their camera or shared screen (via the
* realm-safe {@link SecondScreenVideo} clone leaf) or their avatar, with their
* name and a dominant-speaker ring. Used both as a gallery cell and inside a
* stage filmstrip button. Memoized so a dominant-speaker change only re-renders
* the tiles whose ring flips.
*
* @param {IProps} props - The component props.
* @returns {ReactElement}
*/
const SecondScreenTile = ({ height, isActiveSpeaker, participantId, width, win }: IProps) => {
const { classes, cx } = useStyles();
const name = useSelector((state: IReduxState) => getParticipantDisplayName(state, participantId));
const isScreenshare = useSelector((state: IReduxState) => isScreenShareParticipantById(state, participantId));
const videoTrack = useSelector(
(state: IReduxState) => getVideoTrackByParticipant(state, getParticipantById(state, participantId)));
const track = videoTrack?.jitsiTrack && !videoTrack.muted
? (videoTrack.jitsiTrack.getTrack() as MediaStreamTrack) ?? null
: null;
const avatarSize = Math.max(24, Math.floor(Math.min(height / 2, width - 30, 150)));
return (
<div
className = { cx(classes.tile, isActiveSpeaker && classes.speaking) }
style = {{ height: `${height}px`, width: `${width}px` }}>
{ track ? (
<SecondScreenVideo
fit = { isScreenshare ? 'contain' : 'cover' }
track = { track }
win = { win } />
) : (
<div className = { classes.avatar }>
<Avatar
participantId = { participantId }
size = { avatarSize } />
</div>
) }
<div className = { classes.bottom }>
<span className = { classes.name }>
{ name }
</span>
</div>
</div>
);
};
export default React.memo(SecondScreenTile);

View File

@@ -0,0 +1,79 @@
import React, { useEffect, useRef } from 'react';
/**
* The type of the React {@code Component} props of {@link SecondScreenVideo}.
*/
interface IProps {
/**
* How the video fills its box: {@code contain} (default; never crops, used for
* the stage and shared screens) or {@code cover} (camera tiles).
*/
fit?: 'contain' | 'cover';
/**
* The native meeting track to render in the second window.
*/
track: MediaStreamTrack;
/**
* The second-screen window the video is portaled into. Its own
* {@code MediaStream} constructor is used so the (child-realm) {@code <video>}
* renders the meeting track by reference.
*/
win: Window;
}
/**
* Base full-bleed style for the second-screen video; object-fit is applied per
* instance from the {@code fit} prop.
*/
const VIDEO_STYLE: React.CSSProperties = {
background: '#000',
height: '100%',
width: '100%'
};
/**
* Renders a meeting track into the second-screen window by cloning it and
* wrapping the clone in that window's own {@code MediaStream}. This mirrors the
* feature's original imperative {@code renderTrack}, but driven by React so the
* clone is stopped automatically when the component unmounts or the track
* changes. Cloning into the window's own {@code MediaStream} (rather than
* attaching the meeting-realm stream directly) keeps the {@code srcObject} in the
* same window realm as the {@code <video>}.
*
* @param {IProps} props - The component props.
* @returns {ReactElement}
*/
const SecondScreenVideo = ({ fit = 'contain', track, win }: IProps) => {
const videoRef = useRef<HTMLVideoElement>(null);
useEffect(() => {
const video = videoRef.current;
let clone: MediaStreamTrack | undefined;
if (video) {
clone = track.clone();
video.srcObject = new (win as Window & { MediaStream: typeof MediaStream; }).MediaStream([ clone ]);
}
return () => {
clone?.stop();
if (video) {
video.srcObject = null;
}
};
}, [ track, win ]);
return (
<video
autoPlay = { true }
muted = { true }
playsInline = { true }
ref = { videoRef }
style = {{ ...VIDEO_STYLE, objectFit: fit }} />
);
};
export default SecondScreenVideo;

View File

@@ -0,0 +1,91 @@
import React from 'react';
import { useSelector } from 'react-redux';
import { IReduxState } from '../../app/types';
import SecondScreenGallery from './SecondScreenGallery';
import SecondScreenSharedVideo from './SecondScreenSharedVideo';
import SecondScreenSingle from './SecondScreenSingle';
import SecondScreenStage from './SecondScreenStage';
import SecondScreenWhiteboard from './SecondScreenWhiteboard';
/**
* The type of the React {@code Component} props of {@link SecondScreenView}.
*/
interface IProps {
/**
* The id of the second-screen window this view renders into.
*/
id: string;
/**
* The second-screen window, needed to render a track in its own realm.
*/
win: Window;
}
/**
* Routes a single second-screen window to a layout from its redux source
* descriptor. A {@code whiteboard} source renders the {@link SecondScreenWhiteboard}
* iframe; a {@code sharedvideo} source renders the meeting's shared video via
* {@link SecondScreenSharedVideo}; a {@code screenshare} source renders that
* screenshare full-bleed; a {@code tile} source renders the
* {@link SecondScreenGallery} grid; a stage or participant source renders the
* {@link SecondScreenStage} layout (featured participant plus filmstrip); any
* other source renders the single track/avatar via {@link SecondScreenSingle}.
* Each layout resolves its own state, so the potentially expensive
* {@code resolveSource} selector only runs where it is needed. Portaled into
* the second window by {@link SecondScreenPortals}.
*
* @param {IProps} props - The component props.
* @returns {ReactElement}
*/
const SecondScreenView = ({ id, win }: IProps) => {
const source = useSelector((state: IReduxState) => state['features/multi-screen'].screens[id]?.source);
if (source?.role === 'whiteboard') {
return <SecondScreenWhiteboard />;
}
if (source?.role === 'sharedvideo') {
return (
<SecondScreenSharedVideo
id = { id } />
);
}
// Before the participant check below: a screenshare names its virtual
// screenshare participant to say which one to show, but it is still shown
// full-bleed on its own rather than in the stage layout.
if (source?.role === 'screenshare') {
return (
<SecondScreenSingle
id = { id }
win = { win } />
);
}
if (source?.role === 'tile') {
return (
<SecondScreenGallery
win = { win } />
);
}
if (source && (source.role === 'stage' || Boolean(source.participant))) {
return (
<SecondScreenStage
id = { id }
win = { win } />
);
}
return (
<SecondScreenSingle
id = { id }
win = { win } />
);
};
export default SecondScreenView;

View File

@@ -0,0 +1,112 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { useSelector } from 'react-redux';
import { makeStyles } from 'tss-react/mui';
import { IReduxState } from '../../app/types';
import { getURLWithoutParams } from '../../base/connection/utils';
import { getLocalParticipant } from '../../base/participants/functions';
import {
getCollabDetails,
getCollabServerUrl,
getWhiteboardInfoForURIString,
isWhiteboardEnabled,
isWhiteboardOpen
} from '../../whiteboard/functions';
/**
* The styles, injected into the second window via its own Emotion cache.
*/
const useStyles = makeStyles()(() => {
return {
whiteboard: {
width: '100%',
height: '100%',
border: 'none',
backgroundColor: '#fff'
},
placeholder: {
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#040404'
},
message: {
fontSize: 16,
color: 'rgba(255, 255, 255, 0.5)'
}
};
});
/**
* Renders the meeting's shared whiteboard on a second screen by loading the
* standalone whiteboard page ({@code static/whiteboard.html}) in an iframe,
* pointed at the same collaboration room as the meeting. The iframe runs the
* whiteboard (Excalidraw) in its own browsing context, so there are no
* cross-window-realm issues, and it stays live via the shared collab server.
* This is the kind of non-video content the portal makes possible and a
* {@code <video>} second screen cannot. Requires the whiteboard to be enabled and
* currently open in the meeting; otherwise a hint is shown.
*
* @returns {ReactElement}
*/
const SecondScreenWhiteboard = () => {
const { classes } = useStyles();
const { t } = useTranslation();
const whiteboardEnabled = useSelector(isWhiteboardEnabled);
const whiteboardOpen = useSelector(isWhiteboardOpen);
const collabDetails = useSelector(getCollabDetails);
const collabServerUrl = useSelector(getCollabServerUrl);
const locationURL = useSelector((state: IReduxState) => state['features/base/connection'].locationURL);
const defaultRemoteDisplayName = useSelector(
(state: IReduxState) => state['features/base/config'].defaultRemoteDisplayName);
const localParticipantName
= useSelector((state: IReduxState) => getLocalParticipant(state)?.name)
|| defaultRemoteDisplayName || 'Fellow Jitster';
// Gate on isWhiteboardOpen, not just on the collaboration details: closing the
// whiteboard only flips `isOpen`, the details live on until RESET_WHITEBOARD
// (conference leave), so keying off them alone would keep a closed whiteboard
// on the second screen.
//
// Build the standalone whiteboard URL from the canonical meeting location
// (base/connection), not window.location: the latter can carry a
// `#config.x=...` fragment whose own `/` characters break the path
// derivation in getWhiteboardInfoForURIString. Strip hash/query first.
const url = whiteboardEnabled && whiteboardOpen && collabDetails && collabServerUrl && locationURL
? getWhiteboardInfoForURIString(
getURLWithoutParams(locationURL).href,
collabServerUrl,
collabDetails,
localParticipantName)
: undefined;
if (!url) {
return (
<div className = { classes.placeholder }>
<div className = { classes.message }>
{ t('multiScreen.whiteboardInactive') }
</div>
</div>
);
}
// Scope the embedded whiteboard, mirroring the native WebView's no-multi-window
// and navigation-interception scoping: allow the same-origin app to run and
// export drawings, but block top-level navigation and form submission.
// allow-same-origin is required for the collab/storage session; combined with
// allow-scripts the boundary is soft for same-origin content, so this is
// defense-in-depth rather than a hard sandbox.
return (
<iframe
allow = 'clipboard-read; clipboard-write'
className = { classes.whiteboard }
sandbox = 'allow-same-origin allow-scripts allow-downloads allow-popups'
src = { url }
title = { t('multiScreen.whiteboardTitle') } />
);
};
export default SecondScreenWhiteboard;

View File

@@ -0,0 +1,78 @@
import React, { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { IconScreenshare } from '../../base/icons/svg';
import ContextMenuItem from '../../base/ui/components/web/ContextMenuItem';
import { NOTIFY_CLICK_MODE } from '../../toolbox/types';
import { IButtonProps } from '../../video-menu/types';
import { useSendToSecondScreen } from '../hooks.web';
import { ISecondScreenSource } from '../types';
interface IProps extends IButtonProps {
/**
* Button text class name.
*/
className?: string;
/**
* Whether the icon should be hidden or not.
*/
noIcon?: boolean;
/**
* Click handler executed aside from the main action.
*/
onClick?: Function;
/**
* What to show on the second screen.
*/
source: ISecondScreenSource;
}
/**
* A context-menu entry that sends its source to a second screen, and takes it
* off again once it is there. Renders nothing when the feature is unavailable,
* so it can be added unconditionally to a menu.
*
* @param {IProps} props - The component props.
* @returns {ReactElement | null}
*/
const SendToSecondScreenButton = ({
className,
noIcon = false,
notifyClick,
notifyMode,
onClick,
source
}: IProps) => {
const { t } = useTranslation();
const { active, onClick: sendToSecondScreen, visible } = useSendToSecondScreen(source);
const _onClick = useCallback(() => {
notifyClick?.();
if (notifyMode === NOTIFY_CLICK_MODE.PREVENT_AND_NOTIFY) {
return;
}
sendToSecondScreen();
onClick?.();
}, [ notifyClick, notifyMode, onClick, sendToSecondScreen ]);
if (!visible) {
return null;
}
const text = t(active ? 'multiScreen.removeFromSecondScreen' : 'multiScreen.sendToSecondScreen');
return (
<ContextMenuItem
accessibilityLabel = { text }
icon = { noIcon ? null : IconScreenshare }
onClick = { _onClick }
text = { text }
textClassName = { className } />
);
};
export default SendToSecondScreenButton;

View File

@@ -0,0 +1,96 @@
import React, { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { makeStyles } from 'tss-react/mui';
import { isMobileBrowser } from '../../base/environment/utils';
import { IconScreenshare } from '../../base/icons/svg';
import Button from '../../base/ui/components/web/Button';
import { useSendToSecondScreen } from '../hooks.web';
import { ISecondScreenSource } from '../types';
interface IProps {
/**
* Class name for the button's wrapper. Applied here rather than to a
* container around this component so that a thumbnail that positions the
* trigger itself does not lay out an empty box over its own corner on the
* deployments where the feature is off and nothing renders.
*/
className?: string;
/**
* What to show on the second screen.
*/
source: ISecondScreenSource;
/**
* Whether the tile is hovered, i.e. whether to show the button at all.
*/
visible: boolean;
}
const useStyles = makeStyles()(() => {
return {
button: {
padding: '3px !important',
borderRadius: '4px',
'& svg': {
width: '18px',
height: '18px'
}
}
};
});
/**
* The tile counterpart of {@link SendToSecondScreenButton}, for the thumbnails
* that have no context menu to add an entry to (a screenshare, the shared
* video). It sits where the menu trigger sits on the other thumbnails and, like
* it, appears on hover.
*
* @param {IProps} props - The component props.
* @returns {ReactElement | null}
*/
const SendToSecondScreenIcon = ({ className, source, visible }: IProps) => {
const { classes } = useStyles();
const { t } = useTranslation();
const { active, onClick, visible: enabled } = useSendToSecondScreen(source);
// The thumbnails this sits on are themselves clickable (they pin), so the
// click must not reach them.
const _onClick = useCallback((e?: React.MouseEvent) => {
e?.stopPropagation();
onClick();
}, [ onClick ]);
const _onKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.stopPropagation();
}
}, []);
// Stays put while its source is on a second screen, even once the pointer
// leaves: it is the only way to take it off again, and a control that
// vanishes on unhover would leave the tile with no sign that it is being
// shown elsewhere.
if (!enabled || (!visible && !active) || isMobileBrowser()) {
return null;
}
return (
<span
className = { className }
onKeyDown = { _onKeyDown }>
<Button
accessibilityLabel = {
t(active ? 'multiScreen.removeFromSecondScreen' : 'multiScreen.sendToSecondScreen') }
className = { classes.button }
icon = { IconScreenshare }
onClick = { _onClick }
size = 'small' />
</span>
);
};
export default SendToSecondScreenIcon;

View File

@@ -0,0 +1,13 @@
/**
* The default window id used when the embedder does not specify one.
*/
export const DEFAULT_SECOND_SCREEN_ID = 'default';
/**
* The id prefix of the second-screen windows opened from the in-app triggers,
* as opposed to the ones an embedder opens through the external API. The
* in-app triggers only ever take over a window of their own (see
* {@code pickSecondScreenTarget}), so an embedder's windows keep showing what
* the embedder put on them.
*/
export const UI_SECOND_SCREEN_ID_PREFIX = 'ui-screen-';

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,113 @@
import { useCallback, useMemo } from 'react';
import { useSelector, useStore } from 'react-redux';
import { IReduxState } from '../app/types';
import {
getDominantSpeakerParticipant,
getLocalParticipant,
getLocalScreenShareParticipant
} from '../base/participants/functions';
import { removeSecondScreen, setSecondScreen } from './actions.web';
import {
getSecondScreenShowing,
isSecondScreenEnabled,
pickSecondScreenTarget
} from './functions.web';
import { ISecondScreenSource } from './types';
/**
* Returns the ordered participant ids for a second-screen layout: the local
* participant first, then your own shared screen (a virtual participant) when
* sharing, then the remote participants. Mirrors the main filmstrip ordering.
*
* The remote ids come from the filmstrip's {@code remoteParticipants} array,
* which is reassigned immutably on join/leave (the base participants map is
* mutated in place, so selecting it directly would not re-render on membership
* changes).
*
* @returns {string[]}
*/
export function useSecondScreenParticipantIds(): string[] {
const localId = useSelector((state: IReduxState) => getLocalParticipant(state)?.id);
const localScreenShareId = useSelector((state: IReduxState) => getLocalScreenShareParticipant(state)?.id);
const remoteParticipantIds = useSelector((state: IReduxState) => state['features/filmstrip'].remoteParticipants);
return useMemo(() => {
const ids: string[] = [];
if (localId) {
ids.push(localId);
}
if (localScreenShareId) {
ids.push(localScreenShareId);
}
return ids.concat(remoteParticipantIds);
}, [ localId, localScreenShareId, remoteParticipantIds ]);
}
/**
* Returns the id of the conference dominant speaker, or {@code null}. Drives the
* speaking ring on the second-screen tiles.
*
* @returns {string | null}
*/
export function useDominantSpeakerId(): string | null {
return useSelector((state: IReduxState) => getDominantSpeakerParticipant(state)?.id ?? null);
}
/**
* Backs the in-app "send to second screen" triggers: whether to show one,
* whether the source is already on a screen, and the handler that toggles it.
* The handler dispatches {@code setSecondScreen}, the same action the external
* API dispatches, so the API stays the single control plane;
* {@link pickSecondScreenTarget} chooses which window it lands on.
*
* The trigger is shown whenever the feature is enabled and the browser can
* place a window on another screen, without checking that a second monitor is
* actually attached: that answer needs the window-management permission, and
* the click itself is what should ask for it. With no second monitor the window
* opens on the current screen.
*
* @param {ISecondScreenSource} source - What to send.
* @returns {Object} Whether the trigger is visible, whether its source is
* already on a second screen, and its click handler.
*/
export function useSendToSecondScreen(source: ISecondScreenSource): {
active: boolean; onClick: () => void; visible: boolean;
} {
const store = useStore<IReduxState>();
const visible = useSelector(isSecondScreenEnabled);
const activeId = useSelector((state: IReduxState) => getSecondScreenShowing(state, source));
const onClick = useCallback(() => {
// Already on a screen: the trigger turns it off again. This is the only
// way to close an in-app second screen from the meeting window, short of
// closing the popup by hand on the other display.
if (activeId) {
store.dispatch(removeSecondScreen(activeId));
return;
}
// Dispatch synchronously, always. window.open has to run in this click's
// own task for the popup blocker to see it as user-initiated, and on a
// profile that has not granted window-management yet, obtaining the
// screen details here would spend that activation waiting on a
// permission prompt: Chromium expires it after 5s and the prompt sits
// until the user answers it. The open asks for the permission itself and
// places the window once it has an answer (see openSecondScreenWindow),
// which is also why the target picked here has no screen index on that
// first send: without the details there are no screens to choose between,
// and the open puts it on the first external one.
const { id, screenId } = pickSecondScreenTarget(store.getState());
store.dispatch(setSecondScreen(id, source, screenId));
}, [ activeId, source, store ]);
return { active: Boolean(activeId),
onClick,
visible };
}

View File

@@ -0,0 +1,3 @@
import { getLogger } from '../base/logging/functions';
export default getLogger('app:multi-screen');

View File

@@ -0,0 +1,93 @@
import { IStore } from '../app/types';
import { CONFERENCE_FAILED, CONFERENCE_JOINED, CONFERENCE_LEFT } from '../base/conference/actionTypes';
import MiddlewareRegistry from '../base/redux/MiddlewareRegistry';
import { REMOVE_SECOND_SCREEN, RESET_SECOND_SCREENS, SET_SECOND_SCREEN } from './actionTypes';
import { resetSecondScreens } from './actions.web';
import {
closeAllSecondScreens,
closeSecondScreenHandle,
getHandle,
handleSecondScreenOpenError,
isSecondScreenEnabled,
openOrUpdateSecondScreen,
preloadScreenDetails
} from './functions.web';
import './subscriber.web';
/**
* Middleware that reconciles the live second-screen windows with the requested
* state, tears them all down when the conference ends, and closes any that are
* still open when the main window itself goes away.
*
* @param {IStore} store - The redux store.
* @returns {Function}
*/
MiddlewareRegistry.register((store: IStore) => {
// Close every second-screen window when the main window goes away (tab closed,
// reloaded, or navigated away). The CONFERENCE_LEFT/FAILED path below only
// covers leaving the meeting from within the app; closing the main window
// directly dispatches neither, so without this the popups are orphaned on the
// second display. Closing from an unloading opener is best-effort. Skip
// event.persisted (bfcache eviction): the page may be restored via pageshow
// with the second screens still expected, so only tear down on a real unload.
window.addEventListener('pagehide', (event: PageTransitionEvent) => {
if (!event.persisted) {
closeAllSecondScreens(store);
}
});
return next => action => {
switch (action.type) {
case SET_SECOND_SCREEN: {
const result = next(action);
openOrUpdateSecondScreen(store, action.id, action.screenId)
.catch(e => handleSecondScreenOpenError(store, action.id, e));
return result;
}
case REMOVE_SECOND_SCREEN: {
// Capture the handle, then remove the entry first so the portal unmounts
// (stopping its cloned track) while the window is still open; only then
// close the window.
const handle = getHandle(store.getState(), action.id);
const result = next(action);
closeSecondScreenHandle(handle, action.id);
return result;
}
case RESET_SECOND_SCREENS:
// Same ordering: close the windows while their entries are still in state.
closeAllSecondScreens(store);
return next(action);
case CONFERENCE_JOINED: {
const result = next(action);
// Warm the screen details so an in-app trigger can open its window
// without awaiting, which is what keeps the click's user activation
// (see openOrUpdateSecondScreen). Never prompts on its own.
if (isSecondScreenEnabled(store.getState())) {
preloadScreenDetails();
}
return result;
}
case CONFERENCE_FAILED:
case CONFERENCE_LEFT:
// Resetting closes every second-screen window (via the RESET_SECOND_SCREENS
// case above), so there is no need to close them explicitly here.
store.dispatch(resetSecondScreens());
break;
}
return next(action);
};
});

View File

@@ -0,0 +1,81 @@
import ReducerRegistry from '../base/redux/ReducerRegistry';
import {
REMOVE_SECOND_SCREEN,
RESET_SECOND_SCREENS,
SET_SECOND_SCREEN,
SET_SECOND_SCREEN_WINDOW
} from './actionTypes';
import { ISecondScreenEntry } from './types';
export interface IMultiScreenState {
/**
* The second-screen windows, keyed by window id. This is the single source
* of truth for the feature: each entry holds both the configuration (source
* + target screen) and the live window handle. Redux state is never
* serialized for storage here, so the non-serializable handle (the
* {@code Window}, its {@code <video>} and tracks) lives on the entry too,
* rather than in a separate module-scoped map; the middleware reconciles the
* live windows to this state.
*/
screens: { [id: string]: ISecondScreenEntry; };
}
const DEFAULT_STATE: IMultiScreenState = {
screens: {}
};
ReducerRegistry.register<IMultiScreenState>('features/multi-screen',
(state = DEFAULT_STATE, action): IMultiScreenState => {
switch (action.type) {
case SET_SECOND_SCREEN:
// Merge so a re-configuration of an existing window keeps its live handle.
return {
...state,
screens: {
...state.screens,
[action.id]: {
...state.screens[action.id],
source: action.source,
// Keep the screen an existing window was configured for when
// the new request names none: re-sourcing a window (a
// setSecondScreen with no `screen`) says what it should show,
// not that it should go back to the default screen. Dropping
// it would also be read as "wherever a window with no screen
// goes" everywhere the id is used.
screenId: action.screenId ?? state.screens[action.id]?.screenId,
setAt: action.setAt
}
}
};
case SET_SECOND_SCREEN_WINDOW: {
const entry = state.screens[action.id];
if (!entry) {
return state;
}
return {
...state,
screens: {
...state.screens,
[action.id]: { ...entry, handle: action.handle }
}
};
}
case REMOVE_SECOND_SCREEN: {
const screens = { ...state.screens };
delete screens[action.id];
return { ...state, screens };
}
case RESET_SECOND_SCREENS:
return { ...state, screens: {} };
}
return state;
});

View File

@@ -0,0 +1,18 @@
import StateListenerRegistry from '../base/redux/StateListenerRegistry';
import { getSecondScreenSignature, refreshSecondScreens } from './functions.web';
/**
* Re-applies the second-screen sources whenever what they should render changes
* — most importantly when the active speaker switches (the {@code role: 'stage'}
* source resolves to a different track), so the window's {@code srcObject} is
* swapped in place without ever re-opening it.
*/
StateListenerRegistry.register(
/* selector */ state => getSecondScreenSignature(state),
/* listener */ (signature, store) => {
if (signature) {
refreshSecondScreens(store);
}
}
);

View File

@@ -0,0 +1,66 @@
/**
* Describes what a second-screen window should render. Resolved to an actual
* media track by the feature and kept live (e.g. {@code role: 'stage'} follows
* the active speaker). Because an iframe embedder has no access to the media
* tracks (they live in the meeting document), an explicit selection can only
* name a participant — never a stream — which the feature resolves internally.
*/
export interface ISecondScreenSource {
/**
* The media type of an explicitly pinned participant. Defaults to camera.
*/
media?: 'camera' | 'desktop';
/**
* The id of an explicitly pinned participant to render. On its own it
* selects the stage layout with that participant featured; combined with
* {@code role: 'screenshare'} it names which screenshare to render, so a
* specific one can be shown full-bleed when several are live at once.
*/
participant?: string;
/**
* What the window shows: the active-speaker stage, a screenshare, a tile
* grid of every participant, the shared whiteboard, or the video (e.g.
* YouTube) shared in the meeting.
*/
role?: 'stage' | 'screenshare' | 'tile' | 'whiteboard' | 'sharedvideo';
}
/**
* A single second-screen entry: its configuration plus its live window handle.
* This is the single source of truth for the feature; the middleware reconciles
* the real windows to it.
*/
export interface ISecondScreenEntry {
/**
* The live, non-serializable window handle (the {@code Window}, its
* {@code <video>} and the cloned/avatar tracks), populated by the middleware
* once the window is open. Typed opaquely here because the shared (and
* React Native) build has no DOM lib; its real shape is {@code ISecondScreenHandle}
* in {@code functions.web.ts}, where it is read back with a cast.
*/
handle?: unknown;
/**
* The (optional) index of the physical screen to place the window on, as
* reported by the Window Management API.
*/
screenId?: number;
/**
* When this entry's source was last set. Used to pick which of the in-app
* windows to take over once every screen already has one (the least
* recently targeted). Absent for an entry configured by dispatching the
* raw action rather than through {@code setSecondScreen}, which counts as
* the oldest.
*/
setAt?: number;
/**
* What the window renders.
*/
source: ISecondScreenSource;
}