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:
parent
9f4b09d135
commit
1c9fed8794
|
|
@ -2,6 +2,15 @@
|
||||||
transform: scaleX(-1);
|
transform: scaleX(-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Positions an element offscreen for audio-only players.
|
||||||
|
*/
|
||||||
|
.okhide {
|
||||||
|
position: absolute;
|
||||||
|
left: -9999px;
|
||||||
|
top: -9999px;
|
||||||
|
}
|
||||||
|
|
||||||
.hidden {
|
.hidden {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -500,6 +500,11 @@
|
||||||
"shareYourScreenDisabled": "Screen sharing disabled.",
|
"shareYourScreenDisabled": "Screen sharing disabled.",
|
||||||
"sharedVideoDialogError": "Error: Invalid or forbidden URL",
|
"sharedVideoDialogError": "Error: Invalid or forbidden URL",
|
||||||
"sharedVideoLinkPlaceholder": "YouTube link or direct video link",
|
"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",
|
"show": "Show",
|
||||||
"start": "Start ",
|
"start": "Start ",
|
||||||
"startLiveStreaming": "Start live stream",
|
"startLiveStreaming": "Start live stream",
|
||||||
|
|
@ -1378,6 +1383,8 @@
|
||||||
"shareYourScreen": "Start sharing your screen",
|
"shareYourScreen": "Start sharing your screen",
|
||||||
"shareaudio": "Share audio",
|
"shareaudio": "Share audio",
|
||||||
"sharedvideo": "Share video",
|
"sharedvideo": "Share video",
|
||||||
|
"sharedmusic": "Share music",
|
||||||
|
"stopSharedMusic": "Stop music",
|
||||||
"shortcuts": "Toggle shortcuts",
|
"shortcuts": "Toggle shortcuts",
|
||||||
"show": "Show on stage",
|
"show": "Show on stage",
|
||||||
"showWhiteboard": "Show whiteboard",
|
"showWhiteboard": "Show whiteboard",
|
||||||
|
|
@ -1492,6 +1499,8 @@
|
||||||
"shareRoom": "Invite someone",
|
"shareRoom": "Invite someone",
|
||||||
"shareaudio": "Share audio",
|
"shareaudio": "Share audio",
|
||||||
"sharedvideo": "Share video",
|
"sharedvideo": "Share video",
|
||||||
|
"sharedmusic": "Share music",
|
||||||
|
"stopSharedMusic": "Stop music",
|
||||||
"shortcuts": "View shortcuts",
|
"shortcuts": "View shortcuts",
|
||||||
"showWhiteboard": "Show whiteboard",
|
"showWhiteboard": "Show whiteboard",
|
||||||
"silence": "Silence",
|
"silence": "Silence",
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import '../power-monitor/middleware';
|
||||||
import '../prejoin/middleware';
|
import '../prejoin/middleware';
|
||||||
import '../remote-control/middleware';
|
import '../remote-control/middleware';
|
||||||
import '../screen-share/middleware';
|
import '../screen-share/middleware';
|
||||||
|
import '../shared-music/middleware';
|
||||||
import '../shared-video/middleware';
|
import '../shared-video/middleware';
|
||||||
import '../web-hid/middleware';
|
import '../web-hid/middleware';
|
||||||
import '../settings/middleware';
|
import '../settings/middleware';
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,7 @@ import '../recent-list/reducer';
|
||||||
import '../recording/reducer';
|
import '../recording/reducer';
|
||||||
import '../settings/reducer';
|
import '../settings/reducer';
|
||||||
import '../speaker-stats/reducer';
|
import '../speaker-stats/reducer';
|
||||||
|
import '../shared-music/reducer';
|
||||||
import '../shared-video/reducer';
|
import '../shared-video/reducer';
|
||||||
import '../subtitles/reducer';
|
import '../subtitles/reducer';
|
||||||
import '../screen-share/reducer';
|
import '../screen-share/reducer';
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,7 @@ import { IRemoteControlState } from '../remote-control/reducer';
|
||||||
import { IScreenShareState } from '../screen-share/reducer';
|
import { IScreenShareState } from '../screen-share/reducer';
|
||||||
import { IScreenshotCaptureState } from '../screenshot-capture/reducer';
|
import { IScreenshotCaptureState } from '../screenshot-capture/reducer';
|
||||||
import { IShareRoomState } from '../share-room/reducer';
|
import { IShareRoomState } from '../share-room/reducer';
|
||||||
|
import { ISharedMusicState } from '../shared-music/reducer';
|
||||||
import { ISharedVideoState } from '../shared-video/reducer';
|
import { ISharedVideoState } from '../shared-video/reducer';
|
||||||
import { ISpeakerStatsState } from '../speaker-stats/reducer';
|
import { ISpeakerStatsState } from '../speaker-stats/reducer';
|
||||||
import { ISubtitlesState } from '../subtitles/reducer';
|
import { ISubtitlesState } from '../subtitles/reducer';
|
||||||
|
|
@ -159,6 +160,7 @@ export interface IReduxState {
|
||||||
'features/screenshot-capture': IScreenshotCaptureState;
|
'features/screenshot-capture': IScreenshotCaptureState;
|
||||||
'features/settings': ISettingsState;
|
'features/settings': ISettingsState;
|
||||||
'features/share-room': IShareRoomState;
|
'features/share-room': IShareRoomState;
|
||||||
|
'features/shared-music': ISharedMusicState;
|
||||||
'features/shared-video': ISharedVideoState;
|
'features/shared-video': ISharedVideoState;
|
||||||
'features/speaker-stats': ISpeakerStatsState;
|
'features/speaker-stats': ISpeakerStatsState;
|
||||||
'features/subtitles': ISubtitlesState;
|
'features/subtitles': ISubtitlesState;
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
@ -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))));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -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';
|
||||||
|
|
@ -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
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
|
@ -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));
|
||||||
|
|
@ -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));
|
||||||
|
|
@ -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);
|
||||||
|
|
@ -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);
|
||||||
|
|
@ -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';
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
import { getLogger } from '../base/logging/functions';
|
||||||
|
|
||||||
|
export default getLogger('app:shared-music');
|
||||||
|
|
@ -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
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 };
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -48,6 +48,7 @@ import ShareAudioButton from '../screen-share/components/web/ShareAudioButton';
|
||||||
import { isScreenAudioSupported, isScreenVideoShared } from '../screen-share/functions';
|
import { isScreenAudioSupported, isScreenVideoShared } from '../screen-share/functions';
|
||||||
import { useSecurityDialogButton } from '../security/hooks.web';
|
import { useSecurityDialogButton } from '../security/hooks.web';
|
||||||
import SettingsButton from '../settings/components/web/SettingsButton';
|
import SettingsButton from '../settings/components/web/SettingsButton';
|
||||||
|
import { useSharedMusicButton } from '../shared-music/hooks';
|
||||||
import { useSharedVideoButton } from '../shared-video/hooks';
|
import { useSharedVideoButton } from '../shared-video/hooks';
|
||||||
import SpeakerStats from '../speaker-stats/components/web/SpeakerStats';
|
import SpeakerStats from '../speaker-stats/components/web/SpeakerStats';
|
||||||
import { isSpeakerStatsDisabled } from '../speaker-stats/functions';
|
import { isSpeakerStatsDisabled } from '../speaker-stats/functions';
|
||||||
|
|
@ -283,6 +284,7 @@ export function useToolboxButtons(
|
||||||
const linktosalesforce = useLinkToSalesforceButton();
|
const linktosalesforce = useLinkToSalesforceButton();
|
||||||
const shareaudio = getShareAudioButton();
|
const shareaudio = getShareAudioButton();
|
||||||
const shareVideo = useSharedVideoButton();
|
const shareVideo = useSharedVideoButton();
|
||||||
|
const shareMusic = useSharedMusicButton();
|
||||||
const whiteboard = useWhiteboardButton();
|
const whiteboard = useWhiteboardButton();
|
||||||
const etherpad = useEtherpadButton();
|
const etherpad = useEtherpadButton();
|
||||||
const virtualBackground = useVirtualBackgroundButton();
|
const virtualBackground = useVirtualBackgroundButton();
|
||||||
|
|
@ -315,6 +317,7 @@ export function useToolboxButtons(
|
||||||
livestreaming: liveStreaming,
|
livestreaming: liveStreaming,
|
||||||
linktosalesforce,
|
linktosalesforce,
|
||||||
sharedvideo: shareVideo,
|
sharedvideo: shareVideo,
|
||||||
|
sharedmusic: shareMusic,
|
||||||
shareaudio,
|
shareaudio,
|
||||||
noisesuppression: noiseSuppression,
|
noisesuppression: noiseSuppression,
|
||||||
whiteboard,
|
whiteboard,
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,7 @@ export type ToolbarButton = 'camera' |
|
||||||
'settings' |
|
'settings' |
|
||||||
'shareaudio' |
|
'shareaudio' |
|
||||||
'sharedvideo' |
|
'sharedvideo' |
|
||||||
|
'sharedmusic' |
|
||||||
'shortcuts' |
|
'shortcuts' |
|
||||||
'stats' |
|
'stats' |
|
||||||
'tileview' |
|
'tileview' |
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue