import React, { Component, forwardRef, SyntheticEvent } from 'react' import { getPosX, throttle } from './utils' import { OnSeek } from './index' // ProgressBar encapsulates all logic related to displaying and interacting with // the audio playback progress: (1) current position, (2) buffered segments, // (3) drag & seek interactions (mouse + touch), and (4) accessibility ARIA data. // // NOTABLE BEHAVIOR / DESIGN CHOICES: // - Drag logic is fully managed by adding / removing global window listeners so that // seeking continues even if the pointer leaves the bar's bounding box. // - Time updates are throttled via the provided progressUpdateInterval to limit // React re-renders during playback (performance optimization). // - When an optional async onSeek callback is provided, UI waits (state.waitingForSeekCallback) // so that timeupdate events do not fight with the in-flight seek. // - Download (buffer) progress is animated by toggling a short-lived flag which // influences CSS transition durations. // - Supports externally supplied srcDuration (e.g., for streams where metadata // duration might be unknown or unreliable initially). If srcDuration is passed // it short-circuits reliance on audio.duration. interface ProgressBarForwardRefProps { audio: HTMLAudioElement progressUpdateInterval: number showDownloadProgress: boolean showFilledProgress: boolean srcDuration?: number onSeek?: OnSeek onChangeCurrentTimeError?: (err: Error) => void i18nProgressBar: string } interface ProgressBarProps extends ProgressBarForwardRefProps { progressBar: React.RefObject } interface ProgressBarState { isDraggingProgress: boolean currentTimePos?: string hasDownloadProgressAnimation: boolean downloadProgressArr: DownloadProgress[] waitingForSeekCallback: boolean } interface DownloadProgress { left: string width: string } interface TimePosInfo { currentTime: number currentTimePos: string } class ProgressBar extends Component { audio?: HTMLAudioElement // The candidate time (in seconds) corresponding to the most recent pointer move // while the user is actively dragging. We don't update audio.currentTime until // the drag ends (unless an async onSeek handler is provided) to avoid excessive // media seeks and to keep UI snappy. timeOnMouseMove = 0 // Audio's current time while mouse is down and moving over the progress bar // Prevents adding duplicate media element listeners if component re-renders // or receives the same audio element again. hasAddedAudioEventListener = false downloadProgressAnimationTimer?: number state: ProgressBarState = { isDraggingProgress: false, currentTimePos: '0%', hasDownloadProgressAnimation: false, downloadProgressArr: [], waitingForSeekCallback: false, } getDuration(): number { const { audio, srcDuration } = this.props return typeof srcDuration === 'undefined' ? audio.duration : srcDuration } // Get time info while dragging indicator by mouse or touch getCurrentProgress = (event: MouseEvent | TouchEvent): TimePosInfo => { const { audio, progressBar } = this.props // A single-file progressive download (non-blob) can have transient states // where currentTime is not yet finite. In those cases return zeros to avoid // NaN propagation. const isSingleFileProgressiveDownload = audio.src.indexOf('blob:') !== 0 && typeof this.props.srcDuration === 'undefined' if (isSingleFileProgressiveDownload && (!audio.src || !isFinite(audio.currentTime) || !progressBar.current)) { return { currentTime: 0, currentTimePos: '0%' } } const progressBarRect = progressBar.current.getBoundingClientRect() const maxRelativePos = progressBarRect.width let relativePos = getPosX(event) - progressBarRect.left if (relativePos < 0) { relativePos = 0 } else if (relativePos > maxRelativePos) { relativePos = maxRelativePos } const duration = this.getDuration() const currentTime = (duration * relativePos) / maxRelativePos return { currentTime, currentTimePos: `${((relativePos / maxRelativePos) * 100).toFixed(2)}%` } } handleContextMenu = (event: SyntheticEvent): void => { event.preventDefault() } /* Handle mouse down or touch start on progress bar event */ handleMouseDownOrTouchStartProgressBar = (event: React.MouseEvent | React.TouchEvent): void => { event.stopPropagation() const { currentTime, currentTimePos } = this.getCurrentProgress(event.nativeEvent) if (isFinite(currentTime)) { this.timeOnMouseMove = currentTime this.setState({ isDraggingProgress: true, currentTimePos }) // Attach global listeners so drag remains responsive even if the pointer // leaves the progress bar element. Distinguish mouse vs touch for proper events. if (event.nativeEvent instanceof MouseEvent) { window.addEventListener('mousemove', this.handleWindowMouseOrTouchMove) window.addEventListener('mouseup', this.handleWindowMouseOrTouchUp) } else { window.addEventListener('touchmove', this.handleWindowMouseOrTouchMove) window.addEventListener('touchend', this.handleWindowMouseOrTouchUp) } } } handleWindowMouseOrTouchMove = (event: TouchEvent | MouseEvent): void => { if (event instanceof MouseEvent) { event.preventDefault() } event.stopPropagation() // Prevent Chrome drag selection bug const windowSelection: Selection | null = window.getSelection() if (windowSelection && windowSelection.type === 'Range') { windowSelection.empty() } const { isDraggingProgress } = this.state if (isDraggingProgress) { const { currentTime, currentTimePos } = this.getCurrentProgress(event) this.timeOnMouseMove = currentTime this.setState({ currentTimePos }) } } handleWindowMouseOrTouchUp = (event: MouseEvent | TouchEvent): void => { event.stopPropagation() const newTime = this.timeOnMouseMove const { audio, onChangeCurrentTimeError, onSeek } = this.props if (onSeek) { // When an async onSeek is provided, we don't update audio.currentTime here; // instead we delegate timing to the callback so the integrator can control // buffering / custom seek logic (e.g., remote media, HLS, etc.). While // waiting, we suppress timeupdate-driven UI updates. this.setState({ isDraggingProgress: false, waitingForSeekCallback: true }, () => { onSeek(audio, newTime).then( () => this.setState({ waitingForSeekCallback: false }), (err: unknown) => { const message = err instanceof Error ? err.message : String(err) throw new Error(message) } ) }) } else { const newProps: { isDraggingProgress: boolean; currentTimePos?: string } = { isDraggingProgress: false, } if (audio.readyState === audio.HAVE_NOTHING || audio.readyState === audio.HAVE_METADATA || !isFinite(newTime)) { try { audio.load() } catch (err) { newProps.currentTimePos = '0%' return onChangeCurrentTimeError && onChangeCurrentTimeError(err as Error) } } audio.currentTime = newTime this.setState(newProps) } if (event instanceof MouseEvent) { window.removeEventListener('mousemove', this.handleWindowMouseOrTouchMove) window.removeEventListener('mouseup', this.handleWindowMouseOrTouchUp) } else { window.removeEventListener('touchmove', this.handleWindowMouseOrTouchMove) window.removeEventListener('touchend', this.handleWindowMouseOrTouchUp) } } handleAudioTimeUpdate = throttle((e: Event): void => { const { isDraggingProgress } = this.state const audio = e.target as HTMLAudioElement // Avoid updating UI while user is dragging (we show the drag position instead) // or while an async seek is pending (prevents jitter / race conditions). if (isDraggingProgress || this.state.waitingForSeekCallback === true) return const { currentTime } = audio const duration = this.getDuration() this.setState({ currentTimePos: `${((currentTime / duration) * 100 || 0).toFixed(2)}%`, }) }, this.props.progressUpdateInterval) handleAudioDownloadProgressUpdate = (e: Event): void => { const audio = e.target as HTMLAudioElement const duration = this.getDuration() const downloadProgressArr: DownloadProgress[] = [] for (let i = 0; i < audio.buffered.length; i++) { const bufferedStart: number = audio.buffered.start(i) const bufferedEnd: number = audio.buffered.end(i) downloadProgressArr.push({ left: `${Math.round((100 / duration) * bufferedStart) || 0}%`, width: `${Math.round((100 / duration) * (bufferedEnd - bufferedStart)) || 0}%`, }) } clearTimeout(this.downloadProgressAnimationTimer) // Setting animation flag makes subsequent render use CSS transition for a // short burst, creating a smooth buffer-bar update effect. this.setState({ downloadProgressArr, hasDownloadProgressAnimation: true }) this.downloadProgressAnimationTimer = setTimeout(() => { this.setState({ hasDownloadProgressAnimation: false }) }, 200) as unknown as number } initialize(): void { const { audio } = this.props if (audio && !this.hasAddedAudioEventListener) { this.audio = audio this.hasAddedAudioEventListener = true audio.addEventListener('timeupdate', this.handleAudioTimeUpdate) audio.addEventListener('progress', this.handleAudioDownloadProgressUpdate) } } componentDidMount(): void { this.initialize() } componentDidUpdate(): void { this.initialize() } componentWillUnmount(): void { if (this.audio && this.hasAddedAudioEventListener) { this.audio.removeEventListener('timeupdate', this.handleAudioTimeUpdate) this.audio.removeEventListener('progress', this.handleAudioDownloadProgressUpdate) } clearTimeout(this.downloadProgressAnimationTimer) } render(): React.ReactNode { const { showDownloadProgress, showFilledProgress, progressBar, i18nProgressBar } = this.props const { currentTimePos, downloadProgressArr, hasDownloadProgressAnimation } = this.state return (
{showFilledProgress &&
} {showDownloadProgress && downloadProgressArr.map(({ left, width }, i) => (
))}
) } } const ProgressBarForwardRef = ( props: ProgressBarForwardRefProps, ref: React.Ref ): React.ReactElement => } /> export default forwardRef(ProgressBarForwardRef) export { ProgressBar, ProgressBarForwardRef }