Maintenance/perf (#734)

Render fixes to ItemCards, ItemRows, and Images
This commit is contained in:
Violet Caulfield
2025-11-29 16:11:58 -06:00
committed by GitHub
parent 221450b265
commit 3fd2926415
11 changed files with 596 additions and 528 deletions

View File

@@ -29,11 +29,6 @@ export const useAddFavorite = () => {
})
},
onSuccess: (data, { item, onToggle }) => {
Toast.show({
text1: 'Added favorite',
type: 'success',
})
trigger('notificationSuccess')
if (onToggle) onToggle()
@@ -75,11 +70,6 @@ export const useRemoveFavorite = () => {
})
},
onSuccess: (data, { item, onToggle }) => {
Toast.show({
text1: 'Removed favorite',
type: 'success',
})
trigger('notificationSuccess')
if (onToggle) onToggle()

View File

@@ -143,10 +143,6 @@ export default function Albums({
onEndReached={onEndReached}
ItemSeparatorComponent={ItemSeparatorComponent}
refreshControl={refreshControl}
stickyHeaderConfig={{
// When this is true the flashlist likes to flicker
useNativeDriver: false,
}}
stickyHeaderIndices={stickyHeaderIndices}
removeClippedSubviews
/>

View File

@@ -70,26 +70,15 @@ export default function ArtistHeader(): React.JSX.Element {
return (
<YStack flex={1}>
<ZStack flex={1} height={getTokenValue('$20')}>
<ItemImage
item={artist}
width={width}
height={'$20'}
type={ImageType.Backdrop}
cornered
/>
<ItemImage
item={artist}
width={width}
height={'$20'}
type={ImageType.Backdrop}
cornered
/>
{!isLightMode && (
<LinearGradient
colors={['transparent', theme.background.val]}
style={{
flex: 1,
}}
/>
)}
</ZStack>
<YStack alignItems='center' marginHorizontal={'$3'} backgroundColor={'$background'}>
<YStack alignItems='center' paddingHorizontal={'$3'}>
<XStack alignItems='flex-end' justifyContent='flex-start' flex={1}>
<XStack alignItems='center' flex={1} justifyContent='space-between'>
<H5 flexGrow={1} fontWeight={'bold'}>

View File

@@ -147,10 +147,6 @@ export default function Artists({
}
renderItem={renderItem}
stickyHeaderIndices={stickyHeaderIndices}
stickyHeaderConfig={{
// When this is true the flashlist likes to flicker
useNativeDriver: false,
}}
onStartReached={() => {
if (artistsInfiniteQuery.hasPreviousPage)
artistsInfiniteQuery.fetchPreviousPage()

View File

@@ -32,11 +32,12 @@ const ItemImage = memo(
}: ItemImageProps): React.JSX.Element {
const api = useApi()
const imageUrl = getItemImageUrl(api, item, type)
const imageUrl = useMemo(() => getItemImageUrl(api, item, type), [api, item.Id, type])
return api ? (
return imageUrl ? (
<Image
item={item}
type={type}
imageUrl={imageUrl!}
testID={testID}
height={height}
@@ -48,21 +49,19 @@ const ItemImage = memo(
<></>
)
},
(prevProps, nextProps) => {
return (
prevProps.item.Id === nextProps.item.Id &&
prevProps.type === nextProps.type &&
prevProps.cornered === nextProps.cornered &&
prevProps.circular === nextProps.circular &&
prevProps.width === nextProps.width &&
prevProps.height === nextProps.height &&
prevProps.testID === nextProps.testID
)
},
(prevProps, nextProps) =>
prevProps.item.Id === nextProps.item.Id &&
prevProps.type === nextProps.type &&
prevProps.cornered === nextProps.cornered &&
prevProps.circular === nextProps.circular &&
prevProps.width === nextProps.width &&
prevProps.height === nextProps.height &&
prevProps.testID === nextProps.testID,
)
interface ItemBlurhashProps {
item: BaseItemDto
type: ImageType
cornered?: boolean | undefined
circular?: boolean | undefined
width?: Token | string | number | string | undefined
@@ -79,22 +78,27 @@ const Styles = StyleSheet.create({
},
})
function ItemBlurhash({ item }: ItemBlurhashProps): React.JSX.Element {
const blurhash = getBlurhashFromDto(item)
const ItemBlurhash = memo(
function ItemBlurhash({ item, type }: ItemBlurhashProps): React.JSX.Element {
const blurhash = getBlurhashFromDto(item, type)
return (
<AnimatedBlurhash
resizeMode={'cover'}
style={Styles.blurhash}
blurhash={blurhash}
entering={FadeIn}
exiting={FadeOut}
/>
)
}
return (
<AnimatedBlurhash
resizeMode={'cover'}
style={Styles.blurhash}
blurhash={blurhash}
entering={FadeIn}
exiting={FadeOut}
/>
)
},
(prevProps: ItemBlurhashProps, nextProps: ItemBlurhashProps) =>
prevProps.item.Id === nextProps.item.Id && prevProps.type === nextProps.type,
)
interface ImageProps {
imageUrl: string
type: ImageType
item: BaseItemDto
cornered?: boolean | undefined
circular?: boolean | undefined
@@ -103,66 +107,94 @@ interface ImageProps {
testID?: string | undefined
}
function Image({
item,
imageUrl,
width,
height,
circular,
cornered,
testID,
}: ImageProps): React.JSX.Element {
const [isLoaded, setIsLoaded] = useState<boolean>(false)
const Image = memo(
function Image({
item,
type = ImageType.Primary,
imageUrl,
width,
height,
circular,
cornered,
testID,
}: ImageProps): React.JSX.Element {
const [isLoaded, setIsLoaded] = useState<boolean>(false)
const handleImageLoad = useCallback(() => setIsLoaded(true), [setIsLoaded])
const handleImageLoad = useCallback(() => setIsLoaded(true), [setIsLoaded])
const imageViewStyle = useMemo(
() =>
StyleSheet.create({
view: {
borderRadius: cornered
? 0
: width
? getBorderRadius(circular, width)
: circular
? getTokenValue('$20') * 10
: getTokenValue('$5'),
width: !isUndefined(width)
? typeof width === 'number'
? width
: typeof width === 'string' && width.includes('%')
? width
: getTokenValue(width as Token)
: '100%',
height: !isUndefined(height)
? typeof height === 'number'
? height
: typeof height === 'string' && height.includes('%')
? height
: getTokenValue(height as Token)
: '100%',
alignSelf: 'center',
overflow: 'hidden',
},
}),
[cornered, circular, width, height],
)
const imageViewStyle = useMemo(
() => getImageStyleSheet(width, height, cornered, circular),
[cornered, circular, width, height],
)
return (
<ZStack style={imageViewStyle.view} justifyContent='center' alignContent='center'>
<TamaguiImage
objectFit='cover'
source={{
uri: imageUrl,
}}
testID={testID}
onLoad={handleImageLoad}
style={Styles.blurhash}
animation={'quick'}
/>
{!isLoaded && <ItemBlurhash item={item} />}
</ZStack>
)
const imageSource = useMemo(() => ({ uri: imageUrl }), [imageUrl])
const blurhash = useMemo(
() => (!isLoaded ? <ItemBlurhash item={item} type={type} /> : null),
[isLoaded],
)
return (
<ZStack style={imageViewStyle.view} justifyContent='center' alignContent='center'>
<TamaguiImage
objectFit='cover'
source={imageSource}
testID={testID}
onLoad={handleImageLoad}
style={Styles.blurhash}
animation={'quick'}
/>
{blurhash}
</ZStack>
)
},
(prevProps, nextProps) => {
return (
prevProps.imageUrl === nextProps.imageUrl &&
prevProps.type === nextProps.type &&
prevProps.item.Id === nextProps.item.Id &&
prevProps.cornered === nextProps.cornered &&
prevProps.circular === nextProps.circular &&
prevProps.width === nextProps.width &&
prevProps.height === nextProps.height &&
prevProps.testID === nextProps.testID
)
},
)
function getImageStyleSheet(
width: Token | string | number | string | undefined,
height: Token | string | number | string | undefined,
cornered: boolean | undefined,
circular: boolean | undefined,
) {
return StyleSheet.create({
view: {
borderRadius: cornered
? 0
: width
? getBorderRadius(circular, width)
: circular
? getTokenValue('$20') * 10
: getTokenValue('$5'),
width: !isUndefined(width)
? typeof width === 'number'
? width
: typeof width === 'string' && width.includes('%')
? width
: getTokenValue(width as Token)
: '100%',
height: !isUndefined(height)
? typeof height === 'number'
? height
: typeof height === 'string' && height.includes('%')
? height
: getTokenValue(height as Token)
: '100%',
alignSelf: 'center',
overflow: 'hidden',
},
})
}
/**

View File

@@ -33,20 +33,30 @@ function ItemCardComponent({
captionAlign = 'center',
...cardProps
}: CardProps) {
if (__DEV__) usePerformanceMonitor('ItemCard', 2)
usePerformanceMonitor('ItemCard', 2)
const warmContext = useItemContext()
useEffect(() => {
if (item.Type === 'Audio') warmContext(item)
}, [item.Type, warmContext])
}, [item.Id, warmContext])
const hoverStyle = useMemo(() => (onPress ? { scale: 0.925 } : undefined), [onPress])
const pressStyle = useMemo(() => (onPress ? { scale: 0.875 } : undefined), [onPress])
const handlePressIn = useCallback(
() => (item.Type !== 'Audio' ? warmContext(item) : undefined),
[item.Type, warmContext],
[item.Id, warmContext],
)
const background = useMemo(
() => (
<TamaguiCard.Background>
<ItemImage item={item} circular={!squared} />
</TamaguiCard.Background>
),
[item.Id, squared],
)
return (
@@ -66,9 +76,7 @@ function ItemCardComponent({
pressStyle={pressStyle}
{...cardProps}
>
<TamaguiCard.Background>
<ItemImage item={item} circular={!squared} />
</TamaguiCard.Background>
{background}
</TamaguiCard>
<ItemCardComponentCaption
size={cardProps.size ?? '$10'}
@@ -80,44 +88,51 @@ function ItemCardComponent({
)
}
function ItemCardComponentCaption({
size,
captionAlign = 'center',
caption,
subCaption,
}: {
size: string | number
captionAlign: 'center' | 'left' | 'right'
caption?: string | null | undefined
subCaption?: string | null | undefined
}): React.JSX.Element | null {
if (!caption) return null
const ItemCardComponentCaption = memo(
function ItemCardComponentCaption({
size,
captionAlign = 'center',
caption,
subCaption,
}: {
size: string | number
captionAlign: 'center' | 'left' | 'right'
caption?: string | null | undefined
subCaption?: string | null | undefined
}): React.JSX.Element | null {
if (!caption) return null
return (
<YStack maxWidth={size}>
<Text
bold
lineBreakStrategyIOS='standard'
width={size}
numberOfLines={1}
textAlign={captionAlign}
>
{caption}
</Text>
{subCaption && (
return (
<YStack maxWidth={size}>
<Text
bold
lineBreakStrategyIOS='standard'
width={size}
numberOfLines={1}
textAlign={captionAlign}
>
{subCaption}
{caption}
</Text>
)}
</YStack>
)
}
{subCaption && (
<Text
lineBreakStrategyIOS='standard'
width={size}
numberOfLines={1}
textAlign={captionAlign}
>
{subCaption}
</Text>
)}
</YStack>
)
},
(prevProps, nextProps) =>
prevProps.size === nextProps.size &&
prevProps.captionAlign === nextProps.captionAlign &&
prevProps.caption === nextProps.caption &&
prevProps.subCaption === nextProps.subCaption,
)
export const ItemCard = React.memo(
ItemCardComponent,
@@ -129,6 +144,6 @@ export const ItemCard = React.memo(
a.squared === b.squared &&
a.size === b.size &&
a.testId === b.testId &&
a.onPress === b.onPress &&
!!a.onPress === !!b.onPress &&
a.captionAlign === b.captionAlign,
)

View File

@@ -14,9 +14,14 @@ import { useNetworkStatus } from '../../../stores/network'
import useStreamingDeviceProfile from '../../../stores/device-profile'
import useItemContext from '../../../hooks/use-item-context'
import { RouteProp, useRoute } from '@react-navigation/native'
import React, { memo, useCallback, useState } from 'react'
import React, { memo, useCallback, useMemo, useState } from 'react'
import { LayoutChangeEvent } from 'react-native'
import Animated, { useAnimatedStyle, withTiming } from 'react-native-reanimated'
import Animated, {
SharedValue,
useAnimatedStyle,
useSharedValue,
withTiming,
} from 'react-native-reanimated'
import { useSwipeableRowContext } from './swipeable-row-context'
import SwipeableRow from './SwipeableRow'
import { useSwipeSettingsStore } from '../../../stores/settings/swipe'
@@ -46,7 +51,7 @@ interface ItemRowProps {
*/
const ItemRow = memo(
function ItemRow({ item, circular, navigation, onPress }: ItemRowProps): React.JSX.Element {
const [artworkAreaWidth, setArtworkAreaWidth] = useState(0)
const artworkAreaWidth = useSharedValue(0)
const api = useApi()
@@ -63,7 +68,7 @@ const ItemRow = memo(
const warmContext = useItemContext()
const { data: isFavorite } = useIsFavorite(item)
const onPressIn = useCallback(() => warmContext(item), [warmContext, item])
const onPressIn = useCallback(() => warmContext(item), [warmContext, item.Id])
const onLongPress = useCallback(
() =>
@@ -71,7 +76,7 @@ const ItemRow = memo(
item,
navigation,
}),
[navigationRef, navigation, item],
[navigationRef, navigation, item.Id],
)
const onPressCallback = useCallback(async () => {
@@ -110,14 +115,19 @@ const ItemRow = memo(
break
}
}
}, [loadNewQueue, item, navigation])
}, [loadNewQueue, item.Id, navigation])
const renderRunTime = item.Type === BaseItemKind.Audio && !hideRunTimes
const renderRunTime = useMemo(
() => item.Type === BaseItemKind.Audio && !hideRunTimes,
[item.Type, hideRunTimes],
)
const isAudio = item.Type === 'Audio'
const isAudio = useMemo(() => item.Type === 'Audio', [item.Type])
const playlistTrackCount =
item.Type === 'Playlist' ? (item.SongCount ?? item.ChildCount ?? 0) : undefined
const playlistTrackCount = useMemo(
() => (item.Type === 'Playlist' ? (item.SongCount ?? item.ChildCount ?? 0) : undefined),
[item.Type, item.SongCount, item.ChildCount],
)
const leftSettings = useSwipeSettingsStore((s) => s.left)
const rightSettings = useSwipeSettingsStore((s) => s.right)
@@ -148,13 +158,27 @@ const ItemRow = memo(
],
)
const swipeConfig = isAudio
? buildSwipeConfig({
left: leftSettings,
right: rightSettings,
handlers: swipeHandlers(),
})
: {}
const swipeConfig = useMemo(
() =>
isAudio
? buildSwipeConfig({
left: leftSettings,
right: rightSettings,
handlers: swipeHandlers(),
})
: {},
[isAudio, leftSettings, rightSettings, swipeHandlers],
)
const handleArtworkLayout = useCallback(
(event: LayoutChangeEvent) => {
const { width } = event.nativeEvent.layout
artworkAreaWidth.value = width
},
[artworkAreaWidth],
)
const pressStyle = useMemo(() => ({ opacity: 0.5 }), [])
return (
<SwipeableRow
@@ -171,7 +195,7 @@ const ItemRow = memo(
onPress={onPressCallback}
onLongPress={onLongPress}
animation={'quick'}
pressStyle={{ opacity: 0.5 }}
pressStyle={pressStyle}
paddingVertical={'$2'}
paddingRight={'$2'}
paddingLeft={'$1'}
@@ -181,7 +205,7 @@ const ItemRow = memo(
<HideableArtwork
item={item}
circular={circular}
onLayout={(e) => setArtworkAreaWidth(e.nativeEvent.layout.width)}
onLayout={handleArtworkLayout}
/>
<SlidingTextArea leftGapWidth={artworkAreaWidth}>
<ItemRowDetails item={item} />
@@ -209,114 +233,134 @@ const ItemRow = memo(
return (
prevProps.item.Id === nextProps.item.Id &&
prevProps.circular === nextProps.circular &&
prevProps.onPress === nextProps.onPress &&
!!prevProps.onPress === !!nextProps.onPress &&
prevProps.navigation === nextProps.navigation
)
},
)
function ItemRowDetails({ item }: { item: BaseItemDto }): React.JSX.Element {
const route = useRoute<RouteProp<BaseStackParamList>>()
const ItemRowDetails = memo(
function ItemRowDetails({ item }: { item: BaseItemDto }): React.JSX.Element {
const route = useRoute<RouteProp<BaseStackParamList>>()
const shouldRenderArtistName =
item.Type === 'Audio' || (item.Type === 'MusicAlbum' && route.name !== 'Artist')
const shouldRenderArtistName =
item.Type === 'Audio' || (item.Type === 'MusicAlbum' && route.name !== 'Artist')
const shouldRenderProductionYear = item.Type === 'MusicAlbum' && route.name === 'Artist'
const shouldRenderProductionYear = item.Type === 'MusicAlbum' && route.name === 'Artist'
const shouldRenderGenres = item.Type === 'Playlist' || item.Type === BaseItemKind.MusicArtist
const shouldRenderGenres =
item.Type === 'Playlist' || item.Type === BaseItemKind.MusicArtist
return (
<YStack alignContent='center' justifyContent='center' flexGrow={1}>
<Text bold lineBreakStrategyIOS='standard' numberOfLines={1}>
{item.Name ?? ''}
</Text>
{shouldRenderArtistName && (
<Text color={'$borderColor'} lineBreakStrategyIOS='standard' numberOfLines={1}>
{item.AlbumArtist ?? 'Untitled Artist'}
return (
<YStack alignContent='center' justifyContent='center' flexGrow={1}>
<Text bold lineBreakStrategyIOS='standard' numberOfLines={1}>
{item.Name ?? ''}
</Text>
)}
{shouldRenderProductionYear && (
<XStack gap='$2'>
{shouldRenderArtistName && (
<Text color={'$borderColor'} lineBreakStrategyIOS='standard' numberOfLines={1}>
{item.ProductionYear?.toString() ?? 'Unknown Year'}
{item.AlbumArtist ?? 'Untitled Artist'}
</Text>
)}
<Text color={'$borderColor'}></Text>
{shouldRenderProductionYear && (
<XStack gap='$2'>
<Text
color={'$borderColor'}
lineBreakStrategyIOS='standard'
numberOfLines={1}
>
{item.ProductionYear?.toString() ?? 'Unknown Year'}
</Text>
<RunTimeTicks>{item.RunTimeTicks}</RunTimeTicks>
</XStack>
)}
<Text color={'$borderColor'}></Text>
{shouldRenderGenres && item.Genres && (
<Text color={'$borderColor'} lineBreakStrategyIOS='standard' numberOfLines={1}>
{item.Genres?.join(', ') ?? ''}
</Text>
)}
</YStack>
)
}
<RunTimeTicks>{item.RunTimeTicks}</RunTimeTicks>
</XStack>
)}
{shouldRenderGenres && item.Genres && (
<Text color={'$borderColor'} lineBreakStrategyIOS='standard' numberOfLines={1}>
{item.Genres?.join(', ') ?? ''}
</Text>
)}
</YStack>
)
},
(prevProps, nextProps) => prevProps.item.Id === nextProps.item.Id,
)
// Artwork wrapper that fades out when the quick-action menu is open
function HideableArtwork({
item,
circular,
onLayout,
}: {
item: BaseItemDto
circular?: boolean
onLayout?: (event: LayoutChangeEvent) => void
}): React.JSX.Element {
const { tx } = useSwipeableRowContext()
// Hide artwork as soon as swiping starts (any non-zero tx)
const style = useAnimatedStyle(() => ({
opacity: tx.value === 0 ? withTiming(1) : 0,
}))
return (
<Animated.View style={style} onLayout={onLayout}>
<XStack marginHorizontal={'$3'} marginVertical={'auto'} alignItems='center'>
<ItemImage
item={item}
height={'$12'}
width={'$12'}
circular={item.Type === 'MusicArtist' || circular}
/>
</XStack>
</Animated.View>
)
}
const HideableArtwork = memo(
function HideableArtwork({
item,
circular,
onLayout,
}: {
item: BaseItemDto
circular?: boolean
onLayout?: (event: LayoutChangeEvent) => void
}): React.JSX.Element {
const { tx } = useSwipeableRowContext()
// Hide artwork as soon as swiping starts (any non-zero tx)
const style = useAnimatedStyle(() => ({
opacity: tx.value === 0 ? withTiming(1) : 0,
}))
return (
<Animated.View style={style} onLayout={onLayout}>
<XStack marginHorizontal={'$3'} marginVertical={'auto'} alignItems='center'>
<ItemImage
item={item}
height={'$12'}
width={'$12'}
circular={item.Type === 'MusicArtist' || circular}
/>
</XStack>
</Animated.View>
)
},
(prevProps, nextProps) =>
prevProps.item.Id === nextProps.item.Id &&
prevProps.circular === nextProps.circular &&
!!prevProps.onLayout === !!nextProps.onLayout,
)
function SlidingTextArea({
leftGapWidth,
children,
}: {
leftGapWidth: number
children: React.ReactNode
}): React.JSX.Element {
const { tx, rightWidth } = useSwipeableRowContext()
const tokenValue = getToken('$2', 'space')
const spacingValue = typeof tokenValue === 'number' ? tokenValue : parseFloat(`${tokenValue}`)
const quickActionBuffer = Number.isFinite(spacingValue) ? spacingValue : 8
const style = useAnimatedStyle(() => {
const t = tx.value
let offset = 0
if (t > 0 && leftGapWidth > 0) {
offset = -Math.min(t, leftGapWidth)
} else if (t < 0) {
const rightSpace = Math.max(0, rightWidth)
const compensate = Math.min(-t, rightSpace)
const progress = rightSpace > 0 ? compensate / rightSpace : 1
offset = compensate * 0.7 + quickActionBuffer * progress
}
return { transform: [{ translateX: offset }] }
})
const paddingRightValue = Number.isFinite(spacingValue) ? spacingValue : 8
return (
<Animated.View style={[{ flex: 5, paddingRight: paddingRightValue }, style]}>
{children}
</Animated.View>
)
}
const SlidingTextArea = memo(
function SlidingTextArea({
leftGapWidth,
children,
}: {
leftGapWidth: SharedValue<number>
children: React.ReactNode
}): React.JSX.Element {
const { tx, rightWidth } = useSwipeableRowContext()
const tokenValue = getToken('$2', 'space')
const spacingValue =
typeof tokenValue === 'number' ? tokenValue : parseFloat(`${tokenValue}`)
const quickActionBuffer = Number.isFinite(spacingValue) ? spacingValue : 8
const style = useAnimatedStyle(() => {
const t = tx.value
let offset = 0
if (t > 0 && leftGapWidth.get() > 0) {
offset = -Math.min(t, leftGapWidth.get())
} else if (t < 0) {
const rightSpace = Math.max(0, rightWidth)
const compensate = Math.min(-t, rightSpace)
const progress = rightSpace > 0 ? compensate / rightSpace : 1
offset = compensate * 0.7 + quickActionBuffer * progress
}
return { transform: [{ translateX: offset }] }
})
const paddingRightValue = Number.isFinite(spacingValue) ? spacingValue : 8
return (
<Animated.View style={[{ flex: 5, paddingRight: paddingRightValue }, style]}>
{children}
</Animated.View>
)
},
(prevProps, nextProps) =>
prevProps.leftGapWidth === nextProps.leftGapWidth &&
prevProps.children?.valueOf() === nextProps.children?.valueOf(),
)
export default ItemRow

View File

@@ -1,5 +1,5 @@
import React, { useMemo, useCallback, useState } from 'react'
import { getToken, Spacer, Theme, useTheme, XStack, YStack } from 'tamagui'
import React, { useMemo, useCallback, useState, memo } from 'react'
import { getToken, Theme, useTheme, XStack, YStack } from 'tamagui'
import { Text } from '../helpers/text'
import { RunTimeTicks } from '../helpers/time-codes'
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
@@ -41,91 +41,102 @@ export interface TrackProps {
onLongPress?: () => void | undefined
isNested?: boolean | undefined
invertedColors?: boolean | undefined
prependElement?: React.JSX.Element | undefined
testID?: string | undefined
editing?: boolean | undefined
}
export default function Track({
track,
navigation,
tracklist,
index,
queue,
showArtwork,
onPress,
onLongPress,
testID,
isNested,
invertedColors,
prependElement,
editing,
}: TrackProps): React.JSX.Element {
const theme = useTheme()
const [artworkAreaWidth, setArtworkAreaWidth] = useState(0)
const Track = memo(
function Track({
track,
navigation,
tracklist,
index,
queue,
showArtwork,
onPress,
onLongPress,
testID,
isNested,
invertedColors,
editing,
}: TrackProps): React.JSX.Element {
const theme = useTheme()
const [artworkAreaWidth, setArtworkAreaWidth] = useState(0)
const api = useApi()
const api = useApi()
const deviceProfile = useStreamingDeviceProfile()
const deviceProfile = useStreamingDeviceProfile()
const [hideRunTimes] = useHideRunTimesSetting()
const [hideRunTimes] = useHideRunTimesSetting()
const nowPlaying = useCurrentTrack()
const playQueue = usePlayQueue()
const loadNewQueue = useLoadNewQueue()
const addToQueue = useAddToQueue()
const [networkStatus] = useNetworkStatus()
const nowPlaying = useCurrentTrack()
const playQueue = usePlayQueue()
const loadNewQueue = useLoadNewQueue()
const addToQueue = useAddToQueue()
const [networkStatus] = useNetworkStatus()
const { data: mediaInfo } = useStreamedMediaInfo(track.Id)
const { data: mediaInfo } = useStreamedMediaInfo(track.Id)
const offlineAudio = useDownloadedTrack(track.Id)
const offlineAudio = useDownloadedTrack(track.Id)
const { mutate: addFavorite } = useAddFavorite()
const { mutate: removeFavorite } = useRemoveFavorite()
const { data: isFavoriteTrack } = useIsFavorite(track)
const leftSettings = useSwipeSettingsStore((s) => s.left)
const rightSettings = useSwipeSettingsStore((s) => s.right)
const { mutate: addFavorite } = useAddFavorite()
const { mutate: removeFavorite } = useRemoveFavorite()
const { data: isFavoriteTrack } = useIsFavorite(track)
const leftSettings = useSwipeSettingsStore((s) => s.left)
const rightSettings = useSwipeSettingsStore((s) => s.right)
// Memoize expensive computations
const isPlaying = useMemo(
() => nowPlaying?.item.Id === track.Id,
[nowPlaying?.item.Id, track.Id],
)
// Memoize expensive computations
const isPlaying = useMemo(
() => nowPlaying?.item.Id === track.Id,
[nowPlaying?.item.Id, track.Id],
)
const isOffline = useMemo(
() => networkStatus === networkStatusTypes.DISCONNECTED,
[networkStatus],
)
const isOffline = useMemo(
() => networkStatus === networkStatusTypes.DISCONNECTED,
[networkStatus],
)
// Memoize tracklist for queue loading
const memoizedTracklist = useMemo(
() => tracklist ?? playQueue?.map((track) => track.item) ?? [],
[tracklist, playQueue],
)
// Memoize tracklist for queue loading
const memoizedTracklist = useMemo(
() => tracklist ?? playQueue?.map((track) => track.item) ?? [],
[tracklist, playQueue],
)
// Memoize handlers to prevent recreation
const handlePress = useCallback(async () => {
if (onPress) {
await onPress()
} else {
loadNewQueue({
api,
deviceProfile,
networkStatus,
track,
index,
tracklist: memoizedTracklist,
queue,
queuingType: QueuingType.FromSelection,
startPlayback: true,
})
}
}, [onPress, track, index, memoizedTracklist, queue, useLoadNewQueue])
// Memoize handlers to prevent recreation
const handlePress = useCallback(async () => {
if (onPress) {
await onPress()
} else {
loadNewQueue({
api,
deviceProfile,
networkStatus,
track,
index,
tracklist: memoizedTracklist,
queue,
queuingType: QueuingType.FromSelection,
startPlayback: true,
})
}
}, [onPress, track, index, memoizedTracklist, queue, useLoadNewQueue])
const handleLongPress = useCallback(() => {
if (onLongPress) {
onLongPress()
} else {
const handleLongPress = useCallback(() => {
if (onLongPress) {
onLongPress()
} else {
navigationRef.navigate('Context', {
item: track,
navigation,
streamingMediaSourceInfo: mediaInfo?.MediaSources
? mediaInfo!.MediaSources![0]
: undefined,
downloadedMediaSourceInfo: offlineAudio?.mediaSourceInfo,
})
}
}, [onLongPress, track, isNested, mediaInfo?.MediaSources, offlineAudio])
const handleIconPress = useCallback(() => {
navigationRef.navigate('Context', {
item: track,
navigation,
@@ -134,190 +145,192 @@ export default function Track({
: undefined,
downloadedMediaSourceInfo: offlineAudio?.mediaSourceInfo,
})
}
}, [onLongPress, track, isNested, mediaInfo?.MediaSources, offlineAudio])
}, [track, isNested, mediaInfo?.MediaSources, offlineAudio])
const handleIconPress = useCallback(() => {
navigationRef.navigate('Context', {
item: track,
navigation,
streamingMediaSourceInfo: mediaInfo?.MediaSources
? mediaInfo!.MediaSources![0]
: undefined,
downloadedMediaSourceInfo: offlineAudio?.mediaSourceInfo,
})
}, [track, isNested, mediaInfo?.MediaSources, offlineAudio])
// Memoize text color to prevent recalculation
const textColor = useMemo(() => {
if (isPlaying) return theme.primary.val
if (isOffline) return offlineAudio ? theme.color : theme.neutral.val
return theme.color
}, [isPlaying, isOffline, offlineAudio, theme.primary.val, theme.color, theme.neutral.val])
// Memoize text color to prevent recalculation
const textColor = useMemo(() => {
if (isPlaying) return theme.primary.val
if (isOffline) return offlineAudio ? theme.color : theme.neutral.val
return theme.color
}, [isPlaying, isOffline, offlineAudio, theme.primary.val, theme.color, theme.neutral.val])
// Memoize artists text
const artistsText = useMemo(() => track.Artists?.join(', ') ?? '', [track.Artists])
// Memoize artists text
const artistsText = useMemo(() => track.Artists?.join(', ') ?? '', [track.Artists])
// Memoize track name
const trackName = useMemo(() => track.Name ?? 'Untitled Track', [track.Name])
// Memoize track name
const trackName = useMemo(() => track.Name ?? 'Untitled Track', [track.Name])
// Memoize index number
const indexNumber = useMemo(() => track.IndexNumber?.toString() ?? '', [track.IndexNumber])
// Memoize index number
const indexNumber = useMemo(() => track.IndexNumber?.toString() ?? '', [track.IndexNumber])
// Memoize show artists condition
const shouldShowArtists = useMemo(
() => showArtwork || (track.Artists && track.Artists.length > 1),
[showArtwork, track.Artists],
)
// Memoize show artists condition
const shouldShowArtists = useMemo(
() => showArtwork || (track.Artists && track.Artists.length > 1),
[showArtwork, track.Artists],
)
const swipeHandlers = useMemo(
() => ({
addToQueue: async () => {
console.info('Running add to queue swipe action')
await addToQueue({
api,
deviceProfile,
networkStatus,
tracks: [track],
queuingType: QueuingType.DirectlyQueued,
})
},
toggleFavorite: () => {
console.info(
`Running ${isFavoriteTrack ? 'Remove' : 'Add'} favorite swipe action`,
)
if (isFavoriteTrack) removeFavorite({ item: track })
else addFavorite({ item: track })
},
addToPlaylist: () => {
console.info('Running add to playlist swipe handler')
navigationRef.dispatch(StackActions.push('AddToPlaylist', { track }))
},
}),
[
addToQueue,
api,
deviceProfile,
networkStatus,
track,
addFavorite,
removeFavorite,
isFavoriteTrack,
navigationRef,
],
)
const swipeHandlers = useMemo(
() => ({
addToQueue: async () => {
console.info('Running add to queue swipe action')
await addToQueue({
api,
deviceProfile,
networkStatus,
tracks: [track],
queuingType: QueuingType.DirectlyQueued,
})
},
toggleFavorite: () => {
console.info(`Running ${isFavoriteTrack ? 'Remove' : 'Add'} favorite swipe action`)
if (isFavoriteTrack) removeFavorite({ item: track })
else addFavorite({ item: track })
},
addToPlaylist: () => {
console.info('Running add to playlist swipe handler')
navigationRef.dispatch(StackActions.push('AddToPlaylist', { track }))
},
}),
[
addToQueue,
api,
deviceProfile,
networkStatus,
track,
addFavorite,
removeFavorite,
isFavoriteTrack,
navigationRef,
],
)
const swipeConfig = useMemo(
() =>
buildSwipeConfig({
left: leftSettings,
right: rightSettings,
handlers: swipeHandlers,
}),
[leftSettings, rightSettings, swipeHandlers],
)
const swipeConfig = useMemo(
() =>
buildSwipeConfig({ left: leftSettings, right: rightSettings, handlers: swipeHandlers }),
[leftSettings, rightSettings, swipeHandlers],
)
const runtimeComponent = useMemo(
() =>
hideRunTimes ? (
<></>
) : (
<RunTimeTicks
key={`${track.Id}-runtime`}
props={{
style: {
textAlign: 'right',
minWidth: getToken('$10'),
alignSelf: 'center',
},
}}
>
{track.RunTimeTicks}
</RunTimeTicks>
),
[hideRunTimes, track.RunTimeTicks],
)
const runtimeComponent = useMemo(
() =>
hideRunTimes ? (
<></>
) : (
<RunTimeTicks
key={`${track.Id}-runtime`}
props={{
style: {
textAlign: 'right',
minWidth: getToken('$10'),
alignSelf: 'center',
},
}}
return (
<Theme name={invertedColors ? 'inverted_purple' : undefined}>
<SwipeableRow
disabled={isNested === true}
{...swipeConfig}
onLongPress={handleLongPress}
onPress={handlePress}
>
{track.RunTimeTicks}
</RunTimeTicks>
),
[hideRunTimes, track.RunTimeTicks],
)
return (
<Theme name={invertedColors ? 'inverted_purple' : undefined}>
<SwipeableRow
disabled={isNested === true}
{...swipeConfig}
onLongPress={handleLongPress}
onPress={handlePress}
>
<XStack
alignContent='center'
alignItems='center'
height={showArtwork ? '$6' : '$5'}
flex={1}
testID={testID ?? undefined}
paddingVertical={'$2'}
justifyContent='flex-start'
paddingRight={'$2'}
animation={'quick'}
pressStyle={{ opacity: 0.5 }}
backgroundColor={'$background'}
>
{prependElement ? (
<XStack marginLeft={'$2'} marginRight={'$1'} alignItems='center'>
{prependElement}
</XStack>
) : null}
<XStack
alignContent='center'
justifyContent='center'
marginHorizontal={showArtwork ? '$2' : '$1'}
onLayout={(e) => setArtworkAreaWidth(e.nativeEvent.layout.width)}
alignItems='center'
flex={1}
testID={testID ?? undefined}
paddingVertical={'$2'}
justifyContent='flex-start'
paddingRight={'$2'}
animation={'quick'}
pressStyle={{ opacity: 0.5 }}
backgroundColor={'$background'}
>
{showArtwork ? (
<HideableArtwork>
<ItemImage item={track} width={'$12'} height={'$12'} />
</HideableArtwork>
) : (
<Text
key={`${track.Id}-number`}
color={textColor}
width={getToken('$12')}
textAlign='center'
fontVariant={['tabular-nums']}
>
{indexNumber}
</Text>
)}
</XStack>
<SlidingTextArea leftGapWidth={artworkAreaWidth} hasArtwork={!!showArtwork}>
<YStack alignItems='flex-start' justifyContent='center' flex={6}>
<Text
key={`${track.Id}-name`}
bold
color={textColor}
lineBreakStrategyIOS='standard'
numberOfLines={1}
>
{trackName}
</Text>
{shouldShowArtists && (
<XStack
alignContent='center'
justifyContent='center'
marginHorizontal={showArtwork ? '$2' : '$1'}
onLayout={(e) => setArtworkAreaWidth(e.nativeEvent.layout.width)}
>
{showArtwork ? (
<HideableArtwork>
<ItemImage item={track} width={'$12'} height={'$12'} />
</HideableArtwork>
) : (
<Text
key={`${track.Id}-artists`}
lineBreakStrategyIOS='standard'
numberOfLines={1}
color={'$borderColor'}
key={`${track.Id}-number`}
color={textColor}
width={getToken('$12')}
textAlign='center'
fontVariant={['tabular-nums']}
>
{artistsText}
{indexNumber}
</Text>
)}
</YStack>
</SlidingTextArea>
</XStack>
<XStack justifyContent='flex-end' alignItems='center' flex={2} gap='$1'>
<DownloadedIcon item={track} />
<FavoriteIcon item={track} />
{runtimeComponent}
{!editing && <Icon name={'dots-horizontal'} onPress={handleIconPress} />}
<SlidingTextArea leftGapWidth={artworkAreaWidth} hasArtwork={!!showArtwork}>
<YStack alignItems='flex-start' justifyContent='center' flex={6}>
<Text
key={`${track.Id}-name`}
bold
color={textColor}
lineBreakStrategyIOS='standard'
numberOfLines={1}
>
{trackName}
</Text>
{shouldShowArtists && (
<Text
key={`${track.Id}-artists`}
lineBreakStrategyIOS='standard'
numberOfLines={1}
color={'$borderColor'}
>
{artistsText}
</Text>
)}
</YStack>
</SlidingTextArea>
<XStack justifyContent='flex-end' alignItems='center' flex={2} gap='$1'>
<DownloadedIcon item={track} />
<FavoriteIcon item={track} />
{runtimeComponent}
{!editing && (
<Icon name={'dots-horizontal'} onPress={handleIconPress} />
)}
</XStack>
</XStack>
</XStack>
</SwipeableRow>
</Theme>
)
}
</SwipeableRow>
</Theme>
)
},
(prevProps, nextProps) =>
prevProps.track.Id === nextProps.track.Id &&
prevProps.index === nextProps.index &&
prevProps.showArtwork === nextProps.showArtwork &&
prevProps.isNested === nextProps.isNested &&
prevProps.invertedColors === nextProps.invertedColors &&
prevProps.testID === nextProps.testID &&
prevProps.editing === nextProps.editing &&
prevProps.queue === nextProps.queue &&
prevProps.tracklist === nextProps.tracklist &&
!!prevProps.onPress === !!nextProps.onPress &&
!!prevProps.onLongPress === !!nextProps.onLongPress,
)
function HideableArtwork({ children }: { children: React.ReactNode }) {
const { tx } = useSwipeableRowContext()
@@ -351,3 +364,5 @@ function SlidingTextArea({
})
return <Animated.View style={[{ flex: 5 }, style]}>{children}</Animated.View>
}
export default Track

View File

@@ -12,6 +12,8 @@ import { LayoutChangeEvent } from 'react-native'
import MaterialDesignIcons from '@react-native-vector-icons/material-design-icons'
import navigationRef from '../../../../navigation'
import { useCurrentTrack, useQueueRef } from '../../../stores/player/queue'
import TextTicker from 'react-native-text-ticker'
import { TextTickerConfig } from '../component.config'
export default function PlayerHeader(): React.JSX.Element {
const queueRef = useQueueRef()
@@ -37,17 +39,23 @@ export default function PlayerHeader(): React.JSX.Element {
name={'chevron-down'}
size={22}
onPress={() => navigationRef.goBack()}
style={{ marginVertical: 'auto', width: 22 }}
style={{
marginVertical: 'auto',
width: 22,
marginRight: 8,
}}
/>
<YStack alignItems='center' flexGrow={1}>
<Text>Playing from</Text>
<Text bold numberOfLines={1} lineBreakStrategyIOS='standard'>
{playingFrom}
</Text>
<TextTicker {...TextTickerConfig}>
<Text bold numberOfLines={1}>
{playingFrom}
</Text>
</TextTicker>
</YStack>
<Spacer width={22} />
<Spacer marginLeft={8} width={22} />
</XStack>
<PlayerArtwork />

View File

@@ -1,17 +1,16 @@
import React, { RefObject, useMemo, useRef, useCallback, useEffect } from 'react'
import Track from '../Global/components/track'
import { getToken, Separator, useTheme, XStack, YStack } from 'tamagui'
import { Separator, useTheme, XStack, YStack } from 'tamagui'
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
import { Queue } from '../../player/types/queue-item'
import { FlashList, FlashListRef, ViewToken } from '@shopify/flash-list'
import { FlashList, FlashListRef } from '@shopify/flash-list'
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import { BaseStackParamList } from '../../screens/types'
import { Text } from '../Global/helpers/text'
import AZScroller, { useAlphabetSelector } from '../Global/components/alphabetical-selector'
import { UseInfiniteQueryResult } from '@tanstack/react-query'
import { debounce, isString } from 'lodash'
import { isString } from 'lodash'
import { RefreshControl } from 'react-native-gesture-handler'
import useItemContext from '../../hooks/use-item-context'
import { closeAllSwipeableRows } from '../Global/components/swipeable-row-registry'
import FlashListStickyHeader from '../Global/helpers/flashlist-sticky-header'
@@ -32,8 +31,6 @@ export default function Tracks({
}: TracksProps): React.JSX.Element {
const theme = useTheme()
const warmContext = useItemContext()
const sectionListRef = useRef<FlashListRef<string | number | BaseItemDto>>(null)
const pendingLetterRef = useRef<string | null>(null)
@@ -80,14 +77,11 @@ export default function Tracks({
index={0}
track={track}
testID={`track-item-${index}`}
tracklist={tracksToDisplay.slice(
tracksToDisplay.indexOf(track),
tracksToDisplay.indexOf(track) + 50,
)}
tracklist={tracksToDisplay.slice(index, index + 50)}
queue={queue}
/>
) : null,
[tracksToDisplay, queue],
[tracksToDisplay, queue, navigation, queue],
)
const ItemSeparatorComponent = useCallback(
@@ -168,10 +162,6 @@ export default function Tracks({
</Text>
</YStack>
}
stickyHeaderConfig={{
// When this is true the flashlist likes to flicker
useNativeDriver: false,
}}
removeClippedSubviews
/>

View File

@@ -83,13 +83,6 @@ export const PlayerProvider: () => React.JSX.Element = () => {
case Event.PlaybackProgressUpdated: {
const currentTrack = usePlayerQueueStore.getState().currentTrack
if (currentTrack)
try {
await reportPlaybackProgress(api, currentTrack, event.position)
} catch (error) {
console.error('Unable to report playback progress', error)
}
if (event.position / event.duration > 0.3 && autoDownload && currentTrack) {
await saveAudioItem(api, currentTrack.item, downloadingDeviceProfile, true)
}