diff --git a/frontend/src/v2/components/Gallery/GalleryShell.vue b/frontend/src/v2/components/Gallery/GalleryShell.vue index 235cf3d4c..49f61e33f 100644 --- a/frontend/src/v2/components/Gallery/GalleryShell.vue +++ b/frontend/src/v2/components/Gallery/GalleryShell.vue @@ -61,6 +61,7 @@ import { type ListSortKey } from "@/v2/components/Gallery/listColumns"; import { GameCard, GameCardSkeleton } from "@/v2/components/GameCard"; import { useBreakpoint } from "@/v2/composables/useBreakpoint"; import { coverRatio, isBoxartStyle } from "@/v2/composables/useCoverArt"; +import { useGalleryCoverRatios } from "@/v2/composables/useGalleryCoverRatios"; import { useGalleryFilterUrl } from "@/v2/composables/useGalleryFilterUrl"; import { useGalleryMode } from "@/v2/composables/useGalleryMode"; import { useGalleryViewModeUrl } from "@/v2/composables/useGalleryViewModeUrl"; @@ -262,29 +263,10 @@ const coverAspectRatio = computed(() => ), ); -// Measured natural cover ratios, keyed by rom id (the key survives gallery -// switches, so a re-visited platform re-packs without re-waiting on images). -// Updates batch behind `ratioVersion` → one re-pack per burst, not per cover. -// Intentionally unbounded: two numbers per rom, reset on page reload. -const ratioByRomId = new Map(); -const ratioVersion = ref(0); -let ratioBumpTimer: ReturnType | null = null; -function onCardRatio(payload: { romId: number; ratio: number }) { - const prev = ratioByRomId.get(payload.romId); - // Ignore no-op / sub-pixel changes so we don't re-pack for nothing. - if (prev != null && Math.abs(prev - payload.ratio) < 0.01) return; - ratioByRomId.set(payload.romId, payload.ratio); - if (ratioBumpTimer) return; - ratioBumpTimer = setTimeout(() => { - ratioBumpTimer = null; - ratioVersion.value++; - }, 150); -} -function ratioAt(position: number): number { - const romId = galleryRoms.romIdIndex[position]; - if (romId == null) return 0; // → packer falls back to the default ratio - return ratioByRomId.get(romId) ?? 0; -} +// Measured natural cover ratios feeding the flow-packer — GameCard reports +// each cover's ratio on load (`onCardRatio`), the packer reads `ratioAt`, +// and `ratioVersion` bumps (debounced) to trigger a single re-pack. +const { ratioVersion, ratioAt, onCardRatio } = useGalleryCoverRatios(); // 2D arrow / gamepad nav for both layouts of the gallery. Two passes: // * Grid mode — rows are `.r-v2-shell__row` (the per-virtualizer-item @@ -671,7 +653,6 @@ onBeforeUnmount(() => { gallerySelection.clear(); if (searchDebounce) clearTimeout(searchDebounce); if (fetchDebounceTimer) clearTimeout(fetchDebounceTimer); - if (ratioBumpTimer) clearTimeout(ratioBumpTimer); // Cancel any per-position fetches still in flight for our last // visible set — `invalidateWindows` / `resetGallery` already aborts // window-level fetches; this covers per-card cleanup on unmount. diff --git a/frontend/src/v2/composables/useGalleryCoverRatios/index.test.ts b/frontend/src/v2/composables/useGalleryCoverRatios/index.test.ts new file mode 100644 index 000000000..a74de9c5d --- /dev/null +++ b/frontend/src/v2/composables/useGalleryCoverRatios/index.test.ts @@ -0,0 +1,77 @@ +import { mount } from "@vue/test-utils"; +import { createPinia, setActivePinia } from "pinia"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { defineComponent } from "vue"; +import storeGalleryRoms from "@/v2/stores/galleryRoms"; +import { useGalleryCoverRatios } from "./index"; + +// Run the composable inside a real component so its `onBeforeUnmount` +// cleanup has a host instance. Returns the composable's result. +function withComposable(fn: () => T): T { + let result!: T; + mount( + defineComponent({ + setup() { + result = fn(); + return () => null; + }, + }), + ); + return result; +} + +describe("useGalleryCoverRatios", () => { + beforeEach(() => { + setActivePinia(createPinia()); + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it("ratioAt maps position → rom id → measured ratio, else 0", () => { + storeGalleryRoms().romIdIndex = [101, 102, 103]; + const { ratioAt, onCardRatio } = withComposable(() => + useGalleryCoverRatios(), + ); + + expect(ratioAt(1)).toBe(0); // not yet measured + expect(ratioAt(99)).toBe(0); // position outside the index + + onCardRatio({ romId: 102, ratio: 1.2 }); + expect(ratioAt(1)).toBeCloseTo(1.2); // position 1 → rom 102 + expect(ratioAt(0)).toBe(0); // rom 101 still unmeasured + }); + + it("bumps ratioVersion once per debounce burst", () => { + storeGalleryRoms().romIdIndex = [101, 102]; + const { ratioVersion, onCardRatio } = withComposable(() => + useGalleryCoverRatios(), + ); + + expect(ratioVersion.value).toBe(0); + onCardRatio({ romId: 101, ratio: 0.7 }); + onCardRatio({ romId: 102, ratio: 0.5 }); + expect(ratioVersion.value).toBe(0); // still debouncing + + vi.advanceTimersByTime(150); + expect(ratioVersion.value).toBe(1); // one bump for the whole burst + }); + + it("ignores sub-epsilon changes (no re-store, no extra re-pack)", () => { + storeGalleryRoms().romIdIndex = [101]; + const { ratioVersion, ratioAt, onCardRatio } = withComposable(() => + useGalleryCoverRatios(), + ); + + onCardRatio({ romId: 101, ratio: 0.7 }); + vi.advanceTimersByTime(150); + expect(ratioVersion.value).toBe(1); + expect(ratioAt(0)).toBeCloseTo(0.7); + + onCardRatio({ romId: 101, ratio: 0.705 }); // delta 0.005 < 0.01 + vi.advanceTimersByTime(150); + expect(ratioVersion.value).toBe(1); // no new bump + expect(ratioAt(0)).toBeCloseTo(0.7); // value unchanged + }); +}); diff --git a/frontend/src/v2/composables/useGalleryCoverRatios/index.ts b/frontend/src/v2/composables/useGalleryCoverRatios/index.ts new file mode 100644 index 000000000..92af551c1 --- /dev/null +++ b/frontend/src/v2/composables/useGalleryCoverRatios/index.ts @@ -0,0 +1,55 @@ +// useGalleryCoverRatios — the gallery's measured natural cover ratios. +// +// Covers render at their image's natural aspect, so the flow-packer needs +// each card's ratio (width = cardHeight * ratio) to decide where rows break. +// The API doesn't ship cover dimensions, so the only source is the image +// itself: GameCard reports `@ratio` once its cover loads, and this collects +// the values for the packer to read via `ratioAt(position)`. +// +// Keyed by rom id (not position), so the key survives a gallery context +// switch — a re-visited platform re-packs without re-waiting on images. +// Intentionally unbounded: two numbers per rom, reset on page reload. +// +// Updates batch behind `ratioVersion`: a burst of image loads bumps it once +// (after a short debounce) so the packed layout recomputes a single time +// instead of once per cover. +import { onBeforeUnmount, ref } from "vue"; +import storeGalleryRoms from "@/v2/stores/galleryRoms"; + +// Below this delta a new measurement isn't worth a re-pack (sub-pixel noise). +const RATIO_EPSILON = 0.01; +// Wait this long after the last new ratio before bumping `ratioVersion`. +const REPACK_DEBOUNCE_MS = 150; + +export function useGalleryCoverRatios() { + const galleryRoms = storeGalleryRoms(); + const ratioByRomId = new Map(); + const ratioVersion = ref(0); + let bumpTimer: ReturnType | null = null; + + /** Record a cover's measured ratio (GameCard's `@ratio` handler). */ + function onCardRatio(payload: { romId: number; ratio: number }) { + const prev = ratioByRomId.get(payload.romId); + if (prev != null && Math.abs(prev - payload.ratio) < RATIO_EPSILON) return; + ratioByRomId.set(payload.romId, payload.ratio); + if (bumpTimer) return; + bumpTimer = setTimeout(() => { + bumpTimer = null; + ratioVersion.value++; + }, REPACK_DEBOUNCE_MS); + } + + /** Measured ratio for a position, or 0 when unknown (the packer then + * falls back to its default ratio). */ + function ratioAt(position: number): number { + const romId = galleryRoms.romIdIndex[position]; + if (romId == null) return 0; + return ratioByRomId.get(romId) ?? 0; + } + + onBeforeUnmount(() => { + if (bumpTimer) clearTimeout(bumpTimer); + }); + + return { ratioVersion, ratioAt, onCardRatio }; +}