snapshot of https://github.com/jitsi/jitsi-meet.git @ fd1749dd28f4138aa80234fc7f311b19aaffa0e7 (2026-09-15)
This commit is contained in:
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
211
react/features/multi-screen/components/SecondScreenStage.web.tsx
Normal file
211
react/features/multi-screen/components/SecondScreenStage.web.tsx
Normal 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;
|
||||
@@ -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);
|
||||
147
react/features/multi-screen/components/SecondScreenTile.web.tsx
Normal file
147
react/features/multi-screen/components/SecondScreenTile.web.tsx
Normal 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);
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user