refactor: rewrited appearance and misc section to solid, added Slider and Select components

This commit is contained in:
Toil 2026-08-15 21:14:02 +03:00
parent 166a66ccd0
commit 1b12fa65be
No known key found for this signature in database
GPG key ID: 57248E2819D32070
46 changed files with 1284 additions and 287 deletions

View file

@ -1,10 +1,16 @@
# 1.12.0 (WIP)
- Переработан дизайн блока настроек "Мой аккаунт"
- Переработан дизайн блока "О расширении"
- Все `Checkbox` в настройках были заменены на `Switch`
Это обновление, в большинстве своем, посвящено технической части проекта. Основное внимание уделено полной миграции интерфейса на SolidJS и обновлению его дизайна. Эти изменения упрощают дальнейшее развитие UI, делают его более единообразным и устраняют ряд небольших технических проблем.
- Проведен редизайн некоторых элементов интерфейса расширения
- Переработан дизайн блока настроек "Мой аккаунт"
- Переработан дизайн блока "О расширении"
- Переработан дизайн слайдеров
- Переработан дизайн Select элементов
- `Checkbox` были заменены на `Switch` элементы
- Весь UI расширения переведен на SolidJS
- Исправлена ошибка из-за которой Debug логи попадали в релизную сборку расширения
- Другие небольшие технические исправления
# 1.11.8

View file

@ -13,5 +13,9 @@
&__value {
text-align: end;
color: rgba(var(--vot-primary-rgb, 33, 150, 243), 0.9);
&_detail {
color: rgba(var(--vot-onsurface-rgb, 0, 0, 0), 0.68);
}
}
}

View file

