Skip to content

Commit 9ed0be5

Browse files
dylanjeffersclaude
andcommitted
feat(web): dense TrackRow on Trending behind the track_rows flag
Harmony gains `TrackRow`, a 56px single-line row: rank · 40px artwork · title and artist · plays · favorite · duration · overflow menu. Hover or keyboard focus swaps the rank for a play button and reveals the favorite and menu controls; the playing row shows an equalizer and an accent title. The row is presentational: the app passes links, artwork, formatted numbers and the menu. Web adds `TrackRowTile`, a connected row with the same props as the desktop `TrackTile`, so `TrackLineup` can swap between them with a new `tileVariant` prop. Narrow containers and mobile keep the tile. Trending renders rows when `FeatureFlags.TRACK_ROWS` (`track_rows`, default off) is enabled. At 1440x900 the page shows 11 tracks instead of 3 and a half. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1 parent f24fc25 commit 9ed0be5

8 files changed

Lines changed: 652 additions & 6 deletions

File tree

‎packages/common/src/services/remote-config/feature-flags.ts‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@ export enum FeatureFlags {
1616
LAUNCHPAD_VERIFICATION = 'launchpad_verification',
1717
FAN_CLUB_TEXT_POST_POSTING = 'fan_club_text_post_posting',
1818
QUEUE_NEW_FEATURE_BADGE = 'queue_new_feature_badge',
19-
WEEKLY_ROTATION = 'weekly_rotation'
19+
WEEKLY_ROTATION = 'weekly_rotation',
20+
TRACK_ROWS = 'track_rows'
2021
}
2122

2223
type FlagDefaults = Record<FeatureFlags, boolean>
@@ -51,5 +52,7 @@ export const flagDefaults: FlagDefaults = {
5152
[FeatureFlags.QUEUE_NEW_FEATURE_BADGE]: false,
5253
// Default on so shared links render before the Optimizely datafile loads;
5354
// the remote flag is the kill switch.
54-
[FeatureFlags.WEEKLY_ROTATION]: true
55+
[FeatureFlags.WEEKLY_ROTATION]: true,
56+
// Dense track rows instead of tiles on Trending. Off until rolled out.
57+
[FeatureFlags.TRACK_ROWS]: false
5558
}

