mirror of
https://github.com/redphx/better-xcloud.git
synced 2025-06-06 07:37:19 +02:00
Make Stream settings dialog controller-friendly
This commit is contained in:
parent
be338f3e34
commit
b66cb448ec
@ -17,11 +17,13 @@
|
||||
}
|
||||
|
||||
.bx-settings-wrapper {
|
||||
width: 450px;
|
||||
min-width: 450px;
|
||||
max-width: 600px;
|
||||
margin: auto;
|
||||
padding: 12px 6px;
|
||||
|
||||
@media screen and (max-width: 450px) {
|
||||
min-width: unset;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
|
@ -5,7 +5,7 @@
|
||||
display: inline-block;
|
||||
min-width: 40px;
|
||||
font-family: var(--bx-monospaced-font);
|
||||
font-size: 14px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
button {
|
||||
|
@ -49,6 +49,11 @@
|
||||
background: #2f2f2f;
|
||||
border-color: #484848;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: #fff;
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -70,13 +75,14 @@
|
||||
box-shadow: 0px 0px 6px #000;
|
||||
overflow: overlay;
|
||||
|
||||
> div[data-group=mkb] {
|
||||
> div[data-tab-group=mkb] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&:focus,
|
||||
*:focus {
|
||||
outline: none !important;
|
||||
}
|
||||
@ -106,8 +112,23 @@
|
||||
.bx-stream-settings-row {
|
||||
display: flex;
|
||||
border-bottom: 1px solid #40404080;
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 16px;
|
||||
padding: 16px 8px;
|
||||
border-left: 2px solid transparent;
|
||||
|
||||
&:hover, &:focus-within {
|
||||
background-color: #242424;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
> label {
|
||||
font-size: 16px;
|
||||
@ -120,6 +141,10 @@
|
||||
|
||||
input {
|
||||
accent-color: var(--bx-primary-button-color);
|
||||
|
||||
&:focus {
|
||||
accent-color: var(--bx-danger-button-color);
|
||||
}
|
||||
}
|
||||
|
||||
select:disabled {
|
||||
@ -143,7 +168,7 @@
|
||||
}
|
||||
|
||||
.bx-stream-settings-tab-contents {
|
||||
div[data-group="shortcuts"] {
|
||||
div[data-tab-group="shortcuts"] {
|
||||
> div {
|
||||
&[data-has-gamepad=true] {
|
||||
> div:first-of-type {
|
||||
|
@ -73,10 +73,10 @@
|
||||
|
||||
button.bx-button {
|
||||
border: none;
|
||||
height: 30px;
|
||||
height: 24px;
|
||||
width: 24px;
|
||||
padding: 0;
|
||||
line-height: 30px;
|
||||
line-height: 24px;
|
||||
color: #fff;
|
||||
border-radius: 4px;
|
||||
font-weight: bold;
|
||||
@ -85,7 +85,8 @@
|
||||
|
||||
&.bx-inactive {
|
||||
pointer-events: none;
|
||||
opacity: 0.1;
|
||||
opacity: 0.2;
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
span {
|
||||
|
@ -14,6 +14,19 @@ import { StreamStats } from "./stream-stats";
|
||||
import { BxSelectElement } from "@/web-components/bx-select";
|
||||
import { onChangeVideoPlayerType, updateVideoPlayer } from "./stream-settings-utils";
|
||||
|
||||
enum FocusDirection {
|
||||
UP,
|
||||
RIGHT,
|
||||
DOWN,
|
||||
LEFT,
|
||||
}
|
||||
|
||||
enum FocusContainer {
|
||||
OUTSIDE,
|
||||
TABS,
|
||||
SETTINGS,
|
||||
}
|
||||
|
||||
export class StreamSettings {
|
||||
private static instance: StreamSettings;
|
||||
|
||||
@ -26,6 +39,8 @@ export class StreamSettings {
|
||||
}
|
||||
|
||||
private $container: HTMLElement | undefined;
|
||||
private $tabs: HTMLElement | undefined;
|
||||
private $settings: HTMLElement | undefined;
|
||||
private $overlay: HTMLElement | undefined;
|
||||
|
||||
readonly SETTINGS_UI = [{
|
||||
@ -248,40 +263,207 @@ export class StreamSettings {
|
||||
const $container = this.$container!;
|
||||
// Select tab
|
||||
if (tabId) {
|
||||
const $tab = $container.querySelector(`.bx-stream-settings-tabs svg[data-group=${tabId}]`);
|
||||
const $tab = $container.querySelector(`.bx-stream-settings-tabs svg[data-tab-group=${tabId}]`);
|
||||
$tab && $tab.dispatchEvent(new Event('click'));
|
||||
}
|
||||
|
||||
// Show overlay
|
||||
this.$overlay!.classList.remove('bx-gone');
|
||||
this.$overlay!.dataset.isPlaying = STATES.isPlaying.toString();
|
||||
|
||||
// Show dialog
|
||||
$container.classList.remove('bx-gone');
|
||||
// Lock scroll bar
|
||||
document.body.classList.add('bx-no-scroll');
|
||||
|
||||
if (getPref(PrefKey.UI_CONTROLLER_FRIENDLY)) {
|
||||
// Focus the first visible setting
|
||||
this.#focusDirection(FocusDirection.DOWN);
|
||||
|
||||
// Add event listeners
|
||||
$container.addEventListener('keydown', this);
|
||||
}
|
||||
|
||||
BxEvent.dispatch(window, BxEvent.XCLOUD_DIALOG_SHOWN);
|
||||
}
|
||||
|
||||
hide() {
|
||||
// Hide overlay
|
||||
this.$overlay!.classList.add('bx-gone');
|
||||
// Hide dialog
|
||||
this.$container!.classList.add('bx-gone');
|
||||
|
||||
// Show scroll bar
|
||||
document.body.classList.remove('bx-no-scroll');
|
||||
|
||||
// Remove event listeners
|
||||
this.$container!.removeEventListener('keydown', this);
|
||||
|
||||
BxEvent.dispatch(window, BxEvent.XCLOUD_DIALOG_DISMISSED);
|
||||
}
|
||||
|
||||
#handleTabsNavigation($focusing: HTMLElement, direction: FocusDirection) {
|
||||
if (direction === FocusDirection.UP || direction === FocusDirection.DOWN) {
|
||||
let $sibling = $focusing;
|
||||
const siblingProperty = direction === FocusDirection.UP ? 'previousElementSibling' : 'nextElementSibling';
|
||||
|
||||
while ($sibling[siblingProperty]) {
|
||||
$sibling = $sibling[siblingProperty] as HTMLElement;
|
||||
$sibling && $sibling.focus();
|
||||
return;
|
||||
}
|
||||
} else if (direction === FocusDirection.RIGHT) {
|
||||
this.#focusFirstVisibleSetting();
|
||||
}
|
||||
}
|
||||
|
||||
#handleSettingsNavigation($focusing: HTMLElement, direction: FocusDirection) {
|
||||
// If current element's tabIndex property is not 0
|
||||
if ($focusing.tabIndex !== 0) {
|
||||
// Find first visible setting
|
||||
const $childSetting = $focusing.querySelector('div[data-tab-group]:not(.bx-gone) [tabindex="0"]:not(a)') as HTMLElement;
|
||||
if ($childSetting) {
|
||||
$childSetting.focus();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Current element is setting -> Find the next one
|
||||
// Find parent
|
||||
let $parent = $focusing.closest('.bx-stream-settings-row') || $focusing.closest('h2');
|
||||
|
||||
if (!$parent) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Find sibling setting
|
||||
let $sibling = $parent;
|
||||
if (direction === FocusDirection.UP || direction === FocusDirection.DOWN) {
|
||||
const siblingProperty = direction === FocusDirection.UP ? 'previousElementSibling' : 'nextElementSibling';
|
||||
|
||||
while ($sibling[siblingProperty]) {
|
||||
$sibling = $sibling[siblingProperty];
|
||||
const $childSetting = $sibling.querySelector('[tabindex="0"]:last-of-type') as HTMLElement;
|
||||
if ($childSetting) {
|
||||
$childSetting.focus();
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else if (direction === FocusDirection.LEFT || direction === FocusDirection.RIGHT) {
|
||||
// Find all child elements with tabindex
|
||||
const children = Array.from($parent.querySelectorAll('[tabindex="0"]'));
|
||||
const index = children.indexOf($focusing);
|
||||
let nextIndex;
|
||||
if (direction === FocusDirection.LEFT) {
|
||||
nextIndex = index - 1;
|
||||
} else {
|
||||
nextIndex = index + 1;
|
||||
}
|
||||
|
||||
nextIndex = Math.max(-1, Math.min(nextIndex, children.length - 1));
|
||||
if (nextIndex === -1) {
|
||||
// Focus setting tabs
|
||||
const $tab = this.$tabs!.querySelector('svg.bx-active') as HTMLElement;
|
||||
$tab && $tab.focus();
|
||||
} else if (nextIndex !== index) {
|
||||
(children[nextIndex] as HTMLElement).focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#focusFirstVisibleSetting() {
|
||||
// Focus the first visible tab content
|
||||
const $tab = this.$settings!.querySelector('div[data-tab-group]:not(.bx-gone)') as HTMLElement;
|
||||
|
||||
if ($tab) {
|
||||
// Focus on the first focusable setting
|
||||
const $control = $tab.querySelector('[tabindex="0"]:not(a)') as HTMLElement;
|
||||
if ($control) {
|
||||
$control.focus();
|
||||
} else {
|
||||
// Focus tab
|
||||
$tab.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#focusDirection(direction: FocusDirection) {
|
||||
const $tabs = this.$tabs!;
|
||||
const $settings = this.$settings!;
|
||||
|
||||
// Get current focused element
|
||||
let $focusing = document.activeElement as HTMLElement;
|
||||
|
||||
let focusContainer = FocusContainer.OUTSIDE;
|
||||
if ($focusing) {
|
||||
if ($settings.contains($focusing)) {
|
||||
focusContainer = FocusContainer.SETTINGS;
|
||||
} else if ($tabs.contains($focusing)) {
|
||||
focusContainer = FocusContainer.TABS;
|
||||
}
|
||||
}
|
||||
|
||||
// If not focusing any element or the focused element is not inside the dialog
|
||||
if (focusContainer === FocusContainer.OUTSIDE) {
|
||||
this.#focusFirstVisibleSetting();
|
||||
return;
|
||||
} else if (focusContainer === FocusContainer.SETTINGS) {
|
||||
this.#handleSettingsNavigation($focusing, direction);
|
||||
} else if (focusContainer === FocusContainer.TABS) {
|
||||
this.#handleTabsNavigation($focusing, direction);
|
||||
}
|
||||
}
|
||||
|
||||
handleEvent(event: Event) {
|
||||
switch (event.type) {
|
||||
case 'keydown':
|
||||
const $target = event.target as HTMLElement;
|
||||
const keyboardEvent = event as KeyboardEvent;
|
||||
const keyCode = keyboardEvent.code;
|
||||
|
||||
if (keyCode === 'ArrowUp' || keyCode === 'ArrowDown') {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
this.#focusDirection(keyCode === 'ArrowUp' ? FocusDirection.UP : FocusDirection.DOWN);
|
||||
} else if (keyCode === 'ArrowLeft' || keyCode === 'ArrowRight') {
|
||||
if (($target as any).type !== 'range') {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
this.#focusDirection(keyCode === 'ArrowLeft' ? FocusDirection.LEFT : FocusDirection.RIGHT);
|
||||
}
|
||||
} else if (keyCode === 'Enter' || keyCode === 'Space') {
|
||||
if ($target instanceof SVGElement) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
$target.dispatchEvent(new Event('click'));
|
||||
}
|
||||
} else if (keyCode === 'Escape') {
|
||||
this.hide();
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
#setupDialog() {
|
||||
let $tabs: HTMLElement;
|
||||
let $settings: HTMLElement;
|
||||
|
||||
const $overlay = CE('div', {'class': 'bx-stream-settings-overlay bx-gone'});
|
||||
const $overlay = CE('div', {class: 'bx-stream-settings-overlay bx-gone'});
|
||||
this.$overlay = $overlay;
|
||||
|
||||
const $container = CE('div', {'class': 'bx-stream-settings-dialog bx-gone'},
|
||||
$tabs = CE('div', {'class': 'bx-stream-settings-tabs'}),
|
||||
$settings = CE('div', {'class': 'bx-stream-settings-tab-contents'}),
|
||||
const $container = CE('div', {class: 'bx-stream-settings-dialog bx-gone'},
|
||||
$tabs = CE('div', {class: 'bx-stream-settings-tabs'}),
|
||||
$settings = CE('div', {
|
||||
class: 'bx-stream-settings-tab-contents',
|
||||
tabindex: 10,
|
||||
}),
|
||||
);
|
||||
|
||||
this.$container = $container;
|
||||
this.$tabs = $tabs;
|
||||
this.$settings = $settings;
|
||||
|
||||
// Close dialog when clicking on the overlay
|
||||
$overlay.addEventListener('click', e => {
|
||||
@ -296,10 +478,12 @@ export class StreamSettings {
|
||||
}
|
||||
|
||||
const $svg = createSvgIcon(settingTab.icon);
|
||||
$svg.tabIndex = 0;
|
||||
|
||||
$svg.addEventListener('click', e => {
|
||||
// Switch tab
|
||||
for (const $child of Array.from($settings.children)) {
|
||||
if ($child.getAttribute('data-group') === settingTab.group) {
|
||||
if ($child.getAttribute('data-tab-group') === settingTab.group) {
|
||||
$child.classList.remove('bx-gone');
|
||||
} else {
|
||||
$child.classList.add('bx-gone');
|
||||
@ -316,7 +500,7 @@ export class StreamSettings {
|
||||
|
||||
$tabs.appendChild($svg);
|
||||
|
||||
const $group = CE('div', {'data-group': settingTab.group, 'class': 'bx-gone'});
|
||||
const $group = CE('div', {'data-tab-group': settingTab.group, 'class': 'bx-gone'});
|
||||
|
||||
for (const settingGroup of settingTab.items) {
|
||||
if (!settingGroup) {
|
||||
@ -327,9 +511,10 @@ export class StreamSettings {
|
||||
CE('span', {}, settingGroup.label),
|
||||
settingGroup.help_url && createButton({
|
||||
icon: BxIcon.QUESTION,
|
||||
style: ButtonStyle.GHOST,
|
||||
style: ButtonStyle.GHOST | ButtonStyle.FOCUSABLE,
|
||||
url: settingGroup.help_url,
|
||||
title: t('help'),
|
||||
tabIndex: 0,
|
||||
}),
|
||||
));
|
||||
if (settingGroup.note) {
|
||||
|
@ -203,6 +203,8 @@ export function setupSettingsUi() {
|
||||
$wrapper.appendChild($div);
|
||||
}
|
||||
|
||||
let localeSwitchingTimeout: number | null;
|
||||
|
||||
const onChange = async (e: Event) => {
|
||||
// Clear PatcherCache;
|
||||
PatcherCache.clear();
|
||||
@ -214,12 +216,17 @@ export function setupSettingsUi() {
|
||||
$btnHeaderSettings && $btnHeaderSettings.classList.add('bx-danger');
|
||||
|
||||
if ((e.target as HTMLElement).id === 'bx_setting_' + PrefKey.BETTER_XCLOUD_LOCALE) {
|
||||
// Update locale
|
||||
Translations.refreshCurrentLocale();
|
||||
await Translations.updateTranslations();
|
||||
if (getPref(PrefKey.UI_CONTROLLER_FRIENDLY)) {
|
||||
localeSwitchingTimeout && window.clearTimeout(localeSwitchingTimeout);
|
||||
localeSwitchingTimeout = window.setTimeout(() => {
|
||||
Translations.refreshCurrentLocale();
|
||||
Translations.updateTranslations();
|
||||
}, 1000);
|
||||
} else {
|
||||
// Update locale
|
||||
Translations.refreshCurrentLocale();
|
||||
await Translations.updateTranslations();
|
||||
|
||||
// Don't refresh the page when using controller-friendly UI
|
||||
if (!getPref(PrefKey.UI_CONTROLLER_FRIENDLY)) {
|
||||
$btnReload.textContent = t('settings-reloading');
|
||||
$btnReload.click();
|
||||
}
|
||||
@ -390,9 +397,9 @@ export function setupSettingsUi() {
|
||||
|
||||
if ($control instanceof HTMLSelectElement && getPref(PrefKey.UI_CONTROLLER_FRIENDLY)) {
|
||||
// Controller-friendly <select>
|
||||
$elm = CE('div', {'class': 'bx-settings-row'},
|
||||
$elm = CE('div', {'class': 'bx-settings-row', 'data-group': 0},
|
||||
$label,
|
||||
CE('div', {class: 'bx-setting-control', 'data-group': 0}, BxSelectElement.wrap($control)),
|
||||
CE('div', {class: 'bx-setting-control'}, BxSelectElement.wrap($control)),
|
||||
);
|
||||
} else {
|
||||
$elm = CE('div', {'class': 'bx-settings-row', 'data-group': 0},
|
||||
|
@ -9,6 +9,7 @@ type BxButton = {
|
||||
title?: string;
|
||||
disabled?: boolean;
|
||||
onClick?: EventListener;
|
||||
tabIndex?: number;
|
||||
attributes?: {[key: string]: any},
|
||||
}
|
||||
|
||||
@ -94,6 +95,7 @@ export const createButton = <T=HTMLButtonElement>(options: BxButton): T => {
|
||||
options.title && $btn.setAttribute('title', options.title);
|
||||
options.disabled && (($btn as HTMLButtonElement).disabled = true);
|
||||
options.onClick && $btn.addEventListener('click', options.onClick);
|
||||
typeof options.tabIndex === 'number' && ($btn.tabIndex = options.tabIndex!);
|
||||
|
||||
for (const key in options.attributes) {
|
||||
if (!$btn.hasOwnProperty(key)) {
|
||||
|
@ -175,13 +175,15 @@ export class SettingElement {
|
||||
$btnDec = CE('button', {
|
||||
'data-type': 'dec',
|
||||
type: 'button',
|
||||
tabindex: -1,
|
||||
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',
|
||||
tabindex: -1,
|
||||
class: options.hideSlider ? 'bx-focusable' : '',
|
||||
tabindex: options.hideSlider ? 0 : -1,
|
||||
}, '+') as HTMLButtonElement,
|
||||
);
|
||||
|
||||
|
@ -371,6 +371,7 @@ export class Translations {
|
||||
static async updateTranslations(async=false) {
|
||||
// Don't have to download en-US
|
||||
if (Translations.#selectedLocale === Translations.#EN_US) {
|
||||
localStorage.removeItem(Translations.#KEY_TRANSLATIONS);
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -2,6 +2,9 @@ import { ButtonStyle, CE, createButton } from "@utils/html";
|
||||
|
||||
export class BxSelectElement {
|
||||
static wrap($select: HTMLSelectElement) {
|
||||
// Remove "tabindex" attribute from <select>
|
||||
$select.removeAttribute('tabindex');
|
||||
|
||||
const $btnPrev = createButton({
|
||||
label: '<',
|
||||
style: ButtonStyle.FOCUSABLE,
|
||||
@ -88,10 +91,10 @@ export class BxSelectElement {
|
||||
const disableNext = visibleIndex === $select.querySelectorAll('option').length - 1;
|
||||
|
||||
$btnPrev.classList.toggle('bx-inactive', disablePrev);
|
||||
disablePrev && document.activeElement === $btnPrev && $btnNext.focus();
|
||||
// disablePrev && document.activeElement === $btnPrev && $btnNext.focus();
|
||||
|
||||
$btnNext.classList.toggle('bx-inactive', disableNext);
|
||||
disableNext && document.activeElement === $btnNext &&$btnPrev.focus();
|
||||
// disableNext && document.activeElement === $btnNext &&$btnPrev.focus();
|
||||
}
|
||||
|
||||
const normalizeIndex = (index: number): number => {
|
||||
|
Loading…
x
Reference in New Issue
Block a user