@ -51,7 +51,10 @@ export function AboutSection(props: AccountSectionProps): JSX.Element {
</AboutItem>
<AboutItem label={localizationProvider.get("VOTLocaleHash")}>
{localeHashValue()}
<br />({localizationProvider.get("VOTUpdatedAt")} {localeUpdatedAt})
<br />
<vot-block class="vot-about-item__value_detail">
({localizationProvider.get("VOTUpdatedAt")} {localeUpdatedAt})
</vot-block>
</AboutItem>
<OutlinedButton
onClick={async () => {

View file

@ -1,7 +1,5 @@
.vot-account {
&-menu {
display: flex;
flex-direction: column;
gap: 0.5em;
}
.vot-account-menu {
display: flex;
flex-direction: column;
gap: 0.5em;
}

View file

@ -0,0 +1 @@
@use "../../styles/components/details.scss";

View file

@ -0,0 +1,22 @@
import type { Meta, StoryObj } from "storybook-solidjs-vite";
import { DetailsButton } from "./DetailsButton";
const meta = {
component: DetailsButton,
} satisfies Meta<typeof DetailsButton>;
export default meta;
type Story = StoryObj<typeof meta>;
export const DetailsButtonDefault: Story = {
args: {
children: "Details",
},
};
export const DetailsButtonDisabled: Story = {
args: {
disabled: true,
children: "Details",
},
};

View file

@ -0,0 +1,22 @@
import { type JSX, splitProps } from "solid-js";
import "./DetailsButton.scss";
import { RawButton, type RawButtonProps } from "../Button/RawButton";
import { ChevronIcon } from "../Icons/ChevronIcon";
export type DetailsButtonProps = Omit<RawButtonProps, "class"> & {
children: JSX.Element;
};
export function DetailsButton(props: DetailsButtonProps): JSX.Element {
const [local, buttonProps] = splitProps(props, ["children"]);
return (
<RawButton {...buttonProps} class="vot-details">
<vot-block>{local.children}</vot-block>
<vot-block class="vot-details-arrow-icon">
<ChevronIcon />
</vot-block>
</RawButton>
);
}

View file

@ -1,4 +1,4 @@
import type { JSX } from "solid-js";
import { type JSX, splitProps } from "solid-js";
import "./IconButton.scss";
import { RawButton, type RawButtonProps } from "./RawButton";
@ -8,5 +8,16 @@ export type IconButtonProps = Omit<RawButtonProps, "class" | "ariaLabel"> & {
};
export function IconButton(props: IconButtonProps): JSX.Element {
return <RawButton {...props} class="vot-icon-button" />;
const [local, rest] = splitProps(props, ["ariaLabel", "buttonProps"]);
return (
<RawButton
{...rest}
class="vot-icon-button"
buttonProps={{
"aria-label": local.ariaLabel,
...local.buttonProps,
}}
/>
);
}

View file

@ -1,15 +1,18 @@
import { type JSX, mergeProps } from "solid-js";
import type { OnClickEvent } from "../../types/components/shared";
import type {
DataAttributes,
OnClickEvent,
} from "../../types/components/shared";
import { isKeyboardActivation } from "../../ui/components/componentShared";
export type RawButtonProps = {
class: string;
ariaLabel?: string;
hidden?: boolean;
disabled?: boolean;
ref?: (element: HTMLElement) => void;
children: JSX.Element;
onClick?: (event: OnClickEvent) => void;
buttonProps?: JSX.HTMLAttributes<HTMLElement> & DataAttributes;
};
export function RawButton(props: RawButtonProps): JSX.Element {
@ -22,7 +25,6 @@ export function RawButton(props: RawButtonProps): JSX.Element {
class={finalProps.class}
role="button"
tabIndex={tabIndex()}
aria-label={finalProps.ariaLabel}
aria-disabled={finalProps.disabled ? "true" : undefined}
hidden={finalProps.hidden}
onClick={(event) => {
@ -46,6 +48,7 @@ export function RawButton(props: RawButtonProps): JSX.Element {
event.currentTarget.click();
}}
{...finalProps.buttonProps}
>
{finalProps.children}
</vot-block>

View file

@ -0,0 +1,97 @@
.vot-select_new {
--vot-select-inner-bg: rgb(var(--vot-secondary-rgb, 43, 44, 48));
display: flex;
align-items: center;
justify-content: end;
position: relative;
user-select: none;
&-label {
flex: 1;
}
&-outer {
cursor: pointer;
width: 120px;
max-width: 120px;
display: flex;
align-items: center;
padding: 0.5em;
border-radius: 0.75em;
border: 1px solid var(--vot-border-color);
color: rgba(var(--vot-onsurface-rgb), 0.87);
transition: border-color var(--vot-duration-medium)
var(--vot-easing-standard);
&__title {
flex: 1;
height: 1.5em;
font-size: 0.8em;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
}
&__arrow {
display: flex;
font-size: 1.5em;
fill: currentColor;
transition: transform var(--vot-duration-medium)
var(--vot-easing-standard);
}
&[aria-disabled="true"] {
cursor: not-allowed;
opacity: 0.38;
}
&:not([aria-disabled="true"]):hover {
border-color: var(--vot-border-color-hover);
}
&[aria-expanded="true"] .vot-select_new-outer__arrow {
transform: rotate(180deg);
}
}
&-inner {
position: absolute;
z-index: 2147483647;
display: flex;
flex-direction: column;
gap: 0.5em;
padding: 0.5em;
border-radius: 0.75em;
background: var(--vot-select-inner-bg);
top: calc(100% + 0.5em);
right: 0;
max-height: min(85vh, 500px);
overflow: scroll;
&__option {
cursor: pointer;
padding: 0.5em 0.75em;
border-radius: 0.5em;
transition: background var(--vot-duration-medium)
var(--vot-easing-standard);
&:hover {
background: rgba(var(--vot-onsurface-rgb), 0.14);
}
&--selected {
color: rgb(var(--vot-primary-rgb, 33, 150, 243));
background-color: rgba(var(--vot-primary-rgb, 33, 150, 243), 0.2);
&:hover {
background-color: rgba(
var(--vot-primary-rgb, 33, 150, 243),
0.1
) !important;
}
}
}
}
}

View file

@ -0,0 +1,40 @@
import type { Meta, StoryObj } from "storybook-solidjs-vite";
import { Select, type SelectOption } from "./Select";
const meta = {
component: Select,
} satisfies Meta<typeof Select>;
export default meta;
type Story = StoryObj<typeof meta>;
const selectOptions: SelectOption[] = [
{
label: "Option 1",
value: true,
},
{
label: "Option 2",
value: "two",
},
{
label: "Option 3",
value: 3,
},
];
export const SelectDefault: Story = {
args: {
title: "Select something",
options: selectOptions,
},
};
export const SelectDisabled: Story = {
args: {
title: "Select something",
disabled: true,
options: selectOptions,
},
};

View file

@ -0,0 +1,146 @@
import {
createSignal,
createUniqueId,
For,
type JSX,
mergeProps,
onCleanup,
Show,
} from "solid-js";
import { effect } from "solid-js/web";
import "./Select.scss";
import { RawButton } from "../Button/RawButton";
import { ChevronIcon } from "../Icons/ChevronIcon";
export type SelectOption = {
value: string | number | boolean;
label: string;
};
export type SelectProps = {
title: string;
options: SelectOption[];
children?: JSX.Element;
selectedValue?: number;
disabled?: boolean;
isOpen?: boolean;
onSelect?: (option: SelectOption) => void;
ref?: (element: HTMLElement) => void;
};
export function Select(props: SelectProps): JSX.Element {
const finalProps = mergeProps(
{
disabled: false,
isOpen: false,
selectedValue: -1,
},
props,
);
let outerRef!: HTMLElement;
let innerRef!: HTMLElement;
const selectId = createUniqueId();
const [disabled, setDisabled] = createSignal(finalProps.disabled);
const [selectedValue, setSelectedValue] = createSignal<number>(
finalProps.selectedValue,
);
const visibleTitle = () =>
finalProps.options[selectedValue()]?.label || finalProps.title;
const [isOpen, setIsOpen] = createSignal(finalProps.isOpen);
effect(() => {
setDisabled(finalProps.disabled);
setIsOpen(finalProps.isOpen);
setSelectedValue(finalProps.selectedValue);
});
effect(() => {
if (!isOpen()) {
return;
}
const handlePointerDown = (event: PointerEvent) => {
const path = event.composedPath();
if (!path.includes(innerRef) && !path.includes(outerRef)) {
console.log("click outside select, closing", path);
setIsOpen(false);
}
};
window.addEventListener("pointerdown", handlePointerDown);
onCleanup(() => {
window.removeEventListener("pointerdown", handlePointerDown);
});
});
return (
<vot-block
ref={finalProps.ref}
class="vot-select_new"
aria-disabled={disabled()}
>
<Show when={finalProps.children}>
<vot-block class="vot-select_new-label">
{finalProps.children}
</vot-block>
</Show>
<RawButton
ref={(el) => (outerRef = el)}
class="vot-select_new-outer"
buttonProps={{
"aria-haspopup": "menu",
"aria-controls": selectId,
"aria-expanded": isOpen(),
}}
disabled={disabled()}
onClick={() => {
console.log("current state", isOpen());
setIsOpen(!isOpen());
console.log("new state", isOpen());
}}
>
<vot-block class="vot-select_new-outer__title">
{visibleTitle()}
</vot-block>
<vot-block class="vot-select_new-outer__arrow">
<ChevronIcon />
</vot-block>
</RawButton>
<vot-block
class="vot-select_new-inner"
ref={innerRef}
id={selectId}
role="menu"
hidden={!isOpen()}
>
<For each={finalProps.options}>
{(option, idx) => (
<RawButton
class="vot-select_new-inner__option"
disabled={disabled()}
buttonProps={{
role: "menuitem",
classList: {
"vot-select_new-inner__option--selected":
idx() === selectedValue(),
},
}}
onClick={() => {
setSelectedValue(idx());
setIsOpen(false);
finalProps.onSelect?.(option);
}}
>
{option.label}
</RawButton>
)}
</For>
</vot-block>
</vot-block>
);
}

View file

@ -0,0 +1,64 @@
.vot-slider_new {
--vot-slider-track-bg: rgba(var(--vot-onsurface-rgb, 227, 227, 227), 0.15);
--vot-slider-track-progress-bg: rgb(var(--vot-primary-rgb, 33, 150, 243));
--vot-slider-size: 1em;
--vot-slider-border-radius: 0.5em;
--vot-slider-handle-bg: rgb(var(--vot-primary-rgb, 139, 180, 245));
--vot-slider-handle-size: 44px;
--vot-slider-transition: var(--vot-duration-medium) var(--vot-easing-standard);
display: flex;
align-items: center;
position: relative;
height: var(--vot-slider-handle-size);
&[aria-disabled="true"] {
user-select: none;
opacity: 0.5;
.vot-slider__control {
cursor: not-allowed;
}
}
&__control {
position: absolute;
opacity: 0;
appearance: none;
width: 100%;
height: 100%;
cursor: pointer;
}
&__track {
background: var(--vot-slider-track-bg);
width: 100%;
height: var(--vot-slider-size);
border-radius: var(--vot-slider-border-radius);
pointer-events: none;
&-progress {
background: var(--vot-slider-track-progress-bg);
position: absolute;
width: calc(100% * var(--vot-progress, 0));
border-top-right-radius: 0;
border-bottom-right-radius: 0;
will-change: width;
transition: width var(--vot-slider-transition);
}
}
&__handle {
position: absolute;
left: calc(100% * var(--vot-progress, 0));
width: 4px;
height: var(--vot-slider-handle-size);
background: var(--vot-slider-handle-bg);
outline: 4px solid var(--vot-slider-track-bg);
border-radius: 4px;
pointer-events: none;
will-change: transform;
transition:
transform var(--vot-slider-transition),
left var(--vot-slider-transition);
transform: translateX(calc(-100% * var(--vot-progress, 0)));
}
}

View file

@ -0,0 +1,26 @@
import type { Meta, StoryObj } from "storybook-solidjs-vite";
import { Slider } from "./Slider";
const meta = {
component: Slider,
} satisfies Meta<typeof Slider>;
export default meta;
type Story = StoryObj<typeof meta>;
export const SliderDefault: Story = {
args: {},
};
export const SliderDiffStep: Story = {
args: {
step: 10,
},
};
export const SliderDisabled: Story = {
args: {
disabled: true,
},
};

View file

@ -0,0 +1,74 @@
import { createSignal, type JSX, mergeProps } from "solid-js";
import "./Slider.scss";
import { effect } from "solid-js/web";
import { clampNumber } from "../../utils/number";
export type SliderProps = {
min?: number;
max?: number;
value?: number;
step?: number;
disabled?: boolean;
onInput?: (value: number) => void;
ref?: (element: HTMLElement) => void;
};
export function Slider(props: SliderProps): JSX.Element {
const finalProps = mergeProps(
{
min: 0,
max: 100,
value: 50,
step: 1,
disabled: false,
},
props,
);
const [value, setValue] = createSignal(finalProps.value);
const [disabled, setDisabled] = createSignal(finalProps.disabled);
const [min, setMin] = createSignal(finalProps.min);
const [max, setMax] = createSignal(finalProps.max);
const [step, setStep] = createSignal(finalProps.step);
const progress = () => {
const range = max() - min();
const raw = range <= 0 ? 0 : (value() - min()) / range;
return clampNumber(raw, 0, 1);
};
effect(() => {
setValue(finalProps.value);
setDisabled(finalProps.disabled);
setMin(finalProps.min);
setMax(finalProps.max);
setStep(finalProps.step);
});
return (
<vot-block
ref={finalProps.ref}
class="vot-slider_new"
style={{ "--vot-progress": progress() }}
aria-disabled={disabled()}
>
<input
class="vot-slider_new__control"
type="range"
min={min()}
max={max()}
value={value()}
step={step()}
disabled={disabled()}
oninput={(e) => {
const newValue = e.target.valueAsNumber;
setValue(newValue);
finalProps.onInput?.(newValue);
}}
/>
<vot-block class="vot-slider_new__track" />
<vot-block class="vot-slider_new__track vot-slider_new__track-progress" />
<vot-block class="vot-slider_new__handle" />
</vot-block>
);
}

View file

@ -0,0 +1,24 @@
.vot-slider_new-label {
display: flex;
flex-wrap: nowrap;
align-items: baseline;
gap: 0.75em;
&__text {
flex: 1 1 auto;
text-align: left;
}
&__value {
flex: 0 0 auto;
text-align: right;
font-variant-numeric: tabular-nums;
font-weight: 600 !important;
}
&[aria-disabled="true"] {
opacity: 0.5;
user-select: none;
color: rgba(var(--vot-onsurface-rgb, 227, 227, 227), 0.5);
}
}

View file

@ -0,0 +1,33 @@
import type { Meta, StoryObj } from "storybook-solidjs-vite";
import { SliderLabel } from "./SliderLabel";
const meta = {
component: SliderLabel,
} satisfies Meta<typeof SliderLabel>;
export default meta;
type Story = StoryObj<typeof meta>;
export const SliderLabelDefault: Story = {
args: {
children: "Auto Hide Button Delay",
value: "50",
},
};
export const SliderLabelDisabled: Story = {
args: {
children: "Auto Hide Button Delay",
value: "50",
disabled: true,
},
};
export const SliderLabelWithSuperLongTitle: Story = {
args: {
children:
"lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.",
value: "50",
},
};

View file

@ -0,0 +1,35 @@
import { type JSX, mergeProps } from "solid-js";
import "./SliderLabel.scss";
export type SliderLabelProps = {
children: JSX.Element;
value: string;
disabled?: boolean;
ref?: (element: HTMLElement) => void;
};
export function SliderLabel(props: SliderLabelProps): JSX.Element {
const finalProps = mergeProps(
{
disabled: false,
value: "0",
},
props,
);
return (
<vot-block
ref={finalProps.ref}
class="vot-slider_new-label"
aria-disabled={finalProps.disabled}
>
<vot-block class="vot-slider_new-label__text">
{finalProps.children}
</vot-block>
<vot-block class="vot-slider_new-label__value">
{finalProps.value}
</vot-block>
</vot-block>
);
}

View file

@ -0,0 +1,5 @@
.vot-slider_new-wrapper {
display: flex;
flex-direction: column;
gap: 0.25em;
}

View file

@ -0,0 +1,44 @@
import { createSignal } from "solid-js";
import type { Meta, StoryObj } from "storybook-solidjs-vite";
import { Slider } from "./Slider";
import { SliderLabel } from "./SliderLabel";
import { SliderWrapper } from "./SliderWrapper";
const meta = {
component: SliderWrapper,
} satisfies Meta<typeof SliderWrapper>;
export default meta;
type Story = StoryObj<typeof meta>;
const [value, setValue] = createSignal(50);
const SliderWrapperContent = (
disabled: boolean,
title = "Auto Hide Button Delay",
) => (
<>
<SliderLabel value={`${value()}%`} disabled={disabled}>
{title}
</SliderLabel>
<Slider
value={value()}
disabled={disabled}
onInput={(val) => {
setValue(val);
}}
/>
</>
);
export const SliderWrapperDefault: Story = {
args: {
children: SliderWrapperContent(false),
},
};
export const SliderWrapperWithDisabled: Story = {
args: {
children: SliderWrapperContent(true),
},
};

View file

@ -0,0 +1,16 @@
import type { JSX } from "solid-js";
import "./SliderWrapper.scss";
export type SliderWrapperProps = {
children: JSX.Element;
ref?: (element: HTMLElement) => void;
};
export function SliderWrapper(props: SliderWrapperProps): JSX.Element {
return (
<vot-block ref={props.ref} class="vot-slider_new-wrapper">
{props.children}
</vot-block>
);
}

View file

@ -1,11 +1,13 @@
@use "../../styles/mixins" as shared;
.vot-switch {
--vot-switch-outline: rgba(var(--vot-onsurface-rgb, 0, 0, 0), 0.6);
--vot-switch-primary: rgb(var(--vot-primary-rgb, 33, 150, 243));
--vot-switch-onprimary: rgb(var(--vot-onprimary-rgb, 32, 33, 36));
--vot-switch-track: rgba(var(--vot-onsurface-rgb, 0, 0, 0), 0.12);
--vot-switch-timing: cubic-bezier(0.2, 0, 0, 1);
--vot-switch-duration: var(--vot-duration-medium, 200ms);
--vot-switch-label-offset: 24px;
align-items: center;
box-sizing: border-box;
@ -25,9 +27,13 @@
position: relative;
touch-action: manipulation;
vertical-align: middle;
&[data-disabled="true"] {
cursor: not-allowed;
user-select: none;
}
&_sub {
padding-left: var(--vot-switch-label-offset) !important;
}
&-control {
@ -129,7 +135,7 @@
position: relative;
transition-duration: var(--vot-switch-duration);
transition-property: background-color, border-color;
transition-timing-function: var(--vot-switch-timing);
transition-timing-function: var(--vot-easing-standard);
width: 52px;
&[data-checked="true"] {
@ -156,7 +162,7 @@
transform: translate(0, -50%);
transition-duration: var(--vot-switch-duration);
transition-property: background-color, box-shadow, height, transform, width;
transition-timing-function: var(--vot-switch-timing);
transition-timing-function: var(--vot-easing-standard);
width: 16px;
}

View file

@ -27,6 +27,19 @@ export const SwitchCheckedDisabled: Story = {
checked: true,
},
};
export const SwitchHidden: Story = {
args: {
hidden: true,
heading: "hidden",
description: "you shouldn't see this",
},
};
export const SwitchSub: Story = {
args: {
isSubSwitch: true,
heading: "Translate videos automatically",
},
};
export const SwitchWithHeading: Story = {
args: {

View file

@ -6,25 +6,44 @@ import {
Show,
} from "solid-js";
import "./Switch.scss";
import { effect } from "solid-js/web";
export type SwitchProps = {
checked?: boolean;
description?: JSX.Element;
disabled?: boolean;
heading?: JSX.Element;
isSubSwitch?: boolean;
hidden?: boolean;
onChange?: (checked: boolean) => void;
ref?: (element: HTMLLabelElement) => void;
};
export function Switch(props: SwitchProps): JSX.Element {
const finalProps = mergeProps({ checked: false, disabled: false }, props);
const finalProps = mergeProps(
{ checked: false, disabled: false, isSubSwitch: false },
props,
);
const textId = createUniqueId();
const [checked, setChecked] = createSignal(finalProps.checked);
const [disabled] = createSignal(finalProps.disabled);
const [disabled, setDisabled] = createSignal(finalProps.disabled);
effect(() => {
setChecked(finalProps.checked);
setDisabled(finalProps.disabled);
});
return (
<label ref={finalProps.ref} class="vot-switch" data-disabled={disabled()}>
<label
ref={finalProps.ref}
class="vot-switch"
classList={{
"vot-switch_sub": finalProps.isSubSwitch,
}}
hidden={finalProps.hidden}
data-disabled={disabled()}
>
<input
class="vot-switch-control"
aria-checked={checked()}

View file

@ -0,0 +1,9 @@
import type { JSX } from "solid-js";
export function ChevronIcon(): JSX.Element {
return (
<svg width="1em" height="1em" viewBox="0 0 24 24">
<path d="M12 14.975q-.2 0-.375-.062T11.3 14.7l-4.6-4.6q-.275-.275-.275-.7t.275-.7q.275-.275.7-.275t.7.275l3.9 3.9l3.9-3.9q.275-.275.7-.275t.7.275q.275.275.275.7t-.275.7l-4.6 4.6q-.15.15-.325.213t-.375.062Z" />
</svg>
);
}

View file

@ -0,0 +1,108 @@
import { createSignal, type JSX, mergeProps } from "solid-js";
import { DEFAULT_AUTO_HIDE_DELAY } from "../../config/config";
import { localizationProvider } from "../../localization/localizationProvider";
import { settings } from "../../stores/settings";
import { positions } from "../../types/components/votButton";
import type { Phrase } from "../../types/localization";
import { isPiPAvailable } from "../../utils/utils";
import { Select, type SelectOption } from "../Control/Select";
import { Slider } from "../Control/Slider";
import { SliderLabel } from "../Control/SliderLabel";
import { SliderWrapper } from "../Control/SliderWrapper";
import { Switch } from "../Control/Switch";
import { SettingsSection } from "./SettingsSection";
export type SettingsAppearanceSectionProps = {
ref?: (element: HTMLElement) => void;
onShowPiPButtonChange?: (checked: boolean) => void;
onAutoHideButtonDelayInput?: (delay: number) => void;
onButtonPositionSelect?: (option: SelectOption) => void;
onLangSelect?: (option: SelectOption) => void;
};
const MAX_AUTO_HIDE_BUTTON_DELAY = 3000;
const MIN_AUTO_HIDE_BUTTON_DELAY = 100;
const STEP_AUTO_HIDE_BUTTON_DELAY = 100;
export function SettingsAppearanceSection(
props: SettingsAppearanceSectionProps,
): JSX.Element {
const finalProps = mergeProps(props);
const [autoHideButtonDelay, setAutoHideButtonDelay] = createSignal(
DEFAULT_AUTO_HIDE_DELAY,
);
const autoHideButtonDelaySecs = () =>
Math.round(autoHideButtonDelay() / STEP_AUTO_HIDE_BUTTON_DELAY) / 10;
const autoHideButtonDelayValueText = () =>
`${autoHideButtonDelaySecs()} ${localizationProvider.get("secs")}`;
const buttonPositionOptions: SelectOption[] = positions.map((position) => ({
label: localizationProvider.get(`position.${position}`),
value: position,
}));
const selectedButtonPositionIndex = () =>
buttonPositionOptions.findIndex(
(option) => option.value === settings.buttonPos,
);
const langsOptions: SelectOption[] = localizationProvider
.getAvailableLangs()
.map((lang) => {
const phrase = `langs.${lang}` satisfies Phrase;
const label = localizationProvider.get(phrase);
return {
label: label === phrase ? lang.toUpperCase() : label,
value: lang,
};
});
const selectedLangIndex = () =>
langsOptions.findIndex(
(option) => option.value === localizationProvider.langOverride,
);
return (
<SettingsSection
ref={finalProps.ref}
title={localizationProvider.get("appearance")}
>
<Switch
heading={localizationProvider.get("VOTShowPiPButton")}
checked={settings.showPiPButton}
hidden={!isPiPAvailable()}
onChange={finalProps.onShowPiPButtonChange}
/>
<SliderWrapper>
<SliderLabel value={autoHideButtonDelayValueText()}>
{localizationProvider.get("autoHideButtonDelay")}
</SliderLabel>
<Slider
min={MIN_AUTO_HIDE_BUTTON_DELAY}
max={MAX_AUTO_HIDE_BUTTON_DELAY}
step={STEP_AUTO_HIDE_BUTTON_DELAY}
value={autoHideButtonDelay()}
onInput={(val) => {
setAutoHideButtonDelay(val);
finalProps.onAutoHideButtonDelayInput?.(val);
}}
/>
</SliderWrapper>
<Select
title={localizationProvider.get("buttonPosition")}
options={buttonPositionOptions}
selectedValue={selectedButtonPositionIndex()}
onSelect={finalProps.onButtonPositionSelect}
>
{localizationProvider.get("buttonPosition")}
</Select>
<Select
title={localizationProvider.get("VOTMenuLanguage")}
options={langsOptions}
selectedValue={selectedLangIndex()}
onSelect={finalProps.onLangSelect}
>
{localizationProvider.get("VOTMenuLanguage")}
</Select>
</SettingsSection>
);
}

View file

@ -1,12 +1,25 @@
import type { JSX } from "solid-js";
import { localizationProvider } from "../../localization/localizationProvider";
import { AboutSection } from "../About/AboutSection";
import { AccountMenu } from "../Account/AccountMenu";
import { SettingsAppearanceSection } from "./SettingsAppearanceSection";
import { SettingsMiscSection } from "./SettingsMiscSection";
import { SettingsSection } from "./SettingsSection";
export function SettingsContent(): JSX.Element {
return (
<vot-block style="max-width: 450px;display:block;margin:0 auto;">
<AccountMenu />
<AboutSection />
<vot-block style="max-width: 450px;display:flex;flex-direction:column;gap: 16px;margin: 0 auto;">
<SettingsSection
title={localizationProvider.get("VOTMyAccount")}
isOpen={true}
>
<AccountMenu />
</SettingsSection>
<SettingsAppearanceSection />
<SettingsMiscSection />
<SettingsSection title={localizationProvider.get("aboutExtension")}>
<AboutSection />
</SettingsSection>
</vot-block>
);
}

View file

@ -0,0 +1,64 @@
import { type JSX, mergeProps } from "solid-js";
import { localizationProvider } from "../../localization/localizationProvider";
import { settings } from "../../stores/settings";
import { Switch } from "../Control/Switch";
import { SettingsSection } from "./SettingsSection";
export type SettingsMiscSectionProps = {
ref?: (element: HTMLElement) => void;
onChangeTranslateAPIErrors?: (checked: boolean) => void;
onChangeNewAudioPlayer?: (checked: boolean) => void;
onChangeOnlyBypassMediaCSP?: (checked: boolean) => void;
isAudioContextSupported?: boolean;
needBypassCSP?: boolean;
};
export function SettingsMiscSection(
props: SettingsMiscSectionProps,
): JSX.Element {
const finalProps = mergeProps(
{ isAudioContextSupported: false, needBypassCSP: false },
props,
);
const isWithoutAudioContext = () => !finalProps.isAudioContextSupported;
const newAudioPlayer = () => settings.newAudioPlayer;
return (
<SettingsSection
ref={finalProps.ref}
title={localizationProvider.get("miscSettings")}
>
<Switch
heading={localizationProvider.get("VOTTranslateAPIErrors")}
hidden={localizationProvider.lang === "ru"}
checked={settings.translateAPIErrors}
onChange={finalProps.onChangeTranslateAPIErrors}
/>
<Switch
heading={localizationProvider.get("VOTNewAudioPlayer")}
description={
isWithoutAudioContext()
? localizationProvider.get("VOTNeedWebAudioAPI")
: undefined
}
disabled={isWithoutAudioContext()}
checked={newAudioPlayer()}
onChange={finalProps.onChangeNewAudioPlayer}
/>
<Switch
heading={localizationProvider.get("VOTOnlyBypassMediaCSP")}
description={
finalProps.needBypassCSP
? localizationProvider.get("VOTMediaCSPEnabledOnSite")
: undefined
}
checked={settings.onlyBypassMediaCSP}
hidden={isWithoutAudioContext()}
disabled={!newAudioPlayer()}
isSubSwitch={true}
onChange={finalProps.onChangeOnlyBypassMediaCSP}
/>
</SettingsSection>
);
}

View file

@ -0,0 +1,2 @@
@use "../Button/DetailsButton.scss";
@use "../../styles/components/settings.scss";

View file

@ -0,0 +1,31 @@
import type { Meta, StoryObj } from "storybook-solidjs-vite";
import { SettingsSection } from "./SettingsSection";
const meta = {
component: SettingsSection,
} satisfies Meta<typeof SettingsSection>;
export default meta;
type Story = StoryObj<typeof meta>;
const SectionChildren = () => (
<p>
Hello <b>World</b>!
</p>
);
export const SettingsSectionDefault: Story = {
args: {
title: "Settings Section",
children: SectionChildren(),
},
};
export const SettingsSectionDefaultOpened: Story = {
args: {
title: "Settings Section",
children: SectionChildren(),
isOpen: true,
},
};

View file

@ -0,0 +1,61 @@
import { createSignal, createUniqueId, type JSX, mergeProps } from "solid-js";
import { effect } from "solid-js/web";
import "./SettingsSection.scss";
import { RawButton } from "../Button/RawButton";
import { ChevronIcon } from "../Icons/ChevronIcon";
export type SettingsSectionProps = {
title: string;
children: JSX.Element;
isOpen?: boolean;
ref?: (element: HTMLElement) => void;
};
export function SettingsSection(props: SettingsSectionProps): JSX.Element {
const finalProps = mergeProps({ isOpen: false }, props);
const sectionId = createUniqueId();
const headerId = `${sectionId}-header`;
const contentId = `${sectionId}-content`;
const [isOpen, setIsOpen] = createSignal(finalProps.isOpen);
const isHidden = () => !isOpen();
effect(() => {
setIsOpen(finalProps.isOpen);
});
return (
<vot-block class="vot-settings-section" ref={finalProps.ref}>
<RawButton
class="vot-details vot-settings-section__header"
buttonProps={{
id: headerId,
"aria-controls": contentId,
"data-open": isOpen(),
"aria-expanded": isOpen(),
}}
onClick={() => {
setIsOpen(!isOpen());
}}
>
<vot-block>{finalProps.title}</vot-block>
<vot-block class="vot-details-arrow-icon">
<ChevronIcon />
</vot-block>
</RawButton>
<vot-block
class="vot-settings-section__content"
id={contentId}
role="region"
aria-labelledby={headerId}
hidden={isHidden()}
>
{finalProps.children}
</vot-block>
</vot-block>
);
}

View file

@ -15,7 +15,7 @@ export type TextfieldProps = {
value?: string;
multiline?: boolean;
disabled?: boolean;
ref?: (element: HTMLDivElement) => void;
ref?: (element: HTMLElement) => void;
inputRef?: (element: HTMLInputElement | HTMLTextAreaElement) => void;
labelRef?: (element: HTMLSpanElement) => void;
onInput?: (value: string) => void;
@ -73,13 +73,13 @@ export function Textfield(props: TextfieldProps): JSX.Element {
};
return (
<div ref={finalProps.ref} class="vot-textfield">
<vot-block ref={finalProps.ref} class="vot-textfield">
<Show when={finalProps.multiline} fallback={<input {...common} />}>
<textarea {...common} />
</Show>
<span ref={finalProps.labelRef} id={labelId}>
{finalProps.labelText}
</span>
</div>
</vot-block>
);
}

View file

@ -4,6 +4,7 @@
--vot-primary-rgb: 139, 180, 245;
--vot-onprimary-rgb: 32, 33, 36;
--vot-surface-rgb: 32, 33, 36;
--vot-secondary-rgb: 43, 44, 48;
--vot-onsurface-rgb: 227, 227, 227;
--vot-danger-rgb: 255, 99, 99;
@ -39,10 +40,54 @@
--vot-duration-fast: 120ms;
--vot-duration-medium: 200ms;
--vot-duration-slow: 320ms;
--vot-easing-standard: cubic-bezier(0.4, 0, 0.2, 1);
--vot-easing-standard: cubic-bezier(0.2, 0, 0, 1);
--vot-focus-ring-color: rgba(var(--vot-primary-rgb, 139, 180, 245), 0.9);
--vot-focus-ring: 0 0 0 2px var(--vot-focus-ring-color);
--vot-focus-ring-offset: 0 0 0 4px
rgba(var(--vot-surface-rgb, 32, 33, 36), 0.9);
}
// ------------------------------
// Global baseline for injected UI
// ------------------------------
// Avoid host-page box-sizing and typography resets from affecting our components.
vot-block,
vot-block * {
box-sizing: border-box;
-webkit-tap-highlight-color: transparent;
}
// Enforce hidden attribute behavior inside the injected UI tree.
// Keep opt-out classes for components that use custom hidden animations.
vot-block[hidden]:not(.vot-menu):not(.vot-dialog-container),
vot-block [hidden]:not(.vot-menu):not(.vot-dialog-container) {
display: none !important;
}
// Use a stable typography baseline for all VOT UI.
vot-block {
--vot-font-family: "Roboto", "Segoe UI", system-ui, sans-serif !important;
font-family: var(
--vot-font-family,
"Roboto",
"Segoe UI",
system-ui,
sans-serif
) !important;
font-weight: 400 !important;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
-webkit-text-size-adjust: 100%;
text-size-adjust: 100%;
display: block;
// for reddit
visibility: visible !important;
}
// Keep host-page typography rules from overriding VOT font weights.
vot-block * {
font-weight: inherit !important;
}

View file

@ -10,8 +10,16 @@
@use "Button/IconButton.scss";
@use "Button/TextButton.scss";
@use "Button/OutlinedButton.scss";
@use "Button/DetailsButton.scss";
@use "Control/Select.scss";
@use "Control/Switch.scss";
@use "Control/Slider.scss";
@use "Control/SliderLabel.scss";
@use "Control/SliderWrapper.scss";
@use "Settings/SettingsSection.scss";
@use "Textfield/Textfield.scss";

View file

@ -56,6 +56,6 @@ export const proxyOnlyCountries: string[] = ["UA", "LV", "LT"];
/**
* 100 - 3000 ms - delay before hiding button
*/
export const defaultAutoHideDelay = 1000;
export const DEFAULT_AUTO_HIDE_DELAY = 1000;
export const actualCompatVersion = "2025-05-09";

30
src/stores/settings.ts Normal file
View file

@ -0,0 +1,30 @@
import { createStore } from "solid-js/store";
import type { Position } from "../types/components/votButton";
export type SettingsStore = {
translateAPIErrors: boolean;
newAudioPlayer: boolean;
onlyBypassMediaCSP: boolean;
showPiPButton: boolean;
buttonPos: Position;
};
function createInitialState(): SettingsStore {
return {
translateAPIErrors: true,
// TODO: set default by audioContextSupported?
newAudioPlayer: false,
onlyBypassMediaCSP: false,
showPiPButton: false,
buttonPos: "default",
};
}
export const [settings, setSettings] = createStore<SettingsStore>(
createInitialState(),
);
export function resetSettings() {
setSettings(createInitialState());
}

View file

@ -1,15 +1,11 @@
@use "../mixins" as shared;
.vot-details {
display: flex;
justify-content: space-between;
align-items: center;
color: rgba(var(--vot-helper-onsurface-rgb), 0.87);
font-family: var(
--vot-font-family,
"Roboto",
"Segoe UI",
system-ui,
sans-serif
);
color: rgba(var(--vot-onsurface-rgb), 0.87);
@include shared.vot-font-family;
font-size: 16px;
border-radius: 0.5em !important;
padding: 0.5em !important;
@ -17,6 +13,7 @@
line-height: 1.5;
text-align: start;
cursor: pointer;
user-select: none;
transition: background var(--vot-duration-medium) var(--vot-easing-standard);
&-arrow-icon {
@ -26,10 +23,16 @@
justify-content: center;
align-items: center;
transform: scale(1.25) rotate(-90deg);
fill: rgba(var(--vot-helper-onsurface-rgb), 0.87);
fill: rgba(var(--vot-onsurface-rgb), 0.87);
font-size: 24px;
}
&:hover {
&:not([aria-disabled="true"]):hover {
background: rgba(var(--vot-onsurface-rgb, 0, 0, 0), 0.06);
}
&[aria-disabled="true"] {
cursor: not-allowed;
opacity: 0.38;
}
}

View file

@ -17,21 +17,25 @@
}
}
.vot-settings-section-header {
.vot-settings-section-header,
.vot-settings-section__header {
margin: 0 !important;
padding: 0.45em 0.5em !important;
border-radius: var(--vot-radius-m);
}
.vot-settings-section-header .vot-details-arrow-icon {
.vot-settings-section-header .vot-details-arrow-icon,
.vot-settings-section__header .vot-details-arrow-icon {
transition: transform var(--vot-duration-medium) var(--vot-easing-standard);
}
.vot-settings-section-header[data-open="true"] .vot-details-arrow-icon {
.vot-settings-section-header[data-open="true"] .vot-details-arrow-icon,
.vot-settings-section__header[data-open="true"] .vot-details-arrow-icon {
transform: scale(1.25) rotate(0deg);
}
.vot-settings-section-content {
.vot-settings-section-content,
.vot-settings-section__content {
// Shared grid variables for "settings rows".
// Components (select/slider) read these to align their right column.
// Controls are no longer pushed to the far right edge of the dialog.
@ -42,16 +46,13 @@
display: flex;
flex-direction: column;
gap: var(--vot-settings-row-gap);
padding: 0 var(--vot-space-1) var(--vot-space-1);
& > * {
margin: 0 !important;
}
& > * + * {
margin-top: var(--vot-settings-row-gap) !important;
}
// Consistent padding + full-row hit area.
// IMPORTANT: never override component `display` values here. Some controls
// (hotkey, select, slider) rely on flex/grid internally.

View file

@ -5,3 +5,7 @@ export type OnClickEvent = MouseEvent & {
currentTarget: HTMLElement;
target: Element;
};
export type DataAttributes = {
[K in `data-${string}`]?: string | number | boolean | undefined;
};

View file

@ -42,8 +42,10 @@ import { availableLangs } from "@vot.js/shared/consts";
import { AboutSection } from "../../components/About/AboutSection";
import { AccountMenu } from "../../components/Account/AccountMenu";
import { Switch } from "../../components/Control/Switch";
import { SettingsAppearanceSection } from "../../components/Settings/SettingsAppearanceSection";
import { SettingsMiscSection } from "../../components/Settings/SettingsMiscSection";
import { SettingsSection } from "../../components/Settings/SettingsSection";
import {
defaultAutoHideDelay,
defaultAutoVolume,
defaultDetectService,
defaultTranslationService,
@ -58,6 +60,8 @@ import {
localizationProvider,
} from "../../localization/localizationProvider";
import { account, resetAccount, updateAccount } from "../../stores/account";
import { setLocale } from "../../stores/locale";
import { setSettings } from "../../stores/settings";
import {
getGoogleSubtitleFontFamilyName,
loadGoogleFontsCatalog,
@ -75,7 +79,7 @@ import type {
LanguageSelectKey,
SelectItem,
} from "../../types/components/select";
import { type Position, positions } from "../../types/components/votButton";
import type { Position } from "../../types/components/votButton";
import type {
Account,
ResponseLanguageSubtitles,
@ -96,10 +100,8 @@ import debug from "../../utils/debug";
import { EventImpl } from "../../utils/eventImpl";
import { isProxyOnlyExtension, isSupportGMXhr } from "../../utils/gm";
import { votStorage } from "../../utils/storage";
import { isPiPAvailable } from "../../utils/utils";
import type { VideoHandler } from "../../VideoHandler";
import { getCountryCode } from "../../videoHandler/shared";
import { normalizeButtonPosition } from "../buttonPlacement";
import Checkbox from "../components/checkbox";
import { createDomId } from "../components/componentShared";
import Details from "../components/details";
@ -217,7 +219,7 @@ export class SettingsView {
void this.refreshAccountFromStorage();
};
dialog?: Dialog;
accountMenu?: MountedComponent<HTMLElement>;
accountSection?: MountedComponent<HTMLElement>;
private accountStorageListenerCleanup?: () => void;
autoTranslateSwitch?: MountedComponent<HTMLLabelElement>;
autoSubtitlesSwitch?: MountedComponent<HTMLLabelElement>;
@ -255,24 +257,13 @@ export class SettingsView {
proxyTranslationStatusSelectLabel?: Label;
proxyTranslationStatusSelectTooltip?: Tooltip;
proxyTranslationStatusSelect?: Select;
translateAPIErrorsCheckbox?: Checkbox;
useNewAudioPlayerCheckbox?: Checkbox;
useNewAudioPlayerTooltip?: Tooltip;
onlyBypassMediaCSPCheckbox?: Checkbox;
onlyBypassMediaCSPTooltip?: Tooltip;
translationTextServiceLabel?: Label;
translationTextServiceSelect?: Select<TranslateService>;
translationTextServiceTooltip?: Tooltip;
detectServiceLabel?: Label;
detectServiceSelect?: Select<DetectService>;
showPiPButtonCheckbox?: Checkbox;
autoHideButtonDelaySliderLabel?: SliderLabel;
autoHideButtonDelaySlider?: Slider;
buttonPositionSelectLabel?: Label;
buttonPositionSelect?: Select<Position>;
buttonPositionTooltip?: Tooltip;
menuLanguageSelectLabel?: Label;
menuLanguageSelect?: Select<LangOverride>;
appearanceSection?: MountedComponent<HTMLDivElement>;
miscSection?: MountedComponent<HTMLDivElement>;
aboutSection?: MountedComponent<HTMLDivElement>;
bugReportButton?: HTMLElement;
resetSettingsButton?: HTMLElement;
@ -386,6 +377,16 @@ export class SettingsView {
apply?.(value);
void (async () => {
await votStorage.set(storageKey, value);
if (
[
"translateAPIErrors",
"newAudioPlayer",
"onlyBypassMediaCSP",
].includes(storageKey)
) {
setSettings(storageKey as any, value);
}
debug.log(`${logLabel} value changed. New value:`, value);
await afterPersist?.(value);
dispatch?.(value);
@ -395,6 +396,26 @@ export class SettingsView {
};
}
private createBufferedNumericInputHandler({
storageKey,
logLabel = storageKey,
dispatch,
}: {
storageKey: BufferedNumericStorageKey;
logLabel?: string;
dispatch?: (value: number) => void;
}): (value: number) => void {
return (value) => {
this.data[storageKey] = value;
if (storageKey === "autoHideButtonDelay") {
setSettings(storageKey as any, value);
}
this.scheduleStoragePersist(storageKey, value);
debug.log(`${logLabel} value changed. New value:`, value);
dispatch?.(value);
};
}
private bindBufferedNumericSetting({
control,
label,
@ -425,9 +446,6 @@ export class SettingsView {
private createSettingsSections() {
const sections = [
this.createAccordionSection(localizationProvider.get("VOTMyAccount"), {
open: true,
}),
this.createAccordionSection(
localizationProvider.get("translationSettings"),
{ open: true },
@ -437,42 +455,17 @@ export class SettingsView {
),
this.createAccordionSection(localizationProvider.get("hotkeysSettings")),
this.createAccordionSection(localizationProvider.get("proxySettings")),
this.createAccordionSection(localizationProvider.get("miscSettings")),
this.createAccordionSection(localizationProvider.get("appearance")),
this.createAccordionSection(localizationProvider.get("aboutExtension")),
];
return {
accountSection: sections[0],
translationSection: sections[1],
subtitlesSection: sections[2],
hotkeysSection: sections[3],
proxySection: sections[4],
miscSection: sections[5],
appearanceSection: sections[6],
aboutSection: sections[7],
translationSection: sections[0],
subtitlesSection: sections[1],
hotkeysSection: sections[2],
proxySection: sections[3],
sections,
};
}
private initAccountControls(): void {
this.accountMenu = mountComponent((rootRef) =>
AccountMenu({
onClickLogin: async () => {
debug.log("Account login button clicked");
if (account.isLoggedIn) {
await votStorage.delete("account");
resetAccount();
return this.updateAccountInfo();
}
openAuthWindow();
},
ref: rootRef,
}),
);
}
private bindAccountStorageListener(): void {
this.accountStorageListenerCleanup?.();
this.accountStorageListenerCleanup = votStorage.addValueChangeListener<
@ -578,21 +571,101 @@ export class SettingsView {
titleHtml: localizationProvider.get("VOTSettings"),
});
this.globalPortal.appendChild(this.dialog.container);
this.accountSection = mountComponent<HTMLDivElement>((rootRef) =>
SettingsSection({
title: localizationProvider.get("VOTMyAccount"),
isOpen: true,
ref: rootRef,
children: AccountMenu({
onClickLogin: async () => {
debug.log("Account login button clicked");
if (account.isLoggedIn) {
await votStorage.delete("account");
resetAccount();
return this.updateAccountInfo();
}
openAuthWindow();
},
}),
}),
);
this.appearanceSection = mountComponent<HTMLDivElement>((rootRef) =>
SettingsAppearanceSection({
ref: rootRef,
onShowPiPButtonChange: this.createPersistedSettingHandler({
storageKey: "showPiPButton",
dispatch: (checked) =>
this.events["change:showPiPButton"].dispatch(checked),
}),
onAutoHideButtonDelayInput: this.createBufferedNumericInputHandler({
storageKey: "autoHideButtonDelay",
dispatch: (value) =>
this.events["input:autoHideButtonDelay"].dispatch(value),
}),
onButtonPositionSelect: (option) =>
this.createPersistedSettingHandler({
storageKey: "buttonPos",
dispatch: (item) =>
this.events["select:buttonPosition"].dispatch(item),
})(option.value as Position),
onLangSelect: async (option) => {
const item = option.value as LangOverride;
const result = await localizationProvider.changeLang(item);
if (!result) {
return;
}
this.data.localeUpdatedAt = await votStorage.get(
"localeUpdatedAt",
0,
);
setLocale("updatedAt", 0);
this.events["select:menuLanguage"].dispatch(item);
},
}),
);
this.miscSection = mountComponent<HTMLDivElement>((rootRef) =>
SettingsMiscSection({
ref: rootRef,
onChangeTranslateAPIErrors: this.createPersistedSettingHandler({
storageKey: "translateAPIErrors",
}),
isAudioContextSupported: this.videoHandler?.isAudioContextSupported,
needBypassCSP: this.videoHandler.site.needBypassCSP,
onChangeNewAudioPlayer: this.createPersistedSettingHandler({
storageKey: "newAudioPlayer",
dispatch: (checked) =>
this.events["change:useNewAudioPlayer"].dispatch(checked),
}),
onChangeOnlyBypassMediaCSP: this.createPersistedSettingHandler({
storageKey: "onlyBypassMediaCSP",
dispatch: (checked) =>
this.events["change:onlyBypassMediaCSP"].dispatch(checked),
}),
}),
);
this.aboutSection = mountComponent<HTMLDivElement>((rootRef) =>
SettingsSection({
title: localizationProvider.get("aboutExtension"),
ref: rootRef,
children: AboutSection({}),
}),
);
const {
accountSection,
translationSection,
subtitlesSection,
hotkeysSection,
proxySection,
miscSection,
appearanceSection,
aboutSection,
sections,
} = this.createSettingsSections();
this.dialog.bodyContainer.append(
this.accountSection.root,
...sections.map((section) => section.container),
this.miscSection.root,
this.appearanceSection.root,
this.aboutSection.root,
);
this.initAccountControls();
this.autoTranslateSwitch = mountComponent<HTMLLabelElement>((rootRef) =>
Switch({
heading: localizationProvider.get("VOTAutoTranslate"),
@ -727,7 +800,6 @@ export class SettingsView {
backgroundColor: "var(--vot-helper-ondialog)",
parentElement: this.globalPortal,
});
accountSection.content.append(this.accountMenu.root);
translationSection.content.append(
this.autoTranslateSwitch.root,
@ -916,47 +988,6 @@ export class SettingsView {
this.proxyWorkerHostTextfield.container,
this.proxyTranslationStatusSelect.container,
);
this.translateAPIErrorsCheckbox = new Checkbox({
labelHtml: localizationProvider.get("VOTTranslateAPIErrors"),
checked: this.data.translateAPIErrors ?? true,
});
this.translateAPIErrorsCheckbox.hidden = localizationProvider.lang === "ru";
this.useNewAudioPlayerCheckbox = new Checkbox({
labelHtml: localizationProvider.get("VOTNewAudioPlayer"),
checked: this.data.newAudioPlayer,
});
if (!this.videoHandler?.isAudioContextSupported) {
this.useNewAudioPlayerCheckbox.disabled = true;
this.useNewAudioPlayerTooltip = new Tooltip({
target: this.useNewAudioPlayerCheckbox.container,
content: localizationProvider.get("VOTNeedWebAudioAPI"),
position: "bottom",
backgroundColor: "var(--vot-helper-ondialog)",
parentElement: this.globalPortal,
});
}
const onlyBypassMediaCSPLabel = this.videoHandler?.site.needBypassCSP
? `${localizationProvider.get("VOTOnlyBypassMediaCSP")} (${localizationProvider.get("VOTMediaCSPEnabledOnSite")})`
: localizationProvider.get("VOTOnlyBypassMediaCSP");
this.onlyBypassMediaCSPCheckbox = new Checkbox({
labelHtml: onlyBypassMediaCSPLabel,
checked: this.data.onlyBypassMediaCSP,
isSubCheckbox: true,
});
if (!this.videoHandler?.isAudioContextSupported) {
this.onlyBypassMediaCSPTooltip = new Tooltip({
target: this.onlyBypassMediaCSPCheckbox.container,
content: localizationProvider.get("VOTNeedWebAudioAPI"),
position: "bottom",
backgroundColor: "var(--vot-helper-ondialog)",
parentElement: this.globalPortal,
});
}
this.onlyBypassMediaCSPCheckbox.disabled =
!this.data.newAudioPlayer && !!this.videoHandler?.isAudioContextSupported;
if (!this.data.newAudioPlayer) {
this.onlyBypassMediaCSPCheckbox.hidden = true;
}
this.translationTextServiceLabel = new Label({
labelText: localizationProvider.get("VOTTranslationTextService"),
icon: HELP_ICON,
@ -996,94 +1027,17 @@ export class SettingsView {
selected: service === detectService,
})),
});
this.showPiPButtonCheckbox = new Checkbox({
labelHtml: localizationProvider.get("VOTShowPiPButton"),
checked: this.data.showPiPButton,
});
this.showPiPButtonCheckbox.hidden = !isPiPAvailable();
const autoHideButtonDelaySec =
Math.round(
((this.data.autoHideButtonDelay ?? defaultAutoHideDelay) / 1000) * 10,
) / 10;
this.autoHideButtonDelaySliderLabel = new SliderLabel({
labelText: localizationProvider.get("autoHideButtonDelay"),
labelEOL: ":",
value: autoHideButtonDelaySec,
symbol: ` ${localizationProvider.get("secs")}`,
});
this.autoHideButtonDelaySlider = new Slider({
labelHtml: this.autoHideButtonDelaySliderLabel.container,
value: autoHideButtonDelaySec,
min: 0.1,
max: 3,
step: 0.1,
});
this.buttonPositionSelectLabel = new Label({
labelText: localizationProvider.get("buttonPosition"),
icon: HELP_ICON,
});
const buttonPos = normalizeButtonPosition(this.data.buttonPos);
this.buttonPositionSelect = new Select<Position>({
selectTitle: localizationProvider.get(`position.${buttonPos}`),
dialogTitle: localizationProvider.get("buttonPosition"),
labelElement: this.buttonPositionSelectLabel.container,
dialogParent: this.globalPortal,
items: positions.map<SelectItem<Position>>((position) => ({
label: localizationProvider.get(`position.${position}`),
value: position,
selected: position === buttonPos,
})),
});
this.buttonPositionTooltip = new Tooltip({
target: this.buttonPositionSelectLabel.icon,
content: localizationProvider.get("minButtonPositionContainer"),
position: "bottom",
backgroundColor: "var(--vot-helper-ondialog)",
parentElement: this.globalPortal,
});
this.menuLanguageSelectLabel = new Label({
labelText: localizationProvider.get("VOTMenuLanguage"),
});
this.menuLanguageSelect = new Select<LangOverride>({
selectTitle: localizationProvider.get(
`langs.${localizationProvider.langOverride}` as any,
),
dialogTitle: localizationProvider.get("VOTMenuLanguage"),
labelElement: this.menuLanguageSelectLabel.container,
dialogParent: this.globalPortal,
items: Select.genLanguageItems(
localizationProvider.getAvailableLangs(),
localizationProvider.langOverride,
),
});
this.bugReportButton = ui.createOutlinedButton(
localizationProvider.get("VOTBugReport"),
);
this.resetSettingsButton = ui.createButton(
localizationProvider.get("resetSettings"),
);
miscSection.content.append(
this.translateAPIErrorsCheckbox.container,
this.useNewAudioPlayerCheckbox.container,
this.onlyBypassMediaCSPCheckbox.container,
);
translationSection.content.append(
this.translationTextServiceSelect.container,
this.detectServiceSelect.container,
);
appearanceSection.content.append(
this.showPiPButtonCheckbox.container,
this.autoHideButtonDelaySlider.container,
this.buttonPositionSelect.container,
this.menuLanguageSelect.container,
);
this.aboutSection = mountComponent<HTMLDivElement>((rootRef) =>
AboutSection({
ref: rootRef,
}),
);
aboutSection.content.append(this.aboutSection.root);
this.dialog.footerContainer.append(
this.bugReportButton,
this.resetSettingsButton,
@ -1300,32 +1254,7 @@ export class SettingsView {
this.events["select:proxyTranslationStatus"].dispatch(item);
},
);
this.translateAPIErrorsCheckbox.addEventListener(
"change",
this.createPersistedSettingHandler({
storageKey: "translateAPIErrors",
}),
);
this.useNewAudioPlayerCheckbox.addEventListener(
"change",
this.createPersistedSettingHandler({
storageKey: "newAudioPlayer",
apply: (checked) => {
this.onlyBypassMediaCSPCheckbox.disabled =
this.onlyBypassMediaCSPCheckbox.hidden = !checked;
},
dispatch: (checked) =>
this.events["change:useNewAudioPlayer"].dispatch(checked),
}),
);
this.onlyBypassMediaCSPCheckbox.addEventListener(
"change",
this.createPersistedSettingHandler({
storageKey: "onlyBypassMediaCSP",
dispatch: (checked) =>
this.events["change:onlyBypassMediaCSP"].dispatch(checked),
}),
);
this.translationTextServiceSelect.addEventListener(
"selectItem",
this.createPersistedSettingHandler({
@ -1340,36 +1269,6 @@ export class SettingsView {
storageKey: "detectService",
}),
);
this.showPiPButtonCheckbox.addEventListener(
"change",
this.createPersistedSettingHandler({
storageKey: "showPiPButton",
dispatch: (checked) =>
this.events["change:showPiPButton"].dispatch(checked),
}),
);
this.bindBufferedNumericSetting({
control: this.autoHideButtonDelaySlider,
label: this.autoHideButtonDelaySliderLabel,
storageKey: "autoHideButtonDelay",
logLabel: "autoHideButtonDelay",
toStoredValue: (value) => Math.round(value * 1000),
dispatch: (value) =>
this.events["input:autoHideButtonDelay"].dispatch(value),
});
this.buttonPositionSelect.addEventListener(
"selectItem",
this.createPersistedSettingHandler({
storageKey: "buttonPos",
dispatch: (item) => this.events["select:buttonPosition"].dispatch(item),
}),
);
this.menuLanguageSelect.addEventListener("selectItem", async (item) => {
const result = await localizationProvider.changeLang(item);
if (!result) return;
this.data.localeUpdatedAt = await votStorage.get("localeUpdatedAt", 0);
this.events["select:menuLanguage"].dispatch(item);
});
this.bugReportButton.addEventListener("click", () =>
this.events["click:bugReport"].dispatch(),
);
@ -1394,9 +1293,11 @@ export class SettingsView {
}
private doReleaseUI(): void {
for (const key of [
"accountSection",
"autoTranslateSwitch",
"autoSubtitlesSwitch",
"accountMenu",
"appearanceSection",
"miscSection",
"aboutSection",
] satisfies (keyof (typeof SettingsView)["prototype"])[]) {
const control = this[key] as MountedComponent<any> | undefined;
@ -1408,11 +1309,8 @@ export class SettingsView {
this.dialog?.remove();
for (const tooltip of [
this.useAudioDownloadCheckboxTooltip,
this.useNewAudioPlayerTooltip,
this.onlyBypassMediaCSPTooltip,
this.translationTextServiceTooltip,
this.proxyTranslationStatusSelectTooltip,
this.buttonPositionTooltip,
]) {
tooltip?.release();
}

View file

@ -2,7 +2,7 @@ import YoutubeHelper from "@vot.js/ext/helpers/youtube";
import { getVideoID } from "@vot.js/ext/utils/videoData";
import { availableLangs } from "@vot.js/shared/consts";
import type { RequestLang } from "@vot.js/shared/types/data";
import { defaultAutoHideDelay } from "../../config/config";
import { DEFAULT_AUTO_HIDE_DELAY } from "../../config/config";
import {
isDesktopYouTubeLikeSite,
isMuteSyncDisabledHost,
@ -610,7 +610,7 @@ export function getAutoHideDelay(this: VideoHandler): number {
const delay = this.data?.autoHideButtonDelay;
return typeof delay === "number" && Number.isFinite(delay)
? delay
: defaultAutoHideDelay;
: DEFAULT_AUTO_HIDE_DELAY;
}
export function releaseExtraEvents(this: VideoHandler) {
this.resizeObserver?.disconnect();

View file

@ -1,6 +1,6 @@
import {
actualCompatVersion,
defaultAutoHideDelay,
DEFAULT_AUTO_HIDE_DELAY,
defaultAutoVolume,
defaultDetectService,
defaultTranslationService,
@ -10,7 +10,9 @@ import {
} from "../../config/config";
import { updateAccountFromStorage } from "../../stores/account";
import { setLocale } from "../../stores/locale";
import { setSettings } from "../../stores/settings";
import type { LanguageSelectKey } from "../../types/components/select";
import { normalizeButtonPosition } from "../../ui/buttonPlacement";
import debug from "../../utils/debug";
import { GM_fetch, isProxyOnlyExtension, isSupportGMXhr } from "../../utils/gm";
import { updateConfig, votStorage } from "../../utils/storage";
@ -89,7 +91,7 @@ export async function init(this: VideoHandler) {
translateProxyEnabledDefault: true,
audioBooster: false,
useLivelyVoice: false,
autoHideButtonDelay: defaultAutoHideDelay,
autoHideButtonDelay: DEFAULT_AUTO_HIDE_DELAY,
// Audio download now uses direct network requests (GM_fetch/GM_xmlhttpRequest).
useAudioDownload: isSupportGMXhr,
compatVersion: "",
@ -108,6 +110,13 @@ export async function init(this: VideoHandler) {
updatedAt: this.data.localeUpdatedAt,
hash: this.data.localeHash,
});
setSettings({
translateAPIErrors: this.data.translateAPIErrors,
newAudioPlayer: this.data.newAudioPlayer,
onlyBypassMediaCSP: this.data.onlyBypassMediaCSP,
showPiPButton: this.data.showPiPButton,
buttonPos: normalizeButtonPosition(this.data.buttonPos),
});
try {
if (

View file

@ -5,7 +5,6 @@ import { srcDir } from "../paths.ts";
const localesDir = path.resolve(srcDir, "localization", "locales");
const localeHeadersDir = path.resolve(localesDir, "headers");
const hashesPath = path.resolve(srcDir, "localization", "hashes.json");
const _metaHeadersPath = path.resolve(srcDir, "headers.json");
const priorityLocales = ["auto", "en", "ru"] as const;
type PriorityLocale = (typeof priorityLocales)[number];