feat(v2): morph gallery cover into the player on Play

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-<id>`
  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) <noreply@anthropic.com>
This commit is contained in:
Georges-Antoine Assi
2026-06-22 12:46:31 -04:00
parent 4a49a762a8
commit e471efb987
8 changed files with 123 additions and 31 deletions

View File

@@ -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:**

View File

@@ -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());
}

View File

@@ -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

View File

@@ -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,
};
}

View File

@@ -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<Events>>("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-<id>`
// 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-<id>` 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);
}
}

View File

@@ -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. */

View File

@@ -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<SimpleRom | null>(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<DetailedRom | SimpleRom | null>(
() => rom.value ?? heroSeed.value,
);
const isSavesTabSelected = ref(true);
const selectedState = ref<StateSchema | null>(null);
const selectedDisc = ref<number | null>(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<SaveSchema | StateSchema | null>(() =>
</script>
<template>
<section v-if="rom" class="r-v2-ejs">
<section v-if="rom || heroSeed" class="r-v2-ejs">
<!-- Pre-game configuration -->
<div v-if="!gameRunning" class="r-v2-ejs__config">
<!-- Hero: cover + title + Play CTA -->
@@ -553,9 +568,9 @@ const selectedAsset = computed<SaveSchema | StateSchema | null>(() =>
<GameCover
ref="coverRef"
class="r-v2-ejs__cover-box"
:rom="rom"
:rom="heroRom"
:title="title"
:identified="rom?.is_identified ?? true"
:identified="heroRom?.is_identified ?? true"
:morph-id="morphRomId"
morph-static
hover-motion
@@ -573,6 +588,8 @@ const selectedAsset = computed<SaveSchema | StateSchema | null>(() =>
block
prepend-icon="mdi-play"
class="r-v2-ejs__play"
:loading="!rom"
:disabled="!rom"
@click="onPlay"
>
{{ t("play.play") }}
@@ -657,7 +674,7 @@ const selectedAsset = computed<SaveSchema | StateSchema | null>(() =>
</div>
<div class="r-v2-ejs__setup-body">
<RSelect
v-if="rom.files.length > 1"
v-if="(rom?.files?.length ?? 0) > 1"
v-model="selectedDisc"
variant="outlined"
density="comfortable"
@@ -665,7 +682,12 @@ const selectedAsset = computed<SaveSchema | StateSchema | null>(() =>
clearable
hide-details
:label="t('rom.file')"
:items="rom.files.map((f) => ({ title: f.file_name, value: f.id }))"
:items="
(rom?.files ?? []).map((f) => ({
title: f.file_name,
value: f.id,
}))
"
/>
<RSelect
v-if="supportedCores.length > 1"
@@ -716,7 +738,7 @@ const selectedAsset = computed<SaveSchema | StateSchema | null>(() =>
</div>
<!-- Running state -->
<div v-else class="r-v2-ejs__stage">
<div v-else-if="rom" class="r-v2-ejs__stage">
<Player
:rom="rom"
:state="selectedState"

View File

@@ -9,12 +9,13 @@ import { useI18n } from "vue-i18n";
import { useRoute, useRouter } from "vue-router";
import { ROUTES } from "@/plugins/router";
import romApi from "@/services/api/rom";
import storeRoms, { type DetailedRom } from "@/stores/roms";
import storeRoms, { type DetailedRom, type SimpleRom } from "@/stores/roms";
import type { RuffleSourceAPI } from "@/types/ruffle";
import { getDownloadPath } from "@/utils";
import GameCover from "@/v2/components/shared/GameCover.vue";
import { useBackgroundArt } from "@/v2/composables/useBackgroundArt";
import { useFullscreenPref } from "@/v2/composables/useFullscreenPref";
import storeGalleryRoms from "@/v2/stores/galleryRoms";
import { colorCanvas } from "@/v2/tokens";
const RUFFLE_VERSION = "0.2.0-nightly.2025.8.14";
@@ -37,13 +38,22 @@ 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 in the DOM when the view transition
// captures this view and the morph pairs on entry. `onMounted` refetches.
// Seed synchronously so the hero cover is in the DOM when the view transition
// captures this view and the morph pairs on entry. From GameDetails the full
// DetailedRom is in `currentRom`; on a direct gallery→play only a SimpleRom
// exists, so seed a cover-only `heroSeed` (`rom` stays null until `onMounted`
// refetches). See EmulatorJS for the same pattern.
const seededRom = storeRoms().currentRom;
if (seededRom && String(seededRom.id) === morphRomId.value) {
rom.value = seededRom;
}
const heroSeed = ref<SimpleRom | null>(null);
if (!rom.value && morphRomId.value != null) {
heroSeed.value = storeGalleryRoms().getRomById(Number(morphRomId.value));
}
const heroRom = computed<DetailedRom | SimpleRom | null>(
() => rom.value ?? heroSeed.value,
);
declare global {
interface Window {
@@ -84,12 +94,14 @@ watch(
);
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 ||
"",
);
function onPlay() {
@@ -173,16 +185,16 @@ onMounted(async () => {
</script>
<template>
<section v-if="rom" class="r-v2-ruffle">
<section v-if="rom || heroSeed" class="r-v2-ruffle">
<!-- Pre-game configuration -->
<div v-if="!gameRunning" class="r-v2-ruffle__config">
<!-- Cover column -->
<aside class="r-v2-ruffle__cover">
<GameCover
class="r-v2-ruffle__cover-box"
:rom="rom"
:rom="heroRom"
:title="title"
:identified="rom?.is_identified ?? true"
:identified="heroRom?.is_identified ?? true"
:morph-id="morphRomId"
morph-static
hover-motion
@@ -225,6 +237,8 @@ onMounted(async () => {
block
prepend-icon="mdi-play-circle"
class="r-v2-ruffle__play"
:loading="!rom"
:disabled="!rom"
@click="onPlay"
>
{{ t("play.play") }}