From e471efb9875510d2be28c976ff0ad6dd64032631 Mon Sep 17 00:00:00 2001 From: Georges-Antoine Assi Date: Mon, 22 Jun 2026 12:46:31 -0400 Subject: [PATCH] feat(v2): morph gallery cover into the player on Play MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clicking Play at the center of a gallery cover now runs the shared-element view transition into the /ejs (EmulatorJS) or /ruffle hero, matching the card → details morph. - useGameActions gains an optional `coverEl` resolver; `play()` wraps the navigation in `morphTransition` (cover → player hero, same `rom-cover-` tag the player paints statically) and awaits the push so the snapshot is taken after the player renders. GameCard supplies its GameCover box. - The player heroes only seeded `rom` from `currentRom` (set via GameDetails), so a direct gallery→play left `rom` null and the `v-if`-gated hero never rendered — nothing to morph into. Seed a lightweight `heroSeed` SimpleRom from the gallery store (new `galleryRoms.getRomById`) so the cover paints its morph tag immediately; `rom` fills in on mount. Play is disabled until the full payload loads. - Enable hover-motion on both player heroes so the cover spin / hover video work there too. - Arcade systems (arcade / neogeoaes / neogeomvs) skip the cartridge slot-in animation (new `isArcadeSystem`). Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/FRONTEND_ARCHITECTURE.md | 1 + frontend/src/utils/index.ts | 6 +++ .../src/v2/components/GameCard/GameCard.vue | 6 ++- .../src/v2/composables/useCoverArt/index.ts | 9 +++- .../v2/composables/useGameActions/index.ts | 40 +++++++++++++-- frontend/src/v2/stores/galleryRoms.ts | 10 ++++ frontend/src/v2/views/Player/EmulatorJS.vue | 50 +++++++++++++------ frontend/src/v2/views/Player/Ruffle.vue | 32 ++++++++---- 8 files changed, 123 insertions(+), 31 deletions(-) diff --git a/docs/FRONTEND_ARCHITECTURE.md b/docs/FRONTEND_ARCHITECTURE.md index aebf1abf6..26ce4a254 100644 --- a/docs/FRONTEND_ARCHITECTURE.md +++ b/docs/FRONTEND_ARCHITECTURE.md @@ -1030,6 +1030,7 @@ Request Flow with Cache: - `getSupportedEJSCores()`: platform → EmulatorJS core mapping - `isEJSEmulationSupported()`: WebGL + config check - `isCDBasedSystem()`: 31 CD-based platforms +- `isArcadeSystem()`: 3 arcade platforms **Game Status:** diff --git a/frontend/src/utils/index.ts b/frontend/src/utils/index.ts index 2c3678fce..e703b7753 100644 --- a/frontend/src/utils/index.ts +++ b/frontend/src/utils/index.ts @@ -875,3 +875,9 @@ export const CD_BASED_SYSTEMS = new Set([ export function isCDBasedSystem(platformSlug: string): boolean { return CD_BASED_SYSTEMS.has(platformSlug.toLowerCase()); } + +export const ARCADE_SYSTEMS = new Set(["arcade", "neogeoaes", "neogeomvs"]); + +export function isArcadeSystem(platformSlug: string): boolean { + return ARCADE_SYSTEMS.has(platformSlug.toLowerCase()); +} diff --git a/frontend/src/v2/components/GameCard/GameCard.vue b/frontend/src/v2/components/GameCard/GameCard.vue index 4c6703723..037c4d87e 100644 --- a/frontend/src/v2/components/GameCard/GameCard.vue +++ b/frontend/src/v2/components/GameCard/GameCard.vue @@ -233,7 +233,11 @@ const { morphTransition } = useViewTransition(); // 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); + props.static || props.decorative + ? null + : useGameActions(() => props.rom, { + coverEl: () => coverRef.value?.el() ?? null, + }); if (actions) provide(GAME_ACTIONS_KEY, actions); // Stop propagation so the card's morph + router push doesn't fire when diff --git a/frontend/src/v2/composables/useCoverArt/index.ts b/frontend/src/v2/composables/useCoverArt/index.ts index fbbc98522..bab4fcaf7 100644 --- a/frontend/src/v2/composables/useCoverArt/index.ts +++ b/frontend/src/v2/composables/useCoverArt/index.ts @@ -32,7 +32,11 @@ import { } from "vue"; import { useUISettings } from "@/composables/useUISettings"; import type { SimpleRom } from "@/stores/roms"; -import { FRONTEND_RESOURCES_PATH, isCDBasedSystem } from "@/utils"; +import { + FRONTEND_RESOURCES_PATH, + isCDBasedSystem, + isArcadeSystem, +} from "@/utils"; import { useWebpSupport } from "@/v2/composables/useWebpSupport"; export type BoxartStyle = @@ -164,6 +168,7 @@ export function computeCoverArt( const physicalAlt = style === "physical_path" && isAltArt; const cdBased = physicalAlt && isCDBasedSystem(rom.platform_slug); + const arcadeBased = physicalAlt && isArcadeSystem(rom.platform_slug); const videoUrl = style === "miximage_path" && rom.path_video @@ -178,7 +183,7 @@ export function computeCoverArt( ratio, objectFit: style === "cover_path" ? "cover" : "contain", animateCD: physicalAlt && cdBased, - animateCartridge: physicalAlt && !cdBased, + animateCartridge: physicalAlt && !cdBased && !arcadeBased, videoUrl, }; } diff --git a/frontend/src/v2/composables/useGameActions/index.ts b/frontend/src/v2/composables/useGameActions/index.ts index ec9c9b6a9..a2f019281 100644 --- a/frontend/src/v2/composables/useGameActions/index.ts +++ b/frontend/src/v2/composables/useGameActions/index.ts @@ -25,10 +25,25 @@ import { getDownloadLink, getDownloadPath, isNintendoDSRom } from "@/utils"; import { useCan } from "@/v2/composables/useCan"; import { useCanPlay } from "@/v2/composables/useCanPlay"; import { useSnackbar } from "@/v2/composables/useSnackbar"; +import { useViewTransition } from "@/v2/composables/useViewTransition"; -export function useGameActions(getRom: () => SimpleRom | null | undefined) { +export interface GameActionsOptions { + /** Resolver for the cover element to morph from when `play()` navigates to + * the player view. When it returns an element, the navigation runs through + * a shared-element view transition (cover → player hero, same `rom-cover-` + * tag the destination paints); otherwise navigation is immediate. The + * GameCard passes its GameCover box so clicking Play in the gallery morphs + * the cover into /ejs the same way clicking the card morphs into details. */ + coverEl?: () => HTMLElement | null; +} + +export function useGameActions( + getRom: () => SimpleRom | null | undefined, + options: GameActionsOptions = {}, +) { const { t } = useI18n(); const router = useRouter(); + const { morphTransition } = useViewTransition(); const emitter = inject>("emitter"); const snackbar = useSnackbar(); const romsStore = storeRoms(); @@ -164,10 +179,25 @@ export function useGameActions(getRom: () => SimpleRom | null | undefined) { // The launch "load" flourish (disc/cartridge insert) lives on the // player view itself — see EmulatorJS's onPlay — so navigation is // immediate here. - if (canPlayEJS.value) { - router.push(`/rom/${rom.id}/ejs`); - } else if (canPlayRuffle.value) { - router.push(`/rom/${rom.id}/ruffle`); + let path: string | null = null; + if (canPlayEJS.value) path = `/rom/${rom.id}/ejs`; + else if (canPlayRuffle.value) path = `/rom/${rom.id}/ruffle`; + if (!path) return; + const target = path; + // When the caller supplies a cover element (the gallery card / detail + // hero), morph it into the player's hero cover — same `rom-cover-` + // tag the player paints statically. Degrades to a plain push where view + // transitions aren't available. + const el = options.coverEl?.(); + if (el) { + // Await the push inside the transition so the browser snapshots the + // player view *after* it has rendered its hero cover (which carries the + // same `rom-cover-` tag) — otherwise there's no element to morph to. + morphTransition({ el, name: `rom-cover-${rom.id}` }, async () => { + await router.push(target); + }); + } else { + router.push(target); } } diff --git a/frontend/src/v2/stores/galleryRoms.ts b/frontend/src/v2/stores/galleryRoms.ts index 2376a2a12..a21f5f91c 100644 --- a/frontend/src/v2/stores/galleryRoms.ts +++ b/frontend/src/v2/stores/galleryRoms.ts @@ -218,6 +218,16 @@ export default defineStore("v2GalleryRoms", { return this.byPosition.get(position) ?? null; }, + /** Find a loaded ROM by id, or null. Scans the sparse `byPosition` + * window cache — used to seed the player hero cover synchronously on a + * direct gallery→play so the shared-element morph has a target. */ + getRomById(id: number): SimpleRom | null { + for (const rom of this.byPosition.values()) { + if (rom.id === id) return rom; + } + return null; + }, + /** Drop everything tied to the previous gallery context but keep * order-by / order-dir. Call before switching platform / collection * or when filters change. */ diff --git a/frontend/src/v2/views/Player/EmulatorJS.vue b/frontend/src/v2/views/Player/EmulatorJS.vue index 2ac76ebd8..bd4f858b3 100644 --- a/frontend/src/v2/views/Player/EmulatorJS.vue +++ b/frontend/src/v2/views/Player/EmulatorJS.vue @@ -37,7 +37,7 @@ import socket from "@/services/socket"; import storeAuth from "@/stores/auth"; import storeConfig from "@/stores/config"; import storePlaying from "@/stores/playing"; -import storeRoms, { type DetailedRom } from "@/stores/roms"; +import storeRoms, { type DetailedRom, type SimpleRom } from "@/stores/roms"; import type { Events } from "@/types/emitter"; import { getSupportedEJSCores } from "@/utils"; import AssetPreview from "@/v2/components/Player/AssetPreview.vue"; @@ -49,6 +49,7 @@ import { useCoverArt } from "@/v2/composables/useCoverArt"; import { useFullscreenPref } from "@/v2/composables/useFullscreenPref"; import { useInputModality } from "@/v2/composables/useInputModality"; import type { SliderBtnGroupItem } from "@/v2/lib/primitives/RSliderBtnGroup/types"; +import storeGalleryRoms from "@/v2/stores/galleryRoms"; import { installIOSFullscreenShim } from "@/views/Player/EmulatorJS/utils"; // Reuse v1's heavy emulator integration — do NOT rewrite this. Lazy so the @@ -89,14 +90,26 @@ const morphRomId = computed(() => { return typeof r === "string" ? r : null; }); -// Seed the rom synchronously from the store (set by GameDetails / not -// cleared on its unmount) so the hero cover is already in the DOM when the -// view transition captures this view — the morph from the details / gallery -// cover then pairs on entry. `onMounted` refetches the full payload. +// Seed synchronously so the hero cover is already in the DOM when the view +// transition captures this view — the morph from the details / gallery cover +// then pairs on entry. `onMounted` refetches the full payload. +// * From GameDetails: `currentRom` is the full DetailedRom → seed `rom`. +// * Direct gallery→play: only a SimpleRom exists (the gallery card) → seed a +// cover-only `heroSeed` so the cover still paints its morph tag. `rom` +// stays null (its DetailedRom-only fields are read guarded) until mount. const seededRom = storeRoms().currentRom; if (seededRom && String(seededRom.id) === morphRomId.value) { rom.value = seededRom; } +const heroSeed = ref(null); +if (!rom.value && morphRomId.value != null) { + heroSeed.value = storeGalleryRoms().getRomById(Number(morphRomId.value)); +} +// What the hero cover / title / glow read: the full rom once loaded, else the +// lightweight seed during the morph-in window. +const heroRom = computed( + () => rom.value ?? heroSeed.value, +); const isSavesTabSelected = ref(true); const selectedState = ref(null); const selectedDisc = ref(null); @@ -176,7 +189,7 @@ const setBgArt = useBackgroundArt(); // the active style is alt-art, so the purple glow can be dropped for a // floating disc / cartridge / mix image. The launch flourish is triggered // imperatively on the GameCover via `coverRef` — see onPlay. -const art = useCoverArt(() => rom.value); +const art = useCoverArt(() => heroRom.value); const heroIsAlt = computed( () => art.style.value !== "cover_path" && @@ -481,12 +494,14 @@ function backToPlatform() { } const title = computed( - () => rom.value?.name || rom.value?.fs_name_no_ext || "", + () => heroRom.value?.name || heroRom.value?.fs_name_no_ext || "", ); const platformLabel = computed( () => - rom.value?.platform_custom_name || rom.value?.platform_display_name || "", + heroRom.value?.platform_custom_name || + heroRom.value?.platform_display_name || + "", ); type AssetTab = "save" | "state"; @@ -541,7 +556,7 @@ const selectedAsset = computed(() =>