diff --git a/frontend/src/v2/components/Gallery/GalleryShell.vue b/frontend/src/v2/components/Gallery/GalleryShell.vue index b80e2d41a..20ec189c4 100644 --- a/frontend/src/v2/components/Gallery/GalleryShell.vue +++ b/frontend/src/v2/components/Gallery/GalleryShell.vue @@ -242,19 +242,21 @@ const { groupBy, layout, toolbarPosition } = useGalleryMode(); // CSS grid `minmax(--r-card-art-w, 1fr)` stay in lock-step. const { xs, smAndDown } = useBreakpoint(); const sectionEl = ref(null); -// Single source for the responsive card-art width — feeds both the column -// count and the virtualiser's row-height math so they never drift. -const cardWidth = () => (xs.value ? 108 : 158); -const { columns } = useResponsiveColumns(sectionEl, { +// Card-art width reference (matches GameCard's `--r-card-art-w`). It sets +// the card HEIGHT (a 2/3 cover's height at this width); each card's real +// width then follows its cover's natural ratio. +const CARD_GAP_PX = 12; +const cardWidth = () => (xs.value ? 130 : 158); +const cardHeight = () => Math.round(cardWidth() / (2 / 3)); +const { columns, usableWidth } = useResponsiveColumns(sectionEl, { cardWidth, - gap: 12, + gap: CARD_GAP_PX, inset: () => (xs.value ? 64 : smAndDown.value ? 76 : 108), }); -// Active cover aspect ratio from the gallery-wide boxart style. Drives -// both the cards' `--r-cover-ratio` (via the shell root, inherited) and -// the virtualiser's row height so the cover shape, the grid, and the -// scroll offsets all agree. +// Fallback cover ratio from the gallery-wide boxart style — used only as +// the per-card `--r-cover-ratio` seed (before GameCover measures the real +// image) and for the bootstrap skeleton rows. const { boxartStyle } = useUISettings(); const coverAspectRatio = computed(() => coverRatio( @@ -262,6 +264,31 @@ const coverAspectRatio = computed(() => ), ); +// Measured natural cover ratios, keyed by rom id (stable across gallery +// context switches, so the cache survives navigation). GameCard reports +// each cover's ratio once its image loads; the flow-packer reads them via +// `ratioAt(position)`. Updates are batched behind `ratioVersion` so a burst +// of image loads triggers a single re-pack instead of one per cover. +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; +} + // 2D arrow / gamepad nav for both layouts of the gallery. Two passes: // * Grid mode — rows are `.r-v2-shell__row` (the per-virtualizer-item // wrapper around the row's GameCards). ArrowLeft/Right within a row, @@ -301,8 +328,11 @@ const { virtualItems, letterToIndex, availableLetters, getItemHeight } = notFound: notFoundRef, notFoundMessage: notFoundMessageRef, skeletonRowCount: props.skeletonRowCount, - coverRatio: coverAspectRatio, - cardWidth, + cardHeight, + rowWidth: usableWidth, + gap: CARD_GAP_PX, + ratioAt, + ratioVersion, }); const scrollerRef = ref | null>(null); @@ -644,6 +674,7 @@ 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. @@ -682,10 +713,6 @@ const asEmpty = (i: GalleryItem) => i as EmptyItem; const asListRow = (i: GalleryItem) => i as ListRowItem; const itemKind = (i: GalleryItem) => i.kind; -const rowGridStyle = computed(() => ({ - gridTemplateColumns: `repeat(${Math.max(1, columns.value)}, minmax(var(--r-card-art-w), 1fr))`, -})); - // View-facing surface. Methods only — internal state stays internal. defineExpose({ /** Re-apply the previously-saved scroll position for the current route @@ -787,7 +814,6 @@ defineExpose({
@@ -831,7 +858,6 @@ defineExpose({
(); // Gallery selection — only wired when the consumer opts in via @@ -409,6 +413,7 @@ function onStaticKeydown(e: KeyboardEvent) { :webp="webp" :active="coverActive" :morph-id="isSynthetic ? null : rom.id" + @ratio="emit('ratio', { romId: rom.id, ratio: $event })" >