From 85be36dc0bfb67216f5bae210b15925469c85fa0 Mon Sep 17 00:00:00 2001 From: Georges-Antoine Assi Date: Sat, 29 Aug 2026 10:31:01 -0400 Subject: [PATCH 1/3] refactor(v2): extract a shared player shell MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EmulatorJS.vue and Ruffle.vue each carry their own copy of the block every v2 player opens with: the synchronous hero seeding, title, platform label and background-art watch. Ruffle additionally hand-rolls the cover column, settings card, back buttons and loading spinner that any simple player needs. Extract two seams: * usePlayerHero — the seed/hero/title/platform-label block plus the background-art watch, adopted by both players. The caller keeps ownership of the `rom` ref because EmulatorJS needs a deep one for the save and state lists it mutates. * PlayerShell — the cover column, settings card, play and back buttons, the full-bleed running stage and the loading state, with `settings`, `stage` and `brand` slots. Ruffle keeps only its colour picker, brand strip and stage. EmulatorJS deliberately opts out: its hero sits inside a card with an alt-art glow and it lays out three panels on its own breakpoints, so it takes the composable alone. Stream.vue is left alone: its label comes from the streaming container, its background art clears while playing, and it has its own back route. No intended behaviour change. Ruffle's hand-rolled loading spinner is now RSpinner, matching the other players. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/v2/components/Player/PlayerShell.vue | 227 ++++++++++++ .../src/v2/composables/usePlayerHero/index.ts | 76 ++++ frontend/src/v2/views/Player/EmulatorJS.vue | 77 +--- frontend/src/v2/views/Player/Ruffle.vue | 331 +++--------------- 4 files changed, 351 insertions(+), 360 deletions(-) create mode 100644 frontend/src/v2/components/Player/PlayerShell.vue create mode 100644 frontend/src/v2/composables/usePlayerHero/index.ts diff --git a/frontend/src/v2/components/Player/PlayerShell.vue b/frontend/src/v2/components/Player/PlayerShell.vue new file mode 100644 index 0000000000..7c5b9255f2 --- /dev/null +++ b/frontend/src/v2/components/Player/PlayerShell.vue @@ -0,0 +1,227 @@ + + + + + diff --git a/frontend/src/v2/composables/usePlayerHero/index.ts b/frontend/src/v2/composables/usePlayerHero/index.ts new file mode 100644 index 0000000000..9268b75903 --- /dev/null +++ b/frontend/src/v2/composables/usePlayerHero/index.ts @@ -0,0 +1,76 @@ +// usePlayerHero — the seed / hero / title block a v2 player view opens with. +// EmulatorJS and Ruffle both refetch the full ROM on mount but need a cover in +// the DOM *before* that resolves, so the shared-element morph from the gallery +// or details cover pairs on entry. +// +// Seeding is synchronous: from GameDetails the full DetailedRom is already in +// `currentRom`; on a direct gallery→play only a SimpleRom exists, so a +// cover-only `heroSeed` stands in until the view's own fetch lands. +// +// The caller owns the `rom` ref — EmulatorJS needs a deep one for the save and +// state lists it mutates — and this seeds into it. +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; + heroSeed: Ref; + 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, heroSeed, heroRom, title, platformLabel }; +} diff --git a/frontend/src/v2/views/Player/EmulatorJS.vue b/frontend/src/v2/views/Player/EmulatorJS.vue index 6b2e670837..19110aa2f8 100644 --- a/frontend/src/v2/views/Player/EmulatorJS.vue +++ b/frontend/src/v2/views/Player/EmulatorJS.vue @@ -28,7 +28,7 @@ import { watch, } from "vue"; import { useI18n } from "vue-i18n"; -import { useRoute, useRouter } from "vue-router"; +import { useRouter } from "vue-router"; import type { FirmwareSchema, SaveSchema, StateSchema } from "@/__generated__"; import { ROUTES } from "@/plugins/router"; import firmwareApi from "@/services/api/firmware"; @@ -37,21 +37,19 @@ 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 type { SliderBtnGroupItem } from "@/v2/lib/primitives/RSliderBtnGroup/types"; -import storeGalleryRoms from "@/v2/stores/galleryRoms"; import { resolveBezelHost, resolveBezelUrl, @@ -75,7 +73,6 @@ const Player = defineAsyncComponent( ); const { t } = useI18n(); -const route = useRoute(); const router = useRouter(); const emitter = inject>("emitter"); const auth = storeAuth(); @@ -99,34 +96,7 @@ 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, heroSeed, heroRom, title, platformLabel } = usePlayerHero(rom); const isSavesTabSelected = ref(true); const selectedState = ref(null); const selectedDisc = ref(null); @@ -207,8 +177,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 +201,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 +217,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 +309,7 @@ watch(selectedCore, (newSelectedCore) => { onMounted(async () => { const romResponse = await romApi.getRom({ - romId: parseInt(route.params.rom as string), + romId, }); rom.value = romResponse.data; @@ -499,21 +451,6 @@ function backToPlatform() { }); } -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", @@ -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 diff --git a/frontend/src/v2/views/Player/Ruffle.vue b/frontend/src/v2/views/Player/Ruffle.vue index 79d06c5087..84db71565e 100644 --- a/frontend/src/v2/views/Player/Ruffle.vue +++ b/frontend/src/v2/views/Player/Ruffle.vue @@ -3,37 +3,24 @@ // 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 } 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(); @@ -42,31 +29,6 @@ const rom = ref(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(() => { From 68168f8b5ae84024bd2045fd13418b81919a4379 Mon Sep 17 00:00:00 2001 From: Georges-Antoine Assi Date: Sat, 29 Aug 2026 15:47:51 -0400 Subject: [PATCH 2/3] refactor(v2): adopt the player shell in the js-dos view The js-dos player landed on master with its own copy of the pre-game chrome. It now takes `usePlayerHero` and `PlayerShell` and keeps only the fullscreen switch, the browser-save note and its stage. Co-Authored-By: Claude Opus 5 (1M context) --- frontend/src/v2/views/Player/JsDos.test.ts | 20 +- frontend/src/v2/views/Player/JsDos.vue | 265 +++------------------ 2 files changed, 39 insertions(+), 246 deletions(-) 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 @@ From 36bcb85921cc26567ea6354f4a945e8f3da6823b Mon Sep 17 00:00:00 2001 From: Georges-Antoine Assi Date: Sat, 29 Aug 2026 15:58:21 -0400 Subject: [PATCH 3/3] refactor(v2): fold the last player chrome copies into the seams Follow-up pass over the extraction: - `usePlayerNav` gives the two back links one home. EmulatorJS's copy read `rom.value?.id`, so it pushed undefined params during the seed window the hero seeding exists to cover. - EmulatorJS drops its hand-rolled spinner for `RSpinner` and takes `heroRom` for its render guard, so `heroSeed` leaves the composable's public surface. - The shell owns the full-width brand row instead of letting a consumer reach across the seam for `grid-column`. - Ruffle's rom ref is shallow, matching the other simple players. - `PlayerShell.test.ts` covers the play/quit emits, the missing-platform guard and the loading branch. Co-Authored-By: Claude Opus 5 (1M context) --- .../v2/components/Player/PlayerShell.test.ts | 136 ++++++++++++++++++ .../src/v2/components/Player/PlayerShell.vue | 35 ++--- .../src/v2/composables/usePlayerHero/index.ts | 17 +-- .../src/v2/composables/usePlayerNav/index.ts | 26 ++++ frontend/src/v2/views/Player/EmulatorJS.vue | 47 +++--- frontend/src/v2/views/Player/Ruffle.vue | 6 +- frontend/src/v2/views/Player/Stream.vue | 5 +- 7 files changed, 204 insertions(+), 68 deletions(-) create mode 100644 frontend/src/v2/components/Player/PlayerShell.test.ts create mode 100644 frontend/src/v2/composables/usePlayerNav/index.ts 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 index 7c5b9255f2..b8648c7aa5 100644 --- a/frontend/src/v2/components/Player/PlayerShell.vue +++ b/frontend/src/v2/components/Player/PlayerShell.vue @@ -3,28 +3,21 @@ // settings card, play and back buttons, and the full-bleed running stage. A // player supplies only the controls above the Play button and whatever it // mounts as a stage, through the `settings` and `stage` slots. -// -// EmulatorJS deliberately does not use this: its hero lives inside a card with -// an alt-art glow and it lays out three panels on its own breakpoints. It takes -// `usePlayerHero` instead. import { RBtn, RCard, RSpinner } from "@v2/lib"; import { useI18n } from "vue-i18n"; -import { useRouter } from "vue-router"; -import { ROUTES } from "@/plugins/router"; import type { DetailedRom, SimpleRom } from "@/stores/roms"; import GameCover from "@/v2/components/shared/GameCover.vue"; +import { usePlayerNav } from "@/v2/composables/usePlayerNav"; interface Props { /** Full rom once loaded, else the cover-only seed during the morph-in. */ heroRom: DetailedRom | SimpleRom | null; title: string; platformLabel: string; - /** Route rom id, bound before `heroRom` resolves so the morph tag paints. */ + /** Route rom id, so the morph tag matches even while the hero is a seed. */ romId: number; - /** The full payload has landed, so the game can actually boot. */ ready: boolean; running: boolean; - /** Keeps the quit button busy while an exit is still saving. */ quitting?: boolean; } @@ -36,17 +29,10 @@ const emit = defineEmits<{ }>(); const { t } = useI18n(); -const router = useRouter(); - -function backToRom() { - router.push({ name: ROUTES.ROM, params: { rom: props.romId } }); -} - -function backToPlatform() { - const platformId = props.heroRom?.platform_id; - if (platformId == null) return; - router.push({ name: ROUTES.PLATFORM, params: { platform: platformId } }); -} +const { backToRom, backToPlatform } = usePlayerNav( + props.romId, + () => props.heroRom?.platform_id, +);