From 131320e35d752ec7658abe52cf66ca795b183180 Mon Sep 17 00:00:00 2001 From: Dylan Audius Date: Fri, 29 May 2026 17:11:43 -0700 Subject: [PATCH 1/2] fix(mobile): rich CollectionTile in lineups (large artwork + track list + duration) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In the feed lineup, mobile's CollectionTile rendered as a compact row (small 72×72 thumbnail beside title/artist, no track list, no duration) — missing the rich card layout the web's mobile view shows for the same data (large artwork, "PLAYLIST"/"ALBUM" label, title, duration, 5-row numbered track list with "by ", "+N Tracks" overflow). Two root causes, both fixed: 1. **No UID was passed in**. After #14411 wired CollectionTile into TrackLineup, the entry was constructed with only the collection ID. `useEnhancedCollectionTracks` requires a UID to derive per-row track UIDs; called with `''`, it returns `[]`. With zero tracks, the sum `duration` is `0` (so the formatted text was hidden) and the `CollectionTileTrackList` body rendered empty. - TrackLineup: add a `Kind.COLLECTIONS` `uidFor` and include `uid: collectionUidFor(item.id)` on `CollectionEntry`; pass `uid` to ``. 2. **Wrong layout for the lineup context.** `LineupTileMetadata` is a row-layout (image beside title) with `LineupTileArt` hardcoded at 72×72, and `LineupTileTopRight` hardcodes `isCollection={false}` (so duration is formatted with the wrong shape). Bypassed it for the collection variant and inlined a card header: full-width square 480×480 `CollectionImage`, then `PLAYLIST`/`ALBUM` label, title row with duration on the right (`formatLineupTileDuration(_, false, true)` → "15m 32s"), then the artist link. Stats / track list / action buttons unchanged. 3. **Skeleton during fetch.** Pass `isLoading={tracks.length === 0 && expectedTrackCount > 0}` so `CollectionTileTrackList` renders skeleton rows while tracks are loading, instead of an empty block that looks like the old compact tile. Chat unfurled collections and explore-screen carousel both already pass `collection` and `tracks` (or use sufficiently wide layouts), so the richer card sits inside their existing constraints fine. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 (1M context) --- .../components/lineup-tile/CollectionTile.tsx | 109 ++++++++++++++---- .../src/components/lineup/TrackLineup.tsx | 19 ++- 2 files changed, 101 insertions(+), 27 deletions(-) diff --git a/packages/mobile/src/components/lineup-tile/CollectionTile.tsx b/packages/mobile/src/components/lineup-tile/CollectionTile.tsx index 122d5e325bc..7994cddef3e 100644 --- a/packages/mobile/src/components/lineup-tile/CollectionTile.tsx +++ b/packages/mobile/src/components/lineup-tile/CollectionTile.tsx @@ -20,13 +20,16 @@ import { PurchaseableContentType } from '@audius/common/store' import type { CommonState } from '@audius/common/store' -import { removeNullable } from '@audius/common/utils' +import { formatLineupTileDuration, removeNullable } from '@audius/common/utils' +import { TouchableOpacity, View } from 'react-native' import { useDispatch, useSelector } from 'react-redux' -import { Paper, type ImageProps } from '@audius/harmony-native' +import { Flex, Paper, Text, type ImageProps } from '@audius/harmony-native' +import { UserLink } from 'app/components/user-link' import { useNavigation } from 'app/hooks/useNavigation' import { setVisibility } from 'app/store/drawers/slice' import { getIsCollectionMarkedForDownload } from 'app/store/offline-downloads/selectors' +import { makeStyles } from 'app/styles' import { CollectionDogEar } from '../collection/CollectionDogEar' import { CollectionImage } from '../image/CollectionImage' @@ -34,7 +37,6 @@ import { CollectionImage } from '../image/CollectionImage' import { CollectionTileStats } from './CollectionTileStats' import { CollectionTileTrackList } from './CollectionTileTrackList' import { LineupTileActionButtons } from './LineupTileActionButtons' -import { LineupTileMetadata } from './LineupTileMetadata' import { TilePressBlockContext } from './TilePressBlockContext' import { LineupTileSource, type CollectionTileProps } from './types' import { useEnhancedCollectionTracks } from './useEnhancedCollectionTracks' @@ -49,6 +51,29 @@ const { unsaveCollection } = collectionsSocialActions +const useStyles = makeStyles(({ spacing }) => ({ + artworkWrapper: { + width: '100%', + aspectRatio: 1, + overflow: 'hidden' + }, + artwork: { + width: '100%', + height: '100%' + }, + metadata: { + paddingHorizontal: spacing(4), + paddingVertical: spacing(3), + gap: spacing(1) + }, + titleTouchable: { + flex: 1 + }, + artistTouchable: { + alignSelf: 'flex-start' + } +})) + export const CollectionTile = (props: CollectionTileProps) => { const { uid, @@ -64,6 +89,7 @@ export const CollectionTile = (props: CollectionTileProps) => { const dispatch = useDispatch() const navigation = useNavigation() + const styles = useStyles() const { data: currentUserId } = useCurrentUserId() const { data: cachedCollection } = useCollection(id, { @@ -97,10 +123,6 @@ export const CollectionTile = (props: CollectionTileProps) => { const trackId = getTrackId(state) return tracks.find((track) => track.track_id === trackId) ?? null }) - const isPlayingUid = useSelector((state: CommonState) => { - const trackId = getTrackId(state) - return tracks.some((track) => track.track_id === trackId) - }) const isCollectionMarkedForDownload = useSelector((state) => collection @@ -114,7 +136,7 @@ export const CollectionTile = (props: CollectionTileProps) => { (props: ImageProps) => ( ), @@ -155,6 +177,15 @@ export const CollectionTile = (props: CollectionTileProps) => { ) }, [tracks]) + const expectedTrackCount = + collection?.playlist_contents?.track_ids?.length ?? 0 + // Tracks are fetched lazily via useEnhancedCollectionTracks; while they're + // loading, `tracks` is `[]` even though the collection has track_ids. We + // surface that to CollectionTileTrackList so it renders skeleton rows + // instead of an empty block (which is what previously made the tile look + // "compact" along with the missing duration). + const tracksLoading = tracks.length === 0 && expectedTrackCount > 0 + const handlePressOverflow = useCallback(() => { if (!collection) return const isOwner = collection.playlist_owner_id === currentUserId @@ -224,9 +255,6 @@ export const CollectionTile = (props: CollectionTileProps) => { }, [collection, dispatch]) if (!collection || !tracks || !user) { - console.warn( - 'Collection, tracks, or user missing for CollectionTile, preventing render' - ) return null } @@ -237,23 +265,55 @@ export const CollectionTile = (props: CollectionTileProps) => { const isOwner = collection.playlist_owner_id === currentUserId const isReadonly = variant === 'readonly' const contentType = collection.is_album ? 'album' : 'playlist' + const durationText = + duration > 0 ? formatLineupTileDuration(duration, false, true) : null return ( - + + {/* Card-style header: large square artwork above title + meta */} + + {renderImage({ style: styles.artwork })} + + + + + {contentType} + + + + + {collection.playlist_name} + + + {durationText ? ( + + {durationText} + + ) : null} + + + + + + + + { onPress={handlePressTitle} onPressWithPropagationBlock={handlePressWithPropagationBlock} isAlbum={collection.is_album} - trackCount={tracks.length} + trackCount={expectedTrackCount || tracks.length} + isLoading={tracksLoading} /> {isReadonly ? null : ( makeStableUid(Kind.TRACKS, id, source), [source] ) + // CollectionTile's useEnhancedCollectionTracks expects a UID (not just an + // ID) so it can derive a unique track UID per row in the playlist. Build + // one per collection entry — without it the tile renders empty (no track + // list, no duration). + const collectionUidFor = useCallback( + (id: ID) => makeStableUid(Kind.COLLECTIONS, id, source), + [source] + ) // Build a single ordered list of mixed track/collection entries. When the // caller passes `lineupItems` (mixed feed) we use it verbatim; otherwise we @@ -246,9 +254,13 @@ export const TrackLineup = ({ trackId: item.id, uid: uidFor(item.id) } - : { kind: 'collection' as const, collectionId: item.id } + : { + kind: 'collection' as const, + collectionId: item.id, + uid: collectionUidFor(item.id) + } ), - [visibleItems, uidFor] + [visibleItems, uidFor, collectionUidFor] ) // Synchronous "load more was triggered" flag — set the moment the scroll @@ -316,6 +328,7 @@ export const TrackLineup = ({ ) : ( Date: Fri, 29 May 2026 17:21:43 -0700 Subject: [PATCH 2/2] refactor(mobile): mirror web's collection-ID path for CollectionTile tracks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previous commit on this branch added a UID indirection to fetch the collection's tracks via the existing `useEnhancedCollectionTracks(uid)` hook. That was the wrong shape: web's mobile CollectionTile uses `useOrderedCollectionTracks(collection)` driven directly by the collection ID, with no UID anywhere in the read path. Mirror that exactly. - `CollectionTile.tsx`: drop the `uid` prop / destructure. Drop the custom `useCollection(id, {select})` (web reads the full collection). Replace `useEnhancedCollectionTracks(uid ?? '')` with `useOrderedCollectionTracks(cachedCollection)`. Update `handlePress` to pass only `id` to `togglePlay` — there is no per-row UID to forward (the new playback slice in TrackLineup ignores `uid` anyway and queues by `id`). - `CollectionTileTrackList.tsx`: switch the track type from `LineupTrack` to `CollectionTrack` (TrackMetadata). Mirror web's `TrackItem` exactly: fetch the artist name per row via `useUser(track?.owner_id, { select: u => u?.name })` instead of reading the (no-longer-present) `track.user?.name` join. - `TrackLineup.tsx`: revert the previous commit's `collectionUidFor` and the `uid` field on `CollectionEntry` / on the `` call site. Pure ID flow now. Same visual outcome as before (large 480 artwork, label, title + duration row, artist link, 5-row track list with "by ", "+N Tracks" overflow, skeleton during fetch), but the data path now matches web exactly. No UIDs anywhere in CollectionTile. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 (1M context) --- .../components/lineup-tile/CollectionTile.tsx | 57 +++++++++---------- .../lineup-tile/CollectionTileTrackList.tsx | 19 +++++-- .../src/components/lineup/TrackLineup.tsx | 20 +------ 3 files changed, 44 insertions(+), 52 deletions(-) diff --git a/packages/mobile/src/components/lineup-tile/CollectionTile.tsx b/packages/mobile/src/components/lineup-tile/CollectionTile.tsx index 7994cddef3e..6974f6eb621 100644 --- a/packages/mobile/src/components/lineup-tile/CollectionTile.tsx +++ b/packages/mobile/src/components/lineup-tile/CollectionTile.tsx @@ -1,7 +1,13 @@ import { useCallback, useMemo, useRef } from 'react' -import { useCollection, useCurrentUserId, useUser } from '@audius/common/api' +import { + useCollection, + useCurrentUserId, + useOrderedCollectionTracks, + useUser +} from '@audius/common/api' import { useGatedCollectionAccess } from '@audius/common/hooks' +import type { CollectionTrack } from '@audius/common/api' import { ShareSource, RepostSource, @@ -9,7 +15,6 @@ import { PlaybackSource, SquareSizes } from '@audius/common/models' -import type { Track } from '@audius/common/models' import { collectionsSocialActions, mobileOverflowMenuUIActions, @@ -39,7 +44,6 @@ import { CollectionTileTrackList } from './CollectionTileTrackList' import { LineupTileActionButtons } from './LineupTileActionButtons' import { TilePressBlockContext } from './TilePressBlockContext' import { LineupTileSource, type CollectionTileProps } from './types' -import { useEnhancedCollectionTracks } from './useEnhancedCollectionTracks' const { getTrackId } = playbackSelectors const { requestOpen: requestOpenShareModal } = shareModalUIActions @@ -76,7 +80,6 @@ const useStyles = makeStyles(({ spacing }) => ({ export const CollectionTile = (props: CollectionTileProps) => { const { - uid, id, collection: collectionOverride, tracks: tracksOverride, @@ -92,22 +95,12 @@ export const CollectionTile = (props: CollectionTileProps) => { const styles = useStyles() const { data: currentUserId } = useCurrentUserId() - const { data: cachedCollection } = useCollection(id, { - select: (collection) => ({ - has_current_user_reposted: collection.has_current_user_reposted, - has_current_user_saved: collection.has_current_user_saved, - is_album: collection.is_album, - playlist_id: collection.playlist_id, - playlist_name: collection.playlist_name, - playlist_owner_id: collection.playlist_owner_id, - stream_conditions: collection.stream_conditions, - is_private: collection.is_private, - is_delete: collection.is_delete, - playlist_contents: collection.playlist_contents - }) - }) + // Mirror the web mobile CollectionTile path exactly: fetch the collection + // by ID (no select), feed it to useOrderedCollectionTracks. Returns plain + // CollectionTrack[] / TrackMetadata[] — no UID plumbing required. + const { data: cachedCollection } = useCollection(id) const collection = collectionOverride ?? cachedCollection - const collectionTracks = useEnhancedCollectionTracks(uid ?? '') + const collectionTracks = useOrderedCollectionTracks(cachedCollection) const tracks = tracksOverride ?? collectionTracks const { data: user } = useUser(collection?.playlist_owner_id, { @@ -153,9 +146,10 @@ export const CollectionTile = (props: CollectionTileProps) => { childPressedRef.current = false return } + const startTrackId = currentTrack?.track_id ?? tracks[0]?.track_id + if (!startTrackId) return togglePlay({ - uid: currentTrack?.uid ?? tracks[0]?.uid ?? null, - id: currentTrack?.track_id ?? tracks[0]?.track_id ?? null, + id: startTrackId, source: PlaybackSource.PLAYLIST_TILE_TRACK }) }, 100) @@ -172,19 +166,20 @@ export const CollectionTile = (props: CollectionTileProps) => { const duration = useMemo(() => { return tracks.reduce( - (duration: number, track: Track) => duration + track.duration, + (duration: number, track: CollectionTrack) => + duration + (track.duration ?? 0), 0 ) }, [tracks]) - const expectedTrackCount = - collection?.playlist_contents?.track_ids?.length ?? 0 - // Tracks are fetched lazily via useEnhancedCollectionTracks; while they're - // loading, `tracks` is `[]` even though the collection has track_ids. We - // surface that to CollectionTileTrackList so it renders skeleton rows - // instead of an empty block (which is what previously made the tile look - // "compact" along with the missing duration). - const tracksLoading = tracks.length === 0 && expectedTrackCount > 0 + // Use track_count if present on the collection (canonical full count); + // fall back to the loaded tracks length. Drives the skeleton state on the + // track list while tracks are still being fetched. + const trackCount = + collection?.track_count ?? + collection?.playlist_contents?.track_ids?.length ?? + tracks.length + const tracksLoading = tracks.length === 0 && trackCount > 0 const handlePressOverflow = useCallback(() => { if (!collection) return @@ -324,7 +319,7 @@ export const CollectionTile = (props: CollectionTileProps) => { onPress={handlePressTitle} onPressWithPropagationBlock={handlePressWithPropagationBlock} isAlbum={collection.is_album} - trackCount={expectedTrackCount || tracks.length} + trackCount={trackCount} isLoading={tracksLoading} /> {isReadonly ? null : ( diff --git a/packages/mobile/src/components/lineup-tile/CollectionTileTrackList.tsx b/packages/mobile/src/components/lineup-tile/CollectionTileTrackList.tsx index 9e0a45309a1..ff57a1a88c3 100644 --- a/packages/mobile/src/components/lineup-tile/CollectionTileTrackList.tsx +++ b/packages/mobile/src/components/lineup-tile/CollectionTileTrackList.tsx @@ -1,4 +1,6 @@ -import type { ID, LineupTrack } from '@audius/common/models' +import type { CollectionTrack } from '@audius/common/api' +import { useUser } from '@audius/common/api' +import type { ID } from '@audius/common/models' import type { CommonState } from '@audius/common/store' import { playbackSelectors } from '@audius/common/store' import { pluralize } from '@audius/common/utils' @@ -25,7 +27,7 @@ type LineupTileTrackListProps = { onPress: GestureResponderHandler onPressWithPropagationBlock?: () => void trackCount: number - tracks: LineupTrack[] + tracks: CollectionTrack[] isAlbum: boolean } @@ -77,7 +79,7 @@ const useStyles = makeStyles(({ palette, spacing, typography }) => ({ type TrackItemProps = { showSkeleton?: boolean index: number - track?: LineupTrack + track?: CollectionTrack trackId?: ID isAlbum?: boolean deleted?: boolean @@ -89,6 +91,15 @@ const TrackItem = (props: TrackItemProps) => { const isPlayingUid = useSelector( (state: CommonState) => getTrackId(state) === trackId ) + // Mirror the web mobile CollectionTile.TrackItem path exactly: tracks + // returned by `useOrderedCollectionTracks` are plain CollectionTrack / + // TrackMetadata (no joined `user`), so we fetch the owner's display name + // per row from the user cache. Hooks must run before any conditional + // returns; passing `undefined` to useUser when no track is present is + // safe (selector returns undefined). + const { data: trackOwnerName } = useUser(track?.owner_id, { + select: (user) => user?.name + }) return ( <> @@ -121,7 +132,7 @@ const TrackItem = (props: TrackItemProps) => { ]} numberOfLines={1} > - {`${messages.by} ${track.user?.name}`} + {`${messages.by} ${trackOwnerName ?? ''}`} ) : null} {deleted ? ( diff --git a/packages/mobile/src/components/lineup/TrackLineup.tsx b/packages/mobile/src/components/lineup/TrackLineup.tsx index 7e61457f4a7..9fe4ece185a 100644 --- a/packages/mobile/src/components/lineup/TrackLineup.tsx +++ b/packages/mobile/src/components/lineup/TrackLineup.tsx @@ -53,7 +53,7 @@ const styles = StyleSheet.create({ type LoadingItem = { _loading: true } type TrackEntry = { kind: 'track'; trackId: ID; uid: UID } -type CollectionEntry = { kind: 'collection'; collectionId: ID; uid: UID } +type CollectionEntry = { kind: 'collection'; collectionId: ID } type Entry = TrackEntry | CollectionEntry type RenderItem = Entry | LoadingItem @@ -172,15 +172,6 @@ export const TrackLineup = ({ (id: ID) => makeStableUid(Kind.TRACKS, id, source), [source] ) - // CollectionTile's useEnhancedCollectionTracks expects a UID (not just an - // ID) so it can derive a unique track UID per row in the playlist. Build - // one per collection entry — without it the tile renders empty (no track - // list, no duration). - const collectionUidFor = useCallback( - (id: ID) => makeStableUid(Kind.COLLECTIONS, id, source), - [source] - ) - // Build a single ordered list of mixed track/collection entries. When the // caller passes `lineupItems` (mixed feed) we use it verbatim; otherwise we // wrap the legacy `trackIds` so the rest of the component is uniform. @@ -254,13 +245,9 @@ export const TrackLineup = ({ trackId: item.id, uid: uidFor(item.id) } - : { - kind: 'collection' as const, - collectionId: item.id, - uid: collectionUidFor(item.id) - } + : { kind: 'collection' as const, collectionId: item.id } ), - [visibleItems, uidFor, collectionUidFor] + [visibleItems, uidFor] ) // Synchronous "load more was triggered" flag — set the moment the scroll @@ -328,7 +315,6 @@ export const TrackLineup = ({ ) : (