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 } +}