Skip to content

Commit ca93e15

Browse files
committed
Add waveform to podcast feature card
1 parent 5c2792f commit ca93e15

File tree

7 files changed

+122
-28
lines changed

7 files changed

+122
-28
lines changed

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

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { css } from '@emotion/react';
22
import { from, palette } from '@guardian/source/foundations';
3-
import { WaveForm } from './WaveForm';
3+
import { WaveForm } from '../../WaveForm';
44

55
const cursorWidth = '4px';
66

@@ -85,8 +85,9 @@ export const ProgressBar = ({
8585
{...props}
8686
>
8787
<WaveForm
88+
seed={src}
89+
height={100}
8890
bars={175}
89-
src={src}
9091
progress={progress}
9192
buffer={buffer}
9293
theme={{

dotcom-rendering/src/components/FeatureCard.tsx

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import { MediaDuration } from './MediaDuration';
3232
import { Pill } from './Pill';
3333
import { StarRating } from './StarRating/StarRating';
3434
import { SupportingContent } from './SupportingContent';
35+
import { WaveForm } from './WaveForm';
3536
import { YoutubeBlockComponent } from './YoutubeBlockComponent.importable';
3637

3738
export type Position = 'inner' | 'outer' | 'none';
@@ -112,6 +113,11 @@ const overlayStyles = css`
112113
rgb(0, 0, 0) 64px
113114
);
114115
backdrop-filter: blur(12px) brightness(0.5);
116+
117+
/* Ensure the waveform is behind the other elements, e.g. headline, pill */
118+
> * {
119+
z-index: 1;
120+
}
115121
`;
116122

117123
const podcastImageContainerStyles = css`
@@ -150,6 +156,17 @@ const videoPillStyles = css`
150156
right: ${space[2]}px;
151157
`;
152158

159+
const waveformStyles = css`
160+
position: absolute;
161+
bottom: 0;
162+
left: 0;
163+
z-index: 0;
164+
height: 64px;
165+
max-width: 100%;
166+
overflow: hidden;
167+
opacity: 0.3;
168+
`;
169+
153170
const getMedia = ({
154171
imageUrl,
155172
imageAltText,
@@ -168,9 +185,11 @@ const getMedia = ({
168185
...(imageUrl && { imageUrl }),
169186
} as const;
170187
}
188+
171189
if (imageUrl) {
172190
return { type: 'picture', imageUrl, imageAltText } as const;
173191
}
192+
174193
return undefined;
175194
};
176195

@@ -506,6 +525,18 @@ export const FeatureCard = ({
506525
</div>
507526
)}
508527

528+
{mainMedia?.type === 'Audio' && (
529+
<div css={waveformStyles}>
530+
<WaveForm
531+
seed={mainMedia.duration}
532+
height={64}
533+
// Just enough to cover the full width of the feature card in it's largest form
534+
bars={233}
535+
barWidth={2}
536+
/>
537+
</div>
538+
)}
539+
509540
<CardFooter
510541
format={format}
511542
age={
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { palette } from '@guardian/source/foundations';
2+
import type { Meta, StoryObj } from '@storybook/react';
3+
import { WaveForm as WaveFormComponent } from './WaveForm';
4+
5+
const meta = {
6+
title: 'Components/WaveForm',
7+
component: WaveFormComponent,
8+
} satisfies Meta<typeof WaveFormComponent>;
9+
10+
export default meta;
11+
12+
type Story = StoryObj<typeof meta>;
13+
14+
export const Default = {
15+
args: {
16+
seed: 'https://audio.guim.co.uk/2024/10/18-57753-USEE_181024.mp3',
17+
height: 100,
18+
bars: 175,
19+
},
20+
} satisfies Story;
21+
22+
export const InProgress = {
23+
args: {
24+
...Default.args,
25+
progress: 40,
26+
buffer: 50,
27+
},
28+
} satisfies Story;
29+
30+
export const ShorterWithMoreBars = {
31+
args: {
32+
...InProgress.args,
33+
height: 50,
34+
bars: 200,
35+
barWidth: 2,
36+
},
37+
} satisfies Story;
38+
39+
export const WithTheme = {
40+
args: {
41+
...InProgress.args,
42+
theme: {
43+
progress: palette.neutral[73],
44+
buffer: palette.neutral[60],
45+
wave: palette.neutral[46],
46+
},
47+
},
48+
} satisfies Story;

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

Lines changed: 33 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,7 @@ const getSeededRandomNumberGenerator = (seedString: string) => {
3535
};
3636

3737
/**
38-
* Compresses an of values to a range between the threshold and the existing
39-
* maximum.
38+
* Compresses an array of values to a range between the threshold and the existing maximum.
4039
*/
4140
const compress = (array: number[], threshold: number) => {
4241
const minValue = Math.min(...array);
@@ -51,17 +50,20 @@ const compress = (array: number[], threshold: number) => {
5150
};
5251

5352
// Generate an array of fake audio peaks based on the URL
54-
function generateWaveform(url: string, bars: number) {
53+
function generateWaveform(url: string, bars: number, height: number) {
5554
const getSeededRandomNumber = getSeededRandomNumberGenerator(url);
5655

5756
// Generate an array of fake peaks, pseudo random numbers seeded by the URL
5857
const peaks = Array.from(
5958
{ length: bars },
60-
() => getSeededRandomNumber() * 100,
59+
() => getSeededRandomNumber() * height,
6160
);
6261

62+
// To ensure a good looking waveform, we set a fairly high minimum bar height
63+
const minimumBarHeight = 0.6;
64+
6365
// Return the compressed fake audio data (like a podcast would be)
64-
return compress(peaks, 60);
66+
return compress(peaks, height * minimumBarHeight);
6567
}
6668

6769
type Theme = {
@@ -77,27 +79,36 @@ const defaultTheme: Theme = {
7779
};
7880

7981
type Props = {
80-
src: string;
81-
progress: number;
82-
buffer: number;
82+
/**
83+
* The same seed will generate the same waveform. For example, passing the url
84+
* as the seed will ensure the waveform is the same for the same audio file.
85+
*/
86+
seed: string;
87+
height: number;
88+
bars: number;
89+
progress?: number;
90+
buffer?: number;
8391
theme?: Theme;
8492
gap?: number;
85-
bars?: number;
8693
barWidth?: number;
8794
} & React.SVGProps<SVGSVGElement>;
8895

8996
export const WaveForm = ({
90-
src,
91-
progress,
92-
buffer,
97+
seed,
98+
height,
99+
bars,
100+
progress = 0,
101+
buffer = 0,
93102
theme: userTheme,
94103
gap = 1,
95-
bars = 150,
96104
barWidth = 4,
97105
...props
98106
}: Props) => {
99107
// memoise the waveform data so they aren't recalculated on every render
100-
const barHeights = useMemo(() => generateWaveform(src, bars), [src, bars]);
108+
const barHeights = useMemo(
109+
() => generateWaveform(seed, bars, height),
110+
[seed, bars, height],
111+
);
101112
const totalWidth = useMemo(
102113
() => bars * (barWidth + gap) - gap,
103114
[bars, barWidth, gap],
@@ -112,10 +123,10 @@ export const WaveForm = ({
112123

113124
return (
114125
<svg
115-
viewBox={`0 0 ${totalWidth} 100`}
126+
viewBox={`0 0 ${totalWidth} ${height}`}
116127
preserveAspectRatio="none"
117128
width={totalWidth}
118-
height={100}
129+
height={height}
119130
xmlns="http://www.w3.org/2000/svg"
120131
{...props}
121132
>
@@ -128,7 +139,7 @@ export const WaveForm = ({
128139
<rect
129140
key={x}
130141
x={x}
131-
y={100 - barHeight} // place it on the bottom
142+
y={height - barHeight} // place it on the bottom
132143
width={barWidth}
133144
height={barHeight}
134145
/>
@@ -137,11 +148,14 @@ export const WaveForm = ({
137148
</g>
138149

139150
<clipPath id={`buffer-clip-path-${id}`}>
140-
<rect height="100" width={(buffer / 100) * totalWidth} />
151+
<rect height={height} width={(buffer / 100) * totalWidth} />
141152
</clipPath>
142153

143154
<clipPath id={`progress-clip-path-${id}`}>
144-
<rect height="100" width={(progress / 100) * totalWidth} />
155+
<rect
156+
height={height}
157+
width={(progress / 100) * totalWidth}
158+
/>
145159
</clipPath>
146160
</defs>
147161

dotcom-rendering/src/model/article-schema.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5265,6 +5265,9 @@
52655265
"type": "string",
52665266
"const": "Audio"
52675267
},
5268+
"duration": {
5269+
"type": "string"
5270+
},
52685271
"podcastImage": {
52695272
"type": "object",
52705273
"properties": {
@@ -5275,9 +5278,6 @@
52755278
"type": "string"
52765279
}
52775280
}
5278-
},
5279-
"duration": {
5280-
"type": "string"
52815281
}
52825282
},
52835283
"required": [

dotcom-rendering/src/model/front-schema.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3840,6 +3840,9 @@
38403840
"type": "string",
38413841
"const": "Audio"
38423842
},
3843+
"duration": {
3844+
"type": "string"
3845+
},
38433846
"podcastImage": {
38443847
"type": "object",
38453848
"properties": {
@@ -3850,9 +3853,6 @@
38503853
"type": "string"
38513854
}
38523855
}
3853-
},
3854-
"duration": {
3855-
"type": "string"
38563856
}
38573857
},
38583858
"required": [

dotcom-rendering/src/types/mainMedia.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,8 @@ type Video = Media & {
2121

2222
type Audio = Media & {
2323
type: 'Audio';
24-
podcastImage?: PodcastSeriesImage;
2524
duration: string;
25+
podcastImage?: PodcastSeriesImage;
2626
};
2727

2828
type Gallery = Media & {

0 commit comments

Comments
 (0)