Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/cold-toys-arrive.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@epicgames-ps/lib-pixelstreamingfrontend-ui-ue5.7': minor
'@epicgames-ps/lib-pixelstreamingfrontend-ue5.7': minor
---

Added Viewport Resolution Scale parameter to request higher resolution streams on small screens
3 changes: 2 additions & 1 deletion Frontend/Docs/Settings Panel.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,8 @@ This page will be updated with new features and commands as they become availabl
### UI
| **Setting** | **Description** |
| --- | --- |
| **Match viewport resolution** | Resizes the Unreal Engine application resolution to match the browser's video element size.|
| **Match viewport resolution** | Resizes the Unreal Engine application resolution to match the browser's video element size. (Note: We recommend using `-windowed` on the UE side to allow scaling beyond monitor size.)|
| **Viewport Resolution Scale** | Scale factor for viewport resolution when Match Viewport Resolution is enabled. Range: 0.1-3.0, Default: 1.0 (no scaling). Values above 1.0 (e.g., 1.5, 2.0) can improve visual quality on small screens by requesting higher resolution streams. |
| **Control scheme** | If the scheme is `locked mouse` the browser will use `pointerlock` to capture your mouse, whereas if the scheme is `hovering mouse` you will retain your OS/browser cursor. |
| **Color scheme** | Allows you to switch between light mode and dark mode. |

Expand Down
24 changes: 24 additions & 0 deletions Frontend/library/src/Config/Config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ export class NumericParameters {
static MaxReconnectAttempts = 'MaxReconnectAttempts' as const;
static StreamerAutoJoinInterval = 'StreamerAutoJoinInterval' as const;
static KeepaliveDelay = 'KeepaliveDelay' as const;
static ViewportResScale = 'ViewportResScale' as const;
}

export type NumericParametersKeys = Exclude<keyof typeof NumericParameters, 'prototype'>;
Expand Down Expand Up @@ -821,6 +822,21 @@ export class Config {
useUrlParams
)
);

this.numericParameters.set(
NumericParameters.ViewportResScale,
new SettingNumber(
NumericParameters.ViewportResScale,
'Viewport Resolution Scale',
'Scale factor for viewport resolution when MatchViewportResolution is enabled. 1.0 = 100%, 0.5 = 50%, 2.0 = 200%.',
0.1 /*min*/,
3.0 /*max*/,
settings && Object.prototype.hasOwnProperty.call(settings, NumericParameters.ViewportResScale)
? settings[NumericParameters.ViewportResScale]
: 1.0 /*value*/,
useUrlParams
)
);
}

/**
Expand Down Expand Up @@ -858,6 +874,14 @@ export class Config {
}
}

/**
* @param id The id of the numeric setting to check for.
* @returns True if the numeric setting is registered in this Config.
*/
hasNumericSetting(id: NumericParametersIds): boolean {
return this.numericParameters.has(id);
}

