diff --git a/src/api/mutations/playlist/index.ts b/src/api/mutations/playlist/index.ts index ad59cfcfa..146638043 100644 --- a/src/api/mutations/playlist/index.ts +++ b/src/api/mutations/playlist/index.ts @@ -1,4 +1,4 @@ -import LibraryStackParamList from '@/src/screens/Library/types' +import { LibraryParamList } from '@/src/screens/Library/types' import { useNavigation } from '@react-navigation/native' import { NativeStackNavigationProp } from '@react-navigation/native-stack' import { InfiniteData, useMutation } from '@tanstack/react-query' @@ -14,7 +14,7 @@ import { applyHapticFeedback } from '../../../utils/haptics' export const useAddPlaylist = () => { const user = getUser() - const libraryStackNavigation = useNavigation>() + const libraryStackNavigation = useNavigation>() return useMutation({ mutationFn: ({ name }: { name: string }) => createPlaylist(name), diff --git a/src/api/queries/album/index.ts b/src/api/queries/album/index.ts index 4b804641b..31db0ec2d 100644 --- a/src/api/queries/album/index.ts +++ b/src/api/queries/album/index.ts @@ -5,7 +5,7 @@ import { SortOrder } from '@jellyfin/sdk/lib/generated-client/models/sort-order' import { fetchAlbums } from './utils/album' import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client' import flattenInfiniteQueryPages from '../../../utils/query-selectors' -import { ApiLimits, MaxPages } from '../../../configs/querying/index.config' +import { MaxPages } from '../../../configs/querying/index.config' import { queryClient } from '../../../constants/query-client' import { getApi, getUser } from '../../../stores/auth/utils' import { useJellifyLibrary } from '../../../stores/auth' @@ -14,6 +14,18 @@ import { fetchAlbumDiscs } from '../item' import { Api } from '@jellyfin/sdk/lib/api' import { AlbumDiscsQueryKey } from './keys' import { AlbumQuery, RecentlyAddedQuery } from './queries' +import { InfiniteSectionListPageParam } from '../../types/page-param' +import { createLetterPageParamFns } from '../../utils/page-params' + +const albumSortByOptions = [ + ItemSortBy.Name, + ItemSortBy.SortName, + ItemSortBy.Album, + ItemSortBy.Artist, + ItemSortBy.PlayCount, + ItemSortBy.DateCreated, + ItemSortBy.PremiereDate, +] as ItemSortBy[] export const useAlbum = (album: BaseItemDto) => useQuery(AlbumQuery(album)) @@ -28,16 +40,8 @@ const useAlbums = () => { 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) + + const librarySortBy = albumSortByOptions.includes(rawAlbumSortBy) ? (rawAlbumSortBy as ItemSortBy) : ItemSortBy.Album const sortDescending = librarySortDescendingState.albums ?? false @@ -56,39 +60,90 @@ const useAlbums = () => { return flattenInfiniteQueryPages(data) } - return useInfiniteQuery({ - queryKey: [ - QueryKeys.InfiniteAlbums, - isFavorites, - library?.musicLibraryId, - librarySortBy, - sortDescending, - yearMin, - yearMax, - ], - queryFn: ({ pageParam, signal }) => - fetchAlbums( + const queryKey = [ + QueryKeys.InfiniteAlbums, + isFavorites, + library?.musicLibraryId, + librarySortBy, + sortDescending, + yearMin, + yearMax, + ] + + const sortBy = [librarySortBy ?? ItemSortBy.SortName] + const sortOrder = [sortDescending ? SortOrder.Descending : SortOrder.Ascending] + + const { getNextPageParam, getPreviousPageParam } = createLetterPageParamFns(sortDescending) + + /** + * Jumps the albums list directly to {@link letter} with a single `nameStartsWith`/ + * `nameLessThan` + `limit`-bounded request scoped to that letter, then seeds the query cache + * with that page - no fetching (or paginating through) albums outside the target letter. + */ + const jumpToLetter = async (letter: string, letterReverseOrder: boolean): Promise => { + if (!isSortByLetter || !api || !user || !library) return false + + try { + const pageParam: InfiniteSectionListPageParam = { + letter: letter.toUpperCase(), + index: 0, + } + + const items = await fetchAlbums( api, user, library, pageParam, isFavorites, - [librarySortBy ?? ItemSortBy.SortName], - [sortDescending ? SortOrder.Descending : SortOrder.Ascending], + sortBy, + [letterReverseOrder ? SortOrder.Descending : SortOrder.Ascending], yearMin, yearMax, - signal, - ), - initialPageParam: 0, - select: selectAlbums, - maxPages: MaxPages.Library, - getNextPageParam: (lastPage, allPages, lastPageParam) => { - return lastPage.length === ApiLimits.Library ? lastPageParam + 1 : undefined - }, - getPreviousPageParam: (firstPage, allPages, firstPageParam) => { - return firstPageParam === 0 ? null : firstPageParam - 1 - }, - }) + ) + + // A jump seeks to a new spot in the list, so it replaces the cache with a single page + // rather than merging - old pages aren't adjacent to it, so keeping them around would + // just leave gaps getNextPageParam/getPreviousPageParam can't page across + queryClient.setQueryData>( + queryKey, + { + pages: [items], + pageParams: [pageParam], + }, + ) + return true + } catch { + return false + } + } + + return { + ...useInfiniteQuery({ + queryKey, + queryFn: ({ pageParam, signal }) => + fetchAlbums( + api, + user, + library, + pageParam, + isFavorites, + sortBy, + sortOrder, + yearMin, + yearMax, + signal, + ), + initialPageParam: { + index: 0, + letter: '#', + } as InfiniteSectionListPageParam, + select: selectAlbums, + maxPages: MaxPages.Library, + getNextPageParam, + getPreviousPageParam, + }), + jumpToLetter, + } } export default useAlbums diff --git a/src/api/queries/album/utils/album.ts b/src/api/queries/album/utils/album.ts index bdded95a8..40e50c661 100644 --- a/src/api/queries/album/utils/album.ts +++ b/src/api/queries/album/utils/album.ts @@ -13,12 +13,22 @@ import { ApiLimits } from '../../../../configs/querying/index.config' import buildYearsParam from '../../../../utils/mapping/build-years-param' import { getItemsApi } from '@jellyfin/sdk/lib/utils/api/items-api' import { setQueryUserDataForItems } from '../../user-data' +import { InfiniteSectionListPageParam } from '@/src/api/types/page-param' + +/** + * Maps an AZScroller {@link letter} ('#' or 'A'-'Z') to the `nameStartsWith`/`nameLessThan` + * filters that select just that letter's albums, so a single bounded request can fetch a + * letter directly instead of paginating through the whole library to reach it. + */ +function letterNameParams(letter: string): { nameStartsWith?: string; nameLessThan?: string } { + return letter === '#' ? { nameLessThan: 'A' } : { nameStartsWith: letter } +} export function fetchAlbums( api: Api | undefined, user: JellifyUser | undefined, library: JellifyLibrary | undefined, - page: number, + pageParam: InfiniteSectionListPageParam, isFavorite: boolean | undefined, sortBy: ItemSortBy[] = [ItemSortBy.SortName], sortOrder: SortOrder[] = [SortOrder.Ascending], @@ -32,27 +42,54 @@ export function fetchAlbums( if (!library) return reject('Library has not been set') const yearsParam = buildYearsParam(yearMin, yearMax) + const { nameStartsWith, nameLessThan } = letterNameParams(pageParam.letter) - getItemsApi(api) - .getItems( + const fetchPage = (startIndex: number) => + getItemsApi(api).getItems( { parentId: library.musicLibraryId, includeItemTypes: [BaseItemKind.MusicAlbum], userId: user.id, sortBy: sortBy, sortOrder: sortOrder, - startIndex: page * ApiLimits.Library, + startIndex, limit: ApiLimits.Library, isFavorite: isFavorite, fields: [ItemFields.SortName], recursive: true, years: yearsParam, + nameStartsWith, + nameLessThan, enableUserData: true, }, { signal, }, ) + + // A negative index requests this letter's *last* page (paging backwards across a + // letter boundary) - resolve it with a count-only lookup rather than paginating + // forward through the whole letter just to find where it ends. + const startIndexPromise: Promise = + pageParam.index >= 0 + ? Promise.resolve(pageParam.index) + : fetchAlbumsCount( + api, + user, + library, + isFavorite, + pageParam.letter, + yearMin, + yearMax, + signal, + ).then( + (count) => + Math.max(0, Math.ceil(count / ApiLimits.Library) - 1) * + ApiLimits.Library, + ) + + startIndexPromise + .then(fetchPage) .then(({ data }) => { const items = data.Items ?? [] setQueryUserDataForItems(items) @@ -65,6 +102,55 @@ export function fetchAlbums( }) } +/** + * Fetches the number of albums matching {@link letter} ('#' or 'A'-'Z'), or the total album + * count when omitted. A `limit: 0` count-only request, used to resolve absolute/last-page + * indexes without ever fetching the items themselves. + */ +export function fetchAlbumsCount( + api: Api | undefined, + user: JellifyUser | undefined, + library: JellifyLibrary | undefined, + isFavorite: boolean | undefined, + letter?: string, + yearMin?: number, + yearMax?: number, + signal?: AbortSignal, +): Promise { + return new Promise((resolve, reject) => { + if (!api) return reject('No API instance provided') + if (!user) return reject('No user provided') + if (!library) return reject('Library has not been set') + + const yearsParam = buildYearsParam(yearMin, yearMax) + const { nameStartsWith, nameLessThan } = letter ? letterNameParams(letter) : {} + + getItemsApi(api) + .getItems( + { + parentId: library.musicLibraryId, + includeItemTypes: [BaseItemKind.MusicAlbum], + userId: user.id, + startIndex: 0, + limit: 0, + isFavorite: isFavorite, + recursive: true, + years: yearsParam, + nameStartsWith, + nameLessThan, + enableTotalRecordCount: true, + }, + { + signal, + }, + ) + .then(({ data }) => resolve(data.TotalRecordCount ?? 0)) + .catch((error) => { + reject(error) + }) + }) +} + export function fetchAlbumById( api: Api | undefined, albumId: string, diff --git a/src/api/queries/artist/index.ts b/src/api/queries/artist/index.ts index f86eefd3c..83ff458b8 100644 --- a/src/api/queries/artist/index.ts +++ b/src/api/queries/artist/index.ts @@ -1,16 +1,16 @@ import { QueryKeys } from '../../../enums/query-keys' -import { BaseItemDto, ItemSortBy, SortOrder } from '@jellyfin/sdk/lib/generated-client' -import { InfiniteData, useInfiniteQuery, useQuery } from '@tanstack/react-query' -import { isUndefined } from 'lodash' +import { BaseItemDto, SortOrder } from '@jellyfin/sdk/lib/generated-client' +import { useInfiniteQuery, useQuery } from '@tanstack/react-query' +import { isUndefined, uniqBy } from 'lodash' import { fetchArtistFeaturedOn, fetchArtists } from './utils/artist' import { ApiLimits, MaxPages } from '../../../configs/querying/index.config' -import flattenInfiniteQueryPages from '../../../utils/query-selectors' import { useJellifyLibrary, useJellifyUser } from '../../../stores/auth' import { getApi } from '../../../stores/auth/utils' -import useLibraryStore from '../../../stores/library' import { fetchItem } from '../item' import { ArtistQueryKey } from './keys' import { artistAlbumsQuery } from './queries' +import { ArtistsSortBy } from '@/src/types/sorting/artist' +import ArtistsSortByConfig from '../../../configs/sorting/artist' export const useArtist = (artistId: string | undefined | null) => { const api = getApi() @@ -25,7 +25,7 @@ export const useArtist = (artistId: string | undefined | null) => { export const useArtistAlbums = (artist: BaseItemDto) => { const [library] = useJellifyLibrary() - return useQuery(artistAlbumsQuery(library!, artist)) + return useQuery(artistAlbumsQuery(artist, library)) } export const useArtistFeaturedOn = (artist: BaseItemDto) => { @@ -38,38 +38,62 @@ export const useArtistFeaturedOn = (artist: BaseItemDto) => { }) } -export const useAlbumArtists = () => { +export const useAlbumArtists = ( + isFavorites: true | undefined, + sortBy: ArtistsSortBy, + sortOrder: SortOrder, +) => { const [user] = useJellifyUser() const [library] = useJellifyLibrary() - const { filters, sortDescending: librarySortDescendingState } = useLibraryStore() - const sortDescending = librarySortDescendingState.artists ?? false - const isFavorites = filters.artists.isFavorites - - const selectArtists = (data: InfiniteData) => { - return flattenInfiniteQueryPages(data) - } + const queryKey = [ + QueryKeys.InfiniteArtists, + isFavorites, + sortBy, + sortOrder, + library?.musicLibraryId, + ] return useInfiniteQuery({ - queryKey: [QueryKeys.InfiniteArtists, isFavorites, sortDescending, library?.musicLibraryId], + queryKey, queryFn: ({ pageParam, signal }: { pageParam: number; signal?: AbortSignal }) => - fetchArtists( - user, - library, - pageParam, - isFavorites, - [ItemSortBy.SortName], - [sortDescending ? SortOrder.Descending : SortOrder.Ascending], - signal, - ), - select: selectArtists, + fetchArtists(user, library, pageParam, isFavorites, sortBy, sortOrder, signal), maxPages: MaxPages.Library, initialPageParam: 0, - getNextPageParam: (lastPage, allPages, lastPageParam, allPageParams) => { - return lastPage.length === ApiLimits.Library ? lastPageParam + 1 : undefined - }, + select: ({ pages }) => + uniqBy( + pages.flatMap((page) => page), + 'Id', + ), + getNextPageParam: (lastPage, allPages, lastPageParam, allPageParams) => + getNextAlbumArtistsPageParam(lastPage, lastPageParam, sortBy), getPreviousPageParam: (firstPage, allPages, firstPageParam, allPageParams) => { - return firstPageParam === 0 ? null : firstPageParam - 1 + return firstPageParam <= 0 ? null : Math.max(0, firstPageParam - ApiLimits.Library) }, }) } + +function getNextAlbumArtistsPageParam( + lastPage: BaseItemDto[], + lastPageParam: number, + sortBy: ArtistsSortBy, +): number | undefined { + let nextPageParam: number | undefined + + switch (sortBy) { + case ArtistsSortByConfig.DateLastContentAdded: + case ArtistsSortByConfig.DatePlayed: + nextPageParam = lastPage.length > 0 ? lastPageParam + 1 : undefined + break + + default: + case ArtistsSortByConfig.SortName: + nextPageParam = + lastPage?.length === ApiLimits.Library + ? lastPageParam + ApiLimits.Library + : undefined + } + + console.debug(`Next Artists page param ${nextPageParam}`) + return nextPageParam +} diff --git a/src/api/queries/artist/queries.ts b/src/api/queries/artist/queries.ts index bb75b4912..2f277c334 100644 --- a/src/api/queries/artist/queries.ts +++ b/src/api/queries/artist/queries.ts @@ -6,7 +6,7 @@ import { fetchArtistAlbums } from './utils/artist' import { queryClient } from '../../../constants/query-client' import { getLibrary } from '../../../stores/auth/utils' -export const artistAlbumsQuery = (library: JellifyLibrary, artist: BaseItemDto) => ({ +export const artistAlbumsQuery = (artist: BaseItemDto, library?: JellifyLibrary) => ({ queryKey: ArtistAlbumsQueryKey(artist.Id), queryFn: ({ signal }: { signal: AbortSignal }) => fetchArtistAlbums(library?.musicLibraryId, artist, signal), @@ -15,5 +15,8 @@ export const artistAlbumsQuery = (library: JellifyLibrary, artist: BaseItemDto) export async function ensureArtistAlbumsQueryData(artist: BaseItemDto) { const library = getLibrary() - return await queryClient.ensureQueryData(artistAlbumsQuery(library!, artist)) + return await queryClient.query({ + ...artistAlbumsQuery(artist, library), + staleTime: 'static', + }) } diff --git a/src/api/queries/artist/utils/artist.ts b/src/api/queries/artist/utils/artist.ts index 06b27603c..809cbcfef 100644 --- a/src/api/queries/artist/utils/artist.ts +++ b/src/api/queries/artist/utils/artist.ts @@ -2,6 +2,7 @@ import { JellifyLibrary } from '../../../../types/JellifyLibrary' import { Api } from '@jellyfin/sdk/lib/api' import { BaseItemDto, + BaseItemDtoQueryResult, BaseItemKind, ImageType, ItemFields, @@ -13,52 +14,78 @@ import { JellifyUser } from '../../../../types/JellifyUser' import { ApiLimits } from '../../../../configs/querying/index.config' import { setQueryUserDataForItems } from '../../user-data' import { getApi } from '../../../../stores/auth/utils' +import { ArtistsSortBy } from '../../../../types/sorting/artist' +import { AxiosResponse } from 'axios' +import { queryClient } from '../../../../constants/query-client' +import { PlayItAgainQuery } from '../../recents' +import { captureError, LoggingContext } from '../../../../utils/logging' +import { mapTracksToArtists } from '../../../../utils/mapping/track-to-artist' -export function fetchArtists( +export async function fetchArtists( user: JellifyUser | undefined, library: JellifyLibrary | undefined, page: number, isFavorite: boolean | undefined, - sortBy: ItemSortBy[] = [ItemSortBy.SortName], - sortOrder: SortOrder[] = [SortOrder.Ascending], + sortBy: ArtistsSortBy, + sortOrder: SortOrder, signal?: AbortSignal, -): Promise { - return new Promise((resolve, reject) => { - const api = getApi() +) { + const api = getApi() - if (!api) return reject('No API instance provided') - if (!user) return reject('No user provided') - if (!library) return reject('Library has not been set') + if (!api) return Promise.reject('No API instance provided') + if (!user) return Promise.reject('No user provided') + if (!library) return Promise.reject('Library has not been set') - getArtistsApi(api) - .getAlbumArtists( - { - parentId: library.musicLibraryId, - userId: user.id, - sortBy: sortBy, - sortOrder: sortOrder, - startIndex: page * ApiLimits.Library, - limit: ApiLimits.Library, - isFavorite: isFavorite, - fields: [ItemFields.SortName, ItemFields.Genres], - enableImages: true, - enableImageTypes: [ImageType.Backdrop, ImageType.Primary], - imageTypeLimit: 1, - enableUserData: true, - }, - { - signal, - }, - ) - .then(({ data }) => { - const items = data.Items ?? [] + try { + let result: AxiosResponse + let items: BaseItemDto[] + let recentTracks: BaseItemDto[] + + switch (sortBy) { + case 'DatePlayed': + recentTracks = await queryClient.infiniteQuery({ + ...PlayItAgainQuery(library), + initialPageParam: page, + staleTime: 'static', + }) + + items = mapTracksToArtists(recentTracks) + + break + case 'SortName': + default: + result = await getArtistsApi(api).getAlbumArtists( + { + parentId: library.musicLibraryId, + userId: user.id, + sortBy: [sortBy], + sortOrder: [sortOrder], + startIndex: page * ApiLimits.Library, + limit: ApiLimits.Library, + isFavorite: isFavorite, + fields: [ItemFields.SortName, ItemFields.Genres], + enableImages: true, + enableImageTypes: [ImageType.Backdrop, ImageType.Primary], + imageTypeLimit: 1, + enableUserData: true, + }, + { + signal, + }, + ) + items = result.data.Items ?? [] setQueryUserDataForItems(items) - return resolve(items) - }) - .catch((error) => { - reject(error) - }) - }) + } + + return items + } catch (error) { + captureError( + error, + LoggingContext.Artists, + `Failed to fetch artists with options: [sortBy: '${sortBy.toUpperCase()}', sortOptions: '${sortOrder.toUpperCase()}']`, + ) + return Promise.reject(error) + } } /** @@ -92,7 +119,7 @@ export function fetchArtistAlbums( ItemSortBy.SortName, ], sortOrder: [SortOrder.Descending], - albumArtistIds: [artist.Id!], + artistIds: [artist.Id!], fields: [ItemFields.ChildCount], enableUserData: true, }, diff --git a/src/api/queries/item.ts b/src/api/queries/item.ts index b78adb6a7..88d1b685f 100644 --- a/src/api/queries/item.ts +++ b/src/api/queries/item.ts @@ -1,11 +1,12 @@ import { BaseItemDto, BaseItemKind, + ItemCounts, ItemFields, ItemSortBy, SortOrder, } from '@jellyfin/sdk/lib/generated-client/models' -import { getItemsApi } from '@jellyfin/sdk/lib/utils/api' +import { getItemsApi, getLibraryApi } from '@jellyfin/sdk/lib/utils/api' import { groupBy, isEmpty, isEqual, isUndefined } from 'lodash' import { SectionList } from 'react-native' import { Api } from '@jellyfin/sdk/lib/api' @@ -13,6 +14,7 @@ import { JellifyLibrary } from '../../types/JellifyLibrary' import QueryConfig from '../../configs/querying/index.config' import { JellifyUser } from '../../types/JellifyUser' import { setQueryUserDataForItems } from './user-data' +import { getApi, getUser } from '../../stores/auth/utils' /** * Fetches a single Jellyfin item by it's ID @@ -109,6 +111,26 @@ export async function fetchItems( }) } +export async function fetchItemCounts(): Promise { + const api = getApi() + const user = getUser() + + if (!api) return Promise.reject('Api instance not set') + if (!user) return Promise.reject('User instance not set') + + try { + return await getLibraryApi(api) + .getItemCounts({ + userId: user.id, + }) + .then(({ data }) => { + return data + }) + } catch (error) { + return Promise.reject(error) + } +} + /** * Fetches tracks for an album, sectioned into discs for display in a {@link SectionList} * @param album The album to fetch tracks for diff --git a/src/api/queries/libraries/keys.ts b/src/api/queries/libraries/keys.ts index 82a73c6d9..932a9c9e3 100644 --- a/src/api/queries/libraries/keys.ts +++ b/src/api/queries/libraries/keys.ts @@ -4,6 +4,7 @@ import { Api } from '@jellyfin/sdk' export enum LibraryQueryKeys { Libraries, PlaylistLibrary, + ItemCounts, } export const LibrariesQueryKey = (api: Api | undefined, user: JellifyUser | undefined) => [ diff --git a/src/api/queries/recents/index.ts b/src/api/queries/recents/index.ts index 6e1d5c391..f2cfd9388 100644 --- a/src/api/queries/recents/index.ts +++ b/src/api/queries/recents/index.ts @@ -6,8 +6,7 @@ import { UseInfiniteQueryOptions, } from '@tanstack/react-query' import { fetchRecentlyPlayed, fetchRecentlyPlayedArtists } from './utils' -import { ApiLimits, MaxPages } from '../../../configs/querying/index.config' -import { isUndefined } from 'lodash' +import { isUndefined, uniqBy } from 'lodash' import { useJellifyLibrary } from '../../../stores/auth' import { getApi, getUser } from '../../../stores/auth/utils' import { ONE_HOUR } from '../../../constants/query-client' @@ -16,7 +15,7 @@ import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client' const RECENTS_QUERY_CONFIG = { staleTime: ONE_HOUR, - maxPages: MaxPages.Home, + maxPages: Infinity, } export const useRecentlyPlayedTracks = () => { @@ -27,8 +26,10 @@ export const useRecentlyPlayedTracks = () => { export const PlayItAgainQuery: ( library: JellifyLibrary | undefined, + abortSignal?: AbortSignal, ) => UseInfiniteQueryOptions = ( library: JellifyLibrary | undefined, + abortSignal?: AbortSignal, ) => { const api = getApi() @@ -37,7 +38,7 @@ export const PlayItAgainQuery: ( return { queryKey: RecentlyPlayedTracksQueryKey(user, library), queryFn: ({ pageParam, signal }) => - fetchRecentlyPlayed(api, user, library, pageParam, signal), + fetchRecentlyPlayed(api, user, library, pageParam, abortSignal ?? signal), initialPageParam: 0, select: (data: InfiniteData) => data.pages.flatMap((page) => page), getNextPageParam: ( @@ -46,10 +47,10 @@ export const PlayItAgainQuery: ( lastPageParam: number, allPageParams: number[], ) => { - return lastPage.length === ApiLimits.Recents ? lastPageParam + 1 : undefined + return lastPage.length > 0 ? lastPageParam + 1 : undefined }, getPreviousPageParam: ( - firstPage: BaseItemDto[], + prevPage: BaseItemDto[], allPages: BaseItemDto[][], firstPageParam: number, allPageParams: number[], @@ -65,25 +66,23 @@ export const useRecentArtists = () => { const user = getUser() const [library] = useJellifyLibrary() - const { - data: recentlyPlayedTracks, - isPending: recentlyPlayedTracksPending, - isStale: recentlyPlayedTracksStale, - } = useRecentlyPlayedTracks() + const { data: recentlyPlayedTracks, isPending: recentlyPlayedTracksPending } = + useRecentlyPlayedTracks() return useInfiniteQuery({ queryKey: RecentlyPlayedArtistsQueryKey(user, library), queryFn: ({ pageParam, signal }) => fetchRecentlyPlayedArtists(api, user, library, pageParam, signal), - select: (data) => data.pages.flatMap((page) => page), + select: (data) => + uniqBy( + data.pages.flatMap((page) => page), + 'Id', + ), initialPageParam: 0, getNextPageParam: (lastPage, allPages, lastPageParam, allPageParams) => { return lastPage.length > 0 ? lastPageParam + 1 : undefined }, - enabled: - !isUndefined(recentlyPlayedTracks) && - !recentlyPlayedTracksPending && - !recentlyPlayedTracksStale, + enabled: !isUndefined(recentlyPlayedTracks) && !recentlyPlayedTracksPending, ...RECENTS_QUERY_CONFIG, }) } diff --git a/src/api/queries/recents/utils/index.ts b/src/api/queries/recents/utils/index.ts index 4e83077c4..eea6e5a5f 100644 --- a/src/api/queries/recents/utils/index.ts +++ b/src/api/queries/recents/utils/index.ts @@ -18,6 +18,7 @@ import { RECENTLY_PLAYED_ALBUM_THRESHOLD } from '../../../../configs/categorizin import { PlayItAgainQuery } from '..' import { ArtistQueryKey } from '../../artist/keys' import { setQueryUserDataForItem } from '../../user-data' +import { mapTracksToArtists } from '../../../../utils/mapping/track-to-artist' export async function fetchRecentlyAdded( api: Api | undefined, @@ -83,7 +84,7 @@ export async function fetchRecentlyPlayed( limit, parentId: library.musicLibraryId, recursive: true, - sortBy: [ItemSortBy.DatePlayed], + sortBy: [ItemSortBy.DatePlayed, ItemSortBy.SortName], sortOrder: [SortOrder.Descending], fields: [ItemFields.ParentId, ItemFields.Tags], enableUserData: true, @@ -153,81 +154,28 @@ export async function fetchRecentlyPlayed( * @param page The page number of the recently played tracks to fetch artists from. * @returns The recently played artists. */ -export function fetchRecentlyPlayedArtists( +export async function fetchRecentlyPlayedArtists( api: Api | undefined, user: JellifyUser | undefined, library: JellifyLibrary | undefined, page: number, signal?: AbortSignal, ): Promise { - return new Promise((resolve, reject) => { - if (isUndefined(api)) return reject('Client instance not set') - if (isUndefined(user)) return reject('User instance not set') - if (isUndefined(library)) return reject('Library instance not set') - - // Get the recently played tracks from the query client - queryClient - .ensureInfiniteQueryData(PlayItAgainQuery(library)) - .then((recentlyPlayedTracks) => { - if (!recentlyPlayedTracks) { - return resolve([]) - } - - // Get the artists from the recently played tracks - const artists = recentlyPlayedTracks.pages[page] - - // Map artist from the recently played tracks - .map((track) => (track.ArtistItems ? track.ArtistItems[0] : undefined)) - - // Filter out undefined artists - .filter((artist) => artist !== undefined) - - // Filter out duplicate artists - .filter( - (artist, index, artists) => - artists.findIndex( - (duplicateArtist) => duplicateArtist.Id === artist.Id, - ) === index, - ) - - const artistIds = artists.map((artist) => artist.Id!).filter(Boolean) - - if (artistIds.length === 0) { - return resolve([]) - } - - getItemsApi(api) - .getItems( - { - userId: user.id, - includeItemTypes: [BaseItemKind.MusicArtist], - ids: artistIds, - fields: [ItemFields.Genres, ItemFields.SortName, ItemFields.Tags], - enableImages: true, - enableImageTypes: [ImageType.Backdrop, ImageType.Primary], - imageTypeLimit: 1, - enableUserData: true, - }, - { signal }, - ) - .then(({ data }) => { - const fetchedArtists = data.Items ?? [] - - fetchedArtists.forEach((artist) => { - setQueryUserDataForItem(artist) - queryClient.setQueryData(ArtistQueryKey(artist.Id), artist) - }) - - resolve( - fetchedArtists.sort((a, b) => { - const aIndex = artists.findIndex((artist) => artist.Id === a.Id) - const bIndex = artists.findIndex((artist) => artist.Id === b.Id) - return aIndex - bIndex - }), - ) - }) - .catch(reject) - }) - .catch(reject) - }) + if (isUndefined(api)) return Promise.reject('Client instance not set') + if (isUndefined(user)) return Promise.reject('User instance not set') + if (isUndefined(library)) return Promise.reject('Library instance not set') + + try { + const recentTracks = await queryClient.infiniteQuery({ + ...PlayItAgainQuery(library, signal), + initialPageParam: page, + staleTime: 'static', + }) + + console.debug(recentTracks.map((track) => track.Id).join(',')) + + return mapTracksToArtists(recentTracks) + } catch (error) { + return Promise.reject(error) + } } diff --git a/src/api/queries/track/index.ts b/src/api/queries/track/index.ts index a99b6718b..1a240094a 100644 --- a/src/api/queries/track/index.ts +++ b/src/api/queries/track/index.ts @@ -1,6 +1,6 @@ import { InfiniteData, useInfiniteQuery } from '@tanstack/react-query' import { TracksQueryKey } from './keys' -import fetchTracks from './utils' +import fetchTracks, { fetchTracksCount } from './utils' import { BaseItemDto, ItemSortBy, @@ -59,28 +59,66 @@ const useTracks = ( return data.pages.flatMap((page) => page) } - return useInfiniteQuery({ - queryKey: TracksQueryKey( - isFavorites === true, - isDownloaded, - isUnplayed === true, - finalSortOrder === SortOrder.Descending, - library, - downloadedTracks?.length, - undefined, - finalSortBy, - finalSortOrder, - isDownloaded ? undefined : libraryGenreIds, - libraryYearMin, - libraryYearMax, - ), - queryFn: ({ pageParam, signal }) => { - if (!isDownloaded) { - return fetchTracks( + const queryKey = TracksQueryKey( + isFavorites === true, + isDownloaded, + isUnplayed === true, + finalSortOrder === SortOrder.Descending, + library, + downloadedTracks?.length, + undefined, + finalSortBy, + finalSortOrder, + isDownloaded ? undefined : libraryGenreIds, + libraryYearMin, + libraryYearMax, + ) + + /** + * Jumps the tracks list directly to {@link letter}. + * + * Unlike artists/albums, tracks can't be located via a `nameLessThan`-based count: Jellyfin's + * name filters compare against `SortName`, which for tracks is prefixed with disc/track + * numbers rather than matching the `Name` field the list actually sorts/groups by. Instead, + * this binary searches the (already Name-sorted) results for the letter's boundary index - + * O(log n) single-item probes instead of paginating through every page in between. + */ + const jumpToLetter = async (letter: string, letterReverseOrder: boolean): Promise => { + if (isDownloaded || !api || !user || !library) return false + + try { + const target = letter.toUpperCase() + + const letterOf = (item: BaseItemDto): string => { + const raw = (item.Name ?? '').trim().charAt(0).toUpperCase() + return /[A-Z]/.test(raw) ? raw : '#' + } + + // Whether `value` sorts before `target` in the current sort direction + const isBeforeTarget = (value: string) => + letterReverseOrder ? value > target : value < target + + const totalCount = await fetchTracksCount( + api, + user, + library, + isFavorites, + isUnplayed, + undefined, + libraryGenreIds, + libraryYearMin, + libraryYearMax, + ) + + let lo = 0 + let hi = totalCount + while (lo < hi) { + const mid = Math.floor((lo + hi) / 2) + const [probe] = await fetchTracks( api, user, library, - pageParam, + mid, isFavorites, isUnplayed, finalSortBy, @@ -89,49 +127,107 @@ const useTracks = ( libraryGenreIds, libraryYearMin, libraryYearMax, - signal, - ) - } else { - let items = (downloadedTracks ?? []).map((download) => - getTrackDto(download.originalTrack), + undefined, + 1, ) + if (!probe || isBeforeTarget(letterOf(probe))) lo = mid + 1 + else hi = mid + } - console.debug('Downloaded tracks before filtering and sorting:', items) + const items = await fetchTracks( + api, + user, + library, + lo, + isFavorites, + isUnplayed, + finalSortBy, + finalSortOrder, + undefined, + libraryGenreIds, + libraryYearMin, + libraryYearMax, + ) + + queryClient.setQueryData(queryKey, { pages: [items], pageParams: [lo] }) + return true + } catch { + return false + } + } + + return { + ...useInfiniteQuery({ + queryKey, + queryFn: ({ pageParam, signal }) => { + if (!isDownloaded) { + return fetchTracks( + api, + user, + library, + pageParam, + isFavorites, + isUnplayed, + finalSortBy, + finalSortOrder, + undefined, + libraryGenreIds, + libraryYearMin, + libraryYearMax, + signal, + ) + } else { + let items = (downloadedTracks ?? []).map((download) => + getTrackDto(download.originalTrack), + ) + + console.debug('Downloaded tracks before filtering and sorting:', items) - if (libraryYearMin != null || libraryYearMax != null) { - const min = libraryYearMin ?? 0 - const max = libraryYearMax ?? new Date().getFullYear() + if (libraryYearMin != null || libraryYearMax != null) { + const min = libraryYearMin ?? 0 + const max = libraryYearMax ?? new Date().getFullYear() + items = items + .filter((track) => track !== undefined) + .filter((track) => { + const y = + 'ProductionYear' in track + ? (track as BaseItemDto).ProductionYear + : undefined + if (y == null) return false + return y >= min && y <= max + }) + } + const sortByForCompare = + finalSortBy === ItemSortBy.SortName ? ItemSortBy.Name : finalSortBy items = items + .filter((track) => track !== undefined) + .sort((a, b) => + compareDownloadedTracks(a, b, sortByForCompare, finalSortOrder), + ) + return items .filter((track) => track !== undefined) .filter((track) => { - const y = - 'ProductionYear' in track - ? (track as BaseItemDto).ProductionYear - : undefined - if (y == null) return false - return y >= min && y <= max + if (!isFavorites) return true + else return isDownloadedTrackAlsoFavorite(user, track.Id) }) } - const sortByForCompare = - finalSortBy === ItemSortBy.SortName ? ItemSortBy.Name : finalSortBy - items = items - .filter((track) => track !== undefined) - .sort((a, b) => compareDownloadedTracks(a, b, sortByForCompare, finalSortOrder)) - return items - .filter((track) => track !== undefined) - .filter((track) => { - if (!isFavorites) return true - else return isDownloadedTrackAlsoFavorite(user, track.Id) - }) - } - }, - initialPageParam: 0, - getNextPageParam: (lastPage, allPages, lastPageParam, allPageParams) => { - if (isDownloaded) return undefined - else return lastPage.length === ApiLimits.Library ? lastPageParam + 1 : undefined - }, - select: selectTracks, - }) + }, + initialPageParam: 0, + getNextPageParam: (lastPage, allPages, lastPageParam, allPageParams) => { + if (isDownloaded) return undefined + else + return lastPage.length === ApiLimits.Library + ? lastPageParam + ApiLimits.Library + : undefined + }, + getPreviousPageParam: (firstPage, allPages, firstPageParam, allPageParams) => { + if (isDownloaded) return null + return firstPageParam <= 0 ? null : Math.max(0, firstPageParam - ApiLimits.Library) + }, + select: selectTracks, + }), + jumpToLetter, + } } export const useArtistTracks = ( @@ -184,7 +280,9 @@ export const useArtistTracks = ( initialPageParam: 0, getNextPageParam: (lastPage, allPages, lastPageParam, allPageParams) => { if (!lastPage) return undefined - return lastPage.length === ApiLimits.Library ? lastPageParam + 1 : undefined + return lastPage.length === ApiLimits.Library + ? lastPageParam + ApiLimits.Library + : undefined }, select: selectTracks, }) diff --git a/src/api/queries/track/utils/index.ts b/src/api/queries/track/utils/index.ts index ccff5a372..2429a90d8 100644 --- a/src/api/queries/track/utils/index.ts +++ b/src/api/queries/track/utils/index.ts @@ -29,6 +29,7 @@ export default function fetchTracks( yearMin?: number, yearMax?: number, signal?: AbortSignal, + limit: number = ApiLimits.Library, ) { return new Promise((resolve, reject) => { if (isUndefined(api)) return reject('Client instance not set') @@ -58,8 +59,8 @@ export default function fetchTracks( userId: user.id, recursive: true, filters: filters.length > 0 ? filters : undefined, - limit: ApiLimits.Library, - startIndex: pageParam * ApiLimits.Library, + limit, + startIndex: pageParam, sortBy: [finalSortBy], sortOrder: [sortOrder], fields: [ItemFields.SortName], @@ -83,3 +84,62 @@ export default function fetchTracks( }) }) } + +/** + * Fetches the total track count for the given filters. Tracks can't use the same + * `nameLessThan`-based count trick as artists/albums since Jellyfin's name filters compare + * against `SortName`, which for tracks is prefixed with disc/track numbers (see above) rather + * than the `Name` field the tracks list actually sorts/groups by. + */ +export function fetchTracksCount( + api: Api | undefined, + user: JellifyUser | undefined, + library: JellifyLibrary | undefined, + isFavorite: boolean | undefined, + isUnplayed: boolean | undefined, + artistId?: string, + genreIds?: string[], + yearMin?: number, + yearMax?: number, + signal?: AbortSignal, +): Promise { + return new Promise((resolve, reject) => { + if (isUndefined(api)) return reject('Client instance not set') + if (isUndefined(library)) return reject('Library instance not set') + if (isUndefined(user)) return reject('User instance not set') + + const filters: ItemFilter[] = [] + if (isFavorite === true) { + filters.push(ItemFilter.IsFavorite) + } + if (isUnplayed === true) { + filters.push(ItemFilter.IsUnplayed) + } + + const yearsParam = buildYearsParam(yearMin, yearMax) + + getItemsApi(api) + .getItems( + { + includeItemTypes: [BaseItemKind.Audio], + parentId: library.musicLibraryId, + userId: user.id, + recursive: true, + filters: filters.length > 0 ? filters : undefined, + startIndex: 0, + limit: 0, + artistIds: artistId ? [artistId] : undefined, + genreIds: genreIds && genreIds.length > 0 ? genreIds : undefined, + years: yearsParam, + enableTotalRecordCount: true, + }, + { + signal, + }, + ) + .then(({ data }) => resolve(data.TotalRecordCount ?? 0)) + .catch((error) => { + reject(error) + }) + }) +} diff --git a/src/api/types/page-param.ts b/src/api/types/page-param.ts new file mode 100644 index 000000000..8d740533d --- /dev/null +++ b/src/api/types/page-param.ts @@ -0,0 +1,4 @@ +export type InfiniteSectionListPageParam = { + index: number + letter: string +} diff --git a/src/api/utils/page-params.ts b/src/api/utils/page-params.ts new file mode 100644 index 000000000..32bf2e15f --- /dev/null +++ b/src/api/utils/page-params.ts @@ -0,0 +1,75 @@ +import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client' +import { InfiniteSectionListPageParam } from '../types/page-param' +import { alphabetAtoZ } from '../../components/Global/components/AZScroller' +import { ApiLimits } from '../../configs/querying/index.config' + +/** + * The AZScroller letters in the order pages are traversed for a given sort direction + * (Z -> A -> # when descending, matching the order the API actually returns items in). + */ +export function getOrderedLetters(reverseOrder: boolean): string[] { + return reverseOrder ? [...alphabetAtoZ].reverse() : alphabetAtoZ +} + +/** + * Builds the `getNextPageParam`/`getPreviousPageParam` pair for a letter-sectioned infinite + * query (e.g. albums grouped by AZScroller letter). Each page is scoped to a single letter via + * `nameStartsWith`/`nameLessThan`, so paging within a letter just increments/decrements `index`, + * while exhausting a letter advances to the next/previous letter in {@link reverseOrder}. + * + * @param reverseOrder Letter traversal direction; should match the query's sort direction so + * pagination walks letters in the same order the API returns items (Z -> A when descending). + */ +export function createLetterPageParamFns(reverseOrder: boolean) { + const orderedLetters = getOrderedLetters(reverseOrder) + + function getNextPageParam( + lastPage: BaseItemDto[], + allPages: BaseItemDto[][], + lastPageParam: InfiniteSectionListPageParam, + ): InfiniteSectionListPageParam | undefined { + // Last page was filled, there may be more albums under this letter + if (lastPage.length === ApiLimits.Library) { + return { + ...lastPageParam, + index: lastPageParam.index + ApiLimits.Library, + } + } + + // Last page completed the letter, advance to the next one (if any) + const letterIndex = orderedLetters.indexOf(lastPageParam.letter) + if (letterIndex === -1 || letterIndex + 1 >= orderedLetters.length) return undefined + + return { + letter: orderedLetters[letterIndex + 1], + index: 0, + } + } + + function getPreviousPageParam( + firstPage: BaseItemDto[], + allPages: BaseItemDto[][], + firstPageParam: InfiniteSectionListPageParam, + ): InfiniteSectionListPageParam | undefined { + // Not at the start of the letter yet, step back within it + if (firstPageParam.index > 0) { + return { + ...firstPageParam, + index: Math.max(0, firstPageParam.index - ApiLimits.Library), + } + } + + // Already at the first letter, nothing before it + const letterIndex = orderedLetters.indexOf(firstPageParam.letter) + if (letterIndex <= 0) return undefined + + // Step back a letter; a negative index tells fetchAlbums to resolve this to that + // letter's last page via a count-only lookup, instead of paginating forward through it + return { + letter: orderedLetters[letterIndex - 1], + index: -1, + } + } + + return { getNextPageParam, getPreviousPageParam } +} diff --git a/src/components/Album/footer.tsx b/src/components/Album/footer.tsx index 8b975c26d..1de36bb27 100644 --- a/src/components/Album/footer.tsx +++ b/src/components/Album/footer.tsx @@ -1,16 +1,16 @@ import DiscoverStackParamList from '../../screens/Discover/types' import HomeStackParamList from '../../screens/Home/types' -import LibraryStackParamList from '../../screens/Library/types' +import { LibraryParamList } from '../../screens/Library/types' import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client' import { useNavigation } from '@react-navigation/native' import { NativeStackNavigationProp } from '@react-navigation/native-stack' import { YStack, Spinner, Text } from 'tamagui' -import ItemCard from '../Global/components/item-card' +import ItemCard from '../Global/components/Item/item-card' import { useSimilarItems } from '../../api/queries/suggestions' import HorizontalCardList from '../Global/components/horizontal-list' import navigationRef from '../../screens/navigation' import Animated, { Easing, FadeIn, FadeOut } from 'react-native-reanimated' -import ItemRow from '../Global/components/item-row' +import ItemRow from '../Global/components/Item/item-row' import { formatArtistNames } from '../../utils/formatting/artist-names' import { Freeze } from 'react-freeze' import List from '../Global/helpers/list' @@ -25,7 +25,7 @@ export default function AlbumTrackListFooter({ const navigation = useNavigation< NativeStackNavigationProp< - HomeStackParamList | LibraryStackParamList | DiscoverStackParamList + HomeStackParamList | LibraryParamList | DiscoverStackParamList > >() diff --git a/src/components/Albums/component.tsx b/src/components/Albums/component.tsx index f74735ad7..68653536a 100644 --- a/src/components/Albums/component.tsx +++ b/src/components/Albums/component.tsx @@ -2,22 +2,28 @@ import React, { useRef } from 'react' 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 ItemRow from '../Global/components/Item/item-row' import { SectionListRef } from '@legendapp/list/section-list' -import { LibrarySectionListData, LibrarySectionListRenderItemInfo } from '../Global/types' -import ItemSectionList from '../Global/components/item-section-list' -import ItemList from '../Global/components/item-list' +import { + JumpToLetter, + LibrarySectionListData, + LibrarySectionListRenderItemInfo, +} from '../Global/types' +import ItemSectionList from '../Global/components/Item/item-section-list' +import ItemList from '../Global/components/Item/item-list' interface AlbumsProps { albumsInfiniteQuery: UseInfiniteQueryResult<(BaseItemDto | LibrarySectionListData)[], Error> sortBy?: ItemSortBy sortDescending?: boolean + jumpToLetter?: JumpToLetter } export default function Albums({ albumsInfiniteQuery, sortDescending, sortBy, + jumpToLetter, }: AlbumsProps): React.JSX.Element { const albums = albumsInfiniteQuery.data ?? [] @@ -49,6 +55,7 @@ export default function Albums({ renderItem={renderItem} query={albumsInfiniteQuery as UseInfiniteQueryResult} sortDescending={sortDescending} + jumpToLetter={jumpToLetter} /> ) : ( } /> diff --git a/src/components/Artist/OverviewTab.tsx b/src/components/Artist/OverviewTab.tsx index 7ab4a3495..dd6029010 100644 --- a/src/components/Artist/OverviewTab.tsx +++ b/src/components/Artist/OverviewTab.tsx @@ -9,7 +9,7 @@ import { } from 'react-native' import { SectionList } from '@legendapp/list/section-list' import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client' -import ItemRow from '../Global/components/item-row' +import ItemRow from '../Global/components/Item/item-row' import ArtistHeader from './header' import { Text } from '../Global/helpers/text' import SimilarArtists from './similar' diff --git a/src/components/Artist/similar.tsx b/src/components/Artist/similar.tsx index 1029d4311..fdcc24b7b 100644 --- a/src/components/Artist/similar.tsx +++ b/src/components/Artist/similar.tsx @@ -5,7 +5,7 @@ import { Text } from '../Global/helpers/text' import { useArtistContext } from '../../providers/Artist' import { ActivityIndicator } from 'react-native' import { YStack } from 'tamagui' -import ItemRow from '../Global/components/item-row' +import ItemRow from '../Global/components/Item/item-row' import React from 'react' import { Freeze } from 'react-freeze' import List from '../Global/helpers/list' diff --git a/src/components/Artists/component.tsx b/src/components/Artists/component.tsx index 59fa11d7b..df89b4ec1 100644 --- a/src/components/Artists/component.tsx +++ b/src/components/Artists/component.tsx @@ -1,13 +1,13 @@ -import React, { useRef } from 'react' -import ItemRow from '../Global/components/item-row' +import React from 'react' import { UseInfiniteQueryResult } from '@tanstack/react-query' -import { SectionListRef } from '@legendapp/list/section-list' -import { LibrarySectionListData, LibrarySectionListRenderItemInfo } from '../Global/types' -import ItemSectionList from '../Global/components/item-section-list' +import { JumpToLetter } from '../Global/types' +import ItemList from '../Global/components/Item/item-list' +import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client' export interface ArtistsProps { - artistsInfiniteQuery: UseInfiniteQueryResult + artistsInfiniteQuery: UseInfiniteQueryResult sortDescending?: boolean + jumpToLetter?: JumpToLetter } /** @@ -20,11 +20,10 @@ export interface ArtistsProps { export default function Artists({ artistsInfiniteQuery, sortDescending, + jumpToLetter, }: ArtistsProps): React.JSX.Element { const artists = artistsInfiniteQuery.data ?? [] - const sectionListRef = useRef(null) - // Precompute a stable list-index → object-index map so renderItem can build // `artist-item-N` testIDs in O(1) instead of slicing/filtering the full list // on every row render. React Compiler memoizes this on `artists` identity. @@ -38,16 +37,5 @@ export default function Artists({ } } - const renderItem = ({ index, item: artist }: LibrarySectionListRenderItemInfo) => ( - - ) - - return ( - - ) + return } diff --git a/src/components/Context/components/multiple-artists.tsx b/src/components/Context/components/multiple-artists.tsx index cfc77c414..8746be84b 100644 --- a/src/components/Context/components/multiple-artists.tsx +++ b/src/components/Context/components/multiple-artists.tsx @@ -1,5 +1,5 @@ import { NativeStackNavigationProp } from '@react-navigation/native-stack' -import ItemRow from '../../Global/components/item-row' +import ItemRow from '../../Global/components/Item/item-row' import { PlayerParamList } from '../../../screens/Player/types' import { RouteProp, StackActions, useNavigation } from '@react-navigation/native' import { RootStackParamList } from '../../../screens/types' diff --git a/src/components/Discover/helpers/just-added.tsx b/src/components/Discover/helpers/just-added.tsx index 491c35512..1e7e21b06 100644 --- a/src/components/Discover/helpers/just-added.tsx +++ b/src/components/Discover/helpers/just-added.tsx @@ -1,6 +1,6 @@ import { NativeStackNavigationProp } from '@react-navigation/native-stack' import HorizontalCardList from '../../../components/Global/components/horizontal-list' -import ItemCard from '../../../components/Global/components/item-card' +import ItemCard from '../../Global/components/Item/item-card' import { H5, XStack } from 'tamagui' import Icon from '../../Global/components/icon' import { useNavigation } from '@react-navigation/native' diff --git a/src/components/Discover/helpers/public-playlists.tsx b/src/components/Discover/helpers/public-playlists.tsx index 6c13b8689..90ff7ca34 100644 --- a/src/components/Discover/helpers/public-playlists.tsx +++ b/src/components/Discover/helpers/public-playlists.tsx @@ -2,7 +2,7 @@ import { H5, XStack } from 'tamagui' import { NativeStackNavigationProp } from '@react-navigation/native-stack' import Icon from '../../Global/components/icon' import HorizontalCardList from '../../Global/components/horizontal-list' -import ItemCard from '../../Global/components/item-card' +import ItemCard from '../../Global/components/Item/item-card' import { useSafeAreaFrame } from 'react-native-safe-area-context' import { useNavigation } from '@react-navigation/native' import DiscoverStackParamList from '../../../screens/Discover/types' @@ -13,14 +13,7 @@ import AnimatedRow from '../../Global/helpers/animated-row' import { useDisplayContext } from '../../../providers/Display/display-provider' export default function PublicPlaylists(): React.JSX.Element { - const { - data: playlists, - fetchNextPage, - hasNextPage, - isPending, - isFetchingNextPage, - refetch, - } = usePublicPlaylists() + const { data: playlists } = usePublicPlaylists() const navigation = useNavigation>() @@ -36,14 +29,7 @@ export default function PublicPlaylists(): React.JSX.Element { { - navigation.navigate('PublicPlaylists', { - playlists, - fetchNextPage, - hasNextPage, - isPending, - isFetchingNextPage, - refetch, - }) + navigation.navigate('PublicPlaylists') }} >
diff --git a/src/components/Discover/helpers/suggested-albums.tsx b/src/components/Discover/helpers/suggested-albums.tsx index 5e661a9dd..0768d9f43 100644 --- a/src/components/Discover/helpers/suggested-albums.tsx +++ b/src/components/Discover/helpers/suggested-albums.tsx @@ -1,6 +1,6 @@ import navigationRef from '../../../screens/navigation' import { formatArtistNames } from '../../../utils/formatting/artist-names' -import ItemCard from '../../Global/components/item-card' +import ItemCard from '../../Global/components/Item/item-card' import HorizontalCardList from '../../Global/components/horizontal-list' import { XStack } from 'tamagui' import Icon from '../../Global/components/icon' diff --git a/src/components/Discover/helpers/suggested-artists.tsx b/src/components/Discover/helpers/suggested-artists.tsx index 60c47becf..ba1a1e3e5 100644 --- a/src/components/Discover/helpers/suggested-artists.tsx +++ b/src/components/Discover/helpers/suggested-artists.tsx @@ -1,7 +1,7 @@ import { H5, XStack } from 'tamagui' import Icon from '../../Global/components/icon' import HorizontalCardList from '../../Global/components/horizontal-list' -import ItemCard from '../../Global/components/item-card' +import ItemCard from '../../Global/components/Item/item-card' import { NativeStackNavigationProp } from '@react-navigation/native-stack' import { useNavigation } from '@react-navigation/native' import DiscoverStackParamList from '../../../screens/Discover/types' diff --git a/src/components/Filters/index.tsx b/src/components/Filters/index.tsx index 5e94e5395..eb3689c4e 100644 --- a/src/components/Filters/index.tsx +++ b/src/components/Filters/index.tsx @@ -7,14 +7,14 @@ import { FiltersProps } from './types' import Icon from '../Global/components/icon' import { NativeStackNavigationProp } from '@react-navigation/native-stack' import { useSafeAreaInsets } from 'react-native-safe-area-context' -import LibraryStackParamList from '@/src/screens/Library/types' +import { LibraryParamList } from '@/src/screens/Library/types' import { useNavigation } from '@react-navigation/native' import { applyHapticFeedback } from '../../utils/haptics' export default function Filters({ currentTab }: FiltersProps): React.JSX.Element { const { bottom } = useSafeAreaInsets() - const libraryStackNavigation = useNavigation>() + const libraryStackNavigation = useNavigation>() const { filters, setTracksFilters, setAlbumsFilters, setArtistsFilters } = useLibraryStore() if (!currentTab || currentTab === 'Playlists') { diff --git a/src/components/Global/components/AZScroller/index.tsx b/src/components/Global/components/AZScroller/index.tsx index d3d22093c..8a67ce810 100644 --- a/src/components/Global/components/AZScroller/index.tsx +++ b/src/components/Global/components/AZScroller/index.tsx @@ -5,12 +5,12 @@ import { Gesture, GestureDetector } from 'react-native-gesture-handler' import Animated, { useSharedValue, useAnimatedStyle, withSpring } from 'react-native-reanimated' import { scheduleOnRN } from 'react-native-worklets' import { applyHapticFeedback } from '../../../../utils/haptics' -import { LibrarySectionListData } from '../../types' +import { JumpToLetter, LibrarySectionListData } from '../../types' import { SectionListRef } from '@legendapp/list/section-list' import onLetterPaginateQuery from './utils' import { UseInfiniteQueryResult } from '@tanstack/react-query' -const alphabetAtoZ = '#ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('') +export const alphabetAtoZ = '#ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('') const alphabetZtoA = '#ZYXWVUTSRQPONMLKJIHGFEDCBA'.split('') interface AZScrollerProps { @@ -18,6 +18,8 @@ interface AZScrollerProps { query: UseInfiniteQueryResult alphabet?: string[] reverseOrder?: boolean + /** When provided, jumps directly to the letter's page instead of paginating incrementally */ + jumpToLetter?: JumpToLetter } /** @@ -36,6 +38,7 @@ export default function AZScroller({ query, alphabet: customAlphabet, reverseOrder, + jumpToLetter, }: AZScrollerProps) { const alphabetToUse = customAlphabet ?? (reverseOrder ? alphabetZtoA : alphabetAtoZ) const theme = useTheme() @@ -87,39 +90,35 @@ export default function AZScroller({ } const onLetterSelect = async (letter: string) => { - await onLetterPaginateQuery(letter, query) + await onLetterPaginateQuery(letter, query, reverseOrder) } + /** + * Scrolls to the section for {@link selectedLetter}, or the closest loaded section in the + * current sort direction if there are no items for that exact letter. + * + * Uses the section list's actual (already sorted A-Z or Z-A) order to find the index, rather + * than re-sorting titles, since re-sorting ascending would point at the wrong section when + * the list is sorted Z-A. + */ const scrollToLetter = (selectedLetter: string) => { if (query.data) { - const upperLetters = query.data - .map((section) => section.title) - .map((letter) => letter.toUpperCase()) - .sort() + const upperLetters = query.data.map((section) => section.title.toUpperCase()) + + const index = upperLetters.findIndex((letter) => + reverseOrder ? letter <= selectedLetter : letter >= selectedLetter, + ) - const index = upperLetters.findIndex((letter) => letter >= selectedLetter) + const sectionIndex = index !== -1 ? index : upperLetters.length - 1 - if (index !== -1) { + if (sectionIndex !== -1) { sectionListRef.current?.scrollToLocation({ - sectionIndex: index, + sectionIndex, itemIndex: 0, viewPosition: 0.1, animated: true, }) } - - // else { - // // fallback: scroll to last section - // const lastLetter = upperLetters[upperLetters.length - 1] - // const scrollIndex = artists.indexOf(lastLetter) - // if (scrollIndex !== -1) { - // sectionListRef.current?.scrollToIndex({ - // index: scrollIndex, - // viewPosition: 0.1, - // animated: true, - // }) - // } - // } } } @@ -137,13 +136,31 @@ export default function AZScroller({ const handleGestureEnd = () => { if (selectedLetter.value) { + const letter = selectedLetter.value scheduleOnRN(async () => { setOperationPending(true) - onLetterSelect(selectedLetter.value.toLowerCase()).then(() => { + + const jumped = jumpToLetter + ? await jumpToLetter(letter.toLowerCase(), !!reverseOrder) + : false + + if (jumped) { scheduleOnRN(hideOverlay) setOperationPending(false) - scrollToLetter(selectedLetter.value) - }) + // The seeded page always starts at (or just after) the selected letter + sectionListRef.current?.scrollToLocation({ + sectionIndex: 0, + itemIndex: 0, + viewPosition: 0.1, + animated: true, + }) + } else { + onLetterSelect(letter.toLowerCase()).then(() => { + scheduleOnRN(hideOverlay) + setOperationPending(false) + scrollToLetter(letter) + }) + } }) } else { scheduleOnRN(hideOverlay) diff --git a/src/components/Global/components/AZScroller/utils.ts b/src/components/Global/components/AZScroller/utils.ts index aca26894e..94ca0245f 100644 --- a/src/components/Global/components/AZScroller/utils.ts +++ b/src/components/Global/components/AZScroller/utils.ts @@ -1,17 +1,61 @@ import { UseInfiniteQueryResult } from '@tanstack/react-query' import { LibrarySectionListData } from '../../types' +/** + * Paginates an infinite query so that the section for {@link selectedLetter} becomes available, + * fetching in whichever direction (next or previous page) is needed to reach it. + * + * This allows jumping to a letter that comes "before" data already paginated past (e.g. sections + * evicted from the sliding `maxPages` window), not just letters further ahead, so the user can + * keep paginating up or down the alphabet from wherever they last landed. + * + * @param selectedLetter The letter selected on the AZScroller + * @param query The infinite query backing the section list + * @param reverseOrder Whether the underlying data is sorted Z-A instead of A-Z + */ export default async function onLetterPaginateQuery( selectedLetter: string, query: UseInfiniteQueryResult, + reverseOrder = false, ) { - do { - await query.fetchNextPage() - } while ( - !query.isFetchNextPageError && + const target = selectedLetter.toUpperCase() + + const getTitles = () => (query.data ?? []).map((section) => section.title.toUpperCase()) + + /** + * True once the target letter has a section loaded, or falls within the range of letters + * already loaded (in which case there are simply no items for that exact letter) + */ + const isLoaded = () => { + const titles = getTitles() + if (titles.length === 0) return false + if (titles.includes(target)) return true + + const first = titles[0] + const last = titles[titles.length - 1] + return reverseOrder ? target <= first && target >= last : target >= first && target <= last + } + + if (isLoaded()) return + + const titles = getTitles() + const last = titles[titles.length - 1] + + // Whether the target letter is further along in the fetch direction (needs fetchNextPage) + // or behind what's currently loaded (needs fetchPreviousPage), e.g. because earlier pages + // were evicted by the query's sliding maxPages window + const goForward = titles.length === 0 || (reverseOrder ? target < last : target > last) + + const fetchMore = goForward ? () => query.fetchNextPage() : () => query.fetchPreviousPage() + const canFetchMore = () => (goForward ? query.hasNextPage : query.hasPreviousPage) + + while ( + !isLoaded() && + canFetchMore() && !query.isError && - query.hasNextPage && - query.data?.filter((section) => section.title.localeCompare(selectedLetter) === 0) - .length === 0 - ) + !query.isFetchNextPageError && + !query.isFetchPreviousPageError + ) { + await fetchMore() + } } diff --git a/src/components/Global/components/item-card.tsx b/src/components/Global/components/Item/item-card.tsx similarity index 92% rename from src/components/Global/components/item-card.tsx rename to src/components/Global/components/Item/item-card.tsx index 40a0b1ccb..f11d4e5fc 100644 --- a/src/components/Global/components/item-card.tsx +++ b/src/components/Global/components/Item/item-card.tsx @@ -2,14 +2,14 @@ import React from 'react' import { Spacer, Square, CardProps as TamaguiCardProps, useTheme, View } from 'tamagui' import { Card as TamaguiCard, YStack } from 'tamagui' import { BaseItemDto, BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models' -import { Text } from '../helpers/text' -import ItemImage from './image' -import useItemContext from '../../../hooks/use-item-context' -import { usePerformanceMonitor } from '../../../hooks/use-performance-monitor' -import { getBlurhashFromDto } from '../../../utils/parsing/blurhash' +import { Text } from '../../helpers/text' +import ItemImage from '../image' +import useItemContext from '../../../../hooks/use-item-context' +import { usePerformanceMonitor } from '../../../../hooks/use-performance-monitor' +import { getBlurhashFromDto } from '../../../../utils/parsing/blurhash' import MaterialDesignIcons from '@react-native-vector-icons/material-design-icons' import { StyleSheet } from 'react-native' -import { useAppSettingsStore } from '../../../stores/settings/app' +import { useAppSettingsStore } from '../../../../stores/settings/app' const footerTypesNeedingIndicating: BaseItemKind[] = [ BaseItemKind.MusicAlbum, diff --git a/src/components/Global/components/item-list.tsx b/src/components/Global/components/Item/item-list.tsx similarity index 77% rename from src/components/Global/components/item-list.tsx rename to src/components/Global/components/Item/item-list.tsx index 3838b5a86..ba59fe460 100644 --- a/src/components/Global/components/item-list.tsx +++ b/src/components/Global/components/Item/item-list.tsx @@ -1,21 +1,21 @@ import { UseInfiniteQueryResult } from '@tanstack/react-query' -import List from '../helpers/list' +import List from '../../helpers/list' import { BaseItemDto, BaseItemKind } from '@jellyfin/sdk/lib/generated-client' import { RefreshControl } from 'react-native' -import { LegendListRenderItemProps } from '@legendapp/list/react-native' +import { LegendListProps, LegendListRenderItemProps } from '@legendapp/list/react-native' import ItemRow from './item-row' import { Queue } from '@/src/services/types/queue-item' -import Track from './Track' +import Track from '../Track' import { useNavigation } from '@react-navigation/native' import { NativeStackNavigationProp } from '@react-navigation/native-stack' import { BaseStackParamList } from '@/src/screens/types' -interface ItemListProps { +type ItemListProps = Pick, 'ListHeaderComponent'> & { query: UseInfiniteQueryResult queue?: Queue } -export default function ItemList({ query, queue }: ItemListProps): React.JSX.Element { +export default function ItemList({ query, queue, ...props }: ItemListProps): React.JSX.Element { const tracks = query.data?.filter(({ Type }) => Type === BaseItemKind.Audio) ?? [] const trackIds = new Map(tracks.map((track, index) => [track.Id!, index])) @@ -43,6 +43,7 @@ export default function ItemList({ query, queue }: ItemListProps): React.JSX.Ele ) } + const onStartReached = () => query.hasPreviousPage && query.fetchPreviousPage() const onEndReached = () => query.hasNextPage && query.fetchNextPage() return ( @@ -52,7 +53,9 @@ export default function ItemList({ query, queue }: ItemListProps): React.JSX.Ele } renderItem={renderItem} + onStartReached={onStartReached} onEndReached={onEndReached} + {...props} /> ) } diff --git a/src/components/Global/components/item-row.tsx b/src/components/Global/components/Item/item-row.tsx similarity index 89% rename from src/components/Global/components/item-row.tsx rename to src/components/Global/components/Item/item-row.tsx index 8c05c6da6..6734777db 100644 --- a/src/components/Global/components/item-row.tsx +++ b/src/components/Global/components/Item/item-row.tsx @@ -1,15 +1,15 @@ import { BaseItemDto, BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models' import { XStack, YStack, getToken } from 'tamagui' -import { Text } from '../helpers/text' -import Icon from './icon' -import { QueuingType } from '../../../enums/queuing-type' -import { RunTimeTicks } from '../helpers/time-codes' -import ItemImage from './image' -import FavoriteIcon from './favorite-icon' -import navigationRef from '../../../screens/navigation' +import { Text } from '../../helpers/text' +import Icon from '../icon' +import { QueuingType } from '../../../../enums/queuing-type' +import { RunTimeTicks } from '../../helpers/time-codes' +import ItemImage from '../image' +import FavoriteIcon from '../favorite-icon' +import navigationRef from '../../../../screens/navigation' import { NativeStackNavigationProp } from '@react-navigation/native-stack' -import { BaseStackParamList } from '../../../screens/types' -import useItemContext from '../../../hooks/use-item-context' +import { BaseStackParamList } from '../../../../screens/types' +import useItemContext from '../../../../hooks/use-item-context' import { RouteProp, useNavigation, useRoute } from '@react-navigation/native' import React from 'react' import { LayoutChangeEvent } from 'react-native' @@ -20,16 +20,16 @@ import Animated, { withSpring, withTiming, } from 'react-native-reanimated' -import { useSwipeableRowContext } from './SwipeableRow/context' -import SwipeableRow from './SwipeableRow' -import { useSwipeSettingsStore } from '../../../stores/settings/swipe' -import { buildSwipeConfig } from '../helpers/swipe-actions' -import { useIsFavorite } from '../../../api/queries/user-data' -import { useAddFavorite, useRemoveFavorite } from '../../../api/mutations/favorite' -import { useHideRunTimesSetting } from '../../../stores/settings/app' -import { Queue } from '../../../services/types/queue-item' -import { formatArtistName } from '../../../utils/formatting/artist-names' -import { addToQueue, loadNewQueue } from '../../../player/queuing' +import { useSwipeableRowContext } from '../SwipeableRow/context' +import SwipeableRow from '../SwipeableRow' +import { useSwipeSettingsStore } from '../../../../stores/settings/swipe' +import { buildSwipeConfig } from '../../helpers/swipe-actions' +import { useIsFavorite } from '../../../../api/queries/user-data' +import { useAddFavorite, useRemoveFavorite } from '../../../../api/mutations/favorite' +import { useHideRunTimesSetting } from '../../../../stores/settings/app' +import { Queue } from '../../../../services/types/queue-item' +import { formatArtistName } from '../../../../utils/formatting/artist-names' +import { addToQueue, loadNewQueue } from '../../../../player/queuing' interface ItemRowProps { item: BaseItemDto diff --git a/src/components/Global/components/item-section-list.tsx b/src/components/Global/components/Item/item-section-list.tsx similarity index 59% rename from src/components/Global/components/item-section-list.tsx rename to src/components/Global/components/Item/item-section-list.tsx index a0a9a8598..600b418d3 100644 --- a/src/components/Global/components/item-section-list.tsx +++ b/src/components/Global/components/Item/item-section-list.tsx @@ -1,18 +1,20 @@ import { SectionList, SectionListProps, SectionListRef } from '@legendapp/list/section-list' import { UseInfiniteQueryResult } from '@tanstack/react-query' import { JSX, RefObject } from 'react' -import { LibrarySectionListData, LibrarySectionListRenderItemInfo } from '../types' +import { JumpToLetter, LibrarySectionListData, LibrarySectionListRenderItemInfo } from '../../types' import { Paragraph, useTheme, XStack, YStack } from 'tamagui' import { RefreshControl } from 'react-native' -import { closeAllSwipeableRows } from './SwipeableRow/registery' -import AZScroller from './AZScroller' -import ListStickyHeader from '../helpers/list-sticky-header' +import { closeAllSwipeableRows } from '../SwipeableRow/registery' +import AZScroller from '../AZScroller' +import ListStickyHeader from '../../helpers/list-sticky-header' +import { ItemKeyExtractor } from '../../../../utils/parsing/key-extractor' interface ItemSectionListProps { ref: RefObject query: UseInfiniteQueryResult renderItem: (info: LibrarySectionListRenderItemInfo) => JSX.Element sortDescending: boolean | undefined + jumpToLetter?: JumpToLetter } export default function ItemSectionList({ @@ -20,6 +22,7 @@ export default function ItemSectionList({ query, renderItem, sortDescending, + jumpToLetter, }: ItemSectionListProps) { const theme = useTheme() @@ -33,9 +36,16 @@ export default function ItemSectionList({ )} stickySectionHeadersEnabled renderItem={renderItem} + keyExtractor={ItemKeyExtractor} refreshControl={ @@ -54,9 +64,18 @@ export default function ItemSectionList({ } + // Keeps the viewport anchored to the same visible row when earlier pages are + // prepended (fetchPreviousPage), instead of jumping as content is added above it + maintainVisibleContentPosition={{ data: true }} + recycleItems /> - + ) } diff --git a/src/components/Global/components/nav-row-card.tsx b/src/components/Global/components/nav-row-card.tsx new file mode 100644 index 000000000..85f085188 --- /dev/null +++ b/src/components/Global/components/nav-row-card.tsx @@ -0,0 +1,67 @@ +import { ICON_PRESS_STYLES } from '../../../configs/styling/elements' +import { LibraryParamList } from '@/src/screens/Library/types' +import { SettingsStackParamList } from '@/src/screens/Settings/types' +import { MaterialDesignIconsIconName } from '@react-native-vector-icons/material-design-icons' +import { Card, SizableText, ThemeTokens, XStack } from 'tamagui' +import Icon from './icon' + +export type NavRowCardProps = Omit< + RowCardProps, + 'onPress' +> & { + route: keyof T +} + +interface RowCardProps { + title: string + icon: MaterialDesignIconsIconName + onPress?: () => void + iconColor?: ThemeTokens + description?: string + testID?: string +} + +export default function NavRowCard({ + title, + icon, + onPress, + iconColor = '$borderColor', + description, + testID, +}: RowCardProps): React.JSX.Element { + return ( + + + + + + + {title} + + {description && ( + + {description} + + )} + + + + + + ) +} diff --git a/src/components/Global/helpers/swipe-actions.ts b/src/components/Global/helpers/swipe-actions.ts index 949da562a..4656a0e13 100644 --- a/src/components/Global/helpers/swipe-actions.ts +++ b/src/components/Global/helpers/swipe-actions.ts @@ -20,8 +20,8 @@ function toSwipeAction(type: SwipeActionType, handlers: SwipeHandlers): SwipeAct return { label: 'Add to queue', // Use a distinct icon from Add to Playlist to avoid confusion - icon: 'playlist-play', - color: '$success', + icon: 'playlist-music', + color: '$secondary', onTrigger: handlers.addToQueue, } case 'ToggleFavorite': diff --git a/src/components/Global/types/index.ts b/src/components/Global/types/index.ts index 557f6c75f..1bffd6afa 100644 --- a/src/components/Global/types/index.ts +++ b/src/components/Global/types/index.ts @@ -12,3 +12,12 @@ export type LibrarySectionListRenderItemInfo = SectionListRenderItemInfo< > export type LibrarySectionListData = SectionListData + +/** + * Jumps a library list directly to `letter`, seeding the query cache with the page that starts + * there instead of paginating through every page in between. + * + * Resolves `true` if the cache was seeded and the AZScroller can scroll straight to the top of + * the results, or `false` to fall back to incremental pagination. + */ +export type JumpToLetter = (letter: string, reverseOrder: boolean) => Promise diff --git a/src/components/Home/helpers/frequent-artists.tsx b/src/components/Home/helpers/frequent-artists.tsx index af3f55e43..4ca65591d 100644 --- a/src/components/Home/helpers/frequent-artists.tsx +++ b/src/components/Home/helpers/frequent-artists.tsx @@ -1,7 +1,7 @@ import HorizontalCardList from '../../../components/Global/components/horizontal-list' import { NativeStackNavigationProp } from '@react-navigation/native-stack' import React from 'react' -import ItemCard from '../../../components/Global/components/item-card' +import ItemCard from '../../Global/components/Item/item-card' import { H5, XStack } from 'tamagui' import Icon from '../../Global/components/icon' import { useDisplayContext } from '../../../providers/Display/display-provider' diff --git a/src/components/Home/helpers/frequent-tracks.tsx b/src/components/Home/helpers/frequent-tracks.tsx index 742f59f26..2016b4796 100644 --- a/src/components/Home/helpers/frequent-tracks.tsx +++ b/src/components/Home/helpers/frequent-tracks.tsx @@ -1,7 +1,7 @@ import { NativeStackNavigationProp } from '@react-navigation/native-stack' import { H5, XStack } from 'tamagui' import HorizontalCardList from '../../../components/Global/components/horizontal-list' -import ItemCard from '../../../components/Global/components/item-card' +import ItemCard from '../../Global/components/Item/item-card' import Icon from '../../Global/components/icon' import { useDisplayContext } from '../../../providers/Display/display-provider' import HomeStackParamList from '../../../screens/Home/types' diff --git a/src/components/Home/helpers/recent-artists.tsx b/src/components/Home/helpers/recent-artists.tsx index 63b108dc6..76e79b1ae 100644 --- a/src/components/Home/helpers/recent-artists.tsx +++ b/src/components/Home/helpers/recent-artists.tsx @@ -1,7 +1,7 @@ import React from 'react' import { H5, XStack } from 'tamagui' import { RootStackParamList } from '../../../screens/types' -import ItemCard from '../../Global/components/item-card' +import ItemCard from '../../Global/components/Item/item-card' import { NativeStackNavigationProp } from '@react-navigation/native-stack' import HorizontalCardList from '../../../components/Global/components/horizontal-list' import Icon from '../../Global/components/icon' diff --git a/src/components/Home/helpers/recently-played.tsx b/src/components/Home/helpers/recently-played.tsx index 248ed0d44..f60e99432 100644 --- a/src/components/Home/helpers/recently-played.tsx +++ b/src/components/Home/helpers/recently-played.tsx @@ -1,6 +1,6 @@ import React from 'react' import { H5, XStack } from 'tamagui' -import ItemCard from '../../Global/components/item-card' +import ItemCard from '../../Global/components/Item/item-card' import { RootStackParamList } from '../../../screens/types' import { NativeStackNavigationProp } from '@react-navigation/native-stack' import HorizontalCardList from '../../../components/Global/components/horizontal-list' diff --git a/src/components/Library/component.tsx b/src/components/Library/component.tsx index 359463247..644635af8 100644 --- a/src/components/Library/component.tsx +++ b/src/components/Library/component.tsx @@ -1,17 +1,14 @@ import { createMaterialTopTabNavigator } from '@react-navigation/material-top-tabs' -import PlaylistsTab from './components/playlists-tab' -import TracksTab from './components/tracks-tab' import ArtistsTab from './components/artists-tab' import AlbumsTab from './components/albums-tab' -import LibraryTabBar from './tab-bar' -import React from 'react' +import TracksTab from './components/tracks-tab' +import PlaylistsTab from './components/playlists-tab' -const LibraryTabs = createMaterialTopTabNavigator({ - tabBar: (props) => , +export const LibraryTabs = createMaterialTopTabNavigator({ screenOptions: ({ theme }) => ({ swipeEnabled: false, // Disable tab swiped to prevent conflicts with SwipeableRow gestures tabBarIndicatorStyle: { - borderBottomWidth: 4, + borderBottomWidth: 3, borderBottomColor: theme.colors.primary, }, tabBarActiveTintColor: theme.colors.primary, @@ -20,7 +17,7 @@ const LibraryTabs = createMaterialTopTabNavigator({ backgroundColor: theme.colors.background, }, tabBarLabelStyle: { - fontSize: 16, + fontSize: 14, fontFamily: 'Figtree-Bold', }, tabBarPressOpacity: 0.5, @@ -53,5 +50,3 @@ const LibraryTabs = createMaterialTopTabNavigator({ }, }, }) - -export default LibraryTabs diff --git a/src/components/Library/components/albums-tab.tsx b/src/components/Library/components/albums-tab.tsx index fc7234c30..5fa9babc6 100644 --- a/src/components/Library/components/albums-tab.tsx +++ b/src/components/Library/components/albums-tab.tsx @@ -4,7 +4,7 @@ import useLibraryStore from '../../../stores/library' import { ItemSortBy } from '@jellyfin/sdk/lib/generated-client/models/item-sort-by' function AlbumsTab(): React.JSX.Element { - const albumsInfiniteQuery = useAlbums() + const { jumpToLetter, ...albumsInfiniteQuery } = useAlbums() const sortBy = useLibraryStore((state) => { const sb = state.sortBy as Record | string @@ -22,6 +22,7 @@ function AlbumsTab(): React.JSX.Element { albumsInfiniteQuery={albumsInfiniteQuery} sortBy={sortBy as ItemSortBy} sortDescending={sortDescending} + jumpToLetter={jumpToLetter} /> ) } diff --git a/src/components/Library/components/artists-tab.tsx b/src/components/Library/components/artists-tab.tsx index a66a2f7b7..4f56c18ad 100644 --- a/src/components/Library/components/artists-tab.tsx +++ b/src/components/Library/components/artists-tab.tsx @@ -1,17 +1,84 @@ +import { SizableText, XStack } from 'tamagui' import { useAlbumArtists } from '../../../api/queries/artist' -import Artists from '../../Artists/component' -import useLibraryStore from '../../../stores/library' +import ItemList from '../../Global/components/Item/item-list' +import { useLibraryArtistsStore } from '../../../stores/library/artist' +import Icon from '../../Global/components/icon' +import { MaterialDesignIconsIconName } from '@react-native-vector-icons/material-design-icons' +import { useNavigation } from '@react-navigation/native' +import { NativeStackNavigationProp } from '@react-navigation/native-stack' +import { LibraryParamList } from '@/src/screens/Library/types' +import { BaseItemKind, SortOrder } from '@jellyfin/sdk/lib/generated-client' +import { SORTBY_TEXT } from '../../../configs/messaging/sort-by' -function ArtistsTab(): React.JSX.Element { - const artistsInfiniteQuery = useAlbumArtists() +export default function ArtistsTab(): React.JSX.Element { + const navigation = useNavigation>() - const sortDescending = useLibraryStore((state) => { - const sd = state.sortDescending as Record | boolean - if (typeof sd === 'boolean') return sd - return sd?.artists ?? false - }) + const { isFavorites, setIsFavorites, sortBy, sortOrder, setSortOrder } = + useLibraryArtistsStore() - return -} + const artistsInfiniteQuery = useAlbumArtists(isFavorites, sortBy, sortOrder) + + const iconName: MaterialDesignIconsIconName = isFavorites ? 'heart' : 'heart-outline' + const iconLabel = isFavorites ? 'Favorites' : 'All' + const iconLabelWeight = isFavorites ? '$6' : '$4' + + const sortByLabel = SORTBY_TEXT[sortBy] + + const sortOrderIconName: MaterialDesignIconsIconName = + sortOrder === SortOrder.Ascending ? 'sort-ascending' : 'sort-descending' + const sortOrderIconLabel = sortOrder === SortOrder.Ascending ? 'Asc' : 'Desc' + + const onFavoriteIconPress = () => { + setIsFavorites(isFavorites ? undefined : !isFavorites) + } + + const onSortByIconPress = () => { + navigation.navigate('ItemSortBy', { type: BaseItemKind.MusicArtist }) + } -export default ArtistsTab + return ( + + {/* Favorites Toggle */} + + + + + {iconLabel} + + + + {/* Sort By */} + + + + + {sortByLabel} + + + + {/* Sort Order */} + {}}> + + + + {sortOrderIconLabel} + + + + } + /> + ) +} diff --git a/src/components/Library/components/playlists-tab.tsx b/src/components/Library/components/playlists-tab.tsx index ad061d36c..b18322835 100644 --- a/src/components/Library/components/playlists-tab.tsx +++ b/src/components/Library/components/playlists-tab.tsx @@ -3,17 +3,23 @@ import Playlists from '../../Playlists/component' import React from 'react' function PlaylistsTab(): React.JSX.Element { - const playlistsInfiniteQuery = useUserPlaylists() + const { + data: playlists, + refetch, + fetchNextPage, + hasNextPage, + isPending, + isFetchingNextPage, + } = useUserPlaylists() return ( ) } diff --git a/src/components/Library/tab-bar.tsx b/src/components/Library/components/tab-bar.tsx similarity index 92% rename from src/components/Library/tab-bar.tsx rename to src/components/Library/components/tab-bar.tsx index 9ed6b895f..bec927725 100644 --- a/src/components/Library/tab-bar.tsx +++ b/src/components/Library/components/tab-bar.tsx @@ -1,19 +1,19 @@ import { MaterialTopTabBar, MaterialTopTabBarProps } from '@react-navigation/material-top-tabs' import React from 'react' import { XStack, YStack, Paragraph } from 'tamagui' -import Icon from '../Global/components/icon' +import Icon from '../../Global/components/icon' import { useSafeAreaInsets } from 'react-native-safe-area-context' -import useLibraryStore from '../../stores/library' -import { handleLibraryShuffle } from '../../player/controls/shuffle' +import useLibraryStore from '../../../stores/library' +import { handleLibraryShuffle } from '../../../player/controls/shuffle' import { TrackPlayer } from 'react-native-nitro-player' import { useNavigation } from '@react-navigation/native' import { NativeStackNavigationProp } from '@react-navigation/native-stack' -import LibraryStackParamList from '@/src/screens/Library/types' -import { ICON_PRESS_STYLES } from '../../configs/styling/elements' -import { applyHapticFeedback } from '../../utils/haptics' +import { LibraryParamList } from '../../../screens/Library/types' +import { ICON_PRESS_STYLES } from '../../../configs/styling/elements' +import { applyHapticFeedback } from '../../../utils/haptics' function LibraryTabBar(props: MaterialTopTabBarProps) { - const libraryStackNavigation = useNavigation>() + const libraryStackNavigation = useNavigation>() const insets = useSafeAreaInsets() diff --git a/src/components/Library/components/tracks-tab.tsx b/src/components/Library/components/tracks-tab.tsx index 5ecfd8086..a88669fc5 100644 --- a/src/components/Library/components/tracks-tab.tsx +++ b/src/components/Library/components/tracks-tab.tsx @@ -18,7 +18,7 @@ function TracksTab(): React.JSX.Element { const showAlphabeticalSelector = sortBy === ItemSortBy.Name || sortBy === ItemSortBy.SortName - const tracksInfiniteQuery = useTracks( + const { jumpToLetter, ...tracksInfiniteQuery } = useTracks( sortBy, sortDescending ? SortOrder.Descending : SortOrder.Ascending, isFavorites, @@ -32,6 +32,7 @@ function TracksTab(): React.JSX.Element { showAlphabeticalSelector={showAlphabeticalSelector} sortBy={sortBy as ItemSortBy} sortDescending={sortDescending} + jumpToLetter={jumpToLetter} /> ) } diff --git a/src/components/Library/sort-by.tsx b/src/components/Library/sort-by.tsx new file mode 100644 index 000000000..0591edbc4 --- /dev/null +++ b/src/components/Library/sort-by.tsx @@ -0,0 +1,55 @@ +import { useSafeAreaInsets } from 'react-native-safe-area-context' +import { YStack, SizableText, RadioGroup } from 'tamagui' +import { RadioGroupItemWithLabel } from '../Global/helpers/radio-group-item-with-label' +import { ItemSortByProps } from '@/src/screens/Library/types' +import { BaseItemKind } from '@jellyfin/sdk/lib/generated-client' +import { useLibraryArtistsStore } from '../../stores/library/artist' +import { useState } from 'react' +import { ArtistsSortBy as ArtistsSortByType } from '../../types/sorting/artist' +import ArtistsSortByConfig from '../../configs/sorting/artist' +import { SORTBY_TEXT } from '../../configs/messaging/sort-by' + +export default function ItemSortBy({ route }: ItemSortByProps): React.JSX.Element | null { + const { type } = route.params + + switch (type) { + case BaseItemKind.MusicArtist: + return MusicArtist() + case BaseItemKind.MusicAlbum: + return null + case BaseItemKind.Audio: + return null + default: + return null + } +} + +function MusicArtist() { + const { sortBy, setSortBy } = useLibraryArtistsStore() + + const { bottom } = useSafeAreaInsets() + + const onSortByChange = (value: string) => setSortBy(value as ArtistsSortByType) + + return ( + + + + Sort By + + + + {Object.values(ArtistsSortByConfig).map((option) => ( + + ))} + + + + + ) +} diff --git a/src/components/Playlist/components/header.tsx b/src/components/Playlist/components/header.tsx index 902be62b4..1e2d55288 100644 --- a/src/components/Playlist/components/header.tsx +++ b/src/components/Playlist/components/header.tsx @@ -4,7 +4,7 @@ import { H5, Spacer, XStack, YStack } from 'tamagui' import { InstantMixButton } from '../../Global/components/instant-mix-button' import Icon from '../../Global/components/icon' import { useNavigation } from '@react-navigation/native' -import LibraryStackParamList from '@/src/screens/Library/types' +import { LibraryParamList } from '@/src/screens/Library/types' import ItemImage from '../../Global/components/image' import Input from '../../Global/helpers/input' import Animated, { Easing, FadeInDown, FadeOutDown } from 'react-native-reanimated' @@ -14,7 +14,6 @@ import { RunTimeTicks } from '../../Global/helpers/time-codes' import { BUTTON_PRESS_STYLES } from '../../../configs/styling/elements' import { loadNewQueue } from '../../../player/queuing' import { usePlaylistContext } from '../../../providers/Playlist' -import { LayoutChangeEvent } from 'react-native' export default function PlaylistTracklistHeader(): React.JSX.Element { const { playlist, playlistTracks, editing, newName, setNewName } = usePlaylistContext() @@ -84,7 +83,7 @@ function PlaylistHeaderControls({ playlist: BaseItemDto playlistTracks: BaseItemDto[] }): React.JSX.Element { - const navigation = useNavigation>() + const navigation = useNavigation>() const playPlaylist = async (shuffled: boolean = false) => { if (!playlistTracks || playlistTracks.length === 0) return diff --git a/src/components/Playlists/component.tsx b/src/components/Playlists/component.tsx index 32f7a55a6..4709bdd68 100644 --- a/src/components/Playlists/component.tsx +++ b/src/components/Playlists/component.tsx @@ -1,16 +1,14 @@ import React from 'react' import { Paragraph, useTheme, YStack } from 'tamagui' -import ItemRow from '../Global/components/item-row' +import ItemRow from '../Global/components/Item/item-row' import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models' import { FetchNextPageOptions } from '@tanstack/react-query' import { closeAllSwipeableRows } from '../Global/components/SwipeableRow/registery' import { RefreshControl } from 'react-native' -import { Text } from '../Global/helpers/text' import { LegendListRenderItemProps } from '@legendapp/list/react-native' import List from '../Global/helpers/list' export interface PlaylistsProps { - canEdit?: boolean | undefined playlists: BaseItemDto[] | undefined refetch: () => void fetchNextPage: (options?: FetchNextPageOptions | undefined) => void @@ -25,7 +23,6 @@ export default function Playlists({ hasNextPage, isPending, isFetchingNextPage, - canEdit, }: PlaylistsProps): React.JSX.Element { const theme = useTheme() diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index 1c2d202ed..720b4e24c 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -1,13 +1,13 @@ import React, { useEffect, useState } from 'react' import Input from '../Global/helpers/input' import { H5, Text } from '../Global/helpers/text' -import ItemRow from '../Global/components/item-row' +import ItemRow from '../Global/components/Item/item-row' import { NativeStackNavigationProp } from '@react-navigation/native-stack' import { getToken, H3, Spinner, YStack } from 'tamagui' import Suggestions from './suggestions' import { isEmpty } from 'lodash' import HorizontalCardList from '../Global/components/horizontal-list' -import ItemCard from '../Global/components/item-card' +import ItemCard from '../Global/components/Item/item-card' import SearchParamList from '../../screens/Search/types' import { closeAllSwipeableRows } from '../Global/components/SwipeableRow/registery' import navigationRef from '../../screens/navigation' diff --git a/src/components/Search/suggestions.tsx b/src/components/Search/suggestions.tsx index 03e6c096a..d18ea2941 100644 --- a/src/components/Search/suggestions.tsx +++ b/src/components/Search/suggestions.tsx @@ -1,7 +1,7 @@ -import ItemRow from '../Global/components/item-row' +import ItemRow from '../Global/components/Item/item-row' import { Text } from '../Global/helpers/text' import { getTokenValue, Spinner, YStack } from 'tamagui' -import ItemCard from '../Global/components/item-card' +import ItemCard from '../Global/components/Item/item-card' import HorizontalCardList from '../Global/components/horizontal-list' import SearchParamList from '../../screens/Search/types' import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models' diff --git a/src/components/Settings/components/settings-nav-row.tsx b/src/components/Settings/components/settings-nav-row.tsx index 0f6336d2d..9b5e6ec97 100644 --- a/src/components/Settings/components/settings-nav-row.tsx +++ b/src/components/Settings/components/settings-nav-row.tsx @@ -1,62 +1,17 @@ import React from 'react' -import { XStack, SizableText, Card, ThemeTokens } from 'tamagui' import { useNavigation } from '@react-navigation/native' import { NativeStackNavigationProp } from '@react-navigation/native-stack' -import Icon from '../../Global/components/icon' import { SettingsStackParamList } from '../../../screens/Settings/types' -import { MaterialDesignIconsIconName } from '@react-native-vector-icons/material-design-icons' - -interface SettingsNavRowProps { - title: string - icon: MaterialDesignIconsIconName - route: keyof SettingsStackParamList - iconColor?: ThemeTokens - description?: string - testID?: string -} +import NavRowCard, { NavRowCardProps } from '../../Global/components/nav-row-card' export default function SettingsNavRow({ - title, - icon, route, - iconColor = '$borderColor', - description, - testID, -}: SettingsNavRowProps): React.JSX.Element { + ...props +}: NavRowCardProps): React.JSX.Element { const navigation = useNavigation>() - return ( - navigation.navigate(route)} - > - - - - - {title} - - {description && ( - - {description} - - )} - - - - - ) + const onPress = () => navigation.navigate(route) + + return } diff --git a/src/components/Tracks/component.tsx b/src/components/Tracks/component.tsx index 596fa06d4..afc675e7a 100644 --- a/src/components/Tracks/component.tsx +++ b/src/components/Tracks/component.tsx @@ -6,11 +6,15 @@ import { ItemSortBy } from '@jellyfin/sdk/lib/generated-client/models/item-sort- import { NativeStackNavigationProp } from '@react-navigation/native-stack' import { BaseStackParamList } from '../../screens/types' import { UseInfiniteQueryResult } from '@tanstack/react-query' -import { LibrarySectionListData, LibrarySectionListRenderItemInfo } from '../Global/types' +import { + JumpToLetter, + LibrarySectionListData, + LibrarySectionListRenderItemInfo, +} from '../Global/types' import { SectionListRef } from '@legendapp/list/section-list' import { useNavigation } from '@react-navigation/native' -import ItemList from '../Global/components/item-list' -import ItemSectionList from '../Global/components/item-section-list' +import ItemList from '../Global/components/Item/item-list' +import ItemSectionList from '../Global/components/Item/item-section-list' interface TracksProps { tracksInfiniteQuery: UseInfiniteQueryResult<(BaseItemDto | LibrarySectionListData)[], Error> @@ -19,6 +23,7 @@ interface TracksProps { sortBy?: ItemSortBy sortDescending?: boolean queue: Queue + jumpToLetter?: JumpToLetter } export default function Tracks(props: TracksProps): React.JSX.Element { @@ -37,6 +42,7 @@ function TracksSectionList({ tracksInfiniteQuery, sortDescending, queue, + jumpToLetter, }: Omit) { const navigation = useNavigation>() @@ -65,6 +71,7 @@ function TracksSectionList({ query={tracksInfiniteQuery as UseInfiniteQueryResult} renderItem={renderItem} sortDescending={sortDescending} + jumpToLetter={jumpToLetter} /> ) } diff --git a/src/configs/messaging/sort-by.ts b/src/configs/messaging/sort-by.ts new file mode 100644 index 000000000..4462e03fc --- /dev/null +++ b/src/configs/messaging/sort-by.ts @@ -0,0 +1,15 @@ +import { ItemSortBy } from '@jellyfin/sdk/lib/generated-client' + +export const SORTBY_TEXT: Record< + Extract< + ItemSortBy, + 'SortName' | 'DateLastContentAdded' | 'DatePlayed' | 'PlayCount' | 'Random' + >, + string +> = { + DateLastContentAdded: 'Recently Added', + DatePlayed: 'Last Played', + SortName: 'Name', + PlayCount: 'Play Count', + Random: "I'm feeling lucky", +} diff --git a/src/configs/querying/index.config.ts b/src/configs/querying/index.config.ts index b3d5d09f8..d2f7641ef 100644 --- a/src/configs/querying/index.config.ts +++ b/src/configs/querying/index.config.ts @@ -3,14 +3,14 @@ import { ImageFormat } from '@jellyfin/sdk/lib/generated-client/models' export const MAX_RETRY_ATTEMPTS = 2 export enum MaxPages { - Home = 2, + Home = 4, Library = 5, } /* eslint-disable @typescript-eslint/no-duplicate-enum-values */ export enum ApiLimits { Discover = 50, - Recents = 50, + Recents = 100, Frequents = 200, Library = 400, Similar = 10, diff --git a/src/configs/sorting/album.ts b/src/configs/sorting/album.ts new file mode 100644 index 000000000..e69de29bb diff --git a/src/configs/sorting/artist.ts b/src/configs/sorting/artist.ts new file mode 100644 index 000000000..654920bb4 --- /dev/null +++ b/src/configs/sorting/artist.ts @@ -0,0 +1,12 @@ +import { ItemSortBy } from '@jellyfin/sdk/lib/generated-client' + +/** + * Options for sorting artists + */ +const ArtistsSortByConfig = { + SortName: ItemSortBy.SortName, // Server API call + DateLastContentAdded: ItemSortBy.DateLastContentAdded, // Custom Query Logic, driven from albums + DatePlayed: ItemSortBy.DatePlayed, // Custom Query Logic, driven from tracks +} + +export default ArtistsSortByConfig diff --git a/src/providers/Display/display-provider.tsx b/src/providers/Display/display-provider.tsx index ae6ac6743..fdb23abd8 100644 --- a/src/providers/Display/display-provider.tsx +++ b/src/providers/Display/display-provider.tsx @@ -1,6 +1,6 @@ import { createContext, useContext, useState } from 'react' import { useSafeAreaFrame } from 'react-native-safe-area-context' -import { getTokens } from 'tamagui' +import { getTokens, getTokenValue } from 'tamagui' interface DisplayContext { numberOfColumns: number @@ -15,7 +15,7 @@ const DisplayContextInitializer = () => { const { width } = useSafeAreaFrame() const [numberOfColumns, setNumberOfColumns] = useState( - Math.floor(width / getTokens().size.$12.val), + Math.floor(width / getTokenValue('$size.12') + getTokenValue('$space.1')), ) const [display, setDisplay] = useState<'grid' | 'list'>('grid') diff --git a/src/screens/Discover/albums.tsx b/src/screens/Discover/albums.tsx index 88482876f..aa05c10c4 100644 --- a/src/screens/Discover/albums.tsx +++ b/src/screens/Discover/albums.tsx @@ -1,4 +1,4 @@ -import ItemList from '../../components/Global/components/item-list' +import ItemList from '../../components/Global/components/Item/item-list' import { useDiscoverAlbums } from '../../api/queries/suggestions' import { DiscoverAlbumScreenType, DiscoverAlbumsProps } from './types' import { useRecentlyAddedAlbums } from '../../api/queries/album' diff --git a/src/screens/Discover/artists.tsx b/src/screens/Discover/artists.tsx index 5786fd8ad..7440f5505 100644 --- a/src/screens/Discover/artists.tsx +++ b/src/screens/Discover/artists.tsx @@ -1,4 +1,4 @@ -import ItemList from '../../components/Global/components/item-list' +import ItemList from '../../components/Global/components/Item/item-list' import { useDiscoverArtists } from '../../api/queries/suggestions' import { SuggestedArtistsProps } from './types' diff --git a/src/screens/Discover/playlists.tsx b/src/screens/Discover/playlists.tsx index d6b73c857..57ef7e989 100644 --- a/src/screens/Discover/playlists.tsx +++ b/src/screens/Discover/playlists.tsx @@ -1,18 +1,24 @@ +import { usePublicPlaylists } from '../../api/queries/playlist' import Playlists from '../../components/Playlists/component' -import { PublicPlaylistsProps } from './types' -export default function PublicPlaylists({ - navigation, - route, -}: PublicPlaylistsProps): React.JSX.Element { +export default function PublicPlaylists(): React.JSX.Element { + const { + data: playlists, + fetchNextPage, + hasNextPage, + isPending, + isFetchingNextPage, + refetch, + } = usePublicPlaylists() + return ( ) } diff --git a/src/screens/Discover/types.ts b/src/screens/Discover/types.ts index 4299d8ecd..b22913c17 100644 --- a/src/screens/Discover/types.ts +++ b/src/screens/Discover/types.ts @@ -1,6 +1,5 @@ import { BaseStackParamList } from '../types' import { NativeStackScreenProps } from '@react-navigation/native-stack' -import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models' export enum DiscoverAlbumScreenType { RecentlyAdded = 'RecentlyAdded', @@ -12,21 +11,13 @@ type DiscoverStackParamList = BaseStackParamList & { Albums: { type: DiscoverAlbumScreenType } - PublicPlaylists: { - playlists: BaseItemDto[] | undefined - fetchNextPage: () => void - hasNextPage: boolean - isPending: boolean - isFetchingNextPage: boolean - refetch: () => void - } + PublicPlaylists: undefined SuggestedArtists: undefined } export default DiscoverStackParamList export type DiscoverAlbumsProps = NativeStackScreenProps -export type PublicPlaylistsProps = NativeStackScreenProps export type SuggestedArtistsProps = NativeStackScreenProps< DiscoverStackParamList, 'SuggestedArtists' diff --git a/src/screens/GenreSelection/index.tsx b/src/screens/GenreSelection/index.tsx index 7c5f77bb0..04071221f 100644 --- a/src/screens/GenreSelection/index.tsx +++ b/src/screens/GenreSelection/index.tsx @@ -7,7 +7,7 @@ import ItemImage from '../../components/Global/components/image' import Icon from '../../components/Global/components/icon' import useLibraryStore from '../../stores/library' import { getItemName } from '../../utils/formatting/item-names' -import LibraryStackParamList from '../Library/types' +import { LibraryParamList } from '../Library/types' import { NativeStackNavigationProp } from '@react-navigation/native-stack' import { useNavigation } from '@react-navigation/native' import { applyHapticFeedback } from '../../utils/haptics' @@ -15,7 +15,7 @@ import { LegendListRenderItemProps } from '@legendapp/list/react-native' import List from '../../components/Global/helpers/list' export default function GenreSelectionScreen(): React.JSX.Element { - const libraryStackNavigation = useNavigation>() + const libraryStackNavigation = useNavigation>() const genresInfiniteQuery = useGenres() const { diff --git a/src/screens/Home/artists.tsx b/src/screens/Home/artists.tsx index dfb7f6c43..3234f1bd6 100644 --- a/src/screens/Home/artists.tsx +++ b/src/screens/Home/artists.tsx @@ -2,7 +2,7 @@ import React from 'react' import { MostPlayedArtistsProps, RecentArtistsProps } from './types' import { useRecentArtists } from '../../api/queries/recents' import { useFrequentlyPlayedArtists } from '../../api/queries/frequents' -import ItemList from '../../components/Global/components/item-list' +import ItemList from '../../components/Global/components/Item/item-list' export default function HomeArtistsScreen({ route, diff --git a/src/screens/Home/tracks.tsx b/src/screens/Home/tracks.tsx index 89c601964..a66d59c0e 100644 --- a/src/screens/Home/tracks.tsx +++ b/src/screens/Home/tracks.tsx @@ -4,7 +4,7 @@ import HomeStackParamList, { MostPlayedTracksProps, RecentTracksProps } from './ import { useFrequentlyPlayedTracks } from '../../api/queries/frequents' import { useNavigation } from '@react-navigation/native' import { NativeStackNavigationProp } from '@react-navigation/native-stack' -import ItemList from '../../components/Global/components/item-list' +import ItemList from '../../components/Global/components/Item/item-list' export default function HomeTracksScreen({ route, diff --git a/src/screens/Library/add-playlist.tsx b/src/screens/Library/add-playlist.tsx index 7c47d4a68..bba3cd20d 100644 --- a/src/screens/Library/add-playlist.tsx +++ b/src/screens/Library/add-playlist.tsx @@ -6,12 +6,12 @@ import Button from '../../components/Global/helpers/button' import Icon from '../../components/Global/components/icon' import { isEmpty } from 'lodash' import { useAddPlaylist } from '../../api/mutations/playlist' -import LibraryStackParamList from './types' import { NativeStackNavigationProp } from '@react-navigation/native-stack' import { useNavigation } from '@react-navigation/native' +import { LibraryParamList } from './types' export default function AddPlaylist(): React.JSX.Element { - const libraryStackNavigation = useNavigation>() + const libraryStackNavigation = useNavigation>() const [name, setName] = useState('') diff --git a/src/screens/Library/delete-playlist.tsx b/src/screens/Library/delete-playlist.tsx index 2d5d06d5c..b29bb013c 100644 --- a/src/screens/Library/delete-playlist.tsx +++ b/src/screens/Library/delete-playlist.tsx @@ -2,14 +2,14 @@ import { Spinner, XStack, YStack } from 'tamagui' import Button from '../../components/Global/helpers/button' import { Text } from '../../components/Global/helpers/text' import Icon from '../../components/Global/components/icon' -import LibraryStackParamList, { LibraryDeletePlaylistProps } from '../Library/types' +import { LibraryParamList, LibraryDeletePlaylistProps } from '../Library/types' import { useSafeAreaInsets } from 'react-native-safe-area-context' import { useNavigation } from '@react-navigation/native' import { NativeStackNavigationProp } from '@react-navigation/native-stack' import { useDeletePlaylist } from '../../api/mutations/playlist' export default function DeletePlaylist({ route }: LibraryDeletePlaylistProps): React.JSX.Element { - const libraryStackNavigation = useNavigation>() + const libraryStackNavigation = useNavigation>() const deletePlaylist = useDeletePlaylist() diff --git a/src/screens/Library/index.ts b/src/screens/Library/index.ts index aafb89b7e..084fd7f4c 100644 --- a/src/screens/Library/index.ts +++ b/src/screens/Library/index.ts @@ -1,16 +1,17 @@ import AddPlaylist from './add-playlist' import { createNativeStackNavigator } from '@react-navigation/native-stack' -import LibraryStackParamList from './types' import { bottomSheetPresentation } from '../../utils/navigating/form-sheet' import FiltersSheet from '../Filters' import SortOptionsSheet from '../SortOptions' import YearSelectionScreen from '../YearSelection' import GenreSelectionScreen from '../GenreSelection' import DeletePlaylist from './delete-playlist' -import LibraryTabs from '../../components/Library/component' +import { LibraryTabs } from '../../components/Library/component' import { BaseStackScreens } from '../base-stack' +import { LibraryParamList } from './types' +import ItemSortBy from '../../components/Library/sort-by' -const LibraryStack = createNativeStackNavigator({ +const LibraryStack = createNativeStackNavigator({ initialRouteName: 'LibraryScreen', screenOptions: { headerTitleAlign: 'center', @@ -23,11 +24,6 @@ const LibraryStack = createNativeStackNavigator({ screen: LibraryTabs, options: { title: 'Library', - - // I honestly don't think we need a header for this screen, given that there are - // tabs on the top of the screen for navigating the library, but if we want one, - // we can use the title above - headerShown: false, }, }, ...BaseStackScreens, @@ -85,6 +81,16 @@ const LibraryStack = createNativeStackNavigator({ sheetAllowedDetents: 'fitToContents', }, }, + ItemSortBy: { + screen: ItemSortBy, + options: { + title: 'Sort By', + presentation: bottomSheetPresentation, + headerShown: false, + sheetGrabberVisible: true, + sheetAllowedDetents: 'fitToContents', + }, + }, }, }) diff --git a/src/screens/Library/types.ts b/src/screens/Library/types.ts index c276aadf2..3b2b89c8f 100644 --- a/src/screens/Library/types.ts +++ b/src/screens/Library/types.ts @@ -1,10 +1,10 @@ -import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models' +import { BaseItemDto, BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models' import { NativeStackScreenProps } from '@react-navigation/native-stack' import { BaseStackParamList } from '../types' import { NavigatorScreenParams } from '@react-navigation/native' import { FetchNextPageOptions, InfiniteData } from '@tanstack/react-query' -type LibraryStackParamList = BaseStackParamList & { +export type LibraryParamList = BaseStackParamList & { LibraryScreen: NavigatorScreenParams | undefined AddPlaylist: undefined DeletePlaylist: { @@ -20,24 +20,23 @@ type LibraryStackParamList = BaseStackParamList & { GenreSelection: undefined YearSelection: { tab?: 'Tracks' | 'Albums' } -} -export default LibraryStackParamList + ItemSortBy: { + type: BaseItemKind + } +} -export type LibraryScreenProps = NativeStackScreenProps -export type LibraryArtistProps = NativeStackScreenProps -export type LibraryAlbumProps = NativeStackScreenProps +export type LibraryScreenProps = NativeStackScreenProps +export type LibraryArtistProps = NativeStackScreenProps +export type LibraryAlbumProps = NativeStackScreenProps -export type LibraryAddPlaylistProps = NativeStackScreenProps -export type LibraryDeletePlaylistProps = NativeStackScreenProps< - LibraryStackParamList, - 'DeletePlaylist' -> +export type LibraryAddPlaylistProps = NativeStackScreenProps +export type LibraryDeletePlaylistProps = NativeStackScreenProps -export type FiltersProps = NativeStackScreenProps -export type SortOptionsProps = NativeStackScreenProps -export type GenreSelectionProps = NativeStackScreenProps -export type YearSelectionProps = NativeStackScreenProps +export type FiltersProps = NativeStackScreenProps +export type SortOptionsProps = NativeStackScreenProps +export type GenreSelectionProps = NativeStackScreenProps +export type YearSelectionProps = NativeStackScreenProps export type GenresProps = { genres: InfiniteData | undefined @@ -46,3 +45,5 @@ export type GenresProps = { isPending: boolean isFetchingNextPage: boolean } + +export type ItemSortByProps = NativeStackScreenProps diff --git a/src/screens/YearSelection/index.tsx b/src/screens/YearSelection/index.tsx index 69b987d4e..81bdedb6d 100644 --- a/src/screens/YearSelection/index.tsx +++ b/src/screens/YearSelection/index.tsx @@ -5,7 +5,7 @@ import { Text } from '../../components/Global/helpers/text' import Icon from '../../components/Global/components/icon' import useLibraryStore from '../../stores/library' import { useLibraryYears } from '../../api/queries/years' -import LibraryStackParamList, { YearSelectionProps } from '../Library/types' +import { LibraryParamList, YearSelectionProps } from '../Library/types' import { useNavigation } from '@react-navigation/native' import { NativeStackNavigationProp } from '@react-navigation/native-stack' import { applyHapticFeedback } from '../../utils/haptics' @@ -14,7 +14,7 @@ const ANY = 'any' type Picking = 'min' | 'max' | null export default function YearSelectionScreen({ route }: YearSelectionProps): React.JSX.Element { - const libraryStackNavigation = useNavigation>() + const libraryStackNavigation = useNavigation>() const tab = route.params?.tab ?? 'Tracks' const { years: availableYears, isPending, isError } = useLibraryYears() diff --git a/src/stores/library/artist.ts b/src/stores/library/artist.ts new file mode 100644 index 000000000..1e0ba3b83 --- /dev/null +++ b/src/stores/library/artist.ts @@ -0,0 +1,35 @@ +import { ArtistsSortBy } from '@/src/types/sorting/artist' +import { mmkvStateStorage } from '../../constants/storage' +import { ItemSortBy, SortOrder } from '@jellyfin/sdk/lib/generated-client' +import { create } from 'zustand' +import { createJSONStorage, devtools, persist } from 'zustand/middleware' + +type LibraryArtistsStore = { + isFavorites: true | undefined + setIsFavorites: (isFavorite: true | undefined) => void + sortBy: ArtistsSortBy + setSortBy: (sortBy: ArtistsSortBy) => void + sortOrder: SortOrder + setSortOrder: (sortOrder: SortOrder) => void +} + +export const useLibraryArtistsStore = create()( + devtools( + persist( + (set, get) => ({ + isFavorites: undefined, + setIsFavorites: (isFavorites: true | undefined) => set({ isFavorites }), + + sortBy: ItemSortBy.DateLastContentAdded, + setSortBy: (sortBy: ArtistsSortBy) => set({ sortBy }), + + sortOrder: SortOrder.Ascending, + setSortOrder: (sortOrder: SortOrder) => set({ sortOrder }), + }), + { + name: 'library-artists-store', + storage: createJSONStorage(() => mmkvStateStorage), + }, + ), + ), +) diff --git a/src/types/sorting/artist.ts b/src/types/sorting/artist.ts new file mode 100644 index 000000000..d0ab91dd9 --- /dev/null +++ b/src/types/sorting/artist.ts @@ -0,0 +1,3 @@ +import ArtistsSortByConfig from '../../configs/sorting/artist' + +export type ArtistsSortBy = (typeof ArtistsSortByConfig)[keyof typeof ArtistsSortByConfig] diff --git a/src/utils/logging/enums.ts b/src/utils/logging/enums.ts index f2c4e6195..4ba7c7428 100644 --- a/src/utils/logging/enums.ts +++ b/src/utils/logging/enums.ts @@ -26,6 +26,7 @@ enum LoggingContext { UI = 'UI', MediaInfo = 'MediaInfo', NitroPlayer = 'Nitro Player', + Artists = 'Artists', } export default LoggingContext diff --git a/src/utils/mapping/track-to-artist.ts b/src/utils/mapping/track-to-artist.ts new file mode 100644 index 000000000..9ac643376 --- /dev/null +++ b/src/utils/mapping/track-to-artist.ts @@ -0,0 +1,33 @@ +import { BaseItemDto, BaseItemKind } from '@jellyfin/sdk/lib/generated-client' +import { uniqBy } from 'lodash' +import { captureWarning, LoggingContext } from '../../utils/logging' + +/** + * + * @param tracks + * @param signal + * @returns + */ +export function mapTracksToArtists(tracks: BaseItemDto[]): BaseItemDto[] { + const artists: BaseItemDto[] = uniqBy( + tracks + .flatMap((track) => track.ArtistItems) + .filter((artist) => !!artist && artist.Id) + .map((artist) => ({ + ...artist, + Type: BaseItemKind.MusicArtist, + })), + 'Id', + ) + + if (tracks.length > 0 && artists.length === 0) { + captureWarning( + LoggingContext.Recents, + `mapTracksToArtists got ${tracks.length} tracks but derived 0 artistIds from ArtistItems`, + ) + + return [] + } + + return artists +} diff --git a/src/utils/query-selectors.ts b/src/utils/query-selectors.ts index 91a427311..14fc635cf 100644 --- a/src/utils/query-selectors.ts +++ b/src/utils/query-selectors.ts @@ -54,6 +54,9 @@ export default function flattenInfiniteQueryPages( }) return Array.from(listItems).map(([title, data]) => ({ + // A stable, position-independent key so section headers don't get remounted/misaligned + // when earlier pages are prepended (e.g. AZScroller jump + scrolling backward) + key: title, title, data, }))