diff --git a/packages/common/src/api/tan-query/collection/useCollections.ts b/packages/common/src/api/tan-query/collection/useCollections.ts index c7ca598ce28..6df9ae28557 100644 --- a/packages/common/src/api/tan-query/collection/useCollections.ts +++ b/packages/common/src/api/tan-query/collection/useCollections.ts @@ -25,31 +25,58 @@ export const useCollections = ( const queryClient = useQueryClient() const dispatch = useDispatch() + // Callers often pass a new array each render, so key on the ids themselves + // to keep the queries below stable. + const collectionIdsKey = collectionIds?.join(',') const uniqueCollectionIds = useMemo( () => collectionIds?.filter( (id, index, self) => self.indexOf(id) === index && !!id ), - [collectionIds] + // eslint-disable-next-line react-hooks/exhaustive-deps + [collectionIdsKey] + ) + + // Rebuilding these every render makes tanstack re-hash every key and + // re-sync every observer, so only rebuild when an input changes. + const queries = useMemo( + () => + uniqueCollectionIds?.map((collectionId) => ({ + queryKey: getCollectionQueryKey(collectionId), + queryFn: async () => { + const sdk = await audiusSdk() + return getCollectionQueryFn( + collectionId, + currentUserId, + queryClient, + sdk, + dispatch + ) + }, + ...options, + ...entityCacheOptions, + enabled: + options?.enabled !== false && !!collectionId && collectionId > 0 + })), + // eslint-disable-next-line react-hooks/exhaustive-deps + [ + uniqueCollectionIds, + currentUserId, + audiusSdk, + queryClient, + dispatch, + options?.enabled, + options?.staleTime, + options?.placeholderData, + options?.refetchOnMount, + options?.throwOnError, + options?.refetchInterval, + options?.refetchOnWindowFocus + ] ) const queriesResults = useQueries({ - queries: uniqueCollectionIds?.map((collectionId) => ({ - queryKey: getCollectionQueryKey(collectionId), - queryFn: async () => { - const sdk = await audiusSdk() - return getCollectionQueryFn( - collectionId, - currentUserId, - queryClient, - sdk, - dispatch - ) - }, - ...options, - ...entityCacheOptions, - enabled: options?.enabled !== false && !!collectionId && collectionId > 0 - })), + queries, combine: combineQueryResults }) diff --git a/packages/common/src/api/tan-query/lineups/useExclusiveTracks.ts b/packages/common/src/api/tan-query/lineups/useExclusiveTracks.ts index 6dba670f4b5..5878971f04f 100644 --- a/packages/common/src/api/tan-query/lineups/useExclusiveTracks.ts +++ b/packages/common/src/api/tan-query/lineups/useExclusiveTracks.ts @@ -1,3 +1,5 @@ +import { useMemo } from 'react' + import { EntityType, OptionalId } from '@audius/sdk' import { InfiniteData, @@ -13,7 +15,7 @@ import { ID } from '~/models/Identifiers' import { QUERY_KEYS } from '../queryKeys' import { QueryKey, LineupData, QueryOptions } from '../types' import { useCurrentUserId } from '../users/account/useCurrentUserId' -import { makeLoadNextPage } from '../utils/infiniteQueryLoadNextPage' +import { useLoadNextPage } from '../utils/infiniteQueryLoadNextPage' import { primeTrackData } from '../utils/primeTrackData' const DEFAULT_PAGE_SIZE = 10 @@ -89,10 +91,14 @@ export const useExclusiveTracks = ( enabled: options?.enabled !== false && !!userId }) - const data = query.data ?? [] - const trackIds = data - .filter((d) => d.type === EntityType.TRACK) - .map((d) => d.id as ID) + const data = useMemo(() => query.data ?? [], [query.data]) + const trackIds = useMemo( + () => + data.filter((d) => d.type === EntityType.TRACK).map((d) => d.id as ID), + [data] + ) + + const loadNextPage = useLoadNextPage(query) return { // Raw data @@ -108,7 +114,7 @@ export const useExclusiveTracks = ( isInitialLoading: query.isInitialLoading, hasNextPage: query.hasNextPage, fetchNextPage: query.fetchNextPage, - loadNextPage: makeLoadNextPage(query), + loadNextPage, // Identity queryKey diff --git a/packages/common/src/api/tan-query/lineups/useFeed.ts b/packages/common/src/api/tan-query/lineups/useFeed.ts index bd764aad852..81fc24e1e0a 100644 --- a/packages/common/src/api/tan-query/lineups/useFeed.ts +++ b/packages/common/src/api/tan-query/lineups/useFeed.ts @@ -1,3 +1,5 @@ +import { useMemo } from 'react' + import { EntityType, Id, type GetUserFeedFilterEnum } from '@audius/sdk' import { useInfiniteQuery, useQueryClient } from '@tanstack/react-query' @@ -14,7 +16,7 @@ import { Nullable } from '~/utils/typeUtils' import { QUERY_KEYS } from '../queryKeys' import { LineupData, QueryKey, QueryOptions } from '../types' import { useCurrentUserId } from '../users/account/useCurrentUserId' -import { makeLoadNextPage } from '../utils/infiniteQueryLoadNextPage' +import { useLoadNextPage } from '../utils/infiniteQueryLoadNextPage' import { primeCollectionData } from '../utils/primeCollectionData' import { primeTrackData } from '../utils/primeTrackData' @@ -126,10 +128,14 @@ export const useFeed = ( enabled: options?.enabled !== false && currentUserId != null }) - const data = query.data ?? [] - const trackIds = data - .filter((d) => d.type === EntityType.TRACK) - .map((d) => d.id as ID) + const data = useMemo(() => query.data ?? [], [query.data]) + const trackIds = useMemo( + () => + data.filter((d) => d.type === EntityType.TRACK).map((d) => d.id as ID), + [data] + ) + + const loadNextPage = useLoadNextPage(query) return { data, @@ -142,7 +148,7 @@ export const useFeed = ( isInitialLoading: query.isInitialLoading, hasNextPage: query.hasNextPage, fetchNextPage: query.fetchNextPage, - loadNextPage: makeLoadNextPage(query), + loadNextPage, refetch: query.refetch, queryKey } diff --git a/packages/common/src/api/tan-query/lineups/useForYouFeed.ts b/packages/common/src/api/tan-query/lineups/useForYouFeed.ts index 382da7af7d7..ba8f4302bdd 100644 --- a/packages/common/src/api/tan-query/lineups/useForYouFeed.ts +++ b/packages/common/src/api/tan-query/lineups/useForYouFeed.ts @@ -1,3 +1,5 @@ +import { useMemo } from 'react' + import { EntityType, Id } from '@audius/sdk' import { useInfiniteQuery, useQueryClient } from '@tanstack/react-query' @@ -8,7 +10,7 @@ import { ID, UserCollectionMetadata, UserTrackMetadata } from '~/models' import { QUERY_KEYS } from '../queryKeys' import { LineupData, QueryKey, QueryOptions } from '../types' import { useCurrentUserId } from '../users/account/useCurrentUserId' -import { makeLoadNextPage } from '../utils/infiniteQueryLoadNextPage' +import { useLoadNextPage } from '../utils/infiniteQueryLoadNextPage' import { primeCollectionData } from '../utils/primeCollectionData' import { primeTrackData } from '../utils/primeTrackData' @@ -102,16 +104,20 @@ export const useForYouFeed = ( enabled: options?.enabled !== false && currentUserId != null }) - const data = query.data ?? [] - const trackIds = data - .filter((d) => d.type === EntityType.TRACK) - .map((d) => d.id as ID) + const data = useMemo(() => query.data ?? [], [query.data]) + const trackIds = useMemo( + () => + data.filter((d) => d.type === EntityType.TRACK).map((d) => d.id as ID), + [data] + ) // When the query is disabled, react-query keeps isPending/isLoading true // (data is undefined). Surface them as false so consumers can render an // empty state instead of an indefinite loading state. const isDisabled = currentUserId == null || options?.enabled === false + const loadNextPage = useLoadNextPage(query) + return { data, trackIds, @@ -123,7 +129,7 @@ export const useForYouFeed = ( isInitialLoading: isDisabled ? false : query.isInitialLoading, hasNextPage: query.hasNextPage, fetchNextPage: query.fetchNextPage, - loadNextPage: makeLoadNextPage(query), + loadNextPage, refetch: query.refetch, queryKey } diff --git a/packages/common/src/api/tan-query/lineups/useLibraryTracks.ts b/packages/common/src/api/tan-query/lineups/useLibraryTracks.ts index 4d488d24952..5853865c99a 100644 --- a/packages/common/src/api/tan-query/lineups/useLibraryTracks.ts +++ b/packages/common/src/api/tan-query/lineups/useLibraryTracks.ts @@ -1,3 +1,5 @@ +import { useMemo } from 'react' + import { Id, EntityType, @@ -19,7 +21,7 @@ import { userTrackMetadataFromSDK } from '../../../adapters/track' import { QUERY_KEYS } from '../queryKeys' import { QueryKey, QueryOptions, LineupData } from '../types' import { useCurrentUserId } from '../users/account/useCurrentUserId' -import { makeLoadNextPage } from '../utils/infiniteQueryLoadNextPage' +import { useLoadNextPage } from '../utils/infiniteQueryLoadNextPage' import { primeTrackData } from '../utils/primeTrackData' const DEFAULT_PAGE_SIZE = 5 @@ -121,10 +123,14 @@ export const useLibraryTracks = ( enabled: config?.enabled !== false && !!currentUserId }) - const data = queryData.data ?? [] - const trackIds = data - .filter((d) => d.type === EntityType.TRACK) - .map((d) => d.id as ID) + const data = useMemo(() => queryData.data ?? [], [queryData.data]) + const trackIds = useMemo( + () => + data.filter((d) => d.type === EntityType.TRACK).map((d) => d.id as ID), + [data] + ) + + const loadNextPage = useLoadNextPage(queryData) return { data, @@ -138,7 +144,7 @@ export const useLibraryTracks = ( isInitialLoading: queryData.isInitialLoading, hasNextPage: queryData.hasNextPage, fetchNextPage: queryData.fetchNextPage, - loadNextPage: makeLoadNextPage(queryData), + loadNextPage, queryKey, pageSize } diff --git a/packages/common/src/api/tan-query/lineups/usePremiumTracks.ts b/packages/common/src/api/tan-query/lineups/usePremiumTracks.ts index 18db36bea58..be072cee47e 100644 --- a/packages/common/src/api/tan-query/lineups/usePremiumTracks.ts +++ b/packages/common/src/api/tan-query/lineups/usePremiumTracks.ts @@ -1,3 +1,5 @@ +import { useMemo } from 'react' + import { OptionalId, EntityType } from '@audius/sdk' import { InfiniteData, @@ -13,7 +15,7 @@ import { ID } from '~/models/Identifiers' import { QUERY_KEYS } from '../queryKeys' import { QueryKey, LineupData, QueryOptions } from '../types' import { useCurrentUserId } from '../users/account/useCurrentUserId' -import { makeLoadNextPage } from '../utils/infiniteQueryLoadNextPage' +import { useLoadNextPage } from '../utils/infiniteQueryLoadNextPage' import { primeTrackData } from '../utils/primeTrackData' const DEFAULT_PAGE_SIZE = 10 @@ -68,10 +70,14 @@ export const usePremiumTracks = ( enabled: options?.enabled !== false }) - const data = query.data ?? [] - const trackIds = data - .filter((d) => d.type === EntityType.TRACK) - .map((d) => d.id as ID) + const data = useMemo(() => query.data ?? [], [query.data]) + const trackIds = useMemo( + () => + data.filter((d) => d.type === EntityType.TRACK).map((d) => d.id as ID), + [data] + ) + + const loadNextPage = useLoadNextPage(query) return { data, @@ -84,7 +90,7 @@ export const usePremiumTracks = ( isInitialLoading: query.isInitialLoading, hasNextPage: query.hasNextPage, fetchNextPage: query.fetchNextPage, - loadNextPage: makeLoadNextPage(query), + loadNextPage, queryKey } } diff --git a/packages/common/src/api/tan-query/lineups/useProfileReposts.ts b/packages/common/src/api/tan-query/lineups/useProfileReposts.ts index b4c0a0c1f85..08640ebfca3 100644 --- a/packages/common/src/api/tan-query/lineups/useProfileReposts.ts +++ b/packages/common/src/api/tan-query/lineups/useProfileReposts.ts @@ -1,3 +1,5 @@ +import { useMemo } from 'react' + import { EntityType, OptionalId } from '@audius/sdk' import { InfiniteData, @@ -12,7 +14,7 @@ import { UserTrackMetadata, UserCollectionMetadata, ID } from '~/models' import { QUERY_KEYS } from '../queryKeys' import { QueryKey, LineupData, QueryOptions } from '../types' import { useCurrentUserId } from '../users/account/useCurrentUserId' -import { makeLoadNextPage } from '../utils/infiniteQueryLoadNextPage' +import { useLoadNextPage } from '../utils/infiniteQueryLoadNextPage' import { primeCollectionData } from '../utils/primeCollectionData' import { primeTrackData } from '../utils/primeTrackData' @@ -102,10 +104,14 @@ export const useProfileReposts = ( enabled: options?.enabled !== false && !!handle }) - const data = query.data ?? [] - const trackIds = data - .filter((d) => d.type === EntityType.TRACK) - .map((d) => d.id as ID) + const data = useMemo(() => query.data ?? [], [query.data]) + const trackIds = useMemo( + () => + data.filter((d) => d.type === EntityType.TRACK).map((d) => d.id as ID), + [data] + ) + + const loadNextPage = useLoadNextPage(query) return { data, @@ -118,7 +124,7 @@ export const useProfileReposts = ( isInitialLoading: query.isInitialLoading, hasNextPage: query.hasNextPage, fetchNextPage: query.fetchNextPage, - loadNextPage: makeLoadNextPage(query), + loadNextPage, refetch: query.refetch, queryKey } diff --git a/packages/common/src/api/tan-query/lineups/useProfileTracks.ts b/packages/common/src/api/tan-query/lineups/useProfileTracks.ts index 78f9c9017de..91f661d3be2 100644 --- a/packages/common/src/api/tan-query/lineups/useProfileTracks.ts +++ b/packages/common/src/api/tan-query/lineups/useProfileTracks.ts @@ -1,3 +1,5 @@ +import { useMemo } from 'react' + import { EntityType, OptionalId } from '@audius/sdk' import { InfiniteData, @@ -13,7 +15,7 @@ import { TracksSortMode } from '~/store/pages/profile/types' import { QUERY_KEYS } from '../queryKeys' import { QueryKey, LineupData, QueryOptions } from '../types' import { useCurrentUserId } from '../users/account/useCurrentUserId' -import { makeLoadNextPage } from '../utils/infiniteQueryLoadNextPage' +import { useLoadNextPage } from '../utils/infiniteQueryLoadNextPage' import { primeTrackData } from '../utils/primeTrackData' const DEFAULT_PAGE_SIZE = 10 @@ -96,10 +98,14 @@ export const useProfileTracks = ( enabled: options?.enabled !== false && !!handle }) - const data = query.data ?? [] - const trackIds = data - .filter((d) => d.type === EntityType.TRACK) - .map((d) => d.id as ID) + const data = useMemo(() => query.data ?? [], [query.data]) + const trackIds = useMemo( + () => + data.filter((d) => d.type === EntityType.TRACK).map((d) => d.id as ID), + [data] + ) + + const loadNextPage = useLoadNextPage(query) return { data, @@ -112,7 +118,7 @@ export const useProfileTracks = ( isInitialLoading: query.isInitialLoading, hasNextPage: query.hasNextPage, fetchNextPage: query.fetchNextPage, - loadNextPage: makeLoadNextPage(query), + loadNextPage, refetch: query.refetch, queryKey } diff --git a/packages/common/src/api/tan-query/lineups/useTrackPageLineup.ts b/packages/common/src/api/tan-query/lineups/useTrackPageLineup.ts index 5cd89c0f5b2..31885f4a053 100644 --- a/packages/common/src/api/tan-query/lineups/useTrackPageLineup.ts +++ b/packages/common/src/api/tan-query/lineups/useTrackPageLineup.ts @@ -98,11 +98,44 @@ export const useTrackPageLineup = ( const heroTrackRemixParentTrackId = heroTrack.remix_of?.tracks?.[0]?.parent_track_id + // Fire every request at once instead of one after another. Recommended + // tracks are only shown when there are no remixes, but requesting them + // up front saves a round trip in the common case. + const remixParentPromise = heroTrackRemixParentTrackId + ? sdk.tracks.getTrack({ + trackId: Id.parse(heroTrackRemixParentTrackId), + userId: OptionalId.parse(currentUserId) + }) + : undefined + const remixesPromise = heroTrackRemixParentTrackId + ? undefined + : sdk.tracks.getTrackRemixes({ + trackId: Id.parse(trackId), + userId: OptionalId.parse(currentUserId), + limit: pageSize, + offset: 0 + }) + const moreByPromise = sdk.users.getTracksByUserHandle({ + handle: ownerHandle, + userId: OptionalId.parse(currentUserId), + sort: 'plays', + limit: pageSize, + offset: 0 + }) + const trendingPromise = sdk.tracks.getTrendingTracks({ + genre: heroTrack.genre, + limit: pageSize + }) + // Only awaited (and so only fails the query) when it's needed + trendingPromise.catch(() => {}) + + const [remixParentTrack, remixesData, { data = [] }] = await Promise.all([ + remixParentPromise, + remixesPromise, + moreByPromise + ]) + if (heroTrackRemixParentTrackId) { - const remixParentTrack = await sdk.tracks.getTrack({ - trackId: Id.parse(heroTrackRemixParentTrackId), - userId: OptionalId.parse(currentUserId) - }) const processedParentTrack = remixParentTrack?.data ? userTrackMetadataFromSDK(remixParentTrack.data) : undefined @@ -112,14 +145,7 @@ export const useTrackPageLineup = ( indices.remixParentSection.pageSize = 1 } } else { - // If hero track is remixable (not a remix), get its remixes - const remixesData = await sdk.tracks.getTrackRemixes({ - trackId: Id.parse(trackId), - userId: OptionalId.parse(currentUserId), - limit: pageSize, - offset: 0 - }) - + // If hero track is remixable (not a remix), show its remixes if (remixesData?.data?.tracks) { const processedRemixes = transformAndCleanList( remixesData.data.tracks, @@ -133,15 +159,7 @@ export const useTrackPageLineup = ( } } - // Get more tracks by the artist - const { data = [] } = await sdk.users.getTracksByUserHandle({ - handle: ownerHandle, - userId: OptionalId.parse(currentUserId), - sort: 'plays', - limit: pageSize, - offset: 0 - }) - + // More tracks by the artist const processedTracks = transformAndCleanList( data, userTrackMetadataFromSDK @@ -162,10 +180,7 @@ export const useTrackPageLineup = ( // If there are no remixes, get recommended tracks based on genre if (indices.remixesSection.index === undefined) { - const { data: trendingData } = await sdk.tracks.getTrendingTracks({ - genre: heroTrack.genre, - limit: pageSize - }) + const { data: trendingData } = await trendingPromise if (trendingData) { const processedTracks = transformAndCleanList( @@ -241,8 +256,6 @@ export const useTrackPageLineup = ( * live observer so the warmed data is shared with `TrackScreenLineup`'s own * query. */ -export const usePrefetchTrackPageLineup = ( - trackId: ID | null | undefined -) => { +export const usePrefetchTrackPageLineup = (trackId: ID | null | undefined) => { useTrackPageLineup({ trackId }) } diff --git a/packages/common/src/api/tan-query/lineups/useTrending.ts b/packages/common/src/api/tan-query/lineups/useTrending.ts index 9ba7340d0a0..b76ca82fa7e 100644 --- a/packages/common/src/api/tan-query/lineups/useTrending.ts +++ b/packages/common/src/api/tan-query/lineups/useTrending.ts @@ -1,3 +1,5 @@ +import { useMemo } from 'react' + import { OptionalId, EntityType } from '@audius/sdk' import { InfiniteData, @@ -16,7 +18,7 @@ import { Genre } from '~/utils/genres' import { QUERY_KEYS } from '../queryKeys' import { QueryKey, LineupData, QueryOptions } from '../types' import { useCurrentUserId } from '../users/account/useCurrentUserId' -import { makeLoadNextPage } from '../utils/infiniteQueryLoadNextPage' +import { useLoadNextPage } from '../utils/infiniteQueryLoadNextPage' import { primeTrackData } from '../utils/primeTrackData' export const TRENDING_INITIAL_PAGE_SIZE = 10 @@ -111,10 +113,14 @@ export const useTrending = ( enabled: options?.enabled !== false && !!timeRange }) - const data = query.data ?? [] - const trackIds = data - .filter((d) => d.type === EntityType.TRACK) - .map((d) => d.id as ID) + const data = useMemo(() => query.data ?? [], [query.data]) + const trackIds = useMemo( + () => + data.filter((d) => d.type === EntityType.TRACK).map((d) => d.id as ID), + [data] + ) + + const loadNextPage = useLoadNextPage(query) return { // Raw data @@ -130,7 +136,7 @@ export const useTrending = ( isInitialLoading: query.isInitialLoading, hasNextPage: query.hasNextPage, fetchNextPage: query.fetchNextPage, - loadNextPage: makeLoadNextPage(query), + loadNextPage, // Identity queryKey diff --git a/packages/common/src/api/tan-query/lineups/useTrendingUnderground.ts b/packages/common/src/api/tan-query/lineups/useTrendingUnderground.ts index e57a22b2039..527534254ab 100644 --- a/packages/common/src/api/tan-query/lineups/useTrendingUnderground.ts +++ b/packages/common/src/api/tan-query/lineups/useTrendingUnderground.ts @@ -1,3 +1,5 @@ +import { useMemo } from 'react' + import { OptionalId, EntityType } from '@audius/sdk' import { InfiniteData, @@ -12,7 +14,7 @@ import { ID } from '~/models/Identifiers' import { QUERY_KEYS } from '../queryKeys' import { QueryKey, QueryOptions, LineupData } from '../types' import { useCurrentUserId } from '../users/account/useCurrentUserId' -import { makeLoadNextPage } from '../utils/infiniteQueryLoadNextPage' +import { useLoadNextPage } from '../utils/infiniteQueryLoadNextPage' import { primeTrackData } from '../utils/primeTrackData' const DEFAULT_PAGE_SIZE = 10 @@ -64,10 +66,14 @@ export const useTrendingUnderground = ( enabled: options?.enabled !== false }) - const data = query.data ?? [] - const trackIds = data - .filter((d) => d.type === EntityType.TRACK) - .map((d) => d.id as ID) + const data = useMemo(() => query.data ?? [], [query.data]) + const trackIds = useMemo( + () => + data.filter((d) => d.type === EntityType.TRACK).map((d) => d.id as ID), + [data] + ) + + const loadNextPage = useLoadNextPage(query) return { data, @@ -80,7 +86,7 @@ export const useTrendingUnderground = ( isInitialLoading: query.isInitialLoading, hasNextPage: query.hasNextPage, fetchNextPage: query.fetchNextPage, - loadNextPage: makeLoadNextPage(query), + loadNextPage, queryKey } } diff --git a/packages/common/src/api/tan-query/lineups/useWeeklyRotation.ts b/packages/common/src/api/tan-query/lineups/useWeeklyRotation.ts index 604c86e5901..9bbe5aff4ac 100644 --- a/packages/common/src/api/tan-query/lineups/useWeeklyRotation.ts +++ b/packages/common/src/api/tan-query/lineups/useWeeklyRotation.ts @@ -1,3 +1,5 @@ +import { useMemo } from 'react' + import { Id, OptionalId, EntityType } from '@audius/sdk' import { useQuery, useQueryClient } from '@tanstack/react-query' @@ -71,10 +73,12 @@ export const useWeeklyRotation = ( enabled: options?.enabled !== false && !!userId }) - const data = query.data ?? [] - const trackIds = data - .filter((d) => d.type === EntityType.TRACK) - .map((d) => d.id as ID) + const data = useMemo(() => query.data ?? [], [query.data]) + const trackIds = useMemo( + () => + data.filter((d) => d.type === EntityType.TRACK).map((d) => d.id as ID), + [data] + ) return { data, diff --git a/packages/common/src/api/tan-query/tracks/useTrackRank.ts b/packages/common/src/api/tan-query/tracks/useTrackRank.ts index a8512ea082e..0a93ca859a5 100644 --- a/packages/common/src/api/tan-query/tracks/useTrackRank.ts +++ b/packages/common/src/api/tan-query/tracks/useTrackRank.ts @@ -31,6 +31,10 @@ export const getTrendingIdsQueryKey = (args?: GetTrendingIdsArgs) => { ] as unknown as QueryKey } +// Trending lists change slowly, and every track page reads them for its rank +// badge, so don't refetch on each track page visit. +const TRENDING_IDS_STALE_TIME_MS = 30 * 60 * 1000 + /** * Hook that returns trending track IDs for all time periods */ @@ -42,7 +46,8 @@ export const useGetTrendingIds = (args?: GetTrendingIdsArgs) => { const sdk = await audiusSdk() const { data } = await sdk.tracks.getTrendingTrackIDs(args) return data ? trendingIdsFromSDK(data) : null - } + }, + staleTime: TRENDING_IDS_STALE_TIME_MS }) } diff --git a/packages/common/src/api/tan-query/tracks/useTracks.ts b/packages/common/src/api/tan-query/tracks/useTracks.ts index 36aa2d1b3b5..9226c79e5e9 100644 --- a/packages/common/src/api/tan-query/tracks/useTracks.ts +++ b/packages/common/src/api/tan-query/tracks/useTracks.ts @@ -46,30 +46,55 @@ export const useTracks = ( const queryClient = useQueryClient() const { data: currentUserId } = useCurrentUserId() - // Filter out duplicate IDs + // Filter out duplicate IDs. Callers often pass a new array each render, so + // key on the ids themselves to keep the queries below stable. + const trackIdsKey = trackIds?.join(',') const uniqueTrackIds = useMemo( () => trackIds?.filter((id, index, self) => self.indexOf(id) === index && !!id), - [trackIds] + // eslint-disable-next-line react-hooks/exhaustive-deps + [trackIdsKey] + ) + + // Rebuilding these every render makes tanstack re-hash every key and + // re-sync every observer, so only rebuild when an input changes. + const queries = useMemo( + () => + uniqueTrackIds?.map((trackId) => ({ + queryKey: getTrackQueryKey(trackId), + queryFn: async () => { + const sdk = await audiusSdk() + return getTrackQueryFn( + trackId, + currentUserId, + queryClient, + sdk, + dispatch + ) + }, + ...entityCacheOptions, + ...options, + enabled: options?.enabled !== false && !!trackId && trackId > 0 + })), + // eslint-disable-next-line react-hooks/exhaustive-deps + [ + uniqueTrackIds, + currentUserId, + audiusSdk, + queryClient, + dispatch, + options?.enabled, + options?.staleTime, + options?.placeholderData, + options?.refetchOnMount, + options?.throwOnError, + options?.refetchInterval, + options?.refetchOnWindowFocus + ] ) const queryResults = useQueries({ - queries: uniqueTrackIds?.map((trackId) => ({ - queryKey: getTrackQueryKey(trackId), - queryFn: async () => { - const sdk = await audiusSdk() - return getTrackQueryFn( - trackId, - currentUserId, - queryClient, - sdk, - dispatch - ) - }, - ...entityCacheOptions, - ...options, - enabled: options?.enabled !== false && !!trackId && trackId > 0 - })), + queries, combine: combineQueryResults }) diff --git a/packages/common/src/api/tan-query/users/account/useCurrentAccount.ts b/packages/common/src/api/tan-query/users/account/useCurrentAccount.ts index 86a5dc6d1ae..902bbfa8e36 100644 --- a/packages/common/src/api/tan-query/users/account/useCurrentAccount.ts +++ b/packages/common/src/api/tan-query/users/account/useCurrentAccount.ts @@ -6,7 +6,11 @@ import { primeUserData, useQueryContext } from '~/api/tan-query/utils' import { useAppContext } from '~/context/appContext' import { Status } from '~/models' import { UserMetadata } from '~/models/User' -import { LocalStorage } from '~/services' +import { + AUDIUS_ACCOUNT_KEY, + AUDIUS_ACCOUNT_USER_KEY, + LocalStorage +} from '~/services' import { AccountState } from '~/store' import { QUERY_KEYS } from '../../queryKeys' @@ -55,6 +59,44 @@ const getLocalAccount = ( return null } +// Last placeholder result, keyed on the raw stored strings. Every entity hook +// observes this query, and tanstack re-runs placeholderData on each render +// while the account is pending (or disabled, when signed out), so reuse the +// parsed account until the stored values change. +let placeholderCache: { + localStorage: LocalStorage + account: string | null + user: string | null + result: AccountState | null +} | null = null + +const getLocalAccountPlaceholder = ( + localStorage: LocalStorage, + queryClient: QueryClient +) => { + const account = localStorage.getItemSync?.(AUDIUS_ACCOUNT_KEY) ?? null + const user = localStorage.getItemSync?.(AUDIUS_ACCOUNT_USER_KEY) ?? null + if ( + placeholderCache?.localStorage !== localStorage || + placeholderCache.account !== account || + placeholderCache.user !== user + ) { + placeholderCache = { + localStorage, + account, + user, + result: getLocalAccount(localStorage, queryClient) + } + } else if ( + placeholderCache.result?.userId && + !queryClient.getQueryData(getUserQueryKey(placeholderCache.result.userId)) + ) { + // Re-prime the cached user if its query was removed + getLocalAccount(localStorage, queryClient) + } + return placeholderCache.result ?? undefined +} + export const getCurrentAccountQueryFn = async ( sdk: AudiusSdk, localStorage: LocalStorage, @@ -131,7 +173,7 @@ export const useCurrentAccount = ( // Render cached account immediately while fresh data loads, so the UI // doesn't flash empty but server data still wins once it arrives. placeholderData: () => - getLocalAccount(localStorage, queryClient) ?? undefined, + getLocalAccountPlaceholder(localStorage, queryClient), staleTime: Infinity, gcTime: Infinity, enabled: options?.enabled !== false && !!currentUserWallet, diff --git a/packages/common/src/api/tan-query/users/useUsers.ts b/packages/common/src/api/tan-query/users/useUsers.ts index f327649529c..a8cf662d3be 100644 --- a/packages/common/src/api/tan-query/users/useUsers.ts +++ b/packages/common/src/api/tan-query/users/useUsers.ts @@ -25,28 +25,53 @@ export const useUsers = ( const queryClient = useQueryClient() const { data: currentUserId } = useCurrentUserId() - // Filter out duplicate IDs + // Filter out duplicate IDs. Callers often pass a new array each render, so + // key on the ids themselves to keep the queries below stable. + const userIdsKey = userIds?.join(',') const uniqueUserIds = useMemo( () => userIds?.filter((id, index, self) => self.indexOf(id) === index && !!id), - [userIds] + // eslint-disable-next-line react-hooks/exhaustive-deps + [userIdsKey] + ) + + // Rebuilding these every render makes tanstack re-hash every key and + // re-sync every observer, so only rebuild when an input changes. + const queries = useMemo( + () => + uniqueUserIds?.map((userId) => ({ + queryKey: getUserQueryKey(userId), + queryFn: async () => + getUserQueryFn( + userId, + currentUserId, + queryClient, + await audiusSdk(), + dispatch + ), + ...options, + ...entityCacheOptions, + enabled: options?.enabled !== false && !!userId && userId > 0 + })), + // eslint-disable-next-line react-hooks/exhaustive-deps + [ + uniqueUserIds, + currentUserId, + audiusSdk, + queryClient, + dispatch, + options?.enabled, + options?.staleTime, + options?.placeholderData, + options?.refetchOnMount, + options?.throwOnError, + options?.refetchInterval, + options?.refetchOnWindowFocus + ] ) const queryResults = useQueries({ - queries: uniqueUserIds?.map((userId) => ({ - queryKey: getUserQueryKey(userId), - queryFn: async () => - getUserQueryFn( - userId, - currentUserId, - queryClient, - await audiusSdk(), - dispatch - ), - ...options, - ...entityCacheOptions, - enabled: options?.enabled !== false && !!userId && userId > 0 - })), + queries, combine: combineQueryResults }) const { data: users } = queryResults diff --git a/packages/common/src/api/tan-query/utils/infiniteQueryLoadNextPage.ts b/packages/common/src/api/tan-query/utils/infiniteQueryLoadNextPage.ts index 5a4540330aa..8a0180caeec 100644 --- a/packages/common/src/api/tan-query/utils/infiniteQueryLoadNextPage.ts +++ b/packages/common/src/api/tan-query/utils/infiniteQueryLoadNextPage.ts @@ -1,3 +1,5 @@ +import { useCallback } from 'react' + import { UseInfiniteQueryResult } from '@tanstack/react-query' /** @@ -24,3 +26,20 @@ export const makeLoadNextPage = if (queryData.isFetching || !queryData.hasNextPage) return undefined return queryData.fetchNextPage() } + +/** + * `makeLoadNextPage` with a stable identity between page loads, so lineups + * and lists that take it as a prop don't re-render on every parent render. + */ +export const useLoadNextPage = ({ + isFetching, + hasNextPage, + fetchNextPage +}: Pick< + UseInfiniteQueryResult, + 'isFetching' | 'hasNextPage' | 'fetchNextPage' +>) => + useCallback( + () => makeLoadNextPage({ isFetching, hasNextPage, fetchNextPage })(), + [isFetching, hasNextPage, fetchNextPage] + ) diff --git a/packages/common/src/hooks/chats/useTrackPlayer.ts b/packages/common/src/hooks/chats/useTrackPlayer.ts index 0d33d43eb0f..789c5224a2c 100644 --- a/packages/common/src/hooks/chats/useTrackPlayer.ts +++ b/packages/common/src/hooks/chats/useTrackPlayer.ts @@ -1,8 +1,9 @@ import { useCallback } from 'react' -import { useDispatch, useSelector } from 'react-redux' +import { useDispatch, useSelector, useStore } from 'react-redux' import { ID, Name } from '~/models' +import { CommonState } from '~/store/commonStore' import { playbackActions, playbackSelectors, @@ -17,6 +18,7 @@ import { TrackPlayback } from './types' const { playFrom, pause } = playbackActions const { getPlaying, getTrackId, makeGetCurrent } = playbackSelectors +const getCurrent = makeGetCurrent() type RecordAnalytics = ({ name, id }: { name: TrackPlayback; id: ID }) => void @@ -43,11 +45,12 @@ const queueablesToPlaybackTracks = (entries: Queueable[]) => */ export const usePlayTrack = (recordAnalytics?: RecordAnalytics) => { const dispatch = useDispatch() - const playingTrackId = useSelector(getTrackId) + // Read at call time so the callback doesn't change on every track change + const store = useStore() const playTrack = useCallback( ({ id, entries }: { id: ID; entries: Queueable[] }) => { - if (playingTrackId !== id) { + if (getTrackId(store.getState()) !== id) { const tracks = queueablesToPlaybackTracks(entries) const startIndex = Math.max( 0, @@ -61,7 +64,7 @@ export const usePlayTrack = (recordAnalytics?: RecordAnalytics) => { recordAnalytics({ name: Name.PLAYBACK_PLAY, id }) } }, - [dispatch, recordAnalytics, playingTrackId] + [dispatch, recordAnalytics, store] ) return playTrack @@ -92,15 +95,15 @@ export const useToggleTrack = ({ recordAnalytics, entries: entriesProp }: UseToggleTrack) => { - const currentQueueItem = useSelector(makeGetCurrent()) - const currentTrack = useCurrentTrack() + const isCurrentQueueItem = useSelector((state: CommonState) => { + const currentQueueItem = getCurrent(state) + return currentQueueItem.trackId === id && currentQueueItem.source === source + }) + const hasCurrentTrack = !!useCurrentTrack({ + select: (track) => track.track_id + }) const playing = useSelector(getPlaying) - const isTrackPlaying = !!( - playing && - currentTrack && - currentQueueItem.trackId === id && - currentQueueItem.source === source - ) + const isTrackPlaying = playing && hasCurrentTrack && isCurrentQueueItem const playTrack = usePlayTrack(recordAnalytics) const pauseTrack = usePauseTrack(recordAnalytics) diff --git a/packages/common/src/hooks/useImageSize.test.tsx b/packages/common/src/hooks/useImageSize.test.tsx new file mode 100644 index 00000000000..c045f3cefbb --- /dev/null +++ b/packages/common/src/hooks/useImageSize.test.tsx @@ -0,0 +1,83 @@ +// @vitest-environment jsdom +import { cleanup, renderHook, waitFor } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { SquareSizes } from '~/models' + +import { useImageSize } from './useImageSize' + +const makeArtwork = (id: string) => ({ + [SquareSizes.SIZE_150_BY_150]: `https://a.test/${id}/150x150.jpg`, + [SquareSizes.SIZE_480_BY_480]: `https://a.test/${id}/480x480.jpg`, + [SquareSizes.SIZE_1000_BY_1000]: `https://a.test/${id}/1000x1000.jpg`, + mirrors: ['https://b.test'] +}) + +describe('useImageSize', () => { + afterEach(() => { + cleanup() + global.IMAGE_CACHE.clear() + }) + + it('does not preload again when re-rendered with a new inline preload function', async () => { + const artwork = makeArtwork('inline') + const preload = vi.fn(async (_url: string) => {}) + const { result, rerender } = renderHook(() => + useImageSize({ + artwork, + targetSize: SquareSizes.SIZE_150_BY_150, + preloadImageFn: async (url: string) => preload(url) + }) + ) + await waitFor(() => + expect(result.current.imageUrl).toBe(artwork['150x150']) + ) + rerender() + rerender() + await waitFor(() => expect(preload).toHaveBeenCalledTimes(1)) + }) + + it('falls back to a mirror when the preload fails', async () => { + const artwork = makeArtwork('mirror') + const preloadImageFn = vi.fn(async (url: string) => { + if (url.startsWith('https://a.test')) throw new Error('down') + }) + const { result } = renderHook(() => + useImageSize({ + artwork, + targetSize: SquareSizes.SIZE_480_BY_480, + preloadImageFn + }) + ) + await waitFor(() => + expect(result.current.imageUrl).toBe('https://b.test/mirror/480x480.jpg') + ) + }) + + it('uses a cached larger size only when it is not much larger', async () => { + const artwork = makeArtwork('larger') + global.IMAGE_CACHE.add(artwork['480x480']) + global.IMAGE_CACHE.add(artwork['1000x1000']) + + const { result: forThumbnail } = renderHook(() => + useImageSize({ artwork, targetSize: SquareSizes.SIZE_150_BY_150 }) + ) + await waitFor(() => + expect(forThumbnail.current.imageUrl).toBe(artwork['480x480']) + ) + + global.IMAGE_CACHE.delete(artwork['480x480']) + const preloadImageFn = vi.fn(async (_url: string) => {}) + const { result: withoutMedium } = renderHook(() => + useImageSize({ + artwork, + targetSize: SquareSizes.SIZE_150_BY_150, + preloadImageFn + }) + ) + await waitFor(() => + expect(withoutMedium.current.imageUrl).toBe(artwork['150x150']) + ) + expect(preloadImageFn).toHaveBeenCalledWith(artwork['150x150']) + }) +}) diff --git a/packages/common/src/hooks/useImageSize.ts b/packages/common/src/hooks/useImageSize.ts index 95044f52f4b..d4c05d8f148 100644 --- a/packages/common/src/hooks/useImageSize.ts +++ b/packages/common/src/hooks/useImageSize.ts @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback } from 'react' +import { useState, useEffect, useCallback, useRef } from 'react' import { SquareSizes, WidthSizes } from '~/models' import { Maybe } from '~/utils/typeUtils' @@ -28,6 +28,11 @@ const getWidth = (size: string) => { return parseInt(size.split('x')[0]) } +// A cached larger image is used in place of the target size only up to this +// many times its width, e.g. 480 for 150 but not 1000, so small thumbnails +// don't decode full-size artwork. +const MAX_LARGER_SIZE_RATIO = 3.5 + type Artwork = { [key in T]?: string } /** @@ -64,6 +69,13 @@ export const useImageSize = < useState>(undefined) const [failedUrls, setFailedUrls] = useState>(new Set()) + // Callers often pass an inline function. Reading it from a ref keeps the + // effect below from re-running (and preloading again) on every render. + const preloadImageFnRef = useRef(preloadImageFn) + useEffect(() => { + preloadImageFnRef.current = preloadImageFn + }, [preloadImageFn]) + const fetchWithFallback = useCallback( async (url: string) => { const mirrors = [...(artwork?.mirrors ?? [])] @@ -71,7 +83,7 @@ export const useImageSize = < while (mirrors.length >= 0) { try { - await preloadImageFn?.(currentUrl) + await preloadImageFnRef.current?.(currentUrl) return currentUrl } catch { const nextMirror = mirrors.shift() @@ -84,7 +96,7 @@ export const useImageSize = < throw new Error(`Failed to fetch image from all mirrors ${url}`) }, - [artwork?.mirrors, preloadImageFn] + [artwork?.mirrors] ) const getNextMirrorUrl = useCallback( @@ -140,14 +152,17 @@ export const useImageSize = < IMAGE_CACHE.has(artwork[size as SizeType]!) ) as SizeType | undefined - // Check for larger size + // Check for the smallest cached larger size, up to MAX_LARGER_SIZE_RATIO // If found, set the image url to the larger size and return - const largerSize = Object.keys(artwork).find( - (size) => - getWidth(size) > getWidth(targetSize) && - artwork[size as SizeType] && - IMAGE_CACHE.has(artwork[size as SizeType]!) - ) as SizeType | undefined + const largerSize = Object.keys(artwork) + .filter( + (size) => + getWidth(size) > getWidth(targetSize) && + getWidth(size) <= getWidth(targetSize) * MAX_LARGER_SIZE_RATIO && + artwork[size as SizeType] && + IMAGE_CACHE.has(artwork[size as SizeType]!) + ) + .sort((a, b) => getWidth(a) - getWidth(b))[0] as SizeType | undefined if (largerSize) { setImageUrl(artwork[largerSize]) diff --git a/packages/common/src/store/pages/library-page/selectors.ts b/packages/common/src/store/pages/library-page/selectors.ts index 861f93d5aa1..ac536b00518 100644 --- a/packages/common/src/store/pages/library-page/selectors.ts +++ b/packages/common/src/store/pages/library-page/selectors.ts @@ -1,4 +1,5 @@ import { uniq } from 'lodash' +import { createSelector } from 'reselect' import { CommonState } from '~/store/commonStore' @@ -61,102 +62,93 @@ export const getLocalRemovedPlaylistReposts = (state: CommonState) => state.pages.libraryPage.local.playlist.favorites.removed /** Get the tracks in currently selected category that have been added to the library in current session */ -export const getSelectedCategoryLocalTrackAdds = (state: CommonState) => { - const selectedCategory = getCategory(state, { - currentTab: LibraryPageTabs.TRACKS - }) - const localFavorites = getLocalTrackFavorites(state) - const localPurchases = getLocalTrackPurchases(state) - const localReposts = getLocalTrackReposts(state) - let localLibraryAdditions - if (selectedCategory === LibraryCategory.Favorite) { - localLibraryAdditions = localFavorites - } else if (selectedCategory === LibraryCategory.Purchase) { - localLibraryAdditions = localPurchases - } else if (selectedCategory === LibraryCategory.Repost) { - localLibraryAdditions = localReposts - } else { +export const getSelectedCategoryLocalTrackAdds = createSelector( + [ + getTracksCategory, + getLocalTrackFavorites, + getLocalTrackPurchases, + getLocalTrackReposts + ], + (selectedCategory, localFavorites, localPurchases, localReposts) => { + if (selectedCategory === LibraryCategory.Favorite) { + return localFavorites + } else if (selectedCategory === LibraryCategory.Purchase) { + return localPurchases + } else if (selectedCategory === LibraryCategory.Repost) { + return localReposts + } // Category = ALL - localLibraryAdditions = { + return { ...localReposts, ...localFavorites, ...localPurchases } } +) - return localLibraryAdditions -} +const EMPTY_IDS: ID[] = [] +const getNone = () => EMPTY_IDS -const getSelectedCategoryLocalCollectionUpdates = ( - state: CommonState, - props: { collectionType: 'album' | 'playlist'; updateType: 'add' | 'remove' } -) => { - const { collectionType, updateType } = props - const currentTab = - collectionType === 'album' - ? LibraryPageTabs.ALBUMS - : LibraryPageTabs.PLAYLISTS - const selectedCategory = getCategory(state, { currentTab }) - let localFavorites: ID[], localPurchases: ID[], localReposts: ID[] +const makeGetSelectedCategoryLocalCollectionUpdates = ({ + collectionType, + updateType +}: { + collectionType: 'album' | 'playlist' + updateType: 'add' | 'remove' +}) => { + const isAlbum = collectionType === 'album' + let getFavorites: (state: CommonState) => ID[] + let getPurchases: (state: CommonState) => ID[] + let getReposts: (state: CommonState) => ID[] if (updateType === 'add') { - localFavorites = - collectionType === 'album' - ? getLocalAlbumFavorites(state) - : getLocalPlaylistFavorites(state) - localPurchases = - collectionType === 'album' ? getLocalAlbumPurchases(state) : [] // Can't buy playlists - localReposts = - collectionType === 'album' - ? getLocalAlbumReposts(state) - : getLocalPlaylistReposts(state) + getFavorites = isAlbum ? getLocalAlbumFavorites : getLocalPlaylistFavorites + getPurchases = isAlbum ? getLocalAlbumPurchases : getNone // Can't buy playlists + getReposts = isAlbum ? getLocalAlbumReposts : getLocalPlaylistReposts } else { - localFavorites = - collectionType === 'album' - ? getLocalRemovedAlbumFavorites(state) - : getLocalRemovedPlaylistFavorites(state) - localPurchases = [] // Can't remove purchases - localReposts = - collectionType === 'album' - ? getLocalRemovedAlbumReposts(state) - : getLocalRemovedPlaylistReposts(state) + getFavorites = isAlbum + ? getLocalRemovedAlbumFavorites + : getLocalRemovedPlaylistFavorites + getPurchases = getNone // Can't remove purchases + getReposts = isAlbum + ? getLocalRemovedAlbumReposts + : getLocalRemovedPlaylistReposts } - switch (selectedCategory) { - case LibraryCategory.Favorite: - return localFavorites - case LibraryCategory.Purchase: - return localPurchases - case LibraryCategory.Repost: - return localReposts - default: - // Category = ALL - return uniq([...localReposts, ...localFavorites, ...localPurchases]) - } + return createSelector( + [getCollectionsCategory, getFavorites, getPurchases, getReposts], + (selectedCategory, localFavorites, localPurchases, localReposts) => { + switch (selectedCategory) { + case LibraryCategory.Favorite: + return localFavorites + case LibraryCategory.Purchase: + return localPurchases + case LibraryCategory.Repost: + return localReposts + default: + // Category = ALL + return uniq([...localReposts, ...localFavorites, ...localPurchases]) + } + } + ) } -export const getSelectedCategoryLocalAlbumAdds = (state: CommonState) => { - return getSelectedCategoryLocalCollectionUpdates(state, { +export const getSelectedCategoryLocalAlbumAdds = + makeGetSelectedCategoryLocalCollectionUpdates({ collectionType: 'album', updateType: 'add' }) -} -export const getSelectedCategoryLocalAlbumRemovals = (state: CommonState) => { - return getSelectedCategoryLocalCollectionUpdates(state, { +export const getSelectedCategoryLocalAlbumRemovals = + makeGetSelectedCategoryLocalCollectionUpdates({ collectionType: 'album', updateType: 'remove' }) -} -export const getSelectedCategoryLocalPlaylistRemovals = ( - state: CommonState -) => { - return getSelectedCategoryLocalCollectionUpdates(state, { +export const getSelectedCategoryLocalPlaylistRemovals = + makeGetSelectedCategoryLocalCollectionUpdates({ collectionType: 'playlist', updateType: 'remove' }) -} -export const getSelectedCategoryLocalPlaylistAdds = (state: CommonState) => { - return getSelectedCategoryLocalCollectionUpdates(state, { +export const getSelectedCategoryLocalPlaylistAdds = + makeGetSelectedCategoryLocalCollectionUpdates({ collectionType: 'playlist', updateType: 'add' }) -} diff --git a/packages/mobile/src/components/audio/AudioPlayer.tsx b/packages/mobile/src/components/audio/AudioPlayer.tsx index 0b37caa463f..b9eb5c3ba65 100644 --- a/packages/mobile/src/components/audio/AudioPlayer.tsx +++ b/packages/mobile/src/components/audio/AudioPlayer.tsx @@ -74,6 +74,13 @@ const getArtworkTargetSize = (artwork?: Track['artwork']) => TRACK_ARTWORK_PREFERRED_SIZES.find((size) => artwork?.[size]) ?? SquareSizes.SIZE_1000_BY_1000 +// Checking a track's stream and artwork against their mirrors costs HEAD +// requests, so a new queue only checks the tracks around the starting one. +// The rest go in with their primary URLs; if one fails when it plays, the +// PlaybackError handler re-resolves it against the mirrors. +const RESOLVE_AHEAD = 2 +const RESOLVE_BEHIND = 1 + const { getPlaying, getSeek, getCounter, getPlaybackRate, getTrackId } = playbackSelectors const { setTrackPosition } = playbackPositionActions @@ -223,21 +230,34 @@ const useQueueSync = (isAudioSetup: boolean) => { const offlineAvailabilityByTrackId = useSelector((state) => { const offlineTrackStatus = getOfflineTrackStatus(state) - return queueTrackIds.reduce((result, id) => { + const result: Record = {} + for (const id of queueTrackIds) { if (offlineTrackStatus[id] === OfflineDownloadStatus.SUCCESS) { - return { ...result, [id]: true } + result[id] = true } - return result - }, {}) + } + return result }, isEqual) const [retries, setRetries] = useState(0) const makeTrackData = useCallback( - async ({ track, playerBehavior }: QueueableTrack, retries?: number) => { + async ( + { track, playerBehavior }: QueueableTrack, + retries?: number, + { + resolveMirrors = true, + isUpcoming = false + }: { + /** Check the stream and artwork URLs against their mirrors */ + resolveMirrors?: boolean + /** Not the track about to play, so leave its retry count alone */ + isUpcoming?: boolean + } = {} + ) => { try { if (!track) return unlistedTrackFallbackTrackData - setRetries(retries ?? 0) + if (!isUpcoming) setRetries(retries ?? 0) const trackOwner = queueTrackOwnersMap[track.owner_id] const trackId = track.track_id @@ -251,8 +271,10 @@ const useQueueSync = (isAudioSetup: boolean) => { const audioFilePath = getLocalAudioPath(trackId) url = `file://${audioFilePath}` } else if (streamObj?.url) { - url = - (await resolveStreamUrl(streamObj, retries ?? 0)) ?? streamObj.url + url = resolveMirrors + ? ((await resolveStreamUrl(streamObj, retries ?? 0)) ?? + streamObj.url) + : streamObj.url } else { const sdk = await audiusSdk() const nftAccessSignature = nftAccessSignatureMap[trackId]?.mp3 ?? null @@ -274,13 +296,16 @@ const useQueueSync = (isAudioSetup: boolean) => { ? `file://${getLocalTrackCoverArtPath(trackId.toString())}` : undefined + const artworkTargetSize = getArtworkTargetSize(track.artwork) const imageUrl = localTrackImageSource ?? - (await resolveImageUrl({ - artwork: track.artwork, - targetSize: getArtworkTargetSize(track.artwork), - defaultImage: DEFAULT_IMAGE_URL - })) ?? + (resolveMirrors + ? await resolveImageUrl({ + artwork: track.artwork, + targetSize: artworkTargetSize, + defaultImage: DEFAULT_IMAGE_URL + }) + : track.artwork?.[artworkTargetSize]) ?? DEFAULT_IMAGE_URL return { @@ -333,10 +358,18 @@ const useQueueSync = (isAudioSetup: boolean) => { .filter(({ i }) => i !== startIndex) const resolved = await Promise.all( - remaining.map(async ({ t, i }) => ({ - data: await makeTrackData(t), - i - })) + remaining.map(async ({ t, i }) => { + const resolveMirrors = + i >= startIndex - RESOLVE_BEHIND && + i <= startIndex + RESOLVE_AHEAD + return { + data: await makeTrackData(t, undefined, { + resolveMirrors, + isUpcoming: true + }), + i + } + }) ) // Sort by original index so RNTP order matches redux order @@ -358,7 +391,14 @@ const useQueueSync = (isAudioSetup: boolean) => { // --- appendToQueue: add new tracks to end --- const appendToQueue = useCallback( async (newTracks: QueueableTrack[]) => { - const resolved = await Promise.all(newTracks.map((t) => makeTrackData(t))) + const resolved = await Promise.all( + newTracks.map((t) => + makeTrackData(t, undefined, { + resolveMirrors: false, + isUpcoming: true + }) + ) + ) await TrackPlayer.add(resolved) }, [makeTrackData] diff --git a/packages/mobile/src/components/lineup-tile/CollectionTile.tsx b/packages/mobile/src/components/lineup-tile/CollectionTile.tsx index 0b614b24002..c5679c2c838 100644 --- a/packages/mobile/src/components/lineup-tile/CollectionTile.tsx +++ b/packages/mobile/src/components/lineup-tile/CollectionTile.tsx @@ -168,7 +168,7 @@ export const CollectionTile = (props: CollectionTileProps) => { (props: ImageProps) => ( ), diff --git a/packages/mobile/src/components/lineup/TrackLineup.tsx b/packages/mobile/src/components/lineup/TrackLineup.tsx index f95ded43ac5..bebabeee22b 100644 --- a/packages/mobile/src/components/lineup/TrackLineup.tsx +++ b/packages/mobile/src/components/lineup/TrackLineup.tsx @@ -20,7 +20,11 @@ import { type UID } from '@audius/common/models' import { playbackActions, playbackSelectors } from '@audius/common/store' -import type { PlaybackQuerySource, PlaybackTrack } from '@audius/common/store' +import type { + CommonState, + PlaybackQuerySource, + PlaybackTrack +} from '@audius/common/store' import { makeStableUid } from '@audius/common/utils' import { EntityType } from '@audius/sdk' import { range } from 'lodash' @@ -30,7 +34,7 @@ import type { ViewStyle } from 'react-native' import { StyleSheet, View } from 'react-native' -import { useDispatch, useSelector } from 'react-redux' +import { useDispatch, useStore } from 'react-redux' import { SectionList } from 'app/components/core' import { useBottomChinHeight } from 'app/components/core/BottomChin' @@ -44,7 +48,6 @@ import { make, track } from 'app/services/analytics' const { makeGetCurrent } = playbackSelectors const { getPlaying } = playbackSelectors -const { getCurrentTrackId: getPlaybackCurrentTrackId } = playbackSelectors // Threshold for `onEndReachedThreshold` (fraction of viewport length). // 1.0 means "fetch when one full viewport of content remains below" so the @@ -183,12 +186,11 @@ export const TrackLineup = ({ const dispatch = useDispatch() const ref = useRef(null) + // Playback state is read when a tile is pressed rather than subscribed to, + // so play/pause/skip don't re-render every tile. Tiles select their own + // active/playing state. + const store = useStore() const getCurrentQueueItem = useMemo(() => makeGetCurrent(), []) - const currentLegacy = useSelector(getCurrentQueueItem) - const isPlaying = useSelector(getPlaying) - // playback slice is the source of truth when present; subscribed here so - // tiles re-render when the current track changes. - useSelector(getPlaybackCurrentTrackId) const uidFor = useCallback( (id: ID) => makeStableUid(Kind.TRACKS, id, source), @@ -242,6 +244,9 @@ export const TrackLineup = ({ source?: PlaybackSource collectionId?: ID }) => { + const state = store.getState() + const currentLegacy = getCurrentQueueItem(state) + const isPlaying = getPlaying(state) const currentTrackId = currentLegacy?.trackId ?? null const currentSource = currentLegacy?.source ?? null const isSameTile = currentTrackId === id && currentSource === source @@ -290,10 +295,9 @@ export const TrackLineup = ({ dispatch, tracksForPlayback, querySource, - currentLegacy?.trackId, - currentLegacy?.source, + store, + getCurrentQueueItem, source, - isPlaying, playbackSource ] ) @@ -448,6 +452,9 @@ export const TrackLineup = ({ ListEmptyComponent={LineupEmptyComponent} onEndReached={handleLoadMore} onEndReachedThreshold={LOAD_MORE_THRESHOLD} + // Keep ~5 screens of tiles mounted either side instead of the + // default 10, since feed/trending/profile lineups all stay mounted + windowSize={11} sections={isEmpty ? [] : sections} stickySectionHeadersEnabled={false} keyExtractor={(item: any, index: number) => { diff --git a/packages/mobile/src/components/now-playing-drawer/Artwork.tsx b/packages/mobile/src/components/now-playing-drawer/Artwork.tsx index d8e05ddb390..b0a18c8ffbc 100644 --- a/packages/mobile/src/components/now-playing-drawer/Artwork.tsx +++ b/packages/mobile/src/components/now-playing-drawer/Artwork.tsx @@ -45,9 +45,11 @@ const useStyles = makeStyles(({ palette }) => ({ type ArtworkProps = { track: Nullable + /** Whether the Now Playing drawer is open, so the artwork is visible */ + isOpen: boolean } -export const Artwork = ({ track }: ArtworkProps) => { +export const Artwork = ({ track, isOpen }: ArtworkProps) => { const styles = useStyles() const dominantColors = useSelector((state: CommonState) => @@ -81,7 +83,11 @@ export const Artwork = ({ track }: ArtworkProps) => { key={track?.track_id} trackId={track?.track_id} style={styles.image} - size={SquareSizes.SIZE_1000_BY_1000} + // The collapsed drawer hides the artwork, so don't load the full + // size for every track until the drawer opens + size={ + isOpen ? SquareSizes.SIZE_1000_BY_1000 : SquareSizes.SIZE_480_BY_480 + } /> {shouldShowDogEar ? ( diff --git a/packages/mobile/src/components/now-playing-drawer/NowPlayingDrawer.tsx b/packages/mobile/src/components/now-playing-drawer/NowPlayingDrawer.tsx index cd355cd834a..6cae3a23f58 100644 --- a/packages/mobile/src/components/now-playing-drawer/NowPlayingDrawer.tsx +++ b/packages/mobile/src/components/now-playing-drawer/NowPlayingDrawer.tsx @@ -364,7 +364,7 @@ export const NowPlayingDrawer = memo(function NowPlayingDrawer( - + { renderItem={renderItem} onEndReached={handleScrollToTop} inverted - initialNumToRender={chatMessages?.length} + // Enough to show the screen and reach the earliest unread + // message, without rendering the whole cached history + initialNumToRender={Math.max( + INITIAL_MESSAGES_TO_RENDER, + (earliestUnreadIndex ?? 0) + INITIAL_MESSAGES_TO_RENDER + )} ref={flatListRef} onScroll={handleScroll} onScrollToIndexFailed={handleScrollToIndexFailed} diff --git a/packages/mobile/src/screens/library-screen/TracksTab.tsx b/packages/mobile/src/screens/library-screen/TracksTab.tsx index 39cc8cef66f..ce65fd5d27e 100644 --- a/packages/mobile/src/screens/library-screen/TracksTab.tsx +++ b/packages/mobile/src/screens/library-screen/TracksTab.tsx @@ -11,11 +11,11 @@ import { playbackSelectors, reachabilitySelectors } from '@audius/common/store' -import type { PlaybackTrack } from '@audius/common/store' +import type { CommonState, PlaybackTrack } from '@audius/common/store' import { makeStableUid, type Nullable } from '@audius/common/utils' import { debounce } from 'lodash' import { View } from 'react-native' -import { useDispatch, useSelector } from 'react-redux' +import { useDispatch, useSelector, useStore } from 'react-redux' import { BottomChin } from 'app/components/core/BottomChin' import { EmptyTileCTA } from 'app/components/empty-tile-cta' @@ -211,10 +211,9 @@ export const TracksTab = () => { fetchNextPage ]) - const currentPlaybackTrackId = useSelector( - playbackSelectors.getCurrentTrackId - ) - const isPlaying = useSelector(playbackSelectors.getPlaying) + // Read playback state at press time so play/pause/skip don't re-render the + // whole list + const store = useStore() // Matches legacy `saveTracksLineupActions.prefix` so AudioPlayer's // source-based offline-download check keeps working. @@ -230,6 +229,9 @@ export const TracksTab = () => { const togglePlay = useCallback( (_uid: UID, id: ID) => { + const state = store.getState() + const currentPlaybackTrackId = playbackSelectors.getCurrentTrackId(state) + const isPlaying = playbackSelectors.getPlaying(state) if (currentPlaybackTrackId === id && isPlaying) { dispatch(playbackActions.togglePlay()) return @@ -248,7 +250,7 @@ export const TracksTab = () => { }) ) }, - [dispatch, currentPlaybackTrackId, isPlaying, playbackQueue] + [dispatch, store, playbackQueue] ) const commitFilterValue = useMemo( diff --git a/packages/mobile/src/store/offline-downloads/slice.ts b/packages/mobile/src/store/offline-downloads/slice.ts index 16802c20781..00bf26597ca 100644 --- a/packages/mobile/src/store/offline-downloads/slice.ts +++ b/packages/mobile/src/store/offline-downloads/slice.ts @@ -456,7 +456,14 @@ export const actions = slice.actions const offlineDownloadsPersistConfig = { key: 'offline-downloads', storage: AsyncStorage, - blacklist: ['isDoneLoadingFromDisk', 'queueStatus', 'currentNetworkStateType'] + blacklist: [ + 'isDoneLoadingFromDisk', + 'queueStatus', + 'currentNetworkStateType' + ], + // Each job update otherwise re-serializes and rewrites the whole slice, + // which adds up during large batch downloads + throttle: 1000 } const persistedOfflineDownloadsReducer = persistReducer( diff --git a/packages/web/index.html b/packages/web/index.html index aa90302a969..0b872260e74 100644 --- a/packages/web/index.html +++ b/packages/web/index.html @@ -7,6 +7,18 @@ + + + + { + usePlaybackRatePersistence() + usePlaybackPositionPersistence() + usePlaybackPositionPolling() + return null +} diff --git a/packages/web/src/app/web-player/WebPlayer.tsx b/packages/web/src/app/web-player/WebPlayer.tsx index 9de5a30e0c5..ec49facb262 100644 --- a/packages/web/src/app/web-player/WebPlayer.tsx +++ b/packages/web/src/app/web-player/WebPlayer.tsx @@ -66,9 +66,6 @@ import PlayBarProvider from 'components/play-bar/PlayBarProvider' import { RewardClaimedToast } from 'components/reward-claimed-toast/RewardClaimedToast' import { useActivityPing } from 'hooks/useActivityPing' import { useEnvironment } from 'hooks/useEnvironment' -import { usePlaybackPositionPersistence } from 'hooks/usePlaybackPositionPersistence' -import { usePlaybackPositionPolling } from 'hooks/usePlaybackPositionPolling' -import { usePlaybackRatePersistence } from 'hooks/usePlaybackRatePersistence' import { MAIN_CONTENT_ID, MainContentContext } from 'pages/MainContentContext' import { TableType } from 'pages/pay-and-earn-page/types' import { SubPage } from 'pages/settings-page/types' @@ -81,6 +78,7 @@ import { shouldShowCookieBanner } from 'utils/gdpr' import 'utils/redirect' import { CREATE_PLAYLIST_PAGE, getPathname } from 'utils/route' +import { PlaybackPersistence } from './PlaybackPersistence' import styles from './WebPlayer.module.css' const { getFrostedSurfaceIntensity } = themeSelectors const TrendingGenreSelectionPage = lazy( @@ -510,9 +508,6 @@ const WebPlayer = (props: WebPlayerProps) => { useSelector(getFrostedSurfaceIntensity) ?? FrostedSurfaceIntensity.DEFAULT useActivityPing() - usePlaybackRatePersistence() - usePlaybackPositionPersistence() - usePlaybackPositionPolling() // Convert mapDispatchToProps to useCallback with useDispatch const updateRouteOnSignUpCompletion = useCallback( @@ -1704,6 +1699,7 @@ const WebPlayer = (props: WebPlayerProps) => { + diff --git a/packages/web/src/components/lineup/TrackLineup.tsx b/packages/web/src/components/lineup/TrackLineup.tsx index 9f44b9b15ce..8ff51976635 100644 --- a/packages/web/src/components/lineup/TrackLineup.tsx +++ b/packages/web/src/components/lineup/TrackLineup.tsx @@ -5,6 +5,7 @@ import { usePlayTrack, usePauseTrack } from '@audius/common/hooks' import { FeedTab, ID, PlaybackSource, Name } from '@audius/common/models' import { playbackActions, playbackSelectors } from '@audius/common/store' import type { + CommonState, PlaybackTrack, PlaybackQuerySource, QueueSource @@ -12,7 +13,7 @@ import type { import { Divider, Flex } from '@audius/harmony' import { EntityType } from '@audius/sdk' import cn from 'classnames' -import { useDispatch, useSelector } from 'react-redux' +import { useDispatch, useStore } from 'react-redux' import { make } from 'common/store/analytics/actions' import { CollectionTile as DesktopCollectionTile } from 'components/track/desktop/CollectionTile' @@ -42,7 +43,6 @@ const LOAD_MORE_VIEWPORTS = 2 const { getPlaying: getPlayerPlaying } = playbackSelectors const { makeGetCurrent } = playbackSelectors -const { getCurrentTrackId: getPlaybackCurrentTrackId } = playbackSelectors export type TrackLineupProps = { // Ordered list of track IDs to render. Tiles read full track data from the @@ -168,16 +168,11 @@ export const TrackLineup = ({ ? MobileCollectionTile : DesktopCollectionTile - // For tile highlight: prefer new playback slice's current track when - // present, else fall back to legacy current (non-trending flows). Also - // reads isPlaying/isBuffering from legacy state (the audio service still - // drives those via the player slice). + // Playback state is read when a tile is clicked rather than subscribed to, + // so play/pause/skip don't rebuild every tile. Tiles select their own + // active/playing state. + const store = useStore() const getCurrentQueueItem = useMemo(() => makeGetCurrent(), []) - const currentLegacy = useSelector(getCurrentQueueItem) - // Subscribe so tiles re-render when the playback slice's current track - // changes (even if legacy queue hasn't caught up yet). - useSelector(getPlaybackCurrentTrackId) - const isPlaying = useSelector(getPlayerPlaying) // Build a single ordered list of mixed track/collection entries. When the // caller passes `lineupItems` (mixed feed) we use it verbatim; otherwise we @@ -206,6 +201,9 @@ export const TrackLineup = ({ const togglePlay = useCallback( (trackId: ID, clickSource?: PlaybackSource) => { const analytics = clickSource || playbackSource + const state = store.getState() + const currentLegacy = getCurrentQueueItem(state) + const isPlaying = getPlayerPlaying(state) const currentTrackId = currentLegacy?.trackId ?? null const currentSource = currentLegacy?.source ?? null const isSameTile = currentTrackId === trackId && currentSource === source @@ -251,10 +249,9 @@ export const TrackLineup = ({ tracksForPlayback, playbackTrackIds, querySource, - currentLegacy?.trackId, - currentLegacy?.source, + store, + getCurrentQueueItem, source, - isPlaying, playbackSource, feedType ] diff --git a/packages/web/src/components/now-playing/NowPlaying.tsx b/packages/web/src/components/now-playing/NowPlaying.tsx index 8abc7a55209..491f674cf5e 100644 --- a/packages/web/src/components/now-playing/NowPlaying.tsx +++ b/packages/web/src/components/now-playing/NowPlaying.tsx @@ -178,11 +178,18 @@ const NowPlaying = g( const startSeeking = useCallback(() => { clearInterval(seekInterval.current) - seekInterval.current = window.setInterval(async () => { + seekInterval.current = window.setInterval(() => { if (!audioPlayer) return - const position = await audioPlayer.getPosition() - const duration = await audioPlayer.getDuration() - setTiming({ position, duration }) + const position = audioPlayer.getPosition() + const duration = audioPlayer.getDuration() + // Only re-render when the displayed second or the duration changes; + // the scrubber animates itself between updates + setTiming((prev) => + Object.is(Math.floor(prev.position), Math.floor(position)) && + Object.is(prev.duration, duration) + ? prev + : { position, duration } + ) }, SEEK_INTERVAL) }, [setTiming]) @@ -325,15 +332,15 @@ const NowPlaying = g( const onPrevious = () => { const isLongFormContent = track?.genre === Genre.Podcasts || track?.genre === Genre.Audiobooks + const position = audioPlayer?.getPosition() ?? timing.position if (isLongFormContent) { - const position = timing.position const newPosition = position - SKIP_DURATION_SEC seek(Math.max(0, newPosition)) // Update mediakey so scrubber updates setTiming({ position: newPosition, duration: timing.duration }) setMediaKey((mediaKey) => mediaKey + 1) } else { - const shouldGoToPrevious = timing.position < RESTART_THRESHOLD_SEC + const shouldGoToPrevious = position < RESTART_THRESHOLD_SEC if (shouldGoToPrevious) { previous() } else { @@ -346,7 +353,8 @@ const NowPlaying = g( const isLongFormContent = track?.genre === Genre.Podcasts || track?.genre === Genre.Audiobooks if (isLongFormContent) { - const newPosition = timing.position + SKIP_DURATION_SEC + const position = audioPlayer?.getPosition() ?? timing.position + const newPosition = position + SKIP_DURATION_SEC seek(Math.min(newPosition, timing.duration)) // Update mediakey so scrubber updates setTiming({ position: newPosition, duration: timing.duration }) diff --git a/packages/web/src/components/play-bar/desktop/PlayBar.tsx b/packages/web/src/components/play-bar/desktop/PlayBar.tsx index 30bb3626c31..81a7bc02fd0 100644 --- a/packages/web/src/components/play-bar/desktop/PlayBar.tsx +++ b/packages/web/src/components/play-bar/desktop/PlayBar.tsx @@ -113,14 +113,20 @@ const PlayBar = () => { const playable = !!playingTrackId - // Update timing state + // Update timing state. Only re-render when the displayed second or the + // duration changes; the scrubber animates itself between updates. const startSeeking = useCallback(() => { clearInterval(seekInterval.current) - seekInterval.current = window.setInterval(async () => { + seekInterval.current = window.setInterval(() => { if (!audioPlayer) return - const position = await audioPlayer.getPosition() - const duration = await audioPlayer.getDuration() - setTiming({ position, duration }) + const position = audioPlayer.getPosition() + const duration = audioPlayer.getDuration() + setTiming((prev) => + Object.is(Math.floor(prev.position), Math.floor(position)) && + Object.is(prev.duration, duration) + ? prev + : { position, duration } + ) }, 500) // SEEK_INTERVAL }, [setTiming]) @@ -212,14 +218,16 @@ const PlayBar = () => { dispatch(shuffle({ enable: false })) }, [dispatch]) + // Read the position from the player rather than state so these callbacks + // (and the hotkeys bound to them) don't change on every timing update const onPrevious = useCallback(() => { if (isLongFormContent) { - const position = timing.position || 0 + const position = audioPlayer?.getPosition() || 0 const newPosition = position - SKIP_DURATION_SEC dispatch(seek({ seconds: Math.max(0, newPosition) })) setMediaKey((prev) => prev + 1) } else { - const position = timing.position || 0 + const position = audioPlayer?.getPosition() || 0 const shouldGoToPrevious = position < RESTART_THRESHOLD_SEC if (shouldGoToPrevious) { dispatch(previous()) @@ -227,19 +235,19 @@ const PlayBar = () => { dispatch(reset({ shouldAutoplay: true })) } } - }, [isLongFormContent, dispatch, timing.position]) + }, [isLongFormContent, dispatch]) const onNext = useCallback(() => { if (isLongFormContent) { - const duration = timing.duration || 0 - const position = timing.position || 0 + const duration = audioPlayer?.getDuration() || 0 + const position = audioPlayer?.getPosition() || 0 const newPosition = position + SKIP_DURATION_SEC dispatch(seek({ seconds: Math.min(newPosition, duration) })) setMediaKey((prev) => prev + 1) } else { dispatch(next({ skip: true })) } - }, [isLongFormContent, dispatch, timing.position, timing.duration]) + }, [isLongFormContent, dispatch]) // Effects useEffect(() => { diff --git a/packages/web/src/components/play-bar/mobile/PlayBar.tsx b/packages/web/src/components/play-bar/mobile/PlayBar.tsx index fae9ba8ef38..9aff3d096a7 100644 --- a/packages/web/src/components/play-bar/mobile/PlayBar.tsx +++ b/packages/web/src/components/play-bar/mobile/PlayBar.tsx @@ -48,6 +48,42 @@ const messages = { preview: 'preview' } +/** + * Owns the progress state so the 200ms updates only re-render the bar, + * not the whole PlayBar + */ +const PlayBarTrackingBar = ({ + isPlaying, + trackId: queueTrackId +}: { + isPlaying: boolean + trackId: ID | null +}) => { + const [percentComplete, setPercentComplete] = useState(0) + + // Sync right away whenever play state or the track changes, then poll. + // Polling while paused picks up seeks; an unchanged percent doesn't render. + useEffect(() => { + const updatePercentComplete = () => { + if (!audioPlayer) { + return + } + const duration = audioPlayer.getDuration() + const pos = audioPlayer.getPosition() + if (duration === undefined || pos === undefined) return + + const position = Math.min(pos, duration) + const percent = (position / duration) * 100 + if (percent) setPercentComplete(percent) + } + updatePercentComplete() + const seekInterval = setInterval(updatePercentComplete, SEEK_INTERVAL) + return () => clearInterval(seekInterval) + }, [isPlaying, queueTrackId]) + + return +} + type OwnProps = { onClickInfo: () => void } @@ -70,25 +106,8 @@ const PlayBar = ({ const isMatrixMode = useIsMatrix() const { data: user } = useUser(track?.owner_id) - const [percentComplete, setPercentComplete] = useState(0) const record = useRecord() - useEffect(() => { - const seekInterval = setInterval(async () => { - if (!audioPlayer) { - return - } - const duration = await audioPlayer.getDuration() - const pos = await audioPlayer.getPosition() - if (duration === undefined || pos === undefined) return - - const position = Math.min(pos, duration) - const percent = (position / duration) * 100 - if (percent) setPercentComplete(percent) - }, SEEK_INTERVAL) - return () => clearInterval(seekInterval) - }) - const { imageUrl: image, hasNoArtwork } = useTrackCoverArt({ trackId: track ? track.track_id : undefined, size: SquareSizes.SIZE_150_BY_150, @@ -152,7 +171,7 @@ const PlayBar = ({ return ( <>
- +
{shouldShowPreviewLock || isUnlisted ? null : ( { const dispatch = useDispatch() const positions = useSelector(getPlaybackPositions) + const playing = useSelector(getPlaying) + const trackId = useSelector(getTrackId) const { data: currentUserId } = useCurrentUserId() const hasHydrated = useRef(false) @@ -59,8 +67,45 @@ export const usePlaybackPositionPersistence = () => { } }, [currentUserId, dispatch]) + const pendingPositions = useRef(null) + const persistTimeout = useRef | null>(null) + + const flush = useCallback(() => { + if (persistTimeout.current) { + clearTimeout(persistTimeout.current) + persistTimeout.current = null + } + if (!pendingPositions.current) return + localStorage.setItem( + PLAYBACK_POSITION_LS_KEY, + JSON.stringify(pendingPositions.current) + ) + pendingPositions.current = null + }, []) + useEffect(() => { if (!hasHydrated.current) return - localStorage.setItem(PLAYBACK_POSITION_LS_KEY, JSON.stringify(positions)) - }, [positions]) + pendingPositions.current = positions + if (!persistTimeout.current) { + persistTimeout.current = setTimeout(flush, PERSIST_INTERVAL_MS) + } + }, [positions, flush]) + + // Write right away when playback starts, pauses or moves to another track + useEffect(() => { + flush() + }, [playing, trackId, flush]) + + useEffect(() => { + const handleVisibilityChange = () => { + if (document.visibilityState === 'hidden') flush() + } + window.addEventListener('pagehide', flush) + document.addEventListener('visibilitychange', handleVisibilityChange) + return () => { + window.removeEventListener('pagehide', flush) + document.removeEventListener('visibilitychange', handleVisibilityChange) + flush() + } + }, [flush]) } diff --git a/packages/web/src/hooks/usePlaybackPositionPolling.ts b/packages/web/src/hooks/usePlaybackPositionPolling.ts index be40661ba3e..0eb3b3e4e4f 100644 --- a/packages/web/src/hooks/usePlaybackPositionPolling.ts +++ b/packages/web/src/hooks/usePlaybackPositionPolling.ts @@ -20,23 +20,24 @@ export const usePlaybackPositionPolling = () => { const trackId = useSelector(getTrackId) const playing = useSelector(getPlaying) const { data: currentUserId } = useCurrentUserId() - const { data: track } = useTrack(trackId) + const { data: isLongForm = false } = useTrack(trackId, { + select: isLongFormContent + }) - const stateRef = useRef({ trackId, playing, currentUserId, track }) - stateRef.current = { trackId, playing, currentUserId, track } + const stateRef = useRef({ trackId, playing, currentUserId, isLongForm }) + stateRef.current = { trackId, playing, currentUserId, isLongForm } useEffect(() => { const player = audioPlayer - if (!player) return + if (!player || !playing || !isLongForm) return const interval = setInterval(() => { const { trackId: tId, playing: isPlaying, currentUserId: uId, - track: t + isLongForm: longForm } = stateRef.current - if (!uId || !tId || !isPlaying) return - if (!isLongFormContent(t)) return + if (!uId || !tId || !isPlaying || !longForm) return const position = player.getPosition() dispatch( setTrackPosition({ @@ -50,5 +51,5 @@ export const usePlaybackPositionPolling = () => { ) }, RECORD_PLAYBACK_POSITION_INTERVAL) return () => clearInterval(interval) - }, [dispatch]) + }, [dispatch, playing, isLongForm]) } diff --git a/packages/web/src/pages/collection-page/useCollectionPage.ts b/packages/web/src/pages/collection-page/useCollectionPage.ts index 9c36829f309..b4331e1ab1d 100644 --- a/packages/web/src/pages/collection-page/useCollectionPage.ts +++ b/packages/web/src/pages/collection-page/useCollectionPage.ts @@ -200,7 +200,8 @@ export const useCollectionPage = ( const playerBehavior = useSelector(getPlayerBehavior) const previewing = playerBehavior === PlayerBehavior.PREVIEW_OR_FULL const status = tracksStatus - const currentQueueItem = useSelector(makeGetCurrent()) + const getCurrentQueueItem = useMemo(() => makeGetCurrent(), []) + const currentQueueItem = useSelector(getCurrentQueueItem) const order = useMemo(() => { const map: Record = {} sortedEntries.forEach((entry, idx) => { diff --git a/packages/web/src/pages/library-page/hooks/useLibraryCollections.tsx b/packages/web/src/pages/library-page/hooks/useLibraryCollections.tsx index 46041553495..f53fb0eb646 100644 --- a/packages/web/src/pages/library-page/hooks/useLibraryCollections.tsx +++ b/packages/web/src/pages/library-page/hooks/useLibraryCollections.tsx @@ -33,13 +33,15 @@ export const useLibraryCollections = ({ : getSelectedCategoryLocalPlaylistAdds(state) }) - const locallyRemovedCollectionIds = useSelector((state: CommonState) => { - const ids = - collectionType === 'album' - ? getSelectedCategoryLocalAlbumRemovals(state) - : getSelectedCategoryLocalPlaylistRemovals(state) - return new Set(ids) - }) + const locallyRemovedIds = useSelector((state: CommonState) => + collectionType === 'album' + ? getSelectedCategoryLocalAlbumRemovals(state) + : getSelectedCategoryLocalPlaylistRemovals(state) + ) + const locallyRemovedCollectionIds = useMemo( + () => new Set(locallyRemovedIds), + [locallyRemovedIds] + ) const { data: fetchedCollectionIds, diff --git a/packages/web/src/pages/profile-page/components/desktop/ProfilePage.tsx b/packages/web/src/pages/profile-page/components/desktop/ProfilePage.tsx index ac25f22a969..6ee7b8bdab9 100644 --- a/packages/web/src/pages/profile-page/components/desktop/ProfilePage.tsx +++ b/packages/web/src/pages/profile-page/components/desktop/ProfilePage.tsx @@ -193,8 +193,12 @@ const ProfilePage = ({ containerRef }: ProfilePageProps) => { () => ({ handle: handleLower ?? '' }), [handleLower] ) + // Artists land on Tracks, so once we know the profile is an artist's, only + // fetch reposts when that tab is opened. Everyone else lands on Reposts. const userRepostsQuery = useProfileReposts(repostsArgs, { - enabled: !!handleLower + enabled: + !!handleLower && + (activeTab === ProfilePageTabs.REPOSTS || !profile || !isArtist) }) const repostsQuerySource = useMemo( () => ({ diff --git a/packages/web/src/pages/profile-page/components/mobile/ProfilePage.tsx b/packages/web/src/pages/profile-page/components/mobile/ProfilePage.tsx index 790ea834ca5..03aef932289 100644 --- a/packages/web/src/pages/profile-page/components/mobile/ProfilePage.tsx +++ b/packages/web/src/pages/profile-page/components/mobile/ProfilePage.tsx @@ -157,8 +157,12 @@ const ProfilePage = ({ containerRef }: ProfilePageProps) => { () => ({ handle: handleLower ?? '' }), [handleLower] ) + // Artists land on Tracks, so once we know the profile is an artist's, only + // fetch reposts when that tab is opened. Everyone else lands on Reposts. const userRepostsQuery = useProfileReposts(repostsArgs, { - enabled: !!handleLower + enabled: + !!handleLower && + (activeTab === ProfilePageTabs.REPOSTS || !profile || !isArtist) }) const repostsQuerySource = useMemo( () => ({ diff --git a/packages/web/src/public-site/pages/landing-2026/LandingPage2026.tsx b/packages/web/src/public-site/pages/landing-2026/LandingPage2026.tsx index 7876ae066fe..013775717c2 100644 --- a/packages/web/src/public-site/pages/landing-2026/LandingPage2026.tsx +++ b/packages/web/src/public-site/pages/landing-2026/LandingPage2026.tsx @@ -30,7 +30,7 @@ const BASE_PUBLIC_PATH = const LANDING_FONTS_CSS_HREF = `${BASE_PUBLIC_PATH}/fonts-landing-2026.css` const URBANIST_HREF = 'https://fonts.googleapis.com/css2?family=Urbanist:wght@400;500;600;700&display=swap' -const DUST_BUCER_OTF_HREF = `${BASE_PUBLIC_PATH}/fonts/DustBucer.otf` +const DUST_BUCER_WOFF2_HREF = `${BASE_PUBLIC_PATH}/fonts/DustBucer.woff2` type LandingPage2026Props = { isMobile: boolean @@ -163,8 +163,8 @@ export const LandingPage2026 = (props: LandingPage2026Props) => { diff --git a/packages/web/src/store/persistStorage.test.ts b/packages/web/src/store/persistStorage.test.ts index 7274a6d4196..0db6f06e110 100644 --- a/packages/web/src/store/persistStorage.test.ts +++ b/packages/web/src/store/persistStorage.test.ts @@ -11,6 +11,7 @@ vi.mock('localforage', () => ({ describe('persistStorage', () => { beforeEach(() => { + vi.unstubAllGlobals() window.localStorage.clear() localforageItems.clear() getItemMock.mockClear() @@ -51,4 +52,21 @@ describe('persistStorage', () => { await persistStorage.removeItem('persist:search') expect(await persistStorage.getItem('persist:search')).toBeNull() }) + + it('skips localforage when its database does not exist', async () => { + vi.stubGlobal('indexedDB', { databases: async () => [{ name: 'other' }] }) + localforageItems.set('persist:search', 'old') + expect(await persistStorage.getItem('persist:search')).toBeNull() + expect(await persistStorage.getItem('persist:search')).toBeNull() + expect(getItemMock).not.toHaveBeenCalled() + }) + + it('migrates when the localforage database exists', async () => { + vi.stubGlobal('indexedDB', { + databases: async () => [{ name: 'localforage' }] + }) + localforageItems.set('persist:search', 'old') + expect(await persistStorage.getItem('persist:search')).toBe('old') + expect(getItemMock).toHaveBeenCalledTimes(1) + }) }) diff --git a/packages/web/src/store/persistStorage.ts b/packages/web/src/store/persistStorage.ts index 30293796b1a..e22951fc658 100644 --- a/packages/web/src/store/persistStorage.ts +++ b/packages/web/src/store/persistStorage.ts @@ -13,12 +13,30 @@ const getLocalStorage = () => { } } +// localforage's default IndexedDB database. A browser without it (every new +// visitor) has nothing to migrate, so skip loading localforage at all. +const LOCALFORAGE_DB_NAME = 'localforage' + +const mayHaveLocalforageData = async () => { + try { + if (typeof indexedDB?.databases !== 'function') return true + const databases = await indexedDB.databases() + return databases.some(({ name }) => name === LOCALFORAGE_DB_NAME) + } catch { + return true + } +} + const migrateFromLocalforage = async ( localStorage: globalThis.Storage, key: string ): Promise => { let value: string | null = null try { + if (!(await mayHaveLocalforageData())) { + localStorage.setItem(MIGRATED_PREFIX + key, 'true') + return null + } const { default: localforage } = await import('localforage') const stored = await localforage.getItem(key) if (typeof stored === 'string') { diff --git a/packages/web/vite.config.ts b/packages/web/vite.config.ts index d071f4471aa..70e95acd1b6 100644 --- a/packages/web/vite.config.ts +++ b/packages/web/vite.config.ts @@ -91,6 +91,33 @@ const nobleDedupe = (): Plugin => ({ } }) +/** + * Harmony inlines every Avenir weight as base64 (~240 KB, avenir.css) so other + * consumers work without extra setup. Web serves the same fonts as separate + * woff2 files from public/fonts.css instead, so drop the inlined copies, both + * from the harmony source web aliases to and from dist/harmony.css. + */ +const stripHarmonyFonts = (): Plugin => ({ + name: 'strip-harmony-fonts', + enforce: 'pre', + transform(code, id) { + const file = id.split('?')[0] + if (file.endsWith('/harmony/src/foundations/typography/avenir.css')) { + return { code: '', map: null } + } + if (file.endsWith('/harmony/dist/harmony.css')) { + return { + code: code.replace( + /@font-face\s*\{[^}]*Avenir Next LT Pro[^}]*\}/g, + '' + ), + map: null + } + } + return null + } +}) + const SOURCEMAP_URL = 'https://s3.us-west-1.amazonaws.com/sourcemaps.audius.co/' // Matches what mobile's react-native-svg-transformer runs on the same icons, @@ -239,6 +266,7 @@ export default defineConfig(async ({ mode }) => { } }, nobleDedupe(), + stripHarmonyFonts(), // bip39 bundles every language's wordlist; keys only use English { name: 'bip39-english-only',