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 ( + event.currentTarget.focus()} + /> + ); +} diff --git a/frontend/src/components/player/index.ts b/frontend/src/components/player/index.ts index 07f608e..73433e7 100644 --- a/frontend/src/components/player/index.ts +++ b/frontend/src/components/player/index.ts @@ -2,3 +2,5 @@ export { RufflePlayer } from './RufflePlayer'; export type { RufflePlayerProps } from './RufflePlayer'; export { GamePlayer } from './GamePlayer'; export type { GamePlayerProps } from './GamePlayer'; +export { StreamPlayer } from './StreamPlayer'; +export type { StreamPlayerProps } from './StreamPlayer'; diff --git a/frontend/src/lib/api/games.ts b/frontend/src/lib/api/games.ts index fd330d6..4316a1f 100644 --- a/frontend/src/lib/api/games.ts +++ b/frontend/src/lib/api/games.ts @@ -5,6 +5,7 @@ import type { GameFilters, FilterOptions, GameLaunchData, + StreamSession, } from '@/types/game'; export const gamesApi = { @@ -87,6 +88,11 @@ export const gamesApi = { return data; }, + createStreamSession: async (id: string): Promise => { + const { data } = await apiClient.post(`/games/${id}/stream-session`); + return data; + }, + downloadGame: async ( id: string, gameDataId?: number diff --git a/frontend/src/lib/streamSaves.ts b/frontend/src/lib/streamSaves.ts new file mode 100644 index 0000000..a8e66aa --- /dev/null +++ b/frontend/src/lib/streamSaves.ts @@ -0,0 +1,39 @@ +/** Save files from a streamed game, by path relative to its saves directory, base64-encoded. */ +export type SaveFiles = Record; + +const DB_NAME = 'flashpoint-stream-saves'; +const STORE = 'saves'; + +function openDb(): Promise { + return new Promise((resolve, reject) => { + const request = indexedDB.open(DB_NAME, 1); + request.onupgradeneeded = () => request.result.createObjectStore(STORE); + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); +} + +async function withStore( + mode: IDBTransactionMode, + fn: (store: IDBObjectStore) => IDBRequest +): Promise { + const db = await openDb(); + try { + return await new Promise((resolve, reject) => { + const request = fn(db.transaction(STORE, mode).objectStore(STORE)); + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); + } finally { + db.close(); + } +} + +/** Saves this browser holds for a streamed game, kept per game ID. */ +export async function loadSaves(gameId: string): Promise { + return (await withStore('readonly', (store) => store.get(gameId))) ?? {}; +} + +export async function storeSaves(gameId: string, files: SaveFiles): Promise { + await withStore('readwrite', (store) => store.put(files, gameId)); +} diff --git a/frontend/src/types/game.ts b/frontend/src/types/game.ts index 6214742..d73ed66 100644 --- a/frontend/src/types/game.ts +++ b/frontend/src/types/game.ts @@ -35,6 +35,14 @@ export interface Game { activeDataOnDisk?: number; // Whether the active data is downloaded (0 = no, 1 = yes) logoPath?: string; // Infinity edition only screenshotPath?: string; // Infinity edition only + streamable?: boolean; // Can't run in a browser but can be streamed from the server +} + +export interface StreamSession { + /** Same-origin path the stream is served under, ending in a slash */ + path: string; + width: number; + height: number; } export interface PaginatedResult { @@ -97,6 +105,8 @@ export interface GameLaunchData { applicationPath?: string; playMode?: string; canPlayInBrowser: boolean; + /** True when the game can't run in a browser but can be streamed from the server */ + streamable?: boolean; /** True when the game ZIP is being downloaded in the background */ downloading?: boolean; } diff --git a/frontend/src/views/GameDetailView.tsx b/frontend/src/views/GameDetailView.tsx index 0143d0b..cbd7c00 100644 --- a/frontend/src/views/GameDetailView.tsx +++ b/frontend/src/views/GameDetailView.tsx @@ -291,7 +291,7 @@ export function GameDetailView() { - {game.platformName === 'Flash' || game.platformName === 'HTML5' ? ( + {game.platformName === 'Flash' || game.platformName === 'HTML5' || game.streamable ? ( <> {isDownloading ? ( ({ + gameId: game?.id, title: game?.title ?? '', platform: launchData?.platform ?? '', contentUrl: launchData?.contentUrl, launchCommand: launchData?.launchCommand, canPlayInBrowser: launchData?.canPlayInBrowser ?? false, + streamable: launchData?.streamable ?? false, logoUrl: game ? getGameLogoUrl(game.id) : undefined, developer: game?.developer, onBack: handleBack, @@ -84,6 +86,7 @@ export function GamePlayerView() { launchData?.contentUrl, launchData?.launchCommand, launchData?.canPlayInBrowser, + launchData?.streamable, handleBack, ] );
{state.message}
Starting {title}...