Merge pull request #3576 from rommapp/chore/improve-gallery-responsiveness-z

feat(v2): improve gallery responsiveness
This commit is contained in:
Zurdi
2026-06-22 13:47:27 +02:00
committed by GitHub
9 changed files with 295 additions and 74 deletions

View File

@@ -395,9 +395,25 @@ function onViewportRangeChange(range: { first: number; last: number }) {
scheduleFetchSync(range);
}
// Overscan kept on each side of the viewport — single source for both the
// RVirtualScroller prop and the debug-overlay window math below.
const VIRTUAL_OVERSCAN = 25;
// Rows kept rendered beyond the viewport. Adaptive so the rendered CARD count
// stays bounded regardless of how many columns fit: a fixed row overscan on a
// wide screen (~9 cards/row) mounts hundreds of off-screen cards, and each
// card is an expensive subtree (cover + hover overlay + shared game actions).
// We target a roughly constant overscan-card budget per side, clamped so a
// single-column phone keeps enough rows for smooth scrolling without
// overshooting. List rows are one item each → a flat row count is fine.
const GRID_OVERSCAN_CARDS = 60;
const virtualOverscan = computed(() =>
layout.value === "list"
? 12
: Math.min(
20,
Math.max(
4,
Math.round(GRID_OVERSCAN_CARDS / Math.max(1, columns.value)),
),
),
);
// ── Debug overlay bridge ────────────────────────────────────────────
// Publish the virtual scroller's window stats so the global DebugOverlay
@@ -416,8 +432,9 @@ watchEffect(() => {
const total = items.length;
const vr = viewportRange.value;
const empty = total === 0 || vr.last < vr.first;
const first = empty ? 0 : Math.max(0, vr.first - VIRTUAL_OVERSCAN);
const last = empty ? -1 : Math.min(total - 1, vr.last + VIRTUAL_OVERSCAN);
const overscan = virtualOverscan.value;
const first = empty ? 0 : Math.max(0, vr.first - overscan);
const last = empty ? -1 : Math.min(total - 1, vr.last + overscan);
// Count the cards actually mounted in the rendered window — grid rows fan
// out into many cards, so this is the real DOM weight (not just row count).
let renderedCards = 0;
@@ -436,7 +453,7 @@ watchEffect(() => {
renderedCards,
viewportFirst: vr.first,
viewportLast: vr.last,
overscan: VIRTUAL_OVERSCAN,
overscan,
scrollTop: scrollerRef.value?.scrollTop ?? 0,
});
});
@@ -743,6 +760,16 @@ const asEmpty = (i: GalleryItem) => i as EmptyItem;
const asListRow = (i: GalleryItem) => i as ListRowItem;
const itemKind = (i: GalleryItem) => i.kind;
// Stable identity for the virtualiser. Each GalleryItem carries a content-
// derived `key` (`row-${start}`, `lh-${letter}`, `lr-${p}`, …); feeding it to
// RVirtualScroller as `get-item-key` lets Vue MATCH rows across a re-pack and
// MOVE the unchanged ones instead of patching by array index. Index-keying
// (the default) reassigns every slot's content when a re-pack inserts/removes
// a row above the viewport, remounting every visible card (image re-decode +
// reveal). With identity keys, a moved row keeps its DOM and its inner cards
// (keyed by `:key="p"`) patch in place. `unknown` matches the prop signature.
const galleryItemKey = (item: unknown): string => (item as GalleryItem).key;
// View-facing surface. Methods only — internal state stays internal.
defineExpose({
/** Re-apply the previously-saved scroll position for the current route
@@ -773,7 +800,8 @@ defineExpose({
ref="scrollerRef"
:items="virtualItems"
:get-item-height="getItemHeight"
:overscan="VIRTUAL_OVERSCAN"
:get-item-key="galleryItemKey"
:overscan="virtualOverscan"
class="r-v2-shell__scroller r-v2-scroll-hidden"
@update:viewport-range="onViewportRangeChange"
>

View File

@@ -43,7 +43,10 @@ import type { SimpleRom } from "@/stores/roms";
import type { Events } from "@/types/emitter";
import { romStatusMap } from "@/utils";
import GameActionsList from "@/v2/components/GameActions/GameActionsList.vue";
import { useGameActions } from "@/v2/composables/useGameActions";
import {
GAME_ACTIONS_KEY,
useGameActions,
} from "@/v2/composables/useGameActions";
import {
ENUM_KEYS,
FLAG_KEYS,
@@ -101,7 +104,14 @@ const props = withDefaults(defineProps<Props>(), {
});
const romRef = toRef(props, "rom");
const actions = useGameActions(() => romRef.value);
// Reuse the host's shared instance when one is provided (GameCard provides a
// single `useGameActions` for all its buttons — see GAME_ACTIONS_KEY). Falls
// back to an own instance for standalone use (GameDetails header, list rows).
// Safe to call conditionally: useGameActions registers no lifecycle hooks, and
// setup decides the branch once at mount. The provided instance is bound to
// the same rom this button receives, so behaviour is identical.
const sharedActions = inject(GAME_ACTIONS_KEY, null);
const actions = sharedActions ?? useGameActions(() => romRef.value);
const enumStatus = computed<RomUserStatus | null>(
() => props.rom.rom_user?.status ?? null,

View File

@@ -38,7 +38,7 @@
// * `#overlay` slot renders content on top of the cover for badges
// that aren't part of the default gallery overlay (e.g. metadata
// provider logos in the match-flow source picker).
import { computed, ref } from "vue";
import { computed, provide, ref } from "vue";
import { useI18n } from "vue-i18n";
import { useRouter } from "vue-router";
import type { SimpleRom } from "@/stores/roms";
@@ -48,7 +48,10 @@ import GameCover from "@/v2/components/shared/GameCover.vue";
import { useBackgroundArt } from "@/v2/composables/useBackgroundArt";
import { useCoverArt } from "@/v2/composables/useCoverArt";
import { useGallerySelectionInput } from "@/v2/composables/useGallerySelectionInput";
import { useGameActions } from "@/v2/composables/useGameActions";
import {
GAME_ACTIONS_KEY,
useGameActions,
} from "@/v2/composables/useGameActions";
import { useViewTransition } from "@/v2/composables/useViewTransition";
import RCheckbox from "@/v2/lib/forms/RCheckbox/RCheckbox.vue";
import RPlatformIcon from "@/v2/lib/media/RPlatformIcon/RPlatformIcon.vue";
@@ -222,14 +225,23 @@ const ratingLabel = computed(() => {
// the regular router-link behaviour so opening in a new tab still works.
const router = useRouter();
const { morphTransition } = useViewTransition();
const actions = useGameActions(() => props.rom);
// Only interactive cards need actions: static / decorative cards (list-row
// thumbnails, dialog tiles) render no overlay, so creating the composable for
// them is pure waste. When present, share this single instance with every
// GameActionBtn in the overlay (play / download / collection / favorite /
// status / more) instead of each button spinning up its own — the difference
// between ~1 and ~7 live `useGameActions` per card, which dominates a
// virtualised grid's cost. Safe to call conditionally (no lifecycle hooks).
const actions =
props.static || props.decorative ? null : useGameActions(() => props.rom);
if (actions) provide(GAME_ACTIONS_KEY, actions);
// Stop propagation so the card's morph + router push doesn't fire when
// the user actually wanted to jump to the platform gallery.
function onPlatformClick(e: MouseEvent) {
e.preventDefault();
e.stopPropagation();
actions.goToPlatform();
actions?.goToPlatform();
}
const emit = defineEmits<{
@@ -479,7 +491,7 @@ function onStaticKeydown(e: KeyboardEvent) {
<div class="r-gc__overlay">
<div class="r-gc__overlay-center">
<GameActionBtn
v-if="actions.canPlay.value"
v-if="actions?.canPlay.value"
:rom="rom"
action="play"
variant="emphasized"

View File

@@ -22,6 +22,7 @@
// `--r-cover-radius` var (defaults to the gallery card radius).
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import CoverPlaceholder from "@/v2/components/shared/CoverPlaceholder.vue";
import { revealedCoverSrcs } from "@/v2/components/shared/coverReveal";
import { useCoverAnimation } from "@/v2/composables/useCoverAnimation";
import {
useCoverArt,
@@ -132,12 +133,15 @@ function measureNaturalRatio() {
emit("ratio", r);
}
}
watch(activeSrc, () => {
coverLoaded.value = false;
watch(activeSrc, (src) => {
// Seed from the session-seen set: a URL we've already bloomed once skips the
// reveal so a recycled card doesn't re-flash it (and doesn't pay the blur).
coverLoaded.value = !!src && revealedCoverSrcs.has(src);
naturalRatio.value = null;
});
const onCoverLoad = () => {
coverLoaded.value = true;
if (activeSrc.value) revealedCoverSrcs.add(activeSrc.value);
measureNaturalRatio();
};
// True ratio once known, else the style ratio as a first guess.
@@ -177,11 +181,17 @@ const onLeave = () => {
selfHover.value = false;
};
onMounted(() => {
// Already bloomed this URL once this session → skip the reveal on this
// (recycled) mount, regardless of whether the <img> reports `complete` yet.
if (activeSrc.value && revealedCoverSrcs.has(activeSrc.value)) {
coverLoaded.value = true;
}
// A cached cover can already be decoded before the load listener binds —
// mark it loaded so the reveal still resolves (it bloom-snaps from the
// soft initial paint instead of getting stuck blurred).
if (imgEl.value?.complete && imgEl.value.naturalWidth > 0) {
coverLoaded.value = true;
if (activeSrc.value) revealedCoverSrcs.add(activeSrc.value);
measureNaturalRatio();
}
if (!props.hoverMotion) return;

View File

@@ -0,0 +1,10 @@
// Session-shared set of cover URLs that have finished loading (bloomed) at
// least once. GameCover reads it so a recycled card (virtual scroll) pointing
// at an already-seen URL skips the blur-up reveal: replaying the
// `filter: blur(16px)` bloom on every remount is just flicker, and during the
// gallery's re-packs — which remount cards as positions shuffle between rows —
// that per-card blur cost compounds into real jank.
//
// Module-level so the state is shared across every GameCover instance. Bounded
// by the count of unique covers seen this session; reset on reload.
export const revealedCoverSrcs = new Set<string>();

View File

@@ -19,7 +19,13 @@ 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;
// Each bump re-packs the whole list (O(total)) and cascades into the
// scroller's offsets + letterToIndex, so during a fast scroll through a large
// library — where covers stream in continuously — a tighter window turns into
// a re-pack storm that saturates the main thread. Coalesce more aggressively:
// the layout settling to natural ratios a fraction of a second later is
// imperceptible next to the jank a per-150ms full re-pack causes.
const REPACK_DEBOUNCE_MS = 350;
export function useGalleryCoverRatios() {
const galleryRoms = storeGalleryRoms();

View File

@@ -0,0 +1,73 @@
import { describe, expect, it } from "vitest";
import { assignFlatRowLetters } from "./index";
// Reference (the pre-restructure per-row scan) — each row collects every range
// it overlaps. The linear `assignFlatRowLetters` must match this exactly.
function naive(
rows: ReadonlyArray<{ start: number; end: number }>,
ranges: ReadonlyArray<{ letter: string; start: number; end: number }>,
): string[][] {
return rows.map((row) =>
ranges
.filter((r) => r.end > row.start && r.start < row.end)
.map((r) => r.letter),
);
}
describe("assignFlatRowLetters", () => {
// Letters partition the positions: A[0,4) B[4,9) C[9,12).
const ranges = [
{ letter: "A", start: 0, end: 4 },
{ letter: "B", start: 4, end: 9 },
{ letter: "C", start: 9, end: 12 },
];
it("tags a row fully inside one letter with just that letter", () => {
expect(assignFlatRowLetters([{ start: 0, end: 3 }], ranges)).toEqual([
["A"],
]);
});
it("tags a row straddling a boundary with both letters", () => {
// [2,6) overlaps A[0,4) and B[4,9).
expect(assignFlatRowLetters([{ start: 2, end: 6 }], ranges)).toEqual([
["A", "B"],
]);
});
it("tags a row spanning three letters with all three", () => {
expect(assignFlatRowLetters([{ start: 3, end: 12 }], ranges)).toEqual([
["A", "B", "C"],
]);
});
it("advances correctly across many consecutive rows", () => {
const rows = [
{ start: 0, end: 3 }, // A
{ start: 3, end: 5 }, // A,B
{ start: 5, end: 9 }, // B
{ start: 9, end: 12 }, // C
];
expect(assignFlatRowLetters(rows, ranges)).toEqual([
["A"],
["A", "B"],
["B"],
["C"],
]);
});
it("matches the naive per-row scan on a denser layout", () => {
const rows = [
{ start: 0, end: 2 },
{ start: 2, end: 4 },
{ start: 4, end: 4 }, // empty row (degenerate) → no letters
{ start: 4, end: 11 },
{ start: 11, end: 12 },
];
expect(assignFlatRowLetters(rows, ranges)).toEqual(naive(rows, ranges));
});
it("returns empties when there are no ranges", () => {
expect(assignFlatRowLetters([{ start: 0, end: 3 }], [])).toEqual([[]]);
});
});

View File

@@ -15,6 +15,16 @@
// lands 72 ROMs only re-renders up to ⌈72/cols⌉ rows, not the entire
// virtualItems array.
//
// Re-pack handling: a measured cover ratio change bumps `ratioVersion`, which
// re-runs the build. Two things keep that cheap so a fast scroll through a
// huge library doesn't thrash:
// * Structural sharing — unchanged item objects are reused from the previous
// build (keyed by their stable `key`), so a bump only allocates the rows
// that actually reflowed (paired with the scroller's `get-item-key`, Vue
// then moves the rest instead of remounting their cards).
// * Linear letter assignment — flat rows get their letters in one merge pass
// over the sorted ranges (`assignFlatRowLetters`), not a per-row scan.
//
// AlphaStrip integration is index-based: `letterToIndex` maps each
// available letter to the index of its first item in `virtualItems`,
// fed straight into `RVirtualScroller.scrollToIndex(idx, { stickyOffset })`.
@@ -176,14 +186,25 @@ function buildLetterRanges(
return ranges;
}
function lettersInRange(
ranges: LetterRange[],
startPos: number,
endPos: number,
): string[] {
const out: string[] = [];
for (const r of ranges) {
if (r.end > startPos && r.start < endPos) out.push(r.letter);
/** Assign each flat-packed row the letters its position range overlaps, in a
* single linear pass. Rows and ranges are both ascending, so one advancing
* `base` pointer over the ranges replaces the old per-row overlap scan
* (O(rows × letters) → O(rows + letters)). Exported for unit tests. */
export function assignFlatRowLetters(
rows: ReadonlyArray<{ start: number; end: number }>,
ranges: ReadonlyArray<LetterRange>,
): string[][] {
const out: string[][] = [];
// `base` only advances: once a range ends at or before a row's start it can
// never overlap a later (higher) row, so it's skipped for good.
let base = 0;
for (const row of rows) {
while (base < ranges.length && ranges[base].end <= row.start) base++;
const letters: string[] = [];
for (let j = base; j < ranges.length && ranges[j].start < row.end; j++) {
if (ranges[j].end > row.start) letters.push(ranges[j].letter);
}
out.push(letters);
}
return out;
}
@@ -217,7 +238,64 @@ export function useGalleryVirtualItems(opts: Options) {
return FIXED_HEIGHT_BY_KIND[kind] ?? 0;
}
const virtualItems = computed<GalleryItem[]>(() => {
// ── Structural sharing across re-packs ────────────────────────────────
// A measured cover ratio change bumps `ratioVersion`, re-running the build.
// Only the run of rows from the first re-measured cover onward actually
// reflows; everything before stays identical. So we REUSE the previous
// build's item objects whenever their content matches (looked up by stable
// key). A reused object keeps its reference, so — paired with the scroller's
// content-derived `get-item-key` — Vue moves the untouched rows instead of
// re-rendering them, and only the reflowed rows allocate. This turns a
// per-bump O(total) re-allocation into O(reflowed rows). `prevByKey` holds
// the last build and survives across evaluations as closure state; if it
// ever goes stale the only cost is a missed reuse (emitted items are always
// correct, since they're built fresh from the current pack every time).
let prevByKey = new Map<string, GalleryItem>();
function sameLetters(a: readonly string[], b: readonly string[]): boolean {
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
return true;
}
function shareRow(
start: number,
end: number,
letters: string[],
): GalleryItem {
const prev = prevByKey.get(`row-${start}`);
if (
prev?.kind === "row" &&
prev.endPosition === end &&
sameLetters(prev.letters, letters)
) {
return prev;
}
return {
kind: "row",
key: `row-${start}`,
startPosition: start,
endPosition: end,
letters,
};
}
function shareHeader(letter: string): GalleryItem {
// A header's whole content is its letter, which the key encodes — same key
// ⇒ identical, always reusable.
const prev = prevByKey.get(`lh-${letter}`);
if (prev?.kind === "letter-header") return prev;
return { kind: "letter-header", key: `lh-${letter}`, letter };
}
function shareListRow(position: number, letter: string): GalleryItem {
const prev = prevByKey.get(`lr-${position}`);
if (prev?.kind === "list-row" && prev.letter === letter) return prev;
return { kind: "list-row", key: `lr-${position}`, position, letter };
}
// Build the structural body for the current state. Reads every layout dep
// (so the wrapping computed tracks them) and routes row / header / list-row
// emission through the share helpers above. Split out from the cache refresh
// so every early-returning branch still flows through one place.
function buildItems(): GalleryItem[] {
const items: GalleryItem[] = [];
if (opts.notFound?.value) {
@@ -230,14 +308,13 @@ export function useGalleryVirtualItems(opts: Options) {
}
// List layout — one virtual item per ROM position. The view template
// resolves each row via `getRomAt(position)` and renders skeleton vs
// real inside the same kind. Group-by-letter in list mode is
// deferred (see CLAUDE.md §X — list-mode MVP first).
// resolves each row via `getRomAt(position)` and renders skeleton vs real
// inside the same kind. Group-by-letter in list mode is deferred (see
// CLAUDE.md §X — list-mode MVP first).
if (opts.layout.value === "list") {
if (opts.loadingInitial.value && opts.total.value === 0) {
// Bootstrap phase — paint placeholder rows so the scroller has a
// shape while metadata is in flight. Reasonable count to fill a
// typical viewport without committing to a fixed number.
// Bootstrap phase — placeholder rows give the scroller a shape while
// metadata is in flight. Enough to fill a typical viewport.
const skeletonListRows = Math.max(skeletonRows * 4, 12);
for (let i = 0; i < skeletonListRows; i++) {
items.push({
@@ -258,10 +335,8 @@ export function useGalleryVirtualItems(opts: Options) {
}
const ranges = letterRanges.value;
const total = opts.total.value;
// Pre-compute a position → letter lookup so each list-row knows
// which letter it belongs to (drives AlphaStrip's scroll-spy).
// Walking `ranges` once (sorted by start) is O(total) — acceptable
// because virtualItems already iterates `total`.
// Walk `ranges` once (sorted by start) to tag each position with its
// letter (drives AlphaStrip's scroll-spy).
let rangeIdx = 0;
for (let p = 0; p < total; p++) {
while (
@@ -270,19 +345,13 @@ export function useGalleryVirtualItems(opts: Options) {
) {
rangeIdx++;
}
const letter = ranges[rangeIdx]?.letter ?? "#";
items.push({
kind: "list-row",
key: `lr-${p}`,
position: p,
letter,
});
items.push(shareListRow(p, ranges[rangeIdx]?.letter ?? "#"));
}
return items;
}
// Grid + first-window-loading — show skeleton rows until the server
// tells us `total` and `charIndex`.
// Grid + first-window-loading — skeleton rows until the server returns
// `total` and `charIndex`.
if (opts.loadingInitial.value && opts.total.value === 0) {
for (let i = 0; i < skeletonRows; i++) {
items.push({ kind: "skeleton-row", key: `skel-${i}`, index: i });
@@ -314,11 +383,7 @@ export function useGalleryVirtualItems(opts: Options) {
if (opts.groupBy.value === "letter") {
// Header + own flow-packed rows per letter (rows restart per letter).
for (const range of ranges) {
items.push({
kind: "letter-header",
key: `lh-${range.letter}`,
letter: range.letter,
});
items.push(shareHeader(range.letter));
for (const row of packFlowRows(
range.start,
range.end,
@@ -327,35 +392,29 @@ export function useGalleryVirtualItems(opts: Options) {
gap,
ratioAt,
)) {
items.push({
kind: "row",
key: `row-${row.start}`,
startPosition: row.start,
endPosition: row.end,
letters: [range.letter],
});
items.push(shareRow(row.start, row.end, [range.letter]));
}
}
} else {
// Flat — flow-pack the whole list; rows stay contiguous position runs.
for (const row of packFlowRows(
0,
total,
rowWidth,
cardHeight,
gap,
ratioAt,
)) {
items.push({
kind: "row",
key: `row-${row.start}`,
startPosition: row.start,
endPosition: row.end,
letters: lettersInRange(ranges, row.start, row.end),
});
// Flat — flow-pack the whole list, then tag every row with its
// overlapping letters in one linear pass (not a per-row scan).
const rows = packFlowRows(0, total, rowWidth, cardHeight, gap, ratioAt);
const lettersPerRow = assignFlatRowLetters(rows, ranges);
for (let i = 0; i < rows.length; i++) {
items.push(shareRow(rows[i].start, rows[i].end, lettersPerRow[i]));
}
}
return items;
}
const virtualItems = computed<GalleryItem[]>(() => {
const items = buildItems();
// Refresh the reuse cache from this build for the next re-pack — cheap
// O(items) bookkeeping; the win is the avoided re-allocation in buildItems.
const nextByKey = new Map<string, GalleryItem>();
for (const it of items) nextByKey.set(it.key, it);
prevByKey = nextByKey;
return items;
});

View File

@@ -10,7 +10,7 @@
// actions.isFavorite // reactive Ref<boolean>
// actions.canManageCollections // reactive Ref<boolean>
import type { Emitter } from "mitt";
import { computed, inject } from "vue";
import { computed, inject, type InjectionKey } from "vue";
import { useI18n } from "vue-i18n";
import { useRouter } from "vue-router";
import type { RomUserData, RomUserStatus } from "@/__generated__";
@@ -341,3 +341,16 @@ export function useGameActions(getRom: () => SimpleRom | null | undefined) {
removeFromContinuePlaying,
};
}
export type GameActions = ReturnType<typeof useGameActions>;
/** Injection key for sharing one `useGameActions` instance down a subtree.
* A GameCard hosts ~6 GameActionBtn children (play / download / collection /
* favorite / status / more); each one re-instantiating the full composable
* (i18n + router + emitter + two stores + `useCan`×2 + favorite/can-play
* computeds) is what made a virtualised grid of cards thousands of live
* instances. The card creates a single instance and `provide`s it; each
* button `inject`s and reuses it, falling back to its own only when used
* standalone (GameDetails header, list rows) with no provider. */
export const GAME_ACTIONS_KEY: InjectionKey<GameActions> =
Symbol("v2:gameActions");