Skip to content

Commit 339e7ee

Browse files
committed
feat(demystify): play a ten-second track excerpt, and collapse the track index
The FULL TRACK INDEX is now a closed <details> disclosure showing its count rather than 60 rows dumped under the genre list — every track already appears on its own genre's page. Audio now plays the track rather than a placeholder. The files under public/demystify/genre turned out to be two-second WAV blips with an .mp3 extension, so there was no real audio to excerpt; previews come from the iTunes Search API instead, which needs no key and sends CORS headers. 58 of the 60 tracks resolve. - resolve a track to its catalogue preview on press, cached per session, and play a ten-second window of it - window start comes from an optional 4th field on a track line, else the centre of the clip - source order: track preview, then the genre's local sample, then a chord synthesised from the genre's spectrum - one control per track in the detail list, and the genre row auditions its first track - the control shows resolving / playing / fell-back-to-synth, so a press that finds no preview is never silently ambiguous - drop the redundant wide play button that duplicated the first track
1 parent 6ea597f commit 339e7ee

9 files changed

Lines changed: 479 additions & 108 deletions
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import React from 'react';
2+
3+
const GLYPH = {
4+
idle: '►',
5+
resolving: '…',
6+
playing: '■',
7+
synth: '≈',
8+
};
9+
10+
const HINT = {
11+
idle: 'Play a ten-second excerpt',
12+
resolving: 'Looking for a preview…',
13+
playing: 'Stop',
14+
synth: 'No preview found — playing a chord from this genre’s spectrum',
15+
};
16+
17+
/**
18+
* One control, four states. `≈` means the catalogue had no match and the
19+
* fallback chord played instead, so a silent-looking press is never ambiguous.
20+
*/
21+
const AuditionButton = ({
22+
id,
23+
activeId,
24+
status,
25+
enabled,
26+
onPlay,
27+
label,
28+
wide = false,
29+
}) => {
30+
const state = activeId === id ? status : 'idle';
31+
32+
return (
33+
<button
34+
type="button"
35+
className={`dm-audition${wide ? ' is-wide' : ''}`}
36+
disabled={!enabled}
37+
onClick={onPlay}
38+
aria-label={enabled ? `${HINT[state]}: ${label}` : `Audio off: ${label}`}
39+
title={enabled ? HINT[state] : 'Audio is off'}
40+
>
41+
{!enabled
42+
? wide
43+
? '[MUTED]'
44+
: '[×]'
45+
: wide
46+
? `[${GLYPH[state]} ${state === 'playing' ? 'STOP' : '10s EXCERPT'}]`
47+
: `[${GLYPH[state]}]`}
48+
</button>
49+
);
50+
};
51+
52+
export default AuditionButton;

src/pages/demystify/EntryDetail.jsx

Lines changed: 34 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import React, { useMemo } from 'react';
22
import { Link } from 'react-router-dom';
3+
import AuditionButton from './AuditionButton';
34
import RichText from './RichText';
45
import { renderSpectrum } from './spectrum';
56

