performance improvements related to background data fetching

This commit is contained in:
Violet Caulfield
2025-08-23 05:23:48 -05:00
committed by Violet Caulfield
parent e8be30499e
commit 7ab462b201
10 changed files with 165 additions and 79 deletions

View File

@@ -1,14 +1,17 @@
import { ActivityIndicator, RefreshControl } from 'react-native'
import { getToken, Separator, XStack, YStack } from 'tamagui'
import React from 'react'
import React, { useRef } from 'react'
import { Text } from '../Global/helpers/text'
import { FlashList } from '@shopify/flash-list'
import { FlashList, ViewToken } from '@shopify/flash-list'
import { FetchNextPageOptions } from '@tanstack/react-query'
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
import ItemRow from '../Global/components/item-row'
import { useNavigation } from '@react-navigation/native'
import LibraryStackParamList from '../../screens/Library/types'
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import { warmItemContext } from '../../hooks/use-item-context'
import { useJellifyContext } from '../../providers'
import { useStreamingQualityContext } from '../../providers/Settings'
interface AlbumsProps {
albums: (string | number | BaseItemDto)[] | undefined
@@ -28,6 +31,19 @@ export default function Albums({
}: AlbumsProps): React.JSX.Element {
const navigation = useNavigation<NativeStackNavigationProp<LibraryStackParamList>>()
const { api, user } = useJellifyContext()
const streamingQuality = useStreamingQualityContext()
const onViewableItemsChangedRef = useRef(
({ viewableItems }: { viewableItems: ViewToken<string | number | BaseItemDto>[] }) => {
viewableItems.forEach(({ isViewable, item }) => {
if (isViewable && typeof item === 'object')
warmItemContext(api, user, item, streamingQuality)
})
},
)
// Memoize expensive stickyHeaderIndices calculation to prevent unnecessary re-computations
const stickyHeaderIndices = React.useMemo(() => {
if (!showAlphabeticalSelector || !albums) return []
@@ -89,6 +105,7 @@ export default function Albums({
refreshControl={<RefreshControl refreshing={isPending} />}
stickyHeaderIndices={stickyHeaderIndices}
removeClippedSubviews
onViewableItemsChanged={onViewableItemsChangedRef.current}
/>
</XStack>
)

View File

@@ -1,18 +1,26 @@
import React, { useEffect, useState } from 'react'
import React, { useEffect, useRef, useState } from 'react'
import { ItemCard } from '../Global/components/item-card'
import { ArtistAlbumsProps, ArtistEpsProps, ArtistFeaturedOnProps } from './types'
import { Text } from '../Global/helpers/text'
import { useArtistContext } from '../../providers/Artist'
import { convertRunTimeTicksToSeconds } from '../../utils/runtimeticks'
import Animated, { useAnimatedScrollHandler } from 'react-native-reanimated'
import { ActivityIndicator } from 'react-native'
import { ActivityIndicator, ViewToken } from 'react-native'
import { useSafeAreaFrame } from 'react-native-safe-area-context'
import { getToken } from 'tamagui'
import navigationRef from '../../../navigation'
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models/base-item-dto'
import { warmItemContext } from '../../hooks/use-item-context'
import { useJellifyContext } from '../../providers'
import { useStreamingQualityContext } from '../../providers/Settings'
export default function Albums({
route,
navigation,
}: ArtistAlbumsProps | ArtistEpsProps | ArtistFeaturedOnProps): React.JSX.Element {
const { api, user } = useJellifyContext()
const streamingQuality = useStreamingQualityContext()
const { width } = useSafeAreaFrame()
const { albums, fetchingAlbums, featuredOn, scroll } = useArtistContext()
const scrollHandler = useAnimatedScrollHandler({
@@ -22,6 +30,14 @@ export default function Albums({
},
})
const onViewableItemsChangedRef = useRef(
({ viewableItems }: { viewableItems: ViewToken<BaseItemDto>[] }) => {
viewableItems.forEach(({ isViewable, item }) => {
if (isViewable) warmItemContext(api, user, item, streamingQuality)
})
},
)
const [columns, setColumns] = useState(Math.floor(width / getToken('$20')))
useEffect(() => {
@@ -97,6 +113,7 @@ export default function Albums({
)
}
removeClippedSubviews
onViewableItemsChanged={onViewableItemsChangedRef.current}
/>
)
}

View File

@@ -6,13 +6,16 @@ import { ArtistsProps } from '../../screens/types'
import ItemRow from '../Global/components/item-row'
import { useLibrarySortAndFilterContext } from '../../providers/Library'
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models/base-item-dto'
import { FlashList, FlashListRef } from '@shopify/flash-list'
import { FlashList, FlashListRef, ViewToken } from '@shopify/flash-list'
import { AZScroller } from '../Global/components/alphabetical-selector'
import { useMutation } from '@tanstack/react-query'
import { isString } from 'lodash'
import { useNavigation } from '@react-navigation/native'
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import LibraryStackParamList from '../../screens/Library/types'
import { warmItemContext } from '../../hooks/use-item-context'
import { useJellifyContext } from '../../providers'
import { useStreamingQualityContext } from '../../providers/Settings'
/**
* @param artistsInfiniteQuery - The infinite query for artists
@@ -27,6 +30,11 @@ export default function Artists({
artistPageParams,
}: ArtistsProps): React.JSX.Element {
const theme = useTheme()
const { api, user } = useJellifyContext()
const streamingQuality = useStreamingQualityContext()
const { isFavorites } = useLibrarySortAndFilterContext()
const navigation = useNavigation<NativeStackNavigationProp<LibraryStackParamList>>()
@@ -36,6 +44,15 @@ export default function Artists({
const pendingLetterRef = useRef<string | null>(null)
const onViewableItemsChangedRef = useRef(
({ viewableItems }: { viewableItems: ViewToken<string | number | BaseItemDto>[] }) => {
viewableItems.forEach(({ isViewable, item }) => {
if (isViewable && typeof item === 'object')
warmItemContext(api, user, item, streamingQuality)
})
},
)
const alphabeticalSelectorCallback = async (letter: string) => {
console.debug(`Alphabetical Selector Callback: ${letter}`)
@@ -169,6 +186,7 @@ export default function Artists({
artistsInfiniteQuery.fetchNextPage()
}}
removeClippedSubviews
onViewableItemsChanged={onViewableItemsChangedRef.current}
/>
{showAlphabeticalSelector && artistPageParams && (

View File

@@ -13,7 +13,7 @@ export default function Index(): React.JSX.Element {
useDiscoverContext()
return (
<SafeAreaView style={{ flex: 1 }}>
<SafeAreaView style={{ flex: 1 }} edges={['top']}>
<ScrollView
contentContainerStyle={{
flexGrow: 1,

View File

@@ -1,6 +1,6 @@
import { warmItemContext } from '@/src/hooks/use-item-context'
import { useJellifyContext } from '@/src/providers'
import { useStreamingQualityContext } from '@/src/providers/Settings'
import { warmItemContext } from '../../../hooks/use-item-context'
import { useJellifyContext } from '../../../providers'
import { useStreamingQualityContext } from '../../../providers/Settings'
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models/base-item-dto'
import { FlashList, FlashListProps, ViewToken } from '@shopify/flash-list'
import React, { useRef } from 'react'

View File

@@ -4,9 +4,6 @@ import { getToken, Card as TamaguiCard, View, YStack } from 'tamagui'
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
import { Text } from '../helpers/text'
import ItemImage from './image'
import { warmItemContext } from '../../../hooks/use-item-context'
import { useJellifyContext } from '../../../providers'
import { useStreamingQualityContext } from '../../../providers/Settings'
interface CardProps extends TamaguiCardProps {
caption?: string | null | undefined
@@ -24,10 +21,6 @@ interface CardProps extends TamaguiCardProps {
* @param props
*/
export function ItemCard(props: CardProps) {
const { api, user } = useJellifyContext()
const streamingQuality = useStreamingQualityContext()
return (
<View alignItems='center' margin={'$1.5'}>
<TamaguiCard
@@ -41,7 +34,6 @@ export function ItemCard(props: CardProps) {
animation='bouncy'
hoverStyle={props.onPress ? { scale: 0.925 } : {}}
pressStyle={props.onPress ? { scale: 0.875 } : {}}
onPressIn={() => warmItemContext(api, user, props.item, streamingQuality)}
{...props}
>
<TamaguiCard.Header></TamaguiCard.Header>

View File

@@ -12,9 +12,6 @@ import { runOnJS } from 'react-native-reanimated'
import navigationRef from '../../../../navigation'
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import { BaseStackParamList } from '../../../screens/types'
import { warmItemContext } from '../../../hooks/use-item-context'
import { useJellifyContext } from '../../../providers'
import { useStreamingQualityContext } from '../../../providers/Settings'
interface ItemRowProps {
item: BaseItemDto
@@ -43,10 +40,6 @@ export default function ItemRow({
}: ItemRowProps): React.JSX.Element {
const useLoadNewQueue = useLoadQueueContext()
const { api, user } = useJellifyContext()
const streamingQuality = useStreamingQualityContext()
const gestureCallback = () => {
switch (item.Type) {
case 'Audio': {
@@ -77,7 +70,6 @@ export default function ItemRow({
alignContent='center'
minHeight={'$7'}
width={'100%'}
onPressIn={() => warmItemContext(api, user, item, streamingQuality)}
onLongPress={() => {
navigationRef.navigate('Context', {
item,

View File

@@ -1,12 +1,16 @@
import { RefreshControl } from 'react-native-gesture-handler'
import { Separator } from 'tamagui'
import { FlashList } from '@shopify/flash-list'
import { FlashList, ViewToken } from '@shopify/flash-list'
import ItemRow from '../Global/components/item-row'
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
import { FetchNextPageOptions } from '@tanstack/react-query'
import { useNavigation } from '@react-navigation/native'
import { BaseStackParamList } from '@/src/screens/types'
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import { useRef } from 'react'
import { warmItemContext } from '../../hooks/use-item-context'
import { useJellifyContext } from '../../providers'
import { useStreamingQualityContext } from '../../providers/Settings'
export interface PlaylistsProps {
canEdit?: boolean | undefined
@@ -27,6 +31,19 @@ export default function Playlists({
canEdit,
}: PlaylistsProps): React.JSX.Element {
const navigation = useNavigation<NativeStackNavigationProp<BaseStackParamList>>()
const { api, user } = useJellifyContext()
const streamingQuality = useStreamingQualityContext()
const onViewableItemsChangedRef = useRef(
({ viewableItems }: { viewableItems: ViewToken<BaseItemDto>[] }) => {
viewableItems.forEach(({ isViewable, item }) => {
if (isViewable) warmItemContext(api, user, item, streamingQuality)
})
},
)
return (
<FlashList
contentInsetAdjustmentBehavior='automatic'
@@ -51,6 +68,7 @@ export default function Playlists({
}
}}
removeClippedSubviews
onViewableItemsChanged={onViewableItemsChangedRef.current}
/>
)
}

View File

@@ -1,4 +1,4 @@
import React from 'react'
import React, { useRef } from 'react'
import Track from '../Global/components/track'
import { getTokens, Separator } from 'tamagui'
import { BaseItemDto, UserItemDataDto } from '@jellyfin/sdk/lib/generated-client/models'
@@ -6,9 +6,12 @@ import { Queue } from '../../player/types/queue-item'
import { useNetworkContext } from '../../providers/Network'
import { queryClient } from '../../constants/query-client'
import { QueryKeys } from '../../enums/query-keys'
import { FlashList } from '@shopify/flash-list'
import { FlashList, ViewToken } from '@shopify/flash-list'
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import { BaseStackParamList } from '@/src/screens/types'
import { warmItemContext } from '../../hooks/use-item-context'
import { useJellifyContext } from '../../providers'
import { useStreamingQualityContext } from '../../providers/Settings'
interface TracksProps {
tracks: (string | number | BaseItemDto)[] | undefined
@@ -29,6 +32,9 @@ export default function Tracks({
filterDownloaded,
filterFavorites,
}: TracksProps): React.JSX.Element {
const { api, user } = useJellifyContext()
const streamingQuality = useStreamingQualityContext()
const { downloadedTracks } = useNetworkContext()
// Memoize the expensive tracks processing to prevent memory leaks
@@ -73,6 +79,14 @@ export default function Tracks({
[tracksToDisplay, queue],
)
const onViewableItemsChangedRef = useRef(
({ viewableItems }: { viewableItems: ViewToken<BaseItemDto>[] }) => {
viewableItems.forEach(({ isViewable, item }) => {
if (isViewable) warmItemContext(api, user, item, streamingQuality)
})
},
)
return (
<FlashList
contentInsetAdjustmentBehavior='automatic'

View File

@@ -32,25 +32,6 @@ export default function useItemContext(item: BaseItemDto): void {
}, [api, user, streamingQuality])
}
export function warmArtistContext(api: Api | undefined, artistId: string): void {
// Fail fast if we don't have an artist ID to work with
if (!artistId) return
const queryKey = [QueryKeys.ArtistById, artistId]
// Bail out if we have data
if (queryClient.getQueryState(queryKey)?.status === 'success') return
console.debug(`Warming context cache for artist ${artistId}`)
/**
* Store queryable of artist item
*/
queryClient.ensureQueryData({
queryKey,
queryFn: () => fetchItem(api, artistId!),
})
}
export function warmItemContext(
api: Api | undefined,
user: JellifyUser | undefined,
@@ -64,38 +45,12 @@ export function warmItemContext(
console.debug(`Warming context query cache for item ${Id}`)
if (Type === BaseItemKind.Audio) {
const mediaSourcesQueryKey = [QueryKeys.MediaSources, streamingQuality, Id]
if (queryClient.getQueryState(mediaSourcesQueryKey)?.status !== 'success')
queryClient.ensureQueryData({
queryKey: mediaSourcesQueryKey,
queryFn: () => fetchMediaInfo(api, user, getQualityParams(streamingQuality), Id),
})
const albumQueryKey = [QueryKeys.Album, AlbumId]
if (AlbumId)
queryClient.ensureQueryData({
queryKey: albumQueryKey,
queryFn: () => fetchItem(api, AlbumId!),
})
}
if (Type === BaseItemKind.Audio) warmTrackContext(api, user, item, streamingQuality)
if (Type === BaseItemKind.MusicArtist)
queryClient.setQueryData([QueryKeys.ArtistById, Id], item)
if (Type === BaseItemKind.MusicAlbum) {
queryClient.setQueryData([QueryKeys.Album, Id], item)
const albumDiscsQueryKey = [QueryKeys.ItemTracks, Id]
if (queryClient.getQueryState(albumDiscsQueryKey)?.status !== 'success')
queryClient.ensureQueryData({
queryKey: albumDiscsQueryKey,
queryFn: () => fetchAlbumDiscs(api, item),
})
}
if (Type === BaseItemKind.MusicAlbum) warmAlbumContext(api, item)
/**
* Prefetch query for a playlist's tracks
@@ -114,10 +69,73 @@ export function warmItemContext(
}),
})
if (UserData) queryClient.setQueryData([QueryKeys.UserData, Id], UserData)
else
const userDataQueryKey = [QueryKeys.UserData, Id]
if (queryClient.getQueryState(userDataQueryKey)?.status !== 'success') {
if (UserData) queryClient.setQueryData([QueryKeys.UserData, Id], UserData)
else
queryClient.ensureQueryData({
queryKey: [],
queryFn: () => fetchUserData(api, user, Id),
})
}
}
function warmAlbumContext(api: Api | undefined, album: BaseItemDto): void {
const { Id } = album
queryClient.setQueryData([QueryKeys.Album, Id], album)
const albumDiscsQueryKey = [QueryKeys.ItemTracks, Id]
if (queryClient.getQueryState(albumDiscsQueryKey)?.status !== 'success')
queryClient.ensureQueryData({
queryKey: [QueryKeys.UserData, Id],
queryFn: () => fetchUserData(api, user, Id),
queryKey: albumDiscsQueryKey,
queryFn: () => fetchAlbumDiscs(api, album),
})
}
function warmArtistContext(api: Api | undefined, artistId: string): void {
// Fail fast if we don't have an artist ID to work with
if (!artistId) return
const queryKey = [QueryKeys.ArtistById, artistId]
// Bail out if we have data
if (queryClient.getQueryState(queryKey)?.status === 'success') return
console.debug(`Warming context cache for artist ${artistId}`)
/**
* Store queryable of artist item
*/
queryClient.ensureQueryData({
queryKey,
queryFn: () => fetchItem(api, artistId!),
})
}
function warmTrackContext(
api: Api | undefined,
user: JellifyUser | undefined,
track: BaseItemDto,
streamingQuality: StreamingQuality,
): void {
const { Id, AlbumId, ArtistItems } = track
const mediaSourcesQueryKey = [QueryKeys.MediaSources, streamingQuality, Id]
if (queryClient.getQueryState(mediaSourcesQueryKey)?.status !== 'success')
queryClient.ensureQueryData({
queryKey: mediaSourcesQueryKey,
queryFn: () => fetchMediaInfo(api, user, getQualityParams(streamingQuality), Id!),
})
const albumQueryKey = [QueryKeys.Album, AlbumId]
if (AlbumId && queryClient.getQueryState(albumQueryKey)?.status !== 'success')
queryClient.ensureQueryData({
queryKey: albumQueryKey,
queryFn: () => fetchItem(api, AlbumId!),
})
if (ArtistItems) ArtistItems.forEach((artistItem) => warmArtistContext(api, artistItem.Id!))
}