import React, { Component, cloneElement, isValidElement, createRef, ReactNode, CSSProperties, ReactElement, Key, } from 'react' import { Icon } from '@iconify/react' import playCircle from '@iconify/icons-mdi/play-circle' import pauseCircle from '@iconify/icons-mdi/pause-circle' import skipPrevious from '@iconify/icons-mdi/skip-previous' import skipNext from '@iconify/icons-mdi/skip-next' import fastForward from '@iconify/icons-mdi/fast-forward' import rewind from '@iconify/icons-mdi/rewind' import volumeHigh from '@iconify/icons-mdi/volume-high' import volumeMute from '@iconify/icons-mdi/volume-mute' import repeat from '@iconify/icons-mdi/repeat' import repeatOff from '@iconify/icons-mdi/repeat-off' import ProgressBar from './ProgressBar' import CurrentTime from './CurrentTime' import Duration from './Duration' import VolumeBar from './VolumeBar' import { RHAP_UI, MAIN_LAYOUT, AUDIO_PRELOAD_ATTRIBUTE } from './constants' import { throttle, getMainLayoutClassName } from './utils' type CustomUIModule = RHAP_UI | ReactElement type CustomUIModules = Array interface PlayerProps { /** * HTML5 Audio tag autoPlay property */ autoPlay?: boolean /** * Whether to play music 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?: string mediaGroup?: string onAbort?: (e: Event) => void onCanPlay?: (e: Event) => void onCanPlayThrough?: (e: Event) => void onEnded?: (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 /** * 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 header?: ReactNode footer?: ReactNode customIcons?: CustomIcons layout?: MAIN_LAYOUT customProgressBarSection?: CustomUIModules customControlsSection?: CustomUIModules customAdditionalControls?: CustomUIModules customVolumeControls?: CustomUIModules 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 } class H5AudioPlayer extends Component { static defaultProps: PlayerProps = { autoPlay: false, autoPlayAfterSrcChange: true, listenInterval: 1000, progressJumpStep: 5000, progressJumpSteps: {}, // define when removing progressJumpStep volumeJumpStep: 0.1, loop: false, muted: false, preload: 'auto', progressUpdateInterval: 20, defaultCurrentTime: '--:--', defaultDuration: '--:--', volume: 1, className: '', showJumpControls: true, showSkipControls: false, showDownloadProgress: true, showFilledProgress: true, customIcons: {}, 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], customAdditionalControls: [RHAP_UI.LOOP], customVolumeControls: [RHAP_UI.VOLUME], layout: 'stacked', } audio = createRef() progressBar = createRef() container = createRef() lastVolume: number = this.props.volume // 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.src) { const audioPromise = audio.play() audioPromise.then(null).catch((err) => { const { onPlayError } = this.props onPlayError && onPlayError(new Error(err)) }) } else if (!audio.paused) { audio.pause() } } 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) } handleAbort = (e: Event): void => { const { autoPlayAfterSrcChange } = this.props if (autoPlayAfterSrcChange) { this.audio.current.play() } else { this.forceUpdate() } 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 (!isFinite(duration) || !isFinite(prevTime)) return 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 => { let newVolume = this.audio.current.volume + volume if (newVolume < 0) newVolume = 0 else if (newVolume > 1) newVolume = 1 this.audio.current.volume = newVolume } handleKeyDown = (e: React.KeyboardEvent): void => { switch (e.keyCode) { case 32: // Space 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 37: // Left arrow this.handleClickRewind() break case 39: // Right arrow this.handleClickForward() break case 38: // Up arrow e.preventDefault() // Prevent scrolling page by pressing arrow key this.setJumpVolume(this.props.volumeJumpStep) break case 40: // Down arrow e.preventDefault() // Prevent scrolling page by pressing arrow key this.setJumpVolume(-this.props.volumeJumpStep) break case 76: // L = Loop this.handleClickLoopButton() break case 77: // M = Mute 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, showDownloadProgress, showFilledProgress, defaultDuration, customIcons, showSkipControls, onClickPrevious, onClickNext, showJumpControls, customAdditionalControls, customVolumeControls, muted, volume: volumeProp, loop: loopProp, } = 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 (
) 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 as ReactElement, { 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) => { 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', (e) => { this.props.onEnded && this.props.onEnded(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) }) } componentWillUnmount(): void { const audio = this.audio.current if (audio) { audio.removeEventListener('play', this.handlePlay) audio.removeEventListener('pause', this.handlePause) audio.removeEventListener('abort', this.handleAbort) audio.removeAttribute('src') audio.load() } } render(): ReactNode { const { className, src, loop: loopProp, preload, autoPlay, crossOrigin, mediaGroup, header, footer, layout, customProgressBarSection, customControlsSection, children, style, } = this.props const loop = this.audio.current ? this.audio.current.loop : loopProp 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 }