diff --git a/css/_utils.scss b/css/_utils.scss index ce26463..a7aeed5 100644 --- a/css/_utils.scss +++ b/css/_utils.scss @@ -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; } diff --git a/lang/main.json b/lang/main.json index e6a7541..bf301b8 100644 --- a/lang/main.json +++ b/lang/main.json @@ -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", diff --git a/react/features/app/middlewares.web.ts b/react/features/app/middlewares.web.ts index 9f46023..1156a4b 100644 --- a/react/features/app/middlewares.web.ts +++ b/react/features/app/middlewares.web.ts @@ -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'; diff --git a/react/features/app/reducers.any.ts b/react/features/app/reducers.any.ts index 28e22b3..41152f5 100644 --- a/react/features/app/reducers.any.ts +++ b/react/features/app/reducers.any.ts @@ -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'; diff --git a/react/features/app/types.ts b/react/features/app/types.ts index 0ef5d57..a7ed827 100644 --- a/react/features/app/types.ts +++ b/react/features/app/types.ts @@ -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; diff --git a/react/features/shared-music/actionTypes.ts b/react/features/shared-music/actionTypes.ts new file mode 100644 index 0000000..b3c6460 --- /dev/null +++ b/react/features/shared-music/actionTypes.ts @@ -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'; diff --git a/react/features/shared-music/actions.ts b/react/features/shared-music/actions.ts new file mode 100644 index 0000000..fb44ecb --- /dev/null +++ b/react/features/shared-music/actions.ts @@ -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)))); + } + }; +} diff --git a/react/features/shared-music/components/index.web.ts b/react/features/shared-music/components/index.web.ts new file mode 100644 index 0000000..34768d8 --- /dev/null +++ b/react/features/shared-music/components/index.web.ts @@ -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'; diff --git a/react/features/shared-music/components/web/AbstractMusicManager.ts b/react/features/shared-music/components/web/AbstractMusicManager.ts new file mode 100644 index 0000000..43980bb --- /dev/null +++ b/react/features/shared-music/components/web/AbstractMusicManager.ts @@ -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 { + 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 + })); + } + }; +} diff --git a/react/features/shared-music/components/web/DirectAudioManager.tsx b/react/features/shared-music/components/web/DirectAudioManager.tsx new file mode 100644 index 0000000..8d53e0d --- /dev/null +++ b/react/features/shared-music/components/web/DirectAudioManager.tsx @@ -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; + + /** + * 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 ( +