feat(shared-video): show shared video in tile view and default to tile layout

Remove auto-exit from tile view when video is shared, stop auto-pinning
the video participant, and render shared videos as embedded tiles in the
filmstrip. Users can still click to pin/maximize the video to stage view.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-04-10 16:47:52 -04:00
parent 2fe77055a9
commit 2984a2944c
8 changed files with 216 additions and 17 deletions

63
css/_shared_video.scss Normal file
View File

@ -0,0 +1,63 @@
/**
* Styles for the shared video tile in the filmstrip.
*/
.shared-video-tile {
position: relative;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: #000;
border-radius: 8px;
overflow: hidden;
}
.shared-video-player-wrapper {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 1;
iframe,
video {
width: 100%;
height: 100%;
border: none;
}
}
.shared-video-controls-overlay {
position: absolute;
bottom: 0;
left: 0;
width: 100%;
padding: 8px;
background: linear-gradient(transparent, rgba(0, 0, 0, 0.7));
z-index: 2;
pointer-events: none;
}
.shared-video-title {
color: #fff;
font-size: 12px;
font-weight: 500;
text-align: center;
padding: 4px 8px;
max-width: 90%;
margin: 0 auto;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
background: rgba(0, 0, 0, 0.6);
border-radius: 4px;
}
/* Ensure iframe is interactive for video controls */
.shared-video-player-wrapper iframe,
.shared-video-player-wrapper video {
pointer-events: auto;
}

View File

@ -77,6 +77,7 @@ $flagsImagePath: "../images/";
@import 'reactions-menu';
@import 'plan-limit';
@import 'shared_music';
@import 'shared_video';
@import 'meeting_intelligence';
/* Modules END */

View File

