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:
@@ -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']);
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user