diff --git a/css/_shared_music.scss b/css/_shared_music.scss new file mode 100644 index 0000000..40e7373 --- /dev/null +++ b/css/_shared_music.scss @@ -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; +} diff --git a/css/main.scss b/css/main.scss index dbc4365..17302b2 100644 --- a/css/main.scss +++ b/css/main.scss @@ -76,5 +76,6 @@ $flagsImagePath: "../images/"; @import 'participants-pane'; @import 'reactions-menu'; @import 'plan-limit'; +@import 'shared_music'; /* Modules END */ diff --git a/react/features/base/participants/functions.ts b/react/features/base/participants/functions.ts index 4ce216e..8c9d84a 100644 --- a/react/features/base/participants/functions.ts +++ b/react/features/base/participants/functions.ts @@ -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. * diff --git a/react/features/base/participants/types.ts b/react/features/base/participants/types.ts index 04a0b8d..a3fbc8d 100644 --- a/react/features/base/participants/types.ts +++ b/react/features/base/participants/types.ts @@ -3,6 +3,7 @@ import { IJitsiConference } from '../conference/reducer'; export enum FakeParticipant { LocalScreenShare = 'LocalScreenShare', RemoteScreenShare = 'RemoteScreenShare', + SharedMusic = 'SharedMusic', SharedVideo = 'SharedVideo', Whiteboard = 'Whiteboard' } diff --git a/react/features/filmstrip/components/web/Thumbnail.tsx b/react/features/filmstrip/components/web/Thumbnail.tsx index 277143d..f01aa16 100644 --- a/react/features/filmstrip/components/web/Thumbnail.tsx +++ b/react/features/filmstrip/components/web/Thumbnail.tsx @@ -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 { ); } + /** + * 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 ( + + + + ); + } + /** * Renders the avatar. * @@ -1183,6 +1218,11 @@ class Thumbnail extends Component { return this._renderParticipant(true); } + // Render SharedMusic with custom tile that shows video/controls + if (isSharedMusicParticipant(_participant)) { + return this._renderSharedMusicParticipant(); + } + if (fakeParticipant && !isWhiteboardParticipant(_participant) && !_isVirtualScreenshareParticipant diff --git a/react/features/large-video/components/LargeVideo.web.tsx b/react/features/large-video/components/LargeVideo.web.tsx index 3592d06..13facad 100644 --- a/react/features/large-video/components/LargeVideo.web.tsx +++ b/react/features/large-video/components/LargeVideo.web.tsx @@ -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 { ref = { this._containerRef } style = { style }> - {_whiteboardEnabled && }
diff --git a/react/features/shared-music/components/index.web.ts b/react/features/shared-music/components/index.web.ts index 34768d8..43845d4 100644 --- a/react/features/shared-music/components/index.web.ts +++ b/react/features/shared-music/components/index.web.ts @@ -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'; diff --git a/react/features/shared-music/components/web/AbstractMusicManager.ts b/react/features/shared-music/components/web/AbstractMusicManager.ts index 43980bb..e80c638 100644 --- a/react/features/shared-music/components/web/AbstractMusicManager.ts +++ b/react/features/shared-music/components/web/AbstractMusicManager.ts @@ -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 { * @returns {void} */ onPlay() { - this.smartAudioMute(); this.fireUpdateSharedMusicEvent(); } @@ -224,13 +210,6 @@ class AbstractMusicManager extends PureComponent { * @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 { }); } - /** - * 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, diff --git a/react/features/shared-music/components/web/SharedMusicTile.tsx b/react/features/shared-music/components/web/SharedMusicTile.tsx new file mode 100644 index 0000000..1497240 --- /dev/null +++ b/react/features/shared-music/components/web/SharedMusicTile.tsx @@ -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 = ({ 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 ( +
+ {/* Render the actual player for YouTube videos */} + {isYouTube ? ( +
+ +
+ ) : ( + /* For audio-only, show a background with controls */ +
+ +
+ )} + + {/* Overlay with title and controls for non-owners */} +
+ {/* Title */} +
+ {title || 'Shared Music'} +
+ + {/* Play/Pause button (owner only, shown when YouTube controls are hidden) */} + {isOwner && !isYouTube && ( + + )} + + {/* Status indicator for non-owners when not YouTube */} + {!isOwner && !isYouTube && ( +
+ {isPlaying ? 'Playing' : 'Paused'} +
+ )} +
+
+ ); +}; + +export default SharedMusicTile; diff --git a/react/features/shared-music/components/web/YouTubeMusicManager.tsx b/react/features/shared-music/components/web/YouTubeMusicManager.tsx index de2f684..c87d83c 100644 --- a/react/features/shared-music/components/web/YouTubeMusicManager.tsx +++ b/react/features/shared-music/components/web/YouTubeMusicManager.tsx @@ -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 ( -
+
diff --git a/react/features/shared-music/middleware.ts b/react/features/shared-music/middleware.ts index 71319c3..82faffb 100644 --- a/react/features/shared-music/middleware.ts +++ b/react/features/shared-music/middleware.ts @@ -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,