612 feature display activity indicator when favoriting (#629)

Making adding favorites far smoother, displaying loading simulators when operations are underway
This commit is contained in:
Violet Caulfield
2025-10-30 21:14:20 -05:00
committed by GitHub
parent bbbd1fd3f7
commit 26329eeefd
6 changed files with 187 additions and 175 deletions

View File

@@ -0,0 +1,104 @@
import { queryClient } from '../../../constants/query-client'
import useHapticFeedback from '../../../hooks/use-haptic-feedback'
import { useJellifyContext } from '../../../providers'
import { BaseItemDto, UserItemDataDto } from '@jellyfin/sdk/lib/generated-client'
import { getUserLibraryApi } from '@jellyfin/sdk/lib/utils/api'
import { useMutation } from '@tanstack/react-query'
import { isUndefined } from 'lodash'
import Toast from 'react-native-toast-message'
import UserDataQueryKey from '../../queries/user-data/keys'
interface SetFavoriteMutation {
item: BaseItemDto
onToggle?: () => void
}
export const useAddFavorite = () => {
const { api, user } = useJellifyContext()
const trigger = useHapticFeedback()
return useMutation({
mutationFn: async ({ item }: SetFavoriteMutation) => {
if (isUndefined(api)) Promise.reject('API instance not defined')
else if (isUndefined(item.Id)) Promise.reject('Item ID is undefined')
else
return await getUserLibraryApi(api).markFavoriteItem({
itemId: item.Id,
})
},
onSuccess: (data, { item, onToggle }) => {
Toast.show({
text1: 'Added favorite',
type: 'success',
})
trigger('notificationSuccess')
if (onToggle) onToggle()
if (user)
queryClient.setQueryData(UserDataQueryKey(user, item), (prev: UserItemDataDto) => {
return {
...prev,
IsFavorite: true,
}
})
},
onError: (error, variables) => {
console.error('Unable to set favorite for item', error)
trigger('notificationError')
Toast.show({
text1: 'Failed to add favorite',
type: 'error',
})
},
})
}
export const useRemoveFavorite = () => {
const { api, user } = useJellifyContext()
const trigger = useHapticFeedback()
return useMutation({
mutationFn: async ({ item }: SetFavoriteMutation) => {
if (isUndefined(api)) Promise.reject('API instance not defined')
else if (isUndefined(item.Id)) Promise.reject('Item ID is undefined')
else
return await getUserLibraryApi(api).unmarkFavoriteItem({
itemId: item.Id,
})
},
onSuccess: (data, { item, onToggle }) => {
Toast.show({
text1: 'Removed favorite',
type: 'success',
})
trigger('notificationSuccess')
if (onToggle) onToggle()
if (user)
queryClient.setQueryData(UserDataQueryKey(user, item), (prev: UserItemDataDto) => {
return {
...prev,
IsFavorite: false,
}
})
},
onError: (error, variables) => {
console.error('Unable to remove favorite for item', error)
trigger('notificationError')
Toast.show({
text1: 'Failed to remove favorite',
type: 'error',
})
},
})
}

View File

@@ -32,6 +32,7 @@ const useStreamedMediaInfo = (itemId: string | null | undefined) => {
queryKey: MediaInfoQueryKey({ api, deviceProfile, itemId }),
queryFn: () => fetchMediaInfo(api, deviceProfile, itemId),
staleTime: Infinity, // Only refetch when the user's device profile changes
gcTime: Infinity,
})
}
@@ -60,5 +61,6 @@ export const useDownloadedMediaInfo = (itemId: string | null | undefined) => {
queryKey: MediaInfoQueryKey({ api, deviceProfile, itemId }),
queryFn: () => fetchMediaInfo(api, deviceProfile, itemId),
staleTime: Infinity, // Only refetch when the user's device profile changes
gcTime: Infinity,
})
}

View File

