Improve playlist performance with virtualization and pagination (#748)

* yoooooo dawg this is lit as hell

* Enhance playlist edit functionality by adding loading state for fetching tracks

* fix: explicitly type hasMore as boolean in handleEnterEditMode

---------

Co-authored-by: Violet Caulfield <42452695+anultravioletaurora@users.noreply.github.com>
This commit is contained in:
skalthoff
2025-12-05 11:29:41 -08:00
committed by GitHub
parent 1bd8ba0f6a
commit ad2848e58b
5 changed files with 229 additions and 74 deletions

View File

@@ -1,11 +1,10 @@
import { UserPlaylistsQueryKey } from './keys'
import { useInfiniteQuery, useQuery } from '@tanstack/react-query'
import { fetchUserPlaylists, fetchPublicPlaylists } from './utils'
import { useInfiniteQuery } from '@tanstack/react-query'
import { fetchUserPlaylists, fetchPublicPlaylists, fetchPlaylistTracks } from './utils'
import { ApiLimits } from '../../../configs/query.config'
import { useApi, useJellifyLibrary, useJellifyUser } from '../../../stores'
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client'
import { QueryKeys } from '../../../enums/query-keys'
import { getItemsApi } from '@jellyfin/sdk/lib/utils/api'
export const useUserPlaylists = () => {
const api = useApi()
@@ -18,6 +17,7 @@ export const useUserPlaylists = () => {
select: (data) => data.pages.flatMap((page) => page),
initialPageParam: 0,
getNextPageParam: (lastPage, allPages, lastPageParam, allPageParams) => {
if (!lastPage) return undefined
return lastPage.length === ApiLimits.Library ? lastPageParam + 1 : undefined
},
})
@@ -26,17 +26,17 @@ export const useUserPlaylists = () => {
export const usePlaylistTracks = (playlist: BaseItemDto) => {
const api = useApi()
return useQuery({
queryKey: [QueryKeys.ItemTracks, playlist.Id!],
queryFn: () => {
return getItemsApi(api!)
.getItems({
parentId: playlist.Id!,
})
.then((response) => {
return response.data.Items ? response.data.Items! : []
})
return useInfiniteQuery({
// Changed from QueryKeys.ItemTracks to avoid cache conflicts with old useQuery data
queryKey: [QueryKeys.ItemTracks, 'infinite', playlist.Id!],
queryFn: ({ pageParam }) => fetchPlaylistTracks(api, playlist.Id!, pageParam),
select: (data) => data.pages.flatMap((page) => page),
initialPageParam: 0,
getNextPageParam: (lastPage, allPages, lastPageParam) => {
if (!lastPage) return undefined
return lastPage.length === ApiLimits.Library ? lastPageParam + 1 : undefined
},
enabled: Boolean(api && playlist.Id),
})
}

View File

@@ -1,5 +1,6 @@
import {
BaseItemDto,
BaseItemKind,
ItemFields,
ItemSortBy,
SortOrder,
@@ -9,7 +10,8 @@ import { JellifyUser } from '../../../../types/JellifyUser'
import { Api } from '@jellyfin/sdk'
import { isUndefined } from 'lodash'
import { JellifyLibrary } from '../../../../types/JellifyLibrary'
import QueryConfig from '../../../../configs/query.config'
import QueryConfig, { ApiLimits } from '../../../../configs/query.config'
import { nitroFetch } from '../../../utils/nitro'
/**
* Returns the user's playlists from the Jellyfin server
@@ -102,3 +104,38 @@ export async function fetchPublicPlaylists(
})
})
}
/**
* Fetches tracks for a playlist with pagination using NitroFetch
* for optimized JSON parsing on a background thread.
*
* @param api The {@link Api} instance
* @param playlistId The ID of the playlist to fetch tracks for
* @param pageParam The page number for pagination (0-indexed)
* @returns Array of tracks for the playlist
*/
export async function fetchPlaylistTracks(
api: Api | undefined,
playlistId: string,
pageParam: number = 0,
): Promise<BaseItemDto[]> {
if (isUndefined(api)) {
throw new Error('Client instance not set')
}
const data = await nitroFetch<{ Items: BaseItemDto[]; TotalRecordCount: number }>(
api,
'/Items',
{
ParentId: playlistId,
IncludeItemTypes: [BaseItemKind.Audio],
EnableUserData: true,
Recursive: false,
Limit: ApiLimits.Library,
StartIndex: pageParam * ApiLimits.Library,
Fields: [ItemFields.MediaSources, ItemFields.ParentId, ItemFields.Path],
},
)
return data.Items ?? []
}

View File

@@ -23,7 +23,7 @@ import { useSwipeSettingsStore } from '../../../stores/settings/swipe'
import { buildSwipeConfig } from '../helpers/swipe-actions'
import { useIsFavorite } from '../../../api/queries/user-data'
import { useApi } from '../../../stores'
import { useCurrentTrack, usePlayQueue } from '../../../stores/player/queue'
import { useCurrentTrackId, usePlayQueue } from '../../../stores/player/queue'
import { useAddFavorite, useRemoveFavorite } from '../../../api/mutations/favorite'
import { StackActions } from '@react-navigation/native'
import { useSwipeableRowContext } from './swipeable-row-context'
@@ -68,7 +68,7 @@ export default function Track({
const [hideRunTimes] = useHideRunTimesSetting()
const nowPlaying = useCurrentTrack()
const currentTrackId = useCurrentTrackId()
const playQueue = usePlayQueue()
const loadNewQueue = useLoadNewQueue()
const addToQueue = useAddToQueue()
@@ -85,7 +85,7 @@ export default function Track({
const rightSettings = useSwipeSettingsStore((s) => s.right)
// Memoize expensive computations
const isPlaying = nowPlaying?.item.Id === track.Id
const isPlaying = currentTrackId === track.Id
const isOffline = networkStatus === networkStatusTypes.DISCONNECTED

View File

@@ -1,4 +1,4 @@
import { ScrollView, Spinner, useTheme, XStack } from 'tamagui'
import { ScrollView, Separator, Spinner, useTheme, XStack, YStack } from 'tamagui'
import Track from '../Global/components/track'
import Icon from '../Global/components/icon'
import { PlaylistProps } from './interfaces'
@@ -16,13 +16,15 @@ import { useNetworkStatus } from '../../stores/network'
import { QueuingType } from '../../enums/queuing-type'
import { useApi } from '../../stores'
import useStreamingDeviceProfile from '../../stores/device-profile'
import { useCallback, useEffect, useLayoutEffect, useState } from 'react'
import { RefreshControl } from 'react-native'
import { useEffect, useLayoutEffect, useState } from 'react'
import { updatePlaylist } from '../../../src/api/mutations/playlists'
import { usePlaylistTracks } from '../../../src/api/queries/playlist'
import useHapticFeedback from '../../hooks/use-haptic-feedback'
import { useMutation } from '@tanstack/react-query'
import Animated, { SlideInLeft, SlideOutRight } from 'react-native-reanimated'
import { FlashList, ListRenderItem } from '@shopify/flash-list'
import { Text } from '../Global/helpers/text'
export default function Playlist({
playlist,
@@ -35,13 +37,24 @@ export default function Playlist({
const [editing, setEditing] = useState<boolean>(false)
// State to track when we're loading all pages before entering edit mode
const [isPreparingEditMode, setIsPreparingEditMode] = useState<boolean>(false)
const [newName, setNewName] = useState<string>(playlist.Name ?? '')
const [playlistTracks, setPlaylistTracks] = useState<BaseItemDto[] | undefined>(undefined)
const trigger = useHapticFeedback()
const { data: tracks, isPending, refetch, isSuccess } = usePlaylistTracks(playlist)
const {
data: tracks,
isPending,
refetch,
isSuccess,
hasNextPage,
fetchNextPage,
isFetchingNextPage,
} = usePlaylistTracks(playlist)
const { mutate: useUpdatePlaylist, isPending: isUpdating } = useMutation({
mutationFn: ({
@@ -82,6 +95,27 @@ export default function Playlist({
setPlaylistTracks(tracks)
}
/**
* Fetches all remaining pages before entering edit mode.
* This prevents data loss when saving a playlist that has unloaded tracks.
*/
const handleEnterEditMode = useCallback(async () => {
if (hasNextPage) {
setIsPreparingEditMode(true)
try {
// Fetch all remaining pages
let hasMore: boolean = hasNextPage
while (hasMore) {
const result = await fetchNextPage()
hasMore = result.hasNextPage ?? false
}
} finally {
setIsPreparingEditMode(false)
}
}
setEditing(true)
}, [hasNextPage, fetchNextPage])
useEffect(() => {
if (!isPending && isSuccess) setPlaylistTracks(tracks)
}, [tracks, isPending, isSuccess])
@@ -119,13 +153,15 @@ export default function Playlist({
</>
)}
{!isUpdating ? (
{isUpdating || isPreparingEditMode ? (
<Spinner color={isPreparingEditMode ? '$primary' : '$success'} />
) : (
<Icon
name={editing ? 'floppy' : 'pencil'}
color={editing ? '$success' : '$color'}
onPress={() =>
!editing
? setEditing(true)
? handleEnterEditMode()
: useUpdatePlaylist({
playlist,
tracks: playlistTracks ?? [],
@@ -133,8 +169,6 @@ export default function Playlist({
})
}
/>
) : (
<Spinner color={'$success'} />
)}
</XStack>
),
@@ -146,6 +180,8 @@ export default function Playlist({
playlist,
handleCancel,
isUpdating,
isPreparingEditMode,
handleEnterEditMode,
useUpdatePlaylist,
playlistTracks,
newName,
@@ -158,7 +194,8 @@ export default function Playlist({
const rootNavigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>()
const renderItem = ({ item: track, index }: RenderItemInfo<BaseItemDto>) => {
// Render item for Sortable.Grid (edit mode only)
const renderSortableItem = ({ item: track, index }: RenderItemInfo<BaseItemDto>) => {
const handlePress = async () => {
await loadNewQueue({
track,
@@ -175,23 +212,20 @@ export default function Playlist({
return (
<XStack alignItems='center' key={`${index}-${track.Id}`} flex={1}>
{editing && (
<Animated.View entering={SlideInLeft} exiting={SlideOutRight}>
<Sortable.Handle>
<Icon name='drag' />
</Sortable.Handle>
</Animated.View>
)}
<Animated.View entering={SlideInLeft} exiting={SlideOutRight}>
<Sortable.Handle>
<Icon name='drag' />
</Sortable.Handle>
</Animated.View>
<Sortable.Touchable
style={{ flexGrow: 1 }}
onTap={handlePress}
onLongPress={() => {
if (!editing)
rootNavigation.navigate('Context', {
item: track,
navigation,
})
rootNavigation.navigate('Context', {
item: track,
navigation,
})
}}
>
<Track
@@ -205,24 +239,101 @@ export default function Playlist({
/>
</Sortable.Touchable>
{editing && (
<Sortable.Touchable
onTap={() => {
setPlaylistTracks(
(playlistTracks ?? []).filter(({ Id }) => Id !== track.Id),
)
}}
>
<Icon name='close' color={'$danger'} />
</Sortable.Touchable>
)}
<Sortable.Touchable
onTap={() => {
setPlaylistTracks(
(playlistTracks ?? []).filter(({ Id }) => Id !== track.Id),
)
}}
>
<Icon name='close' color={'$danger'} />
</Sortable.Touchable>
</XStack>
)
}
// Render item for FlashList (normal virtualized mode)
const renderFlashListItem: ListRenderItem<BaseItemDto> = ({ item: track, index }) => {
return (
<Track
navigation={navigation}
track={track}
tracklist={playlistTracks ?? []}
index={index}
queue={playlist}
showArtwork
onPress={async () => {
await loadNewQueue({
track,
tracklist: playlistTracks ?? [],
api,
networkStatus,
deviceProfile: streamingDeviceProfile,
index,
queue: playlist,
queuingType: QueuingType.FromSelection,
startPlayback: true,
})
}}
/>
)
}
const keyExtractor = (item: BaseItemDto) => item.Id!
const handleEndReached = () => {
if (hasNextPage && !isFetchingNextPage) {
fetchNextPage()
}
}
// Edit mode: use Sortable.Grid inside ScrollView (not virtualized, but supports drag-and-drop)
if (editing) {
return (
<ScrollView
flex={1}
refreshControl={
<RefreshControl
refreshing={isPending}
onRefresh={refetch}
tintColor={theme.primary.val}
/>
}
>
<PlaylistTracklistHeader
setNewName={setNewName}
newName={newName}
editing={editing}
playlist={playlist}
playlistTracks={playlistTracks}
/>
<Sortable.Grid
data={playlistTracks ?? []}
keyExtractor={keyExtractor}
autoScrollEnabled
columns={1}
customHandle
overDrag='vertical'
sortEnabled={canEdit}
onDragEnd={({ data }) => setPlaylistTracks(data)}
renderItem={renderSortableItem}
hapticsEnabled={!reducedHaptics}
/>
</ScrollView>
)
}
// Normal mode: use FlashList for virtualized performance
return (
<ScrollView
flex={1}
<FlashList
data={playlistTracks ?? []}
keyExtractor={keyExtractor}
renderItem={renderFlashListItem}
// @ts-expect-error - estimatedItemSize is required by FlashList but types are incorrect
estimatedItemSize={72}
onEndReached={handleEndReached}
onEndReachedThreshold={0.5}
refreshControl={
<RefreshControl
refreshing={isPending}
@@ -230,29 +341,30 @@ export default function Playlist({
tintColor={theme.primary.val}
/>
}
>
<PlaylistTracklistHeader
setNewName={setNewName}
newName={newName}
editing={editing}
playlist={playlist}
playlistTracks={playlistTracks}
/>
<Sortable.Grid
data={playlistTracks ?? []}
keyExtractor={(item) => {
return `${item.Id}`
}}
autoScrollEnabled
columns={1}
customHandle
overDrag='vertical'
sortEnabled={canEdit && editing}
onDragEnd={({ data }) => setPlaylistTracks(data)}
renderItem={renderItem}
hapticsEnabled={!reducedHaptics}
/>
</ScrollView>
ItemSeparatorComponent={() => <Separator />}
ListHeaderComponent={
<PlaylistTracklistHeader
setNewName={setNewName}
newName={newName}
editing={editing}
playlist={playlist}
playlistTracks={playlistTracks}
/>
}
ListEmptyComponent={
isPending ? null : (
<YStack flex={1} justify='center' alignItems='center' padding='$4'>
<Text color='$borderColor'>No tracks in this playlist</Text>
</YStack>
)
}
ListFooterComponent={
isFetchingNextPage ? (
<YStack padding='$4' alignItems='center'>
<Spinner color='$primary' />
</YStack>
) : null
}
/>
)
}

View File

@@ -176,6 +176,12 @@ export const useQueueRef = () => usePlayerQueueStore((state) => state.queueRef)
export const useCurrentTrack = () => usePlayerQueueStore((state) => state.currentTrack)
/**
* Returns only the current track ID for efficient comparisons.
* Use this in list items to avoid re-renders when other track properties change.
*/
export const useCurrentTrackId = () => usePlayerQueueStore((state) => state.currentTrack?.item.Id)
export const useCurrentIndex = () => usePlayerQueueStore((state) => state.currentIndex)
export const useRepeatModeStoreValue = () => usePlayerQueueStore((state) => state.repeatMode)