From 13cefae84c8b5538e80f87894d15ebb0019aefa2 Mon Sep 17 00:00:00 2001 From: Christian Stamati Date: Tue, 25 Nov 2025 22:51:36 +0100 Subject: [PATCH 1/4] Add ViewportResolutionScale configuration parameter --- Frontend/library/src/Config/Config.ts | 17 +++++++++++++++++ Frontend/library/src/VideoPlayer/VideoPlayer.ts | 10 +++++++--- 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/Frontend/library/src/Config/Config.ts b/Frontend/library/src/Config/Config.ts index 165359ee..abb07761 100644 --- a/Frontend/library/src/Config/Config.ts +++ b/Frontend/library/src/Config/Config.ts @@ -65,6 +65,7 @@ export class NumericParameters { static MaxReconnectAttempts = 'MaxReconnectAttempts' as const; static StreamerAutoJoinInterval = 'StreamerAutoJoinInterval' as const; static KeepaliveDelay = 'KeepaliveDelay' as const; + static ViewportResolutionScale = 'ViewportResScale' as const; } export type NumericParametersKeys = Exclude; @@ -821,6 +822,22 @@ export class Config { useUrlParams ) ); + + this.numericParameters.set( + NumericParameters.ViewportResolutionScale, + new SettingNumber( + NumericParameters.ViewportResolutionScale, + 'Viewport Resolution Scale', + 'Scale factor for viewport resolution when MatchViewportResolution is enabled. 1.0 = 100%, 0.5 = 50%, 2.0 = 200%.', + 0.1 /*min*/, + 10.0 /*max*/, + settings && + Object.prototype.hasOwnProperty.call(settings, NumericParameters.ViewportResolutionScale) + ? settings[NumericParameters.ViewportResolutionScale] + : 1.0 /*value*/, + useUrlParams + ) + ); } /** diff --git a/Frontend/library/src/VideoPlayer/VideoPlayer.ts b/Frontend/library/src/VideoPlayer/VideoPlayer.ts index 70e64b85..40d2b66c 100644 --- a/Frontend/library/src/VideoPlayer/VideoPlayer.ts +++ b/Frontend/library/src/VideoPlayer/VideoPlayer.ts @@ -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'; /** @@ -222,9 +222,13 @@ export class VideoPlayer { return; } + const viewportResolutionScale = this.config.getNumericSettingValue( + NumericParameters.ViewportResolutionScale + ); + this.onMatchViewportResolutionCallback( - videoElementParent.clientWidth, - videoElementParent.clientHeight + videoElementParent.clientWidth * viewportResolutionScale, + videoElementParent.clientHeight * viewportResolutionScale ); this.lastTimeResized = new Date().getTime(); From a485192c9961083fcafa939cca0431dc605b14b5 Mon Sep 17 00:00:00 2001 From: Christian Stamati Date: Tue, 25 Nov 2025 23:10:42 +0100 Subject: [PATCH 2/4] Add Docs for Viewport Resolution Scale in Frontend/Docs/Settings Panel.md --- Frontend/Docs/Settings Panel.md | 1 + 1 file changed, 1 insertion(+) diff --git a/Frontend/Docs/Settings Panel.md b/Frontend/Docs/Settings Panel.md index 3e41bb40..6509c8a2 100644 --- a/Frontend/Docs/Settings Panel.md +++ b/Frontend/Docs/Settings Panel.md @@ -31,6 +31,7 @@ This page will be updated with new features and commands as they become availabl | **Setting** | **Description** | | --- | --- | | **Match viewport resolution** | Resizes the Unreal Engine application resolution to match the browser's video element size.| +| **Viewport Resolution Scale** | Scale factor for viewport resolution when Match Viewport Resolution is enabled. Range: 0.1-10.0, Default: 1.0. 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. | From a568f6a6190719933f45c1347e31933c517b082b Mon Sep 17 00:00:00 2001 From: Christian Stamati Date: Sat, 17 Jan 2026 21:38:18 +0100 Subject: [PATCH 3/4] Address PR feedback: improve ViewportResolutionScale configuration --- .changeset/cold-toys-arrive.md | 6 ++++++ Frontend/Docs/Settings Panel.md | 4 ++-- Frontend/library/src/Config/Config.ts | 2 +- Frontend/ui-library/src/Config/ConfigUI.ts | 6 ++++++ 4 files changed, 15 insertions(+), 3 deletions(-) create mode 100644 .changeset/cold-toys-arrive.md diff --git a/.changeset/cold-toys-arrive.md b/.changeset/cold-toys-arrive.md new file mode 100644 index 00000000..f2cc31da --- /dev/null +++ b/.changeset/cold-toys-arrive.md @@ -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 diff --git a/Frontend/Docs/Settings Panel.md b/Frontend/Docs/Settings Panel.md index 654b2e94..45ea48f8 100644 --- a/Frontend/Docs/Settings Panel.md +++ b/Frontend/Docs/Settings Panel.md @@ -30,8 +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.| -| **Viewport Resolution Scale** | Scale factor for viewport resolution when Match Viewport Resolution is enabled. Range: 0.1-10.0, Default: 1.0. Values above 1.0 (e.g., 1.5, 2.0) can improve visual quality on small screens by requesting higher resolution streams. | +| **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. | diff --git a/Frontend/library/src/Config/Config.ts b/Frontend/library/src/Config/Config.ts index abb07761..7b52689a 100644 --- a/Frontend/library/src/Config/Config.ts +++ b/Frontend/library/src/Config/Config.ts @@ -830,7 +830,7 @@ export class Config { 'Viewport Resolution Scale', 'Scale factor for viewport resolution when MatchViewportResolution is enabled. 1.0 = 100%, 0.5 = 50%, 2.0 = 200%.', 0.1 /*min*/, - 10.0 /*max*/, + 3.0 /*max*/, settings && Object.prototype.hasOwnProperty.call(settings, NumericParameters.ViewportResolutionScale) ? settings[NumericParameters.ViewportResolutionScale] diff --git a/Frontend/ui-library/src/Config/ConfigUI.ts b/Frontend/ui-library/src/Config/ConfigUI.ts index 375932be..ffcc1162 100644 --- a/Frontend/ui-library/src/Config/ConfigUI.ts +++ b/Frontend/ui-library/src/Config/ConfigUI.ts @@ -203,6 +203,12 @@ export class ConfigUI { if (isSettingEnabled(settingsConfig, Flags.MatchViewportResolution)) this.addSettingFlag(viewSettingsSection, this.flagsUi.get(Flags.MatchViewportResolution)); + if (isSettingEnabled(settingsConfig, NumericParameters.ViewportResolutionScale)) + this.addSettingNumeric( + viewSettingsSection, + this.numericParametersUi.get(NumericParameters.ViewportResolutionScale) + ); + if (isSettingEnabled(settingsConfig, Flags.HoveringMouseMode)) this.addSettingFlag(viewSettingsSection, this.flagsUi.get(Flags.HoveringMouseMode)); From 1d0d8041ffe168666b9145d52eaeb0c85a5a2413 Mon Sep 17 00:00:00 2001 From: "Matthew.Cotton" Date: Thu, 23 Apr 2026 11:35:01 +1000 Subject: [PATCH 4/4] Harden ViewportResScale: round output, warn past encoder limit, rename constant - Round scaled dimensions to integers before sending Resolution.Width / Height commands to UE (non-integers were being JSON-stringified and handed to cvar parsing). - Warn via Logger when a scaled dimension exceeds the H.264 4096 limit so large viewports with high scales surface a clear encoding-failure hint. - Rename NumericParameters.ViewportResolutionScale -> ViewportResScale so the TS constant matches the URL-param / persisted key ('ViewportResScale'). - Add Config.hasNumericSetting and fall back to 1.0 in VideoPlayer when the setting is absent, so custom Config subclasses do not throw on every resize. - Add VideoPlayer Jest coverage for default, scaled, rounded, >4096-warn, within-limit, missing-setting-fallback, and flag-disabled paths. --- Frontend/library/src/Config/Config.ts | 19 ++- .../src/VideoPlayer/VideoPlayer.test.ts | 141 ++++++++++++++++++ .../library/src/VideoPlayer/VideoPlayer.ts | 26 +++- Frontend/ui-library/src/Config/ConfigUI.ts | 4 +- 4 files changed, 175 insertions(+), 15 deletions(-) create mode 100644 Frontend/library/src/VideoPlayer/VideoPlayer.test.ts diff --git a/Frontend/library/src/Config/Config.ts b/Frontend/library/src/Config/Config.ts index 7b52689a..c541e19b 100644 --- a/Frontend/library/src/Config/Config.ts +++ b/Frontend/library/src/Config/Config.ts @@ -65,7 +65,7 @@ export class NumericParameters { static MaxReconnectAttempts = 'MaxReconnectAttempts' as const; static StreamerAutoJoinInterval = 'StreamerAutoJoinInterval' as const; static KeepaliveDelay = 'KeepaliveDelay' as const; - static ViewportResolutionScale = 'ViewportResScale' as const; + static ViewportResScale = 'ViewportResScale' as const; } export type NumericParametersKeys = Exclude; @@ -824,16 +824,15 @@ export class Config { ); this.numericParameters.set( - NumericParameters.ViewportResolutionScale, + NumericParameters.ViewportResScale, new SettingNumber( - NumericParameters.ViewportResolutionScale, + 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.ViewportResolutionScale) - ? settings[NumericParameters.ViewportResolutionScale] + settings && Object.prototype.hasOwnProperty.call(settings, NumericParameters.ViewportResScale) + ? settings[NumericParameters.ViewportResScale] : 1.0 /*value*/, useUrlParams ) @@ -875,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. diff --git a/Frontend/library/src/VideoPlayer/VideoPlayer.test.ts b/Frontend/library/src/VideoPlayer/VideoPlayer.test.ts new file mode 100644 index 00000000..e50a1370 --- /dev/null +++ b/Frontend/library/src/VideoPlayer/VideoPlayer.test.ts @@ -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 }) + .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(); + }); +}); diff --git a/Frontend/library/src/VideoPlayer/VideoPlayer.ts b/Frontend/library/src/VideoPlayer/VideoPlayer.ts index 40d2b66c..137e046d 100644 --- a/Frontend/library/src/VideoPlayer/VideoPlayer.ts +++ b/Frontend/library/src/VideoPlayer/VideoPlayer.ts @@ -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; @@ -222,14 +225,23 @@ export class VideoPlayer { return; } - const viewportResolutionScale = this.config.getNumericSettingValue( - NumericParameters.ViewportResolutionScale - ); + const viewportResolutionScale = this.config.hasNumericSetting(NumericParameters.ViewportResScale) + ? this.config.getNumericSettingValue(NumericParameters.ViewportResScale) + : 1.0; - this.onMatchViewportResolutionCallback( - videoElementParent.clientWidth * viewportResolutionScale, - videoElementParent.clientHeight * viewportResolutionScale - ); + 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 { diff --git a/Frontend/ui-library/src/Config/ConfigUI.ts b/Frontend/ui-library/src/Config/ConfigUI.ts index ffcc1162..de3830cf 100644 --- a/Frontend/ui-library/src/Config/ConfigUI.ts +++ b/Frontend/ui-library/src/Config/ConfigUI.ts @@ -203,10 +203,10 @@ export class ConfigUI { if (isSettingEnabled(settingsConfig, Flags.MatchViewportResolution)) this.addSettingFlag(viewSettingsSection, this.flagsUi.get(Flags.MatchViewportResolution)); - if (isSettingEnabled(settingsConfig, NumericParameters.ViewportResolutionScale)) + if (isSettingEnabled(settingsConfig, NumericParameters.ViewportResScale)) this.addSettingNumeric( viewSettingsSection, - this.numericParametersUi.get(NumericParameters.ViewportResolutionScale) + this.numericParametersUi.get(NumericParameters.ViewportResScale) ); if (isSettingEnabled(settingsConfig, Flags.HoveringMouseMode))