From a9bbb7cff36a76373760ce0b4915e3f1e29ccd89 Mon Sep 17 00:00:00 2001 From: Dylan Jeffers Date: Tue, 27 Oct 2026 11:22:07 -0700 Subject: [PATCH] feat(contests): preview stems in place on the contest page Adds a play/pause button to each row of the contest Stems & Downloads card so entrants can hear a stem before downloading it. Previews play through a standalone audio element (pausing the main player), resolve the same signed stream url as the playback saga, skip play counting, and are only offered when the listener already has download access. Requested in Discord #feature-requests (Jun 29, 2026). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HvrdX7CeHau9gA4PhK8HYq --- .../components/ContestStemsCard.tsx | 43 ++++- .../contest-page/hooks/useStemPreview.test.ts | 149 ++++++++++++++++++ .../contest-page/hooks/useStemPreview.ts | 109 +++++++++++++ 3 files changed, 297 insertions(+), 4 deletions(-) create mode 100644 packages/web/src/pages/contest-page/hooks/useStemPreview.test.ts create mode 100644 packages/web/src/pages/contest-page/hooks/useStemPreview.ts diff --git a/packages/web/src/pages/contest-page/components/ContestStemsCard.tsx b/packages/web/src/pages/contest-page/components/ContestStemsCard.tsx index 8460f99ba79..ae79eb41f75 100644 --- a/packages/web/src/pages/contest-page/components/ContestStemsCard.tsx +++ b/packages/web/src/pages/contest-page/components/ContestStemsCard.tsx @@ -33,6 +33,8 @@ import { Flex, IconButton, IconCaretDown, + IconPause, + IconPlay, IconReceive, Paper, PlainButton, @@ -46,6 +48,8 @@ import { useDownloadTrackStemsZip } from 'hooks/useDownloadTrackStemsZip' import { useRequiresAccountCallback } from 'hooks/useRequiresAccount' import { useTrackCoverArt } from 'hooks/useTrackCoverArt' +import { useStemPreview } from '../hooks/useStemPreview' + const messages = { heading: 'STEMS & DOWNLOADS', downloadHeading: 'DOWNLOAD', @@ -55,7 +59,9 @@ const messages = { downloadAll: 'Download All', stemsCount: (n: number) => (n === 1 ? '1 Stem' : `${n} Stems`), fullTrack: 'Full Track', - downloadStem: 'Download stem' + downloadStem: 'Download stem', + previewStem: (title: string) => `Preview ${title}`, + stopPreview: (title: string) => `Stop preview of ${title}` } type ContestStemsCardProps = { @@ -97,6 +103,10 @@ export const ContestStemsCard = ({ trackId }: ContestStemsCardProps) => { const { onOpen: openPremiumContentPurchaseModal } = usePremiumContentPurchaseModal() const downloadStemsZip = useDownloadTrackStemsZip(trackId) + const { playingTrackId, loadingTrackId, togglePreview } = useStemPreview() + // Previews resolve the same gated stream the download would, so only + // offer them once the listener has access to the downloads. + const canPreview = !shouldDisplayPremiumDownloadLocked const { onOpen: openWaitForDownloadModal } = useWaitForDownloadModal() // Auto-follow the contest when a signed-in user kicks off a stem @@ -365,6 +375,12 @@ export const ContestStemsCard = ({ trackId }: ContestStemsCardProps) => { subtitle={row.subtitle} size={row.size} onDownload={() => handleDownloadOne(row.trackId)} + onTogglePreview={ + canPreview ? () => togglePreview(row.trackId) : undefined + } + isPreviewing={ + playingTrackId === row.trackId || loadingTrackId === row.trackId + } /> ))} @@ -401,6 +417,9 @@ type ContestStemDownloadRowProps = { subtitle: string size?: number onDownload: () => void + /** Omitted when the listener can't preview (e.g. locked downloads). */ + onTogglePreview?: () => void + isPreviewing?: boolean } const ContestStemDownloadRow = ({ @@ -408,7 +427,9 @@ const ContestStemDownloadRow = ({ title, subtitle, size, - onDownload + onDownload, + onTogglePreview, + isPreviewing = false }: ContestStemDownloadRowProps) => { return ( <> @@ -455,8 +476,8 @@ const ContestStemDownloadRow = ({ ) : null} - {/* Right: size + download icon. `formatBytes` mirrors how - the track-page DownloadRow renders this column. */} + {/* Right: size + preview + download icons. `formatBytes` + mirrors how the track-page DownloadRow renders this column. */} {size ? ( ) : null} + {onTogglePreview ? ( + + ) : null} ({ + dispatch: vi.fn(), + isQueuePlaying: false, + getTrackStreamUrl: vi.fn(), + signGatedContentRequest: vi.fn() +})) + +vi.mock('react-redux', () => ({ + useDispatch: () => mocks.dispatch, + useSelector: () => mocks.isQueuePlaying +})) + +vi.mock('@audius/common/api', () => ({ + useCurrentUserId: () => ({ data: 7 }) +})) + +vi.mock('services/audio-player', () => ({ + audioPlayer: { audio: { volume: 0.5 } } +})) + +vi.mock('services/audius-backend/audius-backend-instance', () => ({ + audiusBackendInstance: { + signGatedContentRequest: mocks.signGatedContentRequest + } +})) + +vi.mock('services/audius-sdk', () => ({ + audiusSdk: async () => ({ + tracks: { getTrackStreamUrl: mocks.getTrackStreamUrl } + }) +})) + +class FakeAudio { + static instances: FakeAudio[] = [] + src: string + volume = 1 + onended: (() => void) | null = null + play = vi.fn(async () => {}) + pause = vi.fn() + load = vi.fn() + removeAttribute = vi.fn() + constructor(src: string) { + this.src = src + FakeAudio.instances.push(this) + } +} + +describe('useStemPreview', () => { + beforeEach(() => { + FakeAudio.instances = [] + vi.stubGlobal('Audio', FakeAudio) + mocks.isQueuePlaying = false + mocks.dispatch.mockReset() + mocks.signGatedContentRequest.mockResolvedValue({ + data: 'data', + signature: 'sig' + }) + mocks.getTrackStreamUrl.mockImplementation( + async ({ trackId }: { trackId: string }) => `https://stream/${trackId}` + ) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('plays a stem without counting a play', async () => { + const { result } = renderHook(() => useStemPreview()) + + await act(async () => { + result.current.togglePreview(11) + }) + + await waitFor(() => expect(result.current.playingTrackId).toBe(11)) + expect(mocks.getTrackStreamUrl).toHaveBeenCalledWith( + expect.objectContaining({ + skipPlayCount: true, + userSignature: 'sig', + userData: 'data' + }) + ) + expect(FakeAudio.instances).toHaveLength(1) + expect(FakeAudio.instances[0].volume).toBe(0.5) + expect(FakeAudio.instances[0].play).toHaveBeenCalled() + }) + + it('stops the preview when toggled again', async () => { + const { result } = renderHook(() => useStemPreview()) + + await act(async () => { + result.current.togglePreview(11) + }) + await waitFor(() => expect(result.current.playingTrackId).toBe(11)) + + act(() => { + result.current.togglePreview(11) + }) + + expect(result.current.playingTrackId).toBeNull() + expect(FakeAudio.instances[0].pause).toHaveBeenCalled() + }) + + it('switches to another stem, stopping the first', async () => { + const { result } = renderHook(() => useStemPreview()) + + await act(async () => { + result.current.togglePreview(11) + }) + await waitFor(() => expect(result.current.playingTrackId).toBe(11)) + + await act(async () => { + result.current.togglePreview(12) + }) + + await waitFor(() => expect(result.current.playingTrackId).toBe(12)) + expect(FakeAudio.instances[0].pause).toHaveBeenCalled() + expect(FakeAudio.instances).toHaveLength(2) + }) + + it('pauses the main player before previewing', async () => { + mocks.isQueuePlaying = true + const { result } = renderHook(() => useStemPreview()) + + await act(async () => { + result.current.togglePreview(11) + }) + + expect(mocks.dispatch).toHaveBeenCalledTimes(1) + }) + + it('resets state when the stream url lookup fails', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + mocks.getTrackStreamUrl.mockRejectedValueOnce(new Error('nope')) + const { result } = renderHook(() => useStemPreview()) + + await act(async () => { + result.current.togglePreview(11) + }) + + await waitFor(() => expect(result.current.loadingTrackId).toBeNull()) + expect(result.current.playingTrackId).toBeNull() + expect(FakeAudio.instances).toHaveLength(0) + }) +}) diff --git a/packages/web/src/pages/contest-page/hooks/useStemPreview.ts b/packages/web/src/pages/contest-page/hooks/useStemPreview.ts new file mode 100644 index 00000000000..e9dbd15de1c --- /dev/null +++ b/packages/web/src/pages/contest-page/hooks/useStemPreview.ts @@ -0,0 +1,109 @@ +import { useCallback, useEffect, useRef, useState } from 'react' + +import { useCurrentUserId } from '@audius/common/api' +import { ID } from '@audius/common/models' +import { playbackActions, playbackSelectors } from '@audius/common/store' +import { Id, OptionalId } from '@audius/sdk' +import { useDispatch, useSelector } from 'react-redux' +import { useUnmount } from 'react-use' + +import { audioPlayer } from 'services/audio-player' +import { audiusBackendInstance } from 'services/audius-backend/audius-backend-instance' +import { audiusSdk } from 'services/audius-sdk' + +const { getPlaying } = playbackSelectors + +/** + * Lightweight, in-place audio preview for contest stems. + * + * Stems aren't queued through the main player: they have no title or + * artwork of their own, so the play bar would show an empty row, and a + * preview shouldn't replace whatever the listener had queued. Instead we + * resolve a signed stream url the same way the playback saga does and + * play it through a standalone `HTMLAudioElement`, pausing the main + * player first. Previews skip play counting. + */ +export const useStemPreview = () => { + const dispatch = useDispatch() + const isQueuePlaying = useSelector(getPlaying) + const { data: currentUserId } = useCurrentUserId() + + const audioRef = useRef(null) + // Bumped on every play/stop so a slow url lookup for an earlier click + // can't start playing after the user has moved on. + const requestIdRef = useRef(0) + const [playingTrackId, setPlayingTrackId] = useState(null) + const [loadingTrackId, setLoadingTrackId] = useState(null) + + const stopPreview = useCallback(() => { + requestIdRef.current += 1 + const audio = audioRef.current + if (audio) { + audio.pause() + audio.removeAttribute('src') + audio.load() + } + audioRef.current = null + setPlayingTrackId(null) + setLoadingTrackId(null) + }, []) + + const playPreview = useCallback( + async (trackId: ID) => { + stopPreview() + const requestId = requestIdRef.current + if (isQueuePlaying) dispatch(playbackActions.pause()) + setLoadingTrackId(trackId) + + try { + const sdk = await audiusSdk() + const { data, signature } = + await audiusBackendInstance.signGatedContentRequest({ sdk }) + const url = await sdk.tracks.getTrackStreamUrl({ + trackId: Id.parse(trackId), + userId: OptionalId.parse(currentUserId), + userSignature: signature, + userData: data, + skipPlayCount: true + }) + if (requestId !== requestIdRef.current) return + + const audio = new Audio(url) + audio.volume = audioPlayer?.audio.volume ?? 1 + audio.onended = () => { + if (audioRef.current === audio) stopPreview() + } + audioRef.current = audio + await audio.play() + if (requestId !== requestIdRef.current) return + setPlayingTrackId(trackId) + setLoadingTrackId(null) + } catch (e) { + console.error('Failed to preview stem', e) + if (requestId === requestIdRef.current) stopPreview() + } + }, + [currentUserId, dispatch, isQueuePlaying, stopPreview] + ) + + const togglePreview = useCallback( + (trackId: ID) => { + if (playingTrackId === trackId || loadingTrackId === trackId) { + stopPreview() + } else { + playPreview(trackId) + } + }, + [loadingTrackId, playPreview, playingTrackId, stopPreview] + ) + + // If the listener starts the main player, end the preview so the two + // never play over each other. + useEffect(() => { + if (isQueuePlaying && audioRef.current) stopPreview() + }, [isQueuePlaying, stopPreview]) + + useUnmount(stopPreview) + + return { playingTrackId, loadingTrackId, togglePreview } +}