From 927f6aa97378412a8e068da2bef76d7e02076aed Mon Sep 17 00:00:00 2001 From: Stephen Arg Date: Tue, 3 Feb 2026 23:35:08 +0100 Subject: [PATCH] Added sorting option to library for artists, albums, tracks (#977) Co-authored-by: StephenArg --- src/api/queries/album/index.ts | 49 ++++++- src/api/queries/artist/index.ts | 36 ++++- src/api/queries/track/index.ts | 29 +++- src/components/Albums/component.tsx | 16 +- src/components/Artists/component.tsx | 5 +- .../Global/components/Track/index.tsx | 11 +- .../components/alphabetical-selector.tsx | 8 +- .../Library/components/albums-tab.tsx | 25 +++- .../Library/components/artists-tab.tsx | 24 ++- .../Library/components/tracks-tab.tsx | 26 +++- src/components/Library/tab-bar.tsx | 71 ++++++--- src/components/SortOptions/index.tsx | 137 ++++++++++++++++++ src/components/Tracks/component.tsx | 23 ++- src/screens/SortOptions/index.tsx | 6 + src/screens/index.tsx | 12 ++ src/screens/types.d.ts | 7 +- src/stores/library.ts | 64 +++++++- src/utils/query-selectors.ts | 41 +++++- 18 files changed, 524 insertions(+), 66 deletions(-) create mode 100644 src/components/SortOptions/index.tsx create mode 100644 src/screens/SortOptions/index.tsx diff --git a/src/api/queries/album/index.ts b/src/api/queries/album/index.ts index 5cca75c6..0c74a34d 100644 --- a/src/api/queries/album/index.ts +++ b/src/api/queries/album/index.ts @@ -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>(new Set()) - // Memize the expensive albums select function - const selectAlbums = (data: InfiniteData) => - 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) => { + 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, diff --git a/src/api/queries/artist/index.ts b/src/api/queries/artist/index.ts index 05b3c6fb..53596a63 100644 --- a/src/api/queries/artist/index.ts +++ b/src/api/queries/artist/index.ts @@ -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>(new Set()) - // Memoize the expensive artists select function - const selectArtists = (data: InfiniteData) => - 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) => { + 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, diff --git a/src/api/queries/track/index.ts b/src/api/queries/track/index.ts index 2666fe8c..c9c0f586 100644 --- a/src/api/queries/track/index.ts +++ b/src/api/queries/track/index.ts @@ -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>(new Set()) const selectTracks = (data: InfiniteData) => { - 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({ diff --git a/src/components/Albums/component.tsx b/src/components/Albums/component.tsx index 7a92e9a8..d32aab73 100644 --- a/src/components/Albums/component.tsx +++ b/src/components/Albums/component.tsx @@ -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> } @@ -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(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' ? ( - + sortBy === ItemSortBy.Artist ? null : ( + + ) ) : typeof album === 'number' ? null : typeof album === 'object' ? ( ) : null @@ -143,6 +150,7 @@ export default function Albums({ {showAlphabeticalSelector && albumPageParams && ( alphabetSelectorMutate({ letter, diff --git a/src/components/Artists/component.tsx b/src/components/Artists/component.tsx index 53102064..d44d445e 100644 --- a/src/components/Artists/component.tsx +++ b/src/components/Artists/component.tsx @@ -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> } @@ -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 && ( alphabetSelectorMutate({ letter, diff --git a/src/components/Global/components/Track/index.tsx b/src/components/Global/components/Track/index.tsx index fb6ec606..089e6484 100644 --- a/src/components/Global/components/Track/index.tsx +++ b/src/components/Global/components/Track/index.tsx @@ -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' diff --git a/src/components/Global/components/alphabetical-selector.tsx b/src/components/Global/components/alphabetical-selector.tsx index f1e5aa5b..930a0517 100644 --- a/src/components/Global/components/alphabetical-selector.tsx +++ b/src/components/Global/components/alphabetical-selector.tsx @@ -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 alphabet?: string[] + reverseOrder?: boolean }) { - const alphabetToUse = customAlphabet ?? alphabet + const alphabetToUse = customAlphabet ?? (reverseOrder ? alphabetZtoA : alphabetAtoZ) const theme = useTheme() const [operationPending, setOperationPending] = useState(false) diff --git a/src/components/Library/components/albums-tab.tsx b/src/components/Library/components/albums-tab.tsx index d3699652..b707b396 100644 --- a/src/components/Library/components/albums-tab.tsx +++ b/src/components/Library/components/albums-tab.tsx @@ -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 + if (typeof sb === 'string') return sb + return sb?.albums ?? ItemSortBy.Album + }) + const sortDescending = useLibraryStore((state) => { + const sd = state.sortDescending as Record | 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 ( ) diff --git a/src/components/Library/components/artists-tab.tsx b/src/components/Library/components/artists-tab.tsx index bec49ff8..a796ab00 100644 --- a/src/components/Library/components/artists-tab.tsx +++ b/src/components/Library/components/artists-tab.tsx @@ -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 + if (typeof sb === 'string') return sb + return sb?.artists ?? ItemSortBy.SortName + }) + const sortDescending = useLibraryStore((state) => { + const sd = state.sortDescending as Record | 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 ( ) diff --git a/src/components/Library/components/tracks-tab.tsx b/src/components/Library/components/tracks-tab.tsx index ce47ec69..6b238745 100644 --- a/src/components/Library/components/tracks-tab.tsx +++ b/src/components/Library/components/tracks-tab.tsx @@ -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 + if (typeof sb === 'string') return sb + return sb?.tracks ?? ItemSortBy.Name + }) + const sortDescending = useLibraryStore((state) => { + const sd = state.sortDescending as Record | 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>() @@ -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} /> ) diff --git a/src/components/Library/tab-bar.tsx b/src/components/Library/tab-bar.tsx index b032ab33..1f4e02cc 100644 --- a/src/components/Library/tab-bar.tsx +++ b/src/components/Library/tab-bar.tsx @@ -99,29 +99,56 @@ function LibraryTabBar(props: MaterialTopTabBarProps) { )} {props.state.routes[props.state.index].name !== 'Playlists' && ( - { - triggerHaptic('impactLight') - if (navigationRef.isReady()) { - navigationRef.navigate('Filters', { - currentTab: currentTab as 'Tracks' | 'Albums' | 'Artists', - }) - } - }} - pressStyle={{ opacity: 0.6 }} - animation='quick' - alignItems={'center'} - justifyContent={'center'} - > - + <> + { + triggerHaptic('impactLight') + if (navigationRef.isReady()) { + navigationRef.navigate('SortOptions', { + currentTab: currentTab as + | 'Tracks' + | 'Albums' + | 'Artists', + }) + } + }} + pressStyle={{ opacity: 0.6 }} + animation='quick' + alignItems={'center'} + justifyContent={'center'} + > + - - Filter - - + Sort + + + { + triggerHaptic('impactLight') + if (navigationRef.isReady()) { + navigationRef.navigate('Filters', { + currentTab: currentTab as + | 'Tracks' + | 'Albums' + | 'Artists', + }) + } + }} + pressStyle={{ opacity: 0.6 }} + animation='quick' + alignItems={'center'} + justifyContent={'center'} + > + + + + Filter + + + )} {props.state.routes[props.state.index].name !== 'Playlists' && diff --git a/src/components/SortOptions/index.tsx b/src/components/SortOptions/index.tsx new file mode 100644 index 00000000..9361bb1a --- /dev/null +++ b/src/components/SortOptions/index.tsx @@ -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 ( + + + + Sort By + + + + {sortByOptions.map((option) => ( + + ))} + + + + + + + Sort Order + + + + + + + + + + ) +} diff --git a/src/components/Tracks/component.tsx b/src/components/Tracks/component.tsx index 43e05dad..546e1a82 100644 --- a/src/components/Tracks/component.tsx +++ b/src/components/Tracks/component.tsx @@ -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> showAlphabeticalSelector?: boolean + sortBy?: ItemSortBy + sortDescending?: boolean navigation: Pick, '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(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 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} /> ) : ( @@ -138,6 +151,7 @@ export default function Tracks({ return ( alphabetSelectorMutate({ letter, diff --git a/src/screens/SortOptions/index.tsx b/src/screens/SortOptions/index.tsx new file mode 100644 index 00000000..53072c52 --- /dev/null +++ b/src/screens/SortOptions/index.tsx @@ -0,0 +1,6 @@ +import SortOptionsComponent from '../../components/SortOptions/index' +import { SortOptionsProps } from '../types' + +export default function SortOptionsSheet({ route }: SortOptionsProps): React.JSX.Element { + return +} diff --git a/src/screens/index.tsx b/src/screens/index.tsx index 41e6da33..38947c43 100644 --- a/src/screens/index.tsx +++ b/src/screens/index.tsx @@ -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() @@ -88,6 +89,17 @@ export default function Root(): React.JSX.Element { }} /> + + export type FiltersProps = NativeStackScreenProps +export type SortOptionsProps = NativeStackScreenProps export type GenreSelectionProps = NativeStackScreenProps export type GenresProps = { diff --git a/src/stores/library.ts b/src/stores/library.ts index 520a1200..08465e33 100644 --- a/src/stores/library.ts +++ b/src/stores/library.ts @@ -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 +type SortOrderState = Record + 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()( 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: { diff --git a/src/utils/query-selectors.ts b/src/utils/query-selectors.ts index b62ea49a..f95561d5 100644 --- a/src/utils/query-selectors.ts +++ b/src/utils/query-selectors.ts @@ -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, pageParams: RefObject>, + 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() /** - * 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() +}