From c6fce155a7c49bde19f63b29e228ecb3134aceb6 Mon Sep 17 00:00:00 2001 From: Violet Caulfield <42452695+anultravioletaurora@users.noreply.github.com> Date: Wed, 9 Sep 2026 06:25:39 -0500 Subject: [PATCH 01/15] i think this is a little better --- src/api/queries/album/index.ts | 100 ++++++--- src/api/queries/album/utils/album.ts | 51 ++++- src/api/queries/artist/index.ts | 87 ++++++-- src/api/queries/artist/utils/artist.ts | 45 +++- src/api/queries/track/index.ts | 210 +++++++++++++----- src/api/queries/track/utils/index.ts | 64 +++++- src/components/Albums/component.tsx | 9 +- src/components/Artists/component.tsx | 9 +- .../Global/components/AZScroller/index.tsx | 67 +++--- .../Global/components/AZScroller/utils.ts | 60 ++++- .../Global/components/item-section-list.tsx | 25 ++- src/components/Global/types/index.ts | 9 + .../Library/components/albums-tab.tsx | 3 +- .../Library/components/artists-tab.tsx | 10 +- .../Library/components/tracks-tab.tsx | 3 +- src/components/Tracks/component.tsx | 9 +- src/utils/query-selectors.ts | 3 + 17 files changed, 613 insertions(+), 151 deletions(-) diff --git a/src/api/queries/album/index.ts b/src/api/queries/album/index.ts index 4b804641b..3176f2478 100644 --- a/src/api/queries/album/index.ts +++ b/src/api/queries/album/index.ts @@ -2,7 +2,7 @@ import { QueryKeys } from '../../../enums/query-keys' import { InfiniteData, useInfiniteQuery, useQuery } from '@tanstack/react-query' import { ItemSortBy } from '@jellyfin/sdk/lib/generated-client/models/item-sort-by' import { SortOrder } from '@jellyfin/sdk/lib/generated-client/models/sort-order' -import { fetchAlbums } from './utils/album' +import { fetchAlbums, fetchAlbumsCount } 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' @@ -56,39 +56,85 @@ 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, + ] + + /** + * Jumps the albums list directly to {@link letter} by computing its absolute index from the + * album counts before/after it, then seeding the query cache with that single page - no + * incremental fetchNextPage/fetchPreviousPage looping required. + */ + const jumpToLetter = async (letter: string, letterReverseOrder: boolean): Promise => { + if (!isSortByLetter || !api || !user || !library) return false + + try { + const target = letter.toUpperCase() + + const [countBelowTarget, totalCount] = await Promise.all([ + fetchAlbumsCount(api, user, library, isFavorites, target, yearMin, yearMax), + fetchAlbumsCount(api, user, library, isFavorites, undefined, yearMin, yearMax), + ]) + + const startIndex = letterReverseOrder + ? Math.max(0, totalCount - countBelowTarget) + : countBelowTarget + + const items = await fetchAlbums( api, user, library, - pageParam, + startIndex, isFavorites, [librarySortBy ?? ItemSortBy.SortName], - [sortDescending ? SortOrder.Descending : SortOrder.Ascending], + [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 - }, - }) + ) + + queryClient.setQueryData(queryKey, { pages: [items], pageParams: [startIndex] }) + return true + } catch { + return false + } + } + + return { + ...useInfiniteQuery({ + queryKey, + queryFn: ({ pageParam, signal }) => + fetchAlbums( + api, + user, + library, + pageParam, + isFavorites, + [librarySortBy ?? ItemSortBy.SortName], + [sortDescending ? SortOrder.Descending : SortOrder.Ascending], + yearMin, + yearMax, + signal, + ), + initialPageParam: 0, + select: selectAlbums, + maxPages: MaxPages.Library, + getNextPageParam: (lastPage, allPages, lastPageParam) => { + return lastPage.length === ApiLimits.Library + ? lastPageParam + ApiLimits.Library + : undefined + }, + getPreviousPageParam: (firstPage, allPages, firstPageParam) => { + return firstPageParam <= 0 ? null : Math.max(0, firstPageParam - ApiLimits.Library) + }, + }), + jumpToLetter, + } } export default useAlbums diff --git a/src/api/queries/album/utils/album.ts b/src/api/queries/album/utils/album.ts index bdded95a8..dae8929ff 100644 --- a/src/api/queries/album/utils/album.ts +++ b/src/api/queries/album/utils/album.ts @@ -18,7 +18,7 @@ export function fetchAlbums( api: Api | undefined, user: JellifyUser | undefined, library: JellifyLibrary | undefined, - page: number, + startIndex: number, isFavorite: boolean | undefined, sortBy: ItemSortBy[] = [ItemSortBy.SortName], sortOrder: SortOrder[] = [SortOrder.Ascending], @@ -41,7 +41,7 @@ export function fetchAlbums( userId: user.id, sortBy: sortBy, sortOrder: sortOrder, - startIndex: page * ApiLimits.Library, + startIndex: startIndex, limit: ApiLimits.Library, isFavorite: isFavorite, fields: [ItemFields.SortName], @@ -65,6 +65,53 @@ export function fetchAlbums( }) } +/** + * Fetches the number of albums whose `SortName` is less than {@link nameLessThan}, or the total + * album count when omitted. Used to jump the AZScroller directly to a letter's absolute index + * without paginating through every page in between. + */ +export function fetchAlbumsCount( + api: Api | undefined, + user: JellifyUser | undefined, + library: JellifyLibrary | undefined, + isFavorite: boolean | undefined, + nameLessThan?: 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) + + getItemsApi(api) + .getItems( + { + parentId: library.musicLibraryId, + includeItemTypes: [BaseItemKind.MusicAlbum], + userId: user.id, + startIndex: 0, + limit: 0, + isFavorite: isFavorite, + recursive: true, + years: yearsParam, + 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..f9e55f4d8 100644 --- a/src/api/queries/artist/index.ts +++ b/src/api/queries/artist/index.ts @@ -2,9 +2,10 @@ 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 { fetchArtistFeaturedOn, fetchArtists } from './utils/artist' +import { fetchArtistFeaturedOn, fetchArtists, fetchArtistsCount } from './utils/artist' import { ApiLimits, MaxPages } from '../../../configs/querying/index.config' import flattenInfiniteQueryPages from '../../../utils/query-selectors' +import { queryClient } from '../../../constants/query-client' import { useJellifyLibrary, useJellifyUser } from '../../../stores/auth' import { getApi } from '../../../stores/auth/utils' import useLibraryStore from '../../../stores/library' @@ -50,26 +51,74 @@ export const useAlbumArtists = () => { return flattenInfiniteQueryPages(data) } - return useInfiniteQuery({ - queryKey: [QueryKeys.InfiniteArtists, isFavorites, sortDescending, library?.musicLibraryId], - queryFn: ({ pageParam, signal }: { pageParam: number; signal?: AbortSignal }) => - fetchArtists( + const queryKey = [ + QueryKeys.InfiniteArtists, + isFavorites, + sortDescending, + library?.musicLibraryId, + ] + + /** + * Jumps the artists list directly to {@link letter} by computing its absolute index from the + * artist counts before/after it, then seeding the query cache with that single page - no + * incremental fetchNextPage/fetchPreviousPage looping required. + */ + const jumpToLetter = async (letter: string, letterReverseOrder: boolean): Promise => { + if (!user || !library) return false + + try { + const target = letter.toUpperCase() + + const [countBelowTarget, totalCount] = await Promise.all([ + fetchArtistsCount(user, library, isFavorites, target), + fetchArtistsCount(user, library, isFavorites), + ]) + + const startIndex = letterReverseOrder + ? Math.max(0, totalCount - countBelowTarget) + : countBelowTarget + + const items = await fetchArtists( user, library, - pageParam, + startIndex, isFavorites, [ItemSortBy.SortName], - [sortDescending ? SortOrder.Descending : SortOrder.Ascending], - signal, - ), - select: selectArtists, - maxPages: MaxPages.Library, - initialPageParam: 0, - getNextPageParam: (lastPage, allPages, lastPageParam, allPageParams) => { - return lastPage.length === ApiLimits.Library ? lastPageParam + 1 : undefined - }, - getPreviousPageParam: (firstPage, allPages, firstPageParam, allPageParams) => { - return firstPageParam === 0 ? null : firstPageParam - 1 - }, - }) + [letterReverseOrder ? SortOrder.Descending : SortOrder.Ascending], + ) + + queryClient.setQueryData(queryKey, { pages: [items], pageParams: [startIndex] }) + return true + } catch { + return false + } + } + + return { + ...useInfiniteQuery({ + queryKey, + queryFn: ({ pageParam, signal }: { pageParam: number; signal?: AbortSignal }) => + fetchArtists( + user, + library, + pageParam, + isFavorites, + [ItemSortBy.SortName], + [sortDescending ? SortOrder.Descending : SortOrder.Ascending], + signal, + ), + select: selectArtists, + maxPages: MaxPages.Library, + initialPageParam: 0, + getNextPageParam: (lastPage, allPages, lastPageParam, allPageParams) => { + return lastPage.length === ApiLimits.Library + ? lastPageParam + ApiLimits.Library + : undefined + }, + getPreviousPageParam: (firstPage, allPages, firstPageParam, allPageParams) => { + return firstPageParam <= 0 ? null : Math.max(0, firstPageParam - ApiLimits.Library) + }, + }), + jumpToLetter, + } } diff --git a/src/api/queries/artist/utils/artist.ts b/src/api/queries/artist/utils/artist.ts index 06b27603c..8b38d25d8 100644 --- a/src/api/queries/artist/utils/artist.ts +++ b/src/api/queries/artist/utils/artist.ts @@ -17,7 +17,7 @@ import { getApi } from '../../../../stores/auth/utils' export function fetchArtists( user: JellifyUser | undefined, library: JellifyLibrary | undefined, - page: number, + startIndex: number, isFavorite: boolean | undefined, sortBy: ItemSortBy[] = [ItemSortBy.SortName], sortOrder: SortOrder[] = [SortOrder.Ascending], @@ -37,7 +37,7 @@ export function fetchArtists( userId: user.id, sortBy: sortBy, sortOrder: sortOrder, - startIndex: page * ApiLimits.Library, + startIndex: startIndex, limit: ApiLimits.Library, isFavorite: isFavorite, fields: [ItemFields.SortName, ItemFields.Genres], @@ -61,6 +61,47 @@ export function fetchArtists( }) } +/** + * Fetches the number of artists whose `SortName` is less than {@link nameLessThan}, or the total + * artist count when omitted. Used to jump the AZScroller directly to a letter's absolute index + * without paginating through every page in between. + */ +export function fetchArtistsCount( + user: JellifyUser | undefined, + library: JellifyLibrary | undefined, + isFavorite: boolean | undefined, + nameLessThan?: string, + signal?: AbortSignal, +): Promise { + return new Promise((resolve, reject) => { + 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') + + getArtistsApi(api) + .getAlbumArtists( + { + parentId: library.musicLibraryId, + userId: user.id, + startIndex: 0, + limit: 0, + isFavorite: isFavorite, + nameLessThan, + enableTotalRecordCount: true, + }, + { + signal, + }, + ) + .then(({ data }) => resolve(data.TotalRecordCount ?? 0)) + .catch((error) => { + reject(error) + }) + }) +} + /** * Fetches all albums for an artist * @param libraryId The ID of the library to fetch albums from 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/components/Albums/component.tsx b/src/components/Albums/component.tsx index f74735ad7..9631ed20d 100644 --- a/src/components/Albums/component.tsx +++ b/src/components/Albums/component.tsx @@ -4,7 +4,11 @@ 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 { SectionListRef } from '@legendapp/list/section-list' -import { LibrarySectionListData, LibrarySectionListRenderItemInfo } from '../Global/types' +import { + JumpToLetter, + LibrarySectionListData, + LibrarySectionListRenderItemInfo, +} from '../Global/types' import ItemSectionList from '../Global/components/item-section-list' import ItemList from '../Global/components/item-list' @@ -12,12 +16,14 @@ 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/Artists/component.tsx b/src/components/Artists/component.tsx index 59fa11d7b..366926459 100644 --- a/src/components/Artists/component.tsx +++ b/src/components/Artists/component.tsx @@ -2,12 +2,17 @@ import React, { useRef } from 'react' import ItemRow from '../Global/components/item-row' import { UseInfiniteQueryResult } from '@tanstack/react-query' import { SectionListRef } from '@legendapp/list/section-list' -import { LibrarySectionListData, LibrarySectionListRenderItemInfo } from '../Global/types' +import { + JumpToLetter, + LibrarySectionListData, + LibrarySectionListRenderItemInfo, +} from '../Global/types' import ItemSectionList from '../Global/components/item-section-list' export interface ArtistsProps { artistsInfiniteQuery: UseInfiniteQueryResult sortDescending?: boolean + jumpToLetter?: JumpToLetter } /** @@ -20,6 +25,7 @@ export interface ArtistsProps { export default function Artists({ artistsInfiniteQuery, sortDescending, + jumpToLetter, }: ArtistsProps): React.JSX.Element { const artists = artistsInfiniteQuery.data ?? [] @@ -48,6 +54,7 @@ export default function Artists({ query={artistsInfiniteQuery} renderItem={renderItem} sortDescending={sortDescending} + jumpToLetter={jumpToLetter} /> ) } diff --git a/src/components/Global/components/AZScroller/index.tsx b/src/components/Global/components/AZScroller/index.tsx index d3d22093c..340e3dd0f 100644 --- a/src/components/Global/components/AZScroller/index.tsx +++ b/src/components/Global/components/AZScroller/index.tsx @@ -5,7 +5,7 @@ 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' @@ -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-section-list.tsx b/src/components/Global/components/item-section-list.tsx index a0a9a8598..3687d81f6 100644 --- a/src/components/Global/components/item-section-list.tsx +++ b/src/components/Global/components/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 { 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/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/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..f227a161b 100644 --- a/src/components/Library/components/artists-tab.tsx +++ b/src/components/Library/components/artists-tab.tsx @@ -3,7 +3,7 @@ import Artists from '../../Artists/component' import useLibraryStore from '../../../stores/library' function ArtistsTab(): React.JSX.Element { - const artistsInfiniteQuery = useAlbumArtists() + const { jumpToLetter, ...artistsInfiniteQuery } = useAlbumArtists() const sortDescending = useLibraryStore((state) => { const sd = state.sortDescending as Record | boolean @@ -11,7 +11,13 @@ function ArtistsTab(): React.JSX.Element { return sd?.artists ?? false }) - return + return ( + + ) } export default ArtistsTab 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/Tracks/component.tsx b/src/components/Tracks/component.tsx index 596fa06d4..db1cec178 100644 --- a/src/components/Tracks/component.tsx +++ b/src/components/Tracks/component.tsx @@ -6,7 +6,11 @@ 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' @@ -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/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, })) From 3e7276acfbdf952dd16e5f4369bcbe889d164a33 Mon Sep 17 00:00:00 2001 From: Violet Caulfield <42452695+anultravioletaurora@users.noreply.github.com> Date: Wed, 9 Sep 2026 13:45:17 -0500 Subject: [PATCH 02/15] vertical library view instead of the tabbed view --- src/api/mutations/playlist/index.ts | 4 +- src/api/queries/album/index.ts | 22 +++-- .../Global/components/nav-row-card.tsx | 65 +++++++++++++ src/components/Library/component.tsx | 92 ++++++++----------- .../Library/components/library-nav-row.tsx | 16 ++++ .../Settings/components/settings-nav-row.tsx | 57 ++---------- src/screens/Library/index.ts | 37 ++++++-- src/screens/Library/types.ts | 61 ++++++------ 8 files changed, 201 insertions(+), 153 deletions(-) create mode 100644 src/components/Global/components/nav-row-card.tsx create mode 100644 src/components/Library/components/library-nav-row.tsx 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 3176f2478..8707b8bb7 100644 --- a/src/api/queries/album/index.ts +++ b/src/api/queries/album/index.ts @@ -15,6 +15,16 @@ import { Api } from '@jellyfin/sdk/lib/api' import { AlbumDiscsQueryKey } from './keys' import { AlbumQuery, RecentlyAddedQuery } from './queries' +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)) const useAlbums = () => { @@ -28,16 +38,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 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..57d53ac78 --- /dev/null +++ b/src/components/Global/components/nav-row-card.tsx @@ -0,0 +1,65 @@ +import { ICON_PRESS_STYLES } from '../../../configs/styling/elements' +import { LibraryStackParamList } 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/Library/component.tsx b/src/components/Library/component.tsx index 359463247..63e882c3e 100644 --- a/src/components/Library/component.tsx +++ b/src/components/Library/component.tsx @@ -1,57 +1,41 @@ -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 { ScrollView } from 'tamagui' +import LibraryNavRow from './components/library-nav-row' -const LibraryTabs = createMaterialTopTabNavigator({ - tabBar: (props) => , - screenOptions: ({ theme }) => ({ - swipeEnabled: false, // Disable tab swiped to prevent conflicts with SwipeableRow gestures - tabBarIndicatorStyle: { - borderBottomWidth: 4, - borderBottomColor: theme.colors.primary, - }, - tabBarActiveTintColor: theme.colors.primary, - tabBarInactiveTintColor: theme.colors.border, - tabBarStyle: { - backgroundColor: theme.colors.background, - }, - tabBarLabelStyle: { - fontSize: 16, - fontFamily: 'Figtree-Bold', - }, - tabBarPressOpacity: 0.5, - lazy: true, // Enable lazy loading to prevent all tabs from mounting simultaneously - }), - screens: { - Artists: { - screen: ArtistsTab, - options: { - tabBarButtonTestID: 'library-artists-tab-button', - }, - }, - Albums: { - screen: AlbumsTab, - options: { - tabBarButtonTestID: 'library-albums-tab-button', - }, - }, - Tracks: { - screen: TracksTab, - options: { - tabBarButtonTestID: 'library-tracks-tab-button', - }, - }, - Playlists: { - screen: PlaylistsTab, - options: { - tabBarButtonTestID: 'library-playlists-tab-button', - }, - }, - }, -}) +export default function Library(): React.JSX.Element { + return ( + + -export default LibraryTabs + + + + + + + ) +} diff --git a/src/components/Library/components/library-nav-row.tsx b/src/components/Library/components/library-nav-row.tsx new file mode 100644 index 000000000..bb024acd2 --- /dev/null +++ b/src/components/Library/components/library-nav-row.tsx @@ -0,0 +1,16 @@ +import { useNavigation } from '@react-navigation/native' +import { LibraryStackParamList } from '../../../screens/Library/types' +import NavRowCard, { NavRowCardProps } from '../../Global/components/nav-row-card' +import React from 'react' +import { NativeStackNavigationProp } from '@react-navigation/native-stack' + +export default function LibraryNavRow({ + route, + ...props +}: NavRowCardProps): React.JSX.Element { + const navigation = useNavigation>() + + const onPress = () => navigation.navigate(route) + + return +} 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/screens/Library/index.ts b/src/screens/Library/index.ts index aafb89b7e..ff668acfc 100644 --- a/src/screens/Library/index.ts +++ b/src/screens/Library/index.ts @@ -1,6 +1,5 @@ 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' @@ -9,8 +8,13 @@ import GenreSelectionScreen from '../GenreSelection' import DeletePlaylist from './delete-playlist' import LibraryTabs from '../../components/Library/component' import { BaseStackScreens } from '../base-stack' +import ArtistsTab from '../../components/Library/components/artists-tab' +import AlbumsTab from '../../components/Library/components/albums-tab' +import TracksTab from '../../components/Library/components/tracks-tab' +import PlaylistsTab from '../../components/Library/components/playlists-tab' +import { LibraryParamList } from './types' -const LibraryStack = createNativeStackNavigator({ +const LibraryStack = createNativeStackNavigator({ initialRouteName: 'LibraryScreen', screenOptions: { headerTitleAlign: 'center', @@ -23,14 +27,33 @@ 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, + LibraryArtists: { + screen: ArtistsTab, + options: { + title: 'Artists', + }, + }, + LibraryAlbums: { + screen: AlbumsTab, + options: { + title: 'Albums', + }, + }, + LibraryTracks: { + screen: TracksTab, + options: { + title: 'Tracks', + }, + }, + Playlists: { + screen: PlaylistsTab, + options: { + title: 'Playlists', + }, + }, AddPlaylist: { screen: AddPlaylist, options: { diff --git a/src/screens/Library/types.ts b/src/screens/Library/types.ts index c276aadf2..1feae18ff 100644 --- a/src/screens/Library/types.ts +++ b/src/screens/Library/types.ts @@ -4,40 +4,43 @@ import { BaseStackParamList } from '../types' import { NavigatorScreenParams } from '@react-navigation/native' import { FetchNextPageOptions, InfiniteData } from '@tanstack/react-query' -type LibraryStackParamList = BaseStackParamList & { - LibraryScreen: NavigatorScreenParams | undefined - AddPlaylist: undefined - DeletePlaylist: { - playlist: BaseItemDto - } - Filters: { - currentTab?: 'Tracks' | 'Albums' | 'Artists' - } - - SortOptions: { - currentTab?: 'Tracks' | 'Albums' | 'Artists' - } - - GenreSelection: undefined - YearSelection: { tab?: 'Tracks' | 'Albums' } +export type LibraryStackParamList = { + LibraryArtists: undefined + LibraryAlbums: undefined + LibraryTracks: undefined + Playlists: undefined } -export default LibraryStackParamList +export type LibraryParamList = BaseStackParamList & + LibraryStackParamList & { + LibraryScreen: NavigatorScreenParams | undefined + AddPlaylist: undefined + DeletePlaylist: { + playlist: BaseItemDto + } + Filters: { + currentTab?: 'Tracks' | 'Albums' | 'Artists' + } + + SortOptions: { + currentTab?: 'Tracks' | 'Albums' | 'Artists' + } + + GenreSelection: undefined + YearSelection: { tab?: 'Tracks' | 'Albums' } + } -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 From 1613482259e75f0c91f4554f31ae597e9c5dcba2 Mon Sep 17 00:00:00 2001 From: Violet Caulfield <42452695+anultravioletaurora@users.noreply.github.com> Date: Wed, 9 Sep 2026 17:24:13 -0500 Subject: [PATCH 03/15] cleaning up the library with new design elements adding item counts to the library --- src/api/queries/album/index.ts | 65 +++--- src/api/queries/album/utils/album.ts | 55 +++++- src/api/queries/item.ts | 24 ++- src/api/queries/libraries/keys.ts | 1 + src/api/types/page-param.ts | 4 + src/api/utils/page-params.ts | 75 +++++++ src/components/Album/footer.tsx | 4 +- .../Discover/helpers/public-playlists.tsx | 9 +- src/components/Filters/index.tsx | 4 +- .../Global/components/AZScroller/index.tsx | 2 +- src/components/Library/component.tsx | 26 +++ .../Library/components/library-nav-row.tsx | 2 +- .../Library/components/playlists-tab.tsx | 22 ++- src/components/Library/tab-bar.tsx | 186 ------------------ src/components/Playlist/components/header.tsx | 5 +- src/components/Playlists/component.tsx | 3 - src/screens/Discover/playlists.tsx | 28 +-- src/screens/Discover/types.ts | 11 +- src/screens/GenreSelection/index.tsx | 2 +- src/screens/Library/add-playlist.tsx | 2 +- src/screens/Library/delete-playlist.tsx | 2 +- src/screens/Library/index.ts | 20 +- src/screens/YearSelection/index.tsx | 2 +- 23 files changed, 267 insertions(+), 287 deletions(-) create mode 100644 src/api/types/page-param.ts create mode 100644 src/api/utils/page-params.ts delete mode 100644 src/components/Library/tab-bar.tsx diff --git a/src/api/queries/album/index.ts b/src/api/queries/album/index.ts index 8707b8bb7..31db0ec2d 100644 --- a/src/api/queries/album/index.ts +++ b/src/api/queries/album/index.ts @@ -2,10 +2,10 @@ import { QueryKeys } from '../../../enums/query-keys' import { InfiniteData, useInfiniteQuery, useQuery } from '@tanstack/react-query' import { ItemSortBy } from '@jellyfin/sdk/lib/generated-client/models/item-sort-by' import { SortOrder } from '@jellyfin/sdk/lib/generated-client/models/sort-order' -import { fetchAlbums, fetchAlbumsCount } from './utils/album' +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,8 @@ 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, @@ -68,39 +70,47 @@ const useAlbums = () => { 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} by computing its absolute index from the - * album counts before/after it, then seeding the query cache with that single page - no - * incremental fetchNextPage/fetchPreviousPage looping required. + * 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 target = letter.toUpperCase() - - const [countBelowTarget, totalCount] = await Promise.all([ - fetchAlbumsCount(api, user, library, isFavorites, target, yearMin, yearMax), - fetchAlbumsCount(api, user, library, isFavorites, undefined, yearMin, yearMax), - ]) - - const startIndex = letterReverseOrder - ? Math.max(0, totalCount - countBelowTarget) - : countBelowTarget + const pageParam: InfiniteSectionListPageParam = { + letter: letter.toUpperCase(), + index: 0, + } const items = await fetchAlbums( api, user, library, - startIndex, + pageParam, isFavorites, - [librarySortBy ?? ItemSortBy.SortName], + sortBy, [letterReverseOrder ? SortOrder.Descending : SortOrder.Ascending], yearMin, yearMax, ) - queryClient.setQueryData(queryKey, { pages: [items], pageParams: [startIndex] }) + // 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 @@ -117,23 +127,20 @@ const useAlbums = () => { library, pageParam, isFavorites, - [librarySortBy ?? ItemSortBy.SortName], - [sortDescending ? SortOrder.Descending : SortOrder.Ascending], + sortBy, + sortOrder, yearMin, yearMax, signal, ), - initialPageParam: 0, + initialPageParam: { + index: 0, + letter: '#', + } as InfiniteSectionListPageParam, select: selectAlbums, maxPages: MaxPages.Library, - getNextPageParam: (lastPage, allPages, lastPageParam) => { - return lastPage.length === ApiLimits.Library - ? lastPageParam + ApiLimits.Library - : undefined - }, - getPreviousPageParam: (firstPage, allPages, firstPageParam) => { - return firstPageParam <= 0 ? null : Math.max(0, firstPageParam - ApiLimits.Library) - }, + getNextPageParam, + getPreviousPageParam, }), jumpToLetter, } diff --git a/src/api/queries/album/utils/album.ts b/src/api/queries/album/utils/album.ts index dae8929ff..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, - startIndex: 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: startIndex, + 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) @@ -66,16 +103,16 @@ export function fetchAlbums( } /** - * Fetches the number of albums whose `SortName` is less than {@link nameLessThan}, or the total - * album count when omitted. Used to jump the AZScroller directly to a letter's absolute index - * without paginating through every page in between. + * 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, - nameLessThan?: string, + letter?: string, yearMin?: number, yearMax?: number, signal?: AbortSignal, @@ -86,6 +123,7 @@ export function fetchAlbumsCount( if (!library) return reject('Library has not been set') const yearsParam = buildYearsParam(yearMin, yearMax) + const { nameStartsWith, nameLessThan } = letter ? letterNameParams(letter) : {} getItemsApi(api) .getItems( @@ -98,6 +136,7 @@ export function fetchAlbumsCount( isFavorite: isFavorite, recursive: true, years: yearsParam, + nameStartsWith, nameLessThan, enableTotalRecordCount: 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/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..59cba9688 100644 --- a/src/components/Album/footer.tsx +++ b/src/components/Album/footer.tsx @@ -1,6 +1,6 @@ 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' @@ -25,7 +25,7 @@ export default function AlbumTrackListFooter({ const navigation = useNavigation< NativeStackNavigationProp< - HomeStackParamList | LibraryStackParamList | DiscoverStackParamList + HomeStackParamList | LibraryParamList | DiscoverStackParamList > >() diff --git a/src/components/Discover/helpers/public-playlists.tsx b/src/components/Discover/helpers/public-playlists.tsx index 6c13b8689..7500388c4 100644 --- a/src/components/Discover/helpers/public-playlists.tsx +++ b/src/components/Discover/helpers/public-playlists.tsx @@ -36,14 +36,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/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 340e3dd0f..8a67ce810 100644 --- a/src/components/Global/components/AZScroller/index.tsx +++ b/src/components/Global/components/AZScroller/index.tsx @@ -10,7 +10,7 @@ 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 { diff --git a/src/components/Library/component.tsx b/src/components/Library/component.tsx index 63e882c3e..33c3ad63b 100644 --- a/src/components/Library/component.tsx +++ b/src/components/Library/component.tsx @@ -1,8 +1,23 @@ import React from 'react' import { ScrollView } from 'tamagui' import LibraryNavRow from './components/library-nav-row' +import { useQuery } from '@tanstack/react-query' +import { LibraryQueryKeys } from '../../api/queries/libraries/keys' +import { useJellifyUser } from '../../stores/auth' +import { fetchItemCounts } from '../../api/queries/item' export default function Library(): React.JSX.Element { + const [user] = useJellifyUser() + + const { data: itemCounts } = useQuery({ + queryKey: [LibraryQueryKeys.ItemCounts, user?.id], + queryFn: fetchItemCounts, + }) + + const artistsCount = itemCounts?.ArtistCount + const albumsCount = itemCounts?.AlbumCount + const tracksCount = itemCounts?.SongCount + return ( navigation.navigate(route) - return + return } 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/tab-bar.tsx deleted file mode 100644 index 9ed6b895f..000000000 --- a/src/components/Library/tab-bar.tsx +++ /dev/null @@ -1,186 +0,0 @@ -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 { useSafeAreaInsets } from 'react-native-safe-area-context' -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' - -function LibraryTabBar(props: MaterialTopTabBarProps) { - const libraryStackNavigation = useNavigation>() - - const insets = useSafeAreaInsets() - - const currentTab = props.state.routes[props.state.index].name as - 'Tracks' | 'Albums' | 'Artists' | 'Playlists' - - // Subscribe directly to the current tab's filter state for reactivity - const currentFilters = useLibraryStore((state) => { - if (currentTab === 'Playlists') return null - return state.filters[currentTab.toLowerCase() as 'tracks' | 'albums' | 'artists'] - }) - - const hasActiveFilters = - currentFilters && - (currentFilters.isFavorites === true || - currentFilters.isDownloaded === true || - currentFilters.isUnplayed === true || - (currentFilters.genreIds && currentFilters.genreIds.length > 0) || - currentFilters.yearMin != null || - currentFilters.yearMax != null) - - const handleShufflePress = async () => { - applyHapticFeedback('info') - - try { - await handleLibraryShuffle() - - await TrackPlayer.play() - } catch (error) { - console.error('Failed to shuffle and play:', error) - } - } - - return ( - - - - {[''].includes(props.state.routes[props.state.index].name) ? null : ( - - {props.state.routes[props.state.index].name === 'Playlists' && ( - { - applyHapticFeedback('info') - props.navigation.navigate('AddPlaylist') - }} - alignItems={'center'} - justifyContent={'center'} - {...ICON_PRESS_STYLES} - > - - - - Create Playlist - - - )} - - {props.state.routes[props.state.index].name === 'Tracks' && ( - - - - - All - - - )} - - {props.state.routes[props.state.index].name !== 'Playlists' && ( - <> - { - applyHapticFeedback('info') - libraryStackNavigation.navigate('SortOptions', { - currentTab: currentTab as 'Tracks' | 'Albums' | 'Artists', - }) - }} - alignItems={'center'} - justifyContent={'center'} - {...ICON_PRESS_STYLES} - > - - - - Sort - - - - { - applyHapticFeedback('info') - libraryStackNavigation.navigate('Filters', { - currentTab: currentTab as 'Tracks' | 'Albums' | 'Artists', - }) - }} - alignItems={'center'} - justifyContent={'center'} - {...ICON_PRESS_STYLES} - > - - - - Filter - - - - )} - - {props.state.routes[props.state.index].name !== 'Playlists' && - hasActiveFilters && ( - { - applyHapticFeedback('info') - // Clear filters only for the current tab - if (currentTab === 'Tracks') { - useLibraryStore.getState().setTracksFilters({ - isFavorites: undefined, - isDownloaded: false, - isUnplayed: false, - genreIds: undefined, - yearMin: undefined, - yearMax: undefined, - }) - } else if (currentTab === 'Albums') { - useLibraryStore.getState().setAlbumsFilters({ - isFavorites: undefined, - yearMin: undefined, - yearMax: undefined, - }) - } else if (currentTab === 'Artists') { - useLibraryStore - .getState() - .setArtistsFilters({ isFavorites: undefined }) - } - }} - pressStyle={{ opacity: 0.6 }} - transition='quick' - alignItems={'center'} - justifyContent={'center'} - > - - - - Clear - - - )} - - )} - - ) -} - -export default LibraryTabBar 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..26f97187d 100644 --- a/src/components/Playlists/component.tsx +++ b/src/components/Playlists/component.tsx @@ -5,12 +5,10 @@ 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/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..539eb8ab2 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 { LibraryStackParamList } from '../Library/types' import { NativeStackNavigationProp } from '@react-navigation/native-stack' import { useNavigation } from '@react-navigation/native' import { applyHapticFeedback } from '../../utils/haptics' diff --git a/src/screens/Library/add-playlist.tsx b/src/screens/Library/add-playlist.tsx index 7c47d4a68..1cc0954fc 100644 --- a/src/screens/Library/add-playlist.tsx +++ b/src/screens/Library/add-playlist.tsx @@ -6,7 +6,7 @@ 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 { LibraryStackParamList } from './types' import { NativeStackNavigationProp } from '@react-navigation/native-stack' import { useNavigation } from '@react-navigation/native' diff --git a/src/screens/Library/delete-playlist.tsx b/src/screens/Library/delete-playlist.tsx index 2d5d06d5c..dfa480377 100644 --- a/src/screens/Library/delete-playlist.tsx +++ b/src/screens/Library/delete-playlist.tsx @@ -2,7 +2,7 @@ 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 { LibraryStackParamList, 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' diff --git a/src/screens/Library/index.ts b/src/screens/Library/index.ts index ff668acfc..c781a33ea 100644 --- a/src/screens/Library/index.ts +++ b/src/screens/Library/index.ts @@ -6,12 +6,12 @@ import SortOptionsSheet from '../SortOptions' import YearSelectionScreen from '../YearSelection' import GenreSelectionScreen from '../GenreSelection' import DeletePlaylist from './delete-playlist' -import LibraryTabs from '../../components/Library/component' +import Library from '../../components/Library/component' import { BaseStackScreens } from '../base-stack' -import ArtistsTab from '../../components/Library/components/artists-tab' -import AlbumsTab from '../../components/Library/components/albums-tab' -import TracksTab from '../../components/Library/components/tracks-tab' -import PlaylistsTab from '../../components/Library/components/playlists-tab' +import LibraryArtists from '../../components/Library/components/artists-tab' +import LibraryAlbums from '../../components/Library/components/albums-tab' +import LibraryTracks from '../../components/Library/components/tracks-tab' +import Playlists from '../../components/Library/components/playlists-tab' import { LibraryParamList } from './types' const LibraryStack = createNativeStackNavigator({ @@ -24,32 +24,32 @@ const LibraryStack = createNativeStackNavigator({ }, screens: { LibraryScreen: { - screen: LibraryTabs, + screen: Library, options: { title: 'Library', }, }, ...BaseStackScreens, LibraryArtists: { - screen: ArtistsTab, + screen: LibraryArtists, options: { title: 'Artists', }, }, LibraryAlbums: { - screen: AlbumsTab, + screen: LibraryAlbums, options: { title: 'Albums', }, }, LibraryTracks: { - screen: TracksTab, + screen: LibraryTracks, options: { title: 'Tracks', }, }, Playlists: { - screen: PlaylistsTab, + screen: Playlists, options: { title: 'Playlists', }, diff --git a/src/screens/YearSelection/index.tsx b/src/screens/YearSelection/index.tsx index 69b987d4e..30624fcc7 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 { LibraryStackParamList, YearSelectionProps } from '../Library/types' import { useNavigation } from '@react-navigation/native' import { NativeStackNavigationProp } from '@react-navigation/native-stack' import { applyHapticFeedback } from '../../utils/haptics' From 606423cc5b3de1a56a2ea2325654d7dd79ac7336 Mon Sep 17 00:00:00 2001 From: Violet Caulfield <42452695+anultravioletaurora@users.noreply.github.com> Date: Thu, 10 Sep 2026 08:08:12 -0500 Subject: [PATCH 04/15] clean up global components, make nav row cards look nicer, library artists screen work --- src/api/queries/artist/index.ts | 86 +++++-------------- src/components/Album/footer.tsx | 4 +- src/components/Albums/component.tsx | 6 +- src/components/Artist/OverviewTab.tsx | 2 +- src/components/Artist/similar.tsx | 2 +- src/components/Artists/component.tsx | 31 ++----- .../Context/components/multiple-artists.tsx | 2 +- .../Discover/helpers/just-added.tsx | 2 +- .../Discover/helpers/public-playlists.tsx | 11 +-- .../Discover/helpers/suggested-albums.tsx | 2 +- .../Discover/helpers/suggested-artists.tsx | 2 +- .../components/{ => Item}/item-card.tsx | 12 +-- .../components/{ => Item}/item-list.tsx | 4 +- .../Global/components/{ => Item}/item-row.tsx | 38 ++++---- .../{ => Item}/item-section-list.tsx | 10 +-- .../Global/components/nav-row-card.tsx | 18 ++-- .../Home/helpers/frequent-artists.tsx | 2 +- .../Home/helpers/frequent-tracks.tsx | 2 +- .../Home/helpers/recent-artists.tsx | 2 +- .../Home/helpers/recently-played.tsx | 2 +- .../Library/components/artists-tab.tsx | 10 +-- src/components/Playlists/component.tsx | 2 +- src/components/Search/index.tsx | 4 +- src/components/Search/suggestions.tsx | 4 +- src/components/Tracks/component.tsx | 4 +- src/providers/Display/display-provider.tsx | 4 +- src/screens/Discover/albums.tsx | 2 +- src/screens/Discover/artists.tsx | 2 +- src/screens/Home/artists.tsx | 2 +- src/screens/Home/tracks.tsx | 2 +- 30 files changed, 101 insertions(+), 175 deletions(-) rename src/components/Global/components/{ => Item}/item-card.tsx (92%) rename src/components/Global/components/{ => Item}/item-list.tsx (96%) rename src/components/Global/components/{ => Item}/item-row.tsx (89%) rename src/components/Global/components/{ => Item}/item-section-list.tsx (89%) diff --git a/src/api/queries/artist/index.ts b/src/api/queries/artist/index.ts index f9e55f4d8..78a82f072 100644 --- a/src/api/queries/artist/index.ts +++ b/src/api/queries/artist/index.ts @@ -1,10 +1,9 @@ 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 { useInfiniteQuery, useQuery } from '@tanstack/react-query' import { isUndefined } from 'lodash' import { fetchArtistFeaturedOn, fetchArtists, fetchArtistsCount } from './utils/artist' import { ApiLimits, MaxPages } from '../../../configs/querying/index.config' -import flattenInfiniteQueryPages from '../../../utils/query-selectors' import { queryClient } from '../../../constants/query-client' import { useJellifyLibrary, useJellifyUser } from '../../../stores/auth' import { getApi } from '../../../stores/auth/utils' @@ -47,10 +46,6 @@ export const useAlbumArtists = () => { const sortDescending = librarySortDescendingState.artists ?? false const isFavorites = filters.artists.isFavorites - const selectArtists = (data: InfiniteData) => { - return flattenInfiniteQueryPages(data) - } - const queryKey = [ QueryKeys.InfiniteArtists, isFavorites, @@ -58,67 +53,28 @@ export const useAlbumArtists = () => { library?.musicLibraryId, ] - /** - * Jumps the artists list directly to {@link letter} by computing its absolute index from the - * artist counts before/after it, then seeding the query cache with that single page - no - * incremental fetchNextPage/fetchPreviousPage looping required. - */ - const jumpToLetter = async (letter: string, letterReverseOrder: boolean): Promise => { - if (!user || !library) return false - - try { - const target = letter.toUpperCase() - - const [countBelowTarget, totalCount] = await Promise.all([ - fetchArtistsCount(user, library, isFavorites, target), - fetchArtistsCount(user, library, isFavorites), - ]) - - const startIndex = letterReverseOrder - ? Math.max(0, totalCount - countBelowTarget) - : countBelowTarget - - const items = await fetchArtists( + return useInfiniteQuery({ + queryKey, + queryFn: ({ pageParam, signal }: { pageParam: number; signal?: AbortSignal }) => + fetchArtists( user, library, - startIndex, + pageParam, isFavorites, [ItemSortBy.SortName], - [letterReverseOrder ? SortOrder.Descending : SortOrder.Ascending], - ) - - queryClient.setQueryData(queryKey, { pages: [items], pageParams: [startIndex] }) - return true - } catch { - return false - } - } - - return { - ...useInfiniteQuery({ - queryKey, - queryFn: ({ pageParam, signal }: { pageParam: number; signal?: AbortSignal }) => - fetchArtists( - user, - library, - pageParam, - isFavorites, - [ItemSortBy.SortName], - [sortDescending ? SortOrder.Descending : SortOrder.Ascending], - signal, - ), - select: selectArtists, - maxPages: MaxPages.Library, - initialPageParam: 0, - getNextPageParam: (lastPage, allPages, lastPageParam, allPageParams) => { - return lastPage.length === ApiLimits.Library - ? lastPageParam + ApiLimits.Library - : undefined - }, - getPreviousPageParam: (firstPage, allPages, firstPageParam, allPageParams) => { - return firstPageParam <= 0 ? null : Math.max(0, firstPageParam - ApiLimits.Library) - }, - }), - jumpToLetter, - } + [sortDescending ? SortOrder.Descending : SortOrder.Ascending], + signal, + ), + maxPages: MaxPages.Library, + initialPageParam: 0, + select: ({ pages }) => pages.flatMap((page) => page), + getNextPageParam: (lastPage, allPages, lastPageParam, allPageParams) => { + return lastPage.length === ApiLimits.Library + ? lastPageParam + ApiLimits.Library + : undefined + }, + getPreviousPageParam: (firstPage, allPages, firstPageParam, allPageParams) => { + return firstPageParam <= 0 ? null : Math.max(0, firstPageParam - ApiLimits.Library) + }, + }) } diff --git a/src/components/Album/footer.tsx b/src/components/Album/footer.tsx index 59cba9688..1de36bb27 100644 --- a/src/components/Album/footer.tsx +++ b/src/components/Album/footer.tsx @@ -5,12 +5,12 @@ 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' diff --git a/src/components/Albums/component.tsx b/src/components/Albums/component.tsx index 9631ed20d..68653536a 100644 --- a/src/components/Albums/component.tsx +++ b/src/components/Albums/component.tsx @@ -2,15 +2,15 @@ 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 { JumpToLetter, LibrarySectionListData, LibrarySectionListRenderItemInfo, } from '../Global/types' -import ItemSectionList from '../Global/components/item-section-list' -import ItemList from '../Global/components/item-list' +import ItemSectionList from '../Global/components/Item/item-section-list' +import ItemList from '../Global/components/Item/item-list' interface AlbumsProps { albumsInfiniteQuery: UseInfiniteQueryResult<(BaseItemDto | LibrarySectionListData)[], Error> 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 366926459..df89b4ec1 100644 --- a/src/components/Artists/component.tsx +++ b/src/components/Artists/component.tsx @@ -1,16 +1,11 @@ -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 { - JumpToLetter, - 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 } @@ -29,8 +24,6 @@ export default function Artists({ }: 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. @@ -44,17 +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 7500388c4..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>() 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/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 96% rename from src/components/Global/components/item-list.tsx rename to src/components/Global/components/Item/item-list.tsx index 3838b5a86..bb8488a6c 100644 --- a/src/components/Global/components/item-list.tsx +++ b/src/components/Global/components/Item/item-list.tsx @@ -1,11 +1,11 @@ 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 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' 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 89% rename from src/components/Global/components/item-section-list.tsx rename to src/components/Global/components/Item/item-section-list.tsx index 3687d81f6..600b418d3 100644 --- a/src/components/Global/components/item-section-list.tsx +++ b/src/components/Global/components/Item/item-section-list.tsx @@ -1,13 +1,13 @@ import { SectionList, SectionListProps, SectionListRef } from '@legendapp/list/section-list' import { UseInfiniteQueryResult } from '@tanstack/react-query' import { JSX, RefObject } from 'react' -import { JumpToLetter, 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 { ItemKeyExtractor } from '../../../utils/parsing/key-extractor' +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 diff --git a/src/components/Global/components/nav-row-card.tsx b/src/components/Global/components/nav-row-card.tsx index 57d53ac78..526bb1b85 100644 --- a/src/components/Global/components/nav-row-card.tsx +++ b/src/components/Global/components/nav-row-card.tsx @@ -32,32 +32,34 @@ export default function NavRowCard({ return ( - - - + + + + {title} {description && ( - + {description} )} + 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/components/artists-tab.tsx b/src/components/Library/components/artists-tab.tsx index f227a161b..a66a2f7b7 100644 --- a/src/components/Library/components/artists-tab.tsx +++ b/src/components/Library/components/artists-tab.tsx @@ -3,7 +3,7 @@ import Artists from '../../Artists/component' import useLibraryStore from '../../../stores/library' function ArtistsTab(): React.JSX.Element { - const { jumpToLetter, ...artistsInfiniteQuery } = useAlbumArtists() + const artistsInfiniteQuery = useAlbumArtists() const sortDescending = useLibraryStore((state) => { const sd = state.sortDescending as Record | boolean @@ -11,13 +11,7 @@ function ArtistsTab(): React.JSX.Element { return sd?.artists ?? false }) - return ( - - ) + return } export default ArtistsTab diff --git a/src/components/Playlists/component.tsx b/src/components/Playlists/component.tsx index 26f97187d..4709bdd68 100644 --- a/src/components/Playlists/component.tsx +++ b/src/components/Playlists/component.tsx @@ -1,6 +1,6 @@ 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' 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/Tracks/component.tsx b/src/components/Tracks/component.tsx index db1cec178..afc675e7a 100644 --- a/src/components/Tracks/component.tsx +++ b/src/components/Tracks/component.tsx @@ -13,8 +13,8 @@ import { } 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> 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/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, From 69492b7712b0271993836010ddf772a1d0230b05 Mon Sep 17 00:00:00 2001 From: Violet Caulfield <42452695+anultravioletaurora@users.noreply.github.com> Date: Thu, 10 Sep 2026 13:53:11 -0500 Subject: [PATCH 05/15] nav row card font sizing --- src/components/Global/components/nav-row-card.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/Global/components/nav-row-card.tsx b/src/components/Global/components/nav-row-card.tsx index 526bb1b85..74ddd2270 100644 --- a/src/components/Global/components/nav-row-card.tsx +++ b/src/components/Global/components/nav-row-card.tsx @@ -50,11 +50,11 @@ export default function NavRowCard({ - + {title} {description && ( - + {description} )} From 74470d2c4a3dfdbcc8775242d607d947341a75b7 Mon Sep 17 00:00:00 2001 From: Violet Caulfield <42452695+anultravioletaurora@users.noreply.github.com> Date: Fri, 11 Sep 2026 06:24:10 -0500 Subject: [PATCH 06/15] library changes --- src/api/queries/artist/index.ts | 64 ++++++++++++++++++++------------- 1 file changed, 39 insertions(+), 25 deletions(-) diff --git a/src/api/queries/artist/index.ts b/src/api/queries/artist/index.ts index 78a82f072..6fa5b57e0 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 { BaseItemDto, SortOrder } from '@jellyfin/sdk/lib/generated-client' import { useInfiniteQuery, useQuery } from '@tanstack/react-query' import { isUndefined } from 'lodash' -import { fetchArtistFeaturedOn, fetchArtists, fetchArtistsCount } from './utils/artist' +import { fetchArtistFeaturedOn, fetchArtists } from './utils/artist' import { ApiLimits, MaxPages } from '../../../configs/querying/index.config' -import { queryClient } from '../../../constants/query-client' 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,43 +38,57 @@ 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 queryKey = [ QueryKeys.InfiniteArtists, isFavorites, - sortDescending, + sortBy, + sortOrder, library?.musicLibraryId, ] return useInfiniteQuery({ queryKey, queryFn: ({ pageParam, signal }: { pageParam: number; signal?: AbortSignal }) => - fetchArtists( - user, - library, - pageParam, - isFavorites, - [ItemSortBy.SortName], - [sortDescending ? SortOrder.Descending : SortOrder.Ascending], - signal, - ), + fetchArtists(user, library, pageParam, isFavorites, sortBy, sortOrder, signal), maxPages: MaxPages.Library, initialPageParam: 0, select: ({ pages }) => pages.flatMap((page) => page), - getNextPageParam: (lastPage, allPages, lastPageParam, allPageParams) => { - return lastPage.length === ApiLimits.Library - ? lastPageParam + ApiLimits.Library - : undefined - }, + getNextPageParam: (lastPage, allPages, lastPageParam, allPageParams) => + getNextAlbumArtistsPageParam(lastPage, lastPageParam, sortBy), getPreviousPageParam: (firstPage, allPages, firstPageParam, allPageParams) => { 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 + } + + return nextPageParam +} From c9be27a1b72923261967f62e524a48143fa6e555 Mon Sep 17 00:00:00 2001 From: Violet Caulfield <42452695+anultravioletaurora@users.noreply.github.com> Date: Fri, 11 Sep 2026 06:24:21 -0500 Subject: [PATCH 07/15] library changes --- src/api/queries/artist/queries.ts | 7 +- src/api/queries/artist/utils/artist.ts | 149 +++++++------- .../Global/components/Item/item-list.tsx | 7 +- .../Global/components/nav-row-card.tsx | 4 +- src/components/Library/component.tsx | 117 +++++------ .../Library/components/artists-tab.tsx | 91 +++++++-- .../Library/components/library-nav-row.tsx | 16 -- src/components/Library/components/tab-bar.tsx | 186 ++++++++++++++++++ src/components/Library/sort-by.tsx | 55 ++++++ src/configs/messaging/sort-by.ts | 15 ++ src/configs/sorting/album.ts | 0 src/configs/sorting/artist.ts | 12 ++ src/screens/GenreSelection/index.tsx | 4 +- src/screens/Library/add-playlist.tsx | 4 +- src/screens/Library/delete-playlist.tsx | 4 +- src/screens/Library/index.ts | 43 ++-- src/screens/Library/types.ts | 46 +++-- src/screens/YearSelection/index.tsx | 4 +- src/stores/library/artist.ts | 35 ++++ src/types/sorting/artist.ts | 3 + src/utils/logging/enums.ts | 1 + 21 files changed, 562 insertions(+), 241 deletions(-) delete mode 100644 src/components/Library/components/library-nav-row.tsx create mode 100644 src/components/Library/components/tab-bar.tsx create mode 100644 src/components/Library/sort-by.tsx create mode 100644 src/configs/messaging/sort-by.ts create mode 100644 src/configs/sorting/album.ts create mode 100644 src/configs/sorting/artist.ts create mode 100644 src/stores/library/artist.ts create mode 100644 src/types/sorting/artist.ts 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 8b38d25d8..4c1e35d2c 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,93 +14,85 @@ 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' -export function fetchArtists( +export async function fetchArtists( user: JellifyUser | undefined, library: JellifyLibrary | undefined, - startIndex: number, + 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: startIndex, - 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 ?? [] - setQueryUserDataForItems(items) - return resolve(items) - }) - .catch((error) => { - reject(error) - }) - }) -} + try { + let result: AxiosResponse + let items: BaseItemDto[] + let recentTracks: BaseItemDto[] -/** - * Fetches the number of artists whose `SortName` is less than {@link nameLessThan}, or the total - * artist count when omitted. Used to jump the AZScroller directly to a letter's absolute index - * without paginating through every page in between. - */ -export function fetchArtistsCount( - user: JellifyUser | undefined, - library: JellifyLibrary | undefined, - isFavorite: boolean | undefined, - nameLessThan?: string, - signal?: AbortSignal, -): Promise { - return new Promise((resolve, reject) => { - const api = getApi() + switch (sortBy) { + case 'DatePlayed': + recentTracks = await queryClient.infiniteQuery({ + ...PlayItAgainQuery(library), + initialPageParam: page, + staleTime: 'static', + }) - if (!api) return reject('No API instance provided') - if (!user) return reject('No user provided') - if (!library) return reject('Library has not been set') + console.debug(recentTracks.map((track) => track.Id).join(',')) - getArtistsApi(api) - .getAlbumArtists( - { - parentId: library.musicLibraryId, - userId: user.id, - startIndex: 0, - limit: 0, - isFavorite: isFavorite, - nameLessThan, - enableTotalRecordCount: true, - }, - { - signal, - }, - ) - .then(({ data }) => resolve(data.TotalRecordCount ?? 0)) - .catch((error) => { - reject(error) - }) - }) + items = recentTracks + .map((track) => track.ArtistItems) + .filter((artists) => !!artists) + .map(([firstArtist]) => ({ + ...firstArtist, + Type: BaseItemKind.MusicArtist, + })) + + 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 items + } catch (error) { + captureError( + error, + LoggingContext.Artists, + `Failed to fetch artists with options: [sortBy: '${sortBy.toUpperCase()}', sortOptions: '${sortOrder.toUpperCase()}']`, + ) + return Promise.reject(error) + } } /** @@ -133,7 +126,7 @@ export function fetchArtistAlbums( ItemSortBy.SortName, ], sortOrder: [SortOrder.Descending], - albumArtistIds: [artist.Id!], + artistIds: [artist.Id!], fields: [ItemFields.ChildCount], enableUserData: true, }, diff --git a/src/components/Global/components/Item/item-list.tsx b/src/components/Global/components/Item/item-list.tsx index bb8488a6c..bca4cab91 100644 --- a/src/components/Global/components/Item/item-list.tsx +++ b/src/components/Global/components/Item/item-list.tsx @@ -2,7 +2,7 @@ import { UseInfiniteQueryResult } from '@tanstack/react-query' 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' @@ -10,12 +10,12 @@ 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])) @@ -53,6 +53,7 @@ export default function ItemList({ query, queue }: ItemListProps): React.JSX.Ele } renderItem={renderItem} onEndReached={onEndReached} + {...props} /> ) } diff --git a/src/components/Global/components/nav-row-card.tsx b/src/components/Global/components/nav-row-card.tsx index 74ddd2270..85f085188 100644 --- a/src/components/Global/components/nav-row-card.tsx +++ b/src/components/Global/components/nav-row-card.tsx @@ -1,11 +1,11 @@ import { ICON_PRESS_STYLES } from '../../../configs/styling/elements' -import { LibraryStackParamList } from '@/src/screens/Library/types' +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< +export type NavRowCardProps = Omit< RowCardProps, 'onPress' > & { diff --git a/src/components/Library/component.tsx b/src/components/Library/component.tsx index 33c3ad63b..644635af8 100644 --- a/src/components/Library/component.tsx +++ b/src/components/Library/component.tsx @@ -1,67 +1,52 @@ -import React from 'react' -import { ScrollView } from 'tamagui' -import LibraryNavRow from './components/library-nav-row' -import { useQuery } from '@tanstack/react-query' -import { LibraryQueryKeys } from '../../api/queries/libraries/keys' -import { useJellifyUser } from '../../stores/auth' -import { fetchItemCounts } from '../../api/queries/item' +import { createMaterialTopTabNavigator } from '@react-navigation/material-top-tabs' +import ArtistsTab from './components/artists-tab' +import AlbumsTab from './components/albums-tab' +import TracksTab from './components/tracks-tab' +import PlaylistsTab from './components/playlists-tab' -export default function Library(): React.JSX.Element { - const [user] = useJellifyUser() - - const { data: itemCounts } = useQuery({ - queryKey: [LibraryQueryKeys.ItemCounts, user?.id], - queryFn: fetchItemCounts, - }) - - const artistsCount = itemCounts?.ArtistCount - const albumsCount = itemCounts?.AlbumCount - const tracksCount = itemCounts?.SongCount - - return ( - - - - - - - - - - ) -} +export const LibraryTabs = createMaterialTopTabNavigator({ + screenOptions: ({ theme }) => ({ + swipeEnabled: false, // Disable tab swiped to prevent conflicts with SwipeableRow gestures + tabBarIndicatorStyle: { + borderBottomWidth: 3, + borderBottomColor: theme.colors.primary, + }, + tabBarActiveTintColor: theme.colors.primary, + tabBarInactiveTintColor: theme.colors.border, + tabBarStyle: { + backgroundColor: theme.colors.background, + }, + tabBarLabelStyle: { + fontSize: 14, + fontFamily: 'Figtree-Bold', + }, + tabBarPressOpacity: 0.5, + lazy: true, // Enable lazy loading to prevent all tabs from mounting simultaneously + }), + screens: { + Artists: { + screen: ArtistsTab, + options: { + tabBarButtonTestID: 'library-artists-tab-button', + }, + }, + Albums: { + screen: AlbumsTab, + options: { + tabBarButtonTestID: 'library-albums-tab-button', + }, + }, + Tracks: { + screen: TracksTab, + options: { + tabBarButtonTestID: 'library-tracks-tab-button', + }, + }, + Playlists: { + screen: PlaylistsTab, + options: { + tabBarButtonTestID: 'library-playlists-tab-button', + }, + }, + }, +}) 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/library-nav-row.tsx b/src/components/Library/components/library-nav-row.tsx deleted file mode 100644 index af62d8e49..000000000 --- a/src/components/Library/components/library-nav-row.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { useNavigation } from '@react-navigation/native' -import { LibraryStackParamList } from '../../../screens/Library/types' -import NavRowCard, { NavRowCardProps } from '../../Global/components/nav-row-card' -import React from 'react' -import { NativeStackNavigationProp } from '@react-navigation/native-stack' - -export default function LibraryNavRow({ - route, - ...props -}: NavRowCardProps): React.JSX.Element { - const navigation = useNavigation>() - - const onPress = () => navigation.navigate(route) - - return -} diff --git a/src/components/Library/components/tab-bar.tsx b/src/components/Library/components/tab-bar.tsx new file mode 100644 index 000000000..bec927725 --- /dev/null +++ b/src/components/Library/components/tab-bar.tsx @@ -0,0 +1,186 @@ +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 { useSafeAreaInsets } from 'react-native-safe-area-context' +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 { 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 insets = useSafeAreaInsets() + + const currentTab = props.state.routes[props.state.index].name as + 'Tracks' | 'Albums' | 'Artists' | 'Playlists' + + // Subscribe directly to the current tab's filter state for reactivity + const currentFilters = useLibraryStore((state) => { + if (currentTab === 'Playlists') return null + return state.filters[currentTab.toLowerCase() as 'tracks' | 'albums' | 'artists'] + }) + + const hasActiveFilters = + currentFilters && + (currentFilters.isFavorites === true || + currentFilters.isDownloaded === true || + currentFilters.isUnplayed === true || + (currentFilters.genreIds && currentFilters.genreIds.length > 0) || + currentFilters.yearMin != null || + currentFilters.yearMax != null) + + const handleShufflePress = async () => { + applyHapticFeedback('info') + + try { + await handleLibraryShuffle() + + await TrackPlayer.play() + } catch (error) { + console.error('Failed to shuffle and play:', error) + } + } + + return ( + + + + {[''].includes(props.state.routes[props.state.index].name) ? null : ( + + {props.state.routes[props.state.index].name === 'Playlists' && ( + { + applyHapticFeedback('info') + props.navigation.navigate('AddPlaylist') + }} + alignItems={'center'} + justifyContent={'center'} + {...ICON_PRESS_STYLES} + > + + + + Create Playlist + + + )} + + {props.state.routes[props.state.index].name === 'Tracks' && ( + + + + + All + + + )} + + {props.state.routes[props.state.index].name !== 'Playlists' && ( + <> + { + applyHapticFeedback('info') + libraryStackNavigation.navigate('SortOptions', { + currentTab: currentTab as 'Tracks' | 'Albums' | 'Artists', + }) + }} + alignItems={'center'} + justifyContent={'center'} + {...ICON_PRESS_STYLES} + > + + + + Sort + + + + { + applyHapticFeedback('info') + libraryStackNavigation.navigate('Filters', { + currentTab: currentTab as 'Tracks' | 'Albums' | 'Artists', + }) + }} + alignItems={'center'} + justifyContent={'center'} + {...ICON_PRESS_STYLES} + > + + + + Filter + + + + )} + + {props.state.routes[props.state.index].name !== 'Playlists' && + hasActiveFilters && ( + { + applyHapticFeedback('info') + // Clear filters only for the current tab + if (currentTab === 'Tracks') { + useLibraryStore.getState().setTracksFilters({ + isFavorites: undefined, + isDownloaded: false, + isUnplayed: false, + genreIds: undefined, + yearMin: undefined, + yearMax: undefined, + }) + } else if (currentTab === 'Albums') { + useLibraryStore.getState().setAlbumsFilters({ + isFavorites: undefined, + yearMin: undefined, + yearMax: undefined, + }) + } else if (currentTab === 'Artists') { + useLibraryStore + .getState() + .setArtistsFilters({ isFavorites: undefined }) + } + }} + pressStyle={{ opacity: 0.6 }} + transition='quick' + alignItems={'center'} + justifyContent={'center'} + > + + + + Clear + + + )} + + )} + + ) +} + +export default LibraryTabBar 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/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/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/screens/GenreSelection/index.tsx b/src/screens/GenreSelection/index.tsx index 539eb8ab2..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/Library/add-playlist.tsx b/src/screens/Library/add-playlist.tsx index 1cc0954fc..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 dfa480377..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 c781a33ea..084fd7f4c 100644 --- a/src/screens/Library/index.ts +++ b/src/screens/Library/index.ts @@ -6,13 +6,10 @@ import SortOptionsSheet from '../SortOptions' import YearSelectionScreen from '../YearSelection' import GenreSelectionScreen from '../GenreSelection' import DeletePlaylist from './delete-playlist' -import Library from '../../components/Library/component' +import { LibraryTabs } from '../../components/Library/component' import { BaseStackScreens } from '../base-stack' -import LibraryArtists from '../../components/Library/components/artists-tab' -import LibraryAlbums from '../../components/Library/components/albums-tab' -import LibraryTracks from '../../components/Library/components/tracks-tab' -import Playlists from '../../components/Library/components/playlists-tab' import { LibraryParamList } from './types' +import ItemSortBy from '../../components/Library/sort-by' const LibraryStack = createNativeStackNavigator({ initialRouteName: 'LibraryScreen', @@ -24,36 +21,12 @@ const LibraryStack = createNativeStackNavigator({ }, screens: { LibraryScreen: { - screen: Library, + screen: LibraryTabs, options: { title: 'Library', }, }, ...BaseStackScreens, - LibraryArtists: { - screen: LibraryArtists, - options: { - title: 'Artists', - }, - }, - LibraryAlbums: { - screen: LibraryAlbums, - options: { - title: 'Albums', - }, - }, - LibraryTracks: { - screen: LibraryTracks, - options: { - title: 'Tracks', - }, - }, - Playlists: { - screen: Playlists, - options: { - title: 'Playlists', - }, - }, AddPlaylist: { screen: AddPlaylist, options: { @@ -108,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 1feae18ff..3b2b89c8f 100644 --- a/src/screens/Library/types.ts +++ b/src/screens/Library/types.ts @@ -1,34 +1,30 @@ -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' -export type LibraryStackParamList = { - LibraryArtists: undefined - LibraryAlbums: undefined - LibraryTracks: undefined - Playlists: undefined -} +export type LibraryParamList = BaseStackParamList & { + LibraryScreen: NavigatorScreenParams | undefined + AddPlaylist: undefined + DeletePlaylist: { + playlist: BaseItemDto + } + Filters: { + currentTab?: 'Tracks' | 'Albums' | 'Artists' + } + + SortOptions: { + currentTab?: 'Tracks' | 'Albums' | 'Artists' + } + + GenreSelection: undefined + YearSelection: { tab?: 'Tracks' | 'Albums' } -export type LibraryParamList = BaseStackParamList & - LibraryStackParamList & { - LibraryScreen: NavigatorScreenParams | undefined - AddPlaylist: undefined - DeletePlaylist: { - playlist: BaseItemDto - } - Filters: { - currentTab?: 'Tracks' | 'Albums' | 'Artists' - } - - SortOptions: { - currentTab?: 'Tracks' | 'Albums' | 'Artists' - } - - GenreSelection: undefined - YearSelection: { tab?: 'Tracks' | 'Albums' } + ItemSortBy: { + type: BaseItemKind } +} export type LibraryScreenProps = NativeStackScreenProps export type LibraryArtistProps = NativeStackScreenProps @@ -49,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 30624fcc7..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 From 53eb0c9aaf7f34626420d06f3f539303b509fd04 Mon Sep 17 00:00:00 2001 From: Violet Caulfield <42452695+anultravioletaurora@users.noreply.github.com> Date: Fri, 11 Sep 2026 06:42:14 -0500 Subject: [PATCH 08/15] prevent duplicate artists --- src/api/queries/artist/index.ts | 8 ++++++-- src/configs/querying/index.config.ts | 2 +- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/api/queries/artist/index.ts b/src/api/queries/artist/index.ts index 6fa5b57e0..59274572d 100644 --- a/src/api/queries/artist/index.ts +++ b/src/api/queries/artist/index.ts @@ -1,7 +1,7 @@ import { QueryKeys } from '../../../enums/query-keys' import { BaseItemDto, SortOrder } from '@jellyfin/sdk/lib/generated-client' import { useInfiniteQuery, useQuery } from '@tanstack/react-query' -import { isUndefined } from 'lodash' +import { isUndefined, uniqBy } from 'lodash' import { fetchArtistFeaturedOn, fetchArtists } from './utils/artist' import { ApiLimits, MaxPages } from '../../../configs/querying/index.config' import { useJellifyLibrary, useJellifyUser } from '../../../stores/auth' @@ -60,7 +60,11 @@ export const useAlbumArtists = ( fetchArtists(user, library, pageParam, isFavorites, sortBy, sortOrder, signal), maxPages: MaxPages.Library, initialPageParam: 0, - select: ({ pages }) => pages.flatMap((page) => page), + select: ({ pages }) => + uniqBy( + pages.flatMap((page) => page), + 'Id', + ), getNextPageParam: (lastPage, allPages, lastPageParam, allPageParams) => getNextAlbumArtistsPageParam(lastPage, lastPageParam, sortBy), getPreviousPageParam: (firstPage, allPages, firstPageParam, allPageParams) => { diff --git a/src/configs/querying/index.config.ts b/src/configs/querying/index.config.ts index b3d5d09f8..f9a760b75 100644 --- a/src/configs/querying/index.config.ts +++ b/src/configs/querying/index.config.ts @@ -3,7 +3,7 @@ import { ImageFormat } from '@jellyfin/sdk/lib/generated-client/models' export const MAX_RETRY_ATTEMPTS = 2 export enum MaxPages { - Home = 2, + Home = 4, Library = 5, } From 07ae6564922cb13974258b4ab09f496c4191a983 Mon Sep 17 00:00:00 2001 From: Violet Caulfield <42452695+anultravioletaurora@users.noreply.github.com> Date: Fri, 11 Sep 2026 07:21:02 -0500 Subject: [PATCH 09/15] I put recent artists in two places i guess --- src/api/queries/artist/utils/artist.ts | 11 +--- src/api/queries/recents/index.ts | 12 +++- src/api/queries/recents/utils/index.ts | 90 ++++++-------------------- src/utils/mapping/track-to-artist.ts | 65 +++++++++++++++++++ 4 files changed, 95 insertions(+), 83 deletions(-) create mode 100644 src/utils/mapping/track-to-artist.ts diff --git a/src/api/queries/artist/utils/artist.ts b/src/api/queries/artist/utils/artist.ts index 4c1e35d2c..5343046f6 100644 --- a/src/api/queries/artist/utils/artist.ts +++ b/src/api/queries/artist/utils/artist.ts @@ -19,6 +19,7 @@ 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 async function fetchArtists( user: JellifyUser | undefined, @@ -48,15 +49,7 @@ export async function fetchArtists( staleTime: 'static', }) - console.debug(recentTracks.map((track) => track.Id).join(',')) - - items = recentTracks - .map((track) => track.ArtistItems) - .filter((artists) => !!artists) - .map(([firstArtist]) => ({ - ...firstArtist, - Type: BaseItemKind.MusicArtist, - })) + items = await mapTracksToArtists(recentTracks, signal) break case 'SortName': diff --git a/src/api/queries/recents/index.ts b/src/api/queries/recents/index.ts index 6e1d5c391..1ef586814 100644 --- a/src/api/queries/recents/index.ts +++ b/src/api/queries/recents/index.ts @@ -7,7 +7,7 @@ import { } 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' @@ -27,8 +27,10 @@ export const useRecentlyPlayedTracks = () => { export const PlayItAgainQuery: ( library: JellifyLibrary | undefined, + abortSignal?: AbortSignal, ) => UseInfiniteQueryOptions = ( library: JellifyLibrary | undefined, + abortSignal?: AbortSignal, ) => { const api = getApi() @@ -37,7 +39,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: ( @@ -75,7 +77,11 @@ export const useRecentArtists = () => { 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 diff --git a/src/api/queries/recents/utils/index.ts b/src/api/queries/recents/utils/index.ts index 4e83077c4..470cd6ab6 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, @@ -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 await mapTracksToArtists(recentTracks) + } catch (error) { + return Promise.reject(error) + } } diff --git a/src/utils/mapping/track-to-artist.ts b/src/utils/mapping/track-to-artist.ts new file mode 100644 index 000000000..7c9a1143e --- /dev/null +++ b/src/utils/mapping/track-to-artist.ts @@ -0,0 +1,65 @@ +import { setQueryUserDataForItem } from '../../api/queries/user-data' +import { getApi, getUser } from '../../stores/auth/utils' +import { + BaseItemDto, + BaseItemKind, + ImageType, + ItemFields, +} from '@jellyfin/sdk/lib/generated-client' +import { getItemsApi } from '@jellyfin/sdk/lib/utils/api' +import { uniq } from 'lodash' +import { queryClient } from '../../constants/query-client' +import { ArtistQueryKey } from '../../api/queries/artist/keys' + +/** + * + * @param tracks + * @param signal + * @returns + */ +export async function mapTracksToArtists( + tracks: BaseItemDto[], + signal?: AbortSignal, +): Promise { + const api = getApi() + const user = getUser() + + const artistIds = uniq( + tracks + .map((track) => track.ArtistItems) + .filter((artists) => !!artists) + .map(([{ Id }]) => Id!), + ) + + return await getItemsApi(api!) + .getItems( + { + userId: user?.id, + includeItemTypes: [BaseItemKind.MusicArtist], + ids: artistIds, + fields: [ItemFields.Genres, ItemFields.SortName], + 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) + }) + + return fetchedArtists.sort((a, b) => { + const aIndex = artistIds.findIndex((Id) => a.Id === Id) + const bIndex = artistIds.findIndex((Id) => b.Id === Id) + + return aIndex - bIndex + }) + }) +} From adbad0e3299616d50785f0b03ce3f4072c745b05 Mon Sep 17 00:00:00 2001 From: Violet Caulfield <42452695+anultravioletaurora@users.noreply.github.com> Date: Fri, 11 Sep 2026 07:27:15 -0500 Subject: [PATCH 10/15] tuning recents --- src/api/queries/recents/index.ts | 2 +- src/api/queries/recents/utils/index.ts | 2 +- src/configs/querying/index.config.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/api/queries/recents/index.ts b/src/api/queries/recents/index.ts index 1ef586814..2ba81bc1c 100644 --- a/src/api/queries/recents/index.ts +++ b/src/api/queries/recents/index.ts @@ -48,7 +48,7 @@ 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[], diff --git a/src/api/queries/recents/utils/index.ts b/src/api/queries/recents/utils/index.ts index 470cd6ab6..5cfffbf0e 100644 --- a/src/api/queries/recents/utils/index.ts +++ b/src/api/queries/recents/utils/index.ts @@ -84,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, diff --git a/src/configs/querying/index.config.ts b/src/configs/querying/index.config.ts index f9a760b75..d2f7641ef 100644 --- a/src/configs/querying/index.config.ts +++ b/src/configs/querying/index.config.ts @@ -10,7 +10,7 @@ export enum MaxPages { /* eslint-disable @typescript-eslint/no-duplicate-enum-values */ export enum ApiLimits { Discover = 50, - Recents = 50, + Recents = 100, Frequents = 200, Library = 400, Similar = 10, From ed27192882d1e1d275362b46a3e477e06530a744 Mon Sep 17 00:00:00 2001 From: Violet Caulfield <42452695+anultravioletaurora@users.noreply.github.com> Date: Fri, 11 Sep 2026 14:29:42 -0500 Subject: [PATCH 11/15] recents tuning --- src/api/queries/artist/index.ts | 1 + src/api/queries/recents/index.ts | 3 +-- src/utils/mapping/track-to-artist.ts | 7 ++++--- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/api/queries/artist/index.ts b/src/api/queries/artist/index.ts index 59274572d..83ff458b8 100644 --- a/src/api/queries/artist/index.ts +++ b/src/api/queries/artist/index.ts @@ -94,5 +94,6 @@ function getNextAlbumArtistsPageParam( : undefined } + console.debug(`Next Artists page param ${nextPageParam}`) return nextPageParam } diff --git a/src/api/queries/recents/index.ts b/src/api/queries/recents/index.ts index 2ba81bc1c..146d13102 100644 --- a/src/api/queries/recents/index.ts +++ b/src/api/queries/recents/index.ts @@ -6,7 +6,6 @@ import { UseInfiniteQueryOptions, } from '@tanstack/react-query' import { fetchRecentlyPlayed, fetchRecentlyPlayedArtists } from './utils' -import { ApiLimits, MaxPages } from '../../../configs/querying/index.config' import { isUndefined, uniqBy } from 'lodash' import { useJellifyLibrary } from '../../../stores/auth' import { getApi, getUser } from '../../../stores/auth/utils' @@ -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 = () => { diff --git a/src/utils/mapping/track-to-artist.ts b/src/utils/mapping/track-to-artist.ts index 7c9a1143e..e1caf27f4 100644 --- a/src/utils/mapping/track-to-artist.ts +++ b/src/utils/mapping/track-to-artist.ts @@ -7,7 +7,7 @@ import { ItemFields, } from '@jellyfin/sdk/lib/generated-client' import { getItemsApi } from '@jellyfin/sdk/lib/utils/api' -import { uniq } from 'lodash' +import { isEmpty, isUndefined, uniq } from 'lodash' import { queryClient } from '../../constants/query-client' import { ArtistQueryKey } from '../../api/queries/artist/keys' @@ -27,8 +27,9 @@ export async function mapTracksToArtists( const artistIds = uniq( tracks .map((track) => track.ArtistItems) - .filter((artists) => !!artists) - .map(([{ Id }]) => Id!), + .filter((artists) => !!artists && artists.length > 0) + .map((artists) => artists?.[0].Id) + .filter((Id) => !isUndefined(Id)), ) return await getItemsApi(api!) From c614949ba1b02049aeb055ec42ab8cc952e29a3d Mon Sep 17 00:00:00 2001 From: Violet Caulfield <42452695+anultravioletaurora@users.noreply.github.com> Date: Fri, 11 Sep 2026 14:48:13 -0500 Subject: [PATCH 12/15] item list fetch previpus page plz --- src/api/queries/recents/index.ts | 2 +- src/components/Global/components/Item/item-list.tsx | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/api/queries/recents/index.ts b/src/api/queries/recents/index.ts index 146d13102..51f21923d 100644 --- a/src/api/queries/recents/index.ts +++ b/src/api/queries/recents/index.ts @@ -50,7 +50,7 @@ export const PlayItAgainQuery: ( return lastPage.length > 0 ? lastPageParam + 1 : undefined }, getPreviousPageParam: ( - firstPage: BaseItemDto[], + prevPage: BaseItemDto[], allPages: BaseItemDto[][], firstPageParam: number, allPageParams: number[], diff --git a/src/components/Global/components/Item/item-list.tsx b/src/components/Global/components/Item/item-list.tsx index bca4cab91..ba59fe460 100644 --- a/src/components/Global/components/Item/item-list.tsx +++ b/src/components/Global/components/Item/item-list.tsx @@ -43,6 +43,7 @@ export default function ItemList({ query, queue, ...props }: ItemListProps): Rea ) } + const onStartReached = () => query.hasPreviousPage && query.fetchPreviousPage() const onEndReached = () => query.hasNextPage && query.fetchNextPage() return ( @@ -52,6 +53,7 @@ export default function ItemList({ query, queue, ...props }: ItemListProps): Rea } renderItem={renderItem} + onStartReached={onStartReached} onEndReached={onEndReached} {...props} /> From 751820f9cfbcac355e3148211f7ffaf261ef1f26 Mon Sep 17 00:00:00 2001 From: Violet Caulfield <42452695+anultravioletaurora@users.noreply.github.com> Date: Fri, 11 Sep 2026 15:14:37 -0500 Subject: [PATCH 13/15] plz --- src/api/queries/recents/index.ts | 12 +++--------- src/utils/mapping/track-to-artist.ts | 9 ++++++++- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/src/api/queries/recents/index.ts b/src/api/queries/recents/index.ts index 51f21923d..f2cfd9388 100644 --- a/src/api/queries/recents/index.ts +++ b/src/api/queries/recents/index.ts @@ -66,11 +66,8 @@ 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), @@ -85,10 +82,7 @@ export const useRecentArtists = () => { 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/utils/mapping/track-to-artist.ts b/src/utils/mapping/track-to-artist.ts index e1caf27f4..3eae01c63 100644 --- a/src/utils/mapping/track-to-artist.ts +++ b/src/utils/mapping/track-to-artist.ts @@ -7,7 +7,7 @@ import { ItemFields, } from '@jellyfin/sdk/lib/generated-client' import { getItemsApi } from '@jellyfin/sdk/lib/utils/api' -import { isEmpty, isUndefined, uniq } from 'lodash' +import { isUndefined, uniq } from 'lodash' import { queryClient } from '../../constants/query-client' import { ArtistQueryKey } from '../../api/queries/artist/keys' @@ -32,6 +32,9 @@ export async function mapTracksToArtists( .filter((Id) => !isUndefined(Id)), ) + // Avoid sending an empty `ids` filter, which some servers treat as "no filter" + if (artistIds.length === 0) return [] + return await getItemsApi(api!) .getItems( { @@ -63,4 +66,8 @@ export async function mapTracksToArtists( return aIndex - bIndex }) }) + .catch((error) => { + console.error('Failed to map tracks to artists', error) + throw error + }) } From 191f53baf0a086223d55541f4b6011c93d87c80d Mon Sep 17 00:00:00 2001 From: Violet Caulfield <42452695+anultravioletaurora@users.noreply.github.com> Date: Fri, 11 Sep 2026 15:42:01 -0500 Subject: [PATCH 14/15] Report recent artists mapping failures to Sentry for diagnosis --- src/api/queries/recents/utils/index.ts | 4 ++-- src/utils/mapping/track-to-artist.ts | 7 ++++++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/api/queries/recents/utils/index.ts b/src/api/queries/recents/utils/index.ts index 5cfffbf0e..4288e96ae 100644 --- a/src/api/queries/recents/utils/index.ts +++ b/src/api/queries/recents/utils/index.ts @@ -19,6 +19,7 @@ import { PlayItAgainQuery } from '..' import { ArtistQueryKey } from '../../artist/keys' import { setQueryUserDataForItem } from '../../user-data' import { mapTracksToArtists } from '../../../../utils/mapping/track-to-artist' +import { captureError, LoggingContext } from '../../../../utils/logging' export async function fetchRecentlyAdded( api: Api | undefined, @@ -172,10 +173,9 @@ export async function fetchRecentlyPlayedArtists( staleTime: 'static', }) - console.debug(recentTracks.map((track) => track.Id).join(',')) - return await mapTracksToArtists(recentTracks) } catch (error) { + captureError(error, LoggingContext.Artists, 'Failed to fetch recently played artists') return Promise.reject(error) } } diff --git a/src/utils/mapping/track-to-artist.ts b/src/utils/mapping/track-to-artist.ts index 3eae01c63..2f1c578bf 100644 --- a/src/utils/mapping/track-to-artist.ts +++ b/src/utils/mapping/track-to-artist.ts @@ -10,6 +10,7 @@ import { getItemsApi } from '@jellyfin/sdk/lib/utils/api' import { isUndefined, uniq } from 'lodash' import { queryClient } from '../../constants/query-client' import { ArtistQueryKey } from '../../api/queries/artist/keys' +import { captureError, LoggingContext } from '../logging' /** * @@ -67,7 +68,11 @@ export async function mapTracksToArtists( }) }) .catch((error) => { - console.error('Failed to map tracks to artists', error) + captureError( + error, + LoggingContext.Artists, + `Failed to map ${tracks.length} tracks to ${artistIds.length} artist ids`, + ) throw error }) } From 5fe978360f274c9532e53dc04f0bceac19ae50a2 Mon Sep 17 00:00:00 2001 From: Violet Caulfield <42452695+anultravioletaurora@users.noreply.github.com> Date: Sat, 12 Sep 2026 05:25:50 -0500 Subject: [PATCH 15/15] smol improvements to home screen refreshing --- src/api/queries/artist/utils/artist.ts | 2 +- src/api/queries/recents/utils/index.ts | 6 +- .../Global/helpers/swipe-actions.ts | 4 +- src/utils/mapping/track-to-artist.ts | 83 +++++-------------- 4 files changed, 25 insertions(+), 70 deletions(-) diff --git a/src/api/queries/artist/utils/artist.ts b/src/api/queries/artist/utils/artist.ts index 5343046f6..809cbcfef 100644 --- a/src/api/queries/artist/utils/artist.ts +++ b/src/api/queries/artist/utils/artist.ts @@ -49,7 +49,7 @@ export async function fetchArtists( staleTime: 'static', }) - items = await mapTracksToArtists(recentTracks, signal) + items = mapTracksToArtists(recentTracks) break case 'SortName': diff --git a/src/api/queries/recents/utils/index.ts b/src/api/queries/recents/utils/index.ts index 4288e96ae..eea6e5a5f 100644 --- a/src/api/queries/recents/utils/index.ts +++ b/src/api/queries/recents/utils/index.ts @@ -19,7 +19,6 @@ import { PlayItAgainQuery } from '..' import { ArtistQueryKey } from '../../artist/keys' import { setQueryUserDataForItem } from '../../user-data' import { mapTracksToArtists } from '../../../../utils/mapping/track-to-artist' -import { captureError, LoggingContext } from '../../../../utils/logging' export async function fetchRecentlyAdded( api: Api | undefined, @@ -173,9 +172,10 @@ export async function fetchRecentlyPlayedArtists( staleTime: 'static', }) - return await mapTracksToArtists(recentTracks) + console.debug(recentTracks.map((track) => track.Id).join(',')) + + return mapTracksToArtists(recentTracks) } catch (error) { - captureError(error, LoggingContext.Artists, 'Failed to fetch recently played artists') return Promise.reject(error) } } 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/utils/mapping/track-to-artist.ts b/src/utils/mapping/track-to-artist.ts index 2f1c578bf..9ac643376 100644 --- a/src/utils/mapping/track-to-artist.ts +++ b/src/utils/mapping/track-to-artist.ts @@ -1,16 +1,6 @@ -import { setQueryUserDataForItem } from '../../api/queries/user-data' -import { getApi, getUser } from '../../stores/auth/utils' -import { - BaseItemDto, - BaseItemKind, - ImageType, - ItemFields, -} from '@jellyfin/sdk/lib/generated-client' -import { getItemsApi } from '@jellyfin/sdk/lib/utils/api' -import { isUndefined, uniq } from 'lodash' -import { queryClient } from '../../constants/query-client' -import { ArtistQueryKey } from '../../api/queries/artist/keys' -import { captureError, LoggingContext } from '../logging' +import { BaseItemDto, BaseItemKind } from '@jellyfin/sdk/lib/generated-client' +import { uniqBy } from 'lodash' +import { captureWarning, LoggingContext } from '../../utils/logging' /** * @@ -18,61 +8,26 @@ import { captureError, LoggingContext } from '../logging' * @param signal * @returns */ -export async function mapTracksToArtists( - tracks: BaseItemDto[], - signal?: AbortSignal, -): Promise { - const api = getApi() - const user = getUser() - - const artistIds = uniq( +export function mapTracksToArtists(tracks: BaseItemDto[]): BaseItemDto[] { + const artists: BaseItemDto[] = uniqBy( tracks - .map((track) => track.ArtistItems) - .filter((artists) => !!artists && artists.length > 0) - .map((artists) => artists?.[0].Id) - .filter((Id) => !isUndefined(Id)), + .flatMap((track) => track.ArtistItems) + .filter((artist) => !!artist && artist.Id) + .map((artist) => ({ + ...artist, + Type: BaseItemKind.MusicArtist, + })), + 'Id', ) - // Avoid sending an empty `ids` filter, which some servers treat as "no filter" - if (artistIds.length === 0) return [] - - return await getItemsApi(api!) - .getItems( - { - userId: user?.id, - includeItemTypes: [BaseItemKind.MusicArtist], - ids: artistIds, - fields: [ItemFields.Genres, ItemFields.SortName], - enableImages: true, - enableImageTypes: [ImageType.Backdrop, ImageType.Primary], - imageTypeLimit: 1, - enableUserData: true, - }, - { - signal, - }, + if (tracks.length > 0 && artists.length === 0) { + captureWarning( + LoggingContext.Recents, + `mapTracksToArtists got ${tracks.length} tracks but derived 0 artistIds from ArtistItems`, ) - .then(({ data }) => { - const fetchedArtists = data.Items ?? [] - - fetchedArtists.forEach((artist) => { - setQueryUserDataForItem(artist) - queryClient.setQueryData(ArtistQueryKey(artist.Id), artist) - }) - return fetchedArtists.sort((a, b) => { - const aIndex = artistIds.findIndex((Id) => a.Id === Id) - const bIndex = artistIds.findIndex((Id) => b.Id === Id) + return [] + } - return aIndex - bIndex - }) - }) - .catch((error) => { - captureError( - error, - LoggingContext.Artists, - `Failed to map ${tracks.length} tracks to ${artistIds.length} artist ids`, - ) - throw error - }) + return artists }