Fix Sort Controls in Library (#789)

Tears down the library sort and filter context provider in favor of an MMKV backed Zustand Store

Adds some subtle animations for when users are pressing on the filters / controls in the library bar
This commit is contained in:
Violet Caulfield
2025-12-06 14:30:23 -06:00
committed by GitHub
parent 33b6ad7863
commit 085269f51b
10 changed files with 131 additions and 156 deletions

View File

@@ -1,4 +1,3 @@
import { useLibrarySortAndFilterContext } from '../../../providers/Library'
import { QueryKeys } from '../../../enums/query-keys'
import { InfiniteData, useInfiniteQuery, UseInfiniteQueryResult } from '@tanstack/react-query'
import { ItemSortBy } from '@jellyfin/sdk/lib/generated-client/models/item-sort-by'
@@ -11,6 +10,7 @@ import { ApiLimits, MaxPages } from '../../../configs/query.config'
import { fetchRecentlyAdded } from '../recents/utils'
import { queryClient } from '../../../constants/query-client'
import { useApi, useJellifyLibrary, useJellifyUser } from '../../../stores'
import useLibraryStore from '../../../stores/library'
const useAlbums: () => [
RefObject<Set<string>>,
@@ -20,7 +20,7 @@ const useAlbums: () => [
const [user] = useJellifyUser()
const [library] = useJellifyLibrary()
const { isFavorites } = useLibrarySortAndFilterContext()
const isFavorites = useLibraryStore((state) => state.isFavorites)
const albumPageParams = useRef<Set<string>>(new Set<string>())

View File

@@ -10,9 +10,9 @@ import { isUndefined } from 'lodash'
import { fetchArtistAlbums, fetchArtistFeaturedOn, fetchArtists } from './utils/artist'
import { ApiLimits, MaxPages } from '../../../configs/query.config'
import { RefObject, useCallback, useRef } from 'react'
import { useLibrarySortAndFilterContext } from '../../../providers/Library'
import flattenInfiniteQueryPages from '../../../utils/query-selectors'
import { useApi, useJellifyLibrary, useJellifyUser } from '../../../stores'
import useLibraryStore from '../../../stores/library'
export const useArtistAlbums = (artist: BaseItemDto) => {
const api = useApi()
@@ -44,7 +44,7 @@ export const useAlbumArtists: () => [
const [user] = useJellifyUser()
const [library] = useJellifyLibrary()
const { isFavorites, sortDescending } = useLibrarySortAndFilterContext()
const { isFavorites, sortDescending } = useLibraryStore()
const artistPageParams = useRef<Set<string>>(new Set<string>())

View File

@@ -1,6 +1,5 @@
import { InfiniteData, useInfiniteQuery, UseInfiniteQueryResult } from '@tanstack/react-query'
import { TracksQueryKey } from './keys'
import { useLibrarySortAndFilterContext } from '../../../providers/Library'
import fetchTracks from './utils'
import {
BaseItemDto,
@@ -16,6 +15,7 @@ import { queryClient } from '../../../constants/query-client'
import UserDataQueryKey from '../user-data/keys'
import { JellifyUser } from '@/src/types/JellifyUser'
import { useApi, useJellifyUser, useJellifyLibrary } from '../../../stores'
import useLibraryStore from '../../../stores/library'
const useTracks: (
artistId?: string,
@@ -35,7 +35,7 @@ const useTracks: (
isFavorites: isLibraryFavorites,
sortDescending: isLibrarySortDescending,
isDownloaded,
} = useLibrarySortAndFilterContext()
} = useLibraryStore()
// Use provided values or fallback to library context
// If artistId is present, we use isFavoritesParam if provided, otherwise false (default to showing all artist tracks)

View File

@@ -12,8 +12,8 @@ import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import AZScroller, { useAlphabetSelector } from '../Global/components/alphabetical-selector'
import { isString } from 'lodash'
import FlashListStickyHeader from '../Global/helpers/flashlist-sticky-header'
import { useLibrarySortAndFilterContext } from '../../providers/Library'
import { closeAllSwipeableRows } from '../Global/components/swipeable-row-registry'
import useLibraryStore from '../../stores/library'
interface AlbumsProps {
albumsInfiniteQuery: UseInfiniteQueryResult<(string | number | BaseItemDto)[], Error>
@@ -30,7 +30,7 @@ export default function Albums({
const albums = albumsInfiniteQuery.data ?? []
const { isFavorites } = useLibrarySortAndFilterContext()
const isFavorites = useLibraryStore((state) => state.isFavorites)
const navigation = useNavigation<NativeStackNavigationProp<LibraryStackParamList>>()
@@ -38,7 +38,6 @@ export default function Albums({
const pendingLetterRef = useRef<string | null>(null)
// Memoize expensive stickyHeaderIndices calculation to prevent unnecessary re-computations
const stickyHeaderIndices =
!showAlphabeticalSelector || !albumsInfiniteQuery.data
? []

View File

@@ -3,7 +3,6 @@ import { Separator, useTheme, XStack, YStack } from 'tamagui'
import { Text } from '../Global/helpers/text'
import { RefreshControl } from 'react-native'
import ItemRow from '../Global/components/item-row'
import { useLibrarySortAndFilterContext } from '../../providers/Library'
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models/base-item-dto'
import { FlashList, FlashListRef } from '@shopify/flash-list'
import AZScroller, { useAlphabetSelector } from '../Global/components/alphabetical-selector'
@@ -14,6 +13,7 @@ import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import LibraryStackParamList from '../../screens/Library/types'
import FlashListStickyHeader from '../Global/helpers/flashlist-sticky-header'
import { closeAllSwipeableRows } from '../Global/components/swipeable-row-registry'
import useLibraryStore from '../../stores/library'
export interface ArtistsProps {
artistsInfiniteQuery: UseInfiniteQueryResult<
@@ -38,7 +38,7 @@ export default function Artists({
}: ArtistsProps): React.JSX.Element {
const theme = useTheme()
const { isFavorites } = useLibrarySortAndFilterContext()
const isFavorites = useLibraryStore((state) => state.isFavorites)
const navigation = useNavigation<NativeStackNavigationProp<LibraryStackParamList>>()

View File

@@ -1,16 +1,16 @@
import React from 'react'
import Tracks from '../../Tracks/component'
import { useLibrarySortAndFilterContext } from '../../../providers/Library'
import { useNavigation } from '@react-navigation/native'
import LibraryStackParamList from '@/src/screens/Library/types'
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import useTracks from '../../../api/queries/track'
import useLibraryStore from '../../../stores/library'
function TracksTab(): React.JSX.Element {
const [trackPageParams, tracksInfiniteQuery] = useTracks()
const { isFavorites, isDownloaded } = useLibrarySortAndFilterContext()
const { isFavorites, isDownloaded } = useLibraryStore()
const navigation = useNavigation<NativeStackNavigationProp<LibraryStackParamList>>()

View File

@@ -1,17 +1,15 @@
import { MaterialTopTabBar, MaterialTopTabBarProps } from '@react-navigation/material-top-tabs'
import React from 'react'
import { getTokenValue, Square, XStack, YStack } from 'tamagui'
import { Square, XStack, YStack } from 'tamagui'
import Icon from '../Global/components/icon'
import { useLibrarySortAndFilterContext } from '../../providers/Library'
import { Text } from '../Global/helpers/text'
import { isUndefined } from 'lodash'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import useHapticFeedback from '../../hooks/use-haptic-feedback'
import StatusBar from '../Global/helpers/status-bar'
import useLibraryStore from '../../stores/library'
function LibraryTabBar(props: MaterialTopTabBarProps) {
const { isFavorites, setIsFavorites, isDownloaded, setIsDownloaded } =
useLibrarySortAndFilterContext()
const { isFavorites, setIsFavorites, isDownloaded, setIsDownloaded } = useLibraryStore()
const trigger = useHapticFeedback()
@@ -39,6 +37,8 @@ function LibraryTabBar(props: MaterialTopTabBarProps) {
trigger('impactLight')
props.navigation.navigate('AddPlaylist')
}}
pressStyle={{ opacity: 0.6 }}
animation='quick'
alignItems={'center'}
justifyContent={'center'}
>
@@ -50,8 +50,10 @@ function LibraryTabBar(props: MaterialTopTabBarProps) {
<XStack
onPress={() => {
trigger('impactLight')
setIsFavorites(!isUndefined(isFavorites) ? undefined : true)
setIsFavorites(!isFavorites)
}}
pressStyle={{ opacity: 0.6 }}
animation='quick'
alignItems={'center'}
justifyContent={'center'}
>
@@ -72,6 +74,8 @@ function LibraryTabBar(props: MaterialTopTabBarProps) {
trigger('impactLight')
setIsDownloaded(!isDownloaded)
}}
pressStyle={{ opacity: 0.6 }}
animation='quick'
alignItems={'center'}
justifyContent={'center'}
>

View File

@@ -1,60 +0,0 @@
import { storage } from '../../constants/storage'
import { MMKVStorageKeys } from '../../enums/mmkv-storage-keys'
import { useContext, useEffect, useState } from 'react'
import { createContext } from 'react'
interface LibrarySortAndFilterContext {
sortDescending: boolean
setSortDescending: (sortDescending: boolean) => void
isFavorites: boolean | undefined
setIsFavorites: (isFavorites: boolean | undefined) => void
isDownloaded: boolean
setIsDownloaded: (isDownloaded: boolean) => void
}
const LibrarySortAndFilterContextInitializer = () => {
const sortDescendingInit = storage.getBoolean(MMKVStorageKeys.LibrarySortDescending)
const isFavoritesInit = storage.getBoolean(MMKVStorageKeys.LibraryIsFavorites)
const isDownloadedInit = storage.getBoolean(MMKVStorageKeys.LibraryIsDownloaded)
const [sortDescending, setSortDescending] = useState(sortDescendingInit ?? false)
const [isFavorites, setIsFavorites] = useState<boolean | undefined>(isFavoritesInit)
const [isDownloaded, setIsDownloaded] = useState(isDownloadedInit ?? false)
useEffect(() => {
storage.set(MMKVStorageKeys.LibrarySortDescending, sortDescending)
storage.set(MMKVStorageKeys.LibraryIsDownloaded, isDownloaded)
if (isFavorites !== undefined) storage.set(MMKVStorageKeys.LibraryIsFavorites, isFavorites)
else storage.delete(MMKVStorageKeys.LibraryIsFavorites)
}, [sortDescending, isFavorites, isDownloaded])
return {
sortDescending,
setSortDescending,
isFavorites,
setIsFavorites,
isDownloaded,
setIsDownloaded,
}
}
const LibrarySortAndFilterContext = createContext<LibrarySortAndFilterContext>({
sortDescending: false,
setSortDescending: () => {},
isFavorites: false,
setIsFavorites: () => {},
isDownloaded: false,
setIsDownloaded: () => {},
})
export const LibrarySortAndFilterProvider = ({ children }: { children: React.ReactNode }) => {
const context = LibrarySortAndFilterContextInitializer()
return (
<LibrarySortAndFilterContext.Provider value={context}>
{children}
</LibrarySortAndFilterContext.Provider>
)
}
export const useLibrarySortAndFilterContext = () => useContext(LibrarySortAndFilterContext)

View File

@@ -9,7 +9,6 @@ import { createNativeStackNavigator } from '@react-navigation/native-stack'
import AlbumScreen from '../Album'
import LibraryStackParamList from './types'
import { LibraryTabProps } from '../Tabs/types'
import { LibrarySortAndFilterProvider } from '../../providers/Library'
import InstantMix from '../../components/InstantMix/component'
import { getItemName } from '../../utils/text'
@@ -19,87 +18,85 @@ export default function LibraryScreen({ route, navigation }: LibraryTabProps): R
const theme = useTheme()
return (
<LibrarySortAndFilterProvider>
<LibraryStack.Navigator initialRouteName='LibraryScreen'>
<LibraryStack.Navigator initialRouteName='LibraryScreen'>
<LibraryStack.Screen
name='LibraryScreen'
component={Library}
options={{
title: 'Library',
// I honestly don't think we need a header for this screen, given that there are
// tabs on the top of the screen for navigating the library, but if we want one,
// we can use the title above
headerShown: false,
}}
/>
<LibraryStack.Screen
name='Artist'
component={ArtistScreen}
options={({ route }) => ({
title: route.params.artist.Name ?? 'Unknown Artist',
headerTitleStyle: {
color: theme.background.val,
},
})}
/>
<LibraryStack.Screen
name='Album'
component={AlbumScreen}
options={({ route }) => ({
title: route.params.album.Name ?? 'Untitled Album',
headerTitleStyle: {
color: theme.background.val,
},
})}
/>
<LibraryStack.Screen
name='Playlist'
component={PlaylistScreen}
options={({ route }) => ({
title: route.params.playlist.Name ?? 'Untitled Playlist',
headerTitleStyle: {
color: theme.background.val,
},
})}
/>
<LibraryStack.Screen
name='InstantMix'
component={InstantMix}
options={({ route }) => ({
headerTitle: `${getItemName(route.params.item)} Mix`,
})}
/>
<LibraryStack.Group
screenOptions={{
presentation: 'formSheet',
sheetAllowedDetents: 'fitToContents',
}}
>
<LibraryStack.Screen
name='LibraryScreen'
component={Library}
name='AddPlaylist'
component={AddPlaylist}
options={{
title: 'Library',
title: 'Add Playlist',
}}
/>
// I honestly don't think we need a header for this screen, given that there are
// tabs on the top of the screen for navigating the library, but if we want one,
// we can use the title above
<LibraryStack.Screen
name='DeletePlaylist'
component={DeletePlaylist}
options={{
title: 'Delete Playlist',
headerShown: false,
sheetGrabberVisible: true,
}}
/>
<LibraryStack.Screen
name='Artist'
component={ArtistScreen}
options={({ route }) => ({
title: route.params.artist.Name ?? 'Unknown Artist',
headerTitleStyle: {
color: theme.background.val,
},
})}
/>
<LibraryStack.Screen
name='Album'
component={AlbumScreen}
options={({ route }) => ({
title: route.params.album.Name ?? 'Untitled Album',
headerTitleStyle: {
color: theme.background.val,
},
})}
/>
<LibraryStack.Screen
name='Playlist'
component={PlaylistScreen}
options={({ route }) => ({
title: route.params.playlist.Name ?? 'Untitled Playlist',
headerTitleStyle: {
color: theme.background.val,
},
})}
/>
<LibraryStack.Screen
name='InstantMix'
component={InstantMix}
options={({ route }) => ({
headerTitle: `${getItemName(route.params.item)} Mix`,
})}
/>
<LibraryStack.Group
screenOptions={{
presentation: 'formSheet',
sheetAllowedDetents: 'fitToContents',
}}
>
<LibraryStack.Screen
name='AddPlaylist'
component={AddPlaylist}
options={{
title: 'Add Playlist',
}}
/>
<LibraryStack.Screen
name='DeletePlaylist'
component={DeletePlaylist}
options={{
title: 'Delete Playlist',
headerShown: false,
sheetGrabberVisible: true,
}}
/>
</LibraryStack.Group>
</LibraryStack.Navigator>
</LibrarySortAndFilterProvider>
</LibraryStack.Group>
</LibraryStack.Navigator>
)
}

35
src/stores/library.ts Normal file
View File

@@ -0,0 +1,35 @@
import { createJSONStorage, devtools, persist } from 'zustand/middleware'
import { mmkvStateStorage } from '../constants/storage'
import { create } from 'zustand'
type LibraryStore = {
sortDescending: boolean
setSortDescending: (sortDescending: boolean) => void
isFavorites: boolean
setIsFavorites: (isFavorites: boolean) => void
isDownloaded: boolean
setIsDownloaded: (isDownloaded: boolean) => void
}
const useLibraryStore = create<LibraryStore>()(
devtools(
persist(
(set) => ({
sortDescending: false,
setSortDescending: (sortDescending: boolean) => set({ sortDescending }),
isFavorites: false,
setIsFavorites: (isFavorites: boolean) => set({ isFavorites }),
isDownloaded: false,
setIsDownloaded: (isDownloaded: boolean) => set({ isDownloaded }),
}),
{
name: 'library-store',
storage: createJSONStorage(() => mmkvStateStorage),
},
),
),
)
export default useLibraryStore