mirror of
https://github.com/Jellify-Music/App.git
synced 2026-03-18 03:00:35 -05:00
Merge branch 'main' into improve-cold-start-performance
This commit is contained in:
@@ -28,16 +28,51 @@ const useAlbums: () => [
|
||||
const user = getUser()
|
||||
const [library] = useJellifyLibrary()
|
||||
|
||||
const isFavorites = useLibraryStore((state) => state.filters.albums.isFavorites)
|
||||
const {
|
||||
filters,
|
||||
sortBy: librarySortByState,
|
||||
sortDescending: librarySortDescendingState,
|
||||
} = useLibraryStore()
|
||||
const rawAlbumSortBy = librarySortByState.albums ?? ItemSortBy.SortName
|
||||
const albumSortByOptions = [
|
||||
ItemSortBy.Name,
|
||||
ItemSortBy.SortName,
|
||||
ItemSortBy.Album,
|
||||
ItemSortBy.Artist,
|
||||
ItemSortBy.PlayCount,
|
||||
ItemSortBy.DateCreated,
|
||||
ItemSortBy.PremiereDate,
|
||||
] as ItemSortBy[]
|
||||
const librarySortBy = albumSortByOptions.includes(rawAlbumSortBy as ItemSortBy)
|
||||
? (rawAlbumSortBy as ItemSortBy)
|
||||
: ItemSortBy.Album
|
||||
const sortDescending = librarySortDescendingState.albums ?? false
|
||||
const isFavorites = filters.albums.isFavorites
|
||||
|
||||
const albumPageParams = useRef<Set<string>>(new Set<string>())
|
||||
|
||||
// Memize the expensive albums select function
|
||||
const selectAlbums = (data: InfiniteData<BaseItemDto[], unknown>) =>
|
||||
flattenInfiniteQueryPages(data, albumPageParams)
|
||||
// Add letter sections when sorting by name/album/artist (for A-Z selector)
|
||||
const isSortByLetter =
|
||||
librarySortBy === ItemSortBy.Name ||
|
||||
librarySortBy === ItemSortBy.SortName ||
|
||||
librarySortBy === ItemSortBy.Album ||
|
||||
librarySortBy === ItemSortBy.Artist
|
||||
|
||||
const selectAlbums = (data: InfiniteData<BaseItemDto[], unknown>) => {
|
||||
if (!isSortByLetter) return data.pages.flatMap((page) => page)
|
||||
return flattenInfiniteQueryPages(data, albumPageParams, {
|
||||
sortBy: librarySortBy === ItemSortBy.Artist ? ItemSortBy.Artist : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
const albumsInfiniteQuery = useInfiniteQuery({
|
||||
queryKey: [QueryKeys.InfiniteAlbums, isFavorites, library?.musicLibraryId],
|
||||
queryKey: [
|
||||
QueryKeys.InfiniteAlbums,
|
||||
isFavorites,
|
||||
library?.musicLibraryId,
|
||||
librarySortBy,
|
||||
sortDescending,
|
||||
],
|
||||
queryFn: ({ pageParam }) =>
|
||||
fetchAlbums(
|
||||
api,
|
||||
@@ -45,8 +80,8 @@ const useAlbums: () => [
|
||||
library,
|
||||
pageParam,
|
||||
isFavorites,
|
||||
[ItemSortBy.SortName],
|
||||
[SortOrder.Ascending],
|
||||
[librarySortBy ?? ItemSortBy.SortName],
|
||||
[sortDescending ? SortOrder.Descending : SortOrder.Ascending],
|
||||
),
|
||||
initialPageParam: 0,
|
||||
select: selectAlbums,
|
||||
|
||||
@@ -44,17 +44,41 @@ export const useAlbumArtists: () => [
|
||||
const [user] = useJellifyUser()
|
||||
const [library] = useJellifyLibrary()
|
||||
|
||||
const { filters, sortDescending } = useLibraryStore()
|
||||
const {
|
||||
filters,
|
||||
sortBy: librarySortByState,
|
||||
sortDescending: librarySortDescendingState,
|
||||
} = useLibraryStore()
|
||||
const rawArtistSortBy = librarySortByState.artists ?? ItemSortBy.SortName
|
||||
// Artists tab only supports sort by name
|
||||
const librarySortBy =
|
||||
rawArtistSortBy === ItemSortBy.SortName || rawArtistSortBy === ItemSortBy.Name
|
||||
? rawArtistSortBy
|
||||
: ItemSortBy.SortName
|
||||
const sortDescending = librarySortDescendingState.artists ?? false
|
||||
const isFavorites = filters.artists.isFavorites
|
||||
|
||||
const artistPageParams = useRef<Set<string>>(new Set<string>())
|
||||
|
||||
// Memoize the expensive artists select function
|
||||
const selectArtists = (data: InfiniteData<BaseItemDto[], unknown>) =>
|
||||
flattenInfiniteQueryPages(data, artistPageParams)
|
||||
const isSortByName =
|
||||
librarySortBy === ItemSortBy.Name ||
|
||||
librarySortBy === ItemSortBy.SortName ||
|
||||
librarySortBy === ItemSortBy.Artist
|
||||
|
||||
// Only add letter sections when sorting by name (for A-Z selector)
|
||||
const selectArtists = (data: InfiniteData<BaseItemDto[], unknown>) => {
|
||||
if (!isSortByName) return data.pages.flatMap((page) => page)
|
||||
return flattenInfiniteQueryPages(data, artistPageParams)
|
||||
}
|
||||
|
||||
const artistsInfiniteQuery = useInfiniteQuery({
|
||||
queryKey: [QueryKeys.InfiniteArtists, isFavorites, sortDescending, library?.musicLibraryId],
|
||||
queryKey: [
|
||||
QueryKeys.InfiniteArtists,
|
||||
isFavorites,
|
||||
sortDescending,
|
||||
library?.musicLibraryId,
|
||||
librarySortBy,
|
||||
],
|
||||
queryFn: ({ pageParam }: { pageParam: number }) =>
|
||||
fetchArtists(
|
||||
api,
|
||||
@@ -62,7 +86,7 @@ export const useAlbumArtists: () => [
|
||||
library,
|
||||
pageParam,
|
||||
isFavorites,
|
||||
[ItemSortBy.SortName],
|
||||
[librarySortBy ?? ItemSortBy.SortName],
|
||||
[sortDescending ? SortOrder.Descending : SortOrder.Ascending],
|
||||
),
|
||||
select: selectArtists,
|
||||
|
||||
@@ -33,7 +33,13 @@ const useTracks: (
|
||||
const api = getApi()
|
||||
const user = getUser()
|
||||
const [library] = useJellifyLibrary()
|
||||
const { filters, sortDescending: isLibrarySortDescending } = useLibraryStore()
|
||||
const {
|
||||
filters,
|
||||
sortBy: librarySortByState,
|
||||
sortDescending: librarySortDescendingState,
|
||||
} = useLibraryStore()
|
||||
const librarySortBy = librarySortByState.tracks ?? undefined
|
||||
const isLibrarySortDescending = librarySortDescendingState.tracks ?? false
|
||||
const isLibraryFavorites = filters.tracks.isFavorites
|
||||
const isDownloaded = filters.tracks.isDownloaded ?? false
|
||||
const isLibraryUnplayed = filters.tracks.isUnplayed ?? false
|
||||
@@ -50,7 +56,7 @@ const useTracks: (
|
||||
: isLibraryFavorites
|
||||
const isUnplayed =
|
||||
isUnplayedParam !== undefined ? isUnplayedParam : artistId ? undefined : isLibraryUnplayed
|
||||
const finalSortBy = sortBy ?? ItemSortBy.Name
|
||||
const finalSortBy = librarySortBy ?? sortBy ?? ItemSortBy.Name
|
||||
const finalSortOrder =
|
||||
sortOrder ?? (isLibrarySortDescending ? SortOrder.Descending : SortOrder.Ascending)
|
||||
|
||||
@@ -59,11 +65,22 @@ const useTracks: (
|
||||
const trackPageParams = useRef<Set<string>>(new Set<string>())
|
||||
|
||||
const selectTracks = (data: InfiniteData<BaseItemDto[], unknown>) => {
|
||||
if (finalSortBy === ItemSortBy.SortName || finalSortBy === ItemSortBy.Name) {
|
||||
return flattenInfiniteQueryPages(data, trackPageParams)
|
||||
} else {
|
||||
return data.pages.flatMap((page) => page)
|
||||
if (
|
||||
finalSortBy === ItemSortBy.SortName ||
|
||||
finalSortBy === ItemSortBy.Name ||
|
||||
finalSortBy === ItemSortBy.Album ||
|
||||
finalSortBy === ItemSortBy.Artist
|
||||
) {
|
||||
return flattenInfiniteQueryPages(data, trackPageParams, {
|
||||
sortBy:
|
||||
finalSortBy === ItemSortBy.Artist
|
||||
? ItemSortBy.Artist
|
||||
: finalSortBy === ItemSortBy.Album
|
||||
? ItemSortBy.Album
|
||||
: undefined,
|
||||
})
|
||||
}
|
||||
return data.pages.flatMap((page) => page)
|
||||
}
|
||||
|
||||
const tracksInfiniteQuery = useInfiniteQuery({
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Text } from '../Global/helpers/text'
|
||||
import { FlashList, FlashListRef } from '@shopify/flash-list'
|
||||
import { UseInfiniteQueryResult } from '@tanstack/react-query'
|
||||
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models'
|
||||
import { ItemSortBy } from '@jellyfin/sdk/lib/generated-client/models/item-sort-by'
|
||||
import ItemRow from '../Global/components/item-row'
|
||||
import { useNavigation } from '@react-navigation/native'
|
||||
import LibraryStackParamList from '../../screens/Library/types'
|
||||
@@ -19,6 +20,8 @@ import MAX_ITEMS_IN_RECYCLE_POOL from '../../configs/library.config'
|
||||
interface AlbumsProps {
|
||||
albumsInfiniteQuery: UseInfiniteQueryResult<(string | number | BaseItemDto)[], Error>
|
||||
showAlphabeticalSelector: boolean
|
||||
sortBy?: ItemSortBy
|
||||
sortDescending?: boolean
|
||||
albumPageParams?: RefObject<Set<string>>
|
||||
}
|
||||
|
||||
@@ -26,6 +29,8 @@ export default function Albums({
|
||||
albumsInfiniteQuery,
|
||||
albumPageParams,
|
||||
showAlphabeticalSelector,
|
||||
sortBy,
|
||||
sortDescending,
|
||||
}: AlbumsProps): React.JSX.Element {
|
||||
const theme = useTheme()
|
||||
|
||||
@@ -40,11 +45,11 @@ export default function Albums({
|
||||
const pendingLetterRef = useRef<string | null>(null)
|
||||
|
||||
const stickyHeaderIndices =
|
||||
!showAlphabeticalSelector || !albumsInfiniteQuery.data
|
||||
!showAlphabeticalSelector || !albumsInfiniteQuery.data || sortBy === ItemSortBy.Artist
|
||||
? []
|
||||
: albumsInfiniteQuery.data
|
||||
.map((album, index) => (typeof album === 'string' ? index : 0))
|
||||
.filter((value, index, indices) => indices.indexOf(value) === index)
|
||||
.map((album, index) => (typeof album === 'string' ? index : null))
|
||||
.filter((v): v is number => v !== null)
|
||||
|
||||
const { mutateAsync: alphabetSelectorMutate, isPending: isAlphabetSelectorPending } =
|
||||
useAlphabetSelector((letter) => (pendingLetterRef.current = letter.toUpperCase()))
|
||||
@@ -68,7 +73,9 @@ export default function Albums({
|
||||
item: BaseItemDto | string | number
|
||||
}) =>
|
||||
typeof album === 'string' ? (
|
||||
<FlashListStickyHeader text={album.toUpperCase()} />
|
||||
sortBy === ItemSortBy.Artist ? null : (
|
||||
<FlashListStickyHeader text={album.toUpperCase()} />
|
||||
)
|
||||
) : typeof album === 'number' ? null : typeof album === 'object' ? (
|
||||
<ItemRow item={album} navigation={navigation} />
|
||||
) : null
|
||||
@@ -143,6 +150,7 @@ export default function Albums({
|
||||
|
||||
{showAlphabeticalSelector && albumPageParams && (
|
||||
<AZScroller
|
||||
reverseOrder={sortDescending}
|
||||
onLetterSelect={(letter) =>
|
||||
alphabetSelectorMutate({
|
||||
letter,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { RefObject, useEffect, useRef } from 'react'
|
||||
import React, { RefObject, useEffect, useRef, useState } from 'react'
|
||||
import { Separator, useTheme, XStack, YStack } from 'tamagui'
|
||||
import { Text } from '../Global/helpers/text'
|
||||
import ItemRow from '../Global/components/item-row'
|
||||
@@ -22,6 +22,7 @@ export interface ArtistsProps {
|
||||
Error
|
||||
>
|
||||
showAlphabeticalSelector: boolean
|
||||
sortDescending?: boolean
|
||||
artistPageParams?: RefObject<Set<string>>
|
||||
}
|
||||
|
||||
@@ -35,6 +36,7 @@ export interface ArtistsProps {
|
||||
export default function Artists({
|
||||
artistsInfiniteQuery,
|
||||
showAlphabeticalSelector,
|
||||
sortDescending,
|
||||
artistPageParams,
|
||||
}: ArtistsProps): React.JSX.Element {
|
||||
const theme = useTheme()
|
||||
@@ -158,6 +160,7 @@ export default function Artists({
|
||||
|
||||
{showAlphabeticalSelector && artistPageParams && (
|
||||
<AZScroller
|
||||
reverseOrder={sortDescending}
|
||||
onLetterSelect={(letter) =>
|
||||
alphabetSelectorMutate({
|
||||
letter,
|
||||
|
||||
@@ -35,6 +35,8 @@ export interface TrackProps {
|
||||
invertedColors?: boolean | undefined
|
||||
testID?: string | undefined
|
||||
editing?: boolean | undefined
|
||||
sortingByAlbum?: boolean | undefined
|
||||
sortingByReleasedDate?: boolean | undefined
|
||||
}
|
||||
|
||||
export default function Track({
|
||||
@@ -50,6 +52,8 @@ export default function Track({
|
||||
isNested,
|
||||
invertedColors,
|
||||
editing,
|
||||
sortingByAlbum,
|
||||
sortingByReleasedDate,
|
||||
}: TrackProps): React.JSX.Element {
|
||||
const theme = useTheme()
|
||||
const [artworkAreaWidth, setArtworkAreaWidth] = useState(0)
|
||||
@@ -132,7 +136,12 @@ export default function Track({
|
||||
: undefined
|
||||
|
||||
// Memoize artists text
|
||||
const artistsText = track.Artists?.join(' • ') ?? ''
|
||||
const artistsText =
|
||||
(sortingByAlbum
|
||||
? track.Album
|
||||
: sortingByReleasedDate
|
||||
? `${track.ProductionYear?.toString()} • ${track.Artists?.join(' • ')}`
|
||||
: track.Artists?.join(' • ')) ?? ''
|
||||
|
||||
// Memoize track name
|
||||
const trackName = track.Name ?? 'Untitled Track'
|
||||
|
||||
@@ -9,7 +9,8 @@ import { UseInfiniteQueryResult, useMutation } from '@tanstack/react-query'
|
||||
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client'
|
||||
import { triggerHaptic } from '../../../hooks/use-haptic-feedback'
|
||||
|
||||
const alphabet = '#ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('')
|
||||
const alphabetAtoZ = '#ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('')
|
||||
const alphabetZtoA = '#ZYXWVUTSRQPONMLKJIHGFEDCBA'.split('')
|
||||
/**
|
||||
* A component that displays a list of hardcoded alphabet letters and a selected letter overlay
|
||||
* When a letter is selected, the overlay will be shown and the callback function will be called
|
||||
@@ -18,16 +19,19 @@ const alphabet = '#ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('')
|
||||
* The overlay will be hidden after 200ms
|
||||
*
|
||||
* @param onLetterSelect - Callback function to be called when a letter is selected
|
||||
* @param reverseOrder - When true, display #, Z-A (for descending sort) instead of #, A-Z
|
||||
* @returns A component that displays a list of letters and a selected letter overlay
|
||||
*/
|
||||
export default function AZScroller({
|
||||
onLetterSelect,
|
||||
alphabet: customAlphabet,
|
||||
reverseOrder,
|
||||
}: {
|
||||
onLetterSelect: (letter: string) => Promise<void>
|
||||
alphabet?: string[]
|
||||
reverseOrder?: boolean
|
||||
}) {
|
||||
const alphabetToUse = customAlphabet ?? alphabet
|
||||
const alphabetToUse = customAlphabet ?? (reverseOrder ? alphabetZtoA : alphabetAtoZ)
|
||||
const theme = useTheme()
|
||||
|
||||
const [operationPending, setOperationPending] = useState<boolean>(false)
|
||||
|
||||
@@ -1,13 +1,36 @@
|
||||
import useAlbums from '../../../api/queries/album'
|
||||
import Albums from '../../Albums/component'
|
||||
import useLibraryStore from '../../../stores/library'
|
||||
import { ItemSortBy } from '@jellyfin/sdk/lib/generated-client/models/item-sort-by'
|
||||
|
||||
function AlbumsTab(): React.JSX.Element {
|
||||
const [albumPageParams, albumsInfiniteQuery] = useAlbums()
|
||||
|
||||
const sortBy = useLibraryStore((state) => {
|
||||
const sb = state.sortBy as Record<string, string> | string
|
||||
if (typeof sb === 'string') return sb
|
||||
return sb?.albums ?? ItemSortBy.Album
|
||||
})
|
||||
const sortDescending = useLibraryStore((state) => {
|
||||
const sd = state.sortDescending as Record<string, boolean> | boolean
|
||||
if (typeof sd === 'boolean') return sd
|
||||
return sd?.albums ?? false
|
||||
})
|
||||
const hasLetterSections =
|
||||
albumsInfiniteQuery.data?.some((item) => typeof item === 'string') ?? false
|
||||
const showAlphabeticalSelector =
|
||||
hasLetterSections ||
|
||||
sortBy === ItemSortBy.Name ||
|
||||
sortBy === ItemSortBy.SortName ||
|
||||
sortBy === ItemSortBy.Album ||
|
||||
sortBy === ItemSortBy.Artist
|
||||
|
||||
return (
|
||||
<Albums
|
||||
albumsInfiniteQuery={albumsInfiniteQuery}
|
||||
showAlphabeticalSelector={true}
|
||||
showAlphabeticalSelector={showAlphabeticalSelector}
|
||||
sortBy={sortBy as ItemSortBy}
|
||||
sortDescending={sortDescending}
|
||||
albumPageParams={albumPageParams}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -1,13 +1,35 @@
|
||||
import { useAlbumArtists } from '../../../api/queries/artist'
|
||||
import Artists from '../../Artists/component'
|
||||
import useLibraryStore from '../../../stores/library'
|
||||
import { ItemSortBy } from '@jellyfin/sdk/lib/generated-client/models/item-sort-by'
|
||||
|
||||
function ArtistsTab(): React.JSX.Element {
|
||||
const [artistPageParams, artistsInfiniteQuery] = useAlbumArtists()
|
||||
|
||||
const sortBy = useLibraryStore((state) => {
|
||||
const sb = state.sortBy as Record<string, string> | string
|
||||
if (typeof sb === 'string') return sb
|
||||
return sb?.artists ?? ItemSortBy.SortName
|
||||
})
|
||||
const sortDescending = useLibraryStore((state) => {
|
||||
const sd = state.sortDescending as Record<string, boolean> | boolean
|
||||
if (typeof sd === 'boolean') return sd
|
||||
return sd?.artists ?? false
|
||||
})
|
||||
const hasLetterSections =
|
||||
artistsInfiniteQuery.data?.some((item) => typeof item === 'string') ?? false
|
||||
// Artists tab only sorts by name, so always show A-Z when we have letter sections
|
||||
const showAlphabeticalSelector =
|
||||
hasLetterSections ||
|
||||
sortBy === ItemSortBy.Name ||
|
||||
sortBy === ItemSortBy.SortName ||
|
||||
sortBy === ItemSortBy.Artist
|
||||
|
||||
return (
|
||||
<Artists
|
||||
artistsInfiniteQuery={artistsInfiniteQuery}
|
||||
showAlphabeticalSelector={true}
|
||||
showAlphabeticalSelector={showAlphabeticalSelector}
|
||||
sortDescending={sortDescending}
|
||||
artistPageParams={artistPageParams}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -6,12 +6,32 @@ 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'
|
||||
import { ItemSortBy } from '@jellyfin/sdk/lib/generated-client/models/item-sort-by'
|
||||
|
||||
function TracksTab(): React.JSX.Element {
|
||||
const [trackPageParams, tracksInfiniteQuery] = useTracks()
|
||||
|
||||
const { filters } = useLibraryStore()
|
||||
const filters = useLibraryStore((state) => state.filters)
|
||||
const sortBy = useLibraryStore((state) => {
|
||||
const sb = state.sortBy as Record<string, string> | string
|
||||
if (typeof sb === 'string') return sb
|
||||
return sb?.tracks ?? ItemSortBy.Name
|
||||
})
|
||||
const sortDescending = useLibraryStore((state) => {
|
||||
const sd = state.sortDescending as Record<string, boolean> | boolean
|
||||
if (typeof sd === 'boolean') return sd
|
||||
return sd?.tracks ?? false
|
||||
})
|
||||
const { isFavorites, isDownloaded } = filters.tracks
|
||||
// Show A-Z when sort is by name OR when data already has letter sections (e.g. after sort change)
|
||||
const hasLetterSections =
|
||||
tracksInfiniteQuery.data?.some((item) => typeof item === 'string') ?? false
|
||||
const showAlphabeticalSelector =
|
||||
hasLetterSections ||
|
||||
sortBy === ItemSortBy.Name ||
|
||||
sortBy === ItemSortBy.SortName ||
|
||||
sortBy === ItemSortBy.Album ||
|
||||
sortBy === ItemSortBy.Artist
|
||||
|
||||
const navigation = useNavigation<NativeStackNavigationProp<LibraryStackParamList>>()
|
||||
|
||||
@@ -20,7 +40,9 @@ function TracksTab(): React.JSX.Element {
|
||||
navigation={navigation}
|
||||
tracksInfiniteQuery={tracksInfiniteQuery}
|
||||
queue={isFavorites ? 'Favorite Tracks' : isDownloaded ? 'Downloaded Tracks' : 'Library'}
|
||||
showAlphabeticalSelector={true}
|
||||
showAlphabeticalSelector={showAlphabeticalSelector}
|
||||
sortBy={sortBy as ItemSortBy}
|
||||
sortDescending={sortDescending}
|
||||
trackPageParams={trackPageParams}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -99,29 +99,56 @@ function LibraryTabBar(props: MaterialTopTabBarProps) {
|
||||
)}
|
||||
|
||||
{props.state.routes[props.state.index].name !== 'Playlists' && (
|
||||
<XStack
|
||||
onPress={() => {
|
||||
triggerHaptic('impactLight')
|
||||
if (navigationRef.isReady()) {
|
||||
navigationRef.navigate('Filters', {
|
||||
currentTab: currentTab as 'Tracks' | 'Albums' | 'Artists',
|
||||
})
|
||||
}
|
||||
}}
|
||||
pressStyle={{ opacity: 0.6 }}
|
||||
animation='quick'
|
||||
alignItems={'center'}
|
||||
justifyContent={'center'}
|
||||
>
|
||||
<Icon
|
||||
name={hasActiveFilters ? 'filter-variant' : 'filter'}
|
||||
color={hasActiveFilters ? '$primary' : '$borderColor'}
|
||||
/>
|
||||
<>
|
||||
<XStack
|
||||
onPress={() => {
|
||||
triggerHaptic('impactLight')
|
||||
if (navigationRef.isReady()) {
|
||||
navigationRef.navigate('SortOptions', {
|
||||
currentTab: currentTab as
|
||||
| 'Tracks'
|
||||
| 'Albums'
|
||||
| 'Artists',
|
||||
})
|
||||
}
|
||||
}}
|
||||
pressStyle={{ opacity: 0.6 }}
|
||||
animation='quick'
|
||||
alignItems={'center'}
|
||||
justifyContent={'center'}
|
||||
>
|
||||
<Icon name={'sort'} color={'$borderColor'} />
|
||||
|
||||
<Text color={hasActiveFilters ? '$primary' : '$borderColor'}>
|
||||
Filter
|
||||
</Text>
|
||||
</XStack>
|
||||
<Text color={'$borderColor'}>Sort</Text>
|
||||
</XStack>
|
||||
|
||||
<XStack
|
||||
onPress={() => {
|
||||
triggerHaptic('impactLight')
|
||||
if (navigationRef.isReady()) {
|
||||
navigationRef.navigate('Filters', {
|
||||
currentTab: currentTab as
|
||||
| 'Tracks'
|
||||
| 'Albums'
|
||||
| 'Artists',
|
||||
})
|
||||
}
|
||||
}}
|
||||
pressStyle={{ opacity: 0.6 }}
|
||||
animation='quick'
|
||||
alignItems={'center'}
|
||||
justifyContent={'center'}
|
||||
>
|
||||
<Icon
|
||||
name={hasActiveFilters ? 'filter-variant' : 'filter'}
|
||||
color={hasActiveFilters ? '$primary' : '$borderColor'}
|
||||
/>
|
||||
|
||||
<Text color={hasActiveFilters ? '$primary' : '$borderColor'}>
|
||||
Filter
|
||||
</Text>
|
||||
</XStack>
|
||||
</>
|
||||
)}
|
||||
|
||||
{props.state.routes[props.state.index].name !== 'Playlists' &&
|
||||
|
||||
137
src/components/SortOptions/index.tsx
Normal file
137
src/components/SortOptions/index.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
import React, { useEffect } from 'react'
|
||||
import { YStack } from 'tamagui'
|
||||
import { Text } from '../Global/helpers/text'
|
||||
import { RadioGroup } from 'tamagui'
|
||||
import { RadioGroupItemWithLabel } from '../Global/helpers/radio-group-item-with-label'
|
||||
import useLibraryStore, { LibraryTab } from '../../stores/library'
|
||||
import { triggerHaptic } from '../../hooks/use-haptic-feedback'
|
||||
import { ItemSortBy } from '@jellyfin/sdk/lib/generated-client/models/item-sort-by'
|
||||
|
||||
const TRACK_SORT_OPTIONS: { value: ItemSortBy; label: string }[] = [
|
||||
{ value: ItemSortBy.Name, label: 'Track' },
|
||||
{ value: ItemSortBy.Album, label: 'Album' },
|
||||
{ value: ItemSortBy.Artist, label: 'Artist' },
|
||||
{ value: ItemSortBy.DateCreated, label: 'Date Added' },
|
||||
{ value: ItemSortBy.PlayCount, label: 'Play Count' },
|
||||
{ value: ItemSortBy.PremiereDate, label: 'Release Date' },
|
||||
{ value: ItemSortBy.Runtime, label: 'Runtime' },
|
||||
]
|
||||
|
||||
const ALBUM_SORT_OPTIONS: { value: ItemSortBy; label: string }[] = [
|
||||
{ value: ItemSortBy.SortName, label: 'Album' },
|
||||
{ value: ItemSortBy.Artist, label: 'Artist' },
|
||||
{ value: ItemSortBy.PlayCount, label: 'Play Count' },
|
||||
{ value: ItemSortBy.DateCreated, label: 'Date Added' },
|
||||
{ value: ItemSortBy.PremiereDate, label: 'Release Date' },
|
||||
]
|
||||
|
||||
const ARTIST_SORT_OPTIONS: { value: ItemSortBy; label: string }[] = [
|
||||
{ value: ItemSortBy.SortName, label: 'Artist' },
|
||||
]
|
||||
|
||||
function toLibraryTab(tab: string | undefined): LibraryTab {
|
||||
const lower = tab?.toLowerCase()
|
||||
return lower === 'albums' || lower === 'artists' ? lower : 'tracks'
|
||||
}
|
||||
|
||||
function getSortByOptionsForTab(tab: LibraryTab): { value: ItemSortBy; label: string }[] {
|
||||
switch (tab) {
|
||||
case 'albums':
|
||||
return ALBUM_SORT_OPTIONS
|
||||
case 'artists':
|
||||
return ARTIST_SORT_OPTIONS
|
||||
default:
|
||||
return TRACK_SORT_OPTIONS
|
||||
}
|
||||
}
|
||||
|
||||
const DATE_SORT_BY: ItemSortBy[] = [ItemSortBy.DateCreated, ItemSortBy.PremiereDate]
|
||||
const NUMERIC_SORT_BY: ItemSortBy[] = [ItemSortBy.PlayCount, ItemSortBy.Runtime]
|
||||
|
||||
function getSortOrderLabels(sortBy: ItemSortBy): { ascending: string; descending: string } {
|
||||
if (DATE_SORT_BY.includes(sortBy)) {
|
||||
return { ascending: 'Oldest', descending: 'Newest' }
|
||||
}
|
||||
if (NUMERIC_SORT_BY.includes(sortBy)) {
|
||||
return { ascending: 'Lowest', descending: 'Highest' }
|
||||
}
|
||||
return { ascending: 'Ascending', descending: 'Descending' }
|
||||
}
|
||||
|
||||
export default function SortOptions({
|
||||
currentTab,
|
||||
}: {
|
||||
currentTab?: 'Tracks' | 'Albums' | 'Artists'
|
||||
}): React.JSX.Element {
|
||||
const tab = toLibraryTab(currentTab)
|
||||
const { getSortBy, getSortDescending, setSortBy, setSortDescending } = useLibraryStore()
|
||||
const sortByOptions = getSortByOptionsForTab(tab)
|
||||
const currentSortBy = getSortBy(tab)
|
||||
const effectiveSortBy = sortByOptions.some((o) => o.value === currentSortBy)
|
||||
? currentSortBy
|
||||
: sortByOptions[0]!.value
|
||||
const sortDescending = getSortDescending(tab)
|
||||
const sortOrderLabels = getSortOrderLabels(effectiveSortBy)
|
||||
|
||||
const handleSortByChange = (value: string) => {
|
||||
triggerHaptic('impactLight')
|
||||
setSortBy(tab, value as ItemSortBy)
|
||||
}
|
||||
|
||||
const handleSortOrderChange = (value: string) => {
|
||||
triggerHaptic('impactLight')
|
||||
setSortDescending(tab, value === 'descending')
|
||||
}
|
||||
|
||||
// When opening the sheet, if stored sort is not in allowed options (e.g. after tab-specific change), persist the fallback
|
||||
useEffect(() => {
|
||||
if (effectiveSortBy !== currentSortBy) {
|
||||
setSortBy(tab, effectiveSortBy)
|
||||
}
|
||||
}, [tab, effectiveSortBy, currentSortBy, setSortBy])
|
||||
|
||||
return (
|
||||
<YStack flex={1} padding={'$4'} gap={'$4'}>
|
||||
<YStack gap={'$2'}>
|
||||
<Text bold fontSize={'$6'} marginBottom={'$2'}>
|
||||
Sort By
|
||||
</Text>
|
||||
<RadioGroup value={effectiveSortBy} onValueChange={handleSortByChange}>
|
||||
<YStack gap={'$2'}>
|
||||
{sortByOptions.map((option) => (
|
||||
<RadioGroupItemWithLabel
|
||||
key={option.value}
|
||||
size={'$4'}
|
||||
value={option.value}
|
||||
label={option.label}
|
||||
/>
|
||||
))}
|
||||
</YStack>
|
||||
</RadioGroup>
|
||||
</YStack>
|
||||
|
||||
<YStack gap={'$2'}>
|
||||
<Text bold fontSize={'$6'} marginBottom={'$2'}>
|
||||
Sort Order
|
||||
</Text>
|
||||
<RadioGroup
|
||||
value={sortDescending ? 'descending' : 'ascending'}
|
||||
onValueChange={handleSortOrderChange}
|
||||
>
|
||||
<YStack gap={'$2'}>
|
||||
<RadioGroupItemWithLabel
|
||||
size={'$4'}
|
||||
value='ascending'
|
||||
label={sortOrderLabels.ascending}
|
||||
/>
|
||||
<RadioGroupItemWithLabel
|
||||
size={'$4'}
|
||||
value='descending'
|
||||
label={sortOrderLabels.descending}
|
||||
/>
|
||||
</YStack>
|
||||
</RadioGroup>
|
||||
</YStack>
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import React, { RefObject, useRef, useEffect } from 'react'
|
||||
import Track from '../Global/components/Track'
|
||||
import { useTheme, XStack, YStack } from 'tamagui'
|
||||
import { BaseItemDto, BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models'
|
||||
import { ItemSortBy } from '@jellyfin/sdk/lib/generated-client/models/item-sort-by'
|
||||
import { Queue } from '../../player/types/queue-item'
|
||||
import { FlashList, FlashListRef } from '@shopify/flash-list'
|
||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
|
||||
@@ -20,6 +21,8 @@ interface TracksProps {
|
||||
tracksInfiniteQuery: UseInfiniteQueryResult<(string | number | BaseItemDto)[], Error>
|
||||
trackPageParams?: RefObject<Set<string>>
|
||||
showAlphabeticalSelector?: boolean
|
||||
sortBy?: ItemSortBy
|
||||
sortDescending?: boolean
|
||||
navigation: Pick<NativeStackNavigationProp<BaseStackParamList>, 'navigate' | 'dispatch'>
|
||||
queue: Queue
|
||||
}
|
||||
@@ -28,6 +31,8 @@ export default function Tracks({
|
||||
tracksInfiniteQuery,
|
||||
trackPageParams,
|
||||
showAlphabeticalSelector,
|
||||
sortBy,
|
||||
sortDescending,
|
||||
navigation,
|
||||
queue,
|
||||
}: TracksProps): React.JSX.Element {
|
||||
@@ -38,11 +43,16 @@ export default function Tracks({
|
||||
const pendingLetterRef = useRef<string | null>(null)
|
||||
|
||||
const stickyHeaderIndicies = (() => {
|
||||
if (!showAlphabeticalSelector || !tracksInfiniteQuery.data) return []
|
||||
|
||||
if (
|
||||
!showAlphabeticalSelector ||
|
||||
!tracksInfiniteQuery.data ||
|
||||
sortBy === ItemSortBy.Artist ||
|
||||
sortBy === ItemSortBy.Album
|
||||
)
|
||||
return []
|
||||
return tracksInfiniteQuery.data
|
||||
.map((track, index) => (typeof track === 'string' ? index : 0))
|
||||
.filter((value, index, indices) => indices.indexOf(value) === index)
|
||||
.map((track, index) => (typeof track === 'string' ? index : null))
|
||||
.filter((v): v is number => v !== null)
|
||||
})()
|
||||
|
||||
const { mutateAsync: alphabetSelectorMutate, isPending: isAlphabetSelectorPending } =
|
||||
@@ -72,6 +82,7 @@ export default function Tracks({
|
||||
}) => {
|
||||
switch (typeof track) {
|
||||
case 'string':
|
||||
if (sortBy === ItemSortBy.Artist || sortBy === ItemSortBy.Album) return null
|
||||
return <FlashListStickyHeader text={track.toUpperCase()} />
|
||||
case 'object':
|
||||
return track.Type === BaseItemKind.Audio ? (
|
||||
@@ -83,6 +94,8 @@ export default function Tracks({
|
||||
testID={`track-item-${index}`}
|
||||
tracklist={tracks.slice(tracks.indexOf(track), tracks.indexOf(track) + 50)}
|
||||
queue={queue}
|
||||
sortingByAlbum={sortBy === ItemSortBy.Album}
|
||||
sortingByReleasedDate={sortBy === ItemSortBy.PremiereDate}
|
||||
/>
|
||||
) : (
|
||||
<ItemRow navigation={navigation} item={track} />
|
||||
@@ -138,6 +151,7 @@ export default function Tracks({
|
||||
return (
|
||||
<XStack flex={1}>
|
||||
<FlashList
|
||||
key={`tracks-${sortBy ?? 'default'}`}
|
||||
ref={sectionListRef}
|
||||
contentInsetAdjustmentBehavior='automatic'
|
||||
numColumns={1}
|
||||
@@ -173,6 +187,7 @@ export default function Tracks({
|
||||
|
||||
{showAlphabeticalSelector && trackPageParams && (
|
||||
<AZScroller
|
||||
reverseOrder={sortDescending}
|
||||
onLetterSelect={(letter) =>
|
||||
alphabetSelectorMutate({
|
||||
letter,
|
||||
|
||||
6
src/screens/SortOptions/index.tsx
Normal file
6
src/screens/SortOptions/index.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import SortOptionsComponent from '../../components/SortOptions/index'
|
||||
import { SortOptionsProps } from '../types'
|
||||
|
||||
export default function SortOptionsSheet({ route }: SortOptionsProps): React.JSX.Element {
|
||||
return <SortOptionsComponent currentTab={route.params?.currentTab} />
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import DeletePlaylist from './Library/delete-playlist'
|
||||
import { Platform } from 'react-native'
|
||||
import { formatArtistNames } from '../utils/formatting/artist-names'
|
||||
import FiltersSheet from './Filters'
|
||||
import SortOptionsSheet from './SortOptions'
|
||||
import GenreSelectionScreen from './GenreSelection'
|
||||
|
||||
const RootStack = createNativeStackNavigator<RootStackParamList>()
|
||||
@@ -88,6 +89,17 @@ export default function Root(): React.JSX.Element {
|
||||
}}
|
||||
/>
|
||||
|
||||
<RootStack.Screen
|
||||
name='SortOptions'
|
||||
component={SortOptionsSheet}
|
||||
options={{
|
||||
headerTitle: 'Sort',
|
||||
presentation: 'formSheet',
|
||||
sheetAllowedDetents: 'fitToContents',
|
||||
sheetGrabberVisible: true,
|
||||
}}
|
||||
/>
|
||||
|
||||
<RootStack.Screen
|
||||
name='AudioSpecs'
|
||||
component={AudioSpecsSheet}
|
||||
|
||||
7
src/screens/types.d.ts
vendored
7
src/screens/types.d.ts
vendored
@@ -66,7 +66,11 @@ export type RootStackParamList = {
|
||||
}
|
||||
|
||||
Filters: {
|
||||
currentTab?: 'Tracks' | 'Albums' | 'Artists' | 'Playlists'
|
||||
currentTab?: 'Tracks' | 'Albums' | 'Artists'
|
||||
}
|
||||
|
||||
SortOptions: {
|
||||
currentTab?: 'Tracks' | 'Albums' | 'Artists'
|
||||
}
|
||||
|
||||
GenreSelection: undefined
|
||||
@@ -93,6 +97,7 @@ export type AudioSpecsProps = NativeStackScreenProps<RootStackParamList, 'AudioS
|
||||
export type DeletePlaylistProps = NativeStackScreenProps<RootStackParamList, 'DeletePlaylist'>
|
||||
|
||||
export type FiltersProps = NativeStackScreenProps<RootStackParamList, 'Filters'>
|
||||
export type SortOptionsProps = NativeStackScreenProps<RootStackParamList, 'SortOptions'>
|
||||
export type GenreSelectionProps = NativeStackScreenProps<RootStackParamList, 'GenreSelection'>
|
||||
|
||||
export type GenresProps = {
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { createJSONStorage, devtools, persist } from 'zustand/middleware'
|
||||
import { mmkvStateStorage } from '../constants/storage'
|
||||
import { create } from 'zustand'
|
||||
import { ItemSortBy } from '@jellyfin/sdk/lib/generated-client/models/item-sort-by'
|
||||
|
||||
export type LibraryTab = 'tracks' | 'albums' | 'artists'
|
||||
|
||||
type TabFilterState = {
|
||||
isFavorites: boolean | undefined
|
||||
@@ -9,9 +12,16 @@ type TabFilterState = {
|
||||
genreIds?: string[] // Only for Tracks tab
|
||||
}
|
||||
|
||||
type SortState = Record<LibraryTab, ItemSortBy>
|
||||
type SortOrderState = Record<LibraryTab, boolean>
|
||||
|
||||
type LibraryStore = {
|
||||
sortDescending: boolean
|
||||
setSortDescending: (sortDescending: boolean) => void
|
||||
sortBy: SortState
|
||||
sortDescending: SortOrderState
|
||||
setSortBy: (tab: LibraryTab, sortBy: ItemSortBy) => void
|
||||
setSortDescending: (tab: LibraryTab, sortDescending: boolean) => void
|
||||
getSortBy: (tab: LibraryTab) => ItemSortBy
|
||||
getSortDescending: (tab: LibraryTab) => boolean
|
||||
filters: {
|
||||
tracks: TabFilterState
|
||||
albums: TabFilterState
|
||||
@@ -28,8 +38,54 @@ const useLibraryStore = create<LibraryStore>()(
|
||||
devtools(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
sortDescending: false,
|
||||
setSortDescending: (sortDescending: boolean) => set({ sortDescending }),
|
||||
sortBy: {
|
||||
tracks: ItemSortBy.Name,
|
||||
albums: ItemSortBy.Name,
|
||||
artists: ItemSortBy.SortName,
|
||||
},
|
||||
sortDescending: {
|
||||
tracks: false,
|
||||
albums: false,
|
||||
artists: false,
|
||||
},
|
||||
setSortBy: (tab: LibraryTab, sortBy: ItemSortBy) =>
|
||||
set((state) => {
|
||||
const current = state.sortBy as SortState | string
|
||||
const next: SortState =
|
||||
typeof current === 'object' && current !== null && 'tracks' in current
|
||||
? { ...current, [tab]: sortBy }
|
||||
: {
|
||||
tracks: ItemSortBy.Name,
|
||||
albums: ItemSortBy.Name,
|
||||
artists: ItemSortBy.SortName,
|
||||
[tab]: sortBy,
|
||||
}
|
||||
return { sortBy: next }
|
||||
}),
|
||||
setSortDescending: (tab: LibraryTab, sortDescending: boolean) =>
|
||||
set((state) => {
|
||||
const current = state.sortDescending as SortOrderState | boolean
|
||||
const next: SortOrderState =
|
||||
typeof current === 'object' && current !== null && 'tracks' in current
|
||||
? { ...current, [tab]: sortDescending }
|
||||
: {
|
||||
tracks: false,
|
||||
albums: false,
|
||||
artists: false,
|
||||
[tab]: sortDescending,
|
||||
}
|
||||
return { sortDescending: next }
|
||||
}),
|
||||
getSortBy: (tab: LibraryTab) => {
|
||||
const sortBy = get().sortBy as SortState | string
|
||||
if (typeof sortBy === 'string') return sortBy as ItemSortBy
|
||||
return sortBy[tab] ?? ItemSortBy.Name
|
||||
},
|
||||
getSortDescending: (tab: LibraryTab) => {
|
||||
const sortDescending = get().sortDescending as SortOrderState | boolean
|
||||
if (typeof sortDescending === 'boolean') return sortDescending
|
||||
return sortDescending[tab] ?? false
|
||||
},
|
||||
|
||||
filters: {
|
||||
tracks: {
|
||||
|
||||
@@ -1,15 +1,26 @@
|
||||
import { BaseItemKind } from '@jellyfin/sdk/lib/generated-client'
|
||||
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models/base-item-dto'
|
||||
import { ItemSortBy } from '@jellyfin/sdk/lib/generated-client/models/item-sort-by'
|
||||
import { InfiniteData } from '@tanstack/react-query'
|
||||
import { isString } from 'lodash'
|
||||
import { RefObject } from 'react'
|
||||
|
||||
export type FlattenInfiniteQueryPagesOptions = {
|
||||
/**
|
||||
* When ItemSortBy.Artist, section letters are derived from the item's artist (AlbumArtist/Artists).
|
||||
* When ItemSortBy.Album, section letters are derived from the item's album name.
|
||||
* Otherwise (Name, SortName, etc.) letters are derived from the item's name/SortName.
|
||||
*/
|
||||
sortBy?: ItemSortBy
|
||||
}
|
||||
|
||||
export default function flattenInfiniteQueryPages(
|
||||
data: InfiniteData<BaseItemDto[], unknown>,
|
||||
pageParams: RefObject<Set<string>>,
|
||||
options?: FlattenInfiniteQueryPagesOptions,
|
||||
) {
|
||||
/**
|
||||
* A flattened array of all artists derived from the infinite query
|
||||
* A flattened array of all items derived from the infinite query
|
||||
*/
|
||||
const flattenedItemPages = data.pages.flatMap((page) => page)
|
||||
|
||||
@@ -19,15 +30,23 @@ export default function flattenInfiniteQueryPages(
|
||||
const seenLetters = new Set<string>()
|
||||
|
||||
/**
|
||||
* The final array that will be provided to and rendered by the {@link Artists} component
|
||||
* The final array that will be provided to and rendered by the list component
|
||||
*/
|
||||
const flashListItems: (string | number | BaseItemDto)[] = []
|
||||
|
||||
// Letter source: Artist → artist; Album → album name; otherwise → item name (track name, etc.)
|
||||
const extractLetter =
|
||||
options?.sortBy === ItemSortBy.Artist
|
||||
? extractFirstLetterByArtist
|
||||
: options?.sortBy === ItemSortBy.Album
|
||||
? extractFirstLetterByAlbum
|
||||
: extractFirstLetter
|
||||
|
||||
flattenedItemPages.forEach((item: BaseItemDto) => {
|
||||
const rawLetter = extractFirstLetter(item)
|
||||
const rawLetter = extractLetter(item)
|
||||
|
||||
/**
|
||||
* An alpha character or a hash if the artist's name doesn't start with a letter
|
||||
* An alpha character or a hash if the name doesn't start with a letter
|
||||
*/
|
||||
const letter = rawLetter.match(/[A-Z]/) ? rawLetter : '#'
|
||||
|
||||
@@ -53,3 +72,17 @@ function extractFirstLetter({ Type, SortName, Name }: BaseItemDto): string {
|
||||
|
||||
return letter
|
||||
}
|
||||
|
||||
function extractFirstLetterByArtist(item: BaseItemDto): string {
|
||||
const raw =
|
||||
(isString(item.AlbumArtist) && item.AlbumArtist.trim()) ||
|
||||
(item.Artists?.[0] && isString(item.Artists[0]) && item.Artists[0].trim())
|
||||
if (!raw) return '#'
|
||||
return raw.charAt(0).toUpperCase()
|
||||
}
|
||||
|
||||
function extractFirstLetterByAlbum(item: BaseItemDto): string {
|
||||
const raw = isString(item.Album) && item.Album.trim()
|
||||
if (!raw) return '#'
|
||||
return raw.charAt(0).toUpperCase()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user