remove memoization since we're using the compiler (#746)

* remove memoization since we're using the compiler

* remove memoization on the track and item rows

* remove memoization from artists list

* remove additional memoization
This commit is contained in:
Violet Caulfield
2025-12-03 20:42:46 -06:00
committed by GitHub
parent cadec335b0
commit af5c02c71a
5 changed files with 576 additions and 736 deletions

View File

@@ -1,6 +1,6 @@
import { ActivityIndicator, RefreshControl } from 'react-native' import { RefreshControl } from 'react-native'
import { Separator, useTheme, XStack, YStack } from 'tamagui' import { Separator, useTheme, XStack, YStack } from 'tamagui'
import React, { RefObject, useCallback, useEffect, useMemo, useRef } from 'react' import React, { RefObject, useEffect, useRef } from 'react'
import { Text } from '../Global/helpers/text' import { Text } from '../Global/helpers/text'
import { FlashList, FlashListRef } from '@shopify/flash-list' import { FlashList, FlashListRef } from '@shopify/flash-list'
import { UseInfiniteQueryResult } from '@tanstack/react-query' import { UseInfiniteQueryResult } from '@tanstack/react-query'
@@ -39,55 +39,52 @@ export default function Albums({
const pendingLetterRef = useRef<string | null>(null) const pendingLetterRef = useRef<string | null>(null)
// Memoize expensive stickyHeaderIndices calculation to prevent unnecessary re-computations // Memoize expensive stickyHeaderIndices calculation to prevent unnecessary re-computations
const stickyHeaderIndices = React.useMemo(() => { const stickyHeaderIndices =
if (!showAlphabeticalSelector || !albumsInfiniteQuery.data) return [] !showAlphabeticalSelector || !albumsInfiniteQuery.data
? []
return albumsInfiniteQuery.data : albumsInfiniteQuery.data
.map((album, index) => (typeof album === 'string' ? index : 0)) .map((album, index) => (typeof album === 'string' ? index : 0))
.filter((value, index, indices) => indices.indexOf(value) === index) .filter((value, index, indices) => indices.indexOf(value) === index)
}, [showAlphabeticalSelector, albumsInfiniteQuery.data])
const { mutateAsync: alphabetSelectorMutate, isPending: isAlphabetSelectorPending } = const { mutateAsync: alphabetSelectorMutate, isPending: isAlphabetSelectorPending } =
useAlphabetSelector((letter) => (pendingLetterRef.current = letter.toUpperCase())) useAlphabetSelector((letter) => (pendingLetterRef.current = letter.toUpperCase()))
const refreshControl = useMemo( const refreshControl = (
() => ( <RefreshControl
<RefreshControl refreshing={albumsInfiniteQuery.isFetching && !isAlphabetSelectorPending}
refreshing={albumsInfiniteQuery.isFetching && !isAlphabetSelectorPending} onRefresh={albumsInfiniteQuery.refetch}
onRefresh={albumsInfiniteQuery.refetch} tintColor={theme.primary.val}
tintColor={theme.primary.val} />
/>
),
[albumsInfiniteQuery.isFetching, isAlphabetSelectorPending, albumsInfiniteQuery.refetch],
) )
const ItemSeparatorComponent = useCallback( const ItemSeparatorComponent = ({
({ leadingItem, trailingItem }: { leadingItem: unknown; trailingItem: unknown }) => leadingItem,
typeof leadingItem === 'string' || typeof trailingItem === 'string' ? null : ( trailingItem,
<Separator /> }: {
), leadingItem: unknown
[], trailingItem: unknown
) }) =>
typeof leadingItem === 'string' || typeof trailingItem === 'string' ? null : <Separator />
const keyExtractor = useCallback( const keyExtractor = (item: BaseItemDto | string | number) =>
(item: BaseItemDto | string | number) => typeof item === 'string' ? item : typeof item === 'number' ? item.toString() : item.Id!
typeof item === 'string' ? item : typeof item === 'number' ? item.toString() : item.Id!,
[],
)
const renderItem = useCallback( const renderItem = ({
({ index, item: album }: { index: number; item: BaseItemDto | string | number }) => index,
typeof album === 'string' ? ( item: album,
<FlashListStickyHeader text={album.toUpperCase()} /> }: {
) : typeof album === 'number' ? null : typeof album === 'object' ? ( index: number
<ItemRow item={album} navigation={navigation} /> item: BaseItemDto | string | number
) : null, }) =>
[navigation], typeof album === 'string' ? (
) <FlashListStickyHeader text={album.toUpperCase()} />
) : typeof album === 'number' ? null : typeof album === 'object' ? (
<ItemRow item={album} navigation={navigation} />
) : null
const onEndReached = useCallback(() => { const onEndReached = () => {
if (albumsInfiniteQuery.hasNextPage) albumsInfiniteQuery.fetchNextPage() if (albumsInfiniteQuery.hasNextPage) albumsInfiniteQuery.fetchNextPage()
}, [albumsInfiniteQuery.hasNextPage, albumsInfiniteQuery.fetchNextPage]) }
// Effect for handling the pending alphabet selector letter // Effect for handling the pending alphabet selector letter
useEffect(() => { useEffect(() => {

View File

@@ -1,5 +1,5 @@
import React, { RefObject, useCallback, useEffect, useMemo, useRef } from 'react' import React, { RefObject, useEffect, useRef } from 'react'
import { getTokenValue, Separator, useTheme, XStack, YStack } from 'tamagui' import { Separator, useTheme, XStack, YStack } from 'tamagui'
import { Text } from '../Global/helpers/text' import { Text } from '../Global/helpers/text'
import { RefreshControl } from 'react-native' import { RefreshControl } from 'react-native'
import ItemRow from '../Global/components/item-row' import ItemRow from '../Global/components/item-row'
@@ -50,41 +50,41 @@ export default function Artists({
const { mutateAsync: alphabetSelectorMutate, isPending: isAlphabetSelectorPending } = const { mutateAsync: alphabetSelectorMutate, isPending: isAlphabetSelectorPending } =
useAlphabetSelector((letter) => (pendingLetterRef.current = letter.toUpperCase())) useAlphabetSelector((letter) => (pendingLetterRef.current = letter.toUpperCase()))
const stickyHeaderIndices = useMemo(() => { const stickyHeaderIndices =
if (!showAlphabeticalSelector || !artists) return [] !showAlphabeticalSelector || !artists
? []
: artists
.map((artist, index, artists) => (typeof artist === 'string' ? index : 0))
.filter((value, index, indices) => indices.indexOf(value) === index)
return artists const ItemSeparatorComponent = ({
.map((artist, index, artists) => (typeof artist === 'string' ? index : 0)) leadingItem,
.filter((value, index, indices) => indices.indexOf(value) === index) trailingItem,
}, [showAlphabeticalSelector, artists]) }: {
leadingItem: unknown
trailingItem: unknown
}) =>
typeof leadingItem === 'string' || typeof trailingItem === 'string' ? null : <Separator />
const ItemSeparatorComponent = useCallback( const KeyExtractor = (item: BaseItemDto | string | number, index: number) =>
({ leadingItem, trailingItem }: { leadingItem: unknown; trailingItem: unknown }) => typeof item === 'string' ? item : typeof item === 'number' ? item.toString() : item.Id!
typeof leadingItem === 'string' || typeof trailingItem === 'string' ? null : (
<Separator />
),
[],
)
const KeyExtractor = useCallback( const renderItem = ({
(item: BaseItemDto | string | number, index: number) => index,
typeof item === 'string' ? item : typeof item === 'number' ? item.toString() : item.Id!, item: artist,
[], }: {
) index: number
item: BaseItemDto | number | string
const renderItem = useCallback( }) =>
({ index, item: artist }: { index: number; item: BaseItemDto | number | string }) => typeof artist === 'string' ? (
typeof artist === 'string' ? ( // Don't render the letter if we don't have any artists that start with it
// Don't render the letter if we don't have any artists that start with it // If the index is the last index, or the next index is not an object, then don't render the letter
// If the index is the last index, or the next index is not an object, then don't render the letter index - 1 === artists.length || typeof artists[index + 1] !== 'object' ? null : (
index - 1 === artists.length || typeof artists[index + 1] !== 'object' ? null : ( <FlashListStickyHeader text={artist.toUpperCase()} />
<FlashListStickyHeader text={artist.toUpperCase()} /> )
) ) : typeof artist === 'number' ? null : typeof artist === 'object' ? (
) : typeof artist === 'number' ? null : typeof artist === 'object' ? ( <ItemRow circular item={artist} navigation={navigation} />
<ItemRow circular item={artist} navigation={navigation} /> ) : null
) : null,
[navigation],
)
// Effect for handling the pending alphabet selector letter // Effect for handling the pending alphabet selector letter
useEffect(() => { useEffect(() => {

View File

@@ -1,4 +1,4 @@
import React, { RefObject, useEffect, useMemo, useRef, useState } from 'react' import React, { RefObject, useEffect, useRef, useState } from 'react'
import { LayoutChangeEvent, Platform, View as RNView } from 'react-native' import { LayoutChangeEvent, Platform, View as RNView } from 'react-native'
import { getToken, Spinner, useTheme, View, YStack } from 'tamagui' import { getToken, Spinner, useTheme, View, YStack } from 'tamagui'
import { Gesture, GestureDetector } from 'react-native-gesture-handler' import { Gesture, GestureDetector } from 'react-native-gesture-handler'
@@ -61,78 +61,70 @@ export default function AZScroller({
}) })
} }
const panGesture = useMemo( const panGesture = Gesture.Pan()
() => .runOnJS(true)
Gesture.Pan() .onBegin((e) => {
.runOnJS(true) const relativeY = e.absoluteY - alphabetSelectorTopY.current
.onBegin((e) => { setOverlayPositionY(relativeY - letterHeight.current * 1.5)
const relativeY = e.absoluteY - alphabetSelectorTopY.current const index = Math.floor(relativeY / letterHeight.current)
setOverlayPositionY(relativeY - letterHeight.current * 1.5) if (alphabet[index]) {
const index = Math.floor(relativeY / letterHeight.current) const letter = alphabet[index]
if (alphabet[index]) { selectedLetter.value = letter
const letter = alphabet[index] setOverlayLetter(letter)
selectedLetter.value = letter scheduleOnRN(showOverlay)
setOverlayLetter(letter) }
scheduleOnRN(showOverlay) })
} .onUpdate((e) => {
}) const relativeY = e.absoluteY - alphabetSelectorTopY.current
.onUpdate((e) => { setOverlayPositionY(relativeY - letterHeight.current * 1.5)
const relativeY = e.absoluteY - alphabetSelectorTopY.current const index = Math.floor(relativeY / letterHeight.current)
setOverlayPositionY(relativeY - letterHeight.current * 1.5) if (alphabet[index]) {
const index = Math.floor(relativeY / letterHeight.current) const letter = alphabet[index]
if (alphabet[index]) { selectedLetter.value = letter
const letter = alphabet[index] setOverlayLetter(letter)
selectedLetter.value = letter scheduleOnRN(showOverlay)
setOverlayLetter(letter) }
scheduleOnRN(showOverlay) })
} .onEnd(() => {
}) if (selectedLetter.value) {
.onEnd(() => { scheduleOnRN(async () => {
if (selectedLetter.value) { setOperationPending(true)
scheduleOnRN(async () => { onLetterSelect(selectedLetter.value.toLowerCase()).then(() => {
setOperationPending(true)
onLetterSelect(selectedLetter.value.toLowerCase()).then(() => {
scheduleOnRN(hideOverlay)
setOperationPending(false)
})
})
} else {
scheduleOnRN(hideOverlay) scheduleOnRN(hideOverlay)
} setOperationPending(false)
}), })
[onLetterSelect], })
) } else {
scheduleOnRN(hideOverlay)
}
})
const tapGesture = useMemo( const tapGesture = Gesture.Tap()
() => .runOnJS(true)
Gesture.Tap() .onBegin((e) => {
.runOnJS(true) const relativeY = e.absoluteY - alphabetSelectorTopY.current
.onBegin((e) => { setOverlayPositionY(relativeY - letterHeight.current * 1.5)
const relativeY = e.absoluteY - alphabetSelectorTopY.current const index = Math.floor(relativeY / letterHeight.current)
setOverlayPositionY(relativeY - letterHeight.current * 1.5) if (alphabet[index]) {
const index = Math.floor(relativeY / letterHeight.current) const letter = alphabet[index]
if (alphabet[index]) { selectedLetter.value = letter
const letter = alphabet[index] setOverlayLetter(letter)
selectedLetter.value = letter scheduleOnRN(showOverlay)
setOverlayLetter(letter) }
scheduleOnRN(showOverlay) })
} .onEnd(() => {
}) if (selectedLetter.value) {
.onEnd(() => { scheduleOnRN(async () => {
if (selectedLetter.value) { setOperationPending(true)
scheduleOnRN(async () => { onLetterSelect(selectedLetter.value.toLowerCase()).then(() => {
setOperationPending(true)
onLetterSelect(selectedLetter.value.toLowerCase()).then(() => {
scheduleOnRN(hideOverlay)
setOperationPending(false)
})
})
} else {
scheduleOnRN(hideOverlay) scheduleOnRN(hideOverlay)
} setOperationPending(false)
}), })
[onLetterSelect], })
) } else {
scheduleOnRN(hideOverlay)
}
})
const gesture = Gesture.Simultaneous(panGesture, tapGesture) const gesture = Gesture.Simultaneous(panGesture, tapGesture)

View File

@@ -14,7 +14,7 @@ import { useNetworkStatus } from '../../../stores/network'
import useStreamingDeviceProfile from '../../../stores/device-profile' import useStreamingDeviceProfile from '../../../stores/device-profile'
import useItemContext from '../../../hooks/use-item-context' import useItemContext from '../../../hooks/use-item-context'
import { RouteProp, useRoute } from '@react-navigation/native' import { RouteProp, useRoute } from '@react-navigation/native'
import React, { memo, useCallback, useMemo, useState } from 'react' import React from 'react'
import { LayoutChangeEvent } from 'react-native' import { LayoutChangeEvent } from 'react-native'
import Animated, { import Animated, {
SharedValue, SharedValue,
@@ -51,322 +51,264 @@ interface ItemRowProps {
* @param navigation - The navigation object. * @param navigation - The navigation object.
* @returns * @returns
*/ */
const ItemRow = memo( function ItemRow({
function ItemRow({ item,
item, circular,
circular, navigation,
navigation, onPress,
onPress, queueName,
queueName, }: ItemRowProps): React.JSX.Element {
}: ItemRowProps): React.JSX.Element { const artworkAreaWidth = useSharedValue(0)
const artworkAreaWidth = useSharedValue(0)
const api = useApi() const api = useApi()
const [networkStatus] = useNetworkStatus() const [networkStatus] = useNetworkStatus()
const deviceProfile = useStreamingDeviceProfile() const deviceProfile = useStreamingDeviceProfile()
const loadNewQueue = useLoadNewQueue() const loadNewQueue = useLoadNewQueue()
const addToQueue = useAddToQueue() const addToQueue = useAddToQueue()
const { mutate: addFavorite } = useAddFavorite() const { mutate: addFavorite } = useAddFavorite()
const { mutate: removeFavorite } = useRemoveFavorite() const { mutate: removeFavorite } = useRemoveFavorite()
const [hideRunTimes] = useHideRunTimesSetting() const [hideRunTimes] = useHideRunTimesSetting()
const warmContext = useItemContext() const warmContext = useItemContext()
const { data: isFavorite } = useIsFavorite(item) const { data: isFavorite } = useIsFavorite(item)
const onPressIn = useCallback(() => warmContext(item), [warmContext, item.Id]) const onPressIn = () => warmContext(item)
const onLongPress = useCallback( const onLongPress = () =>
() => navigationRef.navigate('Context', {
navigationRef.navigate('Context', { item,
item, navigation,
navigation, })
}),
[navigationRef, navigation, item.Id],
)
const onPressCallback = useCallback(async () => { const onPressCallback = async () => {
if (onPress) await onPress() if (onPress) await onPress()
else else
switch (item.Type) { switch (item.Type) {
case 'Audio': { case 'Audio': {
loadNewQueue({ loadNewQueue({
api,
networkStatus,
deviceProfile,
track: item,
tracklist: [item],
index: 0,
queue: queueName ?? 'Search',
queuingType: QueuingType.FromSelection,
startPlayback: true,
})
break
}
case 'MusicArtist': {
navigation?.navigate('Artist', { artist: item })
break
}
case 'MusicAlbum': {
navigation?.navigate('Album', { album: item })
break
}
case 'Playlist': {
navigation?.navigate('Playlist', { playlist: item, canEdit: true })
break
}
default: {
break
}
}
}, [onPress, loadNewQueue, item.Id, navigation, queueName])
const renderRunTime = useMemo(
() => item.Type === BaseItemKind.Audio && !hideRunTimes,
[item.Type, hideRunTimes],
)
const isAudio = useMemo(() => item.Type === 'Audio', [item.Type])
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)
const swipeHandlers = useCallback(
() => ({
addToQueue: async () =>
await addToQueue({
api, api,
deviceProfile,
networkStatus, networkStatus,
tracks: [item], deviceProfile,
queuingType: QueuingType.DirectlyQueued, track: item,
}), tracklist: [item],
toggleFavorite: () => index: 0,
isFavorite ? removeFavorite({ item }) : addFavorite({ item }), queue: queueName ?? 'Search',
addToPlaylist: () => navigationRef.navigate('AddToPlaylist', { track: item }), queuingType: QueuingType.FromSelection,
}), startPlayback: true,
[ })
addToQueue, break
}
case 'MusicArtist': {
navigation?.navigate('Artist', { artist: item })
break
}
case 'MusicAlbum': {
navigation?.navigate('Album', { album: item })
break
}
case 'Playlist': {
navigation?.navigate('Playlist', { playlist: item, canEdit: true })
break
}
default: {
break
}
}
}
const renderRunTime = item.Type === BaseItemKind.Audio && !hideRunTimes
const isAudio = item.Type === 'Audio'
const playlistTrackCount =
item.Type === 'Playlist' ? (item.SongCount ?? item.ChildCount ?? 0) : undefined
const leftSettings = useSwipeSettingsStore((s) => s.left)
const rightSettings = useSwipeSettingsStore((s) => s.right)
const swipeHandlers = () => ({
addToQueue: async () =>
await addToQueue({
api, api,
deviceProfile, deviceProfile,
networkStatus, networkStatus,
item, tracks: [item],
addFavorite, queuingType: QueuingType.DirectlyQueued,
removeFavorite, }),
isFavorite, toggleFavorite: () => (isFavorite ? removeFavorite({ item }) : addFavorite({ item })),
], addToPlaylist: () => navigationRef.navigate('AddToPlaylist', { track: item }),
) })
const swipeConfig = useMemo( const swipeConfig = isAudio
() => ? buildSwipeConfig({
isAudio left: leftSettings,
? buildSwipeConfig({ right: rightSettings,
left: leftSettings, handlers: swipeHandlers(),
right: rightSettings, })
handlers: swipeHandlers(), : {}
})
: {},
[isAudio, leftSettings, rightSettings, swipeHandlers],
)
const handleArtworkLayout = useCallback( const handleArtworkLayout = (event: LayoutChangeEvent) => {
(event: LayoutChangeEvent) => { const { width } = event.nativeEvent.layout
const { width } = event.nativeEvent.layout artworkAreaWidth.value = width
artworkAreaWidth.value = width }
},
[artworkAreaWidth],
)
const pressStyle = useMemo(() => ({ opacity: 0.5 }), []) const pressStyle = {
opacity: 0.5,
}
return ( return (
<SwipeableRow <SwipeableRow
disabled={!isAudio} disabled={!isAudio}
{...swipeConfig} {...swipeConfig}
onLongPress={onLongPress} onLongPress={onLongPress}
onPress={onPressCallback}
>
<XStack
alignContent='center'
width={'100%'}
testID={item.Id ? `item-row-${item.Id}` : undefined}
onPressIn={onPressIn}
onPress={onPressCallback} onPress={onPressCallback}
onLongPress={onLongPress}
animation={'quick'}
pressStyle={pressStyle}
paddingVertical={'$2'}
paddingRight={'$2'}
paddingLeft={'$1'}
backgroundColor={'$background'}
borderRadius={'$2'}
> >
<XStack <HideableArtwork item={item} circular={circular} onLayout={handleArtworkLayout} />
alignContent='center' <SlidingTextArea leftGapWidth={artworkAreaWidth}>
width={'100%'} <ItemRowDetails item={item} />
testID={item.Id ? `item-row-${item.Id}` : undefined} </SlidingTextArea>
onPressIn={onPressIn}
onPress={onPressCallback}
onLongPress={onLongPress}
animation={'quick'}
pressStyle={pressStyle}
paddingVertical={'$2'}
paddingRight={'$2'}
paddingLeft={'$1'}
backgroundColor={'$background'}
borderRadius={'$2'}
>
<HideableArtwork
item={item}
circular={circular}
onLayout={handleArtworkLayout}
/>
<SlidingTextArea leftGapWidth={artworkAreaWidth}>
<ItemRowDetails item={item} />
</SlidingTextArea>
<XStack justifyContent='flex-end' alignItems='center' flexShrink={1}>
{renderRunTime ? (
<RunTimeTicks>{item.RunTimeTicks}</RunTimeTicks>
) : item.Type === 'Playlist' ? (
<Text color={'$borderColor'}>
{`${playlistTrackCount ?? 0} ${playlistTrackCount === 1 ? 'Track' : 'Tracks'}`}
</Text>
) : null}
<FavoriteIcon item={item} />
{item.Type === 'Audio' || item.Type === 'MusicAlbum' ? (
<Icon name='dots-horizontal' onPress={onLongPress} />
) : null}
</XStack>
</XStack>
</SwipeableRow>
)
},
(prevProps, nextProps) =>
prevProps.item.Id === nextProps.item.Id &&
prevProps.circular === nextProps.circular &&
prevProps.navigation === nextProps.navigation &&
prevProps.queueName === nextProps.queueName &&
!!prevProps.onPress === !!nextProps.onPress,
)
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 shouldRenderProductionYear = item.Type === 'MusicAlbum' && route.name === 'Artist'
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'}
</Text>
)}
{shouldRenderProductionYear && (
<XStack gap='$2'>
<Text
color={'$borderColor'}
lineBreakStrategyIOS='standard'
numberOfLines={1}
>
{item.ProductionYear?.toString() ?? 'Unknown Year'}
</Text>
<Text color={'$borderColor'}></Text>
<XStack justifyContent='flex-end' alignItems='center' flexShrink={1}>
{renderRunTime ? (
<RunTimeTicks>{item.RunTimeTicks}</RunTimeTicks> <RunTimeTicks>{item.RunTimeTicks}</RunTimeTicks>
</XStack> ) : item.Type === 'Playlist' ? (
)} <Text color={'$borderColor'}>
{`${playlistTrackCount ?? 0} ${playlistTrackCount === 1 ? 'Track' : 'Tracks'}`}
</Text>
) : null}
<FavoriteIcon item={item} />
{shouldRenderGenres && item.Genres && ( {item.Type === 'Audio' || item.Type === 'MusicAlbum' ? (
<Icon name='dots-horizontal' onPress={onLongPress} />
) : null}
</XStack>
</XStack>
</SwipeableRow>
)
}
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 shouldRenderProductionYear = item.Type === 'MusicAlbum' && route.name === 'Artist'
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'}
</Text>
)}
{shouldRenderProductionYear && (
<XStack gap='$2'>
<Text color={'$borderColor'} lineBreakStrategyIOS='standard' numberOfLines={1}> <Text color={'$borderColor'} lineBreakStrategyIOS='standard' numberOfLines={1}>
{item.Genres?.join(', ') ?? ''} {item.ProductionYear?.toString() ?? 'Unknown Year'}
</Text> </Text>
)}
</YStack> <Text color={'$borderColor'}></Text>
)
}, <RunTimeTicks>{item.RunTimeTicks}</RunTimeTicks>
(prevProps, nextProps) => prevProps.item.Id === nextProps.item.Id, </XStack>
) )}
{shouldRenderGenres && item.Genres && (
<Text color={'$borderColor'} lineBreakStrategyIOS='standard' numberOfLines={1}>
{item.Genres?.join(', ') ?? ''}
</Text>
)}
</YStack>
)
}
// Artwork wrapper that fades out when the quick-action menu is open // Artwork wrapper that fades out when the quick-action menu is open
const HideableArtwork = memo( function HideableArtwork({
function HideableArtwork({ item,
item, circular,
circular, onLayout,
onLayout, }: {
}: { item: BaseItemDto
item: BaseItemDto circular?: boolean
circular?: boolean onLayout?: (event: LayoutChangeEvent) => void
onLayout?: (event: LayoutChangeEvent) => void }): React.JSX.Element {
}): React.JSX.Element { const { tx } = useSwipeableRowContext()
const { tx } = useSwipeableRowContext() // Hide artwork as soon as swiping starts (any non-zero tx)
// Hide artwork as soon as swiping starts (any non-zero tx) const style = useAnimatedStyle(() => ({
const style = useAnimatedStyle(() => ({ opacity: tx.value === 0 ? withTiming(1) : 0,
opacity: tx.value === 0 ? withTiming(1) : 0, }))
})) return (
return ( <Animated.View style={style} onLayout={onLayout}>
<Animated.View style={style} onLayout={onLayout}> <XStack marginHorizontal={'$3'} marginVertical={'auto'} alignItems='center'>
<XStack marginHorizontal={'$3'} marginVertical={'auto'} alignItems='center'> <ItemImage
<ItemImage item={item}
item={item} height={'$12'}
height={'$12'} width={'$12'}
width={'$12'} circular={item.Type === 'MusicArtist' || circular}
circular={item.Type === 'MusicArtist' || circular} />
/> </XStack>
</XStack> </Animated.View>
</Animated.View> )
) }
},
(prevProps, nextProps) =>
prevProps.item.Id === nextProps.item.Id &&
prevProps.circular === nextProps.circular &&
!!prevProps.onLayout === !!nextProps.onLayout,
)
const SlidingTextArea = memo( function SlidingTextArea({
function SlidingTextArea({ leftGapWidth,
leftGapWidth, children,
children, }: {
}: { leftGapWidth: SharedValue<number>
leftGapWidth: SharedValue<number> children: React.ReactNode
children: React.ReactNode }): React.JSX.Element {
}): React.JSX.Element { const { tx, rightWidth } = useSwipeableRowContext()
const { tx, rightWidth } = useSwipeableRowContext() const tokenValue = getToken('$2', 'space')
const tokenValue = getToken('$2', 'space') const spacingValue = typeof tokenValue === 'number' ? tokenValue : parseFloat(`${tokenValue}`)
const spacingValue = const quickActionBuffer = Number.isFinite(spacingValue) ? spacingValue : 8
typeof tokenValue === 'number' ? tokenValue : parseFloat(`${tokenValue}`) const style = useAnimatedStyle(() => {
const quickActionBuffer = Number.isFinite(spacingValue) ? spacingValue : 8 const t = tx.value
const style = useAnimatedStyle(() => { let offset = 0
const t = tx.value if (t > 0 && leftGapWidth.get() > 0) {
let offset = 0 offset = -Math.min(t, leftGapWidth.get())
if (t > 0 && leftGapWidth.get() > 0) { } else if (t < 0) {
offset = -Math.min(t, leftGapWidth.get()) const rightSpace = Math.max(0, rightWidth)
} else if (t < 0) { const compensate = Math.min(-t, rightSpace)
const rightSpace = Math.max(0, rightWidth) const progress = rightSpace > 0 ? compensate / rightSpace : 1
const compensate = Math.min(-t, rightSpace) offset = compensate * 0.7 + quickActionBuffer * progress
const progress = rightSpace > 0 ? compensate / rightSpace : 1 }
offset = compensate * 0.7 + quickActionBuffer * progress return { transform: [{ translateX: offset }] }
} })
return { transform: [{ translateX: offset }] } const paddingRightValue = Number.isFinite(spacingValue) ? spacingValue : 8
}) return (
const paddingRightValue = Number.isFinite(spacingValue) ? spacingValue : 8 <Animated.View style={[{ flex: 5, paddingRight: paddingRightValue }, style]}>
return ( {children}
<Animated.View style={[{ flex: 5, paddingRight: paddingRightValue }, style]}> </Animated.View>
{children} )
</Animated.View> }
)
},
(prevProps, nextProps) =>
prevProps.leftGapWidth === nextProps.leftGapWidth &&
prevProps.children?.valueOf() === nextProps.children?.valueOf(),
)
export default ItemRow export default ItemRow

