From c044f5e9cf9664c5b035760e401929482618cb67 Mon Sep 17 00:00:00 2001 From: Brad Anderson Date: Mon, 12 Oct 2026 18:59:32 -0700 Subject: [PATCH] feat(portal): game page with in-place play Game detail and play are one portal page: a stage with the screenshot and a Play button (or Enter) that loads the game in place, controls under it, details, plays, rating, tags, favorite and add to playlist, and related games. --- docs/06-api-reference/games-api.md | 4 +- docs/10-features/12-discovery-portal.md | 21 ++ frontend/src/App.tsx | 21 +- frontend/src/components/player/GamePlayer.tsx | 28 +- frontend/src/components/player/index.ts | 2 +- frontend/src/components/portal/PortalBox.tsx | 13 +- .../components/portal/game/GameStage.test.tsx | 53 ++++ .../src/components/portal/game/GameStage.tsx | 176 ++++++++++++ frontend/src/components/portal/portal.css | 30 +- frontend/src/views/GamePage.tsx | 270 ++++++++++++++++++ 10 files changed, 598 insertions(+), 20 deletions(-) create mode 100644 frontend/src/components/portal/game/GameStage.test.tsx create mode 100644 frontend/src/components/portal/game/GameStage.tsx create mode 100644 frontend/src/views/GamePage.tsx diff --git a/docs/06-api-reference/games-api.md b/docs/06-api-reference/games-api.md index b2730d5..ad1098c 100644 --- a/docs/06-api-reference/games-api.md +++ b/docs/06-api-reference/games-api.md @@ -144,7 +144,9 @@ canPlayInBrowser, and downloading status. Query params: `limit` (default: 10, max: 50), `showExtreme` (default: false) -Returns array of related games based on developer/series/tags. +Returns games from the same library ranked by shared descriptive tags (meta, +presence, warning and language tags are ignored; rarer tags weigh more), then +the same series, then the same developer. ## Search Examples diff --git a/docs/10-features/12-discovery-portal.md b/docs/10-features/12-discovery-portal.md index ff4a50e..40fa6fd 100644 --- a/docs/10-features/12-discovery-portal.md +++ b/docs/10-features/12-discovery-portal.md @@ -50,6 +50,27 @@ body text uses Verdana. See the [Discovery API](../06-api-reference/discovery-api.md). +## Game Page + +Game detail and play are one page (`views/GamePage.tsx`); the `/play` routes +open the same page with the game already started. Shared playlist links keep the +application's detail and player views. + +- **Stage:** the game's screenshot with its logo and a Play button (or Enter). + Playing loads the game in place, starting a play session and fetching the game + data if needed. Saves, the Ruffle / original Flash Player switch and + Fullscreen sit under the game; in fullscreen the player's own header bar + returns. Platforms that can't run in the browser or be streamed point to the + Flashpoint Launcher. +- **About this game:** description, notes, and release date, developer, + publisher, series, platform, play mode and language; developer, publisher and + series link to browse. +- **Side boxes:** the signed-in user's plays of this game, the rating, and tags + linking to browse. +- **Favorite** and **Add to playlist** for signed-in users. +- **More like this:** + [related games](../06-api-reference/games-api.md#get-related-games). + ## Default Ordering Browse lists default to a shuffle instead of title order. The shuffle is seeded diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f62d90e..d421e1f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -53,6 +53,7 @@ const AnimationsView = lazy(() => const GameDetailView = lazy(() => import('./views/GameDetailView').then((m) => ({ default: m.GameDetailView })) ); +const GamePage = lazy(() => import('./views/GamePage').then((m) => ({ default: m.GamePage }))); const GamePlayerView = lazy(() => import('./views/GamePlayerView').then((m) => ({ default: m.GamePlayerView })) ); @@ -350,7 +351,7 @@ function App() { element={ }> - + } @@ -360,7 +361,7 @@ function App() { element={ }> - + } @@ -370,7 +371,7 @@ function App() { element={ }> - + } @@ -380,7 +381,7 @@ function App() { element={ }> - + } @@ -390,7 +391,7 @@ function App() { element={ }> - + } @@ -400,7 +401,7 @@ function App() { element={ }> - + } @@ -410,7 +411,7 @@ function App() { element={ }> - + } @@ -420,7 +421,7 @@ function App() { element={ }> - + } @@ -432,7 +433,7 @@ function App() { element={ }> - + } @@ -444,7 +445,7 @@ function App() { element={ }> - + } diff --git a/frontend/src/components/player/GamePlayer.tsx b/frontend/src/components/player/GamePlayer.tsx index 32580a1..cbe33c7 100644 --- a/frontend/src/components/player/GamePlayer.tsx +++ b/frontend/src/components/player/GamePlayer.tsx @@ -1,4 +1,4 @@ -import { Fragment, useState, useEffect, useRef, useMemo } from 'react'; +import { Fragment, useState, useEffect, useRef, useMemo, type ReactNode } from 'react'; import { logger } from '@/lib/logger'; import { AlertCircle, @@ -18,6 +18,18 @@ import { useAuthStore } from '@/store/auth'; import { PlayerErrorBoundary } from './PlayerErrorBoundary'; import { PlatformIcon } from '@/components/ui/platform-icon'; +/** Player actions, for pages that place the controls themselves */ +export interface PlayerControls { + isFullscreen: boolean; + toggleFullscreen: () => void; + /** Whether the original Flash Player is in use instead of Ruffle */ + preferStream: boolean; + /** Present when the game can switch between Ruffle and the original Flash Player */ + togglePlayer?: () => void; + /** Present for signed-in players, whose saves are kept on their account */ + openSaves?: () => void; +} + export interface GamePlayerProps { /** Required to stream games that can't run in a browser */ gameId?: string; @@ -41,6 +53,8 @@ export interface GamePlayerProps { developer?: string; /** Callback for back button (shown in fullscreen mode) */ onBack?: () => void; + /** Renders the controls below the game instead of the header bar, outside fullscreen */ + renderControls?: (controls: PlayerControls) => ReactNode; } /** @@ -66,6 +80,7 @@ function GamePlayerInternal({ logoUrl, developer, onBack, + renderControls, }: GamePlayerProps) { const [isFullscreen, setIsFullscreen] = useState(initialFullscreen); // Streams a game Ruffle can play with the original player, for when Ruffle falls short @@ -181,7 +196,7 @@ function GamePlayerInternal({ return (
- {showControls ? ( + {showControls && (isFullscreen || !renderControls) ? (
)}
+ {renderControls && !isFullscreen + ? renderControls({ + isFullscreen, + toggleFullscreen, + preferStream, + togglePlayer: canSwitchPlayer ? () => setPreferStream((prev) => !prev) : undefined, + openSaves: isSignedIn && gameId ? () => setSavesOpen(true) : undefined, + }) + : null} {isSignedIn && gameId ? ( -
-

{title}

- {action} -
+ {title ? ( +
+

{title}

+ {action} +
+ ) : null} {children} ); diff --git a/frontend/src/components/portal/game/GameStage.test.tsx b/frontend/src/components/portal/game/GameStage.test.tsx new file mode 100644 index 0000000..eb2fb26 --- /dev/null +++ b/frontend/src/components/portal/game/GameStage.test.tsx @@ -0,0 +1,53 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { GameStage } from './GameStage'; +import type { Game } from '@/types/game'; + +vi.mock('@/components/player', () => ({ + GamePlayer: ({ title }: { title: string }) =>
playing {title}
, +})); +vi.mock('@/hooks/usePlayTracking', () => ({ usePlaySession: vi.fn() })); +vi.mock('@/hooks/useGames', () => ({ + useGameLaunchData: () => ({ + data: { platform: 'Flash', contentUrl: '/game.swf', canPlayInBrowser: true }, + isLoading: false, + error: null, + }), +})); + +const game = { + id: '1443f6b9-6bec-47cb-873e-72b9aedefccb', + title: '3D Logic', + developer: 'Alex Mateev', + publisher: 'Microgames', + platformName: 'Flash', + library: 'arcade', + orderTitle: '3d logic', +} satisfies Game; + +describe('GameStage', () => { + it('waits for Play before loading the game', async () => { + render(); + expect(screen.queryByText('playing 3D Logic')).not.toBeInTheDocument(); + + await userEvent.click(screen.getByRole('button', { name: 'PLAY' })); + expect(screen.getByText('playing 3D Logic')).toBeInTheDocument(); + }); + + it('starts on Enter, and right away from a play link', async () => { + const { unmount } = render(); + await userEvent.keyboard('{Enter}'); + expect(screen.getByText('playing 3D Logic')).toBeInTheDocument(); + unmount(); + + render(); + expect(screen.getByText('playing 3D Logic')).toBeInTheDocument(); + }); + + it('points launcher-only games to the Flashpoint Launcher', () => { + render(); + expect(screen.queryByRole('button', { name: 'PLAY' })).not.toBeInTheDocument(); + expect(screen.getByText(/only be played in the Flashpoint Launcher/)).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/portal/game/GameStage.tsx b/frontend/src/components/portal/game/GameStage.tsx new file mode 100644 index 0000000..6a3a2ae --- /dev/null +++ b/frontend/src/components/portal/game/GameStage.tsx @@ -0,0 +1,176 @@ +import { useEffect, useRef, useState } from 'react'; +import { History, Loader2, Maximize2, MonitorPlay } from 'lucide-react'; +import { GamePlayer, type PlayerControls } from '@/components/player'; +import { useGameLaunchData } from '@/hooks/useGames'; +import { usePlaySession } from '@/hooks/usePlayTracking'; +import { getGameLogoUrl, getGameScreenshotUrl } from '@/utils/gameUtils'; +import type { Game } from '@/types/game'; + +const STAGE_HEIGHT = 'min(690px, calc(100vh - 140px))'; + +function canPlay(game: Game): boolean { + return game.platformName === 'Flash' || game.platformName === 'HTML5' || Boolean(game.streamable); +} + +function howItPlays(game: Game): string { + if (game.platformName === 'Flash') return 'Plays in your browser with Ruffle'; + if (game.platformName === 'HTML5') return 'Plays in your browser'; + return `${game.platformName ?? 'This game'} streams from the server`; +} + +function isTyping(target: EventTarget | null): boolean { + return ( + target instanceof HTMLElement && + (target.isContentEditable || + ['INPUT', 'TEXTAREA', 'SELECT', 'BUTTON', 'A'].includes(target.tagName)) + ); +} + +interface GameStageProps { + game: Game; + autoPlay: boolean; +} + +/** The game's screenshot with a Play button, replaced by the game itself once started */ +export function GameStage({ game, autoPlay }: GameStageProps) { + const playable = canPlay(game); + const [playing, setPlaying] = useState(autoPlay && playable); + + // Enter starts the game, as on the portals of old + const playingRef = useRef(playing); + useEffect(() => { + playingRef.current = playing; + }, [playing]); + useEffect(() => { + if (!playable) return; + const onKeyDown = (event: KeyboardEvent) => { + if (event.key !== 'Enter' || playingRef.current || isTyping(event.target)) return; + event.preventDefault(); + setPlaying(true); + }; + window.addEventListener('keydown', onKeyDown); + return () => window.removeEventListener('keydown', onKeyDown); + }, [playable]); + + if (!playing) { + return ( +
+ event.currentTarget.remove()} + /> +
+ + event.currentTarget.removeAttribute('src')} + /> + + {playable ? ( + <> + +

+ {howItPlays(game)} · or press Enter +

+ + ) : ( +

+ {game.platformName ?? 'This game'} games can only be played in the Flashpoint + Launcher. +

+ )} +
+
+ ); + } + + return ; +} + +function PlayingStage({ game }: { game: Game }) { + const { data: launchData, isLoading, error } = useGameLaunchData(game.id); + usePlaySession(game.id, game.title); + + if (isLoading || launchData?.downloading) { + return ( +
+
+
+
+ ); + } + + if (error || !launchData) { + return ( +
+
+

This game failed to load.

+

{error?.message}

+
+
+ ); + } + + return ( + } + /> + ); +} + +function StageToolbar({ toggleFullscreen, preferStream, togglePlayer, openSaves }: PlayerControls) { + return ( +
+ {openSaves ? ( + + ) : null} + {togglePlayer ? ( + + ) : null} + +
+ ); +} diff --git a/frontend/src/components/portal/portal.css b/frontend/src/components/portal/portal.css index 427e3f0..738e740 100644 --- a/frontend/src/components/portal/portal.css +++ b/frontend/src/components/portal/portal.css @@ -123,7 +123,8 @@ body.portal { outline: none; } -.portal-capsule[aria-current='page'] { +.portal-capsule[aria-current='page'], +.portal-capsule[aria-pressed='true'] { background: linear-gradient(180deg, var(--p-highlight), var(--p-box)); color: var(--p-header-ink); border-color: var(--p-chrome-1); @@ -321,3 +322,30 @@ body.portal { .portal-meta-row svg { flex: none; } + +.portal-stage { + position: relative; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + background: #000; +} + +.portal-stage-backdrop { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: cover; + filter: blur(3px) brightness(0.4); + transform: scale(1.04); +} + +.portal-stage-splash { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + gap: 16px; +} diff --git a/frontend/src/views/GamePage.tsx b/frontend/src/views/GamePage.tsx new file mode 100644 index 0000000..78458cc --- /dev/null +++ b/frontend/src/views/GamePage.tsx @@ -0,0 +1,270 @@ +import { useMemo, useState } from 'react'; +import { Link, useLocation, useParams } from 'react-router-dom'; +import { formatDistanceToNow } from 'date-fns'; +import { + BookOpen, + Building2, + CalendarDays, + Gamepad2, + Heart, + Languages, + ListPlus, + Users, + Wrench, + type LucideIcon, +} from 'lucide-react'; +import { toast } from 'sonner'; +import { GameRatingSection } from '@/components/game/GameRatingSection'; +import { AddToPlaylistModal } from '@/components/playlist/AddToPlaylistModal'; +import { GameTile } from '@/components/portal/GameTile'; +import { PortalBox } from '@/components/portal/PortalBox'; +import { GameStage } from '@/components/portal/game/GameStage'; +import { Skeleton } from '@/components/ui/skeleton'; +import { useFavoriteGameIds, useToggleFavorite } from '@/hooks/useFavorites'; +import { useFeatureFlags } from '@/hooks/useFeatureFlags'; +import { useGame, useRelatedGames } from '@/hooks/useGames'; +import { useGameStats } from '@/hooks/usePlayTracking'; +import { formatDuration } from '@/lib/cron-utils'; +import { buildFilterSearchParams, type FilterUrlParams } from '@/lib/filterUrlCompression'; +import { useAuthStore } from '@/store/auth'; +import { getErrorMessage } from '@/types/api-error'; +import type { BreadcrumbContext } from '@/components/common/Breadcrumbs'; + +function browseLink(params: FilterUrlParams): string { + return `/browse?${buildFilterSearchParams(params).toString()}`; +} + +interface MetaRowProps { + icon: LucideIcon; + label: string; + value?: string; + href?: string; +} + +function MetaRow({ icon: Icon, label, value, href }: MetaRowProps) { + if (!value) return null; + return ( +
+
+ ); +} + +/** A game's page: the game itself, what it is, and where to go next */ +export function GamePage({ autoPlay = false }: { autoPlay?: boolean }) { + const { id = '' } = useParams<{ id: string }>(); + const location = useLocation(); + const from = (location.state as { breadcrumbContext?: BreadcrumbContext } | null) + ?.breadcrumbContext; + const { data: game, isLoading, error } = useGame(id); + const { data: related } = useRelatedGames(id, 7); + + if (isLoading) { + return ( +
+ + +
+ ); + } + + if (error || !game) { + return ( + +

+ This game isn't in the archive. Back to the homepage +

+
+ ); + } + + const tags = (game.tagsStr ?? '') + .split(';') + .map((tag) => tag.trim()) + .filter(Boolean); + + return ( +
+
+
+ {from ? ( + + ← {from.label} + + ) : null} +

+ {game.title} +

+ {game.developer ? ( +

by {game.developer}

+ ) : null} +
+ +
+ + + + + +
+ + {game.originalDescription ? ( +

{game.originalDescription}

+ ) : null} + {game.notes ? ( +

{game.notes}

+ ) : null} +
+ + + + + + + +
+
+ +
+ + + {tags.length > 0 ? ( + +
+ {tags.map((tag) => ( + + {tag} + + ))} +
+
+ ) : null} +
+
+ + {related && related.length > 0 ? ( + +
+ {related.map((relatedGame) => ( + + ))} +
+
+ ) : null} +
+ ); +} + +function GameActions({ gameId, title }: { gameId: string; title: string }) { + const signedIn = useAuthStore((state) => state.isAuthenticated && !state.isGuest); + const { enableFavorites, enablePlaylists } = useFeatureFlags(); + const { data: favoriteIds } = useFavoriteGameIds(); + const toggleFavorite = useToggleFavorite(); + const [addingToPlaylist, setAddingToPlaylist] = useState(false); + const isFavorite = useMemo(() => favoriteIds?.includes(gameId) ?? false, [favoriteIds, gameId]); + + if (!signedIn) return null; + + const handleFavorite = () => { + toggleFavorite.mutate(gameId, { + onError: (error: unknown) => { + toast.error(getErrorMessage(error) || 'Could not update favorites'); + }, + }); + }; + + return ( +
+ {enableFavorites ? ( + + ) : null} + {enablePlaylists ? ( + <> + + setAddingToPlaylist(false)} + gameId={gameId} + gameTitle={title} + /> + + ) : null} +
+ ); +} + +function YourPlays({ gameId }: { gameId: string }) { + const { data } = useGameStats(); + const stats = data?.data.find((entry) => entry.gameId === gameId); + if (!stats) return null; + + return ( + +

+ Played {stats.totalPlays} {stats.totalPlays === 1 ? 'time' : 'times'} + {stats.totalPlaytimeSeconds > 0 + ? `, ${formatDuration(stats.totalPlaytimeSeconds)} in all` + : ''} +

+

+ Last played {formatDistanceToNow(new Date(stats.lastPlayedAt), { addSuffix: true })} +

+
+ ); +} + +function RatingBox({ gameId }: { gameId: string }) { + const { enableRatings } = useFeatureFlags(); + if (!enableRatings) return null; + return ( + + + + ); +}