better-xcloud/src/utils/preferences.ts
2024-05-01 22:01:39 +07:00

761 lines
25 KiB
TypeScript

import { CE } from "./html";
import { SUPPORTED_LANGUAGES, t } from "./translation";
import { SettingElement, SettingElementType } from "./settings";
import { UserAgentProfile } from "./user-agent";
import { StreamStat } from "@/modules/stream/stream-stats";
import type { PreferenceSettings } from "@/types/preferences";
import { STATES } from "./global";
export enum PrefKey {
LAST_UPDATE_CHECK = 'version_last_check',
LATEST_VERSION = 'version_latest',
CURRENT_VERSION = 'version_current',
BETTER_XCLOUD_LOCALE = 'bx_locale',
SERVER_REGION = 'server_region',
PREFER_IPV6_SERVER = 'prefer_ipv6_server',
STREAM_TARGET_RESOLUTION = 'stream_target_resolution',
STREAM_PREFERRED_LOCALE = 'stream_preferred_locale',
STREAM_CODEC_PROFILE = 'stream_codec_profile',
USER_AGENT_PROFILE = 'user_agent_profile',
USER_AGENT_CUSTOM = 'user_agent_custom',
STREAM_SIMPLIFY_MENU = 'stream_simplify_menu',
STREAM_COMBINE_SOURCES = 'stream_combine_sources',
STREAM_TOUCH_CONTROLLER = 'stream_touch_controller',
STREAM_TOUCH_CONTROLLER_AUTO_OFF = 'stream_touch_controller_auto_off',
STREAM_TOUCH_CONTROLLER_STYLE_STANDARD = 'stream_touch_controller_style_standard',
STREAM_TOUCH_CONTROLLER_STYLE_CUSTOM = 'stream_touch_controller_style_custom',
STREAM_DISABLE_FEEDBACK_DIALOG = 'stream_disable_feedback_dialog',
LOCAL_CO_OP_ENABLED = 'local_co_op_enabled',
// LOCAL_CO_OP_SEPARATE_TOUCH_CONTROLLER = 'local_co_op_separate_touch_controller',
CONTROLLER_ENABLE_SHORTCUTS = 'controller_enable_shortcuts',
CONTROLLER_ENABLE_VIBRATION = 'controller_enable_vibration',
CONTROLLER_DEVICE_VIBRATION = 'controller_device_vibration',
CONTROLLER_VIBRATION_INTENSITY = 'controller_vibration_intensity',
MKB_ENABLED = 'mkb_enabled',
MKB_HIDE_IDLE_CURSOR = 'mkb_hide_idle_cursor',
MKB_ABSOLUTE_MOUSE = 'mkb_absolute_mouse',
MKB_DEFAULT_PRESET_ID = 'mkb_default_preset_id',
SCREENSHOT_BUTTON_POSITION = 'screenshot_button_position',
SCREENSHOT_APPLY_FILTERS = 'screenshot_apply_filters',
BLOCK_TRACKING = 'block_tracking',
BLOCK_SOCIAL_FEATURES = 'block_social_features',
SKIP_SPLASH_VIDEO = 'skip_splash_video',
HIDE_DOTS_ICON = 'hide_dots_icon',
REDUCE_ANIMATIONS = 'reduce_animations',
UI_LOADING_SCREEN_GAME_ART = 'ui_loading_screen_game_art',
UI_LOADING_SCREEN_WAIT_TIME = 'ui_loading_screen_wait_time',
UI_LOADING_SCREEN_ROCKET = 'ui_loading_screen_rocket',
UI_LAYOUT = 'ui_layout',
UI_SCROLLBAR_HIDE = 'ui_scrollbar_hide',
VIDEO_CLARITY = 'video_clarity',
VIDEO_RATIO = 'video_ratio',
VIDEO_BRIGHTNESS = 'video_brightness',
VIDEO_CONTRAST = 'video_contrast',
VIDEO_SATURATION = 'video_saturation',
AUDIO_MIC_ON_PLAYING = 'audio_mic_on_playing',
AUDIO_ENABLE_VOLUME_CONTROL = 'audio_enable_volume_control',
AUDIO_VOLUME = 'audio_volume',
STATS_ITEMS = 'stats_items',
STATS_SHOW_WHEN_PLAYING = 'stats_show_when_playing',
STATS_QUICK_GLANCE = 'stats_quick_glance',
STATS_POSITION = 'stats_position',
STATS_TEXT_SIZE = 'stats_text_size',
STATS_TRANSPARENT = 'stats_transparent',
STATS_OPACITY = 'stats_opacity',
STATS_CONDITIONAL_FORMATTING = 'stats_conditional_formatting',
REMOTE_PLAY_ENABLED = 'xhome_enabled',
REMOTE_PLAY_RESOLUTION = 'xhome_resolution',
GAME_FORTNITE_FORCE_CONSOLE = 'game_fortnite_force_console',
}
export class Preferences {
static SETTINGS: PreferenceSettings = {
[PrefKey.LAST_UPDATE_CHECK]: {
default: 0,
},
[PrefKey.LATEST_VERSION]: {
default: '',
},
[PrefKey.CURRENT_VERSION]: {
default: '',
},
[PrefKey.BETTER_XCLOUD_LOCALE]: {
label: t('language'),
default: localStorage.getItem('better_xcloud_locale') || 'en-US',
options: SUPPORTED_LANGUAGES,
},
[PrefKey.SERVER_REGION]: {
label: t('region'),
default: 'default',
},
[PrefKey.STREAM_PREFERRED_LOCALE]: {
label: t('preferred-game-language'),
default: 'default',
options: {
default: t('default'),
'ar-SA': 'العربية',
'cs-CZ': 'čeština',
'da-DK': 'dansk',
'de-DE': 'Deutsch',
'el-GR': 'Ελληνικά',
'en-GB': 'English (United Kingdom)',
'en-US': 'English (United States)',
'es-ES': 'español (España)',
'es-MX': 'español (Latinoamérica)',
'fi-FI': 'suomi',
'fr-FR': 'français',
'he-IL': 'עברית',
'hu-HU': 'magyar',
'it-IT': 'italiano',
'ja-JP': '日本語',
'ko-KR': '한국어',
'nb-NO': 'norsk bokmål',
'nl-NL': 'Nederlands',
'pl-PL': 'polski',
'pt-BR': 'português (Brasil)',
'pt-PT': 'português (Portugal)',
'ru-RU': 'русский',
'sk-SK': 'slovenčina',
'sv-SE': 'svenska',
'tr-TR': 'Türkçe',
'zh-CN': '中文(简体)',
'zh-TW': '中文 (繁體)',
},
},
[PrefKey.STREAM_TARGET_RESOLUTION]: {
label: t('target-resolution'),
default: 'auto',
options: {
auto: t('default'),
'1080p': '1080p',
'720p': '720p',
},
},
[PrefKey.STREAM_CODEC_PROFILE]: {
label: t('visual-quality'),
default: 'default',
options: (() => {
const options: {[index: string]: string} = {
default: t('default'),
};
if (!('getCapabilities' in RTCRtpReceiver) || typeof RTCRtpTransceiver === 'undefined' || !('setCodecPreferences' in RTCRtpTransceiver.prototype)) {
return options;
}
let hasLowCodec = false;
let hasNormalCodec = false;
let hasHighCodec = false;
const codecs = RTCRtpReceiver.getCapabilities('video')!.codecs;
for (let codec of codecs) {
if (codec.mimeType.toLowerCase() !== 'video/h264' || !codec.sdpFmtpLine) {
continue;
}
const fmtp = codec.sdpFmtpLine.toLowerCase();
if (!hasHighCodec && fmtp.includes('profile-level-id=4d')) {
hasHighCodec = true;
} else if (!hasNormalCodec && fmtp.includes('profile-level-id=42e')) {
hasNormalCodec = true;
} else if (!hasLowCodec && fmtp.includes('profile-level-id=420')) {
hasLowCodec = true;
}
}
if (hasHighCodec) {
if (!hasLowCodec && !hasNormalCodec) {
options.default = `${t('visual-quality-high')} (${t('default')})`;
} else {
options.high = t('visual-quality-high');
}
}
if (hasNormalCodec) {
if (!hasLowCodec && !hasHighCodec) {
options.default = `${t('visual-quality-normal')} (${t('default')})`;
} else {
options.normal = t('visual-quality-normal');
}
}
if (hasLowCodec) {
if (!hasNormalCodec && !hasHighCodec) {
options.default = `${t('visual-quality-low')} (${t('default')})`;
} else {
options.low = t('visual-quality-low');
}
}
return options;
})(),
ready: () => {
const setting = Preferences.SETTINGS[PrefKey.STREAM_CODEC_PROFILE]
const options: any = setting.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];
}
},
},
[PrefKey.PREFER_IPV6_SERVER]: {
label: t('prefer-ipv6-server'),
default: false,
},
[PrefKey.SCREENSHOT_BUTTON_POSITION]: {
label: t('screenshot-button-position'),
default: 'bottom-left',
options: {
'bottom-left': t('bottom-left'),
'bottom-right': t('bottom-right'),
'none': t('disable'),
},
},
[PrefKey.SCREENSHOT_APPLY_FILTERS]: {
label: t('screenshot-apply-filters'),
default: false,
},
[PrefKey.SKIP_SPLASH_VIDEO]: {
label: t('skip-splash-video'),
default: false,
},
[PrefKey.HIDE_DOTS_ICON]: {
label: t('hide-system-menu-icon'),
default: false,
},
[PrefKey.STREAM_COMBINE_SOURCES]: {
label: t('combine-audio-video-streams'),
default: false,
experimental: true,
note: t('combine-audio-video-streams-summary'),
},
[PrefKey.STREAM_TOUCH_CONTROLLER]: {
label: t('tc-availability'),
default: 'all',
options: {
default: t('default'),
all: t('tc-all-games'),
off: t('off'),
},
unsupported: !STATES.hasTouchSupport,
ready: () => {
const setting = Preferences.SETTINGS[PrefKey.STREAM_TOUCH_CONTROLLER];
if (setting.unsupported) {
setting.default = 'default';
}
},
},
[PrefKey.STREAM_TOUCH_CONTROLLER_AUTO_OFF]: {
label: t('tc-auto-off'),
default: false,
unsupported: !STATES.hasTouchSupport,
},
[PrefKey.STREAM_TOUCH_CONTROLLER_STYLE_STANDARD]: {
label: t('tc-standard-layout-style'),
default: 'default',
options: {
default: t('default'),
white: t('tc-all-white'),
muted: t('tc-muted-colors'),
},
unsupported: !STATES.hasTouchSupport,
},
[PrefKey.STREAM_TOUCH_CONTROLLER_STYLE_CUSTOM]: {
label: t('tc-custom-layout-style'),
default: 'default',
options: {
default: t('default'),
muted: t('tc-muted-colors'),
},
unsupported: !STATES.hasTouchSupport,
},
[PrefKey.STREAM_SIMPLIFY_MENU]: {
label: t('simplify-stream-menu'),
default: false,
},
[PrefKey.MKB_HIDE_IDLE_CURSOR]: {
label: t('hide-idle-cursor'),
default: false,
},
[PrefKey.STREAM_DISABLE_FEEDBACK_DIALOG]: {
label: t('disable-post-stream-feedback-dialog'),
default: false,
},
[PrefKey.LOCAL_CO_OP_ENABLED]: {
label: t('enable-local-co-op-support'),
default: false,
note: CE<HTMLAnchorElement>('a', {
href: 'https://github.com/redphx/better-xcloud/discussions/275',
target: '_blank',
}, t('enable-local-co-op-support-note')),
},
/*
[Preferences.LOCAL_CO_OP_SEPARATE_TOUCH_CONTROLLER]: {
default: false,
'note': t('separate-touch-controller-note'),
},
*/
[PrefKey.CONTROLLER_ENABLE_SHORTCUTS]: {
default: false,
},
[PrefKey.CONTROLLER_ENABLE_VIBRATION]: {
default: true,
},
[PrefKey.CONTROLLER_DEVICE_VIBRATION]: {
default: 'off',
options: {
on: t('on'),
auto: t('device-vibration-not-using-gamepad'),
off: t('off'),
},
},
[PrefKey.CONTROLLER_VIBRATION_INTENSITY]: {
type: SettingElementType.NUMBER_STEPPER,
default: 100,
min: 0,
max: 100,
steps: 10,
params: {
suffix: '%',
ticks: 10,
},
},
[PrefKey.MKB_ENABLED]: {
label: t('enable-mkb'),
default: false,
unsupported: ((): string | boolean => {
const userAgent = ((window.navigator as any).orgUserAgent || window.navigator.userAgent || '').toLowerCase();
return userAgent.match(/(android|iphone|ipad)/) ? t('browser-unsupported-feature') : false;
})(),
ready: () => {
const pref = Preferences.SETTINGS[PrefKey.MKB_ENABLED];
let note;
let url;
if (pref.unsupported) {
note = t('browser-unsupported-feature');
url = 'https://github.com/redphx/better-xcloud/issues/206#issuecomment-1920475657';
} else {
note = t('mkb-disclaimer');
url = 'https://better-xcloud.github.io/mouse-and-keyboard/#disclaimer';
}
Preferences.SETTINGS[PrefKey.MKB_ENABLED].note = CE('a', {
href: url,
target: '_blank',
}, '⚠️ ' + note);
},
},
[PrefKey.MKB_DEFAULT_PRESET_ID]: {
default: 0,
},
[PrefKey.MKB_ABSOLUTE_MOUSE]: {
default: false,
},
[PrefKey.REDUCE_ANIMATIONS]: {
label: t('reduce-animations'),
default: false,
},
[PrefKey.UI_LOADING_SCREEN_GAME_ART]: {
label: t('show-game-art'),
default: true,
},
[PrefKey.UI_LOADING_SCREEN_WAIT_TIME]: {
label: t('show-wait-time'),
default: true,
},
[PrefKey.UI_LOADING_SCREEN_ROCKET]: {
label: t('rocket-animation'),
default: 'show',
options: {
show: t('rocket-always-show'),
'hide-queue': t('rocket-hide-queue'),
hide: t('rocket-always-hide'),
},
},
[PrefKey.UI_LAYOUT]: {
label: t('layout'),
default: 'default',
options: {
default: t('default'),
tv: t('smart-tv'),
},
},
[PrefKey.UI_SCROLLBAR_HIDE]: {
label: t('hide-scrollbar'),
default: false,
},
[PrefKey.BLOCK_SOCIAL_FEATURES]: {
label: t('disable-social-features'),
default: false,
},
[PrefKey.BLOCK_TRACKING]: {
label: t('disable-xcloud-analytics'),
default: false,
},
[PrefKey.USER_AGENT_PROFILE]: {
label: t('user-agent-profile'),
default: 'default',
options: {
[UserAgentProfile.DEFAULT]: t('default'),
[UserAgentProfile.EDGE_WINDOWS]: 'Edge + Windows',
[UserAgentProfile.SAFARI_MACOS]: 'Safari + macOS',
[UserAgentProfile.SMARTTV_TIZEN]: 'Samsung Smart TV',
[UserAgentProfile.KIWI_V123]: 'Kiwi Browser v123',
[UserAgentProfile.CUSTOM]: t('custom'),
},
},
[PrefKey.USER_AGENT_CUSTOM]: {
default: '',
},
[PrefKey.VIDEO_CLARITY]: {
type: SettingElementType.NUMBER_STEPPER,
default: 0,
min: 0,
max: 5,
params: {
hideSlider: true,
},
},
[PrefKey.VIDEO_RATIO]: {
default: '16:9',
options: {
'16:9': '16:9',
'18:9': '18:9',
'21:9': '21:9',
'16:10': '16:10',
'4:3': '4:3',
fill: t('stretch'),
//'cover': 'Cover',
},
},
[PrefKey.VIDEO_SATURATION]: {
type: SettingElementType.NUMBER_STEPPER,
default: 100,
min: 50,
max: 150,
params: {
suffix: '%',
ticks: 25,
},
},
[PrefKey.VIDEO_CONTRAST]: {
type: SettingElementType.NUMBER_STEPPER,
default: 100,
min: 50,
max: 150,
params: {
suffix: '%',
ticks: 25,
},
},
[PrefKey.VIDEO_BRIGHTNESS]: {
type: SettingElementType.NUMBER_STEPPER,
default: 100,
min: 50,
max: 150,
params: {
suffix: '%',
ticks: 25,
},
},
[PrefKey.AUDIO_MIC_ON_PLAYING]: {
label: t('enable-mic-on-startup'),
default: false,
},
[PrefKey.AUDIO_ENABLE_VOLUME_CONTROL]: {
label: t('enable-volume-control'),
default: false,
experimental: true,
},
[PrefKey.AUDIO_VOLUME]: {
type: SettingElementType.NUMBER_STEPPER,
default: 100,
min: 0,
max: 600,
params: {
suffix: '%',
ticks: 100,
},
},
[PrefKey.STATS_ITEMS]: {
default: [StreamStat.PING, StreamStat.FPS, StreamStat.BITRATE, StreamStat.DECODE_TIME, StreamStat.PACKETS_LOST, StreamStat.FRAMES_LOST],
multipleOptions: {
[StreamStat.PING]: `${StreamStat.PING.toUpperCase()}: ${t('stat-ping')}`,
[StreamStat.FPS]: `${StreamStat.FPS.toUpperCase()}: ${t('stat-fps')}`,
[StreamStat.BITRATE]: `${StreamStat.BITRATE.toUpperCase()}: ${t('stat-bitrate')}`,
[StreamStat.DECODE_TIME]: `${StreamStat.DECODE_TIME.toUpperCase()}: ${t('stat-decode-time')}`,
[StreamStat.PACKETS_LOST]: `${StreamStat.PACKETS_LOST.toUpperCase()}: ${t('stat-packets-lost')}`,
[StreamStat.FRAMES_LOST]: `${StreamStat.FRAMES_LOST.toUpperCase()}: ${t('stat-frames-lost')}`,
},
params: {
size: 6,
},
},
[PrefKey.STATS_SHOW_WHEN_PLAYING]: {
default: false,
},
[PrefKey.STATS_QUICK_GLANCE]: {
default: true,
},
[PrefKey.STATS_POSITION]: {
default: 'top-right',
options: {
'top-left': t('top-left'),
'top-center': t('top-center'),
'top-right': t('top-right'),
},
},
[PrefKey.STATS_TEXT_SIZE]: {
default: '0.9rem',
options: {
'0.9rem': t('small'),
'1.0rem': t('normal'),
'1.1rem': t('large'),
},
},
[PrefKey.STATS_TRANSPARENT]: {
default: false,
},
[PrefKey.STATS_OPACITY]: {
type: SettingElementType.NUMBER_STEPPER,
default: 80,
min: 50,
max: 100,
params: {
suffix: '%',
ticks: 10,
},
},
[PrefKey.STATS_CONDITIONAL_FORMATTING]: {
default: false,
},
[PrefKey.REMOTE_PLAY_ENABLED]: {
label: t('enable-remote-play-feature'),
default: false,
},
[PrefKey.REMOTE_PLAY_RESOLUTION]: {
default: '1080p',
options: {
'1080p': '1080p',
'720p': '720p',
},
},
[PrefKey.GAME_FORTNITE_FORCE_CONSOLE]: {
label: '🎮 ' + t('fortnite-force-console-version'),
default: false,
note: t('fortnite-allow-stw-mode'),
},
// Deprecated
/*
[Preferences.DEPRECATED_CONTROLLER_SUPPORT_LOCAL_CO_OP]: {
default: false,
'migrate': function(savedPrefs, value) {
this.set(Preferences.LOCAL_CO_OP_ENABLED, value);
savedPrefs[Preferences.LOCAL_CO_OP_ENABLED] = value;
},
},
*/
}
#storage = localStorage;
#key = 'better_xcloud';
#prefs: {[index: string]: any} = {};
constructor() {
let savedPrefsStr = this.#storage.getItem(this.#key);
if (savedPrefsStr == null) {
savedPrefsStr = '{}';
}
const savedPrefs = JSON.parse(savedPrefsStr);
for (let settingId in Preferences.SETTINGS) {
const setting = Preferences.SETTINGS[settingId];
setting.ready && setting.ready.call(this);
if (setting.migrate && settingId in savedPrefs) {
setting.migrate.call(this, savedPrefs, savedPrefs[settingId]);
}
}
for (let settingId in Preferences.SETTINGS) {
const setting = Preferences.SETTINGS[settingId];
if (!setting) {
alert(`Undefined setting key: ${settingId}`);
console.log('Undefined setting key');
continue;
}
// Ignore deprecated settings
if (setting.migrate) {
continue;
}
if (settingId in savedPrefs) {
this.#prefs[settingId] = this.#validateValue(settingId, savedPrefs[settingId]);
} else {
this.#prefs[settingId] = setting.default;
}
}
}
#validateValue(key: keyof typeof Preferences.SETTINGS, value: any) {
const config = Preferences.SETTINGS[key];
if (!config) {
return value;
}
if (typeof value === 'undefined' || value === null) {
value = config.default;
}
if ('min' in config) {
value = Math.max(config.min!, value);
}
if ('max' in config) {
value = Math.min(config.max!, value);
}
if ('options' in config && !(value in config.options!)) {
value = config.default;
} else if ('multipleOptions' in config) {
if (value.length) {
const validOptions = Object.keys(config.multipleOptions!);
value.forEach((item: any, idx: number) => {
(validOptions.indexOf(item) === -1) && value.splice(idx, 1);
});
}
if (!value.length) {
value = config.default;
}
}
return value;
}
get(key: PrefKey) {
if (typeof key === 'undefined') {
debugger;
return;
}
// Return default value if the feature is not supported
if (Preferences.SETTINGS[key].unsupported) {
return Preferences.SETTINGS[key].default;
}
if (!(key in this.#prefs)) {
this.#prefs[key] = this.#validateValue(key, null);
}
return this.#prefs[key];
}
set(key: PrefKey, value: any) {
value = this.#validateValue(key, value);
this.#prefs[key] = value;
this.#updateStorage();
}
#updateStorage() {
this.#storage.setItem(this.#key, JSON.stringify(this.#prefs));
}
toElement(key: keyof typeof Preferences.SETTINGS, onChange: any, overrideParams={}) {
const setting = Preferences.SETTINGS[key];
let currentValue = this.get(key);
let $control;
let type;
if ('type' in setting) {
type = setting.type;
} else if ('options' in setting) {
type = SettingElementType.OPTIONS;
} else if ('multipleOptions' in setting) {
type = SettingElementType.MULTIPLE_OPTIONS;
} else if (typeof setting.default === 'number') {
type = SettingElementType.NUMBER;
} else {
type = SettingElementType.CHECKBOX;
}
const params = Object.assign(overrideParams, setting.params || {});
if (params.disabled) {
currentValue = Preferences.SETTINGS[key].default;
}
$control = SettingElement.render(type!, key as string, setting, currentValue, (e: any, value: any) => {
this.set(key, value);
onChange && onChange(e, value);
}, params);
return $control;
}
toNumberStepper(key: keyof typeof Preferences.SETTINGS, onChange: any, options={}) {
return SettingElement.render(SettingElementType.NUMBER_STEPPER, key, Preferences.SETTINGS[key], this.get(key), (e: any, value: any) => {
this.set(key, value);
onChange && onChange(e, value);
}, options);
}
}
const prefs = new Preferences();
export const getPref = prefs.get.bind(prefs);
export const setPref = prefs.set.bind(prefs);
export const toPrefElement = prefs.toElement.bind(prefs);