Render error state for audio components

Fixes https://github.com/vector-im/element-web/issues/17148

Both `AudioPlayer` and `RecordingPlayback` got a fragment added to their DOM structure, to incorporate the rarely seen error message below it. Additionally, a missing try/catch was added to the `Playback` class to handle uncaught decoding issues.

The similarity of the components is tracked in https://github.com/vector-im/element-web/issues/18161
This commit is contained in:
Travis Ralston 2021-07-21 15:47:52 -06:00
parent b590b1d263
commit 3d72b9e227
4 changed files with 63 additions and 44 deletions

View file

@ -36,6 +36,7 @@ interface IProps {
interface IState { interface IState {
playbackPhase: PlaybackState; playbackPhase: PlaybackState;
error?: boolean;
} }
@replaceableComponent("views.audio_messages.AudioPlayer") @replaceableComponent("views.audio_messages.AudioPlayer")
@ -55,8 +56,10 @@ export default class AudioPlayer extends React.PureComponent<IProps, IState> {
// Don't wait for the promise to complete - it will emit a progress update when it // Don't wait for the promise to complete - it will emit a progress update when it
// is done, and it's not meant to take long anyhow. // is done, and it's not meant to take long anyhow.
// noinspection JSIgnoredPromiseFromCall this.props.playback.prepare().catch(e => {
this.props.playback.prepare(); console.error("Error processing audio file:", e);
this.setState({ error: true });
});
} }
private onPlaybackUpdate = (ev: PlaybackState) => { private onPlaybackUpdate = (ev: PlaybackState) => {
@ -91,34 +94,37 @@ export default class AudioPlayer extends React.PureComponent<IProps, IState> {
public render(): ReactNode { public render(): ReactNode {
// tabIndex=0 to ensure that the whole component becomes a tab stop, where we handle keyboard // tabIndex=0 to ensure that the whole component becomes a tab stop, where we handle keyboard
// events for accessibility // events for accessibility
return <div className='mx_MediaBody mx_AudioPlayer_container' tabIndex={0} onKeyDown={this.onKeyDown}> return <>
<div className='mx_AudioPlayer_primaryContainer'> <div className='mx_MediaBody mx_AudioPlayer_container' tabIndex={0} onKeyDown={this.onKeyDown}>
<PlayPauseButton <div className='mx_AudioPlayer_primaryContainer'>
playback={this.props.playback} <PlayPauseButton
playbackPhase={this.state.playbackPhase} playback={this.props.playback}
tabIndex={-1} // prevent tabbing into the button playbackPhase={this.state.playbackPhase}
ref={this.playPauseRef} tabIndex={-1} // prevent tabbing into the button
/> ref={this.playPauseRef}
<div className='mx_AudioPlayer_mediaInfo'> />
<span className='mx_AudioPlayer_mediaName'> <div className='mx_AudioPlayer_mediaInfo'>
{ this.props.mediaName || _t("Unnamed audio") } <span className='mx_AudioPlayer_mediaName'>
</span> { this.props.mediaName || _t("Unnamed audio") }
<div className='mx_AudioPlayer_byline'> </span>
<DurationClock playback={this.props.playback} /> <div className='mx_AudioPlayer_byline'>
&nbsp; { /* easiest way to introduce a gap between the components */ } <DurationClock playback={this.props.playback} />
{ this.renderFileSize() } &nbsp; { /* easiest way to introduce a gap between the components */ }
{ this.renderFileSize() }
</div>
</div> </div>
</div> </div>
<div className='mx_AudioPlayer_seek'>
<SeekBar
playback={this.props.playback}
tabIndex={-1} // prevent tabbing into the bar
playbackPhase={this.state.playbackPhase}
ref={this.seekRef}
/>
<PlaybackClock playback={this.props.playback} defaultDisplaySeconds={0} />
</div>
</div> </div>
<div className='mx_AudioPlayer_seek'> { this.state.error && <div className="text-warning">{ _t("Error downloading audio") }</div> }
<SeekBar </>;
playback={this.props.playback}
tabIndex={-1} // prevent tabbing into the bar
playbackPhase={this.state.playbackPhase}
ref={this.seekRef}
/>
<PlaybackClock playback={this.props.playback} defaultDisplaySeconds={0} />
</div>
</div>;
} }
} }

View file

@ -22,6 +22,7 @@ import PlaybackClock from "./PlaybackClock";
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
import { TileShape } from "../rooms/EventTile"; import { TileShape } from "../rooms/EventTile";
import PlaybackWaveform from "./PlaybackWaveform"; import PlaybackWaveform from "./PlaybackWaveform";
import { _t } from "../../../languageHandler";
interface IProps { interface IProps {
// Playback instance to render. Cannot change during component lifecycle: create // Playback instance to render. Cannot change during component lifecycle: create
@ -33,6 +34,7 @@ interface IProps {
interface IState { interface IState {
playbackPhase: PlaybackState; playbackPhase: PlaybackState;
error?: boolean;
} }
@replaceableComponent("views.audio_messages.RecordingPlayback") @replaceableComponent("views.audio_messages.RecordingPlayback")
@ -49,8 +51,10 @@ export default class RecordingPlayback extends React.PureComponent<IProps, IStat
// Don't wait for the promise to complete - it will emit a progress update when it // Don't wait for the promise to complete - it will emit a progress update when it
// is done, and it's not meant to take long anyhow. // is done, and it's not meant to take long anyhow.
// noinspection JSIgnoredPromiseFromCall this.props.playback.prepare().catch(e => {
this.props.playback.prepare(); console.error("Error processing audio file:", e);
this.setState({ error: true });
});
} }
private get isWaveformable(): boolean { private get isWaveformable(): boolean {
@ -65,10 +69,13 @@ export default class RecordingPlayback extends React.PureComponent<IProps, IStat
public render(): ReactNode { public render(): ReactNode {
const shapeClass = !this.isWaveformable ? 'mx_VoiceMessagePrimaryContainer_noWaveform' : ''; const shapeClass = !this.isWaveformable ? 'mx_VoiceMessagePrimaryContainer_noWaveform' : '';
return <div className={'mx_MediaBody mx_VoiceMessagePrimaryContainer ' + shapeClass}> return <>
<PlayPauseButton playback={this.props.playback} playbackPhase={this.state.playbackPhase} /> <div className={'mx_MediaBody mx_VoiceMessagePrimaryContainer ' + shapeClass}>
<PlaybackClock playback={this.props.playback} /> <PlayPauseButton playback={this.props.playback} playbackPhase={this.state.playbackPhase} />
{ this.isWaveformable && <PlaybackWaveform playback={this.props.playback} /> } <PlaybackClock playback={this.props.playback} />
</div>; { this.isWaveformable && <PlaybackWaveform playback={this.props.playback} /> }
</div>
{ this.state.error && <div className="text-warning">{ _t("Error downloading audio") }</div> }
</>;
} }
} }

