Skip to content
Merged
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
1 change: 1 addition & 0 deletions packages/common/src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ export * from './tan-query/lineups/useProfileReposts'
export * from './tan-query/lineups/useProfileTracks'
export * from './tan-query/lineups/useTrending'
export * from './tan-query/lineups/useTrendingUnderground'
export * from './tan-query/lineups/useDiscoverWeekly'
export * from './tan-query/lineups/useTrendingWinners'
export * from './tan-query/lineups/useTrackPageLineup'

Expand Down
89 changes: 89 additions & 0 deletions packages/common/src/api/tan-query/lineups/useDiscoverWeekly.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { Id, OptionalId, EntityType } from '@audius/sdk'
import { useQuery, useQueryClient } from '@tanstack/react-query'

import { transformAndCleanList, userTrackMetadataFromSDK } from '~/adapters'
import { useQueryContext } from '~/api/tan-query/utils'
import { ID } from '~/models/Identifiers'

import { QUERY_KEYS } from '../queryKeys'
import { QueryKey, QueryOptions, LineupData } from '../types'
import { useCurrentUserId } from '../users/account/useCurrentUserId'
import { primeTrackData } from '../utils/primeTrackData'

const DEFAULT_LIMIT = 30

// The server recomputes at the ISO week boundary and caches for hours, so
// anything on this order is cheap. Bounded rather than Infinity so a transient
// failure or an empty response doesn't stick for the whole session.
const STALE_TIME_MS = 30 * 60 * 1000

export type UseDiscoverWeeklyArgs = {
limit?: number
}

export const getDiscoverWeeklyQueryKey = ({
userId,
limit = DEFAULT_LIMIT
}: UseDiscoverWeeklyArgs & { userId: ID | null | undefined }) =>
[QUERY_KEYS.discoverWeekly, userId, { limit }] as unknown as QueryKey<
LineupData[]
>

