Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ import {
Flex,
IconButton,
IconCaretDown,
IconPause,
IconPlay,
IconReceive,
Paper,
PlainButton,
Expand All @@ -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',
Expand All @@ -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 = {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}
/>
))}
</Box>
Expand Down Expand Up @@ -401,14 +417,19 @@ 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 = ({
index,
title,
subtitle,
size,
onDownload
onDownload,
onTogglePreview,
isPreviewing = false
}: ContestStemDownloadRowProps) => {
return (
<>
Expand Down Expand Up @@ -455,8 +476,8 @@ const ContestStemDownloadRow = ({
) : null}
</Flex>
</Flex>
{/* 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. */}
<Flex alignItems='center' gap='l' css={{ flexShrink: 0 }}>
{size ? (
<Text
Expand All @@ -468,6 +489,20 @@ const ContestStemDownloadRow = ({
{formatBytes(size)}
</Text>
) : null}
{onTogglePreview ? (
<IconButton
icon={isPreviewing ? IconPause : IconPlay}
size='s'
color='default'
aria-label={
isPreviewing
? messages.stopPreview(title)
: messages.previewStem(title)
}
aria-pressed={isPreviewing}
onClick={onTogglePreview}
/>
) : null}
<IconButton
icon={IconReceive}
size='s'
Expand Down
149 changes: 149 additions & 0 deletions packages/web/src/pages/contest-page/hooks/useStemPreview.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import { act, renderHook, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

import { useStemPreview } from './useStemPreview'

const mocks = vi.hoisted(() => ({
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)
})
})
109 changes: 109 additions & 0 deletions packages/web/src/pages/contest-page/hooks/useStemPreview.ts
Original file line number Diff line number Diff line change
@@ -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<HTMLAudioElement | null>(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<ID | null>(null)
const [loadingTrackId, setLoadingTrackId] = useState<ID | null>(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 }
}
Loading