Merge pull request 'Stream Flash games with the original Flash Player' (#2) from flash-streaming into main
Reviewed-on: branderson/flashpoint-web#2 Reviewed-by: branderson <brad@branderson.io>
This commit is contained in:
@@ -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']);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 { 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<string | null>(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({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{allowFullscreen ? (
|
||||
<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 className="flex items-center gap-2 flex-shrink-0">
|
||||
{canSwitchPlayer ? (
|
||||
<button
|
||||
onClick={() => setPreferStream((prev) => !prev)}
|
||||
className="flex items-center gap-2 px-3 py-2 bg-secondary hover:bg-secondary/80 rounded-lg transition-colors flex-shrink-0"
|
||||
title={
|
||||
preferStream
|
||||
? 'Play in the browser with Ruffle'
|
||||
: 'Play with the original Flash Player, streamed from the server'
|
||||
}
|
||||
aria-label={preferStream ? 'Use Ruffle' : 'Use original Flash Player'}
|
||||
>
|
||||
<MonitorPlay size={18} aria-hidden="true" />
|
||||
<span className="text-sm hidden sm:inline">
|
||||
{preferStream ? 'Use Ruffle' : 'Original Flash Player'}
|
||||
</span>
|
||||
</button>
|
||||
) : null}
|
||||
{allowFullscreen ? (
|
||||
<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>
|
||||
) : null}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user