import React, { Component, cloneElement, isValidElement, createRef, ReactNode, CSSProperties, ReactElement, Key, } from 'react' import { Icon } from '@iconify/react' import ProgressBar from './ProgressBar' import CurrentTime from './CurrentTime' import Duration from './Duration' import VolumeBar from './VolumeBar' import { RHAP_UI, MAIN_LAYOUT, AUDIO_PRELOAD_ATTRIBUTE, TIME_FORMAT } from './constants' import { throttle, getMainLayoutClassName, getDisplayTimeBySeconds } from './utils' type CustomUIModule = RHAP_UI | ReactElement type CustomUIModules = Array type OnSeek = (audio: HTMLAudioElement, time: number) => Promise interface MSEPropsObject { onSeek: OnSeek onEcrypted?: (e: unknown) => void srcDuration: number } interface PlayerProps { /** * HTML5 Audio tag autoPlay property */ autoPlay?: boolean /** * Whether to play audio after src prop is changed */ autoPlayAfterSrcChange?: boolean /** * custom classNames */ className?: string /** * The time interval to trigger onListen */ listenInterval?: number progressJumpStep?: number progressJumpSteps?: { backward?: number forward?: number } volumeJumpStep?: number loop?: boolean muted?: boolean crossOrigin?: React.AudioHTMLAttributes['crossOrigin'] mediaGroup?: string hasDefaultKeyBindings?: boolean onAbort?: (e: Event) => void onCanPlay?: (e: Event) => void onCanPlayThrough?: (e: Event) => void onEnded?: (e: Event) => void onPlaying?: (e: Event) => void onSeeking?: (e: Event) => void onSeeked?: (e: Event) => void onStalled?: (e: Event) => void onSuspend?: (e: Event) => void onLoadStart?: (e: Event) => void onLoadedMetaData?: (e: Event) => void onLoadedData?: (e: Event) => void onWaiting?: (e: Event) => void onEmptied?: (e: Event) => void onError?: (e: Event) => void onListen?: (e: Event) => void onVolumeChange?: (e: Event) => void onPause?: (e: Event) => void onPlay?: (e: Event) => void onClickPrevious?: (e: React.SyntheticEvent) => void onClickNext?: (e: React.SyntheticEvent) => void onPlayError?: (err: Error) => void onChangeCurrentTimeError?: (err: Error) => void mse?: MSEPropsObject /** * HTML5 Audio tag preload property */ preload?: AUDIO_PRELOAD_ATTRIBUTE /** * Pregress indicator refresh interval */ progressUpdateInterval?: number /** * HTML5 Audio tag src property */ src?: string defaultCurrentTime?: ReactNode defaultDuration?: ReactNode volume?: number showJumpControls?: boolean showSkipControls?: boolean showDownloadProgress?: boolean showFilledProgress?: boolean showFilledVolume?: boolean timeFormat?: TIME_FORMAT header?: ReactNode footer?: ReactNode customIcons?: CustomIcons layout?: MAIN_LAYOUT customProgressBarSection?: CustomUIModules customControlsSection?: CustomUIModules customAdditionalControls?: CustomUIModules customVolumeControls?: CustomUIModules i18nAriaLabels?: I18nAriaLabels children?: ReactNode style?: CSSProperties } interface CustomIcons { play?: ReactNode pause?: ReactNode rewind?: ReactNode forward?: ReactNode previous?: ReactNode next?: ReactNode loop?: ReactNode loopOff?: ReactNode volume?: ReactNode volumeMute?: ReactNode } interface I18nAriaLabels { player?: string progressControl?: string volumeControl?: string play?: string pause?: string rewind?: string forward?: string previous?: string next?: string loop?: string loopOff?: string volume?: string volumeMute?: string } class H5AudioPlayer extends Component { static defaultI18nAriaLabels: I18nAriaLabels = { player: 'Audio player', progressControl: 'Audio progress control', volumeControl: 'Volume control', play: 'Play', pause: 'Pause', rewind: 'Rewind', forward: 'Forward', previous: 'Previous', next: 'Skip', loop: 'Disable loop', loopOff: 'Enable loop', volume: 'Mute', volumeMute: 'Unmute', } static defaultProps: Partial = { progressJumpSteps: { backward: 5_000, forward: 5_000, }, progressJumpStep: 5_000, volumeJumpStep: 0.1, } audio = createRef() progressBar = createRef() container = createRef() lastVolume: number = this.props.volume ?? 1 // To store the volume before clicking mute button listenTracker?: number // Determine whether onListen event should be called continuously volumeAnimationTimer?: number downloadProgressAnimationTimer?: number togglePlay = (e: React.SyntheticEvent): void => { e.stopPropagation() const audio = this.audio.current if ((audio.paused || audio.ended) && audio.src) { this.playAudioPromise() } else if (!audio.paused) { audio.pause() } } /** * Safely play audio * * Reference: https://developers.google.com/web/updates/2017/06/play-request-was-interrupted */ playAudioPromise = (): void => { if (this.audio.current.error) { this.audio.current.load() } const playPromise = this.audio.current.play() // playPromise is null in IE 11 if (playPromise) { playPromise.then(null).catch((err: unknown) => { const { onPlayError } = this.props const message = err instanceof Error ? err.message : String(err) onPlayError && onPlayError(new Error(message)) }) } else { // Remove forceUpdate when stop supporting IE 11 this.forceUpdate() } } isPlaying = (): boolean => { const audio = this.audio.current if (!audio) return false return !audio.paused && !audio.ended } handlePlay = (e: Event): void => { this.forceUpdate() this.props.onPlay && this.props.onPlay(e) } handlePause = (e: Event): void => { if (!this.audio) return this.forceUpdate() this.props.onPause && this.props.onPause(e) } handleEnded = (e: Event): void => { if (!this.audio) return // Remove forceUpdate when stop supporting IE 11 this.forceUpdate() this.props.onEnded && this.props.onEnded(e) } handleAbort = (e: Event): void => { this.props.onAbort && this.props.onAbort(e) } handleClickVolumeButton = (): void => { const audio = this.audio.current if (audio.volume > 0) { this.lastVolume = audio.volume audio.volume = 0 } else { audio.volume = this.lastVolume } } handleMuteChange = (): void => { this.forceUpdate() } handleClickLoopButton = (): void => { this.audio.current.loop = !this.audio.current.loop this.forceUpdate() } handleClickRewind = (): void => { const { progressJumpSteps, progressJumpStep } = this.props const jumpStep = progressJumpSteps.backward || progressJumpStep this.setJumpTime(-jumpStep) } handleClickForward = (): void => { const { progressJumpSteps, progressJumpStep } = this.props const jumpStep = progressJumpSteps.forward || progressJumpStep this.setJumpTime(jumpStep) } setJumpTime = (time: number): void => { const audio = this.audio.current const { duration, currentTime: prevTime } = audio if ( audio.readyState === audio.HAVE_NOTHING || audio.readyState === audio.HAVE_METADATA || !isFinite(duration) || !isFinite(prevTime) ) { try { audio.load() } catch (err) { return this.props.onChangeCurrentTimeError && this.props.onChangeCurrentTimeError(err as Error) } } let currentTime = prevTime + time / 1000 if (currentTime < 0) { audio.currentTime = 0 currentTime = 0 } else if (currentTime > duration) { audio.currentTime = duration currentTime = duration } else { audio.currentTime = currentTime } } setJumpVolume = (volume: number): void => { const audio = this.audio.current const volumeStep = Number(volume) const currentVolume = audio?.volume if (!audio || !Number.isFinite(volumeStep) || !Number.isFinite(currentVolume)) { return } let newVolume = currentVolume + volumeStep if (newVolume < 0) newVolume = 0 else if (newVolume > 1) newVolume = 1 audio.volume = newVolume } handleKeyDown = (e: React.KeyboardEvent): void => { if (this.props.hasDefaultKeyBindings ?? true) { switch (e.key) { case ' ': if (e.target === this.container.current || e.target === this.progressBar.current) { e.preventDefault() // Prevent scrolling page by pressing Space key this.togglePlay(e) } break case 'ArrowLeft': this.handleClickRewind() break case 'ArrowRight': this.handleClickForward() break case 'ArrowUp': e.preventDefault() // Prevent scrolling page by pressing arrow key this.setJumpVolume(this.props.volumeJumpStep) break case 'ArrowDown': e.preventDefault() // Prevent scrolling page by pressing arrow key this.setJumpVolume(-this.props.volumeJumpStep) break case 'l': this.handleClickLoopButton() break case 'm': this.handleClickVolumeButton() break } } } renderUIModules = (modules: CustomUIModules): Array => { return modules.map((comp, i) => this.renderUIModule(comp, i)) } renderUIModule = (comp: CustomUIModule, key: Key): ReactElement => { const { defaultCurrentTime = '--:--', progressUpdateInterval = 20, showDownloadProgress = true, showFilledProgress = true, showFilledVolume = false, defaultDuration = '--:--', customIcons = {}, showSkipControls = false, onClickPrevious, onClickNext, onChangeCurrentTimeError, showJumpControls = true, customAdditionalControls = [RHAP_UI.LOOP], customVolumeControls = [RHAP_UI.VOLUME], muted = false, timeFormat = 'auto', volume: volumeProp = 1, loop: loopProp = false, mse, i18nAriaLabels = H5AudioPlayer.defaultI18nAriaLabels, } = this.props switch (comp) { case RHAP_UI.CURRENT_TIME: return (
) case RHAP_UI.CURRENT_LEFT_TIME: return (
) case RHAP_UI.PROGRESS_BAR: return ( ) case RHAP_UI.DURATION: return (
{mse && mse.srcDuration ? ( getDisplayTimeBySeconds(mse.srcDuration, mse.srcDuration, this.props.timeFormat) ) : ( )}
) case RHAP_UI.ADDITIONAL_CONTROLS: return (
{this.renderUIModules(customAdditionalControls)}
) case RHAP_UI.MAIN_CONTROLS: { const isPlaying = this.isPlaying() let actionIcon: ReactNode if (isPlaying) { actionIcon = customIcons.pause ? customIcons.pause : } else { actionIcon = customIcons.play ? customIcons.play : } return (
{showSkipControls && ( )} {showJumpControls && ( )} {showJumpControls && ( )} {showSkipControls && ( )}
) } case RHAP_UI.VOLUME_CONTROLS: return (
{this.renderUIModules(customVolumeControls)}
) case RHAP_UI.LOOP: { const loop = this.audio.current ? this.audio.current.loop : loopProp let loopIcon: ReactNode if (loop) { loopIcon = customIcons.loop ? customIcons.loop : } else { loopIcon = customIcons.loopOff ? customIcons.loopOff : } return ( ) } case RHAP_UI.VOLUME: { const { volume = muted ? 0 : volumeProp } = this.audio.current || {} let volumeIcon: ReactNode if (volume) { volumeIcon = customIcons.volume ? customIcons.volume : } else { volumeIcon = customIcons.volume ? customIcons.volumeMute : } return (
) } default: if (!isValidElement(comp)) { return null } return comp.key ? comp : cloneElement(comp, { key }) } } componentDidMount(): void { // force update to pass this.audio to child components this.forceUpdate() // audio player object const audio = this.audio.current if (this.props.muted) { audio.volume = 0 } else { audio.volume = this.lastVolume } audio.addEventListener('error', (e) => { const target = e.target as HTMLAudioElement // Calls onEnded when currentTime is the same as duration even if there is an error if (target.error && target.currentTime === target.duration) { return this.props.onEnded && this.props.onEnded(e) } this.props.onError && this.props.onError(e) }) // When enough of the file has downloaded to start playing audio.addEventListener('canplay', (e) => { this.props.onCanPlay && this.props.onCanPlay(e) }) // When enough of the file has downloaded to play the entire file audio.addEventListener('canplaythrough', (e) => { this.props.onCanPlayThrough && this.props.onCanPlayThrough(e) }) // When audio play starts audio.addEventListener('play', this.handlePlay) // When unloading the audio player (switching to another src) audio.addEventListener('abort', this.handleAbort) // When the file has finished playing to the end audio.addEventListener('ended', this.handleEnded) // When the media has enough data to start playing, after the play event, but also when recovering from being // stalled, when looping media restarts, and after seeked, if it was playing before seeking. audio.addEventListener('playing', (e) => { this.props.onPlaying && this.props.onPlaying(e) }) // When a seek operation begins audio.addEventListener('seeking', (e) => { this.props.onSeeking && this.props.onSeeking(e) }) // when a seek operation completes audio.addEventListener('seeked', (e) => { this.props.onSeeked && this.props.onSeeked(e) }) // when the requested operation (such as playback) is delayed pending the completion of another operation (such as // a seek). audio.addEventListener('waiting', (e) => { this.props.onWaiting && this.props.onWaiting(e) }) // The media has become empty; for example, this event is sent if the media has already been loaded (or partially // loaded), and the load() method is called to reload it. audio.addEventListener('emptied', (e) => { this.props.onEmptied && this.props.onEmptied(e) }) // when the user agent is trying to fetch media data, but data is unexpectedly not forthcoming audio.addEventListener('stalled', (e) => { this.props.onStalled && this.props.onStalled(e) }) // when loading of the media is suspended; this may happen either because the download has completed or because it // has been paused for any other reason audio.addEventListener('suspend', (e) => { this.props.onSuspend && this.props.onSuspend(e) }) // when loading of the media begins audio.addEventListener('loadstart', (e) => { this.props.onLoadStart && this.props.onLoadStart(e) }) // when media's metadata has finished loading; all attributes now contain as much useful information as they're // going to audio.addEventListener('loadedmetadata', (e) => { this.props.onLoadedMetaData && this.props.onLoadedMetaData(e) }) // when the first frame of the media has finished loading. audio.addEventListener('loadeddata', (e) => { this.props.onLoadedData && this.props.onLoadedData(e) }) // When the user pauses playback audio.addEventListener('pause', this.handlePause) audio.addEventListener( 'timeupdate', throttle((e) => { this.props.onListen && this.props.onListen(e) }, this.props.listenInterval) ) audio.addEventListener('volumechange', (e) => { this.props.onVolumeChange && this.props.onVolumeChange(e) }) audio.addEventListener('encrypted', (e) => { const { mse } = this.props mse && mse.onEcrypted && mse.onEcrypted(e) }) } componentDidUpdate(prevProps: PlayerProps): void { const { src, autoPlayAfterSrcChange } = this.props if (prevProps.src !== src) { if (autoPlayAfterSrcChange) { this.playAudioPromise() } else { // Updating pause icon to play icon this.forceUpdate() } } } render(): ReactNode { const { className = '', src, loop: loopProp = false, preload = 'auto', autoPlay = false, crossOrigin, mediaGroup, header, footer, layout = 'stacked', customProgressBarSection = [RHAP_UI.CURRENT_TIME, RHAP_UI.PROGRESS_BAR, RHAP_UI.DURATION], customControlsSection = [RHAP_UI.ADDITIONAL_CONTROLS, RHAP_UI.MAIN_CONTROLS, RHAP_UI.VOLUME_CONTROLS], children, style, i18nAriaLabels = H5AudioPlayer.defaultI18nAriaLabels, } = this.props const loop = this.audio.current ? this.audio.current.loop : loopProp const loopClass = loop ? 'rhap_loop--on' : 'rhap_loop--off' const isPlayingClass = this.isPlaying() ? 'rhap_play-status--playing' : 'rhap_play-status--paused' return ( /* We want the container to catch bubbled events */ /* eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions */
{/* User can pass through children */} {/* eslint-disable-next-line jsx-a11y/media-has-caption */} {header &&
{header}
}
{this.renderUIModules(customProgressBarSection)}
{this.renderUIModules(customControlsSection)}
{footer &&
{footer}
}
) } } export default H5AudioPlayer export { RHAP_UI, OnSeek }