From 37bec1e1d8071e4739a2807ba72f4d97746613fe Mon Sep 17 00:00:00 2001 From: Dylan Audius Date: Thu, 22 Oct 2026 14:25:35 -0700 Subject: [PATCH] feat(mobile): Library tracks as rows, search-style filter, row press animation Library's Tracks tab renders the same TrackRow as Trending behind the track_rows flag: artwork, title with the offline download indicator, artist with badges, duration. Tap plays, long-press opens the actions. The old TrackList card stays for the flag-off path. FilterInput is now a flush search field (surface2 pill, search icon, clear button) instead of a Paper card. Every caller keeps the same props. TrackRow presses settle the row with a scale and surface2 highlight; a long-press dips further and springs back as the actions open. Co-Authored-By: Claude Fable 5.1 --- .../components/filter-input/FilterInput.tsx | 105 +++++++++++--- .../components/lineup-tile/TrackRowTile.tsx | 14 +- .../components/TrackRow/TrackRow.tsx | 129 ++++++++++++++---- .../library-screen/LibraryTrackRows.tsx | 102 ++++++++++++++ .../src/screens/library-screen/TracksTab.tsx | 29 ++++ 5 files changed, 337 insertions(+), 42 deletions(-) create mode 100644 packages/mobile/src/screens/library-screen/LibraryTrackRows.tsx diff --git a/packages/mobile/src/components/filter-input/FilterInput.tsx b/packages/mobile/src/components/filter-input/FilterInput.tsx index 90a3fe8be7d..97bc483c5d4 100644 --- a/packages/mobile/src/components/filter-input/FilterInput.tsx +++ b/packages/mobile/src/components/filter-input/FilterInput.tsx @@ -1,33 +1,102 @@ -import Animated, { Layout } from 'react-native-reanimated' +import { useCallback, useState } from 'react' -import type { PaperProps, TextInputProps } from '@audius/harmony-native' +import type { TextInputProps as RNTextInputProps } from 'react-native' +import { Pressable, TextInput as RNTextInput } from 'react-native' +import Animated, { LinearTransition } from 'react-native-reanimated' + +import type { FlexProps } from '@audius/harmony-native' import { - IconFilter, - Paper, - TextInput, - TextInputSize + Flex, + IconCloseAlt, + IconSearch, + useTheme } from '@audius/harmony-native' -type FilterInputProps = PaperProps & - Pick +const messages = { + clear: 'Clear filter' +} + +const INPUT_HEIGHT = 40 +type FilterInputProps = Omit & + Pick + +/** + * A search-style text field for narrowing a list in place. Sits flush on the + * page above the list it filters, with no card around it. + */ export const FilterInput = (props: FilterInputProps) => { - const { onChangeText, placeholder, value, autoFocus, ...paperProps } = props + const { + onChangeText, + placeholder, + value: valueProp, + autoFocus, + ...flexProps + } = props + const { color, typography, spacing } = useTheme() + + // Mirror the text so the clear button works for uncontrolled callers too. + const [internalValue, setInternalValue] = useState(valueProp ?? '') + const value = valueProp ?? internalValue + + const handleChangeText = useCallback( + (text: string) => { + setInternalValue(text) + onChangeText?.(text) + }, + [onChangeText] + ) + + const handleClear = useCallback(() => { + handleChangeText('') + }, [handleChangeText]) return ( - - - + + + } + onChangeText={handleChangeText} autoFocus={autoFocus} + autoCorrect={false} + autoCapitalize='none' + returnKeyType='search' + clearButtonMode='never' + style={{ + flex: 1, + height: INPUT_HEIGHT, + padding: 0, + fontFamily: typography.fontByWeight.medium, + fontSize: typography.size.m, + color: color.text.default + }} /> - + {value ? ( + + + + ) : null} + ) } diff --git a/packages/mobile/src/components/lineup-tile/TrackRowTile.tsx b/packages/mobile/src/components/lineup-tile/TrackRowTile.tsx index 6e3b0565a14..10b5c87e35d 100644 --- a/packages/mobile/src/components/lineup-tile/TrackRowTile.tsx +++ b/packages/mobile/src/components/lineup-tile/TrackRowTile.tsx @@ -37,6 +37,7 @@ import { useIsUSDCEnabled } from 'app/hooks/useIsUSDCEnabled' import { setVisibility } from 'app/store/drawers/slice' import { TrackImage } from '../image/TrackImage' +import { TrackDownloadStatusIndicator } from '../offline-downloads/TrackDownloadStatusIndicator' import { LineupTileAccessStatus } from './LineupTileAccessStatus' import { useOpenTrackOverflow } from './useOpenTrackOverflow' @@ -65,7 +66,12 @@ const overlayStyle = { backgroundColor: 'rgba(0,0,0,0.45)' } -const TrackRowTileComponent = (props: TrackTileProps) => { +export type TrackRowTileProps = TrackTileProps & { + /** Shows the offline download state after the title (Library). */ + showDownloadStatus?: boolean +} + +const TrackRowTileComponent = (props: TrackRowTileProps) => { const { id, uid, @@ -74,6 +80,7 @@ const TrackRowTileComponent = (props: TrackTileProps) => { togglePlay, onPress, showArtistPick = false, + showDownloadStatus = false, source } = props @@ -209,6 +216,11 @@ const TrackRowTileComponent = (props: TrackTileProps) => { } title={track.title} + titleAdornment={ + showDownloadStatus ? ( + + ) : undefined + } subtitle={ { rank, artwork, title, + titleAdornment, subtitle, status, duration, @@ -54,7 +74,42 @@ export const TrackRow = (props: TrackRowProps) => { onLongPress, accessibilityLabel } = props - const { color, spacing } = useTheme() + const { color, spacing, motion } = useTheme() + + const scale = useSharedValue(1) + const highlight = useSharedValue(0) + const longPressFiredRef = useRef(false) + + const handlePressIn = useCallback(() => { + longPressFiredRef.current = false + scale.value = withTiming(PRESS_SCALE, motion.press) + highlight.value = withTiming(1, motion.press) + }, [scale, highlight, motion.press]) + + const handlePressOut = useCallback(() => { + highlight.value = withTiming(0, motion.expressive) + // The long-press pop already ends at rest; don't cut it short. + if (longPressFiredRef.current) return + scale.value = withSpring(1, releaseSpring) + }, [scale, highlight, motion.expressive]) + + const handleLongPress = useCallback(() => { + if (!onLongPress) return + longPressFiredRef.current = true + scale.value = withSequence( + withTiming(LONG_PRESS_SCALE, motion.quick), + withSpring(1, popSpring) + ) + highlight.value = withTiming(0, motion.calm) + onLongPress() + }, [onLongPress, scale, highlight, motion.quick, motion.calm]) + + const rowStyle = useAnimatedStyle(() => ({ + transform: [{ scale: scale.value }] + })) + const highlightStyle = useAnimatedStyle(() => ({ + opacity: highlight.value + })) const rankCell = rank === undefined ? null : ( @@ -116,9 +171,11 @@ export const TrackRow = (props: TrackRowProps) => { } return ( - { onAccessibilityAction={(event) => { if (event.nativeEvent.actionName === 'longpress') onLongPress?.() }} - style={({ pressed }) => ({ - flexDirection: 'row', - alignItems: 'center', - gap: spacing.m, - minHeight: TRACK_ROW_HEIGHT, - paddingVertical: spacing.s, - paddingLeft, - paddingRight: spacing.l, - backgroundColor: pressed ? color.background.surface1 : undefined, - opacity: isDisabled ? 0.5 : 1 - })} + style={[ + { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.m, + minHeight: TRACK_ROW_HEIGHT, + paddingVertical: spacing.s, + paddingLeft, + paddingRight: spacing.l, + opacity: isDisabled ? 0.5 : 1 + }, + rowStyle + ]} > + {rankCell} { {context} - - {title} - + + {title} + + {titleAdornment} + {subtitle} {status ?? @@ -170,6 +253,6 @@ export const TrackRow = (props: TrackRowProps) => { {duration} ) : null)} - + ) } diff --git a/packages/mobile/src/screens/library-screen/LibraryTrackRows.tsx b/packages/mobile/src/screens/library-screen/LibraryTrackRows.tsx new file mode 100644 index 00000000000..3791c09675b --- /dev/null +++ b/packages/mobile/src/screens/library-screen/LibraryTrackRows.tsx @@ -0,0 +1,102 @@ +import { useCallback, useMemo } from 'react' + +import type { ID, UID } from '@audius/common/models' +import { getNumericIdFromUid } from '@audius/common/utils' +import type { FlashListProps } from '@shopify/flash-list' +import { FlashList } from '@shopify/flash-list' + +import { TrackRow } from '@audius/harmony-native' +import { TrackRowTile } from 'app/components/lineup-tile/TrackRowTile' + +type LoadingItem = { _loading: true } +type Item = UID | LoadingItem + +type LibraryTrackRowsProps = { + uids?: UID[] + /** Renders only skeleton rows. */ + showSkeleton?: boolean + skeletonRowCount?: number + /** Appends a page of skeleton rows while the next page loads. */ + hasNextPage?: boolean + pageSize?: number + togglePlay: (uid: UID, trackId: ID) => void +} & Pick< + FlashListProps, + | 'onEndReached' + | 'onEndReachedThreshold' + | 'ListFooterComponent' + | 'keyboardShouldPersistTaps' +> + +const DEFAULT_PAGE_SIZE = 10 +const MAX_SKELETON_ROWS = 50 +const LOADING_ITEM: LoadingItem = { _loading: true } + +const isLoadingItem = (item: Item): item is LoadingItem => + typeof item === 'object' && '_loading' in item + +const keyExtractor = (item: Item, index: number) => + isLoadingItem(item) ? `loading-${index}` : item + +/** + * Library tracks as dense rows, the same row Trending and profiles use. + * The list is full-bleed: the filter above it and the page provide the + * spacing, not a card. + */ +export const LibraryTrackRows = (props: LibraryTrackRowsProps) => { + const { + uids, + showSkeleton, + skeletonRowCount = 10, + hasNextPage, + pageSize = DEFAULT_PAGE_SIZE, + togglePlay, + ...listProps + } = props + + const data = useMemo(() => { + if (showSkeleton) { + const count = Math.min(Math.max(skeletonRowCount, 1), MAX_SKELETON_ROWS) + return Array.from({ length: count }, () => LOADING_ITEM) + } + const rows: Item[] = uids ?? [] + if (hasNextPage) { + return [...rows, ...Array.from({ length: pageSize }, () => LOADING_ITEM)] + } + return rows + }, [showSkeleton, skeletonRowCount, uids, hasNextPage, pageSize]) + + const handleTogglePlay = useCallback( + (args?: { uid?: UID; id: ID }) => { + if (args?.uid) togglePlay(args.uid, args.id) + }, + [togglePlay] + ) + + const renderItem = useCallback< + NonNullable['renderItem']> + >( + ({ item, index }) => { + if (isLoadingItem(item)) return + return ( + + ) + }, + [handleTogglePlay] + ) + + return ( + + ) +} diff --git a/packages/mobile/src/screens/library-screen/TracksTab.tsx b/packages/mobile/src/screens/library-screen/TracksTab.tsx index ce65fd5d27e..ce7dd281c4e 100644 --- a/packages/mobile/src/screens/library-screen/TracksTab.tsx +++ b/packages/mobile/src/screens/library-screen/TracksTab.tsx @@ -1,8 +1,10 @@ import React, { useCallback, useMemo, useState } from 'react' import { useLibraryTracks, useTracks, useUsers } from '@audius/common/api' +import { useFeatureFlag } from '@audius/common/hooks' import { Kind, Status } from '@audius/common/models' import type { ID, UID, Track, User } from '@audius/common/models' +import { FeatureFlags } from '@audius/common/services' import { libraryPageSelectors, LibraryCategory, @@ -24,6 +26,7 @@ import { TrackList } from 'app/components/track-list' import { getIsDoneLoadingFromDisk } from 'app/store/offline-downloads/selectors' import { makeStyles } from 'app/styles' +import { LibraryTrackRows } from './LibraryTrackRows' import { NoTracksPlaceholder } from './NoTracksPlaceholder' import { OfflineContentBanner } from './OfflineContentBanner' @@ -44,6 +47,9 @@ const useStyles = makeStyles(({ palette, spacing }) => ({ root: { flex: 1 }, + rows: { + flex: 1 + }, rowsContainer: { flex: 1, marginTop: spacing(3), @@ -91,6 +97,9 @@ export const TracksTab = () => { const dispatch = useDispatch() const styles = useStyles() const isReachable = useSelector(getIsReachable) + const { isEnabled: isTrackRowsEnabled } = useFeatureFlag( + FeatureFlags.TRACK_ROWS + ) const [filterValue, setFilterValue] = useState('') const [debouncedFilterValue, setDebouncedFilterValue] = useState('') @@ -296,6 +305,26 @@ export const TracksTab = () => { return null } + if (isTrackRowsEnabled) { + return ( + + } + keyboardShouldPersistTaps='handled' + togglePlay={togglePlay} + uids={showTrackSkeletonList ? undefined : filteredTrackUids} + /> + + ) + } + return (