feat(shared-music): add synchronized music playback feature

Add a new shared-music feature that allows users to share YouTube or
direct audio URLs with all participants. Music plays locally on each
device with the host controlling playback (play/pause/seek/volume).

New feature module includes:
- Redux state management (actions, reducer, middleware)
- Conference command sync for real-time playback coordination
- YouTube player (hidden video, audio only) via react-youtube
- HTML5 audio player for direct audio URLs
- Toolbar button with share dialog
- Translation strings for all UI elements

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-01-28 18:43:22 +00:00
parent 9f4b09d135
commit 1c9fed8794
23 changed files with 2025 additions and 0 deletions

View File

@ -2,6 +2,15 @@
transform: scaleX(-1);
}
/**
* Positions an element offscreen for audio-only players.
*/
.okhide {
position: absolute;
left: -9999px;
top: -9999px;
}
.hidden {
display: none;
}

View File

@ -500,6 +500,11 @@
"shareYourScreenDisabled": "Screen sharing disabled.",
"sharedVideoDialogError": "Error: Invalid or forbidden URL",
"sharedVideoLinkPlaceholder": "YouTube link or direct video link",
"shareMusicTitle": "Share music",
"shareMusicLinkError": "Oops, this music cannot be played.",
"sharedMusicDialogError": "Error: Invalid URL",
"sharedMusicLinkPlaceholder": "YouTube link or direct audio URL",
"musicLink": "Music link",
"show": "Show",
"start": "Start ",
"startLiveStreaming": "Start live stream",
@ -1378,6 +1383,8 @@
"shareYourScreen": "Start sharing your screen",
"shareaudio": "Share audio",
"sharedvideo": "Share video",
"sharedmusic": "Share music",
"stopSharedMusic": "Stop music",
"shortcuts": "Toggle shortcuts",
"show": "Show on stage",
"showWhiteboard": "Show whiteboard",
@ -1492,6 +1499,8 @@
"shareRoom": "Invite someone",
"shareaudio": "Share audio",
"sharedvideo": "Share video",
"sharedmusic": "Share music",
"stopSharedMusic": "Stop music",
"shortcuts": "View shortcuts",
"showWhiteboard": "Show whiteboard",
"silence": "Silence",

View File

@ -17,6 +17,7 @@ import '../power-monitor/middleware';
import '../prejoin/middleware';
import '../remote-control/middleware';
import '../screen-share/middleware';
import '../shared-music/middleware';
import '../shared-video/middleware';
import '../web-hid/middleware';
import '../settings/middleware';

View File

@ -46,6 +46,7 @@ import '../recent-list/reducer';
import '../recording/reducer';
import '../settings/reducer';
import '../speaker-stats/reducer';
import '../shared-music/reducer';
import '../shared-video/reducer';
import '../subtitles/reducer';
import '../screen-share/reducer';

View File