/**
* The current user's Discover Weekly mix: tracks they haven't heard, weighted
* toward artists they don't already follow.
*
* Deliberately a plain `useQuery` rather than an infinite one — the mix is a
* fixed-size artifact, not a lineup you scroll. There is no page 2.
*
* The server holds the mix constant for the ISO week, so the client cache can
* be long-lived -- but NOT infinite. With staleTime: Infinity and
* refetchOnMount: false, a single failed or empty first fetch was permanent for
* the session: nothing retried it, and the surfaces that hide themselves on an
* empty result stayed hidden until the app restarted. A bounded staleTime keeps
* the request count low while still letting a bad result heal.
*/
export const useDiscoverWeekly = (
{ limit = DEFAULT_LIMIT }: UseDiscoverWeeklyArgs = {},
options?: QueryOptions
) => {
const { audiusSdk } = useQueryContext()
const { data: currentUserId } = useCurrentUserId()
const queryClient = useQueryClient()

const query = useQuery({
queryKey: getDiscoverWeeklyQueryKey({ userId: currentUserId, limit }),
queryFn: async () => {
const sdk = await audiusSdk()
const { data = [] } = await sdk.users.getDiscoverWeekly({
id: Id.parse(currentUserId),
limit,
userId: OptionalId.parse(currentUserId)
})
const tracks = transformAndCleanList(data, userTrackMetadataFromSDK)
primeTrackData({ tracks, queryClient })
return tracks.map((t) => ({
id: t.track_id,
type: EntityType.TRACK
}))
},
staleTime: STALE_TIME_MS,
...options,
enabled: options?.enabled !== false && !!currentUserId
})

const data = query.data ?? []
const trackIds = data
.filter((d) => d.type === EntityType.TRACK)
.map((d) => d.id as ID)

return {
data,
trackIds,
isPending: query.isPending,
isLoading: query.isLoading,
isFetching: query.isFetching,
isSuccess: query.isSuccess,
isError: query.isError
}
}
1 change: 1 addition & 0 deletions packages/common/src/api/tan-query/queryKeys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ export const QUERY_KEYS = {
trending: 'trending',
suggestedArtists: 'suggestedArtists',
suggestedFollows: 'suggestedFollows',
discoverWeekly: 'discoverWeekly',
topArtistsInGenre: 'topArtistsInGenre',
audioTransactions: 'audioTransactions',
audioTransactionsCount: 'audioTransactionsCount',
Expand Down
8 changes: 8 additions & 0 deletions packages/common/src/messages/explore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,14 @@ export const exploreMessages = {
feelingLucky: 'Feeling Lucky?',
imFeelingLucky: "I'm Feeling Lucky",
recentlyPlayed: 'Recently Played',
discoverWeekly: 'Your Discover Weekly',
discoverWeeklySubtitle: 'Updated every Monday',
discoverWeeklyBadge: 'New',
discoverWeeklyPitch:
'A fresh mix of tracks picked just for you. Updated every Monday.',
discoverWeeklyCta: 'Listen Now',
discoverWeeklyTrackCount: (count: number) =>
`${count} ${count === 1 ? 'track' : 'tracks'}`,
undergroundTrending: 'Underground Trending',
verified: 'Verified'
}
44 changes: 44 additions & 0 deletions packages/common/src/models/Analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,12 @@ export enum Name {
EXPLORE_SECTION_VIEW = 'Explore: Section View',
EXPLORE_SECTION_CLICK = 'Explore: Section Click',

// Discover Weekly
DISCOVER_WEEKLY_BANNER_VIEW = 'Discover Weekly: Banner View',
DISCOVER_WEEKLY_BANNER_CLICK = 'Discover Weekly: Banner Click',
DISCOVER_WEEKLY_PAGE_VIEW = 'Discover Weekly: Page View',
DISCOVER_WEEKLY_PLAY_ALL = 'Discover Weekly: Play All',

// Errors
ERROR_PAGE = 'Error Page',
NOT_FOUND_PAGE = 'Not Found Page',
Expand Down Expand Up @@ -1312,6 +1318,7 @@ export type ExploreSectionName =
| 'Recommended Tracks'
| 'Artist Coin Tracks'
| 'Recently Played'
| 'Discover Weekly'
| 'Quick Search'
| 'Featured Playlists'
| 'Fan Clubs'
Expand Down Expand Up @@ -1347,6 +1354,39 @@ type ExploreSectionClick = {
link?: string
}

/**
* Surface the banner was rendered on. The mix is reachable from more than one
* place, so every Discover Weekly event carries this -- otherwise there's no
* way to tell which entry point is actually driving listens.
*/
export type DiscoverWeeklySurface = 'explore' | 'feed'

type DiscoverWeeklyBannerView = {
eventName: Name.DISCOVER_WEEKLY_BANNER_VIEW
surface: DiscoverWeeklySurface
source: 'web' | 'mobile'
trackCount: number
}

type DiscoverWeeklyBannerClick = {
eventName: Name.DISCOVER_WEEKLY_BANNER_CLICK
surface: DiscoverWeeklySurface
source: 'web' | 'mobile'
trackCount: number
}

type DiscoverWeeklyPageView = {
eventName: Name.DISCOVER_WEEKLY_PAGE_VIEW
source: 'web' | 'mobile'
trackCount: number
}

type DiscoverWeeklyPlayAll = {
eventName: Name.DISCOVER_WEEKLY_PLAY_ALL
source: 'web' | 'mobile'
trackCount: number
}

type BrowserNotificationSetting = {
eventName: Name.BROWSER_NOTIFICATION_SETTINGS
provider: 'safari' | 'gcm'
Expand Down Expand Up @@ -2722,6 +2762,10 @@ export type AllTrackingEvents =
| SearchResultSelect
| ExploreSectionView
| ExploreSectionClick
| DiscoverWeeklyBannerView
| DiscoverWeeklyBannerClick
| DiscoverWeeklyPageView
| DiscoverWeeklyPlayAll
| ErrorPage
| NotFoundPage
| PageView
Expand Down
3 changes: 3 additions & 0 deletions packages/common/src/utils/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export const TRENDING_PLAYLISTS_PAGE_LEGACY = '/trending/playlists'
export const EXPLORE_PAGE = '/explore'
export const TRENDING_PLAYLISTS_PAGE = '/explore/playlists'
export const TRENDING_UNDERGROUND_PAGE = '/explore/underground'
export const DISCOVER_WEEKLY_PAGE = '/explore/discover-weekly'
export const CONTESTS_PAGE = '/contests'

// DEPRECATED - use /library instead.
Expand Down Expand Up @@ -301,6 +302,7 @@ export const orderedRoutes = [
TRENDING_GENRES,
TRENDING_PAGE,
EXPLORE_PAGE,
DISCOVER_WEEKLY_PAGE,
CONTESTS_PAGE,
EMPTY_PAGE,
SEARCH_PAGE,
Expand Down Expand Up @@ -355,6 +357,7 @@ export const staticRoutes = new Set([
FEED_PAGE,
TRENDING_PAGE,
EXPLORE_PAGE,
DISCOVER_WEEKLY_PAGE,
CONTESTS_PAGE,
HOST_REMIX_CONTEST_ROOT_PAGE,
TRENDING_PLAYLISTS_PAGE,
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 6 additions & 0 deletions packages/mobile/src/screens/app-screen/AppTabScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import {
} from 'app/screens/coin-details-screen'
import { CoinRedeemScreen } from 'app/screens/coin-redeem-screen'
import { CollectionScreen } from 'app/screens/collection-screen/CollectionScreen'
import { DiscoverWeeklyScreen } from 'app/screens/discover-weekly-screen'
import { EditProfileScreen } from 'app/screens/edit-profile-screen'
import { ProfileScreen } from 'app/screens/profile-screen'
import { RewardsScreen } from 'app/screens/rewards-screen'
Expand Down Expand Up @@ -110,6 +111,7 @@ export type AppTabScreenParamList = {
SettingsScreen: undefined
AboutScreen: undefined
ListeningHistoryScreen: undefined
DiscoverWeeklyScreen: undefined
AccountSettingsScreen: undefined
ChangeEmail: undefined
ChangePassword: undefined
Expand Down Expand Up @@ -317,6 +319,10 @@ export const AppTabScreen = ({ baseScreen, Stack }: AppTabScreenProps) => {
name='FanClubsExplore'
component={FanClubsExploreScreen}
/>
<Stack.Screen
name='DiscoverWeeklyScreen'
component={DiscoverWeeklyScreen}
/>
<Stack.Screen name='FanClubSort' component={FanClubSortScreen} />

<Stack.Group>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import React, { useCallback, useEffect, useMemo, useRef } from 'react'

import { useDiscoverWeekly } from '@audius/common/api'
import { useAnalytics } from '@audius/common/hooks'
import { exploreMessages } from '@audius/common/messages'
import type { ID } from '@audius/common/models'
import { Name } from '@audius/common/models'
import { playbackActions, playbackSelectors } from '@audius/common/store'
import type { PlaybackTrack } from '@audius/common/store'
import { Image } from 'react-native'
import { useDispatch, useSelector } from 'react-redux'

import {
Button,
Flex,
IconPause,
IconPlay,
Paper,
Text
} from '@audius/harmony-native'
import discoverWeeklyArt from 'app/assets/images/discoverWeekly.jpg'
import { Screen, ScreenContent } from 'app/components/core'
import { TrackLineup } from 'app/components/lineup/TrackLineup'

const messages = {
title: 'Discover Weekly'
}

const ART_SIZE = 120
const DISCOVER_WEEKLY_SOURCE = 'DISCOVER_WEEKLY_TRACKS'

/**
* The full Discover Weekly mix. Mirrors the web page: artwork header, then the
* track list.
*
* The endpoint returns a fixed 30, so there is no pagination -- hasNextPage is
* false and loadNextPage is a no-op.
*/
export const DiscoverWeeklyScreen = () => {
const { trackIds, isPending, isFetching } = useDiscoverWeekly({ limit: 30 })
const { trackEvent } = useAnalytics()
const dispatch = useDispatch()

const isPlaying = useSelector(playbackSelectors.getPlaying)
const currentPlaybackTrackId = useSelector(
playbackSelectors.getCurrentTrackId
)

const playbackQueue: PlaybackTrack[] = useMemo(
() =>
trackIds.map((id) => ({
trackId: id,
source: DISCOVER_WEEKLY_SOURCE
})),
[trackIds]
)

// Mirrors the web page's play-all: toggle when we're already on the first
// track, otherwise start the queue from the top.
const handlePlay = useCallback(() => {
if (playbackQueue.length === 0) return
const firstId = playbackQueue[0].trackId as ID

if (currentPlaybackTrackId === firstId) {
dispatch(
isPlaying ? playbackActions.togglePlay() : playbackActions.play()
)
return
}

trackEvent({
eventName: Name.DISCOVER_WEEKLY_PLAY_ALL,
source: 'mobile',
trackCount: playbackQueue.length
})
dispatch(
playbackActions.playFrom({
tracks: playbackQueue,
startIndex: 0,
querySource: null
})
)
}, [dispatch, isPlaying, currentPlaybackTrackId, playbackQueue, trackEvent])

// Fired once the mix resolves, so trackCount is real and a failed load
// doesn't register as a page view.
const hasTrackedView = useRef(false)
useEffect(() => {
if (hasTrackedView.current || !trackIds.length) return
hasTrackedView.current = true
trackEvent({
eventName: Name.DISCOVER_WEEKLY_PAGE_VIEW,
source: 'mobile',
trackCount: trackIds.length
})
}, [trackIds.length, trackEvent])

return (
<Screen title={messages.title} topbarRight={null} variant='secondary'>
<ScreenContent>
<Paper m='l' gap='l' h='100%'>
<Flex row gap='l' alignItems='center' p='l'>
<Image
source={discoverWeeklyArt}
style={{ width: ART_SIZE, height: ART_SIZE, borderRadius: 8 }}
/>
<Flex column gap='xs' style={{ flex: 1 }}>
<Text variant='title' size='l'>
{exploreMessages.discoverWeekly}
</Text>
<Text variant='body' size='s' color='subdued'>
{exploreMessages.discoverWeeklySubtitle}
</Text>
{trackIds.length ? (
<Text variant='body' size='s' color='subdued'>
{exploreMessages.discoverWeeklyTrackCount(trackIds.length)}
</Text>
) : null}
<Button
variant='primary'
size='small'
iconLeft={isPlaying ? IconPause : IconPlay}
onPress={handlePlay}
disabled={!trackIds.length}
>
{isPlaying ? 'Pause' : 'Play'}
</Button>
</Flex>
</Flex>
<TrackLineup
trackIds={trackIds}
source='DISCOVER_WEEKLY_TRACKS'
isPending={isPending}
isFetching={isFetching}
hasNextPage={false}
loadNextPage={() => {}}
pageSize={30}
/>
</Paper>
</ScreenContent>
</Screen>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './DiscoverWeeklyScreen'
Loading
Loading