Compare commits

...

7 Commits

Author SHA1 Message Date
8f49c48e74 Bump version to 5.8.2 2024-10-11 07:11:37 +07:00
6fa1f73702 Optimize built scripts 2024-10-10 21:43:42 +07:00
728abced45 Add jitter stat 2024-10-10 21:35:36 +07:00
411e43ceb0 Disable inputPollingDurationStats 2024-10-10 20:55:57 +07:00
baa22dbefc Optimize Clarity Boost shader 2024-10-10 17:28:19 +07:00
97fb7a114f Set Sharpness's suggested value to 2 2024-10-09 09:02:52 +07:00
39b2f814b6 Fix stream badge always show "IPv6" even when connecting to IPv4 server #517 2024-10-09 06:30:09 +07:00
15 changed files with 487 additions and 424 deletions

View File

@ -55,7 +55,7 @@ const postProcess = (str: string): string => {
// Minify SVG import code // Minify SVG import code
const svgMap = {} const svgMap = {}
str = str.replaceAll(/var ([\w_]+) = ("<svg.*?");\n\n/g, function(match, p1, p2) { str = str.replaceAll(/var ([\w_]+) = ("<svg.*?");\n\n/g, (match, p1, p2) => {
// Remove new lines in SVG // Remove new lines in SVG
p2 = p2.replaceAll(/\\n*\s*/g, ''); p2 = p2.replaceAll(/\\n*\s*/g, '');
@ -76,6 +76,17 @@ const postProcess = (str: string): string => {
// Remove blank lines // Remove blank lines
str = str.replaceAll(/\n([\s]*)\n/g, "\n"); str = str.replaceAll(/\n([\s]*)\n/g, "\n");
// Minify WebGL shaders & JS strings
// Replace "\n " with "\n"
str = str.replaceAll(/\\n+\s*/g, '\\n');
// Remove comment line
str = str.replaceAll(/\\n\/\/.*?(?=\\n)/g, '');
// Replace ${"time".toUpperCase()} with "TIME"
str = str.replaceAll(/\$\{"([^"]+)"\.toUpperCase\(\)\}/g, (match, p1) => {
return p1.toUpperCase();
});
assert(str.includes('/* ADDITIONAL CODE */')); assert(str.includes('/* ADDITIONAL CODE */'));
assert(str.includes('window.BX_EXPOSED = BxExposed')); assert(str.includes('window.BX_EXPOSED = BxExposed'));
assert(str.includes('window.BxEvent = BxEvent')); assert(str.includes('window.BxEvent = BxEvent'));

File diff suppressed because one or more lines are too long

View File

@ -1,5 +1,5 @@
// ==UserScript== // ==UserScript==
// @name Better xCloud // @name Better xCloud
// @namespace https://github.com/redphx // @namespace https://github.com/redphx
// @version 5.8.1 // @version 5.8.2
// ==/UserScript== // ==/UserScript==

File diff suppressed because one or more lines are too long

View File

@ -91,6 +91,7 @@ div[class^=StreamMenu-module__container] .bx-badges {
&[data-stats*="[batt]"] > .bx-stat-batt, &[data-stats*="[batt]"] > .bx-stat-batt,
&[data-stats*="[fps]"] > .bx-stat-fps, &[data-stats*="[fps]"] > .bx-stat-fps,
&[data-stats*="[ping]"] > .bx-stat-ping, &[data-stats*="[ping]"] > .bx-stat-ping,
&[data-stats*="[jit]"] > .bx-stat-jit,
&[data-stats*="[btr]"] > .bx-stat-btr, &[data-stats*="[btr]"] > .bx-stat-btr,
&[data-stats*="[dt]"] > .bx-stat-dt, &[data-stats*="[dt]"] > .bx-stat-dt,
&[data-stats*="[pl]"] > .bx-stat-pl, &[data-stats*="[pl]"] > .bx-stat-pl,
@ -106,6 +107,7 @@ div[class^=StreamMenu-module__container] .bx-badges {
&[data-stats$="[batt]"] > .bx-stat-batt, &[data-stats$="[batt]"] > .bx-stat-batt,
&[data-stats$="[fps]"] > .bx-stat-fps, &[data-stats$="[fps]"] > .bx-stat-fps,
&[data-stats$="[ping]"] > .bx-stat-ping, &[data-stats$="[ping]"] > .bx-stat-ping,
&[data-stats$="[jit]"] > .bx-stat-jit,
&[data-stats$="[btr]"] > .bx-stat-btr, &[data-stats$="[btr]"] > .bx-stat-btr,
&[data-stats$="[dt]"] > .bx-stat-dt, &[data-stats$="[dt]"] > .bx-stat-dt,
&[data-stats$="[pl]"] > .bx-stat-pl, &[data-stats$="[pl]"] > .bx-stat-pl,

View File

@ -211,7 +211,8 @@ const PATCHES = {
// Block gamepad stats collecting // Block gamepad stats collecting
if (getPref(PrefKey.BLOCK_TRACKING)) { if (getPref(PrefKey.BLOCK_TRACKING)) {
codeBlock = codeBlock.replaceAll('this.inputPollingIntervalStats.addValue', ''); codeBlock = codeBlock.replace('this.inputPollingIntervalStats.addValue', '');
codeBlock = codeBlock.replace('this.inputPollingDurationStats.addValue', '');
} }
// Map the Share button on Xbox Series controller with the capturing screenshot feature // Map the Share button on Xbox Series controller with the capturing screenshot feature
@ -219,8 +220,8 @@ const PATCHES = {
if (match) { if (match) {
const gamepadVar = match[1]; const gamepadVar = match[1];
const newCode = renderString(codeControllerShortcuts, { const newCode = renderString(codeControllerShortcuts, {
gamepadVar, gamepadVar,
}); });
codeBlock = codeBlock.replace('this.gamepadTimestamps.set', newCode + 'this.gamepadTimestamps.set'); codeBlock = codeBlock.replace('this.gamepadTimestamps.set', newCode + 'this.gamepadTimestamps.set');
} }

View File

@ -1,9 +1,15 @@
precision mediump float;
uniform sampler2D data;
uniform vec2 iResolution;
const int FILTER_UNSHARP_MASKING = 1; const int FILTER_UNSHARP_MASKING = 1;
const int FILTER_CAS = 2; const int FILTER_CAS = 2;
precision highp float; // constrast = 0.8
uniform sampler2D data; const float CAS_CONTRAST_PEAK = (-3.0 * 0.8 + 8.0);
uniform vec2 iResolution;
// Luminosity factor
const vec3 LUMINOSITY_FACTOR = vec3(0.2126, 0.7152, 0.0722);
uniform int filterId; uniform int filterId;
uniform float sharpenFactor; uniform float sharpenFactor;
@ -11,27 +17,24 @@ uniform float brightness;
uniform float contrast; uniform float contrast;
uniform float saturation; uniform float saturation;
vec3 textureAt(sampler2D tex, vec2 coord) { vec3 clarityBoost(sampler2D tex, vec2 coord) {
return texture2D(tex, coord / iResolution.xy).rgb; vec2 texelSize = 1.0 / iResolution.xy;
}
vec3 clarityBoost(sampler2D tex, vec2 coord)
{
// Load a collection of samples in a 3x3 neighorhood, where e is the current pixel. // Load a collection of samples in a 3x3 neighorhood, where e is the current pixel.
// a b c // a b c
// d e f // d e f
// g h i // g h i
vec3 a = textureAt(tex, coord + vec2(-1, 1)); vec3 a = texture2D(tex, coord + texelSize * vec2(-1, 1)).rgb;
vec3 b = textureAt(tex, coord + vec2(0, 1)); vec3 b = texture2D(tex, coord + texelSize * vec2(0, 1)).rgb;
vec3 c = textureAt(tex, coord + vec2(1, 1)); vec3 c = texture2D(tex, coord + texelSize * vec2(1, 1)).rgb;
vec3 d = textureAt(tex, coord + vec2(-1, 0)); vec3 d = texture2D(tex, coord + texelSize * vec2(-1, 0)).rgb;
vec3 e = textureAt(tex, coord); vec3 e = texture2D(tex, coord).rgb;
vec3 f = textureAt(tex, coord + vec2(1, 0)); vec3 f = texture2D(tex, coord + texelSize * vec2(1, 0)).rgb;
vec3 g = textureAt(tex, coord + vec2(-1, -1)); vec3 g = texture2D(tex, coord + texelSize * vec2(-1, -1)).rgb;
vec3 h = textureAt(tex, coord + vec2(0, -1)); vec3 h = texture2D(tex, coord + texelSize * vec2(0, -1)).rgb;
vec3 i = textureAt(tex, coord + vec2(1, -1)); vec3 i = texture2D(tex, coord + texelSize * vec2(1, -1)).rgb;
if (filterId == FILTER_CAS) { if (filterId == FILTER_CAS) {
// Soft min and max. // Soft min and max.
@ -54,10 +57,7 @@ vec3 clarityBoost(sampler2D tex, vec2 coord)
// Shaping amount of sharpening. // Shaping amount of sharpening.
amplifyRgb = inversesqrt(amplifyRgb); amplifyRgb = inversesqrt(amplifyRgb);
float contrast = 0.8; vec3 weightRgb = -(1.0 / (amplifyRgb * CAS_CONTRAST_PEAK));
float peak = -3.0 * contrast + 8.0;
vec3 weightRgb = -(1.0 / (amplifyRgb * peak));
vec3 reciprocalWeightRgb = 1.0 / (4.0 * weightRgb + 1.0); vec3 reciprocalWeightRgb = 1.0 / (4.0 * weightRgb + 1.0);
// 0 w 0 // 0 w 0
@ -70,9 +70,10 @@ vec3 clarityBoost(sampler2D tex, vec2 coord)
return outColor; return outColor;
} else if (filterId == FILTER_UNSHARP_MASKING) { } else if (filterId == FILTER_UNSHARP_MASKING) {
vec3 gaussianBlur = (a * 1.0 + b * 2.0 + c * 1.0 + vec3 gaussianBlur = (a + c + g + i) * 1.0 +
d * 2.0 + e * 4.0 + f * 2.0 + (b + d + f + h) * 2.0 +
g * 1.0 + h * 2.0 + i * 1.0) / 16.0; e * 4.0;
gaussianBlur /= 16.0;
// Return edge detection // Return edge detection
return e + (e - gaussianBlur) * sharpenFactor / 3.0; return e + (e - gaussianBlur) * sharpenFactor / 3.0;
@ -81,40 +82,30 @@ vec3 clarityBoost(sampler2D tex, vec2 coord)
return e; return e;
} }
vec3 adjustBrightness(vec3 color) {
return (1.0 + brightness) * color;
}
vec3 adjustContrast(vec3 color) {
return 0.5 + (1.0 + contrast) * (color - 0.5);
}
vec3 adjustSaturation(vec3 color) {
const vec3 luminosityFactor = vec3(0.2126, 0.7152, 0.0722);
vec3 grayscale = vec3(dot(color, luminosityFactor));
return mix(grayscale, color, 1.0 + saturation);
}
void main() { void main() {
vec3 color; vec3 color;
vec2 uv = gl_FragCoord.xy / iResolution.xy;
if (sharpenFactor > 0.0) { if (sharpenFactor > 0.0) {
color = clarityBoost(data, gl_FragCoord.xy); color = clarityBoost(data, uv);
} else { } else {
color = textureAt(data, gl_FragCoord.xy); color = texture2D(data, uv).rgb;
} }
if (saturation != 0.0) { // Saturation
color = adjustSaturation(color); if (saturation != 1.0) {
vec3 grayscale = vec3(dot(color, LUMINOSITY_FACTOR));
color = mix(grayscale, color, saturation);
} }
if (contrast != 0.0) { // Contrast
color = adjustContrast(color); if (contrast != 1.0) {
color = 0.5 + contrast * (color - 0.5);
} }
if (brightness != 0.0) { // Brightness
color = adjustBrightness(color); if (brightness != 1.0) {
color = brightness * color;
} }
gl_FragColor = vec4(color, 1.0); gl_FragColor = vec4(color, 1.0);

View File

@ -1,5 +1,5 @@
attribute vec2 position; attribute vec4 position;
void main() { void main() {
gl_Position = vec4(position, 0, 1); gl_Position = position;
} }

View File

@ -8,16 +8,16 @@ import { getPref } from "@/utils/settings-storages/global-settings-storage";
const LOG_TAG = 'WebGL2Player'; const LOG_TAG = 'WebGL2Player';
export class WebGL2Player { export class WebGL2Player {
#$video: HTMLVideoElement; private $video: HTMLVideoElement;
#$canvas: HTMLCanvasElement; private $canvas: HTMLCanvasElement;
#gl: WebGL2RenderingContext | null = null; private gl: WebGL2RenderingContext | null = null;
#resources: Array<any> = []; private resources: Array<any> = [];
#program: WebGLProgram | null = null; private program: WebGLProgram | null = null;
#stopped: boolean = false; private stopped: boolean = false;
#options = { private options = {
filterId: 1, filterId: 1,
sharpenFactor: 0, sharpenFactor: 0,
brightness: 0.0, brightness: 0.0,
@ -25,112 +25,112 @@ export class WebGL2Player {
saturation: 0.0, saturation: 0.0,
}; };
#animFrameId: number | null = null; private animFrameId: number | null = null;
constructor($video: HTMLVideoElement) { constructor($video: HTMLVideoElement) {
BxLogger.info(LOG_TAG, 'Initialize'); BxLogger.info(LOG_TAG, 'Initialize');
this.#$video = $video; this.$video = $video;
const $canvas = document.createElement('canvas'); const $canvas = document.createElement('canvas');
$canvas.width = $video.videoWidth; $canvas.width = $video.videoWidth;
$canvas.height = $video.videoHeight; $canvas.height = $video.videoHeight;
this.#$canvas = $canvas; this.$canvas = $canvas;
this.#setupShaders(); this.setupShaders();
this.#setupRendering(); this.setupRendering();
$video.insertAdjacentElement('afterend', $canvas); $video.insertAdjacentElement('afterend', $canvas);
} }
setFilter(filterId: number, update = true) { setFilter(filterId: number, update = true) {
this.#options.filterId = filterId; this.options.filterId = filterId;
update && this.updateCanvas(); update && this.updateCanvas();
} }
setSharpness(sharpness: number, update = true) { setSharpness(sharpness: number, update = true) {
this.#options.sharpenFactor = sharpness; this.options.sharpenFactor = sharpness;
update && this.updateCanvas(); update && this.updateCanvas();
} }
setBrightness(brightness: number, update = true) { setBrightness(brightness: number, update = true) {
this.#options.brightness = (brightness - 100) / 100; this.options.brightness = 1 + (brightness - 100) / 100;
update && this.updateCanvas(); update && this.updateCanvas();
} }
setContrast(contrast: number, update = true) { setContrast(contrast: number, update = true) {
this.#options.contrast = (contrast - 100) / 100; this.options.contrast = 1 + (contrast - 100) / 100;
update && this.updateCanvas(); update && this.updateCanvas();
} }
setSaturation(saturation: number, update = true) { setSaturation(saturation: number, update = true) {
this.#options.saturation = (saturation - 100) / 100; this.options.saturation = 1 + (saturation - 100) / 100;
update && this.updateCanvas(); update && this.updateCanvas();
} }
getCanvas() { getCanvas() {
return this.#$canvas; return this.$canvas;
} }
updateCanvas() { updateCanvas() {
const gl = this.#gl!; const gl = this.gl!;
const program = this.#program!; const program = this.program!;
gl.uniform2f(gl.getUniformLocation(program, 'iResolution'), this.#$canvas.width, this.#$canvas.height); gl.uniform2f(gl.getUniformLocation(program, 'iResolution'), this.$canvas.width, this.$canvas.height);
gl.uniform1i(gl.getUniformLocation(program, 'filterId'), this.#options.filterId); gl.uniform1i(gl.getUniformLocation(program, 'filterId'), this.options.filterId);
gl.uniform1f(gl.getUniformLocation(program, 'sharpenFactor'), this.#options.sharpenFactor); gl.uniform1f(gl.getUniformLocation(program, 'sharpenFactor'), this.options.sharpenFactor);
gl.uniform1f(gl.getUniformLocation(program, 'brightness'), this.#options.brightness); gl.uniform1f(gl.getUniformLocation(program, 'brightness'), this.options.brightness);
gl.uniform1f(gl.getUniformLocation(program, 'contrast'), this.#options.contrast); gl.uniform1f(gl.getUniformLocation(program, 'contrast'), this.options.contrast);
gl.uniform1f(gl.getUniformLocation(program, 'saturation'), this.#options.saturation); gl.uniform1f(gl.getUniformLocation(program, 'saturation'), this.options.saturation);
} }
drawFrame() { drawFrame() {
const gl = this.#gl!; const gl = this.gl!;
const $video = this.#$video; const $video = this.$video;
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGB, gl.RGB, gl.UNSIGNED_BYTE, $video); gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGB, gl.RGB, gl.UNSIGNED_BYTE, $video);
gl.drawArrays(gl.TRIANGLES, 0, 6); gl.drawArrays(gl.TRIANGLES, 0, 6);
} }
#setupRendering() { private setupRendering() {
let animate: any; let animate: any;
if ('requestVideoFrameCallback' in HTMLVideoElement.prototype) { if ('requestVideoFrameCallback' in HTMLVideoElement.prototype) {
const $video = this.#$video; const $video = this.$video;
animate = () => { animate = () => {
if (this.#stopped) { if (this.stopped) {
return; return;
} }
this.drawFrame(); this.drawFrame();
this.#animFrameId = $video.requestVideoFrameCallback(animate); this.animFrameId = $video.requestVideoFrameCallback(animate);
} }
this.#animFrameId = $video.requestVideoFrameCallback(animate); this.animFrameId = $video.requestVideoFrameCallback(animate);
} else { } else {
animate = () => { animate = () => {
if (this.#stopped) { if (this.stopped) {
return; return;
} }
this.drawFrame(); this.drawFrame();
this.#animFrameId = requestAnimationFrame(animate); this.animFrameId = requestAnimationFrame(animate);
} }
this.#animFrameId = requestAnimationFrame(animate); this.animFrameId = requestAnimationFrame(animate);
} }
} }
#setupShaders() { private setupShaders() {
BxLogger.info(LOG_TAG, 'Setting up', getPref(PrefKey.VIDEO_POWER_PREFERENCE)); BxLogger.info(LOG_TAG, 'Setting up', getPref(PrefKey.VIDEO_POWER_PREFERENCE));
const gl = this.#$canvas.getContext('webgl', { const gl = this.$canvas.getContext('webgl', {
isBx: true, isBx: true,
antialias: true, antialias: true,
alpha: false, alpha: false,
powerPreference: getPref(PrefKey.VIDEO_POWER_PREFERENCE), powerPreference: getPref(PrefKey.VIDEO_POWER_PREFERENCE),
}) as WebGL2RenderingContext; }) as WebGL2RenderingContext;
this.#gl = gl; this.gl = gl;
gl.viewport(0, 0, gl.drawingBufferWidth, gl.drawingBufferWidth); gl.viewport(0, 0, gl.drawingBufferWidth, gl.drawingBufferWidth);
@ -145,7 +145,7 @@ export class WebGL2Player {
// Create and link program // Create and link program
const program = gl.createProgram()!; const program = gl.createProgram()!;
this.#program = program; this.program = program;
gl.attachShader(program, vShader); gl.attachShader(program, vShader);
gl.attachShader(program, fShader); gl.attachShader(program, fShader);
@ -162,7 +162,7 @@ export class WebGL2Player {
// Vertices: A screen-filling quad made from two triangles // Vertices: A screen-filling quad made from two triangles
const buffer = gl.createBuffer(); const buffer = gl.createBuffer();
this.#resources.push(buffer); this.resources.push(buffer);
gl.bindBuffer(gl.ARRAY_BUFFER, buffer); gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([ gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([
@ -179,7 +179,7 @@ export class WebGL2Player {
// Texture to contain the video data // Texture to contain the video data
const texture = gl.createTexture(); const texture = gl.createTexture();
this.#resources.push(texture); this.resources.push(texture);
gl.bindTexture(gl.TEXTURE_2D, texture); gl.bindTexture(gl.TEXTURE_2D, texture);
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true); gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);
@ -197,26 +197,26 @@ export class WebGL2Player {
resume() { resume() {
this.stop(); this.stop();
this.#stopped = false; this.stopped = false;
BxLogger.info(LOG_TAG, 'Resume'); BxLogger.info(LOG_TAG, 'Resume');
this.#$canvas.classList.remove('bx-gone'); this.$canvas.classList.remove('bx-gone');
this.#setupRendering(); this.setupRendering();
} }
stop() { stop() {
BxLogger.info(LOG_TAG, 'Stop'); BxLogger.info(LOG_TAG, 'Stop');
this.#$canvas.classList.add('bx-gone'); this.$canvas.classList.add('bx-gone');
this.#stopped = true; this.stopped = true;
if (this.#animFrameId) { if (this.animFrameId) {
if ('requestVideoFrameCallback' in HTMLVideoElement.prototype) { if ('requestVideoFrameCallback' in HTMLVideoElement.prototype) {
this.#$video.cancelVideoFrameCallback(this.#animFrameId); this.$video.cancelVideoFrameCallback(this.animFrameId);
} else { } else {
cancelAnimationFrame(this.#animFrameId); cancelAnimationFrame(this.animFrameId);
} }
this.#animFrameId = null; this.animFrameId = null;
} }
} }
@ -224,11 +224,11 @@ export class WebGL2Player {
BxLogger.info(LOG_TAG, 'Destroy'); BxLogger.info(LOG_TAG, 'Destroy');
this.stop(); this.stop();
const gl = this.#gl; const gl = this.gl;
if (gl) { if (gl) {
gl.getExtension('WEBGL_lose_context')?.loseContext(); gl.getExtension('WEBGL_lose_context')?.loseContext();
for (const resource of this.#resources) { for (const resource of this.resources) {
if (resource instanceof WebGLProgram) { if (resource instanceof WebGLProgram) {
gl.useProgram(null); gl.useProgram(null);
gl.deleteProgram(resource); gl.deleteProgram(resource);
@ -241,14 +241,14 @@ export class WebGL2Player {
} }
} }
this.#gl = null; this.gl = null;
} }
if (this.#$canvas.isConnected) { if (this.$canvas.isConnected) {
this.#$canvas.parentElement?.removeChild(this.#$canvas); this.$canvas.parentElement?.removeChild(this.$canvas);
} }
this.#$canvas.width = 1; this.$canvas.width = 1;
this.#$canvas.height = 1; this.$canvas.height = 1;
} }
} }

View File

@ -17,35 +17,35 @@ export type StreamPlayerOptions = Partial<{
}>; }>;
export class StreamPlayer { export class StreamPlayer {
#$video: HTMLVideoElement; private $video: HTMLVideoElement;
#playerType: StreamPlayerType = StreamPlayerType.VIDEO; private playerType: StreamPlayerType = StreamPlayerType.VIDEO;
#options: StreamPlayerOptions = {}; private options: StreamPlayerOptions = {};
#webGL2Player: WebGL2Player | null = null; private webGL2Player: WebGL2Player | null = null;
#$videoCss: HTMLStyleElement | null = null; private $videoCss: HTMLStyleElement | null = null;
#$usmMatrix: SVGFEConvolveMatrixElement | null = null; private $usmMatrix: SVGFEConvolveMatrixElement | null = null;
constructor($video: HTMLVideoElement, type: StreamPlayerType, options: StreamPlayerOptions) { constructor($video: HTMLVideoElement, type: StreamPlayerType, options: StreamPlayerOptions) {
this.#setupVideoElements(); this.setupVideoElements();
this.#$video = $video; this.$video = $video;
this.#options = options || {}; this.options = options || {};
this.setPlayerType(type); this.setPlayerType(type);
} }
#setupVideoElements() { private setupVideoElements() {
this.#$videoCss = document.getElementById('bx-video-css') as HTMLStyleElement; this.$videoCss = document.getElementById('bx-video-css') as HTMLStyleElement;
if (this.#$videoCss) { if (this.$videoCss) {
this.#$usmMatrix = this.#$videoCss.querySelector('#bx-filter-usm-matrix') as any; this.$usmMatrix = this.$videoCss.querySelector('#bx-filter-usm-matrix') as any;
return; return;
} }
const $fragment = document.createDocumentFragment(); const $fragment = document.createDocumentFragment();
this.#$videoCss = CE<HTMLStyleElement>('style', {id: 'bx-video-css'}); this.$videoCss = CE<HTMLStyleElement>('style', {id: 'bx-video-css'});
$fragment.appendChild(this.#$videoCss); $fragment.appendChild(this.$videoCss);
// Setup SVG filters // Setup SVG filters
const $svg = CE('svg', { const $svg = CE('svg', {
@ -56,7 +56,7 @@ export class StreamPlayer {
CE('filter', { CE('filter', {
id: 'bx-filter-usm', id: 'bx-filter-usm',
xmlns: 'http://www.w3.org/2000/svg', xmlns: 'http://www.w3.org/2000/svg',
}, this.#$usmMatrix = CE('feConvolveMatrix', { }, this.$usmMatrix = CE('feConvolveMatrix', {
id: 'bx-filter-usm-matrix', id: 'bx-filter-usm-matrix',
order: '3', order: '3',
xmlns: 'http://www.w3.org/2000/svg', xmlns: 'http://www.w3.org/2000/svg',
@ -67,29 +67,29 @@ export class StreamPlayer {
document.documentElement.appendChild($fragment); document.documentElement.appendChild($fragment);
} }
#getVideoPlayerFilterStyle() { private getVideoPlayerFilterStyle() {
const filters = []; const filters = [];
const sharpness = this.#options.sharpness || 0; const sharpness = this.options.sharpness || 0;
if (this.#options.processing === StreamVideoProcessing.USM && sharpness != 0) { if (this.options.processing === StreamVideoProcessing.USM && sharpness != 0) {
const level = (7 - ((sharpness / 2) - 1) * 0.5).toFixed(1); // 5, 5.5, 6, 6.5, 7 const level = (7 - ((sharpness / 2) - 1) * 0.5).toFixed(1); // 5, 5.5, 6, 6.5, 7
const matrix = `0 -1 0 -1 ${level} -1 0 -1 0`; const matrix = `0 -1 0 -1 ${level} -1 0 -1 0`;
this.#$usmMatrix?.setAttributeNS(null, 'kernelMatrix', matrix); this.$usmMatrix?.setAttributeNS(null, 'kernelMatrix', matrix);
filters.push(`url(#bx-filter-usm)`); filters.push(`url(#bx-filter-usm)`);
} }
const saturation = this.#options.saturation || 100; const saturation = this.options.saturation || 100;
if (saturation != 100) { if (saturation != 100) {
filters.push(`saturate(${saturation}%)`); filters.push(`saturate(${saturation}%)`);
} }
const contrast = this.#options.contrast || 100; const contrast = this.options.contrast || 100;
if (contrast != 100) { if (contrast != 100) {
filters.push(`contrast(${contrast}%)`); filters.push(`contrast(${contrast}%)`);
} }
const brightness = this.#options.brightness || 100; const brightness = this.options.brightness || 100;
if (brightness != 100) { if (brightness != 100) {
filters.push(`brightness(${brightness}%)`); filters.push(`brightness(${brightness}%)`);
} }
@ -97,14 +97,14 @@ export class StreamPlayer {
return filters.join(' '); return filters.join(' ');
} }
#resizePlayer() { private resizePlayer() {
const PREF_RATIO = getPref(PrefKey.VIDEO_RATIO); const PREF_RATIO = getPref(PrefKey.VIDEO_RATIO);
const $video = this.#$video; const $video = this.$video;
const isNativeTouchGame = STATES.currentStream.titleInfo?.details.hasNativeTouchSupport; const isNativeTouchGame = STATES.currentStream.titleInfo?.details.hasNativeTouchSupport;
let $webGL2Canvas; let $webGL2Canvas;
if (this.#playerType == StreamPlayerType.WEBGL2) { if (this.playerType == StreamPlayerType.WEBGL2) {
$webGL2Canvas = this.#webGL2Player?.getCanvas()!; $webGL2Canvas = this.webGL2Player?.getCanvas()!;
} }
let targetWidth; let targetWidth;
@ -166,67 +166,67 @@ export class StreamPlayer {
} }
// Update video dimensions // Update video dimensions
if (isNativeTouchGame && this.#playerType == StreamPlayerType.WEBGL2) { if (isNativeTouchGame && this.playerType == StreamPlayerType.WEBGL2) {
window.BX_EXPOSED.streamSession.updateDimensions(); window.BX_EXPOSED.streamSession.updateDimensions();
} }
} }
setPlayerType(type: StreamPlayerType, refreshPlayer: boolean = false) { setPlayerType(type: StreamPlayerType, refreshPlayer: boolean = false) {
if (this.#playerType !== type) { if (this.playerType !== type) {
// Switch from Video -> WebGL2 // Switch from Video -> WebGL2
if (type === StreamPlayerType.WEBGL2) { if (type === StreamPlayerType.WEBGL2) {
// Initialize WebGL2 player // Initialize WebGL2 player
if (!this.#webGL2Player) { if (!this.webGL2Player) {
this.#webGL2Player = new WebGL2Player(this.#$video); this.webGL2Player = new WebGL2Player(this.$video);
} else { } else {
this.#webGL2Player.resume(); this.webGL2Player.resume();
} }
this.#$videoCss!.textContent = ''; this.$videoCss!.textContent = '';
this.#$video.classList.add('bx-pixel'); this.$video.classList.add('bx-pixel');
} else { } else {
// Cleanup WebGL2 Player // Cleanup WebGL2 Player
this.#webGL2Player?.stop(); this.webGL2Player?.stop();
this.#$video.classList.remove('bx-pixel'); this.$video.classList.remove('bx-pixel');
} }
} }
this.#playerType = type; this.playerType = type;
refreshPlayer && this.refreshPlayer(); refreshPlayer && this.refreshPlayer();
} }
setOptions(options: StreamPlayerOptions, refreshPlayer: boolean = false) { setOptions(options: StreamPlayerOptions, refreshPlayer: boolean = false) {
this.#options = options; this.options = options;
refreshPlayer && this.refreshPlayer(); refreshPlayer && this.refreshPlayer();
} }
updateOptions(options: StreamPlayerOptions, refreshPlayer: boolean = false) { updateOptions(options: StreamPlayerOptions, refreshPlayer: boolean = false) {
this.#options = Object.assign(this.#options, options); this.options = Object.assign(this.options, options);
refreshPlayer && this.refreshPlayer(); refreshPlayer && this.refreshPlayer();
} }
getPlayerElement(playerType?: StreamPlayerType) { getPlayerElement(playerType?: StreamPlayerType) {
if (typeof playerType === 'undefined') { if (typeof playerType === 'undefined') {
playerType = this.#playerType; playerType = this.playerType;
} }
if (playerType === StreamPlayerType.WEBGL2) { if (playerType === StreamPlayerType.WEBGL2) {
return this.#webGL2Player?.getCanvas(); return this.webGL2Player?.getCanvas();
} }
return this.#$video; return this.$video;
} }
getWebGL2Player() { getWebGL2Player() {
return this.#webGL2Player; return this.webGL2Player;
} }
refreshPlayer() { refreshPlayer() {
if (this.#playerType === StreamPlayerType.WEBGL2) { if (this.playerType === StreamPlayerType.WEBGL2) {
const options = this.#options; const options = this.options;
const webGL2Player = this.#webGL2Player!; const webGL2Player = this.webGL2Player!;
if (options.processing === StreamVideoProcessing.USM) { if (options.processing === StreamVideoProcessing.USM) {
webGL2Player.setFilter(1); webGL2Player.setFilter(1);
@ -241,7 +241,7 @@ export class StreamPlayer {
webGL2Player.setContrast(options.contrast || 100); webGL2Player.setContrast(options.contrast || 100);
webGL2Player.setBrightness(options.brightness || 100); webGL2Player.setBrightness(options.brightness || 100);
} else { } else {
let filters = this.#getVideoPlayerFilterStyle(); let filters = this.getVideoPlayerFilterStyle();
let videoCss = ''; let videoCss = '';
if (filters) { if (filters) {
videoCss += `filter: ${filters} !important;`; videoCss += `filter: ${filters} !important;`;
@ -257,26 +257,26 @@ export class StreamPlayer {
css = `#game-stream video { ${videoCss} }`; css = `#game-stream video { ${videoCss} }`;
} }
this.#$videoCss!.textContent = css; this.$videoCss!.textContent = css;
} }
this.#resizePlayer(); this.resizePlayer();
} }
reloadPlayer() { reloadPlayer() {
this.#cleanUpWebGL2Player(); this.cleanUpWebGL2Player();
this.#playerType = StreamPlayerType.VIDEO; this.playerType = StreamPlayerType.VIDEO;
this.setPlayerType(StreamPlayerType.WEBGL2, false); this.setPlayerType(StreamPlayerType.WEBGL2, false);
} }
#cleanUpWebGL2Player() { private cleanUpWebGL2Player() {
// Clean up WebGL2 Player // Clean up WebGL2 Player
this.#webGL2Player?.destroy(); this.webGL2Player?.destroy();
this.#webGL2Player = null; this.webGL2Player = null;
} }
destroy() { destroy() {
this.#cleanUpWebGL2Player(); this.cleanUpWebGL2Player();
} }
} }

View File

@ -345,7 +345,7 @@ export class StreamBadges {
text += server.region; text += server.region;
} }
text += '@' + (server ? 'IPv6' : 'IPv4'); text += '@' + (server.ipv6 ? 'IPv6' : 'IPv4');
this.badges.server.$element = this.renderBadge(StreamBadge.SERVER, text); this.badges.server.$element = this.renderBadge(StreamBadge.SERVER, text);
} }
} }

View File

@ -37,6 +37,10 @@ export class StreamStats {
name: t('stat-ping'), name: t('stat-ping'),
$element: CE('span'), $element: CE('span'),
}, },
[StreamStat.JITTER]: {
name: t('jitter'),
$element: CE('span'),
},
[StreamStat.FPS]: { [StreamStat.FPS]: {
name: t('stat-fps'), name: t('stat-fps'),
$element: CE('span'), $element: CE('span'),
@ -179,10 +183,8 @@ export class StreamStats {
$element.textContent = value.toString(); $element.textContent = value.toString();
// Get stat's grade // Get stat's grade
if (PREF_STATS_CONDITIONAL_FORMATTING) { if (PREF_STATS_CONDITIONAL_FORMATTING && 'grades' in value) {
if (statKey === StreamStat.PING || statKey === StreamStat.DECODE_TIME) { grade = statsCollector.calculateGrade(value.current, value.grades);
grade = (value as any).calculateGrade();
}
} }
if ($element.dataset.grade !== grade) { if ($element.dataset.grade !== grade) {

View File

@ -631,7 +631,7 @@ export class GlobalSettingsStorage extends BaseSettingsStorage {
}, },
suggest: { suggest: {
lowest: 0, lowest: 0,
highest: 4, highest: 2,
}, },
}, },
[PrefKey.VIDEO_RATIO]: { [PrefKey.VIDEO_RATIO]: {
@ -714,6 +714,7 @@ export class GlobalSettingsStorage extends BaseSettingsStorage {
[StreamStat.PLAYTIME]: `${StreamStat.PLAYTIME.toUpperCase()}: ${t('playtime')}`, [StreamStat.PLAYTIME]: `${StreamStat.PLAYTIME.toUpperCase()}: ${t('playtime')}`,
[StreamStat.BATTERY]: `${StreamStat.BATTERY.toUpperCase()}: ${t('battery')}`, [StreamStat.BATTERY]: `${StreamStat.BATTERY.toUpperCase()}: ${t('battery')}`,
[StreamStat.PING]: `${StreamStat.PING.toUpperCase()}: ${t('stat-ping')}`, [StreamStat.PING]: `${StreamStat.PING.toUpperCase()}: ${t('stat-ping')}`,
[StreamStat.JITTER]: `${StreamStat.JITTER.toUpperCase()}: ${t('jitter')}`,
[StreamStat.FPS]: `${StreamStat.FPS.toUpperCase()}: ${t('stat-fps')}`, [StreamStat.FPS]: `${StreamStat.FPS.toUpperCase()}: ${t('stat-fps')}`,
[StreamStat.BITRATE]: `${StreamStat.BITRATE.toUpperCase()}: ${t('stat-bitrate')}`, [StreamStat.BITRATE]: `${StreamStat.BITRATE.toUpperCase()}: ${t('stat-bitrate')}`,
[StreamStat.DECODE_TIME]: `${StreamStat.DECODE_TIME.toUpperCase()}: ${t('stat-decode-time')}`, [StreamStat.DECODE_TIME]: `${StreamStat.DECODE_TIME.toUpperCase()}: ${t('stat-decode-time')}`,

View File

@ -4,6 +4,7 @@ import { humanFileSize, secondsToHm } from "./html";
export enum StreamStat { export enum StreamStat {
PING = 'ping', PING = 'ping',
JITTER = 'jit',
FPS = 'fps', FPS = 'fps',
BITRATE = 'btr', BITRATE = 'btr',
DECODE_TIME = 'dt', DECODE_TIME = 'dt',
@ -21,7 +22,13 @@ export type StreamStatGrade = '' | 'bad' | 'ok' | 'good';
type CurrentStats = { type CurrentStats = {
[StreamStat.PING]: { [StreamStat.PING]: {
current: number; current: number;
calculateGrade: () => StreamStatGrade; grades: [number, number, number];
toString: () => string;
};
[StreamStat.JITTER]: {
current: number;
grades: [number, number, number];
toString: () => string; toString: () => string;
}; };
@ -50,7 +57,7 @@ type CurrentStats = {
[StreamStat.DECODE_TIME]: { [StreamStat.DECODE_TIME]: {
current: number; current: number;
total: number; total: number;
calculateGrade: () => StreamStatGrade; grades: [number, number, number];
toString: () => string; toString: () => string;
}; };
@ -96,17 +103,27 @@ export class StreamStatsCollector {
// Collect in background - 60 seconds // Collect in background - 60 seconds
static readonly INTERVAL_BACKGROUND = 60 * 1000; static readonly INTERVAL_BACKGROUND = 60 * 1000;
public calculateGrade(value: number, grades: [number, number, number]): StreamStatGrade {
return (value > grades[2]) ? 'bad' : (value > grades[1]) ? 'ok' : (value > grades[0]) ? 'good' : '';
}
private currentStats: CurrentStats = { private currentStats: CurrentStats = {
[StreamStat.PING]: { [StreamStat.PING]: {
current: -1, current: -1,
calculateGrade() { grades: [40, 75, 100],
return (this.current >= 100) ? 'bad' : (this.current > 75) ? 'ok' : (this.current > 40) ? 'good' : '';
},
toString() { toString() {
return this.current === -1 ? '???' : this.current.toString(); return this.current === -1 ? '???' : this.current.toString();
}, },
}, },
[StreamStat.JITTER]: {
current: 0,
grades: [30, 40, 60],
toString() {
return `${this.current.toFixed(2)}ms`;
},
},
[StreamStat.FPS]: { [StreamStat.FPS]: {
current: 0, current: 0,
toString() { toString() {
@ -142,9 +159,7 @@ export class StreamStatsCollector {
[StreamStat.DECODE_TIME]: { [StreamStat.DECODE_TIME]: {
current: 0, current: 0,
total: 0, total: 0,
calculateGrade() { grades: [6, 9, 12],
return (this.current > 12) ? 'bad' : (this.current > 9) ? 'ok' : (this.current > 6) ? 'good' : '';
},
toString() { toString() {
return isNaN(this.current) ? '??ms' : `${this.current.toFixed(2)}ms`; return isNaN(this.current) ? '??ms' : `${this.current.toFixed(2)}ms`;
}, },
@ -200,12 +215,15 @@ export class StreamStatsCollector {
}, },
}; };
private lastVideoStat?: RTCBasicStat | null; private lastVideoStat?: RTCInboundRtpStreamStats | null;
async collect() { async collect() {
const stats = await STATES.currentStream.peerConnection?.getStats(); const stats = await STATES.currentStream.peerConnection?.getStats();
if (!stats) {
return;
}
stats?.forEach(stat => { stats.forEach(stat => {
if (stat.type === 'inbound-rtp' && stat.kind === 'video') { if (stat.type === 'inbound-rtp' && stat.kind === 'video') {
// FPS // FPS
const fps = this.currentStats[StreamStat.FPS]; const fps = this.currentStats[StreamStat.FPS];
@ -229,15 +247,23 @@ export class StreamStatsCollector {
const lastStat = this.lastVideoStat; const lastStat = this.lastVideoStat;
// Jitter
const jit = this.currentStats[StreamStat.JITTER];
const bufferDelayDiff = (stat as RTCInboundRtpStreamStats).jitterBufferDelay! - lastStat.jitterBufferDelay!;
const emittedCountDiff = (stat as RTCInboundRtpStreamStats).jitterBufferEmittedCount! - lastStat.jitterBufferEmittedCount!;
if (emittedCountDiff > 0) {
jit.current = bufferDelayDiff / emittedCountDiff * 1000;
}
// Bitrate // Bitrate
const btr = this.currentStats[StreamStat.BITRATE]; const btr = this.currentStats[StreamStat.BITRATE];
const timeDiff = stat.timestamp - lastStat.timestamp; const timeDiff = stat.timestamp - lastStat.timestamp;
btr.current = 8 * (stat.bytesReceived - lastStat.bytesReceived) / timeDiff / 1000; btr.current = 8 * (stat.bytesReceived - lastStat.bytesReceived!) / timeDiff / 1000;
// Decode time // Decode time
const dt = this.currentStats[StreamStat.DECODE_TIME]; const dt = this.currentStats[StreamStat.DECODE_TIME];
dt.total = stat.totalDecodeTime - lastStat.totalDecodeTime; dt.total = stat.totalDecodeTime - lastStat.totalDecodeTime!;
const framesDecodedDiff = stat.framesDecoded - lastStat.framesDecoded; const framesDecodedDiff = stat.framesDecoded - lastStat.framesDecoded!;
dt.current = dt.total / framesDecodedDiff * 1000; dt.current = dt.total / framesDecodedDiff * 1000;
this.lastVideoStat = stat; this.lastVideoStat = stat;

View File

@ -131,6 +131,7 @@ const Texts = {
"increase": "Increase", "increase": "Increase",
"install-android": "Better xCloud app for Android", "install-android": "Better xCloud app for Android",
"japan": "Japan", "japan": "Japan",
"jitter": "Jitter",
"keyboard-shortcuts": "Keyboard shortcuts", "keyboard-shortcuts": "Keyboard shortcuts",
"korea": "Korea", "korea": "Korea",
"language": "Language", "language": "Language",