import { api } from "lwc"; import memoize from "memoize-one"; import Popover from "dx/popover"; import { track } from "dxUtils/analytics"; import { LightningElementWithTypedRefs } from "dxUtils/withTypedRefs"; type TrackColors = { before: string; buffer?: string; after: string; }; // eslint-disable-next-line no-use-before-define type PlaybackSpeed = (typeof formattedPlaybackSpeeds)[number]; /* * Color settings for the "track" of the slider, which dynamically updates based on amount buffered, * amount played, and amount remaining. The "before" color is what goes _before_ the "thumb" on the * slider, and so on. */ const uiConfig = { seekSlider: { trackColors: { before: "var(--dx-c-track-before-color)", buffer: "var(--dx-c-track-custom-medium-gray)", after: "var(--dx-c-track-custom-light-gray)" } as TrackColors }, volumeSlider: { trackColors: { before: "var(--dx-g-indigo-vibrant-30)", after: "var(--dx-c-track-custom-medium-gray)" } as TrackColors } }; // Standard playback speeds, though the "formatted" version for 1 is "Normal" (mimicking Chrome) const formattedPlaybackSpeeds = [ "0.25", "0.5", "0.75", "Normal", "1.25", "1.5", "1.75", "2" ] as const; export default class Audio extends LightningElementWithTypedRefs<{ audioElement: HTMLAudioElement; container: HTMLDivElement; playbackSpeedMenu: HTMLDivElement; playbackSpeedPopover: Popover; playerSeekSlider: HTMLInputElement; playerVolumeSlider: HTMLInputElement; playerVolumeContainer: HTMLDivElement; }> { @api audioSrc!: string; @api audioTitle!: string; private _bufferedTimeRanges?: TimeRanges; private _currentTimeSeconds = 0; private _durationSeconds = 0; private _volume = 100; // 100 is browser default (and max), at least in Chrome, for audio elements private animationFrameId: number | null = null; // controls movement of the timeline when audio is playing private currentPlaybackSpeed: PlaybackSpeed = "Normal"; private didRender = false; private isAnimating = false; private isPlaying = false; private isSettingPlaybackSpeed = false; // popover menu has two panes; this manages that state // `playbackSpeedData` tracks which value is selected, in a way useable by LWC for:each without requiring a separate sub-component private playbackSpeedData = formattedPlaybackSpeeds.map((value) => ({ selected: value === "Normal", value })); private prevVolume = this._volume; // previous volume is tracked so that it can be restored on unmute private volumeIcon: "volume_high" | "volume_off" = "volume_high"; // built-in browser UI doesn't distinguish between high/low, just "on"/"off" (we use the "high" icon for "on") private get formattedCurrentTime() { return this.getFormatted(this.currentTimeSeconds); } private get formattedDuration() { return this.getFormatted(this.durationSeconds); } private get mainControlAriaLabel() { return this.isPlaying ? "Pause" : "Play"; } private get mainControlIconSymbol() { return this.isPlaying ? "pause" : "play"; } private get playerAriaLabel() { return `Audio: ${this.audioTitle}`; } // NOTE that values with getters and setters here have side effects that update the UI elements. // This essentially makes these items the source of truth for the UI. private get bufferedTimeRanges() { return this._bufferedTimeRanges; } private set bufferedTimeRanges(value: TimeRanges | undefined) { this._bufferedTimeRanges = value; this.updateRangeInputStyles( this.typedRefs.playerSeekSlider, this.currentTimeSeconds ); } private get currentTimeSeconds() { return this._currentTimeSeconds; } private set currentTimeSeconds(value: number) { this._currentTimeSeconds = value; this.updateRangeInputStyles(this.typedRefs.playerSeekSlider, value); } private get durationSeconds() { return this._durationSeconds; } private set durationSeconds(value: number) { if (!Number.isFinite(value)) { return; } this._durationSeconds = value; this.updateRangeInputStyles( this.typedRefs.playerSeekSlider, this.currentTimeSeconds ); } private get volume() { return this._volume; } private set volume(value: number) { this.prevVolume = this.volume; this._volume = value; this.updateRangeInputStyles(this.typedRefs.playerVolumeSlider, value); } renderedCallback(): void { if (this.didRender) { return; } this.didRender = true; this.volume = this.typedRefs.audioElement.volume * 100; // We only need to listen for the metadata event if the metadata isn't already loaded; // sometimes, browsers can load it _before_ the event handler can even be added. if (this.typedRefs.audioElement.readyState > 0) { this.handleAudioLoadedMetadata(); } else { this.typedRefs.audioElement.addEventListener( "loadedmetadata", this.handleAudioLoadedMetadata ); } // "progress" handles buffering of audio data, lets us update the UI for the buffered amount this.typedRefs.audioElement.addEventListener( "progress", this.handleAudioProgress ); this.typedRefs.audioElement.addEventListener( "ended", this.handleAudioEnded ); } disconnectedCallback(): void { this.typedRefs.audioElement.removeEventListener( "loadedmetadata", this.handleAudioLoadedMetadata ); this.typedRefs.audioElement.removeEventListener( "progress", this.handleAudioProgress ); this.typedRefs.audioElement.removeEventListener( "ended", this.handleAudioEnded ); } /* BEGIN event handlers */ handleAudioLoadedMetadata = () => { // `loadedmetadata` is the first spot at which duration is available. this.durationSeconds = this.typedRefs.audioElement.duration; }; handleAudioProgress = () => { // New buffered amount received, store the buffered time ranges. this.bufferedTimeRanges = this.typedRefs.audioElement.buffered; }; handleMainControlClick = (event: Event) => { if (this.isPlaying) { this.handleAudioPause(event); } else { this.handleAudioPlay(event); } }; handleAudioPlay = (event: Event) => { this.typedRefs.audioElement.play(); this.syncTimeWithAudio(); this.isPlaying = true; this.trackPlay(event); }; handleAudioPause = (event: Event) => { this.typedRefs.audioElement.pause(); cancelAnimationFrame(this.animationFrameId as number); this.isPlaying = false; this.trackPause(event); }; handleAudioEnded = (event: Event) => { cancelAnimationFrame(this.animationFrameId as number); this.isPlaying = false; this.trackEnded(event); }; // Handles any user-driven movement on the "seek" slider (timeline) handleSeekInput = () => { if (!this.durationSeconds) { // No moving the "thumb" when metadata hasn't even loaded. return; } if (this.isPlaying) { // To allow the user to interact with the slider, we temporarily have to stop moving the // "thumb," but leave the player in the "isPlaying" state so that the play button // remains and audio picks back up once the user is done interacting (the last bit is // handled in the `handleSeekChange` handler, when the user actually commits the change) cancelAnimationFrame(this.animationFrameId as number); } this.currentTimeSeconds = parseInt( this.typedRefs.playerSeekSlider.value, 10 ); }; // `change` is only fired once the user *commits* to a value (e.g., by releasing the mouse click), unlike `input` above handleSeekChange = () => { if (this.isPlaying) { // Resume playing after user finishes interacting with seek slider, if the audio was // already playing (see note in `handleSeekInput`) this.syncTimeWithAudio(); } this.currentTimeSeconds = parseInt( this.typedRefs.playerSeekSlider.value, 10 ); this.typedRefs.audioElement!.currentTime = this.currentTimeSeconds; }; // Handles any user-driven movement on the volume slider handleVolumeInput = ({ currentTarget }: InputEvent & { currentTarget: HTMLInputElement }) => { this.volume = parseInt(currentTarget.value, 10); this.typedRefs.audioElement.volume = this.volume / 100; }; // Direct click on the volume button is mute/unmute handleVolumeClick = () => { if (this.typedRefs.audioElement.muted) { this.typedRefs.audioElement.muted = false; this.volumeIcon = "volume_high"; this.volume = this.prevVolume; // restore to previous volume level } else { this.typedRefs.audioElement.muted = true; this.volumeIcon = "volume_off"; this.volume = 0; } }; handleVolumeFocusIn = () => { this.typedRefs.playerVolumeContainer.classList.add( "focused-by-keyboard" ); }; handleVolumeFocusOut = (event: FocusEvent) => { const { playerVolumeContainer } = this.typedRefs; const isFocusingChildOfVolumeContainer = Array.from( playerVolumeContainer.children ).some((childElement) => event.relatedTarget === childElement); if (!isFocusingChildOfVolumeContainer) { this.typedRefs.playerVolumeContainer.classList.remove( "focused-by-keyboard" ); } }; // Set "pane" state for playback popover menu handlePlaybackSpeedClick = () => { this.handlePlaybackMenuNavigation(true); }; handlePlaybackOptionsClick = () => { this.handlePlaybackMenuNavigation(false); }; handlePlaybackMenuNavigation = (isSettingPlaybackSpeed: boolean) => { this.isSettingPlaybackSpeed = isSettingPlaybackSpeed; this.refocusPlaybackSpeedMenu(); }; resetIsSettingPlaybackSpeed = () => { this.isSettingPlaybackSpeed = false; }; // Handle selection of a playback speed in the "three dot" menu handleSetPlaybackSpeed = (event: Event) => { const currentTarget = event.currentTarget as Node; const selectedPlaybackSpeed = currentTarget.textContent as PlaybackSpeed; if (selectedPlaybackSpeed === this.currentPlaybackSpeed) { return; } this.playbackSpeedData = formattedPlaybackSpeeds.map((value) => ({ selected: value === selectedPlaybackSpeed, value })); this.currentPlaybackSpeed = selectedPlaybackSpeed; this.typedRefs.playbackSpeedPopover.closePopover(); this.resetIsSettingPlaybackSpeed(); this.typedRefs.audioElement.playbackRate = selectedPlaybackSpeed === "Normal" ? 1 : parseFloat(selectedPlaybackSpeed); }; handleContainerKeyUp = (event: KeyboardEvent) => { if (event.key === " " && event.target === this.typedRefs.container) { if (this.isPlaying) { this.handleAudioPause(event); } else { this.handleAudioPlay(event); } } }; /* END event handlers, BEGIN private utility methods */ // Called when menu's inner pane changes, ensuring focus is on the newly-displayed menu private refocusPlaybackSpeedMenu = () => { // Promise.resolve() allows LWC to re-render before we select Promise.resolve().then(() => ( this.typedRefs.playbackSpeedMenu.querySelector( ".pane-control" ) as HTMLButtonElement ).focus() ); }; // Animates the seek slider (timeline) each frame so that it is N*Sync with current play // time. We do this using requestAnimationFrame so that we can temporarily cancel the // animating whenever the user is interacting with the slider. private syncTimeWithAudio = () => { this.currentTimeSeconds = Math.floor( this.typedRefs.audioElement.currentTime ); this.animationFrameId = requestAnimationFrame(this.syncTimeWithAudio); }; // Convert raw numerical seconds to strings formatted as "X:XX" private getFormatted(totalSeconds: number) { const minutes = Math.floor(totalSeconds / 60); const seconds = Math.floor(totalSeconds % 60); const doubleDigitSeconds = seconds < 10 ? `0${seconds}` : `${seconds}`; return `${minutes}:${doubleDigitSeconds}`; } // Find the end of the buffered amount of audio, if any, that overlaps with the current // position. We care about the overlap because buffered time ranges can have gaps. // Memoized because it is called whenever input styles are updated, and bufferedTimeRanges // rarely changes. private getOverlappingBufferEnd = memoize( (currentTimeSeconds: number, bufferedTimeRanges?: TimeRanges) => { let nearestBufferEnd = 0; if (bufferedTimeRanges?.length) { for (let i = 0; i < bufferedTimeRanges.length; i++) { if ( bufferedTimeRanges.start(i) <= currentTimeSeconds && bufferedTimeRanges.end(i) > currentTimeSeconds ) { nearestBufferEnd = bufferedTimeRanges.end(i); break; } } } return nearestBufferEnd; } ); // Visually updates sliders (timeline or volume) so that the amount "covered" (before the // "thumb") is visually different than the amount remaining; also handles visually showing the // buffered amount if there is buffer data and the element is the "seek"/timeline slider private updateRangeInputStyles( target: HTMLInputElement | null, currentValue: number ) { if ( !target || (target === this.typedRefs.playerSeekSlider && !this.durationSeconds) ) { return; } const maximumValue = target === this.typedRefs.playerVolumeSlider ? 100 : this.durationSeconds; const trackColors = target === this.typedRefs.playerVolumeSlider ? uiConfig.volumeSlider.trackColors : uiConfig.seekSlider.trackColors; const percentageCovered = maximumValue === 100 ? currentValue : (currentValue / maximumValue) * 100; const overlappingBufferEnd = this.getOverlappingBufferEnd( this.currentTimeSeconds, this.bufferedTimeRanges ); if (trackColors.buffer && overlappingBufferEnd) { // Slider with three colors: the before amount, the buffered amount, and the full // "remaining" slider color. const bufferPercentage = (overlappingBufferEnd / maximumValue) * 100; target.style.background = `linear-gradient(to right, ${trackColors.before} ${percentageCovered}%, ${trackColors.buffer} ${percentageCovered}% ${bufferPercentage}%, ${trackColors.after} ${bufferPercentage}%)`; } else { // "Basic" slider, with only two colors, no buffering. target.style.background = `linear-gradient(to right, ${trackColors.before} ${percentageCovered}%, ${trackColors.after} ${percentageCovered}%)`; } } private trackClick = (e: Event, eventType: string, action: string) => { const payload = { event: eventType, media_action: action, media_name: this.audioTitle, media_percentage_played: this.durationSeconds === 0 ? this.durationSeconds : ( (this.currentTimeSeconds * 100) / this.durationSeconds ).toFixed(0), media_seconds_played: this.currentTimeSeconds, media_type: "blog audio" }; track(e.currentTarget!, "custEv_blogAudioPlay", payload); }; private trackPlay = (e: Event) => { this.trackClick(e, "custEv_blogAudioPlay", "play"); }; private trackEnded = (e: Event) => { this.trackClick(e, "custEv_blogAudioComplete", "complete"); }; private trackPause = (e: Event) => { this.trackClick(e, "custEv_blogAudioPause", "pause"); }; }