@@ -20,7 +21,8 @@ const EntryDetail = ({
2021
basePath,
2122
prev,
2223
next,
23-
isPlaying,
24+
activeId,
25+
audioStatus,
2426
audioEnabled,
2527
onPlay,
2628
}) => {
@@ -70,23 +72,6 @@ const EntryDetail = ({
7072
</dl>
7173
)}
7274

73-
{entry.audio && (
74-
<div className="dm-entry-actions">
75-
<button
76-
type="button"
77-
className="dm-audition is-wide"
78-
onClick={() => onPlay(entry)}
79-
disabled={!audioEnabled}
80-
>
81-
{!audioEnabled
82-
? '[MUTED]'
83-
: isPlaying
84-
? '[■ STOP SAMPLE]'
85-
: '[► PLAY SAMPLE]'}
86-
</button>
87-
</div>
88-
)}
89-
9075
<RichText label="WHAT IT IS / WHEN" paragraphs={entry.what} />
9176
<RichText label="SIGNIFICANT ARTISTS" paragraphs={entry.artists} />
9277
<RichText label="TRIVIA" paragraphs={entry.trivia} />
@@ -97,18 +82,37 @@ const EntryDetail = ({
9782
{entry.tracks.length > 0 && (
9883
<section className="dm-prose">
9984
<h3 className="dm-section-title">ON REPEAT — TRACKS IN THIS GENRE</h3>
100-
<ul className="dm-example-list">
101-
{entry.tracks.map((track) => (
102-
<li className="dm-example" key={`${track.pos}-${track.title}`}>
103-
<span className="dm-example-title">
104-
{track.pos && <span className="dm-track-pos">{track.pos}</span>}
105-
{track.title}
106-
</span>
107-
{track.artist && (
108-
<span className="dm-example-note">{track.artist}</span>
109-
)}
110-
</li>
111-
))}
85+
<p className="dm-hint">
86+
Each control plays a ten-second excerpt of the track, taken from its
87+
catalogue preview clip.
88+
</p>
89+
<ul className="dm-tracklist">
90+
{entry.tracks.map((track) => {
91+
const id = `${entry.id}:${track.pos}:${track.title}`;
92+
return (
93+
<li className="dm-track" key={id}>
94+
<span className="dm-track-text">
95+
<span className="dm-track-title">
96+
{track.pos && (
97+
<span className="dm-track-pos">{track.pos}</span>
98+
)}
99+
{track.title}
100+
</span>
101+
{track.artist && (
102+
<span className="dm-track-artist">{track.artist}</span>
103+
)}
104+
</span>
105+
<AuditionButton
106+
id={id}
107+
activeId={activeId}
108+
status={audioStatus}
109+
enabled={audioEnabled}
110+
label={`${track.title} by ${track.artist}`}
111+
onPlay={() => onPlay({ id, track, entry })}
112+
/>
113+
</li>
114+
);
115+
})}
112116
</ul>
113117
</section>
114118
)}

src/pages/demystify/GenreCollectionPage.jsx

Lines changed: 28 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import React, { useEffect, useMemo, useState } from 'react';
22
import { Link, useParams } from 'react-router-dom';
33
import Seo from '../../components/Seo';
44
import usePersistentState from '../../hooks/usePersistentState';
5+
import AuditionButton from './AuditionButton';
56
import DemystifyShell from './DemystifyShell';
67
import EntryDetail from './EntryDetail';
78
import useAudioSample from './useAudioSample';
@@ -57,7 +58,9 @@ const GenreCollectionPage = () => {
5758
'demystify-audio',
5859
true,
5960
);
60-
const { play, playingId } = useAudioSample({ enabled: audioEnabled });
61+
const { play, activeId, status: audioStatus } = useAudioSample({
62+
enabled: audioEnabled,
63+
});
6164

6265
useEffect(() => {
6366
window.scrollTo({ top: 0, behavior: 'auto' });
@@ -164,7 +167,8 @@ const GenreCollectionPage = () => {
164167
basePath={BASE_PATH}
165168
prev={prev}
166169
next={next}
167-
isPlaying={playingId === entry.id}
170+
activeId={activeId}
171+
audioStatus={audioStatus}
168172
audioEnabled={audioEnabled}
169173
onPlay={play}
170174
/>
@@ -267,28 +271,34 @@ const GenreCollectionPage = () => {
267271
268272
</span>
269273
</Link>
270-
<button
271-
type="button"
272-
className="dm-audition"
273-
onClick={() => play(item)}
274-
disabled={!audioEnabled}
275-
aria-label={`Play a sample of ${item.name}`}
276-
>
277-
{!audioEnabled
278-
? '[MUTED]'
279-
: playingId === item.id
280-
? '[■]'
281-
: '[►]'}
282-
</button>
274+
<AuditionButton
275+
id={item.id}
276+
activeId={activeId}
277+
status={audioStatus}
278+
enabled={audioEnabled}
279+
label={item.name}
280+
onPlay={() =>
281+
play({
282+
id: item.id,
283+
track: item.tracks[0],
284+
entry: item,
285+
})
286+
}
287+
/>
283288
</li>
284289
))}
285290
</ul>
286291
</>
287292
)}
288293

289294
{allTracks.length > 0 && (
290-
<section className="dm-trackindex">
291-
<h3 className="dm-section-title">FULL TRACK INDEX</h3>
295+
<details className="dm-trackindex">
296+
<summary className="dm-summary">
297+
FULL TRACK INDEX
298+
<span className="dm-summary-count">
299+
{allTracks.length} TRACKS
300+
</span>
301+
</summary>
292302
<p className="dm-resultcount">
293303
P1 = first screenshot, P2 = second. Every position accounted
294304
for.
@@ -319,7 +329,7 @@ const GenreCollectionPage = () => {
319329
</tbody>
320330
</table>
321331
</div>
322-
</section>
332+
</details>
323333
)}
324334
</>
325335
)}

src/pages/demystify/demystifyData.js

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -102,10 +102,23 @@ export const items = (fields, key) =>
102102
.map(([title, note = '']) => ({ title, note }))
103103
.filter((entry) => entry.title);
104104

105-
/** Reads `- position | title | artist` lines out of a field. */
105+
/**
106+
* Reads `- position | title | artist | start` lines out of a field.
107+
*
108+
* `start` is optional: seconds into the preview clip where the significant part
109+
* begins. Without it the player centres its window on the clip instead.
110+
*/
106111
export const tracks = (fields, key) =>
107112
rows(fields, key)
108-
.map(([pos, title = '', artist = '']) => ({ pos, title, artist }))
113+
.map(([pos, title = '', artist = '', start]) => {
114+
const startAt = Number.parseFloat(start);
115+
return {
116+
pos,
117+
title,
118+
artist,
119+
start: Number.isFinite(startAt) ? startAt : null,
120+
};
121+
})
109122
.filter((track) => track.title);
110123

111124
export const numbers = (fields, key, fallback) => {

src/pages/demystify/demystifyData.test.js

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -91,13 +91,13 @@ describe('atlas fields', () => {
9191
expect(fields.Note).toBeUndefined();
9292
});
9393

94-
it('reads three-field track lines', () => {
94+
it('reads track lines, with an optional excerpt start', () => {
9595
const [fields] = parseBlocks(
96-
'===\ntracks:\n - P1·01 | Snake Eyes | Feint, CoMa\n - P2·30 | Saltwater | Chicane\n',
96+
'===\ntracks:\n - P1·01 | Snake Eyes | Feint, CoMa\n - P2·30 | Saltwater | Chicane | 42.5\n',
9797
);
9898
expect(tracks(fields, 'tracks')).toEqual([
99-
{ pos: 'P1·01', title: 'Snake Eyes', artist: 'Feint, CoMa' },
100-
{ pos: 'P2·30', title: 'Saltwater', artist: 'Chicane' },
99+
{ pos: 'P1·01', title: 'Snake Eyes', artist: 'Feint, CoMa', start: null },
100+
{ pos: 'P2·30', title: 'Saltwater', artist: 'Chicane', start: 42.5 },
101101
]);
102102
});
103103

src/pages/demystify/demystifyPages.test.jsx

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import DemystifyHubPage from './DemystifyHubPage';
77
import GenreCollectionPage from './GenreCollectionPage';
88
import { clearDemystifyCache } from './demystifyData';
99
import { renderSpectrum } from './spectrum';
10+
import { previewQuery } from './trackPreview';
1011
// Aliased: the testing-library lint rule treats any `render*` call as a
1112
// component render and objects to how its result is named.
1213
import { renderInline as inline } from './RichText';
@@ -155,6 +156,42 @@ describe('renderSpectrum', () => {
155156
});
156157
});
157158

159+
describe('previewQuery', () => {
160+
// The catalogue does not know our display strings, so the search term has to
161+
// be looser than the text shown on screen.
162+
it.each([
163+
[
164+
{ title: 'Trains — 2017 Remaster', artist: 'Porcupine Tree' },
165+
'Trains Porcupine Tree',
166+
],
167+
[
168+
{ title: 'Hemorrhage (In My Hands)', artist: 'Fuel' },
169+
'Hemorrhage Fuel',
170+
],
171+
[
172+
{ title: 'Mad About You — Live with Orchestra', artist: 'Hooverphonic' },
173+
'Mad About You Hooverphonic',
174+
],
175+
// Multi-artist credits only match on the lead name.
176+
[{ title: 'Snake Eyes', artist: 'Feint, CoMa' }, 'Snake Eyes Feint'],
177+
[
178+
{ title: 'Stylo', artist: 'Gorillaz, Bobby Womack, Mos Def' },
179+
'Stylo Gorillaz',
180+
],
181+
[
182+
{ title: "Peace of Akatosh (From 'Oblivion')", artist: 'Dreyma' },
183+
'Peace of Akatosh Dreyma',
184+
],
185+
])('cleans %o', (track, expected) => {
186+
expect(previewQuery(track)).toBe(expected);
187+
});
188+
189+
it('is empty for a track with nothing to search on', () => {
190+
expect(previewQuery({})).toBe('');
191+
expect(previewQuery(undefined)).toBe('');
192+
});
193+
});
194+
158195
describe('renderInline', () => {
159196
it('turns *bold* and _italic_ into elements and leaves the rest as text', () => {
160197
const parts = inline('a *bee* and _cee_');
@@ -278,9 +315,20 @@ describe('GenreCollectionPage — index', () => {
278315
const row = list().getByRole('link', { name: /TRIP HOP/ });
279316
expect(within(row).queryByRole('button')).toBeNull();
280317
expect(
281-
screen.getByRole('button', { name: /Play a sample of TRIP HOP/ }),
318+
list().getByRole('button', { name: /ten-second excerpt.*TRIP HOP/ }),
282319
).toBeInTheDocument();
283320
});
321+
322+
it('keeps the full track index collapsed rather than listing all of it', async () => {
323+
renderGenre('/demystify/genre');
324+
await settle();
325+
326+
const disclosure = screen.getByText(/FULL TRACK INDEX/);
327+
expect(disclosure.tagName).toBe('SUMMARY');
328+
expect(screen.getByText('2 TRACKS')).toBeInTheDocument();
329+
// The rows exist in the DOM but the disclosure ships closed.
330+
expect(screen.getByRole('group')).not.toHaveAttribute('open');
331+
});
284332
});
285333

286334
describe('GenreCollectionPage — detail', () => {
@@ -311,6 +359,17 @@ describe('GenreCollectionPage — detail', () => {
311359
expect(screen.getByText('Morcheeba')).toBeInTheDocument();
312360
});
313361

362+
it('gives each track its own ten-second excerpt control', async () => {
363+
renderGenre('/demystify/genre/trip-hop');
364+
await screen.findByRole('heading', { name: /TRIP HOP/ });
365+
366+
expect(
367+
screen.getByRole('button', {
368+
name: /ten-second excerpt.*Easier Said Than Done by Morcheeba/,
369+
}),
370+
).toBeInTheDocument();
371+
});
372+
314373
it('offers the next entry from a detail page', async () => {
315374
renderGenre('/demystify/genre/trip-hop');
316375
const next = await screen.findByRole('link', { name: /PROGRESSIVE ROCK/ });

0 commit comments

Comments
 (0)