mirror of
https://github.com/rommapp/romm.git
synced 2026-06-27 22:35:57 +00:00
refactor(v2): extract gallery cover-ratio measurement into a composable
Move the measured-ratio map, debounced ratioVersion, onCardRatio handler, and ratioAt resolver out of GalleryShell into useGalleryCoverRatios. The composable owns its debounce-timer cleanup (onBeforeUnmount), so the shell no longer tracks ratioBumpTimer. Behaviour is unchanged; adds a unit test for the dedup / debounce / position→rom→ratio mapping. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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<number, number>();
|
||||
const ratioVersion = ref(0);
|
||||
let ratioBumpTimer: ReturnType<typeof setTimeout> | 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.
|
||||
|
||||
@@ -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<T>(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
|
||||
});
|
||||
});
|
||||
55
frontend/src/v2/composables/useGalleryCoverRatios/index.ts
Normal file
55
frontend/src/v2/composables/useGalleryCoverRatios/index.ts
Normal file
@@ -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<number, number>();
|
||||
const ratioVersion = ref(0);
|
||||
let bumpTimer: ReturnType<typeof setTimeout> | 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user