mirror of
https://github.com/Jellify-Music/App.git
synced 2026-04-20 00:12:53 -05:00
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:
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
4
src/api/types/letter-filter.ts
Normal file
4
src/api/types/letter-filter.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export interface LetterFilter {
|
||||
nameStartsWithOrGreater?: string
|
||||
nameLessThan?: string
|
||||
}
|
||||
137
src/components/Albums/letter-anchored.tsx
Normal file
137
src/components/Albums/letter-anchored.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
144
src/components/Artists/letter-anchored.tsx
Normal file
144
src/components/Artists/letter-anchored.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
162
src/components/Tracks/letter-anchored.tsx
Normal file
162
src/components/Tracks/letter-anchored.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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',
|
||||
}
|
||||
|
||||
156
src/hooks/use-library-cache-validation.ts
Normal file
156
src/hooks/use-library-cache-validation.ts
Normal 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 }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user