@ -69,6 +69,7 @@ import { IRemoteControlState } from '../remote-control/reducer';
import { IScreenShareState } from '../screen-share/reducer';
import { IScreenshotCaptureState } from '../screenshot-capture/reducer';
import { IShareRoomState } from '../share-room/reducer';
import { ISharedMusicState } from '../shared-music/reducer';
import { ISharedVideoState } from '../shared-video/reducer';
import { ISpeakerStatsState } from '../speaker-stats/reducer';
import { ISubtitlesState } from '../subtitles/reducer';
@ -159,6 +160,7 @@ export interface IReduxState {
'features/screenshot-capture': IScreenshotCaptureState;
'features/settings': ISettingsState;
'features/share-room': IShareRoomState;
'features/shared-music': ISharedMusicState;
'features/shared-video': ISharedVideoState;
'features/speaker-stats': ISpeakerStatsState;
'features/subtitles': ISubtitlesState;

View File

@ -0,0 +1,30 @@
/**
* The type of the action which signals to update the current known state of the
* shared music.
*
* {
* type: SET_SHARED_MUSIC_STATUS,
* status: string
* }
*/
export const SET_SHARED_MUSIC_STATUS = 'SET_SHARED_MUSIC_STATUS';
/**
* The type of the action which signals to reset the current known state of the
* shared music.
*
* {
* type: RESET_SHARED_MUSIC_STATUS,
* }
*/
export const RESET_SHARED_MUSIC_STATUS = 'RESET_SHARED_MUSIC_STATUS';
/**
* The type of the action which toggles the minimized state of the music player.
*
* {
* type: SET_SHARED_MUSIC_MINIMIZED,
* minimized: boolean
* }
*/
export const SET_SHARED_MUSIC_MINIMIZED = 'SET_SHARED_MUSIC_MINIMIZED';

View File

@ -0,0 +1,164 @@
import { IStore } from '../app/types';
import { getCurrentConference } from '../base/conference/functions';
import { openDialog } from '../base/dialog/actions';
import { getLocalParticipant } from '../base/participants/functions';
import {
RESET_SHARED_MUSIC_STATUS,
SET_SHARED_MUSIC_MINIMIZED,
SET_SHARED_MUSIC_STATUS
} from './actionTypes';
import { SharedMusicDialog } from './components';
import { PLAYBACK_START, PLAYBACK_STATUSES } from './constants';
import { extractMusicUrl, isSharedMusicEnabled, sendShareMusicCommand } from './functions';
import { SourceType } from './types';
/**
* Resets the status of the shared music.
*
* @returns {{
* type: RESET_SHARED_MUSIC_STATUS,
* }}
*/
export function resetSharedMusicStatus() {
return {
type: RESET_SHARED_MUSIC_STATUS
};
}
/**
* Updates the current known status of the shared music.
*
* @param {Object} options - The options.
* @param {number} options.duration - Track duration.
* @param {boolean} options.muted - Is music muted.
* @param {string} options.musicUrl - URL of the shared music.
* @param {string} options.ownerId - Participant ID of the owner.
* @param {string} options.sourceType - Source type (youtube or direct).
* @param {string} options.status - Sharing status.
* @param {number} options.time - Playback timestamp.
* @param {string} options.title - Track title.
* @param {number} options.volume - Volume level.
* @returns {Object}
*/
export function setSharedMusicStatus({ musicUrl, status, time, ownerId, muted, volume, sourceType, title, duration }: {
duration?: number;
musicUrl: string;
muted?: boolean;
ownerId?: string;
sourceType?: SourceType;
status: string;
time: number;
title?: string;
volume?: number;
}) {
return {
type: SET_SHARED_MUSIC_STATUS,
duration,
muted,
musicUrl,
ownerId,
sourceType,
status,
time,
title,
volume
};
}
/**
* Sets the minimized state of the music player.
*
* @param {boolean} minimized - Whether the player is minimized.
* @returns {{
* type: SET_SHARED_MUSIC_MINIMIZED,
* minimized: boolean
* }}
*/
export function setSharedMusicMinimized(minimized: boolean) {
return {
type: SET_SHARED_MUSIC_MINIMIZED,
minimized
};
}
/**
* Displays the dialog for entering the music link.
*
* @param {Function} onPostSubmit - The function to be invoked when a valid link is entered.
* @returns {Function}
*/
export function showSharedMusicDialog(onPostSubmit: Function) {
return openDialog('SharedMusicDialog', SharedMusicDialog, { onPostSubmit });
}
/**
* Stops playing shared music.
*
* @returns {Function}
*/
export function stopSharedMusic() {
return (dispatch: IStore['dispatch'], getState: IStore['getState']) => {
const state = getState();
const { ownerId } = state['features/shared-music'];
const localParticipant = getLocalParticipant(state);
if (ownerId === localParticipant?.id) {
dispatch(resetSharedMusicStatus());
}
};
}
/**
* Plays shared music.
*
* @param {string} input - The music url or YouTube ID to be played.
* @returns {Function}
*/
export function playSharedMusic(input: string) {
return (dispatch: IStore['dispatch'], getState: IStore['getState']) => {
if (!isSharedMusicEnabled(getState())) {
return;
}
const extracted = extractMusicUrl(input);
if (!extracted) {
return;
}
const { url, sourceType } = extracted;
const conference = getCurrentConference(getState());
if (conference) {
const localParticipant = getLocalParticipant(getState());
sendShareMusicCommand({
conference,
id: url,
localParticipantId: localParticipant?.id,
sourceType,
status: PLAYBACK_START,
time: 0
});
}
};
}
/**
* Toggles shared music - opens dialog if not playing, stops if playing.
*
* @returns {Function}
*/
export function toggleSharedMusic() {
return (dispatch: IStore['dispatch'], getState: IStore['getState']) => {
const state = getState();
const { status = '' } = state['features/shared-music'];
if ([ PLAYBACK_STATUSES.PLAYING, PLAYBACK_START, PLAYBACK_STATUSES.PAUSED ].includes(status)) {
dispatch(stopSharedMusic());
} else {
dispatch(showSharedMusicDialog((url: string) => dispatch(playSharedMusic(url))));
}
};
}

View File

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

View File

@ -0,0 +1,460 @@
import { throttle } from 'lodash-es';
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';
import { SourceType } from '../../types';
/**
* Return true if the difference between the two times is larger than 5.
*
* @param {number} newTime - The current time.
* @param {number} previousTime - The previous time.
* @private
* @returns {boolean}
*/
function shouldSeekToPosition(newTime: number, previousTime: number) {
return Math.abs(newTime - previousTime) > 5;
}
/**
* The type of the React {@link PureComponent} props of {@link AbstractMusicManager}.
*/
export interface IProps {
/**
* The current conference.
*/
_conference?: IJitsiConference;
/**
* Warning that indicates an incorrect music url.
*/
_displayWarning: Function;
/**
* Indicates whether the local audio is muted.
*/
_isLocalAudioMuted: boolean;
/**
* Is the music shared by the local user.
*/
_isOwner: boolean;
/**
* The music URL.
*/
_musicUrl?: string;
/**
* Mutes local audio track.
*/
_muteLocal: Function;
/**
* Store flag for muted state.
*/
_muted?: boolean;
/**
* The shared music owner id.
*/
_ownerId?: string;
/**
* Updates the shared music status.
*/
_setSharedMusicStatus: Function;
/**
* The source type (youtube or direct).
*/
_sourceType?: SourceType;
/**
* The shared music status.
*/
_status?: string;
/**
* Action to stop music sharing.
*/
_stopSharedMusic: Function;
/**
* Seek time in seconds.
*/
_time?: number;
/**
* The music id/url.
*/
musicId: string;
}
/**
* Manager of shared music.
*/
class AbstractMusicManager extends PureComponent<IProps> {
throttledFireUpdateSharedMusicEvent: Function;
/**
* Initializes a new instance of AbstractMusicManager.
*
* @param {IProps} props - Component props.
* @returns {void}
*/
constructor(props: IProps) {
super(props);
this.throttledFireUpdateSharedMusicEvent = throttle(this.fireUpdateSharedMusicEvent.bind(this), 5000);
}
/**
* Implements React Component's componentDidMount.
*
* @inheritdoc
*/
override componentDidMount() {
this.processUpdatedProps();
}
/**
* Implements React Component's componentDidUpdate.
*
* @inheritdoc
*/
override componentDidUpdate(_prevProps: IProps) {
this.processUpdatedProps();
}
/**
* Implements React Component's componentWillUnmount.
*
* @inheritdoc
*/
override componentWillUnmount() {
if (this.dispose) {
this.dispose();
}
}
/**
* Processes new properties.
*
* @returns {void}
*/
processUpdatedProps() {
const { _status, _time, _isOwner, _muted } = this.props;
if (_isOwner) {
return;
}
const playerTime = this.getTime();
if (shouldSeekToPosition(Number(_time), Number(playerTime))) {
this.seek(Number(_time));
}
if (this.getPlaybackStatus() !== _status) {
if (_status === PLAYBACK_STATUSES.PLAYING) {
this.play();
}
if (_status === PLAYBACK_STATUSES.PAUSED) {
this.pause();
}
}
if (this.isMuted() !== _muted) {
if (_muted) {
this.mute();
} else {
this.unMute();
}
}
}
/**
* Handle music error.
*
* @param {Object|undefined} e - The error returned by the API or none.
* @returns {void}
*/
onError(e?: any) {
logger.error('Error in the music player', e?.data);
this.props._stopSharedMusic();
this.props._displayWarning();
}
/**
* Handle music playing.
*
* @returns {void}
*/
onPlay() {
this.smartAudioMute();
this.fireUpdateSharedMusicEvent();
}
/**
* Handle music paused.
*
* @returns {void}
*/
onPause() {
this.fireUpdateSharedMusicEvent();
}
/**
* Handle volume changed.
*
* @returns {void}
*/
onVolumeChange() {
const volume = this.getVolume();
const muted = this.isMuted();
if (Number(volume) > 0 && !muted) {
this.smartAudioMute();
}
this.fireUpdatePlayingMusicEvent();
}
/**
* Handle changes to the shared playing music.
*
* @returns {void}
*/
fireUpdatePlayingMusicEvent() {
if (this.getPlaybackStatus() === PLAYBACK_STATUSES.PLAYING) {
this.fireUpdateSharedMusicEvent();
}
}
/**
* Dispatches an update action for the shared music.
*
* @returns {void}
*/
fireUpdateSharedMusicEvent() {
const { _isOwner } = this.props;
if (!_isOwner) {
return;
}
const status = this.getPlaybackStatus();
if (!Object.values(PLAYBACK_STATUSES).includes(status ?? '')) {
return;
}
const {
_ownerId,
_setSharedMusicStatus,
_musicUrl,
_sourceType
} = this.props;
_setSharedMusicStatus({
musicUrl: _musicUrl,
status,
time: this.getTime(),
ownerId: _ownerId,
muted: this.isMuted(),
sourceType: _sourceType
});
}
/**
* 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.
*
* @param {number} _time - Time to seek to.
* @returns {void}
*/
seek(_time: number) {
// to be implemented by subclass
}
/**
* Indicates the playback state of the music.
*
* @returns {string}
*/
getPlaybackStatus(): string | undefined {
return;
}
/**
* Indicates whether the music is muted.
*
* @returns {boolean}
*/
isMuted(): boolean | undefined {
return;
}
/**
* Retrieves current volume.
*
* @returns {number}
*/
getVolume() {
return 1;
}
/**
* Plays music.
*
* @returns {void}
*/
play() {
// to be implemented by subclass
}
/**
* Pauses music.
*
* @returns {void}
*/
pause() {
// to be implemented by subclass
}
/**
* Mutes music.
*
* @returns {void}
*/
mute() {
// to be implemented by subclass
}
/**
* Unmutes music.
*
* @returns {void}
*/
unMute() {
// to be implemented by subclass
}
/**
* Retrieves current time.
*
* @returns {number}
*/
getTime() {
return 0;
}
/**
* Disposes current music player.
*
* @returns {void}
*/
dispose() {
// to be implemented by subclass
}
}
export default AbstractMusicManager;
/**
* Maps part of the Redux store to the props of this component.
*
* @param {Object} state - The Redux state.
* @returns {IProps}
*/
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,
_ownerId: ownerId,
_sourceType: sourceType,
_status: status,
_time: time
};
}
/**
* Maps part of the props of this component to Redux actions.
*
* @param {Function} dispatch - The Redux dispatch function.
* @returns {IProps}
*/
export function _mapDispatchToProps(dispatch: IStore['dispatch']) {
return {
_displayWarning: () => {
dispatch(showWarningNotification({
titleKey: 'dialog.shareMusicLinkError'
}, NOTIFICATION_TIMEOUT_TYPE.LONG));
},
_stopSharedMusic: () => {
dispatch(stopSharedMusic());
},
_muteLocal: (value: boolean) => {
dispatch(muteLocal(value, MEDIA_TYPE.AUDIO));
},
_setSharedMusicStatus: ({ musicUrl, status, time, ownerId, muted, sourceType }: any) => {
dispatch(setSharedMusicStatus({
musicUrl,
status,
time,
ownerId,
muted,
sourceType
}));
}
};
}

