|
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'; |
9 | 9 |
|
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 | | - */ |
53 | 10 | 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 | +}) => { |
260 | 25 | return ( |
261 | 26 | <> |
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 | + } |
299 | 35 | /> |
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} |
303 | 42 | /> |
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 | + /> |
306 | 53 | </> |
307 | 54 | ); |
308 | 55 | }; |
0 commit comments