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:
parent
b6fc0ae0b5
commit
751a04b950
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -76,5 +76,6 @@ $flagsImagePath: "../images/";
|
||||||
@import 'participants-pane';
|
@import 'participants-pane';
|
||||||
@import 'reactions-menu';
|
@import 'reactions-menu';
|
||||||
@import 'plan-limit';
|
@import 'plan-limit';
|
||||||
|
@import 'shared_music';
|
||||||
|
|
||||||
/* Modules END */
|
/* Modules END */
|
||||||
|
|
|
||||||
|
|
@ -349,6 +349,16 @@ export function isWhiteboardParticipant(participant?: IParticipant): boolean {
|
||||||
return participant?.fakeParticipant === FakeParticipant.Whiteboard;
|
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.
|
* Returns a count of the known remote participants in the passed in redux state.
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { IJitsiConference } from '../conference/reducer';
|
||||||
export enum FakeParticipant {
|
export enum FakeParticipant {
|
||||||
LocalScreenShare = 'LocalScreenShare',
|
LocalScreenShare = 'LocalScreenShare',
|
||||||
RemoteScreenShare = 'RemoteScreenShare',
|
RemoteScreenShare = 'RemoteScreenShare',
|
||||||
|
SharedMusic = 'SharedMusic',
|
||||||
SharedVideo = 'SharedVideo',
|
SharedVideo = 'SharedVideo',
|
||||||
Whiteboard = 'Whiteboard'
|
Whiteboard = 'Whiteboard'
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ import {
|
||||||
hasRaisedHand,
|
hasRaisedHand,
|
||||||
isLocalScreenshareParticipant,
|
isLocalScreenshareParticipant,
|
||||||
isScreenShareParticipant,
|
isScreenShareParticipant,
|
||||||
|
isSharedMusicParticipant,
|
||||||
isWhiteboardParticipant
|
isWhiteboardParticipant
|
||||||
} from '../../../base/participants/functions';
|
} from '../../../base/participants/functions';
|
||||||
import { IParticipant } from '../../../base/participants/types';
|
import { IParticipant } from '../../../base/participants/types';
|
||||||
|
|
@ -39,6 +40,7 @@ import { getVideoObjectPosition } from '../../../face-landmarks/functions';
|
||||||
import { hideGif, showGif } from '../../../gifs/actions';
|
import { hideGif, showGif } from '../../../gifs/actions';
|
||||||
import { getGifDisplayMode, getGifForParticipant } from '../../../gifs/functions';
|
import { getGifDisplayMode, getGifForParticipant } from '../../../gifs/functions';
|
||||||
import PresenceLabel from '../../../presence-status/components/PresenceLabel';
|
import PresenceLabel from '../../../presence-status/components/PresenceLabel';
|
||||||
|
import { SharedMusicTile } from '../../../shared-music/components';
|
||||||
import { LAYOUTS } from '../../../video-layout/constants';
|
import { LAYOUTS } from '../../../video-layout/constants';
|
||||||
import { getCurrentLayout } from '../../../video-layout/functions.web';
|
import { getCurrentLayout } from '../../../video-layout/functions.web';
|
||||||
import { togglePinStageParticipant } from '../../actions';
|
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.
|
* Renders the avatar.
|
||||||
*
|
*
|
||||||
|
|
@ -1183,6 +1218,11 @@ class Thumbnail extends Component<IProps, IState> {
|
||||||
return this._renderParticipant(true);
|
return this._renderParticipant(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Render SharedMusic with custom tile that shows video/controls
|
||||||
|
if (isSharedMusicParticipant(_participant)) {
|
||||||
|
return this._renderSharedMusicParticipant();
|
||||||
|
}
|
||||||
|
|
||||||
if (fakeParticipant
|
if (fakeParticipant
|
||||||
&& !isWhiteboardParticipant(_participant)
|
&& !isWhiteboardParticipant(_participant)
|
||||||
&& !_isVirtualScreenshareParticipant
|
&& !_isVirtualScreenshareParticipant
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,6 @@ import { isSpotTV } from '../../base/util/spot';
|
||||||
import StageParticipantNameLabel from '../../display-name/components/web/StageParticipantNameLabel';
|
import StageParticipantNameLabel from '../../display-name/components/web/StageParticipantNameLabel';
|
||||||
import { FILMSTRIP_BREAKPOINT } from '../../filmstrip/constants';
|
import { FILMSTRIP_BREAKPOINT } from '../../filmstrip/constants';
|
||||||
import { getVerticalViewMaxWidth, isFilmstripResizable } from '../../filmstrip/functions.web';
|
import { getVerticalViewMaxWidth, isFilmstripResizable } from '../../filmstrip/functions.web';
|
||||||
import { SharedMusicPlayer } from '../../shared-music/components';
|
|
||||||
import SharedVideo from '../../shared-video/components/web/SharedVideo';
|
import SharedVideo from '../../shared-video/components/web/SharedVideo';
|
||||||
import Captions from '../../subtitles/components/web/Captions';
|
import Captions from '../../subtitles/components/web/Captions';
|
||||||
import { areClosedCaptionsEnabled } from '../../subtitles/functions.any';
|
import { areClosedCaptionsEnabled } from '../../subtitles/functions.any';
|
||||||
|
|
@ -219,7 +218,6 @@ class LargeVideo extends Component<IProps> {
|
||||||
ref = { this._containerRef }
|
ref = { this._containerRef }
|
||||||
style = { style }>
|
style = { style }>
|
||||||
<SharedVideo />
|
<SharedVideo />
|
||||||
<SharedMusicPlayer />
|
|
||||||
{_whiteboardEnabled && <Whiteboard />}
|
{_whiteboardEnabled && <Whiteboard />}
|
||||||
<div id = 'etherpad' />
|
<div id = 'etherpad' />
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
export { default as SharedMusicDialog } from './web/SharedMusicDialog';
|
export { default as SharedMusicDialog } from './web/SharedMusicDialog';
|
||||||
export { default as SharedMusicButton } from './web/SharedMusicButton';
|
export { default as SharedMusicButton } from './web/SharedMusicButton';
|
||||||
export { default as SharedMusicPlayer } from './web/SharedMusicPlayer';
|
export { default as SharedMusicPlayer } from './web/SharedMusicPlayer';
|
||||||
|
export { default as SharedMusicTile } from './web/SharedMusicTile';
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,9 @@ import { PureComponent } from 'react';
|
||||||
import { IReduxState, IStore } from '../../../app/types';
|
import { IReduxState, IStore } from '../../../app/types';
|
||||||
import { getCurrentConference } from '../../../base/conference/functions';
|
import { getCurrentConference } from '../../../base/conference/functions';
|
||||||
import { IJitsiConference } from '../../../base/conference/reducer';
|
import { IJitsiConference } from '../../../base/conference/reducer';
|
||||||
import { MEDIA_TYPE } from '../../../base/media/constants';
|
|
||||||
import { getLocalParticipant } from '../../../base/participants/functions';
|
import { getLocalParticipant } from '../../../base/participants/functions';
|
||||||
import { isLocalTrackMuted } from '../../../base/tracks/functions';
|
|
||||||
import { showWarningNotification } from '../../../notifications/actions';
|
import { showWarningNotification } from '../../../notifications/actions';
|
||||||
import { NOTIFICATION_TIMEOUT_TYPE } from '../../../notifications/constants';
|
import { NOTIFICATION_TIMEOUT_TYPE } from '../../../notifications/constants';
|
||||||
import { muteLocal } from '../../../video-menu/actions.any';
|
|
||||||
import { setSharedMusicStatus, stopSharedMusic } from '../../actions';
|
import { setSharedMusicStatus, stopSharedMusic } from '../../actions';
|
||||||
import { PLAYBACK_STATUSES } from '../../constants';
|
import { PLAYBACK_STATUSES } from '../../constants';
|
||||||
import logger from '../../logger';
|
import logger from '../../logger';
|
||||||
|
|
@ -42,11 +39,6 @@ export interface IProps {
|
||||||
*/
|
*/
|
||||||
_displayWarning: Function;
|
_displayWarning: Function;
|
||||||
|
|
||||||
/**
|
|
||||||
* Indicates whether the local audio is muted.
|
|
||||||
*/
|
|
||||||
_isLocalAudioMuted: boolean;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Is the music shared by the local user.
|
* Is the music shared by the local user.
|
||||||
*/
|
*/
|
||||||
|
|
@ -57,11 +49,6 @@ export interface IProps {
|
||||||
*/
|
*/
|
||||||
_musicUrl?: string;
|
_musicUrl?: string;
|
||||||
|
|
||||||
/**
|
|
||||||
* Mutes local audio track.
|
|
||||||
*/
|
|
||||||
_muteLocal: Function;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Store flag for muted state.
|
* Store flag for muted state.
|
||||||
*/
|
*/
|
||||||
|
|
@ -205,7 +192,6 @@ class AbstractMusicManager extends PureComponent<IProps> {
|
||||||
* @returns {void}
|
* @returns {void}
|
||||||
*/
|
*/
|
||||||
onPlay() {
|
onPlay() {
|
||||||
this.smartAudioMute();
|
|
||||||
this.fireUpdateSharedMusicEvent();
|
this.fireUpdateSharedMusicEvent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -224,13 +210,6 @@ class AbstractMusicManager extends PureComponent<IProps> {
|
||||||
* @returns {void}
|
* @returns {void}
|
||||||
*/
|
*/
|
||||||
onVolumeChange() {
|
onVolumeChange() {
|
||||||
const volume = this.getVolume();
|
|
||||||
const muted = this.isMuted();
|
|
||||||
|
|
||||||
if (Number(volume) > 0 && !muted) {
|
|
||||||
this.smartAudioMute();
|
|
||||||
}
|
|
||||||
|
|
||||||
this.fireUpdatePlayingMusicEvent();
|
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.
|
* Seeks music to provided time.
|
||||||
*
|
*
|
||||||
|
|
@ -412,11 +363,9 @@ export default AbstractMusicManager;
|
||||||
export function _mapStateToProps(state: IReduxState) {
|
export function _mapStateToProps(state: IReduxState) {
|
||||||
const { ownerId, status, time, musicUrl, muted, sourceType } = state['features/shared-music'];
|
const { ownerId, status, time, musicUrl, muted, sourceType } = state['features/shared-music'];
|
||||||
const localParticipant = getLocalParticipant(state);
|
const localParticipant = getLocalParticipant(state);
|
||||||
const _isLocalAudioMuted = isLocalTrackMuted(state['features/base/tracks'], MEDIA_TYPE.AUDIO);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
_conference: getCurrentConference(state),
|
_conference: getCurrentConference(state),
|
||||||
_isLocalAudioMuted,
|
|
||||||
_isOwner: ownerId === localParticipant?.id,
|
_isOwner: ownerId === localParticipant?.id,
|
||||||
_muted: muted,
|
_muted: muted,
|
||||||
_musicUrl: musicUrl,
|
_musicUrl: musicUrl,
|
||||||
|
|
@ -443,9 +392,6 @@ export function _mapDispatchToProps(dispatch: IStore['dispatch']) {
|
||||||
_stopSharedMusic: () => {
|
_stopSharedMusic: () => {
|
||||||
dispatch(stopSharedMusic());
|
dispatch(stopSharedMusic());
|
||||||
},
|
},
|
||||||
_muteLocal: (value: boolean) => {
|
|
||||||
dispatch(muteLocal(value, MEDIA_TYPE.AUDIO));
|
|
||||||
},
|
|
||||||
_setSharedMusicStatus: ({ musicUrl, status, time, ownerId, muted, sourceType }: any) => {
|
_setSharedMusicStatus: ({ musicUrl, status, time, ownerId, muted, sourceType }: any) => {
|
||||||
dispatch(setSharedMusicStatus({
|
dispatch(setSharedMusicStatus({
|
||||||
musicUrl,
|
musicUrl,
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -194,14 +194,15 @@ class YouTubeMusicManager extends AbstractMusicManager {
|
||||||
const options = {
|
const options = {
|
||||||
id: 'sharedMusicPlayer',
|
id: 'sharedMusicPlayer',
|
||||||
opts: {
|
opts: {
|
||||||
height: '1',
|
height: '100%',
|
||||||
width: '1',
|
width: '100%',
|
||||||
playerVars: {
|
playerVars: {
|
||||||
'origin': location.origin,
|
'origin': location.origin,
|
||||||
'fs': '0',
|
'fs': '0',
|
||||||
'autoplay': 0,
|
'autoplay': 0,
|
||||||
'controls': showControls,
|
'controls': showControls,
|
||||||
'rel': 0
|
'rel': 0,
|
||||||
|
'modestbranding': 1
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onError: (e: any) => this.onError(e),
|
onError: (e: any) => this.onError(e),
|
||||||
|
|
@ -219,9 +220,9 @@ class YouTubeMusicManager extends AbstractMusicManager {
|
||||||
* @inheritdoc
|
* @inheritdoc
|
||||||
*/
|
*/
|
||||||
override render() {
|
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 (
|
return (
|
||||||
<div className = 'okhide'>
|
<div className = 'youtube-player-container'>
|
||||||
<YouTube
|
<YouTube
|
||||||
{ ...this.getPlayerOptions() } />
|
{ ...this.getPlayerOptions() } />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,14 @@
|
||||||
|
import { batch } from 'react-redux';
|
||||||
|
|
||||||
import { IStore } from '../app/types';
|
import { IStore } from '../app/types';
|
||||||
import { CONFERENCE_JOIN_IN_PROGRESS, CONFERENCE_LEFT } from '../base/conference/actionTypes';
|
import { CONFERENCE_JOIN_IN_PROGRESS, CONFERENCE_LEFT } from '../base/conference/actionTypes';
|
||||||
import { getCurrentConference } from '../base/conference/functions';
|
import { getCurrentConference } from '../base/conference/functions';
|
||||||
import { IJitsiConference } from '../base/conference/reducer';
|
import { IJitsiConference } from '../base/conference/reducer';
|
||||||
import { MEDIA_TYPE } from '../base/media/constants';
|
import { MEDIA_TYPE } from '../base/media/constants';
|
||||||
import { PARTICIPANT_LEFT } from '../base/participants/actionTypes';
|
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 MiddlewareRegistry from '../base/redux/MiddlewareRegistry';
|
||||||
|
|
||||||
import { RESET_SHARED_MUSIC_STATUS, SET_SHARED_MUSIC_STATUS } from './actionTypes';
|
import { RESET_SHARED_MUSIC_STATUS, SET_SHARED_MUSIC_STATUS } from './actionTypes';
|
||||||
|
|
@ -13,6 +17,7 @@ import {
|
||||||
setSharedMusicStatus
|
setSharedMusicStatus
|
||||||
} from './actions';
|
} from './actions';
|
||||||
import {
|
import {
|
||||||
|
MUSIC_PLAYER_PARTICIPANT_NAME,
|
||||||
PLAYBACK_START,
|
PLAYBACK_START,
|
||||||
PLAYBACK_STATUSES,
|
PLAYBACK_STATUSES,
|
||||||
SHARED_MUSIC,
|
SHARED_MUSIC,
|
||||||
|
|
@ -63,6 +68,12 @@ MiddlewareRegistry.register(store => next => action => {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sharedMusicStatus === 'stop') {
|
if (sharedMusicStatus === 'stop') {
|
||||||
|
const musicParticipant = getParticipantById(state, value);
|
||||||
|
|
||||||
|
dispatch(participantLeft(value, conference, {
|
||||||
|
fakeParticipant: musicParticipant?.fakeParticipant
|
||||||
|
}));
|
||||||
|
|
||||||
if (localParticipantId !== attributes.from) {
|
if (localParticipantId !== attributes.from) {
|
||||||
dispatch(resetSharedMusicStatus());
|
dispatch(resetSharedMusicStatus());
|
||||||
}
|
}
|
||||||
|
|
@ -76,10 +87,14 @@ MiddlewareRegistry.register(store => next => action => {
|
||||||
break;
|
break;
|
||||||
case PARTICIPANT_LEFT: {
|
case PARTICIPANT_LEFT: {
|
||||||
const state = getState();
|
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) {
|
if (action.participant.id === stateOwnerId) {
|
||||||
dispatch(resetSharedMusicStatus());
|
batch(() => {
|
||||||
|
dispatch(resetSharedMusicStatus());
|
||||||
|
dispatch(participantLeft(stateMusicUrl ?? '', conference));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
@ -154,17 +169,18 @@ MiddlewareRegistry.register(store => next => action => {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles the playing, pause and start statuses for the shared music.
|
* 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.
|
* Sets the SharedMusicStatus if the event was triggered by the local user.
|
||||||
*
|
*
|
||||||
* @param {Store} store - The redux store.
|
* @param {Store} store - The redux store.
|
||||||
* @param {string} musicUrl - The id/url of the music to be shared.
|
* @param {string} musicUrl - The id/url of the music to be shared.
|
||||||
* @param {Object} attributes - The attributes received from the share music command.
|
* @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}
|
* @returns {void}
|
||||||
*/
|
*/
|
||||||
function handleSharingMusicStatus(store: IStore, musicUrl: string,
|
function handleSharingMusicStatus(store: IStore, musicUrl: string,
|
||||||
attributes: IMusicCommandAttributes,
|
attributes: IMusicCommandAttributes,
|
||||||
_conference: IJitsiConference) {
|
conference: IJitsiConference) {
|
||||||
const { dispatch, getState } = store;
|
const { dispatch, getState } = store;
|
||||||
const localParticipantId = getLocalParticipant(getState())?.id;
|
const localParticipantId = getLocalParticipant(getState())?.id;
|
||||||
const oldStatus = getState()['features/shared-music']?.status ?? '';
|
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;
|
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)) {
|
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) {
|
if (localParticipantId === attributes.from) {
|
||||||
dispatch(setSharedMusicStatus({
|
dispatch(setSharedMusicStatus({
|
||||||
musicUrl,
|
musicUrl,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue