mirror of
https://github.com/rommapp/romm.git
synced 2026-06-27 22:35:57 +00:00
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:
@@ -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:**
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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") }}
|
||||
|
||||
Reference in New Issue
Block a user