@@ -1,10 +1,10 @@
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
import React from 'react'
import React, { useCallback } from 'react'
import Icon from './icon'
import { isUndefined } from 'lodash'
import { useJellifyUserDataContext } from '../../../providers/UserData'
import Animated, { FadeIn, FadeOut } from 'react-native-reanimated'
import { useAddFavorite, useRemoveFavorite } from '../../../api/mutations/favorite'
import { useIsFavorite } from '../../../api/queries/user-data'
import { getTokenValue, Spinner } from 'tamagui'
interface FavoriteButtonProps {
item: BaseItemDto
@@ -12,30 +12,29 @@ interface FavoriteButtonProps {
}
export default function FavoriteButton({ item, onToggle }: FavoriteButtonProps): React.JSX.Element {
const { toggleFavorite } = useJellifyUserDataContext()
const { data: isFavorite, isPending } = useIsFavorite(item)
const { data: isFavorite } = useIsFavorite(item)
return isPending ? (
<Spinner color={'$primary'} width={34 + getTokenValue('$0.5')} height={'$1'} />
) : isFavorite ? (
<AddFavoriteButton item={item} onToggle={onToggle} />
) : (
<RemoveFavoriteButton item={item} onToggle={onToggle} />
)
}
return isFavorite ? (
function AddFavoriteButton({ item, onToggle }: FavoriteButtonProps): React.JSX.Element {
const { mutate, isPending } = useRemoveFavorite()
return isPending ? (
<Spinner color={'$primary'} width={34 + getTokenValue('$0.5')} height={'$1'} />
) : (
<Animated.View entering={FadeIn} exiting={FadeOut}>
<Icon
name={'heart'}
color={'$primary'}
onPress={() =>
toggleFavorite(isFavorite, {
item,
onToggle,
})
}
/>
</Animated.View>
) : (
<Animated.View entering={FadeIn} exiting={FadeOut}>
<Icon
name={'heart-outline'}
color={'$primary'}
onPress={() =>
toggleFavorite(!!isFavorite, {
mutate({
item,
onToggle,
})
@@ -45,10 +44,23 @@ export default function FavoriteButton({ item, onToggle }: FavoriteButtonProps):
)
}
export function isFavoriteItem(item: BaseItemDto): boolean {
return isUndefined(item.UserData)
? false
: isUndefined(item.UserData.IsFavorite)
? false
: item.UserData.IsFavorite
function RemoveFavoriteButton({ item, onToggle }: FavoriteButtonProps): React.JSX.Element {
const { mutate, isPending } = useAddFavorite()
return isPending ? (
<Spinner color={'$primary'} width={34 + getTokenValue('$0.5')} height={'$1'} />
) : (
<Animated.View entering={FadeIn} exiting={FadeOut}>
<Icon
name={'heart-outline'}
color={'$primary'}
onPress={() =>
mutate({
item,
onToggle,
})
}
/>
</Animated.View>
)
}

View File

@@ -1,53 +1,34 @@
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
import { ListItem, XStack } from 'tamagui'
import Icon from './icon'
import { useJellifyUserDataContext } from '../../../providers/UserData'
import { Text } from '../helpers/text'
import Animated, { FadeIn, FadeOut } from 'react-native-reanimated'
import { useIsFavorite } from '../../../api/queries/user-data'
import { useAddFavorite, useRemoveFavorite } from '../../../api/mutations/favorite'
export default function FavoriteContextMenuRow({ item }: { item: BaseItemDto }): React.JSX.Element {
const { toggleFavorite } = useJellifyUserDataContext()
const { data: isFavorite, refetch } = useIsFavorite(item)
const { data: isFavorite } = useIsFavorite(item)
return isFavorite ? (
<ListItem
animation={'quick'}
backgroundColor={'transparent'}
justifyContent='flex-start'
onPress={() => {
toggleFavorite(isFavorite, {
item,
onToggle: () => refetch(),
})
}}
pressStyle={{ opacity: 0.5 }}
>
<Animated.View
entering={FadeIn}
exiting={FadeOut}
key={`${item.Id}-remove-favorite-row`}
>
<XStack alignItems='center' justifyContent='flex-start' gap={'$2.5'}>
<Icon name={'heart'} small color={'$primary'} />
<Text bold>Remove from favorites</Text>
</XStack>
</Animated.View>
</ListItem>
<RemoveFavoriteContextMenuRow item={item} />
) : (
<AddFavoriteContextMenuRow item={item} />
)
}
function AddFavoriteContextMenuRow({ item }: { item: BaseItemDto }): React.JSX.Element {
const { mutate, isPending } = useAddFavorite()
return (
<ListItem
animation={'quick'}
backgroundColor={'transparent'}
justifyContent='flex-start'
onPress={() => {
toggleFavorite(!!isFavorite, {
item,
onToggle: () => refetch(),
})
mutate({ item })
}}
pressStyle={{ opacity: 0.5 }}
disabled={isPending}
>
<Animated.View entering={FadeIn} exiting={FadeOut} key={`${item.Id}-favorite-row`}>
<XStack alignItems='center' justifyContent='flex-start' gap={'$2.5'}>
@@ -59,3 +40,28 @@ export default function FavoriteContextMenuRow({ item }: { item: BaseItemDto }):
</ListItem>
)
}
function RemoveFavoriteContextMenuRow({ item }: { item: BaseItemDto }): React.JSX.Element {
const { mutate, isPending } = useRemoveFavorite()
return (
<ListItem
animation={'quick'}
backgroundColor={'transparent'}
justifyContent='flex-start'
onPress={() => {
mutate({ item })
}}
pressStyle={{ opacity: 0.5 }}
disabled={isPending}
>
<Animated.View entering={FadeIn} exiting={FadeOut} key={`${item.Id}-favorite-row`}>
<XStack alignItems='center' justifyContent='flex-start' gap={'$2.5'}>
<Icon small name={'heart'} color={'$primary'} />
<Text bold>Remove from favorites</Text>
</XStack>
</Animated.View>
</ListItem>
)
}

View File

@@ -3,7 +3,6 @@ import React, { useEffect } from 'react'
import Root from '../screens'
import { PlayerProvider } from '../providers/Player'
import { JellifyProvider, useJellifyContext } from '../providers'
import { JellifyUserDataProvider } from '../providers/UserData'
import { NetworkContextProvider } from '../providers/Network'
import { DisplayProvider } from '../providers/Display/display-provider'
import {
@@ -80,14 +79,11 @@ function App(): React.JSX.Element {
}, [sendMetrics])
return (
<JellifyUserDataProvider>
<NetworkContextProvider>
<PlayerProvider />
<CarPlayProvider />
<Root />
</NetworkContextProvider>
<NetworkContextProvider>
<PlayerProvider />
<CarPlayProvider />
<Root />
<Toast topOffset={getToken('$12')} config={JellifyToastConfig(theme)} />
</JellifyUserDataProvider>
</NetworkContextProvider>
)
}

View File

@@ -1,108 +0,0 @@
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
import { getUserLibraryApi } from '@jellyfin/sdk/lib/utils/api'
import { useMutation } from '@tanstack/react-query'
import { createContext, ReactNode, useContext } from 'react'
import { queryClient } from '../../constants/query-client'
import { QueryKeys } from '../../enums/query-keys'
import Toast from 'react-native-toast-message'
import { useJellifyContext } from '..'
import useHapticFeedback from '../../hooks/use-haptic-feedback'
import UserDataQueryKey from '../../api/queries/user-data/keys'
interface SetFavoriteMutation {
item: BaseItemDto
onToggle?: () => void
}
interface JellifyUserDataContext {
toggleFavorite: (isFavorite: boolean, mutation: SetFavoriteMutation) => void
}
const JellifyUserDataContextInitializer = () => {
const { api, user } = useJellifyContext()
const trigger = useHapticFeedback()
const useSetFavorite = useMutation({
mutationFn: async (mutation: SetFavoriteMutation) => {
return getUserLibraryApi(api!).markFavoriteItem({
itemId: mutation.item.Id!,
})
},
onSuccess: ({ data }, { item, onToggle }) => {
// Burnt.alert({
// title: `Added favorite`,
// duration: 1,
// preset: 'heart',
// })
Toast.show({
text1: 'Added favorite',
type: 'success',
})
trigger('notificationSuccess')
if (onToggle) onToggle()
// Force refresh of track user data
queryClient.invalidateQueries({ queryKey: UserDataQueryKey(user!, item) })
},
})
const useRemoveFavorite = useMutation({
mutationFn: async (mutation: SetFavoriteMutation) => {
return getUserLibraryApi(api!).unmarkFavoriteItem({
itemId: mutation.item.Id!,
})
},
onSuccess: ({ data }, { item, onToggle }) => {
// Burnt.alert({
// title: `Removed favorite`,
// duration: 1,
// preset: 'done',
// })
Toast.show({
text1: 'Removed favorite',
type: 'error',
})
trigger('notificationSuccess')
if (onToggle) onToggle()
// Force refresh of track user data
queryClient.invalidateQueries({ queryKey: UserDataQueryKey(user!, item) })
},
})
const toggleFavorite = (isFavorite: boolean, mutation: SetFavoriteMutation) =>
(isFavorite ? useRemoveFavorite : useSetFavorite).mutate(mutation)
return {
toggleFavorite,
}
}
const JellifyUserDataContext = createContext<JellifyUserDataContext>({
toggleFavorite: () => {},
})
export const JellifyUserDataProvider: ({
children,
}: {
children: ReactNode
}) => React.JSX.Element = ({ children }: { children: ReactNode }) => {
const { toggleFavorite } = JellifyUserDataContextInitializer()
return (
<JellifyUserDataContext.Provider
value={{
toggleFavorite,
}}
>
{children}
</JellifyUserDataContext.Provider>
)
}
export const useJellifyUserDataContext = () => useContext(JellifyUserDataContext)