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 ? (