feat(shared-music): display YouTube video in tile view with controls

- Add SharedMusicTile component for rendering video in filmstrip
- Create fake participant for shared music (shows as tile, not pinned)
- Make YouTube player visible with proper dimensions in tile
- Remove auto-mute behavior when sharing music
- Add SharedMusic to FakeParticipant enum
- Add isSharedMusicParticipant helper function
- Move player rendering from LargeVideo to tile
- Add CSS styles for player wrapper and controls overlay

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-02-05 16:29:59 +00:00
parent b6fc0ae0b5
commit 751a04b950
11 changed files with 346 additions and 67 deletions

142
css/_shared_music.scss Normal file
View File

@ -0,0 +1,142 @@
/**
* Styles for the shared music tile in the filmstrip.
*/
.shared-music-tile {
position: relative;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
border-radius: 8px;
overflow: hidden;
}
.shared-music-player-wrapper {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 1;
#sharedMusic {
width: 100%;
height: 100%;
.youtube-player-container {
width: 100%;
height: 100%;
iframe {
width: 100%;
height: 100%;
border: none;
}
}
}
}
.shared-music-audio-bg {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex;
align-items: center;
justify-content: center;
&::before {
content: '🎵';
font-size: 48px;
opacity: 0.5;
}
}
.shared-music-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-music-title,
.shared-music-control-button,
.shared-music-status {
pointer-events: auto;
}
}
.shared-music-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;
}
.shared-music-control-button {
width: 48px;
height: 48px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.9);
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
margin: 8px auto 0;
&:hover {
background: #fff;
transform: scale(1.1);
}
&:active {
transform: scale(0.95);
}
svg {
width: 24px;
height: 24px;
fill: #1a1a2e;
}
}
.shared-music-status {
color: #fff;
font-size: 12px;
font-weight: 500;
padding: 4px 8px;
background: rgba(0, 0, 0, 0.6);
border-radius: 4px;
text-align: center;
margin-top: 8px;
}
/* Ensure YouTube iframe is interactive */
.shared-music-player-wrapper iframe {
pointer-events: auto;
}
/* Hide the old styles that are no longer needed */
.shared-music-thumbnail,
.shared-music-overlay {
display: none;
}

View File

@ -76,5 +76,6 @@ $flagsImagePath: "../images/";
@import 'participants-pane';
@import 'reactions-menu';
@import 'plan-limit';
@import 'shared_music';
/* Modules END */

View File