View File

@ -0,0 +1,184 @@
import React from 'react';
import { connect } from 'react-redux';
import { PLAYBACK_STATUSES } from '../../constants';
import AbstractMusicManager, {
IProps,
_mapDispatchToProps,
_mapStateToProps
} from './AbstractMusicManager';
/**
* Manager for direct audio URL playback using HTML5 audio element.
*/
class DirectAudioManager extends AbstractMusicManager {
audioRef: React.RefObject<HTMLAudioElement>;
/**
* Initializes a new DirectAudioManager instance.
*
* @param {Object} props - This component's props.
* @returns {void}
*/
constructor(props: IProps) {
super(props);
this.audioRef = React.createRef();
}
/**
* Retrieves the current audio element ref.
*
* @returns {HTMLAudioElement | null}
*/
get player() {
return this.audioRef.current;
}
/**
* Indicates the playback state of the music.
*
* @returns {string}
*/
override getPlaybackStatus() {
let status;
if (!this.player) {
return;
}
if (this.player.paused) {
status = PLAYBACK_STATUSES.PAUSED;
} else {
status = PLAYBACK_STATUSES.PLAYING;
}
return status;
}
/**
* Indicates whether the music is muted.
*
* @returns {boolean}
*/
override isMuted() {
return this.player?.muted;
}
/**
* Retrieves current volume.
*
* @returns {number}
*/
override getVolume() {
return Number(this.player?.volume);
}
/**
* Retrieves current time.
*
* @returns {number}
*/
override getTime() {
return Number(this.player?.currentTime);
}
/**
* Seeks music to provided time.
*
* @param {number} time - The time to seek to.
* @returns {void}
*/
override seek(time: number) {
if (this.player) {
this.player.currentTime = time;
}
}
/**
* Plays music.
*
* @returns {void}
*/
override play() {
return this.player?.play();
}
/**
* Pauses music.
*
* @returns {void}
*/
override pause() {
return this.player?.pause();
}
/**
* Mutes music.
*
* @returns {void}
*/
override mute() {
if (this.player) {
this.player.muted = true;
}
}
/**
* Unmutes music.
*
* @returns {void}
*/
override unMute() {
if (this.player) {
this.player.muted = false;
}
}
/**
* Retrieves audio element options.
*
* @returns {Object}
*/
getPlayerOptions() {
const { _isOwner, musicId } = this.props;
let options: any = {
autoPlay: true,
src: musicId,
controls: _isOwner,
onError: () => this.onError(),
onPlay: () => this.onPlay(),
onVolumeChange: () => this.onVolumeChange()
};
if (_isOwner) {
options = {
...options,
onPause: () => this.onPause(),
onTimeUpdate: this.throttledFireUpdateSharedMusicEvent
};
}
return options;
}
/**
* Implements React Component's render.
*
* @inheritdoc
*/
override render() {
return (
<audio
className = 'hide'
id = 'sharedMusicPlayer'
ref = { this.audioRef }
{ ...this.getPlayerOptions() } />
);
}
}
export default connect(_mapStateToProps, _mapDispatchToProps)(DirectAudioManager);

