mirror of
https://github.com/Jellify-Music/App.git
synced 2025-12-30 23:39:51 -06:00
Maintenance/perf (#734)
Render fixes to ItemCards, ItemRows, and Images
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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
|
||||
/>
|
||||
|
||||
@@ -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'}>
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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
|
||||
/>
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user