@ -24,6 +24,7 @@ import {
isLocalScreenshareParticipant,
isScreenShareParticipant,
isSharedMusicParticipant,
isSharedVideoParticipant,
isWhiteboardParticipant
} from '../../../base/participants/functions';
import { IParticipant } from '../../../base/participants/types';
@ -41,6 +42,7 @@ import { hideGif, showGif } from '../../../gifs/actions';
import { getGifDisplayMode, getGifForParticipant } from '../../../gifs/functions';
import PresenceLabel from '../../../presence-status/components/PresenceLabel';
import { SharedMusicTile } from '../../../shared-music/components';
import { SharedVideoTile } from '../../../shared-video/components';
import { LAYOUTS } from '../../../video-layout/constants';
import { getCurrentLayout } from '../../../video-layout/functions.web';
import { togglePinStageParticipant } from '../../actions';
@ -934,6 +936,39 @@ class Thumbnail extends Component<IProps, IState> {
);
}
/**
* Renders a shared video participant thumbnail with embedded video player.
*
* @returns {ReactElement}
*/
_renderSharedVideoParticipant() {
const { _isMobile, _participant } = this.props;
const { id, pinned, name } = _participant;
const styles = this._getStyles();
const containerClassName = this._getContainerClassName();
return (
<span
aria-label = { this.props.t(pinned ? 'unpinParticipant' : 'pinParticipant', {
participantName: name
}) }
className = { containerClassName }
id = 'sharedVideoTileContainer'
onClick = { this._onClick }
onKeyDown = { this._onTogglePinButtonKeyDown }
{ ...(_isMobile ? {} : {
onMouseEnter: this._onMouseEnter,
onMouseMove: this._onMouseMove,
onMouseLeave: this._onMouseLeave
}) }
role = 'button'
style = { styles.thumbnail }
tabIndex = { 0 }>
<SharedVideoTile participantId = { id } />
</span>
);
}
/**
* Renders the avatar.
*
@ -1215,6 +1250,11 @@ class Thumbnail extends Component<IProps, IState> {
return this._renderSharedMusicParticipant();
}
// Render SharedVideo with embedded player in tile view
if (isSharedVideoParticipant(_participant)) {
return this._renderSharedVideoParticipant();
}
if (fakeParticipant
&& !isWhiteboardParticipant(_participant)
&& !_isVirtualScreenshareParticipant

View File

@ -1,3 +1,4 @@
export { default as SharedVideoDialog } from './web/SharedVideoDialog';
export { default as SharedVideoButton } from './web/SharedVideoButton';
export { default as ShareVideoConfirmDialog } from './web/ShareVideoConfirmDialog';
export { default as SharedVideoTile } from './web/SharedVideoTile';

View File

@ -131,11 +131,13 @@ class SharedVideo extends Component<IProps> {
}
if (EMBED_VIDEO_TYPES.includes(sourceType as any)) {
return (<EmbedVideoManager
// @ts-ignore
sourceType = { sourceType }
// @ts-ignore
videoId = { videoUrl } />);
return (
<EmbedVideoManager
// @ts-ignore
sourceType = { sourceType }
// @ts-ignore
videoId = { videoUrl } />
);
}
return <VideoManager videoId = { videoUrl } />;
@ -150,16 +152,12 @@ class SharedVideo extends Component<IProps> {
override render() {
const { isEnabled, isResizing, isVideoShared, onStage } = this.props;
if (!isEnabled || !isVideoShared) {
if (!isEnabled || !isVideoShared || !onStage) {
return null;
}
const style: any = this.getDimensions();
if (!onStage) {
style.display = 'none';
}
return (
<div
className = { (isResizing && 'disable-pointer') || '' }

View File

@ -0,0 +1,101 @@
import React from 'react';
import { useSelector } from 'react-redux';
import { IReduxState } from '../../../app/types';
import { VIDEO_SOURCE_TYPES } from '../../constants';
import { getVideoEmbedUrl, getVideoSourceType, isVideoPlaying } from '../../functions';
interface IProps {
/**
* The participant ID (video URL or YouTube ID).
*/
participantId: string;
}
/**
* Component that renders a shared video tile in the filmstrip.
* Displays the embedded video player inside the filmstrip thumbnail.
*
* @param {IProps} props - The component props.
* @returns {React.ReactElement | null}
*/
const SharedVideoTile: React.FC<IProps> = ({ participantId }) => {
const { videoUrl } = useSelector(
(state: IReduxState) => state['features/shared-video']
);
const isShared = useSelector((state: IReduxState) => isVideoPlaying(state));
if (!isShared || participantId !== videoUrl) {
return null;
}
const sourceType = getVideoSourceType(videoUrl ?? '');
const renderPlayer = () => {
if (!videoUrl) {
return null;
}
if (sourceType === VIDEO_SOURCE_TYPES.YOUTUBE) {
// videoUrl is a YouTube ID (not a full URL) for YouTube videos
const youtubeId = videoUrl.match(/^https?:\/\//) ? null : videoUrl;
const embedId = youtubeId ?? videoUrl;
return (
<iframe
allow = 'autoplay; encrypted-media'
allowFullScreen = { true }
frameBorder = '0'
height = '100%'
src = { `https://www.youtube.com/embed/${embedId}?autoplay=1&controls=1&rel=0` }
title = 'Shared Video'
width = '100%' />
);
}
if (sourceType === VIDEO_SOURCE_TYPES.VIMEO
|| sourceType === VIDEO_SOURCE_TYPES.DAILYMOTION
|| sourceType === VIDEO_SOURCE_TYPES.TWITCH) {
const embedUrl = getVideoEmbedUrl(videoUrl, sourceType);
return (
<iframe
allow = 'autoplay; encrypted-media; fullscreen'
allowFullScreen = { true }
frameBorder = '0'
height = '100%'
src = { embedUrl }
title = 'Shared Video'
width = '100%' />
);
}
// Direct video URL
return (
<video
autoPlay = { true }
controls = { true }
height = '100%'
src = { videoUrl }
width = '100%'>
<track kind = 'captions' />
</video>
);
};
return (
<div className = 'shared-video-tile'>
<div className = 'shared-video-player-wrapper'>
{renderPlayer()}
</div>
<div className = 'shared-video-controls-overlay'>
<div className = 'shared-video-title'>
{'Shared Video'}
</div>
</div>
</div>
);
};
export default SharedVideoTile;

View File

@ -7,7 +7,7 @@ import { IJitsiConference } from '../base/conference/reducer';
import { SET_CONFIG } from '../base/config/actionTypes';
import { MEDIA_TYPE } from '../base/media/constants';
import { PARTICIPANT_LEFT } from '../base/participants/actionTypes';
import { participantJoined, participantLeft, pinParticipant } from '../base/participants/actions';
import { participantJoined, participantLeft } from '../base/participants/actions';
import { getLocalParticipant, getParticipantById, getParticipantDisplayName } from '../base/participants/functions';
import { FakeParticipant } from '../base/participants/types';
import MiddlewareRegistry from '../base/redux/MiddlewareRegistry';
@ -266,8 +266,6 @@ function handleSharingVideoStatus(store: IStore, videoUrl: string,
name: VIDEO_PLAYER_PARTICIPANT_NAME
}));
dispatch(pinParticipant(videoUrl));
if (localParticipantId === from) {
dispatch(setSharedVideoStatus({
videoUrl,

View File

@ -5,7 +5,7 @@ import { pinParticipant } from '../base/participants/actions';
import { getParticipantCount, getPinnedParticipant } from '../base/participants/functions';
import { FakeParticipant } from '../base/participants/types';
import { isStageFilmstripAvailable, isTileViewModeDisabled } from '../filmstrip/functions';
import { isVideoPlaying } from '../shared-video/functions';
// isVideoPlaying no longer used to force exit from tile view
import { VIDEO_QUALITY_LEVELS } from '../video-quality/constants';
import { getReceiverVideoQualityLevel } from '../video-quality/functions';
import { getMinHeightForQualityLvlMap } from '../video-quality/selector';
@ -100,9 +100,6 @@ export function shouldDisplayTileView(state: IReduxState) {
// It's a 1-on-1 meeting
|| participantCount < 3
// There is a shared YouTube video in the meeting
|| isVideoPlaying(state)
// We want jibri to use stage view by default
|| iAmRecorder
);