Merge branch 'main' into improve-cold-start-performance

This commit is contained in:
skalthoff
2026-02-04 11:09:04 -08:00
committed by GitHub
18 changed files with 524 additions and 66 deletions

View File

@@ -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,

View File

@@ -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,

View File

@@ -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({

View File

@@ -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,

View File

@@ -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,

View File

@@ -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'

View File

@@ -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)

View File

@@ -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}
/>
)

View File

@@ -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}
/>
)

View File

@@ -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}
/>
)

View File

@@ -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' &&

View 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>
)
}

View File

@@ -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,

View 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} />
}

View File

@@ -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}

View File

@@ -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 = {

View File

@@ -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: {

View File

@@ -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()
}