diff --git a/packages/common/src/api/index.ts b/packages/common/src/api/index.ts
index 431c75327b8..ce42e24032f 100644
--- a/packages/common/src/api/index.ts
+++ b/packages/common/src/api/index.ts
@@ -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'
diff --git a/packages/common/src/api/tan-query/lineups/useDiscoverWeekly.ts b/packages/common/src/api/tan-query/lineups/useDiscoverWeekly.ts
new file mode 100644
index 00000000000..e5bdcbe96ba
--- /dev/null
+++ b/packages/common/src/api/tan-query/lineups/useDiscoverWeekly.ts
@@ -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
+ }
+}
diff --git a/packages/common/src/api/tan-query/queryKeys.ts b/packages/common/src/api/tan-query/queryKeys.ts
index 4e3715d502c..344931caee7 100644
--- a/packages/common/src/api/tan-query/queryKeys.ts
+++ b/packages/common/src/api/tan-query/queryKeys.ts
@@ -53,6 +53,7 @@ export const QUERY_KEYS = {
trending: 'trending',
suggestedArtists: 'suggestedArtists',
suggestedFollows: 'suggestedFollows',
+ discoverWeekly: 'discoverWeekly',
topArtistsInGenre: 'topArtistsInGenre',
audioTransactions: 'audioTransactions',
audioTransactionsCount: 'audioTransactionsCount',
diff --git a/packages/common/src/messages/explore.ts b/packages/common/src/messages/explore.ts
index 3e0158613e7..d0b27be8f22 100644
--- a/packages/common/src/messages/explore.ts
+++ b/packages/common/src/messages/explore.ts
@@ -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'
}
diff --git a/packages/common/src/models/Analytics.ts b/packages/common/src/models/Analytics.ts
index bcb30c408c7..b35301ffbf9 100644
--- a/packages/common/src/models/Analytics.ts
+++ b/packages/common/src/models/Analytics.ts
@@ -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',
@@ -1312,6 +1318,7 @@ export type ExploreSectionName =
| 'Recommended Tracks'
| 'Artist Coin Tracks'
| 'Recently Played'
+ | 'Discover Weekly'
| 'Quick Search'
| 'Featured Playlists'
| 'Fan Clubs'
@@ -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'
@@ -2722,6 +2762,10 @@ export type AllTrackingEvents =
| SearchResultSelect
| ExploreSectionView
| ExploreSectionClick
+ | DiscoverWeeklyBannerView
+ | DiscoverWeeklyBannerClick
+ | DiscoverWeeklyPageView
+ | DiscoverWeeklyPlayAll
| ErrorPage
| NotFoundPage
| PageView
diff --git a/packages/common/src/utils/route.ts b/packages/common/src/utils/route.ts
index 994f210f995..16f680e80af 100644
--- a/packages/common/src/utils/route.ts
+++ b/packages/common/src/utils/route.ts
@@ -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.
@@ -301,6 +302,7 @@ export const orderedRoutes = [
TRENDING_GENRES,
TRENDING_PAGE,
EXPLORE_PAGE,
+ DISCOVER_WEEKLY_PAGE,
CONTESTS_PAGE,
EMPTY_PAGE,
SEARCH_PAGE,
@@ -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,
diff --git a/packages/mobile/src/assets/images/discoverWeekly.jpg b/packages/mobile/src/assets/images/discoverWeekly.jpg
new file mode 100644
index 00000000000..34bb5380ffe
Binary files /dev/null and b/packages/mobile/src/assets/images/discoverWeekly.jpg differ
diff --git a/packages/mobile/src/screens/app-screen/AppTabScreen.tsx b/packages/mobile/src/screens/app-screen/AppTabScreen.tsx
index 18d590627cf..39a99b49a2c 100644
--- a/packages/mobile/src/screens/app-screen/AppTabScreen.tsx
+++ b/packages/mobile/src/screens/app-screen/AppTabScreen.tsx
@@ -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'
@@ -110,6 +111,7 @@ export type AppTabScreenParamList = {
SettingsScreen: undefined
AboutScreen: undefined
ListeningHistoryScreen: undefined
+ DiscoverWeeklyScreen: undefined
AccountSettingsScreen: undefined
ChangeEmail: undefined
ChangePassword: undefined
@@ -317,6 +319,10 @@ export const AppTabScreen = ({ baseScreen, Stack }: AppTabScreenProps) => {
name='FanClubsExplore'
component={FanClubsExploreScreen}
/>
+
diff --git a/packages/mobile/src/screens/discover-weekly-screen/DiscoverWeeklyScreen.tsx b/packages/mobile/src/screens/discover-weekly-screen/DiscoverWeeklyScreen.tsx
new file mode 100644
index 00000000000..8089b005d85
--- /dev/null
+++ b/packages/mobile/src/screens/discover-weekly-screen/DiscoverWeeklyScreen.tsx
@@ -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 (
+
+
+
+
+
+
+
+ {exploreMessages.discoverWeekly}
+
+
+ {exploreMessages.discoverWeeklySubtitle}
+
+ {trackIds.length ? (
+
+ {exploreMessages.discoverWeeklyTrackCount(trackIds.length)}
+
+ ) : null}
+
+
+
+ {}}
+ pageSize={30}
+ />
+
+
+
+ )
+}
diff --git a/packages/mobile/src/screens/discover-weekly-screen/index.ts b/packages/mobile/src/screens/discover-weekly-screen/index.ts
new file mode 100644
index 00000000000..4d4cd538596
--- /dev/null
+++ b/packages/mobile/src/screens/discover-weekly-screen/index.ts
@@ -0,0 +1 @@
+export * from './DiscoverWeeklyScreen'
diff --git a/packages/mobile/src/screens/explore-screen/components/DiscoverWeekly.tsx b/packages/mobile/src/screens/explore-screen/components/DiscoverWeekly.tsx
new file mode 100644
index 00000000000..b01bb2b2868
--- /dev/null
+++ b/packages/mobile/src/screens/explore-screen/components/DiscoverWeekly.tsx
@@ -0,0 +1,89 @@
+import React, { useCallback, useEffect, useRef } from 'react'
+
+import { useDiscoverWeekly } from '@audius/common/api'
+import { useAnalytics } from '@audius/common/hooks'
+import { exploreMessages as messages } from '@audius/common/messages'
+import { Name, type DiscoverWeeklySurface } from '@audius/common/models'
+import { Image } from 'react-native'
+
+import { Flex, Paper, Text } from '@audius/harmony-native'
+import discoverWeeklyArt from 'app/assets/images/discoverWeekly.jpg'
+import { useNavigation } from 'app/hooks/useNavigation'
+
+import { useExploreSectionTracking } from '../hooks/useExploreSectionTracking'
+
+const ART_SIZE = 96
+
+/**
+ * Promotional banner for Discover Weekly, pinned to the top of Explore.
+ *
+ * Mirrors the web banner: an entry point rather than a content row, so it
+ * navigates to the full mix instead of playing in place. Deliberately not
+ * wrapped in ExploreSection -- it sits above the section stack.
+ */
+type DiscoverWeeklyProps = {
+ /** Which surface this instance renders on -- carried on every event so we
+ * can tell which entry point actually drives listens. */
+ surface?: DiscoverWeeklySurface
+}
+
+export const DiscoverWeekly = ({
+ surface = 'explore'
+}: DiscoverWeeklyProps) => {
+ const { InViewWrapper, inView } = useExploreSectionTracking('Discover Weekly')
+ const navigation = useNavigation()
+ const { trackEvent } = useAnalytics()
+ const { trackIds, isError, isSuccess } = useDiscoverWeekly(
+ { limit: 30 },
+ { enabled: inView }
+ )
+
+ // Fire the impression once, and only once there's a real mix behind it.
+ const hasTrackedView = useRef(false)
+ useEffect(() => {
+ if (hasTrackedView.current || !inView || !trackIds.length) return
+ hasTrackedView.current = true
+ trackEvent({
+ eventName: Name.DISCOVER_WEEKLY_BANNER_VIEW,
+ surface,
+ source: 'mobile',
+ trackCount: trackIds.length
+ })
+ }, [inView, trackIds.length, surface, trackEvent])
+
+ const handlePress = useCallback(() => {
+ trackEvent({
+ eventName: Name.DISCOVER_WEEKLY_BANNER_CLICK,
+ surface,
+ source: 'mobile',
+ trackCount: trackIds.length
+ })
+ navigation.navigate('DiscoverWeeklyScreen')
+ }, [navigation, trackEvent, surface, trackIds.length])
+
+ if (isError || (isSuccess && trackIds.length === 0)) {
+ return null
+ }
+
+ return (
+
+
+
+
+
+ {messages.discoverWeeklyBadge}
+
+
+ {messages.discoverWeekly}
+
+
+ {messages.discoverWeeklyPitch}
+
+
+
+
+ )
+}
diff --git a/packages/mobile/src/screens/explore-screen/components/ExploreContent.tsx b/packages/mobile/src/screens/explore-screen/components/ExploreContent.tsx
index 8f5e78cea76..ba5182e75dd 100644
--- a/packages/mobile/src/screens/explore-screen/components/ExploreContent.tsx
+++ b/packages/mobile/src/screens/explore-screen/components/ExploreContent.tsx
@@ -8,6 +8,7 @@ import { useSearchCategory } from 'app/screens/search-screen/searchState'
import { ArtistSpotlight } from './ArtistSpotlight'
import { BestSellingAlbums } from './BestSellingAlbums'
+import { DiscoverWeekly } from './DiscoverWeekly'
import { FeaturedPlaylists } from './FeaturedPlaylists'
import { FeaturedRemixContests } from './FeaturedRemixContests'
import { FeelingLucky } from './FeelingLucky'
@@ -30,6 +31,9 @@ export const ExploreContent = () => {
return (
+ {showTrackContent && showUserContextualContent && (
+
+ )}
{showPlaylistContent && }
{showTrackContent && }
{showAlbumContent && }
diff --git a/packages/mobile/src/screens/feed-screen/FeedScreen.tsx b/packages/mobile/src/screens/feed-screen/FeedScreen.tsx
index f7313230339..fdcbe496a70 100644
--- a/packages/mobile/src/screens/feed-screen/FeedScreen.tsx
+++ b/packages/mobile/src/screens/feed-screen/FeedScreen.tsx
@@ -35,6 +35,7 @@ import {
useResetGlassScroll
} from 'app/screens/app-screen/GlassChromeContext'
import { MobileRootHeader } from 'app/screens/app-screen/MobileRootHeader'
+import { DiscoverWeekly } from 'app/screens/explore-screen/components/DiscoverWeekly'
import { make, track } from 'app/services/analytics'
import { FeedFilterButton } from './FeedFilterButton'
@@ -271,6 +272,7 @@ export const FeedScreen = () => {
source='DISCOVER_FEED'
pullToRefresh={false}
hideHeaderOnEmpty
+ header={}
LineupEmptyComponent={}
ListFooterComponent={
@@ -285,6 +287,7 @@ export const FeedScreen = () => {
source='DISCOVER_FEED'
pullToRefresh
hideHeaderOnEmpty
+ header={}
LineupEmptyComponent={}
ListFooterComponent={
diff --git a/packages/sdk/src/sdk/api/users/UsersApi.ts b/packages/sdk/src/sdk/api/users/UsersApi.ts
index dc04a39c904..33b34685545 100644
--- a/packages/sdk/src/sdk/api/users/UsersApi.ts
+++ b/packages/sdk/src/sdk/api/users/UsersApi.ts
@@ -23,7 +23,9 @@ import {
DownloadUSDCWithdrawalsAsCSVRequest,
UsersApi as GeneratedUsersApi,
RelatedArtistResponseFromJSON,
+ TracksResponseFromJSON,
type RelatedArtistResponse,
+ type TracksResponse,
type UserPlaylistLibrary
} from '../generated/default'
import * as runtime from '../generated/default/runtime'
@@ -58,7 +60,8 @@ import {
type UserFileUploadParams,
type EntityManagerPlaylistLibraryContents,
type UsersApiServicesConfig,
- type GetSuggestedFollowsRequest
+ type GetSuggestedFollowsRequest,
+ type GetDiscoverWeeklyRequest
} from './types'
export class UsersApi extends GeneratedUsersApi {
@@ -933,4 +936,68 @@ export class UsersApi extends GeneratedUsersApi {
RelatedArtistResponseFromJSON(jsonValue)
).value()
}
+
+ /**
+ * Gets the user's Discover Weekly mix: tracks they have not heard,
+ * weighted toward artists they do not already follow. The mix is fixed for
+ * the calendar week (ISO week, UTC) and rotates when the week rolls over.
+ *
+ * Unlike `getSuggestedFollows`, this returns results for a listener with no
+ * history, so callers do not need a non-personalized fallback.
+ *
+ * Hand-written for the same reason as `getSuggestedFollows`: this endpoint
+ * is newer than the checked-in generated client. It mirrors what the
+ * generator would emit, so replacing it later is a no-op for callers.
+ */
+ async getDiscoverWeekly(
+ params: GetDiscoverWeeklyRequest,
+ initOverrides?: RequestInit | runtime.InitOverrideFunction
+ ): Promise {
+ if (params.id === null || params.id === undefined) {
+ throw new runtime.RequiredError(
+ 'id',
+ 'Required parameter params.id was null or undefined when calling getDiscoverWeekly.'
+ )
+ }
+
+ const queryParameters: any = {}
+
+ if (params.limit !== undefined) {
+ queryParameters.limit = params.limit
+ }
+
+ if (params.userId !== undefined) {
+ queryParameters.user_id = params.userId
+ }
+
+ const headerParameters: runtime.HTTPHeaders = {}
+
+ if (
+ !headerParameters.Authorization &&
+ this.configuration &&
+ this.configuration.accessToken
+ ) {
+ const token = await this.configuration.accessToken('OAuth2', ['read'])
+ if (token) {
+ headerParameters.Authorization = token
+ }
+ }
+
+ const response = await this.request(
+ {
+ path: `/users/{id}/discover-weekly`.replace(
+ `{${'id'}}`,
+ encodeURIComponent(String(params.id))
+ ),
+ method: 'GET',
+ headers: headerParameters,
+ query: queryParameters
+ },
+ initOverrides
+ )
+
+ return await new runtime.JSONApiResponse(response, (jsonValue) =>
+ TracksResponseFromJSON(jsonValue)
+ ).value()
+ }
}
diff --git a/packages/sdk/src/sdk/api/users/types.ts b/packages/sdk/src/sdk/api/users/types.ts
index 457694c7414..85910821ec5 100644
--- a/packages/sdk/src/sdk/api/users/types.ts
+++ b/packages/sdk/src/sdk/api/users/types.ts
@@ -339,3 +339,20 @@ export type GetSuggestedFollowsRequest = {
/** The user ID of the user making the request */
userId?: string
}
+
+/**
+ * Params for `UsersApi.getDiscoverWeekly`.
+ *
+ * Hand-written for the same reason as `GetSuggestedFollowsRequest`: the
+ * endpoint post-dates the last SDK regeneration. Delete this and use the
+ * generated request type once `npm run gen` has been re-run against a node
+ * serving /users/{id}/discover-weekly.
+ */
+export type GetDiscoverWeeklyRequest = {
+ /** A User ID */
+ id: string
+ /** The number of tracks to fetch (default 30, max 50) */
+ limit?: number
+ /** The user ID of the user making the request */
+ userId?: string
+}
diff --git a/packages/web/src/app/web-player/WebPlayer.tsx b/packages/web/src/app/web-player/WebPlayer.tsx
index e4504fb07ee..4e688a65c6d 100644
--- a/packages/web/src/app/web-player/WebPlayer.tsx
+++ b/packages/web/src/app/web-player/WebPlayer.tsx
@@ -239,6 +239,7 @@ const {
NOTIFICATION_PAGE,
NOTIFICATION_USERS_PAGE,
EXPLORE_PAGE,
+ DISCOVER_WEEKLY_PAGE,
CONTESTS_PAGE,
SAVED_PAGE,
LIBRARY_PAGE,
@@ -329,6 +330,9 @@ const {
// TODO: do we need to lazy load edit?
const EditTrackPage = lazy(() => import('pages/edit-page'))
+const DiscoverWeeklyPage = lazy(
+ () => import('pages/discover-weekly-page/DiscoverWeeklyPage')
+)
const UploadPage = lazy(() => import('pages/upload-page'))
const CheckPage = lazy(() => import('pages/check-page/CheckPage'))
const Modals = lazy(() => import('pages/modals/Modals'))
@@ -924,6 +928,10 @@ const WebPlayer = (props: WebPlayerProps) => {
element={}
/>
} />
+ }
+ />
{!isProduction ? (
} />
) : null}
@@ -1402,6 +1410,10 @@ const WebPlayer = (props: WebPlayerProps) => {
element={}
/>
} />
+ }
+ />
{!isProduction ? (
} />
) : null}
diff --git a/packages/web/src/assets/img/discoverWeekly.jpg b/packages/web/src/assets/img/discoverWeekly.jpg
new file mode 100644
index 00000000000..34bb5380ffe
Binary files /dev/null and b/packages/web/src/assets/img/discoverWeekly.jpg differ
diff --git a/packages/web/src/components/discover-weekly/DiscoverWeeklyBanner.tsx b/packages/web/src/components/discover-weekly/DiscoverWeeklyBanner.tsx
new file mode 100644
index 00000000000..1e6c4ede2ad
--- /dev/null
+++ b/packages/web/src/components/discover-weekly/DiscoverWeeklyBanner.tsx
@@ -0,0 +1,139 @@
+import { useCallback, useEffect, useRef } from 'react'
+
+import { useDiscoverWeekly } from '@audius/common/api'
+import { useAnalytics } from '@audius/common/hooks'
+import { exploreMessages as messages } from '@audius/common/messages'
+import { Name, type DiscoverWeeklySurface } from '@audius/common/models'
+import { route } from '@audius/common/utils'
+import {
+ Artwork,
+ Button,
+ Flex,
+ IconArrowRight,
+ Paper,
+ Text
+} from '@audius/harmony'
+import { useInView } from 'react-intersection-observer'
+import { useNavigate } from 'react-router'
+
+import discoverWeeklyArt from 'assets/img/discoverWeekly.jpg'
+import { useIsMobile } from 'hooks/useIsMobile'
+
+const { DISCOVER_WEEKLY_PAGE } = route
+
+const ART_SIZE_DESKTOP = 140
+const ART_SIZE_MOBILE = 96
+
+type DiscoverWeeklyBannerProps = {
+ /** Which surface this instance is rendered on -- carried on every event so
+ * we can tell which entry point actually drives listens. */
+ surface: DiscoverWeeklySurface
+}
+
+/**
+ * Promotional banner for Discover Weekly.
+ *
+ * Shared across Explore and the feed rather than duplicated, so the two stay
+ * visually identical and the analytics differ only by `surface`.
+ *
+ * Navigates to the full mix rather than playing in place -- the banner is an
+ * entry point, and the page it opens has the play-all.
+ */
+export const DiscoverWeeklyBanner = ({
+ surface
+}: DiscoverWeeklyBannerProps) => {
+ const navigate = useNavigate()
+ const isMobile = useIsMobile()
+ const { trackEvent } = useAnalytics()
+
+ const { ref, inView } = useInView({
+ threshold: 0,
+ rootMargin: '200px',
+ triggerOnce: true,
+ fallbackInView: true
+ })
+
+ const { trackIds, isError, isSuccess } = useDiscoverWeekly(
+ { limit: 30 },
+ { enabled: inView }
+ )
+
+ // Fire the impression once, and only once there's a real mix behind it --
+ // an impression for a banner that then hides itself would inflate the
+ // denominator on click-through.
+ const hasTrackedView = useRef(false)
+ useEffect(() => {
+ if (hasTrackedView.current || !inView || !trackIds.length) return
+ hasTrackedView.current = true
+ trackEvent({
+ eventName: Name.DISCOVER_WEEKLY_BANNER_VIEW,
+ surface,
+ source: isMobile ? 'mobile' : 'web',
+ trackCount: trackIds.length
+ })
+ }, [inView, trackIds.length, surface, isMobile, trackEvent])
+
+ const handleClick = useCallback(() => {
+ trackEvent({
+ eventName: Name.DISCOVER_WEEKLY_BANNER_CLICK,
+ surface,
+ source: isMobile ? 'mobile' : 'web',
+ trackCount: trackIds.length
+ })
+ navigate(DISCOVER_WEEKLY_PAGE)
+ }, [navigate, trackEvent, surface, isMobile, trackIds.length])
+
+ // Hidden entirely when there's no mix to promote -- a banner advertising an
+ // empty page is worse than no banner.
+ if (isError || (isSuccess && trackIds.length === 0)) {
+ return null
+ }
+
+ const artSize = isMobile ? ART_SIZE_MOBILE : ART_SIZE_DESKTOP
+
+ return (
+
+
+
+
+
+ {messages.discoverWeeklyBadge}
+
+
+ {messages.discoverWeekly}
+
+
+ {messages.discoverWeeklyPitch}
+
+
+ {isMobile ? null : (
+
+ )}
+
+
+ )
+}
diff --git a/packages/web/src/components/discover-weekly/index.ts b/packages/web/src/components/discover-weekly/index.ts
new file mode 100644
index 00000000000..42ec029842f
--- /dev/null
+++ b/packages/web/src/components/discover-weekly/index.ts
@@ -0,0 +1 @@
+export * from './DiscoverWeeklyBanner'
diff --git a/packages/web/src/components/table/responsiveCoverage.test.ts b/packages/web/src/components/table/responsiveCoverage.test.ts
index 46cfa8c237c..83c90a314a8 100644
--- a/packages/web/src/components/table/responsiveCoverage.test.ts
+++ b/packages/web/src/components/table/responsiveCoverage.test.ts
@@ -14,6 +14,7 @@ const responsiveConsumerFiles = [
'src/pages/collection-page/components/desktop/CollectionPage.tsx',
'src/pages/dashboard-page/components/ArtistDashboardTracksTab.tsx',
'src/pages/history-page/components/desktop/HistoryPage.tsx',
+ 'src/pages/discover-weekly-page/DiscoverWeeklyPage.tsx',
'src/pages/dashboard-page/components/ArtistDashboardAlbumsTab.tsx',
'src/pages/fan-clubs-launchpad-page/components/FanClubsTable.tsx',
'src/components/audio-transactions-table/AudioTransactionsTable.tsx',
@@ -77,6 +78,7 @@ describe('responsive table coverage', () => {
'collectionPlaylistTracks',
'dashboardAlbums',
'dashboardTracks',
+ 'discoverWeeklyTracks',
'historyTracks',
'libraryTracks',
'purchases',
diff --git a/packages/web/src/components/table/responsivePolicies.ts b/packages/web/src/components/table/responsivePolicies.ts
index 49f430ae5c9..7f83d31d8c0 100644
--- a/packages/web/src/components/table/responsivePolicies.ts
+++ b/packages/web/src/components/table/responsivePolicies.ts
@@ -37,6 +37,10 @@ export const RESPONSIVE_TABLE_POLICIES = {
['dateReleased', 'dateListened', 'time', 'reposts', 'plays'],
['trackName', 'trackActions']
),
+ discoverWeeklyTracks: makeHideOrderPolicy(
+ ['dateReleased', 'reposts', 'plays', 'time'],
+ ['trackName', 'trackActions']
+ ),
dashboardAlbums: makeHideOrderPolicy(
['spacer', 'reposts', 'saves', 'dateReleased'],
['name', 'overflowMenu']
diff --git a/packages/web/src/pages/discover-weekly-page/DiscoverWeeklyPage.tsx b/packages/web/src/pages/discover-weekly-page/DiscoverWeeklyPage.tsx
new file mode 100644
index 00000000000..023fa63299f
--- /dev/null
+++ b/packages/web/src/pages/discover-weekly-page/DiscoverWeeklyPage.tsx
@@ -0,0 +1,205 @@
+import { useCallback, useEffect, useMemo, useRef } from 'react'
+
+import { useCurrentUserId, useDiscoverWeekly } from '@audius/common/api'
+import { useAnalytics } from '@audius/common/hooks'
+import { exploreMessages } from '@audius/common/messages'
+import { ID, Name, PlaybackSource } from '@audius/common/models'
+import { playbackActions, playbackSelectors } from '@audius/common/store'
+import type { PlaybackTrack } from '@audius/common/store'
+import {
+ Artwork,
+ Button,
+ Flex,
+ IconPause,
+ IconPlay,
+ Text
+} from '@audius/harmony'
+import { useDispatch, useSelector } from 'react-redux'
+
+import discoverWeeklyArt from 'assets/img/discoverWeekly.jpg'
+import { make } from 'common/store/analytics/actions'
+import Page from 'components/page/Page'
+import { RESPONSIVE_TABLE_POLICIES } from 'components/table/responsivePolicies'
+import { TrackTableLineup, TracksTableColumn } from 'components/tracks-table'
+import { useIsMobile } from 'hooks/useIsMobile'
+import { useMainContentRef } from 'pages/MainContentContext'
+
+const messages = {
+ title: 'Discover Weekly',
+ description:
+ 'A fresh mix of tracks picked for you, updated every Monday on Audius.'
+}
+
+const DISCOVER_WEEKLY_SOURCE = 'DISCOVER_WEEKLY_TRACKS'
+const PAGE_SIZE = 30
+const ARTWORK_SIZE = 200
+
+const columns: TracksTableColumn[] = [
+ 'trackName',
+ 'releaseDate',
+ 'length',
+ 'plays',
+ 'reposts',
+ 'overflowActions'
+]
+
+/**
+ * The full Discover Weekly mix.
+ *
+ * Structured like a collection page -- artwork, title, play-all, track list --
+ * but it isn't backed by a collection entity, so it's assembled from the same
+ * pieces the History page uses rather than reusing the collection page.
+ * Artwork is the bundled asset for the same reason: there's no playlist_id to
+ * hang cover art on.
+ *
+ * The endpoint returns a fixed 30, so there is no pagination.
+ */
+export const DiscoverWeeklyPage = () => {
+ const dispatch = useDispatch()
+ const isMobile = useIsMobile()
+ const { trackEvent } = useAnalytics()
+ const mainContentRef = useMainContentRef()
+ const { data: currentUserId } = useCurrentUserId()
+
+ const { trackIds, isPending, isFetching, isLoading } = useDiscoverWeekly({
+ limit: PAGE_SIZE
+ })
+
+ // Fired once the mix resolves rather than on mount, 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: isMobile ? 'mobile' : 'web',
+ trackCount: trackIds.length
+ })
+ }, [trackIds.length, isMobile, trackEvent])
+
+ 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 History 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()
+ )
+ dispatch(
+ make(isPlaying ? Name.PLAYBACK_PAUSE : Name.PLAYBACK_PLAY, {
+ id: `${firstId}`,
+ source: PlaybackSource.PLAYLIST_PAGE
+ })
+ )
+ return
+ }
+
+ trackEvent({
+ eventName: Name.DISCOVER_WEEKLY_PLAY_ALL,
+ source: isMobile ? 'mobile' : 'web',
+ trackCount: playbackQueue.length
+ })
+ dispatch(
+ playbackActions.playFrom({
+ tracks: playbackQueue,
+ startIndex: 0,
+ querySource: null
+ })
+ )
+ dispatch(
+ make(Name.PLAYBACK_PLAY, {
+ id: `${firstId}`,
+ source: PlaybackSource.PLAYLIST_PAGE
+ })
+ )
+ }, [
+ dispatch,
+ isPlaying,
+ currentPlaybackTrackId,
+ playbackQueue,
+ trackEvent,
+ isMobile
+ ])
+
+ const isEmpty = !isLoading && trackIds.length === 0
+
+ return (
+
+
+
+
+
+ {exploreMessages.discoverWeekly}
+
+
+ {exploreMessages.discoverWeeklySubtitle}
+ {trackIds.length
+ ? ` · ${exploreMessages.discoverWeeklyTrackCount(trackIds.length)}`
+ : ''}
+
+
+
+
+
+ {}}
+ pageSize={PAGE_SIZE}
+ columns={columns}
+ userId={currentUserId}
+ showArtistInTrackNameColumn
+ responsiveColumns={RESPONSIVE_TABLE_POLICIES.discoverWeeklyTracks}
+ scrollRef={mainContentRef}
+ />
+
+ )
+}
+
+export default DiscoverWeeklyPage
diff --git a/packages/web/src/pages/feed-page/components/desktop/FeedPageContent.tsx b/packages/web/src/pages/feed-page/components/desktop/FeedPageContent.tsx
index 4fd92aebc3a..dcf79782ca6 100644
--- a/packages/web/src/pages/feed-page/components/desktop/FeedPageContent.tsx
+++ b/packages/web/src/pages/feed-page/components/desktop/FeedPageContent.tsx
@@ -15,6 +15,7 @@ import { Flex, IconFeed } from '@audius/harmony'
import { make, useRecord } from 'common/store/analytics/actions'
import { MIN_DESKTOP_CONTENT_WIDTH_PX } from 'common/utils/layout'
+import { DiscoverWeeklyBanner } from 'components/discover-weekly'
import { Header } from 'components/header/desktop/Header'
import EndOfLineup from 'components/lineup/EndOfLineup'
import { TrackLineup } from 'components/lineup/TrackLineup'
@@ -154,7 +155,16 @@ const FeedPageContent = ({ containerRef }: FeedPageContentProps) => {
size='large'
header={header}
>
-
+
+ {/* Above the lineup so the mix is reachable without leaving the feed:
+ Explore is the only other entry point and it takes a deliberate
+ detour to get to. */}
+
+ },
{
key: 'featuredPlaylists',
shouldRender: showPlaylistContent,
diff --git a/packages/web/src/pages/search-explore-page/components/mobile/SearchExplorePage.tsx b/packages/web/src/pages/search-explore-page/components/mobile/SearchExplorePage.tsx
index 2554f68179f..1271eda816e 100644
--- a/packages/web/src/pages/search-explore-page/components/mobile/SearchExplorePage.tsx
+++ b/packages/web/src/pages/search-explore-page/components/mobile/SearchExplorePage.tsx
@@ -22,6 +22,7 @@ import { capitalize } from 'lodash'
import { useSearchParams } from 'react-router'
import { useDebounce, usePrevious } from 'react-use'
+import { DiscoverWeeklyBanner } from 'components/discover-weekly'
import Header from 'components/header/mobile/Header'
import { HeaderContext } from 'components/header/mobile/HeaderContextProvider'
import MobilePageContainer from 'components/mobile-page-container/MobilePageContainer'
@@ -227,6 +228,9 @@ const SearchExplorePage = ({
display: inputValue || showSearchResults ? 'none' : undefined
}}
>
+ {showTrackContent && showUserContextualContent ? (
+
+ ) : null}
{isTracksTab ? : null}
{showTrackContent && showUserContextualContent ? (