View File

@ -0,0 +1,97 @@
import { connect } from 'react-redux';
import { IReduxState } from '../../../app/types';
import { translate } from '../../../base/i18n/functions';
import { IconAudioOnly } from '../../../base/icons/svg';
import { getLocalParticipant } from '../../../base/participants/functions';
import AbstractButton, { IProps as AbstractButtonProps } from '../../../base/toolbox/components/AbstractButton';
import { toggleSharedMusic } from '../../actions';
import { isSharingStatus } from '../../functions';
interface IProps extends AbstractButtonProps {
/**
* Whether or not the button is disabled.
*/
_isDisabled: boolean;
/**
* Whether or not the local participant is sharing music.
*/
_sharingMusic: boolean;
}
/**
* Implements a button to share music with all participants.
*/
class SharedMusicButton extends AbstractButton<IProps> {
override accessibilityLabel = 'toolbar.accessibilityLabel.sharedmusic';
override toggledAccessibilityLabel = 'toolbar.accessibilityLabel.stopSharedMusic';
override icon = IconAudioOnly;
override label = 'toolbar.sharedmusic';
override toggledLabel = 'toolbar.stopSharedMusic';
override tooltip = 'toolbar.sharedmusic';
override toggledTooltip = 'toolbar.stopSharedMusic';
/**
* Handles clicking / pressing the button, and opens a new dialog.
*
* @private
* @returns {void}
*/
override _handleClick() {
this._doToggleSharedMusic();
}
/**
* Indicates whether this button is in toggled state or not.
*
* @override
* @protected
* @returns {boolean}
*/
override _isToggled() {
return this.props._sharingMusic;
}
/**
* Indicates whether this button is disabled or not.
*
* @override
* @protected
* @returns {boolean}
*/
override _isDisabled() {
return this.props._isDisabled;
}
/**
* Dispatches an action to toggle music sharing.
*
* @private
* @returns {void}
*/
_doToggleSharedMusic() {
this.props.dispatch(toggleSharedMusic());
}
}
/**
* Maps part of the Redux state to the props of this component.
*
* @param {Object} state - The Redux state.
* @private
* @returns {IProps}
*/
function _mapStateToProps(state: IReduxState) {
const { ownerId, status: sharedMusicStatus } = state['features/shared-music'];
const localParticipantId = getLocalParticipant(state)?.id;
const isSharing = isSharingStatus(sharedMusicStatus ?? '');
return {
_isDisabled: isSharing && ownerId !== localParticipantId,
_sharingMusic: isSharing
};
}
export default translate(connect(_mapStateToProps)(SharedMusicButton));

View File

@ -0,0 +1,135 @@
import React, { Component } from 'react';
import { WithTranslation } from 'react-i18next';
import { connect } from 'react-redux';
import { IReduxState, IStore } from '../../../app/types';
import { hideDialog } from '../../../base/dialog/actions';
import { translate } from '../../../base/i18n/functions';
import Dialog from '../../../base/ui/components/web/Dialog';
import Input from '../../../base/ui/components/web/Input';
import { extractMusicUrl } from '../../functions';
interface IProps extends WithTranslation {
/**
* Invoked to update the shared music link.
*/
dispatch: IStore['dispatch'];
/**
* Function to be invoked after typing a valid URL.
*/
onPostSubmit: Function;
}
interface IState {
error: boolean;
okDisabled: boolean;
value: string;
}
/**
* Component that renders the music share dialog.
*/
class SharedMusicDialog extends Component<IProps, IState> {
/**
* Instantiates a new component.
*
* @inheritdoc
*/
constructor(props: IProps) {
super(props);
this.state = {
value: '',
okDisabled: true,
error: false
};
this._onChange = this._onChange.bind(this);
this._onSubmitValue = this._onSubmitValue.bind(this);
}
/**
* Callback for the onChange event of the field.
*
* @param {string} value - The input value.
* @returns {void}
*/
_onChange(value: string) {
this.setState({
value,
okDisabled: !value,
error: false
});
}
/**
* Validates and submits the music link.
*
* @returns {boolean}
*/
_onSubmitValue() {
const { onPostSubmit, dispatch } = this.props;
const extracted = extractMusicUrl(this.state.value);
if (!extracted) {
this.setState({ error: true });
return false;
}
onPostSubmit(this.state.value);
dispatch(hideDialog());
return true;
}
/**
* Implements React's {@link Component#render()}.
*
* @inheritdoc
*/
override render() {
const { t } = this.props;
const { error } = this.state;
return (
<Dialog
disableAutoHideOnSubmit = { true }
ok = {{
disabled: this.state.okDisabled,
translationKey: 'dialog.Share'
}}
onSubmit = { this._onSubmitValue }
titleKey = 'dialog.shareMusicTitle'>
<Input
autoFocus = { true }
bottomLabel = { error ? t('dialog.sharedMusicDialogError') : undefined }
className = 'dialog-bottom-margin'
error = { error }
id = 'shared-music-url-input'
label = { t('dialog.musicLink') }
name = 'sharedMusicUrl'
onChange = { this._onChange }
placeholder = { t('dialog.sharedMusicLinkPlaceholder') }
type = 'text'
value = { this.state.value } />
</Dialog>
);
}
}
/**
* Maps part of the Redux state to the props of this component.
*
* @param {Object} _state - The Redux state.
* @private
* @returns {Object}
*/
function mapStateToProps(_state: IReduxState) {
return {};
}
export default translate(connect(mapStateToProps)(SharedMusicDialog));

