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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Positions an element offscreen for audio-only players.
|
||||
*/
|
||||
.okhide {
|
||||
position: absolute;
|
||||
left: -9999px;
|
||||
top: -9999px;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 { useSecurityDialogButton } from '../security/hooks.web';
|
||||
import SettingsButton from '../settings/components/web/SettingsButton';
|
||||
import { useSharedMusicButton } from '../shared-music/hooks';
|
||||
import { useSharedVideoButton } from '../shared-video/hooks';
|
||||
import SpeakerStats from '../speaker-stats/components/web/SpeakerStats';
|
||||
import { isSpeakerStatsDisabled } from '../speaker-stats/functions';
|
||||
|
|
@ -283,6 +284,7 @@ export function useToolboxButtons(
|
|||
const linktosalesforce = useLinkToSalesforceButton();
|
||||
const shareaudio = getShareAudioButton();
|
||||
const shareVideo = useSharedVideoButton();
|
||||
const shareMusic = useSharedMusicButton();
|
||||
const whiteboard = useWhiteboardButton();
|
||||
const etherpad = useEtherpadButton();
|
||||
const virtualBackground = useVirtualBackgroundButton();
|
||||
|
|
@ -315,6 +317,7 @@ export function useToolboxButtons(
|
|||
livestreaming: liveStreaming,
|
||||
linktosalesforce,
|
||||
sharedvideo: shareVideo,
|
||||
sharedmusic: shareMusic,
|
||||
shareaudio,
|
||||
noisesuppression: noiseSuppression,
|
||||
whiteboard,
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ export type ToolbarButton = 'camera' |
|
|||
'settings' |
|
||||
'shareaudio' |
|
||||
'sharedvideo' |
|
||||
'sharedmusic' |
|
||||
'shortcuts' |
|
||||
'stats' |
|
||||
'tileview' |
|
||||
|
|
|
|||
Loading…
Reference in New Issue