From 4f7e0a4f7f78533ecb7d616960e63ba5095d2e25 Mon Sep 17 00:00:00 2001 From: redphx <96280+redphx@users.noreply.github.com> Date: Mon, 26 Aug 2024 17:27:34 +0700 Subject: [PATCH] Add "Suggest settings" feature --- bun.lockb | Bin 47632 -> 48007 bytes package.json | 2 +- src/assets/css/root.styl | 2 +- src/assets/css/settings-dialog.styl | 235 +++++++++++-- src/index.ts | 4 +- src/modules/controller-shortcut.ts | 6 +- src/modules/game-bar/game-bar.ts | 4 +- src/modules/patcher.ts | 14 +- src/modules/ui/dialog/navigation-dialog.ts | 2 +- src/modules/ui/dialog/settings-dialog.ts | 330 +++++++++++++++++- src/modules/vibration-manager.ts | 6 +- src/types/index.d.ts | 2 - src/types/setting-definition.d.ts | 18 + src/utils/bx-exposed.ts | 10 +- src/utils/bx-flags.ts | 10 +- src/utils/html.ts | 57 ++- src/utils/setting-element.ts | 197 ++++++----- .../base-settings-storage.ts | 36 +- .../global-settings-storage.ts | 117 +++++-- src/utils/translation.ts | 29 ++ src/utils/xcloud-interceptor.ts | 6 +- src/utils/xhome-interceptor.ts | 6 +- src/web-components/bx-select.ts | 16 +- 23 files changed, 880 insertions(+), 229 deletions(-) diff --git a/bun.lockb b/bun.lockb index 89daf25d472589c4ee1f847f39b0998a4d692ee8..9017b1f8e181daf5d2d411434fc915b9a3b3c4fc 100755 GIT binary patch delta 4411 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 delta 4244 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 = { + const sections: PartialRecord = { [UiSection.NATIVE_MKB]: GamePassCloudGallery.NATIVE_MKB, [UiSection.MOST_POPULAR]: GamePassCloudGallery.MOST_POPULAR, }; @@ -991,9 +991,9 @@ let PLAYING_PATCH_ORDERS: PatchArray = [ getPref(PrefKey.STREAM_DISABLE_FEEDBACK_DIALOG) && 'skipFeedbackDialog', ...(STATES.userAgent.capabilities.touch ? [ - getPref(PrefKey.STREAM_TOUCH_CONTROLLER) === 'all' && 'patchShowSensorControls', - getPref(PrefKey.STREAM_TOUCH_CONTROLLER) === 'all' && 'exposeTouchLayoutManager', - (getPref(PrefKey.STREAM_TOUCH_CONTROLLER) === 'off' || getPref(PrefKey.STREAM_TOUCH_CONTROLLER_AUTO_OFF) || !STATES.userAgent.capabilities.touch) && 'disableTakRenderer', + getPref(PrefKey.STREAM_TOUCH_CONTROLLER) === StreamTouchController.ALL && 'patchShowSensorControls', + getPref(PrefKey.STREAM_TOUCH_CONTROLLER) === StreamTouchController.ALL && 'exposeTouchLayoutManager', + (getPref(PrefKey.STREAM_TOUCH_CONTROLLER) === StreamTouchController.OFF || getPref(PrefKey.STREAM_TOUCH_CONTROLLER_AUTO_OFF) || !STATES.userAgent.capabilities.touch) && 'disableTakRenderer', getPref(PrefKey.STREAM_TOUCH_CONTROLLER_DEFAULT_OPACITY) !== 100 && 'patchTouchControlDefaultOpacity', 'patchBabylonRendererClass', ] : []), diff --git a/src/modules/ui/dialog/navigation-dialog.ts b/src/modules/ui/dialog/navigation-dialog.ts index 45fd3c6..6f74338 100644 --- a/src/modules/ui/dialog/navigation-dialog.ts +++ b/src/modules/ui/dialog/navigation-dialog.ts @@ -544,7 +544,7 @@ export class NavigationDialogManager { const children = Array.from($elm.children); // Search from right to left if the orientation is horizontal - const orientation = ($elm as NavigationElement).nearby?.orientation; + const orientation = ($elm as NavigationElement).nearby?.orientation || 'vertical'; if (orientation === 'horizontal' || (orientation === 'vertical' && direction === NavigationDirection.UP)) { children.reverse(); } diff --git a/src/modules/ui/dialog/settings-dialog.ts b/src/modules/ui/dialog/settings-dialog.ts index 85a6497..6dbcc32 100644 --- a/src/modules/ui/dialog/settings-dialog.ts +++ b/src/modules/ui/dialog/settings-dialog.ts @@ -1,5 +1,5 @@ import { onChangeVideoPlayerType, updateVideoPlayer } from "@/modules/stream/stream-settings-utils"; -import { ButtonStyle, CE, createButton, createSvgIcon } from "@/utils/html"; +import { ButtonStyle, CE, createButton, createSvgIcon, removeChildElements } from "@/utils/html"; import { NavigationDialog, NavigationDirection } from "./navigation-dialog"; import { ControllerShortcut } from "@/modules/controller-shortcut"; import { MkbRemapper } from "@/modules/mkb/mkb-remapper"; @@ -17,13 +17,13 @@ import { setNearby } from "@/utils/navigation-utils"; import { PatcherCache } from "@/modules/patcher"; import { UserAgentProfile } from "@/enums/user-agent"; import { UserAgent } from "@/utils/user-agent"; -import { BX_FLAGS } from "@/utils/bx-flags"; +import { BX_FLAGS, NATIVE_FETCH } from "@/utils/bx-flags"; import { copyToClipboard } from "@/utils/utils"; import { GamepadKey } from "@/enums/mkb"; import { PrefKey, StorageKey } from "@/enums/pref-keys"; -import { getPref, getPrefDefinition, setPref } from "@/utils/settings-storages/global-settings-storage"; -import { SettingElement } from "@/utils/setting-element"; -import type { SettingDefinition } from "@/types/setting-definition"; +import { ControllerDeviceVibration, getPref, getPrefDefinition, setPref, StreamTouchController } from "@/utils/settings-storages/global-settings-storage"; +import { SettingElement, type BxHtmlSettingElement } from "@/utils/setting-element"; +import type { RecommendedSettings, SettingDefinition, SuggestedSettingCategory as SuggestedSettingProfile } from "@/types/setting-definition"; import { FullscreenText } from "../fullscreen-text"; @@ -72,9 +72,19 @@ export class SettingsNavigationDialog extends NavigationDialog { private $btnReload!: HTMLElement; private $btnGlobalReload!: HTMLButtonElement; private $noteGlobalReload!: HTMLElement; + private $btnSuggestion!: HTMLButtonElement; private renderFullSettings: boolean; + private suggestedSettings: Record> = { + recommended: {}, + default: {}, + lowest: {}, + highest: {}, + }; + private suggestedSettingLabels: PartialRecord = {}; + private settingElements: PartialRecord = {}; + private readonly TAB_GLOBAL_ITEMS: Array = [{ group: 'general', label: t('better-xcloud'), @@ -135,6 +145,17 @@ export class SettingsNavigationDialog extends NavigationDialog { }, t('settings-reload-note')); topButtons.push(this.$noteGlobalReload); + // Suggestion + this.$btnSuggestion = CE('div', { + class: 'bx-suggest-toggler bx-focusable', + tabindex: 0, + }, CE('label', {}, t('suggest-settings')), + CE('span', {}, '❯'), + ); + this.$btnSuggestion.addEventListener('click', this.renderSuggestions.bind(this)); + + topButtons.push(this.$btnSuggestion); + // Add buttons to parent const $div = CE('div', { class: 'bx-top-buttons', @@ -306,7 +327,10 @@ export class SettingsNavigationDialog extends NavigationDialog { label: 'Debug info', style: ButtonStyle.GHOST | ButtonStyle.FULL_WIDTH | ButtonStyle.FOCUSABLE, onClick: e => { - (e.target as HTMLElement).closest('button')?.nextElementSibling?.classList.toggle('bx-gone'); + const $pre = (e.target as HTMLElement).closest('button')?.nextElementSibling!; + + $pre.classList.toggle('bx-gone'); + $pre.scrollIntoView(); }, }), CE('pre', { @@ -617,6 +641,291 @@ export class SettingsNavigationDialog extends NavigationDialog { window.location.reload(); } + private async getRecommendedSettings(deviceCode: string): Promise { + // Get recommended settings from GitHub + try { + const response = await NATIVE_FETCH(`https://raw.githubusercontent.com/redphx/better-xcloud/gh-pages/devices/${deviceCode.toLowerCase()}.json`); + const json = (await response.json()) as RecommendedSettings; + const recommended: PartialRecord = {}; + + // Only supports schema version 1 + if (json.schema_version !== 1) { + return null; + } + + const scriptSettings = json.settings.script; + + // Set base settings + if (scriptSettings._base) { + let base = typeof scriptSettings._base === 'string' ? [scriptSettings._base] : scriptSettings._base; + for (const profile of base) { + Object.assign(recommended, this.suggestedSettings[profile]); + } + + delete scriptSettings._base; + } + + // Override settings + let key: Exclude; + // @ts-ignore + for (key in scriptSettings) { + recommended[key] = scriptSettings[key]; + } + + // Update device type in BxFlags + BX_FLAGS.DeviceInfo.deviceType = json.device_type; + + this.suggestedSettings.recommended = recommended; + + return json.device_name; + } catch (e) {} + + return null; + } + + private addDefaultSuggestedSetting(prefKey: PrefKey, value: any) { + let key: keyof typeof this.suggestedSettings; + for (key in this.suggestedSettings) { + if (key !== 'default' && !(prefKey in this.suggestedSettings)) { + this.suggestedSettings[key][prefKey] = value; + } + } + } + + private generateDefaultSuggestedSettings() { + let key: keyof typeof this.suggestedSettings; + for (key in this.suggestedSettings) { + if (key === 'default') { + continue; + } + + let prefKey: PrefKey; + for (prefKey in this.suggestedSettings[key]) { + if (!(prefKey in this.suggestedSettings.default)) { + this.suggestedSettings.default[prefKey] = getPrefDefinition(prefKey).default; + } + } + } + } + + private async renderSuggestions(e: Event) { + const $btnSuggest = (e.target as HTMLElement).closest('div')!; + $btnSuggest.toggleAttribute('bx-open'); + + let $content = $btnSuggest.nextElementSibling as HTMLElement; + if ($content) { + BxEvent.dispatch($content.querySelector('select'), 'input'); + return; + } + + // Get labels + for (const settingTab of this.SETTINGS_UI) { + if (!settingTab || !settingTab.items) { + continue; + } + + for (const settingTabContent of settingTab.items) { + if (!settingTabContent || !settingTabContent.items) { + continue; + } + + for (const setting of settingTabContent.items) { + let prefKey: PrefKey | undefined; + + if (typeof setting === 'string') { + prefKey = setting; + } else if (typeof setting === 'object') { + prefKey = setting.pref as PrefKey; + } + + if (prefKey) { + this.suggestedSettingLabels[prefKey] = settingTabContent.label; + } + } + } + } + + // Get recommended settings for Android devices + let recommendedDevice: string | null = ''; + + if (BX_FLAGS.DeviceInfo.deviceType.includes('android')) { + if (BX_FLAGS.DeviceInfo.androidInfo) { + const deviceCode = BX_FLAGS.DeviceInfo.androidInfo.board; + recommendedDevice = await this.getRecommendedSettings(deviceCode); + } + } + // recommendedDevice = await this.getRecommendedSettings('foster_e'); + + const hasRecommendedSettings = Object.keys(this.suggestedSettings.recommended).length > 0; + + // Add some specific setings based on device type + const deviceType = BX_FLAGS.DeviceInfo.deviceType; + if (deviceType === 'android-handheld') { + // Disable touch + this.addDefaultSuggestedSetting(PrefKey.STREAM_TOUCH_CONTROLLER, StreamTouchController.OFF); + // Enable device vibration + this.addDefaultSuggestedSetting(PrefKey.CONTROLLER_DEVICE_VIBRATION, ControllerDeviceVibration.ON); + } else if (deviceType === 'android') { + // Enable device vibration + this.addDefaultSuggestedSetting(PrefKey.CONTROLLER_DEVICE_VIBRATION, ControllerDeviceVibration.AUTO); + } else if (deviceType === 'android-tv') { + // Disable touch + this.addDefaultSuggestedSetting(PrefKey.STREAM_TOUCH_CONTROLLER, StreamTouchController.OFF); + } + + // Set value for Default profile + this.generateDefaultSuggestedSettings(); + + // Start rendering + const $suggestedSettings = CE('div', {class: 'bx-suggest-wrapper'}); + const $select = CE('select', {}, + hasRecommendedSettings && CE('option', {value: 'recommended'}, t('recommended')), + !hasRecommendedSettings && CE('option', {value: 'highest'}, t('highest-quality')), + CE('option', {value: 'default'}, t('default')), + CE('option', {value: 'lowest'}, t('lowest-quality')), + ); + $select.addEventListener('input', e => { + const profile = $select.value as SuggestedSettingProfile; + + // Empty children + removeChildElements($suggestedSettings); + const fragment = document.createDocumentFragment(); + + let note: HTMLElement | string | undefined; + if (profile === 'recommended') { + note = t('recommended-settings-for-device', {device: recommendedDevice}); + } else if (profile === 'highest') { + // Add note for "Highest quality" profile + note = '⚠️ ' + t('highest-quality-note'); + } + + note && fragment.appendChild(CE('div', {class: 'bx-suggest-note'}, note)); + + const settings = this.suggestedSettings[profile]; + let prefKey: PrefKey; + for (prefKey in settings) { + const currentValue = getPref(prefKey, false); + const suggestedValue = settings[prefKey]; + const currentValueText = STORAGE.Global.getValueText(prefKey, currentValue); + const isSameValue = currentValue === suggestedValue; + + let $child: HTMLElement; + let $value: HTMLElement | string; + if (isSameValue) { + // No changes + $value = currentValueText; + } else { + const suggestedValueText = STORAGE.Global.getValueText(prefKey, suggestedValue); + $value = currentValueText + ' ➔ ' + suggestedValueText; + } + + let $checkbox: HTMLInputElement; + const breadcrumb = this.suggestedSettingLabels[prefKey] + ' ❯ ' + STORAGE.Global.getLabel(prefKey); + + $child = CE('div', { + class: `bx-suggest-row ${isSameValue ? 'bx-suggest-ok' : 'bx-suggest-change'}`, + }, + $checkbox = CE('input', { + type: 'checkbox', + tabindex: 0, + checked: true, + id: `bx_suggest_${prefKey}`, + }), + CE('label', { + for: `bx_suggest_${prefKey}`, + }, + CE('div', { + class: 'bx-suggest-label', + }, breadcrumb), + CE('div', { + class: 'bx-suggest-value', + }, $value), + ), + ); + + if (isSameValue) { + $checkbox.disabled = true; + $checkbox.checked = true; + } + + fragment.appendChild($child); + } + + $suggestedSettings.appendChild(fragment); + }); + + BxEvent.dispatch($select, 'input'); + + const onClickApply = () => { + const profile = $select.value as SuggestedSettingProfile; + const settings = this.suggestedSettings[profile]; + + let prefKey: PrefKey; + for (prefKey in settings) { + const suggestedValue = settings[prefKey]; + const $checkBox = $content.querySelector(`#bx_suggest_${prefKey}`) as HTMLInputElement; + if (!$checkBox.checked || $checkBox.disabled) { + continue; + } + + const $control = this.settingElements[prefKey] as HTMLElement; + + // Set value directly if the control element is not available + if (!$control) { + setPref(prefKey, suggestedValue); + continue; + } + + if ('setValue' in $control) { + ($control as BxHtmlSettingElement).setValue(suggestedValue); + } else { + ($control as HTMLInputElement).value = suggestedValue; + } + + BxEvent.dispatch($control, 'input', { + manualTrigger: true, + }); + } + + // Refresh suggested settings + BxEvent.dispatch($select, 'input'); + }; + + // Apply button + const $btnApply = createButton({ + label: t('apply'), + style: ButtonStyle.FULL_WIDTH | ButtonStyle.FOCUSABLE, + onClick: onClickApply, + }); + + $content = CE('div', { + class: 'bx-suggest-box', + _nearby: { + orientation: 'vertical', + } + }, + BxSelectElement.wrap($select), + $suggestedSettings, + $btnApply, + + BX_FLAGS.DeviceInfo.deviceType.includes('android') && CE('a', { + class: 'bx-suggest-link bx-focusable', + href: 'https://better-xcloud.github.io/guide/android-webview-tweaks/', + target: '_blank', + tabindex: 0, + }, t('how-to-improve-app-performance')), + + BX_FLAGS.DeviceInfo.deviceType.includes('android') && !hasRecommendedSettings && CE('a', { + class: 'bx-suggest-link bx-focusable', + href: 'https://github.com/redphx/better-xcloud-devices', + target: '_blank', + tabindex: 0, + }, t('suggest-settings-link')), + ); + + $btnSuggest?.insertAdjacentElement('afterend', $content); + } + private renderTab(settingTab: SettingTab) { const $svg = createSvgIcon(settingTab.icon as any); $svg.dataset.group = settingTab.group; @@ -771,6 +1080,8 @@ export class SettingsNavigationDialog extends NavigationDialog { if ($control instanceof HTMLSelectElement && getPref(PrefKey.UI_CONTROLLER_FRIENDLY)) { $control = BxSelectElement.wrap($control); } + + pref && (this.settingElements[pref] = $control); } let prefDefinition: SettingDefinition | null = null; @@ -782,6 +1093,13 @@ export class SettingsNavigationDialog extends NavigationDialog { let note = prefDefinition?.note || setting.note; const experimental = prefDefinition?.experimental || setting.experimental; + if (settingTabContent.label && setting.pref) { + if (prefDefinition?.suggest) { + typeof prefDefinition.suggest.lowest !== 'undefined' && (this.suggestedSettings.lowest[setting.pref] = prefDefinition.suggest.lowest); + typeof prefDefinition.suggest.highest !== 'undefined' && (this.suggestedSettings.highest[setting.pref] = prefDefinition.suggest.highest); + } + } + // Add Experimental text if (experimental) { label = '🧪 ' + label; diff --git a/src/modules/vibration-manager.ts b/src/modules/vibration-manager.ts index 3f89f7c..cc33e72 100644 --- a/src/modules/vibration-manager.ts +++ b/src/modules/vibration-manager.ts @@ -1,7 +1,7 @@ import { AppInterface } from "@utils/global"; import { BxEvent } from "@utils/bx-event"; import { PrefKey } from "@/enums/pref-keys"; -import { getPref } from "@/utils/settings-storages/global-settings-storage"; +import { ControllerDeviceVibration, getPref } from "@/utils/settings-storages/global-settings-storage"; const VIBRATION_DATA_MAP = { 'gamepadIndex': 8, @@ -69,9 +69,9 @@ export class VibrationManager { const value = getPref(PrefKey.CONTROLLER_DEVICE_VIBRATION); let enabled; - if (value === 'on') { + if (value === ControllerDeviceVibration.ON) { enabled = true; - } else if (value === 'auto') { + } else if (value === ControllerDeviceVibration.AUTO) { enabled = true; const gamepads = window.navigator.getGamepads(); for (const gamepad of gamepads) { diff --git a/src/types/index.d.ts b/src/types/index.d.ts index 0c22947..9fca0ce 100644 --- a/src/types/index.d.ts +++ b/src/types/index.d.ts @@ -70,8 +70,6 @@ type BxStates = { pointerServerPort: number; } -type DualEnum = {[index: string]: number} & {[index: number]: string}; - type XcloudTitleInfo = { titleId: string, diff --git a/src/types/setting-definition.d.ts b/src/types/setting-definition.d.ts index 724f391..044c995 100644 --- a/src/types/setting-definition.d.ts +++ b/src/types/setting-definition.d.ts @@ -1,3 +1,19 @@ +import type { PrefKey } from "@/enums/pref-keys"; +import type { SettingElementType } from "@/utils/setting-element"; + +export type SuggestedSettingCategory = 'recommended' | 'lowest' | 'highest' | 'default'; +export type RecommendedSettings = { + schema_version: 1, + device_name: string, + device_type: 'android' | 'android-tv' | 'android-handheld' | 'webos', + settings: { + app: any, + script: { + _base?: 'lowest' | 'highest', + } & PartialRecord, + }, +}; + export type SettingDefinition = { default: any; } & Partial<{ @@ -5,7 +21,9 @@ export type SettingDefinition = { note: string | HTMLElement; experimental: boolean; unsupported: string | boolean; + suggest: PartialRecord, ready: (setting: SettingDefinition) => void; + type: SettingElementType, // migrate?: (this: Preferences, savedPrefs: any, value: any) => void; }> & ( {} | { diff --git a/src/utils/bx-exposed.ts b/src/utils/bx-exposed.ts index b179496..a55a3f8 100644 --- a/src/utils/bx-exposed.ts +++ b/src/utils/bx-exposed.ts @@ -5,7 +5,7 @@ import { BxLogger } from "./bx-logger"; import { BX_FLAGS } from "./bx-flags"; import { NavigationDialogManager } from "@/modules/ui/dialog/navigation-dialog"; import { PrefKey } from "@/enums/pref-keys"; -import { getPref } from "./settings-storages/global-settings-storage"; +import { getPref, StreamTouchController } from "./settings-storages/global-settings-storage"; export enum SupportedInputType { CONTROLLER = 'Controller', @@ -41,7 +41,7 @@ export const BxExposed = { let touchControllerAvailability = getPref(PrefKey.STREAM_TOUCH_CONTROLLER); // Disable touch control when gamepad found - if (touchControllerAvailability !== 'off' && getPref(PrefKey.STREAM_TOUCH_CONTROLLER_AUTO_OFF)) { + if (touchControllerAvailability !== StreamTouchController.OFF && getPref(PrefKey.STREAM_TOUCH_CONTROLLER_AUTO_OFF)) { const gamepads = window.navigator.getGamepads(); let gamepadFound = false; @@ -52,10 +52,10 @@ export const BxExposed = { } } - gamepadFound && (touchControllerAvailability = 'off'); + gamepadFound && (touchControllerAvailability = StreamTouchController.OFF); } - if (touchControllerAvailability === 'off') { + if (touchControllerAvailability === StreamTouchController.OFF) { // Disable touch on all games (not native touch) supportedInputTypes = supportedInputTypes.filter(i => i !== SupportedInputType.CUSTOM_TOUCH_OVERLAY && i !== SupportedInputType.GENERIC_TOUCH); // Empty TABs @@ -68,7 +68,7 @@ export const BxExposed = { supportedInputTypes.includes(SupportedInputType.CUSTOM_TOUCH_OVERLAY) || supportedInputTypes.includes(SupportedInputType.GENERIC_TOUCH); - if (!titleInfo.details.hasTouchSupport && touchControllerAvailability === 'all') { + if (!titleInfo.details.hasTouchSupport && touchControllerAvailability === StreamTouchController.ALL) { // Add generic touch support for non touch-supported games titleInfo.details.hasFakeTouchSupport = true; supportedInputTypes.push(SupportedInputType.GENERIC_TOUCH); diff --git a/src/utils/bx-flags.ts b/src/utils/bx-flags.ts index 51414db..7d244d2 100644 --- a/src/utils/bx-flags.ts +++ b/src/utils/bx-flags.ts @@ -1,3 +1,5 @@ +import { BxLogger } from "./bx-logger"; + type BxFlags = { CheckForUpdate: boolean; EnableXcloudLogging: boolean; @@ -7,8 +9,12 @@ type BxFlags = { FeatureGates: {[key: string]: boolean} | null, DeviceInfo: { - deviceType: 'android' | 'android-tv' | 'webos' | 'unknown', + deviceType: 'android' | 'android-tv' | 'android-handheld' | 'webos' | 'unknown', userAgent?: string, + + androidInfo?: { + board: string, + }, } } @@ -35,4 +41,6 @@ if (!BX_FLAGS.DeviceInfo.userAgent) { BX_FLAGS.DeviceInfo.userAgent = window.navigator.userAgent; } +BxLogger.info('BxFlags', BX_FLAGS); + export const NATIVE_FETCH = window.fetch; diff --git a/src/utils/html.ts b/src/utils/html.ts index 3885185..d158791 100644 --- a/src/utils/html.ts +++ b/src/utils/html.ts @@ -2,8 +2,36 @@ import type { BxIcon } from "@utils/bx-icon"; import { setNearby } from "./navigation-utils"; import type { NavigationNearbyElements } from "@/modules/ui/dialog/navigation-dialog"; +export enum ButtonStyle { + PRIMARY = 1, + DANGER = 2, + GHOST = 4, + FROSTED = 8, + DROP_SHADOW = 16, + FOCUSABLE = 32, + FULL_WIDTH = 64, + FULL_HEIGHT = 128, + TALL = 256, + CIRCULAR = 512, + NORMAL_CASE = 1024, +} + +const ButtonStyleClass = { + [ButtonStyle.PRIMARY]: 'bx-primary', + [ButtonStyle.DANGER]: 'bx-danger', + [ButtonStyle.GHOST]: 'bx-ghost', + [ButtonStyle.FROSTED]: 'bx-frosted', + [ButtonStyle.DROP_SHADOW]: 'bx-drop-shadow', + [ButtonStyle.FOCUSABLE]: 'bx-focusable', + [ButtonStyle.FULL_WIDTH]: 'bx-full-width', + [ButtonStyle.FULL_HEIGHT]: 'bx-full-height', + [ButtonStyle.TALL]: 'bx-tall', + [ButtonStyle.CIRCULAR]: 'bx-circular', + [ButtonStyle.NORMAL_CASE]: 'bx-normal-case', +} + type BxButton = { - style?: number | string | ButtonStyle; + style?: ButtonStyle; url?: string; classes?: string[]; icon?: typeof BxIcon; @@ -15,8 +43,6 @@ type BxButton = { attributes?: {[key: string]: any}, } -type ButtonStyle = {[index: string]: number} & {[index: number]: string}; - // Quickly create a tree of elements without having to use innerHTML type CreateElementOptions = { [index: string]: any; @@ -80,20 +106,7 @@ export const createSvgIcon = (icon: typeof BxIcon) => { return svgParser(icon.toString()); } -export const ButtonStyle: DualEnum = {}; -ButtonStyle[ButtonStyle.PRIMARY = 1] = 'bx-primary'; -ButtonStyle[ButtonStyle.DANGER = 2] = 'bx-danger'; -ButtonStyle[ButtonStyle.GHOST = 4] = 'bx-ghost'; -ButtonStyle[ButtonStyle.FROSTED = 8] = 'bx-frosted'; -ButtonStyle[ButtonStyle.DROP_SHADOW = 16] = 'bx-drop-shadow'; -ButtonStyle[ButtonStyle.FOCUSABLE = 32] = 'bx-focusable'; -ButtonStyle[ButtonStyle.FULL_WIDTH = 64] = 'bx-full-width'; -ButtonStyle[ButtonStyle.FULL_HEIGHT = 128] = 'bx-full-height'; -ButtonStyle[ButtonStyle.TALL = 256] = 'bx-tall'; -ButtonStyle[ButtonStyle.CIRCULAR = 512] = 'bx-circular'; -ButtonStyle[ButtonStyle.NORMAL_CASE = 1024] = 'bx-normal-case'; - -const ButtonStyleIndices = Object.keys(ButtonStyle).splice(0, Object.keys(ButtonStyle).length / 2).map(i => parseInt(i)); +const ButtonStyleIndices = Object.keys(ButtonStyleClass).map(i => parseInt(i)); export const createButton = (options: BxButton): T => { let $btn; @@ -106,8 +119,8 @@ export const createButton = (options: BxButton): T => { } const style = (options.style || 0) as number; - style && ButtonStyleIndices.forEach(index => { - (style & index) && $btn.classList.add(ButtonStyle[index] as string); + style && ButtonStyleIndices.forEach((index: keyof typeof ButtonStyleClass) => { + (style & index) && $btn.classList.add(ButtonStyleClass[index] as string); }); options.classes && $btn.classList.add(...options.classes); @@ -153,3 +166,9 @@ export function isElementVisible($elm: HTMLElement): boolean { export const CTN = document.createTextNode.bind(document); window.BX_CE = createElement; + +export function removeChildElements($parent: HTMLElement) { + while ($parent.firstElementChild) { + $parent.firstElementChild.remove(); + } +} diff --git a/src/utils/setting-element.ts b/src/utils/setting-element.ts index 73c0cf4..89d4cba 100644 --- a/src/utils/setting-element.ts +++ b/src/utils/setting-element.ts @@ -4,6 +4,7 @@ import { setNearby } from "./navigation-utils"; import type { PrefKey } from "@/enums/pref-keys"; import type { BaseSettingsStore } from "./settings-storages/base-settings-storage"; import { type MultipleOptionsParams, type NumberStepperParams } from "@/types/setting-definition"; +import { BxEvent } from "./bx-event"; export enum SettingElementType { OPTIONS = 'options', @@ -13,16 +14,26 @@ export enum SettingElementType { CHECKBOX = 'checkbox', } +interface BxBaseSettingElement { + setValue: (value: any) => void, +} + +export interface BxHtmlSettingElement extends HTMLElement, BxBaseSettingElement {}; + +export interface BxSelectSettingElement extends HTMLSelectElement, BxBaseSettingElement {} + export class SettingElement { - static #renderOptions(key: string, setting: PreferenceSetting, currentValue: any, onChange: any) { - const $control = CE('select', { + static #renderOptions(key: string, setting: PreferenceSetting, currentValue: any, onChange: any): BxSelectSettingElement { + const $control = CE('select', { // title: setting.label, tabindex: 0, - }) as HTMLSelectElement; + }); let $parent: HTMLElement; if (setting.optionsGroup) { - $parent = CE('optgroup', {'label': setting.optionsGroup}); + $parent = CE('optgroup', { + label: setting.optionsGroup, + }); $control.appendChild($parent); } else { $parent = $control; @@ -44,19 +55,20 @@ export class SettingElement { }); // Custom method - ($control as any).setValue = (value: any) => { + $control.setValue = (value: any) => { $control.value = value; }; return $control; } - static #renderMultipleOptions(key: string, setting: PreferenceSetting, currentValue: any, onChange: any, params: MultipleOptionsParams={}) { - const $control = CE('select', { + static #renderMultipleOptions(key: string, setting: PreferenceSetting, currentValue: any, onChange: any, params: MultipleOptionsParams={}): BxSelectSettingElement { + const $control = CE('select', { // title: setting.label, multiple: true, tabindex: 0, }); + if (params && params.size) { $control.setAttribute('size', params.size.toString()); } @@ -75,7 +87,7 @@ export class SettingElement { const $parent = target.parentElement!; $parent.focus(); - $parent.dispatchEvent(new Event('input')); + BxEvent.dispatch($parent, 'input'); }); $control.appendChild($option); @@ -100,9 +112,15 @@ export class SettingElement { } static #renderNumber(key: string, setting: PreferenceSetting, currentValue: any, onChange: any) { - const $control = CE('input', {'tabindex': 0, 'type': 'number', 'min': setting.min, 'max': setting.max}) as HTMLInputElement; + const $control = CE('input', { + tabindex: 0, + type: 'number', + min: setting.min, + max: setting.max, + }); + $control.value = currentValue; - onChange && $control.addEventListener('change', (e: Event) => { + onChange && $control.addEventListener('input', (e: Event) => { const target = e.target as HTMLInputElement; const value = Math.max(setting.min!, Math.min(setting.max!, parseInt(target.value))); @@ -118,7 +136,7 @@ export class SettingElement { const $control = CE('input', {'type': 'checkbox', 'tabindex': 0}) as HTMLInputElement; $control.checked = currentValue; - onChange && $control.addEventListener('change', e => { + onChange && $control.addEventListener('input', e => { !(e as any).ignoreOnChange && onChange(e, (e.target as HTMLInputElement).checked); }); @@ -162,77 +180,21 @@ export class SettingElement { $btnInc.classList.toggle('bx-inactive', controlValue === MAX); } - const $wrapper = CE('div', {'class': 'bx-number-stepper', id: `bx_setting_${key}`}, - $btnDec = CE('button', { - 'data-type': 'dec', - type: 'button', - class: options.hideSlider ? 'bx-focusable' : '', - tabindex: options.hideSlider ? 0 : -1, - }, '-') as HTMLButtonElement, - $text = CE('span', {}, renderTextValue(value)) as HTMLSpanElement, - $btnInc = CE('button', { - 'data-type': 'inc', - type: 'button', - class: options.hideSlider ? 'bx-focusable' : '', - tabindex: options.hideSlider ? 0 : -1, - }, '+') as HTMLButtonElement, - ); - - if (options.disabled) { - ($wrapper as any).disabled = true; - } - - if (!options.disabled && !options.hideSlider) { - $range = CE('input', { - id: `bx_setting_${key}`, - type: 'range', - min: MIN, - max: MAX, - value: value, - step: STEPS, - tabindex: 0, - }) as HTMLInputElement; - - $range.addEventListener('input', e => { - value = parseInt((e.target as HTMLInputElement).value); - const valueChanged = controlValue !== value; - - if (!valueChanged) { - return; - } - - controlValue = value; - updateButtonsVisibility(); - $text.textContent = renderTextValue(value); - - !(e as any).ignoreOnChange && onChange && onChange(e, value); - }); - - $wrapper.appendChild($range); - - if (options.ticks || options.exactTicks) { - const markersId = `markers-${key}`; - const $markers = CE('datalist', {'id': markersId}); - $range.setAttribute('list', markersId); - - if (options.exactTicks) { - let start = Math.max(Math.floor(MIN / options.exactTicks), 1) * options.exactTicks; - - if (start === MIN) { - start += options.exactTicks; - } - - for (let i = start; i < MAX; i += options.exactTicks) { - $markers.appendChild(CE('option', {'value': i})); - } - } else { - for (let i = MIN + options.ticks!; i < MAX; i += options.ticks!) { - $markers.appendChild(CE('option', {'value': i})); - } - } - $wrapper.appendChild($markers); - } - } + const $wrapper = CE('div', {'class': 'bx-number-stepper', id: `bx_setting_${key}`}, + $btnDec = CE('button', { + 'data-type': 'dec', + type: 'button', + class: options.hideSlider ? 'bx-focusable' : '', + tabindex: options.hideSlider ? 0 : -1, + }, '-') as HTMLButtonElement, + $text = CE('span', {}, renderTextValue(value)) as HTMLSpanElement, + $btnInc = CE('button', { + 'data-type': 'inc', + type: 'button', + class: options.hideSlider ? 'bx-focusable' : '', + tabindex: options.hideSlider ? 0 : -1, + }, '+') as HTMLButtonElement, + ); if (options.disabled) { $btnInc.disabled = true; @@ -240,9 +202,66 @@ export class SettingElement { $btnDec.disabled = true; $btnDec.classList.add('bx-inactive'); + + ($wrapper as any).disabled = true; return $wrapper; } + $range = CE('input', { + id: `bx_setting_${key}`, + type: 'range', + min: MIN, + max: MAX, + value: value, + step: STEPS, + tabindex: 0, + }); + + options.hideSlider && $range.classList.add('bx-gone'); + + $range.addEventListener('input', e => { + value = parseInt((e.target as HTMLInputElement).value); + const valueChanged = controlValue !== value; + + if (!valueChanged) { + return; + } + + controlValue = value; + updateButtonsVisibility(); + $text.textContent = renderTextValue(value); + + !(e as any).ignoreOnChange && onChange && onChange(e, value); + }); + + $wrapper.addEventListener('input', e => { + BxEvent.dispatch($range, 'input'); + }); + $wrapper.appendChild($range); + + if (options.ticks || options.exactTicks) { + const markersId = `markers-${key}`; + const $markers = CE('datalist', {'id': markersId}); + $range.setAttribute('list', markersId); + + if (options.exactTicks) { + let start = Math.max(Math.floor(MIN / options.exactTicks), 1) * options.exactTicks; + + if (start === MIN) { + start += options.exactTicks; + } + + for (let i = start; i < MAX; i += options.exactTicks) { + $markers.appendChild(CE('option', {'value': i})); + } + } else { + for (let i = MIN + options.ticks!; i < MAX; i += options.ticks!) { + $markers.appendChild(CE('option', {'value': i})); + } + } + $wrapper.appendChild($markers); + } + updateButtonsVisibility(); let interval: number; @@ -278,16 +297,14 @@ export class SettingElement { const onMouseDown = (e: PointerEvent) => { e.preventDefault(); - isHolding = true; const args = arguments; interval && clearInterval(interval); interval = window.setInterval(() => { - const event = new Event('click'); - (event as any).arguments = args; - - e.target?.dispatchEvent(event); + e.target && BxEvent.dispatch(e.target as HTMLElement, 'click', { + arguments: args, + }); }, 200); }; @@ -301,11 +318,9 @@ export class SettingElement { const onContextMenu = (e: Event) => e.preventDefault(); // Custom method - ($wrapper as any).setValue = (value: any) => { - controlValue = parseInt(value); - + $wrapper.setValue = (value: any) => { $text.textContent = renderTextValue(value); - $range && ($range.value = value); + $range.value = value; }; $btnDec.addEventListener('click', onClick); diff --git a/src/utils/settings-storages/base-settings-storage.ts b/src/utils/settings-storages/base-settings-storage.ts index bc884c8..eeb6789 100644 --- a/src/utils/settings-storages/base-settings-storage.ts +++ b/src/utils/settings-storages/base-settings-storage.ts @@ -1,6 +1,7 @@ import type { PrefKey } from "@/enums/pref-keys"; -import type { SettingDefinitions } from "@/types/setting-definition"; +import type { NumberStepperParams, SettingDefinitions } from "@/types/setting-definition"; import { BxEvent } from "../bx-event"; +import { SettingElementType } from "../setting-element"; export class BaseSettingsStore { private storage: Storage; @@ -12,7 +13,8 @@ export class BaseSettingsStore { this.storage = window.localStorage; this.storageKey = storageKey; - for (const settingId in definitions) { + let settingId: keyof typeof definitions + for (settingId in definitions) { const setting = definitions[settingId]; /* @@ -49,14 +51,14 @@ export class BaseSettingsStore { return this.definitions[key]; } - getSetting(key: PrefKey) { + getSetting(key: PrefKey, checkUnsupported = true) { if (typeof key === 'undefined') { debugger; return; } // Return default value if the feature is not supported - if (this.definitions[key].unsupported) { + if (checkUnsupported && this.definitions[key].unsupported) { return this.definitions[key].default; } @@ -121,4 +123,30 @@ export class BaseSettingsStore { return value; } + + getLabel(key: PrefKey): string { + return this.definitions[key].label || key; + } + + getValueText(key: PrefKey, value: any): string { + const definition = this.definitions[key]; + if (definition.type === SettingElementType.NUMBER_STEPPER) { + const params = (definition as any).params as NumberStepperParams; + if (params.customTextValue) { + const text = params.customTextValue(value); + if (text) { + return text; + } + } + + return value.toString(); + } else if ('options' in definition) { + const options = (definition as any).options; + if (value in options) { + return options[value]; + } + } + + return value.toString(); + } } diff --git a/src/utils/settings-storages/global-settings-storage.ts b/src/utils/settings-storages/global-settings-storage.ts index c322f81..0b2efa1 100644 --- a/src/utils/settings-storages/global-settings-storage.ts +++ b/src/utils/settings-storages/global-settings-storage.ts @@ -4,8 +4,7 @@ import { StreamPlayerType, StreamVideoProcessing } from "@/enums/stream-player"; import { UiSection } from "@/enums/ui-sections"; import { UserAgentProfile } from "@/enums/user-agent"; import { StreamStat } from "@/modules/stream/stream-stats"; -import type { PreferenceSetting } from "@/types/preferences"; -import { type SettingDefinitions } from "@/types/setting-definition"; +import { type SettingDefinition, type SettingDefinitions } from "@/types/setting-definition"; import { BX_FLAGS } from "../bx-flags"; import { STATES, AppInterface, STORAGE } from "../global"; import { CE } from "../html"; @@ -15,8 +14,33 @@ import { BaseSettingsStore as BaseSettingsStorage } from "./base-settings-storag import { SettingElementType } from "../setting-element"; +export const enum StreamResolution { + DIM_720P = '720p', + DIM_1080P = '1080p', +} + +export const enum CodecProfile { + DEFAULT = 'default', + LOW = 'low', + NORMAL = 'normal', + HIGH = 'high', +}; + +export const enum StreamTouchController { + DEFAULT = 'default', + ALL = 'all', + OFF = 'off', +} + +export const enum ControllerDeviceVibration { + ON = 'on', + AUTO = 'auto', + OFF = 'off', +} + + function getSupportedCodecProfiles() { - const options: {[index: string]: string} = { + const options: PartialRecord = { default: t('default'), }; @@ -46,25 +70,25 @@ function getSupportedCodecProfiles() { if (hasLowCodec) { if (!hasNormalCodec && !hasHighCodec) { - options.default = `${t('visual-quality-low')} (${t('default')})`; + options[CodecProfile.DEFAULT] = `${t('visual-quality-low')} (${t('default')})`; } else { - options.low = t('visual-quality-low'); + options[CodecProfile.LOW] = t('visual-quality-low'); } } if (hasNormalCodec) { if (!hasLowCodec && !hasHighCodec) { - options.default = `${t('visual-quality-normal')} (${t('default')})`; + options[CodecProfile.DEFAULT] = `${t('visual-quality-normal')} (${t('default')})`; } else { - options.normal = t('visual-quality-normal'); + options[CodecProfile.NORMAL] = t('visual-quality-normal'); } } if (hasHighCodec) { if (!hasLowCodec && !hasNormalCodec) { - options.default = `${t('visual-quality-high')} (${t('default')})`; + options[CodecProfile.DEFAULT] = `${t('visual-quality-high')} (${t('default')})`; } else { - options.high = t('visual-quality-high'); + options[CodecProfile.HIGH] = t('visual-quality-high'); } } @@ -140,25 +164,31 @@ export class GlobalSettingsStorage extends BaseSettingsStorage { default: 'auto', options: { auto: t('default'), - '720p': '720p', - '1080p': '1080p', + [StreamResolution.DIM_720P]: '720p', + [StreamResolution.DIM_1080P]: '1080p', + }, + suggest: { + lowest: StreamResolution.DIM_720P, + highest: StreamResolution.DIM_1080P, }, }, [PrefKey.STREAM_CODEC_PROFILE]: { label: t('visual-quality'), default: 'default', options: getSupportedCodecProfiles(), - ready: (setting: PreferenceSetting) => { - const options: any = setting.options; + ready: (setting: SettingDefinition) => { + const options = (setting as any).options; const keys = Object.keys(options); if (keys.length <= 1) { // Unsupported setting.unsupported = true; setting.note = '⚠️ ' + t('browser-unsupported-feature'); - } else { - // Set default value to the best codec profile - // setting.default = keys[keys.length - 1]; } + + setting.suggest = { + lowest: keys.length === 1 ? keys[0] : keys[1], + highest: keys[keys.length - 1], + }; }, }, [PrefKey.PREFER_IPV6_SERVER]: { @@ -189,16 +219,16 @@ export class GlobalSettingsStorage extends BaseSettingsStorage { [PrefKey.STREAM_TOUCH_CONTROLLER]: { label: t('tc-availability'), - default: 'all', + default: StreamTouchController.ALL, options: { - default: t('default'), - all: t('tc-all-games'), - off: t('off'), + [StreamTouchController.DEFAULT]: t('default'), + [StreamTouchController.ALL]: t('tc-all-games'), + [StreamTouchController.OFF]: t('off'), }, unsupported: !STATES.userAgent.capabilities.touch, - ready: (setting: PreferenceSetting) => { + ready: (setting: SettingDefinition) => { if (setting.unsupported) { - setting.default = 'default'; + setting.default = StreamTouchController.DEFAULT; } }, }, @@ -274,6 +304,9 @@ export class GlobalSettingsStorage extends BaseSettingsStorage { } }, }, + suggest: { + highest: 0, + } }, [PrefKey.GAME_BAR_POSITION]: { @@ -318,11 +351,11 @@ export class GlobalSettingsStorage extends BaseSettingsStorage { [PrefKey.CONTROLLER_DEVICE_VIBRATION]: { label: t('device-vibration'), - default: 'off', + default: ControllerDeviceVibration.OFF, options: { - on: t('on'), - auto: t('device-vibration-not-using-gamepad'), - off: t('off'), + [ControllerDeviceVibration.ON]: t('on'), + [ControllerDeviceVibration.AUTO]: t('device-vibration-not-using-gamepad'), + [ControllerDeviceVibration.OFF]: t('off'), }, }, @@ -346,7 +379,7 @@ export class GlobalSettingsStorage extends BaseSettingsStorage { const userAgent = ((window.navigator as any).orgUserAgent || window.navigator.userAgent || '').toLowerCase(); return !AppInterface && userAgent.match(/(android|iphone|ipad)/) ? t('browser-unsupported-feature') : false; })(), - ready: (setting: PreferenceSetting) => { + ready: (setting: SettingDefinition) => { let note; let url; if (setting.unsupported) { @@ -372,16 +405,15 @@ export class GlobalSettingsStorage extends BaseSettingsStorage { on: t('on'), off: t('off'), }, - ready: (setting: PreferenceSetting) => { + ready: (setting: SettingDefinition) => { if (AppInterface) { - } else if (UserAgent.isMobile()) { setting.unsupported = true; setting.default = 'off'; - delete setting.options!['default']; - delete setting.options!['on']; + delete (setting as any).options['default']; + delete (setting as any).options['on']; } else { - delete setting.options!['on']; + delete (setting as any).options['on']; } }, }, @@ -530,6 +562,10 @@ export class GlobalSettingsStorage extends BaseSettingsStorage { [StreamPlayerType.VIDEO]: t('default'), [StreamPlayerType.WEBGL2]: t('webgl2'), }, + suggest: { + lowest: StreamPlayerType.VIDEO, + highest: StreamPlayerType.WEBGL2, + }, }, [PrefKey.VIDEO_PROCESSING]: { label: t('clarity-boost'), @@ -538,6 +574,10 @@ export class GlobalSettingsStorage extends BaseSettingsStorage { [StreamVideoProcessing.USM]: t('unsharp-masking'), [StreamVideoProcessing.CAS]: t('amd-fidelity-cas'), }, + suggest: { + lowest: StreamVideoProcessing.USM, + highest: StreamVideoProcessing.CAS, + }, }, [PrefKey.VIDEO_POWER_PREFERENCE]: { label: t('renderer-configuration'), @@ -547,6 +587,9 @@ export class GlobalSettingsStorage extends BaseSettingsStorage { 'low-power': t('low-power'), 'high-performance': t('high-performance'), }, + suggest: { + highest: 'low-power', + }, }, [PrefKey.VIDEO_SHARPNESS]: { label: t('sharpness'), @@ -561,6 +604,10 @@ export class GlobalSettingsStorage extends BaseSettingsStorage { return value === 0 ? t('off') : value.toString(); }, }, + suggest: { + lowest: 0, + highest: 4, + }, }, [PrefKey.VIDEO_RATIO]: { label: t('aspect-ratio'), @@ -701,10 +748,10 @@ export class GlobalSettingsStorage extends BaseSettingsStorage { }, [PrefKey.REMOTE_PLAY_RESOLUTION]: { - default: '1080p', + default: StreamResolution.DIM_1080P, options: { - '1080p': '1080p', - '720p': '720p', + [StreamResolution.DIM_1080P]: '1080p', + [StreamResolution.DIM_720P]: '720p', }, }, diff --git a/src/utils/translation.ts b/src/utils/translation.ts index 5099ba2..c5313a5 100644 --- a/src/utils/translation.ts +++ b/src/utils/translation.ts @@ -121,8 +121,11 @@ const Texts = { "hide-system-menu-icon": "Hide System menu's icon", "hide-touch-controller": "Hide touch controller", "high-performance": "High performance", + "highest-quality": "Highest quality", + "highest-quality-note": "Your device may not be powerful enough to use these settings", "horizontal-scroll-sensitivity": "Horizontal scroll sensitivity", "horizontal-sensitivity": "Horizontal sensitivity", + "how-to-improve-app-performance": "How to improve app's performance", "ignore": "Ignore", "import": "Import", "increase": "Increase", @@ -137,6 +140,7 @@ const Texts = { "loading-screen": "Loading screen", "local-co-op": "Local co-op", "low-power": "Low power", + "lowest-quality": "Lowest quality", "map-mouse-to": "Map mouse to", "may-not-work-properly": "May not work properly!", "menu": "Menu", @@ -189,6 +193,28 @@ const Texts = { ], "press-to-bind": "Press a key or do a mouse click to bind...", "prompt-preset-name": "Preset's name:", + "recommended": "Recommended", + "recommended-settings-for-device": [ + (e: any) => `Recommended settings for ${e.device}`, + , + , + , + , + (e: any) => `Ajustes recomendados para ${e.device}`, + , + (e: any) => `Configurazioni consigliate per ${e.device}`, + , + (e: any) => `다음 기기에서 권장되는 설정: ${e.device}`, + , + , + , + , + , + (e: any) => `Рекомендовані налаштування для ${e.device}`, + (e: any) => `Cấu hình được đề xuất cho ${e.device}`, + , + , + ], "reduce-animations": "Reduce UI animations", "region": "Region", "reload-page": "Reload page", @@ -250,6 +276,8 @@ const Texts = { "stream-settings": "Stream settings", "stream-stats": "Stream stats", "stretch": "Stretch", + "suggest-settings": "Suggest settings", + "suggest-settings-link": "Suggest recommended settings for this device", "support-better-xcloud": "Support Better xCloud", "swap-buttons": "Swap buttons", "take-screenshot": "Take screenshot", @@ -314,6 +342,7 @@ const Texts = { "volume": "Volume", "wait-time-countdown": "Countdown", "wait-time-estimated": "Estimated finish time", + "wallpaper": "Wallpaper", "webgl2": "WebGL2", }; diff --git a/src/utils/xcloud-interceptor.ts b/src/utils/xcloud-interceptor.ts index ad425b1..f4d9bdb 100644 --- a/src/utils/xcloud-interceptor.ts +++ b/src/utils/xcloud-interceptor.ts @@ -9,7 +9,7 @@ import { patchIceCandidates } from "./network"; import { getPreferredServerRegion } from "./region"; import { BypassServerIps } from "@/enums/bypass-servers"; import { PrefKey } from "@/enums/pref-keys"; -import { getPref } from "./settings-storages/global-settings-storage"; +import { getPref, StreamResolution, StreamTouchController } from "./settings-storages/global-settings-storage"; export class XcloudInterceptor { @@ -111,7 +111,7 @@ class XcloudInterceptor { // Force stream's resolution if (PREF_STREAM_TARGET_RESOLUTION !== 'auto') { - const osName = (PREF_STREAM_TARGET_RESOLUTION === '720p') ? 'android' : 'windows'; + const osName = (PREF_STREAM_TARGET_RESOLUTION === StreamResolution.DIM_720P) ? 'android' : 'windows'; body.settings.osName = osName; } @@ -147,7 +147,7 @@ class XcloudInterceptor { } // Touch controller for all games - if (getPref(PrefKey.STREAM_TOUCH_CONTROLLER) === 'all') { + if (getPref(PrefKey.STREAM_TOUCH_CONTROLLER) === StreamTouchController.ALL) { const titleInfo = STATES.currentStream.titleInfo; if (titleInfo?.details.hasTouchSupport) { TouchController.disable(); diff --git a/src/utils/xhome-interceptor.ts b/src/utils/xhome-interceptor.ts index a916428..4d18071 100644 --- a/src/utils/xhome-interceptor.ts +++ b/src/utils/xhome-interceptor.ts @@ -6,7 +6,7 @@ import { NATIVE_FETCH } from "./bx-flags"; import { STATES } from "./global"; import { patchIceCandidates } from "./network"; import { PrefKey } from "@/enums/pref-keys"; -import { getPref } from "./settings-storages/global-settings-storage"; +import { getPref, StreamResolution, StreamTouchController } from "./settings-storages/global-settings-storage"; import type { RemotePlayConsoleAddresses } from "@/types/network"; export class XhomeInterceptor { @@ -70,7 +70,7 @@ export class XhomeInterceptor { static async #handleInputConfigs(request: Request | URL, opts: {[index: string]: any}) { const response = await NATIVE_FETCH(request); - if (getPref(PrefKey.STREAM_TOUCH_CONTROLLER) !== 'all') { + if (getPref(PrefKey.STREAM_TOUCH_CONTROLLER) !== StreamTouchController.ALL) { return response; } @@ -150,7 +150,7 @@ export class XhomeInterceptor { // Patch resolution const deviceInfo = RemotePlay.BASE_DEVICE_INFO; - if (getPref(PrefKey.REMOTE_PLAY_RESOLUTION) === '720p') { + if (getPref(PrefKey.REMOTE_PLAY_RESOLUTION) === StreamResolution.DIM_720P) { deviceInfo.dev.os.name = 'android'; } diff --git a/src/web-components/bx-select.ts b/src/web-components/bx-select.ts index 0491a38..45495b5 100644 --- a/src/web-components/bx-select.ts +++ b/src/web-components/bx-select.ts @@ -1,4 +1,6 @@ import type { NavigationElement } from "@/modules/ui/dialog/navigation-dialog"; +import { BxEvent } from "@/utils/bx-event"; +import type { BxSelectSettingElement } from "@/utils/setting-element"; import { ButtonStyle, CE, createButton } from "@utils/html"; export class BxSelectElement { @@ -40,7 +42,7 @@ export class BxSelectElement { const $option = getOptionAtIndex(visibleIndex); $option && ($option.selected = (e.target as HTMLInputElement).checked); - $select.dispatchEvent(new Event('input')); + BxEvent.dispatch($select, 'input'); }); } else { $content = CE('div', {}, @@ -122,7 +124,7 @@ export class BxSelectElement { if (isMultiple) { render(); } else { - $select.dispatchEvent(new Event('input')); + BxEvent.dispatch($select, 'input'); } }; @@ -178,7 +180,15 @@ export class BxSelectElement { $div.dispatchEvent = function() { // @ts-ignore return $select.dispatchEvent.apply($select, arguments); - } + }; + + ($div as any).setValue = (value: any) => { + if ('setValue' in $select) { + ($select as BxSelectSettingElement).setValue(value); + } else { + $select.value = value; + } + }; return $div; }