View File

@ -0,0 +1,101 @@
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { IReduxState } from '../../../app/types';
import { getLocalParticipant } from '../../../base/participants/functions';
import { SOURCE_TYPES } from '../../constants';
import { isSharingStatus } from '../../functions';
import { SourceType } from '../../types';
import DirectAudioManager from './DirectAudioManager';
import YouTubeMusicManager from './YouTubeMusicManager';
interface IProps {
/**
* Whether music is currently being shared.
*/
isMusicShared: boolean;
/**
* Whether the local participant is the owner.
*/
isOwner: boolean;
/**
* The music URL.
*/
musicUrl?: string;
/**
* The source type.
*/
sourceType?: SourceType;
}
/**
* Component that manages and renders the appropriate music player.
*/
class SharedMusicPlayer extends Component<IProps> {
/**
* Retrieves the manager to be used for playing the shared music.
*
* @returns {React.ReactNode}
*/
getManager() {
const { musicUrl, sourceType } = this.props;
if (!musicUrl) {
return null;
}
if (sourceType === SOURCE_TYPES.YOUTUBE) {
return <YouTubeMusicManager musicId = { musicUrl } />;
}
return <DirectAudioManager musicId = { musicUrl } />;
}
/**
* Implements React's {@link Component#render()}.
*
* @inheritdoc
* @returns {React.ReactNode}
*/
override render() {
const { isMusicShared } = this.props;
if (!isMusicShared) {
return null;
}
return (
<div id = 'sharedMusic'>
{this.getManager()}
</div>
);
}
}
/**
* Maps (parts of) the Redux state to the associated props.
*
* @param {Object} state - The Redux state.
* @private
* @returns {IProps}
*/
function _mapStateToProps(state: IReduxState) {
const { musicUrl, ownerId, status, sourceType } = state['features/shared-music'];
const localParticipant = getLocalParticipant(state);
const isMusicShared = isSharingStatus(status ?? '');
return {
isMusicShared,
isOwner: ownerId === localParticipant?.id,
musicUrl,
sourceType
};
}
export default connect(_mapStateToProps)(SharedMusicPlayer);

View File

@ -0,0 +1,232 @@
/* eslint-disable no-invalid-this */
import React from 'react';
import { connect } from 'react-redux';
import YouTube from 'react-youtube';
import { PLAYBACK_STATUSES } from '../../constants';
import AbstractMusicManager, {
IProps,
_mapDispatchToProps,
_mapStateToProps
} from './AbstractMusicManager';
/**
* Manager for YouTube music playback.
* Uses react-youtube but renders the video hidden since we only need audio.
*/
class YouTubeMusicManager extends AbstractMusicManager {
isPlayerAPILoaded: boolean;
player?: any;
/**
* Initializes a new YouTubeMusicManager instance.
*
* @param {Object} props - This component's props.
* @returns {void}
*/
constructor(props: IProps) {
super(props);
this.isPlayerAPILoaded = false;
}
/**
* Indicates the playback state of the music.
*
* @returns {string}
*/
override getPlaybackStatus() {
let status;
if (!this.player) {
return;
}
const playerState = this.player.getPlayerState();
if (playerState === YouTube.PlayerState.PLAYING) {
status = PLAYBACK_STATUSES.PLAYING;
}
if (playerState === YouTube.PlayerState.PAUSED) {
status = PLAYBACK_STATUSES.PAUSED;
}
return status;
}
/**
* Indicates whether the music is muted.
*
* @returns {boolean}
*/
override isMuted() {
return this.player?.isMuted();
}
/**
* Retrieves current volume.
*
* @returns {number}
*/
override getVolume() {
return this.player?.getVolume();
}
/**
* Retrieves current time.
*
* @returns {number}
*/
override getTime() {
return this.player?.getCurrentTime();
}
/**
* Seeks music to provided time.
*
* @param {number} time - The time to seek to.
* @returns {void}
*/
override seek(time: number) {
return this.player?.seekTo(time);
}
/**
* Plays music.
*
* @returns {void}
*/
override play() {
return this.player?.playVideo();
}
/**
* Pauses music.
*
* @returns {void}
*/
override pause() {
return this.player?.pauseVideo();
}
/**
* Mutes music.
*
* @returns {void}
*/
override mute() {
return this.player?.mute();
}
/**
* Unmutes music.
*
* @returns {void}
*/
override unMute() {
return this.player?.unMute();
}
/**
* Disposes of the current music player.
*
* @returns {void}
*/
override dispose() {
if (this.player) {
this.player.destroy();
this.player = null;
}
}
/**
* Fired on play state toggle.
*
* @param {Object} event - The yt player stateChange event.
* @returns {void}
*/
onPlayerStateChange = (event: any) => {
if (event.data === YouTube.PlayerState.PLAYING) {
this.onPlay();
} else if (event.data === YouTube.PlayerState.PAUSED) {
this.onPause();
}
};
/**
* Fired when youtube player is ready.
*
* @param {Object} event - The youtube player event.
* @returns {void}
*/
onPlayerReady = (event: any) => {
const { _isOwner } = this.props;
this.player = event.target;
this.player.addEventListener('onVolumeChange', () => {
this.onVolumeChange();
});
if (_isOwner) {
this.player.addEventListener('onVideoProgress', this.throttledFireUpdateSharedMusicEvent);
}
this.play();
// Sometimes youtube can get muted state from previous videos
if (this.isMuted()) {
this.unMute();
}
};
/**
* Returns player options for YouTube.
*
* @returns {Object}
*/
getPlayerOptions = () => {
const { _isOwner, musicId } = this.props;
const showControls = _isOwner ? 1 : 0;
const options = {
id: 'sharedMusicPlayer',
opts: {
height: '1',
width: '1',
playerVars: {
'origin': location.origin,
'fs': '0',
'autoplay': 0,
'controls': showControls,
'rel': 0
}
},
onError: (e: any) => this.onError(e),
onReady: this.onPlayerReady,
onStateChange: this.onPlayerStateChange,
videoId: musicId
};
return options;
};
/**
* Implements React Component's render.
*
* @inheritdoc
*/
override render() {
// Render the YouTube player hidden (offscreen) since we only need audio
return (
<div className = 'okhide'>
<YouTube
{ ...this.getPlayerOptions() } />
</div>
);
}
}
export default connect(_mapStateToProps, _mapDispatchToProps)(YouTubeMusicManager);

