From 488fe2168ce090b20a8e52e1713b106d3fbec69c Mon Sep 17 00:00:00 2001 From: Violet Caulfield <42452695+anultravioletaurora@users.noreply.github.com> Date: Sun, 2 Nov 2025 09:22:36 -0600 Subject: [PATCH] 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 --- src/api/{queries => mutations}/home/index.ts | 13 +- src/api/queries/playlist/index.ts | 22 +- src/components/AddToPlaylist/index.tsx | 242 ++++++++----------- src/components/Home/index.tsx | 2 +- src/providers/Player/functions/queue.ts | 12 +- src/providers/Player/index.tsx | 38 ++- src/providers/Playlist/index.tsx | 23 +- 7 files changed, 164 insertions(+), 188 deletions(-) rename src/api/{queries => mutations}/home/index.ts (63%) diff --git a/src/api/queries/home/index.ts b/src/api/mutations/home/index.ts similarity index 63% rename from src/api/queries/home/index.ts rename to src/api/mutations/home/index.ts index 05d1cc13..fffce39f 100644 --- a/src/api/queries/home/index.ts +++ b/src/api/mutations/home/index.ts @@ -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 }, diff --git a/src/api/queries/playlist/index.ts b/src/api/queries/playlist/index.ts index d1d56233..92885ca3 100644 --- a/src/api/queries/playlist/index.ts +++ b/src/api/queries/playlist/index.ts @@ -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! : [] + }) + }, + }) +} diff --git a/src/components/AddToPlaylist/index.tsx b/src/components/AddToPlaylist/index.tsx index 4dc4d7d9..7458713c 100644 --- a/src/components/AddToPlaylist/index.tsx +++ b/src/components/AddToPlaylist/index.tsx @@ -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 = {} - 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 ( {(source ?? track) && ( @@ -134,7 +50,7 @@ export default function AddToPlaylist({ {(source ?? track)?.ArtistItems && ( - {`${(source ?? track)!.ArtistItems?.map((artist) => getItemName(artist)).join(',')}`} + {`${(source ?? track)!.ArtistItems?.map((artist) => getItemName(artist)).join(', ')}`} )} @@ -144,54 +60,110 @@ export default function AddToPlaylist({ {!playlistsFetchPending && playlistsFetchSuccess && ( } marginBottom={bottom} paddingBottom={'$10'}> - {playlists?.map((playlist) => { - const isInPlaylist = isTrackInPlaylist[playlist.Id!] - - return ( - - { - if (!isInPlaylist) { - useAddToPlaylist.mutate({ - track, - tracks, - playlist, - }) - } - }} - > - - - - - {playlist.Name ?? 'Untitled Playlist'} - - {`${ - playlist.ChildCount ?? 0 - } tracks`} - - - {isInPlaylist ? ( - - ) : ( - - )} - - - - ) - })} + {playlists?.map((playlist) => ( + + ))} )} ) } + +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( + tracks.filter((track) => + playlistTracks?.map((playlistTrack) => playlistTrack.Id).includes(track.Id), + ).length > 0, + ) + + return ( + + { + if (!isInPlaylist) { + useAddToPlaylist.mutate({ + track: undefined, + tracks, + playlist, + }) + } + }} + > + + + + + {playlist.Name ?? 'Untitled Playlist'} + + {`${ + playlistTracks?.length ?? 0 + } tracks`} + + + + {isInPlaylist ? ( + + ) : ( + + )} + + + + + ) +} diff --git a/src/components/Home/index.tsx b/src/components/Home/index.tsx index 9f13eda4..fe2ce4f9 100644 --- a/src/components/Home/index.tsx +++ b/src/components/Home/index.tsx @@ -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' diff --git a/src/providers/Player/functions/queue.ts b/src/providers/Player/functions/queue.ts index b16647ac..7b3b77bc 100644 --- a/src/providers/Player/functions/queue.ts +++ b/src/providers/Player/functions/queue.ts @@ -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)' : ''}`, diff --git a/src/providers/Player/index.tsx b/src/providers/Player/index.tsx index 6b150af5..33c43a50 100644 --- a/src/providers/Player/index.tsx +++ b/src/providers/Player/index.tsx @@ -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) diff --git a/src/providers/Playlist/index.tsx b/src/providers/Playlist/index.tsx index f7b26fe3..3163c31c 100644 --- a/src/providers/Playlist/index.tsx +++ b/src/providers/Playlist/index.tsx @@ -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[] }) => {