Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
139 changes: 138 additions & 1 deletion src/api/queries/album/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -132,3 +132,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,
}
}
8 changes: 8 additions & 0 deletions src/api/queries/album/utils/album.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@ import { getItemsApi } from '@jellyfin/sdk/lib/utils/api'
import { ApiLimits } from '../../../../configs/query.config'
import { nitroFetch } from '../../../utils/nitro'

export interface LetterFilter {
nameStartsWithOrGreater?: string
nameLessThan?: string
}

export function fetchAlbums(
api: Api | undefined,
user: JellifyUser | undefined,
Expand All @@ -21,6 +26,7 @@ export function fetchAlbums(
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')
Expand All @@ -38,6 +44,8 @@ export function fetchAlbums(
IsFavorite: isFavorite,
Fields: [ItemFields.SortName],
Recursive: true,
NameStartsWithOrGreater: letterFilter?.nameStartsWithOrGreater,
NameLessThan: letterFilter?.nameLessThan,
}).then((data) => {
return data.Items ? resolve(data.Items) : resolve([])
})
Expand Down
169 changes: 167 additions & 2 deletions src/api/queries/artist/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ 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 flattenInfiniteQueryPages from '../../../utils/query-selectors'
import { RefObject, useRef, useState } from 'react'
import flattenInfiniteQueryPages, { flattenWithLetterHeaders } from '../../../utils/query-selectors'
import { useApi, useJellifyLibrary, useJellifyUser } from '../../../stores'
import useLibraryStore from '../../../stores/library'

Expand Down Expand Up @@ -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,
}
}
Loading