diff --git a/src/assets/css/global-settings.styl b/src/assets/css/global-settings.styl index eb6f2c9..c9f8382 100644 --- a/src/assets/css/global-settings.styl +++ b/src/assets/css/global-settings.styl @@ -29,6 +29,13 @@ outline: none !important; } + .bx-top-buttons { + .bx-button { + display: block; + margin-bottom: 8px; + } + } + .bx-settings-title-wrapper { display: flex; margin-bottom: 10px; @@ -49,10 +56,6 @@ } } - .bx-button.bx-primary { - margin-top: 8px; - } - a.bx-settings-update { display: block; color: #ff834b; diff --git a/src/assets/css/number-stepper.styl b/src/assets/css/number-stepper.styl index e6ad59f..136e5ef 100644 --- a/src/assets/css/number-stepper.styl +++ b/src/assets/css/number-stepper.styl @@ -20,7 +20,6 @@ font-weight: bold; font-size: 14px; font-family: var(--bx-monospaced-font); - color: #fff; &:hover { @media (hover: hover) { diff --git a/src/assets/css/root.styl b/src/assets/css/root.styl index a4e6d72..45059a0 100644 --- a/src/assets/css/root.styl +++ b/src/assets/css/root.styl @@ -23,9 +23,10 @@ --bx-dialog-z-index: 9101; --bx-dialog-overlay-z-index: 9100; --bx-remote-play-popup-z-index: 9090; - --bx-stats-bar-z-index: 9001; - --bx-stream-settings-z-index: 9000; - --bx-mkb-pointer-lock-msg-z-index: 8999; + --bx-stats-bar-z-index: 9010; + --bx-stream-settings-z-index: 9001; + --bx-mkb-pointer-lock-msg-z-index: 9000; + --bx-stream-settings-overlay-z-index: 8999; --bx-game-bar-z-index: 8888; --bx-wait-time-box-z-index: 100; --bx-screenshot-animation-z-index: 1; @@ -79,6 +80,14 @@ div[class^=HUDButton-module__hiddenContainer] ~ div:not([class^=HUDButton-module visibility: hidden !important; } +.bx-invisible { + opacity: 0; +} + +.bx-unclickable { + pointer-events: none; +} + .bx-pixel { width: 1px !important; height: 1px !important; @@ -112,3 +121,10 @@ div[class*=NotFocusedDialog] { #game-stream video:not([src]) { visibility: hidden; } + +/* Hide Controller icon in Game tiles */ +div[class*=SupportedInputsBadge] { + &:not(:has(:nth-child(2))), svg:first-of-type { + display: none; + } +} diff --git a/src/assets/css/stream-settings.styl b/src/assets/css/stream-settings.styl index 24e40bc..9fdbccd 100644 --- a/src/assets/css/stream-settings.styl +++ b/src/assets/css/stream-settings.styl @@ -7,6 +7,16 @@ -webkit-user-select: none; } +.bx-stream-settings-overlay { + position: fixed; + background: transparent; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: var(--bx-stream-settings-overlay-z-index); +} + .bx-stream-settings-tabs { position: fixed; top: 0; @@ -95,7 +105,7 @@ margin-bottom: 16px; padding-bottom: 16px; - label { + > label { font-size: 16px; display: block; text-align: left; diff --git a/src/assets/css/styles.styl b/src/assets/css/styles.styl index d9c6780..574666c 100644 --- a/src/assets/css/styles.styl +++ b/src/assets/css/styles.styl @@ -7,6 +7,7 @@ @import 'toast.styl'; @import 'loading-screen.styl'; @import 'remote-play.styl'; +@import 'web-components.styl'; @import 'stream.styl'; @import 'number-stepper.styl'; diff --git a/src/assets/css/web-components.styl b/src/assets/css/web-components.styl new file mode 100644 index 0000000..969027b --- /dev/null +++ b/src/assets/css/web-components.styl @@ -0,0 +1,48 @@ +.bx-select { + select { + display: none; + } + + > div { + display: inline-block; + min-width: 110px; + text-align: center; + margin: 0 10px; + line-height: 24px; + vertical-align: middle; + background: #fff; + color: #000; + border-radius: 4px; + padding: 2px 4px; + + input { + display: inline-block; + margin-right: 8px; + } + + label { + margin-bottom: 0; + } + } + + button { + border: none; + width: 24px; + height: 24px; + line-height: 24px; + color: #fff; + border-radius: 4px; + font-weight: bold; + font-size: 14px; + font-family: var(--bx-monospaced-font); + + &.bx-inactive { + pointer-events: none; + opacity: 0.2; + } + + span { + line-height: unset; + } + } +} diff --git a/src/index.ts b/src/index.ts index 725fed7..926ff67 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,9 +9,9 @@ import { showGamepadToast } from "@utils/gamepad"; import { EmulatedMkbHandler } from "@modules/mkb/mkb-handler"; import { StreamBadges } from "@modules/stream/stream-badges"; import { StreamStats } from "@modules/stream/stream-stats"; -import { addCss } from "@utils/css"; +import { addCss, preloadFonts } from "@utils/css"; import { Toast } from "@utils/toast"; -import { setupStreamUi, updateVideoPlayer } from "@modules/ui/ui"; +import { setupStreamUi } from "@modules/ui/ui"; import { PrefKey, getPref } from "@utils/preferences"; import { LoadingScreen } from "@modules/loading-screen"; import { MouseCursorHider } from "@modules/mkb/mouse-cursor-hider"; @@ -31,6 +31,8 @@ import { GameBar } from "./modules/game-bar/game-bar"; import { Screenshot } from "./utils/screenshot"; import { NativeMkbHandler } from "./modules/mkb/native-mkb-handler"; import { GuideMenu, GuideMenuTab } from "./modules/ui/guide-menu"; +import { StreamSettings } from "./modules/stream/stream-settings"; +import { updateVideoPlayer } from "./modules/stream/stream-settings-utils"; // Handle login page @@ -183,11 +185,7 @@ function unload() { window.BX_EXPOSED.shouldShowSensorControls = false; window.BX_EXPOSED.stopTakRendering = false; - const $streamSettingsDialog = document.querySelector('.bx-stream-settings-dialog'); - if ($streamSettingsDialog) { - $streamSettingsDialog.classList.add('bx-gone'); - } - + StreamSettings.getInstance().hide(); StreamStats.getInstance().onStoppedPlaying(); MouseCursorHider.stop(); @@ -289,9 +287,11 @@ function main() { // Setup UI addCss(); + preloadFonts(); Toast.setup(); (getPref(PrefKey.GAME_BAR_POSITION) !== 'off') && GameBar.getInstance(); BX_FLAGS.PreloadUi && setupStreamUi(); + Screenshot.setup(); GuideMenu.observe(); StreamBadges.setupEvents(); diff --git a/src/modules/loading-screen.ts b/src/modules/loading-screen.ts index ec46a48..2981b6b 100644 --- a/src/modules/loading-screen.ts +++ b/src/modules/loading-screen.ts @@ -109,7 +109,7 @@ export class LoadingScreen { let $waitTimeBox = LoadingScreen.#$waitTimeBox; if (!$waitTimeBox) { - $waitTimeBox = CE('div', {'class': 'bx-wait-time-box'}, + $waitTimeBox = CE('div', {'class': 'bx-wait-time-box'}, CE('label', {}, t('server')), CE('span', {}, getPreferredServerRegion()), CE('label', {}, t('wait-time-estimated')), diff --git a/src/modules/mkb/mkb-handler.ts b/src/modules/mkb/mkb-handler.ts index c5cf0e6..31dc297 100644 --- a/src/modules/mkb/mkb-handler.ts +++ b/src/modules/mkb/mkb-handler.ts @@ -8,13 +8,13 @@ import { t } from "@utils/translation"; import { LocalDb } from "@utils/local-db"; import { KeyHelper } from "./key-helper"; import type { MkbStoredPreset } from "@/types/mkb"; -import { showStreamSettings } from "@modules/stream/stream-ui"; import { AppInterface, STATES } from "@utils/global"; import { UserAgent } from "@utils/user-agent"; import { BxLogger } from "@utils/bx-logger"; import { PointerClient } from "./pointer-client"; import { NativeMkbHandler } from "./native-mkb-handler"; import { MkbHandler, MouseDataProvider } from "./base-mkb-handler"; +import { StreamSettings } from "../stream/stream-settings"; const LOG_TAG = 'MkbHandler'; @@ -507,7 +507,7 @@ export class EmulatedMkbHandler extends MkbHandler { e.preventDefault(); e.stopPropagation(); - showStreamSettings('mkb'); + StreamSettings.getInstance().show('mkb'); }, }), ), diff --git a/src/modules/mkb/mkb-remapper.ts b/src/modules/mkb/mkb-remapper.ts index ee7305d..3dc997f 100644 --- a/src/modules/mkb/mkb-remapper.ts +++ b/src/modules/mkb/mkb-remapper.ts @@ -10,6 +10,7 @@ import { BxIcon } from "@utils/bx-icon"; import { SettingElement } from "@utils/settings"; import type { MkbPresetData, MkbStoredPresets } from "@/types/mkb"; import { MkbPresetKey, GamepadKey, GamepadKeyName } from "@enums/mkb"; +import { deepClone } from "@utils/global"; type MkbRemapperElements = { @@ -291,7 +292,7 @@ export class MkbRemapper { this.#$.wrapper!.classList.toggle('bx-editing', this.#STATE.isEditing); if (this.#STATE.isEditing) { - this.#STATE.editingPresetData = structuredClone(this.#getCurrentPreset().data); + this.#STATE.editingPresetData = deepClone(this.#getCurrentPreset().data); } else { this.#STATE.editingPresetData = null; } @@ -510,7 +511,7 @@ export class MkbRemapper { label: t('save'), style: ButtonStyle.PRIMARY, onClick: e => { - const updatedPreset = structuredClone(this.#getCurrentPreset()); + const updatedPreset = deepClone(this.#getCurrentPreset()); updatedPreset.data = this.#STATE.editingPresetData as MkbPresetData; LocalDb.INSTANCE.updatePreset(updatedPreset).then(id => { diff --git a/src/modules/patcher.ts b/src/modules/patcher.ts index c0793ed..b15c922 100644 --- a/src/modules/patcher.ts +++ b/src/modules/patcher.ts @@ -623,7 +623,18 @@ true` + text; str = str.replace(text, text + 'return;'); return str; - } + }, + + // Fix crashing when RequestInfo.origin is empty + patchRequestInfoCrash(str: string) { + const text = 'if(!e)throw new Error("RequestInfo.origin is falsy");'; + if (!str.includes(text)) { + return false; + } + + str = str.replace(text, 'if (!e) e = "https://www.xbox.com";'); + return str; + }, }; let PATCH_ORDERS: PatchArray = [ @@ -634,6 +645,8 @@ let PATCH_ORDERS: PatchArray = [ 'exposeInputSink', ] : []), + 'patchRequestInfoCrash', + 'disableStreamGate', 'overrideSettings', 'broadcastPollingMode', diff --git a/src/modules/stream/stream-settings-utils.ts b/src/modules/stream/stream-settings-utils.ts new file mode 100644 index 0000000..5ec0353 --- /dev/null +++ b/src/modules/stream/stream-settings-utils.ts @@ -0,0 +1,54 @@ +import { StreamPlayerType, StreamVideoProcessing } from "@enums/stream-player"; +import { STATES } from "@utils/global"; +import { getPref, PrefKey, setPref } from "@utils/preferences"; +import { UserAgent } from "@utils/user-agent"; +import type { StreamPlayerOptions } from "../stream-player"; + +export function onChangeVideoPlayerType() { + const playerType = getPref(PrefKey.VIDEO_PLAYER_TYPE); + const $videoProcessing = document.getElementById('bx_setting_video_processing') as HTMLSelectElement; + const $videoSharpness = document.getElementById('bx_setting_video_sharpness') as HTMLElement; + + let isDisabled = false; + + if (playerType === StreamPlayerType.WEBGL2) { + ($videoProcessing.querySelector(`option[value=${StreamVideoProcessing.CAS}]`) as HTMLOptionElement).disabled = false; + } else { + // Only allow USM when player type is Video + $videoProcessing.value = StreamVideoProcessing.USM; + setPref(PrefKey.VIDEO_PROCESSING, StreamVideoProcessing.USM); + + ($videoProcessing.querySelector(`option[value=${StreamVideoProcessing.CAS}]`) as HTMLOptionElement).disabled = true; + + if (UserAgent.isSafari()) { + isDisabled = true; + } + } + + $videoProcessing.disabled = isDisabled; + $videoSharpness.dataset.disabled = isDisabled.toString(); + + updateVideoPlayer(); +} + + +export function updateVideoPlayer() { + const streamPlayer = STATES.currentStream.streamPlayer; + if (!streamPlayer) { + return; + } + + const options = { + processing: getPref(PrefKey.VIDEO_PROCESSING), + sharpness: getPref(PrefKey.VIDEO_SHARPNESS), + saturation: getPref(PrefKey.VIDEO_SATURATION), + contrast: getPref(PrefKey.VIDEO_CONTRAST), + brightness: getPref(PrefKey.VIDEO_BRIGHTNESS), + } satisfies StreamPlayerOptions; + + streamPlayer.setPlayerType(getPref(PrefKey.VIDEO_PLAYER_TYPE)); + streamPlayer.updateOptions(options); + streamPlayer.refreshPlayer(); +} + +window.addEventListener('resize', updateVideoPlayer); diff --git a/src/modules/stream/stream-settings.ts b/src/modules/stream/stream-settings.ts new file mode 100644 index 0000000..86bfae0 --- /dev/null +++ b/src/modules/stream/stream-settings.ts @@ -0,0 +1,389 @@ +import { BxEvent } from "@utils/bx-event"; +import { BxIcon } from "@utils/bx-icon"; +import { STATES, AppInterface } from "@utils/global"; +import { ButtonStyle, CE, createButton, createSvgIcon } from "@utils/html"; +import { PrefKey, Preferences, getPref, toPrefElement } from "@utils/preferences"; +import { t } from "@utils/translation"; +import { ControllerShortcut } from "../controller-shortcut"; +import { MkbRemapper } from "../mkb/mkb-remapper"; +import { NativeMkbHandler } from "../mkb/native-mkb-handler"; +import { SoundShortcut } from "../shortcuts/shortcut-sound"; +import { TouchController } from "../touch-controller"; +import { VibrationManager } from "../vibration-manager"; +import { StreamStats } from "./stream-stats"; +import { BX_FLAGS } from "@/utils/bx-flags"; +import { BxSelectElement } from "@/web-components/bx-select"; +import { onChangeVideoPlayerType, updateVideoPlayer } from "./stream-settings-utils"; + +export class StreamSettings { + private static instance: StreamSettings; + + public static getInstance(): StreamSettings { + if (!StreamSettings.instance) { + StreamSettings.instance = new StreamSettings(); + } + + return StreamSettings.instance; + } + + private $container: HTMLElement | undefined; + private $overlay: HTMLElement | undefined; + + readonly SETTINGS_UI = [{ + icon: BxIcon.DISPLAY, + group: 'stream', + items: [{ + group: 'audio', + label: t('audio'), + help_url: 'https://better-xcloud.github.io/ingame-features/#audio', + items: [{ + pref: PrefKey.AUDIO_VOLUME, + onChange: (e: any, value: number) => { + SoundShortcut.setGainNodeVolume(value); + }, + params: { + disabled: !getPref(PrefKey.AUDIO_ENABLE_VOLUME_CONTROL), + }, + onMounted: ($elm: HTMLElement) => { + const $range = $elm.querySelector('input[type=range') as HTMLInputElement; + window.addEventListener(BxEvent.GAINNODE_VOLUME_CHANGED, e => { + $range.value = (e as any).volume; + BxEvent.dispatch($range, 'input', { + ignoreOnChange: true, + }); + }); + }, + }], + }, { + group: 'video', + label: t('video'), + help_url: 'https://better-xcloud.github.io/ingame-features/#video', + items: [{ + pref: PrefKey.VIDEO_PLAYER_TYPE, + onChange: onChangeVideoPlayerType, + }, { + pref: PrefKey.VIDEO_RATIO, + onChange: updateVideoPlayer, + }, { + pref: PrefKey.VIDEO_PROCESSING, + onChange: updateVideoPlayer, + }, { + pref: PrefKey.VIDEO_SHARPNESS, + onChange: updateVideoPlayer, + }, { + pref: PrefKey.VIDEO_SATURATION, + onChange: updateVideoPlayer, + }, { + pref: PrefKey.VIDEO_CONTRAST, + onChange: updateVideoPlayer, + }, { + pref: PrefKey.VIDEO_BRIGHTNESS, + onChange: updateVideoPlayer, + }], + }], + }, { + icon: BxIcon.CONTROLLER, + group: 'controller', + items: [{ + group: 'controller', + label: t('controller'), + help_url: 'https://better-xcloud.github.io/ingame-features/#controller', + items: [{ + pref: PrefKey.CONTROLLER_ENABLE_VIBRATION, + unsupported: !VibrationManager.supportControllerVibration(), + onChange: () => VibrationManager.updateGlobalVars(), + }, { + pref: PrefKey.CONTROLLER_DEVICE_VIBRATION, + unsupported: !VibrationManager.supportDeviceVibration(), + onChange: () => VibrationManager.updateGlobalVars(), + }, (VibrationManager.supportControllerVibration() || VibrationManager.supportDeviceVibration()) && { + pref: PrefKey.CONTROLLER_VIBRATION_INTENSITY, + unsupported: !VibrationManager.supportDeviceVibration(), + onChange: () => VibrationManager.updateGlobalVars(), + }], + }, + + STATES.userAgentHasTouchSupport && { + group: 'touch-controller', + label: t('touch-controller'), + items: [{ + label: t('layout'), + content: CE('select', {disabled: true}, CE('option', {}, t('default'))), + onMounted: ($elm: HTMLSelectElement) => { + $elm.addEventListener('change', e => { + TouchController.loadCustomLayout(STATES.currentStream?.xboxTitleId!, $elm.value, 1000); + }); + + window.addEventListener(BxEvent.CUSTOM_TOUCH_LAYOUTS_LOADED, e => { + const data = (e as any).data; + + if (STATES.currentStream?.xboxTitleId && ($elm as any).xboxTitleId === STATES.currentStream?.xboxTitleId) { + $elm.dispatchEvent(new Event('change')); + return; + } + + ($elm as any).xboxTitleId = STATES.currentStream?.xboxTitleId; + + // Clear options + while ($elm.firstChild) { + $elm.removeChild($elm.firstChild); + } + + $elm.disabled = !data; + if (!data) { + $elm.appendChild(CE('option', {value: ''}, t('default'))); + $elm.value = ''; + $elm.dispatchEvent(new Event('change')); + return; + } + + // Add options + const $fragment = document.createDocumentFragment(); + for (const key in data.layouts) { + const layout = data.layouts[key]; + + let name; + if (layout.author) { + name = `${layout.name} (${layout.author})`; + } else { + name = layout.name; + } + + const $option = CE('option', {value: key}, name); + $fragment.appendChild($option); + } + + $elm.appendChild($fragment); + $elm.value = data.default_layout; + $elm.dispatchEvent(new Event('change')); + }); + }, + }], + }], + }, + + getPref(PrefKey.MKB_ENABLED) && { + icon: BxIcon.VIRTUAL_CONTROLLER, + group: 'mkb', + items: [{ + group: 'mkb', + label: t('virtual-controller'), + help_url: 'https://better-xcloud.github.io/mouse-and-keyboard/', + content: MkbRemapper.INSTANCE.render(), + }], + }, + + AppInterface && getPref(PrefKey.NATIVE_MKB_ENABLED) === 'on' && { + icon: BxIcon.NATIVE_MKB, + group: 'native-mkb', + items: [{ + group: 'native-mkb', + label: t('native-mkb'), + items: [{ + pref: PrefKey.NATIVE_MKB_SCROLL_VERTICAL_SENSITIVITY, + onChange: (e: any, value: number) => { + NativeMkbHandler.getInstance().setVerticalScrollMultiplier(value / 100); + }, + }, { + pref: PrefKey.NATIVE_MKB_SCROLL_HORIZONTAL_SENSITIVITY, + onChange: (e: any, value: number) => { + NativeMkbHandler.getInstance().setHorizontalScrollMultiplier(value / 100); + }, + }], + }], + }, { + icon: BxIcon.COMMAND, + group: 'shortcuts', + items: [{ + group: 'shortcuts_controller', + label: t('controller-shortcuts'), + content: ControllerShortcut.renderSettings(), + }], + }, { + icon: BxIcon.STREAM_STATS, + group: 'stats', + items: [{ + group: 'stats', + label: t('stream-stats'), + help_url: 'https://better-xcloud.github.io/stream-stats/', + items: [{ + pref: PrefKey.STATS_SHOW_WHEN_PLAYING, + }, { + pref: PrefKey.STATS_QUICK_GLANCE, + onChange: (e: InputEvent) => { + const streamStats = StreamStats.getInstance(); + (e.target! as HTMLInputElement).checked ? streamStats.quickGlanceSetup() : streamStats.quickGlanceStop(); + }, + }, { + pref: PrefKey.STATS_ITEMS, + onChange: StreamStats.refreshStyles, + }, { + pref: PrefKey.STATS_POSITION, + onChange: StreamStats.refreshStyles, + }, { + pref: PrefKey.STATS_TEXT_SIZE, + onChange: StreamStats.refreshStyles, + }, { + pref: PrefKey.STATS_OPACITY, + onChange: StreamStats.refreshStyles, + }, { + pref: PrefKey.STATS_TRANSPARENT, + onChange: StreamStats.refreshStyles, + }, { + pref: PrefKey.STATS_CONDITIONAL_FORMATTING, + onChange: StreamStats.refreshStyles, + }, + ], + }], + }, + ]; + + constructor() { + this.#setupDialog(); + } + + show(tabId?: string) { + const $container = this.$container!; + // Select tab + if (tabId) { + const $tab = $container.querySelector(`.bx-stream-settings-tabs svg[data-group=${tabId}]`); + $tab && $tab.dispatchEvent(new Event('click')); + } + + this.$overlay!.classList.remove('bx-gone'); + $container.classList.remove('bx-gone'); + + document.body.classList.add('bx-no-scroll'); + } + + hide() { + this.$overlay!.classList.add('bx-gone'); + this.$container!.classList.add('bx-gone'); + + document.body.classList.remove('bx-no-scroll'); + } + + #setupDialog() { + let $tabs: HTMLElement; + let $settings: HTMLElement; + + const $overlay = CE('div', {'class': 'bx-stream-settings-overlay bx-gone'}); + this.$overlay = $overlay; + + const $container = CE('div', {'class': 'bx-stream-settings-dialog bx-gone'}, + $tabs = CE('div', {'class': 'bx-stream-settings-tabs'}), + $settings = CE('div', {'class': 'bx-stream-settings-tab-contents'}), + ); + this.$container = $container; + + // Close dialog when clicking on the overlay + $overlay.addEventListener('click', e => { + e.preventDefault(); + e.stopPropagation(); + this.hide(); + }); + + for (const settingTab of this.SETTINGS_UI) { + if (!settingTab) { + continue; + } + + const $svg = createSvgIcon(settingTab.icon); + $svg.addEventListener('click', e => { + // Switch tab + for (const $child of Array.from($settings.children)) { + if ($child.getAttribute('data-group') === settingTab.group) { + $child.classList.remove('bx-gone'); + } else { + $child.classList.add('bx-gone'); + } + } + + // Highlight current tab button + for (const $child of Array.from($tabs.children)) { + $child.classList.remove('bx-active'); + } + + $svg.classList.add('bx-active'); + }); + + $tabs.appendChild($svg); + + const $group = CE('div', {'data-group': settingTab.group, 'class': 'bx-gone'}); + + for (const settingGroup of settingTab.items) { + if (!settingGroup) { + continue; + } + + $group.appendChild(CE('h2', {}, + CE('span', {}, settingGroup.label), + settingGroup.help_url && createButton({ + icon: BxIcon.QUESTION, + style: ButtonStyle.GHOST, + url: settingGroup.help_url, + title: t('help'), + }), + )); + if (settingGroup.note) { + if (typeof settingGroup.note === 'string') { + settingGroup.note = document.createTextNode(settingGroup.note); + } + $group.appendChild(settingGroup.note); + } + + if (settingGroup.content) { + $group.appendChild(settingGroup.content); + continue; + } + + if (!settingGroup.items) { + settingGroup.items = []; + } + + for (const setting of settingGroup.items) { + if (!setting) { + continue; + } + + const pref = setting.pref; + + let $control; + if (setting.content) { + $control = setting.content; + } else if (!setting.unsupported) { + $control = toPrefElement(pref, setting.onChange, setting.params); + + if ($control instanceof HTMLSelectElement && BX_FLAGS.ScriptUi === 'tv') { + $control = BxSelectElement.wrap($control); + } + } + + const label = Preferences.SETTINGS[pref as PrefKey]?.label || setting.label; + const note = Preferences.SETTINGS[pref as PrefKey]?.note || setting.note; + + const $content = CE('div', {'class': 'bx-stream-settings-row', 'data-type': settingGroup.group}, + CE('label', {for: `bx_setting_${pref}`}, + label, + note && CE('div', {'class': 'bx-stream-settings-dialog-note'}, note), + setting.unsupported && CE('div', {'class': 'bx-stream-settings-dialog-note'}, t('browser-unsupported-feature')), + ), + !setting.unsupported && $control, + ); + + $group.appendChild($content); + + setting.onMounted && setting.onMounted($control); + } + } + + $settings.appendChild($group); + } + + // Select first tab + $tabs.firstElementChild!.dispatchEvent(new Event('click')); + + document.documentElement.appendChild($overlay); + document.documentElement.appendChild($container); + } +} diff --git a/src/modules/stream/stream-stats.ts b/src/modules/stream/stream-stats.ts index a17c0d3..e64f474 100644 --- a/src/modules/stream/stream-stats.ts +++ b/src/modules/stream/stream-stats.ts @@ -39,6 +39,10 @@ export class StreamStats { #quickGlanceObserver?: MutationObserver | null; + constructor() { + this.#render(); + } + start(glancing=false) { if (!this.isHidden() || (glancing && this.isGlancing())) { return; @@ -85,11 +89,15 @@ export class StreamStats { isGlancing = () => this.#$container && this.#$container.dataset.display === 'glancing'; quickGlanceSetup() { - if (this.#quickGlanceObserver) { + if (!STATES.isPlaying || this.#quickGlanceObserver) { return; } const $uiContainer = document.querySelector('div[data-testid=ui-container]')!; + if (!$uiContainer) { + return; + } + this.#quickGlanceObserver = new MutationObserver((mutationList, observer) => { for (let record of mutationList) { if (record.attributeName && record.attributeName === 'aria-expanded') { @@ -212,10 +220,6 @@ export class StreamStats { } #render() { - if (this.#$container) { - return; - } - const stats = { [StreamStat.PING]: [t('stat-ping'), this.#$ping = CE('span', {}, '0')], [StreamStat.FPS]: [t('stat-fps'), this.#$fps = CE('span', {}, '0')], @@ -246,10 +250,6 @@ export class StreamStats { } static setupEvents() { - window.addEventListener(BxEvent.STREAM_LOADING, e => { - StreamStats.getInstance().#render(); - }); - window.addEventListener(BxEvent.STREAM_PLAYING, e => { const PREF_STATS_QUICK_GLANCE = getPref(PrefKey.STATS_QUICK_GLANCE); const PREF_STATS_SHOW_WHEN_PLAYING = getPref(PrefKey.STATS_SHOW_WHEN_PLAYING); diff --git a/src/modules/stream/stream-ui.ts b/src/modules/stream/stream-ui.ts index 504f2d4..f09137b 100644 --- a/src/modules/stream/stream-ui.ts +++ b/src/modules/stream/stream-ui.ts @@ -5,6 +5,7 @@ import { BxEvent } from "@utils/bx-event.ts"; import { t } from "@utils/translation.ts"; import { StreamBadges } from "./stream-badges.ts"; import { StreamStats } from "./stream-stats.ts"; +import { StreamSettings } from "./stream-settings.ts"; function cloneStreamHudButton($orgButton: HTMLElement, label: string, svgIcon: typeof BxIcon) { @@ -87,27 +88,6 @@ export function injectStreamMenuButtons() { ($screen as any).xObserving = true; - const $settingsDialog = document.querySelector('.bx-stream-settings-dialog')!; - const $parent = $screen.parentElement; - const hideSettingsFunc = (e?: MouseEvent | TouchEvent) => { - if (e) { - const $target = e.target as HTMLElement; - e.stopPropagation(); - if ($target != $parent && $target.id !== 'MultiTouchSurface' && !$target.querySelector('#BabylonCanvasContainer-main')) { - return; - } - if ($target.id === 'MultiTouchSurface') { - $target.removeEventListener('touchstart', hideSettingsFunc); - } - } - - // Hide Stream settings dialog - $settingsDialog.classList.add('bx-gone'); - - $parent?.removeEventListener('click', hideSettingsFunc); - // $parent.removeEventListener('touchstart', hideSettingsFunc); - } - let $btnStreamSettings: HTMLElement; let $btnStreamStats: HTMLElement; const streamStats = StreamStats.getInstance(); @@ -145,7 +125,7 @@ export function injectStreamMenuButtons() { // Hide Stream Settings dialog when closing HUD $btnCloseHud.addEventListener('click', e => { - $settingsDialog.classList.add('bx-gone'); + StreamSettings.getInstance().hide(); }); // Create Refresh button from the Close button @@ -165,7 +145,6 @@ export function injectStreamMenuButtons() { const $menu = document.querySelector('div[class*=StreamMenu-module__menuContainer] > div[class*=Menu-module]'); $menu?.appendChild(await StreamBadges.getInstance().render()); - hideSettingsFunc(); return; } @@ -205,13 +184,7 @@ export function injectStreamMenuButtons() { e.preventDefault(); // Show Stream Settings dialog - $settingsDialog.classList.remove('bx-gone'); - - $parent?.addEventListener('click', hideSettingsFunc); - //$parent.addEventListener('touchstart', hideSettingsFunc); - - const $touchSurface = document.getElementById('MultiTouchSurface'); - $touchSurface && $touchSurface.style.display != 'none' && $touchSurface.addEventListener('touchstart', hideSettingsFunc); + StreamSettings.getInstance().show(); }); } @@ -249,37 +222,3 @@ export function injectStreamMenuButtons() { }); observer.observe($screen, {subtree: true, childList: true}); } - - -export function showStreamSettings(tabId: string) { - const $wrapper = document.querySelector('.bx-stream-settings-dialog'); - if (!$wrapper) { - return; - } - - // Select tab - if (tabId) { - const $tab = $wrapper.querySelector(`.bx-stream-settings-tabs svg[data-group=${tabId}]`); - $tab && $tab.dispatchEvent(new Event('click')); - } - - $wrapper.classList.remove('bx-gone'); - - const $screen = document.querySelector('#PageContent section[class*=PureScreens]'); - if ($screen && $screen.parentElement) { - const $parent = $screen.parentElement; - if (!$parent || ($parent as any).bxClick) { - return; - } - - ($parent as any).bxClick = true; - - const onClick = (e: Event) => { - $wrapper.classList.add('bx-gone'); - ($parent as any).bxClick = false; - $parent.removeEventListener('click', onClick); - }; - - $parent.addEventListener('click', onClick); - } -} diff --git a/src/modules/ui/global-settings.ts b/src/modules/ui/global-settings.ts index 8868aca..a916161 100644 --- a/src/modules/ui/global-settings.ts +++ b/src/modules/ui/global-settings.ts @@ -7,6 +7,9 @@ import { getPref, Preferences, PrefKey, setPref, toPrefElement } from "@utils/pr import { t, Translations } from "@utils/translation"; import { PatcherCache } from "../patcher"; import { UserAgentProfile } from "@enums/user-agent"; +import { BX_FLAGS } from "@/utils/bx-flags"; +import { BxSelectElement } from "@/web-components/bx-select"; +import { StreamSettings } from "../stream/stream-settings"; const SETTINGS_UI = { 'Better xCloud': { @@ -121,14 +124,12 @@ export function setupSettingsUi() { let $btnReload: HTMLButtonElement; // Setup Settings UI - const $container = CE('div', { + const $container = CE('div', { 'class': 'bx-settings-container bx-gone', }); - let $updateAvailable; - - const $wrapper = CE('div', {'class': 'bx-settings-wrapper'}, - CE('div', {'class': 'bx-settings-title-wrapper'}, + const $wrapper = CE('div', {'class': 'bx-settings-wrapper'}, + CE('div', {'class': 'bx-settings-title-wrapper'}, CE('a', { 'class': 'bx-settings-title', 'href': 'https://github.com/redphx/better-xcloud/releases', @@ -142,46 +143,61 @@ export function setupSettingsUi() { }), ) ); - $updateAvailable = CE('a', { - 'class': 'bx-settings-update bx-gone', - 'href': 'https://github.com/redphx/better-xcloud/releases/latest', - 'target': '_blank', - }); - $wrapper.appendChild($updateAvailable); + const topButtons = []; - // Show new version indicator + // "New version available" button if (!SCRIPT_VERSION.includes('beta') && PREF_LATEST_VERSION && PREF_LATEST_VERSION != SCRIPT_VERSION) { - $updateAvailable.textContent = `🌟 Version ${PREF_LATEST_VERSION} available`; - $updateAvailable.classList.remove('bx-gone'); + // Show new version indicator + topButtons.push(createButton({ + label: `🌟 Version ${PREF_LATEST_VERSION} available`, + style: ButtonStyle.PRIMARY | ButtonStyle.FOCUSABLE | ButtonStyle.FULL_WIDTH, + url: 'https://github.com/redphx/better-xcloud/releases/latest', + })); } + // "Stream settings" button + topButtons.push(createButton({ + label: t('stream-settings'), + icon: BxIcon.STREAM_SETTINGS, + style: ButtonStyle.FULL_WIDTH | ButtonStyle.FOCUSABLE, + onClick: e => { + StreamSettings.getInstance().show(); + }, + })); + + // Buttons for Android app if (AppInterface) { // Show Android app settings button - const $btn = createButton({ + topButtons.push(createButton({ label: t('android-app-settings'), icon: BxIcon.STREAM_SETTINGS, style: ButtonStyle.FULL_WIDTH | ButtonStyle.FOCUSABLE, onClick: e => { AppInterface.openAppSettings && AppInterface.openAppSettings(); }, - }); - - $wrapper.appendChild($btn); + })); } else { // Show link to Android app const userAgent = UserAgent.getDefault().toLowerCase(); if (userAgent.includes('android')) { - const $btn = createButton({ + topButtons.push(createButton({ label: '🔥 ' + t('install-android'), style: ButtonStyle.FULL_WIDTH | ButtonStyle.FOCUSABLE, url: 'https://better-xcloud.github.io/android', - }); - - $wrapper.appendChild($btn); + })); } } + if (topButtons.length) { + const $div = CE('div', {class: 'bx-top-buttons'}); + for (const $button of topButtons) { + $div.appendChild($button); + } + + $wrapper.appendChild($div); + } + const onChange = async (e: Event) => { // Clear PatcherCache; PatcherCache.clear(); @@ -197,8 +213,11 @@ export function setupSettingsUi() { Translations.refreshCurrentLocale(); await Translations.updateTranslations(); - $btnReload.textContent = t('settings-reloading'); - $btnReload.click(); + // Don't refresh the page on TV + if (BX_FLAGS.ScriptUi !== 'tv') { + $btnReload.textContent = t('settings-reloading'); + $btnReload.click(); + } } }; @@ -258,7 +277,7 @@ export function setupSettingsUi() { placeholder: defaultUserAgent, 'class': 'bx-settings-custom-user-agent', }); - $inpCustomUserAgent.addEventListener('change', e => { + $inpCustomUserAgent.addEventListener('input', e => { const profile = $control.value; const custom = (e.target as HTMLInputElement).value.trim(); @@ -289,7 +308,7 @@ export function setupSettingsUi() { }); $control.name = $control.id; - $control.addEventListener('change', (e: Event) => { + $control.addEventListener('input', (e: Event) => { setPref(settingId, (e.target as HTMLSelectElement).value); onChange(e); }); @@ -354,10 +373,20 @@ export function setupSettingsUi() { if (settingNote) { $label.appendChild(CE('b', {}, settingNote)); } - const $elm = CE('div', {'class': 'bx-settings-row'}, + + let $elm: HTMLElement; + + if ($control instanceof HTMLSelectElement && BX_FLAGS.ScriptUi === 'tv') { + $elm = CE('div', {'class': 'bx-settings-row'}, + $label, + BxSelectElement.wrap($control), + ); + } else { + $elm = CE('div', {'class': 'bx-settings-row'}, $label, $control, ); + } $wrapper.appendChild($elm); @@ -366,7 +395,7 @@ export function setupSettingsUi() { $wrapper.appendChild($inpCustomUserAgent!); // Trigger 'change' event $control.disabled = true; - $control.dispatchEvent(new Event('change')); + $control.dispatchEvent(new Event('input')); $control.disabled = false; } } @@ -400,7 +429,7 @@ export function setupSettingsUi() { try { const appVersion = (document.querySelector('meta[name=gamepass-app-version]') as HTMLMetaElement).content; const appDate = new Date((document.querySelector('meta[name=gamepass-app-date]') as HTMLMetaElement).content).toISOString().substring(0, 10); - $wrapper.appendChild(CE('div', {'class': 'bx-settings-app-version'}, `xCloud website version ${appVersion} (${appDate})`)); + $wrapper.appendChild(CE('div', {'class': 'bx-settings-app-version'}, `xCloud website version ${appVersion} (${appDate})`)); } catch (e) {} $container.appendChild($wrapper); diff --git a/src/modules/ui/ui.ts b/src/modules/ui/ui.ts index a7486bd..01fdc88 100644 --- a/src/modules/ui/ui.ts +++ b/src/modules/ui/ui.ts @@ -1,20 +1,6 @@ -import { AppInterface, STATES } from "@utils/global"; -import { CE, createButton, ButtonStyle, createSvgIcon } from "@utils/html"; -import { BxIcon } from "@utils/bx-icon"; -import { BxEvent } from "@utils/bx-event"; -import { MkbRemapper } from "@modules/mkb/mkb-remapper"; -import { getPref, Preferences, PrefKey, setPref, toPrefElement } from "@utils/preferences"; -import { StreamStats } from "@modules/stream/stream-stats"; -import { TouchController } from "@modules/touch-controller"; -import { t } from "@utils/translation"; -import { VibrationManager } from "@modules/vibration-manager"; -import { Screenshot } from "@/utils/screenshot"; -import { ControllerShortcut } from "../controller-shortcut"; -import { SoundShortcut } from "../shortcuts/shortcut-sound"; -import { NativeMkbHandler } from "../mkb/native-mkb-handler"; -import { UserAgent } from "@/utils/user-agent"; -import type { StreamPlayerOptions } from "../stream-player"; -import { StreamPlayerType, StreamVideoProcessing } from "@enums/stream-player"; +import { CE } from "@utils/html"; +import { onChangeVideoPlayerType } from "../stream/stream-settings-utils"; +import { StreamSettings } from "../stream/stream-settings"; export function localRedirect(path: string) { @@ -40,451 +26,8 @@ export function localRedirect(path: string) { $anchor.click(); } -function setupStreamSettingsDialog() { - const SETTINGS_UI = [ - { - icon: BxIcon.DISPLAY, - group: 'stream', - items: [ - { - group: 'audio', - label: t('audio'), - help_url: 'https://better-xcloud.github.io/ingame-features/#audio', - items: [ - { - pref: PrefKey.AUDIO_VOLUME, - onChange: (e: any, value: number) => { - SoundShortcut.setGainNodeVolume(value); - }, - params: { - disabled: !getPref(PrefKey.AUDIO_ENABLE_VOLUME_CONTROL), - }, - onMounted: ($elm: HTMLElement) => { - const $range = $elm.querySelector('input[type=range') as HTMLInputElement; - window.addEventListener(BxEvent.GAINNODE_VOLUME_CHANGED, e => { - $range.value = (e as any).volume; - BxEvent.dispatch($range, 'input', { - ignoreOnChange: true, - }); - }); - }, - }, - ], - }, - - { - group: 'video', - label: t('video'), - help_url: 'https://better-xcloud.github.io/ingame-features/#video', - items: [ - { - pref: PrefKey.VIDEO_PLAYER_TYPE, - onChange: onChangeVideoPlayerType, - }, - - { - pref: PrefKey.VIDEO_RATIO, - onChange: updateVideoPlayer, - }, - - { - pref: PrefKey.VIDEO_PROCESSING, - onChange: updateVideoPlayer, - }, - - { - pref: PrefKey.VIDEO_SHARPNESS, - onChange: updateVideoPlayer, - }, - - { - pref: PrefKey.VIDEO_SATURATION, - onChange: updateVideoPlayer, - }, - - { - pref: PrefKey.VIDEO_CONTRAST, - onChange: updateVideoPlayer, - }, - - { - pref: PrefKey.VIDEO_BRIGHTNESS, - onChange: updateVideoPlayer, - }, - ], - }, - ], - }, - - { - icon: BxIcon.CONTROLLER, - group: 'controller', - items: [ - { - group: 'controller', - label: t('controller'), - help_url: 'https://better-xcloud.github.io/ingame-features/#controller', - items: [ - { - pref: PrefKey.CONTROLLER_ENABLE_VIBRATION, - unsupported: !VibrationManager.supportControllerVibration(), - onChange: () => VibrationManager.updateGlobalVars(), - }, - - { - pref: PrefKey.CONTROLLER_DEVICE_VIBRATION, - unsupported: !VibrationManager.supportDeviceVibration(), - onChange: () => VibrationManager.updateGlobalVars(), - }, - - (VibrationManager.supportControllerVibration() || VibrationManager.supportDeviceVibration()) && { - pref: PrefKey.CONTROLLER_VIBRATION_INTENSITY, - unsupported: !VibrationManager.supportDeviceVibration(), - onChange: () => VibrationManager.updateGlobalVars(), - }, - ], - }, - - STATES.userAgentHasTouchSupport && { - group: 'touch-controller', - label: t('touch-controller'), - items: [ - { - label: t('layout'), - content: CE('select', {disabled: true}, CE('option', {}, t('default'))), - onMounted: ($elm: HTMLSelectElement) => { - $elm.addEventListener('change', e => { - TouchController.loadCustomLayout(STATES.currentStream?.xboxTitleId!, $elm.value, 1000); - }); - - window.addEventListener(BxEvent.CUSTOM_TOUCH_LAYOUTS_LOADED, e => { - const data = (e as any).data; - - if (STATES.currentStream?.xboxTitleId && ($elm as any).xboxTitleId === STATES.currentStream?.xboxTitleId) { - $elm.dispatchEvent(new Event('change')); - return; - } - - ($elm as any).xboxTitleId = STATES.currentStream?.xboxTitleId; - - // Clear options - while ($elm.firstChild) { - $elm.removeChild($elm.firstChild); - } - - $elm.disabled = !data; - if (!data) { - $elm.appendChild(CE('option', {value: ''}, t('default'))); - $elm.value = ''; - $elm.dispatchEvent(new Event('change')); - return; - } - - // Add options - const $fragment = document.createDocumentFragment(); - for (const key in data.layouts) { - const layout = data.layouts[key]; - - let name; - if (layout.author) { - name = `${layout.name} (${layout.author})`; - } else { - name = layout.name; - } - - const $option = CE('option', {value: key}, name); - $fragment.appendChild($option); - } - - $elm.appendChild($fragment); - $elm.value = data.default_layout; - $elm.dispatchEvent(new Event('change')); - }); - }, - }, - ], - } - ], - }, - - getPref(PrefKey.MKB_ENABLED) && { - icon: BxIcon.VIRTUAL_CONTROLLER, - group: 'mkb', - items: [ - { - group: 'mkb', - label: t('virtual-controller'), - help_url: 'https://better-xcloud.github.io/mouse-and-keyboard/', - content: MkbRemapper.INSTANCE.render(), - }, - ], - }, - - AppInterface && getPref(PrefKey.NATIVE_MKB_ENABLED) === 'on' && { - icon: BxIcon.NATIVE_MKB, - group: 'native-mkb', - items: [ - { - group: 'native-mkb', - label: t('native-mkb'), - items: [ - { - pref: PrefKey.NATIVE_MKB_SCROLL_VERTICAL_SENSITIVITY, - onChange: (e: any, value: number) => { - NativeMkbHandler.getInstance().setVerticalScrollMultiplier(value / 100); - }, - }, - { - pref: PrefKey.NATIVE_MKB_SCROLL_HORIZONTAL_SENSITIVITY, - onChange: (e: any, value: number) => { - NativeMkbHandler.getInstance().setHorizontalScrollMultiplier(value / 100); - }, - }, - ], - }, - ], - }, - - { - icon: BxIcon.COMMAND, - group: 'shortcuts', - items: [ - { - group: 'shortcuts_controller', - label: t('controller-shortcuts'), - content: ControllerShortcut.renderSettings(), - }, - ], - }, - - { - icon: BxIcon.STREAM_STATS, - group: 'stats', - items: [ - { - group: 'stats', - label: t('stream-stats'), - help_url: 'https://better-xcloud.github.io/stream-stats/', - items: [ - { - pref: PrefKey.STATS_SHOW_WHEN_PLAYING, - }, - { - pref: PrefKey.STATS_QUICK_GLANCE, - onChange: (e: InputEvent) => { - const streamStats = StreamStats.getInstance(); - (e.target! as HTMLInputElement).checked ? streamStats.quickGlanceSetup() : streamStats.quickGlanceStop(); - }, - }, - { - pref: PrefKey.STATS_ITEMS, - onChange: StreamStats.refreshStyles, - }, - { - pref: PrefKey.STATS_POSITION, - onChange: StreamStats.refreshStyles, - }, - { - pref: PrefKey.STATS_TEXT_SIZE, - onChange: StreamStats.refreshStyles, - }, - { - pref: PrefKey.STATS_OPACITY, - onChange: StreamStats.refreshStyles, - }, - { - pref: PrefKey.STATS_TRANSPARENT, - onChange: StreamStats.refreshStyles, - }, - { - pref: PrefKey.STATS_CONDITIONAL_FORMATTING, - onChange: StreamStats.refreshStyles, - }, - ], - }, - ], - }, - ]; - - let $tabs: HTMLElement; - let $settings: HTMLElement; - - const $wrapper = CE('div', {'class': 'bx-stream-settings-dialog bx-gone'}, - $tabs = CE('div', {'class': 'bx-stream-settings-tabs'}), - $settings = CE('div', {'class': 'bx-stream-settings-tab-contents'}), - ); - - for (const settingTab of SETTINGS_UI) { - if (!settingTab) { - continue; - } - - const $svg = createSvgIcon(settingTab.icon); - $svg.addEventListener('click', e => { - // Switch tab - for (const $child of Array.from($settings.children)) { - if ($child.getAttribute('data-group') === settingTab.group) { - $child.classList.remove('bx-gone'); - } else { - $child.classList.add('bx-gone'); - } - } - - // Highlight current tab button - for (const $child of Array.from($tabs.children)) { - $child.classList.remove('bx-active'); - } - - $svg.classList.add('bx-active'); - }); - - $tabs.appendChild($svg); - - const $group = CE('div', {'data-group': settingTab.group, 'class': 'bx-gone'}); - - for (const settingGroup of settingTab.items) { - if (!settingGroup) { - continue; - } - - $group.appendChild(CE('h2', {}, - CE('span', {}, settingGroup.label), - settingGroup.help_url && createButton({ - icon: BxIcon.QUESTION, - style: ButtonStyle.GHOST, - url: settingGroup.help_url, - title: t('help'), - }), - )); - if (settingGroup.note) { - if (typeof settingGroup.note === 'string') { - settingGroup.note = document.createTextNode(settingGroup.note); - } - $group.appendChild(settingGroup.note); - } - - if (settingGroup.content) { - $group.appendChild(settingGroup.content); - continue; - } - - if (!settingGroup.items) { - settingGroup.items = []; - } - - for (const setting of settingGroup.items) { - if (!setting) { - continue; - } - - const pref = setting.pref; - - let $control; - if (setting.content) { - $control = setting.content; - } else if (!setting.unsupported) { - $control = toPrefElement(pref, setting.onChange, setting.params); - } - - const label = Preferences.SETTINGS[pref as PrefKey]?.label || setting.label; - const note = Preferences.SETTINGS[pref as PrefKey]?.note || setting.note; - - const $content = CE('div', {'class': 'bx-stream-settings-row', 'data-type': settingGroup.group}, - CE('label', {for: `bx_setting_${pref}`}, - label, - note && CE('div', {'class': 'bx-stream-settings-dialog-note'}, note), - setting.unsupported && CE('div', {'class': 'bx-stream-settings-dialog-note'}, t('browser-unsupported-feature')), - ), - !setting.unsupported && $control, - ); - - $group.appendChild($content); - - setting.onMounted && setting.onMounted($control); - } - } - - $settings.appendChild($group); - } - - // Select first tab - $tabs.firstElementChild!.dispatchEvent(new Event('click')); - - document.documentElement.appendChild($wrapper); -} - - -function onChangeVideoPlayerType() { - const playerType = getPref(PrefKey.VIDEO_PLAYER_TYPE); - const $videoProcessing = document.getElementById('bx_setting_video_processing') as HTMLSelectElement; - const $videoSharpness = document.getElementById('bx_setting_video_sharpness') as HTMLElement; - - let isDisabled = false; - - if (playerType === StreamPlayerType.WEBGL2) { - ($videoProcessing.querySelector(`option[value=${StreamVideoProcessing.CAS}]`) as HTMLOptionElement).disabled = false; - } else { - // Only allow USM when player type is Video - $videoProcessing.value = StreamVideoProcessing.USM; - setPref(PrefKey.VIDEO_PROCESSING, StreamVideoProcessing.USM); - - ($videoProcessing.querySelector(`option[value=${StreamVideoProcessing.CAS}]`) as HTMLOptionElement).disabled = true; - - if (UserAgent.isSafari()) { - isDisabled = true; - } - } - - $videoProcessing.disabled = isDisabled; - $videoSharpness.dataset.disabled = isDisabled.toString(); - - updateVideoPlayer(); -} - - -export function updateVideoPlayer() { - const streamPlayer = STATES.currentStream.streamPlayer; - if (!streamPlayer) { - return; - } - - const options = { - processing: getPref(PrefKey.VIDEO_PROCESSING), - sharpness: getPref(PrefKey.VIDEO_SHARPNESS), - saturation: getPref(PrefKey.VIDEO_SATURATION), - contrast: getPref(PrefKey.VIDEO_CONTRAST), - brightness: getPref(PrefKey.VIDEO_BRIGHTNESS), - } satisfies StreamPlayerOptions; - - streamPlayer.setPlayerType(getPref(PrefKey.VIDEO_PLAYER_TYPE)); - streamPlayer.updateOptions(options); - streamPlayer.refreshPlayer(); -} - - -function preloadFonts() { - const $link = CE('link', { - rel: 'preload', - href: 'https://redphx.github.io/better-xcloud/fonts/promptfont.otf', - as: 'font', - type: 'font/otf', - crossorigin: '', - }); - - document.querySelector('head')?.appendChild($link); -} - export function setupStreamUi() { - // Prevent initializing multiple times - if (!document.querySelector('.bx-stream-settings-dialog')) { - preloadFonts(); - - window.addEventListener('resize', updateVideoPlayer); - setupStreamSettingsDialog(); - - Screenshot.setup(); - } - + StreamSettings.getInstance(); onChangeVideoPlayerType(); } diff --git a/src/utils/bx-exposed.ts b/src/utils/bx-exposed.ts index e20ad28..46711e7 100644 --- a/src/utils/bx-exposed.ts +++ b/src/utils/bx-exposed.ts @@ -1,6 +1,6 @@ import { ControllerShortcut } from "@/modules/controller-shortcut"; import { BxEvent } from "@utils/bx-event"; -import { STATES } from "@utils/global"; +import { deepClone, STATES } from "@utils/global"; import { getPref, PrefKey } from "@utils/preferences"; import { BxLogger } from "./bx-logger"; import { BX_FLAGS } from "./bx-flags"; @@ -19,7 +19,7 @@ export const BxExposed = { modifyTitleInfo: (titleInfo: XcloudTitleInfo): XcloudTitleInfo => { // Clone the object since the original is read-only - titleInfo = structuredClone(titleInfo); + titleInfo = deepClone(titleInfo); let supportedInputTypes = titleInfo.details.supportedInputTypes; diff --git a/src/utils/bx-flags.ts b/src/utils/bx-flags.ts index 6df6e86..0a0e44c 100644 --- a/src/utils/bx-flags.ts +++ b/src/utils/bx-flags.ts @@ -9,6 +9,8 @@ type BxFlags = Partial<{ ForceNativeMkbTitles: string[]; FeatureGates: {[key: string]: boolean} | null, + + ScriptUi: 'default' | 'tv', }> // Setup flags @@ -23,6 +25,8 @@ const DEFAULT_FLAGS: BxFlags = { ForceNativeMkbTitles: [], FeatureGates: null, + + ScriptUi: 'default', } export const BX_FLAGS: BxFlags = Object.assign(DEFAULT_FLAGS, window.BX_FLAGS || {}); diff --git a/src/utils/css.ts b/src/utils/css.ts index 732cecb..2849606 100644 --- a/src/utils/css.ts +++ b/src/utils/css.ts @@ -140,3 +140,16 @@ body::-webkit-scrollbar { const $style = CE('style', {}, css); document.documentElement.appendChild($style); } + + +export function preloadFonts() { + const $link = CE('link', { + rel: 'preload', + href: 'https://redphx.github.io/better-xcloud/fonts/promptfont.otf', + as: 'font', + type: 'font/otf', + crossorigin: '', + }); + + document.querySelector('head')?.appendChild($link); +} diff --git a/src/utils/global.ts b/src/utils/global.ts index 5b7d8c9..53a24da 100644 --- a/src/utils/global.ts +++ b/src/utils/global.ts @@ -24,3 +24,15 @@ export const STATES: BxStates = { pointerServerPort: 9269, }; + +export function deepClone(obj: any): any { + if ('structuredClone' in window) { + return structuredClone(obj); + } + + if (!obj) { + return obj; + } + + return JSON.parse(JSON.stringify(obj)); +} diff --git a/src/utils/html.ts b/src/utils/html.ts index a324d5a..42259ac 100644 --- a/src/utils/html.ts +++ b/src/utils/html.ts @@ -9,6 +9,7 @@ type BxButton = { title?: string; disabled?: boolean; onClick?: EventListener; + attributes?: {[key: string]: any}, } type ButtonStyle = {[index: string]: number} & {[index: number]: string}; @@ -94,6 +95,12 @@ export const createButton = (options: BxButton): T => { options.disabled && (($btn as HTMLButtonElement).disabled = true); options.onClick && $btn.addEventListener('click', options.onClick); + for (const key in options.attributes) { + if (!$btn.hasOwnProperty(key)) { + $btn.setAttribute(key, options.attributes[key]); + } + } + return $btn as T; } diff --git a/src/utils/network.ts b/src/utils/network.ts index 719571b..5ce0a36 100644 --- a/src/utils/network.ts +++ b/src/utils/network.ts @@ -576,8 +576,10 @@ export function interceptHttpRequests() { const response = await NATIVE_FETCH(request, init); const json = await response.json(); - for (const key in FeatureGates) { - json.exp.treatments[key] = FeatureGates[key] + if (json && json.exp && json.treatments) { + for (const key in FeatureGates) { + json.exp.treatments[key] = FeatureGates[key] + } } response.json = () => Promise.resolve(json); diff --git a/src/utils/preferences.ts b/src/utils/preferences.ts index 212f65d..5af17e1 100644 --- a/src/utils/preferences.ts +++ b/src/utils/preferences.ts @@ -570,7 +570,7 @@ export class Preferences { [UserAgentProfile.SMARTTV_GENERIC]: 'Smart TV', [UserAgentProfile.SMARTTV_TIZEN]: 'Samsung Smart TV', [UserAgentProfile.VR_OCULUS]: 'Meta Quest VR', - [UserAgentProfile.ANDROID_KIWI_V123]: 'Kiwi Browser v123', + [UserAgentProfile.ANDROID_KIWI_V123]: 'Kiwi Browser v124 Fix', [UserAgentProfile.CUSTOM]: t('custom'), }, }, diff --git a/src/utils/preload-state.ts b/src/utils/preload-state.ts index abfac50..b2ad0fa 100644 --- a/src/utils/preload-state.ts +++ b/src/utils/preload-state.ts @@ -1,4 +1,4 @@ -import { STATES } from "@utils/global"; +import { deepClone, STATES } from "@utils/global"; import { BxLogger } from "./bx-logger"; import { TouchController } from "@modules/touch-controller"; import { GamePassCloudGallery } from "../enums/game-pass-gallery"; @@ -59,7 +59,7 @@ export function overridePreloadState() { // @ts-ignore _state = state; - STATES.appContext = structuredClone(state.appContext); + STATES.appContext = deepClone(state.appContext); } }); } diff --git a/src/utils/settings.ts b/src/utils/settings.ts index 854f506..a9bcccb 100644 --- a/src/utils/settings.ts +++ b/src/utils/settings.ts @@ -38,7 +38,7 @@ export class SettingElement { } $control.value = currentValue; - onChange && $control.addEventListener('change', e => { + onChange && $control.addEventListener('input', e => { const target = e.target as HTMLSelectElement; const value = (setting.type && setting.type === 'number') ? parseInt(target.value) : target.value; onChange(e, value); @@ -76,7 +76,7 @@ export class SettingElement { const $parent = target.parentElement!; $parent.focus(); - $parent.dispatchEvent(new Event('change')); + $parent.dispatchEvent(new Event('input')); }); $control.appendChild($option); @@ -90,7 +90,7 @@ export class SettingElement { $control.addEventListener('mousemove', e => e.preventDefault()); - onChange && $control.addEventListener('change', (e: Event) => { + onChange && $control.addEventListener('input', (e: Event) => { const target = e.target as HTMLSelectElement const values = Array.from(target.selectedOptions).map(i => i.value); onChange(e, values); diff --git a/src/utils/translation.ts b/src/utils/translation.ts index d6f9317..e7d387b 100644 --- a/src/utils/translation.ts +++ b/src/utils/translation.ts @@ -377,8 +377,12 @@ export class Translations { const resp = await NATIVE_FETCH(`https://raw.githubusercontent.com/redphx/better-xcloud/gh-pages/translations/${locale}.json`); const translations = await resp.json(); - window.localStorage.setItem(Translations.#KEY_TRANSLATIONS, JSON.stringify(translations)); - Translations.#foreignTranslations = translations; + // Prevent saving incorrect translations + let currentLocale = localStorage.getItem(Translations.#KEY_LOCALE); + if (currentLocale === locale) { + window.localStorage.setItem(Translations.#KEY_TRANSLATIONS, JSON.stringify(translations)); + Translations.#foreignTranslations = translations; + } return true; } catch (e) { debugger; diff --git a/src/utils/user-agent.ts b/src/utils/user-agent.ts index 7aa4f55..0251c02 100644 --- a/src/utils/user-agent.ts +++ b/src/utils/user-agent.ts @@ -1,4 +1,5 @@ import { UserAgentProfile } from "@enums/user-agent"; +import { deepClone } from "./global"; type UserAgentConfig = { profile: UserAgentProfile, @@ -45,7 +46,7 @@ export class UserAgent { } static updateStorage(profile: UserAgentProfile, custom?: string) { - const clonedConfig = structuredClone(UserAgent.#config); + const clonedConfig = deepClone(UserAgent.#config); clonedConfig.profile = profile; if (typeof custom !== 'undefined') { diff --git a/src/web-components/bx-select.ts b/src/web-components/bx-select.ts new file mode 100644 index 0000000..539f7f0 --- /dev/null +++ b/src/web-components/bx-select.ts @@ -0,0 +1,115 @@ +import { ButtonStyle, CE, createButton } from "@utils/html"; + +export class BxSelectElement { + static wrap($select: HTMLSelectElement) { + const $btnPrev = createButton({ + label: '<', + style: ButtonStyle.FOCUSABLE, + attributes: { + tabindex: 0, + }, + }); + + const $btnNext = createButton({ + label: '>', + style: ButtonStyle.FOCUSABLE, + attributes: { + tabindex: 0, + }, + }); + + const isMultiple = $select.multiple; + let visibleIndex = $select.selectedIndex; + let $checkBox: HTMLInputElement; + let $label: HTMLElement; + + const $content = CE('div', {}, + $checkBox = CE('input', {type: 'checkbox', id: $select.id + '_checkbox'}), + $label = CE('label', {for: $select.id + '_checkbox'}, ''), + ); + + isMultiple && $checkBox.addEventListener('input', e => { + const $option = getOptionAtIndex(visibleIndex); + $option && ($option.selected = (e.target as HTMLInputElement).checked); + + $select.dispatchEvent(new Event('input')); + }); + + // Only show checkbox in "multiple"