diff --git a/frontend/src/v2/components/Player/PlayerShell.test.ts b/frontend/src/v2/components/Player/PlayerShell.test.ts
new file mode 100644
index 0000000000..cbb364d298
--- /dev/null
+++ b/frontend/src/v2/components/Player/PlayerShell.test.ts
@@ -0,0 +1,136 @@
+import { mount, type VueWrapper } from "@vue/test-utils";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import type { SimpleRom } from "@/stores/roms";
+import PlayerShell from "./PlayerShell.vue";
+
+const mocks = vi.hoisted(() => ({ push: vi.fn() }));
+
+vi.mock("vue-i18n", () => ({
+ useI18n: () => ({ t: (key: string) => key }),
+}));
+
+vi.mock("vue-router", () => ({
+ useRouter: () => ({ push: mocks.push }),
+}));
+
+vi.mock("@/plugins/router", () => ({
+ ROUTES: { ROM: "rom", PLATFORM: "platform" },
+}));
+
+vi.mock("@/v2/components/shared/GameCover.vue", () => ({
+ default: { template: "
" },
+}));
+
+const heroRom = { id: 1, platform_id: 2 } as unknown as SimpleRom;
+
+function mountShell(
+ props: Partial<{
+ heroRom: SimpleRom | null;
+ ready: boolean;
+ running: boolean;
+ quitting: boolean;
+ }> = {},
+): VueWrapper {
+ return mount(PlayerShell, {
+ props: {
+ heroRom,
+ title: "Game",
+ platformLabel: "Platform",
+ romId: 1,
+ ready: true,
+ running: false,
+ ...props,
+ },
+ slots: { stage: "
" },
+ global: {
+ stubs: {
+ RBtn: {
+ props: ["disabled"],
+ emits: ["click"],
+ template:
+ ' ',
+ },
+ RCard: { template: "
" },
+ RSpinner: true,
+ },
+ },
+ });
+}
+
+beforeEach(() => {
+ mocks.push.mockReset();
+});
+
+describe("PlayerShell", () => {
+ it("waits on a spinner until a hero is available", () => {
+ const wrapper = mountShell({ heroRom: null });
+
+ expect(wrapper.findComponent({ name: "RSpinner" }).exists()).toBe(true);
+ expect(wrapper.find(".r-v2-player__play").exists()).toBe(false);
+ });
+
+ it("blocks play until the full payload has landed", async () => {
+ const wrapper = mountShell({ ready: false });
+
+ const play = wrapper.get(".r-v2-player__play");
+ expect(play.attributes("disabled")).toBeDefined();
+
+ await play.trigger("click");
+ expect(wrapper.emitted("play")).toBeUndefined();
+ });
+
+ it("emits play once the payload is ready", async () => {
+ const wrapper = mountShell();
+
+ await wrapper.get(".r-v2-player__play").trigger("click");
+
+ expect(wrapper.emitted("play")).toHaveLength(1);
+ });
+
+ it("navigates back to the game and to its platform", async () => {
+ const wrapper = mountShell();
+
+ const buttons = wrapper.findAll("button");
+ await buttons[1]!.trigger("click");
+ await buttons[2]!.trigger("click");
+
+ expect(mocks.push).toHaveBeenNthCalledWith(1, {
+ name: "rom",
+ params: { rom: 1 },
+ });
+ expect(mocks.push).toHaveBeenNthCalledWith(2, {
+ name: "platform",
+ params: { platform: 2 },
+ });
+ });
+
+ it("skips the gallery link when the hero carries no platform", async () => {
+ const wrapper = mountShell({
+ heroRom: { id: 1 } as unknown as SimpleRom,
+ });
+
+ await wrapper.findAll("button")[2]!.trigger("click");
+
+ expect(mocks.push).not.toHaveBeenCalledWith(
+ expect.objectContaining({ name: "platform" }),
+ );
+ });
+
+ it("swaps the config panel for the stage while running", async () => {
+ const wrapper = mountShell({ running: true });
+
+ expect(wrapper.find(".stage").exists()).toBe(true);
+ expect(wrapper.find(".r-v2-player__play").exists()).toBe(false);
+
+ await wrapper.get(".r-v2-player__quit").trigger("click");
+ expect(wrapper.emitted("quit")).toHaveLength(1);
+ });
+
+ it("keeps the quit button busy while an exit is still saving", () => {
+ const wrapper = mountShell({ running: true, quitting: true });
+
+ expect(
+ wrapper.get(".r-v2-player__quit").attributes("disabled"),
+ ).toBeDefined();
+ });
+});
diff --git a/frontend/src/v2/components/Player/PlayerShell.vue b/frontend/src/v2/components/Player/PlayerShell.vue
new file mode 100644
index 0000000000..b8648c7aa5
--- /dev/null
+++ b/frontend/src/v2/components/Player/PlayerShell.vue
@@ -0,0 +1,220 @@
+
+
+
+
+
+
+
+
+ {{ title }}
+
+
+ {{ platformLabel }}
+
+
+
+
+
+
+
+
+ {{ t("play.play") }}
+
+
+
+ {{ t("play.back-to-game-details") }}
+
+
+ {{ t("play.back-to-gallery") }}
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t("play.quit") }}
+
+
+
+
+
+
+
+
diff --git a/frontend/src/v2/composables/usePlayerHero/index.ts b/frontend/src/v2/composables/usePlayerHero/index.ts
new file mode 100644
index 0000000000..9485363211
--- /dev/null
+++ b/frontend/src/v2/composables/usePlayerHero/index.ts
@@ -0,0 +1,71 @@
+// usePlayerHero — the seed / hero / title block a v2 player view opens with.
+// A player refetches the full ROM on mount, so the seed is synchronous: it puts
+// a cover in the DOM before that resolves, which is what the shared-element
+// morph from the gallery or details cover pairs with on entry.
+//
+// The `rom` ref is passed in rather than created here so each caller picks its
+// own depth (JsDos and Ruffle want a shallow one, EmulatorJS a deep one).
+import { computed, type ComputedRef, type Ref, shallowRef, watch } from "vue";
+import { useI18n } from "vue-i18n";
+import { useRoute } from "vue-router";
+import storeRoms, { type DetailedRom, type SimpleRom } from "@/stores/roms";
+import { useBackgroundArt } from "@/v2/composables/useBackgroundArt";
+import { usePageTitle } from "@/v2/composables/usePageTitle";
+import storeGalleryRoms from "@/v2/stores/galleryRoms";
+
+export function usePlayerHero(rom: Ref): {
+ romId: number;
+ heroRom: ComputedRef;
+ title: ComputedRef;
+ platformLabel: ComputedRef;
+} {
+ const { t } = useI18n();
+ const route = useRoute();
+ const setBgArt = useBackgroundArt();
+
+ const romId = Number(route.params.rom);
+
+ const seededRom = storeRoms().currentRom;
+ if (seededRom?.id === romId) {
+ rom.value = seededRom;
+ }
+ const heroSeed = shallowRef(null);
+ if (!rom.value) {
+ heroSeed.value = storeGalleryRoms().getRomById(romId);
+ }
+
+ const heroRom = computed(
+ () => rom.value ?? heroSeed.value,
+ );
+
+ const title = computed(
+ () => heroRom.value?.name || heroRom.value?.fs_name_no_ext || "",
+ );
+
+ usePageTitle(() =>
+ title.value ? t("play.page-title", { name: title.value }) : null,
+ );
+
+ const platformLabel = computed(
+ () =>
+ heroRom.value?.platform_custom_name ||
+ heroRom.value?.platform_display_name ||
+ "",
+ );
+
+ // Background art keeps the plain 2D cover — a blurred disc or cartridge
+ // reads poorly as a full-bleed backdrop.
+ watch(
+ () => {
+ const r = rom.value;
+ if (!r) return null;
+ return r.path_cover_large ?? r.path_cover_small ?? r.url_cover ?? null;
+ },
+ (url) => {
+ if (url) setBgArt(url);
+ },
+ { immediate: true },
+ );
+
+ return { romId, heroRom, title, platformLabel };
+}
diff --git a/frontend/src/v2/composables/usePlayerNav/index.ts b/frontend/src/v2/composables/usePlayerNav/index.ts
new file mode 100644
index 0000000000..de95ee8abf
--- /dev/null
+++ b/frontend/src/v2/composables/usePlayerNav/index.ts
@@ -0,0 +1,26 @@
+// usePlayerNav — the two back links every v2 player view carries. The route id
+// is used rather than the hero's, so the links work during the seed window.
+import { useRouter } from "vue-router";
+import { ROUTES } from "@/plugins/router";
+
+export function usePlayerNav(
+ romId: number,
+ platformId: () => number | null | undefined,
+): {
+ backToRom: () => void;
+ backToPlatform: () => void;
+} {
+ const router = useRouter();
+
+ function backToRom() {
+ router.push({ name: ROUTES.ROM, params: { rom: romId } });
+ }
+
+ function backToPlatform() {
+ const platform = platformId();
+ if (platform == null) return;
+ router.push({ name: ROUTES.PLATFORM, params: { platform } });
+ }
+
+ return { backToRom, backToPlatform };
+}
diff --git a/frontend/src/v2/views/Player/EmulatorJS.vue b/frontend/src/v2/views/Player/EmulatorJS.vue
index 6b2e670837..7d991ef01f 100644
--- a/frontend/src/v2/views/Player/EmulatorJS.vue
+++ b/frontend/src/v2/views/Player/EmulatorJS.vue
@@ -13,7 +13,15 @@
// The running state mounts the v1 component (600 lines of EJS
// wiring — not worth rewriting). The v1 SelectSaveDialog / SelectStateDialog
// + CacheDialog are mounted in GlobalDialogs so the emitter bridge works.
-import { RBtn, RCard, RIcon, RSelect, RSliderBtnGroup, RSwitch } from "@v2/lib";
+import {
+ RBtn,
+ RCard,
+ RIcon,
+ RSelect,
+ RSliderBtnGroup,
+ RSpinner,
+ RSwitch,
+} from "@v2/lib";
import { useEventListener, useLocalStorage } from "@vueuse/core";
import type { Emitter } from "mitt";
import { storeToRefs } from "pinia";
@@ -28,30 +36,27 @@ import {
watch,
} from "vue";
import { useI18n } from "vue-i18n";
-import { useRoute, useRouter } from "vue-router";
import type { FirmwareSchema, SaveSchema, StateSchema } from "@/__generated__";
-import { ROUTES } from "@/plugins/router";
import firmwareApi from "@/services/api/firmware";
import romApi from "@/services/api/rom";
import socket from "@/services/socket";
import storeAuth from "@/stores/auth";
import storeConfig from "@/stores/config";
import storePlaying from "@/stores/playing";
-import storeRoms, { type DetailedRom, type SimpleRom } from "@/stores/roms";
+import type { DetailedRom } from "@/stores/roms";
import type { Events } from "@/types/emitter";
import { getSupportedEJSCores } from "@/utils";
import AssetPreview from "@/v2/components/Player/AssetPreview.vue";
import AssetList from "@/v2/components/shared/AssetList.vue";
import AssetStrip from "@/v2/components/shared/AssetStrip.vue";
import GameCover from "@/v2/components/shared/GameCover.vue";
-import { useBackgroundArt } from "@/v2/composables/useBackgroundArt";
import { useCoverArt } from "@/v2/composables/useCoverArt";
import { useFullscreenPref } from "@/v2/composables/useFullscreenPref";
import { useInputModality } from "@/v2/composables/useInputModality";
-import { usePageTitle } from "@/v2/composables/usePageTitle";
import { usePlaySession } from "@/v2/composables/usePlaySession";
+import { usePlayerHero } from "@/v2/composables/usePlayerHero";
+import { usePlayerNav } from "@/v2/composables/usePlayerNav";
import type { SliderBtnGroupItem } from "@/v2/lib/primitives/RSliderBtnGroup/types";
-import storeGalleryRoms from "@/v2/stores/galleryRoms";
import {
resolveBezelHost,
resolveBezelUrl,
@@ -75,8 +80,6 @@ const Player = defineAsyncComponent(
);
const { t } = useI18n();
-const route = useRoute();
-const router = useRouter();
const emitter = inject>("emitter");
const auth = storeAuth();
const playingStore = storePlaying();
@@ -99,33 +102,10 @@ const rom = ref(null);
const firmwareOptions = ref([]);
const selectedSave = ref(null);
-// Rom id straight from the route param (available before `rom` resolves),
-// so the hero cover paints its `view-transition-name` immediately and the
-// shared-element morph from the gallery / details cover pairs on entry.
-const morphRomId = computed(() => {
- const r = route.params.rom;
- return typeof r === "string" ? r : null;
-});
-
-// 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 { romId, heroRom, title, platformLabel } = usePlayerHero(rom);
+const { backToRom, backToPlatform } = usePlayerNav(
+ romId,
+ () => heroRom.value?.platform_id,
);
const isSavesTabSelected = ref(true);
const selectedState = ref(null);
@@ -207,8 +187,6 @@ const discItems = computed<{ title: string; value: DiscSelection }[]>(() => [
})),
]);
-const setBgArt = useBackgroundArt();
-
// The hero cover is the shared GameCover (same component as gallery +
// details). We keep a lightweight `useCoverArt` here only to know whether
// the active style is alt-art, so the purple glow can be dropped for a
@@ -233,7 +211,7 @@ const bezelUrl = computed(() =>
// the route param so it binds before `rom` resolves; stored as the compact "0"
// hidden / "1" shown marker (anything else fails safe to shown), and defaults
// are not written so merely opening a game leaves storage untouched.
-const showBezel = useLocalStorage(`player:${morphRomId.value}:bezel`, true, {
+const showBezel = useLocalStorage(`player:${romId}:bezel`, true, {
writeDefaults: false,
serializer: {
read: resolveStoredBezelVisible,
@@ -249,22 +227,6 @@ useEventListener(document, "fullscreenchange", () => {
bezelHost.value = resolveBezelHost(document.fullscreenElement);
});
-// Background art keeps the plain 2D cover — a blurred disc / cartridge
-// reads poorly as a full-bleed backdrop.
-const bgCoverUrl = computed(() => {
- const r = rom.value;
- if (!r) return null;
- return r.path_cover_large ?? r.path_cover_small ?? r.url_cover ?? null;
-});
-
-watch(
- bgCoverUrl,
- (url) => {
- if (url) setBgArt(url);
- },
- { immediate: true },
-);
-
async function onPlay() {
// Launch flourish on the visible cover (disc drop+spin / cartridge
// slot-in) before booting, so the insert is seen. Returns 0 for non-
@@ -357,7 +319,7 @@ watch(selectedCore, (newSelectedCore) => {
onMounted(async () => {
const romResponse = await romApi.getRom({
- romId: parseInt(route.params.rom as string),
+ romId,
});
rom.value = romResponse.data;
@@ -489,31 +451,6 @@ function openCacheDialog() {
emitter?.emit("openEmulatorJSCacheDialog", null);
}
-function backToRom() {
- router.push({ name: ROUTES.ROM, params: { rom: rom.value?.id } });
-}
-function backToPlatform() {
- router.push({
- name: ROUTES.PLATFORM,
- params: { platform: rom.value?.platform_id },
- });
-}
-
-const title = computed(
- () => heroRom.value?.name || heroRom.value?.fs_name_no_ext || "",
-);
-
-usePageTitle(() =>
- title.value ? t("play.page-title", { name: title.value }) : null,
-);
-
-const platformLabel = computed(
- () =>
- heroRom.value?.platform_custom_name ||
- heroRom.value?.platform_display_name ||
- "",
-);
-
type AssetTab = "save" | "state";
const activeAssetTab = computed(() =>
isSavesTabSelected.value ? "save" : "state",
@@ -566,7 +503,7 @@ const selectedAsset = computed(() =>
-
+
@@ -581,7 +518,7 @@ const selectedAsset = computed
(() =>
:rom="heroRom"
:title="title"
:identified="heroRom?.is_identified ?? true"
- :morph-id="morphRomId"
+ :morph-id="romId"
style-context="player"
morph-static
hover-motion
@@ -778,7 +715,7 @@ const selectedAsset = computed(() =>
@@ -1004,19 +941,6 @@ const selectedAsset = computed(() =>
display: grid;
place-items: center;
}
-.r-v2-ejs__spinner {
- width: 40px;
- height: 40px;
- border-radius: 50%;
- border: 2px solid var(--r-color-surface-hover);
- border-top-color: var(--r-color-brand-primary);
- animation: r-ejs-spin 0.8s linear infinite;
-}
-@keyframes r-ejs-spin {
- to {
- transform: rotate(360deg);
- }
-}
/* ── Responsive ──────────────────────────────────────────── */
html[data-bp~="md-and-down"] .r-v2-ejs__config {
diff --git a/frontend/src/v2/views/Player/JsDos.test.ts b/frontend/src/v2/views/Player/JsDos.test.ts
index 9202c3daad..a9062739de 100644
--- a/frontend/src/v2/views/Player/JsDos.test.ts
+++ b/frontend/src/v2/views/Player/JsDos.test.ts
@@ -187,7 +187,7 @@ async function mountPlayer(handle: JsDosProps): Promise {
);
const wrapper = mountView();
await flushPromises();
- await wrapper.get(".r-v2-jsdos__play").trigger("click");
+ await wrapper.get(".r-v2-player__play").trigger("click");
await nextTick();
return wrapper;
}
@@ -242,7 +242,7 @@ describe("JsDos player exit", () => {
const wrapper = mountView();
await flushPromises();
- await wrapper.get(".r-v2-jsdos__play").trigger("click");
+ await wrapper.get(".r-v2-player__play").trigger("click");
expect(mocks.snackbarError).toHaveBeenCalledWith(
"play.stream-error-generic",
@@ -276,7 +276,7 @@ describe("JsDos player exit", () => {
const handle = makeHandle();
const wrapper = await mountPlayer(handle);
- await wrapper.get(".r-v2-jsdos__quit").trigger("click");
+ await wrapper.get(".r-v2-player__quit").trigger("click");
await flushPromises();
expect(handle.save).toHaveBeenCalledOnce();
@@ -292,7 +292,7 @@ describe("JsDos player exit", () => {
const handle = makeHandle(false);
const wrapper = await mountPlayer(handle);
- await wrapper.get(".r-v2-jsdos__quit").trigger("click");
+ await wrapper.get(".r-v2-player__quit").trigger("click");
await flushPromises();
expect(mocks.snackbarError).toHaveBeenCalledWith(
@@ -303,7 +303,7 @@ describe("JsDos player exit", () => {
expect(mocks.flushPlaySession).not.toHaveBeenCalled();
expect(mocks.setPlaying).not.toHaveBeenCalledWith(false);
expect(
- wrapper.get(".r-v2-jsdos__quit").attributes("disabled"),
+ wrapper.get(".r-v2-player__quit").attributes("disabled"),
).toBeUndefined();
wrapper.unmount();
});
@@ -313,7 +313,7 @@ describe("JsDos player exit", () => {
const handle = makeHandle(false);
const wrapper = await mountPlayer(handle);
- await wrapper.get(".r-v2-jsdos__quit").trigger("click");
+ await wrapper.get(".r-v2-player__quit").trigger("click");
await flushPromises();
expect(handle.stop).toHaveBeenCalledOnce();
@@ -328,7 +328,7 @@ describe("JsDos player exit", () => {
handle.save.mockRejectedValue(new Error("save failed"));
const wrapper = await mountPlayer(handle);
- await wrapper.get(".r-v2-jsdos__quit").trigger("click");
+ await wrapper.get(".r-v2-player__quit").trigger("click");
await flushPromises();
expect(mocks.snackbarError).toHaveBeenCalledWith(
@@ -349,8 +349,8 @@ describe("JsDos player exit", () => {
);
const wrapper = await mountPlayer(handle);
- await wrapper.get(".r-v2-jsdos__quit").trigger("click");
- await wrapper.get(".r-v2-jsdos__quit").trigger("click");
+ await wrapper.get(".r-v2-player__quit").trigger("click");
+ await wrapper.get(".r-v2-player__quit").trigger("click");
expect(handle.save).toHaveBeenCalledOnce();
finishSave?.(true);
@@ -369,7 +369,7 @@ describe("JsDos player exit", () => {
);
const wrapper = await mountPlayer(handle);
- await wrapper.get(".r-v2-jsdos__quit").trigger("click");
+ await wrapper.get(".r-v2-player__quit").trigger("click");
expect(mocks.routeLeaveGuard?.({ fullPath: "/platform/2" })).toBe(false);
expect(handle.save).toHaveBeenCalledOnce();
diff --git a/frontend/src/v2/views/Player/JsDos.vue b/frontend/src/v2/views/Player/JsDos.vue
index 4e4dd112ab..9d9adfde06 100644
--- a/frontend/src/v2/views/Player/JsDos.vue
+++ b/frontend/src/v2/views/Player/JsDos.vue
@@ -1,30 +1,20 @@
-
-
-
-
-
- {{ title }}
-
-
- {{ platformLabel }}
-
-
-
-
-
-
-
-
- {{ t("play.jsdos-browser-save-warning") }}
-
-
-
- {{ t("play.play") }}
-
-
-
- {{ t("play.back-to-game-details") }}
-
-
- {{ t("play.back-to-gallery") }}
-
-
-
-
-
-
+
+
+
+
+
+ {{ t("play.jsdos-browser-save-warning") }}
+
+
+
+
-
- {{ t("play.quit") }}
-
-
-
-
-
+
+
diff --git a/frontend/src/v2/views/Player/Ruffle.vue b/frontend/src/v2/views/Player/Ruffle.vue
index 79d06c5087..6d45fade12 100644
--- a/frontend/src/v2/views/Player/Ruffle.vue
+++ b/frontend/src/v2/views/Player/Ruffle.vue
@@ -3,70 +3,32 @@
// createPlayer, fullscreen) is ported verbatim from
// `src/views/Player/RuffleRS/Base.vue` so playback stays identical; only the
// chrome is v2. No shared state with EJS — Flash has its own config.
-import { RBtn, RCard, RIcon, RSwitch } from "@v2/lib";
-import {
- computed,
- nextTick,
- onBeforeUnmount,
- onMounted,
- ref,
- watch,
-} from "vue";
+import { RIcon, RSwitch } from "@v2/lib";
+import { nextTick, onBeforeUnmount, onMounted, ref, shallowRef } from "vue";
import { useI18n } from "vue-i18n";
-import { useRoute, useRouter } from "vue-router";
-import { ROUTES } from "@/plugins/router";
import romApi from "@/services/api/rom";
import storePlaying from "@/stores/playing";
-import storeRoms, { type DetailedRom, type SimpleRom } from "@/stores/roms";
+import type { DetailedRom } 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 PlayerShell from "@/v2/components/Player/PlayerShell.vue";
import { useFullscreenPref } from "@/v2/composables/useFullscreenPref";
-import { usePageTitle } from "@/v2/composables/usePageTitle";
import { usePlaySession } from "@/v2/composables/usePlaySession";
-import storeGalleryRoms from "@/v2/stores/galleryRoms";
+import { usePlayerHero } from "@/v2/composables/usePlayerHero";
import { colorCanvas } from "@/v2/tokens";
const RUFFLE_VERSION = "0.2.0-nightly.2025.8.14";
const DEFAULT_BACKGROUND_COLOR = colorCanvas.bgDeep;
const { t } = useI18n();
-const route = useRoute();
-const router = useRouter();
const { fullscreenOnPlay } = useFullscreenPref();
const playingStore = storePlaying();
const playSession = usePlaySession();
-const rom = ref(null);
+const rom = shallowRef(null);
const gameRunning = ref(false);
const backgroundColor = ref(DEFAULT_BACKGROUND_COLOR);
-// Rom id from the route param (available before `rom` resolves) so the hero
-// cover paints its `view-transition-name` immediately and the shared-element
-// morph from the gallery / details cover pairs on entry.
-const morphRomId = computed(() => {
- const r = route.params.rom;
- return typeof r === "string" ? r : null;
-});
-
-// 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(null);
-if (!rom.value && morphRomId.value != null) {
- heroSeed.value = storeGalleryRoms().getRomById(Number(morphRomId.value));
-}
-const heroRom = computed(
- () => rom.value ?? heroSeed.value,
-);
-
declare global {
interface Window {
RufflePlayer: {
@@ -84,41 +46,7 @@ declare global {
window.RufflePlayer = window.RufflePlayer || {};
-const setBgArt = useBackgroundArt();
-
-// The cover is the shared GameCover (same component as gallery + details +
-// EmulatorJS) — it owns style/ratio/placeholder. Flash has no disc /
-// cartridge metadata, so it's effectively always 2D box art here.
-
-// Background art keeps the plain 2D cover.
-const bgCoverUrl = computed(() => {
- const r = rom.value;
- if (!r) return null;
- return r.path_cover_large ?? r.path_cover_small ?? r.url_cover ?? null;
-});
-
-watch(
- bgCoverUrl,
- (url) => {
- if (url) setBgArt(url);
- },
- { immediate: true },
-);
-
-const title = computed(
- () => heroRom.value?.name || heroRom.value?.fs_name_no_ext || "",
-);
-
-usePageTitle(() =>
- title.value ? t("play.page-title", { name: title.value }) : null,
-);
-
-const platformLabel = computed(
- () =>
- heroRom.value?.platform_custom_name ||
- heroRom.value?.platform_display_name ||
- "",
-);
+const { romId, heroRom, title, platformLabel } = usePlayerHero(rom);
function onPlay() {
gameRunning.value = true;
@@ -173,20 +101,8 @@ function onlyQuit() {
window.history.back();
}
-function backToRom() {
- router.push({ name: ROUTES.ROM, params: { rom: rom.value?.id } });
-}
-function backToPlatform() {
- router.push({
- name: ROUTES.PLATFORM,
- params: { platform: rom.value?.platform_id },
- });
-}
-
onMounted(async () => {
- const romResponse = await romApi.getRom({
- romId: parseInt(route.params.rom as string),
- });
+ const romResponse = await romApi.getRom({ romId });
rom.value = romResponse.data;
if (rom.value) {
@@ -216,173 +132,52 @@ onBeforeUnmount(() => {
-
-
-
-
-
-
+
+
+
+ {{ t("play.select-background-color") }}
+
+
+
-
- {{ title }}
-
-
- {{ platformLabel }}
-
-
-
-
-
-
-
-
- {{ t("play.select-background-color") }}
-
-
-
-
- {{ backgroundColor.toUpperCase() }}
-
-
-
-
-
-
- {{ t("play.play") }}
-
+
+ {{ backgroundColor.toUpperCase() }}
+
+
-
- {{ t("play.back-to-game-details") }}
-
-
- {{ t("play.back-to-gallery") }}
-
-
-
+
+
+
{{ t("play.powered-by") }}
-
+
-
-
+
-
- {{ t("play.quit") }}
-
-
-
-
-
+
+
diff --git a/frontend/src/v2/views/Player/Stream.vue b/frontend/src/v2/views/Player/Stream.vue
index 1822a13bdd..a7630321a2 100644
--- a/frontend/src/v2/views/Player/Stream.vue
+++ b/frontend/src/v2/views/Player/Stream.vue
@@ -73,8 +73,9 @@ const romId = computed(() => Number(route.params.rom));
// 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 / Ruffle
-// for the same pattern.
+// (`rom` stays null until `onMounted` refetches). `usePlayerHero` does this
+// for the other players; Stream keeps its own copy because its label comes
+// from the streaming container and it clears the background art while playing.
if (romsStore.currentRom && romsStore.currentRom.id === romId.value) {
rom.value = romsStore.currentRom;
}