From fd665b6fcd0119b2cec9b020a09be8c48af712dc Mon Sep 17 00:00:00 2001 From: redphx <96280+redphx@users.noreply.github.com> Date: Sun, 2 Feb 2025 21:37:21 +0700 Subject: [PATCH] Add WebGPU renderer (#648) --- build.sh | 2 +- bun.lock | 9 +- dist/better-xcloud.pretty.user.js | 585 ++++++++++++------ dist/better-xcloud.user.js | 4 +- package.json | 1 + src/enums/pref-values.ts | 1 + src/index.ts | 5 +- src/modules/player/base-canvas-player.ts | 119 ++++ src/modules/player/base-stream-player.ts | 48 ++ src/modules/player/video/video-player.ts | 102 +++ src/modules/player/webgl2-player.ts | 268 -------- .../shaders/clarity-boost.fs} | 4 +- .../shaders/clarity-boost.vert} | 0 src/modules/player/webgl2/webgl2-player.ts | 141 +++++ .../player/webgpu/shaders/clarity-boost.wgsl | 93 +++ src/modules/player/webgpu/webgpu-player.ts | 186 ++++++ src/modules/settings-manager.ts | 3 +- src/modules/stream-player-manager.ts | 191 ++++++ src/modules/stream-player.ts | 297 --------- src/modules/stream/stream-settings-utils.ts | 33 +- src/types/index.d.ts | 60 +- src/types/states.d.ts | 53 ++ src/types/stream.d.ts | 8 +- src/utils/bx-event-bus.ts | 2 + src/utils/feature-gates.ts | 1 + src/utils/global.ts | 1 + src/utils/monkey-patches.ts | 12 +- src/utils/screenshot-manager.ts | 24 +- .../stream-settings-storage.ts | 12 + src/utils/translation.ts | 1 + tsconfig.json | 3 + 31 files changed, 1428 insertions(+), 841 deletions(-) create mode 100644 src/modules/player/base-canvas-player.ts create mode 100644 src/modules/player/base-stream-player.ts create mode 100644 src/modules/player/video/video-player.ts delete mode 100755 src/modules/player/webgl2-player.ts rename src/modules/player/{shaders/clarity_boost.fs => webgl2/shaders/clarity-boost.fs} (95%) rename src/modules/player/{shaders/clarity_boost.vert => webgl2/shaders/clarity-boost.vert} (100%) create mode 100755 src/modules/player/webgl2/webgl2-player.ts create mode 100644 src/modules/player/webgpu/shaders/clarity-boost.wgsl create mode 100644 src/modules/player/webgpu/webgpu-player.ts create mode 100755 src/modules/stream-player-manager.ts delete mode 100755 src/modules/stream-player.ts create mode 100644 src/types/states.d.ts diff --git a/build.sh b/build.sh index 2c767c9..68f9ce7 100755 --- a/build.sh +++ b/build.sh @@ -5,8 +5,8 @@ build_all () { printf "\033c" # Build all variants - bun build.ts --version $1 --variant full --meta bun build.ts --version $1 --variant full --pretty + bun build.ts --version $1 --variant full --meta # bun build.ts --version $1 --variant lite # Wait for key diff --git a/bun.lock b/bun.lock index 4bbb893..47bbd22 100644 --- a/bun.lock +++ b/bun.lock @@ -3,10 +3,11 @@ "workspaces": { "": { "devDependencies": { - "@types/bun": "^1.1.14", - "@types/node": "^22.10.2", + "@types/bun": "^1.2.0", + "@types/node": "^22.10.10", "@types/stylus": "^0.48.43", - "eslint": "^9.17.0", + "@webgpu/types": "^0.1.53", + "eslint": "^9.19.0", "eslint-plugin-compat": "^6.0.2", "stylus": "^0.64.0", }, @@ -60,6 +61,8 @@ "@types/ws": ["@types/ws@8.5.10", "", { "dependencies": { "@types/node": "*" } }, "sha512-vmQSUcfalpIq0R9q7uTo2lXs6eGIpt9wtnLdMv9LVpIjCA/+ufZRozlVoVelIYixx1ugCBKDhn89vnsEGOCx9A=="], + "@webgpu/types": ["@webgpu/types@0.1.53", "", {}, "sha512-x+BLw/opaz9LiVyrMsP75nO1Rg0QfrACUYIbVSfGwY/w0DiWIPYYrpte6us//KZXinxFAOJl0+C17L1Vi2vmDw=="], + "acorn": ["acorn@8.14.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA=="], "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], diff --git a/dist/better-xcloud.pretty.user.js b/dist/better-xcloud.pretty.user.js index 021a18e..be3bba8 100644 --- a/dist/better-xcloud.pretty.user.js +++ b/dist/better-xcloud.pretty.user.js @@ -1,7 +1,7 @@ // ==UserScript== // @name Better xCloud // @namespace https://github.com/redphx -// @version 6.3.2-beta +// @version 6.4.0-beta // @description Improve Xbox Cloud Gaming (xCloud) experience // @author redphx // @license MIT @@ -190,7 +190,7 @@ class UserAgent { }); } } -var SCRIPT_VERSION = "6.3.2-beta", SCRIPT_VARIANT = "full", AppInterface = window.AppInterface; +var SCRIPT_VERSION = "6.4.0-beta", SCRIPT_VARIANT = "full", AppInterface = window.AppInterface; UserAgent.init(); var userAgent = window.navigator.userAgent.toLowerCase(), isTv = userAgent.includes("smart-tv") || userAgent.includes("smarttv") || /\baft.*\b/.test(userAgent), isVr = window.navigator.userAgent.includes("VR") && window.navigator.userAgent.includes("OculusBrowser"), browserHasTouchSupport = "ontouchstart" in window || navigator.maxTouchPoints > 0, userAgentHasTouchSupport = !isTv && !isVr && browserHasTouchSupport, STATES = { supportedRegion: !0, @@ -398,6 +398,7 @@ var SUPPORTED_LANGUAGES = { "zh-CN": "中文(简体)", "zh-TW": "中文(繁體)" }, Texts = { + webgpu: "WebGPU", achievements: "Achievements", activate: "Activate", activated: "Activated", @@ -2033,6 +2034,266 @@ class ControllerShortcutsTable extends BasePresetsTable { BxLogger.info(this.LOG_TAG, "constructor()"); } } +var clarity_boost_default = `struct Params { +filterId: f32, +sharpness: f32, +brightness: f32, +contrast: f32, +saturation: f32, +}; +struct VertexOutput { +@builtin(position) position: vec4, +@location(0) uv: vec2, +}; +@group(0) @binding(0) var ourSampler: sampler; +@group(0) @binding(1) var ourTexture: texture_external; +@group(0) @binding(2) var ourParams: Params; +const FILTER_UNSHARP_MASKING: f32 = 1.0; +const CAS_CONTRAST_PEAK: f32 = 0.8 * -3.0 + 8.0; +const LUMINOSITY_FACTOR = vec3(0.299, 0.587, 0.114); +@vertex +fn vsMain(@location(0) pos: vec2) -> VertexOutput { +var out: VertexOutput; +out.position = vec4(pos, 0.0, 1.0); +out.uv = (vec2(pos.x, 1.0 - (pos.y + 1.0)) + vec2(1.0, 1.0)) * 0.5; +return out; +} +fn clarityBoost(coord: vec2, texSize: vec2, e: vec3) -> vec3 { +let texelSize = 1.0 / texSize; +let a = textureSampleBaseClampToEdge(ourTexture, ourSampler, coord + texelSize * vec2(-1.0, 1.0)).rgb; +let b = textureSampleBaseClampToEdge(ourTexture, ourSampler, coord + texelSize * vec2( 0.0, 1.0)).rgb; +let c = textureSampleBaseClampToEdge(ourTexture, ourSampler, coord + texelSize * vec2( 1.0, 1.0)).rgb; +let d = textureSampleBaseClampToEdge(ourTexture, ourSampler, coord + texelSize * vec2(-1.0, 0.0)).rgb; +let f = textureSampleBaseClampToEdge(ourTexture, ourSampler, coord + texelSize * vec2( 1.0, 0.0)).rgb; +let g = textureSampleBaseClampToEdge(ourTexture, ourSampler, coord + texelSize * vec2(-1.0, -1.0)).rgb; +let h = textureSampleBaseClampToEdge(ourTexture, ourSampler, coord + texelSize * vec2( 0.0, -1.0)).rgb; +let i = textureSampleBaseClampToEdge(ourTexture, ourSampler, coord + texelSize * vec2( 1.0, -1.0)).rgb; +if ourParams.filterId == FILTER_UNSHARP_MASKING { +let gaussianBlur = (a + c + g + i) * 1.0 + (b + d + f + h) * 2.0 + e * 4.0; +let blurred = gaussianBlur / 16.0; +return e + (e - blurred) * (ourParams.sharpness / 3.0); +} +let minRgb = min(min(min(d, e), min(f, b)), h) + min(min(a, c), min(g, i)); +let maxRgb = max(max(max(d, e), max(f, b)), h) + max(max(a, c), max(g, i)); +let reciprocalMaxRgb = 1.0 / maxRgb; +var amplifyRgb = clamp(min(minRgb, 2.0 - maxRgb) * reciprocalMaxRgb, vec3(0.0), vec3(1.0)); +amplifyRgb = 1.0 / sqrt(amplifyRgb); +let weightRgb = -(1.0 / (amplifyRgb * CAS_CONTRAST_PEAK)); +let reciprocalWeightRgb = 1.0 / (4.0 * weightRgb + 1.0); +let window = b + d + f + h; +let outColor = clamp((window * weightRgb + e) * reciprocalWeightRgb, vec3(0.0), vec3(1.0)); +return mix(e, outColor, ourParams.sharpness / 2.0); +} +@fragment +fn fsMain(input: VertexOutput) -> @location(0) vec4 { +let texSize = vec2(textureDimensions(ourTexture)); +let center = textureSampleBaseClampToEdge(ourTexture, ourSampler, input.uv); +var adjustedRgb = clarityBoost(input.uv, texSize, center.rgb); +let gray = dot(adjustedRgb, LUMINOSITY_FACTOR); +adjustedRgb = mix(vec3(gray), adjustedRgb, ourParams.saturation); +adjustedRgb = (adjustedRgb - 0.5) * ourParams.contrast + 0.5; +adjustedRgb *= ourParams.brightness; +return vec4(adjustedRgb, 1.0); +}`; +class BaseStreamPlayer { + logTag; + playerType; + elementType; + $video; + options = { + processing: "usm", + sharpness: 0, + brightness: 1, + contrast: 1, + saturation: 1 + }; + isStopped = !1; + constructor(playerType, elementType, $video, logTag) { + this.playerType = playerType, this.elementType = elementType, this.$video = $video, this.logTag = logTag; + } + init() { + BxLogger.info(this.logTag, "Initialize"); + } + updateOptions(newOptions, refresh = !1) { + this.options = Object.assign(this.options, newOptions), refresh && this.refreshPlayer(); + } +} +class BaseCanvasPlayer extends BaseStreamPlayer { + $canvas; + targetFps = 60; + frameInterval = 0; + lastFrameTime = 0; + animFrameId = null; + frameCallback; + boundDrawFrame; + constructor(playerType, $video, logTag) { + super(playerType, "canvas", $video, logTag); + let $canvas = document.createElement("canvas"); + $canvas.width = $video.videoWidth, $canvas.height = $video.videoHeight, this.$canvas = $canvas, $video.insertAdjacentElement("afterend", this.$canvas); + let frameCallback; + if ("requestVideoFrameCallback" in HTMLVideoElement.prototype) { + let $video2 = this.$video; + frameCallback = $video2.requestVideoFrameCallback.bind($video2); + } else frameCallback = requestAnimationFrame; + this.frameCallback = frameCallback, this.boundDrawFrame = this.drawFrame.bind(this); + } + async init() { + super.init(), await this.setupShaders(), this.setupRendering(); + } + setTargetFps(target) { + this.targetFps = target, this.lastFrameTime = 0, this.frameInterval = target ? Math.floor(1000 / target) : 0; + } + getCanvas() { + return this.$canvas; + } + destroy() { + if (BxLogger.info(this.logTag, "Destroy"), this.isStopped = !0, this.animFrameId) { + if ("requestVideoFrameCallback" in HTMLVideoElement.prototype) this.$video.cancelVideoFrameCallback(this.animFrameId); + else cancelAnimationFrame(this.animFrameId); + this.animFrameId = null; + } + if (this.$canvas.isConnected) this.$canvas.remove(); + this.$canvas.width = 1, this.$canvas.height = 1; + } + toFilterId(processing) { + return processing === "cas" ? 2 : 1; + } + shouldDraw() { + if (this.targetFps >= 60) return !0; + else if (this.targetFps === 0) return !1; + let currentTime = performance.now(); + if (currentTime - this.lastFrameTime < this.frameInterval) return !1; + return this.lastFrameTime = currentTime, !0; + } + drawFrame() { + if (this.isStopped) return; + if (this.animFrameId = this.frameCallback(this.boundDrawFrame), !this.shouldDraw()) return; + this.updateFrame(); + } + setupRendering() { + this.animFrameId = this.frameCallback(this.boundDrawFrame); + } +} +class WebGPUPlayer extends BaseCanvasPlayer { + static device; + context; + pipeline; + sampler; + bindGroup; + optionsUpdated = !1; + paramsBuffer; + vertexBuffer; + static async prepare() { + if (!navigator.gpu) { + BxEventBus.Script.emit("webgpu.ready", {}); + return; + } + try { + let adapter = await navigator.gpu.requestAdapter(); + if (adapter) WebGPUPlayer.device = await adapter.requestDevice(), WebGPUPlayer.device?.addEventListener("uncapturederror", (e) => { + console.error(e.error.message); + }); + } catch (ex) { + alert(ex); + } + BxEventBus.Script.emit("webgpu.ready", {}); + } + constructor($video) { + super("webgpu", $video, "WebGPUPlayer"); + } + setupShaders() { + if (this.context = this.$canvas.getContext("webgpu"), !this.context) { + alert("Can't initiate context"); + return; + } + let format = navigator.gpu.getPreferredCanvasFormat(); + this.context.configure({ + device: WebGPUPlayer.device, + format, + alphaMode: "opaque" + }), this.vertexBuffer = WebGPUPlayer.device.createBuffer({ + label: "vertex buffer", + size: 24, + usage: GPUBufferUsage.VERTEX, + mappedAtCreation: !0 + }); + let mappedRange = this.vertexBuffer.getMappedRange(); + new Float32Array(mappedRange).set([ + -1, + 3, + -1, + -1, + 3, + -1 + ]), this.vertexBuffer.unmap(); + let shaderModule = WebGPUPlayer.device.createShaderModule({ code: clarity_boost_default }); + this.pipeline = WebGPUPlayer.device.createRenderPipeline({ + layout: "auto", + vertex: { + module: shaderModule, + entryPoint: "vsMain", + buffers: [{ + arrayStride: 8, + attributes: [{ + format: "float32x2", + offset: 0, + shaderLocation: 0 + }] + }] + }, + fragment: { + module: shaderModule, + entryPoint: "fsMain", + targets: [{ format }] + }, + primitive: { topology: "triangle-list" } + }), this.sampler = WebGPUPlayer.device.createSampler({ magFilter: "linear", minFilter: "linear" }), this.updateCanvas(); + } + prepareUniformBuffer(value, classType) { + let uniform = new classType(value), uniformBuffer = WebGPUPlayer.device.createBuffer({ + size: uniform.byteLength, + usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST + }); + return WebGPUPlayer.device.queue.writeBuffer(uniformBuffer, 0, uniform), uniformBuffer; + } + updateCanvas() { + let externalTexture = WebGPUPlayer.device.importExternalTexture({ source: this.$video }); + if (!this.optionsUpdated) this.paramsBuffer = this.prepareUniformBuffer([ + this.toFilterId(this.options.processing), + this.options.sharpness, + this.options.brightness / 100, + this.options.contrast / 100, + this.options.saturation / 100 + ], Float32Array), this.optionsUpdated = !0; + this.bindGroup = WebGPUPlayer.device.createBindGroup({ + layout: this.pipeline.getBindGroupLayout(0), + entries: [ + { binding: 0, resource: this.sampler }, + { binding: 1, resource: externalTexture }, + { binding: 2, resource: { buffer: this.paramsBuffer } } + ] + }); + } + updateFrame() { + this.updateCanvas(); + let commandEncoder = WebGPUPlayer.device.createCommandEncoder(), passEncoder = commandEncoder.beginRenderPass({ + colorAttachments: [{ + view: this.context.getCurrentTexture().createView(), + loadOp: "clear", + storeOp: "store", + clearValue: [0, 0, 0, 1] + }] + }); + passEncoder.setPipeline(this.pipeline), passEncoder.setBindGroup(0, this.bindGroup), passEncoder.setVertexBuffer(0, this.vertexBuffer), passEncoder.draw(3), passEncoder.end(), WebGPUPlayer.device.queue.submit([commandEncoder.finish()]); + } + refreshPlayer() { + this.optionsUpdated = !1, this.updateCanvas(); + } + destroy() { + if (super.destroy(), this.isStopped = !0, this.pipeline = null, this.bindGroup = null, this.sampler = null, this.paramsBuffer?.destroy(), this.paramsBuffer = null, this.vertexBuffer?.destroy(), this.vertexBuffer = null, this.context) this.context.unconfigure(), this.context = null; + console.log("WebGPU context successfully freed."); + } +} class StreamSettingsStorage extends BaseSettingsStorage { static DEFINITIONS = { "deviceVibration.mode": { @@ -2146,11 +2407,17 @@ class StreamSettingsStorage extends BaseSettingsStorage { default: "default", options: { default: t("default"), - webgl2: t("webgl2") + webgl2: t("webgl2"), + webgpu: `${t("webgpu")} (${t("experimental")})` }, suggest: { lowest: "default", highest: "webgl2" + }, + ready: (setting) => { + BxEventBus.Script.on("webgpu.ready", () => { + if (!navigator.gpu || !WebGPUPlayer.device) delete setting.options["webgpu"]; + }); } }, "video.processing": { @@ -4187,9 +4454,8 @@ class SettingsManager { }, "video.player.powerPreference": { onChange: () => { - let streamPlayer = STATES.currentStream.streamPlayer; - if (!streamPlayer) return; - streamPlayer.reloadPlayer(), updateVideoPlayer(); + if (!STATES.currentStream.streamPlayerManager) return; + updateVideoPlayer(); } }, "video.processing": { @@ -4347,17 +4613,17 @@ function onChangeVideoPlayerType() { let playerType = getStreamPref("video.player.type"), settingsManager = SettingsManager.getInstance(); if (!settingsManager.hasElement("video.processing")) return; let isDisabled = !1, $videoProcessing = settingsManager.getElement("video.processing"), $videoSharpness = settingsManager.getElement("video.processing.sharpness"), $videoPowerPreference = settingsManager.getElement("video.player.powerPreference"), $videoMaxFps = settingsManager.getElement("video.maxFps"), $optCas = $videoProcessing.querySelector(`option[value=${"cas"}]`); - if (playerType === "webgl2") $optCas && ($optCas.disabled = !1); - else if ($videoProcessing.value = "usm", setStreamPref("video.processing", "usm", "direct"), $optCas && ($optCas.disabled = !0), UserAgent.isSafari()) isDisabled = !0; - $videoProcessing.disabled = isDisabled, $videoSharpness.dataset.disabled = isDisabled.toString(), $videoPowerPreference.closest(".bx-settings-row").classList.toggle("bx-gone", playerType !== "webgl2"), $videoMaxFps.closest(".bx-settings-row").classList.toggle("bx-gone", playerType !== "webgl2"); + if (playerType === "default") { + if ($videoProcessing.value = "usm", setStreamPref("video.processing", "usm", "direct"), $optCas && ($optCas.disabled = !0), UserAgent.isSafari()) isDisabled = !0; + } else $optCas && ($optCas.disabled = !1); + $videoProcessing.disabled = isDisabled, $videoSharpness.dataset.disabled = isDisabled.toString(), $videoPowerPreference.closest(".bx-settings-row").classList.toggle("bx-gone", playerType !== "webgl2"), $videoMaxFps.closest(".bx-settings-row").classList.toggle("bx-gone", playerType === "default"); } function limitVideoPlayerFps(targetFps) { - STATES.currentStream.streamPlayer?.getWebGL2Player()?.setTargetFps(targetFps); + STATES.currentStream.streamPlayerManager?.getCanvasPlayer()?.setTargetFps(targetFps); } function updateVideoPlayer() { - let streamPlayer = STATES.currentStream.streamPlayer; - if (!streamPlayer) return; - limitVideoPlayerFps(getStreamPref("video.maxFps")); + let streamPlayerManager = STATES.currentStream.streamPlayerManager; + if (!streamPlayerManager) return; let options = { processing: getStreamPref("video.processing"), sharpness: getStreamPref("video.processing.sharpness"), @@ -4365,9 +4631,12 @@ function updateVideoPlayer() { contrast: getStreamPref("video.contrast"), brightness: getStreamPref("video.brightness") }; - streamPlayer.setPlayerType(getStreamPref("video.player.type")), streamPlayer.updateOptions(options), streamPlayer.refreshPlayer(); + streamPlayerManager.switchPlayerType(getStreamPref("video.player.type")), limitVideoPlayerFps(getStreamPref("video.maxFps")), streamPlayerManager.updateOptions(options), streamPlayerManager.refreshPlayer(); } -window.addEventListener("resize", updateVideoPlayer); +function resizeVideoPlayer() { + STATES.currentStream.streamPlayerManager?.resizePlayer(); +} +window.addEventListener("resize", resizeVideoPlayer); class NavigationDialog { dialogManager; onMountedCallbacks = []; @@ -7680,17 +7949,18 @@ class ScreenshotManager { e.target.classList.remove("bx-taking-screenshot"); } takeScreenshot(callback) { - let currentStream = STATES.currentStream, streamPlayer = currentStream.streamPlayer, $canvas = this.$canvas; - if (!streamPlayer || !$canvas) return; + let currentStream = STATES.currentStream, streamPlayerManager = currentStream.streamPlayerManager, $canvas = this.$canvas; + if (!streamPlayerManager || !$canvas) return; let $player; - if (getGlobalPref("screenshot.applyFilters")) $player = streamPlayer.getPlayerElement(); - else $player = streamPlayer.getPlayerElement("default"); + if (getGlobalPref("screenshot.applyFilters")) $player = streamPlayerManager.getPlayerElement(); + else $player = streamPlayerManager.getPlayerElement("video"); if (!$player || !$player.isConnected) return; + let canvasContext = this.canvasContext; + if ($player instanceof HTMLCanvasElement) streamPlayerManager.getCanvasPlayer()?.updateFrame(); + canvasContext.drawImage($player, 0, 0); let $gameStream = $player.closest("#game-stream"); if ($gameStream) $gameStream.addEventListener("animationend", this.onAnimationEnd, { once: !0 }), $gameStream.classList.add("bx-taking-screenshot"); - let canvasContext = this.canvasContext; - if ($player instanceof HTMLCanvasElement) streamPlayer.getWebGL2Player().forceDrawFrame(); - if (canvasContext.drawImage($player, 0, 0, $canvas.width, $canvas.height), AppInterface) { + if (AppInterface) { let data = $canvas.toDataURL("image/png").split(";base64,")[1]; AppInterface.saveScreenshot(currentStream.titleSlug, data), canvasContext.clearRect(0, 0, $canvas.width, $canvas.height), callback && callback(); return; @@ -7885,7 +8155,8 @@ var FeatureGates = { EnableWifiWarnings: !1, EnableUpdateRequiredPage: !1, ShowForcedUpdateScreen: !1, - EnableTakControlResizing: !0 + EnableTakControlResizing: !0, + EnableLazyLoadedHome: !1 }, nativeMkbMode = getGlobalPref("nativeMkb.mode"); if (nativeMkbMode !== "default") FeatureGates.EnableMouseAndKeyboard = nativeMkbMode === "on"; var blockFeatures = getGlobalPref("block.features"); @@ -9128,18 +9399,18 @@ function patchSdpBitrate(sdp, video, audio) { return lines.join(`\r `); } -var clarity_boost_default = `#version 300 es +var clarity_boost_default2 = `#version 300 es in vec4 position; void main() { gl_Position = position; }`; -var clarity_boost_default2 = `#version 300 es +var clarity_boost_default3 = `#version 300 es precision mediump float; uniform sampler2D data; uniform vec2 iResolution; const int FILTER_UNSHARP_MASKING = 1; const float CAS_CONTRAST_PEAK = 0.8 * -3.0 + 8.0; -const vec3 LUMINOSITY_FACTOR = vec3(0.2126, 0.7152, 0.0722); +const vec3 LUMINOSITY_FACTOR = vec3(0.299, 0.587, 0.114); uniform int filterId; uniform float sharpenFactor; uniform float brightness; @@ -9183,156 +9454,105 @@ color = contrast * (color - 0.5) + 0.5; color = brightness * color; fragColor = vec4(color, 1.0); }`; -class WebGL2Player { - LOG_TAG = "WebGL2Player"; - $video; - $canvas; +class WebGL2Player extends BaseCanvasPlayer { gl = null; resources = []; program = null; - stopped = !1; - options = { - filterId: 1, - sharpenFactor: 0, - brightness: 0, - contrast: 0, - saturation: 0 - }; - targetFps = 60; - frameInterval = 0; - lastFrameTime = 0; - animFrameId = null; constructor($video) { - BxLogger.info(this.LOG_TAG, "Initialize"), this.$video = $video; - let $canvas = document.createElement("canvas"); - $canvas.width = $video.videoWidth, $canvas.height = $video.videoHeight, this.$canvas = $canvas, this.setupShaders(), this.setupRendering(), $video.insertAdjacentElement("afterend", $canvas); - } - setFilter(filterId, update = !0) { - this.options.filterId = filterId, update && this.updateCanvas(); - } - setSharpness(sharpness, update = !0) { - this.options.sharpenFactor = sharpness, update && this.updateCanvas(); - } - setBrightness(brightness, update = !0) { - this.options.brightness = 1 + (brightness - 100) / 100, update && this.updateCanvas(); - } - setContrast(contrast, update = !0) { - this.options.contrast = 1 + (contrast - 100) / 100, update && this.updateCanvas(); - } - setSaturation(saturation, update = !0) { - this.options.saturation = 1 + (saturation - 100) / 100, update && this.updateCanvas(); - } - setTargetFps(target) { - this.targetFps = target, this.lastFrameTime = 0, this.frameInterval = target ? Math.floor(1000 / target) : 0; - } - getCanvas() { - return this.$canvas; + super("webgl2", $video, "WebGL2Player"); } updateCanvas() { - let gl = this.gl, program = this.program; - gl.uniform2f(gl.getUniformLocation(program, "iResolution"), this.$canvas.width, this.$canvas.height), gl.uniform1i(gl.getUniformLocation(program, "filterId"), this.options.filterId), gl.uniform1f(gl.getUniformLocation(program, "sharpenFactor"), this.options.sharpenFactor), gl.uniform1f(gl.getUniformLocation(program, "brightness"), this.options.brightness), gl.uniform1f(gl.getUniformLocation(program, "contrast"), this.options.contrast), gl.uniform1f(gl.getUniformLocation(program, "saturation"), this.options.saturation); + console.log("updateCanvas", this.options); + let gl = this.gl, program = this.program, filterId = this.toFilterId(this.options.processing); + gl.uniform2f(gl.getUniformLocation(program, "iResolution"), this.$canvas.width, this.$canvas.height), gl.uniform1i(gl.getUniformLocation(program, "filterId"), filterId), gl.uniform1f(gl.getUniformLocation(program, "sharpenFactor"), this.options.sharpness), gl.uniform1f(gl.getUniformLocation(program, "brightness"), this.options.brightness / 100), gl.uniform1f(gl.getUniformLocation(program, "contrast"), this.options.contrast / 100), gl.uniform1f(gl.getUniformLocation(program, "saturation"), this.options.saturation / 100); } - forceDrawFrame() { + updateFrame() { let gl = this.gl; - gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGB, gl.RGB, gl.UNSIGNED_BYTE, this.$video), gl.drawArrays(gl.TRIANGLES, 0, 6); + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGB, gl.RGB, gl.UNSIGNED_BYTE, this.$video), gl.drawArrays(gl.TRIANGLES, 0, 3); } - setupRendering() { - let frameCallback; - if ("requestVideoFrameCallback" in HTMLVideoElement.prototype) { - let $video = this.$video; - frameCallback = $video.requestVideoFrameCallback.bind($video); - } else frameCallback = requestAnimationFrame; - let animate = () => { - if (this.stopped) return; - this.animFrameId = frameCallback(animate); - let draw = !0; - if (this.targetFps === 0) draw = !1; - else if (this.targetFps < 60) { - let currentTime = performance.now(); - if (currentTime - this.lastFrameTime < this.frameInterval) draw = !1; - else this.lastFrameTime = currentTime; - } - if (draw) { - let gl = this.gl; - gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGB, gl.RGB, gl.UNSIGNED_BYTE, this.$video), gl.drawArrays(gl.TRIANGLES, 0, 6); - } - }; - this.animFrameId = frameCallback(animate); - } - setupShaders() { - BxLogger.info(this.LOG_TAG, "Setting up", getStreamPref("video.player.powerPreference")); + async setupShaders() { let gl = this.$canvas.getContext("webgl2", { isBx: !0, antialias: !0, alpha: !1, + depth: !1, + preserveDrawingBuffer: !1, + stencil: !1, powerPreference: getStreamPref("video.player.powerPreference") }); this.gl = gl, gl.viewport(0, 0, gl.drawingBufferWidth, gl.drawingBufferWidth); let vShader = gl.createShader(gl.VERTEX_SHADER); - gl.shaderSource(vShader, clarity_boost_default), gl.compileShader(vShader); + gl.shaderSource(vShader, clarity_boost_default2), gl.compileShader(vShader); let fShader = gl.createShader(gl.FRAGMENT_SHADER); - gl.shaderSource(fShader, clarity_boost_default2), gl.compileShader(fShader); + gl.shaderSource(fShader, clarity_boost_default3), gl.compileShader(fShader); let program = gl.createProgram(); if (this.program = program, gl.attachShader(program, vShader), gl.attachShader(program, fShader), gl.linkProgram(program), gl.useProgram(program), !gl.getProgramParameter(program, gl.LINK_STATUS)) console.error(`Link failed: ${gl.getProgramInfoLog(program)}`), console.error(`vs info-log: ${gl.getShaderInfoLog(vShader)}`), console.error(`fs info-log: ${gl.getShaderInfoLog(fShader)}`); this.updateCanvas(); let buffer = gl.createBuffer(); - this.resources.push(buffer), gl.bindBuffer(gl.ARRAY_BUFFER, buffer), gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1]), gl.STATIC_DRAW), gl.enableVertexAttribArray(0), gl.vertexAttribPointer(0, 2, gl.FLOAT, !1, 0, 0); + this.resources.push(buffer), gl.bindBuffer(gl.ARRAY_BUFFER, buffer), gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([ + -1, + -1, + 3, + -1, + -1, + 3 + ]), gl.STATIC_DRAW), gl.enableVertexAttribArray(0), gl.vertexAttribPointer(0, 2, gl.FLOAT, !1, 0, 0); let texture = gl.createTexture(); this.resources.push(texture), gl.bindTexture(gl.TEXTURE_2D, texture), gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, !0), gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE), gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE), gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR), gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR), gl.uniform1i(gl.getUniformLocation(program, "data"), 0), gl.activeTexture(gl.TEXTURE0); } - resume() { - this.stop(), this.stopped = !1, BxLogger.info(this.LOG_TAG, "Resume"), this.$canvas.classList.remove("bx-gone"), this.setupRendering(); - } - stop() { - if (BxLogger.info(this.LOG_TAG, "Stop"), this.$canvas.classList.add("bx-gone"), this.stopped = !0, this.animFrameId) { - if ("requestVideoFrameCallback" in HTMLVideoElement.prototype) this.$video.cancelVideoFrameCallback(this.animFrameId); - else cancelAnimationFrame(this.animFrameId); - this.animFrameId = null; - } - } destroy() { - BxLogger.info(this.LOG_TAG, "Destroy"), this.stop(); + super.destroy(); let gl = this.gl; - if (gl) { - gl.getExtension("WEBGL_lose_context")?.loseContext(), gl.useProgram(null); - for (let resource of this.resources) - if (resource instanceof WebGLProgram) gl.deleteProgram(resource); - else if (resource instanceof WebGLShader) gl.deleteShader(resource); - else if (resource instanceof WebGLTexture) gl.deleteTexture(resource); - else if (resource instanceof WebGLBuffer) gl.deleteBuffer(resource); - this.gl = null; - } - if (this.$canvas.isConnected) this.$canvas.parentElement?.removeChild(this.$canvas); - this.$canvas.width = 1, this.$canvas.height = 1; + if (!gl) return; + gl.getExtension("WEBGL_lose_context")?.loseContext(), gl.useProgram(null); + for (let resource of this.resources) + if (resource instanceof WebGLProgram) gl.deleteProgram(resource); + else if (resource instanceof WebGLShader) gl.deleteShader(resource); + else if (resource instanceof WebGLTexture) gl.deleteTexture(resource); + else if (resource instanceof WebGLBuffer) gl.deleteBuffer(resource); + this.gl = null; + } + refreshPlayer() { + this.updateCanvas(); } } -class StreamPlayer { - $video; - playerType = "default"; - options = {}; - webGL2Player = null; - $videoCss = null; - $usmMatrix = null; - constructor($video, type, options) { - this.setupVideoElements(), this.$video = $video, this.options = options || {}, this.setPlayerType(type); +class VideoPlayer extends BaseStreamPlayer { + $videoCss; + $usmMatrix; + constructor($video, logTag) { + super("default", "video", $video, logTag); } - setupVideoElements() { - if (this.$videoCss = document.getElementById("bx-video-css"), this.$videoCss) return; - let $fragment = document.createDocumentFragment(); - this.$videoCss = CE("style", { id: "bx-video-css" }), $fragment.appendChild(this.$videoCss); - let $svg = CE("svg", { + init() { + super.init(); + let xmlns = "http://www.w3.org/2000/svg", $svg = CE("svg", { id: "bx-video-filters", - xmlns: "http://www.w3.org/2000/svg", - class: "bx-gone" + class: "bx-gone", + xmlns }, CE("defs", { xmlns: "http://www.w3.org/2000/svg" }, CE("filter", { id: "bx-filter-usm", - xmlns: "http://www.w3.org/2000/svg" + xmlns }, this.$usmMatrix = CE("feConvolveMatrix", { id: "bx-filter-usm-matrix", order: "3", - xmlns: "http://www.w3.org/2000/svg" + xmlns })))); - $fragment.appendChild($svg), document.documentElement.appendChild($fragment); + this.$videoCss = CE("style", { id: "bx-video-css" }); + let $fragment = document.createDocumentFragment(); + $fragment.append(this.$videoCss, $svg), document.documentElement.appendChild($fragment); + } + setupRendering() {} + forceDrawFrame() {} + updateCanvas() {} + refreshPlayer() { + let filters = this.getVideoPlayerFilterStyle(), videoCss = ""; + if (filters) videoCss += `filter: ${filters} !important;`; + if (getGlobalPref("screenshot.applyFilters")) ScreenshotManager.getInstance().updateCanvasFilters(filters); + let css = ""; + if (videoCss) css = `#game-stream video { ${videoCss} }`; + this.$videoCss.textContent = css; + } + clearFilters() { + this.$videoCss.textContent = ""; } getVideoPlayerFilterStyle() { let filters = [], sharpness = this.options.sharpness || 0; @@ -9348,10 +9568,20 @@ class StreamPlayer { if (brightness != 100) filters.push(`brightness(${brightness}%)`); return filters.join(" "); } +} +class StreamPlayerManager { + static instance; + static getInstance = () => StreamPlayerManager.instance ?? (StreamPlayerManager.instance = new StreamPlayerManager); + $video; + videoPlayer; + canvasPlayer; + playerType = "default"; + constructor() {} + setVideoElement($video) { + this.$video = $video, this.videoPlayer = new VideoPlayer($video, "VideoPlayer"), this.videoPlayer.init(); + } resizePlayer() { - let PREF_RATIO = getStreamPref("video.ratio"), $video = this.$video, isNativeTouchGame = STATES.currentStream.titleInfo?.details.hasNativeTouchSupport, $webGL2Canvas; - if (this.playerType == "webgl2") $webGL2Canvas = this.webGL2Player?.getCanvas(); - let targetWidth, targetHeight, targetObjectFit; + let PREF_RATIO = getStreamPref("video.ratio"), $video = this.$video, isNativeTouchGame = STATES.currentStream.titleInfo?.details.hasNativeTouchSupport, targetWidth, targetHeight, targetObjectFit; if (PREF_RATIO.includes(":")) { let tmp = PREF_RATIO.split(":"), videoRatio = parseFloat(tmp[0]) / parseFloat(tmp[1]), width = 0, height = 0, parentRect = $video.parentElement.getBoundingClientRect(); if (parentRect.width / parentRect.height > videoRatio) height = parentRect.height, width = height * videoRatio; @@ -9367,58 +9597,49 @@ class StreamPlayer { } targetWidth = `${width}px`, targetHeight = `${height}px`, targetObjectFit = PREF_RATIO === "16:9" ? "contain" : "fill"; } else targetWidth = "100%", targetHeight = "100%", targetObjectFit = PREF_RATIO, $video.dataset.width = window.innerWidth.toString(), $video.dataset.height = window.innerHeight.toString(); - if ($video.style.width = targetWidth, $video.style.height = targetHeight, $video.style.objectFit = targetObjectFit, $webGL2Canvas) $webGL2Canvas.style.width = targetWidth, $webGL2Canvas.style.height = targetHeight, $webGL2Canvas.style.objectFit = targetObjectFit, $video.dispatchEvent(new Event("resize")); - if (isNativeTouchGame && this.playerType == "webgl2") window.BX_EXPOSED.streamSession.updateDimensions(); + if ($video.style.width = targetWidth, $video.style.height = targetHeight, $video.style.objectFit = targetObjectFit, this.canvasPlayer) { + let $canvas = this.canvasPlayer.getCanvas(); + $canvas.style.width = targetWidth, $canvas.style.height = targetHeight, $canvas.style.objectFit = targetObjectFit, $video.dispatchEvent(new Event("resize")); + } + if (isNativeTouchGame && this.playerType !== "default") window.BX_EXPOSED.streamSession.updateDimensions(); } - setPlayerType(type, refreshPlayer = !1) { + switchPlayerType(type, refreshPlayer = !1) { if (this.playerType !== type) { let videoClass = BX_FLAGS.DeviceInfo.deviceType === "android-tv" ? "bx-pixel" : "bx-gone"; - if (type === "webgl2") { - if (!this.webGL2Player) this.webGL2Player = new WebGL2Player(this.$video); - else this.webGL2Player.resume(); - this.$videoCss.textContent = "", this.$video.classList.add(videoClass); - } else this.webGL2Player?.stop(), this.$video.classList.remove(videoClass); + if (this.cleanUpCanvasPlayer(), type === "default") this.$video.classList.remove(videoClass); + else { + if (type === "webgpu") this.canvasPlayer = new WebGPUPlayer(this.$video); + else this.canvasPlayer = new WebGL2Player(this.$video); + this.canvasPlayer.init(), this.videoPlayer.clearFilters(), this.$video.classList.add(videoClass); + } + this.playerType = type; } - this.playerType = type, refreshPlayer && this.refreshPlayer(); - } - setOptions(options, refreshPlayer = !1) { - this.options = options, refreshPlayer && this.refreshPlayer(); + refreshPlayer && this.refreshPlayer(); } updateOptions(options, refreshPlayer = !1) { - this.options = Object.assign(this.options, options), refreshPlayer && this.refreshPlayer(); + (this.canvasPlayer || this.videoPlayer).updateOptions(options, refreshPlayer); } - getPlayerElement(playerType) { - if (typeof playerType === "undefined") playerType = this.playerType; - if (playerType === "webgl2") return this.webGL2Player?.getCanvas(); + getPlayerElement(elementType) { + if (typeof elementType === "undefined") elementType = this.playerType === "default" ? "video" : "canvas"; + if (elementType !== "video") return this.canvasPlayer?.getCanvas(); return this.$video; } - getWebGL2Player() { - return this.webGL2Player; + getCanvasPlayer() { + return this.canvasPlayer; } refreshPlayer() { - if (this.playerType === "webgl2") { - let options = this.options, webGL2Player = this.webGL2Player; - if (options.processing === "usm") webGL2Player.setFilter(1); - else webGL2Player.setFilter(2); - ScreenshotManager.getInstance().updateCanvasFilters("none"), webGL2Player.setSharpness(options.sharpness || 0), webGL2Player.setSaturation(options.saturation || 100), webGL2Player.setContrast(options.contrast || 100), webGL2Player.setBrightness(options.brightness || 100); - } else { - let filters = this.getVideoPlayerFilterStyle(), videoCss = ""; - if (filters) videoCss += `filter: ${filters} !important;`; - if (getGlobalPref("screenshot.applyFilters")) ScreenshotManager.getInstance().updateCanvasFilters(filters); - let css = ""; - if (videoCss) css = `#game-stream video { ${videoCss} }`; - this.$videoCss.textContent = css; - } + if (this.playerType === "default") this.videoPlayer.refreshPlayer(); + else ScreenshotManager.getInstance().updateCanvasFilters("none"), this.canvasPlayer?.refreshPlayer(); this.resizePlayer(); } - reloadPlayer() { - this.cleanUpWebGL2Player(), this.playerType = "default", this.setPlayerType("webgl2", !1); + getVideoPlayerFilterStyle() { + throw new Error("Method not implemented."); } - cleanUpWebGL2Player() { - this.webGL2Player?.destroy(), this.webGL2Player = null; + cleanUpCanvasPlayer() { + this.canvasPlayer?.destroy(), this.canvasPlayer = null; } destroy() { - this.cleanUpWebGL2Player(); + this.cleanUpCanvasPlayer(); } } function patchVideoApi() { @@ -9430,8 +9651,8 @@ function patchVideoApi() { saturation: getStreamPref("video.saturation"), contrast: getStreamPref("video.contrast"), brightness: getStreamPref("video.brightness") - }; - STATES.currentStream.streamPlayer = new StreamPlayer(this, getStreamPref("video.player.type"), playerOptions), BxEventBus.Stream.emit("state.playing", { + }, streamPlayerManager = StreamPlayerManager.getInstance(); + streamPlayerManager.setVideoElement(this), streamPlayerManager.updateOptions(playerOptions, !1), streamPlayerManager.switchPlayerType(getStreamPref("video.player.type")), STATES.currentStream.streamPlayerManager = streamPlayerManager, BxEventBus.Stream.emit("state.playing", { $video: this }); }, nativePlay = HTMLMediaElement.prototype.play; @@ -10260,7 +10481,7 @@ BxEventBus.Stream.on("dataChannelCreated", (payload) => { }); function unload() { if (!STATES.isPlaying) return; - KeyboardShortcutHandler.getInstance().stop(), EmulatedMkbHandler.getInstance()?.destroy(), NativeMkbHandler.getInstance()?.destroy(), DeviceVibrationManager.getInstance()?.reset(), STATES.currentStream.streamPlayer?.destroy(), STATES.isPlaying = !1, STATES.currentStream = {}, window.BX_EXPOSED.shouldShowSensorControls = !1, window.BX_EXPOSED.stopTakRendering = !1, NavigationDialogManager.getInstance().hide(), StreamStats.getInstance().destroy(), StreamBadges.getInstance().destroy(), MouseCursorHider.getInstance()?.stop(), TouchController.reset(), GameBar.getInstance()?.disable(), BxEventBus.Stream.emit("xboxTitleId.changed", { id: -1 }); + KeyboardShortcutHandler.getInstance().stop(), EmulatedMkbHandler.getInstance()?.destroy(), NativeMkbHandler.getInstance()?.destroy(), DeviceVibrationManager.getInstance()?.reset(), STATES.currentStream.streamPlayerManager?.destroy(), STATES.isPlaying = !1, STATES.currentStream = {}, window.BX_EXPOSED.shouldShowSensorControls = !1, window.BX_EXPOSED.stopTakRendering = !1, NavigationDialogManager.getInstance().hide(), StreamStats.getInstance().destroy(), StreamBadges.getInstance().destroy(), MouseCursorHider.getInstance()?.stop(), TouchController.reset(), GameBar.getInstance()?.disable(), BxEventBus.Stream.emit("xboxTitleId.changed", { id: -1 }); } BxEventBus.Stream.on("state.stopped", unload); window.addEventListener("pagehide", (e) => { @@ -10275,7 +10496,7 @@ function main() { BX_FLAGS.ForceNativeMkbTitles.push(...customList); } if (StreamSettings.setup(), patchRtcPeerConnection(), patchRtcCodecs(), interceptHttpRequests(), patchVideoApi(), patchCanvasContext(), AppInterface && patchPointerLockApi(), getGlobalPref("audio.volume.booster.enabled") && patchAudioContext(), getGlobalPref("block.tracking")) patchMeControl(), disableAdobeAudienceManager(); - if (RootDialogObserver.waitForRootDialog(), addCss(), GuideMenu.getInstance().addEventListeners(), StreamStatsCollector.setupEvents(), StreamBadges.setupEvents(), StreamStats.setupEvents(), STATES.userAgent.capabilities.touch && TouchController.updateCustomList(), DeviceVibrationManager.getInstance(), BX_FLAGS.CheckForUpdate && checkForUpdate(), Patcher.init(), disablePwa(), getGlobalPref("xhome.enabled")) RemotePlayManager.detect(); + if (RootDialogObserver.waitForRootDialog(), addCss(), GuideMenu.getInstance().addEventListeners(), StreamStatsCollector.setupEvents(), StreamBadges.setupEvents(), StreamStats.setupEvents(), WebGPUPlayer.prepare(), STATES.userAgent.capabilities.touch && TouchController.updateCustomList(), DeviceVibrationManager.getInstance(), BX_FLAGS.CheckForUpdate && checkForUpdate(), Patcher.init(), disablePwa(), getGlobalPref("xhome.enabled")) RemotePlayManager.detect(); if (getGlobalPref("touchController.mode") === "all") TouchController.setup(); if (AppInterface && (getGlobalPref("mkb.enabled") || getGlobalPref("nativeMkb.mode") === "on")) STATES.pointerServerPort = AppInterface.startPointerServer() || 9269, BxLogger.info("startPointerServer", "Port", STATES.pointerServerPort.toString()); if (getGlobalPref("ui.gameCard.waitTime.show") && GameTile.setup(), EmulatedMkbHandler.setupEvents(), getGlobalPref("ui.controllerStatus.show")) window.addEventListener("gamepadconnected", (e) => showGamepadToast(e.gamepad)), window.addEventListener("gamepaddisconnected", (e) => showGamepadToast(e.gamepad)); diff --git a/dist/better-xcloud.user.js b/dist/better-xcloud.user.js index da96a11..341cf65 100755 --- a/dist/better-xcloud.user.js +++ b/dist/better-xcloud.user.js @@ -134,7 +134,7 @@ adjustedRgb *= ourParams.brightness; return vec4(adjustedRgb, 1.0);}`; class BaseStreamPlayer {logTag;playerType;elementType;$video;options = {processing: "usm",sharpness: 0,brightness: 1,contrast: 1,saturation: 1};isStopped = !1;constructor(playerType, elementType, $video, logTag) {this.playerType = playerType, this.elementType = elementType, this.$video = $video, this.logTag = logTag;}init() {BxLogger.info(this.logTag, "Initialize");}updateOptions(newOptions, refresh = !1) {this.options = Object.assign(this.options, newOptions), refresh && this.refreshPlayer();}} class BaseCanvasPlayer extends BaseStreamPlayer {$canvas;targetFps = 60;frameInterval = 0;lastFrameTime = 0;animFrameId = null;frameCallback;boundDrawFrame;constructor(playerType, $video, logTag) {super(playerType, "canvas", $video, logTag);let $canvas = document.createElement("canvas");$canvas.width = $video.videoWidth, $canvas.height = $video.videoHeight, this.$canvas = $canvas, $video.insertAdjacentElement("afterend", this.$canvas);let frameCallback;if ("requestVideoFrameCallback" in HTMLVideoElement.prototype) {let $video2 = this.$video;frameCallback = $video2.requestVideoFrameCallback.bind($video2);} else frameCallback = requestAnimationFrame;this.frameCallback = frameCallback, this.boundDrawFrame = this.drawFrame.bind(this);}async init() {super.init(), await this.setupShaders(), this.setupRendering();}setTargetFps(target) {this.targetFps = target, this.lastFrameTime = 0, this.frameInterval = target ? Math.floor(1000 / target) : 0;}getCanvas() {return this.$canvas;}destroy() {if (BxLogger.info(this.logTag, "Destroy"), this.isStopped = !0, this.animFrameId) {if ("requestVideoFrameCallback" in HTMLVideoElement.prototype) this.$video.cancelVideoFrameCallback(this.animFrameId);else cancelAnimationFrame(this.animFrameId);this.animFrameId = null;}if (this.$canvas.isConnected) this.$canvas.remove();this.$canvas.width = 1, this.$canvas.height = 1;}toFilterId(processing) {return processing === "cas" ? 2 : 1;}shouldDraw() {if (this.targetFps >= 60) return !0;else if (this.targetFps === 0) return !1;let currentTime = performance.now();if (currentTime - this.lastFrameTime < this.frameInterval) return !1;return this.lastFrameTime = currentTime, !0;}drawFrame() {if (this.isStopped) return;if (this.animFrameId = this.frameCallback(this.boundDrawFrame), !this.shouldDraw()) return;this.updateFrame();}setupRendering() {this.animFrameId = this.frameCallback(this.boundDrawFrame);}} -class WebGPUPlayer extends BaseCanvasPlayer {static device;context;pipeline;sampler;bindGroup;optionsUpdated = !1;paramsBuffer;vertexBuffer;static async prepare() {if (!navigator.gpu) {BxEventBus.Script.emit("webgpu.ready", {});return;}try {let adapter = await navigator.gpu.requestAdapter({powerPreference: "low-power"});if (adapter) WebGPUPlayer.device = await adapter.requestDevice(), WebGPUPlayer.device.addEventListener("uncapturederror", (e) => {console.error(e.error.message);});} catch (ex) {alert(ex);}BxEventBus.Script.emit("webgpu.ready", {});}constructor($video) {super("webgpu", $video, "WebGPUPlayer");}setupShaders() {if (this.context = this.$canvas.getContext("webgpu"), !this.context) {alert("Can't initiate context");return;}let format = navigator.gpu.getPreferredCanvasFormat();this.context.configure({device: WebGPUPlayer.device,format,alphaMode: "opaque"}), this.vertexBuffer = WebGPUPlayer.device.createBuffer({label: "vertex buffer",size: 24,usage: GPUBufferUsage.VERTEX,mappedAtCreation: !0});let mappedRange = this.vertexBuffer.getMappedRange();new Float32Array(mappedRange).set([-1,3,-1,-1,3,-1]), this.vertexBuffer.unmap();let shaderModule = WebGPUPlayer.device.createShaderModule({ code: clarity_boost_default });this.pipeline = WebGPUPlayer.device.createRenderPipeline({layout: "auto",vertex: {module: shaderModule,entryPoint: "vsMain",buffers: [{arrayStride: 8,attributes: [{format: "float32x2",offset: 0,shaderLocation: 0}]}]},fragment: {module: shaderModule,entryPoint: "fsMain",targets: [{ format }]},primitive: { topology: "triangle-list" }}), this.sampler = WebGPUPlayer.device.createSampler({ magFilter: "linear", minFilter: "linear" }), this.updateCanvas();}prepareUniformBuffer(value, classType) {let uniform = new classType(value), uniformBuffer = WebGPUPlayer.device.createBuffer({size: uniform.byteLength,usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST});return WebGPUPlayer.device.queue.writeBuffer(uniformBuffer, 0, uniform), uniformBuffer;}updateCanvas() {let externalTexture = WebGPUPlayer.device.importExternalTexture({ source: this.$video });if (!this.optionsUpdated) this.paramsBuffer = this.prepareUniformBuffer([this.toFilterId(this.options.processing),this.options.sharpness,this.options.brightness / 100,this.options.contrast / 100,this.options.saturation / 100], Float32Array), this.optionsUpdated = !0;this.bindGroup = WebGPUPlayer.device.createBindGroup({layout: this.pipeline.getBindGroupLayout(0),entries: [{ binding: 0, resource: this.sampler },{ binding: 1, resource: externalTexture },{ binding: 2, resource: { buffer: this.paramsBuffer } }]});}updateFrame() {this.updateCanvas();let commandEncoder = WebGPUPlayer.device.createCommandEncoder(), passEncoder = commandEncoder.beginRenderPass({colorAttachments: [{view: this.context.getCurrentTexture().createView(),loadOp: "clear",storeOp: "store",clearValue: [0, 0, 0, 1]}]});passEncoder.setPipeline(this.pipeline), passEncoder.setBindGroup(0, this.bindGroup), passEncoder.setVertexBuffer(0, this.vertexBuffer), passEncoder.draw(3), passEncoder.end(), WebGPUPlayer.device.queue.submit([commandEncoder.finish()]);}refreshPlayer() {this.optionsUpdated = !1, this.updateCanvas();}destroy() {if (super.destroy(), this.isStopped = !0, this.pipeline = null, this.bindGroup = null, this.sampler = null, this.paramsBuffer?.destroy(), this.paramsBuffer = null, this.vertexBuffer?.destroy(), this.vertexBuffer = null, this.context) this.context.unconfigure(), this.context = null;console.log("WebGPU context successfully freed.");}} +class WebGPUPlayer extends BaseCanvasPlayer {static device;context;pipeline;sampler;bindGroup;optionsUpdated = !1;paramsBuffer;vertexBuffer;static async prepare() {if (!navigator.gpu) {BxEventBus.Script.emit("webgpu.ready", {});return;}try {let adapter = await navigator.gpu.requestAdapter();if (adapter) WebGPUPlayer.device = await adapter.requestDevice(), WebGPUPlayer.device?.addEventListener("uncapturederror", (e) => {console.error(e.error.message);});} catch (ex) {alert(ex);}BxEventBus.Script.emit("webgpu.ready", {});}constructor($video) {super("webgpu", $video, "WebGPUPlayer");}setupShaders() {if (this.context = this.$canvas.getContext("webgpu"), !this.context) {alert("Can't initiate context");return;}let format = navigator.gpu.getPreferredCanvasFormat();this.context.configure({device: WebGPUPlayer.device,format,alphaMode: "opaque"}), this.vertexBuffer = WebGPUPlayer.device.createBuffer({label: "vertex buffer",size: 24,usage: GPUBufferUsage.VERTEX,mappedAtCreation: !0});let mappedRange = this.vertexBuffer.getMappedRange();new Float32Array(mappedRange).set([-1,3,-1,-1,3,-1]), this.vertexBuffer.unmap();let shaderModule = WebGPUPlayer.device.createShaderModule({ code: clarity_boost_default });this.pipeline = WebGPUPlayer.device.createRenderPipeline({layout: "auto",vertex: {module: shaderModule,entryPoint: "vsMain",buffers: [{arrayStride: 8,attributes: [{format: "float32x2",offset: 0,shaderLocation: 0}]}]},fragment: {module: shaderModule,entryPoint: "fsMain",targets: [{ format }]},primitive: { topology: "triangle-list" }}), this.sampler = WebGPUPlayer.device.createSampler({ magFilter: "linear", minFilter: "linear" }), this.updateCanvas();}prepareUniformBuffer(value, classType) {let uniform = new classType(value), uniformBuffer = WebGPUPlayer.device.createBuffer({size: uniform.byteLength,usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST});return WebGPUPlayer.device.queue.writeBuffer(uniformBuffer, 0, uniform), uniformBuffer;}updateCanvas() {let externalTexture = WebGPUPlayer.device.importExternalTexture({ source: this.$video });if (!this.optionsUpdated) this.paramsBuffer = this.prepareUniformBuffer([this.toFilterId(this.options.processing),this.options.sharpness,this.options.brightness / 100,this.options.contrast / 100,this.options.saturation / 100], Float32Array), this.optionsUpdated = !0;this.bindGroup = WebGPUPlayer.device.createBindGroup({layout: this.pipeline.getBindGroupLayout(0),entries: [{ binding: 0, resource: this.sampler },{ binding: 1, resource: externalTexture },{ binding: 2, resource: { buffer: this.paramsBuffer } }]});}updateFrame() {this.updateCanvas();let commandEncoder = WebGPUPlayer.device.createCommandEncoder(), passEncoder = commandEncoder.beginRenderPass({colorAttachments: [{view: this.context.getCurrentTexture().createView(),loadOp: "clear",storeOp: "store",clearValue: [0, 0, 0, 1]}]});passEncoder.setPipeline(this.pipeline), passEncoder.setBindGroup(0, this.bindGroup), passEncoder.setVertexBuffer(0, this.vertexBuffer), passEncoder.draw(3), passEncoder.end(), WebGPUPlayer.device.queue.submit([commandEncoder.finish()]);}refreshPlayer() {this.optionsUpdated = !1, this.updateCanvas();}destroy() {if (super.destroy(), this.isStopped = !0, this.pipeline = null, this.bindGroup = null, this.sampler = null, this.paramsBuffer?.destroy(), this.paramsBuffer = null, this.vertexBuffer?.destroy(), this.vertexBuffer = null, this.context) this.context.unconfigure(), this.context = null;console.log("WebGPU context successfully freed.");}} class StreamSettingsStorage extends BaseSettingsStorage {static DEFINITIONS = {"deviceVibration.mode": {requiredVariants: "full",label: t("device-vibration"),default: "off",options: {off: t("off"),on: t("on"),auto: t("device-vibration-not-using-gamepad")}},"deviceVibration.intensity": {requiredVariants: "full",label: t("vibration-intensity"),default: 50,min: 10,max: 100,params: {steps: 10,suffix: "%",exactTicks: 20}},"controller.pollingRate": {requiredVariants: "full",label: t("polling-rate"),default: 4,min: 4,max: 60,params: {steps: 4,exactTicks: 20,reverse: !0,customTextValue(value) {value = parseInt(value);let text = +(1000 / value).toFixed(2) + " Hz";if (value === 4) text = `${text} (${t("default")})`;return text;}}},"controller.settings": {default: {}},"nativeMkb.scroll.sensitivityX": {requiredVariants: "full",label: t("horizontal-scroll-sensitivity"),default: 0,min: 0,max: 1e4,params: {steps: 10,exactTicks: 2000,customTextValue: (value) => {if (!value) return t("default");return (value / 100).toFixed(1) + "x";}}},"nativeMkb.scroll.sensitivityY": {requiredVariants: "full",label: t("vertical-scroll-sensitivity"),default: 0,min: 0,max: 1e4,params: {steps: 10,exactTicks: 2000,customTextValue: (value) => {if (!value) return t("default");return (value / 100).toFixed(1) + "x";}}},"mkb.p1.preset.mappingId": {requiredVariants: "full",default: -1},"mkb.p1.slot": {requiredVariants: "full",default: 1,min: 1,max: 4,params: {hideSlider: !0}},"mkb.p2.preset.mappingId": {requiredVariants: "full",default: 0},"mkb.p2.slot": {requiredVariants: "full",default: 0,min: 0,max: 4,params: {hideSlider: !0,customTextValue(value) {return value = parseInt(value), value === 0 ? t("off") : value.toString();}}},"keyboardShortcuts.preset.inGameId": {requiredVariants: "full",default: -1},"video.player.type": {label: t("renderer"),default: "default",options: {default: t("default"),webgl2: t("webgl2"),webgpu: `${t("webgpu")} (${t("experimental")})`},suggest: {lowest: "default",highest: "webgl2"},ready: (setting) => {BxEventBus.Script.on("webgpu.ready", () => {if (!navigator.gpu || !WebGPUPlayer.device) delete setting.options["webgpu"];});}},"video.processing": {label: t("clarity-boost"),default: "usm",options: {usm: t("unsharp-masking"),cas: t("amd-fidelity-cas")},suggest: {lowest: "usm",highest: "cas"}},"video.player.powerPreference": {label: t("renderer-configuration"),default: "default",options: {default: t("default"),"low-power": t("battery-saving"),"high-performance": t("high-performance")},suggest: {highest: "low-power"}},"video.maxFps": {label: t("limit-fps"),default: 60,min: 10,max: 60,params: {steps: 10,exactTicks: 10,customTextValue: (value) => {return value = parseInt(value), value === 60 ? t("unlimited") : value + "fps";}}},"video.processing.sharpness": {label: t("sharpness"),default: 0,min: 0,max: 10,params: {exactTicks: 2,customTextValue: (value) => {return value = parseInt(value), value === 0 ? t("off") : value.toString();}},suggest: {lowest: 0,highest: 2}},"video.ratio": {label: t("aspect-ratio"),note: STATES.browser.capabilities.touch ? t("aspect-ratio-note") : void 0,default: "16:9",options: {"16:9": `16:9 (${t("default")})`,"18:9": "18:9","21:9": "21:9","16:10": "16:10","4:3": "4:3",fill: t("stretch")}},"video.position": {label: t("position"),note: STATES.browser.capabilities.touch ? t("aspect-ratio-note") : void 0,default: "center",options: {top: t("top"),"top-half": t("top-half"),center: `${t("center")} (${t("default")})`,"bottom-half": t("bottom-half"),bottom: t("bottom")}},"video.saturation": {label: t("saturation"),default: 100,min: 50,max: 150,params: {suffix: "%",ticks: 25}},"video.contrast": {label: t("contrast"),default: 100,min: 50,max: 150,params: {suffix: "%",ticks: 25}},"video.brightness": {label: t("brightness"),default: 100,min: 50,max: 150,params: {suffix: "%",ticks: 25}},"audio.volume": {label: t("volume"),default: 100,min: 0,max: 600,params: {steps: 10,suffix: "%",ticks: 100}},"stats.items": {label: t("stats"),default: ["ping", "fps", "btr", "dt", "pl", "fl"],multipleOptions: {time: t("clock"),play: t("playtime"),batt: t("battery"),ping: t("stat-ping"),jit: t("jitter"),fps: t("stat-fps"),btr: t("stat-bitrate"),dt: t("stat-decode-time"),pl: t("stat-packets-lost"),fl: t("stat-frames-lost"),dl: t("downloaded"),ul: t("uploaded")},params: {size: 0},ready: (setting) => {let multipleOptions = setting.multipleOptions;if (!STATES.browser.capabilities.batteryApi) delete multipleOptions["batt"];for (let key in multipleOptions)multipleOptions[key] = key.toUpperCase() + ": " + multipleOptions[key];}},"stats.showWhenPlaying": {label: t("show-stats-on-startup"),default: !1},"stats.quickGlance.enabled": {label: "👀 " + t("enable-quick-glance-mode"),default: !0},"stats.position": {label: t("position"),default: "top-right",options: {"top-left": t("top-left"),"top-center": t("top-center"),"top-right": t("top-right")}},"stats.textSize": {label: t("text-size"),default: "0.9rem",options: {"0.9rem": t("small"),"1.0rem": t("normal"),"1.1rem": t("large")}},"stats.opacity.all": {label: t("opacity"),default: 80,min: 50,max: 100,params: {steps: 10,suffix: "%",ticks: 10}},"stats.opacity.background": {label: t("background-opacity"),default: 100,min: 0,max: 100,params: {steps: 10,suffix: "%",ticks: 10}},"stats.colors": {label: t("conditional-formatting"),default: !1},"localCoOp.enabled": {requiredVariants: "full",label: t("enable-local-co-op-support"),labelIcon: BxIcon.LOCAL_CO_OP,default: !1,note: () => CE("div", !1, CE("a", {href: "https://github.com/redphx/better-xcloud/discussions/275",target: "_blank"}, t("enable-local-co-op-support-note")), CE("br"), "⚠️ " + t("unexpected-behavior"))}};gameSettings = {};xboxTitleId = -1;constructor() {super("BetterXcloud.Stream", StreamSettingsStorage.DEFINITIONS);}setGameId(id) {this.xboxTitleId = id;}getGameSettings(id) {if (id > -1) {if (!this.gameSettings[id]) {let gameStorage = new GameSettingsStorage(id);this.gameSettings[id] = gameStorage;for (let key in gameStorage.settings)this.getSettingByGame(id, key);}return this.gameSettings[id];}return null;}getSetting(key, checkUnsupported) {return this.getSettingByGame(this.xboxTitleId, key, checkUnsupported);}getSettingByGame(id, key, checkUnsupported) {let gameSettings = this.getGameSettings(id);if (gameSettings?.hasSetting(key)) {let gameValue = gameSettings.getSetting(key, checkUnsupported), globalValue = super.getSetting(key, checkUnsupported);if (globalValue === gameValue) this.deleteSettingByGame(id, key), gameValue = globalValue;return gameValue;}return super.getSetting(key, checkUnsupported);}setSetting(key, value, origin) {return this.setSettingByGame(this.xboxTitleId, key, value, origin);}setSettingByGame(id, key, value, origin) {let gameSettings = this.getGameSettings(id);if (gameSettings) return BxLogger.info("setSettingByGame", id, key, value), gameSettings.setSetting(key, value, origin);return BxLogger.info("setSettingByGame", id, key, value), super.setSetting(key, value, origin);}deleteSettingByGame(id, key) {let gameSettings = this.getGameSettings(id);if (gameSettings) return gameSettings.deleteSetting(key);return !1;}hasGameSetting(id, key) {let gameSettings = this.getGameSettings(id);return !!(gameSettings && gameSettings.hasSetting(key));}getControllerSetting(gamepadId) {let controllerSetting = this.getSetting("controller.settings")[gamepadId];if (!controllerSetting) controllerSetting = {};if (!controllerSetting.hasOwnProperty("shortcutPresetId")) controllerSetting.shortcutPresetId = -1;if (!controllerSetting.hasOwnProperty("customizationPresetId")) controllerSetting.customizationPresetId = 0;return controllerSetting;}} function migrateStreamSettings() {let storage = window.localStorage, globalSettings = JSON.parse(storage.getItem("BetterXcloud") || "{}"), streamSettings = JSON.parse(storage.getItem("BetterXcloud.Stream") || "{}"), modified2 = !1;for (let key in globalSettings)if (isStreamPref(key)) {if (!streamSettings.hasOwnProperty(key)) streamSettings[key] = globalSettings[key];delete globalSettings[key], modified2 = !0;}if (modified2) storage.setItem("BetterXcloud", JSON.stringify(globalSettings)), storage.setItem("BetterXcloud.Stream", JSON.stringify(streamSettings));} migrateStreamSettings(); @@ -186,7 +186,7 @@ class SettingElement {static renderOptions(key, setting, currentValue, onChange) class BxSelectElement extends HTMLSelectElement {isControllerFriendly;optionsList;indicatorsList;$indicators;visibleIndex;isMultiple;$select;$btnNext;$btnPrev;$label;$checkBox;static create($select, forceFriendly = !1) {let isControllerFriendly = forceFriendly || getGlobalPref("ui.controllerFriendly");if ($select.multiple && !isControllerFriendly) return $select.classList.add("bx-select"), $select;$select.removeAttribute("tabindex");let $wrapper = CE("div", {class: "bx-select",_dataset: {controllerFriendly: isControllerFriendly}});if ($select.classList.contains("bx-full-width")) $wrapper.classList.add("bx-full-width");let $content, self = $wrapper;self.isControllerFriendly = isControllerFriendly, self.isMultiple = $select.multiple, self.visibleIndex = $select.selectedIndex, self.$select = $select, self.optionsList = Array.from($select.querySelectorAll("option")), self.$indicators = CE("div", { class: "bx-select-indicators" }), self.indicatorsList = [];let $btnPrev, $btnNext;if (isControllerFriendly) {$btnPrev = createButton({label: "<",style: 64}), $btnNext = createButton({label: ">",style: 64}), setNearby($wrapper, {orientation: "horizontal",focus: $btnNext}), self.$btnNext = $btnNext, self.$btnPrev = $btnPrev;let boundOnPrevNext = BxSelectElement.onPrevNext.bind(self);$btnPrev.addEventListener("click", boundOnPrevNext), $btnNext.addEventListener("click", boundOnPrevNext);} else $select.addEventListener("change", (e) => {self.visibleIndex = $select.selectedIndex, BxSelectElement.resetIndicators.call(self), BxSelectElement.render.call(self);});if (self.isMultiple) $content = CE("button", {class: "bx-select-value bx-focusable",tabindex: 0}, CE("div", !1, self.$checkBox = CE("input", { type: "checkbox" }), self.$label = CE("span", !1, "")), self.$indicators), $content.addEventListener("click", (e) => {self.$checkBox.click();}), self.$checkBox.addEventListener("input", (e) => {let $option = BxSelectElement.getOptionAtIndex.call(self, self.visibleIndex);$option && ($option.selected = e.target.checked), BxEvent.dispatch($select, "input");});else $content = CE("div", !1, self.$label = CE("label", { for: $select.id + "_checkbox" }, ""), self.$indicators);return $select.addEventListener("input", BxSelectElement.render.bind(self)), new MutationObserver((mutationList, observer2) => {mutationList.forEach((mutation) => {if (mutation.type === "childList" || mutation.type === "attributes") self.visibleIndex = $select.selectedIndex, self.optionsList = Array.from($select.querySelectorAll("option")), BxSelectElement.resetIndicators.call(self), BxSelectElement.render.call(self);});}).observe($select, {subtree: !0,childList: !0,attributes: !0}), self.append($select, $btnPrev || "", $content, $btnNext || ""), BxSelectElement.resetIndicators.call(self), BxSelectElement.render.call(self), Object.defineProperty(self, "value", {get() {return $select.value;},set(value) {self.optionsList = Array.from($select.querySelectorAll("option")), $select.value = value, self.visibleIndex = $select.selectedIndex, BxSelectElement.resetIndicators.call(self), BxSelectElement.render.call(self);}}), Object.defineProperty(self, "disabled", {get() {return $select.disabled;},set(value) {$select.disabled = value;}}), self.addEventListener = function() {$select.addEventListener.apply($select, arguments);}, self.removeEventListener = function() {$select.removeEventListener.apply($select, arguments);}, self.dispatchEvent = function() {return $select.dispatchEvent.apply($select, arguments);}, self.appendChild = function(node) {return $select.appendChild(node), node;}, self;}static resetIndicators() {let {optionsList,indicatorsList,$indicators} = this, targetSize = optionsList.length;if (indicatorsList.length > targetSize) while (indicatorsList.length > targetSize)indicatorsList.pop()?.remove();else if (indicatorsList.length < targetSize) while (indicatorsList.length < targetSize) {let $indicator = CE("span", {});indicatorsList.push($indicator), $indicators.appendChild($indicator);}for (let $indicator of indicatorsList)clearDataSet($indicator);$indicators.classList.toggle("bx-invisible", targetSize <= 1);}static getOptionAtIndex(index) {return this.optionsList[index];}static render(e) {let {$label,$btnNext,$btnPrev,$checkBox,optionsList,indicatorsList} = this;if (e && e.manualTrigger) this.visibleIndex = this.$select.selectedIndex;this.visibleIndex = BxSelectElement.normalizeIndex.call(this, this.visibleIndex);let $option = BxSelectElement.getOptionAtIndex.call(this, this.visibleIndex), content = "";if ($option) {let $parent = $option.parentElement, hasLabel = $parent instanceof HTMLOptGroupElement || this.$select.querySelector("optgroup");if (content = $option.dataset.label || $option.textContent || "", content && hasLabel) {let groupLabel = $parent instanceof HTMLOptGroupElement ? $parent.label : " ";$label.innerHTML = "";let fragment = document.createDocumentFragment();fragment.appendChild(CE("span", !1, groupLabel)), fragment.appendChild(document.createTextNode(content)), $label.appendChild(fragment);} else $label.textContent = content;} else $label.textContent = content;if ($label.classList.toggle("bx-line-through", $option && $option.disabled), this.isMultiple) $checkBox.checked = $option?.selected || !1, $checkBox.classList.toggle("bx-gone", !content);let disableButtons = optionsList.length <= 1;$btnPrev?.classList.toggle("bx-gone", disableButtons), $btnNext?.classList.toggle("bx-gone", disableButtons);for (let i = 0;i < optionsList.length; i++) {let $option2 = optionsList[i], $indicator = indicatorsList[i];if (!$option2 || !$indicator) continue;if (clearDataSet($indicator), $option2.selected) $indicator.dataset.selected = "true";if ($option2.index === this.visibleIndex) $indicator.dataset.highlighted = "true";}}static normalizeIndex(index) {return Math.min(Math.max(index, 0), this.optionsList.length - 1);}static onPrevNext(e) {if (!e.target) return;let {$btnNext,$select,isMultiple,visibleIndex: currentIndex} = this, newIndex = e.target.closest("button") === $btnNext ? currentIndex + 1 : currentIndex - 1;if (newIndex > this.optionsList.length - 1) newIndex = 0;else if (newIndex < 0) newIndex = this.optionsList.length - 1;if (newIndex = BxSelectElement.normalizeIndex.call(this, newIndex), this.visibleIndex = newIndex, !isMultiple && newIndex !== currentIndex) $select.selectedIndex = newIndex;if (isMultiple) BxSelectElement.render.call(this);else BxEvent.dispatch($select, "input");}} class XboxApi {static CACHED_TITLES = {};static async getProductTitle(xboxTitleId) {if (xboxTitleId = xboxTitleId.toString(), XboxApi.CACHED_TITLES[xboxTitleId]) return XboxApi.CACHED_TITLES[xboxTitleId];let title;try {let url = `https://displaycatalog.mp.microsoft.com/v7.0/products/lookup?market=US&languages=en&value=${xboxTitleId}&alternateId=XboxTitleId&fieldsTemplate=browse`;title = (await (await NATIVE_FETCH(url)).json()).Products[0].LocalizedProperties[0].ProductTitle;} catch (e) {title = "Unknown Game #" + xboxTitleId;}return XboxApi.CACHED_TITLES[xboxTitleId] = title, title;}} class SettingsManager {static instance;static getInstance = () => SettingsManager.instance ?? (SettingsManager.instance = new SettingsManager);$streamSettingsSelection;$tips;playingGameId = -1;targetGameId = -1;SETTINGS = {"localCoOp.enabled": {onChange: () => {BxExposed.toggleLocalCoOp(getStreamPref("localCoOp.enabled"));}},"deviceVibration.mode": {onChange: StreamSettings.refreshControllerSettings},"deviceVibration.intensity": {onChange: StreamSettings.refreshControllerSettings},"controller.pollingRate": {onChange: StreamSettings.refreshControllerSettings},"controller.settings": {onChange: StreamSettings.refreshControllerSettings},"nativeMkb.scroll.sensitivityX": {onChange: () => {let value = getStreamPref("nativeMkb.scroll.sensitivityX");NativeMkbHandler.getInstance()?.setHorizontalScrollMultiplier(value / 100);}},"nativeMkb.scroll.sensitivityY": {onChange: () => {let value = getStreamPref("nativeMkb.scroll.sensitivityY");NativeMkbHandler.getInstance()?.setVerticalScrollMultiplier(value / 100);}},"video.player.type": {onChange: updateVideoPlayer,onChangeUi: onChangeVideoPlayerType},"video.player.powerPreference": {onChange: () => {if (!STATES.currentStream.streamPlayerManager) return;updateVideoPlayer();}},"video.processing": {onChange: updateVideoPlayer},"video.processing.sharpness": {onChange: updateVideoPlayer},"video.maxFps": {onChange: () => {let value = getStreamPref("video.maxFps");limitVideoPlayerFps(value);}},"video.ratio": {onChange: updateVideoPlayer},"video.brightness": {onChange: updateVideoPlayer},"video.contrast": {onChange: updateVideoPlayer},"video.saturation": {onChange: updateVideoPlayer},"video.position": {onChange: updateVideoPlayer},"audio.volume": {onChange: () => {let value = getStreamPref("audio.volume");SoundShortcut.setGainNodeVolume(value);}},"stats.items": {onChange: StreamStats.refreshStyles},"stats.quickGlance.enabled": {onChange: () => {let value = getStreamPref("stats.quickGlance.enabled"), streamStats = StreamStats.getInstance();value ? streamStats.quickGlanceSetup() : streamStats.quickGlanceStop();}},"stats.position": {onChange: StreamStats.refreshStyles},"stats.textSize": {onChange: StreamStats.refreshStyles},"stats.opacity.all": {onChange: StreamStats.refreshStyles},"stats.opacity.background": {onChange: StreamStats.refreshStyles},"stats.colors": {onChange: StreamStats.refreshStyles},"mkb.p1.preset.mappingId": {onChange: StreamSettings.refreshMkbSettings},"mkb.p1.slot": {onChange: () => {EmulatedMkbHandler.getInstance()?.resetXcloudGamepads();}},"keyboardShortcuts.preset.inGameId": {onChange: StreamSettings.refreshKeyboardShortcuts}};constructor() {BxEventBus.Stream.on("setting.changed", (data) => {if (isStreamPref(data.settingKey)) this.updateStreamElement(data.settingKey);}), BxEventBus.Stream.on("gameSettings.switched", ({ id }) => {this.switchGameSettings(id);}), this.renderStreamSettingsSelection();}updateStreamElement(key, onChanges, onChangeUis) {let info = this.SETTINGS[key];if (info.onChangeUi) if (onChangeUis) onChangeUis.add(info.onChangeUi);else info.onChangeUi();if (info.onChange && STATES.isPlaying) if (onChanges) onChanges.add(info.onChange);else info.onChange();let $elm = info.$element;if (!$elm) return;let value = getGamePref(this.targetGameId, key, !0);if ("setValue" in $elm) $elm.setValue(value);else $elm.value = value.toString();this.updateDataset($elm, key);}switchGameSettings(id) {if (setGameIdPref(id), this.targetGameId === id) return;let onChanges = new Set, onChangeUis = new Set, oldGameId = this.targetGameId;this.targetGameId = id;let key;for (key in this.SETTINGS) {if (!isStreamPref(key)) continue;let oldValue = getGamePref(oldGameId, key, !0), newValue = getGamePref(this.targetGameId, key, !0);if (oldValue === newValue) continue;this.updateStreamElement(key, onChanges, onChangeUis);}onChangeUis.forEach((fn) => fn && fn()), onChanges.forEach((fn) => fn && fn()), this.$tips.classList.toggle("bx-gone", id < 0);}setElement(pref, $elm) {if (!this.SETTINGS[pref]) this.SETTINGS[pref] = {};this.updateDataset($elm, pref), this.SETTINGS[pref].$element = $elm;}getElement(pref, params) {if (!this.SETTINGS[pref]) this.SETTINGS[pref] = {};let $elm = this.SETTINGS[pref].$element;if (!$elm) $elm = SettingElement.fromPref(pref, null, params), this.SETTINGS[pref].$element = $elm;return this.updateDataset($elm, pref), $elm;}hasElement(pref) {return !!this.SETTINGS[pref]?.$element;}updateDataset($elm, pref) {if (this.targetGameId === this.playingGameId && hasGamePref(this.playingGameId, pref)) $elm.dataset.override = "true";else delete $elm.dataset.override;}renderStreamSettingsSelection() {this.$tips = CE("p", { class: "bx-gone" }, `⇐ Q ⟶: ${t("reset-highlighted-setting")}`);let $select = BxSelectElement.create(CE("select", !1, CE("optgroup", { label: t("settings-for") }, CE("option", { value: -1 }, t("all-games")))), !0);$select.addEventListener("input", (e) => {let id = parseInt($select.value);BxEventBus.Stream.emit("gameSettings.switched", { id });}), this.$streamSettingsSelection = CE("div", {class: "bx-stream-settings-selection bx-gone",_nearby: { orientation: "vertical" }}, CE("div", !1, $select), this.$tips), BxEventBus.Stream.on("xboxTitleId.changed", async ({ id }) => {this.playingGameId = id;let gameSettings = STORAGE.Stream.getGameSettings(id), selectedId = gameSettings && !gameSettings.isEmpty() ? id : -1;setGameIdPref(selectedId);let $optGroup = $select.querySelector("optgroup");while ($optGroup.childElementCount > 1)$optGroup.lastElementChild?.remove();if (id >= 0) {let title = id === 0 ? "Xbox" : await XboxApi.getProductTitle(id);$optGroup.appendChild(CE("option", {value: id}, title));}$select.value = selectedId.toString(), BxEventBus.Stream.emit("gameSettings.switched", { id: selectedId });});}getStreamSettingsSelection() {return this.$streamSettingsSelection;}getTargetGameId() {return this.targetGameId;}} -function onChangeVideoPlayerType() {let playerType = getStreamPref("video.player.type"), settingsManager = SettingsManager.getInstance();if (!settingsManager.hasElement("video.processing")) return;let isDisabled = !1, $videoProcessing = settingsManager.getElement("video.processing"), $videoSharpness = settingsManager.getElement("video.processing.sharpness"), $videoPowerPreference = settingsManager.getElement("video.player.powerPreference"), $videoMaxFps = settingsManager.getElement("video.maxFps"), $optCas = $videoProcessing.querySelector(`option[value=${"cas"}]`);if (playerType === "default") {if ($videoProcessing.value = "usm", setStreamPref("video.processing", "usm", "direct"), $optCas && ($optCas.disabled = !0), UserAgent.isSafari()) isDisabled = !0;} else $optCas && ($optCas.disabled = !1);$videoProcessing.disabled = isDisabled, $videoSharpness.dataset.disabled = isDisabled.toString(), $videoPowerPreference.closest(".bx-settings-row").classList.toggle("bx-gone", playerType === "default"), $videoMaxFps.closest(".bx-settings-row").classList.toggle("bx-gone", playerType === "default");} +function onChangeVideoPlayerType() {let playerType = getStreamPref("video.player.type"), settingsManager = SettingsManager.getInstance();if (!settingsManager.hasElement("video.processing")) return;let isDisabled = !1, $videoProcessing = settingsManager.getElement("video.processing"), $videoSharpness = settingsManager.getElement("video.processing.sharpness"), $videoPowerPreference = settingsManager.getElement("video.player.powerPreference"), $videoMaxFps = settingsManager.getElement("video.maxFps"), $optCas = $videoProcessing.querySelector(`option[value=${"cas"}]`);if (playerType === "default") {if ($videoProcessing.value = "usm", setStreamPref("video.processing", "usm", "direct"), $optCas && ($optCas.disabled = !0), UserAgent.isSafari()) isDisabled = !0;} else $optCas && ($optCas.disabled = !1);$videoProcessing.disabled = isDisabled, $videoSharpness.dataset.disabled = isDisabled.toString(), $videoPowerPreference.closest(".bx-settings-row").classList.toggle("bx-gone", playerType !== "webgl2"), $videoMaxFps.closest(".bx-settings-row").classList.toggle("bx-gone", playerType === "default");} function limitVideoPlayerFps(targetFps) {STATES.currentStream.streamPlayerManager?.getCanvasPlayer()?.setTargetFps(targetFps);} function updateVideoPlayer() {let streamPlayerManager = STATES.currentStream.streamPlayerManager;if (!streamPlayerManager) return;let options = {processing: getStreamPref("video.processing"),sharpness: getStreamPref("video.processing.sharpness"),saturation: getStreamPref("video.saturation"),contrast: getStreamPref("video.contrast"),brightness: getStreamPref("video.brightness")};streamPlayerManager.switchPlayerType(getStreamPref("video.player.type")), limitVideoPlayerFps(getStreamPref("video.maxFps")), streamPlayerManager.updateOptions(options), streamPlayerManager.refreshPlayer();} function resizeVideoPlayer() {STATES.currentStream.streamPlayerManager?.resizePlayer();} diff --git a/package.json b/package.json index dd0800f..6995173 100755 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "@types/bun": "^1.2.0", "@types/node": "^22.10.10", "@types/stylus": "^0.48.43", + "@webgpu/types": "^0.1.53", "eslint": "^9.19.0", "eslint-plugin-compat": "^6.0.2", "stylus": "^0.64.0" diff --git a/src/enums/pref-values.ts b/src/enums/pref-values.ts index bcb3a2d..bd0b58e 100755 --- a/src/enums/pref-values.ts +++ b/src/enums/pref-values.ts @@ -116,6 +116,7 @@ export const enum VideoPowerPreference { export const enum StreamPlayerType { VIDEO = 'default', WEBGL2 = 'webgl2', + WEBGPU = 'webgpu', } export const enum StreamVideoProcessing { diff --git a/src/index.ts b/src/index.ts index 6db4cc0..bb6b6b2 100755 --- a/src/index.ts +++ b/src/index.ts @@ -47,6 +47,7 @@ import { BxEventBus } from "./utils/bx-event-bus"; import { getGlobalPref, getStreamPref } from "./utils/pref-utils"; import { SettingsManager } from "./modules/settings-manager"; import { Toast } from "./utils/toast"; +import { WebGPUPlayer } from "./modules/player/webgpu/webgpu-player"; SettingsManager.getInstance(); @@ -347,7 +348,7 @@ function unload() { } // Destroy StreamPlayer - STATES.currentStream.streamPlayer?.destroy(); + STATES.currentStream.streamPlayerManager?.destroy(); STATES.isPlaying = false; STATES.currentStream = {}; @@ -416,6 +417,8 @@ function main() { StreamStats.setupEvents(); if (isFullVersion()) { + WebGPUPlayer.prepare(); + STATES.userAgent.capabilities.touch && TouchController.updateCustomList(); DeviceVibrationManager.getInstance(); diff --git a/src/modules/player/base-canvas-player.ts b/src/modules/player/base-canvas-player.ts new file mode 100644 index 0000000..1759a3b --- /dev/null +++ b/src/modules/player/base-canvas-player.ts @@ -0,0 +1,119 @@ +import { BxLogger } from "@/utils/bx-logger"; +import { BaseStreamPlayer, StreamPlayerElement, StreamPlayerFilter } from "./base-stream-player"; +import { StreamVideoProcessing, type StreamPlayerType } from "@/enums/pref-values"; + +export abstract class BaseCanvasPlayer extends BaseStreamPlayer { + protected $canvas: HTMLCanvasElement; + + protected targetFps = 60; + protected frameInterval = 0; + protected lastFrameTime = 0; + protected animFrameId: number | null = null; + protected frameCallback: any; + private boundDrawFrame: () => void; + + constructor(playerType: StreamPlayerType, $video: HTMLVideoElement, logTag: string) { + super(playerType, StreamPlayerElement.CANVAS, $video, logTag); + + const $canvas = document.createElement('canvas'); + $canvas.width = $video.videoWidth; + $canvas.height = $video.videoHeight; + this.$canvas = $canvas; + + $video.insertAdjacentElement('afterend', this.$canvas); + + let frameCallback: any; + if ('requestVideoFrameCallback' in HTMLVideoElement.prototype) { + const $video = this.$video; + frameCallback = $video.requestVideoFrameCallback.bind($video); + } else { + frameCallback = requestAnimationFrame; + } + + this.frameCallback = frameCallback; + this.boundDrawFrame = this.drawFrame.bind(this); + } + + async init(): Promise { + super.init(); + + await this.setupShaders(); + this.setupRendering(); + } + + setTargetFps(target: number) { + this.targetFps = target; + this.lastFrameTime = 0; + this.frameInterval = target ? Math.floor(1000 / target) : 0; + } + + getCanvas() { + return this.$canvas; + } + + destroy() { + BxLogger.info(this.logTag, 'Destroy'); + + this.isStopped = true; + if (this.animFrameId) { + if ('requestVideoFrameCallback' in HTMLVideoElement.prototype) { + this.$video.cancelVideoFrameCallback(this.animFrameId); + } else { + cancelAnimationFrame(this.animFrameId); + } + + this.animFrameId = null; + } + + if (this.$canvas.isConnected) { + this.$canvas.remove(); + } + + this.$canvas.width = 1; + this.$canvas.height = 1; + } + + toFilterId(processing: StreamVideoProcessing) { + return processing === StreamVideoProcessing.CAS ? StreamPlayerFilter.CAS : StreamPlayerFilter.USM; + } + + protected shouldDraw() { + if (this.targetFps >= 60) { + // Always draw + return true; + } else if (this.targetFps === 0) { + // Don't draw when FPS is 0 + return false; + } + + const currentTime = performance.now(); + const timeSinceLastFrame = currentTime - this.lastFrameTime; + if (timeSinceLastFrame < this.frameInterval) { + // Skip frame to limit FPS + return false; + } + + this.lastFrameTime = currentTime; + return true; + } + + private drawFrame() { + if (this.isStopped) { + return; + } + + this.animFrameId = this.frameCallback(this.boundDrawFrame); + if (!this.shouldDraw()) { + return; + } + + this.updateFrame(); + } + + protected setupRendering(): void { + this.animFrameId = this.frameCallback(this.boundDrawFrame); + } + + protected abstract setupShaders(): void; + abstract updateFrame(): void; +} diff --git a/src/modules/player/base-stream-player.ts b/src/modules/player/base-stream-player.ts new file mode 100644 index 0000000..660f1be --- /dev/null +++ b/src/modules/player/base-stream-player.ts @@ -0,0 +1,48 @@ +import { StreamVideoProcessing, type StreamPlayerType } from "@/enums/pref-values"; +import type { StreamPlayerOptions } from "@/types/stream"; +import { BxLogger } from "@/utils/bx-logger"; + +export const enum StreamPlayerElement { + VIDEO = 'video', + CANVAS = 'canvas', +} + +export const enum StreamPlayerFilter { + USM = 1, + CAS = 2, +} + +export abstract class BaseStreamPlayer { + protected logTag: string; + protected playerType: StreamPlayerType; + protected elementType: StreamPlayerElement; + protected $video: HTMLVideoElement; + + protected options: StreamPlayerOptions = { + processing: StreamVideoProcessing.USM, + sharpness: 0, + brightness: 1.0, + contrast: 1.0, + saturation: 1.0, + }; + + protected isStopped = false; + + constructor(playerType: StreamPlayerType, elementType: StreamPlayerElement, $video: HTMLVideoElement, logTag: string) { + this.playerType = playerType; + this.elementType = elementType; + this.$video = $video; + this.logTag = logTag; + } + + init() { + BxLogger.info(this.logTag, 'Initialize'); + } + + updateOptions(newOptions: Partial, refresh=false) { + this.options = Object.assign(this.options, newOptions); + refresh && this.refreshPlayer(); + } + + abstract refreshPlayer(): void; +} diff --git a/src/modules/player/video/video-player.ts b/src/modules/player/video/video-player.ts new file mode 100644 index 0000000..f73c1c5 --- /dev/null +++ b/src/modules/player/video/video-player.ts @@ -0,0 +1,102 @@ +import { CE } from "@/utils/html"; +import { BaseStreamPlayer, StreamPlayerElement } from "../base-stream-player"; +import { StreamPlayerType, StreamVideoProcessing } from "@/enums/pref-values"; +import { GlobalPref } from "@/enums/pref-keys"; +import { getGlobalPref } from "@/utils/pref-utils"; +import { ScreenshotManager } from "@/utils/screenshot-manager"; + +export class VideoPlayer extends BaseStreamPlayer { + private $videoCss!: HTMLStyleElement; + private $usmMatrix!: SVGFEConvolveMatrixElement; + + constructor($video: HTMLVideoElement, logTag: string) { + super(StreamPlayerType.VIDEO, StreamPlayerElement.VIDEO, $video, logTag); + } + + init(): void { + super.init(); + + // Setup SVG filters + const xmlns = 'http://www.w3.org/2000/svg'; + const $svg = CE('svg', { + id: 'bx-video-filters', + class: 'bx-gone', + xmlns, + }, + CE('defs', { xmlns: 'http://www.w3.org/2000/svg' }, + CE('filter', { + id: 'bx-filter-usm', + xmlns, + }, this.$usmMatrix = CE('feConvolveMatrix', { + id: 'bx-filter-usm-matrix', + order: '3', + xmlns, + }) as unknown as SVGFEConvolveMatrixElement), + ), + ); + + this.$videoCss = CE('style', { id: 'bx-video-css' }); + + const $fragment = document.createDocumentFragment(); + $fragment.append(this.$videoCss, $svg); + document.documentElement.appendChild($fragment); + } + + protected setupRendering(): void {} + forceDrawFrame(): void {} + updateCanvas(): void {} + + refreshPlayer() { + let filters = this.getVideoPlayerFilterStyle(); + let videoCss = ''; + if (filters) { + videoCss += `filter: ${filters} !important;`; + } + + // Apply video filters to screenshots + if (getGlobalPref(GlobalPref.SCREENSHOT_APPLY_FILTERS)) { + ScreenshotManager.getInstance().updateCanvasFilters(filters); + } + + let css = ''; + if (videoCss) { + css = `#game-stream video { ${videoCss} }`; + } + + this.$videoCss.textContent = css; + } + + clearFilters() { + this.$videoCss.textContent = ''; + } + + private getVideoPlayerFilterStyle() { + const filters = []; + + const sharpness = this.options.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 matrix = `0 -1 0 -1 ${level} -1 0 -1 0`; + this.$usmMatrix?.setAttributeNS(null, 'kernelMatrix', matrix); + + filters.push(`url(#bx-filter-usm)`); + } + + const saturation = this.options.saturation || 100; + if (saturation != 100) { + filters.push(`saturate(${saturation}%)`); + } + + const contrast = this.options.contrast || 100; + if (contrast != 100) { + filters.push(`contrast(${contrast}%)`); + } + + const brightness = this.options.brightness || 100; + if (brightness != 100) { + filters.push(`brightness(${brightness}%)`); + } + + return filters.join(' '); + } +} diff --git a/src/modules/player/webgl2-player.ts b/src/modules/player/webgl2-player.ts deleted file mode 100755 index 4029345..0000000 --- a/src/modules/player/webgl2-player.ts +++ /dev/null @@ -1,268 +0,0 @@ -import vertClarityBoost from "./shaders/clarity_boost.vert" with { type: "text" }; -import fsClarityBoost from "./shaders/clarity_boost.fs" with { type: "text" }; -import { BxLogger } from "@/utils/bx-logger"; -import { StreamPref } from "@/enums/pref-keys"; -import { getStreamPref } from "@/utils/pref-utils"; - - -export class WebGL2Player { - private readonly LOG_TAG = 'WebGL2Player'; - - private $video: HTMLVideoElement; - private $canvas: HTMLCanvasElement; - - private gl: WebGL2RenderingContext | null = null; - private resources: Array = []; - private program: WebGLProgram | null = null; - - private stopped: boolean = false; - - private options = { - filterId: 1, - sharpenFactor: 0, - brightness: 0.0, - contrast: 0.0, - saturation: 0.0, - }; - - private targetFps = 60; - private frameInterval = 0; - private lastFrameTime = 0; - - private animFrameId: number | null = null; - - constructor($video: HTMLVideoElement) { - BxLogger.info(this.LOG_TAG, 'Initialize'); - this.$video = $video; - - const $canvas = document.createElement('canvas'); - $canvas.width = $video.videoWidth; - $canvas.height = $video.videoHeight; - this.$canvas = $canvas; - - this.setupShaders(); - this.setupRendering(); - - $video.insertAdjacentElement('afterend', $canvas); - } - - setFilter(filterId: number, update = true) { - this.options.filterId = filterId; - update && this.updateCanvas(); - } - - setSharpness(sharpness: number, update = true) { - this.options.sharpenFactor = sharpness; - update && this.updateCanvas(); - } - - setBrightness(brightness: number, update = true) { - this.options.brightness = 1 + (brightness - 100) / 100; - update && this.updateCanvas(); - } - - setContrast(contrast: number, update = true) { - this.options.contrast = 1 + (contrast - 100) / 100; - update && this.updateCanvas(); - } - - setSaturation(saturation: number, update = true) { - this.options.saturation = 1 + (saturation - 100) / 100; - update && this.updateCanvas(); - } - - setTargetFps(target: number) { - this.targetFps = target; - this.lastFrameTime = 0; - this.frameInterval = target ? Math.floor(1000 / target) : 0; - } - - getCanvas() { - return this.$canvas; - } - - updateCanvas() { - const gl = this.gl!; - const program = this.program!; - - gl.uniform2f(gl.getUniformLocation(program, 'iResolution'), this.$canvas.width, this.$canvas.height); - - gl.uniform1i(gl.getUniformLocation(program, 'filterId'), this.options.filterId); - gl.uniform1f(gl.getUniformLocation(program, 'sharpenFactor'), this.options.sharpenFactor); - gl.uniform1f(gl.getUniformLocation(program, 'brightness'), this.options.brightness); - gl.uniform1f(gl.getUniformLocation(program, 'contrast'), this.options.contrast); - gl.uniform1f(gl.getUniformLocation(program, 'saturation'), this.options.saturation); - } - - forceDrawFrame() { - const gl = this.gl!; - gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGB, gl.RGB, gl.UNSIGNED_BYTE, this.$video); - gl.drawArrays(gl.TRIANGLES, 0, 6); - } - - private setupRendering() { - let frameCallback: any; - if ('requestVideoFrameCallback' in HTMLVideoElement.prototype) { - const $video = this.$video; - frameCallback = $video.requestVideoFrameCallback.bind($video); - } else { - frameCallback = requestAnimationFrame; - } - - let animate = () => { - if (this.stopped) { - return; - } - - this.animFrameId = frameCallback(animate); - - let draw = true; - - // Don't draw when FPS is 0 - if (this.targetFps === 0) { - draw = false; - } else if (this.targetFps < 60) { - // Limit FPS - const currentTime = performance.now(); - const timeSinceLastFrame = currentTime - this.lastFrameTime; - if (timeSinceLastFrame < this.frameInterval) { - draw = false; - } else { - this.lastFrameTime = currentTime; - } - } - - if (draw) { - const gl = this.gl!; - gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGB, gl.RGB, gl.UNSIGNED_BYTE, this.$video); - gl.drawArrays(gl.TRIANGLES, 0, 6); - } - } - - this.animFrameId = frameCallback(animate); - } - - private setupShaders() { - BxLogger.info(this.LOG_TAG, 'Setting up', getStreamPref(StreamPref.VIDEO_POWER_PREFERENCE)); - - const gl = this.$canvas.getContext('webgl2', { - isBx: true, - antialias: true, - alpha: false, - powerPreference: getStreamPref(StreamPref.VIDEO_POWER_PREFERENCE), - }) as WebGL2RenderingContext; - this.gl = gl; - - gl.viewport(0, 0, gl.drawingBufferWidth, gl.drawingBufferWidth); - - // Vertex shader: Identity map - const vShader = gl.createShader(gl.VERTEX_SHADER)!; - gl.shaderSource(vShader, vertClarityBoost); - gl.compileShader(vShader); - - const fShader = gl.createShader(gl.FRAGMENT_SHADER)!; - gl.shaderSource(fShader, fsClarityBoost); - gl.compileShader(fShader); - - // Create and link program - const program = gl.createProgram()!; - this.program = program; - - gl.attachShader(program, vShader); - gl.attachShader(program, fShader); - gl.linkProgram(program); - gl.useProgram(program); - - if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { - console.error(`Link failed: ${gl.getProgramInfoLog(program)}`); - console.error(`vs info-log: ${gl.getShaderInfoLog(vShader)}`); - console.error(`fs info-log: ${gl.getShaderInfoLog(fShader)}`); - } - - this.updateCanvas(); - - // Vertices: A screen-filling quad made from two triangles - const buffer = gl.createBuffer(); - this.resources.push(buffer); - - gl.bindBuffer(gl.ARRAY_BUFFER, buffer); - gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1]), gl.STATIC_DRAW); - - gl.enableVertexAttribArray(0); - gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0); - - // Texture to contain the video data - const texture = gl.createTexture(); - this.resources.push(texture); - - gl.bindTexture(gl.TEXTURE_2D, texture); - gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true); - gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); - gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); - gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); - gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); - - // Bind texture to the "data" argument to the fragment shader - gl.uniform1i(gl.getUniformLocation(program, 'data'), 0); - - gl.activeTexture(gl.TEXTURE0); - // gl.bindTexture(gl.TEXTURE_2D, texture); - } - - resume() { - this.stop(); - this.stopped = false; - BxLogger.info(this.LOG_TAG, 'Resume'); - - this.$canvas.classList.remove('bx-gone'); - this.setupRendering(); - } - - stop() { - BxLogger.info(this.LOG_TAG, 'Stop'); - this.$canvas.classList.add('bx-gone'); - - this.stopped = true; - if (this.animFrameId) { - if ('requestVideoFrameCallback' in HTMLVideoElement.prototype) { - this.$video.cancelVideoFrameCallback(this.animFrameId); - } else { - cancelAnimationFrame(this.animFrameId); - } - - this.animFrameId = null; - } - } - - destroy() { - BxLogger.info(this.LOG_TAG, 'Destroy'); - this.stop(); - - const gl = this.gl; - if (gl) { - gl.getExtension('WEBGL_lose_context')?.loseContext(); - gl.useProgram(null); - - for (const resource of this.resources) { - if (resource instanceof WebGLProgram) { - gl.deleteProgram(resource); - } else if (resource instanceof WebGLShader) { - gl.deleteShader(resource); - } else if (resource instanceof WebGLTexture) { - gl.deleteTexture(resource); - } else if (resource instanceof WebGLBuffer) { - gl.deleteBuffer(resource); - } - } - - this.gl = null; - } - - if (this.$canvas.isConnected) { - this.$canvas.parentElement?.removeChild(this.$canvas); - } - - this.$canvas.width = 1; - this.$canvas.height = 1; - } -} diff --git a/src/modules/player/shaders/clarity_boost.fs b/src/modules/player/webgl2/shaders/clarity-boost.fs similarity index 95% rename from src/modules/player/shaders/clarity_boost.fs rename to src/modules/player/webgl2/shaders/clarity-boost.fs index b22901b..2c0dca1 100755 --- a/src/modules/player/shaders/clarity_boost.fs +++ b/src/modules/player/webgl2/shaders/clarity-boost.fs @@ -10,8 +10,8 @@ const int FILTER_UNSHARP_MASKING = 1; // constrast = 0.8 const float CAS_CONTRAST_PEAK = 0.8 * -3.0 + 8.0; -// Luminosity factor -const vec3 LUMINOSITY_FACTOR = vec3(0.2126, 0.7152, 0.0722); +// Luminosity factor: https://www.w3.org/TR/AERT/#color-contrast +const vec3 LUMINOSITY_FACTOR = vec3(0.299, 0.587, 0.114); uniform int filterId; uniform float sharpenFactor; diff --git a/src/modules/player/shaders/clarity_boost.vert b/src/modules/player/webgl2/shaders/clarity-boost.vert similarity index 100% rename from src/modules/player/shaders/clarity_boost.vert rename to src/modules/player/webgl2/shaders/clarity-boost.vert diff --git a/src/modules/player/webgl2/webgl2-player.ts b/src/modules/player/webgl2/webgl2-player.ts new file mode 100755 index 0000000..3e09a67 --- /dev/null +++ b/src/modules/player/webgl2/webgl2-player.ts @@ -0,0 +1,141 @@ +import vertClarityBoost from "./shaders/clarity-boost.vert" with { type: "text" }; +import fsClarityBoost from "./shaders/clarity-boost.fs" with { type: "text" }; +import { StreamPref } from "@/enums/pref-keys"; +import { getStreamPref } from "@/utils/pref-utils"; +import { BaseCanvasPlayer } from "../base-canvas-player"; +import { StreamPlayerType } from "@/enums/pref-values"; + + +export class WebGL2Player extends BaseCanvasPlayer { + private gl: WebGL2RenderingContext | null = null; + private resources: Array = []; + private program: WebGLProgram | null = null; + + constructor($video: HTMLVideoElement) { + super(StreamPlayerType.WEBGL2, $video, 'WebGL2Player'); + } + + private updateCanvas() { + console.log('updateCanvas', this.options); + + const gl = this.gl!; + const program = this.program!; + const filterId = this.toFilterId(this.options.processing); + + gl.uniform2f(gl.getUniformLocation(program, 'iResolution'), this.$canvas.width, this.$canvas.height); + + gl.uniform1i(gl.getUniformLocation(program, 'filterId'), filterId); + gl.uniform1f(gl.getUniformLocation(program, 'sharpenFactor'), this.options.sharpness); + gl.uniform1f(gl.getUniformLocation(program, 'brightness'), this.options.brightness / 100); + gl.uniform1f(gl.getUniformLocation(program, 'contrast'), this.options.contrast / 100); + gl.uniform1f(gl.getUniformLocation(program, 'saturation'), this.options.saturation / 100); + } + + updateFrame() { + const gl = this.gl!; + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGB, gl.RGB, gl.UNSIGNED_BYTE, this.$video); + gl.drawArrays(gl.TRIANGLES, 0, 3); + } + + protected async setupShaders(): Promise { + const gl = this.$canvas.getContext('webgl2', { + isBx: true, + antialias: true, + alpha: false, + depth: false, + preserveDrawingBuffer: false, + stencil: false, + powerPreference: getStreamPref(StreamPref.VIDEO_POWER_PREFERENCE), + } as WebGLContextAttributes) as WebGL2RenderingContext; + this.gl = gl; + + gl.viewport(0, 0, gl.drawingBufferWidth, gl.drawingBufferWidth); + + // Vertex shader: Identity map + const vShader = gl.createShader(gl.VERTEX_SHADER)!; + gl.shaderSource(vShader, vertClarityBoost); + gl.compileShader(vShader); + + const fShader = gl.createShader(gl.FRAGMENT_SHADER)!; + gl.shaderSource(fShader, fsClarityBoost); + gl.compileShader(fShader); + + // Create and link program + const program = gl.createProgram()!; + this.program = program; + + gl.attachShader(program, vShader); + gl.attachShader(program, fShader); + gl.linkProgram(program); + gl.useProgram(program); + + if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { + console.error(`Link failed: ${gl.getProgramInfoLog(program)}`); + console.error(`vs info-log: ${gl.getShaderInfoLog(vShader)}`); + console.error(`fs info-log: ${gl.getShaderInfoLog(fShader)}`); + } + + this.updateCanvas(); + + // Vertices: A screen-filling quad made from two triangles + const buffer = gl.createBuffer(); + this.resources.push(buffer); + + gl.bindBuffer(gl.ARRAY_BUFFER, buffer); + gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([ + -1.0, -1.0, // Bottom-left + 3.0, -1.0, // Bottom-right + -1.0, 3.0, // Top-left + ]), gl.STATIC_DRAW); + + gl.enableVertexAttribArray(0); + gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0); + + // Texture to contain the video data + const texture = gl.createTexture(); + this.resources.push(texture); + + gl.bindTexture(gl.TEXTURE_2D, texture); + gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); + + // Bind texture to the "data" argument to the fragment shader + gl.uniform1i(gl.getUniformLocation(program, 'data'), 0); + + gl.activeTexture(gl.TEXTURE0); + // gl.bindTexture(gl.TEXTURE_2D, texture); + } + + destroy() { + super.destroy(); + + const gl = this.gl; + if (!gl) { + return; + } + + gl.getExtension('WEBGL_lose_context')?.loseContext(); + gl.useProgram(null); + + for (const resource of this.resources) { + if (resource instanceof WebGLProgram) { + gl.deleteProgram(resource); + } else if (resource instanceof WebGLShader) { + gl.deleteShader(resource); + } else if (resource instanceof WebGLTexture) { + gl.deleteTexture(resource); + } else if (resource instanceof WebGLBuffer) { + gl.deleteBuffer(resource); + } + } + + this.gl = null; + } + + refreshPlayer(): void { + this.updateCanvas(); + } +} diff --git a/src/modules/player/webgpu/shaders/clarity-boost.wgsl b/src/modules/player/webgpu/shaders/clarity-boost.wgsl new file mode 100644 index 0000000..dc374e8 --- /dev/null +++ b/src/modules/player/webgpu/shaders/clarity-boost.wgsl @@ -0,0 +1,93 @@ +struct Params { + filterId: f32, + sharpness: f32, + brightness: f32, + contrast: f32, + saturation: f32, +}; + + +struct VertexOutput { + @builtin(position) position: vec4, + @location(0) uv: vec2, +}; + + +@group(0) @binding(0) var ourSampler: sampler; +@group(0) @binding(1) var ourTexture: texture_external; +@group(0) @binding(2) var ourParams: Params; + + +const FILTER_UNSHARP_MASKING: f32 = 1.0; +const CAS_CONTRAST_PEAK: f32 = 0.8 * -3.0 + 8.0; +// Luminosity factor: https://www.w3.org/TR/AERT/#color-contrast +const LUMINOSITY_FACTOR = vec3(0.299, 0.587, 0.114); + +@vertex +fn vsMain(@location(0) pos: vec2) -> VertexOutput { + var out: VertexOutput; + out.position = vec4(pos, 0.0, 1.0); + // Flip the Y-coordinate of UVs + out.uv = (vec2(pos.x, 1.0 - (pos.y + 1.0)) + vec2(1.0, 1.0)) * 0.5; + return out; +} + + +fn clarityBoost(coord: vec2, texSize: vec2, e: vec3) -> vec3 { + let texelSize = 1.0 / texSize; + + // Load 3x3 neighborhood samples + let a = textureSampleBaseClampToEdge(ourTexture, ourSampler, coord + texelSize * vec2(-1.0, 1.0)).rgb; + let b = textureSampleBaseClampToEdge(ourTexture, ourSampler, coord + texelSize * vec2( 0.0, 1.0)).rgb; + let c = textureSampleBaseClampToEdge(ourTexture, ourSampler, coord + texelSize * vec2( 1.0, 1.0)).rgb; + + let d = textureSampleBaseClampToEdge(ourTexture, ourSampler, coord + texelSize * vec2(-1.0, 0.0)).rgb; + let f = textureSampleBaseClampToEdge(ourTexture, ourSampler, coord + texelSize * vec2( 1.0, 0.0)).rgb; + + let g = textureSampleBaseClampToEdge(ourTexture, ourSampler, coord + texelSize * vec2(-1.0, -1.0)).rgb; + let h = textureSampleBaseClampToEdge(ourTexture, ourSampler, coord + texelSize * vec2( 0.0, -1.0)).rgb; + let i = textureSampleBaseClampToEdge(ourTexture, ourSampler, coord + texelSize * vec2( 1.0, -1.0)).rgb; + + // Unsharp Masking (USM) + if ourParams.filterId == FILTER_UNSHARP_MASKING { + let gaussianBlur = (a + c + g + i) * 1.0 + (b + d + f + h) * 2.0 + e * 4.0; + let blurred = gaussianBlur / 16.0; + return e + (e - blurred) * (ourParams.sharpness / 3.0); + } + + // Contrast Adaptive Sharpening (CAS) + let minRgb = min(min(min(d, e), min(f, b)), h) + min(min(a, c), min(g, i)); + let maxRgb = max(max(max(d, e), max(f, b)), h) + max(max(a, c), max(g, i)); + + let reciprocalMaxRgb = 1.0 / maxRgb; + var amplifyRgb = clamp(min(minRgb, 2.0 - maxRgb) * reciprocalMaxRgb, vec3(0.0), vec3(1.0)); + amplifyRgb = 1.0 / sqrt(amplifyRgb); + + let weightRgb = -(1.0 / (amplifyRgb * CAS_CONTRAST_PEAK)); + let reciprocalWeightRgb = 1.0 / (4.0 * weightRgb + 1.0); + + let window = b + d + f + h; + let outColor = clamp((window * weightRgb + e) * reciprocalWeightRgb, vec3(0.0), vec3(1.0)); + + return mix(e, outColor, ourParams.sharpness / 2.0); +} + + +@fragment +fn fsMain(input: VertexOutput) -> @location(0) vec4 { + let texSize = vec2(textureDimensions(ourTexture)); + let center = textureSampleBaseClampToEdge(ourTexture, ourSampler, input.uv); + var adjustedRgb = clarityBoost(input.uv, texSize, center.rgb); + + // Compute grayscale intensity + let gray = dot(adjustedRgb, LUMINOSITY_FACTOR); + // Interpolate between grayscale and color + adjustedRgb = mix(vec3(gray), adjustedRgb, ourParams.saturation); + + // Adjust contrast + adjustedRgb = (adjustedRgb - 0.5) * ourParams.contrast + 0.5; + + // Adjust brightness + adjustedRgb *= ourParams.brightness; + return vec4(adjustedRgb, 1.0); +} diff --git a/src/modules/player/webgpu/webgpu-player.ts b/src/modules/player/webgpu/webgpu-player.ts new file mode 100644 index 0000000..b3fb955 --- /dev/null +++ b/src/modules/player/webgpu/webgpu-player.ts @@ -0,0 +1,186 @@ +import wgslClarityBoost from "./shaders/clarity-boost.wgsl" with { type: "text" }; +import { BaseCanvasPlayer } from "../base-canvas-player"; +import { StreamPlayerType } from "@/enums/pref-values"; +import { BxEventBus } from "@/utils/bx-event-bus"; + +export class WebGPUPlayer extends BaseCanvasPlayer { + static device: GPUDevice; + + context!: GPUCanvasContext | null; + pipeline!: GPURenderPipeline | null; + sampler!: GPUSampler | null; + bindGroup!: GPUBindGroup | null; + optionsUpdated: boolean = false; + paramsBuffer!: GPUBuffer | null; + vertexBuffer!: GPUBuffer | null; + + static async prepare(): Promise { + if (!navigator.gpu) { + BxEventBus.Script.emit('webgpu.ready', {}); + return; + } + + try { + const adapter = await navigator.gpu.requestAdapter(); + + if (adapter) { + WebGPUPlayer.device = await adapter.requestDevice(); + WebGPUPlayer.device?.addEventListener('uncapturederror', e => { + console.error((e as GPUUncapturedErrorEvent).error.message); + }); + } + } catch (ex) { + alert(ex); + } + + BxEventBus.Script.emit('webgpu.ready', {}); + } + + constructor($video: HTMLVideoElement) { + super(StreamPlayerType.WEBGPU, $video, 'WebGPUPlayer'); + } + + protected setupShaders(): void { + this.context = this.$canvas.getContext('webgpu')!; + if (!this.context) { + alert('Can\'t initiate context'); + return; + } + + const format = navigator.gpu.getPreferredCanvasFormat(); + this.context.configure({ + device: WebGPUPlayer.device, + format, + alphaMode: 'opaque', + }); + + this.vertexBuffer = WebGPUPlayer.device.createBuffer({ + label: 'vertex buffer', + size: 6 * 4, // 6 floats (2 per vertex) + usage: GPUBufferUsage.VERTEX, + mappedAtCreation: true, + }); + + const mappedRange = this.vertexBuffer.getMappedRange(); + new Float32Array(mappedRange).set([ + -1, 3, // Vertex 1 + -1, -1, // Vertex 2 + 3, -1, // Vertex 3 + ]); + this.vertexBuffer.unmap(); + + const shaderModule = WebGPUPlayer.device.createShaderModule({ code: wgslClarityBoost }); + this.pipeline = WebGPUPlayer.device.createRenderPipeline({ + layout: 'auto', + vertex: { + module: shaderModule, + entryPoint: 'vsMain', + buffers: [{ + arrayStride: 8, + attributes: [{ + format: 'float32x2', + offset: 0, + shaderLocation: 0, + }], + }], + }, + fragment: { + module: shaderModule, + entryPoint: 'fsMain', + targets: [{ format }], + }, + primitive: { topology: 'triangle-list' }, + }); + + this.sampler = WebGPUPlayer.device.createSampler({ magFilter: 'linear', minFilter: 'linear' }); + this.updateCanvas(); + } + + private prepareUniformBuffer(value: any, classType: any) { + const uniform = new classType(value); + const uniformBuffer = WebGPUPlayer.device.createBuffer({ + size: uniform.byteLength, + usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST + }); + + WebGPUPlayer.device.queue.writeBuffer(uniformBuffer, 0, uniform); + return uniformBuffer; + } + + private updateCanvas() { + const externalTexture = WebGPUPlayer.device.importExternalTexture({ source: this.$video }); + + if (!this.optionsUpdated) { + this.paramsBuffer = this.prepareUniformBuffer([ + this.toFilterId(this.options.processing), + this.options.sharpness, + this.options.brightness / 100, + this.options.contrast / 100, + this.options.saturation / 100, + ], Float32Array); + + this.optionsUpdated = true; + } + + this.bindGroup = WebGPUPlayer.device.createBindGroup({ + layout: this.pipeline!.getBindGroupLayout(0), + entries: [ + { binding: 0, resource: this.sampler }, + { binding: 1, resource: externalTexture as any }, + { binding: 2, resource: { buffer: this.paramsBuffer } }, + ], + }); + } + + updateFrame(): void { + this.updateCanvas(); + + const commandEncoder = WebGPUPlayer.device.createCommandEncoder(); + const passEncoder = commandEncoder.beginRenderPass({ + colorAttachments: [{ + view: this.context!.getCurrentTexture().createView(), + loadOp: 'clear', + storeOp: 'store', + clearValue: [0.0, 0.0, 0.0, 1.0], + }] + }); + + passEncoder.setPipeline(this.pipeline!); + passEncoder.setBindGroup(0, this.bindGroup); + passEncoder.setVertexBuffer(0, this.vertexBuffer); + passEncoder.draw(3); + passEncoder.end(); + + WebGPUPlayer.device.queue.submit([commandEncoder.finish()]); + } + + refreshPlayer(): void { + this.optionsUpdated = false; + this.updateCanvas(); + } + + destroy(): void { + super.destroy(); + + this.isStopped = true; + + // Unset GPU resources + this.pipeline = null; + this.bindGroup = null; + this.sampler = null; + + this.paramsBuffer?.destroy(); + this.paramsBuffer = null; + + this.vertexBuffer?.destroy(); + this.vertexBuffer = null; + + // Reset the WebGPU context (force garbage collection) + if (this.context) { + this.context.unconfigure(); + this.context = null; + } + + console.log('WebGPU context successfully freed.'); + } +} diff --git a/src/modules/settings-manager.ts b/src/modules/settings-manager.ts index 33c628c..50a4d99 100644 --- a/src/modules/settings-manager.ts +++ b/src/modules/settings-manager.ts @@ -72,12 +72,11 @@ export class SettingsManager { }, [StreamPref.VIDEO_POWER_PREFERENCE]: { onChange: () => { - const streamPlayer = STATES.currentStream.streamPlayer; + const streamPlayer = STATES.currentStream.streamPlayerManager; if (!streamPlayer) { return; } - streamPlayer.reloadPlayer(); updateVideoPlayer(); }, }, diff --git a/src/modules/stream-player-manager.ts b/src/modules/stream-player-manager.ts new file mode 100755 index 0000000..a000e4c --- /dev/null +++ b/src/modules/stream-player-manager.ts @@ -0,0 +1,191 @@ +import { WebGL2Player } from "./player/webgl2/webgl2-player"; +import { ScreenshotManager } from "@/utils/screenshot-manager"; +import { STATES } from "@/utils/global"; +import { StreamPref } from "@/enums/pref-keys"; +import { BX_FLAGS } from "@/utils/bx-flags"; +import { StreamPlayerType, VideoPosition } from "@/enums/pref-values"; +import { getStreamPref } from "@/utils/pref-utils"; +import type { BaseCanvasPlayer } from "./player/base-canvas-player"; +import { VideoPlayer } from "./player/video/video-player"; +import { StreamPlayerElement } from "./player/base-stream-player"; +import { WebGPUPlayer } from "./player/webgpu/webgpu-player"; +import type { StreamPlayerOptions } from "@/types/stream"; + + +export class StreamPlayerManager { + private static instance: StreamPlayerManager; + public static getInstance = () => StreamPlayerManager.instance ?? (StreamPlayerManager.instance = new StreamPlayerManager()); + + private $video!: HTMLVideoElement; + private videoPlayer!: VideoPlayer; + private canvasPlayer: BaseCanvasPlayer | null | undefined; + private playerType: StreamPlayerType = StreamPlayerType.VIDEO; + + private constructor() {} + + setVideoElement($video: HTMLVideoElement) { + this.$video = $video; + this.videoPlayer = new VideoPlayer($video, 'VideoPlayer'); + this.videoPlayer.init(); + } + + resizePlayer() { + const PREF_RATIO = getStreamPref(StreamPref.VIDEO_RATIO); + const $video = this.$video; + const isNativeTouchGame = STATES.currentStream.titleInfo?.details.hasNativeTouchSupport; + + let targetWidth; + let targetHeight; + let targetObjectFit; + + if (PREF_RATIO.includes(':')) { + const tmp = PREF_RATIO.split(':'); + + // Get preferred ratio + const videoRatio = parseFloat(tmp[0]) / parseFloat(tmp[1]); + + let width = 0; + let height = 0; + + // Get parent's ratio + const parentRect = $video.parentElement!.getBoundingClientRect(); + const parentRatio = parentRect.width / parentRect.height; + + // Get target width & height + if (parentRatio > videoRatio) { + height = parentRect.height; + width = height * videoRatio; + } else { + width = parentRect.width; + height = width / videoRatio; + } + + // Avoid floating points + width = Math.ceil(Math.min(parentRect.width, width)); + height = Math.ceil(Math.min(parentRect.height, height)); + + $video.dataset.width = width.toString(); + $video.dataset.height = height.toString(); + + // Set position + const $parent = $video.parentElement!; + const position = getStreamPref(StreamPref.VIDEO_POSITION); + $parent.style.removeProperty('padding-top'); + + $parent.dataset.position = position; + if (position === VideoPosition.TOP_HALF || position === VideoPosition.BOTTOM_HALF) { + let padding = Math.floor((window.innerHeight - height) / 4); + if (padding > 0) { + if (position === VideoPosition.BOTTOM_HALF) { + padding *= 3; + } + + $parent.style.paddingTop = padding + 'px'; + } + } + + // Update size + targetWidth = `${width}px`; + targetHeight = `${height}px`; + targetObjectFit = PREF_RATIO === '16:9' ? 'contain' : 'fill'; + } else { + targetWidth = '100%'; + targetHeight = '100%'; + targetObjectFit = PREF_RATIO; + + $video.dataset.width = window.innerWidth.toString(); + $video.dataset.height = window.innerHeight.toString(); + } + + $video.style.width = targetWidth; + $video.style.height = targetHeight; + $video.style.objectFit = targetObjectFit; + + if (this.canvasPlayer) { + const $canvas = this.canvasPlayer.getCanvas(); + $canvas.style.width = targetWidth; + $canvas.style.height = targetHeight; + $canvas.style.objectFit = targetObjectFit; + + $video.dispatchEvent(new Event('resize')); + } + + // Update video dimensions + if (isNativeTouchGame && this.playerType !== StreamPlayerType.VIDEO) { + window.BX_EXPOSED.streamSession.updateDimensions(); + } + } + + switchPlayerType(type: StreamPlayerType, refreshPlayer: boolean = false) { + if (this.playerType !== type) { + const videoClass = BX_FLAGS.DeviceInfo.deviceType === 'android-tv' ? 'bx-pixel' : 'bx-gone'; + + // Destroy old player + this.cleanUpCanvasPlayer(); + + if (type === StreamPlayerType.VIDEO) { + // Switch from Canvas -> Video + this.$video.classList.remove(videoClass); + } else { + // Switch from Video -> Canvas + if (type === StreamPlayerType.WEBGPU) { + this.canvasPlayer = new WebGPUPlayer(this.$video); + } else { + this.canvasPlayer = new WebGL2Player(this.$video); + } + this.canvasPlayer.init(); + + this.videoPlayer.clearFilters(); + this.$video.classList.add(videoClass); + } + + this.playerType = type; + } + + refreshPlayer && this.refreshPlayer(); + } + + updateOptions(options: StreamPlayerOptions, refreshPlayer: boolean = false) { + (this.canvasPlayer || this.videoPlayer).updateOptions(options, refreshPlayer); + } + + getPlayerElement(elementType?: StreamPlayerElement) { + if (typeof elementType === 'undefined') { + elementType = this.playerType === StreamPlayerType.VIDEO ? StreamPlayerElement.VIDEO : StreamPlayerElement.CANVAS; + } + + if (elementType !== StreamPlayerElement.VIDEO) { + return this.canvasPlayer?.getCanvas(); + } + + return this.$video; + } + + getCanvasPlayer() { + return this.canvasPlayer; + } + + refreshPlayer() { + if (this.playerType === StreamPlayerType.VIDEO) { + this.videoPlayer.refreshPlayer(); + } else { + ScreenshotManager.getInstance().updateCanvasFilters('none'); + this.canvasPlayer?.refreshPlayer(); + } + + this.resizePlayer(); + } + + getVideoPlayerFilterStyle() { + throw new Error("Method not implemented."); + } + + private cleanUpCanvasPlayer() { + this.canvasPlayer?.destroy(); + this.canvasPlayer = null; + } + + destroy() { + this.cleanUpCanvasPlayer(); + } +} diff --git a/src/modules/stream-player.ts b/src/modules/stream-player.ts deleted file mode 100755 index 3b6a842..0000000 --- a/src/modules/stream-player.ts +++ /dev/null @@ -1,297 +0,0 @@ -import { isFullVersion } from "@macros/build" with { type: "macro" }; - -import { CE } from "@/utils/html"; -import { WebGL2Player } from "./player/webgl2-player"; -import { ScreenshotManager } from "@/utils/screenshot-manager"; -import { STATES } from "@/utils/global"; -import { GlobalPref, StreamPref } from "@/enums/pref-keys"; -import { getGlobalPref } from "@/utils/pref-utils"; -import { BX_FLAGS } from "@/utils/bx-flags"; -import { StreamPlayerType, StreamVideoProcessing, VideoPosition } from "@/enums/pref-values"; -import { getStreamPref } from "@/utils/pref-utils"; - - -export class StreamPlayer { - private $video: HTMLVideoElement; - private playerType: StreamPlayerType = StreamPlayerType.VIDEO; - - private options: StreamPlayerOptions = {}; - - private webGL2Player: WebGL2Player | null = null; - - private $videoCss: HTMLStyleElement | null = null; - private $usmMatrix: SVGFEConvolveMatrixElement | null = null; - - constructor($video: HTMLVideoElement, type: StreamPlayerType, options: StreamPlayerOptions) { - this.setupVideoElements(); - - this.$video = $video; - this.options = options || {}; - this.setPlayerType(type); - } - - private setupVideoElements() { - this.$videoCss = document.getElementById('bx-video-css') as HTMLStyleElement; - if (this.$videoCss) { - return; - } - - const $fragment = document.createDocumentFragment(); - - this.$videoCss = CE('style', { id: 'bx-video-css' }); - $fragment.appendChild(this.$videoCss); - - // Setup SVG filters - const $svg = CE('svg', { - id: 'bx-video-filters', - xmlns: 'http://www.w3.org/2000/svg', - class: 'bx-gone', - }, CE('defs', { xmlns: 'http://www.w3.org/2000/svg' }, - CE('filter', { - id: 'bx-filter-usm', - xmlns: 'http://www.w3.org/2000/svg', - }, this.$usmMatrix = CE('feConvolveMatrix', { - id: 'bx-filter-usm-matrix', - order: '3', - xmlns: 'http://www.w3.org/2000/svg', - }) as unknown as SVGFEConvolveMatrixElement), - ), - ); - $fragment.appendChild($svg); - document.documentElement.appendChild($fragment); - } - - private getVideoPlayerFilterStyle() { - const filters = []; - - const sharpness = this.options.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 matrix = `0 -1 0 -1 ${level} -1 0 -1 0`; - this.$usmMatrix?.setAttributeNS(null, 'kernelMatrix', matrix); - - filters.push(`url(#bx-filter-usm)`); - } - - const saturation = this.options.saturation || 100; - if (saturation != 100) { - filters.push(`saturate(${saturation}%)`); - } - - const contrast = this.options.contrast || 100; - if (contrast != 100) { - filters.push(`contrast(${contrast}%)`); - } - - const brightness = this.options.brightness || 100; - if (brightness != 100) { - filters.push(`brightness(${brightness}%)`); - } - - return filters.join(' '); - } - - private resizePlayer() { - const PREF_RATIO = getStreamPref(StreamPref.VIDEO_RATIO); - const $video = this.$video; - const isNativeTouchGame = STATES.currentStream.titleInfo?.details.hasNativeTouchSupport; - - let $webGL2Canvas; - if (this.playerType == StreamPlayerType.WEBGL2) { - $webGL2Canvas = this.webGL2Player?.getCanvas()!; - } - - let targetWidth; - let targetHeight; - let targetObjectFit; - - if (PREF_RATIO.includes(':')) { - const tmp = PREF_RATIO.split(':'); - - // Get preferred ratio - const videoRatio = parseFloat(tmp[0]) / parseFloat(tmp[1]); - - let width = 0; - let height = 0; - - // Get parent's ratio - const parentRect = $video.parentElement!.getBoundingClientRect(); - const parentRatio = parentRect.width / parentRect.height; - - // Get target width & height - if (parentRatio > videoRatio) { - height = parentRect.height; - width = height * videoRatio; - } else { - width = parentRect.width; - height = width / videoRatio; - } - - // Prevent floating points - width = Math.ceil(Math.min(parentRect.width, width)); - height = Math.ceil(Math.min(parentRect.height, height)); - - $video.dataset.width = width.toString(); - $video.dataset.height = height.toString(); - - // Set position - const $parent = $video.parentElement!; - const position = getStreamPref(StreamPref.VIDEO_POSITION); - $parent.style.removeProperty('padding-top'); - - $parent.dataset.position = position; - if (position === VideoPosition.TOP_HALF || position === VideoPosition.BOTTOM_HALF) { - let padding = Math.floor((window.innerHeight - height) / 4); - if (padding > 0) { - if (position === VideoPosition.BOTTOM_HALF) { - padding *= 3; - } - - $parent.style.paddingTop = padding + 'px'; - } - } - - // Update size - targetWidth = `${width}px`; - targetHeight = `${height}px`; - targetObjectFit = PREF_RATIO === '16:9' ? 'contain' : 'fill'; - } else { - targetWidth = '100%'; - targetHeight = '100%'; - targetObjectFit = PREF_RATIO; - - $video.dataset.width = window.innerWidth.toString(); - $video.dataset.height = window.innerHeight.toString(); - } - - $video.style.width = targetWidth; - $video.style.height = targetHeight; - $video.style.objectFit = targetObjectFit; - - // $video.style.padding = padding; - - if ($webGL2Canvas) { - $webGL2Canvas.style.width = targetWidth; - $webGL2Canvas.style.height = targetHeight; - $webGL2Canvas.style.objectFit = targetObjectFit; - - $video.dispatchEvent(new Event('resize')); - } - - // Update video dimensions - if (isNativeTouchGame && this.playerType == StreamPlayerType.WEBGL2) { - window.BX_EXPOSED.streamSession.updateDimensions(); - } - } - - setPlayerType(type: StreamPlayerType, refreshPlayer: boolean = false) { - if (this.playerType !== type) { - const videoClass = BX_FLAGS.DeviceInfo.deviceType === 'android-tv' ? 'bx-pixel' : 'bx-gone'; - - // Switch from Video -> WebGL2 - if (type === StreamPlayerType.WEBGL2) { - // Initialize WebGL2 player - if (!this.webGL2Player) { - this.webGL2Player = new WebGL2Player(this.$video); - } else { - this.webGL2Player.resume(); - } - - this.$videoCss!.textContent = ''; - - this.$video.classList.add(videoClass); - } else { - // Cleanup WebGL2 Player - this.webGL2Player?.stop(); - - this.$video.classList.remove(videoClass); - } - } - - this.playerType = type; - refreshPlayer && this.refreshPlayer(); - } - - setOptions(options: StreamPlayerOptions, refreshPlayer: boolean = false) { - this.options = options; - refreshPlayer && this.refreshPlayer(); - } - - updateOptions(options: StreamPlayerOptions, refreshPlayer: boolean = false) { - this.options = Object.assign(this.options, options); - refreshPlayer && this.refreshPlayer(); - } - - getPlayerElement(playerType?: StreamPlayerType) { - if (typeof playerType === 'undefined') { - playerType = this.playerType; - } - - if (playerType === StreamPlayerType.WEBGL2) { - return this.webGL2Player?.getCanvas(); - } - - return this.$video; - } - - getWebGL2Player() { - return this.webGL2Player; - } - - refreshPlayer() { - if (this.playerType === StreamPlayerType.WEBGL2) { - const options = this.options; - const webGL2Player = this.webGL2Player!; - - if (options.processing === StreamVideoProcessing.USM) { - webGL2Player.setFilter(1); - } else { - webGL2Player.setFilter(2); - } - - isFullVersion() && ScreenshotManager.getInstance().updateCanvasFilters('none'); - - webGL2Player.setSharpness(options.sharpness || 0); - webGL2Player.setSaturation(options.saturation || 100); - webGL2Player.setContrast(options.contrast || 100); - webGL2Player.setBrightness(options.brightness || 100); - } else { - let filters = this.getVideoPlayerFilterStyle(); - let videoCss = ''; - if (filters) { - videoCss += `filter: ${filters} !important;`; - } - - // Apply video filters to screenshots - if (isFullVersion() && getGlobalPref(GlobalPref.SCREENSHOT_APPLY_FILTERS)) { - ScreenshotManager.getInstance().updateCanvasFilters(filters); - } - - let css = ''; - if (videoCss) { - css = `#game-stream video { ${videoCss} }`; - } - - this.$videoCss!.textContent = css; - } - - this.resizePlayer(); - } - - reloadPlayer() { - this.cleanUpWebGL2Player(); - - this.playerType = StreamPlayerType.VIDEO; - this.setPlayerType(StreamPlayerType.WEBGL2, false); - } - - private cleanUpWebGL2Player() { - // Clean up WebGL2 Player - this.webGL2Player?.destroy(); - this.webGL2Player = null; - } - - destroy() { - this.cleanUpWebGL2Player(); - } -} diff --git a/src/modules/stream/stream-settings-utils.ts b/src/modules/stream/stream-settings-utils.ts index 7353b1a..3704aee 100755 --- a/src/modules/stream/stream-settings-utils.ts +++ b/src/modules/stream/stream-settings-utils.ts @@ -4,6 +4,7 @@ import { StreamPref } from "@/enums/pref-keys"; import { StreamVideoProcessing, StreamPlayerType } from "@/enums/pref-values"; import { getStreamPref, setStreamPref } from "@/utils/pref-utils"; import { SettingsManager } from "../settings-manager"; +import type { StreamPlayerOptions } from "@/types/stream"; export function onChangeVideoPlayerType() { const playerType = getStreamPref(StreamPref.VIDEO_PLAYER_TYPE); @@ -21,9 +22,7 @@ export function onChangeVideoPlayerType() { const $optCas = $videoProcessing.querySelector(`option[value=${StreamVideoProcessing.CAS}]`); - if (playerType === StreamPlayerType.WEBGL2) { - $optCas && ($optCas.disabled = false); - } else { + if (playerType === StreamPlayerType.VIDEO) { // Only allow USM when player type is Video $videoProcessing.value = StreamVideoProcessing.USM; setStreamPref(StreamPref.VIDEO_PROCESSING, StreamVideoProcessing.USM, 'direct'); @@ -33,6 +32,8 @@ export function onChangeVideoPlayerType() { if (UserAgent.isSafari()) { isDisabled = true; } + } else { + $optCas && ($optCas.disabled = false); } $videoProcessing.disabled = isDisabled; @@ -40,24 +41,22 @@ export function onChangeVideoPlayerType() { // Hide Power Preference setting if renderer isn't WebGL2 $videoPowerPreference.closest('.bx-settings-row')!.classList.toggle('bx-gone', playerType !== StreamPlayerType.WEBGL2); - $videoMaxFps.closest('.bx-settings-row')!.classList.toggle('bx-gone', playerType !== StreamPlayerType.WEBGL2); + $videoMaxFps.closest('.bx-settings-row')!.classList.toggle('bx-gone', playerType === StreamPlayerType.VIDEO); } export function limitVideoPlayerFps(targetFps: number) { - const streamPlayer = STATES.currentStream.streamPlayer; - streamPlayer?.getWebGL2Player()?.setTargetFps(targetFps); + const streamPlayer = STATES.currentStream.streamPlayerManager; + streamPlayer?.getCanvasPlayer()?.setTargetFps(targetFps); } export function updateVideoPlayer() { - const streamPlayer = STATES.currentStream.streamPlayer; - if (!streamPlayer) { + const streamPlayerManager = STATES.currentStream.streamPlayerManager; + if (!streamPlayerManager) { return; } - limitVideoPlayerFps(getStreamPref(StreamPref.VIDEO_MAX_FPS)); - const options = { processing: getStreamPref(StreamPref.VIDEO_PROCESSING), sharpness: getStreamPref(StreamPref.VIDEO_SHARPNESS), @@ -66,9 +65,15 @@ export function updateVideoPlayer() { brightness: getStreamPref(StreamPref.VIDEO_BRIGHTNESS), } satisfies StreamPlayerOptions; - streamPlayer.setPlayerType(getStreamPref(StreamPref.VIDEO_PLAYER_TYPE)); - streamPlayer.updateOptions(options); - streamPlayer.refreshPlayer(); + streamPlayerManager.switchPlayerType(getStreamPref(StreamPref.VIDEO_PLAYER_TYPE)); + limitVideoPlayerFps(getStreamPref(StreamPref.VIDEO_MAX_FPS)); + streamPlayerManager.updateOptions(options); + streamPlayerManager.refreshPlayer(); } -window.addEventListener('resize', updateVideoPlayer); +function resizeVideoPlayer() { + const streamPlayerManager = STATES.currentStream.streamPlayerManager; + streamPlayerManager?.resizePlayer(); +} + +window.addEventListener('resize', resizeVideoPlayer); diff --git a/src/types/index.d.ts b/src/types/index.d.ts index 5360675..0d08e7e 100755 --- a/src/types/index.d.ts +++ b/src/types/index.d.ts @@ -22,58 +22,6 @@ type ServerRegion = { contintent: ServerContinent; }; -type BxStates = { - supportedRegion: boolean; - serverRegions: Record; - selectedRegion: any; - gsToken: string; - isSignedIn: boolean; - - isPlaying: boolean; - - browser: { - capabilities: { - touch: boolean; - batteryApi: boolean; - deviceVibration: boolean; - mkb: boolean; - emulatedNativeMkb: boolean; - }; - }; - - userAgent: { - isTv: boolean; - capabilities: { - touch: boolean; - mkb: boolean; - }; - }; - - currentStream: Partial<{ - titleSlug: string; - titleInfo: XcloudTitleInfo; - xboxTitleId: number | null; - gameSpecificSettings: boolean; - - streamPlayer: StreamPlayer | null; - - peerConnection: RTCPeerConnection; - audioContext: AudioContext | null; - audioGainNode: GainNode | null; - }>; - - remotePlay: Partial<{ - isPlaying: boolean; - server: string; - config: { - serverId: string; - }; - titleId?: string; - }>; - - pointerServerPort: number; -} - type XcloudTitleInfo = { titleId: string, @@ -106,10 +54,12 @@ declare module '*.js' { const content: string; export default content; } + declare module '*.svg' { const content: string; export default content; } + declare module '*.styl' { const content: string; export default content; @@ -119,11 +69,17 @@ declare module '*.fs' { const content: string; export default content; } + declare module '*.vert' { const content: string; export default content; } +declare module '*.wgsl' { + const content: string; + export default content; +} + type MkbMouseMove = { movementX: number; movementY: number; diff --git a/src/types/states.d.ts b/src/types/states.d.ts new file mode 100644 index 0000000..e5237f8 --- /dev/null +++ b/src/types/states.d.ts @@ -0,0 +1,53 @@ +import type { StreamPlayerManager } from "@/modules/stream-player-manager"; + +type BxStates = { + supportedRegion: boolean; + serverRegions: Record; + selectedRegion: any; + gsToken: string; + isSignedIn: boolean; + + isPlaying: boolean; + + browser: { + capabilities: { + touch: boolean; + batteryApi: boolean; + deviceVibration: boolean; + mkb: boolean; + emulatedNativeMkb: boolean; + }; + }; + + userAgent: { + isTv: boolean; + capabilities: { + touch: boolean; + mkb: boolean; + }; + }; + + currentStream: Partial<{ + titleSlug: string; + titleInfo: XcloudTitleInfo; + xboxTitleId: number | null; + gameSpecificSettings: boolean; + + streamPlayerManager: StreamPlayerManager | null; + + peerConnection: RTCPeerConnection; + audioContext: AudioContext | null; + audioGainNode: GainNode | null; + }>; + + remotePlay: Partial<{ + isPlaying: boolean; + server: string; + config: { + serverId: string; + }; + titleId?: string; + }>; + + pointerServerPort: number; +} diff --git a/src/types/stream.d.ts b/src/types/stream.d.ts index ff4459e..60c4999 100644 --- a/src/types/stream.d.ts +++ b/src/types/stream.d.ts @@ -1,7 +1,9 @@ -type StreamPlayerOptions = Partial<{ - processing: string, +import type { StreamVideoProcessing } from "@/enums/pref-values"; + +type StreamPlayerOptions = { + processing: StreamVideoProcessing, sharpness: number, saturation: number, contrast: number, brightness: number, -}>; +}; diff --git a/src/utils/bx-event-bus.ts b/src/utils/bx-event-bus.ts index 5fee987..7f6d598 100644 --- a/src/utils/bx-event-bus.ts +++ b/src/utils/bx-event-bus.ts @@ -32,6 +32,8 @@ type ScriptEvents = { 'list.localCoOp.updated': { ids: Set; }; + + 'webgpu.ready': {}, }; type StreamEvents = { diff --git a/src/utils/feature-gates.ts b/src/utils/feature-gates.ts index 13c0653..0cdd557 100755 --- a/src/utils/feature-gates.ts +++ b/src/utils/feature-gates.ts @@ -9,6 +9,7 @@ export let FeatureGates: { [key: string]: boolean } = { EnableUpdateRequiredPage: false, ShowForcedUpdateScreen: false, EnableTakControlResizing: true, // Experimenting + EnableLazyLoadedHome: false, }; // Enable Native Mouse & Keyboard diff --git a/src/utils/global.ts b/src/utils/global.ts index cd9e37f..879f220 100755 --- a/src/utils/global.ts +++ b/src/utils/global.ts @@ -1,3 +1,4 @@ +import type { BxStates } from "@/types/states"; import { UserAgent } from "./user-agent"; export const SCRIPT_VERSION = Bun.env.SCRIPT_VERSION!; diff --git a/src/utils/monkey-patches.ts b/src/utils/monkey-patches.ts index e7e27e0..741e5a1 100755 --- a/src/utils/monkey-patches.ts +++ b/src/utils/monkey-patches.ts @@ -2,12 +2,13 @@ import { BxEvent } from "@utils/bx-event"; import { STATES } from "@utils/global"; import { BxLogger } from "@utils/bx-logger"; import { patchSdpBitrate, setCodecPreferences } from "./sdp"; -import { StreamPlayer } from "@/modules/stream-player"; +import { StreamPlayerManager } from "@/modules/stream-player-manager"; import { GlobalPref, StreamPref } from "@/enums/pref-keys"; import { CodecProfile } from "@/enums/pref-values"; import type { SettingDefinition } from "@/types/setting-definition"; import { BxEventBus } from "./bx-event-bus"; import { getGlobalPref, getGlobalPrefDefinition, getStreamPref } from "@/utils/pref-utils"; +import type { StreamPlayerOptions } from "@/types/stream"; export function patchVideoApi() { const PREF_SKIP_SPLASH_VIDEO = getGlobalPref(GlobalPref.UI_SKIP_SPLASH_VIDEO); @@ -26,7 +27,13 @@ export function patchVideoApi() { contrast: getStreamPref(StreamPref.VIDEO_CONTRAST), brightness: getStreamPref(StreamPref.VIDEO_BRIGHTNESS), } satisfies StreamPlayerOptions; - STATES.currentStream.streamPlayer = new StreamPlayer(this, getStreamPref(StreamPref.VIDEO_PLAYER_TYPE), playerOptions); + + const streamPlayerManager= StreamPlayerManager.getInstance(); + streamPlayerManager.setVideoElement(this); + streamPlayerManager.updateOptions(playerOptions, false); + streamPlayerManager.switchPlayerType(getStreamPref(StreamPref.VIDEO_PLAYER_TYPE)); + + STATES.currentStream.streamPlayerManager = streamPlayerManager; BxEventBus.Stream.emit('state.playing', { $video: this, @@ -231,6 +238,7 @@ export function patchCanvasContext() { } } + // @ts-ignore return nativeGetContext.apply(this, [contextType, contextAttributes]); } } diff --git a/src/utils/screenshot-manager.ts b/src/utils/screenshot-manager.ts index f2c609f..d50a6f0 100755 --- a/src/utils/screenshot-manager.ts +++ b/src/utils/screenshot-manager.ts @@ -2,8 +2,8 @@ import { AppInterface, STATES } from "./global"; import { CE } from "./html"; import { GlobalPref } from "@/enums/pref-keys"; import { BxLogger } from "./bx-logger"; -import { StreamPlayerType } from "@/enums/pref-values"; import { getGlobalPref } from "@/utils/pref-utils"; +import { StreamPlayerElement } from "@/modules/player/base-stream-player"; export class ScreenshotManager { @@ -42,36 +42,36 @@ export class ScreenshotManager { takeScreenshot(callback?: any) { const currentStream = STATES.currentStream; - const streamPlayer = currentStream.streamPlayer; + const streamPlayerManager = currentStream.streamPlayerManager; const $canvas = this.$canvas; - if (!streamPlayer || !$canvas) { + if (!streamPlayerManager || !$canvas) { return; } let $player; if (getGlobalPref(GlobalPref.SCREENSHOT_APPLY_FILTERS)) { - $player = streamPlayer.getPlayerElement(); + $player = streamPlayerManager.getPlayerElement(); } else { - $player = streamPlayer.getPlayerElement(StreamPlayerType.VIDEO); + $player = streamPlayerManager.getPlayerElement(StreamPlayerElement.VIDEO); } if (!$player || !$player.isConnected) { return; } + const canvasContext = this.canvasContext; + if ($player instanceof HTMLCanvasElement) { + streamPlayerManager.getCanvasPlayer()?.updateFrame(); + } + canvasContext.drawImage($player, 0, 0); + + // Play animation const $gameStream = $player.closest('#game-stream'); if ($gameStream) { $gameStream.addEventListener('animationend', this.onAnimationEnd, { once: true }); $gameStream.classList.add('bx-taking-screenshot'); } - const canvasContext = this.canvasContext; - - if ($player instanceof HTMLCanvasElement) { - streamPlayer.getWebGL2Player().forceDrawFrame(); - } - canvasContext.drawImage($player, 0, 0, $canvas.width, $canvas.height); - // Get data URL and pass to parent app if (AppInterface) { const data = $canvas.toDataURL('image/png').split(';base64,')[1]; diff --git a/src/utils/settings-storages/stream-settings-storage.ts b/src/utils/settings-storages/stream-settings-storage.ts index 6a9fa17..4a9ed55 100644 --- a/src/utils/settings-storages/stream-settings-storage.ts +++ b/src/utils/settings-storages/stream-settings-storage.ts @@ -12,6 +12,8 @@ import { GameSettingsStorage } from "./game-settings-storage"; import { BxLogger } from "../bx-logger"; import { ControllerCustomizationDefaultPresetId } from "../local-db/controller-customizations-table"; import { ControllerShortcutDefaultId } from "../local-db/controller-shortcuts-table"; +import { BxEventBus } from "../bx-event-bus"; +import { WebGPUPlayer } from "@/modules/player/webgpu/webgpu-player"; export class StreamSettingsStorage extends BaseSettingsStorage { @@ -150,11 +152,21 @@ export class StreamSettingsStorage extends BaseSettingsStorage { options: { [StreamPlayerType.VIDEO]: t('default'), [StreamPlayerType.WEBGL2]: t('webgl2'), + [StreamPlayerType.WEBGPU]: `${t('webgpu')} (${t('experimental')})`, }, suggest: { lowest: StreamPlayerType.VIDEO, highest: StreamPlayerType.WEBGL2, }, + ready: (setting: any) => { + BxEventBus.Script.on('webgpu.ready', () => { + if (!navigator.gpu || !WebGPUPlayer.device) { + // Remove WebGPU option on unsupported browsers + delete setting.options[StreamPlayerType.WEBGPU]; + } + } + ); + }, }, [StreamPref.VIDEO_PROCESSING]: { label: t('clarity-boost'), diff --git a/src/utils/translation.ts b/src/utils/translation.ts index 3d791e4..decf334 100755 --- a/src/utils/translation.ts +++ b/src/utils/translation.ts @@ -27,6 +27,7 @@ export const SUPPORTED_LANGUAGES = { }; const Texts = { + "webgpu": "WebGPU", "achievements": "Achievements", "activate": "Activate", "activated": "Activated", diff --git a/tsconfig.json b/tsconfig.json index d5b0413..5c72d90 100755 --- a/tsconfig.json +++ b/tsconfig.json @@ -16,6 +16,9 @@ "@modules/*": ["./modules/*"], "@utils/*": ["./utils/*"], }, + + "types": ["@types/bun", "@webgpu/types"], + // Enable latest features "lib": ["ESNext", "DOM"], "target": "ESNext",