From 4e21ec152915dee8bb1b694bd1693684f77c6fb9 Mon Sep 17 00:00:00 2001 From: Brad Anderson Date: Sun, 11 Oct 2026 00:49:52 -0700 Subject: [PATCH] feat(streaming): play streamed games in the player and keep their saves in the browser Games the backend reports as streamable get a Play button. The player starts a stream session, embeds the stream, and keeps the game's saves in IndexedDB: sent when the session starts and updated as the server pushes changes. --- frontend/src/components/player/GamePlayer.tsx | 17 +++- .../components/player/StreamPlayer.test.tsx | 82 ++++++++++++++++ .../src/components/player/StreamPlayer.tsx | 93 +++++++++++++++++++ frontend/src/components/player/index.ts | 2 + frontend/src/lib/api/games.ts | 6 ++ frontend/src/lib/streamSaves.ts | 39 ++++++++ frontend/src/types/game.ts | 10 ++ frontend/src/views/GameDetailView.tsx | 2 +- frontend/src/views/GamePlayerView.tsx | 3 + 9 files changed, 250 insertions(+), 4 deletions(-) create mode 100644 frontend/src/components/player/StreamPlayer.test.tsx create mode 100644 frontend/src/components/player/StreamPlayer.tsx create mode 100644 frontend/src/lib/streamSaves.ts diff --git a/frontend/src/components/player/GamePlayer.tsx b/frontend/src/components/player/GamePlayer.tsx index f9a294f..9f49898 100644 --- a/frontend/src/components/player/GamePlayer.tsx +++ b/frontend/src/components/player/GamePlayer.tsx @@ -3,15 +3,20 @@ import { logger } from '@/lib/logger'; import { AlertCircle, Maximize2, Minimize2, Loader2, ArrowLeft } from 'lucide-react'; import { LazyImage } from '@/components/common/LazyImage'; import { RufflePlayer } from './RufflePlayer'; +import { StreamPlayer } from './StreamPlayer'; import { PlayerErrorBoundary } from './PlayerErrorBoundary'; import { PlatformIcon } from '@/components/ui/platform-icon'; export interface GamePlayerProps { + /** Required to stream games that can't run in a browser */ + gameId?: string; title: string; platform: string; contentUrl?: string; launchCommand?: string; canPlayInBrowser: boolean; + /** Can't run in a browser but can be streamed from the server */ + streamable?: boolean; allowFullscreen?: boolean; initialFullscreen?: boolean; onFullscreenChange?: (isFullscreen: boolean) => void; @@ -34,11 +39,13 @@ export interface GamePlayerProps { * Note: Use the default export (wrapped with PlayerErrorBoundary) instead of this component directly. */ function GamePlayerInternal({ + gameId, title, platform, contentUrl, launchCommand, canPlayInBrowser, + streamable = false, allowFullscreen = true, initialFullscreen = false, onFullscreenChange, @@ -98,7 +105,9 @@ function GamePlayerInternal({ } }, [contentUrl, platform]); - if (!canPlayInBrowser) { + const isStreamed = !canPlayInBrowser && streamable && Boolean(gameId); + + if (!canPlayInBrowser && !isStreamed) { const isWebPlatform = platform === 'Flash' || platform === 'HTML5'; let reason: string; @@ -255,8 +264,10 @@ function GamePlayerInternal({ } } > - {/* Flash SWF - use Ruffle emulator */} - {flashContentType === 'swf' && contentUrl ? ( + {/* Other platforms - stream from the server */} + {isStreamed && gameId ? ( + + ) : /* Flash SWF - use Ruffle emulator */ flashContentType === 'swf' && contentUrl ? ( vi.fn()); +vi.mock('@/lib/api/games', () => ({ gamesApi: { createStreamSession } })); + +const loadSaves = vi.hoisted(() => vi.fn()); +const storeSaves = vi.hoisted(() => vi.fn()); +vi.mock('@/lib/streamSaves', () => ({ loadSaves, storeSaves })); + +class FakeEventSource { + static instances: FakeEventSource[] = []; + onmessage: ((event: MessageEvent) => void) | null = null; + listeners: Record void> = {}; + closed = false; + + constructor(public url: string) { + FakeEventSource.instances.push(this); + } + + addEventListener(type: string, listener: () => void): void { + this.listeners[type] = listener; + } + + close(): void { + this.closed = true; + } +} + +const GAME_ID = '247a4c93-c252-49c8-8bc9-fb43d5c039df'; +const fetchMock = vi.fn(); + +describe('StreamPlayer', () => { + beforeEach(() => { + vi.clearAllMocks(); + FakeEventSource.instances = []; + vi.stubGlobal('EventSource', FakeEventSource); + vi.stubGlobal('fetch', fetchMock); + createStreamSession.mockResolvedValue({ path: '/stream/abc/', width: 472, height: 354 }); + loadSaves.mockResolvedValue({ 'cookies.txt': 'YQ==' }); + storeSaves.mockResolvedValue(undefined); + fetchMock.mockResolvedValue(new Response(null, { status: 204 })); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('starts the session with stored saves, shows the stream, and stores pushed saves', async () => { + const { unmount } = render(); + expect(screen.getByText('Starting Insaniquarium!...')).toBeInTheDocument(); + + const iframe = await screen.findByTitle('Insaniquarium!'); + expect(iframe.getAttribute('src')).toBe('/stream/abc/'); + expect(fetchMock).toHaveBeenCalledWith('/stream/abc/_fp/start', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ files: { 'cookies.txt': 'YQ==' } }), + }); + + const events = FakeEventSource.instances[0]; + expect(events.url).toBe('/stream/abc/_fp/saves'); + events.onmessage?.( + new MessageEvent('message', { data: JSON.stringify({ files: { 'cookies.txt': 'Yg==' } }) }) + ); + expect(storeSaves).toHaveBeenCalledWith(GAME_ID, { 'cookies.txt': 'Yg==' }); + + unmount(); + expect(events.closed).toBe(true); + }); + + it('shows an error when the session cannot start', async () => { + fetchMock.mockResolvedValue(new Response(null, { status: 502 })); + + render(); + + await waitFor(() => expect(screen.getByText('Failed to Start Game')).toBeInTheDocument()); + expect(screen.getByText('The game failed to start (502)')).toBeInTheDocument(); + expect(FakeEventSource.instances).toHaveLength(0); + }); +}); diff --git a/frontend/src/components/player/StreamPlayer.tsx b/frontend/src/components/player/StreamPlayer.tsx new file mode 100644 index 0000000..52d71c3 --- /dev/null +++ b/frontend/src/components/player/StreamPlayer.tsx @@ -0,0 +1,93 @@ +import { useEffect, useState } from 'react'; +import { AlertCircle, Loader2 } from 'lucide-react'; +import { gamesApi } from '@/lib/api/games'; +import { logger } from '@/lib/logger'; +import { loadSaves, storeSaves } from '@/lib/streamSaves'; +import { getErrorMessage } from '@/types/api-error'; + +export interface StreamPlayerProps { + gameId: string; + title: string; +} + +type StreamState = + | { status: 'starting' } + | { status: 'running'; path: string } + | { status: 'error'; message: string }; + +/** + * Plays a game that can't run in a browser by streaming it from a server-side + * container. Saves are kept in this browser and synced with the container. + */ +export function StreamPlayer({ gameId, title }: StreamPlayerProps) { + const [state, setState] = useState({ status: 'starting' }); + + useEffect(() => { + let cancelled = false; + let events: EventSource | undefined; + + (async () => { + const session = await gamesApi.createStreamSession(gameId); + const files = await loadSaves(gameId); + const response = await fetch(`${session.path}_fp/start`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ files }), + }); + if (!response.ok) { + throw new Error(`The game failed to start (${response.status})`); + } + if (cancelled) return; + + events = new EventSource(`${session.path}_fp/saves`); + events.onmessage = (event) => { + storeSaves(gameId, JSON.parse(event.data).files).catch((error) => + logger.error('[StreamPlayer] Failed to store saves:', error) + ); + }; + events.addEventListener('end', () => events?.close()); + setState({ status: 'running', path: session.path }); + })().catch((error) => { + logger.error('[StreamPlayer] Failed to start stream:', error); + if (!cancelled) setState({ status: 'error', message: getErrorMessage(error) }); + }); + + return () => { + cancelled = true; + events?.close(); + }; + }, [gameId]); + + if (state.status === 'error') { + return ( +
+
+ +

Failed to Start Game

+

{state.message}

+
+
+ ); + } + + if (state.status === 'starting') { + return ( +
+
+ +

Starting {title}...

+
+
+ ); + } + + return ( +