/**
* @param id The id of the text setting we are interested in getting a value for.
* @returns The text value stored in the parameter with the passed id.
Expand Down
141 changes: 141 additions & 0 deletions Frontend/library/src/VideoPlayer/VideoPlayer.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import { Logger } from '@epicgames-ps/lib-pixelstreamingcommon-ue5.7';
import { Config, Flags, NumericParameters } from '../Config/Config';
import { mockRTCRtpReceiver, unmockRTCRtpReceiver } from '../__test__/mockRTCRtpReceiver';
import { VideoPlayer } from './VideoPlayer';

/**
* Tests for the ViewportResScale numeric parameter added to VideoPlayer.
*
* The callback onMatchViewportResolutionCallback is invoked with the scaled
* viewport dimensions when MatchViewportResolution is enabled. We validate:
* - default scale (1.0) leaves dimensions unchanged
* - explicit scale multiplies both dimensions
* - non-integer products are rounded to integers
* - dimensions > 4096 emit a warning via Logger
* - a Config missing the setting falls back to 1.0 instead of throwing
*/
describe('VideoPlayer.updateVideoStreamSize — ViewportResScale', () => {
let parent: HTMLDivElement;
let config: Config;
let player: VideoPlayer;
let callback: jest.Mock;

const setViewportSize = (w: number, h: number) => {
Object.defineProperty(parent, 'clientWidth', { configurable: true, value: w });
Object.defineProperty(parent, 'clientHeight', { configurable: true, value: h });
};

beforeEach(() => {
mockRTCRtpReceiver();
parent = document.createElement('div');
document.body.appendChild(parent);

config = new Config({ initialSettings: { [Flags.MatchViewportResolution]: true } });

player = new VideoPlayer(parent, config);
callback = jest.fn();
player.onMatchViewportResolutionCallback = callback;

// Bypass the 300ms throttle in updateVideoStreamSize.
(player as unknown as { lastTimeResized: number }).lastTimeResized = 0;
});

afterEach(() => {
player.destroy();
parent.remove();
unmockRTCRtpReceiver();
jest.restoreAllMocks();
});

it('passes viewport dimensions through unchanged when scale is 1.0 (default)', () => {
setViewportSize(375, 667);
player.updateVideoStreamSize();
expect(callback).toHaveBeenCalledWith(375, 667);
});

it('multiplies both dimensions by the configured scale', () => {
config.setNumericSetting(NumericParameters.ViewportResScale, 2.0);
setViewportSize(375, 667);

// lastTimeResized was updated on construction, reset again.
(player as unknown as { lastTimeResized: number }).lastTimeResized = 0;
player.updateVideoStreamSize();

expect(callback).toHaveBeenCalledWith(750, 1334);
});

it('rounds non-integer products to integers', () => {
config.setNumericSetting(NumericParameters.ViewportResScale, 1.5);
setViewportSize(375, 667);

(player as unknown as { lastTimeResized: number }).lastTimeResized = 0;
player.updateVideoStreamSize();

// 375 * 1.5 = 562.5 → 563, 667 * 1.5 = 1000.5 → 1001
expect(callback).toHaveBeenCalledWith(563, 1001);
const [w, h] = callback.mock.calls[0] as [number, number];
expect(Number.isInteger(w)).toBe(true);
expect(Number.isInteger(h)).toBe(true);
});

it('logs a warning when scaled width or height exceeds 4096', () => {
const warnSpy = jest.spyOn(Logger, 'Warning').mockImplementation(() => {});

config.setNumericSetting(NumericParameters.ViewportResScale, 3.0);
setViewportSize(2000, 1000); // 2000*3 = 6000 > 4096

(player as unknown as { lastTimeResized: number }).lastTimeResized = 0;
player.updateVideoStreamSize();

expect(warnSpy).toHaveBeenCalledTimes(1);
expect(warnSpy.mock.calls[0][0]).toContain('4096');
expect(warnSpy.mock.calls[0][0]).toContain('6000');
expect(callback).toHaveBeenCalledWith(6000, 3000);
});

it('does not warn when scaled dimensions stay within the encoder limit', () => {
const warnSpy = jest.spyOn(Logger, 'Warning').mockImplementation(() => {});

config.setNumericSetting(NumericParameters.ViewportResScale, 2.0);
setViewportSize(1920, 1080); // 3840 x 2160, under 4096

(player as unknown as { lastTimeResized: number }).lastTimeResized = 0;
player.updateVideoStreamSize();

expect(warnSpy).not.toHaveBeenCalled();
});

it('falls back to scale 1.0 when the setting is not registered on the Config', () => {
const strippedConfig = new Config({ initialSettings: { [Flags.MatchViewportResolution]: true } });
// Remove the registration to simulate a custom Config subclass that omits it.
const params = (strippedConfig as unknown as { numericParameters: Map<string, unknown> })
.numericParameters;
params.delete(NumericParameters.ViewportResScale);

const strippedParent = document.createElement('div');
document.body.appendChild(strippedParent);
const strippedPlayer = new VideoPlayer(strippedParent, strippedConfig);
const strippedCallback = jest.fn();
strippedPlayer.onMatchViewportResolutionCallback = strippedCallback;

Object.defineProperty(strippedParent, 'clientWidth', { configurable: true, value: 500 });
Object.defineProperty(strippedParent, 'clientHeight', { configurable: true, value: 400 });

(strippedPlayer as unknown as { lastTimeResized: number }).lastTimeResized = 0;
expect(() => strippedPlayer.updateVideoStreamSize()).not.toThrow();
expect(strippedCallback).toHaveBeenCalledWith(500, 400);

strippedPlayer.destroy();
strippedParent.remove();
});

it('does not invoke the callback when MatchViewportResolution is disabled', () => {
config.setFlagEnabled(Flags.MatchViewportResolution, false);
setViewportSize(375, 667);

(player as unknown as { lastTimeResized: number }).lastTimeResized = 0;
player.updateVideoStreamSize();

expect(callback).not.toHaveBeenCalled();
});
});
26 changes: 21 additions & 5 deletions Frontend/library/src/VideoPlayer/VideoPlayer.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// Copyright Epic Games, Inc. All Rights Reserved.