View file

@ -2601,6 +2601,7 @@
"Use email to optionally be discoverable by existing contacts.": "Use email to optionally be discoverable by existing contacts.", "Use email to optionally be discoverable by existing contacts.": "Use email to optionally be discoverable by existing contacts.",
"Sign in with SSO": "Sign in with SSO", "Sign in with SSO": "Sign in with SSO",
"Unnamed audio": "Unnamed audio", "Unnamed audio": "Unnamed audio",
"Error downloading audio": "Error downloading audio",
"Pause": "Pause", "Pause": "Pause",
"Play": "Play", "Play": "Play",
"Couldn't load page": "Couldn't load page", "Couldn't load page": "Couldn't load page",

View file

@ -135,18 +135,23 @@ export class Playback extends EventEmitter implements IDestroyable {
// Safari compat: promise API not supported on this function // Safari compat: promise API not supported on this function
this.audioBuf = await new Promise((resolve, reject) => { this.audioBuf = await new Promise((resolve, reject) => {
this.context.decodeAudioData(this.buf, b => resolve(b), async e => { this.context.decodeAudioData(this.buf, b => resolve(b), async e => {
// This error handler is largely for Safari as well, which doesn't support Opus/Ogg try {
// very well. // This error handler is largely for Safari as well, which doesn't support Opus/Ogg
console.error("Error decoding recording: ", e); // very well.
console.warn("Trying to re-encode to WAV instead..."); console.error("Error decoding recording: ", e);
console.warn("Trying to re-encode to WAV instead...");
const wav = await decodeOgg(this.buf); const wav = await decodeOgg(this.buf);
// noinspection ES6MissingAwait - not needed when using callbacks // noinspection ES6MissingAwait - not needed when using callbacks
this.context.decodeAudioData(wav, b => resolve(b), e => { this.context.decodeAudioData(wav, b => resolve(b), e => {
console.error("Still failed to decode recording: ", e); console.error("Still failed to decode recording: ", e);
reject(e);
});
} catch (e) {
console.error("Caught decoding error:", e);
reject(e); reject(e);
}); }
}); });
}); });