diff --git a/__mocks__/@capacitor/core.ts b/__mocks__/@capacitor/core.ts index 445c1e8d..7794cf7a 100644 --- a/__mocks__/@capacitor/core.ts +++ b/__mocks__/@capacitor/core.ts @@ -3,6 +3,12 @@ import { vi } from "vitest"; export const Capacitor = { isNativePlatform: vi.fn().mockReturnValue(false), getPlatform: vi.fn().mockReturnValue("web"), - isPluginAvailable: vi.fn().mockReturnValue(false), + // Preferences ships a web implementation and is mocked globally, so it is + // available on every platform. Modules loaded by the test setup keep this + // instance even when a test file mocks @capacitor/core as native, so a + // missing Preferences plugin would fail preference hydration there. + isPluginAvailable: vi.fn( + (pluginName: string) => pluginName === "Preferences", + ), convertFileSrc: vi.fn((filePath: string) => filePath), }; diff --git a/src/__tests__/integration/index-route.test.tsx b/src/__tests__/integration/index-route.test.tsx index 062db08f..54d47ec2 100644 --- a/src/__tests__/integration/index-route.test.tsx +++ b/src/__tests__/integration/index-route.test.tsx @@ -200,6 +200,10 @@ vi.mock("@/lib/toastUtils", () => ({ // Mock NFC cancel session vi.mock("@/lib/nfc", () => ({ cancelSession: vi.fn(), + sessionManager: { + setShouldRestart: vi.fn(), + setLaunchOnScan: vi.fn(), + }, })); // Use vi.hoisted for Capacitor mock diff --git a/src/__tests__/unit/components/MediaSearchModal.test.tsx b/src/__tests__/unit/components/MediaSearchModal.test.tsx index 85567671..dd14ddaf 100644 --- a/src/__tests__/unit/components/MediaSearchModal.test.tsx +++ b/src/__tests__/unit/components/MediaSearchModal.test.tsx @@ -27,7 +27,7 @@ vi.mock("@capacitor/app", () => ({ vi.mock("@capacitor/preferences", () => ({ Preferences: { - get: vi.fn().mockResolvedValue({ value: "all" }), + get: vi.fn().mockResolvedValue({ value: null }), set: vi.fn(), }, })); diff --git a/src/__tests__/unit/components/library/FavoriteButton.test.tsx b/src/__tests__/unit/components/library/FavoriteButton.test.tsx index a80a7ccf..05798700 100644 --- a/src/__tests__/unit/components/library/FavoriteButton.test.tsx +++ b/src/__tests__/unit/components/library/FavoriteButton.test.tsx @@ -1,8 +1,10 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import userEvent from "@testing-library/user-event"; +import { QueryClient } from "@tanstack/react-query"; import { render, screen, waitFor } from "@/test-utils"; import { FavoriteButton } from "@/components/library/FavoriteButton"; import { CoreAPI } from "@/lib/coreApi"; +import { LIBRARY_QUERY_KEYS } from "@/lib/libraryMedia"; import { logger } from "@/lib/logger"; import type { MediaBrowseEntry } from "@/lib/models"; import { useStatusStore } from "@/lib/store"; @@ -68,6 +70,41 @@ describe("FavoriteButton", () => { ).toBeInTheDocument(); }); + it("should refresh letter cursors before browse lists after a change", async () => { + vi.spyOn(CoreAPI, "mediaTagsUpdate").mockResolvedValue({ + tags: [{ type: "user", tag: "favorite" }], + }); + const invalidateSpy = vi.spyOn(QueryClient.prototype, "invalidateQueries"); + const user = userEvent.setup(); + + render( + , + ); + await user.click( + screen.getByRole("button", { name: "library.addFavorite" }), + ); + + await waitFor(() => expect(invalidateSpy).toHaveBeenCalled()); + const invalidated = invalidateSpy.mock.calls.map( + ([filters]) => filters?.queryKey, + ); + const indexAt = invalidated.findIndex( + (key) => key?.[0] === LIBRARY_QUERY_KEYS.browseIndex, + ); + const browseAt = invalidated.findIndex( + (key) => key?.[0] === LIBRARY_QUERY_KEYS.browse, + ); + expect(invalidated[indexAt]).toEqual([ + LIBRARY_QUERY_KEYS.browseIndex, + "device-a", + ]); + expect(indexAt).toBeLessThan(browseAt); + }); + it("should remove a favorite using system and path fallback", async () => { const updateSpy = vi.spyOn(CoreAPI, "mediaTagsUpdate").mockResolvedValue({ tags: [], diff --git a/src/__tests__/unit/components/library/LibraryBrowseList.test.tsx b/src/__tests__/unit/components/library/LibraryBrowseList.test.tsx index 70f2b717..f55b598e 100644 --- a/src/__tests__/unit/components/library/LibraryBrowseList.test.tsx +++ b/src/__tests__/unit/components/library/LibraryBrowseList.test.tsx @@ -1,6 +1,14 @@ import { createRef, type ComponentProps } from "react"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { findA11yViolations, fireEvent, render, screen } from "@/test-utils"; +import { useVirtualizer } from "@tanstack/react-virtual"; +import { + act, + findA11yViolations, + fireEvent, + render, + screen, + waitFor, +} from "@/test-utils"; import { LibraryBrowseList, type LibraryBrowseListHandle, @@ -16,14 +24,32 @@ vi.mock("@/hooks/useScreenReaderEnabled", () => ({ useScreenReaderEnabled: () => false, })); +function mockVirtualRows(indices: number[], isScrolling = false) { + vi.mocked(useVirtualizer).mockImplementation( + () => + ({ + getVirtualItems: () => + indices.map((index) => ({ + index, + key: `row-${index}`, + start: index * 88, + size: 88, + })), + getTotalSize: () => 176, + measureElement: vi.fn(), + measure: mockMeasure, + scrollToIndex: mockScrollToIndex, + isScrolling, + range: + indices.length > 0 + ? { startIndex: indices[0]!, endIndex: indices.at(-1)! } + : null, + }) as unknown as ReturnType, + ); +} + vi.mock("@tanstack/react-virtual", () => ({ - useVirtualizer: vi.fn(() => ({ - getVirtualItems: () => [{ index: 0, key: "row-0", start: 0, size: 88 }], - getTotalSize: () => 176, - measureElement: vi.fn(), - measure: mockMeasure, - scrollToIndex: mockScrollToIndex, - })), + useVirtualizer: vi.fn(), })); vi.mock("@/components/library/LibraryArtwork", () => ({ @@ -75,6 +101,7 @@ function renderList( describe("LibraryBrowseList", () => { beforeEach(() => { vi.clearAllMocks(); + mockVirtualRows([0]); useStatusStore.setState({ corePlatform: null }); usePreferencesStore.setState({ accessibleLists: false, @@ -179,4 +206,126 @@ describe("LibraryBrowseList", () => { expect(scrollIntoView).toHaveBeenCalledWith({ block: "start" }); expect(mockScrollToIndex).not.toHaveBeenCalled(); }); + it("should wait for a letter jump to land before loading more rows", () => { + const onFetchMore = vi.fn(); + const view = renderList({ + hasNextPage: true, + interactionDisabled: true, + onFetchMore, + }); + + expect(onFetchMore).not.toHaveBeenCalled(); + + view.rerender( + , + ); + + expect(onFetchMore).toHaveBeenCalledTimes(1); + }); + + it("should request the first visible unloaded row once scrolling settles", () => { + vi.useFakeTimers(); + mockVirtualRows([0, 1, 2]); + const onLoadIndex = vi.fn(); + renderList({ + entries: [undefined, undefined, entries[0]!], + onLoadIndex, + }); + + expect(screen.getAllByRole("listitem")).toHaveLength(1); + expect(onLoadIndex).not.toHaveBeenCalled(); + + act(() => { + vi.advanceTimersByTime(150); + }); + + expect(onLoadIndex).toHaveBeenCalledTimes(1); + expect(onLoadIndex).toHaveBeenCalledWith(0); + }); + + it("should not request unloaded rows while the list is scrolling", () => { + vi.useFakeTimers(); + mockVirtualRows([0, 1, 2], true); + const onLoadIndex = vi.fn(); + renderList({ + entries: [undefined, undefined, entries[0]!], + onLoadIndex, + }); + + act(() => { + vi.advanceTimersByTime(500); + }); + + expect(onLoadIndex).not.toHaveBeenCalled(); + }); + + it("should offer a retry instead of reloading a failed range", () => { + vi.useFakeTimers(); + mockVirtualRows([0, 1, 2]); + const onLoadIndex = vi.fn(); + renderList({ + entries: [undefined, undefined, entries[0]!], + failedRanges: [{ start: 0, end: 2 }], + onLoadIndex, + }); + + act(() => { + vi.advanceTimersByTime(150); + }); + expect(onLoadIndex).not.toHaveBeenCalled(); + + fireEvent.click( + screen.getByRole("button", { name: "library.loadEarlier" }), + ); + expect(onLoadIndex).toHaveBeenCalledWith(1, { retry: true }); + }); + + it("should load earlier rows on request in accessible mode", async () => { + usePreferencesStore.setState({ accessibleLists: true }); + const onLoadIndex = vi.fn(); + const { baseElement, rerender } = renderList({ + entries: [undefined, undefined, entries[1]!], + onLoadIndex, + }); + + expect(await findA11yViolations(baseElement)).toEqual([]); + fireEvent.click( + screen.getByRole("button", { name: "library.loadEarlier" }), + ); + expect(onLoadIndex).toHaveBeenCalledWith(1, { retry: true }); + + rerender( + , + ); + + expect( + screen.queryByRole("button", { name: "library.loadEarlier" }), + ).not.toBeInTheDocument(); + await waitFor(() => + expect(screen.getByRole("button", { name: "Game One" })).toHaveFocus(), + ); + }); }); diff --git a/src/__tests__/unit/hooks/useLibraryBrowse.test.tsx b/src/__tests__/unit/hooks/useLibraryBrowse.test.tsx index f7a81111..b50a9293 100644 --- a/src/__tests__/unit/hooks/useLibraryBrowse.test.tsx +++ b/src/__tests__/unit/hooks/useLibraryBrowse.test.tsx @@ -1,8 +1,19 @@ +import { useState } from "react"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { act, renderHook, waitFor } from "@/test-utils"; -import { CoreAPI } from "@/lib/coreApi"; +import { + act, + createProvidersWithQueryClient, + createTestQueryClient, + renderHook, + waitFor, +} from "@/test-utils"; +import { CoreAPI, CoreApiError } from "@/lib/coreApi"; +import { libraryBrowseIndexQueryOptions } from "@/lib/libraryBrowse"; +import { LIBRARY_QUERY_KEYS } from "@/lib/libraryMedia"; +import type { LibraryBrowseWindow } from "@/lib/librarySessionStore"; import type { MediaBrowseEntry, + MediaBrowseIndexResponse, MediaBrowseParams, MediaBrowseResponse, } from "@/lib/models"; @@ -18,6 +29,107 @@ function entries(start: number, count: number): MediaBrowseEntry[] { })); } +const SCOPE = { + deviceKey: "device-a", + systemId: "SNES", + path: "/roms/SNES", + sort: "name-asc" as const, +}; + +const INDEX: MediaBrowseIndexResponse = { + scheme: "latin", + totalFiles: 1700, + groups: [ + { key: "A", label: "A", count: 400, cursor: "", offset: 0 }, + { key: "S", label: "S", count: 1100, cursor: "letter-s", offset: 400 }, + { key: "T", label: "T", count: 200, cursor: "letter-t", offset: 1500 }, + ], +}; + +const T_WINDOW: LibraryBrowseWindow = { + anchorKey: "T", + anchorStart: 1502, + totalDirs: 2, + loadedKeys: [], +}; + +function directories(count: number): MediaBrowseEntry[] { + return Array.from({ length: count }, (_, offset) => ({ + name: `Folder ${offset + 1}`, + path: `/roms/SNES/folder-${offset + 1}`, + type: "directory" as const, + fileCount: 2, + })); +} + +function page( + pageEntries: MediaBrowseEntry[], + nextCursor: string | null, + totalDirs?: number, +): MediaBrowseResponse { + return { + path: SCOPE.path, + entries: pageEntries, + totalFiles: 1700, + totalDirs, + pagination: { + hasNextPage: nextCursor !== null, + pageSize: pageEntries.length, + nextCursor, + }, + }; +} + +// Two directories followed by 1700 files, bucketed as in INDEX. +async function browseLibrary(params: MediaBrowseParams) { + const maxResults = params.maxResults ?? 100; + switch (params.cursor) { + case undefined: + return page( + [...directories(2), ...entries(1, maxResults - 2)], + "after-top", + 2, + ); + case "letter-s": + return page(entries(401, maxResults), "letter-s-2"); + case "letter-s-2": + return page(entries(1401, maxResults), "letter-t"); + case "letter-t": + return page(entries(1501, maxResults), "after-t"); + default: + return page([], null); + } +} + +function renderWindowedBrowse(initialWindow: LibraryBrowseWindow | null) { + const queryClient = createTestQueryClient(); + queryClient.setQueryData( + libraryBrowseIndexQueryOptions(SCOPE).queryKey, + INDEX, + ); + const view = renderHook( + () => { + const [browseWindow, setBrowseWindow] = + useState(initialWindow); + const browse = useLibraryBrowse({ + ...SCOPE, + enabled: true, + browseWindow, + updateBrowseWindow: setBrowseWindow, + }); + return { browse, browseWindow }; + }, + { wrapper: createProvidersWithQueryClient(queryClient) }, + ); + return { ...view, queryClient }; +} + +function browseCursors() { + return vi + .mocked(CoreAPI.mediaBrowse) + .mock.calls.map(([params]) => params.cursor); +} + describe("useLibraryBrowse", () => { beforeEach(() => { vi.restoreAllMocks(); @@ -51,7 +163,7 @@ describe("useLibraryBrowse", () => { ); await waitFor(() => - expect(result.current.entries.map((entry) => entry.name)).toEqual([ + expect(result.current.entries.map((entry) => entry?.name)).toEqual([ "Zulu", "Alpha", ]), @@ -110,7 +222,7 @@ describe("useLibraryBrowse", () => { }); await waitFor(() => - expect(result.current.entries.map((entry) => entry.name)).toEqual([ + expect(result.current.entries.map((entry) => entry?.name)).toEqual([ "Game Title", ]), ); @@ -161,56 +273,283 @@ describe("useLibraryBrowse", () => { ); }); - it("should use deterministic page sizes when loading through a jump target", async () => { + it("should start a jumped list at its bucket cursor with earlier rows unloaded", async () => { + vi.spyOn(CoreAPI, "mediaBrowse").mockImplementation(browseLibrary); + const { result } = renderWindowedBrowse(T_WINDOW); + + await waitFor(() => + expect(result.current.browse.entries).toHaveLength(1602), + ); + expect(result.current.browse.entries[1501]).toBeUndefined(); + expect(result.current.browse.entries[1502]?.name).toBe("Game 1501"); + expect(result.current.browse.totalDirs).toBe(2); + expect(CoreAPI.mediaBrowse).toHaveBeenCalledTimes(1); + expect(CoreAPI.mediaBrowse).toHaveBeenCalledWith( + expect.objectContaining({ cursor: "letter-t", maxResults: 100 }), + expect.any(AbortSignal), + ); + }); + + it("should load an earlier bucket once in pages of at most 1000 rows", async () => { + vi.spyOn(CoreAPI, "mediaBrowse").mockImplementation(browseLibrary); + const { result } = renderWindowedBrowse(T_WINDOW); + await waitFor(() => + expect(result.current.browse.entries[1502]).toBeDefined(), + ); + + act(() => result.current.browse.loadIndex(1000)); + + await waitFor(() => + expect(result.current.browse.entries[1501]?.name).toBe("Game 1500"), + ); + expect(result.current.browse.entries[402]?.name).toBe("Game 401"); + expect(result.current.browse.entries[401]).toBeUndefined(); + expect( + vi + .mocked(CoreAPI.mediaBrowse) + .mock.calls.map(([params]) => [params.cursor, params.maxResults]), + ).toEqual([ + ["letter-t", 100], + ["letter-s", 1000], + ["letter-s-2", 100], + ]); + + act(() => result.current.browse.loadIndex(1200)); + expect(result.current.browseWindow?.loadedKeys).toEqual(["S"]); + expect(CoreAPI.mediaBrowse).toHaveBeenCalledTimes(3); + }); + + it("should load the first bucket together with its leading directories", async () => { + vi.spyOn(CoreAPI, "mediaBrowse").mockImplementation(browseLibrary); + const { result } = renderWindowedBrowse(T_WINDOW); + await waitFor(() => + expect(result.current.browse.entries[1502]).toBeDefined(), + ); + + act(() => result.current.browse.loadIndex(0)); + + await waitFor(() => + expect(result.current.browse.entries[401]?.name).toBe("Game 400"), + ); + expect(result.current.browse.entries[0]?.type).toBe("directory"); + expect(result.current.browse.entries[402]).toBeUndefined(); + expect(CoreAPI.mediaBrowse).toHaveBeenLastCalledWith( + expect.objectContaining({ cursor: undefined, maxResults: 402 }), + expect.any(AbortSignal), + ); + }); + + it("should retry an expired bucket cursor from a fresh index", async () => { + const calls: string[] = []; + vi.spyOn(CoreAPI, "mediaBrowse").mockImplementation(async (params) => { + calls.push(`browse:${params.cursor}`); + if (params.cursor === "letter-t") { + throw new CoreApiError( + "library visibility changed; restart browse without cursor", + -32602, + ); + } + if (params.cursor === undefined) return browseLibrary(params); + return page(entries(1501, 100), null); + }); + vi.spyOn(CoreAPI, "mediaBrowseIndex").mockImplementation(async () => { + calls.push("index"); + return { + ...INDEX, + groups: INDEX.groups.map((group) => + group.key === "T" ? { ...group, cursor: "letter-t-2" } : group, + ), + }; + }); + const { result } = renderWindowedBrowse(T_WINDOW); + + await waitFor(() => + expect(result.current.browse.entries[1502]).toBeDefined(), + ); + // The visibility change also re-reads the directory count from the top. + expect(calls).toEqual([ + "browse:letter-t", + "browse:undefined", + "index", + "browse:letter-t-2", + ]); + await waitFor(() => + expect(result.current.browse.isLoadingIndex).toBe(false), + ); + expect(result.current.browseWindow).toEqual(T_WINDOW); + }); + + it("should move a jumped list when a library refresh changes the directory count", async () => { + let directoryCount = 2; + vi.spyOn(CoreAPI, "mediaBrowse").mockImplementation(async (params) => { + if (params.cursor !== undefined) return browseLibrary(params); + const maxResults = params.maxResults ?? 100; + return page( + [ + ...directories(directoryCount), + ...entries(1, Math.max(maxResults - directoryCount, 0)), + ], + "after-top", + directoryCount, + ); + }); + vi.spyOn(CoreAPI, "mediaBrowseIndex").mockResolvedValue(INDEX); + const { result, queryClient } = renderWindowedBrowse(T_WINDOW); + await waitFor(() => + expect(result.current.browse.entries[1502]).toBeDefined(), + ); + act(() => result.current.browse.loadIndex(1000)); + await waitFor(() => + expect(result.current.browse.entries[402]?.name).toBe("Game 401"), + ); + + directoryCount = 5; + await act(async () => { + await queryClient.invalidateQueries({ + queryKey: [LIBRARY_QUERY_KEYS.browseIndex], + }); + await queryClient.invalidateQueries({ + queryKey: [LIBRARY_QUERY_KEYS.browse], + }); + }); + + await waitFor(() => + expect(result.current.browseWindow).toEqual({ + ...T_WINDOW, + anchorStart: 1505, + totalDirs: 5, + loadedKeys: ["S"], + }), + ); + await waitFor(() => + expect(result.current.browse.entries[405]?.name).toBe("Game 401"), + ); + expect(result.current.browse.entries[1505]?.name).toBe("Game 1501"); + expect(result.current.browse.entries[1504]?.name).toBe("Game 1500"); + expect(result.current.browse.totalDirs).toBe(5); + expect(CoreAPI.mediaBrowse).toHaveBeenCalledWith( + expect.objectContaining({ path: SCOPE.path, maxResults: 1 }), + expect.any(AbortSignal), + ); + }); + + it("should refetch a jumped list from its bucket rather than the top", async () => { + vi.spyOn(CoreAPI, "mediaBrowse").mockImplementation(browseLibrary); + const { result } = renderWindowedBrowse(T_WINDOW); + await waitFor(() => + expect(result.current.browse.entries[1502]).toBeDefined(), + ); + vi.spyOn(CoreAPI, "mediaBrowseIndex").mockResolvedValue(INDEX); + + await act(() => result.current.browse.refetch()); + + expect(browseCursors()).toEqual(["letter-t", "letter-t"]); + expect(result.current.browse.entries).toHaveLength(1602); + }); + + it("should return to the top list when a refreshed index drops the anchor", async () => { + vi.spyOn(CoreAPI, "mediaBrowse").mockImplementation(browseLibrary); + const { result, queryClient } = renderWindowedBrowse(T_WINDOW); + await waitFor(() => + expect(result.current.browse.entries[1502]).toBeDefined(), + ); + + act(() => { + queryClient.setQueryData(libraryBrowseIndexQueryOptions(SCOPE).queryKey, { + ...INDEX, + groups: INDEX.groups.filter((group) => group.key !== "T"), + }); + }); + + await waitFor(() => expect(result.current.browseWindow).toBeNull()); + await waitFor(() => + expect(result.current.browse.entries).toHaveLength(100), + ); + }); + + it("should keep a failed bucket unloaded until it is retried", async () => { + let bucketFails = true; + vi.spyOn(CoreAPI, "mediaBrowse").mockImplementation(async (params) => { + if (params.cursor === "letter-s" && bucketFails) { + throw new Error("bucket failed"); + } + return browseLibrary(params); + }); + const { result } = renderWindowedBrowse(T_WINDOW); + await waitFor(() => + expect(result.current.browse.entries[1502]).toBeDefined(), + ); + + act(() => result.current.browse.loadIndex(1000)); + + await waitFor(() => + expect(result.current.browse.failedRanges).toEqual([ + { start: 402, end: 1502 }, + ]), + ); + const failedCalls = vi.mocked(CoreAPI.mediaBrowse).mock.calls.length; + act(() => result.current.browse.loadIndex(1000)); + expect(CoreAPI.mediaBrowse).toHaveBeenCalledTimes(failedCalls); + + bucketFails = false; + act(() => result.current.browse.loadIndex(1000, { retry: true })); + + await waitFor(() => + expect(result.current.browse.entries[402]?.name).toBe("Game 401"), + ); + expect(result.current.browse.failedRanges).toEqual([]); + }); + + it("should jump from the top list with a single bucket cursor request", async () => { + vi.spyOn(CoreAPI, "mediaBrowse").mockImplementation(browseLibrary); + const { result } = renderWindowedBrowse(null); + await waitFor(() => + expect(result.current.browse.entries).toHaveLength(100), + ); + + const tGroup = INDEX.groups[2]!; + let jump: Awaited>; + await act(async () => { + jump = await result.current.browse.jumpToGroup(tGroup); + }); + expect(jump!).toEqual({ + targetIndex: 1502, + anchor: T_WINDOW, + }); + expect(result.current.browseWindow).toBeNull(); + + act(() => result.current.browse.commitJump(jump)); + + await waitFor(() => + expect(result.current.browse.entries[1502]?.name).toBe("Game 1501"), + ); + expect(browseCursors()).toEqual([undefined, "letter-t"]); + }); + + it("should page through leading directories when jumping to the first bucket", async () => { const requestedLimits: number[] = []; vi.spyOn(CoreAPI, "mediaBrowse").mockImplementation( async (params: MediaBrowseParams) => { requestedLimits.push(params.maxResults ?? 0); - if (!params.cursor) { - return { - path: "/roms/SNES", - entries: entries(1, 2), - totalFiles: 1500, - pagination: { - hasNextPage: true, - pageSize: 100, - nextCursor: "next-1", - }, - }; - } - return { - path: "/roms/SNES", - entries: entries(3, params.maxResults ?? 0), - totalFiles: 1500, - pagination: { - hasNextPage: false, - pageSize: params.maxResults ?? 0, - nextCursor: null, - }, - }; + return params.cursor + ? page([...directories(50), ...entries(1, 50)], null, 150) + : page(directories(100), "more-directories", 150); }, ); - const { result } = renderHook(() => - useLibraryBrowse({ - deviceKey: "device-a", - systemId: "SNES", - path: "/roms/SNES", - enabled: true, - }), + const { result } = renderWindowedBrowse(null); + await waitFor(() => + expect(result.current.browse.entries).toHaveLength(100), ); - await waitFor(() => expect(result.current.entries).toHaveLength(2)); - let loaded = false; + let jump: Awaited>; await act(async () => { - loaded = await result.current.loadThroughIndex(100); + jump = await result.current.browse.jumpToGroup(INDEX.groups[0]!); }); - expect(loaded).toBe(true); + expect(jump!).toEqual({ targetIndex: 150 }); expect(requestedLimits).toEqual([100, 100]); - await waitFor(() => expect(result.current.entries).toHaveLength(102)); - - await act(() => result.current.refetch()); - - expect(requestedLimits).toEqual([100, 100, 100, 100]); + await waitFor(() => + expect(result.current.browse.entries).toHaveLength(200), + ); }); }); diff --git a/src/__tests__/unit/hooks/useScreenReaderEnabled.test.ts b/src/__tests__/unit/hooks/useScreenReaderEnabled.test.ts index 7a359356..bccbfb5e 100644 --- a/src/__tests__/unit/hooks/useScreenReaderEnabled.test.ts +++ b/src/__tests__/unit/hooks/useScreenReaderEnabled.test.ts @@ -43,6 +43,48 @@ describe("useScreenReaderEnabled", () => { }); }); + it("should start a remount in the last detected state", async () => { + vi.mocked(Capacitor.isNativePlatform).mockReturnValue(true); + vi.mocked(ScreenReader.isEnabled).mockResolvedValue({ value: false }); + const view = renderHook(() => useScreenReaderEnabled()); + await waitFor(() => expect(view.result.current).toBe(false)); + view.unmount(); + + // A remounted list must not flash the accessible layout, which has a + // different height than the virtualized one its scroll was saved from. + vi.mocked(ScreenReader.isEnabled).mockReturnValue(new Promise(() => {})); + const { result } = renderHook(() => useScreenReaderEnabled()); + + expect(result.current).toBe(false); + }); + + it("should ignore a detection that resolves after unmount", async () => { + vi.mocked(Capacitor.isNativePlatform).mockReturnValue(true); + let resolveStale: (state: { value: boolean }) => void = () => {}; + vi.mocked(ScreenReader.isEnabled).mockReturnValueOnce( + new Promise((resolve) => { + resolveStale = resolve; + }), + ); + const { unmount: unmountStale } = renderHook(() => + useScreenReaderEnabled(), + ); + unmountStale(); + + vi.mocked(ScreenReader.isEnabled).mockResolvedValue({ value: false }); + const { result: current, unmount } = renderHook(() => + useScreenReaderEnabled(), + ); + await waitFor(() => expect(current.current).toBe(false)); + await act(async () => resolveStale({ value: true })); + unmount(); + + vi.mocked(ScreenReader.isEnabled).mockReturnValue(new Promise(() => {})); + const { result } = renderHook(() => useScreenReaderEnabled()); + + expect(result.current).toBe(false); + }); + it("should subscribe to state changes on native platform", async () => { vi.mocked(Capacitor.isNativePlatform).mockReturnValue(true); vi.mocked(ScreenReader.isEnabled).mockResolvedValue({ value: false }); diff --git a/src/__tests__/unit/lib/librarySessionStore.test.ts b/src/__tests__/unit/lib/librarySessionStore.test.ts index 5c524706..0ac478af 100644 --- a/src/__tests__/unit/lib/librarySessionStore.test.ts +++ b/src/__tests__/unit/lib/librarySessionStore.test.ts @@ -1,9 +1,20 @@ import { beforeEach, describe, expect, it } from "vitest"; import { + forgetLibraryBrowse, + type LibraryBrowseWindow, libraryBrowseScrollKey, librarySearchScrollKey, useLibrarySessionStore, } from "@/lib/librarySessionStore"; +import { useTabSessionStore } from "@/lib/tabSessionStore"; + +const BROWSE_KEY = libraryBrowseScrollKey("SNES", "name-asc", "/roms/SNES"); +const T_WINDOW: LibraryBrowseWindow = { + anchorKey: "T", + anchorStart: 502, + totalDirs: 2, + loadedKeys: [], +}; describe("librarySessionStore navigation", () => { it("should build stable scroll keys for folder and search history", () => { @@ -74,4 +85,45 @@ describe("librarySessionStore navigation", () => { expect(state.embeddedSearchOpen).toEqual({}); expect(state.searches).toEqual({}); }); + + it("should apply browse window updates to the latest window", () => { + const store = useLibrarySessionStore.getState(); + store.updateBrowseWindow(BROWSE_KEY, () => T_WINDOW); + store.updateBrowseWindow(BROWSE_KEY, (current) => + current ? { ...current, loadedKeys: [...current.loadedKeys, "S"] } : null, + ); + store.updateBrowseWindow(BROWSE_KEY, (current) => + current ? { ...current, loadedKeys: [...current.loadedKeys, "R"] } : null, + ); + + expect( + useLibrarySessionStore.getState().browseWindows[BROWSE_KEY]?.loadedKeys, + ).toEqual(["S", "R"]); + }); + + it("should forget a folder's scroll and jump window together", () => { + useTabSessionStore.getState().reset(); + useTabSessionStore.getState().rememberScroll(BROWSE_KEY, 0, 4400); + useLibrarySessionStore + .getState() + .updateBrowseWindow(BROWSE_KEY, () => T_WINDOW); + + forgetLibraryBrowse("SNES", "name-asc", "/roms/SNES"); + + expect(useLibrarySessionStore.getState().browseWindows).toEqual({}); + expect(useTabSessionStore.getState().scrollPositions[BROWSE_KEY]).toBe( + undefined, + ); + }); + + it("should clear jump windows with navigation and device changes", () => { + const store = useLibrarySessionStore.getState(); + store.updateBrowseWindow(BROWSE_KEY, () => T_WINDOW); + store.resetNavigation(); + expect(useLibrarySessionStore.getState().browseWindows).toEqual({}); + + store.updateBrowseWindow(BROWSE_KEY, () => T_WINDOW); + store.activateDevice("device-b"); + expect(useLibrarySessionStore.getState().browseWindows).toEqual({}); + }); }); diff --git a/src/__tests__/unit/routes/create.search.loader.test.ts b/src/__tests__/unit/routes/create.search.loader.test.ts index 508ae7a4..6400733a 100644 --- a/src/__tests__/unit/routes/create.search.loader.test.ts +++ b/src/__tests__/unit/routes/create.search.loader.test.ts @@ -3,7 +3,8 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; // Mock dependencies vi.mock("@capacitor/preferences", () => ({ Preferences: { - get: vi.fn(), + get: vi.fn().mockResolvedValue({ value: null }), + set: vi.fn().mockResolvedValue(undefined), }, })); diff --git a/src/__tests__/unit/routes/library.system.test.tsx b/src/__tests__/unit/routes/library.system.test.tsx index 4299ce89..fa78bd5d 100644 --- a/src/__tests__/unit/routes/library.system.test.tsx +++ b/src/__tests__/unit/routes/library.system.test.tsx @@ -2,7 +2,12 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import userEvent from "@testing-library/user-event"; import { act, fireEvent, render, screen, waitFor, within } from "@/test-utils"; import { CoreAPI } from "@/lib/coreApi"; -import type { MediaBrowseEntry, MediaBrowseParams } from "@/lib/models"; +import type { + MediaBrowseEntry, + MediaBrowseIndexResponse, + MediaBrowseParams, + MediaBrowseResponse, +} from "@/lib/models"; import { useStatusStore } from "@/lib/store"; import { usePreferencesStore } from "@/lib/preferencesStore"; import { @@ -13,13 +18,19 @@ import { useTabSessionStore } from "@/lib/tabSessionStore"; import { LibrarySystem } from "@/routes/library.$system"; import { seedActiveDevice } from "@/test-utils/deviceRegistry"; -const { mockNavigate, mockOutOfRangeScroll, mockScrollToIndex } = vi.hoisted( - () => ({ - mockNavigate: vi.fn(), - mockOutOfRangeScroll: vi.fn(), - mockScrollToIndex: vi.fn(), - }), -); +const { + mockErrorToast, + mockNavigate, + mockOutOfRangeScroll, + mockScrollToIndex, + mockVirtualScroll, +} = vi.hoisted(() => ({ + mockErrorToast: vi.fn(), + mockNavigate: vi.fn(), + mockOutOfRangeScroll: vi.fn(), + mockScrollToIndex: vi.fn(), + mockVirtualScroll: { startIndex: 0 }, +})); vi.mock("@tanstack/react-router", async (importOriginal) => { const actual = @@ -33,22 +44,37 @@ vi.mock("@tanstack/react-router", async (importOriginal) => { }); vi.mock("@tanstack/react-virtual", () => ({ - useVirtualizer: ({ count }: { count: number }) => ({ - getVirtualItems: () => - Array.from({ length: Math.min(count, 5) }, (_, index) => ({ - key: index, - index, - size: 88, - start: index * 88, - })), - getTotalSize: () => count * 88, - measureElement: vi.fn(), - measure: vi.fn(), - scrollToIndex: (index: number, options: { align: string }) => { - if (index >= count) mockOutOfRangeScroll(index, count); - mockScrollToIndex(index, options); - }, - }), + useVirtualizer: ({ count }: { count: number }) => { + const startIndex = Math.max( + 0, + Math.min(mockVirtualScroll.startIndex, count - 1), + ); + const length = Math.max(0, Math.min(count - startIndex, 5)); + return { + getVirtualItems: () => + Array.from({ length }, (_, offset) => ({ + key: startIndex + offset, + index: startIndex + offset, + size: 88, + start: (startIndex + offset) * 88, + })), + getTotalSize: () => count * 88, + measureElement: vi.fn(), + measure: vi.fn(), + isScrolling: false, + range: + length > 0 ? { startIndex, endIndex: startIndex + length - 1 } : null, + scrollToIndex: (index: number, options: { align: string }) => { + if (index >= count) mockOutOfRangeScroll(index, count); + mockVirtualScroll.startIndex = index; + mockScrollToIndex(index, options); + }, + }; + }, +})); + +vi.mock("@/lib/toastUtils", () => ({ + showRateLimitedErrorToast: mockErrorToast, })); vi.mock("@/hooks/usePageHeadingFocus", () => ({ @@ -97,6 +123,107 @@ function browseResult(path: string, entries: MediaBrowseEntry[]) { }; } +const ARCADE_ROOT = mediaEntry({ + mediaId: undefined, + name: "Arcade", + path: "/media/fat/_Arcade", + type: "root", +}); + +const LETTER_INDEX: MediaBrowseIndexResponse = { + scheme: "latin", + totalFiles: 600, + groups: [ + { key: "A", label: "A", count: 400, cursor: "", offset: 0 }, + { key: "S", label: "S", count: 100, cursor: "letter-s", offset: 400 }, + { key: "T", label: "T", count: 100, cursor: "letter-t", offset: 500 }, + ], +}; + +function arcadeFiles(first: number, count: number): MediaBrowseEntry[] { + return Array.from({ length: count }, (_, offset) => + mediaEntry({ + mediaId: first + offset, + name: `Game ${first + offset}`, + path: `/media/fat/_Arcade/game-${first + offset}.mra`, + }), + ); +} + +// Two directories followed by 600 files, bucketed as in LETTER_INDEX. +async function browseArcade( + params: MediaBrowseParams, +): Promise { + if (!params.path) return browseResult("", [ARCADE_ROOT]); + const listing = (entries: MediaBrowseEntry[], nextCursor: string | null) => ({ + path: params.path ?? "", + entries, + totalFiles: 600, + totalDirs: params.cursor ? 0 : 2, + pagination: { + hasNextPage: nextCursor !== null, + pageSize: params.maxResults ?? 100, + nextCursor, + }, + }); + const maxResults = params.maxResults ?? 100; + switch (params.cursor) { + case undefined: + return listing( + [ + mediaEntry({ + mediaId: undefined, + name: "Bootlegs", + path: "/media/fat/_Arcade/Bootlegs", + type: "directory", + fileCount: 3, + }), + mediaEntry({ + mediaId: undefined, + name: "Hacks", + path: "/media/fat/_Arcade/Hacks", + type: "directory", + fileCount: 3, + }), + ...arcadeFiles(1, maxResults - 2), + ], + "next-page", + ); + case "letter-s": + return listing(arcadeFiles(401, maxResults), "letter-t"); + case "letter-t": + return listing(arcadeFiles(501, maxResults), null); + default: + return listing(arcadeFiles(99, maxResults), "next-page"); + } +} + +function arcadeBrowseCursors() { + return vi + .mocked(CoreAPI.mediaBrowse) + .mock.calls.filter(([params]) => params.path === ARCADE_ROOT.path) + .map(([params]) => params.cursor); +} + +async function chooseLetter( + user: ReturnType, + label: string, +) { + await user.click( + await screen.findByRole("button", { name: "library.optionsTitle" }), + ); + await user.click( + within( + screen.getByRole("dialog", { name: "library.optionsTitle" }), + ).getByRole("button", { name: "library.goToTitle" }), + ); + await user.click( + within( + await screen.findByRole("dialog", { name: "library.goToTitle" }), + ).getByRole("button", { name: `${label} library.itemCount` }), + ); +} + function metadataResult(name: string, path: string) { return { media: { @@ -125,6 +252,8 @@ describe("Library system browser", () => { mockNavigate.mockReset(); mockOutOfRangeScroll.mockReset(); mockScrollToIndex.mockReset(); + mockErrorToast.mockReset(); + mockVirtualScroll.startIndex = 0; useLibrarySessionStore.getState().reset(); useTabSessionStore.getState().reset(); usePreferencesStore.setState({ @@ -606,91 +735,113 @@ describe("Library system browser", () => { ); }); - it("should wait for distant letter rows before scrolling", async () => { - const folder = mediaEntry({ - mediaId: undefined, - name: "Arcade", - path: "/media/fat/_Arcade", - type: "root", - }); - const firstPage = Array.from({ length: 100 }, (_, index) => - mediaEntry({ mediaId: index + 1, name: `Game ${index + 1}` }), - ); - const jumpPage = Array.from({ length: 500 }, (_, index) => - mediaEntry({ mediaId: index + 101, name: `Game ${index + 101}` }), - ); - const browseSpy = vi - .spyOn(CoreAPI, "mediaBrowse") - .mockImplementation(async (params: MediaBrowseParams) => { - if (!params.path) return browseResult("", [folder]); - if (!params.cursor) { - return { - ...browseResult(params.path, firstPage), - totalFiles: 600, - totalDirs: 0, - pagination: { - hasNextPage: true, - pageSize: 100, - nextCursor: "next-page", - }, - }; - } - return { - ...browseResult(params.path, jumpPage), - totalFiles: 600, - totalDirs: 0, - pagination: { - hasNextPage: false, - pageSize: 500, - nextCursor: null, - }, - }; - }); - vi.spyOn(CoreAPI, "mediaBrowseIndex").mockResolvedValue({ - scheme: "latin", - totalFiles: 600, - groups: [ - { - key: "T", - label: "T", - count: 100, - cursor: "letter-t", - offset: 500, - }, - ], - }); - const user = userEvent.setup(); + it("should jump to a distant letter with its index cursor", async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + vi.spyOn(CoreAPI, "mediaBrowse").mockImplementation(browseArcade); + vi.spyOn(CoreAPI, "mediaBrowseIndex").mockResolvedValue(LETTER_INDEX); + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); render(); - await user.click( - await screen.findByRole("button", { name: "library.optionsTitle" }), - ); - await user.click( - within( - screen.getByRole("dialog", { name: "library.optionsTitle" }), - ).getByRole("button", { name: "library.goToTitle" }), - ); - await user.click( - within( - await screen.findByRole("dialog", { name: "library.goToTitle" }), - ).getByRole("button", { name: "T library.itemCount" }), - ); + await chooseLetter(user, "T"); await waitFor(() => - expect(mockScrollToIndex).toHaveBeenCalledWith(500, { + expect(mockScrollToIndex).toHaveBeenCalledWith(502, { align: "start", }), ); expect(mockOutOfRangeScroll).not.toHaveBeenCalled(); - expect(browseSpy).toHaveBeenCalledWith( - expect.objectContaining({ - cursor: "next-page", - maxResults: 100, + expect( + screen.getByRole("button", { name: "Game 501" }), + ).toBeInTheDocument(); + + // Rows around the target are loaded, so settling does not fetch a bucket. + await act(() => vi.advanceTimersByTimeAsync(500)); + expect(arcadeBrowseCursors()).toEqual([undefined, "letter-t"]); + expect(CoreAPI.mediaBrowse).toHaveBeenCalledWith( + expect.objectContaining({ cursor: "letter-t", maxResults: 100 }), + expect.any(AbortSignal), + ); + }); + + it("should load an earlier letter when a restored jump shows its rows", async () => { + const session = useLibrarySessionStore.getState(); + session.activateDevice("device-a"); + session.setFolderLevels("SNES", [ + { name: "Arcade", path: ARCADE_ROOT.path }, + ]); + session.updateBrowseWindow( + libraryBrowseScrollKey("SNES", "name-asc", ARCADE_ROOT.path), + () => ({ + anchorKey: "T", + anchorStart: 502, + totalDirs: 2, + loadedKeys: [], }), + ); + mockVirtualScroll.startIndex = 499; + vi.spyOn(CoreAPI, "mediaBrowse").mockImplementation(browseArcade); + vi.spyOn(CoreAPI, "mediaBrowseIndex").mockResolvedValue(LETTER_INDEX); + + render(); + + expect( + await screen.findByRole("button", { name: "Game 500" }), + ).toBeInTheDocument(); + expect(arcadeBrowseCursors()).toEqual(["letter-t", "letter-s"]); + expect(CoreAPI.mediaBrowse).toHaveBeenLastCalledWith( + expect.objectContaining({ cursor: "letter-s", maxResults: 100 }), expect.any(AbortSignal), ); }); + it("should not keep a jump that finishes after leaving the folder", async () => { + let finishJump: (response: MediaBrowseResponse) => void = () => {}; + vi.spyOn(CoreAPI, "mediaBrowse").mockImplementation(async (params) => + params.cursor === "letter-t" + ? new Promise((resolve) => { + finishJump = resolve; + }) + : browseArcade(params), + ); + vi.spyOn(CoreAPI, "mediaBrowseIndex").mockResolvedValue(LETTER_INDEX); + const user = userEvent.setup(); + + render(); + await chooseLetter(user, "T"); + await waitFor(() => expect(arcadeBrowseCursors()).toContain("letter-t")); + await user.click(screen.getByRole("button", { name: "nav.back" })); + + await act(async () => { + finishJump( + await browseArcade({ path: ARCADE_ROOT.path, cursor: "letter-t" }), + ); + }); + + expect(useLibrarySessionStore.getState().browseWindows).toEqual({}); + expect(mockScrollToIndex).not.toHaveBeenCalled(); + }); + + it("should report a letter jump whose cursor page fails", async () => { + vi.spyOn(CoreAPI, "mediaBrowse").mockImplementation(async (params) => { + if (params.cursor === "letter-t") throw new Error("browse failed"); + return browseArcade(params); + }); + vi.spyOn(CoreAPI, "mediaBrowseIndex").mockResolvedValue(LETTER_INDEX); + const user = userEvent.setup(); + + render(); + await chooseLetter(user, "T"); + + await waitFor(() => + expect(mockErrorToast).toHaveBeenCalledWith("library.jumpError"), + ); + expect(useLibrarySessionStore.getState().browseWindows).toEqual({}); + expect(mockScrollToIndex).not.toHaveBeenCalled(); + expect( + screen.getByRole("button", { name: "library.optionsTitle" }), + ).toBeEnabled(); + }); + it("should use browse index groups to jump within loaded rows", async () => { const folder = mediaEntry({ mediaId: undefined, diff --git a/src/components/ConnectionProvider.tsx b/src/components/ConnectionProvider.tsx index 79696708..d200feb1 100644 --- a/src/components/ConnectionProvider.tsx +++ b/src/components/ConnectionProvider.tsx @@ -617,10 +617,10 @@ export function ConnectionProvider({ children }: ConnectionProviderProps) { queryKey: [LIBRARY_QUERY_KEYS.favorites], }); queryClient.invalidateQueries({ - queryKey: [LIBRARY_QUERY_KEYS.browse], + queryKey: [LIBRARY_QUERY_KEYS.browseIndex], }); queryClient.invalidateQueries({ - queryKey: [LIBRARY_QUERY_KEYS.browseIndex], + queryKey: [LIBRARY_QUERY_KEYS.browse], }); queryClient.invalidateQueries({ queryKey: [LIBRARY_QUERY_KEYS.meta], @@ -787,10 +787,10 @@ export function ConnectionProvider({ children }: ConnectionProviderProps) { queryKey: [LIBRARY_QUERY_KEYS.favorites], }); queryClient.invalidateQueries({ - queryKey: [LIBRARY_QUERY_KEYS.browse], + queryKey: [LIBRARY_QUERY_KEYS.browseIndex], }); queryClient.invalidateQueries({ - queryKey: [LIBRARY_QUERY_KEYS.browseIndex], + queryKey: [LIBRARY_QUERY_KEYS.browse], }); queryClient.invalidateQueries({ queryKey: [LIBRARY_QUERY_KEYS.meta], @@ -1156,10 +1156,10 @@ export function ConnectionProvider({ children }: ConnectionProviderProps) { queryKey: [LIBRARY_QUERY_KEYS.favorites], }); queryClient.invalidateQueries({ - queryKey: [LIBRARY_QUERY_KEYS.browse], + queryKey: [LIBRARY_QUERY_KEYS.browseIndex], }); queryClient.invalidateQueries({ - queryKey: [LIBRARY_QUERY_KEYS.browseIndex], + queryKey: [LIBRARY_QUERY_KEYS.browse], }); queryClient.invalidateQueries({ queryKey: [LIBRARY_QUERY_KEYS.meta], diff --git a/src/components/library/FavoriteButton.tsx b/src/components/library/FavoriteButton.tsx index f1eaeb06..79d80e4b 100644 --- a/src/components/library/FavoriteButton.tsx +++ b/src/components/library/FavoriteButton.tsx @@ -73,6 +73,11 @@ export function FavoriteButton(props: { queryClient.invalidateQueries({ queryKey: [LIBRARY_QUERY_KEYS.favorites, props.deviceKey], }), + // Preference edits expire bucket cursors; invalidate the index first so + // browse refetches that start from a bucket read a fresh one. + queryClient.invalidateQueries({ + queryKey: [LIBRARY_QUERY_KEYS.browseIndex, props.deviceKey], + }), queryClient.invalidateQueries({ queryKey: [LIBRARY_QUERY_KEYS.browse, props.deviceKey], }), diff --git a/src/components/library/LibraryBrowseList.tsx b/src/components/library/LibraryBrowseList.tsx index 630a696a..f991495d 100644 --- a/src/components/library/LibraryBrowseList.tsx +++ b/src/components/library/LibraryBrowseList.tsx @@ -6,7 +6,7 @@ import { useRef, useState, } from "react"; -import type { RefObject } from "react"; +import type { ReactNode, RefObject } from "react"; import { useVirtualizer } from "@tanstack/react-virtual"; import { FolderIcon, Heart } from "lucide-react"; import { useTranslation } from "react-i18next"; @@ -22,10 +22,12 @@ import { libraryEntryDisplayName, libraryEntryTags, } from "@/lib/libraryMedia"; +import type { LibraryBrowseRange } from "@/lib/libraryBrowse"; import type { MediaBrowseEntry } from "@/lib/models"; import { NextIcon } from "@/lib/images"; import { useSystemNameResolver } from "@/hooks/useSystemName"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; +import { Skeleton } from "@/components/ui/skeleton"; import { Button } from "@/components/wui/Button"; import { useAnnouncer } from "@/components/A11yAnnouncer"; import { useHapticPress } from "@/hooks/useHapticPress"; @@ -35,6 +37,38 @@ import { TagList } from "@/components/TagList"; import { LibraryArtwork } from "@/components/library/LibraryArtwork"; const ROW_HEIGHT = 88; +// Matches the virtualizer's scroll-end delay so jumps and flings settle before +// unloaded rows are requested. +const LOAD_INDEX_DELAY_MS = 150; + +function inRanges( + ranges: readonly LibraryBrowseRange[] | undefined, + index: number, +): boolean { + return ( + ranges?.some((range) => index >= range.start && index < range.end) ?? false + ); +} + +function LibraryBrowsePlaceholderRow(props: { + textZoomed: boolean; + minHeight: number; + hasDivider: boolean; +}) { + return ( + + + + + ); +} function AdaptiveLibraryTitle({ title, @@ -225,7 +259,8 @@ export interface LibraryBrowseListHandle { export const LibraryBrowseList = forwardRef< LibraryBrowseListHandle, { - entries: MediaBrowseEntry[]; + /** Absolute rows; `undefined` marks a row that has not loaded yet. */ + entries: readonly (MediaBrowseEntry | undefined)[]; systemId: string; deviceKey: string; scrollRef: RefObject; @@ -235,6 +270,9 @@ export const LibraryBrowseList = forwardRef< interactionDisabled: boolean; onFetchMore: () => void; onSelect: (entry: MediaBrowseEntry) => void; + onLoadIndex?: (index: number, options?: { retry?: boolean }) => void; + isLoadingIndex?: boolean; + failedRanges?: readonly LibraryBrowseRange[]; showSystemName?: boolean; ariaLabel?: string; } @@ -251,6 +289,7 @@ export const LibraryBrowseList = forwardRef< const rowHeight = Math.ceil(baseRowHeight * Math.max(1, textZoomLevel)); const rowRefs = useRef(new Map()); const loadMoreStartRef = useRef(null); + const loadEarlierRangeRef = useRef(null); const initialScrollOffset = useInitialPageScrollOffset(); // eslint-disable-next-line react-hooks/incompatible-library -- TanStack Virtual exposes imperative functions that React Compiler cannot memoize safely. const virtualizer = useVirtualizer({ @@ -261,7 +300,9 @@ export const LibraryBrowseList = forwardRef< initialOffset: initialScrollOffset, }); const virtualItems = virtualizer.getVirtualItems(); + const isScrolling = virtualizer.isScrolling; const fetchMore = props.onFetchMore; + const loadIndex = props.onLoadIndex; useLayoutEffect(() => { // Cached route data and text zoom can change measured row dimensions after @@ -305,8 +346,11 @@ export const LibraryBrowseList = forwardRef< useEffect(() => { const lastItem = virtualItems.at(-1); + // A jump to an earlier bucket can shorten the list before it scrolls to + // the target, leaving the old position at the new end for a frame. if ( !accessibleLists && + !props.interactionDisabled && lastItem && lastItem.index >= props.entries.length - 5 && props.hasNextPage && @@ -319,10 +363,81 @@ export const LibraryBrowseList = forwardRef< fetchMore, props.entries.length, props.hasNextPage, + props.interactionDisabled, props.isFetchingNextPage, virtualItems, ]); + useEffect(() => { + const range = loadEarlierRangeRef.current; + if (!range) return; + + let firstLoaded: number | null = null; + let loadedCount = 0; + for (let index = range.start; index < range.end; index++) { + if (props.entries[index] === undefined) continue; + firstLoaded ??= index; + loadedCount++; + } + if (firstLoaded === null) return; + + announce(t("library.loadedMore", { count: loadedCount })); + const focusIndex = firstLoaded; + requestAnimationFrame(() => { + rowRefs.current + .get(focusIndex) + ?.querySelector("button") + ?.focus(); + }); + loadEarlierRangeRef.current = null; + }, [announce, props.entries, t]); + + useEffect(() => { + if ( + !loadIndex || + accessibleLists || + isScrolling || + props.interactionDisabled || + props.isLoadingIndex + ) { + return; + } + + // Wait for scrolling to settle and read the visible range without overscan, + // so rows passed during a fling or next to a jump target are not fetched. + const timer = setTimeout(() => { + if (virtualizer.isScrolling || !virtualizer.range) return; + const lastVisible = Math.min( + virtualizer.range.endIndex, + props.entries.length - 1, + ); + for ( + let index = virtualizer.range.startIndex; + index <= lastVisible; + index++ + ) { + if ( + props.entries[index] === undefined && + !inRanges(props.failedRanges, index) + ) { + loadIndex(index); + return; + } + } + }, LOAD_INDEX_DELAY_MS); + return () => clearTimeout(timer); + }, [ + accessibleLists, + isScrolling, + loadIndex, + props.entries, + props.failedRanges, + props.interactionDisabled, + props.isLoadingIndex, + virtualItems, + virtualizer, + ]); + const renderEntry = ( entry: MediaBrowseEntry, index: number, @@ -346,28 +461,71 @@ export const LibraryBrowseList = forwardRef< ); const setSize = props.hasNextPage ? -1 : props.entries.length; + const renderLoadEarlier = (range: LibraryBrowseRange) => ( + { + loadEarlierRangeRef.current = range; + props.onLoadIndex?.(range.end - 1, { retry: true }); + }} + /> + ); + if (accessibleLists) { + const rows: ReactNode[] = []; + for (let index = 0; index < props.entries.length; index++) { + const entry = props.entries[index]; + if (entry) { + const rowIndex = index; + rows.push( + { + if (element) rowRefs.current.set(rowIndex, element); + else rowRefs.current.delete(rowIndex); + }} + role="listitem" + aria-posinset={index + 1} + aria-setsize={setSize} + style={{ minHeight: `${rowHeight}px` }} + > + {renderEntry(entry, index, true)} + , + ); + continue; + } + + const start = index; + while ( + index + 1 < props.entries.length && + props.entries[index + 1] === undefined + ) { + index++; + } + rows.push( + + {renderLoadEarlier({ start, end: index + 1 })} + , + ); + } + return ( <> - {props.entries.map((entry, index) => ( - { - if (element) rowRefs.current.set(index, element); - else rowRefs.current.delete(index); - }} - role="listitem" - aria-posinset={index + 1} - aria-setsize={setSize} - style={{ minHeight: `${rowHeight}px` }} - > - {renderEntry(entry, index, true)} - - ))} + {rows} {props.hasNextPage && ( @@ -423,16 +581,20 @@ export const LibraryBrowseList = forwardRef< ) : null; } - if (!entry) return null; + const failedRange = entry + ? undefined + : props.failedRanges?.find( + (range) => virtualItem.index === range.end - 1, + ); return ( - {renderEntry(entry, virtualItem.index, false)} + {entry ? ( + renderEntry(entry, virtualItem.index, false) + ) : failedRange ? ( + + {renderLoadEarlier(failedRange)} + + ) : ( + + )} ); })} diff --git a/src/components/library/LibraryLetterJumpModal.tsx b/src/components/library/LibraryLetterJumpModal.tsx index 5ca82bca..3e236709 100644 --- a/src/components/library/LibraryLetterJumpModal.tsx +++ b/src/components/library/LibraryLetterJumpModal.tsx @@ -1,7 +1,6 @@ import { useQuery } from "@tanstack/react-query"; import { useTranslation } from "react-i18next"; -import { CoreAPI } from "@/lib/coreApi"; -import { LIBRARY_QUERY_KEYS } from "@/lib/libraryMedia"; +import { libraryBrowseIndexQueryOptions } from "@/lib/libraryBrowse"; import type { MediaBrowseIndexGroup, MediaBrowseSort } from "@/lib/models"; import { SlideModal } from "@/components/SlideModal"; import { EmptyState } from "@/components/wui/EmptyState"; @@ -22,24 +21,13 @@ export function LibraryLetterJumpModal(props: { const { t } = useTranslation(); const handleHapticPress = useHapticPress(); const indexQuery = useQuery({ - queryKey: [ - LIBRARY_QUERY_KEYS.browseIndex, - props.deviceKey, - props.systemId, - props.path, - props.sort, - ], - queryFn: ({ signal }) => - CoreAPI.mediaBrowseIndex( - { - path: props.path, - systems: [props.systemId], - sort: props.sort, - }, - signal, - ), + ...libraryBrowseIndexQueryOptions({ + deviceKey: props.deviceKey, + systemId: props.systemId, + path: props.path, + sort: props.sort, + }), enabled: props.isOpen && props.path !== "", - staleTime: 5 * 60 * 1000, }); return ( diff --git a/src/hooks/useLibraryBrowse.ts b/src/hooks/useLibraryBrowse.ts index beee85aa..bad4aff5 100644 --- a/src/hooks/useLibraryBrowse.ts +++ b/src/hooks/useLibraryBrowse.ts @@ -1,26 +1,46 @@ -import { useCallback, useMemo } from "react"; -import { useInfiniteQuery } from "@tanstack/react-query"; -import { CoreAPI } from "@/lib/coreApi"; +import { useCallback, useEffect, useMemo } from "react"; +import { + type QueryObserverResult, + useInfiniteQuery, + useQueries, + useQuery, + useQueryClient, +} from "@tanstack/react-query"; +import { + libraryBrowseGroupAt, + libraryBrowseGroupEnd, + libraryBrowseGroupQueryOptions, + type LibraryBrowseGroupRows, + libraryBrowseGroupStart, + libraryBrowseIndexQueryOptions, + libraryBrowsePageTotalDirs, + type LibraryBrowseRange, + libraryBrowseStreamQueryOptions, + libraryBrowseTotalDirsQueryOptions, +} from "@/lib/libraryBrowse"; import { collapseRedundantSystemRoots, flattenBrowsePages, - LIBRARY_QUERY_KEYS, - resolveSingletonFolderEntries, - soleInitialRootPath, } from "@/lib/libraryMedia"; +import type { + LibraryBrowseWindow, + LibraryBrowseWindowUpdater, +} from "@/lib/librarySessionStore"; import type { MediaBrowseEntry, + MediaBrowseIndexGroup, MediaBrowseResponse, MediaBrowseSort, } from "@/lib/models"; import { compareStrings } from "@/lib/utils"; -const BROWSE_PAGE_SIZE = 100; +const EMPTY_KEYS: string[] = []; -interface BrowsePageParam { - cursor?: string; - maxResults: number; -} +/** Result of preparing a letter jump; commit it only if the jump is current. */ +export type LibraryBrowseJump = + | { targetIndex: number } + | { targetIndex: number; anchor: LibraryBrowseWindow } + | { targetIndex: number; loadKey: string }; function sortRootEntries( entries: MediaBrowseEntry[], @@ -37,72 +57,148 @@ function sortRootEntries( }); } +function combineGroupResults( + results: QueryObserverResult[], +) { + return { + rows: results.map((result) => result.data), + failed: results.map((result) => result.isError && !result.isFetching), + fetching: results.some((result) => result.isFetching), + }; +} + export function useLibraryBrowse(options: { deviceKey: string; systemId: string; path: string; sort?: MediaBrowseSort; enabled: boolean; + browseWindow?: LibraryBrowseWindow | null; + updateBrowseWindow?: (updater: LibraryBrowseWindowUpdater) => void; }) { + const queryClient = useQueryClient(); const sort = options.sort ?? "name-asc"; + const { deviceKey, systemId, path } = options; + const scope = useMemo( + () => ({ deviceKey, systemId, path, sort }), + [deviceKey, path, sort, systemId], + ); + const browseWindow = options.browseWindow ?? null; + const updateBrowseWindow = options.updateBrowseWindow; + const loadedKeys = browseWindow?.loadedKeys ?? EMPTY_KEYS; + const browseQuery = useInfiniteQuery({ - queryKey: [ - LIBRARY_QUERY_KEYS.browse, - options.deviceKey, - options.systemId, - options.path, - sort, - ], - queryFn: async ({ pageParam, signal }) => { - const response = await CoreAPI.mediaBrowse( - { - path: options.path, - systems: [options.systemId], - maxResults: pageParam.maxResults, - cursor: pageParam.cursor, - sort, - }, - signal, - ); - const preservedRootPath = - options.path === "" ? soleInitialRootPath(response) : null; - const entries = await resolveSingletonFolderEntries( - response.entries, - options.systemId, - signal, - CoreAPI, - preservedRootPath, - ); - return { ...response, entries }; - }, - initialPageParam: { - cursor: undefined, - maxResults: BROWSE_PAGE_SIZE, - } as BrowsePageParam, - getNextPageParam: (lastPage): BrowsePageParam | undefined => - lastPage.pagination?.hasNextPage && lastPage.pagination.nextCursor - ? { - cursor: lastPage.pagination.nextCursor, - maxResults: BROWSE_PAGE_SIZE, - } - : undefined, + ...libraryBrowseStreamQueryOptions( + queryClient, + scope, + browseWindow?.anchorKey, + ), enabled: options.enabled, - staleTime: 60 * 1000, - retry: 2, + }); + const indexQuery = useQuery({ + ...libraryBrowseIndexQueryOptions(scope), + enabled: options.enabled && browseWindow !== null, + }); + const groups = indexQuery.data?.groups; + // Seeded from the window so a jump or restore does not re-read the count. + const totalDirsQuery = useQuery({ + ...libraryBrowseTotalDirsQueryOptions(scope), + enabled: options.enabled && browseWindow !== null, + initialData: browseWindow?.totalDirs, + }); + const groupResults = useQueries({ + queries: loadedKeys.map((key) => ({ + ...libraryBrowseGroupQueryOptions( + queryClient, + scope, + key, + browseWindow?.totalDirs ?? 0, + ), + enabled: options.enabled && browseWindow !== null, + })), + combine: combineGroupResults, }); - const entries = useMemo(() => { - const flattened = flattenBrowsePages(browseQuery.data?.pages); - return options.path === "" - ? sortRootEntries(collapseRedundantSystemRoots(flattened), sort) - : flattened; - }, [browseQuery.data?.pages, options.path, sort]); + const streamEntries = useMemo( + () => flattenBrowsePages(browseQuery.data?.pages), + [browseQuery.data?.pages], + ); + const entries = useMemo((): (MediaBrowseEntry | undefined)[] => { + if (!browseWindow) { + return path === "" + ? sortRootEntries(collapseRedundantSystemRoots(streamEntries), sort) + : streamEntries; + } + + const rows: (MediaBrowseEntry | undefined)[] = Array.from({ + length: browseWindow.anchorStart + streamEntries.length, + }); + for (const groupRows of groupResults.rows) { + groupRows?.entries.forEach((entry, offset) => { + const index = groupRows.start + offset; + if (index < browseWindow.anchorStart) rows[index] = entry; + }); + } + streamEntries.forEach((entry, offset) => { + rows[browseWindow.anchorStart + offset] = entry; + }); + return rows; + }, [browseWindow, groupResults.rows, path, sort, streamEntries]); + const firstPage = browseQuery.data?.pages[0]; const totalFiles = firstPage?.totalFiles ?? 0; const totalDirs = - firstPage?.totalDirs ?? - firstPage?.entries.filter((entry) => entry.type !== "media").length ?? - 0; + browseWindow?.totalDirs ?? libraryBrowsePageTotalDirs(firstPage); + + const failedRanges = useMemo((): LibraryBrowseRange[] => { + if (!browseWindow || !groups) return []; + return loadedKeys.flatMap((key, index) => { + const group = groups.find((candidate) => candidate.key === key); + if (!groupResults.failed[index] || !group) return []; + return [ + { + start: libraryBrowseGroupStart(group, browseWindow.totalDirs), + end: Math.min( + libraryBrowseGroupEnd(group, browseWindow.totalDirs), + browseWindow.anchorStart, + ), + }, + ]; + }); + }, [browseWindow, groupResults.failed, groups, loadedKeys]); + + // A refreshed index or directory count can move or drop the anchor bucket, + // for example after media is hidden or the library is rebuilt. Wait for both + // so the window never mixes an old count with new bucket offsets. + const currentTotalDirs = totalDirsQuery.data; + const refreshing = indexQuery.isFetching || totalDirsQuery.isFetching; + useEffect(() => { + if ( + !browseWindow || + !groups || + currentTotalDirs === undefined || + refreshing || + !updateBrowseWindow + ) { + return; + } + const anchor = groups.find((group) => group.key === browseWindow.anchorKey); + const anchorStart = + anchor && anchor.cursor !== "" ? currentTotalDirs + anchor.offset : null; + if ( + anchorStart === browseWindow.anchorStart && + currentTotalDirs === browseWindow.totalDirs + ) { + return; + } + + updateBrowseWindow((current) => { + if (current?.anchorKey !== browseWindow.anchorKey) return current; + return anchorStart === null + ? null + : { ...current, anchorStart, totalDirs: currentTotalDirs }; + }); + }, [browseWindow, currentTotalDirs, groups, refreshing, updateBrowseWindow]); const fetchMore = useCallback(async () => { await browseQuery.fetchNextPage({ cancelRefetch: false }); @@ -137,12 +233,135 @@ export function useLibraryBrowse(options: { [browseQuery], ); + const loadIndex = useCallback( + (index: number, loadOptions?: { retry?: boolean }) => { + if (!browseWindow || !groups || index >= browseWindow.anchorStart) { + return; + } + const group = libraryBrowseGroupAt(groups, index, browseWindow.totalDirs); + if (!group) return; + + const loadedAt = browseWindow.loadedKeys.indexOf(group.key); + if (loadedAt !== -1) { + if (loadOptions?.retry && groupResults.failed[loadedAt]) { + void queryClient.refetchQueries({ + queryKey: libraryBrowseGroupQueryOptions( + queryClient, + scope, + group.key, + browseWindow.totalDirs, + ).queryKey, + exact: true, + }); + } + return; + } + if (groupResults.fetching || !updateBrowseWindow) return; + + updateBrowseWindow((current) => + current?.anchorKey === browseWindow.anchorKey && + !current.loadedKeys.includes(group.key) + ? { ...current, loadedKeys: [...current.loadedKeys, group.key] } + : current, + ); + }, + [ + browseWindow, + groupResults.failed, + groupResults.fetching, + groups, + queryClient, + scope, + updateBrowseWindow, + ], + ); + + const jumpToGroup = useCallback( + async (group: MediaBrowseIndexGroup): Promise => { + let jumpTotalDirs = browseWindow?.totalDirs; + if (jumpTotalDirs === undefined) { + const topList = await queryClient.ensureInfiniteQueryData( + libraryBrowseStreamQueryOptions(queryClient, scope), + ); + jumpTotalDirs = libraryBrowsePageTotalDirs(topList.pages[0]); + } + const targetIndex = jumpTotalDirs + group.offset; + if (entries[targetIndex]) return { targetIndex }; + + if (group.cursor === "") { + if (browseWindow) { + await queryClient.ensureQueryData( + libraryBrowseGroupQueryOptions( + queryClient, + scope, + group.key, + jumpTotalDirs, + ), + ); + return { targetIndex, loadKey: group.key }; + } + if (!(await loadThroughIndex(targetIndex))) { + throw new Error("Letter target is outside browse results"); + } + return { targetIndex }; + } + + await queryClient.fetchInfiniteQuery({ + ...libraryBrowseStreamQueryOptions(queryClient, scope, group.key), + pages: 1, + retry: false, + }); + return { + targetIndex, + anchor: { + anchorKey: group.key, + anchorStart: targetIndex, + totalDirs: jumpTotalDirs, + loadedKeys: [], + }, + }; + }, + [browseWindow, entries, loadThroughIndex, queryClient, scope], + ); + + const commitJump = useCallback( + (jump: LibraryBrowseJump) => { + if (!updateBrowseWindow) return; + if ("anchor" in jump) { + updateBrowseWindow(() => jump.anchor); + } else if ("loadKey" in jump) { + updateBrowseWindow((current) => + current && !current.loadedKeys.includes(jump.loadKey) + ? { ...current, loadedKeys: [...current.loadedKeys, jump.loadKey] } + : current, + ); + } + }, + [updateBrowseWindow], + ); + + const refetchIndex = indexQuery.refetch; + const refetchStream = browseQuery.refetch; + const windowed = browseWindow !== null; + const refetch = useCallback(async () => { + if (windowed) await refetchIndex(); + return refetchStream(); + }, [refetchIndex, refetchStream, windowed]); + return { ...browseQuery, + isError: + browseQuery.isError || + (windowed && indexQuery.isError && indexQuery.data === undefined), + refetch, entries, totalFiles, totalDirs, + failedRanges, + isLoadingIndex: groupResults.fetching, fetchMore, - loadThroughIndex, + loadIndex, + jumpToGroup, + commitJump, }; } diff --git a/src/hooks/useScreenReaderEnabled.ts b/src/hooks/useScreenReaderEnabled.ts index 931bf9c6..50535a11 100644 --- a/src/hooks/useScreenReaderEnabled.ts +++ b/src/hooks/useScreenReaderEnabled.ts @@ -2,6 +2,14 @@ import { useEffect, useState } from "react"; import { ScreenReader } from "@capacitor/screen-reader"; import { Capacitor } from "@capacitor/core"; +// Last state any mounted hook detected. A remounted list starts in its settled +// layout, so its page restores scroll against the height it was saved from. +let lastDetected: boolean | undefined; + +export function __resetScreenReaderDetectionForTests(): void { + lastDetected = undefined; +} + /** * Hook that detects if a screen reader (VoiceOver/TalkBack) is currently enabled. * @@ -11,7 +19,9 @@ import { Capacitor } from "@capacitor/core"; export function useScreenReaderEnabled(): boolean { // Start native sessions in accessible mode until plugin detection resolves, // avoiding a brief inaccessible virtualized tree for screen-reader users. - const [isEnabled, setIsEnabled] = useState(Capacitor.isNativePlatform()); + const [isEnabled, setIsEnabled] = useState( + () => Capacitor.isNativePlatform() && (lastDetected ?? true), + ); useEffect(() => { // Screen reader detection only works on native platforms @@ -19,19 +29,27 @@ export function useScreenReaderEnabled(): boolean { return; } + // A detection that resolves after unmount could overwrite a newer mount's + // result, so only an active effect records it. + let active = true; + const update = (value: boolean) => { + if (!active) return; + lastDetected = value; + setIsEnabled(value); + }; + // Check initial state ScreenReader.isEnabled() - .then(({ value }) => { - setIsEnabled(value); - }) - .catch(() => setIsEnabled(false)); + .then(({ value }) => update(value)) + .catch(() => update(false)); // Listen for changes const listener = ScreenReader.addListener("stateChange", ({ value }) => { - setIsEnabled(value); + update(value); }); return () => { + active = false; listener.then((l) => l.remove()); }; }, []); diff --git a/src/lib/coreApi.ts b/src/lib/coreApi.ts index 39f8a128..8ce9c47e 100644 --- a/src/lib/coreApi.ts +++ b/src/lib/coreApi.ts @@ -451,6 +451,16 @@ export function isUnindexedMediaError(error: unknown): boolean { ); } +/** + * Core rejects browse cursors minted before a media preference edit or a + * visibility change; the client restarts from a fresh cursor. + */ +export function isBrowseCursorExpiredError(error: unknown): boolean { + return getErrorMessage(error) + .toLowerCase() + .startsWith("library visibility changed"); +} + export function isTransientApiConnectionError(error: unknown): boolean { const message = getErrorMessage(error).toLowerCase(); return [ @@ -514,7 +524,10 @@ function logMediaApiFailure( return; } - if (isExpectedMediaDatabaseError(error)) { + if ( + isExpectedMediaDatabaseError(error) || + isBrowseCursorExpiredError(error) + ) { logger.warn(`${label}:`, error, { category: "api", action, diff --git a/src/lib/libraryBrowse.ts b/src/lib/libraryBrowse.ts new file mode 100644 index 00000000..ce863d2f --- /dev/null +++ b/src/lib/libraryBrowse.ts @@ -0,0 +1,335 @@ +import { + infiniteQueryOptions, + queryOptions, + type QueryClient, +} from "@tanstack/react-query"; +import { CoreAPI, isBrowseCursorExpiredError } from "@/lib/coreApi"; +import { + LIBRARY_QUERY_KEYS, + resolveSingletonFolderEntries, + soleInitialRootPath, +} from "@/lib/libraryMedia"; +import type { + MediaBrowseEntry, + MediaBrowseIndexGroup, + MediaBrowseIndexResponse, + MediaBrowseResponse, + MediaBrowseSort, +} from "@/lib/models"; + +export const BROWSE_PAGE_SIZE = 100; +const BROWSE_MAX_RESULTS = 1000; +const BROWSE_STALE_TIME = 60 * 1000; + +export interface LibraryBrowseScope { + deviceKey: string; + systemId: string; + path: string; + sort: MediaBrowseSort; +} + +export interface LibraryBrowseRange { + start: number; + end: number; +} + +/** Rows of one index bucket, placed at their absolute list position. */ +export interface LibraryBrowseGroupRows { + start: number; + entries: MediaBrowseEntry[]; +} + +interface BrowsePageParam { + /** Index bucket whose cursor starts this page, resolved at fetch time. */ + groupKey?: string; + cursor?: string; + maxResults: number; +} + +export class LibraryBrowseGroupMissingError extends Error { + constructor(groupKey: string) { + super(`Browse index no longer has group ${groupKey}`); + this.name = "LibraryBrowseGroupMissingError"; + } +} + +export function libraryBrowseQueryKey(scope: LibraryBrowseScope) { + return [ + LIBRARY_QUERY_KEYS.browse, + scope.deviceKey, + scope.systemId, + scope.path, + scope.sort, + ] as const; +} + +export function libraryBrowseIndexQueryOptions(scope: LibraryBrowseScope) { + return queryOptions({ + queryKey: [ + LIBRARY_QUERY_KEYS.browseIndex, + scope.deviceKey, + scope.systemId, + scope.path, + scope.sort, + ], + queryFn: ({ signal }) => + CoreAPI.mediaBrowseIndex( + { + path: scope.path, + systems: [scope.systemId], + sort: scope.sort, + }, + signal, + ), + staleTime: 5 * 60 * 1000, + }); +} + +/** + * First row of a bucket. The first bucket has no cursor because it starts the + * listing, so it also carries the directories Core lists before files. + */ +export function libraryBrowseGroupStart( + group: MediaBrowseIndexGroup, + totalDirs: number, +): number { + return group.cursor === "" ? 0 : totalDirs + group.offset; +} + +export function libraryBrowseGroupEnd( + group: MediaBrowseIndexGroup, + totalDirs: number, +): number { + return totalDirs + group.offset + group.count; +} + +export function libraryBrowseGroupAt( + groups: readonly MediaBrowseIndexGroup[], + index: number, + totalDirs: number, +): MediaBrowseIndexGroup | undefined { + return groups.find( + (group) => + index >= libraryBrowseGroupStart(group, totalDirs) && + index < libraryBrowseGroupEnd(group, totalDirs), + ); +} + +export function libraryBrowsePageTotalDirs( + page: MediaBrowseResponse | undefined, +): number { + return ( + page?.totalDirs ?? + page?.entries.filter((entry) => entry.type !== "media").length ?? + 0 + ); +} + +/** + * Directory count of a folder, read from a response that starts at the top. + * Bucket cursor responses do not report it. Only library and visibility + * changes move the count, and those invalidate it. + */ +export function libraryBrowseTotalDirsQueryOptions(scope: LibraryBrowseScope) { + return queryOptions({ + queryKey: [...libraryBrowseQueryKey(scope), "totalDirs"], + queryFn: async ({ signal }) => + libraryBrowsePageTotalDirs( + await CoreAPI.mediaBrowse( + { + path: scope.path, + systems: [scope.systemId], + maxResults: 1, + sort: scope.sort, + }, + signal, + ), + ), + staleTime: Infinity, + retry: 2, + }); +} + +async function fetchLibraryBrowsePage( + scope: LibraryBrowseScope, + params: { cursor?: string; maxResults: number }, + signal: AbortSignal, +): Promise { + const response = await CoreAPI.mediaBrowse( + { + path: scope.path, + systems: [scope.systemId], + maxResults: params.maxResults, + cursor: params.cursor, + sort: scope.sort, + }, + signal, + ); + const preservedRootPath = + scope.path === "" ? soleInitialRootPath(response) : null; + const entries = await resolveSingletonFolderEntries( + response.entries, + scope.systemId, + signal, + CoreAPI, + preservedRootPath, + ); + return { ...response, entries }; +} + +/** + * Runs a fetch that starts from a bucket cursor. Media preference edits expire + * every earlier cursor, so an expired cursor is retried once from a fresh index. + */ +async function fetchFromLibraryBrowseGroup( + queryClient: QueryClient, + scope: LibraryBrowseScope, + groupKey: string, + signal: AbortSignal, + fetch: (group: MediaBrowseIndexGroup) => Promise, +): Promise { + const indexOptions = libraryBrowseIndexQueryOptions(scope); + const findGroup = (index: MediaBrowseIndexResponse) => { + const group = index.groups.find((candidate) => candidate.key === groupKey); + if (!group) throw new LibraryBrowseGroupMissingError(groupKey); + return group; + }; + + try { + return await fetch(findGroup(await queryClient.fetchQuery(indexOptions))); + } catch (error) { + if (!isBrowseCursorExpiredError(error)) throw error; + } + + // A visibility change can also hide or reveal directories. + void queryClient.invalidateQueries({ + queryKey: libraryBrowseTotalDirsQueryOptions(scope).queryKey, + exact: true, + }); + const index = await CoreAPI.mediaBrowseIndex( + { path: scope.path, systems: [scope.systemId], sort: scope.sort }, + signal, + ); + queryClient.setQueryData(indexOptions.queryKey, index); + return fetch(findGroup(index)); +} + +async function fetchLibraryBrowseGroupRows( + scope: LibraryBrowseScope, + group: MediaBrowseIndexGroup, + totalDirs: number, + signal: AbortSignal, +): Promise { + const start = libraryBrowseGroupStart(group, totalDirs); + const rawEntries: MediaBrowseEntry[] = []; + let rowCount = libraryBrowseGroupEnd(group, totalDirs) - start; + let cursor = group.cursor || undefined; + + while (rawEntries.length < rowCount) { + const response = await CoreAPI.mediaBrowse( + { + path: scope.path, + systems: [scope.systemId], + maxResults: Math.min(rowCount - rawEntries.length, BROWSE_MAX_RESULTS), + cursor, + sort: scope.sort, + }, + signal, + ); + if (group.cursor === "" && rawEntries.length === 0) { + rowCount = (response.totalDirs ?? totalDirs) + group.count; + } + rawEntries.push(...response.entries); + const nextCursor = response.pagination?.nextCursor; + if (!response.pagination?.hasNextPage || !nextCursor) break; + cursor = nextCursor; + } + + // Resolve singleton folders per browse page so a bucket matches the rows the + // top-down list shows for the same positions. + const entries: MediaBrowseEntry[] = []; + const rows = rawEntries.slice(0, rowCount); + for (let offset = 0; offset < rows.length; offset += BROWSE_PAGE_SIZE) { + // Awaiting inside the spread breaks the legacy build's async transform. + const resolved = await resolveSingletonFolderEntries( + rows.slice(offset, offset + BROWSE_PAGE_SIZE), + scope.systemId, + signal, + CoreAPI, + ); + entries.push(...resolved); + } + return { start, entries }; +} + +/** + * Forward-paged browse list. Without an anchor it starts at the top of the + * folder; with one it starts at that index bucket's cursor. + */ +export function libraryBrowseStreamQueryOptions( + queryClient: QueryClient, + scope: LibraryBrowseScope, + anchorKey?: string, +) { + const baseKey = libraryBrowseQueryKey(scope); + return infiniteQueryOptions({ + queryKey: + anchorKey === undefined ? [...baseKey] : [...baseKey, "from", anchorKey], + queryFn: ({ pageParam, signal }) => { + const { groupKey } = pageParam; + if (groupKey === undefined) { + return fetchLibraryBrowsePage(scope, pageParam, signal); + } + return fetchFromLibraryBrowseGroup( + queryClient, + scope, + groupKey, + signal, + (group) => + fetchLibraryBrowsePage( + scope, + { + cursor: group.cursor || undefined, + maxResults: pageParam.maxResults, + }, + signal, + ), + ); + }, + initialPageParam: { + groupKey: anchorKey, + maxResults: BROWSE_PAGE_SIZE, + } as BrowsePageParam, + getNextPageParam: (lastPage): BrowsePageParam | undefined => + lastPage.pagination?.hasNextPage && lastPage.pagination.nextCursor + ? { + cursor: lastPage.pagination.nextCursor, + maxResults: BROWSE_PAGE_SIZE, + } + : undefined, + staleTime: BROWSE_STALE_TIME, + retry: 2, + }); +} + +export function libraryBrowseGroupQueryOptions( + queryClient: QueryClient, + scope: LibraryBrowseScope, + groupKey: string, + totalDirs: number, +) { + return queryOptions({ + // Rows are placed using the directory count, so a new count refetches them. + queryKey: [...libraryBrowseQueryKey(scope), "group", groupKey, totalDirs], + queryFn: ({ signal }) => + fetchFromLibraryBrowseGroup( + queryClient, + scope, + groupKey, + signal, + (group) => fetchLibraryBrowseGroupRows(scope, group, totalDirs, signal), + ), + staleTime: BROWSE_STALE_TIME, + retry: 2, + }); +} diff --git a/src/lib/librarySessionStore.ts b/src/lib/librarySessionStore.ts index 71d48897..d1df96ce 100644 --- a/src/lib/librarySessionStore.ts +++ b/src/lib/librarySessionStore.ts @@ -1,6 +1,9 @@ import { create } from "zustand"; import type { MediaBrowseSort } from "@/lib/models"; -import type { SessionSearchParams } from "@/lib/tabSessionStore"; +import { + type SessionSearchParams, + useTabSessionStore, +} from "@/lib/tabSessionStore"; import type { SystemReleasePeriod, SystemSort } from "@/lib/systemFilters"; export interface LibraryFolderLevel { @@ -20,6 +23,21 @@ export function librarySearchScrollKey(scope: string): string { return `library:${scope}:search`; } +/** + * A folder list opened at an index bucket instead of the top. Rows before + * `anchorStart` load one bucket at a time as they scroll into view. + */ +export interface LibraryBrowseWindow { + anchorKey: string; + anchorStart: number; + totalDirs: number; + loadedKeys: string[]; +} + +export type LibraryBrowseWindowUpdater = ( + current: LibraryBrowseWindow | null, +) => LibraryBrowseWindow | null; + interface LibrarySessionState { deviceAddress: string | null; category: string; @@ -31,6 +49,7 @@ interface LibrarySessionState { autoEnteredRoots: Record; embeddedSearchOpen: Record; searches: Record; + browseWindows: Record; setCategory: (category: string) => void; setManufacturer: (manufacturer: string) => void; setReleasePeriod: (releasePeriod: SystemReleasePeriod) => void; @@ -40,6 +59,10 @@ interface LibrarySessionState { setAutoEnteredRoot: (systemId: string, autoEntered: boolean) => void; setEmbeddedSearchOpen: (systemId: string, open: boolean) => void; setSearch: (scope: string, search: SessionSearchParams) => void; + updateBrowseWindow: ( + scrollKey: string, + updater: LibraryBrowseWindowUpdater, + ) => void; resetNavigation: () => void; activateDevice: (deviceAddress: string) => void; reset: () => void; @@ -56,6 +79,7 @@ const initialLibrarySessionState = { autoEnteredRoots: {}, embeddedSearchOpen: {}, searches: {}, + browseWindows: {}, }; export const useLibrarySessionStore = create()((set) => ({ @@ -87,12 +111,23 @@ export const useLibrarySessionStore = create()((set) => ({ set((state) => ({ searches: { ...state.searches, [scope]: search }, })), + updateBrowseWindow: (scrollKey, updater) => + set((state) => { + const current = state.browseWindows[scrollKey] ?? null; + const next = updater(current); + if (next === current) return state; + const browseWindows = { ...state.browseWindows }; + if (next) browseWindows[scrollKey] = next; + else delete browseWindows[scrollKey]; + return { browseWindows }; + }), resetNavigation: () => set({ folderLevels: {}, autoEnteredRoots: {}, embeddedSearchOpen: {}, searches: {}, + browseWindows: {}, }), activateDevice: (deviceAddress) => set((state) => @@ -102,3 +137,14 @@ export const useLibrarySessionStore = create()((set) => ({ ), reset: () => set(initialLibrarySessionState), })); + +/** Starts a folder list from the top: no saved scroll and no bucket window. */ +export function forgetLibraryBrowse( + systemId: string, + sort: MediaBrowseSort, + path: string, +): void { + const scrollKey = libraryBrowseScrollKey(systemId, sort, path); + useTabSessionStore.getState().forgetScroll(scrollKey); + useLibrarySessionStore.getState().updateBrowseWindow(scrollKey, () => null); +} diff --git a/src/routes/library.$system.tsx b/src/routes/library.$system.tsx index f8ad71e5..c788f7d8 100644 --- a/src/routes/library.$system.tsx +++ b/src/routes/library.$system.tsx @@ -35,8 +35,10 @@ import type { import { showRateLimitedErrorToast } from "@/lib/toastUtils"; import { useStatusStore } from "@/lib/store"; import { + forgetLibraryBrowse, libraryBrowseScrollKey, librarySearchScrollKey, + type LibraryBrowseWindowUpdater, type LibraryFolderLevel, useLibrarySessionStore, } from "@/lib/librarySessionStore"; @@ -148,6 +150,19 @@ export function LibrarySystem() { const searchScrollKey = librarySearchScrollKey(systemId); const sessionScrollKey = searchOpen ? searchScrollKey : browseScrollKey; const forgetScroll = useTabSessionStore((state) => state.forgetScroll); + const browseWindow = useLibrarySessionStore((state) => + state.deviceAddress === deviceKey + ? (state.browseWindows[browseScrollKey] ?? null) + : null, + ); + const updateBrowseWindows = useLibrarySessionStore( + (state) => state.updateBrowseWindow, + ); + const updateBrowseWindow = useCallback( + (updater: LibraryBrowseWindowUpdater) => + updateBrowseWindows(browseScrollKey, updater), + [browseScrollKey, updateBrowseWindows], + ); const systemsQuery = useQuery({ queryKey: ["systems", deviceKey, { all: false }], queryFn: () => CoreAPI.systems(), @@ -167,13 +182,15 @@ export function LibrarySystem() { path: currentPath, sort: mediaSort, enabled: connected && gamesIndex.exists && libraryFeature.available, + browseWindow, + updateBrowseWindow, }); const initialRootPath = levels.length === 0 && browse.data?.pages[0] ? soleInitialRootPath(browse.data.pages[0]) : null; const initialRoot = initialRootPath - ? browse.entries.find((entry) => entry.path === initialRootPath) + ? browse.entries.find((entry) => entry?.path === initialRootPath) : undefined; const autoEnteringInitialRoot = Boolean(initialRootPath && initialRoot); @@ -195,7 +212,7 @@ export function LibrarySystem() { if (!initialRootPath || !initialRoot) return; setAutoEnteredRoot(systemId, true); - forgetScroll(libraryBrowseScrollKey(systemId, mediaSort, initialRootPath)); + forgetLibraryBrowse(systemId, mediaSort, initialRootPath); setFolderLevels(systemId, [ { name: @@ -208,7 +225,6 @@ export function LibrarySystem() { initialRoot, initialRootPath, corePlatform, - forgetScroll, mediaSort, setAutoEnteredRoot, setFolderLevels, @@ -238,6 +254,13 @@ export function LibrarySystem() { return () => cancelAnimationFrame(frame); }, [browse.entries.length, pendingJump]); + const failedGroupCount = browse.failedRanges.length; + useEffect(() => { + if (failedGroupCount > 0) { + showRateLimitedErrorToast(t("library.browseError")); + } + }, [failedGroupCount, t]); + const closeSearch = useCallback(() => { setEmbeddedSearchOpen(systemId, false); }, [setEmbeddedSearchOpen, systemId]); @@ -277,14 +300,14 @@ export function LibrarySystem() { }); const changeMediaSort = (nextSort: MediaBrowseSort) => { - forgetScroll(libraryBrowseScrollKey(systemId, nextSort, currentPath)); + forgetLibraryBrowse(systemId, nextSort, currentPath); setMediaSort(nextSort); }; const selectEntry = (entry: MediaBrowseEntry) => { if (isPlainFolderEntry(entry)) { setSelectedEntry(null); - forgetScroll(libraryBrowseScrollKey(systemId, mediaSort, entry.path)); + forgetLibraryBrowse(systemId, mediaSort, entry.path); setFolderLevels(systemId, [ ...levels, { @@ -309,15 +332,12 @@ export function LibrarySystem() { queryKey: [LIBRARY_QUERY_KEYS.image, deviceKey], }); if (jumpGenerationRef.current !== generation) return; - const targetIndex = browse.totalDirs + group.offset; try { - const loaded = - browse.entries.length > targetIndex || - (await browse.loadThroughIndex(targetIndex)); + const jump = await browse.jumpToGroup(group); if (jumpGenerationRef.current !== generation) return; - if (!loaded) throw new Error("Letter target is outside browse results"); - setPendingJump({ generation, targetIndex }); + browse.commitJump(jump); + setPendingJump({ generation, targetIndex: jump.targetIndex }); } catch (error) { if (jumpGenerationRef.current !== generation) return; logger.error("Failed to jump within media library", error, { @@ -385,6 +405,9 @@ export function LibrarySystem() { interactionDisabled={jumpingLabel !== null} onFetchMore={() => void browse.fetchMore()} onSelect={selectEntry} + onLoadIndex={browse.loadIndex} + isLoadingIndex={browse.isLoadingIndex} + failedRanges={browse.failedRanges} /> ); } diff --git a/src/routes/library.index.tsx b/src/routes/library.index.tsx index 32d6c71a..53fce966 100644 --- a/src/routes/library.index.tsx +++ b/src/routes/library.index.tsx @@ -21,7 +21,7 @@ import { } from "@/lib/systemFilters"; import { useStatusStore } from "@/lib/store"; import { - libraryBrowseScrollKey, + forgetLibraryBrowse, useLibrarySessionStore, } from "@/lib/librarySessionStore"; import { useTabSessionStore } from "@/lib/tabSessionStore"; @@ -148,7 +148,7 @@ export function Library() { manufacturerFilter !== "" || releasePeriod !== "any" || sort !== "name-asc"; const beginSystemNavigation = (systemId: string) => { const path = folderLevels[systemId]?.at(-1)?.path ?? ""; - forgetScroll(libraryBrowseScrollKey(systemId, mediaSort, path)); + forgetLibraryBrowse(systemId, mediaSort, path); }; const withLibrarySections = (systemsContent: ReactNode) => ( diff --git a/src/test-setup.ts b/src/test-setup.ts index d040c4e0..eae6c59e 100644 --- a/src/test-setup.ts +++ b/src/test-setup.ts @@ -108,6 +108,14 @@ afterEach(async () => { } catch { // lib/nfc is mocked without the test-reset export in this file } + // Forget the detected screen reader state so native hooks start accessible. + // Many files mock this hook without the reset export, so ignore those. + try { + const screenReaderModule = await import("@/hooks/useScreenReaderEnabled"); + screenReaderModule.__resetScreenReaderDetectionForTests(); + } catch { + // useScreenReaderEnabled is mocked without the test-reset export + } // Reset LiveUpdate mock state between tests __resetLiveUpdateMock(); // Reset SecureStorage mock state between tests diff --git a/src/translations/de-DE.json b/src/translations/de-DE.json index 9a540fb9..9b499542 100644 --- a/src/translations/de-DE.json +++ b/src/translations/de-DE.json @@ -87,6 +87,7 @@ "itemCount_other": "{{count}} Elemente", "loadingMore": "Weitere werden geladen...", "loadMore": "Weitere laden", + "loadEarlier": "Frühere laden", "loadedMore_one": "{{count}} weiteres Element geladen", "loadedMore_other": "{{count}} weitere Elemente geladen", "folder": "Ordner", diff --git a/src/translations/en-GB.json b/src/translations/en-GB.json index ce80d07a..354be8dc 100644 --- a/src/translations/en-GB.json +++ b/src/translations/en-GB.json @@ -87,6 +87,7 @@ "itemCount_other": "{{count}} items", "loadingMore": "Loading more...", "loadMore": "Load more", + "loadEarlier": "Load earlier", "loadedMore_one": "{{count}} more item loaded", "loadedMore_other": "{{count}} more items loaded", "folder": "Folder", diff --git a/src/translations/en-US.json b/src/translations/en-US.json index 346628f1..58a44060 100644 --- a/src/translations/en-US.json +++ b/src/translations/en-US.json @@ -87,6 +87,7 @@ "itemCount_other": "{{count}} items", "loadingMore": "Loading more...", "loadMore": "Load more", + "loadEarlier": "Load earlier", "loadedMore_one": "{{count}} more item loaded", "loadedMore_other": "{{count}} more items loaded", "folder": "Folder", diff --git a/src/translations/es-ES.json b/src/translations/es-ES.json index 5b41db04..09d1251c 100644 --- a/src/translations/es-ES.json +++ b/src/translations/es-ES.json @@ -87,6 +87,7 @@ "itemCount_other": "{{count}} elementos", "loadingMore": "Cargando más...", "loadMore": "Cargar más", + "loadEarlier": "Cargar anteriores", "loadedMore_one": "{{count}} elemento más cargado", "loadedMore_other": "{{count}} elementos más cargados", "folder": "Carpeta", diff --git a/src/translations/fr-FR.json b/src/translations/fr-FR.json index a3c3db8c..d71269e4 100644 --- a/src/translations/fr-FR.json +++ b/src/translations/fr-FR.json @@ -87,6 +87,7 @@ "itemCount_other": "{{count}} éléments", "loadingMore": "Chargement supplémentaire...", "loadMore": "Charger plus", + "loadEarlier": "Charger les précédents", "loadedMore_one": "{{count}} élément supplémentaire chargé", "loadedMore_other": "{{count}} éléments supplémentaires chargés", "folder": "Dossier", diff --git a/src/translations/ja-JP.json b/src/translations/ja-JP.json index 05cd5a94..cb6d7e02 100644 --- a/src/translations/ja-JP.json +++ b/src/translations/ja-JP.json @@ -87,6 +87,7 @@ "itemCount_other": "{{count}}件", "loadingMore": "さらに読み込み中...", "loadMore": "さらに読み込む", + "loadEarlier": "前の項目を読み込む", "loadedMore_one": "さらに{{count}}件読み込みました", "loadedMore_other": "さらに{{count}}件読み込みました", "folder": "フォルダー", diff --git a/src/translations/ko-KR.json b/src/translations/ko-KR.json index 7eb4f2bf..2788814c 100644 --- a/src/translations/ko-KR.json +++ b/src/translations/ko-KR.json @@ -87,6 +87,7 @@ "itemCount_other": "항목 {{count}}개", "loadingMore": "더 불러오는 중...", "loadMore": "더 보기", + "loadEarlier": "이전 항목 보기", "loadedMore_one": "항목 {{count}}개를 더 불러왔습니다", "loadedMore_other": "항목 {{count}}개를 더 불러왔습니다", "folder": "폴더", diff --git a/src/translations/nl-NL.json b/src/translations/nl-NL.json index 2d05d4b5..61dd533e 100644 --- a/src/translations/nl-NL.json +++ b/src/translations/nl-NL.json @@ -87,6 +87,7 @@ "itemCount_other": "{{count}} items", "loadingMore": "Meer laden...", "loadMore": "Meer laden", + "loadEarlier": "Eerdere laden", "loadedMore_one": "Nog {{count}} item geladen", "loadedMore_other": "Nog {{count}} items geladen", "folder": "Map", diff --git a/src/translations/zh-CN.json b/src/translations/zh-CN.json index 0d7bc702..268dd8c5 100644 --- a/src/translations/zh-CN.json +++ b/src/translations/zh-CN.json @@ -87,6 +87,7 @@ "itemCount_other": "{{count}} 个项目", "loadingMore": "正在加载更多...", "loadMore": "加载更多", + "loadEarlier": "加载前面的内容", "loadedMore_one": "已加载另外 {{count}} 个项目", "loadedMore_other": "已加载另外 {{count}} 个项目", "folder": "文件夹",