diff --git a/jest/functional/LetterJump.test.ts b/jest/functional/LetterJump.test.ts new file mode 100644 index 000000000..ff7006cba --- /dev/null +++ b/jest/functional/LetterJump.test.ts @@ -0,0 +1,518 @@ +import { BaseItemDto, BaseItemKind, ItemSortBy } from '@jellyfin/sdk/lib/generated-client/models' +import { + azRank, + KnownPoint, + locateBoundaryInPage, + narrowBoundaryToPage, + normalizedRank, + recordKnownPoint, + sectionLocationForOffset, +} from '../../src/api/queries/letter-jump/utils' +import { + clearLetterJumpMemo, + createLocalLetterJump, + executeLetterJump, + LetterJumpDeps, +} from '../../src/api/queries/letter-jump' +import { getSectionLetter } from '../../src/utils/query-selectors' +import { queryClient } from '../../src/constants/query-client' + +const PAGE_SIZE = 400 + +describe('azRank and normalizedRank', () => { + it('ranks # and non-alphabetic characters as 0', () => { + expect(azRank('#')).toBe(0) + expect(azRank('1')).toBe(0) + expect(azRank('~')).toBe(0) + expect(azRank('')).toBe(0) + }) + + it('ranks A-Z as 1-26 regardless of case', () => { + expect(azRank('A')).toBe(1) + expect(azRank('a')).toBe(1) + expect(azRank('M')).toBe(13) + expect(azRank('z')).toBe(26) + }) + + it('normalizes ranks to be increasing along the list in both directions', () => { + expect(normalizedRank(1, false)).toBeLessThan(normalizedRank(26, false)) + expect(normalizedRank(26, true)).toBeLessThan(normalizedRank(1, true)) + }) +}) + +describe('recordKnownPoint', () => { + it('keeps points sorted by index and replaces samples at the same index', () => { + const points: KnownPoint[] = [] + recordKnownPoint(points, { index: 100, rank: 5 }) + recordKnownPoint(points, { index: 10, rank: 1 }) + recordKnownPoint(points, { index: 50, rank: 3 }) + recordKnownPoint(points, { index: 50, rank: 4 }) + + expect(points.map((point) => point.index)).toEqual([10, 50, 100]) + expect(points[1].rank).toBe(4) + }) + + it('caps the list without losing coverage at the edges', () => { + const points: KnownPoint[] = [] + for (let i = 0; i < 200; i++) { + recordKnownPoint(points, { index: i * 10, rank: 1 }) + } + expect(points.length).toBeLessThanOrEqual(64) + expect(points[0].index).toBe(0) + }) +}) + +/** + * A synthetic server-sorted result set: 27 sections (#, A-Z) spread over the + * given total according to the weights, ascending or descending + */ +const buildLibrary = ( + total: number, + sortDescending = false, + weights?: Partial>, +) => { + const sectionWeights = Array.from({ length: 27 }, (_, rank) => weights?.[rank] ?? 1) + const weightSum = sectionWeights.reduce((sum, weight) => sum + weight, 0) + + const ranks: number[] = [] + for (let rank = 0; rank <= 26 && ranks.length < total; rank++) { + const count = + rank === 26 + ? total - ranks.length + : Math.round((sectionWeights[rank] / weightSum) * total) + for (let i = 0; i < count && ranks.length < total; i++) ranks.push(rank) + } + while (ranks.length < total) ranks.push(26) + + if (sortDescending) ranks.reverse() + return ranks +} + +/** The lower-bound boundary index for a letter rank within a rank array */ +const expectedBoundary = (ranks: number[], targetRank: number, sortDescending: boolean) => { + const targetNorm = normalizedRank(targetRank, sortDescending) + const index = ranks.findIndex((rank) => normalizedRank(rank, sortDescending) >= targetNorm) + return index === -1 ? ranks.length : index +} + +describe('narrowBoundaryToPage', () => { + const probeFor = (ranks: number[]) => { + let probeCount = 0 + const probe = async (index: number) => { + probeCount++ + return ranks[index] + } + return { probe, probeCalls: () => probeCount } + } + + it('brackets the boundary page of a 200k library in a handful of probes', async () => { + const ranks = buildLibrary(200_000) + const { probe, probeCalls } = probeFor(ranks) + const points: KnownPoint[] = [] + + const bracket = await narrowBoundaryToPage({ + targetRank: azRank('Q'), + total: ranks.length, + pageSize: PAGE_SIZE, + sortDescending: false, + probeRankAt: probe, + knownPoints: points, + }) + + const boundary = expectedBoundary(ranks, azRank('Q'), false) + expect(boundary).toBeGreaterThanOrEqual(bracket.low) + expect(boundary).toBeLessThanOrEqual(bracket.high) + expect(Math.floor(bracket.low / PAGE_SIZE)).toBe(Math.floor(bracket.high / PAGE_SIZE)) + // Interpolation over an even letter spread converges faster than + // pure bisection (which would need ~9 probes to reach page granularity) + expect(probeCalls()).toBeLessThanOrEqual(8) + }) + + it('stays within twice the bisection bound on a pathologically skewed library', async () => { + // 95% of the library lives under one letter + const ranks = buildLibrary(200_000, false, { 13: 500 }) + const { probe, probeCalls } = probeFor(ranks) + + const bracket = await narrowBoundaryToPage({ + targetRank: azRank('Z'), + total: ranks.length, + pageSize: PAGE_SIZE, + sortDescending: false, + probeRankAt: probe, + knownPoints: [], + }) + + const boundary = expectedBoundary(ranks, azRank('Z'), false) + expect(boundary).toBeGreaterThanOrEqual(bracket.low) + expect(boundary).toBeLessThanOrEqual(bracket.high) + const bisectionBound = Math.ceil(Math.log2(200_000 / PAGE_SIZE)) + expect(probeCalls()).toBeLessThanOrEqual(2 * bisectionBound + 2) + }) + + it('needs no probes when known points already bracket the page', async () => { + const ranks = buildLibrary(200_000) + const boundary = expectedBoundary(ranks, azRank('Q'), false) + const { probe, probeCalls } = probeFor(ranks) + + const knownPoints: KnownPoint[] = [] + recordKnownPoint(knownPoints, { index: boundary - 10, rank: ranks[boundary - 10] }) + recordKnownPoint(knownPoints, { index: boundary + 10, rank: ranks[boundary + 10] }) + + const bracket = await narrowBoundaryToPage({ + targetRank: azRank('Q'), + total: ranks.length, + pageSize: PAGE_SIZE, + sortDescending: false, + probeRankAt: probe, + knownPoints, + }) + + expect(probeCalls()).toBe(0) + expect(boundary).toBeGreaterThanOrEqual(bracket.low) + expect(boundary).toBeLessThanOrEqual(bracket.high) + }) + + it('handles descending order', async () => { + const ranks = buildLibrary(50_000, true) + const { probe } = probeFor(ranks) + + const bracket = await narrowBoundaryToPage({ + targetRank: azRank('C'), + total: ranks.length, + pageSize: PAGE_SIZE, + sortDescending: true, + probeRankAt: probe, + knownPoints: [], + }) + + const boundary = expectedBoundary(ranks, azRank('C'), true) + expect(boundary).toBeGreaterThanOrEqual(bracket.low) + expect(boundary).toBeLessThanOrEqual(bracket.high) + }) + + it('stops early when a probe fails instead of throwing', async () => { + const bracket = await narrowBoundaryToPage({ + targetRank: azRank('M'), + total: 100_000, + pageSize: PAGE_SIZE, + sortDescending: false, + probeRankAt: async () => undefined, + knownPoints: [], + }) + expect(bracket.low).toBe(0) + }) +}) + +describe('locateBoundaryInPage', () => { + const item = (name: string): BaseItemDto => ({ + Id: name, + Type: BaseItemKind.Audio, + Name: name, + }) + const rankOf = (track: BaseItemDto) => azRank(getSectionLetter(track)) + + it('finds the first item of the target section', () => { + const items = [item('Alpha'), item('Atom'), item('Beta'), item('Bravo'), item('Cap')] + expect( + locateBoundaryInPage({ + items, + pageStartIndex: 800, + targetRank: azRank('B'), + sortDescending: false, + rankOf, + }), + ).toBe(802) + }) + + it('lands on the next section when the letter has no items', () => { + const items = [item('Alpha'), item('Cap')] + expect( + locateBoundaryInPage({ + items, + pageStartIndex: 0, + targetRank: azRank('B'), + sortDescending: false, + rankOf, + }), + ).toBe(1) + }) + + it('returns the index past the page when every item sorts before the target', () => { + const items = [item('Alpha'), item('Atom')] + expect( + locateBoundaryInPage({ + items, + pageStartIndex: 400, + targetRank: azRank('Z'), + sortDescending: false, + rankOf, + }), + ).toBe(402) + }) +}) + +describe('sectionLocationForOffset', () => { + const sections = [ + { title: 'A', data: [1, 2, 3] }, + { title: 'B', data: [4] }, + { title: 'C', data: [5, 6] }, + ] + + it('maps offsets to section list locations', () => { + expect(sectionLocationForOffset(sections, 0)).toEqual({ sectionIndex: 0, itemIndex: 0 }) + expect(sectionLocationForOffset(sections, 3)).toEqual({ sectionIndex: 1, itemIndex: 0 }) + expect(sectionLocationForOffset(sections, 5)).toEqual({ sectionIndex: 2, itemIndex: 1 }) + }) + + it('clamps to the last loaded item when the offset is beyond the window', () => { + expect(sectionLocationForOffset(sections, 99)).toEqual({ sectionIndex: 2, itemIndex: 1 }) + }) + + it('handles empty sections input', () => { + expect(sectionLocationForOffset([], 5)).toEqual({ sectionIndex: 0, itemIndex: 0 }) + }) +}) + +describe('executeLetterJump', () => { + /** + * Fake deps over a synthetic library, counting every network operation + * so each path's request budget can be asserted + */ + const buildDeps = ( + ranks: number[], + options?: { sortDescending?: boolean; aligned?: boolean }, + ) => { + const sortDescending = options?.sortDescending ?? false + + const itemAt = (index: number): BaseItemDto => ({ + Id: `item-${index}`, + Type: BaseItemKind.Audio, + // Ranks map back to '#', 'A'.. 'Z' names so rankOf round-trips + Name: ranks[index] === 0 ? '#0' : String.fromCharCode(64 + ranks[index]), + }) + + const calls = { probes: 0, counts: 0, pageFetches: 0 } + let window: { pageParams: number[]; pages: BaseItemDto[][] } | undefined + + const deps: LetterJumpDeps = { + probeAt: async (index, withTotal) => { + calls.probes++ + return { + rank: ranks[index], + total: withTotal ? ranks.length : undefined, + } + }, + countSortingBefore: async (lowercaseLetter) => { + calls.counts++ + // NameLessThan semantics over an ascending SortName order + const bound = azRank(lowercaseLetter) + let count = 0 + const ascendingRanks = sortDescending ? [...ranks].reverse() : ranks + for (const rank of ascendingRanks) { + if (rank < bound) count++ + else break + } + return count + }, + fetchPage: async (pageNumber) => { + calls.pageFetches++ + const start = pageNumber * PAGE_SIZE + return Array.from( + { length: Math.min(PAGE_SIZE, Math.max(ranks.length - start, 0)) }, + (_, i) => itemAt(start + i), + ) + }, + readCachedWindow: () => window, + repositionCache: async (pageNumber, items) => { + window = { pageParams: [pageNumber], pages: [items] } + }, + rankOf: (track) => azRank(getSectionLetter(track)), + pageSize: PAGE_SIZE, + sortDescending, + sortNameAligned: options?.aligned ?? false, + memo: { points: [] }, + } + + return { deps, calls, getWindow: () => window } + } + + it('resolves an aligned ascending jump with one count query and one page fetch', async () => { + const ranks = buildLibrary(200_000) + const { deps, calls } = buildDeps(ranks, { aligned: true }) + + const jump = await executeLetterJump(deps, 'q') + + expect(jump?.targetIndex).toBe(expectedBoundary(ranks, azRank('Q'), false)) + expect(jump?.windowStartIndex).toBe(Math.floor(jump!.targetIndex / PAGE_SIZE) * PAGE_SIZE) + expect(calls.counts).toBe(1) + expect(calls.pageFetches).toBe(1) + expect(calls.probes).toBe(1) // the one-time total probe + }) + + it('resolves an aligned descending jump as the complement of the next letter count', async () => { + const ranks = buildLibrary(50_000, true) + const { deps, calls } = buildDeps(ranks, { aligned: true, sortDescending: true }) + + const jump = await executeLetterJump(deps, 'q') + + expect(jump?.targetIndex).toBe(expectedBoundary(ranks, azRank('Q'), true)) + expect(calls.counts).toBe(1) + expect(calls.pageFetches).toBe(1) + }) + + it('jumps to the list head with no resolution requests at all', async () => { + const ranks = buildLibrary(10_000) + const { deps, calls } = buildDeps(ranks, { aligned: true }) + + const jump = await executeLetterJump(deps, '#') + + expect(jump?.targetIndex).toBe(0) + expect(calls.probes + calls.counts).toBe(0) + expect(calls.pageFetches).toBe(1) + }) + + it('resolves a cold 200k unaligned jump exactly, within a small probe budget', async () => { + const ranks = buildLibrary(200_000) + const { deps, calls } = buildDeps(ranks) + + const jump = await executeLetterJump(deps, 's') + + expect(jump?.targetIndex).toBe(expectedBoundary(ranks, azRank('S'), false)) + expect(calls.pageFetches).toBe(1) + // 1 total probe + interpolated search to page granularity; pure + // bisection to the exact index would need ~18-19 + expect(calls.probes).toBeLessThanOrEqual(11) + }) + + it('repeats a jump to the same letter with no probes at all', async () => { + const ranks = buildLibrary(200_000) + const { deps, calls } = buildDeps(ranks) + + const first = await executeLetterJump(deps, 's') + const probesAfterFirst = calls.probes + const fetchesAfterFirst = calls.pageFetches + + const second = await executeLetterJump(deps, 's') + + expect(second).toEqual(first) + // The prior search's samples bracket the boundary within one page, + // and that page is already the loaded window + expect(calls.probes).toBe(probesAfterFirst) + expect(calls.pageFetches).toBe(fetchesAfterFirst) + }) + + it('reuses memoized samples so repeat jumps cost fewer probes', async () => { + const ranks = buildLibrary(200_000) + const { deps, calls } = buildDeps(ranks) + + await executeLetterJump(deps, 's') + const coldProbes = calls.probes + + await executeLetterJump(deps, 't') + const warmProbes = calls.probes - coldProbes + + expect(warmProbes).toBeLessThan(coldProbes) + expect(warmProbes).toBeLessThanOrEqual(8) + }) + + it('skips fetching when the target page is already the loaded window', async () => { + // ~37 items per letter: adjacent letters share a 400-item page + const ranks = buildLibrary(1_000) + const { deps, calls } = buildDeps(ranks, { aligned: true }) + + const first = await executeLetterJump(deps, 'b') + const fetchesAfterFirst = calls.pageFetches + + // Jumping to an adjacent letter on the same page reuses the window + const second = await executeLetterJump(deps, 'c') + + expect(Math.floor(second!.targetIndex / PAGE_SIZE)).toBe( + Math.floor(first!.targetIndex / PAGE_SIZE), + ) + expect(calls.pageFetches).toBe(fetchesAfterFirst) + }) + + it('resolves to null for an empty result set', async () => { + const { deps } = buildDeps([]) + expect(await executeLetterJump(deps, 'm')).toBeNull() + }) +}) + +describe('createLocalLetterJump', () => { + afterEach(() => { + queryClient.clear() + clearLetterJumpMemo() + }) + + const tracks: BaseItemDto[] = [ + { Id: '1', Type: BaseItemKind.Audio, Name: 'Apple' }, + { Id: '2', Type: BaseItemKind.Audio, Name: 'Banana' }, + { Id: '3', Type: BaseItemKind.Audio, Name: 'Cherry' }, + ] + + it('resolves the first item of the letter section without any fetching', async () => { + const queryKey = ['local-jump-test', 'ascending'] + queryClient.setQueryData(queryKey, { pages: [tracks], pageParams: [0] }) + + const jump = createLocalLetterJump({ queryKey, sortDescending: false }) + + expect(await jump('b')).toEqual({ letter: 'b', targetIndex: 1, windowStartIndex: 0 }) + }) + + it('respects descending order', async () => { + const queryKey = ['local-jump-test', 'descending'] + queryClient.setQueryData(queryKey, { + pages: [[...tracks].reverse()], + pageParams: [0], + }) + + const jump = createLocalLetterJump({ queryKey, sortDescending: true }) + + expect(await jump('b')).toEqual({ letter: 'b', targetIndex: 1, windowStartIndex: 0 }) + }) + + it('resolves to null when nothing is cached', async () => { + const jump = createLocalLetterJump({ + queryKey: ['local-jump-test', 'empty'], + sortDescending: false, + }) + + expect(await jump('b')).toBeNull() + }) +}) + +describe('getSectionLetter', () => { + it('uses the display name for audio, ignoring number-prefixed sort names', () => { + const track: BaseItemDto = { + Type: BaseItemKind.Audio, + Name: 'Hey Jude', + SortName: '0001 - 0005 - hey jude', + } + expect(getSectionLetter(track)).toBe('H') + }) + + it('uses the sort name for non-audio items', () => { + const artist: BaseItemDto = { + Type: BaseItemKind.MusicArtist, + Name: 'The Beatles', + SortName: 'beatles', + } + expect(getSectionLetter(artist)).toBe('B') + }) + + it('uses the artist when sorting by artist', () => { + const track: BaseItemDto = { Name: 'Around the World', AlbumArtist: 'Daft Punk' } + expect(getSectionLetter(track, ItemSortBy.Artist)).toBe('D') + }) + + it('uses the album when sorting by album', () => { + const track: BaseItemDto = { Name: 'Get Lucky', Album: 'Random Access Memories' } + expect(getSectionLetter(track, ItemSortBy.Album)).toBe('R') + }) + + it('buckets non-alphabetic names under #', () => { + expect(getSectionLetter({ Type: BaseItemKind.Audio, Name: '99 Problems' })).toBe('#') + expect(getSectionLetter({ Type: BaseItemKind.MusicArtist, SortName: '311' })).toBe('#') + expect(getSectionLetter({})).toBe('#') + }) +}) diff --git a/src/api/queries/album/index.ts b/src/api/queries/album/index.ts index 29354f605..503772f98 100644 --- a/src/api/queries/album/index.ts +++ b/src/api/queries/album/index.ts @@ -3,7 +3,7 @@ import { InfiniteData, useInfiniteQuery, useQuery } from '@tanstack/react-query' import { ItemSortBy } from '@jellyfin/sdk/lib/generated-client/models/item-sort-by' import { SortOrder } from '@jellyfin/sdk/lib/generated-client/models/sort-order' import { fetchAlbums } from './utils/album' -import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client' +import { BaseItemDto, BaseItemKind, ItemFields } from '@jellyfin/sdk/lib/generated-client' import flattenInfiniteQueryPages from '../../../utils/query-selectors' import { ApiLimits, MaxPages } from '../../../configs/query.config' import { queryClient } from '../../../constants/query-client' @@ -14,6 +14,8 @@ import { fetchAlbumDiscs } from '../item' import { Api } from '@jellyfin/sdk/lib/api' import { AlbumDiscsQueryKey } from './keys' import { AlbumQuery, RecentlyAddedQuery } from './queries' +import buildYearsParam from '../../../utils/mapping/build-years-param' +import { createLetterJump } from '../letter-jump' export const useAlbum = (album: BaseItemDto) => useQuery(AlbumQuery(album)) @@ -56,28 +58,34 @@ const useAlbums = () => { return flattenInfiniteQueryPages(data) } - return useInfiniteQuery({ - queryKey: [ - QueryKeys.InfiniteAlbums, + const sortOrder = [sortDescending ? SortOrder.Descending : SortOrder.Ascending] + + const queryKey = [ + QueryKeys.InfiniteAlbums, + isFavorites, + library?.musicLibraryId, + librarySortBy, + sortDescending, + yearMin, + yearMax, + ] + + const fetchPage = (pageNumber: number) => + fetchAlbums( + api, + user, + library, + pageNumber, isFavorites, - library?.musicLibraryId, - librarySortBy, - sortDescending, + [librarySortBy], + sortOrder, yearMin, yearMax, - ], - queryFn: ({ pageParam }) => - fetchAlbums( - api, - user, - library, - pageParam, - isFavorites, - [librarySortBy ?? ItemSortBy.SortName], - [sortDescending ? SortOrder.Descending : SortOrder.Ascending], - yearMin, - yearMax, - ), + ) + + const infiniteQuery = useInfiniteQuery({ + queryKey, + queryFn: ({ pageParam }) => fetchPage(pageParam), initialPageParam: 0, select: selectAlbums, maxPages: MaxPages.Library, @@ -88,6 +96,32 @@ const useAlbums = () => { return firstPageParam === 0 ? null : firstPageParam - 1 }, }) + + const jumpToLetter = createLetterJump({ + scope: { + endpoint: 'items', + params: { + parentId: library?.musicLibraryId, + includeItemTypes: [BaseItemKind.MusicAlbum], + userId: user?.id, + sortBy: [librarySortBy], + sortOrder, + isFavorite: isFavorites, + fields: [ItemFields.SortName], + recursive: true, + years: buildYearsParam(yearMin, yearMax), + }, + }, + sortDescending, + // Sections derive from SortName, so the NameLessThan count is exact + // only when the list is also SortName-ordered; Name/Album orders use + // the probe search instead + sortNameAligned: librarySortBy === ItemSortBy.SortName, + queryKey, + fetchPage, + }) + + return { infiniteQuery, jumpToLetter } } export default useAlbums diff --git a/src/api/queries/artist/index.ts b/src/api/queries/artist/index.ts index c80df50a1..98bebb9d3 100644 --- a/src/api/queries/artist/index.ts +++ b/src/api/queries/artist/index.ts @@ -1,5 +1,5 @@ import { QueryKeys } from '../../../enums/query-keys' -import { BaseItemDto, ItemSortBy, SortOrder } from '@jellyfin/sdk/lib/generated-client' +import { BaseItemDto, ItemFields, ItemSortBy, SortOrder } from '@jellyfin/sdk/lib/generated-client' import { InfiniteData, useInfiniteQuery, useQuery } from '@tanstack/react-query' import { isUndefined } from 'lodash' import { fetchArtistFeaturedOn, fetchArtists } from './utils/artist' @@ -11,6 +11,7 @@ import useLibraryStore from '../../../stores/library' import { fetchItem } from '../item' import { ArtistQueryKey } from './keys' import { artistAlbumsQuery } from './queries' +import { createLetterJump } from '../letter-jump' export const useArtist = (artistId: string | undefined | null) => { const api = getApi() @@ -46,21 +47,25 @@ export const useAlbumArtists = () => { const sortDescending = librarySortDescendingState.artists ?? false const isFavorites = filters.artists.isFavorites + const sortOrder = [sortDescending ? SortOrder.Descending : SortOrder.Ascending] + const selectArtists = (data: InfiniteData) => { return flattenInfiniteQueryPages(data) } - return useInfiniteQuery({ - queryKey: [QueryKeys.InfiniteArtists, isFavorites, sortDescending, library?.musicLibraryId], - queryFn: ({ pageParam }: { pageParam: number }) => - fetchArtists( - user, - library, - pageParam, - isFavorites, - [ItemSortBy.SortName], - [sortDescending ? SortOrder.Descending : SortOrder.Ascending], - ), + const queryKey = [ + QueryKeys.InfiniteArtists, + isFavorites, + sortDescending, + library?.musicLibraryId, + ] + + const fetchPage = (pageNumber: number) => + fetchArtists(user, library, pageNumber, isFavorites, [ItemSortBy.SortName], sortOrder) + + const infiniteQuery = useInfiniteQuery({ + queryKey, + queryFn: ({ pageParam }: { pageParam: number }) => fetchPage(pageParam), select: selectArtists, maxPages: MaxPages.Library, initialPageParam: 0, @@ -71,4 +76,26 @@ export const useAlbumArtists = () => { return firstPageParam === 0 ? null : firstPageParam - 1 }, }) + + const jumpToLetter = createLetterJump({ + scope: { + endpoint: 'albumArtists', + params: { + parentId: library?.musicLibraryId, + userId: user?.id, + sortBy: [ItemSortBy.SortName], + sortOrder, + isFavorite: isFavorites, + fields: [ItemFields.SortName], + }, + }, + sortDescending, + // Artists are always SortName-ordered and section by SortName, so a + // single NameLessThan count resolves any letter exactly + sortNameAligned: true, + queryKey, + fetchPage, + }) + + return { infiniteQuery, jumpToLetter } } diff --git a/src/api/queries/letter-jump/index.ts b/src/api/queries/letter-jump/index.ts new file mode 100644 index 000000000..e1873a9b0 --- /dev/null +++ b/src/api/queries/letter-jump/index.ts @@ -0,0 +1,459 @@ +import { InfiniteData, QueryKey } from '@tanstack/react-query' +import { + BaseItemDto, + BaseItemDtoQueryResult, + ItemSortBy, +} from '@jellyfin/sdk/lib/generated-client/models' +import { ArtistsApiGetAlbumArtistsRequest } from '@jellyfin/sdk/lib/generated-client/api/artists-api' +import { ItemsApiGetItemsRequest } from '@jellyfin/sdk/lib/generated-client/api/items-api' +import { getArtistsApi, getItemsApi } from '@jellyfin/sdk/lib/utils/api' +import { getApi } from '../../../stores/auth/utils' +import { queryClient } from '../../../constants/query-client' +import { ApiLimits } from '../../../configs/query.config' +import { getSectionLetter } from '../../../utils/query-selectors' +import { + azRank, + KnownPoint, + locateBoundaryInPage, + narrowBoundaryToPage, + normalizedRank, + recordKnownPoint, +} from './utils' + +/** + * The result of a completed A-Z jump, used to scroll the section list to the + * start of the selected letter's section + */ +export type LetterJump = { + letter: string + + /** + * Index of the first item of the letter's section within the full + * server-side result set + */ + targetIndex: number + + /** + * Index within the full server-side result set of the first item now + * held in the infinite query's cache + */ + windowStartIndex: number +} + +/** + * Jumps a library list to the given section letter, resolving to null when + * there is nothing to jump to (empty result set) + */ +export type JumpToLetter = (letter: string) => Promise + +/** + * The endpoint and query params backing the list being jumped. The params + * must match the list's own query exactly — same filters, sort, and scope, + * minus paging — or the resolved indices would point into the wrong list + */ +export type LetterJumpScope = + | { endpoint: 'albumArtists'; params: ArtistsApiGetAlbumArtistsRequest } + | { endpoint: 'items'; params: ItemsApiGetItemsRequest } + +export type LetterJumpConfig = { + scope: LetterJumpScope + + sortDescending: boolean + + /** + * Sort mode used to derive section letters, mirroring the sectioning in + * {@link flattenInfiniteQueryPages} + */ + sectionSortBy?: ItemSortBy + + /** + * When true, boundaries are resolved with a single NameLessThan count + * query per jump instead of a probe search. Only valid for lists ordered + * by SortName whose section letters also derive from SortName: the + * server applies NameLessThan to the stored (lowercased) SortName, so + * filter and order must agree. Never valid for tracks — Audio sort + * names are disc/track-number prefixed and unrelated to display order + */ + sortNameAligned: boolean + + /** + * Query key of the infinite query whose cache the jump repositions + */ + queryKey: QueryKey + + /** + * Fetches one page of the list, identical to the infinite query's own + * queryFn. pageNumber is the same pageParam the query uses + */ + fetchPage: (pageNumber: number) => Promise + + pageSize?: number +} + +/** + * Accumulated knowledge about one list's server-side ordering: its total size + * and every (index, letter-rank) sample resolved so far. Lives for the app + * session, keyed by the list's query key — every jump makes later jumps on + * the same list cheaper, typically reaching 0-1 probes after a few uses. + */ +type JumpMemo = { + total?: number + points: KnownPoint[] +} + +const memoRegistry = new Map() + +const MEMO_MAX_LISTS = 12 + +function memoFor(queryKey: QueryKey): JumpMemo { + const key = JSON.stringify(queryKey) + let memo = memoRegistry.get(key) + + if (!memo) { + memo = { points: [] } + memoRegistry.set(key, memo) + + if (memoRegistry.size > MEMO_MAX_LISTS) { + const oldest = memoRegistry.keys().next().value + if (oldest !== undefined) memoRegistry.delete(oldest) + } + } + + return memo +} + +/** Test hook: drop all accumulated ordering knowledge */ +export function clearLetterJumpMemo(): void { + memoRegistry.clear() +} + +/** + * The network and cache operations a jump needs, injected so the jump logic + * is testable without the SDK. {@link createLetterJump} binds the real ones. + */ +export type LetterJumpDeps = { + /** Rank (and optionally result-set total) of the item at an index */ + probeAt: (index: number, withTotal: boolean) => Promise<{ rank?: number; total?: number }> + + /** Items sorting strictly before the (lowercase) letter, via NameLessThan */ + countSortingBefore: (lowercaseLetter: string) => Promise + + fetchPage: (pageNumber: number) => Promise + + /** The infinite query's raw (pre-select) cached pages, if any */ + readCachedWindow: () => { pageParams: number[]; pages: BaseItemDto[][] } | undefined + + /** Replaces the infinite query's cache with the single given page */ + repositionCache: (pageNumber: number, items: BaseItemDto[]) => Promise + + rankOf: (item: BaseItemDto) => number + + pageSize: number + sortDescending: boolean + sortNameAligned: boolean + memo: JumpMemo +} + +/** The lowercase letter for a rank, for NameLessThan bounds: 1 → 'a' … 26 → 'z' */ +function lowercaseLetterForRank(rank: number): string { + return String.fromCharCode(96 + rank) +} + +/** + * Seeds the known-point list with the edges of the currently loaded window — + * the cache already tells us which letters live at those indices for free + */ +function seedPointsFromWindow(deps: LetterJumpDeps): void { + const window = deps.readCachedWindow() + if (!window || window.pageParams.length === 0) return + + const windowStart = Math.min(...window.pageParams) * deps.pageSize + const items = window.pages.flat() + if (items.length === 0) return + + recordKnownPoint(deps.memo.points, { index: windowStart, rank: deps.rankOf(items[0]) }) + recordKnownPoint(deps.memo.points, { + index: windowStart + items.length - 1, + rank: deps.rankOf(items[items.length - 1]), + }) +} + +/** + * Repositions the query cache for a boundary, reusing the already-loaded + * window or an already-fetched page when possible — at most one page fetch + * per jump in every path. + * + * @returns The window start index and the letter jump's final shape + */ +async function finalizeJump( + deps: LetterJumpDeps, + letter: string, + boundary: number, + prefetched?: { pageNumber: number; items: BaseItemDto[] }, +): Promise { + const pageNumber = Math.floor(boundary / deps.pageSize) + + const window = deps.readCachedWindow() + const loadedPages = window?.pageParams ?? [] + + if (loadedPages.includes(pageNumber)) { + return { + letter, + targetIndex: boundary, + windowStartIndex: Math.min(...loadedPages) * deps.pageSize, + } + } + + const items = + prefetched && prefetched.pageNumber === pageNumber + ? prefetched.items + : await deps.fetchPage(pageNumber) + + await deps.repositionCache(pageNumber, items) + + return { letter, targetIndex: boundary, windowStartIndex: pageNumber * deps.pageSize } +} + +/** + * Resolves where the letter's section starts and repositions the query cache + * onto the page containing it. + * + * Request budget per jump on a SortName-aligned list: one COUNT query (plus a + * one-time total probe per list) and at most one page fetch. On other lists: + * an interpolated, memoized probe search narrowed only to page granularity — + * the exact boundary comes from scanning the page that gets fetched anyway. + */ +export async function executeLetterJump( + deps: LetterJumpDeps, + letter: string, +): Promise { + const targetRank = azRank(letter) + const targetNorm = normalizedRank(targetRank, deps.sortDescending) + + // The head of the list needs no resolution at all: '#' ascending and 'Z' + // descending always sort first when present, and lower-bound semantics + // put absent sections at the top anyway + if (targetNorm === 0) { + return finalizeJump(deps, letter, 0) + } + + let total = deps.memo.total + if (total === undefined) { + const first = await deps.probeAt(0, true) + total = first.total ?? 0 + deps.memo.total = total + if (first.rank !== undefined) { + recordKnownPoint(deps.memo.points, { index: 0, rank: first.rank }) + } + } + + if (total === 0) return null + + // First item already sorts at-or-past the target: jump to the top + const headPoint = deps.memo.points.find((point) => point.index === 0) + if (headPoint && normalizedRank(headPoint.rank, deps.sortDescending) >= targetNorm) { + return finalizeJump(deps, letter, 0) + } + + if (deps.sortNameAligned) { + // Ascending: the count of items before the letter is its start index. + // Descending: everything at-or-after the section in ascending order + // sorts before it when reversed, so the boundary is the complement + // of the count before the NEXT letter + const count = deps.sortDescending + ? await deps + .countSortingBefore(lowercaseLetterForRank(targetRank + 1)) + .then((value) => (value === undefined ? undefined : total - value)) + : await deps.countSortingBefore(lowercaseLetterForRank(targetRank)) + + if (count !== undefined) { + const boundary = Math.min(Math.max(count, 0), total - 1) + return finalizeJump(deps, letter, boundary) + } + // Count failed — fall through to the probe search + } + + seedPointsFromWindow(deps) + + const bracket = await narrowBoundaryToPage({ + targetRank, + total, + pageSize: deps.pageSize, + sortDescending: deps.sortDescending, + probeRankAt: (index) => deps.probeAt(index, false).then((probe) => probe.rank), + knownPoints: deps.memo.points, + }) + + const searchIndex = Math.min(Math.max(bracket.low, 0), total - 1) + const pageNumber = Math.floor(searchIndex / deps.pageSize) + const pageStartIndex = pageNumber * deps.pageSize + + // The bracket fits within one page: fetch it once and resolve the exact + // boundary from its items. The same page then becomes the new window. + const window = deps.readCachedWindow() + const cachedPageIndex = window?.pageParams.indexOf(pageNumber) ?? -1 + const items = + cachedPageIndex >= 0 ? window!.pages[cachedPageIndex] : await deps.fetchPage(pageNumber) + + const boundary = Math.min( + locateBoundaryInPage({ + items, + pageStartIndex, + targetRank, + sortDescending: deps.sortDescending, + rankOf: deps.rankOf, + }), + total - 1, + ) + + if (items.length > 0) { + recordKnownPoint(deps.memo.points, { + index: pageStartIndex, + rank: deps.rankOf(items[0]), + }) + recordKnownPoint(deps.memo.points, { + index: pageStartIndex + items.length - 1, + rank: deps.rankOf(items[items.length - 1]), + }) + } + + return finalizeJump(deps, letter, boundary, { pageNumber, items }) +} + +type ItemsResponse = BaseItemDtoQueryResult + +type ProbeOverrides = { + startIndex: number + limit: number + enableTotalRecordCount: boolean + nameLessThan?: string +} + +/** + * Runs a probe query against the scope's endpoint. Probes skip images and + * user data — only the item's name fields matter for resolving a boundary. + */ +async function fetchScope( + scope: LetterJumpScope, + overrides: ProbeOverrides, +): Promise { + const api = getApi() + + if (!api) throw new Error('No API instance available for letter jump') + + const probeParams = { + enableImages: false, + enableUserData: false, + ...overrides, + } + + if (scope.endpoint === 'albumArtists') { + const { data } = await getArtistsApi(api).getAlbumArtists({ + ...scope.params, + ...probeParams, + }) + return data + } + + const { data } = await getItemsApi(api).getItems({ ...scope.params, ...probeParams }) + return data +} + +/** + * Builds the {@link JumpToLetter} for a server-backed library list + */ +export function createLetterJump(config: LetterJumpConfig): JumpToLetter { + const pageSize = config.pageSize ?? ApiLimits.Library + + const rankOf = (item: BaseItemDto) => azRank(getSectionLetter(item, config.sectionSortBy)) + + const deps: Omit = { + probeAt: async (index, withTotal) => { + const response = await fetchScope(config.scope, { + startIndex: index, + limit: 1, + // COUNT queries are skipped on bisection probes to spare the + // server a full count per request on very large libraries + enableTotalRecordCount: withTotal, + }) + const item = response.Items?.[0] + return { + rank: item ? rankOf(item) : undefined, + total: response.TotalRecordCount ?? undefined, + } + }, + + countSortingBefore: async (lowercaseLetter) => { + const response = await fetchScope(config.scope, { + startIndex: 0, + limit: 1, + enableTotalRecordCount: true, + nameLessThan: lowercaseLetter, + }) + return response.TotalRecordCount ?? undefined + }, + + fetchPage: config.fetchPage, + + readCachedWindow: () => { + const cached = queryClient.getQueryData>( + config.queryKey, + ) + if (!cached) return undefined + return { pageParams: cached.pageParams as number[], pages: cached.pages } + }, + + repositionCache: async (pageNumber, items) => { + // Drop any in-flight page fetches so they can't clobber the + // repositioned window + await queryClient.cancelQueries({ queryKey: config.queryKey, exact: true }) + queryClient.setQueryData>(config.queryKey, { + pages: [items], + pageParams: [pageNumber], + }) + }, + + rankOf, + pageSize, + sortDescending: config.sortDescending, + sortNameAligned: config.sortNameAligned, + } + + return (letter: string) => + executeLetterJump({ ...deps, memo: memoFor(config.queryKey) }, letter) +} + +/** + * Builds the {@link JumpToLetter} for a fully-local list (e.g. downloaded + * tracks), where every item is already in the query cache and the jump is a + * pure scroll — no network involved + */ +export function createLocalLetterJump({ + queryKey, + sortDescending, + sectionSortBy, +}: { + queryKey: QueryKey + sortDescending: boolean + sectionSortBy?: ItemSortBy +}): JumpToLetter { + return async (letter: string) => { + const cached = queryClient.getQueryData>(queryKey) + const items = cached?.pages.flat() ?? [] + + if (items.length === 0) return null + + const targetNorm = normalizedRank(azRank(letter), sortDescending) + const boundary = items.findIndex( + (item) => + normalizedRank(azRank(getSectionLetter(item, sectionSortBy)), sortDescending) >= + targetNorm, + ) + + return { + letter, + targetIndex: boundary === -1 ? items.length - 1 : boundary, + windowStartIndex: 0, + } + } +} diff --git a/src/api/queries/letter-jump/utils.ts b/src/api/queries/letter-jump/utils.ts new file mode 100644 index 000000000..783638b8d --- /dev/null +++ b/src/api/queries/letter-jump/utils.ts @@ -0,0 +1,206 @@ +import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models/base-item-dto' + +/** + * Rank of a section letter for ordering comparisons. + * + * '#' (and anything non-alphabetic) is 0, 'A' through 'Z' are 1 through 26, + * mirroring how the server sorts non-alphabetic sort names before letters. + */ +export function azRank(letter: string): number { + const upper = letter.toUpperCase() + return upper >= 'A' && upper <= 'Z' ? upper.charCodeAt(0) - 64 : 0 +} + +/** + * Rank remapped so it is monotonically non-decreasing along the list order + * regardless of sort direction, which lets one lower-bound search serve both + */ +export function normalizedRank(rank: number, sortDescending: boolean): number { + return sortDescending ? 26 - rank : rank +} + +/** + * A resolved (index, rank) sample of the server-sorted result set. Probes are + * recorded as known points so later searches start from a tighter bracket. + */ +export type KnownPoint = { index: number; rank: number } + +const MAX_KNOWN_POINTS = 64 + +/** + * Inserts a sample into the sorted known-point list, replacing any existing + * sample at the same index and capping the list size + */ +export function recordKnownPoint(points: KnownPoint[], point: KnownPoint): void { + let insertAt = points.length + for (let i = 0; i < points.length; i++) { + if (points[i].index === point.index) { + points[i] = point + return + } + if (points[i].index > point.index) { + insertAt = i + break + } + } + points.splice(insertAt, 0, point) + + if (points.length > MAX_KNOWN_POINTS) { + // Drop every other point to keep coverage spread across the list + for (let i = points.length - 2; i > 0; i -= 2) { + points.splice(i, 1) + } + } +} + +/** + * Resolves the section-letter rank of the item at the given index of the + * server-sorted result set, or undefined if the item couldn't be fetched + */ +export type RankProbe = (index: number) => Promise + +/** + * Narrows a [low, high] bracket around the boundary of the target letter's + * section until the bracket fits within a single page — the exact boundary is + * then resolved locally from that page's items, which the jump has to fetch + * anyway. Stopping at page granularity saves ~log2(pageSize) probes per jump. + * + * Probe positions interpolate between the bracketing samples (section letters + * are spread roughly evenly through a music library), alternating with plain + * bisection so a skewed distribution can't degrade beyond 2x the bisection + * worst case. Every probe is recorded into knownPoints, so repeated jumps on + * the same list converge toward zero probes. + */ +export async function narrowBoundaryToPage({ + targetRank, + total, + pageSize, + sortDescending, + probeRankAt, + knownPoints, +}: { + targetRank: number + total: number + pageSize: number + sortDescending: boolean + probeRankAt: RankProbe + knownPoints: KnownPoint[] +}): Promise<{ low: number; high: number; probeCount: number }> { + const targetNorm = normalizedRank(targetRank, sortDescending) + + let low = 0 + let lowNorm = -1 + let high = total + let highNorm = 27 + + // Initialize the bracket from prior samples: the last one sorting before + // the target and the first one sorting at-or-after it + for (const point of knownPoints) { + if (point.index >= total) continue + const pointNorm = normalizedRank(point.rank, sortDescending) + if (pointNorm < targetNorm) { + if (point.index + 1 > low) { + low = point.index + 1 + lowNorm = pointNorm + } + } else if (point.index < high) { + high = point.index + highNorm = pointNorm + } + } + + let probeCount = 0 + let useInterpolation = true + + while (low < high && Math.floor(low / pageSize) !== Math.floor(high / pageSize)) { + let middle: number + // Interpolation only carries information while the bracket spans more + // than one letter transition; inside a single transition the boundary + // is uniformly distributed, so bisection is optimal + if (useInterpolation && highNorm - lowNorm > 1) { + // Aim at the estimated START of the target's section: half a rank + // before the target, assuming ranks spread evenly across the bracket + const fraction = (targetNorm - 0.5 - lowNorm) / (highNorm - lowNorm) + // Keep interpolated probes off the bracket edges so a skewed + // distribution still shrinks the bracket meaningfully + const clamped = Math.min(Math.max(fraction, 0.05), 0.95) + middle = low + Math.floor((high - low) * clamped) + } else { + middle = low + Math.floor((high - low) / 2) + } + middle = Math.min(Math.max(middle, low), high - 1) + useInterpolation = !useInterpolation + + const rank = await probeRankAt(middle) + probeCount++ + + if (rank === undefined) break + + recordKnownPoint(knownPoints, { index: middle, rank }) + + const middleNorm = normalizedRank(rank, sortDescending) + if (middleNorm < targetNorm) { + low = middle + 1 + lowNorm = middleNorm + } else { + high = middle + highNorm = middleNorm + } + } + + return { low, high, probeCount } +} + +/** + * Locates the boundary of the target letter's section within a fetched page: + * the first item sorting at-or-after the target, as a global index. Returns + * the index just past the page when every item sorts before the target. + */ +export function locateBoundaryInPage({ + items, + pageStartIndex, + targetRank, + sortDescending, + rankOf, +}: { + items: readonly BaseItemDto[] + pageStartIndex: number + targetRank: number + sortDescending: boolean + rankOf: (item: BaseItemDto) => number +}): number { + const targetNorm = normalizedRank(targetRank, sortDescending) + + for (let i = 0; i < items.length; i++) { + if (normalizedRank(rankOf(items[i]), sortDescending) >= targetNorm) { + return pageStartIndex + i + } + } + + return pageStartIndex + items.length +} + +/** + * Maps an item offset within the loaded window to a section list location. + * Falls back to the last loaded item when the offset is beyond the window. + */ +export function sectionLocationForOffset( + sections: readonly { data: readonly unknown[] }[], + offset: number, +): { sectionIndex: number; itemIndex: number } { + let remaining = Math.max(offset, 0) + + for (let sectionIndex = 0; sectionIndex < sections.length; sectionIndex++) { + const sectionLength = sections[sectionIndex].data.length + if (remaining < sectionLength) { + return { sectionIndex, itemIndex: remaining } + } + remaining -= sectionLength + } + + const lastSection = Math.max(sections.length - 1, 0) + return { + sectionIndex: lastSection, + itemIndex: Math.max((sections[lastSection]?.data.length ?? 1) - 1, 0), + } +} diff --git a/src/api/queries/track/index.ts b/src/api/queries/track/index.ts index 5750225c9..dc36422fb 100644 --- a/src/api/queries/track/index.ts +++ b/src/api/queries/track/index.ts @@ -3,12 +3,15 @@ import { TracksQueryKey } from './keys' import fetchTracks from './utils' import { BaseItemDto, + BaseItemKind, + ItemFields, + ItemFilter, ItemSortBy, SortOrder, UserItemDataDto, } from '@jellyfin/sdk/lib/generated-client' import flattenInfiniteQueryPages from '../../../utils/query-selectors' -import { ApiLimits } from '../../../configs/query.config' +import { ApiLimits, MaxPages } from '../../../configs/query.config' import { queryClient } from '../../../constants/query-client' import UserDataQueryKey from '../user-data/keys' import { JellifyUser } from '@/src/types/JellifyUser' @@ -17,6 +20,8 @@ import { getApi, getUser } from '../../../stores/auth/utils' import useLibraryStore from '../../../stores/library' import getTrackDto from '../../../utils/mapping/track-extra-payload' import { useDownloadedTracks } from 'react-native-nitro-player' +import buildYearsParam from '../../../utils/mapping/build-years-param' +import { createLetterJump, createLocalLetterJump } from '../letter-jump' const useTracks = ( sortBy: ItemSortBy, @@ -59,37 +64,82 @@ const useTracks = ( return data.pages.flatMap((page) => page) } - return useInfiniteQuery({ - queryKey: TracksQueryKey( - isFavorites === true, - isDownloaded, - isUnplayed === true, - finalSortOrder === SortOrder.Descending, + const queryKey = TracksQueryKey( + isFavorites === true, + isDownloaded, + isUnplayed === true, + finalSortOrder === SortOrder.Descending, + library, + downloadedTracks?.length, + undefined, + finalSortBy, + finalSortOrder, + isDownloaded ? undefined : libraryGenreIds, + libraryYearMin, + libraryYearMax, + ) + + const fetchPage = (pageNumber: number) => + fetchTracks( + api, + user, library, - downloadedTracks?.length, - undefined, + pageNumber, + isFavorites, + isUnplayed, finalSortBy, finalSortOrder, - isDownloaded ? undefined : libraryGenreIds, + undefined, + libraryGenreIds, libraryYearMin, libraryYearMax, - ), + ) + + // fetchTracks forces SortName to Name (Audio sort names are + // disc/track-number prefixed); boundary probes must sort identically + const effectiveSortBy = finalSortBy === ItemSortBy.SortName ? ItemSortBy.Name : finalSortBy + + const trackFilters: ItemFilter[] = [] + if (isFavorites === true) trackFilters.push(ItemFilter.IsFavorite) + if (isUnplayed === true) trackFilters.push(ItemFilter.IsUnplayed) + + const jumpToLetter = isDownloaded + ? createLocalLetterJump({ + queryKey, + sortDescending: finalSortOrder === SortOrder.Descending, + }) + : createLetterJump({ + scope: { + endpoint: 'items', + params: { + includeItemTypes: [BaseItemKind.Audio], + parentId: library?.musicLibraryId, + userId: user?.id, + recursive: true, + filters: trackFilters.length > 0 ? trackFilters : undefined, + sortBy: [effectiveSortBy], + sortOrder: [finalSortOrder], + fields: [ItemFields.SortName], + genreIds: + libraryGenreIds && libraryGenreIds.length > 0 + ? libraryGenreIds + : undefined, + years: buildYearsParam(libraryYearMin, libraryYearMax), + }, + }, + sortDescending: finalSortOrder === SortOrder.Descending, + // Never valid for tracks: NameLessThan compares stored SortNames, + // which for Audio are number-prefixed and unrelated to Name order + sortNameAligned: false, + queryKey, + fetchPage, + }) + + const infiniteQuery = useInfiniteQuery({ + queryKey, queryFn: ({ pageParam }) => { if (!isDownloaded) { - return fetchTracks( - api, - user, - library, - pageParam, - isFavorites, - isUnplayed, - finalSortBy, - finalSortOrder, - undefined, - libraryGenreIds, - libraryYearMin, - libraryYearMax, - ) + return fetchPage(pageParam) } else { let items = (downloadedTracks ?? []).map((download) => getTrackDto(download.originalTrack), @@ -129,8 +179,15 @@ const useTracks = ( if (isDownloaded) return undefined else return lastPage.length === ApiLimits.Library ? lastPageParam + 1 : undefined }, + getPreviousPageParam: (firstPage, allPages, firstPageParam) => { + if (isDownloaded) return null + return firstPageParam === 0 ? null : firstPageParam - 1 + }, + maxPages: isDownloaded ? undefined : MaxPages.Library, select: selectTracks, }) + + return { infiniteQuery, jumpToLetter } } export const useArtistTracks = ( diff --git a/src/components/Albums/component.tsx b/src/components/Albums/component.tsx index f74735ad7..9849a313a 100644 --- a/src/components/Albums/component.tsx +++ b/src/components/Albums/component.tsx @@ -7,17 +7,20 @@ import { SectionListRef } from '@legendapp/list/section-list' import { LibrarySectionListData, LibrarySectionListRenderItemInfo } from '../Global/types' import ItemSectionList from '../Global/components/item-section-list' import ItemList from '../Global/components/item-list' +import { JumpToLetter } from '../../api/queries/letter-jump' interface AlbumsProps { albumsInfiniteQuery: UseInfiniteQueryResult<(BaseItemDto | LibrarySectionListData)[], Error> sortBy?: ItemSortBy sortDescending?: boolean + onJumpToLetter?: JumpToLetter } export default function Albums({ albumsInfiniteQuery, sortDescending, sortBy, + onJumpToLetter, }: AlbumsProps): React.JSX.Element { const albums = albumsInfiniteQuery.data ?? [] @@ -49,6 +52,7 @@ export default function Albums({ renderItem={renderItem} query={albumsInfiniteQuery as UseInfiniteQueryResult} sortDescending={sortDescending} + onJumpToLetter={onJumpToLetter} /> ) : ( } /> diff --git a/src/components/Artists/component.tsx b/src/components/Artists/component.tsx index 59fa11d7b..e2a99000e 100644 --- a/src/components/Artists/component.tsx +++ b/src/components/Artists/component.tsx @@ -4,10 +4,12 @@ import { UseInfiniteQueryResult } from '@tanstack/react-query' import { SectionListRef } from '@legendapp/list/section-list' import { LibrarySectionListData, LibrarySectionListRenderItemInfo } from '../Global/types' import ItemSectionList from '../Global/components/item-section-list' +import { JumpToLetter } from '../../api/queries/letter-jump' export interface ArtistsProps { artistsInfiniteQuery: UseInfiniteQueryResult sortDescending?: boolean + onJumpToLetter?: JumpToLetter } /** @@ -20,6 +22,7 @@ export interface ArtistsProps { export default function Artists({ artistsInfiniteQuery, sortDescending, + onJumpToLetter, }: ArtistsProps): React.JSX.Element { const artists = artistsInfiniteQuery.data ?? [] @@ -48,6 +51,7 @@ export default function Artists({ query={artistsInfiniteQuery} renderItem={renderItem} sortDescending={sortDescending} + onJumpToLetter={onJumpToLetter} /> ) } diff --git a/src/components/Global/components/AZScroller/index.tsx b/src/components/Global/components/AZScroller/index.tsx index d3d22093c..04f7b48ce 100644 --- a/src/components/Global/components/AZScroller/index.tsx +++ b/src/components/Global/components/AZScroller/index.tsx @@ -1,14 +1,14 @@ import React, { RefObject, useEffect, useRef, useState } from 'react' -import { LayoutChangeEvent, View as RNView, Text as RNText } from 'react-native' +import { LayoutChangeEvent, Text as RNText } from 'react-native' import { getToken, Paragraph, Spinner, useTheme, View, YStack } from 'tamagui' import { Gesture, GestureDetector } from 'react-native-gesture-handler' import Animated, { useSharedValue, useAnimatedStyle, withSpring } from 'react-native-reanimated' -import { scheduleOnRN } from 'react-native-worklets' import { applyHapticFeedback } from '../../../../utils/haptics' import { LibrarySectionListData } from '../../types' import { SectionListRef } from '@legendapp/list/section-list' -import onLetterPaginateQuery from './utils' import { UseInfiniteQueryResult } from '@tanstack/react-query' +import { JumpToLetter, LetterJump } from '../../../../api/queries/letter-jump' +import { sectionLocationForOffset } from '../../../../api/queries/letter-jump/utils' const alphabetAtoZ = '#ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('') const alphabetZtoA = '#ZYXWVUTSRQPONMLKJIHGFEDCBA'.split('') @@ -16,24 +16,32 @@ const alphabetZtoA = '#ZYXWVUTSRQPONMLKJIHGFEDCBA'.split('') interface AZScrollerProps { sectionListRef: RefObject query: UseInfiniteQueryResult + + /** + * Repositions the list's query onto the selected letter's section. + * Resolved server-side, so any letter is reachable in a couple of small + * requests regardless of library size or jump direction + */ + onJumpToLetter: JumpToLetter + alphabet?: string[] reverseOrder?: boolean } /** * A component that displays a list of hardcoded alphabet letters and a selected letter overlay - * When a letter is selected, the overlay will be shown and the callback function will be called - * with the selected letter + * When a letter is selected, the overlay will be shown and the list will jump to that + * letter's section * - * The overlay will be hidden after 200ms + * The overlay shows a spinner while the jump is pending and hides when it settles * - * @param onLetterSelect - Callback function to be called when a letter is selected * @param reverseOrder - When true, display #, Z-A (for descending sort) instead of #, A-Z * @returns A component that displays a list of letters and a selected letter overlay */ export default function AZScroller({ sectionListRef, query, + onJumpToLetter, alphabet: customAlphabet, reverseOrder, }: AZScrollerProps) { @@ -46,22 +54,20 @@ export default function AZScroller({ const gesturePositionY = useSharedValue(0) - const alphabetSelectorRef = useRef(null) - const alphabetSelectorHeight = useRef(0) - const letterHeight = useRef(0) - const selectedLetter = useSharedValue('') + const selectedLetter = useRef('') const [overlayLetter, setOverlayLetter] = useState('') + const pendingJumpRef = useRef(null) + const [jumpTick, setJumpTick] = useState(0) + const showOverlay = () => { - 'worklet' overlayOpacity.value = withSpring(1) } const hideOverlay = () => { - 'worklet' overlayOpacity.value = withSpring(0) } @@ -75,7 +81,6 @@ export default function AZScroller({ * @param y The relative y coordinate of the event */ const setOverlayPositionY = (y: number) => { - 'worklet' gesturePositionY.value = withSpring( Math.min(Math.max(25, y - 50), alphabetSelectorHeight.current - 125), { @@ -86,70 +91,78 @@ export default function AZScroller({ ) } - const onLetterSelect = async (letter: string) => { - await onLetterPaginateQuery(letter, query) - } + const handleGestureBeginOrUpdate = (e: { y: number }) => { + const height = alphabetSelectorHeight.current - const scrollToLetter = (selectedLetter: string) => { - if (query.data) { - const upperLetters = query.data - .map((section) => section.title) - .map((letter) => letter.toUpperCase()) - .sort() - - const index = upperLetters.findIndex((letter) => letter >= selectedLetter) - - if (index !== -1) { - sectionListRef.current?.scrollToLocation({ - sectionIndex: index, - itemIndex: 0, - viewPosition: 0.1, - animated: true, - }) - } - - // else { - // // fallback: scroll to last section - // const lastLetter = upperLetters[upperLetters.length - 1] - // const scrollIndex = artists.indexOf(lastLetter) - // if (scrollIndex !== -1) { - // sectionListRef.current?.scrollToIndex({ - // index: scrollIndex, - // viewPosition: 0.1, - // animated: true, - // }) - // } - // } - } - } + // Layout hasn't settled yet — without a height we can't map the + // gesture to a letter + if (height <= 0) return - const handleGestureBeginOrUpdate = (e: { y: number }) => { - const relativeY = e.y - setOverlayPositionY(relativeY) - const index = Math.floor(relativeY / letterHeight.current) - if (alphabetToUse[index]) { - const letter = alphabetToUse[index] - selectedLetter.value = letter + setOverlayPositionY(e.y) + + const letterHeight = height / alphabetToUse.length + const index = Math.min( + Math.max(Math.floor(e.y / letterHeight), 0), + alphabetToUse.length - 1, + ) + const letter = alphabetToUse[index] + + if (letter !== selectedLetter.current) { + selectedLetter.current = letter setOverlayLetter(letter) - scheduleOnRN(showOverlay) } + + showOverlay() } const handleGestureEnd = () => { - if (selectedLetter.value) { - scheduleOnRN(async () => { - setOperationPending(true) - onLetterSelect(selectedLetter.value.toLowerCase()).then(() => { - scheduleOnRN(hideOverlay) - setOperationPending(false) - scrollToLetter(selectedLetter.value) - }) - }) - } else { - scheduleOnRN(hideOverlay) + const letter = selectedLetter.current + + if (!letter) { + hideOverlay() + return } + + setOperationPending(true) + onJumpToLetter(letter.toLowerCase()) + .then((jump) => { + if (jump) { + pendingJumpRef.current = jump + setJumpTick((tick) => tick + 1) + } + }) + .catch((error) => { + console.error(`Unable to jump to letter ${letter}`, error) + }) + .finally(() => { + setOperationPending(false) + selectedLetter.current = '' + hideOverlay() + }) } + // Scroll once the repositioned section data has rendered + useEffect(() => { + const jump = pendingJumpRef.current + const sections = query.data + + if (!jump || !sections || sections.length === 0) return + + pendingJumpRef.current = null + + const { sectionIndex, itemIndex } = sectionLocationForOffset( + sections, + jump.targetIndex - jump.windowStartIndex, + ) + + sectionListRef.current?.scrollToLocation({ + sectionIndex, + itemIndex, + viewPosition: 0, + animated: true, + }) + }, [jumpTick, query.data]) + const panGesture = Gesture.Pan() .runOnJS(true) .onBegin(handleGestureBeginOrUpdate) @@ -169,7 +182,7 @@ export default function AZScroller({ top: gesturePositionY.value, })) - const alphabetElements = alphabetToUse.map((letter, index) => ( + const alphabetElements = alphabetToUse.map((letter) => ( { - const { height } = e.nativeEvent.layout - alphabetSelectorHeight.current = height - letterHeight.current = height / alphabetToUse.length + alphabetSelectorHeight.current = e.nativeEvent.layout.height } return ( - + {alphabetElements} diff --git a/src/components/Global/components/AZScroller/utils.ts b/src/components/Global/components/AZScroller/utils.ts deleted file mode 100644 index aca26894e..000000000 --- a/src/components/Global/components/AZScroller/utils.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { UseInfiniteQueryResult } from '@tanstack/react-query' -import { LibrarySectionListData } from '../../types' - -export default async function onLetterPaginateQuery( - selectedLetter: string, - query: UseInfiniteQueryResult, -) { - do { - await query.fetchNextPage() - } while ( - !query.isFetchNextPageError && - !query.isError && - query.hasNextPage && - query.data?.filter((section) => section.title.localeCompare(selectedLetter) === 0) - .length === 0 - ) -} diff --git a/src/components/Global/components/item-section-list.tsx b/src/components/Global/components/item-section-list.tsx index 2edc137ac..ec1a45342 100644 --- a/src/components/Global/components/item-section-list.tsx +++ b/src/components/Global/components/item-section-list.tsx @@ -1,4 +1,4 @@ -import { SectionList, SectionListProps, SectionListRef } from '@legendapp/list/section-list' +import { SectionList, SectionListRef } from '@legendapp/list/section-list' import { UseInfiniteQueryResult } from '@tanstack/react-query' import { JSX, RefObject } from 'react' import { LibrarySectionListData, LibrarySectionListRenderItemInfo } from '../types' @@ -7,12 +7,14 @@ import { RefreshControl } from 'react-native' import { closeAllSwipeableRows } from './SwipeableRow/registery' import AZScroller from './AZScroller' import ListStickyHeader from '../helpers/list-sticky-header' +import { JumpToLetter } from '../../../api/queries/letter-jump' interface ItemSectionListProps { ref: RefObject query: UseInfiniteQueryResult renderItem: (info: LibrarySectionListRenderItemInfo) => JSX.Element sortDescending: boolean | undefined + onJumpToLetter?: JumpToLetter } export default function ItemSectionList({ @@ -20,6 +22,7 @@ export default function ItemSectionList({ query, renderItem, sortDescending, + onJumpToLetter, }: ItemSectionListProps) { const theme = useTheme() @@ -35,16 +38,16 @@ export default function ItemSectionList({ renderItem={renderItem} refreshControl={ } onStartReached={() => { - if (query.hasPreviousPage) query.fetchPreviousPage() + if (query.hasPreviousPage && !query.isFetching) query.fetchPreviousPage() }} onEndReached={() => { - if (query.hasNextPage) query.fetchNextPage() + if (query.hasNextPage && !query.isFetching) query.fetchNextPage() }} onScrollBeginDrag={closeAllSwipeableRows} ListEmptyComponent={ @@ -56,7 +59,14 @@ export default function ItemSectionList({ } /> - + {onJumpToLetter && ( + + )} ) } diff --git a/src/components/Library/components/albums-tab.tsx b/src/components/Library/components/albums-tab.tsx index fc7234c30..52fd69b76 100644 --- a/src/components/Library/components/albums-tab.tsx +++ b/src/components/Library/components/albums-tab.tsx @@ -4,7 +4,7 @@ import useLibraryStore from '../../../stores/library' import { ItemSortBy } from '@jellyfin/sdk/lib/generated-client/models/item-sort-by' function AlbumsTab(): React.JSX.Element { - const albumsInfiniteQuery = useAlbums() + const { infiniteQuery: albumsInfiniteQuery, jumpToLetter } = useAlbums() const sortBy = useLibraryStore((state) => { const sb = state.sortBy as Record | string @@ -22,6 +22,7 @@ function AlbumsTab(): React.JSX.Element { albumsInfiniteQuery={albumsInfiniteQuery} sortBy={sortBy as ItemSortBy} sortDescending={sortDescending} + onJumpToLetter={jumpToLetter} /> ) } diff --git a/src/components/Library/components/artists-tab.tsx b/src/components/Library/components/artists-tab.tsx index a66a2f7b7..cfe1f1867 100644 --- a/src/components/Library/components/artists-tab.tsx +++ b/src/components/Library/components/artists-tab.tsx @@ -3,7 +3,7 @@ import Artists from '../../Artists/component' import useLibraryStore from '../../../stores/library' function ArtistsTab(): React.JSX.Element { - const artistsInfiniteQuery = useAlbumArtists() + const { infiniteQuery: artistsInfiniteQuery, jumpToLetter } = useAlbumArtists() const sortDescending = useLibraryStore((state) => { const sd = state.sortDescending as Record | boolean @@ -11,7 +11,13 @@ function ArtistsTab(): React.JSX.Element { return sd?.artists ?? false }) - return + return ( + + ) } export default ArtistsTab diff --git a/src/components/Library/components/tracks-tab.tsx b/src/components/Library/components/tracks-tab.tsx index 5ecfd8086..d75b58df9 100644 --- a/src/components/Library/components/tracks-tab.tsx +++ b/src/components/Library/components/tracks-tab.tsx @@ -18,7 +18,7 @@ function TracksTab(): React.JSX.Element { const showAlphabeticalSelector = sortBy === ItemSortBy.Name || sortBy === ItemSortBy.SortName - const tracksInfiniteQuery = useTracks( + const { infiniteQuery: tracksInfiniteQuery, jumpToLetter } = useTracks( sortBy, sortDescending ? SortOrder.Descending : SortOrder.Ascending, isFavorites, @@ -32,6 +32,7 @@ function TracksTab(): React.JSX.Element { showAlphabeticalSelector={showAlphabeticalSelector} sortBy={sortBy as ItemSortBy} sortDescending={sortDescending} + onJumpToLetter={jumpToLetter} /> ) } diff --git a/src/components/Tracks/component.tsx b/src/components/Tracks/component.tsx index 596fa06d4..5fa34611b 100644 --- a/src/components/Tracks/component.tsx +++ b/src/components/Tracks/component.tsx @@ -1,4 +1,4 @@ -import React, { RefObject, useRef } from 'react' +import React, { useRef } from 'react' import Track from '../Global/components/Track' import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models' import { Queue } from '../../services/types/queue-item' @@ -11,13 +11,20 @@ import { SectionListRef } from '@legendapp/list/section-list' import { useNavigation } from '@react-navigation/native' import ItemList from '../Global/components/item-list' import ItemSectionList from '../Global/components/item-section-list' +import { JumpToLetter } from '../../api/queries/letter-jump' + +/** + * The number of upcoming tracks loaded into the player queue when a track is + * pressed + */ +const QUEUE_SLICE_SIZE = 50 interface TracksProps { tracksInfiniteQuery: UseInfiniteQueryResult<(BaseItemDto | LibrarySectionListData)[], Error> - trackPageParams?: RefObject> showAlphabeticalSelector?: boolean sortBy?: ItemSortBy sortDescending?: boolean + onJumpToLetter?: JumpToLetter queue: Queue } @@ -36,28 +43,44 @@ function TracksList({ tracksInfiniteQuery }: TracksProps) { function TracksSectionList({ tracksInfiniteQuery, sortDescending, + onJumpToLetter, queue, }: Omit) { const navigation = useNavigation>() const sectionListRef = useRef(null) - const tracks = - ( - tracksInfiniteQuery as UseInfiniteQueryResult - ).data?.flatMap((section) => section.data) ?? [] + const sections = + (tracksInfiniteQuery as UseInfiniteQueryResult).data ?? [] - const renderItem = ({ item: track, index }: LibrarySectionListRenderItemInfo) => ( - - ) + // Single pass over the sections: a flat tracklist plus a track-id → flat + // index map, so renderItem builds its queue slice in O(1) instead of + // scanning the whole list per row. React Compiler memoizes this on the + // sections' identity. + const tracks: BaseItemDto[] = [] + const flatIndexById = new Map() + for (const section of sections) { + for (const item of section.data) { + if (item.Id) flatIndexById.set(item.Id, tracks.length) + tracks.push(item) + } + } + + const renderItem = ({ item: track, index }: LibrarySectionListRenderItemInfo) => { + const flatIndex = track.Id ? (flatIndexById.get(track.Id) ?? 0) : 0 + + return ( + + ) + } return ( } renderItem={renderItem} sortDescending={sortDescending} + onJumpToLetter={onJumpToLetter} /> ) } diff --git a/src/utils/query-selectors.ts b/src/utils/query-selectors.ts index 91a427311..dc0b73a66 100644 --- a/src/utils/query-selectors.ts +++ b/src/utils/query-selectors.ts @@ -3,7 +3,6 @@ import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models/base-item import { ItemSortBy } from '@jellyfin/sdk/lib/generated-client/models/item-sort-by' import { InfiniteData } from '@tanstack/react-query' import { isString } from 'lodash' -import { RefObject } from 'react' import { LibrarySectionListData } from '../components/Global/types' export type FlattenInfiniteQueryPagesOptions = { @@ -29,25 +28,12 @@ export default function flattenInfiniteQueryPages( */ const listItems = new Map() - // Letter source: Artist → artist; Album → album name; otherwise → item name (track name, etc.) - const extractLetter = - options?.sortBy === ItemSortBy.Artist - ? extractFirstLetterByArtist - : options?.sortBy === ItemSortBy.Album - ? extractFirstLetterByAlbum - : extractFirstLetter - flattenedItemPages.forEach((item: BaseItemDto) => { - const rawLetter = extractLetter(item) - - /** - * An alpha character or a hash if the name doesn't start with a letter - */ - const letter = rawLetter.match(/[A-Z]/) ? rawLetter : '#' + const letter = getSectionLetter(item, options?.sortBy) - if (listItems.has(letter)) { - const letterItems = listItems.get(letter) - listItems.set(letter, [...(letterItems ?? []), item]) + const letterItems = listItems.get(letter) + if (letterItems) { + letterItems.push(item) } else { listItems.set(letter, [item]) } @@ -59,6 +45,24 @@ export default function flattenInfiniteQueryPages( })) } +/** + * The section letter an item is displayed under in an A-Z sectioned list: + * 'A' through 'Z', or '#' when the relevant name doesn't start with a letter. + * + * Letter source mirrors the sectioning above: Artist → artist name, + * Album → album name; otherwise the item's name (tracks) or SortName. + */ +export function getSectionLetter(item: BaseItemDto, sortBy?: ItemSortBy): string { + const rawLetter = + sortBy === ItemSortBy.Artist + ? extractFirstLetterByArtist(item) + : sortBy === ItemSortBy.Album + ? extractFirstLetterByAlbum(item) + : extractFirstLetter(item) + + return rawLetter.match(/[A-Z]/) ? rawLetter : '#' +} + function extractFirstLetter({ Type, SortName, Name }: BaseItemDto): string { let letter = '#'