View File

@ -0,0 +1,45 @@
/**
* Fixed name of the music player fake participant.
*
* @type {string}
*/
export const MUSIC_PLAYER_PARTICIPANT_NAME = 'Music';
/**
* Shared music command.
*
* @type {string}
*/
export const SHARED_MUSIC = 'shared-music';
/**
* Available playback statuses.
*/
export const PLAYBACK_STATUSES = {
PLAYING: 'playing',
PAUSED: 'pause',
STOPPED: 'stop'
};
/**
* Playback start state.
*/
export const PLAYBACK_START = 'start';
/**
* Source types for shared music.
*/
export const SOURCE_TYPES = {
YOUTUBE: 'youtube',
DIRECT: 'direct'
} as const;
/**
* The domain for youtube URLs.
*/
export const YOUTUBE_URL_DOMAIN = 'youtube.com';
/**
* YouTube Music domain.
*/
export const YOUTUBE_MUSIC_URL_DOMAIN = 'music.youtube.com';

View File

@ -0,0 +1,188 @@
import { IStateful } from '../base/app/types';
import { IJitsiConference } from '../base/conference/reducer';
import { toState } from '../base/redux/functions';
import {
PLAYBACK_START,
PLAYBACK_STATUSES,
SHARED_MUSIC,
SOURCE_TYPES,
YOUTUBE_MUSIC_URL_DOMAIN,
YOUTUBE_URL_DOMAIN
} from './constants';
import { SourceType } from './types';
/**
* Validates the entered URL and extracts YouTube ID if applicable.
*
* @param {string} url - The entered URL.
* @returns {string | null} The youtube video id if matched.
*/
function getYoutubeId(url: string): string | null {
if (!url) {
return null;
}
// eslint-disable-next-line max-len
const p = /^(?:https?:\/\/)?(?:www\.)?(?:youtu\.be\/|(?:m\.)?(?:music\.)?youtube\.com\/(?:embed\/|v\/|watch\?v=|watch\?.+&v=))((\w|-){11})(?:\S+)?$/;
const result = url.match(p);
return result ? result[1] : null;
}
/**
* Checks if the status is one that is actually sharing music - playing, pause or start.
*
* @param {string} status - The shared music status.
* @returns {boolean}
*/
export function isSharingStatus(status: string): boolean {
return [ PLAYBACK_STATUSES.PLAYING, PLAYBACK_STATUSES.PAUSED, PLAYBACK_START ].includes(status);
}
/**
* Determines the source type of the given URL.
*
* @param {string} url - The URL to check.
* @returns {SourceType} The source type.
*/
export function getSourceType(url: string): SourceType {
const youtubeId = getYoutubeId(url);
if (youtubeId) {
return SOURCE_TYPES.YOUTUBE;
}
try {
const urlObj = new URL(url);
if (urlObj.hostname.includes(YOUTUBE_URL_DOMAIN)
|| urlObj.hostname.includes(YOUTUBE_MUSIC_URL_DOMAIN)) {
return SOURCE_TYPES.YOUTUBE;
}
} catch (_) {
// Not a valid URL
}
return SOURCE_TYPES.DIRECT;
}
/**
* Extracts a YouTube ID or validates a direct URL.
*
* @param {string} input - The user input.
* @returns {Object | undefined} An object with url and sourceType, or undefined.
*/
export function extractMusicUrl(input: string): { sourceType: SourceType; url: string; } | undefined {
if (!input) {
return;
}
const trimmedLink = input.trim();
if (!trimmedLink) {
return;
}
const youtubeId = getYoutubeId(trimmedLink);
if (youtubeId) {
return {
url: youtubeId,
sourceType: SOURCE_TYPES.YOUTUBE
};
}
// Check if the URL is valid
try {
const urlObj = new URL(trimmedLink);
// Check if it's a YouTube URL
if (urlObj.hostname.includes(YOUTUBE_URL_DOMAIN)
|| urlObj.hostname.includes(YOUTUBE_MUSIC_URL_DOMAIN)) {
const videoId = getYoutubeId(trimmedLink);
if (videoId) {
return {
url: videoId,
sourceType: SOURCE_TYPES.YOUTUBE
};
}
}
// It's a direct URL
return {
url: trimmedLink,
sourceType: SOURCE_TYPES.DIRECT
};
} catch (_) {
return;
}
}
/**
* Returns true if shared music functionality is enabled.
*
* @param {IStateful} stateful - The redux store or getState function.
* @returns {boolean}
*/
export function isSharedMusicEnabled(stateful: IStateful): boolean {
const state = toState(stateful);
const { disableThirdPartyRequests = false } = state['features/base/config'];
return !disableThirdPartyRequests;
}
/**
* Returns true if music is currently playing.
*
* @param {IStateful} stateful - The redux store or getState function.
* @returns {boolean}
*/
export function isMusicPlaying(stateful: IStateful): boolean {
const state = toState(stateful);
const { status } = state['features/shared-music'];
return isSharingStatus(status ?? '');
}
/**
* Sends SHARED_MUSIC command.
*
* @param {Object} options - The command options.
* @param {string} options.id - The id of the music.
* @param {string} options.status - The status of the shared music.
* @param {IJitsiConference} options.conference - The current conference.
* @param {string} options.localParticipantId - The id of the local participant.
* @param {number} options.time - The seek position of the music.
* @param {boolean} options.muted - Whether the music is muted.
* @param {number} options.volume - The volume level.
* @param {string} options.sourceType - The source type.
* @param {string} options.title - The track title.
* @returns {void}
*/
export function sendShareMusicCommand({ id, status, conference, localParticipantId = '', time, muted, volume,
sourceType, title }: {
conference?: IJitsiConference;
id: string;
localParticipantId?: string;
muted?: boolean;
sourceType?: string;
status: string;
time: number;
title?: string;
volume?: number;
}): void {
conference?.sendCommandOnce(SHARED_MUSIC, {
value: id,
attributes: {
from: localParticipantId,
muted,
sourceType,
state: status,
time,
title,
volume
}
});
}

