Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 87 additions & 18 deletions packages/mobile/src/components/filter-input/FilterInput.tsx
Original file line number Diff line number Diff line change
@@ -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<TextInputProps, 'onChangeText' | 'placeholder' | 'value' | 'autoFocus'>
const messages = {
clear: 'Clear filter'
}

const INPUT_HEIGHT = 40

type FilterInputProps = Omit<FlexProps, 'children'> &
Pick<RNTextInputProps, 'onChangeText' | 'placeholder' | 'value' | 'autoFocus'>

/**
* 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 (
<Animated.View layout={Layout}>
<Paper mv='l' mh='m' p='s' {...paperProps}>
<TextInput
label='filter'
size={TextInputSize.SMALL}
<Animated.View layout={LinearTransition}>
<Flex
row
alignItems='center'
gap='s'
mh='l'
mv='s'
ph='m'
h={INPUT_HEIGHT}
backgroundColor='surface2'
style={{ borderRadius: INPUT_HEIGHT / 2 }}
{...flexProps}
>
<IconSearch color='subdued' size='s' />
<RNTextInput
accessibilityLabel={placeholder}
placeholder={placeholder}
onChangeText={onChangeText}
placeholderTextColor={color.text.subdued}
value={value}
returnKeyType='search'
endAdornment={<IconFilter color='subdued' size='m' />}
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
}}
/>
</Paper>
{value ? (
<Pressable
onPress={handleClear}
accessibilityRole='button'
accessibilityLabel={messages.clear}
hitSlop={spacing.s}
>
<IconCloseAlt color='subdued' size='s' />
</Pressable>
) : null}
</Flex>
</Animated.View>
)
}
14 changes: 13 additions & 1 deletion packages/mobile/src/components/lineup-tile/TrackRowTile.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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,
Expand All @@ -74,6 +80,7 @@ const TrackRowTileComponent = (props: TrackTileProps) => {
togglePlay,
onPress,
showArtistPick = false,
showDownloadStatus = false,
source
} = props

Expand Down Expand Up @@ -209,6 +216,11 @@ const TrackRowTileComponent = (props: TrackTileProps) => {
</>
}
title={track.title}
titleAdornment={
showDownloadStatus ? (
<TrackDownloadStatusIndicator trackId={track.track_id} size='s' />
) : undefined
}
subtitle={
<Flex row alignItems='center' gap='xs'>
<Text
Expand Down
129 changes: 106 additions & 23 deletions packages/mobile/src/harmony-native/components/TrackRow/TrackRow.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,15 @@
import type { ReactNode } from 'react'
import { useCallback, useRef } from 'react'

import { useTheme } from '@emotion/react'
import { Pressable, View } from 'react-native'
import Animated, {
useAnimatedStyle,
useSharedValue,
withSequence,
withSpring,
withTiming
} from 'react-native-reanimated'

import { Skeleton } from '../Skeleton'
import { Text } from '../Text/Text'
Expand All @@ -10,15 +18,26 @@ export const TRACK_ROW_HEIGHT = 64
const ARTWORK_SIZE = 48
const RANK_WIDTH = 20

// Press settles the row slightly into the page. A long-press dips further
// and springs back, marking the moment the actions open.
const PRESS_SCALE = 0.985
const LONG_PRESS_SCALE = 0.96
const releaseSpring = { damping: 18, stiffness: 320, mass: 0.6 }
const popSpring = { damping: 11, stiffness: 280, mass: 0.7 }

// Skeleton fills its parent absolutely by default; rows lay it out inline.
const skeletonStyle = { position: 'relative' as const, borderRadius: 4 }

const AnimatedPressable = Animated.createAnimatedComponent(Pressable)

export type TrackRowProps = {
/** 1-based position shown before the artwork. Omit for unranked lists. */
rank?: number
/** 48px square artwork, including any playing overlay. */
artwork?: ReactNode
title?: string
/** Small element after the title, such as a download indicator. */
titleAdornment?: ReactNode
/** Artist line under the title. */
subtitle?: ReactNode
/** Shown in place of the duration, e.g. a price button on a locked track. */
Expand All @@ -43,6 +62,7 @@ export const TrackRow = (props: TrackRowProps) => {
rank,
artwork,
title,
titleAdornment,
subtitle,
status,
duration,
Expand All @@ -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 : (
Expand Down Expand Up @@ -116,9 +171,11 @@ export const TrackRow = (props: TrackRowProps) => {
}

return (
<Pressable
<AnimatedPressable
onPress={onPress}
onLongPress={onLongPress}
onPressIn={handlePressIn}
onPressOut={handlePressOut}
onLongPress={onLongPress ? handleLongPress : undefined}
delayLongPress={300}
accessibilityRole='button'
accessibilityLabel={accessibilityLabel}
Expand All @@ -128,18 +185,34 @@ export const TrackRow = (props: TrackRowProps) => {
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
]}
>
<Animated.View
pointerEvents='none'
style={[
{
position: 'absolute',
top: 0,
right: 0,
bottom: 0,
left: 0,
backgroundColor: color.background.surface2
},
highlightStyle
]}
/>
{rankCell}
<View
style={{
Expand All @@ -153,15 +226,25 @@ export const TrackRow = (props: TrackRowProps) => {
</View>
<View style={{ flex: 1, minWidth: 0, justifyContent: 'center' }}>
{context}
<Text
variant='body'
size='l'
strength='strong'
color={isActive ? 'accent' : 'default'}
numberOfLines={1}
<View
style={{
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs
}}
>
{title}
</Text>
<Text
variant='body'
size='l'
strength='strong'
color={isActive ? 'accent' : 'default'}
numberOfLines={1}
style={{ flexShrink: 1 }}
>
{title}
</Text>
{titleAdornment}
</View>
{subtitle}
</View>
{status ??
Expand All @@ -170,6 +253,6 @@ export const TrackRow = (props: TrackRowProps) => {
{duration}
</Text>
) : null)}
</Pressable>
</AnimatedPressable>
)
}
Loading
Loading