diff --git a/dotcom-rendering/package.json b/dotcom-rendering/package.json
index e24753777a6..bc242c80a88 100644
--- a/dotcom-rendering/package.json
+++ b/dotcom-rendering/package.json
@@ -190,7 +190,6 @@
"typescript-json-schema": "0.64.0",
"unified": "11.0.5",
"valibot": "0.28.1",
- "wavesurfer.js": "7.8.6",
"web-vitals": "4.2.3",
"webpack": "5.94.0",
"webpack-assets-manifest": "5.2.1",
diff --git a/dotcom-rendering/src/components/AudioPlayer/AudioPlayer.tsx b/dotcom-rendering/src/components/AudioPlayer/AudioPlayer.tsx
index dbbb99bed5d..d84d58ae7cf 100644
--- a/dotcom-rendering/src/components/AudioPlayer/AudioPlayer.tsx
+++ b/dotcom-rendering/src/components/AudioPlayer/AudioPlayer.tsx
@@ -1,281 +1,12 @@
-import { css } from '@emotion/react';
-import { isUndefined, log } from '@guardian/libs';
-import { from, palette, textSans15 } from '@guardian/source/foundations';
-import {
- SvgAudio,
- SvgAudioMute,
- SvgMediaControlsBack,
- SvgMediaControlsForward,
- SvgMediaControlsPause,
- SvgMediaControlsPlay,
-} from '@guardian/source/react-components';
-import { forwardRef, useEffect, useRef, useState } from 'react';
-import WaveSurfer from 'wavesurfer.js';
-import { formatTime } from '../../lib/formatTime';
-
-// default base styling for all buttons
-const buttonBaseCss = css`
- display: inline-flex;
- align-items: center;
- justify-content: center;
- background: none;
- border: 0;
- margin: 0;
- padding: 0;
-
- :focus {
- outline: none;
- }
-
- :not(:disabled):hover {
- opacity: 0.8;
- cursor: pointer;
- }
-`;
-
-// ****************** Wrapper/grid etc ******************
-
-const Wrapper = ({
- showVolumeControls,
- ...props
-}: { showVolumeControls: boolean } & React.ComponentPropsWithoutRef<'div'>) => (
-
-);
-
-// ****************** Time Display ******************
-
-const timeCSS = css`
- padding-top: 4px;
- display: flex;
- align-items: center;
- justify-content: center;
- ${textSans15};
- color: ${palette.neutral[86]};
- background-color: ${palette.neutral[20]};
- z-index: 1;
-
- ${from.leftCol} {
- padding-top: 0;
- }
-`;
-
-const CurrentTime = ({ time }: { time: number }) => (
-
-);
-
-const Duration = ({ time }: { time: number }) => (
-
-);
-
-// ****************** Progress bar/waveform ******************
-
-const WaveForm = forwardRef<
- HTMLDivElement,
- React.ComponentPropsWithoutRef<'div'> & { progress: number }
->(({ progress, ...props }, ref) => (
-
-));
-
-// ****************** Playback Controls ******************
-
-const PlayButton = ({
- isReady,
- ...props
-}: { isReady: boolean } & React.ComponentPropsWithoutRef<'button'>) => {
- return (
-
- );
-};
-
-const SkipButton = (props: React.ComponentPropsWithoutRef<'button'>) => {
- return (
-
- );
-};
-
-const PlaybackControls = (props: React.ComponentPropsWithoutRef<'div'>) => (
-
-);
-
-// ****************** Volume Controls ******************
-
-const VolumeButton = ({
- isReady,
- ...props
-}: { isReady: boolean } & React.ComponentPropsWithoutRef<'button'>) => {
- return (
-
- );
-};
-
-const VolumeControls = (props: React.ComponentPropsWithoutRef<'div'>) => (
-
-);
+import { log } from '@guardian/libs';
+import type { AudioEvent } from '@guardian/ophan-tracker-js';
+import { useCallback, useEffect, useRef, useState } from 'react';
+// import { submitComponentEvent } from '../../client/ophan/ophan';
+import { Playback } from './components/Playback';
+import { ProgressBar } from './components/ProgressBar';
+import { CurrentTime, Duration } from './components/time';
+import { Volume } from './components/Volume';
+import { Wrapper } from './components/Wrapper';
type AudioPlayerProps = {
/** The audio source you want to play. */
@@ -284,14 +15,14 @@ type AudioPlayerProps = {
* Optional, pre-computed duration of the audio source.
* If it's not provided it will be calculated once the audio is loaded.
*/
- duration?: string;
+ duration?: number;
/**
* Optionally hide the volume controls if setting the volume is better
* handled elsewhere, e.g on a mobile device.
*/
showVolumeControls?: boolean;
/** media element ID for Ophan */
- mediaId?: string;
+ mediaId: string;
};
/**
@@ -299,98 +30,203 @@ type AudioPlayerProps = {
*/
export const AudioPlayer = ({
src,
- duration: preComputedDuration,
+ duration: preCalculatedDuration,
showVolumeControls = true,
mediaId,
}: AudioPlayerProps) => {
- // creates a ref for the wavesurfer instance (https://wavesurfer.xyz/)
- const [wavesurfer, setWavesurfer] = useState();
+ // ********************* ophan stuff *********************
+
+ // we'll send listening progress reports to ophan at these percentage points
+ // through playback (100% is handled by the 'ended' event)
+ const ophanProgressEvents = useRef(new Set([25, 50, 75]));
- const [audioLoadingProgress, setAudioLoadingProgress] = useState(0);
- const [isReady, setIsReady] = useState(false);
+ // wrapper to send audio events to ophan
+ const sendToOphan = useCallback(
+ (eventName: string) => {
+ const ophanEvent: AudioEvent = {
+ id: mediaId,
+ eventType: `audio:content:${eventName}`,
+ };
+
+ console.log(ophanEvent);
+
+ // return submitComponentEvent(ophanEvent, 'dotcom-rendering');
+ },
+ [mediaId],
+ );
+
+ // ********************* player *********************
+
+ // state for displaying feedback to the user
const [isPlaying, setIsPlaying] = useState(false);
const [isMuted, setIsMuted] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
- const [duration, setDuration] = useState(
- parseInt(preComputedDuration ?? '', 10),
- );
+ const [duration, setDuration] = useState(preCalculatedDuration);
+ const [progress, setProgress] = useState(0);
+ const [isWaiting, setIsWaiting] = useState(false);
- // ref to the audio element
+ // ref to the element that handles playback
const audioRef = useRef(null);
- // ref to the waveform container
- const waveformRef = useRef(null);
-
- // functions that handle interactions with the audio player
- const playPause = () => {
- void wavesurfer?.playPause();
- };
-
- const skipForward = () => {
- wavesurfer?.skip(15);
- };
-
- const skipBackward = () => {
- wavesurfer?.skip(-15);
- };
+ // ********************* interactions *********************
+
+ const playPause = useCallback(() => {
+ if (audioRef.current) {
+ if (audioRef.current.paused) {
+ void audioRef.current.play().catch((error) => {
+ console.log(error);
+ });
+ } else {
+ audioRef.current.pause();
+ setIsWaiting(false);
+ }
+ }
+ }, []);
+
+ const skipForward = useCallback(() => {
+ if (audioRef.current) {
+ audioRef.current.currentTime = Math.min(
+ audioRef.current.currentTime + 15,
+ audioRef.current.duration,
+ );
+ }
+ }, []);
+
+ const skipBackward = useCallback(() => {
+ if (audioRef.current) {
+ audioRef.current.currentTime = Math.max(
+ audioRef.current.currentTime - 15,
+ 0,
+ );
+ }
+ }, []);
+
+ const skipToPoint = useCallback(
+ (event: React.MouseEvent) => {
+ if (audioRef.current) {
+ const { width, left } =
+ event.currentTarget.getBoundingClientRect();
+ const clickX = event.clientX - left;
+ const newTime = (clickX / width) * audioRef.current.duration;
+ audioRef.current.currentTime = newTime;
+ }
+ },
+ [],
+ );
- const mute = () => {
- wavesurfer?.setVolume(0);
- setIsMuted(true);
- };
+ const mute = useCallback(() => {
+ if (audioRef.current) {
+ audioRef.current.volume = 0;
+ setIsMuted(true);
+ }
+ }, []);
- const unMute = () => {
- wavesurfer?.setVolume(1);
- setIsMuted(false);
- };
+ const unMute = useCallback(() => {
+ if (audioRef.current) {
+ audioRef.current.volume = 1;
+ setIsMuted(false);
+ }
+ }, []);
+
+ // ******************** events *********************
+
+ const onWaiting = useCallback(() => {
+ setIsWaiting(true);
+ }, []);
+
+ const onCanPlay = useCallback(() => {
+ setIsWaiting(false);
+ }, []);
+
+ const onTimeupdate = useCallback(() => {
+ if (audioRef.current) {
+ setCurrentTime(audioRef.current.currentTime);
+
+ const newProgress =
+ (audioRef.current.currentTime / audioRef.current.duration) *
+ 100;
+ setProgress(newProgress);
+
+ // Send progress events to ophan,
+ // but only if the audio is playing. We don't want to send these events
+ // just because you skipped around the audio while paused.
+ if (isPlaying) {
+ for (const stage of ophanProgressEvents.current) {
+ if (newProgress >= stage) {
+ ophanProgressEvents.current.delete(stage);
+ sendToOphan(String(stage));
+ }
+ }
+ }
+ }
+ }, [isPlaying, sendToOphan]);
+
+ const onPlay = useCallback(() => {
+ setIsPlaying(true);
+ }, []);
+
+ const onPlayOnce = useCallback(() => {
+ sendToOphan('play');
+ }, [sendToOphan]);
+
+ const onPause = useCallback(() => {
+ setIsPlaying(false);
+ }, []);
+
+ const onEnded = useCallback(() => {
+ sendToOphan('end');
+ }, [sendToOphan]);
+
+ const onError = useCallback((event: Event) => {
+ window.guardian.modules.sentry.reportError(
+ new Error(event.type),
+ 'audio-player',
+ );
+ log('dotcom', 'Audio player (WaveSurfer) error:', event);
+ }, []);
+
+ // Set the duration to what we now *know* it is.
+ // If we already had the correct duration, this will be a no-op anyway.
+ const onDurationChange = useCallback(() => {
+ if (audioRef.current) {
+ setDuration(audioRef.current.duration);
+ }
+ }, []);
- // instantiate the wavesurfer instance once the component has mounted
useEffect(() => {
- if (
- isUndefined(wavesurfer) &&
- waveformRef.current &&
- audioRef.current
- ) {
- const ws = WaveSurfer.create({
- container: waveformRef.current,
- height: 'auto',
- fillParent: true,
- waveColor: palette.neutral[46],
- progressColor: palette.neutral[100],
- cursorColor: palette.brandAlt[400],
- cursorWidth: 4,
- barWidth: 2,
- barGap: 1,
- barRadius: 0,
- normalize: true,
- barAlign: 'bottom',
- dragToSeek: true,
- media: audioRef.current,
- duration: isNaN(duration) ? undefined : duration,
- });
-
- ws.on('loading', (percent) => {
- setAudioLoadingProgress(percent);
- });
-
- ws.on('ready', (srcDuration) => {
- setIsReady(true);
- if (isNaN(duration)) setDuration(srcDuration);
- });
- ws.on('play', () => setIsPlaying(true));
- ws.on('pause', () => setIsPlaying(false));
- ws.on('timeupdate', (newTime) => setCurrentTime(newTime));
- ws.on('error', (error) => {
- window.guardian.modules.sentry.reportError(
- error,
- 'audio-player',
- );
- log('dotcom', 'Audio player (WaveSurfer) error:', error);
- });
-
- setWavesurfer(ws);
- }
- }, [duration, src, wavesurfer]);
+ const audio = audioRef.current;
+ audio?.addEventListener('waiting', onWaiting);
+ audio?.addEventListener('canplay', onCanPlay);
+ audio?.addEventListener('timeupdate', onTimeupdate);
+ audio?.addEventListener('durationchange', onDurationChange);
+ audio?.addEventListener('play', onPlay);
+ audio?.addEventListener('play', onPlayOnce, { once: true });
+ audio?.addEventListener('pause', onPause);
+ audio?.addEventListener('ended', onEnded);
+ audio?.addEventListener('error', onError);
+
+ return () => {
+ audio?.removeEventListener('waiting', onWaiting);
+ audio?.removeEventListener('canplay', onCanPlay);
+ audio?.removeEventListener('timeupdate', onTimeupdate);
+ audio?.removeEventListener('durationchange', onDurationChange);
+ audio?.removeEventListener('play', onPlay);
+ audio?.removeEventListener('play', onPlayOnce);
+ audio?.removeEventListener('pause', onPause);
+ audio?.removeEventListener('ended', onEnded);
+ audio?.removeEventListener('error', onError);
+ };
+ }, [
+ onTimeupdate,
+ onDurationChange,
+ onPlay,
+ onPause,
+ onEnded,
+ onPlayOnce,
+ onError,
+ onWaiting,
+ onCanPlay,
+ ]);
return (
@@ -399,89 +235,38 @@ export const AudioPlayer = ({
ref={audioRef}
autoPlay={false}
data-media-id={mediaId}
+ controls={true}
+ preload="none"
>
-
-
+
+
-
+
-
-
+
-
-
-
+
- {isPlaying ? (
-
- ) : (
-
- )}
-
-
+
-
-
-
-
-
-
-
-
-
-
-
-
+ disabled={isWaiting || !isPlaying}
+ />
+
+
+
+
+
+
);
};
diff --git a/dotcom-rendering/src/components/AudioPlayer/components/Playback.tsx b/dotcom-rendering/src/components/AudioPlayer/components/Playback.tsx
new file mode 100644
index 00000000000..7c6e3f94cac
--- /dev/null
+++ b/dotcom-rendering/src/components/AudioPlayer/components/Playback.tsx
@@ -0,0 +1,114 @@
+import { css } from '@emotion/react';
+import { from, palette } from '@guardian/source/foundations';
+import {
+ Spinner,
+ SvgMediaControlsPause,
+ SvgMediaControlsPlay,
+} from '@guardian/source/react-components';
+import SkipBackIcon from '../../../static/icons/audio/skip-backward-15.svg';
+import SkipForwardIcon from '../../../static/icons/audio/skip-forward-15.svg';
+import { buttonBaseCss } from '../styles';
+
+type ButtonProps = React.ComponentPropsWithoutRef<'button'>;
+
+const SkipButton = (props: ButtonProps) => {
+ return (
+
+ );
+};
+
+export const Playback = (props: React.ComponentPropsWithoutRef<'div'>) => (
+
+);
+
+Playback.Play = ({
+ isWaiting,
+ isPlaying,
+ ...props
+}: {
+ isWaiting: boolean;
+ isPlaying: boolean;
+} & ButtonProps) => {
+ return (
+
+ );
+};
+
+Playback.SkipBack = (props: Pick) => (
+
+
+
+);
+
+Playback.SkipForward = (props: Pick) => (
+
+
+
+);
diff --git a/dotcom-rendering/src/components/AudioPlayer/components/ProgressBar.tsx b/dotcom-rendering/src/components/AudioPlayer/components/ProgressBar.tsx
new file mode 100644
index 00000000000..1ec08ffde4a
--- /dev/null
+++ b/dotcom-rendering/src/components/AudioPlayer/components/ProgressBar.tsx
@@ -0,0 +1,41 @@
+import { css } from '@emotion/react';
+import { from, palette } from '@guardian/source/foundations';
+
+const cursorWidth = '4px';
+
+export const ProgressBar = ({
+ progress,
+ ...props
+}: React.ComponentPropsWithoutRef<'div'> & { progress: number }) => (
+
+);
diff --git a/dotcom-rendering/src/components/AudioPlayer/components/Volume.tsx b/dotcom-rendering/src/components/AudioPlayer/components/Volume.tsx
new file mode 100644
index 00000000000..cd086ee59e9
--- /dev/null
+++ b/dotcom-rendering/src/components/AudioPlayer/components/Volume.tsx
@@ -0,0 +1,85 @@
+import { css } from '@emotion/react';
+import { from, palette } from '@guardian/source/foundations';
+import { SvgAudio } from '@guardian/source/react-components';
+import { buttonBaseCss } from '../styles';
+
+type ButtonProps = React.ComponentPropsWithoutRef<'button'>;
+
+const Control = (props: ButtonProps) => {
+ return (
+
+ );
+};
+
+export const Volume = (props: React.ComponentPropsWithoutRef<'div'>) => (
+
+);
+
+Volume.Mute = ({
+ isMuted,
+ ...props
+}: { isMuted: boolean } & Omit) => (
+
+
+
+);
+
+Volume.UnMute = ({
+ isMuted,
+ ...props
+}: { isMuted: boolean } & Omit) => (
+
+
+
+);
diff --git a/dotcom-rendering/src/components/AudioPlayer/components/Wrapper.tsx b/dotcom-rendering/src/components/AudioPlayer/components/Wrapper.tsx
new file mode 100644
index 00000000000..7964254adb2
--- /dev/null
+++ b/dotcom-rendering/src/components/AudioPlayer/components/Wrapper.tsx
@@ -0,0 +1,34 @@
+import { css } from '@emotion/react';
+import { from, palette } from '@guardian/source/foundations';
+
+export const Wrapper = ({
+ showVolumeControls,
+ ...props
+}: { showVolumeControls: boolean } & React.ComponentPropsWithoutRef<'div'>) => (
+
+);
diff --git a/dotcom-rendering/src/components/AudioPlayer/components/time.tsx b/dotcom-rendering/src/components/AudioPlayer/components/time.tsx
new file mode 100644
index 00000000000..c698a4c86b7
--- /dev/null
+++ b/dotcom-rendering/src/components/AudioPlayer/components/time.tsx
@@ -0,0 +1,51 @@
+import { css } from '@emotion/react';
+import { isUndefined } from '@guardian/libs';
+import { from, palette, textSans15 } from '@guardian/source/foundations';
+import { formatTime } from '../../../lib/formatTime';
+
+const timeCSS = css`
+ padding-top: 4px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ ${textSans15};
+ color: ${palette.neutral[86]};
+ background-color: ${palette.neutral[20]};
+ z-index: 1;
+
+ ${from.leftCol} {
+ padding-top: 0;
+ }
+`;
+
+export const CurrentTime = ({ currentTime }: { currentTime: number }) => (
+
+);
+
+export const Duration = ({ duration }: { duration?: number }) => (
+
+);
diff --git a/dotcom-rendering/src/components/AudioPlayer/styles.ts b/dotcom-rendering/src/components/AudioPlayer/styles.ts
new file mode 100644
index 00000000000..ba08402b250
--- /dev/null
+++ b/dotcom-rendering/src/components/AudioPlayer/styles.ts
@@ -0,0 +1,21 @@
+import { css } from '@emotion/react';
+
+// default base styling for all buttons
+export const buttonBaseCss = css`
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ background: none;
+ border: 0;
+ margin: 0;
+ padding: 0;
+
+ :focus {
+ outline: none;
+ }
+
+ :not(:disabled):hover {
+ opacity: 0.8;
+ cursor: pointer;
+ }
+`;
diff --git a/dotcom-rendering/src/static/icons/audio/skip-backward-15.svg b/dotcom-rendering/src/static/icons/audio/skip-backward-15.svg
new file mode 100644
index 00000000000..26a675938a5
--- /dev/null
+++ b/dotcom-rendering/src/static/icons/audio/skip-backward-15.svg
@@ -0,0 +1,4 @@
+
diff --git a/dotcom-rendering/src/static/icons/audio/skip-forward-15.svg b/dotcom-rendering/src/static/icons/audio/skip-forward-15.svg
new file mode 100644
index 00000000000..21d02a1e563
--- /dev/null
+++ b/dotcom-rendering/src/static/icons/audio/skip-forward-15.svg
@@ -0,0 +1,4 @@
+
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index ff25413a417..153a6c8547a 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -787,9 +787,6 @@ importers:
valibot:
specifier: 0.28.1
version: 0.28.1
- wavesurfer.js:
- specifier: 7.8.6
- version: 7.8.6
web-vitals:
specifier: 4.2.3
version: 4.2.3
@@ -18592,10 +18589,6 @@ packages:
graceful-fs: 4.2.11
dev: false
- /wavesurfer.js@7.8.6:
- resolution: {integrity: sha512-EDexkMwkkQBTWruhfWQRkTtvRggtKFTPuJX/oZ5wbIZEfyww9EBeLr2mtkxzA1S8TlWPx6adY5WyjOlNYNyHSg==}
- dev: false
-
/wbuf@1.7.3:
resolution: {integrity: sha512-O84QOnr0icsbFGLS0O3bI5FswxzRr8/gHwWkDlQFskhSPryQXvrTMxjxGP4+iWYoauLoBvfDpkrOauZ+0iZpDA==}
dependencies: