mirror of
https://github.com/redphx/better-xcloud.git
synced 2025-06-28 18:31:44 +02:00
Compare commits
20 Commits
Author | SHA1 | Date | |
---|---|---|---|
63e5e90443 | |||
9034c173e7 | |||
5949e1e411 | |||
5ce7ade574 | |||
e45537adf0 | |||
f9c9dc9684 | |||
ff9a7962c5 | |||
d4f070f6bb | |||
66b1f92f4c | |||
7a69e7f284 | |||
664e865b82 | |||
7894dea5ff | |||
fd665b6fcd | |||
39ecef976c | |||
0d5fa0fc96 | |||
fccd84b7ef | |||
eb1c027c30 | |||
6a211db52e | |||
17dc7996b1 | |||
fe418e6918 |
2
build.sh
2
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
|
||||
|
9
bun.lock
9
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=="],
|
||||
|
2
dist/better-xcloud.meta.js
vendored
2
dist/better-xcloud.meta.js
vendored
@ -1,5 +1,5 @@
|
||||
// ==UserScript==
|
||||
// @name Better xCloud
|
||||
// @namespace https://github.com/redphx
|
||||
// @version 6.3.1
|
||||
// @version 6.4.0
|
||||
// ==/UserScript==
|
||||
|
710
dist/better-xcloud.pretty.user.js
vendored
710
dist/better-xcloud.pretty.user.js
vendored
File diff suppressed because one or more lines are too long
123
dist/better-xcloud.user.js
vendored
123
dist/better-xcloud.user.js
vendored
File diff suppressed because one or more lines are too long
@ -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"
|
||||
|
@ -24,8 +24,12 @@ How to:
|
||||
const enabled = true;
|
||||
|
||||
enabled && (window.BX_FLAGS = {
|
||||
// Toggle WebGPU Renderer
|
||||
// https://github.com/redphx/better-xcloud/discussions/657
|
||||
EnableWebGPURenderer: false,
|
||||
|
||||
/*
|
||||
Add titleId of the game(s) you want to add here.
|
||||
Add titleId of the game(s) you want to test native M&KB support here.
|
||||
Keep in mind: this method only works with some games.
|
||||
|
||||
Example:
|
||||
|
@ -51,7 +51,7 @@ button_color(name, normal, hover, active, disabled)
|
||||
}
|
||||
|
||||
/* Fix Stream menu buttons not hiding */
|
||||
div[class^=HUDButton-module__hiddenContainer] ~ div:not([class^=HUDButton-module__hiddenContainer]) {
|
||||
#StreamHud div[class^=HUDButton-module__hiddenContainer] ~ div:not([class^=HUDButton-module__hiddenContainer]) {
|
||||
opacity: 0;
|
||||
pointer-events: none !important;
|
||||
position: absolute;
|
||||
@ -182,16 +182,18 @@ select[multiple], select[multiple]:focus {
|
||||
display: none;
|
||||
}
|
||||
|
||||
div[class*=NotFocusedDialog] {
|
||||
position: absolute !important;
|
||||
top: -9999px !important;
|
||||
left: -9999px !important;
|
||||
width: 0px !important;
|
||||
height: 0px !important;
|
||||
}
|
||||
#game-stream {
|
||||
div[class^=NotFocusedDialog] {
|
||||
position: absolute !important;
|
||||
top: -9999px !important;
|
||||
left: -9999px !important;
|
||||
width: 0px !important;
|
||||
height: 0px !important;
|
||||
}
|
||||
|
||||
#game-stream video:not([src]) {
|
||||
visibility: hidden;
|
||||
video:not([src]) {
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.bx-game-tile-wait-time {
|
||||
|
@ -155,7 +155,6 @@
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
padding: 16px 10px;
|
||||
margin: 0;
|
||||
background: #2a2a2a;
|
||||
border-bottom: 1px solid #343434;
|
||||
|
||||
@ -305,6 +304,7 @@
|
||||
border-left: 4px solid orange !important;
|
||||
border-top-left-radius: 0 !important;
|
||||
border-bottom-left-radius: 0 !important;
|
||||
padding-left: 6px !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,4 +1,4 @@
|
||||
div[class*=StreamMenu-module__menuContainer] > div[class*=Menu-module] {
|
||||
#game-stream div[class^=StreamMenu-module__menuContainer] > div[class^=Menu-module] {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
|
@ -1,7 +1,9 @@
|
||||
export enum GamePassCloudGallery {
|
||||
ALL = '29a81209-df6f-41fd-a528-2ae6b91f719c',
|
||||
ALL_WITH_BYGO = 'ce573635-7c18-4d0c-9d68-90b932393470',
|
||||
LEAVING_SOON = '393f05bf-e596-4ef6-9487-6d4fa0eab987',
|
||||
MOST_POPULAR = 'e7590b22-e299-44db-ae22-25c61405454c',
|
||||
NATIVE_MKB = '8fa264dd-124f-4af3-97e8-596fcdf4b486',
|
||||
RECENTLY_ADDED = '44a55037-770f-4bbf-bde5-a9fa27dba1da',
|
||||
TOUCH = '9c86f07a-f3e8-45ad-82a0-a1f759597059',
|
||||
}
|
||||
|
@ -1,5 +1,5 @@
|
||||
import type { BaseSettingsStorage } from "@/utils/settings-storages/base-settings-storage";
|
||||
import type { BlockFeature, CodecProfile, DeviceVibrationMode, GameBarPosition, LoadingScreenRocket, NativeMkbMode, StreamPlayerType, StreamResolution, StreamStat, StreamStatPosition, StreamVideoProcessing, TouchControllerMode, TouchControllerStyleCustom, TouchControllerStyleStandard, UiLayout, UiSection, VideoPosition, VideoPowerPreference, VideoRatio } from "./pref-values"
|
||||
import type { BlockFeature, CodecProfile, DeviceVibrationMode, GameBarPosition, LoadingScreenRocket, NativeMkbMode, StreamPlayerType, StreamResolution, StreamStat, StreamStatPosition, StreamVideoProcessing, TouchControllerMode, TouchControllerStyleCustom, TouchControllerStyleStandard, UiLayout, UiSection, UiTheme, VideoPosition, VideoPowerPreference, VideoRatio } from "./pref-values"
|
||||
|
||||
export const enum StorageKey {
|
||||
GLOBAL = 'BetterXcloud',
|
||||
@ -74,6 +74,7 @@ export const enum GlobalPref {
|
||||
UI_HIDE_SYSTEM_MENU_ICON = 'ui.systemMenu.hideHandle',
|
||||
UI_REDUCE_ANIMATIONS = 'ui.reduceAnimations',
|
||||
UI_IMAGE_QUALITY = 'ui.imageQuality',
|
||||
UI_THEME = 'ui.theme',
|
||||
|
||||
AUDIO_MIC_ON_PLAYING = 'audio.mic.onPlaying',
|
||||
AUDIO_VOLUME_CONTROL_ENABLED = 'audio.volume.booster.enabled',
|
||||
@ -126,6 +127,7 @@ export type GlobalPrefTypeMap = {
|
||||
[GlobalPref.UI_SCROLLBAR_HIDE]: boolean;
|
||||
[GlobalPref.UI_SIMPLIFY_STREAM_MENU]: boolean;
|
||||
[GlobalPref.UI_SKIP_SPLASH_VIDEO]: boolean;
|
||||
[GlobalPref.UI_THEME]: UiTheme;
|
||||
[GlobalPref.VERSION_CURRENT]: string;
|
||||
[GlobalPref.VERSION_LAST_CHECK]: number;
|
||||
[GlobalPref.VERSION_LATEST]: string;
|
||||
@ -258,6 +260,7 @@ export const ALL_PREFS: {
|
||||
GlobalPref.UI_SCROLLBAR_HIDE,
|
||||
GlobalPref.UI_SIMPLIFY_STREAM_MENU,
|
||||
GlobalPref.UI_SKIP_SPLASH_VIDEO,
|
||||
GlobalPref.UI_THEME,
|
||||
GlobalPref.VERSION_CURRENT,
|
||||
GlobalPref.VERSION_LAST_CHECK,
|
||||
GlobalPref.VERSION_LATEST,
|
||||
|
@ -6,6 +6,9 @@ export const enum UiSection {
|
||||
NEWS = 'news',
|
||||
TOUCH = 'touch',
|
||||
BOYG = 'byog',
|
||||
RECENTLY_ADDED = 'recently-added',
|
||||
LEAVING_SOON = 'leaving-soon',
|
||||
GENRES = 'genres',
|
||||
}
|
||||
|
||||
export const enum GameBarPosition {
|
||||
@ -116,6 +119,7 @@ export const enum VideoPowerPreference {
|
||||
export const enum StreamPlayerType {
|
||||
VIDEO = 'default',
|
||||
WEBGL2 = 'webgl2',
|
||||
WEBGPU = 'webgpu',
|
||||
}
|
||||
|
||||
export const enum StreamVideoProcessing {
|
||||
@ -130,3 +134,8 @@ export const enum BlockFeature {
|
||||
NOTIFICATIONS_INVITES = 'notifications-invites',
|
||||
NOTIFICATIONS_ACHIEVEMENTS = 'notifications-achievements',
|
||||
}
|
||||
|
||||
export const enum UiTheme {
|
||||
DEFAULT = 'default',
|
||||
DARK_OLED = 'dark-oled',
|
||||
}
|
||||
|
@ -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();
|
||||
|
@ -744,16 +744,8 @@ true` + text;
|
||||
// Don't render "All Games" sections
|
||||
ignoreAllGamesSection(str: string) {
|
||||
let index = str.indexOf('className:"AllGamesRow-module__allGamesRowContainer');
|
||||
if (index < 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
index = PatcherUtils.indexOf(str, 'grid:!0,', index, 1500);
|
||||
if (index < 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
index = PatcherUtils.lastIndexOf(str, '(0,', index, 70);
|
||||
index > -1 && (index = PatcherUtils.indexOf(str, 'grid:!0,', index, 1500));
|
||||
index > -1 && (index = PatcherUtils.lastIndexOf(str, '(0,', index, 70));
|
||||
if (index < 0) {
|
||||
return false;
|
||||
}
|
||||
@ -762,6 +754,18 @@ true` + text;
|
||||
return str;
|
||||
},
|
||||
|
||||
ignoreByogSection(str: string) {
|
||||
let index = str.indexOf('"ByogRow-module__container');
|
||||
index > -1 && (index = PatcherUtils.lastIndexOf(str, 'return', index, 100));
|
||||
|
||||
if (index < 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
str = PatcherUtils.insertAt(str, index, 'return null;');
|
||||
return str;
|
||||
},
|
||||
|
||||
// home-page.js
|
||||
ignorePlayWithTouchSection(str: string) {
|
||||
let index = str.indexOf('("Play_With_Touch"),');
|
||||
@ -796,6 +800,8 @@ true` + text;
|
||||
const sections: PartialRecord<UiSection, GamePassCloudGallery> = {
|
||||
[UiSection.NATIVE_MKB]: GamePassCloudGallery.NATIVE_MKB,
|
||||
[UiSection.MOST_POPULAR]: GamePassCloudGallery.MOST_POPULAR,
|
||||
[UiSection.LEAVING_SOON]: GamePassCloudGallery.LEAVING_SOON,
|
||||
[UiSection.RECENTLY_ADDED]: GamePassCloudGallery.RECENTLY_ADDED,
|
||||
};
|
||||
|
||||
for (const section of PREF_HIDE_SECTIONS) {
|
||||
@ -817,6 +823,17 @@ if (e && e.id) {
|
||||
return str;
|
||||
},
|
||||
|
||||
ignoreGenresSection(str: string) {
|
||||
let index = str.indexOf('="GenresRow"');
|
||||
index > -1 && (index = PatcherUtils.lastIndexOf(str, '{', index));
|
||||
if (index < 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
str = PatcherUtils.insertAt(str, index + 1, 'return null;');
|
||||
return str;
|
||||
},
|
||||
|
||||
// Override Storage.getSettings()
|
||||
overrideStorageGetSettings(str: string) {
|
||||
let text = '}getSetting(e){';
|
||||
@ -1191,8 +1208,11 @@ let PATCH_ORDERS = PatcherUtils.filterPatches([
|
||||
const hideSections = getGlobalPref(GlobalPref.UI_HIDE_SECTIONS);
|
||||
let HOME_PAGE_PATCH_ORDERS = PatcherUtils.filterPatches([
|
||||
hideSections.includes(UiSection.NEWS) && 'ignoreNewsSection',
|
||||
hideSections.includes(UiSection.FRIENDS) && 'ignorePlayWithFriendsSection',
|
||||
(getGlobalPref(GlobalPref.BLOCK_FEATURES).includes(BlockFeature.FRIENDS) || hideSections.includes(UiSection.FRIENDS)) && 'ignorePlayWithFriendsSection',
|
||||
hideSections.includes(UiSection.ALL_GAMES) && 'ignoreAllGamesSection',
|
||||
hideSections.includes(UiSection.GENRES) && 'ignoreGenresSection',
|
||||
!getGlobalPref(GlobalPref.BLOCK_FEATURES).includes(BlockFeature.BYOG) && hideSections.includes(UiSection.BOYG) && 'ignoreByogSection',
|
||||
|
||||
STATES.browser.capabilities.touch && hideSections.includes(UiSection.TOUCH) && 'ignorePlayWithTouchSection',
|
||||
hideSections.some(value => [UiSection.NATIVE_MKB, UiSection.MOST_POPULAR].includes(value)) && 'ignoreSiglSections',
|
||||
|
||||
|
119
src/modules/player/base-canvas-player.ts
Normal file
119
src/modules/player/base-canvas-player.ts
Normal file
@ -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<void> {
|
||||
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;
|
||||
}
|
48
src/modules/player/base-stream-player.ts
Normal file
48
src/modules/player/base-stream-player.ts
Normal file
@ -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<StreamPlayerOptions>, refresh=false) {
|
||||
this.options = Object.assign(this.options, newOptions);
|
||||
refresh && this.refreshPlayer();
|
||||
}
|
||||
|
||||
abstract refreshPlayer(): void;
|
||||
}
|
102
src/modules/player/video/video-player.ts
Normal file
102
src/modules/player/video/video-player.ts
Normal file
@ -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(' ');
|
||||
}
|
||||
}
|
@ -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<any> = [];
|
||||
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;
|
||||
}
|
||||
}
|
@ -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;
|
141
src/modules/player/webgl2/webgl2-player.ts
Executable file
141
src/modules/player/webgl2/webgl2-player.ts
Executable file
@ -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<WebGLBuffer | WebGLTexture | WebGLProgram | WebGLShader> = [];
|
||||
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<void> {
|
||||
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();
|
||||
}
|
||||
}
|
93
src/modules/player/webgpu/shaders/clarity-boost.wgsl
Normal file
93
src/modules/player/webgpu/shaders/clarity-boost.wgsl
Normal file
@ -0,0 +1,93 @@
|
||||
struct Params {
|
||||
filterId: f32,
|
||||
sharpness: f32,
|
||||
brightness: f32,
|
||||
contrast: f32,
|
||||
saturation: f32,
|
||||
};
|
||||
|
||||
|
||||
struct VertexOutput {
|
||||
@builtin(position) position: vec4<f32>,
|
||||
@location(0) uv: vec2<f32>,
|
||||
};
|
||||
|
||||
|
||||
@group(0) @binding(0) var ourSampler: sampler;
|
||||
@group(0) @binding(1) var ourTexture: texture_external;
|
||||
@group(0) @binding(2) var<uniform> 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<f32>) -> 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<f32>, texSize: vec2<f32>, e: vec3<f32>) -> vec3<f32> {
|
||||
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<f32> {
|
||||
let texSize = vec2<f32>(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);
|
||||
}
|
187
src/modules/player/webgpu/webgpu-player.ts
Normal file
187
src/modules/player/webgpu/webgpu-player.ts
Normal file
@ -0,0 +1,187 @@
|
||||
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";
|
||||
import { BX_FLAGS } from "@/utils/bx-flags";
|
||||
|
||||
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<void> {
|
||||
if (!BX_FLAGS.EnableWebGPURenderer || !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.');
|
||||
}
|
||||
}
|
@ -18,7 +18,7 @@ import { EmulatedMkbHandler } from "./mkb/mkb-handler";
|
||||
type SettingType = Partial<{
|
||||
hidden: true;
|
||||
onChange: () => void;
|
||||
alwaysTriggerOnChange: boolean; // Always trigger onChange(), not just when playing
|
||||
onChangeUi: () => void;
|
||||
$element: HTMLElement;
|
||||
}>;
|
||||
|
||||
@ -67,23 +67,16 @@ export class SettingsManager {
|
||||
},
|
||||
},
|
||||
[StreamPref.VIDEO_PLAYER_TYPE]: {
|
||||
onChange: () => {
|
||||
onChangeVideoPlayerType();
|
||||
|
||||
if (STATES.isPlaying) {
|
||||
updateVideoPlayer();
|
||||
}
|
||||
},
|
||||
alwaysTriggerOnChange: true,
|
||||
onChange: updateVideoPlayer,
|
||||
onChangeUi: onChangeVideoPlayerType,
|
||||
},
|
||||
[StreamPref.VIDEO_POWER_PREFERENCE]: {
|
||||
onChange: () => {
|
||||
const streamPlayer = STATES.currentStream.streamPlayer;
|
||||
const streamPlayer = STATES.currentStream.streamPlayerManager;
|
||||
if (!streamPlayer) {
|
||||
return;
|
||||
}
|
||||
|
||||
streamPlayer.reloadPlayer();
|
||||
updateVideoPlayer();
|
||||
},
|
||||
},
|
||||
@ -177,11 +170,21 @@ export class SettingsManager {
|
||||
this.renderStreamSettingsSelection();
|
||||
}
|
||||
|
||||
private updateStreamElement(key: StreamPref, onChanges?: Set<SettingType['onChange']>) {
|
||||
private updateStreamElement(key: StreamPref, onChanges?: Set<SettingType['onChange']>, onChangeUis?: Set<SettingType['onChangeUi']>) {
|
||||
const info = this.SETTINGS[key];
|
||||
|
||||
// Add event
|
||||
if (info.onChange && (STATES.isPlaying || info.alwaysTriggerOnChange)) {
|
||||
// Add events
|
||||
if (info.onChangeUi) {
|
||||
if (onChangeUis) {
|
||||
// Save to a Set()
|
||||
onChangeUis.add(info.onChangeUi);
|
||||
} else {
|
||||
// Trigger onChangeUi()
|
||||
info.onChangeUi();
|
||||
}
|
||||
}
|
||||
|
||||
if (info.onChange && STATES.isPlaying) {
|
||||
if (onChanges) {
|
||||
// Save to a Set()
|
||||
onChanges.add(info.onChange);
|
||||
@ -198,7 +201,6 @@ export class SettingsManager {
|
||||
}
|
||||
|
||||
const value = getGamePref(this.targetGameId, key, true)!;
|
||||
|
||||
if ('setValue' in $elm) {
|
||||
($elm as any).setValue(value);
|
||||
} else {
|
||||
@ -218,6 +220,7 @@ export class SettingsManager {
|
||||
|
||||
// Re-apply all stream settings
|
||||
const onChanges: Set<SettingType['onChange']> = new Set();
|
||||
const onChangeUis: Set<SettingType['onChangeUi']> = new Set();
|
||||
const oldGameId = this.targetGameId;
|
||||
this.targetGameId = id;
|
||||
|
||||
@ -227,21 +230,20 @@ export class SettingsManager {
|
||||
continue;
|
||||
}
|
||||
|
||||
const oldValue = getGamePref(oldGameId, key, true, true);
|
||||
const newValue = getGamePref(this.targetGameId, key, true, true);
|
||||
const oldValue = getGamePref(oldGameId, key, true);
|
||||
const newValue = getGamePref(this.targetGameId, key, true);
|
||||
|
||||
if (oldValue === newValue) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Only apply Stream settings
|
||||
this.updateStreamElement(key, onChanges);
|
||||
this.updateStreamElement(key, onChanges, onChangeUis);
|
||||
}
|
||||
|
||||
// BxLogger.warning('Settings Manager', onChanges);
|
||||
onChanges.forEach(onChange => {
|
||||
onChange && onChange();
|
||||
});
|
||||
// Trigger onChange callbacks
|
||||
onChangeUis.forEach(fn => fn && fn());
|
||||
onChanges.forEach(fn => fn && fn());
|
||||
|
||||
// Toggle tips if not playing anything
|
||||
this.$tips.classList.toggle('bx-gone', id < 0);
|
||||
@ -314,8 +316,7 @@ export class SettingsManager {
|
||||
|
||||
// Only switch to game settings if it's not empty
|
||||
const gameSettings = STORAGE.Stream.getGameSettings(id);
|
||||
const customSettings = gameSettings && !gameSettings.isEmpty();
|
||||
const selectedId = customSettings ? id : -1;
|
||||
const selectedId = (gameSettings && !gameSettings.isEmpty()) ? id : -1;
|
||||
|
||||
setGameIdPref(selectedId);
|
||||
|
||||
|
191
src/modules/stream-player-manager.ts
Executable file
191
src/modules/stream-player-manager.ts
Executable file
@ -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 (BX_FLAGS.EnableWebGPURenderer && 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();
|
||||
}
|
||||
}
|
@ -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();
|
||||
}
|
||||
}
|
@ -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<HTMLOptionElement>(`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);
|
||||
|
@ -272,6 +272,7 @@ export class SettingsDialog extends NavigationDialog {
|
||||
label: t('ui'),
|
||||
items: [
|
||||
GlobalPref.UI_LAYOUT,
|
||||
GlobalPref.UI_THEME,
|
||||
GlobalPref.UI_IMAGE_QUALITY,
|
||||
GlobalPref.UI_GAME_CARD_SHOW_WAIT_TIME,
|
||||
GlobalPref.UI_CONTROLLER_SHOW_STATUS,
|
||||
@ -1310,8 +1311,7 @@ export class SettingsDialog extends NavigationDialog {
|
||||
}
|
||||
|
||||
// Delete settings
|
||||
const gameSettings = STORAGE.Stream.getGameSettings(targetGameId);
|
||||
const deleted = gameSettings?.deleteSetting(pref);
|
||||
const deleted = STORAGE.Stream.deleteSettingByGame(targetGameId, pref);
|
||||
if (deleted) {
|
||||
BxEventBus.Stream.emit('setting.changed', {
|
||||
storageKey: `${StorageKey.STREAM}.${targetGameId}`,
|
||||
|
62
src/types/index.d.ts
vendored
62
src/types/index.d.ts
vendored
@ -22,58 +22,6 @@ type ServerRegion = {
|
||||
contintent: ServerContinent;
|
||||
};
|
||||
|
||||
type BxStates = {
|
||||
supportedRegion: boolean;
|
||||
serverRegions: Record<string, ServerRegion>;
|
||||
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;
|
||||
@ -222,6 +178,8 @@ type BxFlags = {
|
||||
EnableXcloudLogging: boolean;
|
||||
SafariWorkaround: boolean;
|
||||
|
||||
EnableWebGPURenderer: boolean;
|
||||
|
||||
ForceNativeMkbTitles: string[];
|
||||
FeatureGates: { [key: string]: boolean } | null,
|
||||
|
||||
|
53
src/types/states.d.ts
vendored
Normal file
53
src/types/states.d.ts
vendored
Normal file
@ -0,0 +1,53 @@
|
||||
import type { StreamPlayerManager } from "@/modules/stream-player-manager";
|
||||
|
||||
type BxStates = {
|
||||
supportedRegion: boolean;
|
||||
serverRegions: Record<string, ServerRegion>;
|
||||
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;
|
||||
}
|
8
src/types/stream.d.ts
vendored
8
src/types/stream.d.ts
vendored
@ -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,
|
||||
}>;
|
||||
};
|
||||
|
@ -32,6 +32,8 @@ type ScriptEvents = {
|
||||
'list.localCoOp.updated': {
|
||||
ids: Set<string>;
|
||||
};
|
||||
|
||||
'webgpu.ready': {},
|
||||
};
|
||||
|
||||
type StreamEvents = {
|
||||
|
@ -81,6 +81,14 @@ export const BxExposed = {
|
||||
BxLogger.error(LOG_TAG, e);
|
||||
}
|
||||
|
||||
// Disable header & footer
|
||||
try {
|
||||
state.uhf.headerMode = 'Off';
|
||||
state.uhf.footerMode = 'Off';
|
||||
} catch (e) {
|
||||
BxLogger.error(LOG_TAG, e);
|
||||
}
|
||||
|
||||
// Redirect to /en-US/play if visiting from an unsupported region
|
||||
try {
|
||||
const xCloud = state.xcloud.authentication.authStatusByStrategy.XCloud;
|
||||
|
@ -8,6 +8,8 @@ const DEFAULT_FLAGS: BxFlags = {
|
||||
EnableXcloudLogging: false,
|
||||
SafariWorkaround: true,
|
||||
|
||||
EnableWebGPURenderer: false,
|
||||
|
||||
ForceNativeMkbTitles: [],
|
||||
FeatureGates: null,
|
||||
|
||||
|
@ -1,8 +1,9 @@
|
||||
import { CE } from "@utils/html";
|
||||
import { compressCss, isLiteVersion, renderStylus } from "@macros/build" with { type: "macro" };
|
||||
import { BlockFeature, UiSection } from "@/enums/pref-values";
|
||||
import { BlockFeature, UiSection, UiTheme } from "@/enums/pref-values";
|
||||
import { GlobalPref } from "@/enums/pref-keys";
|
||||
import { getGlobalPref } from "./pref-utils";
|
||||
import { containsAll } from "./utils";
|
||||
|
||||
|
||||
export function addCss() {
|
||||
@ -14,8 +15,8 @@ export function addCss() {
|
||||
|
||||
if (isLiteVersion()) {
|
||||
// Hide Controller icon in Game tiles
|
||||
selectorToHide.push('div[class*=SupportedInputsBadge] svg:first-of-type');
|
||||
selectorToHide.push('div[class*=SupportedInputsBadge]:not(:has(:nth-child(2)))');
|
||||
selectorToHide.push('div[role=img][class*=SupportedInputsBadge] svg:first-of-type');
|
||||
selectorToHide.push('div[role=img][class*=SupportedInputsBadge]:not(:has(:nth-child(2)))');
|
||||
}
|
||||
|
||||
// Hide "News" section
|
||||
@ -24,7 +25,7 @@ export function addCss() {
|
||||
}
|
||||
|
||||
// Hide BYOG section
|
||||
if (getGlobalPref(GlobalPref.BLOCK_FEATURES).includes(BlockFeature.BYOG)) {
|
||||
if (getGlobalPref(GlobalPref.BLOCK_FEATURES).includes(BlockFeature.BYOG) || getGlobalPref(GlobalPref.UI_HIDE_SECTIONS).includes(UiSection.BOYG)) {
|
||||
selectorToHide.push('#BodyContent > div[class*=ByogRow-module__container___]');
|
||||
}
|
||||
|
||||
@ -44,6 +45,21 @@ export function addCss() {
|
||||
selectorToHide.push('#BodyContent div[class*=HomePage-module__bottomSpacing]:has(a[href="/play/gallery/touch"])');
|
||||
}
|
||||
|
||||
// Hide "Recently added" section
|
||||
if (PREF_HIDE_SECTIONS.includes(UiSection.RECENTLY_ADDED)) {
|
||||
selectorToHide.push('#BodyContent div[class*=HomePage-module__bottomSpacing]:has(a[href="/play/gallery/recently-added"])');
|
||||
}
|
||||
|
||||
// Hide "Genres section"
|
||||
if (PREF_HIDE_SECTIONS.includes(UiSection.GENRES)) {
|
||||
selectorToHide.push('#BodyContent div[class*=HomePage-module__genresRow]');
|
||||
}
|
||||
|
||||
// Hide "GamePassPromo"
|
||||
if (containsAll(PREF_HIDE_SECTIONS, [UiSection.RECENTLY_ADDED, UiSection.LEAVING_SOON, UiSection.GENRES, UiSection.ALL_GAMES])) {
|
||||
selectorToHide.push('#BodyContent div[class*=GamePassPromoSection-module__container]');
|
||||
}
|
||||
|
||||
// Hide "Start a party" button in the Guide menu
|
||||
if (getGlobalPref(GlobalPref.BLOCK_FEATURES).includes(BlockFeature.FRIENDS)) {
|
||||
selectorToHide.push('#gamepass-dialog-root div[class^=AchievementsPreview-module__container] + button[class*=HomeLandingPage-module__button]');
|
||||
@ -53,12 +69,31 @@ export function addCss() {
|
||||
css += selectorToHide.join(',') + '{ display: none; }';
|
||||
}
|
||||
|
||||
// Change site's background
|
||||
if (getGlobalPref(GlobalPref.UI_THEME) === UiTheme.DARK_OLED) {
|
||||
css += compressCss(`
|
||||
body[data-theme=dark] {
|
||||
--gds-containerSolidAppBackground: #000 !important;
|
||||
}
|
||||
|
||||
div[aria-hidden=true][class^=BackgroundImageAbsoluteContainer][class*=ProductDetailPage-module__backgroundImageGradient]:after {
|
||||
background: radial-gradient(ellipse 100% 100% at 50% 0, #1515178c 0, #1a1b1ea6 32%, #000000 100%) !important;
|
||||
}
|
||||
|
||||
a[href="/play/gallery/all-games"][class*=AllGamesRow-module__seeAllCloudGames] {
|
||||
background: none !important;
|
||||
}
|
||||
`);
|
||||
}
|
||||
|
||||
// Reduce animations
|
||||
if (getGlobalPref(GlobalPref.UI_REDUCE_ANIMATIONS)) {
|
||||
css += compressCss(`
|
||||
div[class*=GameCard-module__gameTitleInnerWrapper],
|
||||
div[class*=GameCard-module__card],
|
||||
div[class*=ScrollArrows-module] {
|
||||
/*div[class*=GameCard-module__card],*/
|
||||
div[class^=GameCard-module__gameTitleInnerWrapper],
|
||||
div[class^=ScrollArrows-module],
|
||||
div[class^=ContextMenu-module__][class*=Dropdown-module__dropdownWrapper] {
|
||||
animation: none !important;
|
||||
transition: none !important;
|
||||
}
|
||||
`);
|
||||
@ -67,32 +102,32 @@ div[class*=ScrollArrows-module] {
|
||||
// Hide the top-left dots icon while playing
|
||||
if (getGlobalPref(GlobalPref.UI_HIDE_SYSTEM_MENU_ICON)) {
|
||||
css += compressCss(`
|
||||
div[class*=Grip-module__container] {
|
||||
#StreamHud div[class^=Grip-module__container] {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
button[class*=GripHandle-module__container]:hover div[class*=Grip-module__container] {
|
||||
#StreamHud button[class^=GripHandle-module__container]:hover div[class^=Grip-module__container] {
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
|
||||
button[class*=GripHandle-module__container][aria-expanded=true] div[class*=Grip-module__container] {
|
||||
#StreamHud button[class^=GripHandle-module__container][aria-expanded=true] div[class^=Grip-module__container] {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
button[class*=GripHandle-module__container][aria-expanded=false] {
|
||||
#StreamHud button[class^=GripHandle-module__container][aria-expanded=false] {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
div[class*=StreamHUD-module__buttonsContainer] {
|
||||
#StreamHud div[class^=StreamHUD-module__buttonsContainer] {
|
||||
padding: 0px !important;
|
||||
}
|
||||
`);
|
||||
}
|
||||
|
||||
css += compressCss(`
|
||||
div[class*=StreamMenu-module__menu] {
|
||||
#game-stream div[class*=StreamMenu-module__menu] {
|
||||
min-width: 100vw !important;
|
||||
}
|
||||
`);
|
||||
@ -100,7 +135,7 @@ div[class*=StreamMenu-module__menu] {
|
||||
// Simplify Stream's menu
|
||||
if (getGlobalPref(GlobalPref.UI_SIMPLIFY_STREAM_MENU)) {
|
||||
css += compressCss(`
|
||||
div[class*=Menu-module__scrollable] {
|
||||
#game-stream div[class*=Menu-module__scrollable] {
|
||||
--bxStreamMenuItemSize: 80px;
|
||||
--streamMenuItemSize: calc(var(--bxStreamMenuItemSize) + 40px) !important;
|
||||
}
|
||||
@ -113,18 +148,18 @@ body[data-media-type=tv] .bx-badges {
|
||||
top: calc(var(--streamMenuItemSize) - 10px) !important;
|
||||
}
|
||||
|
||||
button[class*=MenuItem-module__container] {
|
||||
#game-stream button[class*=MenuItem-module__container] {
|
||||
min-width: auto !important;
|
||||
min-height: auto !important;
|
||||
width: var(--bxStreamMenuItemSize) !important;
|
||||
height: var(--bxStreamMenuItemSize) !important;
|
||||
}
|
||||
|
||||
div[class*=MenuItem-module__label] {
|
||||
#game-stream div[class*=MenuItem-module__label] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
svg[class*=MenuItem-module__icon] {
|
||||
#game-stream svg[class*=MenuItem-module__icon] {
|
||||
width: 36px;
|
||||
height: 100% !important;
|
||||
padding: 0 !important;
|
||||
|
@ -9,6 +9,7 @@ export let FeatureGates: { [key: string]: boolean } = {
|
||||
EnableUpdateRequiredPage: false,
|
||||
ShowForcedUpdateScreen: false,
|
||||
EnableTakControlResizing: true, // Experimenting
|
||||
EnableLazyLoadedHome: false,
|
||||
};
|
||||
|
||||
// Enable Native Mouse & Keyboard
|
||||
|
@ -1,3 +1,4 @@
|
||||
import type { BxStates } from "@/types/states";
|
||||
import { UserAgent } from "./user-agent";
|
||||
|
||||
export const SCRIPT_VERSION = Bun.env.SCRIPT_VERSION!;
|
||||
|
@ -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]);
|
||||
}
|
||||
}
|
||||
|
@ -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];
|
||||
|
@ -63,7 +63,7 @@ export class BaseSettingsStorage<T extends AnyPref> {
|
||||
return this.definitions[key];
|
||||
}
|
||||
|
||||
hasSetting<K extends keyof PrefTypeMap<K>>(key: K): boolean {
|
||||
hasSetting(key: T): boolean {
|
||||
return key in this.settings;
|
||||
}
|
||||
|
||||
@ -196,4 +196,15 @@ export class BaseSettingsStorage<T extends AnyPref> {
|
||||
|
||||
return value.toString();
|
||||
}
|
||||
|
||||
deleteSetting(pref: T) {
|
||||
if (this.hasSetting(pref)) {
|
||||
delete this.settings[pref];
|
||||
this.saveSettings();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
@ -10,15 +10,4 @@ export class GameSettingsStorage extends BaseSettingsStorage<StreamPref> {
|
||||
isEmpty() {
|
||||
return Object.keys(this.settings).length === 0;
|
||||
}
|
||||
|
||||
deleteSetting(pref: StreamPref) {
|
||||
if (this.hasSetting(pref)) {
|
||||
delete this.settings[pref];
|
||||
this.saveSettings();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
@ -8,7 +8,7 @@ import { CE } from "../html";
|
||||
import { t, SUPPORTED_LANGUAGES } from "../translation";
|
||||
import { UserAgent } from "../user-agent";
|
||||
import { BaseSettingsStorage } from "./base-settings-storage";
|
||||
import { CodecProfile, StreamResolution, TouchControllerMode, TouchControllerStyleStandard, TouchControllerStyleCustom, GameBarPosition, NativeMkbMode, UiLayout, UiSection, BlockFeature } from "@/enums/pref-values";
|
||||
import { CodecProfile, StreamResolution, TouchControllerMode, TouchControllerStyleStandard, TouchControllerStyleCustom, GameBarPosition, NativeMkbMode, UiLayout, UiSection, BlockFeature, UiTheme } from "@/enums/pref-values";
|
||||
import { GhPagesUtils } from "../gh-pages";
|
||||
import { BxEventBus } from "../bx-event-bus";
|
||||
import { BxIcon } from "../bx-icon";
|
||||
@ -210,6 +210,14 @@ export class GlobalSettingsStorage extends BaseSettingsStorage<GlobalPref> {
|
||||
},
|
||||
},
|
||||
},
|
||||
[GlobalPref.UI_THEME]: {
|
||||
label: t('theme'),
|
||||
default: UiTheme.DEFAULT,
|
||||
options: {
|
||||
[UiTheme.DEFAULT]: t('default'),
|
||||
[UiTheme.DARK_OLED]: t('oled'),
|
||||
},
|
||||
},
|
||||
|
||||
[GlobalPref.STREAM_COMBINE_SOURCES]: {
|
||||
requiredVariants: 'full',
|
||||
@ -457,8 +465,11 @@ export class GlobalSettingsStorage extends BaseSettingsStorage<GlobalPref> {
|
||||
[UiSection.FRIENDS]: t('section-play-with-friends'),
|
||||
[UiSection.NATIVE_MKB]: t('section-native-mkb'),
|
||||
[UiSection.TOUCH]: t('section-touch'),
|
||||
// [UiSection.BOYG]: t('section-byog'),
|
||||
[UiSection.MOST_POPULAR]: t('section-most-popular'),
|
||||
[UiSection.BOYG]: t('stream-your-own-game'),
|
||||
[UiSection.RECENTLY_ADDED]: t('section-recently-added'),
|
||||
[UiSection.LEAVING_SOON]: t('section-leaving-soon'),
|
||||
[UiSection.GENRES]: t('section-genres'),
|
||||
[UiSection.ALL_GAMES]: t('section-all-games'),
|
||||
},
|
||||
params: {
|
||||
|
@ -12,6 +12,9 @@ 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";
|
||||
import { BX_FLAGS } from "../bx-flags";
|
||||
|
||||
|
||||
export class StreamSettingsStorage extends BaseSettingsStorage<StreamPref> {
|
||||
@ -150,11 +153,20 @@ export class StreamSettingsStorage extends BaseSettingsStorage<StreamPref> {
|
||||
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 (!WebGPUPlayer.device) {
|
||||
// Remove WebGPU option on unsupported browsers
|
||||
delete setting.options[StreamPlayerType.WEBGPU];
|
||||
}
|
||||
});
|
||||
},
|
||||
},
|
||||
[StreamPref.VIDEO_PROCESSING]: {
|
||||
label: t('clarity-boost'),
|
||||
@ -398,7 +410,13 @@ export class StreamSettingsStorage extends BaseSettingsStorage<StreamPref> {
|
||||
getGameSettings(id: number) {
|
||||
if (id > -1) {
|
||||
if (!this.gameSettings[id]) {
|
||||
this.gameSettings[id] = new GameSettingsStorage(id);
|
||||
const gameStorage = new GameSettingsStorage(id);
|
||||
this.gameSettings[id] = gameStorage;
|
||||
|
||||
// Remove values same as global's
|
||||
for (const key in gameStorage.settings) {
|
||||
this.getSettingByGame(id, key);
|
||||
}
|
||||
}
|
||||
|
||||
return this.gameSettings[id];
|
||||
@ -408,20 +426,25 @@ export class StreamSettingsStorage extends BaseSettingsStorage<StreamPref> {
|
||||
}
|
||||
|
||||
getSetting<K extends keyof PrefTypeMap<K>>(key: K, checkUnsupported?: boolean): PrefTypeMap<K>[K] {
|
||||
return this.getSettingByGame(this.xboxTitleId, key, true, checkUnsupported)!;
|
||||
return this.getSettingByGame(this.xboxTitleId, key, checkUnsupported)!;
|
||||
}
|
||||
|
||||
getSettingByGame<K extends keyof PrefTypeMap<K>>(id: number, key: K, returnBaseValue: boolean=true, checkUnsupported?: boolean): PrefTypeMap<K>[K] | undefined {
|
||||
getSettingByGame<K extends keyof PrefTypeMap<K>>(id: number, key: K, checkUnsupported?: boolean): PrefTypeMap<K>[K] | undefined {
|
||||
const gameSettings = this.getGameSettings(id);
|
||||
if (gameSettings?.hasSetting(key)) {
|
||||
return gameSettings.getSetting(key, checkUnsupported);
|
||||
if (gameSettings?.hasSetting(key as StreamPref)) {
|
||||
let gameValue = gameSettings.getSetting(key, checkUnsupported);
|
||||
const globalValue = super.getSetting(key, checkUnsupported);
|
||||
|
||||
// Remove value if it's the same as global's
|
||||
if (globalValue === gameValue) {
|
||||
this.deleteSettingByGame(id, key as StreamPref);
|
||||
gameValue = globalValue;
|
||||
}
|
||||
|
||||
return gameValue;
|
||||
}
|
||||
|
||||
if (returnBaseValue) {
|
||||
return super.getSetting(key, checkUnsupported);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
return super.getSetting(key, checkUnsupported);
|
||||
}
|
||||
|
||||
setSetting<V = any>(key: StreamPref, value: V, origin: SettingActionOrigin): V {
|
||||
@ -439,6 +462,15 @@ export class StreamSettingsStorage extends BaseSettingsStorage<StreamPref> {
|
||||
return super.setSetting(key, value, origin);
|
||||
}
|
||||
|
||||
deleteSettingByGame(id: number, key: StreamPref): boolean {
|
||||
const gameSettings = this.getGameSettings(id);
|
||||
if (gameSettings) {
|
||||
return gameSettings.deleteSetting(key);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
hasGameSetting(id: number, key: StreamPref): boolean {
|
||||
const gameSettings = this.getGameSettings(id);
|
||||
return !!(gameSettings && gameSettings.hasSetting(key));
|
||||
|
@ -222,6 +222,7 @@ const Texts = {
|
||||
"notifications": "Notifications",
|
||||
"off": "Off",
|
||||
"official": "Official",
|
||||
"oled": "OLED",
|
||||
"on": "On",
|
||||
"only-supports-some-games": "Only supports some games",
|
||||
"opacity": "Opacity",
|
||||
@ -305,10 +306,13 @@ const Texts = {
|
||||
"screen": "Screen",
|
||||
"screenshot-apply-filters": "Apply video filters to screenshots",
|
||||
"section-all-games": "All games",
|
||||
"section-genres": "Genres",
|
||||
"section-leaving-soon": "Leaving soon",
|
||||
"section-most-popular": "Most popular",
|
||||
"section-native-mkb": "Play with mouse & keyboard",
|
||||
"section-news": "News",
|
||||
"section-play-with-friends": "Play with friends",
|
||||
"section-recently-added": "Recently added",
|
||||
"section-touch": "Play with touch",
|
||||
"separate-touch-controller": "Separate Touch controller & Controller #1",
|
||||
"separate-touch-controller-note": "Touch controller is Player 1, Controller #1 is Player 2",
|
||||
@ -363,6 +367,7 @@ const Texts = {
|
||||
"tc-muted-colors": "Muted colors",
|
||||
"tc-standard-layout-style": "Standard layout's button style",
|
||||
"text-size": "Text size",
|
||||
"theme": "Theme",
|
||||
"toggle": "Toggle",
|
||||
"top": "Top",
|
||||
"top-center": "Top-center",
|
||||
@ -424,6 +429,7 @@ const Texts = {
|
||||
"waiting-for-input": "Waiting for input...",
|
||||
"wallpaper": "Wallpaper",
|
||||
"webgl2": "WebGL2",
|
||||
"webgpu": "WebGPU",
|
||||
};
|
||||
|
||||
export class Translations {
|
||||
|
@ -154,9 +154,13 @@ export function clearAllData() {
|
||||
alert(t('clear-data-success'));
|
||||
}
|
||||
|
||||
export function containsAll(arr: Array<any>, values: Array<any>) {
|
||||
return values.every(val => arr.includes(val));
|
||||
}
|
||||
|
||||
export function blockAllNotifications() {
|
||||
const blockFeatures = getGlobalPref(GlobalPref.BLOCK_FEATURES);
|
||||
const blockAll = [BlockFeature.FRIENDS, BlockFeature.NOTIFICATIONS_ACHIEVEMENTS, BlockFeature.NOTIFICATIONS_INVITES].every(value => blockFeatures.includes(value));
|
||||
const blockAll = containsAll(blockFeatures, [BlockFeature.FRIENDS, BlockFeature.NOTIFICATIONS_ACHIEVEMENTS, BlockFeature.NOTIFICATIONS_INVITES]);
|
||||
return blockAll;
|
||||
}
|
||||
|
||||
|
@ -16,6 +16,9 @@
|
||||
"@modules/*": ["./modules/*"],
|
||||
"@utils/*": ["./utils/*"],
|
||||
},
|
||||
|
||||
"types": ["@types/bun", "@webgpu/types"],
|
||||
|
||||
// Enable latest features
|
||||
"lib": ["ESNext", "DOM"],
|
||||
"target": "ESNext",
|
||||
|
Reference in New Issue
Block a user