View File

@ -0,0 +1,23 @@
import { useSelector } from 'react-redux';
import { SharedMusicButton } from './components';
import { isSharedMusicEnabled } from './functions';
const sharedMusic = {
key: 'sharedmusic',
Content: SharedMusicButton,
group: 3
};
/**
* A hook that returns the shared music button if it is enabled and undefined otherwise.
*
* @returns {Object | undefined}
*/
export function useSharedMusicButton() {
const sharedMusicEnabled = useSelector(isSharedMusicEnabled);
if (sharedMusicEnabled) {
return sharedMusic;
}
}

View File

@ -0,0 +1,3 @@
import { getLogger } from '../base/logging/functions';
export default getLogger('app:shared-music');

View File

@ -0,0 +1,208 @@
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 MiddlewareRegistry from '../base/redux/MiddlewareRegistry';
import { RESET_SHARED_MUSIC_STATUS, SET_SHARED_MUSIC_STATUS } from './actionTypes';
import {
resetSharedMusicStatus,
setSharedMusicStatus
} from './actions';
import {
PLAYBACK_START,
PLAYBACK_STATUSES,
SHARED_MUSIC,
SOURCE_TYPES
} from './constants';
import { isSharedMusicEnabled, isSharingStatus, sendShareMusicCommand } from './functions';
import logger from './logger';
import { IMusicCommandAttributes, SourceType } from './types';
/**
* Middleware that captures actions related to music sharing and updates
* components not hooked into redux.
*
* @param {Store} store - The redux store.
* @returns {Function}
*/
MiddlewareRegistry.register(store => next => action => {
const { dispatch, getState } = store;
if (!isSharedMusicEnabled(getState())) {
return next(action);
}
switch (action.type) {
case CONFERENCE_JOIN_IN_PROGRESS: {
const { conference } = action;
const localParticipantId = getLocalParticipant(getState())?.id;
conference.addCommandListener(SHARED_MUSIC,
({ value, attributes }: { attributes: IMusicCommandAttributes; value: string; }) => {
const state = getState();
const sharedMusicStatus = attributes.state;
const { ownerId } = state['features/shared-music'];
if (ownerId && ownerId !== attributes.from) {
logger.warn(
`User with id: ${attributes.from} sent shared music command: ${sharedMusicStatus} `
+ 'while we are playing.');
return;
}
if (isSharingStatus(sharedMusicStatus)) {
handleSharingMusicStatus(store, value, attributes, conference);
return;
}
if (sharedMusicStatus === 'stop') {
if (localParticipantId !== attributes.from) {
dispatch(resetSharedMusicStatus());
}
}
}
);
break;
}
case CONFERENCE_LEFT:
dispatch(resetSharedMusicStatus());
break;
case PARTICIPANT_LEFT: {
const state = getState();
const { ownerId: stateOwnerId } = state['features/shared-music'];
if (action.participant.id === stateOwnerId) {
dispatch(resetSharedMusicStatus());
}
break;
}
case SET_SHARED_MUSIC_STATUS: {
const state = getState();
const conference = getCurrentConference(state);
const localParticipantId = getLocalParticipant(state)?.id;
const { musicUrl, status, ownerId, time, muted, volume, sourceType, title } = action;
const operator = status === PLAYBACK_STATUSES.PLAYING ? 'is' : '';
logger.debug(`User with id: ${ownerId} ${operator} ${status} music sharing.`);
if (typeof APP !== 'undefined') {
APP.API.notifyAudioOrVideoSharingToggled(MEDIA_TYPE.AUDIO, status, ownerId);
}
// Don't send command for start status - already sent in playSharedMusic
if (status === 'start') {
break;
}
if (localParticipantId === ownerId) {
sendShareMusicCommand({
conference,
localParticipantId,
muted,
status,
time,
id: musicUrl,
volume,
sourceType,
title
});
}
break;
}
case RESET_SHARED_MUSIC_STATUS: {
const state = getState();
const localParticipantId = getLocalParticipant(state)?.id;
const { ownerId: stateOwnerId, musicUrl: stateMusicUrl, sourceType } = state['features/shared-music'];
if (!stateOwnerId) {
break;
}
logger.debug(`User with id: ${stateOwnerId} stop music sharing.`);
if (typeof APP !== 'undefined') {
APP.API.notifyAudioOrVideoSharingToggled(MEDIA_TYPE.AUDIO, 'stop', stateOwnerId);
}
if (localParticipantId === stateOwnerId) {
const conference = getCurrentConference(state);
sendShareMusicCommand({
conference,
id: stateMusicUrl ?? '',
localParticipantId,
muted: true,
status: 'stop',
time: 0,
volume: 0,
sourceType
});
}
break;
}
}
return next(action);
});
/**
* Handles the playing, pause and start statuses for the shared music.
* 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.
* @returns {void}
*/
function handleSharingMusicStatus(store: IStore, musicUrl: string,
attributes: IMusicCommandAttributes,
_conference: IJitsiConference) {
const { dispatch, getState } = store;
const localParticipantId = getLocalParticipant(getState())?.id;
const oldStatus = getState()['features/shared-music']?.status ?? '';
const oldMusicUrl = getState()['features/shared-music'].musicUrl;
if (oldMusicUrl && oldMusicUrl !== musicUrl) {
logger.warn(
`User with id: ${attributes.from} sent musicUrl: ${musicUrl} while we are playing: ${oldMusicUrl}`);
return;
}
const sourceType = (attributes.sourceType as SourceType) || SOURCE_TYPES.DIRECT;
// If music was not started, set the initial status
if (attributes.state === PLAYBACK_START || !isSharingStatus(oldStatus)) {
if (localParticipantId === attributes.from) {
dispatch(setSharedMusicStatus({
musicUrl,
status: attributes.state,
time: Number(attributes.time),
ownerId: localParticipantId,
sourceType,
title: attributes.title
}));
}
}
if (localParticipantId !== attributes.from) {
dispatch(setSharedMusicStatus({
muted: attributes.muted === 'true',
ownerId: attributes.from,
status: attributes.state,
time: Number(attributes.time),
musicUrl,
volume: attributes.volume ? Number(attributes.volume) : undefined,
sourceType,
title: attributes.title
}));
}
}