View File

@@ -1,4 +1,4 @@
import React, { useMemo, useCallback, useState, memo } from 'react' import React, { useState } from 'react'
import { getToken, Theme, useTheme, XStack, YStack } from 'tamagui' import { getToken, Theme, useTheme, XStack, YStack } from 'tamagui'
import { Text } from '../helpers/text' import { Text } from '../helpers/text'
import { RunTimeTicks } from '../helpers/time-codes' import { RunTimeTicks } from '../helpers/time-codes'
@@ -28,10 +28,7 @@ import { useAddFavorite, useRemoveFavorite } from '../../../api/mutations/favori
import { StackActions } from '@react-navigation/native' import { StackActions } from '@react-navigation/native'
import { useSwipeableRowContext } from './swipeable-row-context' import { useSwipeableRowContext } from './swipeable-row-context'
import { useHideRunTimesSetting } from '../../../stores/settings/app' import { useHideRunTimesSetting } from '../../../stores/settings/app'
import { queryClient, ONE_HOUR } from '../../../constants/query-client' import useStreamedMediaInfo from '../../../api/queries/media'
import { fetchMediaInfo } from '../../../api/queries/media/utils'
import MediaInfoQueryKey from '../../../api/queries/media/keys'
import JellifyTrack from '../../../types/JellifyTrack'
export interface TrackProps { export interface TrackProps {
track: BaseItemDto track: BaseItemDto
@@ -48,329 +45,243 @@ export interface TrackProps {
editing?: boolean | undefined editing?: boolean | undefined
} }
const queueItemsCache = new WeakMap<JellifyTrack[], BaseItemDto[]>() export default 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 getQueueItems = (queue: JellifyTrack[] | undefined): BaseItemDto[] => { const api = useApi()
if (!queue?.length) return []
const cached = queueItemsCache.get(queue) const deviceProfile = useStreamingDeviceProfile()
if (cached) return cached
const mapped = queue.map((entry) => entry.item) const [hideRunTimes] = useHideRunTimesSetting()
queueItemsCache.set(queue, mapped)
return mapped
}
const Track = memo( const nowPlaying = useCurrentTrack()
function Track({ const playQueue = usePlayQueue()
track, const loadNewQueue = useLoadNewQueue()
navigation, const addToQueue = useAddToQueue()
tracklist, const [networkStatus] = useNetworkStatus()
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 { data: mediaInfo } = useStreamedMediaInfo(track.Id)
const deviceProfile = useStreamingDeviceProfile() const offlineAudio = useDownloadedTrack(track.Id)
const [hideRunTimes] = useHideRunTimesSetting() 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 nowPlaying = useCurrentTrack() // Memoize expensive computations
const playQueue = usePlayQueue() const isPlaying = nowPlaying?.item.Id === track.Id
const loadNewQueue = useLoadNewQueue()
const addToQueue = useAddToQueue()
const [networkStatus] = useNetworkStatus()
const offlineAudio = useDownloadedTrack(track.Id) const isOffline = networkStatus === networkStatusTypes.DISCONNECTED
const { mutate: addFavorite } = useAddFavorite() // Memoize tracklist for queue loading
const { mutate: removeFavorite } = useRemoveFavorite() const memoizedTracklist = tracklist ?? playQueue?.map((track) => track.item) ?? []
const { data: isFavoriteTrack } = useIsFavorite(track)
const leftSettings = useSwipeSettingsStore((s) => s.left)
const rightSettings = useSwipeSettingsStore((s) => s.right)
// Memoize expensive computations // Memoize handlers to prevent recreation
const isPlaying = useMemo( const handlePress = async () => {
() => nowPlaying?.item.Id === track.Id, if (onPress) {
[nowPlaying?.item.Id, track.Id], await onPress()
) } else {
loadNewQueue({
const isOffline = useMemo(
() => networkStatus === networkStatusTypes.DISCONNECTED,
[networkStatus],
)
// Memoize tracklist for queue loading
const memoizedTracklist = useMemo(
() => tracklist ?? getQueueItems(playQueue),
[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,
api,
deviceProfile,
networkStatus,
track,
index,
memoizedTracklist,
queue,
loadNewQueue,
])
const fetchStreamingMediaSourceInfo = useCallback(async () => {
if (!api || !deviceProfile || !track.Id) return undefined
const queryKey = MediaInfoQueryKey({ api, deviceProfile, itemId: track.Id })
try {
const info = await queryClient.ensureQueryData({
queryKey,
queryFn: () => fetchMediaInfo(api, deviceProfile, track.Id),
staleTime: ONE_HOUR,
gcTime: ONE_HOUR,
})
return info.MediaSources?.[0]
} catch (error) {
console.warn('Failed to fetch media info for context sheet', error)
return undefined
}
}, [api, deviceProfile, track.Id])
const openContextSheet = useCallback(async () => {
const streamingMediaSourceInfo = await fetchStreamingMediaSourceInfo()
navigationRef.navigate('Context', {
item: track,
navigation,
streamingMediaSourceInfo,
downloadedMediaSourceInfo: offlineAudio?.mediaSourceInfo,
})
}, [fetchStreamingMediaSourceInfo, track, navigation, offlineAudio?.mediaSourceInfo])
const handleLongPress = useCallback(() => {
if (onLongPress) {
onLongPress()
return
}
void openContextSheet()
}, [onLongPress, openContextSheet])
const handleIconPress = useCallback(() => {
void openContextSheet()
}, [openContextSheet])
// Memoize artists text
const artistsText = useMemo(() => track.Artists?.join(', ') ?? '', [track.Artists])
// Memoize track name
const trackName = useMemo(() => track.Name ?? 'Untitled Track', [track.Name])
// 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],
)
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, api,
deviceProfile, deviceProfile,
networkStatus, networkStatus,
track, track,
addFavorite, index,
removeFavorite, tracklist: memoizedTracklist,
isFavoriteTrack, queue,
navigationRef, queuingType: QueuingType.FromSelection,
], startPlayback: true,
) })
}
}
const swipeConfig = useMemo( const handleLongPress = () => {
() => if (onLongPress) {
buildSwipeConfig({ onLongPress()
left: leftSettings, } else {
right: rightSettings, navigationRef.navigate('Context', {
handlers: swipeHandlers, item: track,
}), navigation,
[leftSettings, rightSettings, swipeHandlers], streamingMediaSourceInfo: mediaInfo?.MediaSources
) ? mediaInfo!.MediaSources![0]
: undefined,
downloadedMediaSourceInfo: offlineAudio?.mediaSourceInfo,
})
}
}
const textColor = useMemo( const handleIconPress = () => {
() => (isPlaying ? theme.primary.val : theme.color.val), navigationRef.navigate('Context', {
[isPlaying], item: track,
) navigation,
streamingMediaSourceInfo: mediaInfo?.MediaSources
? mediaInfo!.MediaSources![0]
: undefined,
downloadedMediaSourceInfo: offlineAudio?.mediaSourceInfo,
})
}
const runtimeComponent = useMemo( // Memoize text color to prevent recalculation
() => const textColor = isPlaying
hideRunTimes ? ( ? theme.primary.val
<></> : isOffline
) : ( ? offlineAudio
<RunTimeTicks ? theme.color
key={`${track.Id}-runtime`} : theme.neutral.val
props={{ : theme.color
style: {
textAlign: 'right',
minWidth: getToken('$10'),
alignSelf: 'center',
},
}}
>
{track.RunTimeTicks}
</RunTimeTicks>
),
[hideRunTimes, track.RunTimeTicks],
)
return ( // Memoize artists text
<Theme name={invertedColors ? 'inverted_purple' : undefined}> const artistsText = track.Artists?.join(', ') ?? ''
<SwipeableRow
disabled={isNested === true} // Memoize track name
{...swipeConfig} const trackName = track.Name ?? 'Untitled Track'
onLongPress={handleLongPress}
onPress={handlePress} // Memoize index number
const indexNumber = track.IndexNumber?.toString() ?? ''
// Memoize show artists condition
const shouldShowArtists = showArtwork || (track.Artists && track.Artists.length > 1)
const swipeHandlers = {
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 }))
},
}
const swipeConfig = buildSwipeConfig({
left: leftSettings,
right: rightSettings,
handlers: swipeHandlers,
})
const runtimeComponent = hideRunTimes ? (
<></>
) : (
<RunTimeTicks
key={`${track.Id}-runtime`}
props={{
style: {
textAlign: 'right',
minWidth: getToken('$10'),
alignSelf: 'center',
},
}}
>
{track.RunTimeTicks}
</RunTimeTicks>
)
return (
<Theme name={invertedColors ? 'inverted_purple' : undefined}>
<SwipeableRow
disabled={isNested === true}
{...swipeConfig}
onLongPress={handleLongPress}
onPress={handlePress}
>
<XStack
alignContent='center'
alignItems='center'
flex={1}
testID={testID ?? undefined}
paddingVertical={'$2'}
justifyContent='flex-start'
paddingRight={'$2'}
animation={'quick'}
pressStyle={{ opacity: 0.5 }}
backgroundColor={'$background'}
> >
<XStack <XStack
alignContent='center' alignContent='center'
alignItems='center' justifyContent='center'
flex={1} marginHorizontal={showArtwork ? '$2' : '$1'}
testID={testID ?? undefined} onLayout={(e) => setArtworkAreaWidth(e.nativeEvent.layout.width)}
paddingVertical={'$2'}
justifyContent='flex-start'
paddingRight={'$2'}
animation={'quick'}
pressStyle={{ opacity: 0.5 }}
backgroundColor={'$background'}
> >
<XStack {showArtwork ? (
alignContent='center' <HideableArtwork>
justifyContent='center' <ItemImage item={track} width={'$12'} height={'$12'} />
marginHorizontal={showArtwork ? '$2' : '$1'} </HideableArtwork>
onLayout={(e) => setArtworkAreaWidth(e.nativeEvent.layout.width)} ) : (
> <Text
{showArtwork ? ( key={`${track.Id}-number`}
<HideableArtwork> color={textColor}
<ItemImage item={track} width={'$12'} height={'$12'} /> width={getToken('$12')}
</HideableArtwork> textAlign='center'
) : ( fontVariant={['tabular-nums']}
<Text >
key={`${track.Id}-number`} {indexNumber}
color={textColor} </Text>
width={getToken('$12')} )}
textAlign='center' </XStack>
fontVariant={['tabular-nums']}
>
{indexNumber}
</Text>
)}
</XStack>
<SlidingTextArea leftGapWidth={artworkAreaWidth} hasArtwork={!!showArtwork}> <SlidingTextArea leftGapWidth={artworkAreaWidth} hasArtwork={!!showArtwork}>
<YStack alignItems='flex-start' justifyContent='center' flex={6}> <YStack alignItems='flex-start' justifyContent='center' flex={1}>
<Text
key={`${track.Id}-name`}
bold
color={textColor}
lineBreakStrategyIOS='standard'
numberOfLines={1}
>
{trackName}
</Text>
{shouldShowArtists && (
<Text <Text
key={`${track.Id}-name`} key={`${track.Id}-artists`}
bold
color={textColor}
lineBreakStrategyIOS='standard' lineBreakStrategyIOS='standard'
numberOfLines={1} numberOfLines={1}
color={'$borderColor'}
> >
{trackName} {artistsText}
</Text> </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> </YStack>
</SlidingTextArea>
<XStack justifyContent='flex-end' alignItems='center' flexShrink={1} gap='$1'>
<DownloadedIcon item={track} />
<FavoriteIcon item={track} />
{runtimeComponent}
{!editing && <Icon name={'dots-horizontal'} onPress={handleIconPress} />}
</XStack> </XStack>
</SwipeableRow> </XStack>
</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 }) { function HideableArtwork({ children }: { children: React.ReactNode }) {
const { tx } = useSwipeableRowContext() const { tx } = useSwipeableRowContext()
@@ -402,7 +313,5 @@ function SlidingTextArea({
} }
return { transform: [{ translateX: offset }] } return { transform: [{ translateX: offset }] }
}) })
return <Animated.View style={[{ flex: 5 }, style]}>{children}</Animated.View> return <Animated.View style={[{ flex: 1 }, style]}>{children}</Animated.View>
} }
export default Track