‎packages/harmony/src/components/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,3 +38,4 @@ export * from './internal/MenuItem'
3838
export * from './internal/Menu'
3939
export * from './internal/OptionKeyHandler'
4040
export * from './tooltip'
41+
export * from './track-row'
Lines changed: 293 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,293 @@
1+
import { forwardRef, MouseEvent, useCallback } from 'react'
2+
3+
import { css, keyframes, useTheme } from '@emotion/react'
4+
5+
import { IconHeart, IconPause, IconPlay } from '../../icons'
6+
import { IconButton } from '../button'
7+
import { Box } from '../layout/Box'
8+
import { Flex } from '../layout/Flex'
9+
import { Skeleton } from '../skeleton'
10+
import { Text } from '../text'
11+
12+
import type { TrackRowProps } from './types'
13+
14+
const ROW_HEIGHT = 56
15+
const ART_SIZE = 40
16+
17+
const REVEAL_CLASS = 'trackRowReveal'
18+
const HIDE_ON_HOVER_CLASS = 'trackRowHideOnHover'
19+
20+
const bounce = keyframes`
21+
0%, 100% { height: 4px; }
22+
50% { height: 14px; }
23+
`
24+
25+
/** Three animated bars shown in place of the rank while the row is playing. */
26+
const Equalizer = () => {
27+
const { color } = useTheme()
28+
return (
29+
<Flex
30+
aria-hidden
31+
gap='2xs'
32+
alignItems='flex-end'
33+
justifyContent='center'
34+
h={14}
35+
css={{
36+
'& > i': {
37+
display: 'block',
38+
width: 3,
39+
borderRadius: 1,
40+
backgroundColor: color.text.accent,
41+
animation: `${bounce} 1s ease-in-out infinite`
42+
},
43+
'& > i:nth-of-type(2)': { animationDelay: '-0.4s' },
44+
'& > i:nth-of-type(3)': { animationDelay: '-0.7s' },
45+
'@media (prefers-reduced-motion: reduce)': {
46+
'& > i': { animation: 'none', height: 10 }
47+
}
48+
}}
49+
>
50+
<i />
51+
<i />
52+
<i />
53+
</Flex>
54+
)
55+
}
56+
57+
/**
58+
* A dense, single-line track row for lineups: rank · artwork · title and
59+
* artist · plays · favorite · duration · menu. Fifty-six pixels tall, so a
60+
* desktop viewport shows ten to twelve tracks instead of three or four tiles.
61+
*
62+
* The row is presentational. The app supplies the title and artist nodes
63+
* (links), the artwork, the formatted numbers and the overflow menu; the row
64+
* handles layout, hover and playing states, and reveals actions on hover or
65+
* keyboard focus.
66+
*/
67+
export const TrackRow = forwardRef<HTMLDivElement, TrackRowProps>(
68+
(props, ref) => {
69+
const {
70+
rank,
71+
artwork,
72+
title,
73+
subtitle,
74+
playCount,
75+
duration,
76+
isActive = false,
77+
isPlaying = false,
78+
isFavorited = false,
79+
isLoading = false,
80+
isDisabled = false,
81+
onTogglePlay,
82+
onToggleFavorite,
83+
menu,
84+
playLabel = isPlaying ? 'Pause' : 'Play',
85+
favoriteLabel = isFavorited ? 'Unfavorite' : 'Favorite',
86+
className,
87+
...other
88+
} = props
89+
const { color, cornerRadius, motion, spacing, typography } = useTheme()
90+
91+
const isInteractive = !isLoading && !isDisabled
92+
93+
// Clicks on links, buttons and the menu belong to those controls; any
94+
// other click on the row toggles playback, like the tile does.
95+
const handleRowClick = useCallback(
96+
(event: MouseEvent<HTMLDivElement>) => {
97+
if (!isInteractive) return
98+
const target = event.target as Element
99+
if (target.closest('a, button, [role="menu"], [data-row-control]')) {
100+
return
101+
}
102+
onTogglePlay?.()
103+
},
104+
[isInteractive, onTogglePlay]
105+
)
106+
107+
const handlePlayClick = useCallback(
108+
(event: MouseEvent<HTMLButtonElement>) => {
109+
event.stopPropagation()
110+
onTogglePlay?.()
111+
},
112+
[onTogglePlay]
113+
)
114+
115+
const handleFavoriteClick = useCallback(
116+
(event: MouseEvent<HTMLButtonElement>) => {
117+
event.stopPropagation()
118+
onToggleFavorite?.()
119+
},
120+
[onToggleFavorite]
121+
)
122+
123+
const rowCss = css({
124+
display: 'grid',
125+
gridTemplateColumns: `28px ${ART_SIZE}px minmax(0, 1fr) auto 32px 48px 32px`,
126+
alignItems: 'center',
127+
columnGap: spacing.m,
128+
height: ROW_HEIGHT,
129+
paddingLeft: spacing.s,
130+
paddingRight: spacing.s,
131+
borderRadius: cornerRadius.m,
132+
cursor: isInteractive ? 'pointer' : 'default',
133+
opacity: isDisabled ? 0.5 : 1,
134+
transition: `background-color ${motion.hover}`,
135+
[`& .${REVEAL_CLASS}`]: {
136+
opacity: 0,
137+
transition: `opacity ${motion.hover}`
138+
},
139+
...(isInteractive && {
140+
'&:hover, &:focus-within': {
141+
backgroundColor: color.background.surface1,
142+
[`& .${REVEAL_CLASS}`]: { opacity: 1 },
143+
[`& .${HIDE_ON_HOVER_CLASS}`]: { opacity: 0 }
144+
}
145+
})
146+
})
147+
148+
// The numeral, the equalizer and the play button share one grid cell so
149+
// they stack dead-center and cross-fade on hover.
150+
const rankCss = css({
151+
display: 'grid',
152+
placeItems: 'center',
153+
height: '100%',
154+
'& > *': { gridArea: '1 / 1' }
155+
})
156+
157+
const numeralCss = css({
158+
fontVariantNumeric: 'tabular-nums',
159+
textAlign: 'right',
160+
whiteSpace: 'nowrap'
161+
})
162+
163+
const rankFace = css({
164+
fontFamily: typography.fontDisplay,
165+
'--harmony-font-family': typography.fontDisplay,
166+
transition: `opacity ${motion.hover}`
167+
})
168+
169+
if (isLoading) {
170+
return (
171+
<div ref={ref} css={rowCss} className={className} aria-busy {...other}>
172+
<Box />
173+
<Skeleton w={ART_SIZE} h={ART_SIZE} borderRadius='s' />
174+
<Flex direction='column' gap='xs' css={{ minWidth: 0 }}>
175+
<Skeleton w='45%' h={14} />
176+
<Skeleton w='28%' h={12} />
177+
</Flex>
178+
<Skeleton w={36} h={12} />
179+
<Box />
180+
<Skeleton w={32} h={12} />
181+
<Box />
182+
</div>
183+
)
184+
}
185+
186+
const showEqualizer = isActive && isPlaying
187+
const PlayIcon = showEqualizer ? IconPause : IconPlay
188+
189+
return (
190+
<div
191+
ref={ref}
192+
css={rowCss}
193+
className={className}
194+
onClick={handleRowClick}
195+
aria-current={isActive ? 'true' : undefined}
196+
{...other}
197+
>
198+
<div css={rankCss}>
199+
{rank != null && !showEqualizer ? (
200+
<Text
201+
variant='title'
202+
size='l'
203+
strength='strong'
204+
color='default'
205+
className={HIDE_ON_HOVER_CLASS}
206+
css={rankFace}
207+
>
208+
{rank}
209+
</Text>
210+
) : null}
211+
{showEqualizer ? (
212+
<span className={HIDE_ON_HOVER_CLASS}>
213+
<Equalizer />
214+
</span>
215+
) : null}
216+
{isInteractive && onTogglePlay ? (
217+
<IconButton
218+
size='s'
219+
icon={PlayIcon}
220+
color='default'
221+
aria-label={playLabel}
222+
onClick={handlePlayClick}
223+
className={REVEAL_CLASS}
224+
css={{ '&:focus-visible': { opacity: 1 } }}
225+
/>
226+
) : null}
227+
</div>
228+
229+
<Box
230+
w={ART_SIZE}
231+
h={ART_SIZE}
232+
borderRadius='s'
233+
css={{
234+
overflow: 'hidden',
235+
backgroundColor: color.background.surface2
236+
}}
237+
>
238+
{artwork}
239+
</Box>
240+
241+
<Flex direction='column' css={{ minWidth: 0 }}>
242+
<Text
243+
variant='body'
244+
size='m'
245+
strength='strong'
246+
color={isActive ? 'accent' : 'default'}
247+
ellipses
248+
css={{ fontWeight: typography.weight.medium }}
249+
>
250+
{title}
251+
</Text>
252+
{subtitle ? (
253+
<Text variant='body' size='s' color='subdued' ellipses>
254+
{subtitle}
255+
</Text>
256+
) : null}
257+
</Flex>
258+
259+
<Text variant='body' size='xs' color='subdued' css={numeralCss}>
260+
{playCount ?? ''}
261+
</Text>
262+
263+
<Flex justifyContent='center'>
264+
{onToggleFavorite ? (
265+
<IconButton
266+
size='s'
267+
icon={IconHeart}
268+
color={isFavorited ? 'active' : 'subdued'}
269+
aria-label={favoriteLabel}
270+
aria-pressed={isFavorited}
271+
onClick={handleFavoriteClick}
272+
className={isFavorited ? undefined : REVEAL_CLASS}
273+
css={{ '&:focus-visible': { opacity: 1 } }}
274+
/>
275+
) : null}
276+
</Flex>
277+
278+
<Text variant='body' size='xs' color='subdued' css={numeralCss}>
279+
{duration ?? ''}
280+
</Text>
281+
282+
<Flex
283+
justifyContent='center'
284+
className={REVEAL_CLASS}
285+
data-row-control
286+
css={{ '&:focus-within': { opacity: 1 } }}
287+
>
288+
{menu}
289+
</Flex>
290+
</div>
291+
)
292+
}
293+
)
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export * from './TrackRow'
2+
export type { TrackRowProps } from './types'
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import type { ComponentPropsWithoutRef, ReactNode } from 'react'
2+
3+
export type TrackRowProps = {
4+
/** 1-based position shown in the leading cell. Omit for unranked lists. */
5+
rank?: number
6+
/** 40px square artwork. Rendered as given, inside a fixed-size cell. */
7+
artwork?: ReactNode
8+
/** Track title. Usually a link; the row ellipsizes it. */
9+
title: ReactNode
10+
/** Artist line under the title. */
11+
subtitle?: ReactNode
12+
/** Pre-formatted play count, e.g. "6.6K". */
13+
playCount?: string
14+
/** Pre-formatted duration, e.g. "2:30". */
15+
duration?: string
16+
/** This track is the one loaded in the player. */
17+
isActive?: boolean
18+
/** This track is the one loaded in the player and audio is playing. */
19+
isPlaying?: boolean
20+
isFavorited?: boolean
21+
/** Renders skeletons in every cell and disables interaction. */
22+
isLoading?: boolean
23+
isDisabled?: boolean
24+
/** Fires when the row, its play button or its artwork is clicked. */
25+
onTogglePlay?: () => void
26+
/** When omitted, the favorite cell stays empty. */
27+
onToggleFavorite?: () => void
28+
/** Overflow menu trigger. Hidden until hover or focus. */
29+
menu?: ReactNode
30+
/** Accessible name for the play button. */
31+
playLabel?: string
32+
/** Accessible name for the favorite button. */
33+
favoriteLabel?: string
34+
} & Omit<ComponentPropsWithoutRef<'div'>, 'title' | 'onClick'>

0 commit comments

Comments
 (0)