diff --git a/bun.lockb b/bun.lockb index 89daf25..9017b1f 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index 74f6103..1124b02 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ }, "devDependencies": { "@types/bun": "^1.1.6", - "@types/node": "^20.14.15", + "@types/node": "^20.16.1", "@types/stylus": "^0.48.42", "eslint": "^9.9.0", "eslint-plugin-compat": "^6.0.0", diff --git a/src/assets/css/root.styl b/src/assets/css/root.styl index b7c84f6..3fcf53d 100644 --- a/src/assets/css/root.styl +++ b/src/assets/css/root.styl @@ -59,7 +59,7 @@ div[class^=HUDButton-module__hiddenContainer] ~ div:not([class^=HUDButton-module } /* Remove the "Cloud Gaming" text in header when the screen is too small */ -@media screen and (max-width: 600px) { +@media screen and (max-width: 640px) { header a[href="/play"] { display: none; } diff --git a/src/assets/css/settings-dialog.styl b/src/assets/css/settings-dialog.styl index 3f0e3a7..df24a7d 100644 --- a/src/assets/css/settings-dialog.styl +++ b/src/assets/css/settings-dialog.styl @@ -31,6 +31,42 @@ font-weight: normal; height: var(--bx-button-height); } + + input { + accent-color: var(--bx-primary-button-color); + + &:focus { + accent-color: var(--bx-danger-button-color); + } + } + + select:disabled { + -webkit-appearance: none; + background: transparent; + text-align-last: right; + border: none; + color: #fff; + } + + select option:disabled { + display: none; + } + + input[type=checkbox], + select { + &:focus { + filter: drop-shadow(1px 0 0 #fff) drop-shadow(-1px 0 0 #fff) drop-shadow(0 1px 0 #fff) drop-shadow(0 -1px 0 #fff); + } + } + + a { + color: #5dc21e; + text-decoration: none; + + &:hover, &:focus { + color: #128112; + } + } } .bx-settings-tabs-container { @@ -258,13 +294,6 @@ flex-wrap: wrap; } - input[type=checkbox], - select { - &:focus { - filter: drop-shadow(1px 0 0 #fff) drop-shadow(-1px 0 0 #fff) drop-shadow(0 1px 0 #fff) drop-shadow(0 -1px 0 #fff); - } - } - /* &:has(input:focus), &:has(select:focus), &:has(button:focus) { border-left-color: white; @@ -282,26 +311,6 @@ margin: 0 0 0 auto; } } - - input { - accent-color: var(--bx-primary-button-color); - - &:focus { - accent-color: var(--bx-danger-button-color); - } - } - - select:disabled { - -webkit-appearance: none; - background: transparent; - text-align-last: right; - border: none; - color: #fff; - } - - select option:disabled { - display: none; - } } .bx-settings-dialog-note { @@ -335,15 +344,6 @@ line-height: 20px; font-size: 14px; margin-top: 10px; - color: #5dc21e; - - &:hover { - color: #6dd72b; - } - - &:focus { - text-decoration: underline; - } } .bx-debug-info { @@ -404,3 +404,166 @@ } } } + +.bx-suggest-toggler { + text-align: left; + display: flex; + border-radius: 4px; + overflow: hidden; + background: #003861; + + label { + flex: 1; + margin-bottom: 0; + padding: 10px; + background: #004f87; + } + + span { + display: inline-block; + align-self: center; + padding: 10px; + width: 40px; + text-align: center; + } + + &:hover, &:focus { + cursor: pointer; + background: #005da1; + + label { + background: #006fbe; + } + } + + &[bx-open] { + span { + transform: rotate(90deg); + } + + &+ .bx-suggest-box { + display: block; + } + } +} + +.bx-suggest-box { + display: none; + background: #161616; + padding: 10px; + box-shadow: 0px 0px 12px #0f0f0f inset; + border-radius: 10px; +} + +.bx-suggest-wrapper { + display: flex; + flex-direction: column; + gap: 10px; + margin: 10px; +} + +.bx-suggest-note { + font-size: 11px; + color: #8c8c8c; + font-style: italic; + font-weight: 100; +} + +.bx-suggest-link { + font-size: 12px; + display: inline-block; + margin-top: 10px; +} + +.bx-suggest-row { + display: flex; + flex-direction: row; + gap: 10px; + + label { + flex: 1; + overflow: overlay; + border-radius: 4px; + + .bx-suggest-label { + background: #323232; + padding: 4px 10px; + font-size: 12px; + text-align: left; + } + + .bx-suggest-value { + padding: 6px; + font-size: 14px; + + &.bx-suggest-change { + background-color: var(--bx-warning-color); + } + } + } + + &.bx-suggest-ok { + input { + visibility: hidden; + } + + .bx-suggest-label { + background-color: #008114; + } + + .bx-suggest-value { + background-color: #13a72a; + } + } + + &.bx-suggest-change { + .bx-suggest-label { + background-color: #a65e08; + } + + .bx-suggest-value { + background-color: #d57f18; + } + + &:hover { + label { + cursor: pointer; + } + + .bx-suggest-label { + background-color: #995707; + } + + .bx-suggest-value { + background-color: #bd7115; + } + } + + // Unchecked setting + input:not(:checked) + label { + opacity: 0.5; + + .bx-suggest-label { + background-color: #2a2a2a; + } + + .bx-suggest-value { + background-color: #393939; + } + } + + &:hover { + input:not(:checked) + label { + opacity: 1; + + .bx-suggest-label { + background-color: #202020; + } + + .bx-suggest-value { + background-color: #303030; + } + } + } + } +} diff --git a/src/index.ts b/src/index.ts index 05adb15..06cd02e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -34,7 +34,7 @@ import { GameTile } from "./modules/ui/game-tile"; import { ProductDetailsPage } from "./modules/ui/product-details"; import { NavigationDialogManager } from "./modules/ui/dialog/navigation-dialog"; import { PrefKey } from "./enums/pref-keys"; -import { getPref } from "./utils/settings-storages/global-settings-storage"; +import { getPref, StreamTouchController } from "./utils/settings-storages/global-settings-storage"; import { compressCss } from "@macros/build" with {type: "macro"}; import { SettingsNavigationDialog } from "./modules/ui/dialog/settings-dialog"; import { StreamUiHandler } from "./modules/stream/stream-ui"; @@ -359,7 +359,7 @@ function main() { RemotePlay.detect(); } - if (getPref(PrefKey.STREAM_TOUCH_CONTROLLER) === 'all') { + if (getPref(PrefKey.STREAM_TOUCH_CONTROLLER) === StreamTouchController.ALL) { TouchController.setup(); } diff --git a/src/modules/controller-shortcut.ts b/src/modules/controller-shortcut.ts index 6fdb69f..08b657a 100644 --- a/src/modules/controller-shortcut.ts +++ b/src/modules/controller-shortcut.ts @@ -1,7 +1,7 @@ import { Screenshot } from "@utils/screenshot"; import { GamepadKey } from "@enums/mkb"; import { PrompFont } from "@enums/prompt-font"; -import { CE } from "@utils/html"; +import { CE, removeChildElements } from "@utils/html"; import { t } from "@utils/translation"; import { EmulatedMkbHandler } from "./mkb/mkb-handler"; import { StreamStats } from "./stream/stream-stats"; @@ -174,9 +174,7 @@ export class ControllerShortcut { const $fragment = document.createDocumentFragment(); // Remove old profiles - while ($select.firstElementChild) { - $select.firstElementChild.remove(); - } + removeChildElements($select); const gamepads = navigator.getGamepads(); let hasGamepad = false; diff --git a/src/modules/game-bar/game-bar.ts b/src/modules/game-bar/game-bar.ts index d57ec1a..edf5187 100644 --- a/src/modules/game-bar/game-bar.ts +++ b/src/modules/game-bar/game-bar.ts @@ -7,7 +7,7 @@ import type { BaseGameBarAction } from "./action-base"; import { STATES } from "@utils/global"; import { MicrophoneAction } from "./action-microphone"; import { PrefKey } from "@/enums/pref-keys"; -import { getPref } from "@/utils/settings-storages/global-settings-storage"; +import { getPref, StreamTouchController } from "@/utils/settings-storages/global-settings-storage"; export class GameBar { @@ -42,7 +42,7 @@ export class GameBar { this.actions = [ new ScreenshotAction(), - ...(STATES.userAgent.capabilities.touch && (getPref(PrefKey.STREAM_TOUCH_CONTROLLER) !== 'off') ? [new TouchControlAction()] : []), + ...(STATES.userAgent.capabilities.touch && (getPref(PrefKey.STREAM_TOUCH_CONTROLLER) !== StreamTouchController.OFF) ? [new TouchControlAction()] : []), new MicrophoneAction(), ]; diff --git a/src/modules/patcher.ts b/src/modules/patcher.ts index c1e376c..1466f27 100644 --- a/src/modules/patcher.ts +++ b/src/modules/patcher.ts @@ -15,7 +15,7 @@ import codeVibrationAdjust from "./patches/vibration-adjust.js" with { type: "te import { FeatureGates } from "@/utils/feature-gates.js"; import { UiSection } from "@/enums/ui-sections.js"; import { PrefKey } from "@/enums/pref-keys.js"; -import { getPref } from "@/utils/settings-storages/global-settings-storage"; +import { getPref, StreamTouchController } from "@/utils/settings-storages/global-settings-storage"; import { GamePassCloudGallery } from "@/enums/game-pass-gallery.js"; type PatchArray = (keyof typeof PATCHES)[]; @@ -393,7 +393,7 @@ if (window.BX_EXPOSED.stopTakRendering) { } let autoOffCode = ''; - if (getPref(PrefKey.STREAM_TOUCH_CONTROLLER) === 'off') { + if (getPref(PrefKey.STREAM_TOUCH_CONTROLLER) === StreamTouchController.OFF) { autoOffCode = 'return;'; } else if (getPref(PrefKey.STREAM_TOUCH_CONTROLLER_AUTO_OFF)) { autoOffCode = ` @@ -450,7 +450,7 @@ e.guideUI = null; `; // Remove the TAK Edit button when the touch controller is disabled - if (getPref(PrefKey.STREAM_TOUCH_CONTROLLER) === 'off') { + if (getPref(PrefKey.STREAM_TOUCH_CONTROLLER) === StreamTouchController.OFF) { newCode += 'e.canShowTakHUD = false;'; } @@ -804,7 +804,7 @@ true` + text; const PREF_HIDE_SECTIONS = getPref(PrefKey.UI_HIDE_SECTIONS) as UiSection[]; const siglIds: GamePassCloudGallery[] = []; - const sections: Partial> = { + 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; }