mirror of
https://github.com/anultravioletaurora/Jellify.git
synced 2025-12-30 10:20:00 -06:00
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:
@@ -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),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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 ?? []
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user