View File

@ -0,0 +1,52 @@
import ReducerRegistry from '../base/redux/ReducerRegistry';
import {
RESET_SHARED_MUSIC_STATUS,
SET_SHARED_MUSIC_MINIMIZED,
SET_SHARED_MUSIC_STATUS
} from './actionTypes';
import { ISharedMusicState } from './types';
const initialState: ISharedMusicState = {
minimized: false
};
/**
* Reduces the Redux actions of the feature features/shared-music.
*/
ReducerRegistry.register<ISharedMusicState>('features/shared-music',
(state = initialState, action): ISharedMusicState => {
const { musicUrl, status, time, ownerId, muted, volume, sourceType, title, duration } = action;
switch (action.type) {
case RESET_SHARED_MUSIC_STATUS:
return {
...initialState
};
case SET_SHARED_MUSIC_STATUS:
return {
...state,
duration,
muted,
musicUrl,
ownerId,
sourceType,
status,
time,
title,
volume
};
case SET_SHARED_MUSIC_MINIMIZED:
return {
...state,
minimized: action.minimized
};
default:
return state;
}
});
export { ISharedMusicState };

View File

@ -0,0 +1,74 @@
import { SOURCE_TYPES } from './constants';
/**
* The source type for shared music.
*/
export type SourceType = typeof SOURCE_TYPES[keyof typeof SOURCE_TYPES];
/**
* The shared music state interface.
*/
export interface ISharedMusicState {
/**
* The duration of the current music track.
*/
duration?: number;
/**
* Whether the music player is minimized.
*/
minimized: boolean;
/**
* The music URL being shared.
*/
musicUrl?: string;
/**
* Whether the music is muted.
*/
muted?: boolean;
/**
* The ID of the participant who owns/started the shared music.
*/
ownerId?: string;
/**
* The type of the music source (youtube or direct).
*/
sourceType?: SourceType;
/**
* The playback status (playing, paused, stopped).
*/
status?: string;
/**
* The current playback time in seconds.
*/
time?: number;
/**
* The title of the music track.
*/
title?: string;
/**
* The volume level (0-100).
*/
volume?: number;
}
/**
* Music command attributes received from conference.
*/
export interface IMusicCommandAttributes {
from: string;
muted: string;
sourceType: string;
state: string;
time: string;
title?: string;
volume?: string;
}

View File

@ -48,6 +48,7 @@ import ShareAudioButton from '../screen-share/components/web/ShareAudioButton';
import { isScreenAudioSupported, isScreenVideoShared } from '../screen-share/functions';
import { useSecurityDialogButton } from '../security/hooks.web';
import SettingsButton from '../settings/components/web/SettingsButton';
import { useSharedMusicButton } from '../shared-music/hooks';
import { useSharedVideoButton } from '../shared-video/hooks';
import SpeakerStats from '../speaker-stats/components/web/SpeakerStats';
import { isSpeakerStatsDisabled } from '../speaker-stats/functions';
@ -283,6 +284,7 @@ export function useToolboxButtons(
const linktosalesforce = useLinkToSalesforceButton();
const shareaudio = getShareAudioButton();
const shareVideo = useSharedVideoButton();
const shareMusic = useSharedMusicButton();
const whiteboard = useWhiteboardButton();
const etherpad = useEtherpadButton();
const virtualBackground = useVirtualBackgroundButton();
@ -315,6 +317,7 @@ export function useToolboxButtons(
livestreaming: liveStreaming,
linktosalesforce,
sharedvideo: shareVideo,
sharedmusic: shareMusic,
shareaudio,
noisesuppression: noiseSuppression,
whiteboard,

View File

@ -49,6 +49,7 @@ export type ToolbarButton = 'camera' |
'settings' |
'shareaudio' |
'sharedvideo' |
'sharedmusic' |
'shortcuts' |
'stats' |
'tileview' |