@ -349,6 +349,16 @@ export function isWhiteboardParticipant(participant?: IParticipant): boolean {
return participant?.fakeParticipant === FakeParticipant.Whiteboard;
}
/**
* Returns true if the passed in participant is a shared music participant.
*
* @param {IParticipant|undefined} participant - The participant entity.
* @returns {boolean} - True if it's a shared music participant.
*/
export function isSharedMusicParticipant(participant?: IParticipant): boolean {
return participant?.fakeParticipant === FakeParticipant.SharedMusic;
}
/**
* Returns a count of the known remote participants in the passed in redux state.
*

View File

@ -3,6 +3,7 @@ import { IJitsiConference } from '../conference/reducer';
export enum FakeParticipant {
LocalScreenShare = 'LocalScreenShare',
RemoteScreenShare = 'RemoteScreenShare',
SharedMusic = 'SharedMusic',
SharedVideo = 'SharedVideo',
Whiteboard = 'Whiteboard'
}

View File

@ -23,6 +23,7 @@ import {
hasRaisedHand,
isLocalScreenshareParticipant,
isScreenShareParticipant,
isSharedMusicParticipant,
isWhiteboardParticipant
} from '../../../base/participants/functions';
import { IParticipant } from '../../../base/participants/types';
@ -39,6 +40,7 @@ import { getVideoObjectPosition } from '../../../face-landmarks/functions';
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 { LAYOUTS } from '../../../video-layout/constants';
import { getCurrentLayout } from '../../../video-layout/functions.web';
import { togglePinStageParticipant } from '../../actions';
@ -907,6 +909,39 @@ class Thumbnail extends Component<IProps, IState> {
);
}
/**
* Renders a shared music participant thumbnail with video and controls.
*
* @returns {ReactElement}
*/
_renderSharedMusicParticipant() {
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 = 'sharedMusicContainer'
onClick = { this._onClick }
onKeyDown = { this._onTogglePinButtonKeyDown }
{ ...(_isMobile ? {} : {
onMouseEnter: this._onMouseEnter,
onMouseMove: this._onMouseMove,
onMouseLeave: this._onMouseLeave
}) }
role = 'button'
style = { styles.thumbnail }
tabIndex = { 0 }>
<SharedMusicTile participantId = { id } />
</span>
);
}
/**
* Renders the avatar.
*
@ -1183,6 +1218,11 @@ class Thumbnail extends Component<IProps, IState> {
return this._renderParticipant(true);
}
// Render SharedMusic with custom tile that shows video/controls
if (isSharedMusicParticipant(_participant)) {
return this._renderSharedMusicParticipant();
}
if (fakeParticipant
&& !isWhiteboardParticipant(_participant)
&& !_isVirtualScreenshareParticipant

View File

@ -15,7 +15,6 @@ import { isSpotTV } from '../../base/util/spot';
import StageParticipantNameLabel from '../../display-name/components/web/StageParticipantNameLabel';
import { FILMSTRIP_BREAKPOINT } from '../../filmstrip/constants';
import { getVerticalViewMaxWidth, isFilmstripResizable } from '../../filmstrip/functions.web';
import { SharedMusicPlayer } from '../../shared-music/components';
import SharedVideo from '../../shared-video/components/web/SharedVideo';
import Captions from '../../subtitles/components/web/Captions';
import { areClosedCaptionsEnabled } from '../../subtitles/functions.any';
@ -219,7 +218,6 @@ class LargeVideo extends Component<IProps> {
ref = { this._containerRef }
style = { style }>
<SharedVideo />
<SharedMusicPlayer />
{_whiteboardEnabled && <Whiteboard />}
<div id = 'etherpad' />

View File

@ -1,3 +1,4 @@
export { default as SharedMusicDialog } from './web/SharedMusicDialog';
export { default as SharedMusicButton } from './web/SharedMusicButton';
export { default as SharedMusicPlayer } from './web/SharedMusicPlayer';
export { default as SharedMusicTile } from './web/SharedMusicTile';

View File

@ -4,12 +4,9 @@ import { PureComponent } from 'react';
import { IReduxState, IStore } from '../../../app/types';
import { getCurrentConference } from '../../../base/conference/functions';
import { IJitsiConference } from '../../../base/conference/reducer';
import { MEDIA_TYPE } from '../../../base/media/constants';
import { getLocalParticipant } from '../../../base/participants/functions';
import { isLocalTrackMuted } from '../../../base/tracks/functions';
import { showWarningNotification } from '../../../notifications/actions';
import { NOTIFICATION_TIMEOUT_TYPE } from '../../../notifications/constants';
import { muteLocal } from '../../../video-menu/actions.any';
import { setSharedMusicStatus, stopSharedMusic } from '../../actions';
import { PLAYBACK_STATUSES } from '../../constants';
import logger from '../../logger';
@ -42,11 +39,6 @@ export interface IProps {
*/
_displayWarning: Function;
/**
* Indicates whether the local audio is muted.
*/
_isLocalAudioMuted: boolean;
/**
* Is the music shared by the local user.
*/
@ -57,11 +49,6 @@ export interface IProps {
*/
_musicUrl?: string;
/**
* Mutes local audio track.
*/
_muteLocal: Function;
/**
* Store flag for muted state.
*/
@ -205,7 +192,6 @@ class AbstractMusicManager extends PureComponent<IProps> {
* @returns {void}
*/
onPlay() {
this.smartAudioMute();
this.fireUpdateSharedMusicEvent();
}
@ -224,13 +210,6 @@ class AbstractMusicManager extends PureComponent<IProps> {
* @returns {void}
*/
onVolumeChange() {
const volume = this.getVolume();
const muted = this.isMuted();
if (Number(volume) > 0 && !muted) {
this.smartAudioMute();
}
this.fireUpdatePlayingMusicEvent();
}
@ -280,34 +259,6 @@ class AbstractMusicManager extends PureComponent<IProps> {
});
}
/**
* Indicates if the player volume is currently on. This will return true if
* we have an available player, which is currently in a PLAYING state,
* which isn't muted and has its volume greater than 0.
*
* @returns {boolean} Indicating if the volume of the shared music is
* currently on.
*/
isSharedMusicVolumeOn() {
return this.getPlaybackStatus() === PLAYBACK_STATUSES.PLAYING
&& !this.isMuted()
&& Number(this.getVolume()) > 0;
}
/**
* Smart mike mute. If the mike isn't currently muted and the shared music
* volume is on we mute the mike.
*
* @returns {void}
*/
smartAudioMute() {
const { _isLocalAudioMuted, _muteLocal } = this.props;
if (!_isLocalAudioMuted && this.isSharedMusicVolumeOn()) {
_muteLocal(true);
}
}
/**
* Seeks music to provided time.
*
@ -412,11 +363,9 @@ export default AbstractMusicManager;
export function _mapStateToProps(state: IReduxState) {
const { ownerId, status, time, musicUrl, muted, sourceType } = state['features/shared-music'];
const localParticipant = getLocalParticipant(state);
const _isLocalAudioMuted = isLocalTrackMuted(state['features/base/tracks'], MEDIA_TYPE.AUDIO);
return {
_conference: getCurrentConference(state),
_isLocalAudioMuted,
_isOwner: ownerId === localParticipant?.id,
_muted: muted,
_musicUrl: musicUrl,
@ -443,9 +392,6 @@ export function _mapDispatchToProps(dispatch: IStore['dispatch']) {
_stopSharedMusic: () => {
dispatch(stopSharedMusic());
},
_muteLocal: (value: boolean) => {
dispatch(muteLocal(value, MEDIA_TYPE.AUDIO));
},
_setSharedMusicStatus: ({ musicUrl, status, time, ownerId, muted, sourceType }: any) => {
dispatch(setSharedMusicStatus({
musicUrl,

View File

@ -0,0 +1,109 @@
import React, { useCallback } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { IReduxState } from '../../../app/types';
import { IconPlay, IconStop } from '../../../base/icons/svg';
import { getLocalParticipant } from '../../../base/participants/functions';
import { setSharedMusicStatus } from '../../actions';
import { PLAYBACK_STATUSES, SOURCE_TYPES } from '../../constants';
import { isSharingStatus } from '../../functions';
import SharedMusicPlayer from './SharedMusicPlayer';
interface IProps {
/**
* The participant ID (music URL).
*/
participantId: string;
}
/**
* Component that renders a shared music tile with the actual video player.
* This is displayed in the filmstrip thumbnail for shared music.
*
* @param {IProps} props - The component props.
* @returns {React.ReactElement | null}
*/
const SharedMusicTile: React.FC<IProps> = ({ participantId }) => {
const dispatch = useDispatch();
const { musicUrl, ownerId, status, sourceType, time, title } = useSelector(
(state: IReduxState) => state['features/shared-music']
);
const localParticipant = useSelector((state: IReduxState) => getLocalParticipant(state));
const isOwner = ownerId === localParticipant?.id;
const isMusicShared = isSharingStatus(status ?? '');
const isPlaying = status === PLAYBACK_STATUSES.PLAYING;
const isYouTube = sourceType === SOURCE_TYPES.YOUTUBE;
const handlePlayPause = useCallback((e: React.MouseEvent) => {
e.stopPropagation(); // Prevent thumbnail click from pinning
if (!isOwner || !musicUrl) {
return; // Only owner can control playback, and musicUrl must exist
}
const newStatus = isPlaying ? PLAYBACK_STATUSES.PAUSED : PLAYBACK_STATUSES.PLAYING;
dispatch(setSharedMusicStatus({
musicUrl,
status: newStatus,
time: time ?? 0,
ownerId,
sourceType
}));
}, [ dispatch, isOwner, isPlaying, musicUrl, ownerId, sourceType, time ]);
if (!isMusicShared || participantId !== musicUrl) {
return null;
}
return (
<div className = 'shared-music-tile'>
{/* Render the actual player for YouTube videos */}
{isYouTube ? (
<div className = 'shared-music-player-wrapper'>
<SharedMusicPlayer />
</div>
) : (
/* For audio-only, show a background with controls */
<div className = 'shared-music-audio-bg'>
<SharedMusicPlayer />
</div>
)}
{/* Overlay with title and controls for non-owners */}
<div className = 'shared-music-controls-overlay'>
{/* Title */}
<div className = 'shared-music-title'>
{title || 'Shared Music'}
</div>
{/* Play/Pause button (owner only, shown when YouTube controls are hidden) */}
{isOwner && !isYouTube && (
<button
aria-label = { isPlaying ? 'Pause' : 'Play' }
className = 'shared-music-control-button'
onClick = { handlePlayPause }
type = 'button'>
{isPlaying ? (
<IconStop />
) : (
<IconPlay />
)}
</button>
)}
{/* Status indicator for non-owners when not YouTube */}
{!isOwner && !isYouTube && (
<div className = 'shared-music-status'>
{isPlaying ? 'Playing' : 'Paused'}
</div>
)}
</div>
</div>
);
};
export default SharedMusicTile;

