feat(streaming): stream Flash with the original player when Ruffle falls short

Flash games Ruffle can play now also report streamable, and the player
offers a switch to the original Flash Player streamed from the server.
Flash games launched from a page, which need the browser plugin, stream
instead of loading a page that can't play, and search no longer hides them
while streaming is enabled.
This commit is contained in:
2026-10-11 17:50:24 -07:00
parent 3b4b9623f8
commit 1965a1392b
5 changed files with 163 additions and 30 deletions
+53 -4
View File
@@ -34,8 +34,9 @@ vi.mock('../services/GameDataService', () => ({
const isStreamable = vi.hoisted(() => vi.fn()); const isStreamable = vi.hoisted(() => vi.fn());
const createSession = vi.hoisted(() => vi.fn()); const createSession = vi.hoisted(() => vi.fn());
const isEnabled = vi.hoisted(() => vi.fn());
vi.mock('../services/StreamingService', () => ({ vi.mock('../services/StreamingService', () => ({
StreamingService: { isStreamable, createSession }, StreamingService: { isStreamable, createSession, isEnabled },
})); }));
import gamesRouter from './games'; import gamesRouter from './games';
@@ -118,6 +119,7 @@ describe('streaming games that cannot run in a browser', () => {
}); });
mountGameZip.mockResolvedValue({ mounted: true, downloading: false }); mountGameZip.mockResolvedValue({ mounted: true, downloading: false });
isStreamable.mockResolvedValue(true); isStreamable.mockResolvedValue(true);
isEnabled.mockReturnValue(true);
createSession.mockResolvedValue({ path: '/stream/abc/', width: 472, height: 354 }); createSession.mockResolvedValue({ path: '/stream/abc/', width: 472, height: 354 });
}); });
@@ -135,12 +137,12 @@ describe('streaming games that cannot run in a browser', () => {
expect(launch.body).toMatchObject({ canPlayInBrowser: false, streamable: true }); expect(launch.body).toMatchObject({ canPlayInBrowser: false, streamable: true });
}); });
it('does not ask the session manager about browser-playable games', async () => { it('does not ask the session manager about HTML5 games', async () => {
signInAs(['games.read', 'games.play']); signInAs(['games.read', 'games.play']);
getGameById.mockResolvedValue({ getGameById.mockResolvedValue({
id: GAME_ID, id: GAME_ID,
platformName: 'Flash', platformName: 'HTML5',
launchCommand: 'http://a/b.swf', launchCommand: 'http://a/index.html',
}); });
const details = await request(createApp()) const details = await request(createApp())
@@ -151,6 +153,53 @@ describe('streaming games that cannot run in a browser', () => {
expect(isStreamable).not.toHaveBeenCalled(); expect(isStreamable).not.toHaveBeenCalled();
}); });
it('offers streaming for Flash games Ruffle can play', async () => {
signInAs(['games.read', 'games.play']);
getGameById.mockResolvedValue({
id: GAME_ID,
platformName: 'Flash',
launchCommand: 'http://a/b.swf?v=1',
});
const launch = await request(createApp())
.get(`/api/games/${GAME_ID}/launch`)
.set('Authorization', 'Bearer token');
expect(launch.body).toMatchObject({ canPlayInBrowser: true, streamable: true });
});
it('streams Flash games launched from a page, which need a plugin', async () => {
signInAs(['games.read', 'games.play']);
getGameById.mockResolvedValue({
id: GAME_ID,
platformName: 'Flash',
launchCommand: 'http://a/game.html',
});
const launch = await request(createApp())
.get(`/api/games/${GAME_ID}/launch`)
.set('Authorization', 'Bearer token');
expect(launch.body).toMatchObject({ canPlayInBrowser: false, streamable: true });
});
it('leaves Flash pages to the browser when streaming is off', async () => {
signInAs(['games.read', 'games.play']);
isEnabled.mockReturnValue(false);
isStreamable.mockResolvedValue(false);
getGameById.mockResolvedValue({
id: GAME_ID,
platformName: 'Flash',
launchCommand: 'http://a/game.html',
});
const launch = await request(createApp())
.get(`/api/games/${GAME_ID}/launch`)
.set('Authorization', 'Bearer token');
expect(launch.body).toMatchObject({ canPlayInBrowser: true, streamable: false });
});
it('creates a session for users with games.play', async () => { it('creates a session for users with games.play', async () => {
signInAs(['games.read', 'games.play']); signInAs(['games.read', 'games.play']);
+11 -3
View File
@@ -237,7 +237,7 @@ router.get(
res.locals.game = game; res.locals.game = game;
const streamable = const streamable =
!isWebPlayablePlatform(game.platformName) && (await StreamingService.isStreamable(game.id)); game.platformName !== 'HTML5' && (await StreamingService.isStreamable(game.id));
res.json({ ...game, streamable }); res.json({ ...game, streamable });
}) })
@@ -315,8 +315,16 @@ router.get(
const hasContent = launchCommand.trim().length > 0; const hasContent = launchCommand.trim().length > 0;
const canPlayInBrowser = isWebPlayablePlatform(game.platformName) && hasContent; // Without a plugin, a Flash game launched from a page only plays streamed
const streamable = !canPlayInBrowser && (await StreamingService.isStreamable(game.id)); const isFlashPage =
game.platformName === 'Flash' &&
!/\.swf(\?|$)/i.test(launchCommand) &&
StreamingService.isEnabled();
const canPlayInBrowser = isWebPlayablePlatform(game.platformName) && hasContent && !isFlashPage;
// Flash games Ruffle can play may also be streamed with the original Flash Player
const streamable =
(!canPlayInBrowser || game.platformName === 'Flash') &&
(await StreamingService.isStreamable(game.id));
res.locals.platform = game.platformName; res.locals.platform = game.platformName;
res.locals.canPlayInBrowser = canPlayInBrowser; res.locals.canPlayInBrowser = canPlayInBrowser;
+7 -1
View File
@@ -1,6 +1,7 @@
import { DatabaseService } from './DatabaseService'; import { DatabaseService } from './DatabaseService';
import { logger } from '../utils/logger'; import { logger } from '../utils/logger';
import { config } from '../config'; import { config } from '../config';
import { StreamingService } from './StreamingService';
export interface GameSearchQuery { export interface GameSearchQuery {
search?: string; search?: string;
@@ -311,7 +312,8 @@ export class GameService {
} }
/** /**
* SQL condition to exclude Flash games without SWF launch commands. * SQL condition to exclude Flash games without SWF launch commands, unless they
* can be streamed.
* Checks both game_data.launchCommand and game.launchCommand as fallback. * Checks both game_data.launchCommand and game.launchCommand as fallback.
* *
* Performance: ~150ms overhead per query. Acceptable for searchGames() since results * Performance: ~150ms overhead per query. Acceptable for searchGames() since results
@@ -319,6 +321,10 @@ export class GameService {
* getPlatformOptions(), use getFlashSwfGameIds() cache instead. * getPlatformOptions(), use getFlashSwfGameIds() cache instead.
*/ */
private getFlashSwfCondition(tableAlias: string = 'g'): string { private getFlashSwfCondition(tableAlias: string = 'g'): string {
// Streaming plays Flash games whose launch command is a page, so keep them
if (StreamingService.isEnabled()) {
return '1';
}
return `( return `(
${tableAlias}.platformName != 'Flash' ${tableAlias}.platformName != 'Flash'
OR EXISTS ( OR EXISTS (
@@ -0,0 +1,48 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { GamePlayer } from './GamePlayer';
vi.mock('./RufflePlayer', () => ({ RufflePlayer: () => <div>ruffle</div> }));
vi.mock('./StreamPlayer', () => ({ StreamPlayer: () => <div>stream</div> }));
const flashGame = {
gameId: '247a4c93-c252-49c8-8bc9-fb43d5c039df',
title: 'Superman',
platform: 'Flash',
contentUrl: '/game-proxy/http://example.com/game.swf',
canPlayInBrowser: true,
};
describe('GamePlayer player choice', () => {
it('switches a streamable Flash game between Ruffle and the original Flash Player', async () => {
render(<GamePlayer {...flashGame} streamable />);
expect(screen.getByText('ruffle')).toBeInTheDocument();
await userEvent.click(screen.getByRole('button', { name: 'Use original Flash Player' }));
expect(screen.getByText('stream')).toBeInTheDocument();
expect(screen.queryByText('ruffle')).not.toBeInTheDocument();
await userEvent.click(screen.getByRole('button', { name: 'Use Ruffle' }));
expect(screen.getByText('ruffle')).toBeInTheDocument();
});
it('offers no choice when the game cannot be streamed', () => {
render(<GamePlayer {...flashGame} streamable={false} />);
expect(
screen.queryByRole('button', { name: 'Use original Flash Player' })
).not.toBeInTheDocument();
});
it('streams a Flash game that cannot play in the browser', () => {
render(
<GamePlayer
{...flashGame}
contentUrl="/game-proxy/http://example.com/game.html"
canPlayInBrowser={false}
streamable
/>
);
expect(screen.getByText('stream')).toBeInTheDocument();
});
});
+44 -22
View File
@@ -1,6 +1,6 @@
import { useState, useEffect, useRef, useMemo } from 'react'; import { useState, useEffect, useRef, useMemo } from 'react';
import { logger } from '@/lib/logger'; import { logger } from '@/lib/logger';
import { AlertCircle, Maximize2, Minimize2, Loader2, ArrowLeft } from 'lucide-react'; import { AlertCircle, Maximize2, Minimize2, Loader2, ArrowLeft, MonitorPlay } from 'lucide-react';
import { LazyImage } from '@/components/common/LazyImage'; import { LazyImage } from '@/components/common/LazyImage';
import { RufflePlayer } from './RufflePlayer'; import { RufflePlayer } from './RufflePlayer';
import { StreamPlayer } from './StreamPlayer'; import { StreamPlayer } from './StreamPlayer';
@@ -57,6 +57,8 @@ function GamePlayerInternal({
onBack, onBack,
}: GamePlayerProps) { }: GamePlayerProps) {
const [isFullscreen, setIsFullscreen] = useState(initialFullscreen); const [isFullscreen, setIsFullscreen] = useState(initialFullscreen);
// Streams a game Ruffle can play with the original player, for when Ruffle falls short
const [preferStream, setPreferStream] = useState(false);
const [iframeError, setIframeError] = useState<string | null>(null); const [iframeError, setIframeError] = useState<string | null>(null);
const [logoError, setLogoError] = useState(false); const [logoError, setLogoError] = useState(false);
@@ -105,7 +107,8 @@ function GamePlayerInternal({
} }
}, [contentUrl, platform]); }, [contentUrl, platform]);
const isStreamed = !canPlayInBrowser && streamable && Boolean(gameId); const canSwitchPlayer = canPlayInBrowser && streamable && Boolean(gameId);
const isStreamed = streamable && Boolean(gameId) && (!canPlayInBrowser || preferStream);
if (!canPlayInBrowser && !isStreamed) { if (!canPlayInBrowser && !isStreamed) {
const isWebPlatform = platform === 'Flash' || platform === 'HTML5'; const isWebPlatform = platform === 'Flash' || platform === 'HTML5';
@@ -230,26 +233,45 @@ function GamePlayerInternal({
</div> </div>
</div> </div>
{allowFullscreen ? ( <div className="flex items-center gap-2 flex-shrink-0">
<button {canSwitchPlayer ? (
onClick={toggleFullscreen} <button
className="flex items-center gap-2 px-3 py-2 bg-secondary hover:bg-secondary/80 rounded-lg transition-colors flex-shrink-0" onClick={() => setPreferStream((prev) => !prev)}
title={isFullscreen ? 'Exit Fullscreen (ESC)' : 'Enter Fullscreen'} className="flex items-center gap-2 px-3 py-2 bg-secondary hover:bg-secondary/80 rounded-lg transition-colors flex-shrink-0"
aria-label={isFullscreen ? 'Exit Fullscreen' : 'Enter Fullscreen'} title={
> preferStream
{isFullscreen ? ( ? 'Play in the browser with Ruffle'
<> : 'Play with the original Flash Player, streamed from the server'
<Minimize2 size={18} /> }
<span className="text-sm hidden sm:inline">Exit</span> aria-label={preferStream ? 'Use Ruffle' : 'Use original Flash Player'}
</> >
) : ( <MonitorPlay size={18} aria-hidden="true" />
<> <span className="text-sm hidden sm:inline">
<Maximize2 size={18} /> {preferStream ? 'Use Ruffle' : 'Original Flash Player'}
<span className="text-sm hidden sm:inline">Fullscreen</span> </span>
</> </button>
)} ) : null}
</button> {allowFullscreen ? (
) : null} <button
onClick={toggleFullscreen}
className="flex items-center gap-2 px-3 py-2 bg-secondary hover:bg-secondary/80 rounded-lg transition-colors flex-shrink-0"
title={isFullscreen ? 'Exit Fullscreen (ESC)' : 'Enter Fullscreen'}
aria-label={isFullscreen ? 'Exit Fullscreen' : 'Enter Fullscreen'}
>
{isFullscreen ? (
<>
<Minimize2 size={18} />
<span className="text-sm hidden sm:inline">Exit</span>
</>
) : (
<>
<Maximize2 size={18} />
<span className="text-sm hidden sm:inline">Fullscreen</span>
</>
)}
</button>
) : null}
</div>
</div> </div>
) : null} ) : null}