Improve A-Z navigation performance (#980)

* add letter filter parameters to API fetch functions

Add NameStartsWithOrGreater and NameLessThan parameters to:
- fetchArtists
- fetchAlbums
- fetchTracks

These parameters enable direct letter-based filtering without
fetching all preceding items.

* add flattenWithLetterHeaders utility function

Export helper function for merging bidirectional query data
with letter section headers.

* add letter-anchored hooks for bidirectional navigation

Implement useLetterAnchoredArtists, useLetterAnchoredAlbums,
and useLetterAnchoredTracks hooks that use dual infinite queries:
- Forward query with NameStartsWithOrGreater for items from anchor
- Backward query with NameLessThan for items before anchor

This enables instant letter jumping without paginating through
all preceding items.

* add library cache validation hook

Implement useLibraryCacheValidation hook that validates cached
data on app focus using lightweight Limit=0 count requests.
Invalidates relevant queries when library counts change.

* add letter-anchored list components

Create LetterAnchoredArtists, LetterAnchoredAlbums, and
LetterAnchoredTracks components that use the new bidirectional
hooks and support instant A-Z navigation with smooth scrolling
in both directions.

* update library tabs to use letter-anchored components

Switch ArtistsTab, AlbumsTab, and TracksTab to use the new
letter-anchored components for instant A-Z navigation.

* remove unnecessary memoization

React Compiler handles memoization automatically, so useMemo
and useCallback are not needed in the letter-anchored hooks.

* improve AZ scroller letter distribution across screen sizes

* consolidate LetterFilter type, remove unused import, fix cache validation sharing

- Move duplicated LetterFilter interface to shared src/api/types/letter-filter.ts
- Remove unused flattenWithLetterHeaders import from artist/index.ts
- Use module-level state in useLibraryCacheValidation so multiple
  component instances share the same throttle/mutex
This commit is contained in:
skalthoff
2026-02-18 18:19:20 -08:00
committed by GitHub
parent f0935e2063
commit f1be9c00f7
17 changed files with 1141 additions and 114 deletions

View File

@@ -8,7 +8,7 @@ import {
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 { RefObject, useRef } from 'react'
import { RefObject, useRef, useState } from 'react'
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client'
import flattenInfiniteQueryPages from '../../../utils/query-selectors'
import { ApiLimits, MaxPages } from '../../../configs/query.config'
@@ -86,6 +86,7 @@ const useAlbums: () => [
isFavorites,
[librarySortBy ?? ItemSortBy.SortName],
[sortDescending ? SortOrder.Descending : SortOrder.Ascending],
undefined,
yearMin,
yearMax,
),
@@ -138,3 +139,140 @@ export const AlbumDiscsQuery = (api: Api | undefined, album: BaseItemDto) => ({
queryKey: AlbumDiscsQueryKey(album),
queryFn: () => fetchAlbumDiscs(api, album),
})
export interface LetterAnchoredAlbumsResult {
data: (string | BaseItemDto)[]
letters: Set<string>
anchorLetter: string | null
setAnchorLetter: (letter: string | null) => void
fetchNextPage: () => void
hasNextPage: boolean
fetchPreviousPage: () => void
hasPreviousPage: boolean
isFetching: boolean
isPending: boolean
refetch: () => void
anchorIndex: number
}
export const useLetterAnchoredAlbums = (): LetterAnchoredAlbumsResult => {
const api = getApi()
const user = getUser()
const [library] = useJellifyLibrary()
const isFavorites = useLibraryStore((state) => state.filters.albums.isFavorites)
const [anchorLetter, setAnchorLetter] = useState<string | null>(null)
const forwardQuery = useInfiniteQuery({
queryKey: [
QueryKeys.InfiniteAlbums,
'forward',
anchorLetter,
isFavorites,
library?.musicLibraryId,
],
queryFn: ({ pageParam }: { pageParam: number }) =>
fetchAlbums(
api,
user,
library,
pageParam,
isFavorites,
[ItemSortBy.SortName],
[SortOrder.Ascending],
anchorLetter ? { nameStartsWithOrGreater: anchorLetter } : undefined,
),
maxPages: MaxPages.Library,
initialPageParam: 0,
getNextPageParam: (lastPage, allPages, lastPageParam) => {
return lastPage.length === ApiLimits.Library ? lastPageParam + 1 : undefined
},
staleTime: Infinity,
})
const backwardQuery = useInfiniteQuery({
queryKey: [
QueryKeys.InfiniteAlbums,
'backward',
anchorLetter,
isFavorites,
library?.musicLibraryId,
],
queryFn: ({ pageParam }: { pageParam: number }) =>
fetchAlbums(
api,
user,
library,
pageParam,
isFavorites,
[ItemSortBy.SortName],
[SortOrder.Descending],
{ nameLessThan: anchorLetter! },
),
enabled: anchorLetter !== null,
maxPages: MaxPages.Library,
initialPageParam: 0,
getNextPageParam: (lastPage, allPages, lastPageParam) => {
return lastPage.length === ApiLimits.Library ? lastPageParam + 1 : undefined
},
staleTime: Infinity,
})
const seenLetters = new Set<string>()
const mergedData: (string | BaseItemDto)[] = []
const backwardItems = backwardQuery.data?.pages.flat().reverse() ?? []
backwardItems.forEach((item: BaseItemDto) => {
const rawLetter = item.SortName?.charAt(0).toUpperCase() ?? '#'
const letter = rawLetter.match(/[A-Z]/) ? rawLetter : '#'
if (!seenLetters.has(letter)) {
seenLetters.add(letter)
mergedData.push(letter)
}
mergedData.push(item)
})
const anchorIndex = mergedData.length
const forwardItems = forwardQuery.data?.pages.flat() ?? []
forwardItems.forEach((item: BaseItemDto) => {
const rawLetter = item.SortName?.charAt(0).toUpperCase() ?? '#'
const letter = rawLetter.match(/[A-Z]/) ? rawLetter : '#'
if (!seenLetters.has(letter)) {
seenLetters.add(letter)
mergedData.push(letter)
}
mergedData.push(item)
})
const handleSetAnchorLetter = (letter: string | null) => {
if (letter === '#') {
setAnchorLetter(null)
} else {
setAnchorLetter(letter?.toUpperCase() ?? null)
}
}
const refetch = () => {
forwardQuery.refetch()
if (anchorLetter) backwardQuery.refetch()
}
return {
data: mergedData,
letters: seenLetters,
anchorLetter,
setAnchorLetter: handleSetAnchorLetter,
fetchNextPage: forwardQuery.fetchNextPage,
hasNextPage: forwardQuery.hasNextPage ?? false,
fetchPreviousPage: backwardQuery.fetchNextPage,
hasPreviousPage: (anchorLetter !== null && backwardQuery.hasNextPage) ?? false,
isFetching: forwardQuery.isFetching || backwardQuery.isFetching,
isPending: forwardQuery.isPending,
refetch,
anchorIndex,
}
}

View File

@@ -12,6 +12,7 @@ import { JellifyUser } from '../../../../types/JellifyUser'
import { ApiLimits } from '../../../../configs/query.config'
import { nitroFetch } from '../../../utils/nitro'
import buildYearsParam from '../../../../utils/mapping/build-years-param'
import { LetterFilter } from '../../../types/letter-filter'
export function fetchAlbums(
api: Api | undefined,
@@ -21,6 +22,7 @@ export function fetchAlbums(
isFavorite: boolean | undefined,
sortBy: ItemSortBy[] = [ItemSortBy.SortName],
sortOrder: SortOrder[] = [SortOrder.Ascending],
letterFilter?: LetterFilter,
yearMin?: number,
yearMax?: number,
): Promise<BaseItemDto[]> {
@@ -42,6 +44,8 @@ export function fetchAlbums(
IsFavorite: isFavorite,
Fields: [ItemFields.SortName],
Recursive: true,
NameStartsWithOrGreater: letterFilter?.nameStartsWithOrGreater,
NameLessThan: letterFilter?.nameLessThan,
Years: yearsParam,
})
.then((data) => {

View File

@@ -9,7 +9,7 @@ import {
import { isUndefined } from 'lodash'
import { fetchArtistAlbums, fetchArtistFeaturedOn, fetchArtists } from './utils/artist'
import { ApiLimits, MaxPages } from '../../../configs/query.config'
import { RefObject, useRef } from 'react'
import { RefObject, useRef, useState } from 'react'
import flattenInfiniteQueryPages from '../../../utils/query-selectors'
import { useApi, useJellifyLibrary, useJellifyUser } from '../../../stores'
import useLibraryStore from '../../../stores/library'
@@ -102,3 +102,168 @@ export const useAlbumArtists: () => [
return [artistPageParams, artistsInfiniteQuery]
}
export interface LetterAnchoredArtistsResult {
/** The merged data with letter headers, ready for FlashList */
data: (string | BaseItemDto)[]
/** Set of letters present in the data */
letters: Set<string>
/** Current anchor letter (null = start from beginning) */
anchorLetter: string | null
/** Set anchor letter - triggers instant jump */
setAnchorLetter: (letter: string | null) => void
/** Fetch next page (forward direction) */
fetchNextPage: () => void
/** Whether there are more pages forward */
hasNextPage: boolean
/** Fetch previous page (backward direction) */
fetchPreviousPage: () => void
/** Whether there are more pages backward */
hasPreviousPage: boolean
/** Whether any query is currently fetching */
isFetching: boolean
/** Whether the initial load is pending */
isPending: boolean
/** Refetch both queries */
refetch: () => void
/** Index where forward data starts (for scroll positioning) */
anchorIndex: number
}
/**
* Hook for letter-anchored bidirectional artist navigation.
* Instantly jumps to a letter using NameStartsWithOrGreater,
* and supports scrolling backward using NameLessThan.
*/
export const useLetterAnchoredArtists = (): LetterAnchoredArtistsResult => {
const api = useApi()
const [user] = useJellifyUser()
const [library] = useJellifyLibrary()
const { filters, sortDescending } = useLibraryStore()
const isFavorites = filters.artists.isFavorites
// Anchor letter state - null means start from beginning
const [anchorLetter, setAnchorLetter] = useState<string | null>(null)
// Forward query: fetches from anchor letter onwards
const forwardQuery = useInfiniteQuery({
queryKey: [
QueryKeys.InfiniteArtists,
'forward',
anchorLetter,
isFavorites,
sortDescending,
library?.musicLibraryId,
],
queryFn: ({ pageParam }: { pageParam: number }) =>
fetchArtists(
api,
user,
library,
pageParam,
isFavorites,
[ItemSortBy.SortName],
[SortOrder.Ascending],
anchorLetter ? { nameStartsWithOrGreater: anchorLetter } : undefined,
),
maxPages: MaxPages.Library,
initialPageParam: 0,
getNextPageParam: (lastPage, allPages, lastPageParam) => {
return lastPage.length === ApiLimits.Library ? lastPageParam + 1 : undefined
},
staleTime: Infinity,
})
// Backward query: fetches items before anchor letter (only when anchor is set)
const backwardQuery = useInfiniteQuery({
queryKey: [
QueryKeys.InfiniteArtists,
'backward',
anchorLetter,
isFavorites,
sortDescending,
library?.musicLibraryId,
],
queryFn: ({ pageParam }: { pageParam: number }) =>
fetchArtists(
api,
user,
library,
pageParam,
isFavorites,
[ItemSortBy.SortName],
[SortOrder.Descending], // Descending to get L, K, J... order
{ nameLessThan: anchorLetter! },
),
enabled: anchorLetter !== null, // Only fetch when we have an anchor
maxPages: MaxPages.Library,
initialPageParam: 0,
getNextPageParam: (lastPage, allPages, lastPageParam) => {
return lastPage.length === ApiLimits.Library ? lastPageParam + 1 : undefined
},
staleTime: Infinity,
})
// Merge backward (reversed) + forward data with letter headers
const seenLetters = new Set<string>()
const mergedData: (string | BaseItemDto)[] = []
// Process backward data (reverse it to get correct order: A, B, C... not L, K, J...)
const backwardItems = backwardQuery.data?.pages.flat().reverse() ?? []
backwardItems.forEach((item: BaseItemDto) => {
const rawLetter = item.SortName?.charAt(0).toUpperCase() ?? '#'
const letter = rawLetter.match(/[A-Z]/) ? rawLetter : '#'
if (!seenLetters.has(letter)) {
seenLetters.add(letter)
mergedData.push(letter)
}
mergedData.push(item)
})
// Track where forward data starts
const anchorIndex = mergedData.length
// Process forward data
const forwardItems = forwardQuery.data?.pages.flat() ?? []
forwardItems.forEach((item: BaseItemDto) => {
const rawLetter = item.SortName?.charAt(0).toUpperCase() ?? '#'
const letter = rawLetter.match(/[A-Z]/) ? rawLetter : '#'
if (!seenLetters.has(letter)) {
seenLetters.add(letter)
mergedData.push(letter)
}
mergedData.push(item)
})
const handleSetAnchorLetter = (letter: string | null) => {
// '#' means items before 'A' (numbers/symbols)
if (letter === '#') {
setAnchorLetter(null) // Start from beginning
} else {
setAnchorLetter(letter?.toUpperCase() ?? null)
}
}
const refetch = () => {
forwardQuery.refetch()
if (anchorLetter) backwardQuery.refetch()
}
return {
data: mergedData,
letters: seenLetters,
anchorLetter,
setAnchorLetter: handleSetAnchorLetter,
fetchNextPage: forwardQuery.fetchNextPage,
hasNextPage: forwardQuery.hasNextPage ?? false,
fetchPreviousPage: backwardQuery.fetchNextPage, // Note: "next" in backward direction
hasPreviousPage: (anchorLetter !== null && backwardQuery.hasNextPage) ?? false,
isFetching: forwardQuery.isFetching || backwardQuery.isFetching,
isPending: forwardQuery.isPending,
refetch,
anchorIndex,
}
}

View File

@@ -11,6 +11,7 @@ import { getArtistsApi, getItemsApi } from '@jellyfin/sdk/lib/utils/api'
import { JellifyUser } from '../../../../types/JellifyUser'
import { ApiLimits } from '../../../../configs/query.config'
import { nitroFetch } from '../../../utils/nitro'
import { LetterFilter } from '../../../types/letter-filter'
export function fetchArtists(
api: Api | undefined,
@@ -20,6 +21,7 @@ export function fetchArtists(
isFavorite: boolean | undefined,
sortBy: ItemSortBy[] = [ItemSortBy.SortName],
sortOrder: SortOrder[] = [SortOrder.Ascending],
letterFilter?: LetterFilter,
): Promise<BaseItemDto[]> {
return new Promise((resolve, reject) => {
if (!api) return reject('No API instance provided')
@@ -35,6 +37,8 @@ export function fetchArtists(
Limit: ApiLimits.Library,
IsFavorite: isFavorite,
Fields: [ItemFields.SortName, ItemFields.Genres],
NameStartsWithOrGreater: letterFilter?.nameStartsWithOrGreater,
NameLessThan: letterFilter?.nameLessThan,
})
.then((data) => {
return data.Items ? resolve(data.Items) : resolve([])
@@ -45,6 +49,29 @@ export function fetchArtists(
})
}
/**
* Fetches just the total count of artists (for cache validation)
*/
export function fetchArtistsCount(
api: Api | undefined,
user: JellifyUser | undefined,
library: JellifyLibrary | undefined,
): Promise<number> {
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')
nitroFetch<{ TotalRecordCount: number }>(api, '/Artists/AlbumArtists', {
ParentId: library.musicLibraryId,
UserId: user.id,
Limit: 0,
})
.then((data) => resolve(data.TotalRecordCount ?? 0))
.catch((error) => reject(error))
})
}
/**
* Fetches all albums for an artist
* @param api The Jellyfin {@link Api} instance

View File

@@ -7,15 +7,16 @@ import {
SortOrder,
UserItemDataDto,
} from '@jellyfin/sdk/lib/generated-client'
import { RefObject, useRef } from 'react'
import { RefObject, useRef, useState } from 'react'
import flattenInfiniteQueryPages from '../../../utils/query-selectors'
import { ApiLimits } from '../../../configs/query.config'
import { ApiLimits, MaxPages } from '../../../configs/query.config'
import { useAllDownloadedTracks } from '../download'
import { queryClient } from '../../../constants/query-client'
import UserDataQueryKey from '../user-data/keys'
import { JellifyUser } from '@/src/types/JellifyUser'
import { useJellifyLibrary, getApi, getUser } from '../../../stores'
import useLibraryStore from '../../../stores/library'
import { QueryKeys } from '../../../enums/query-keys'
const useTracks: (
artistId?: string,
@@ -113,6 +114,7 @@ const useTracks: (
finalSortOrder,
artistId,
libraryGenreIds,
undefined,
libraryYearMin,
libraryYearMax,
)
@@ -164,6 +166,157 @@ function isDownloadedTrackAlsoFavorite(user: JellifyUser | undefined, track: Bas
return userData?.IsFavorite ?? false
}
export interface LetterAnchoredTracksResult {
data: (string | BaseItemDto)[]
letters: Set<string>
anchorLetter: string | null
setAnchorLetter: (letter: string | null) => void
fetchNextPage: () => void
hasNextPage: boolean
fetchPreviousPage: () => void
hasPreviousPage: boolean
isFetching: boolean
isPending: boolean
refetch: () => void
anchorIndex: number
}
export const useLetterAnchoredTracks = (): LetterAnchoredTracksResult => {
const api = getApi()
const user = getUser()
const [library] = useJellifyLibrary()
const { filters } = useLibraryStore()
const isFavorites = filters.tracks.isFavorites
const isUnplayed = filters.tracks.isUnplayed
const genreIds = filters.tracks.genreIds
const [anchorLetter, setAnchorLetter] = useState<string | null>(null)
const forwardQuery = useInfiniteQuery({
queryKey: [
QueryKeys.InfiniteTracks,
'forward',
anchorLetter,
isFavorites,
isUnplayed,
genreIds,
library?.musicLibraryId,
],
queryFn: ({ pageParam }: { pageParam: number }) =>
fetchTracks(
api,
user,
library,
pageParam,
isFavorites,
isUnplayed,
ItemSortBy.Name,
SortOrder.Ascending,
undefined, // artistId
genreIds,
anchorLetter ? { nameStartsWithOrGreater: anchorLetter } : undefined,
),
maxPages: MaxPages.Library,
initialPageParam: 0,
getNextPageParam: (lastPage, allPages, lastPageParam) => {
return lastPage.length === ApiLimits.Library ? lastPageParam + 1 : undefined
},
staleTime: Infinity,
})
const backwardQuery = useInfiniteQuery({
queryKey: [
QueryKeys.InfiniteTracks,
'backward',
anchorLetter,
isFavorites,
isUnplayed,
genreIds,
library?.musicLibraryId,
],
queryFn: ({ pageParam }: { pageParam: number }) =>
fetchTracks(
api,
user,
library,
pageParam,
isFavorites,
isUnplayed,
ItemSortBy.Name,
SortOrder.Descending,
undefined,
genreIds,
{ nameLessThan: anchorLetter! },
),
enabled: anchorLetter !== null,
maxPages: MaxPages.Library,
initialPageParam: 0,
getNextPageParam: (lastPage, allPages, lastPageParam) => {
return lastPage.length === ApiLimits.Library ? lastPageParam + 1 : undefined
},
staleTime: Infinity,
})
// For tracks, we use Name instead of SortName (see comment in fetchTracks)
const seenLetters = new Set<string>()
const mergedData: (string | BaseItemDto)[] = []
const backwardItems = backwardQuery.data?.pages.flat().reverse() ?? []
backwardItems.forEach((item: BaseItemDto) => {
const rawLetter = item.Name?.trim().charAt(0).toUpperCase() ?? '#'
const letter = rawLetter.match(/[A-Z]/) ? rawLetter : '#'
if (!seenLetters.has(letter)) {
seenLetters.add(letter)
mergedData.push(letter)
}
mergedData.push(item)
})
const anchorIndex = mergedData.length
const forwardItems = forwardQuery.data?.pages.flat() ?? []
forwardItems.forEach((item: BaseItemDto) => {
const rawLetter = item.Name?.trim().charAt(0).toUpperCase() ?? '#'
const letter = rawLetter.match(/[A-Z]/) ? rawLetter : '#'
if (!seenLetters.has(letter)) {
seenLetters.add(letter)
mergedData.push(letter)
}
mergedData.push(item)
})
const handleSetAnchorLetter = (letter: string | null) => {
if (letter === '#') {
setAnchorLetter(null)
} else {
setAnchorLetter(letter?.toUpperCase() ?? null)
}
}
const refetch = () => {
forwardQuery.refetch()
if (anchorLetter) backwardQuery.refetch()
}
return {
data: mergedData,
letters: seenLetters,
anchorLetter,
setAnchorLetter: handleSetAnchorLetter,
fetchNextPage: forwardQuery.fetchNextPage,
hasNextPage: forwardQuery.hasNextPage ?? false,
fetchPreviousPage: backwardQuery.fetchNextPage,
hasPreviousPage: (anchorLetter !== null && backwardQuery.hasNextPage) ?? false,
isFetching: forwardQuery.isFetching || backwardQuery.isFetching,
isPending: forwardQuery.isPending,
refetch,
anchorIndex,
}
}
function getSortValue(item: BaseItemDto, sortBy: ItemSortBy): string | number {
switch (sortBy) {
case ItemSortBy.Name:

View File

@@ -13,6 +13,7 @@ import { isUndefined } from 'lodash'
import { ApiLimits } from '../../../../configs/query.config'
import { JellifyUser } from '../../../../types/JellifyUser'
import buildYearsParam from '../../../../utils/mapping/build-years-param'
import { LetterFilter } from '../../../types/letter-filter'
export default function fetchTracks(
api: Api | undefined,
@@ -25,6 +26,7 @@ export default function fetchTracks(
sortOrder: SortOrder = SortOrder.Ascending,
artistId?: string,
genreIds?: string[],
letterFilter?: LetterFilter,
yearMin?: number,
yearMax?: number,
) {
@@ -61,6 +63,8 @@ export default function fetchTracks(
Fields: [ItemFields.SortName],
ArtistIds: artistId ? [artistId] : undefined,
GenreIds: genreIds && genreIds.length > 0 ? genreIds : undefined,
NameStartsWithOrGreater: letterFilter?.nameStartsWithOrGreater,
NameLessThan: letterFilter?.nameLessThan,
Years: yearsParam,
})
.then((data) => {

View File

@@ -0,0 +1,4 @@
export interface LetterFilter {
nameStartsWithOrGreater?: string
nameLessThan?: string
}

View File

@@ -0,0 +1,137 @@
import React, { useEffect, useRef } from 'react'
import { RefreshControl } from 'react-native'
import { useTheme, XStack, YStack } from 'tamagui'
import { FlashList, FlashListRef } from '@shopify/flash-list'
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models/base-item-dto'
import { useNavigation } from '@react-navigation/native'
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import { Text } from '../Global/helpers/text'
import ItemRow from '../Global/components/item-row'
import AZScroller from '../Global/components/alphabetical-selector'
import FlashListStickyHeader from '../Global/helpers/flashlist-sticky-header'
import { closeAllSwipeableRows } from '../Global/components/swipeable-row-registry'
import LibraryStackParamList from '../../screens/Library/types'
import useLibraryStore from '../../stores/library'
import MAX_ITEMS_IN_RECYCLE_POOL from '../../configs/library.config'
import { useLetterAnchoredAlbums } from '../../api/queries/album'
import useLibraryCacheValidation from '../../hooks/use-library-cache-validation'
/**
* Letter-anchored Albums component with instant A-Z navigation.
*/
export default function LetterAnchoredAlbums(): React.JSX.Element {
const theme = useTheme()
const navigation = useNavigation<NativeStackNavigationProp<LibraryStackParamList>>()
const isFavorites = useLibraryStore((state) => state.filters.albums.isFavorites)
useLibraryCacheValidation()
const {
data: albums,
letters,
anchorLetter,
setAnchorLetter,
fetchNextPage,
hasNextPage,
fetchPreviousPage,
hasPreviousPage,
isFetching,
isPending,
refetch,
anchorIndex,
} = useLetterAnchoredAlbums()
const sectionListRef = useRef<FlashListRef<string | BaseItemDto>>(null)
const pendingScrollRef = useRef<boolean>(false)
const stickyHeaderIndices = albums
.map((item, index) => (typeof item === 'string' ? index : -1))
.filter((index) => index !== -1)
const keyExtractor = (item: BaseItemDto | string, index: number) =>
typeof item === 'string' ? `header-${item}` : item.Id!
const renderItem = ({ index, item }: { index: number; item: BaseItemDto | string }) => {
if (typeof item === 'string') {
if (index + 1 >= albums.length || typeof albums[index + 1] !== 'object') {
return null
}
return <FlashListStickyHeader text={item} />
}
return <ItemRow item={item} navigation={navigation} />
}
useEffect(() => {
if (pendingScrollRef.current && anchorIndex > 0 && albums.length > 0) {
const timer = setTimeout(() => {
sectionListRef.current?.scrollToIndex({
index: anchorIndex,
viewPosition: 0.1,
animated: true,
})
pendingScrollRef.current = false
}, 100)
return () => clearTimeout(timer)
}
}, [anchorIndex, albums.length])
const handleLetterSelect = async (letter: string) => {
pendingScrollRef.current = true
setAnchorLetter(letter === '#' ? null : letter.toUpperCase())
if (letter === '#' || letter.toUpperCase() === 'A') {
pendingScrollRef.current = false
sectionListRef.current?.scrollToIndex({
index: 0,
viewPosition: 0,
animated: true,
})
}
}
return (
<XStack flex={1}>
<FlashList
contentInsetAdjustmentBehavior='automatic'
ref={sectionListRef}
extraData={isFavorites}
keyExtractor={keyExtractor}
ListEmptyComponent={
<YStack flex={1} justify='center' alignItems='center'>
<Text marginVertical='auto' color={'$borderColor'}>
No albums
</Text>
</YStack>
}
data={albums}
refreshControl={
<RefreshControl
refreshing={isPending}
onRefresh={refetch}
tintColor={theme.primary.val}
/>
}
renderItem={renderItem}
stickyHeaderIndices={stickyHeaderIndices}
stickyHeaderConfig={{
useNativeDriver: false,
}}
onStartReached={() => {
if (hasPreviousPage && !isFetching) {
fetchPreviousPage()
}
}}
onEndReached={() => {
if (hasNextPage && !isFetching) {
fetchNextPage()
}
}}
onScrollBeginDrag={closeAllSwipeableRows}
removeClippedSubviews
maxItemsInRecyclePool={MAX_ITEMS_IN_RECYCLE_POOL}
/>
<AZScroller onLetterSelect={handleLetterSelect} />
</XStack>
)
}

View File

@@ -0,0 +1,144 @@
import React, { useEffect, useRef } from 'react'
import { RefreshControl } from 'react-native'
import { useTheme, XStack, YStack } from 'tamagui'
import { FlashList, FlashListRef } from '@shopify/flash-list'
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models/base-item-dto'
import { useNavigation } from '@react-navigation/native'
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import { Text } from '../Global/helpers/text'
import ItemRow from '../Global/components/item-row'
import AZScroller from '../Global/components/alphabetical-selector'
import FlashListStickyHeader from '../Global/helpers/flashlist-sticky-header'
import { closeAllSwipeableRows } from '../Global/components/swipeable-row-registry'
import LibraryStackParamList from '../../screens/Library/types'
import useLibraryStore from '../../stores/library'
import MAX_ITEMS_IN_RECYCLE_POOL from '../../configs/library.config'
import { useLetterAnchoredArtists } from '../../api/queries/artist'
import useLibraryCacheValidation from '../../hooks/use-library-cache-validation'
/**
* Letter-anchored Artists component with instant A-Z navigation.
* Uses bidirectional queries for smooth scrolling in both directions.
*/
export default function LetterAnchoredArtists(): React.JSX.Element {
const theme = useTheme()
const navigation = useNavigation<NativeStackNavigationProp<LibraryStackParamList>>()
const isFavorites = useLibraryStore((state) => state.filters.artists.isFavorites)
// Enable cache validation on app focus
useLibraryCacheValidation()
const {
data: artists,
letters,
anchorLetter,
setAnchorLetter,
fetchNextPage,
hasNextPage,
fetchPreviousPage,
hasPreviousPage,
isFetching,
isPending,
refetch,
anchorIndex,
} = useLetterAnchoredArtists()
const sectionListRef = useRef<FlashListRef<string | BaseItemDto>>(null)
const pendingScrollRef = useRef<boolean>(false)
// Calculate sticky header indices (positions of letter headers)
const stickyHeaderIndices = artists
.map((item, index) => (typeof item === 'string' ? index : -1))
.filter((index) => index !== -1)
const keyExtractor = (item: BaseItemDto | string, index: number) =>
typeof item === 'string' ? `header-${item}` : item.Id!
const renderItem = ({ index, item }: { index: number; item: BaseItemDto | string }) => {
if (typeof item === 'string') {
// Don't render empty letter headers
if (index + 1 >= artists.length || typeof artists[index + 1] !== 'object') {
return null
}
return <FlashListStickyHeader text={item} />
}
return <ItemRow circular item={item} navigation={navigation} />
}
// Scroll to anchor position when anchor changes
useEffect(() => {
if (pendingScrollRef.current && anchorIndex > 0 && artists.length > 0) {
// Small delay to let FlashList update
const timer = setTimeout(() => {
sectionListRef.current?.scrollToIndex({
index: anchorIndex,
viewPosition: 0.1,
animated: true,
})
pendingScrollRef.current = false
}, 100)
return () => clearTimeout(timer)
}
}, [anchorIndex, artists.length])
const handleLetterSelect = async (letter: string) => {
pendingScrollRef.current = true
setAnchorLetter(letter === '#' ? null : letter.toUpperCase())
// If selecting # or A (start of list), scroll to top
if (letter === '#' || letter.toUpperCase() === 'A') {
pendingScrollRef.current = false
sectionListRef.current?.scrollToIndex({
index: 0,
viewPosition: 0,
animated: true,
})
}
}
return (
<XStack flex={1}>
<FlashList
contentInsetAdjustmentBehavior='automatic'
ref={sectionListRef}
extraData={isFavorites}
keyExtractor={keyExtractor}
ListEmptyComponent={
<YStack flex={1} justify='center' alignItems='center'>
<Text marginVertical='auto' color={'$borderColor'}>
No artists
</Text>
</YStack>
}
data={artists}
refreshControl={
<RefreshControl
refreshing={isPending}
onRefresh={refetch}
tintColor={theme.primary.val}
/>
}
renderItem={renderItem}
stickyHeaderIndices={stickyHeaderIndices}
stickyHeaderConfig={{
useNativeDriver: false,
}}
onStartReached={() => {
if (hasPreviousPage && !isFetching) {
fetchPreviousPage()
}
}}
onEndReached={() => {
if (hasNextPage && !isFetching) {
fetchNextPage()
}
}}
onScrollBeginDrag={closeAllSwipeableRows}
removeClippedSubviews
maxItemsInRecyclePool={MAX_ITEMS_IN_RECYCLE_POOL}
/>
<AZScroller onLetterSelect={handleLetterSelect} />
</XStack>
)
}

View File

@@ -152,9 +152,10 @@ export default function AZScroller({
<>
<GestureDetector gesture={gesture}>
<YStack
flex={1}
minWidth={'$2'}
maxWidth={'$3'}
justifyContent='flex-start'
justifyContent='space-evenly'
alignItems='center'
alignContent='center'
paddingVertical={0}
@@ -186,7 +187,6 @@ export default function AZScroller({
fontSize='$6'
textAlign='center'
color='$neutral'
lineHeight={'$1'}
userSelect='none'
>
{letter}
@@ -194,11 +194,13 @@ export default function AZScroller({
)
return index === 0 ? (
<View key={letter} onLayout={handleLetterLayout}>
<View key={letter} flex={1} onLayout={handleLetterLayout}>
{letterElement}
</View>
) : (
letterElement
<View key={letter} flex={1}>
{letterElement}
</View>
)
})}
</YStack>

View File

@@ -1,39 +1,7 @@
import useAlbums from '../../../api/queries/album'
import Albums from '../../Albums/component'
import useLibraryStore from '../../../stores/library'
import { ItemSortBy } from '@jellyfin/sdk/lib/generated-client/models/item-sort-by'
import LetterAnchoredAlbums from '../../Albums/letter-anchored'
function AlbumsTab(): React.JSX.Element {
const [albumPageParams, albumsInfiniteQuery] = useAlbums()
const sortBy = useLibraryStore((state) => {
const sb = state.sortBy as Record<string, string> | string
if (typeof sb === 'string') return sb
return sb?.albums ?? ItemSortBy.Album
})
const sortDescending = useLibraryStore((state) => {
const sd = state.sortDescending as Record<string, boolean> | boolean
if (typeof sd === 'boolean') return sd
return sd?.albums ?? false
})
const hasLetterSections =
albumsInfiniteQuery.data?.some((item) => typeof item === 'string') ?? false
const showAlphabeticalSelector =
hasLetterSections ||
sortBy === ItemSortBy.Name ||
sortBy === ItemSortBy.SortName ||
sortBy === ItemSortBy.Album ||
sortBy === ItemSortBy.Artist
return (
<Albums
albumsInfiniteQuery={albumsInfiniteQuery}
showAlphabeticalSelector={showAlphabeticalSelector}
sortBy={sortBy as ItemSortBy}
sortDescending={sortDescending}
albumPageParams={albumPageParams}
/>
)
return <LetterAnchoredAlbums />
}
export default AlbumsTab

View File

@@ -1,38 +1,7 @@
import { useAlbumArtists } from '../../../api/queries/artist'
import Artists from '../../Artists/component'
import useLibraryStore from '../../../stores/library'
import { ItemSortBy } from '@jellyfin/sdk/lib/generated-client/models/item-sort-by'
import LetterAnchoredArtists from '../../Artists/letter-anchored'
function ArtistsTab(): React.JSX.Element {
const [artistPageParams, artistsInfiniteQuery] = useAlbumArtists()
const sortBy = useLibraryStore((state) => {
const sb = state.sortBy as Record<string, string> | string
if (typeof sb === 'string') return sb
return sb?.artists ?? ItemSortBy.SortName
})
const sortDescending = useLibraryStore((state) => {
const sd = state.sortDescending as Record<string, boolean> | boolean
if (typeof sd === 'boolean') return sd
return sd?.artists ?? false
})
const hasLetterSections =
artistsInfiniteQuery.data?.some((item) => typeof item === 'string') ?? false
// Artists tab only sorts by name, so always show A-Z when we have letter sections
const showAlphabeticalSelector =
hasLetterSections ||
sortBy === ItemSortBy.Name ||
sortBy === ItemSortBy.SortName ||
sortBy === ItemSortBy.Artist
return (
<Artists
artistsInfiniteQuery={artistsInfiniteQuery}
showAlphabeticalSelector={showAlphabeticalSelector}
sortDescending={sortDescending}
artistPageParams={artistPageParams}
/>
)
return <LetterAnchoredArtists />
}
export default ArtistsTab

View File

@@ -1,49 +1,13 @@
import React from 'react'
import Tracks from '../../Tracks/component'
import { useNavigation } from '@react-navigation/native'
import LibraryStackParamList from '@/src/screens/Library/types'
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import useTracks from '../../../api/queries/track'
import LetterAnchoredTracks from '../../Tracks/letter-anchored'
import useLibraryStore from '../../../stores/library'
import { ItemSortBy } from '@jellyfin/sdk/lib/generated-client/models/item-sort-by'
function TracksTab(): React.JSX.Element {
const [trackPageParams, tracksInfiniteQuery] = useTracks()
const filters = useLibraryStore((state) => state.filters)
const sortBy = useLibraryStore((state) => {
const sb = state.sortBy as Record<string, string> | string
if (typeof sb === 'string') return sb
return sb?.tracks ?? ItemSortBy.Name
})
const sortDescending = useLibraryStore((state) => {
const sd = state.sortDescending as Record<string, boolean> | boolean
if (typeof sd === 'boolean') return sd
return sd?.tracks ?? false
})
const { filters } = useLibraryStore()
const { isFavorites, isDownloaded } = filters.tracks
// Show A-Z when sort is by name OR when data already has letter sections (e.g. after sort change)
const hasLetterSections =
tracksInfiniteQuery.data?.some((item) => typeof item === 'string') ?? false
const showAlphabeticalSelector =
hasLetterSections ||
sortBy === ItemSortBy.Name ||
sortBy === ItemSortBy.SortName ||
sortBy === ItemSortBy.Album ||
sortBy === ItemSortBy.Artist
const navigation = useNavigation<NativeStackNavigationProp<LibraryStackParamList>>()
return (
<Tracks
navigation={navigation}
tracksInfiniteQuery={tracksInfiniteQuery}
<LetterAnchoredTracks
queue={isFavorites ? 'Favorite Tracks' : isDownloaded ? 'Downloaded Tracks' : 'Library'}
showAlphabeticalSelector={showAlphabeticalSelector}
sortBy={sortBy as ItemSortBy}
sortDescending={sortDescending}
trackPageParams={trackPageParams}
/>
)
}

View File

@@ -0,0 +1,162 @@
import React, { useEffect, useRef } from 'react'
import { RefreshControl } from 'react-native'
import { useTheme, XStack, YStack } from 'tamagui'
import { FlashList, FlashListRef } from '@shopify/flash-list'
import { BaseItemDto, BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models'
import { useNavigation } from '@react-navigation/native'
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import { Text } from '../Global/helpers/text'
import Track from '../Global/components/Track'
import ItemRow from '../Global/components/item-row'
import AZScroller from '../Global/components/alphabetical-selector'
import FlashListStickyHeader from '../Global/helpers/flashlist-sticky-header'
import { closeAllSwipeableRows } from '../Global/components/swipeable-row-registry'
import { BaseStackParamList } from '../../screens/types'
import MAX_ITEMS_IN_RECYCLE_POOL from '../../configs/library.config'
import { useLetterAnchoredTracks } from '../../api/queries/track'
import useLibraryCacheValidation from '../../hooks/use-library-cache-validation'
import { Queue } from '../../player/types/queue-item'
interface LetterAnchoredTracksProps {
queue: Queue
}
/**
* Letter-anchored Tracks component with instant A-Z navigation.
*/
export default function LetterAnchoredTracks({
queue,
}: LetterAnchoredTracksProps): React.JSX.Element {
const theme = useTheme()
const navigation = useNavigation<NativeStackNavigationProp<BaseStackParamList>>()
useLibraryCacheValidation()
const {
data: tracksData,
letters,
anchorLetter,
setAnchorLetter,
fetchNextPage,
hasNextPage,
fetchPreviousPage,
hasPreviousPage,
isFetching,
isPending,
refetch,
anchorIndex,
} = useLetterAnchoredTracks()
const sectionListRef = useRef<FlashListRef<string | BaseItemDto>>(null)
const pendingScrollRef = useRef<boolean>(false)
// Filter to just audio tracks for playback
const tracks = tracksData.filter(
(item): item is BaseItemDto => typeof item === 'object' && item.Type === BaseItemKind.Audio,
)
const stickyHeaderIndices = tracksData
.map((item, index) => (typeof item === 'string' ? index : -1))
.filter((index) => index !== -1)
const keyExtractor = (item: BaseItemDto | string, index: number) =>
typeof item === 'string' ? `header-${item}` : item.Id!
const renderItem = ({ index, item }: { index: number; item: BaseItemDto | string }) => {
if (typeof item === 'string') {
if (index + 1 >= tracksData.length || typeof tracksData[index + 1] !== 'object') {
return null
}
return <FlashListStickyHeader text={item} />
}
if (item.Type === BaseItemKind.Audio) {
return (
<Track
navigation={navigation}
showArtwork
index={0}
track={item}
testID={`track-item-${index}`}
tracklist={tracks.slice(tracks.indexOf(item), tracks.indexOf(item) + 50)}
queue={queue}
/>
)
}
return <ItemRow navigation={navigation} item={item} />
}
useEffect(() => {
if (pendingScrollRef.current && anchorIndex > 0 && tracksData.length > 0) {
const timer = setTimeout(() => {
sectionListRef.current?.scrollToIndex({
index: anchorIndex,
viewPosition: 0.1,
animated: true,
})
pendingScrollRef.current = false
}, 100)
return () => clearTimeout(timer)
}
}, [anchorIndex, tracksData.length])
const handleLetterSelect = async (letter: string) => {
pendingScrollRef.current = true
setAnchorLetter(letter === '#' ? null : letter.toUpperCase())
if (letter === '#' || letter.toUpperCase() === 'A') {
pendingScrollRef.current = false
sectionListRef.current?.scrollToIndex({
index: 0,
viewPosition: 0,
animated: true,
})
}
}
return (
<XStack flex={1}>
<FlashList
contentInsetAdjustmentBehavior='automatic'
ref={sectionListRef}
keyExtractor={keyExtractor}
ListEmptyComponent={
<YStack flex={1} justify='center' alignItems='center'>
<Text marginVertical='auto' color={'$borderColor'}>
No tracks
</Text>
</YStack>
}
data={tracksData}
refreshControl={
<RefreshControl
refreshing={isPending}
onRefresh={refetch}
tintColor={theme.primary.val}
/>
}
renderItem={renderItem}
stickyHeaderIndices={stickyHeaderIndices}
stickyHeaderConfig={{
useNativeDriver: false,
}}
onStartReached={() => {
if (hasPreviousPage && !isFetching) {
fetchPreviousPage()
}
}}
onEndReached={() => {
if (hasNextPage && !isFetching) {
fetchNextPage()
}
}}
onScrollBeginDrag={closeAllSwipeableRows}
removeClippedSubviews
maxItemsInRecyclePool={MAX_ITEMS_IN_RECYCLE_POOL}
/>
<AZScroller onLetterSelect={handleLetterSelect} />
</XStack>
)
}

View File

@@ -113,6 +113,11 @@ export enum QueryKeys {
* Query representing the fetching of suggested artists in an infinite query
*/
InfiniteSuggestedArtists = 'InfiniteSuggestedArtists',
/**
* Query representing the fetching of tracks in an infinite query
*/
InfiniteTracks = 'InfiniteTracks',
Album = 'Album',
TrackArtists = 'TrackArtists',
}

View File

@@ -0,0 +1,156 @@
import { useCallback, useEffect } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import { useApi, useJellifyLibrary, useJellifyUser } from '../stores'
import { storage } from '../constants/storage'
import { QueryKeys } from '../enums/query-keys'
import { nitroFetch } from '../api/utils/nitro'
import useAppActive from './use-app-active'
const CACHE_COUNT_KEY = 'LIBRARY_CACHE_COUNTS'
// Module-level state shared across all hook instances to prevent
// duplicate validations when multiple components use this hook
let isValidating = false
let lastValidationTime = 0
interface LibraryCounts {
artists: number
albums: number
tracks: number
timestamp: number
}
/**
* Hook that validates library cache on app focus.
* Uses lightweight Limit=0 requests to check if total counts have changed.
* If counts differ, invalidates relevant queries to trigger refetch.
*/
export default function useLibraryCacheValidation() {
const api = useApi()
const [user] = useJellifyUser()
const [library] = useJellifyLibrary()
const queryClient = useQueryClient()
const isAppActive = useAppActive()
const getCachedCounts = useCallback((): LibraryCounts | null => {
const stored = storage.getString(CACHE_COUNT_KEY)
if (!stored) return null
try {
return JSON.parse(stored) as LibraryCounts
} catch {
return null
}
}, [])
const setCachedCounts = useCallback((counts: LibraryCounts) => {
storage.set(CACHE_COUNT_KEY, JSON.stringify(counts))
}, [])
const fetchCounts = useCallback(async (): Promise<LibraryCounts | null> => {
if (!api || !user || !library?.musicLibraryId) return null
try {
// Fetch all counts in parallel using Limit=0
const [artistsResponse, albumsResponse, tracksResponse] = await Promise.all([
nitroFetch<{ TotalRecordCount: number }>(api, '/Artists/AlbumArtists', {
ParentId: library.musicLibraryId,
UserId: user.id,
Limit: 0,
}),
nitroFetch<{ TotalRecordCount: number }>(api, '/Items', {
ParentId: library.musicLibraryId,
UserId: user.id,
IncludeItemTypes: 'MusicAlbum',
Recursive: true,
Limit: 0,
}),
nitroFetch<{ TotalRecordCount: number }>(api, '/Items', {
ParentId: library.musicLibraryId,
UserId: user.id,
IncludeItemTypes: 'Audio',
Recursive: true,
Limit: 0,
}),
])
return {
artists: artistsResponse.TotalRecordCount ?? 0,
albums: albumsResponse.TotalRecordCount ?? 0,
tracks: tracksResponse.TotalRecordCount ?? 0,
timestamp: Date.now(),
}
} catch (error) {
console.warn('[CacheValidation] Failed to fetch counts:', error)
return null
}
}, [api, user, library])
const validateCache = useCallback(async () => {
// Prevent concurrent validations
if (isValidating) return
// Don't validate more than once per minute
const now = Date.now()
if (now - lastValidationTime < 60000) return
isValidating = true
lastValidationTime = now
try {
const serverCounts = await fetchCounts()
if (!serverCounts) {
isValidating = false
return
}
const cachedCounts = getCachedCounts()
// If no cached counts, just store current and return
if (!cachedCounts) {
setCachedCounts(serverCounts)
isValidating = false
return
}
// Check if any counts changed
const artistsChanged = cachedCounts.artists !== serverCounts.artists
const albumsChanged = cachedCounts.albums !== serverCounts.albums
const tracksChanged = cachedCounts.tracks !== serverCounts.tracks
if (artistsChanged) {
console.debug(
`[CacheValidation] Artists count changed: ${cachedCounts.artists} -> ${serverCounts.artists}`,
)
queryClient.invalidateQueries({ queryKey: [QueryKeys.InfiniteArtists] })
}
if (albumsChanged) {
console.debug(
`[CacheValidation] Albums count changed: ${cachedCounts.albums} -> ${serverCounts.albums}`,
)
queryClient.invalidateQueries({ queryKey: [QueryKeys.InfiniteAlbums] })
}
if (tracksChanged) {
console.debug(
`[CacheValidation] Tracks count changed: ${cachedCounts.tracks} -> ${serverCounts.tracks}`,
)
queryClient.invalidateQueries({ queryKey: [QueryKeys.InfiniteTracks] })
}
// Update cached counts
setCachedCounts(serverCounts)
} finally {
isValidating = false
}
}, [fetchCounts, getCachedCounts, setCachedCounts, queryClient])
// Validate cache when app becomes active
useEffect(() => {
if (isAppActive && api && user && library?.musicLibraryId) {
validateCache()
}
}, [isAppActive, api, user, library?.musicLibraryId, validateCache])
return { validateCache }
}

View File

@@ -86,3 +86,28 @@ function extractFirstLetterByAlbum(item: BaseItemDto): string {
if (!raw) return '#'
return raw.charAt(0).toUpperCase()
}
/**
* Flattens an array of items and adds letter headers
* Used for bidirectional letter-anchored queries
*/
export function flattenWithLetterHeaders(
items: BaseItemDto[],
seenLetters: Set<string>,
): (string | BaseItemDto)[] {
const result: (string | BaseItemDto)[] = []
items.forEach((item: BaseItemDto) => {
const rawLetter = extractFirstLetter(item)
const letter = rawLetter.match(/[A-Z]/) ? rawLetter : '#'
if (!seenLetters.has(letter)) {
seenLetters.add(letter.toUpperCase())
result.push(letter.toUpperCase())
}
result.push(item)
})
return result
}