fix(shared-music): render player and add mobile/native support

- Add SharedMusicPlayer to LargeVideo.web.tsx so audio actually plays
- Handle mobile autoplay restrictions in DirectAudioManager with
  fallback to play on user interaction
- Add native (React Native) components for SharedMusicDialog and
  SharedMusicButton to support iOS/Android apps

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-01-29 11:04:01 +00:00
parent 1c9fed8794
commit dfebe26cd1
5 changed files with 255 additions and 3 deletions

View File

@ -15,6 +15,7 @@ import { isSpotTV } from '../../base/util/spot';
import StageParticipantNameLabel from '../../display-name/components/web/StageParticipantNameLabel';
import { FILMSTRIP_BREAKPOINT } from '../../filmstrip/constants';
import { getVerticalViewMaxWidth, isFilmstripResizable } from '../../filmstrip/functions.web';
import { SharedMusicPlayer } from '../../shared-music/components';
import SharedVideo from '../../shared-video/components/web/SharedVideo';
import Captions from '../../subtitles/components/web/Captions';
import { areClosedCaptionsEnabled } from '../../subtitles/functions.any';
@ -218,6 +219,7 @@ class LargeVideo extends Component<IProps> {
ref = { this._containerRef }
style = { style }>
<SharedVideo />
<SharedMusicPlayer />
{_whiteboardEnabled && <Whiteboard />}
<div id = 'etherpad' />

View File

@ -0,0 +1,5 @@
export { default as SharedMusicDialog } from './native/SharedMusicDialog';
export { default as SharedMusicButton } from './native/SharedMusicButton';
// SharedMusicPlayer is web-only - no native implementation yet
export const SharedMusicPlayer = null;

View File

@ -0,0 +1,110 @@
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';
/**
* The type of the React {@code Component} props of {@link SharedMusicButton}.
*/
interface IProps extends AbstractButtonProps {
/**
* Whether or not the button is disabled.
*/
_isDisabled: boolean;
/**
* Whether or not the local participant is sharing music.
*/
_sharingMusic: boolean;
}
/**
* Component that renders a toolbar button for sharing music.
*
* @augments AbstractButton
*/
class SharedMusicButton extends AbstractButton<IProps> {
override accessibilityLabel = 'toolbar.accessibilityLabel.sharedmusic';
override icon = IconAudioOnly;
override label = 'toolbar.sharedmusic';
override toggledLabel = 'toolbar.stopSharedMusic';
/**
* Handles clicking / pressing the button.
*
* @override
* @protected
* @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.
* @param {Object} ownProps - The properties explicitly passed to the component instance.
* @private
* @returns {IProps}
*/
function _mapStateToProps(state: IReduxState, ownProps: any) {
const { ownerId, status: sharedMusicStatus } = state['features/shared-music'];
const localParticipantId = getLocalParticipant(state)?.id;
const { visible = true } = ownProps;
if (ownerId !== localParticipantId) {
return {
_isDisabled: isSharingStatus(sharedMusicStatus ?? ''),
_sharingMusic: false,
visible
};
}
return {
_isDisabled: false,
_sharingMusic: isSharingStatus(sharedMusicStatus ?? ''),
visible
};
}
export default translate(connect(_mapStateToProps)(SharedMusicButton));

View File

@ -0,0 +1,104 @@
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 InputDialog from '../../../base/dialog/components/native/InputDialog';
import { translate } from '../../../base/i18n/functions';
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;
}
/**
* Component that renders the music share dialog for native.
*/
class SharedMusicDialog extends Component<IProps, IState> {
/**
* Instantiates a new component.
*
* @inheritdoc
*/
constructor(props: IProps) {
super(props);
this.state = {
error: false
};
this._onSubmitValue = this._onSubmitValue.bind(this);
}
/**
* Callback to be invoked when the value of the link input is submitted.
*
* @param {string} value - The entered music link.
* @returns {boolean}
*/
_onSubmitValue(value: string) {
const { onPostSubmit, dispatch } = this.props;
const extracted = extractMusicUrl(value);
if (!extracted) {
this.setState({ error: true });
return false;
}
onPostSubmit(value);
dispatch(hideDialog());
return true;
}
/**
* Implements React's {@link Component#render()}.
*
* @inheritdoc
*/
override render() {
const { t } = this.props;
const { error } = this.state;
return (
<InputDialog
messageKey = { error ? 'dialog.sharedMusicDialogError' : undefined }
onSubmit = { this._onSubmitValue }
textInputProps = {{
autoCapitalize: 'none',
autoCorrect: false,
placeholder: t('dialog.sharedMusicLinkPlaceholder')
}}
titleKey = 'dialog.shareMusicTitle' />
);
}
}
/**
* Maps part of the Redux state to the props of this component.
*
* @param {Object} _state - The Redux state.
* @private
* @returns {Object}
*/
function mapStateToProps(_state: IReduxState) {
return {};
}
export default translate(connect(mapStateToProps)(SharedMusicDialog));

View File

@ -2,6 +2,7 @@ import React from 'react';
import { connect } from 'react-redux';
import { PLAYBACK_STATUSES } from '../../constants';
import logger from '../../logger';
import AbstractMusicManager, {
IProps,
@ -15,6 +16,7 @@ import AbstractMusicManager, {
*/
class DirectAudioManager extends AbstractMusicManager {
audioRef: React.RefObject<HTMLAudioElement>;
_autoplayBlocked: boolean;
/**
* Initializes a new DirectAudioManager instance.
@ -26,6 +28,7 @@ class DirectAudioManager extends AbstractMusicManager {
super(props);
this.audioRef = React.createRef();
this._autoplayBlocked = false;
}
/**
@ -98,12 +101,40 @@ class DirectAudioManager extends AbstractMusicManager {
}
/**
* Plays music.
* Plays music. Handles autoplay restrictions on mobile browsers.
*
* @returns {void}
* @returns {Promise<void> | undefined}
*/
override play() {
return this.player?.play();
const playPromise = this.player?.play();
if (playPromise !== undefined) {
playPromise.catch((error: Error) => {
if (error.name === 'NotAllowedError') {
logger.warn('Autoplay blocked by browser. Music will play on user interaction.');
this._autoplayBlocked = true;
// Add a one-time click listener to resume playback
const resumePlayback = () => {
if (this._autoplayBlocked && this.player) {
this.player.play().catch(() => {
// Ignore if still blocked
});
this._autoplayBlocked = false;
}
document.removeEventListener('click', resumePlayback);
document.removeEventListener('touchstart', resumePlayback);
};
document.addEventListener('click', resumePlayback, { once: true });
document.addEventListener('touchstart', resumePlayback, { once: true });
} else {
logger.error('Error playing music:', error);
}
});
}
return playPromise;
}
/**