Make Stream settings dialog controller-friendly

This commit is contained in:
redphx 2024-07-16 17:08:56 +07:00
parent be338f3e34
commit b66cb448ec
10 changed files with 257 additions and 29 deletions

View File

@ -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%;
}

View File

@ -5,7 +5,7 @@
display: inline-block;
min-width: 40px;
font-family: var(--bx-monospaced-font);
font-size: 14px;
font-size: 12px;
}
button {

View File

@ -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 {

View File

@ -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 {

View File

@ -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) {

View File

@ -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},

View File

@ -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)) {

View File

@ -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,
);

View File

@ -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;
}

View File

@ -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 => {