Skip to content

Commit a2c6ceb

Browse files
Tidy up audio player components
1 parent 1edc6a7 commit a2c6ceb

8 files changed

Lines changed: 388 additions & 412 deletions

dotcom-rendering/src/components/AppsAudioPlayButton.stories.tsx renamed to dotcom-rendering/src/components/AudioPlayer/AppsAudioPlayButton.stories.tsx

File renamed without changes.

dotcom-rendering/src/components/AppsAudioPlayButton.tsx renamed to dotcom-rendering/src/components/AudioPlayer/AppsAudioPlayButton.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { ListenToArticleButton } from './ListenToArticleButton';
1+
import { ListenToArticleButton } from '../ListenToArticleButton';
22

33
type Props = {
44
onClickHandler: () => void;

dotcom-rendering/src/components/AudioPlayer/AudioPlayer.stories.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { defaultFormats } from '../../../.storybook/decorators/splitThemeDecorator';
22
import { allModes } from '../../../.storybook/modes';
33
import preview from '../../../.storybook/preview';
4-
import { AudioPlayer as Player } from './AudioPlayer';
4+
import { AudioPlayerWeb as Player } from './AudioPlayerWeb.island';
55
// import audioFile from './stories/default_audio_test.mp3';
66

77
const meta = preview.meta({
@@ -14,6 +14,8 @@ export const AudioPlayer = meta.story({
1414
// src: audioFile,
1515
src: 'https://audio.guim.co.uk/2024/10/18-57753-USEE_181024.mp3',
1616
mediaId: 'mediaId',
17+
contentIsNotSensitive: true,
18+
isAcastEnabled: true,
1719
},
1820
parameters: {
1921
// We only want to snapshot the `multipleFormats` version below.
Lines changed: 46 additions & 299 deletions
Original file line numberDiff line numberDiff line change
@@ -1,308 +1,55 @@
1-
import { log } from '@guardian/libs';
2-
import type { AudioEvent, TAudioEventType } from '@guardian/ophan-tracker-js';
3-
import { useCallback, useEffect, useRef, useState } from 'react';
4-
import { getOphan } from '../../client/ophan/ophan';
5-
import { Playback } from './components/Playback';
6-
import { ProgressBar } from './components/ProgressBar';
7-
import { CurrentTime, Duration } from './components/time';
8-
import { Wrapper } from './components/Wrapper';
1+
import { css } from '@emotion/react';
2+
import { space } from '@guardian/source/foundations';
3+
import { StraightLines } from '@guardian/source-development-kitchen/react-components';
4+
import { palette } from '../../palette';
5+
import { Island } from '../Island';
6+
import { formatAudioDuration } from '../ListenToArticle.island';
7+
import { AudioPlayerApps } from './AudioPlayerApps.island';
8+
import { AudioPlayerWeb } from './AudioPlayerWeb.island';
99

10-
// ********************* ophan stuff *********************
11-
12-
// possible events for audio in ophan
13-
type AudioEvents = TAudioEventType extends `audio:content:${infer E}`
14-
? E
15-
: never;
16-
17-
// possible progress events for audio in ophan
18-
type AudioProgressEvents =
19-
Extract<AudioEvents, `${number}`> extends `${infer N extends number}`
20-
? N
21-
: never;
22-
23-
const reportAudioEvent = (mediaId: string, eventName: AudioEvents) => {
24-
const audioEvent: AudioEvent = {
25-
id: mediaId,
26-
eventType: `audio:content:${eventName}`,
27-
};
28-
29-
void getOphan('Web').then((ophan) => {
30-
ophan.record({
31-
audio: audioEvent,
32-
});
33-
});
34-
};
35-
36-
// ********************* Component *********************
37-
38-
type AudioPlayerProps = {
39-
/** The audio source you want to play. */
40-
src: string;
41-
/**
42-
* Optional, pre-computed duration of the audio source.
43-
* If it's not provided it will be calculated once the audio is loaded.
44-
*/
45-
duration?: number;
46-
/** media element ID for Ophan */
47-
mediaId: string;
48-
};
49-
50-
/**
51-
* Audio player component.
52-
*/
5310
export const AudioPlayer = ({
54-
src,
55-
duration: preCalculatedDuration,
56-
mediaId,
57-
}: AudioPlayerProps) => {
58-
// ********************* player *********************
59-
60-
// state for displaying feedback to the user
61-
const [isPlaying, setIsPlaying] = useState(false);
62-
const [currentTime, setCurrentTime] = useState(0);
63-
const [duration, setDuration] = useState(preCalculatedDuration);
64-
const [progress, setProgress] = useState(0);
65-
const [isWaiting, setIsWaiting] = useState(false);
66-
const [isScrubbing, setIsScrubbing] = useState(false);
67-
const [buffer, setBuffer] = useState(0);
68-
69-
const isFirstPlay = useRef(true);
70-
71-
// ref to the <audio /> element that handles playback
72-
const audioRef = useRef<HTMLAudioElement>(null);
73-
74-
// ********************* ophan stuff *********************
75-
76-
// we'll send listening progress reports to ophan at these percentage points
77-
// through playback (100% is handled by the 'ended' event)
78-
const audioProgressEvents = useRef<Set<AudioProgressEvents>>(
79-
new Set([25, 50, 75]),
80-
);
81-
82-
// ******************** events *********************
83-
84-
const onTimeupdate = useCallback(() => {
85-
if (audioRef.current) {
86-
const newProgress =
87-
(audioRef.current.currentTime / audioRef.current.duration) *
88-
100;
89-
90-
setCurrentTime(audioRef.current.currentTime);
91-
setProgress(newProgress);
92-
93-
// Send progress events to ophan,
94-
// but only if the audio is playing. We don't want to send these events
95-
// just because you skipped around the audio while paused.
96-
if (isPlaying) {
97-
for (const stage of audioProgressEvents.current) {
98-
if (newProgress >= stage) {
99-
audioProgressEvents.current.delete(stage);
100-
reportAudioEvent(mediaId, String(stage) as AudioEvents);
101-
}
102-
}
103-
}
104-
}
105-
}, [isPlaying, mediaId]);
106-
107-
const onPlay = useCallback(() => {
108-
setIsPlaying(true);
109-
110-
if (isFirstPlay.current) {
111-
isFirstPlay.current = false;
112-
reportAudioEvent(mediaId, 'play');
113-
}
114-
}, [mediaId]);
115-
116-
const onProgress = useCallback(() => {
117-
if (audioRef.current) {
118-
const buffers = audioRef.current.buffered.length;
119-
if (buffers === 0) {
120-
return;
121-
}
122-
123-
const end = audioRef.current.buffered.end(buffers - 1);
124-
setBuffer((end / audioRef.current.duration) * 100);
125-
}
126-
}, []);
127-
128-
const onError = useCallback((event: Event) => {
129-
window.guardian.modules.sentry.reportError(
130-
new Error(event.type),
131-
'audio-player',
132-
);
133-
log('dotcom', 'Audio player error:', event);
134-
}, []);
135-
136-
// Set the duration to what we now *know* it is.
137-
// If we already had the correct duration, this will be a no-op anyway.
138-
const onDurationChange = useCallback(() => {
139-
if (audioRef.current) {
140-
setDuration(audioRef.current.duration);
141-
}
142-
}, []);
143-
144-
// ********************* interactions *********************
145-
146-
const boundingClientRect = useRef<DOMRect>();
147-
148-
const playPause = useCallback(() => {
149-
if (audioRef.current) {
150-
if (audioRef.current.paused) {
151-
void audioRef.current.play().catch(onError);
152-
} else {
153-
audioRef.current.pause();
154-
setIsWaiting(false);
155-
}
156-
}
157-
}, [onError]);
158-
159-
const skipForward = useCallback(() => {
160-
if (audioRef.current) {
161-
audioRef.current.currentTime = Math.min(
162-
audioRef.current.currentTime + 15,
163-
audioRef.current.duration,
164-
);
165-
}
166-
}, []);
167-
168-
const skipBackward = useCallback(() => {
169-
if (audioRef.current) {
170-
audioRef.current.currentTime = Math.max(
171-
audioRef.current.currentTime - 15,
172-
0,
173-
);
174-
}
175-
}, []);
176-
177-
const setPlaybackTime = useCallback(
178-
(newTime: number) => {
179-
if (audioRef.current) {
180-
audioRef.current.currentTime = newTime;
181-
}
182-
},
183-
[audioRef],
184-
);
185-
186-
const jumpToPoint = useCallback(
187-
(event: React.MouseEvent<HTMLDivElement>) => {
188-
if (audioRef.current && !isNaN(audioRef.current.duration)) {
189-
setIsScrubbing(true);
190-
191-
boundingClientRect.current =
192-
event.currentTarget.getBoundingClientRect();
193-
194-
const { width, left } = boundingClientRect.current;
195-
const clickX = event.clientX - left;
196-
const newTime = (clickX / width) * audioRef.current.duration;
197-
198-
setPlaybackTime(newTime);
199-
}
200-
},
201-
[setPlaybackTime],
202-
);
203-
204-
const scrub = useCallback(
205-
(event: React.MouseEvent<HTMLDivElement>) => {
206-
if (isScrubbing && audioRef.current && boundingClientRect.current) {
207-
const { width, left } = boundingClientRect.current;
208-
const eventX = event.clientX - left;
209-
const newTime = (eventX / width) * audioRef.current.duration;
210-
setPlaybackTime(newTime);
211-
}
212-
},
213-
[isScrubbing, setPlaybackTime],
214-
);
215-
216-
const stopScrubbing = useCallback(() => {
217-
setIsScrubbing(false);
218-
}, []);
219-
220-
// ********************* effects *********************
221-
222-
useEffect(() => {
223-
if (!audioRef.current) {
224-
return;
225-
}
226-
227-
const audio = audioRef.current;
228-
229-
const onPause = () => setIsPlaying(false);
230-
const onEnded = () => reportAudioEvent(mediaId, 'end');
231-
232-
const onWaiting = () => setIsWaiting(true);
233-
const onCanPlay = () => setIsWaiting(false);
234-
235-
audio.addEventListener('waiting', onWaiting);
236-
audio.addEventListener('canplay', onCanPlay);
237-
audio.addEventListener('timeupdate', onTimeupdate);
238-
audio.addEventListener('seeking', onTimeupdate);
239-
audio.addEventListener('durationchange', onDurationChange);
240-
audio.addEventListener('play', onPlay);
241-
audio.addEventListener('pause', onPause);
242-
audio.addEventListener('ended', onEnded);
243-
audio.addEventListener('error', onError);
244-
audio.addEventListener('progress', onProgress);
245-
246-
return () => {
247-
audio.removeEventListener('waiting', onWaiting);
248-
audio.removeEventListener('canplay', onCanPlay);
249-
audio.removeEventListener('timeupdate', onTimeupdate);
250-
audio.removeEventListener('seeking', onTimeupdate);
251-
audio.removeEventListener('durationchange', onDurationChange);
252-
audio.removeEventListener('play', onPlay);
253-
audio.removeEventListener('pause', onPause);
254-
audio.removeEventListener('ended', onEnded);
255-
audio.removeEventListener('error', onError);
256-
audio.removeEventListener('progress', onProgress);
257-
};
258-
}, [onTimeupdate, onDurationChange, onPlay, onError, onProgress, mediaId]);
259-
11+
audioData,
12+
isSensitive,
13+
isAcastEnabled,
14+
isApps,
15+
}: {
16+
audioData: {
17+
audioDownloadUrl: string;
18+
durationSeconds?: number;
19+
mediaId: string;
20+
};
21+
isSensitive: boolean;
22+
isAcastEnabled: boolean;
23+
isApps: boolean;
24+
}) => {
26025
return (
26126
<>
262-
{/* native audio player and controls */}
263-
<audio
264-
ref={audioRef}
265-
autoPlay={false}
266-
data-media-id={mediaId}
267-
preload="none"
268-
controls={false}
269-
>
270-
<source src={src} type="audio/mpeg" />
271-
<track kind="captions" />
272-
</audio>
273-
274-
{/* custom guardian controls that interact with the native player */}
275-
<Wrapper>
276-
<CurrentTime currentTime={currentTime} />
277-
<Duration duration={duration} />
278-
279-
<ProgressBar
280-
isScrubbing={isScrubbing}
281-
canJumpToPoint={Boolean(audioRef.current?.duration)}
282-
buffer={buffer}
283-
progress={progress}
284-
src={src}
285-
onMouseDown={jumpToPoint}
286-
onMouseUp={stopScrubbing}
287-
onMouseMove={scrub}
288-
/>
289-
290-
<Playback>
291-
<Playback.SkipBack
292-
onClick={skipBackward}
293-
disabled={isWaiting || !isPlaying}
294-
/>
295-
<Playback.Play
296-
isWaiting={isWaiting}
297-
isPlaying={isPlaying}
298-
onClick={playPause}
27+
<Island priority="critical" defer={{ until: 'visible' }}>
28+
{isApps ? (
29+
<AudioPlayerApps
30+
audioDuration={
31+
typeof audioData.durationSeconds === 'number'
32+
? formatAudioDuration(audioData.durationSeconds)
33+
: undefined
34+
}
29935
/>
300-
<Playback.SkipForward
301-
onClick={skipForward}
302-
disabled={isWaiting || !isPlaying}
36+
) : (
37+
<AudioPlayerWeb
38+
contentIsNotSensitive={!isSensitive}
39+
isAcastEnabled={isAcastEnabled}
40+
src={audioData.audioDownloadUrl}
41+
mediaId={audioData.mediaId}
30342
/>
304-
</Playback>
305-
</Wrapper>
43+
)}
44+
</Island>
45+
<StraightLines
46+
cssOverrides={css`
47+
display: block;
48+
margin-bottom: ${space[2]}px;
49+
`}
50+
count={1}
51+
color={palette('--straight-lines')}
52+
/>
30653
</>
30754
);
30855
};

dotcom-rendering/src/components/AppsAudioPlayer.island.tsx renamed to dotcom-rendering/src/components/AudioPlayer/AudioPlayerApps.island.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { log } from '@guardian/libs';
22
import { useCallback, useEffect, useRef, useState } from 'react';
3-
import { getAudioClient } from '../lib/bridgetApi';
4-
import { useIsBridgetCompatible } from '../lib/useIsBridgetCompatible';
3+
import { getAudioClient } from '../../lib/bridgetApi';
4+
import { useIsBridgetCompatible } from '../../lib/useIsBridgetCompatible';
55
import { AppsAudioPlayButton } from './AppsAudioPlayButton';
66

77
const AUDIO_BRIDGET_VERSION = '8.9.0';
@@ -11,7 +11,7 @@ type Props = {
1111
audioDuration?: string;
1212
};
1313

14-
export const AppsAudioPlayer = ({ audioDuration }: Props) => {
14+
export const AudioPlayerApps = ({ audioDuration }: Props) => {
1515
const [showButton, setShowButton] = useState<boolean>(false);
1616
const pollingRef = useRef<ReturnType<typeof setInterval> | null>(null);
1717

0 commit comments

Comments
 (0)