From c5d844e1188fcd845c9d5e104399ed398cbdf1c6 Mon Sep 17 00:00:00 2001 From: zurdi Date: Mon, 22 Jun 2026 11:36:39 +0000 Subject: [PATCH] feat(v2): improve gallery responsiveness and optimize game actions handling --- .../v2/components/Gallery/GalleryShell.vue | 42 ++++- .../components/GameActions/GameActionBtn.vue | 14 +- .../src/v2/components/GameCard/GameCard.vue | 22 ++- .../src/v2/components/shared/GameCover.vue | 14 +- .../src/v2/components/shared/coverReveal.ts | 10 + .../useGalleryCoverRatios/index.ts | 8 +- .../assignFlatRowLetters.test.ts | 73 ++++++++ .../useGalleryVirtualItems/index.ts | 171 ++++++++++++------ .../v2/composables/useGameActions/index.ts | 15 +- 9 files changed, 295 insertions(+), 74 deletions(-) create mode 100644 frontend/src/v2/components/shared/coverReveal.ts create mode 100644 frontend/src/v2/composables/useGalleryVirtualItems/assignFlatRowLetters.test.ts diff --git a/frontend/src/v2/components/Gallery/GalleryShell.vue b/frontend/src/v2/components/Gallery/GalleryShell.vue index 53718de9f..0ae70ad19 100644 --- a/frontend/src/v2/components/Gallery/GalleryShell.vue +++ b/frontend/src/v2/components/Gallery/GalleryShell.vue @@ -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" > diff --git a/frontend/src/v2/components/GameActions/GameActionBtn.vue b/frontend/src/v2/components/GameActions/GameActionBtn.vue index 6e3b16018..cd2fd1416 100644 --- a/frontend/src/v2/components/GameActions/GameActionBtn.vue +++ b/frontend/src/v2/components/GameActions/GameActionBtn.vue @@ -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(), { }); 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( () => props.rom.rom_user?.status ?? null, diff --git a/frontend/src/v2/components/GameCard/GameCard.vue b/frontend/src/v2/components/GameCard/GameCard.vue index e1e1cdbf9..4c6703723 100644 --- a/frontend/src/v2/components/GameCard/GameCard.vue +++ b/frontend/src/v2/components/GameCard/GameCard.vue @@ -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) {
{ - 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 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; diff --git a/frontend/src/v2/components/shared/coverReveal.ts b/frontend/src/v2/components/shared/coverReveal.ts new file mode 100644 index 000000000..a2c4225d3 --- /dev/null +++ b/frontend/src/v2/components/shared/coverReveal.ts @@ -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(); diff --git a/frontend/src/v2/composables/useGalleryCoverRatios/index.ts b/frontend/src/v2/composables/useGalleryCoverRatios/index.ts index 92af551c1..e1a6d7d3b 100644 --- a/frontend/src/v2/composables/useGalleryCoverRatios/index.ts +++ b/frontend/src/v2/composables/useGalleryCoverRatios/index.ts @@ -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(); diff --git a/frontend/src/v2/composables/useGalleryVirtualItems/assignFlatRowLetters.test.ts b/frontend/src/v2/composables/useGalleryVirtualItems/assignFlatRowLetters.test.ts new file mode 100644 index 000000000..6583426f4 --- /dev/null +++ b/frontend/src/v2/composables/useGalleryVirtualItems/assignFlatRowLetters.test.ts @@ -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([[]]); + }); +}); diff --git a/frontend/src/v2/composables/useGalleryVirtualItems/index.ts b/frontend/src/v2/composables/useGalleryVirtualItems/index.ts index 4d1ffe919..5641f0c7c 100644 --- a/frontend/src/v2/composables/useGalleryVirtualItems/index.ts +++ b/frontend/src/v2/composables/useGalleryVirtualItems/index.ts @@ -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, +): 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(() => { + // ── 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(); + + 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(() => { + 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(); + for (const it of items) nextByKey.set(it.key, it); + prevByKey = nextByKey; return items; }); diff --git a/frontend/src/v2/composables/useGameActions/index.ts b/frontend/src/v2/composables/useGameActions/index.ts index 3d7b7e36f..ec9c9b6a9 100644 --- a/frontend/src/v2/composables/useGameActions/index.ts +++ b/frontend/src/v2/composables/useGameActions/index.ts @@ -10,7 +10,7 @@ // actions.isFavorite // reactive Ref // actions.canManageCollections // reactive Ref 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; + +/** 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 = + Symbol("v2:gameActions");