import { Config, Flags } from '../Config/Config';
import { Config, Flags, NumericParameters } from '../Config/Config';
import { Logger } from '@epicgames-ps/lib-pixelstreamingcommon-ue5.7';

/**
Expand All @@ -16,6 +16,9 @@ declare global {
* The video player html element
*/
export class VideoPlayer {
// Common H.264 maximum encoding dimension. Streams beyond this commonly fail to encode.
private static readonly maxEncoderDimension = 4096;

private config: Config;
private videoElement: HTMLVideoElement;
private audioElement?: HTMLAudioElement;
Expand Down Expand Up @@ -222,10 +225,23 @@ export class VideoPlayer {
return;
}

this.onMatchViewportResolutionCallback(
videoElementParent.clientWidth,
videoElementParent.clientHeight
);
const viewportResolutionScale = this.config.hasNumericSetting(NumericParameters.ViewportResScale)
? this.config.getNumericSettingValue(NumericParameters.ViewportResScale)
: 1.0;

const scaledWidth = Math.round(videoElementParent.clientWidth * viewportResolutionScale);
const scaledHeight = Math.round(videoElementParent.clientHeight * viewportResolutionScale);

if (
scaledWidth > VideoPlayer.maxEncoderDimension ||
scaledHeight > VideoPlayer.maxEncoderDimension
) {
Logger.Warning(
`Requested stream resolution (${scaledWidth}x${scaledHeight}) exceeds the common H.264 encoder limit of ${VideoPlayer.maxEncoderDimension}x${VideoPlayer.maxEncoderDimension}; encoding may fail. Lower ViewportResScale or disable MatchViewportResolution.`
);
}

this.onMatchViewportResolutionCallback(scaledWidth, scaledHeight);

this.lastTimeResized = new Date().getTime();
} else {
Expand Down
6 changes: 6 additions & 0 deletions Frontend/ui-library/src/Config/ConfigUI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,12 @@ export class ConfigUI {
if (isSettingEnabled(settingsConfig, Flags.MatchViewportResolution))
this.addSettingFlag(viewSettingsSection, this.flagsUi.get(Flags.MatchViewportResolution));

if (isSettingEnabled(settingsConfig, NumericParameters.ViewportResScale))
this.addSettingNumeric(
viewSettingsSection,
this.numericParametersUi.get(NumericParameters.ViewportResScale)
);

if (isSettingEnabled(settingsConfig, Flags.HoveringMouseMode))
this.addSettingFlag(viewSettingsSection, this.flagsUi.get(Flags.HoveringMouseMode));

Expand Down
Loading