From 1965a1392b1afb5c3c14f62897a6cfecc6b0e3f2 Mon Sep 17 00:00:00 2001 From: Brad Anderson Date: Sun, 11 Oct 2026 17:50:24 -0700 Subject: [PATCH] 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. --- backend/src/routes/games.test.ts | 57 ++++++++++++++-- backend/src/routes/games.ts | 14 +++- backend/src/services/GameService.ts | 8 ++- .../src/components/player/GamePlayer.test.tsx | 48 ++++++++++++++ frontend/src/components/player/GamePlayer.tsx | 66 ++++++++++++------- 5 files changed, 163 insertions(+), 30 deletions(-) create mode 100644 frontend/src/components/player/GamePlayer.test.tsx diff --git a/backend/src/routes/games.test.ts b/backend/src/routes/games.test.ts index 2247d55..5c19856 100644 --- a/backend/src/routes/games.test.ts +++ b/backend/src/routes/games.test.ts @@ -34,8 +34,9 @@ vi.mock('../services/GameDataService', () => ({ const isStreamable = vi.hoisted(() => vi.fn()); const createSession = vi.hoisted(() => vi.fn()); +const isEnabled = vi.hoisted(() => vi.fn()); vi.mock('../services/StreamingService', () => ({ - StreamingService: { isStreamable, createSession }, + StreamingService: { isStreamable, createSession, isEnabled }, })); import gamesRouter from './games'; @@ -118,6 +119,7 @@ describe('streaming games that cannot run in a browser', () => { }); mountGameZip.mockResolvedValue({ mounted: true, downloading: false }); isStreamable.mockResolvedValue(true); + isEnabled.mockReturnValue(true); 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 }); }); - 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']); getGameById.mockResolvedValue({ id: GAME_ID, - platformName: 'Flash', - launchCommand: 'http://a/b.swf', + platformName: 'HTML5', + launchCommand: 'http://a/index.html', }); const details = await request(createApp()) @@ -151,6 +153,53 @@ describe('streaming games that cannot run in a browser', () => { 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 () => { signInAs(['games.read', 'games.play']); diff --git a/backend/src/routes/games.ts b/backend/src/routes/games.ts index d332b8f..3e466b4 100644 --- a/backend/src/routes/games.ts +++ b/backend/src/routes/games.ts @@ -237,7 +237,7 @@ router.get( res.locals.game = game; const streamable = - !isWebPlayablePlatform(game.platformName) && (await StreamingService.isStreamable(game.id)); + game.platformName !== 'HTML5' && (await StreamingService.isStreamable(game.id)); res.json({ ...game, streamable }); }) @@ -315,8 +315,16 @@ router.get( const hasContent = launchCommand.trim().length > 0; - const canPlayInBrowser = isWebPlayablePlatform(game.platformName) && hasContent; - const streamable = !canPlayInBrowser && (await StreamingService.isStreamable(game.id)); + // Without a plugin, a Flash game launched from a page only plays streamed + 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.canPlayInBrowser = canPlayInBrowser; diff --git a/backend/src/services/GameService.ts b/backend/src/services/GameService.ts index 7e477aa..0d94557 100644 --- a/backend/src/services/GameService.ts +++ b/backend/src/services/GameService.ts @@ -1,6 +1,7 @@ import { DatabaseService } from './DatabaseService'; import { logger } from '../utils/logger'; import { config } from '../config'; +import { StreamingService } from './StreamingService'; export interface GameSearchQuery { 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. * * Performance: ~150ms overhead per query. Acceptable for searchGames() since results @@ -319,6 +321,10 @@ export class GameService { * getPlatformOptions(), use getFlashSwfGameIds() cache instead. */ 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 `( ${tableAlias}.platformName != 'Flash' OR EXISTS ( diff --git a/frontend/src/components/player/GamePlayer.test.tsx b/frontend/src/components/player/GamePlayer.test.tsx new file mode 100644 index 0000000..deb4641 --- /dev/null +++ b/frontend/src/components/player/GamePlayer.test.tsx @@ -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: () =>
ruffle
})); +vi.mock('./StreamPlayer', () => ({ StreamPlayer: () =>
stream
})); + +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(); + 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(); + expect( + screen.queryByRole('button', { name: 'Use original Flash Player' }) + ).not.toBeInTheDocument(); + }); + + it('streams a Flash game that cannot play in the browser', () => { + render( + + ); + expect(screen.getByText('stream')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/player/GamePlayer.tsx b/frontend/src/components/player/GamePlayer.tsx index 9f49898..af746a8 100644 --- a/frontend/src/components/player/GamePlayer.tsx +++ b/frontend/src/components/player/GamePlayer.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useRef, useMemo } from 'react'; 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 { RufflePlayer } from './RufflePlayer'; import { StreamPlayer } from './StreamPlayer'; @@ -57,6 +57,8 @@ function GamePlayerInternal({ onBack, }: GamePlayerProps) { 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(null); const [logoError, setLogoError] = useState(false); @@ -105,7 +107,8 @@ function GamePlayerInternal({ } }, [contentUrl, platform]); - const isStreamed = !canPlayInBrowser && streamable && Boolean(gameId); + const canSwitchPlayer = canPlayInBrowser && streamable && Boolean(gameId); + const isStreamed = streamable && Boolean(gameId) && (!canPlayInBrowser || preferStream); if (!canPlayInBrowser && !isStreamed) { const isWebPlatform = platform === 'Flash' || platform === 'HTML5'; @@ -230,26 +233,45 @@ function GamePlayerInternal({ - {allowFullscreen ? ( - - ) : null} +
+ {canSwitchPlayer ? ( + + ) : null} + {allowFullscreen ? ( + + ) : null} +
) : null}