Enhancments to Add to Playlist Sheet, Playback Reporting Bugfix (#623)

* some improvements to the addto playlist modal in terms of performance

leveraging tanstack query cache for storing the tracks in a playlist

eagerly fetching playlists on the home screen since we may want to display those someday anyways

* fix playback reporting bug
This commit is contained in:
Violet Caulfield
2025-11-02 09:22:36 -06:00
committed by GitHub
parent 58913d1645
commit 488fe2168c
7 changed files with 164 additions and 188 deletions

View File

@@ -1,8 +1,11 @@
import { useMutation } from '@tanstack/react-query'
import { useFrequentlyPlayedArtists, useFrequentlyPlayedTracks } from '../frequents'
import { useRecentArtists, useRecentlyPlayedTracks } from '../recents'
import { useFrequentlyPlayedArtists, useFrequentlyPlayedTracks } from '../../queries/frequents'
import { useRecentArtists, useRecentlyPlayedTracks } from '../../queries/recents'
import { useUserPlaylists } from '../../queries/playlist'
const useHomeQueries = () => {
const { refetch: refetchUserPlaylists } = useUserPlaylists()
const { refetch: refetchRecentArtists } = useRecentArtists()
const { refetch: refetchRecentlyPlayed } = useRecentlyPlayedTracks()
@@ -13,7 +16,11 @@ const useHomeQueries = () => {
return useMutation({
mutationFn: async () => {
await Promise.all([refetchRecentlyPlayed(), refetchFrequentlyPlayed()])
await Promise.all([
refetchRecentlyPlayed(),
refetchFrequentlyPlayed(),
refetchUserPlaylists(),
])
await Promise.all([refetchFrequentArtists(), refetchRecentArtists()])
return true
},

View File

@@ -1,8 +1,11 @@
import { useJellifyContext } from '../../../providers'
import { UserPlaylistsQueryKey } from './keys'
import { useInfiniteQuery } from '@tanstack/react-query'
import { useInfiniteQuery, useQuery } from '@tanstack/react-query'
import { fetchUserPlaylists, fetchPublicPlaylists } from './utils'
import { ApiLimits } from '../query.config'
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, user, library } = useJellifyContext()
@@ -17,3 +20,20 @@ export const useUserPlaylists = () => {
},
})
}
export const usePlaylistTracks = (playlist: BaseItemDto) => {
const { api } = useJellifyContext()
return useQuery({
queryKey: [QueryKeys.ItemTracks, playlist.Id!],
queryFn: () => {
return getItemsApi(api!)
.getItems({
parentId: playlist.Id!,
})
.then((response) => {
return response.data.Items ? response.data.Items! : []
})
},
})
}

View File

@@ -1,11 +1,8 @@
import { useMutation, useQuery } from '@tanstack/react-query'
import { useMutation } from '@tanstack/react-query'
import { useJellifyContext } from '../../providers'
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
import { QueryKeys } from '../../enums/query-keys'
import { addManyToPlaylist, addToPlaylist } from '../../api/mutations/playlists'
import { queryClient } from '../../constants/query-client'
import { getItemsApi } from '@jellyfin/sdk/lib/utils/api'
import { useMemo } from 'react'
import { useState } from 'react'
import Toast from 'react-native-toast-message'
import { YStack, XStack, Spacer, YGroup, Separator, ListItem, getTokens, ScrollView } from 'tamagui'
import Icon from '../Global/components/icon'
@@ -16,8 +13,9 @@ import TextTicker from 'react-native-text-ticker'
import { TextTickerConfig } from '../Player/component.config'
import { getItemName } from '../../utils/text'
import useHapticFeedback from '../../hooks/use-haptic-feedback'
import { useUserPlaylists } from '../../api/queries/playlist'
import { usePlaylistTracks, useUserPlaylists } from '../../api/queries/playlist'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import Animated, { FadeIn, FadeOut } from 'react-native-reanimated'
export default function AddToPlaylist({
track,
@@ -28,96 +26,14 @@ export default function AddToPlaylist({
tracks?: BaseItemDto[]
source?: BaseItemDto
}): React.JSX.Element {
const { api, user } = useJellifyContext()
const trigger = useHapticFeedback()
const { bottom } = useSafeAreaInsets()
const {
data: playlists,
refetch,
isPending: playlistsFetchPending,
isSuccess: playlistsFetchSuccess,
} = useUserPlaylists()
// Fetch all playlist tracks to check if the current track(s) is/are already in any playlists
const playlistsWithTracks = useQuery({
queryKey: [QueryKeys.PlaylistItemCheckCache, playlists?.map((p) => p.Id).join(',')],
enabled: !!playlists && playlists.length > 0,
queryFn: () => {
console.debug('Fetching playlist contents')
return Promise.all(
playlists!.map(async (playlist) => {
const response = await getItemsApi(api!).getItems({
parentId: playlist.Id!,
})
return {
playlistId: playlist.Id!,
tracks: response.data.Items || [],
}
}),
)
},
})
// Check if a track is in a playlist
const isTrackInPlaylist = useMemo(() => {
if (!playlistsWithTracks.data) return {}
const selectedTracks = tracks ?? (track ? [track] : [])
const result: Record<string, boolean> = {}
playlistsWithTracks.data.forEach((playlistData) => {
result[playlistData.playlistId] = selectedTracks.length
? selectedTracks.every((t) =>
playlistData.tracks.some((playlistTrack) => playlistTrack.Id === t.Id),
)
: false
})
return result
}, [playlistsWithTracks.data, track?.Id, tracks?.length])
const useAddToPlaylist = useMutation({
mutationFn: ({
track,
playlist,
tracks,
}: AddToPlaylistMutation & { tracks?: BaseItemDto[] }) => {
trigger('impactLight')
if (tracks && tracks.length > 0) {
return addManyToPlaylist(api, user, tracks, playlist)
}
return addToPlaylist(api, user, track!, playlist)
},
onSuccess: (data, { playlist }) => {
Toast.show({
text1: 'Added to playlist',
type: 'success',
})
trigger('notificationSuccess')
if (refetch) void refetch()
queryClient.invalidateQueries({
queryKey: [QueryKeys.ItemTracks, playlist.Id!],
})
// Invalidate our playlist check cache
queryClient.invalidateQueries({
queryKey: [QueryKeys.PlaylistItemCheckCache],
})
},
onError: () => {
Toast.show({
text1: 'Unable to add',
type: 'error',
})
trigger('notificationError')
},
})
return (
<ScrollView>
{(source ?? track) && (
@@ -134,7 +50,7 @@ export default function AddToPlaylist({
{(source ?? track)?.ArtistItems && (
<TextTicker {...TextTickerConfig}>
<Text bold>
{`${(source ?? track)!.ArtistItems?.map((artist) => getItemName(artist)).join(',')}`}
{`${(source ?? track)!.ArtistItems?.map((artist) => getItemName(artist)).join(', ')}`}
</Text>
</TextTicker>
)}
@@ -144,54 +60,110 @@ export default function AddToPlaylist({
{!playlistsFetchPending && playlistsFetchSuccess && (
<YGroup separator={<Separator />} marginBottom={bottom} paddingBottom={'$10'}>
{playlists?.map((playlist) => {
const isInPlaylist = isTrackInPlaylist[playlist.Id!]
return (
<YGroup.Item key={playlist.Id!}>
<ListItem
animation={'quick'}
disabled={isInPlaylist}
hoverTheme
opacity={isInPlaylist ? 0.7 : 1}
pressStyle={{ opacity: 0.5 }}
onPress={() => {
if (!isInPlaylist) {
useAddToPlaylist.mutate({
track,
tracks,
playlist,
})
}
}}
>
<XStack alignItems='center' gap={'$2'}>
<ItemImage item={playlist} height={'$11'} width={'$11'} />
<YStack alignItems='flex-start' flex={5}>
<Text bold>{playlist.Name ?? 'Untitled Playlist'}</Text>
<Text color={getTokens().color.amethyst.val}>{`${
playlist.ChildCount ?? 0
} tracks`}</Text>
</YStack>
{isInPlaylist ? (
<Icon
flex={1}
name='check-circle-outline'
color={'$success'}
/>
) : (
<Spacer flex={1} />
)}
</XStack>
</ListItem>
</YGroup.Item>
)
})}
{playlists?.map((playlist) => (
<AddToPlaylistRow
key={playlist.Id}
playlist={playlist}
tracks={tracks ? tracks : track ? [track] : []}
/>
))}
</YGroup>
)}
</ScrollView>
)
}
function AddToPlaylistRow({
playlist,
tracks,
}: {
playlist: BaseItemDto
tracks: BaseItemDto[]
}): React.JSX.Element {
const { api, user } = useJellifyContext()
const trigger = useHapticFeedback()
const {
data: playlistTracks,
isPending: fetchingPlaylistTracks,
refetch: refetchPlaylistTracks,
} = usePlaylistTracks(playlist)
const useAddToPlaylist = useMutation({
mutationFn: ({
track,
playlist,
tracks,
}: AddToPlaylistMutation & { tracks?: BaseItemDto[] }) => {
trigger('impactLight')
if (tracks && tracks.length > 0) {
return addManyToPlaylist(api, user, tracks, playlist)
}
return addToPlaylist(api, user, track!, playlist)
},
onSuccess: (data, { playlist }) => {
trigger('notificationSuccess')
setIsInPlaylist(true)
refetchPlaylistTracks()
},
onError: () => {
Toast.show({
text1: 'Unable to add',
type: 'error',
})
trigger('notificationError')
},
})
const [isInPlaylist, setIsInPlaylist] = useState<boolean>(
tracks.filter((track) =>
playlistTracks?.map((playlistTrack) => playlistTrack.Id).includes(track.Id),
).length > 0,
)
return (
<YGroup.Item key={playlist.Id!}>
<ListItem
animation={'quick'}
disabled={isInPlaylist}
hoverTheme
opacity={isInPlaylist ? 0.7 : 1}
pressStyle={{ opacity: 0.5 }}
onPress={() => {
if (!isInPlaylist) {
useAddToPlaylist.mutate({
track: undefined,
tracks,
playlist,
})
}
}}
>
<XStack alignItems='center' gap={'$2'}>
<ItemImage item={playlist} height={'$11'} width={'$11'} />
<YStack alignItems='flex-start' flex={5}>
<Text bold>{playlist.Name ?? 'Untitled Playlist'}</Text>
<Text color={getTokens().color.amethyst.val}>{`${
playlistTracks?.length ?? 0
} tracks`}</Text>
</YStack>
<Animated.View entering={FadeIn} exiting={FadeOut}>
{isInPlaylist ? (
<Icon flex={1} name='check-circle-outline' color={'$success'} />
) : (
<Spacer flex={1} />
)}
</Animated.View>
</XStack>
</ListItem>
</YGroup.Item>
)
}

View File

@@ -5,7 +5,7 @@ import RecentlyPlayed from './helpers/recently-played'
import FrequentArtists from './helpers/frequent-artists'
import FrequentlyPlayedTracks from './helpers/frequent-tracks'
import { usePreventRemove } from '@react-navigation/native'
import useHomeQueries from '../../api/queries/home'
import useHomeQueries from '../../api/mutations/home'
import { usePerformanceMonitor } from '../../hooks/use-performance-monitor'
const COMPONENT_NAME = 'Home'

View File

@@ -5,10 +5,8 @@ import { AddToQueueMutation, QueueMutation } from '../interfaces'
import { QueuingType } from '../../../enums/queuing-type'
import { shuffleJellifyTracks } from '../utils/shuffle'
import TrackPlayer from 'react-native-track-player'
import Toast from 'react-native-toast-message'
import { findPlayQueueIndexStart } from '../utils'
import JellifyTrack from '../../../types/JellifyTrack'
import { getActiveIndex, getCurrentTrack } from '.'
import { getCurrentTrack } from '.'
import { JellifyDownload } from '../../../types/JellifyDownload'
import { usePlayerQueueStore } from '../../../stores/player/queue'
@@ -79,7 +77,13 @@ export async function loadQueue({
console.debug(`Final start index is ${finalStartIndex}`)
await TrackPlayer.setQueue(queue)
/**
* Keep the requested track as the currently playing track so there
* isn't any flickering in the miniplayer
*/
await TrackPlayer.setQueue([queue[finalStartIndex]])
await TrackPlayer.add([...queue.slice(0, finalStartIndex), ...queue.slice(finalStartIndex + 1)])
await TrackPlayer.move(0, finalStartIndex)
console.debug(
`Queued ${queue.length} tracks, starting at ${finalStartIndex}${shuffled ? ' (shuffled)' : ''}`,

View File

@@ -19,7 +19,6 @@ import { useDownloadingDeviceProfile } from '../../stores/device-profile'
import { NOW_PLAYING_QUERY } from './constants/queries'
import Initialize from './functions/initialization'
import { useEnableAudioNormalization } from '../../stores/settings/player'
import { useCurrentIndex, usePlayerQueueStore } from '../../stores/player/queue'
const PLAYER_EVENTS: Event[] = [
Event.PlaybackActiveTrackChanged,
@@ -42,8 +41,6 @@ export const PlayerProvider: () => React.JSX.Element = () => {
usePerformanceMonitor('PlayerProvider', 3)
const currentIndex = useCurrentIndex()
const isRestoring = useIsRestoring()
const eventHandler = useCallback(
@@ -57,29 +54,22 @@ export const PlayerProvider: () => React.JSX.Element = () => {
// When we load a new queue, our index is updated before RNTP
// Because of this, we only need to respond to this event
// if the index from the event differs from what we have stored
if (event.index && event.index !== currentIndex) {
if (event.track && enableAudioNormalization) {
console.debug('Normalizing audio track')
nowPlaying = event.track as JellifyTrack
if (event.track && enableAudioNormalization) {
console.debug('Normalizing audio track')
nowPlaying = event.track as JellifyTrack
const volume = calculateTrackVolume(nowPlaying)
await TrackPlayer.setVolume(volume)
} else if (event.track) {
reportPlaybackStarted(api, event.track)
}
const volume = calculateTrackVolume(nowPlaying)
await TrackPlayer.setVolume(volume)
} else if (event.track) {
reportPlaybackStarted(api, event.track)
}
await handleActiveTrackChanged()
await handleActiveTrackChanged()
if (event.lastTrack) {
if (
isPlaybackFinished(
event.lastPosition,
event.lastTrack.duration ?? 1,
)
)
reportPlaybackCompleted(api, event.lastTrack as JellifyTrack)
else reportPlaybackStopped(api, event.lastTrack as JellifyTrack)
}
if (event.lastTrack) {
if (isPlaybackFinished(event.lastPosition, event.lastTrack.duration ?? 1))
reportPlaybackCompleted(api, event.lastTrack as JellifyTrack)
else reportPlaybackStopped(api, event.lastTrack as JellifyTrack)
}
break
@@ -112,7 +102,7 @@ export const PlayerProvider: () => React.JSX.Element = () => {
break
}
},
[api, autoDownload, enableAudioNormalization, currentIndex],
[api, autoDownload, enableAudioNormalization],
)
useTrackPlayerEvents(PLAYER_EVENTS, eventHandler)

View File

@@ -1,13 +1,12 @@
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
import { useMutation, UseMutationResult, useQuery } from '@tanstack/react-query'
import { QueryKeys } from '../../enums/query-keys'
import { useMutation, UseMutationResult } from '@tanstack/react-query'
import { useJellifyContext } from '..'
import { createContext, ReactNode, useContext, useEffect, useState } from 'react'
import { getItemsApi } from '@jellyfin/sdk/lib/utils/api'
import { removeFromPlaylist, updatePlaylist } from '../../api/mutations/playlists'
import { RemoveFromPlaylistMutation } from '../../components/Playlist/interfaces'
import { SharedValue, useSharedValue } from 'react-native-reanimated'
import useHapticFeedback from '../../hooks/use-haptic-feedback'
import { usePlaylistTracks } from '../../api/queries/playlist'
interface PlaylistContext {
playlist: BaseItemDto
@@ -42,23 +41,7 @@ const PlaylistContextInitializer = (playlist: BaseItemDto) => {
const trigger = useHapticFeedback()
const {
data: tracks,
isPending,
refetch,
isSuccess,
} = useQuery({
queryKey: [QueryKeys.ItemTracks, playlist.Id!],
queryFn: () => {
return getItemsApi(api!)
.getItems({
parentId: playlist.Id!,
})
.then((response) => {
return response.data.Items ? response.data.Items! : []
})
},
})
const { data: tracks, isPending, refetch, isSuccess } = usePlaylistTracks(playlist)
const useUpdatePlaylist = useMutation({
mutationFn: ({ playlist, tracks }: { playlist: BaseItemDto; tracks: BaseItemDto[] }) => {