View File

@ -194,14 +194,15 @@ class YouTubeMusicManager extends AbstractMusicManager {
const options = {
id: 'sharedMusicPlayer',
opts: {
height: '1',
width: '1',
height: '100%',
width: '100%',
playerVars: {
'origin': location.origin,
'fs': '0',
'autoplay': 0,
'controls': showControls,
'rel': 0
'rel': 0,
'modestbranding': 1
}
},
onError: (e: any) => this.onError(e),
@ -219,9 +220,9 @@ class YouTubeMusicManager extends AbstractMusicManager {
* @inheritdoc
*/
override render() {
// Render the YouTube player hidden (offscreen) since we only need audio
// Render the YouTube player visible for video playback in the tile
return (
<div className = 'okhide'>
<div className = 'youtube-player-container'>
<YouTube
{ ...this.getPlayerOptions() } />
</div>

View File

@ -1,10 +1,14 @@
import { batch } from 'react-redux';
import { IStore } from '../app/types';
import { CONFERENCE_JOIN_IN_PROGRESS, CONFERENCE_LEFT } from '../base/conference/actionTypes';
import { getCurrentConference } from '../base/conference/functions';
import { IJitsiConference } from '../base/conference/reducer';
import { MEDIA_TYPE } from '../base/media/constants';
import { PARTICIPANT_LEFT } from '../base/participants/actionTypes';
import { getLocalParticipant } from '../base/participants/functions';
import { participantJoined, participantLeft } from '../base/participants/actions';
import { getLocalParticipant, getParticipantById } from '../base/participants/functions';
import { FakeParticipant } from '../base/participants/types';
import MiddlewareRegistry from '../base/redux/MiddlewareRegistry';
import { RESET_SHARED_MUSIC_STATUS, SET_SHARED_MUSIC_STATUS } from './actionTypes';
@ -13,6 +17,7 @@ import {
setSharedMusicStatus
} from './actions';
import {
MUSIC_PLAYER_PARTICIPANT_NAME,
PLAYBACK_START,
PLAYBACK_STATUSES,
SHARED_MUSIC,
@ -63,6 +68,12 @@ MiddlewareRegistry.register(store => next => action => {
}
if (sharedMusicStatus === 'stop') {
const musicParticipant = getParticipantById(state, value);
dispatch(participantLeft(value, conference, {
fakeParticipant: musicParticipant?.fakeParticipant
}));
if (localParticipantId !== attributes.from) {
dispatch(resetSharedMusicStatus());
}
@ -76,10 +87,14 @@ MiddlewareRegistry.register(store => next => action => {
break;
case PARTICIPANT_LEFT: {
const state = getState();
const { ownerId: stateOwnerId } = state['features/shared-music'];
const conference = getCurrentConference(state);
const { ownerId: stateOwnerId, musicUrl: stateMusicUrl } = state['features/shared-music'];
if (action.participant.id === stateOwnerId) {
dispatch(resetSharedMusicStatus());
batch(() => {
dispatch(resetSharedMusicStatus());
dispatch(participantLeft(stateMusicUrl ?? '', conference));
});
}
break;
}
@ -154,17 +169,18 @@ MiddlewareRegistry.register(store => next => action => {
/**
* Handles the playing, pause and start statuses for the shared music.
* Dispatches participantJoined event to show music as a participant tile.
* Sets the SharedMusicStatus if the event was triggered by the local user.
*
* @param {Store} store - The redux store.
* @param {string} musicUrl - The id/url of the music to be shared.
* @param {Object} attributes - The attributes received from the share music command.
* @param {JitsiConference} _conference - The current conference.
* @param {JitsiConference} conference - The current conference.
* @returns {void}
*/
function handleSharingMusicStatus(store: IStore, musicUrl: string,
attributes: IMusicCommandAttributes,
_conference: IJitsiConference) {
conference: IJitsiConference) {
const { dispatch, getState } = store;
const localParticipantId = getLocalParticipant(getState())?.id;
const oldStatus = getState()['features/shared-music']?.status ?? '';
@ -179,8 +195,22 @@ function handleSharingMusicStatus(store: IStore, musicUrl: string,
const sourceType = (attributes.sourceType as SourceType) || SOURCE_TYPES.DIRECT;
// If music was not started, set the initial status
// If music was not started (no participant), create the fake participant
// This can be triggered by start, playing, or paused commands (joining late)
if (attributes.state === PLAYBACK_START || !isSharingStatus(oldStatus)) {
// Create a fake participant for the music player (shows as a tile, not pinned)
const displayName = attributes.title || MUSIC_PLAYER_PARTICIPANT_NAME;
dispatch(participantJoined({
conference,
fakeParticipant: FakeParticipant.SharedMusic,
id: musicUrl,
name: displayName
}));
// Note: We intentionally do NOT pin the participant so it appears as a tile
// instead of taking over the whole screen like shared video does
if (localParticipantId === attributes.from) {
dispatch(setSharedMusicStatus({
musicUrl,