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.
This commit is contained in:
@@ -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 ? (
|
||||
<StreamPlayer gameId={gameId} title={title} />
|
||||
) : /* Flash SWF - use Ruffle emulator */ flashContentType === 'swf' && contentUrl ? (
|
||||
<RufflePlayer
|
||||
swfUrl={contentUrl}
|
||||
width="100%"
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import { StreamPlayer } from './StreamPlayer';
|
||||
|
||||
const createStreamSession = vi.hoisted(() => 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<string, () => 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(<StreamPlayer gameId={GAME_ID} title="Insaniquarium!" />);
|
||||
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(<StreamPlayer gameId={GAME_ID} title="Insaniquarium!" />);
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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<StreamState>({ 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 (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center max-w-md p-6">
|
||||
<AlertCircle size={48} className="text-red-500 mx-auto mb-4" />
|
||||
<h3 className="text-xl font-bold mb-2">Failed to Start Game</h3>
|
||||
<p className="text-muted-foreground">{state.message}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (state.status === 'starting') {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center">
|
||||
<Loader2 size={48} className="animate-spin text-muted-foreground mx-auto mb-4" />
|
||||
<p>Starting {title}...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<iframe
|
||||
src={state.path}
|
||||
className="w-full h-full border-0"
|
||||
title={title}
|
||||
allow="autoplay; fullscreen; clipboard-read; clipboard-write; gamepad"
|
||||
onLoad={(event) => event.currentTarget.focus()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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<StreamSession> => {
|
||||
const { data } = await apiClient.post<StreamSession>(`/games/${id}/stream-session`);
|
||||
return data;
|
||||
},
|
||||
|
||||
downloadGame: async (
|
||||
id: string,
|
||||
gameDataId?: number
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
/** Save files from a streamed game, by path relative to its saves directory, base64-encoded. */
|
||||
export type SaveFiles = Record<string, string>;
|
||||
|
||||
const DB_NAME = 'flashpoint-stream-saves';
|
||||
const STORE = 'saves';
|
||||
|
||||
function openDb(): Promise<IDBDatabase> {
|
||||
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<T>(
|
||||
mode: IDBTransactionMode,
|
||||
fn: (store: IDBObjectStore) => IDBRequest<T>
|
||||
): Promise<T> {
|
||||
const db = await openDb();
|
||||
try {
|
||||
return await new Promise<T>((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<SaveFiles> {
|
||||
return (await withStore<SaveFiles | undefined>('readonly', (store) => store.get(gameId))) ?? {};
|
||||
}
|
||||
|
||||
export async function storeSaves(gameId: string, files: SaveFiles): Promise<void> {
|
||||
await withStore('readwrite', (store) => store.put(files, gameId));
|
||||
}
|
||||
@@ -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<T> {
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -291,7 +291,7 @@ export function GameDetailView() {
|
||||
</div>
|
||||
|
||||
<div className="text-center space-y-2 flex-shrink-0">
|
||||
{game.platformName === 'Flash' || game.platformName === 'HTML5' ? (
|
||||
{game.platformName === 'Flash' || game.platformName === 'HTML5' || game.streamable ? (
|
||||
<>
|
||||
{isDownloading ? (
|
||||
<button
|
||||
|
||||
@@ -67,11 +67,13 @@ export function GamePlayerView() {
|
||||
|
||||
const playerProps = useMemo(
|
||||
() => ({
|
||||
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,
|
||||
]
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user