refactor: complete solid migration

This commit is contained in:
Toil 2026-09-01 14:04:58 +03:00
parent 58b7ed8956
commit 737dd3168e
No known key found for this signature in database
GPG key ID: 57248E2819D32070
72 changed files with 1273 additions and 1610 deletions

View file

@ -37,3 +37,5 @@ Only use the new localization key after it has been added through this command.
## Commits
ALWAYS write commit messages in English. You MUST use the semantic commits format. NEVER use `src/localization/locales/*` files to write commit messages, except in the case where these are the only changes.
NEVER make push or pull requests without ASK an user!

View file

@ -42,7 +42,7 @@
"test": "bun test",
"test:unit": "bun test",
"test:unit:watch": "bun test --watch",
"test:ui": "vite build --config vite/vite.test-ui.config.ts",
"test:ui": "vitest run --project storybook --maxWorkers=4",
"build:gm:default": "vite build --config vite/vite.config.ts",
"build:gm:min": "vite build --mode minify --config vite/vite.config.ts",
"build:gm": "run-p build:gm:default build:gm:min",

View file

@ -1 +1,38 @@
@use "../../styles/components/details.scss";
@use "../mixins" as shared;
.vot-details {
display: flex;
justify-content: space-between;
align-items: center;
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;
margin: -0.5em !important;
line-height: 1.5;
text-align: start;
cursor: pointer;
user-select: none;
transition: background var(--vot-duration-medium) var(--vot-easing-standard);
&-arrow-icon {
width: 20px;
height: 32px;
display: flex;
justify-content: center;
align-items: center;
transform: scale(1.25) rotate(-90deg);
fill: rgba(var(--vot-onsurface-rgb), 0.87);
font-size: 24px;
}
&: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

@ -1 +1,43 @@
@use "../../styles/components/buttons/contained.scss";
@use "./mixins" as button;
.vot-button {
--vot-helper-theme: var(
--vot-theme-rgb,
var(--vot-primary-rgb, 33, 150, 243)
);
--vot-helper-ontheme: var(
--vot-ontheme-rgb,
var(--vot-onprimary-rgb, 255, 255, 255)
);
@include button.vot-button-base(
$radius: var(--vot-radius-s),
$padding: 0 var(--vot-space-4),
$min-width: 64px,
$height: 36px,
$line-height: 36px
);
border: none !important;
color: rgb(var(--vot-helper-ontheme));
background-color: rgb(var(--vot-helper-theme));
box-shadow: var(--vot-shadow-1);
transition: box-shadow var(--vot-duration-medium) var(--vot-easing-standard);
@include button.vot-button-layers(rgb(var(--vot-helper-ontheme)));
@include button.vot-button-layer-hover(0.08);
@include button.vot-button-layer-active(0.32);
&:hover {
box-shadow: var(--vot-shadow-2);
}
&:active {
box-shadow: var(--vot-shadow-2);
}
&[disabled="true"] {
background-color: rgba(var(--vot-onsurface-rgb, 0, 0, 0), 0.12);
color: rgba(var(--vot-onsurface-rgb, 0, 0, 0), 0.38);
box-shadow: none;
cursor: initial;
@include button.vot-button-layer-reset-disabled;
}
}

View file

@ -1,4 +1,4 @@
@use "../../styles/components/buttons/mixins" as button;
@use "./mixins" as button;
.vot-hotkey {
display: flex;

View file

@ -1 +1,36 @@
@use "../../styles/components/buttons/icon.scss";
@use "./mixins" as button;
.vot-icon-button {
--vot-helper-onsurface: rgba(var(--vot-onsurface-rgb, 0, 0, 0), 0.87);
@include button.vot-button-base(
$radius: 50%,
$padding: 0,
$min-width: 36px,
$height: 36px,
$line-height: 36px,
$margin: 0 !important
);
border: none !important;
width: 36px;
fill: var(--vot-helper-onsurface);
color: var(--vot-helper-onsurface);
background-color: transparent;
@include button.vot-button-layers(var(--vot-helper-onsurface));
@include button.vot-button-layer-hover(0.04);
@include button.vot-button-layer-active(0.32);
&[disabled="true"],
&[aria-disabled="true"] {
background-color: transparent;
color: rgba(var(--vot-onsurface-rgb, 0, 0, 0), 0.38);
fill: rgba(var(--vot-onsurface-rgb, 0, 0, 0), 0.38);
cursor: initial;
@include button.vot-button-layer-reset-disabled;
}
& svg {
fill: inherit;
stroke: inherit;
width: 24px;
height: 36px;
}
}

View file

@ -1 +1,30 @@
@use "../../styles/components/buttons/outlined.scss";
@use "./mixins" as button;
.vot-outlined-button {
--vot-helper-theme: var(
--vot-theme-rgb,
var(--vot-primary-rgb, 33, 150, 243)
);
@include button.vot-button-base(
$radius: var(--vot-radius-s),
$padding: 0 var(--vot-space-4),
$min-width: 64px,
$height: 36px,
$line-height: 34px,
$margin: 0 !important
);
border: solid 1px var(--vot-border-color) !important;
color: rgb(var(--vot-helper-theme));
background-color: transparent;
@include button.vot-button-layers(rgb(var(--vot-helper-theme)));
@include button.vot-button-layer-hover(0.04);
@include button.vot-button-layer-active(0.16);
&[disabled="true"],
&[aria-disabled="true"] {
background-color: transparent;
color: rgba(var(--vot-onsurface-rgb, 0, 0, 0), 0.38);
cursor: initial;
@include button.vot-button-layer-reset-disabled;
}
}

View file

@ -6,9 +6,9 @@ import { ProgressIconButton } from "./ProgressIconButton";
const meta = {
component: ProgressIconButton,
render: (args) => (
<div style="display: flex;color:#fff;font-size: 40px">
<vot-block style="display: flex;color:#fff;font-size: 40px">
<ProgressIconButton {...args} />
</div>
</vot-block>
),
} satisfies Meta<typeof ProgressIconButton>;
@ -32,7 +32,7 @@ export const ProgressIconButtonChanging: Story = {
const [progress, setProgress] = createSignal(0);
return (
<div style="display: flex; color: #fff; font-size: 40px">
<vot-block style="display: flex; color: #fff; font-size: 40px">
<ProgressIconButton
{...args}
showProgress={showProgress()}
@ -56,7 +56,7 @@ export const ProgressIconButtonChanging: Story = {
>
<DownloadIcon />
</ProgressIconButton>
</div>
</vot-block>
);
},
};

View file

@ -3,7 +3,7 @@ import type {
DataAttributes,
OnClickEvent,
} from "../../types/components/shared";
import { isKeyboardActivation } from "../../ui/components/componentShared";
import { isKeyboardActivation } from "../componentShared";
export type RawButtonProps = {
class: string;

View file

@ -1 +1,30 @@
@use "../../styles/components/buttons/text.scss";
@use "./mixins" as button;
.vot-text-button {
--vot-helper-theme: var(
--vot-theme-rgb,
var(--vot-primary-rgb, 33, 150, 243)
);
@include button.vot-button-base(
$radius: var(--vot-radius-s),
$padding: 0 var(--vot-space-2),
$min-width: 64px,
$height: 36px,
$line-height: 36px,
$margin: 0 !important
);
border: none !important;
color: rgb(var(--vot-helper-theme));
background-color: transparent;
@include button.vot-button-layers(rgb(var(--vot-helper-theme)));
@include button.vot-button-layer-hover(0.04);
@include button.vot-button-layer-active(0.16);
&[disabled="true"],
&[aria-disabled="true"] {
background-color: transparent;
color: rgba(var(--vot-onsurface-rgb, 0, 0, 0), 0.38);
cursor: initial;
@include button.vot-button-layer-reset-disabled;
}
}

View file

@ -1,4 +1,4 @@
@use "../../mixins" as shared;
@use "../mixins" as shared;
@mixin vot-button-base(
$radius,

View file

@ -1,4 +1,4 @@
.vot-select_new {
.vot-select {
display: flex;
align-items: center;
justify-content: end;
@ -59,7 +59,7 @@
border-color: var(--vot-border-color-hover);
}
&[aria-expanded="true"] .vot-select_new-outer__arrow {
&[aria-expanded="true"] .vot-select-outer__arrow {
transform: rotate(180deg);
}
}

View file

@ -84,12 +84,12 @@ export const SelectNearViewportBottom: Story = {
</vot-block>
),
play: async ({ canvasElement }) => {
const trigger = canvasElement.querySelector<HTMLElement>(
".vot-select_new-outer",
);
const popup = canvasElement.ownerDocument.querySelector<HTMLElement>(
".vot-select_new-inner",
);
const trigger =
canvasElement.querySelector<HTMLElement>(".vot-select-outer");
const popup =
canvasElement.ownerDocument.querySelector<HTMLElement>(
".vot-select-inner",
);
await expect(trigger).not.toBeNull();
await expect(popup).not.toBeNull();
@ -119,7 +119,7 @@ export const SelectNearViewportBottom: Story = {
expect(popup.hidden).toBe(true);
});
const select = trigger.closest<HTMLElement>(".vot-select_new");
const select = trigger.closest<HTMLElement>(".vot-select");
await expect(select).not.toBeNull();
if (!select?.parentNode) {
return;
@ -166,7 +166,7 @@ export const SelectLoading: Story = {
play: async () => {
const listbox = document.querySelector('[role="listbox"]');
const loader = document.querySelector(
'.vot-select_new-inner__no-options[data-searching="true"]',
'.vot-select-inner__no-options[data-searching="true"]',
);
await expect(listbox).toHaveAttribute("aria-busy", "true");
@ -198,12 +198,9 @@ export const SelectStaysOpenWhenOptionsChange: Story = {
);
},
play: async ({ canvasElement }) => {
const trigger = canvasElement.querySelector<HTMLElement>(
".vot-select_new-outer",
);
const popup = canvasElement.querySelector<HTMLElement>(
".vot-select_new-inner",
);
const trigger =
canvasElement.querySelector<HTMLElement>(".vot-select-outer");
const popup = canvasElement.querySelector<HTMLElement>(".vot-select-inner");
await expect(trigger).not.toBeNull();
await expect(popup).not.toBeNull();
@ -233,9 +230,8 @@ export const SelectWithSearchWithCustomProvider: Story = {
},
},
play: async ({ canvasElement }) => {
const trigger = canvasElement.querySelector<HTMLElement>(
".vot-select_new-outer",
);
const trigger =
canvasElement.querySelector<HTMLElement>(".vot-select-outer");
await expect(trigger).not.toBeNull();
if (!trigger) {
return;
@ -261,7 +257,7 @@ export const SelectWithSearchWithCustomProvider: Story = {
: initialPopupRect.bottom;
const input = canvasElement.ownerDocument.querySelector<HTMLInputElement>(
".vot-select_new-inner input",
".vot-select-inner input",
);
await expect(input).not.toBeNull();
if (!input) {
@ -275,7 +271,7 @@ export const SelectWithSearchWithCustomProvider: Story = {
providerOption =
Array.from(
canvasElement.ownerDocument.querySelectorAll<HTMLElement>(
".vot-select_new-inner__option",
".vot-select-inner__option",
),
).find((option) => option.textContent === "123") ?? null;
expect(providerOption).not.toBeNull();

View file

@ -335,17 +335,15 @@ export function Select(props: SelectProps): JSX.Element {
return (
<vot-block
ref={finalProps.ref}
class="vot-select_new"
class="vot-select"
aria-disabled={finalProps.disabled}
>
<Show when={finalProps.children}>
<vot-block class="vot-select_new-label">
{finalProps.children}
</vot-block>
<vot-block class="vot-select-label">{finalProps.children}</vot-block>
</Show>
<RawButton
ref={(el) => (outerRef = el)}
class="vot-select_new-outer"
class="vot-select-outer"
buttonProps={{
"aria-haspopup": "listbox",
"aria-controls": selectId,
@ -361,15 +359,13 @@ export function Select(props: SelectProps): JSX.Element {
finalProps.onOpen?.();
}}
>
<vot-block class="vot-select_new-outer__title">
{visibleTitle()}
</vot-block>
<vot-block class="vot-select_new-outer__arrow">
<vot-block class="vot-select-outer__title">{visibleTitle()}</vot-block>
<vot-block class="vot-select-outer__arrow">
<ChevronIcon />
</vot-block>
</RawButton>
<vot-block
class="vot-select_new-inner"
class="vot-select-inner"
ref={innerRef}
id={selectId}
hidden={!isOpen()}
@ -382,7 +378,7 @@ export function Select(props: SelectProps): JSX.Element {
/>
</Show>
<vot-block
class="vot-select_new-inner__options"
class="vot-select-inner__options"
role="listbox"
aria-busy={finalProps.loading ? "true" : undefined}
aria-multiselectable={finalProps.multiple ? "true" : undefined}
@ -390,7 +386,7 @@ export function Select(props: SelectProps): JSX.Element {
<For each={filteredOptions()}>
{(option) => (
<RawButton
class="vot-select_new-inner__option"
class="vot-select-inner__option"
disabled={option.disabled || finalProps.disabled}
buttonProps={{
role: "option",
@ -410,16 +406,13 @@ export function Select(props: SelectProps): JSX.Element {
</For>
</vot-block>
<Show when={finalProps.loading}>
<vot-block
class="vot-select_new-inner__no-options"
data-searching="true"
>
<vot-block class="vot-select-inner__no-options" data-searching="true">
<LoadingDotsIcon />
</vot-block>
</Show>
<Show when={filteredOptions().length === 0 && !finalProps.loading}>
<vot-block
class="vot-select_new-inner__no-options"
class="vot-select-inner__no-options"
data-searching={isSearching()}
>
<Show

View file

@ -1,4 +1,4 @@
.vot-slider_new {
.vot-slider {
--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;
@ -15,7 +15,7 @@
&[aria-disabled="true"] {
user-select: none;
opacity: 0.5;
.vot-slider_new__control {
.vot-slider__control {
cursor: not-allowed;
}
}
@ -62,8 +62,8 @@
}
&[data-dragging] {
.vot-slider_new__track-progress,
.vot-slider_new__handle {
.vot-slider__track-progress,
.vot-slider__handle {
transition: none;
}
}

View file

@ -49,13 +49,13 @@ export function Slider(props: SliderProps): JSX.Element {
return (
<vot-block
ref={finalProps.ref}
class="vot-slider_new"
class="vot-slider"
data-dragging={dragging() ? "" : undefined}
style={{ "--vot-progress": progress() }}
aria-disabled={finalProps.disabled}
>
<input
class="vot-slider_new__control"
class="vot-slider__control"
type="range"
min={finalProps.min}
max={finalProps.max}
@ -97,9 +97,9 @@ export function Slider(props: SliderProps): JSX.Element {
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 class="vot-slider__track" />
<vot-block class="vot-slider__track vot-slider__track-progress" />
<vot-block class="vot-slider__handle" />
</vot-block>
);
}

View file

@ -1,4 +1,4 @@
.vot-slider_new-label {
.vot-slider-label {
display: flex;
flex-wrap: nowrap;
align-items: baseline;

View file

@ -16,7 +16,7 @@ export type SliderLabelDescProps = {
export function SliderLabelDesc(props: SliderLabelDescProps): JSX.Element {
return (
<vot-block ref={props.ref} class="vot-slider_new-label__text-desc">
<vot-block ref={props.ref} class="vot-slider-label__text-desc">
{props.children}
</vot-block>
);
@ -34,15 +34,13 @@ export function SliderLabel(props: SliderLabelProps): JSX.Element {
return (
<vot-block
ref={finalProps.ref}
class="vot-slider_new-label"
class="vot-slider-label"
aria-disabled={finalProps.disabled}
>
<vot-block class="vot-slider_new-label__text">
<vot-block class="vot-slider-label__text">
{finalProps.children}
</vot-block>
<vot-block class="vot-slider_new-label__value">
{finalProps.value}
</vot-block>
<vot-block class="vot-slider-label__value">{finalProps.value}</vot-block>
</vot-block>
);
}

View file

@ -1,4 +1,4 @@
.vot-slider_new-wrapper {
.vot-slider-wrapper {
display: flex;
flex-direction: column;
gap: 0.25em;

View file

@ -9,7 +9,7 @@ export type SliderWrapperProps = {
export function SliderWrapper(props: SliderWrapperProps): JSX.Element {
return (
<vot-block ref={props.ref} class="vot-slider_new-wrapper">
<vot-block ref={props.ref} class="vot-slider-wrapper">
{props.children}
</vot-block>
);

View file

@ -1,4 +1,4 @@
@use "../../styles/mixins" as shared;
@use "../mixins" as shared;
.vot-switch {
--vot-switch-outline: rgba(var(--vot-onsurface-rgb, 0, 0, 0), 0.6);

View file

@ -63,22 +63,25 @@ export function Switch(props: SwitchProps): JSX.Element {
}}
/>
<Show when={finalProps.heading || finalProps.description}>
<span class="vot-switch-text">
<vot-block class="vot-switch-text">
<Show when={finalProps.heading}>
<span class="vot-switch-heading" id={`${textId}-heading`}>
<vot-block class="vot-switch-heading" id={`${textId}-heading`}>
{finalProps.heading}
</span>
</vot-block>
</Show>
<Show when={finalProps.description}>
<span class="vot-switch-description" id={`${textId}-description`}>
<vot-block
class="vot-switch-description"
id={`${textId}-description`}
>
{finalProps.description}
</span>
</vot-block>
</Show>
</span>
</vot-block>
</Show>
<span class="vot-switch-track" data-checked={checked()}>
<span class="vot-switch-handle"></span>
</span>
<vot-block class="vot-switch-track" data-checked={checked()}>
<vot-block class="vot-switch-handle" />
</vot-block>
</label>
);
}

View file

@ -1,4 +1,4 @@
@use "../../styles/mixins" as shared;
@use "../mixins" as shared;
.vot-dialog {
--vot-helper-surface-rgb: var(--vot-surface-rgb, 255, 255, 255);

View file

@ -5,9 +5,9 @@ import { ProgressIcon } from "./ProgressIcon";
const meta = {
component: ProgressIcon,
render: (args) => (
<div style="display: flex;color:#fff;font-size: 40px">
<vot-block style="display: flex;color:#fff;font-size: 40px">
<ProgressIcon {...args} />
</div>
</vot-block>
),
} satisfies Meta<typeof ProgressIcon>;
@ -53,9 +53,9 @@ export const ProgressIconChanging: Story = {
onCleanup(() => window.clearInterval(intervalId));
return (
<div style="display: flex; color: #fff; font-size: 40px">
<vot-block style="display: flex; color: #fff; font-size: 40px">
<ProgressIcon progress={progress()} />
</div>
</vot-block>
);
},
};

View file

@ -5,9 +5,9 @@ import { TranslateIcon } from "./TranslateIcon";
const meta = {
component: TranslateIcon,
render: (args) => (
<div style="color: white">
<vot-block style="color: white">
<TranslateIcon {...args} />
</div>
</vot-block>
),
} satisfies Meta<typeof TranslateIcon>;

View file

@ -1,6 +1,6 @@
import { expect, fn, userEvent } from "storybook/test";
import type { Meta, StoryObj } from "storybook-solidjs-vite";
import { setSettings, settings } from "../stores/settings";
import { setSettings, settings } from "../../stores/settings";
import { OverlayView, type OverlayViewControls } from "./OverlayView";
const onSettingsClick = fn();
@ -10,7 +10,7 @@ let smallLayoutControls: OverlayViewControls | undefined;
const meta = {
component: OverlayView,
render: (args) => (
<div style="display: flex;padding: 20px 200px 200px;height: 90vh;">
<vot-block style="display: flex;padding: 20px 200px 200px;height: 90vh;">
<OverlayView
{...args}
baseOpacity={1}
@ -28,7 +28,7 @@ const meta = {
>
change direction
</button>
</div>
</vot-block>
),
} satisfies Meta<typeof OverlayView>;

View file

@ -9,29 +9,29 @@ import {
onMount,
Show,
} from "solid-js";
import type { SelectOption } from "../components/Control/Select";
import { PreviewSegmentedButtonOverlay } from "../components/SegmentedButton/PreviewSegmentedButtonOverlay";
import type { SegmentedButtonControls } from "../components/SegmentedButton/SegmentedButton";
import type { SegmentedButtonMenuControls } from "../components/SegmentedButton/SegmentedButtonMenu";
import {
SegmentedButtonMenuOverlay,
type SegmentedButtonMenuOverlayProps,
} from "../components/SegmentedButton/SegmentedButtonMenuOverlay";
import {
SegmentedButtonOverlay,
type SegmentedButtonOverlayProps,
} from "../components/SegmentedButton/SegmentedButtonOverlay";
import { localizationProvider } from "../localization/localizationProvider";
import { setSettings, settings } from "../stores/settings";
import type { Position, Status } from "../types/components/votButton";
import { localizationProvider } from "../../localization/localizationProvider";
import { setSettings, settings } from "../../stores/settings";
import type { Position, Status } from "../../types/components/votButton";
import {
getButtonDirection,
isSideButtonPosition,
normalizeButtonPosition,
resolveButtonPositionFromPointer,
} from "../ui/buttonPlacement";
import { votStorage } from "../utils/storage";
import { isPiPAvailable } from "../utils/utils";
} from "../../ui/buttonPlacement";
import { votStorage } from "../../utils/storage";
import { isPiPAvailable } from "../../utils/utils";
import type { SelectOption } from "../Control/Select";
import { PreviewSegmentedButtonOverlay } from "../SegmentedButton/PreviewSegmentedButtonOverlay";
import type { SegmentedButtonControls } from "../SegmentedButton/SegmentedButton";
import type { SegmentedButtonMenuControls } from "../SegmentedButton/SegmentedButtonMenu";
import {
SegmentedButtonMenuOverlay,
type SegmentedButtonMenuOverlayProps,
} from "../SegmentedButton/SegmentedButtonMenuOverlay";
import {
SegmentedButtonOverlay,
type SegmentedButtonOverlayProps,
} from "../SegmentedButton/SegmentedButtonOverlay";
type ButtonDragState = {
pointerId: number;
@ -87,7 +87,6 @@ export type OverlayViewControls = {
>;
export type OverlayViewProps = {
ref?: (element: HTMLElement) => void;
controlsRef?: (controls: OverlayViewControls) => void;
isBigContainer?: boolean;
/**
@ -572,11 +571,11 @@ export function OverlayView(props: OverlayViewProps): JSX.Element {
}
const path = event.composedPath();
// select_new-inner can be outside of these overlay
// Select options can be portaled outside of these overlays.
const isClickInsideSelect = path.some(
(element) =>
element instanceof HTMLElement &&
element.classList.contains("vot-select_new-inner"),
element.classList.contains("vot-select-inner"),
);
if (
path.includes(buttonOverlay) ||
@ -642,7 +641,6 @@ export function OverlayView(props: OverlayViewProps): JSX.Element {
<vot-block
ref={(element) => {
rootElement = element;
finalProps.ref?.(element);
}}
>
<SegmentedButtonOverlay

View file

@ -5,9 +5,9 @@ import { MenuHeaderContent } from "./SegmentedButtonMenu";
const meta = {
component: MenuHeaderContent,
render: (args) => (
<div style="display: flex;padding: 20px 200px 200px;">
<vot-block style="display: flex;padding: 20px 200px 200px;">
<MenuHeaderContent {...args} />
</div>
</vot-block>
),
} satisfies Meta<typeof MenuHeaderContent>;

View file

@ -5,9 +5,9 @@ import { PreviewSegmentedButton } from "./PreviewSegmentedButton";
const meta = {
component: PreviewSegmentedButton,
render: (args) => (
<div style="display: flex;background: gray;padding: 20px 200px 200px;">
<vot-block style="display: flex;background: gray;padding: 20px 200px 200px;">
<PreviewSegmentedButton {...args} />
</div>
</vot-block>
),
} satisfies Meta<typeof PreviewSegmentedButton>;
@ -61,8 +61,8 @@ export const PreviewSegmentedButtonAsColumn: Story = {
direction: "column",
},
render: (args) => (
<div style="display: flex;background: gray;padding: 200px;padding-left: 20px;">
<vot-block style="display: flex;background: gray;padding: 200px;padding-left: 20px;">
<PreviewSegmentedButton {...args} />
</div>
</vot-block>
),
};

View file

@ -1,5 +1,5 @@
@use "../../styles/mixins" as shared;
@use "../../styles/components/buttons/mixins" as button;
@use "../mixins" as shared;
@use "../Button/mixins" as button;
.vot-segmented-button {
--vot-helper-theme-rgb: var(--vot-onsurface-rgb, 0, 0, 0);

View file

@ -23,9 +23,9 @@ function dispatchPrimaryPointerUp(
const meta = {
component: SegmentedButton,
render: (args) => (
<div style="display: flex;background: gray;padding: 20px 200px 200px;">
<vot-block style="display: flex;background: gray;padding: 20px 200px 200px;">
<SegmentedButton {...args} />
</div>
</vot-block>
),
} satisfies Meta<typeof SegmentedButton>;
@ -137,9 +137,9 @@ export const SegmentedButtonAsColumn: Story = {
tooltipPos: "right",
},
render: (args) => (
<div style="display: flex;background: gray;padding: 200px;padding-left: 20px;">
<vot-block style="display: flex;background: gray;padding: 200px;padding-left: 20px;">
<SegmentedButton {...args} />
</div>
</vot-block>
),
};

View file

@ -11,12 +11,12 @@ import "./SegmentedButton.scss";
import { localizationProvider } from "../../localization/localizationProvider";
import { setSettings, settings } from "../../stores/settings";
import type { Direction, Status } from "../../types/components/votButton";
import { isTouchFirstInput } from "../../utils/inputDevice";
import { RawButton } from "../Button/RawButton";
import {
isKeyboardActivation,
isPrimaryPointerAction,
} from "../../ui/components/componentShared";
import { isTouchFirstInput } from "../../utils/inputDevice";
import { RawButton } from "../Button/RawButton";
} from "../componentShared";
import { ChevronIcon } from "../Icons/ChevronIcon";
import { MenuIcon } from "../Icons/MenuIcon";
import { PiPIcon } from "../Icons/PiPIcon";

View file

@ -5,9 +5,9 @@ import { SegmentedButtonMenu } from "./SegmentedButtonMenu";
const meta = {
component: SegmentedButtonMenu,
render: (args) => (
<div style="display: flex;background: gray;padding: 20px 200px 200px;">
<vot-block style="display: flex;background: gray;padding: 20px 200px 200px;">
<SegmentedButtonMenu {...args} />
</div>
</vot-block>
),
} satisfies Meta<typeof SegmentedButtonMenu>;

View file

@ -1 +1,319 @@
@use "../../styles/components/voice-popover.scss";
@use "../mixins" as shared;
.vot-voice-icon {
display: block;
overflow: visible;
.vot-eq-bar {
transform-origin: 50% 100%;
transform-box: fill-box;
transform: scaleY(1);
}
&--standard .vot-eq-bar {
fill: rgba(var(--vot-onsurface-rgb, 227, 227, 227), 0.4);
}
&--live .vot-eq-bar {
fill: #e040a0;
}
}
.vot-voice-popover {
--vot-helper-surface-rgb: var(--vot-surface-rgb, 32, 33, 36);
--vot-helper-surface: rgb(var(--vot-helper-surface-rgb));
--vot-helper-onsurface-rgb: var(--vot-onsurface-rgb, 227, 227, 227);
--vot-helper-onsurface: rgba(var(--vot-helper-onsurface-rgb), 0.87);
--vot-helper-onsurface-secondary: rgba(var(--vot-helper-onsurface-rgb), 0.55);
--vot-voice-active-standard-bg: rgba(
var(--vot-primary-rgb, 139, 180, 245),
0.1
);
--vot-voice-active-standard-fg: rgb(var(--vot-primary-rgb, 139, 180, 245));
--vot-voice-active-live-bg: rgba(224, 64, 160, 0.1);
--vot-voice-active-live-fg: #e040a0;
position: absolute;
top: 0;
left: 0;
z-index: 2147483647;
display: block;
background: var(--vot-helper-surface);
border: 1px solid var(--vot-border-color) !important;
border-radius: var(--vot-radius-m) !important;
box-shadow: var(--vot-shadow-2) !important;
overflow: hidden;
min-width: 230px;
max-width: var(--vot-voice-popover-max-width, 310px);
max-height: var(--vot-voice-popover-max-height, calc(100vh - 16px));
width: max-content;
cursor: default;
user-select: none;
overflow-y: auto;
overscroll-behavior: contain;
transform-origin: top left;
@include shared.vot-font-family;
text-align: left !important;
&,
& * {
box-sizing: border-box !important;
}
opacity: 0;
transform: none;
visibility: hidden;
pointer-events: none;
transition: none;
&[hidden] {
display: none !important;
}
&:not([hidden]),
&.is-open,
&[aria-hidden="false"] {
opacity: 1;
transform: none;
visibility: visible;
pointer-events: auto;
}
&[data-placement="top"] {
transform-origin: bottom center;
}
&[data-placement="bottom"] {
transform-origin: top center;
}
&[data-placement="left"] {
transform-origin: right center;
}
&[data-placement="right"] {
transform-origin: left center;
}
&.is-closing {
opacity: 0;
transform: none;
visibility: visible;
pointer-events: none;
}
&__item {
position: relative;
overflow: hidden;
display: flex;
gap: 12px;
padding: 14px 44px 14px 16px !important;
min-height: 62px;
cursor: pointer;
color: var(--vot-helper-onsurface);
outline: none;
transition: none;
&::before {
content: "";
position: absolute;
inset: 0;
opacity: 0;
pointer-events: none;
background: linear-gradient(
180deg,
rgba(var(--vot-helper-onsurface-rgb), 0.045),
rgba(var(--vot-helper-onsurface-rgb), 0.06)
);
transition: none;
}
&:hover,
&:focus-visible {
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.03),
inset 0 -1px 0 rgba(0, 0, 0, 0.04);
}
&:hover::before,
&:focus-visible::before {
opacity: 1;
}
&:focus-visible {
box-shadow: inset 0 0 0 1px
rgba(var(--vot-primary-rgb, 139, 180, 245), 0.18);
}
&::after {
content: "";
position: absolute;
right: 14px;
top: 50%;
width: 18px;
height: 18px;
opacity: 0;
transform: translateY(-50%) scale(0.88);
transition: none;
background-color: currentColor;
-webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z'/%3E%3C/svg%3E")
center / contain no-repeat;
mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z'/%3E%3C/svg%3E")
center / contain no-repeat;
}
&--active {
font-weight: 500 !important;
&::after {
opacity: 1;
transform: translateY(-50%) scale(1);
}
}
}
&__item[data-voice="standard"].vot-voice-popover__item--active {
background-color: var(--vot-voice-active-standard-bg);
color: var(--vot-voice-active-standard-fg);
.vot-voice-popover__item-title {
color: inherit;
}
.vot-voice-icon--standard .vot-eq-bar {
fill: currentColor;
}
}
&__item[data-voice="live"].vot-voice-popover__item--active {
background-color: var(--vot-voice-active-live-bg);
color: var(--vot-voice-active-live-fg);
.vot-voice-popover__item-title {
color: inherit;
}
}
&__item-icon {
font-size: 20px;
}
&__item-text {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
&__item-title {
font-size: 15px;
font-weight: 400 !important;
line-height: 1.3;
color: inherit;
white-space: nowrap;
transition: none;
}
&__item-subtitle {
font-size: 12px;
line-height: 1.4;
color: var(--vot-helper-onsurface-secondary);
white-space: normal;
}
&__divider {
height: 1px;
background: var(--vot-border-color);
margin: 0 !important;
}
}
@media (prefers-reduced-motion: reduce) {
.vot-voice-icon .vot-eq-bar {
animation: none !important;
transform: scaleY(1) !important;
}
.vot-voice-popover,
.vot-voice-popover *,
.vot-voice-popover::before,
.vot-voice-popover::after {
transition: none !important;
animation: none !important;
}
.vot-voice-popover {
transform: none !important;
}
.vot-voice-popover.is-closing,
.vot-voice-popover:not([hidden]),
.vot-voice-popover.is-open,
.vot-voice-popover[aria-hidden="false"] {
transform: none !important;
}
}

View file

@ -1,2 +1,67 @@
@use "../Button/DetailsButton.scss";
@use "../../styles/components/settings.scss";
.vot-settings-section {
border: 1px solid var(--vot-border-color);
border-radius: var(--vot-radius-l);
padding: var(--vot-space-2);
background: rgba(var(--vot-helper-onsurface-rgb), 0.03);
display: flex;
flex-direction: column;
// Avoid `gap` for predictable layout across host pages.
& > * {
margin: 0 !important;
}
// Spacing between header and content.
& > * + * {
margin-top: var(--vot-space-2) !important;
}
}
.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 {
transition: transform var(--vot-duration-medium) var(--vot-easing-standard);
}
.vot-settings-section__header[data-open="true"] .vot-details-arrow-icon {
transform: scale(1.25) rotate(0deg);
}
.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.
// We still use a "comfort" width so dropdown pills are consistent.
--vot-settings-control-width: 200px;
// Slightly tighter vertical rhythm for long settings pages.
--vot-settings-row-gap: var(--vot-space-2);
display: flex;
flex-direction: column;
gap: var(--vot-settings-row-gap);
padding: 0 var(--vot-space-1) var(--vot-space-1);
& > * {
margin: 0 !important;
}
// Consistent padding + full-row hit area.
// IMPORTANT: never override component `display` values here. Some controls
// (hotkey, select, slider) rely on flex/grid internally.
// Apply only to *direct* children.
// Some rows are composite controls (e.g. Select/Slider with a checkbox as
// the label element). Styling nested `.vot-switch` would add double
// padding and break alignment.
& > .vot-switch,
& > .vot-hotkey,
& > .vot-select,
& > .vot-slider-wrapper {
width: 100% !important;
padding: var(--vot-space-1);
box-sizing: border-box;
}
}

View file

@ -10,9 +10,9 @@ export default meta;
type Story = StoryObj<typeof meta>;
const SectionChildren = () => (
<p>
<vot-block>
Hello <b>World</b>!
</p>
</vot-block>
);
export const SettingsSectionDefault: Story = {

View file

@ -240,7 +240,7 @@ export function SettingsTranslationSection(
>
{localizationProvider.get("VOTTranslationTextService")}
<br />
<vot-block class="vot-select_new-label__description">
<vot-block class="vot-select-label__description">
{localizationProvider.get("VOTNotAffectToVoice")}
</vot-block>
</Select>

View file

@ -37,9 +37,11 @@ function SubtitleTokenInfo(props: {
.replace("{0}", props.translationService)}
</vot-block>
<vot-block class="vot-subtitles-info-title">
<span class="vot-subtitles-info-source">{props.source}</span>
<span class="vot-subtitles-info-divider">—</span>
<span class="vot-subtitles-info-header">{props.header()}</span>
<vot-block class="vot-subtitles-info-source">{props.source}</vot-block>
<vot-block class="vot-subtitles-info-divider">—</vot-block>
<vot-block class="vot-subtitles-info-header">
{props.header()}
</vot-block>
</vot-block>
<vot-block class="vot-subtitles-info-context">
{props.context()}

View file

@ -1 +1,229 @@
@use "../../styles/components/textfield.scss";
.vot-textfield {
--vot-helper-theme: rgb(
var(--vot-theme-rgb, var(--vot-primary-rgb, 33, 150, 243))
) !important;
--vot-helper-safari1: rgba(
var(--vot-onsurface-rgb, 0, 0, 0),
0.38
) !important;
--vot-helper-safari2: rgba(var(--vot-onsurface-rgb, 0, 0, 0), 0.6) !important;
--vot-helper-safari3: rgba(
var(--vot-onsurface-rgb, 0, 0, 0),
0.87
) !important;
position: relative !important;
display: inline-block;
padding-top: 6px !important;
font-family: var(
--vot-font-family,
"Roboto",
"Segoe UI",
system-ui,
sans-serif
) !important;
font-size: 16px !important;
line-height: 1.5 !important;
text-align: start !important;
& > :is(input, textarea) {
box-sizing: border-box !important;
margin: 0 !important;
border-style: solid !important;
border-width: 1px !important;
border-color: transparent var(--vot-helper-safari2)
var(--vot-helper-safari2) !important;
border-radius: 4px !important;
padding: 15px 13px 15px !important;
width: 100% !important;
height: inherit !important;
color: rgba(var(--vot-onsurface-rgb, 0, 0, 0), 0.87) !important;
-webkit-text-fill-color: currentColor !important; /* Safari */
background-color: transparent !important;
box-shadow:
inset 1px 0 transparent,
inset -1px 0 transparent,
inset 0 -1px transparent !important;
font-family: inherit !important;
font-size: inherit !important;
line-height: inherit !important;
caret-color: var(--vot-helper-theme) !important;
transition:
border 0.2s,
box-shadow 0.2s !important;
&:not(:focus):not(
:is(.vot-show-placeholder, .vot-show-placeholer)
)::placeholder {
color: transparent !important;
}
&:not(:focus) {
&:placeholder-shown {
border-top-color: var(--vot-helper-safari2) !important;
}
}
& + .vot-textfield__label {
position: absolute !important;
top: 0 !important;
left: 0 !important;
display: flex !important;
width: 100% !important;
max-height: 100% !important;
color: rgba(var(--vot-onsurface-rgb, 0, 0, 0), 0.6) !important;
font-size: 75% !important;
font-family: inherit;
line-height: 15px !important;
cursor: text !important;
transition:
color 0.2s,
font-size 0.2s,
line-height 0.2s !important;
pointer-events: none !important;
}
&:not(:focus):placeholder-shown + .vot-textfield__label {
font-size: inherit !important;
line-height: 68px !important;
}
}
/* Corners */
& > input,
& > textarea {
& + .vot-textfield__label::before,
& + .vot-textfield__label::after {
content: "" !important;
display: block !important;
box-sizing: border-box !important;
margin-top: 6px !important;
border-top: solid 1px var(--vot-helper-safari2) !important;
min-width: 10px !important;
height: 8px !important;
pointer-events: none !important;
box-shadow: inset 0 1px transparent !important;
transition:
border 0.2s,
box-shadow 0.2s !important;
}
& + .vot-textfield__label::before {
margin-right: 4px !important;
border-left: solid 1px transparent !important;
border-radius: 4px 0 !important;
}
& + .vot-textfield__label::after {
flex-grow: 1 !important;
margin-left: 4px !important;
border-right: solid 1px transparent !important;
border-radius: 0 4px !important;
}
&:is(.vot-show-placeholder, .vot-show-placeholer) + .vot-textfield__label {
&::before {
margin-right: 0 !important;
}
&::after {
margin-left: 0 !important;
}
}
&:is(:not(:focus):placeholder-shown) {
& + .vot-textfield__label::before,
& + .vot-textfield__label::after {
border-top-color: transparent !important;
}
}
}
& > textarea {
resize: none !important;
}
&:hover {
& > input:not(:disabled),
& > textarea:not(:disabled) {
border-color: transparent var(--vot-helper-safari3)
var(--vot-helper-safari3) !important;
& + .vot-textfield__label::before,
& + .vot-textfield__label::after {
border-top-color: var(--vot-helper-safari3) !important;
}
&:not(:focus):placeholder-shown {
border-color: var(--vot-helper-safari3) !important;
}
}
}
& > input:focus,
& > textarea:focus {
border-color: transparent var(--vot-helper-theme) var(--vot-helper-theme) !important;
box-shadow:
inset 1px 0 var(--vot-helper-theme),
inset -1px 0 var(--vot-helper-theme),
inset 0 -1px var(--vot-helper-theme) !important;
outline: none !important;
& + .vot-textfield__label {
color: var(--vot-helper-theme) !important;
&::before,
&::after {
border-top-color: var(--vot-helper-theme) !important;
box-shadow: inset 0 1px var(--vot-helper-theme) !important;
}
}
}
& > input:disabled,
& > textarea:disabled {
&,
& + .vot-textfield__label {
border-color: transparent var(--vot-helper-safari1)
var(--vot-helper-safari1) !important;
color: rgba(var(--vot-onsurface-rgb, 0, 0, 0), 0.38) !important;
pointer-events: none !important;
}
& + .vot-textfield__label {
&::before,
&::after {
border-top-color: var(--vot-helper-safari1) !important;
}
}
&:placeholder-shown {
&,
& + .vot-textfield__label {
border-top-color: var(--vot-helper-safari1) !important;
}
& + .vot-textfield__label {
&::before,
&::after {
border-top-color: transparent !important;
}
}
}
}
}
/* Faster transition in Safari for less noticable fractional font-size issue */
@media not all and (min-resolution: 0.001dpcm) {
@supports (-webkit-appearance: none) {
.vot-textfield > input,
.vot-textfield > input + .vot-textfield__label,
.vot-textfield > textarea,
.vot-textfield > textarea + .vot-textfield__label,
.vot-textfield > input + .vot-textfield__label::before,
.vot-textfield > input + .vot-textfield__label::after,
.vot-textfield > textarea + .vot-textfield__label::before,
.vot-textfield > textarea + .vot-textfield__label::after {
transition-duration: 0.1s !important;
}
}
}

View file

@ -77,9 +77,13 @@ export function Textfield(props: TextfieldProps): JSX.Element {
<Show when={finalProps.multiline} fallback={<input {...common} />}>
<textarea {...common} />
</Show>
<span ref={finalProps.labelRef} id={labelId}>
<vot-block
ref={finalProps.labelRef}
id={labelId}
class="vot-textfield__label"
>
{finalProps.labelText}
</span>
</vot-block>
</vot-block>
);
}

View file

@ -1,4 +1,4 @@
@use "../../styles/mixins" as shared;
@use "../mixins" as shared;
.vot-menu {
--vot-helper-surface-rgb: var(--vot-surface-rgb, 255, 255, 255);

View file

@ -5,9 +5,9 @@ import { Menu } from "./Menu";
const meta = {
component: Menu,
render: (args) => (
<div style="display: flex;background: gray;padding: 20px 200px 200px;">
<vot-block style="display: flex;background: gray;padding: 20px 200px 200px;">
<Menu {...args} />
</div>
</vot-block>
),
} satisfies Meta<typeof Menu>;
@ -17,14 +17,14 @@ type Story = StoryObj<typeof meta>;
export const MenuDefault: Story = {
args: {
title: "Settings",
children: <div>hello world</div>,
children: <vot-block>hello world</vot-block>,
},
};
export const MenuWithHeaderAndFooter: Story = {
args: {
title: "Settings",
children: <div>hello world</div>,
children: <vot-block>hello world</vot-block>,
headerChildren: "header",
footerChildren: "footer",
},

View file

@ -11,6 +11,6 @@ type Story = StoryObj<typeof meta>;
export const OrBlockDefault: Story = {
args: {
children: <p>hello world</p>,
children: <vot-block>hello world</vot-block>,
},
};

View file

@ -1,4 +1,4 @@
@use "../../styles/mixins" as shared;
@use "../mixins" as shared;
.vot-overlay {
--vot-overlay-default-top: 0;

View file

@ -0,0 +1,7 @@
export function isPrimaryPointerAction(event: PointerEvent): boolean {
return event.isPrimary && event.button === 0;
}
export function isKeyboardActivation(event: KeyboardEvent): boolean {
return event.key === "Enter" || event.key === " ";
}

View file

@ -88,6 +88,56 @@ vot-block {
}
// Keep host-page typography rules from overriding VOT font weights.
vot-block * {
font-weight: inherit !important;
}
vot-block * {
font-weight: inherit !important;
}
.vot-portal-local,
.vot-subtitles-widget {
isolation: isolate;
}
vot-block:focus,
vot-block *:focus {
outline: none !important;
box-shadow: none !important;
}
html.vot-keyboard-nav vot-block:focus-visible,
html.vot-keyboard-nav vot-block *:focus-visible {
box-shadow: var(--vot-focus-ring), var(--vot-focus-ring-offset) !important;
}
@supports not selector(:focus-visible) {
html.vot-keyboard-nav vot-block:focus,
html.vot-keyboard-nav vot-block *:focus {
box-shadow: var(--vot-focus-ring), var(--vot-focus-ring-offset) !important;
}
}
@media (prefers-reduced-motion: reduce) {
.vot-portal-local *,
.vot-portal *,
.vot-subtitles-widget * {
transition-duration: 0.001ms !important;
animation-duration: 0.001ms !important;
animation-iteration-count: 1 !important;
scroll-behavior: auto !important;
}
}
.vot-portal {
display: contents;
&-local {
position: fixed;
left: 0;
top: 0;
z-index: 2147483647;
}
}
.vot-segmented-button,
.vot-menu {
pointer-events: auto;
}

View file

@ -1,4 +1,4 @@
import type { OverlayViewControls } from "../views/OverlayView";
import type { OverlayViewControls } from "../components/OverlayView/OverlayView";
export type LifecycleTranslationResetHost = {
videoData?: unknown;

View file

@ -1,12 +1,11 @@
import type { VideoDataSubtitle } from "@vot.js/core/types/client";
import type { ServiceConf, VideoService } from "@vot.js/ext/types/service";
import type { RequestLang, ResponseLang } from "@vot.js/shared/types/data";
import type { OverlayViewControls } from "../components/OverlayView/OverlayView";
import type { StorageData } from "../types/storage";
import debug from "../utils/debug";
import { containsCrossShadow } from "../utils/dom";
import type { VideoData } from "../videoHandler/shared";
import type { OverlayViewControls } from "../views/OverlayView";
import { findConnectedContainerBySelector } from "./containerResolution";
import { hideLifecycleOverlay, resetAndHideLifecycle } from "./lifecycleShared";

View file

@ -1,43 +0,0 @@
@use "./mixins" as button;
.vot-button {
--vot-helper-theme: var(
--vot-theme-rgb,
var(--vot-primary-rgb, 33, 150, 243)
);
--vot-helper-ontheme: var(
--vot-ontheme-rgb,
var(--vot-onprimary-rgb, 255, 255, 255)
);
@include button.vot-button-base(
$radius: var(--vot-radius-s),
$padding: 0 var(--vot-space-4),
$min-width: 64px,
$height: 36px,
$line-height: 36px
);
border: none !important;
color: rgb(var(--vot-helper-ontheme));
background-color: rgb(var(--vot-helper-theme));
box-shadow: var(--vot-shadow-1);
transition: box-shadow var(--vot-duration-medium) var(--vot-easing-standard);
@include button.vot-button-layers(rgb(var(--vot-helper-ontheme)));
@include button.vot-button-layer-hover(0.08);
@include button.vot-button-layer-active(0.32);
&:hover {
box-shadow: var(--vot-shadow-2);
}
&:active {
box-shadow: var(--vot-shadow-2);
}
&[disabled="true"] {
background-color: rgba(var(--vot-onsurface-rgb, 0, 0, 0), 0.12);
color: rgba(var(--vot-onsurface-rgb, 0, 0, 0), 0.38);
box-shadow: none;
cursor: initial;
@include button.vot-button-layer-reset-disabled;
}
}

View file

@ -1,36 +0,0 @@
@use "./mixins" as button;
.vot-icon-button {
--vot-helper-onsurface: rgba(var(--vot-onsurface-rgb, 0, 0, 0), 0.87);
@include button.vot-button-base(
$radius: 50%,
$padding: 0,
$min-width: 36px,
$height: 36px,
$line-height: 36px,
$margin: 0 !important
);
border: none !important;
width: 36px;
fill: var(--vot-helper-onsurface);
color: var(--vot-helper-onsurface);
background-color: transparent;
@include button.vot-button-layers(var(--vot-helper-onsurface));
@include button.vot-button-layer-hover(0.04);
@include button.vot-button-layer-active(0.32);
&[disabled="true"],
&[aria-disabled="true"] {
background-color: transparent;
color: rgba(var(--vot-onsurface-rgb, 0, 0, 0), 0.38);
fill: rgba(var(--vot-onsurface-rgb, 0, 0, 0), 0.38);
cursor: initial;
@include button.vot-button-layer-reset-disabled;
}
& svg {
fill: inherit;
stroke: inherit;
width: 24px;
height: 36px;
}
}

View file

@ -1,30 +0,0 @@
@use "./mixins" as button;
.vot-outlined-button {
--vot-helper-theme: var(
--vot-theme-rgb,
var(--vot-primary-rgb, 33, 150, 243)
);
@include button.vot-button-base(
$radius: var(--vot-radius-s),
$padding: 0 var(--vot-space-4),
$min-width: 64px,
$height: 36px,
$line-height: 34px,
$margin: 0 !important
);
border: solid 1px var(--vot-border-color) !important;
color: rgb(var(--vot-helper-theme));
background-color: transparent;
@include button.vot-button-layers(rgb(var(--vot-helper-theme)));
@include button.vot-button-layer-hover(0.04);
@include button.vot-button-layer-active(0.16);
&[disabled="true"],
&[aria-disabled="true"] {
background-color: transparent;
color: rgba(var(--vot-onsurface-rgb, 0, 0, 0), 0.38);
cursor: initial;
@include button.vot-button-layer-reset-disabled;
}
}

View file

@ -1,30 +0,0 @@
@use "./mixins" as button;
.vot-text-button {
--vot-helper-theme: var(
--vot-theme-rgb,
var(--vot-primary-rgb, 33, 150, 243)
);
@include button.vot-button-base(
$radius: var(--vot-radius-s),
$padding: 0 var(--vot-space-2),
$min-width: 64px,
$height: 36px,
$line-height: 36px,
$margin: 0 !important
);
border: none !important;
color: rgb(var(--vot-helper-theme));
background-color: transparent;
@include button.vot-button-layers(rgb(var(--vot-helper-theme)));
@include button.vot-button-layer-hover(0.04);
@include button.vot-button-layer-active(0.16);
&[disabled="true"],
&[aria-disabled="true"] {
background-color: transparent;
color: rgba(var(--vot-onsurface-rgb, 0, 0, 0), 0.38);
cursor: initial;
@include button.vot-button-layer-reset-disabled;
}
}

View file

@ -1,38 +0,0 @@
@use "../mixins" as shared;
.vot-details {
display: flex;
justify-content: space-between;
align-items: center;
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;
margin: -0.5em !important;
line-height: 1.5;
text-align: start;
cursor: pointer;
user-select: none;
transition: background var(--vot-duration-medium) var(--vot-easing-standard);
&-arrow-icon {
width: 20px;
height: 32px;
display: flex;
justify-content: center;
align-items: center;
transform: scale(1.25) rotate(-90deg);
fill: rgba(var(--vot-onsurface-rgb), 0.87);
font-size: 24px;
}
&: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

@ -1,71 +0,0 @@
.vot-settings-section {
border: 1px solid var(--vot-border-color);
border-radius: var(--vot-radius-l);
padding: var(--vot-space-2);
background: rgba(var(--vot-helper-onsurface-rgb), 0.03);
display: flex;
flex-direction: column;
// Avoid `gap` for predictable layout across host pages.
& > * {
margin: 0 !important;
}
// Spacing between header and content.
& > * + * {
margin-top: var(--vot-space-2) !important;
}
}
.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 {
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 {
transform: scale(1.25) rotate(0deg);
}
.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.
// We still use a "comfort" width so dropdown pills are consistent.
--vot-settings-control-width: 200px;
// Slightly tighter vertical rhythm for long settings pages.
--vot-settings-row-gap: var(--vot-space-2);
display: flex;
flex-direction: column;
gap: var(--vot-settings-row-gap);
padding: 0 var(--vot-space-1) var(--vot-space-1);
& > * {
margin: 0 !important;
}
// Consistent padding + full-row hit area.
// IMPORTANT: never override component `display` values here. Some controls
// (hotkey, select, slider) rely on flex/grid internally.
// Apply only to *direct* children.
// Some rows are composite controls (e.g. Select/Slider with a checkbox as
// the label element). Styling nested `.vot-checkbox` would add double
// padding and break alignment.
& > .vot-checkbox,
& > .vot-hotkey,
& > .vot-select,
& > .vot-slider {
width: 100% !important;
padding: var(--vot-space-1);
box-sizing: border-box;
}
}

View file

@ -1,229 +0,0 @@
.vot-textfield {
--vot-helper-theme: rgb(
var(--vot-theme-rgb, var(--vot-primary-rgb, 33, 150, 243))
) !important;
--vot-helper-safari1: rgba(
var(--vot-onsurface-rgb, 0, 0, 0),
0.38
) !important;
--vot-helper-safari2: rgba(var(--vot-onsurface-rgb, 0, 0, 0), 0.6) !important;
--vot-helper-safari3: rgba(
var(--vot-onsurface-rgb, 0, 0, 0),
0.87
) !important;
position: relative !important;
display: inline-block;
padding-top: 6px !important;
font-family: var(
--vot-font-family,
"Roboto",
"Segoe UI",
system-ui,
sans-serif
) !important;
font-size: 16px !important;
line-height: 1.5 !important;
text-align: start !important;
& > :is(input, textarea) {
box-sizing: border-box !important;
margin: 0 !important;
border-style: solid !important;
border-width: 1px !important;
border-color: transparent var(--vot-helper-safari2)
var(--vot-helper-safari2) !important;
border-radius: 4px !important;
padding: 15px 13px 15px !important;
width: 100% !important;
height: inherit !important;
color: rgba(var(--vot-onsurface-rgb, 0, 0, 0), 0.87) !important;
-webkit-text-fill-color: currentColor !important; /* Safari */
background-color: transparent !important;
box-shadow:
inset 1px 0 transparent,
inset -1px 0 transparent,
inset 0 -1px transparent !important;
font-family: inherit !important;
font-size: inherit !important;
line-height: inherit !important;
caret-color: var(--vot-helper-theme) !important;
transition:
border 0.2s,
box-shadow 0.2s !important;
&:not(:focus):not(
:is(.vot-show-placeholder, .vot-show-placeholer)
)::placeholder {
color: transparent !important;
}
&:not(:focus) {
&:placeholder-shown {
border-top-color: var(--vot-helper-safari2) !important;
}
}
& + span {
position: absolute !important;
top: 0 !important;
left: 0 !important;
display: flex !important;
width: 100% !important;
max-height: 100% !important;
color: rgba(var(--vot-onsurface-rgb, 0, 0, 0), 0.6) !important;
font-size: 75% !important;
font-family: inherit;
line-height: 15px !important;
cursor: text !important;
transition:
color 0.2s,
font-size 0.2s,
line-height 0.2s !important;
pointer-events: none !important;
}
&:not(:focus):placeholder-shown + span {
font-size: inherit !important;
line-height: 68px !important;
}
}
/* Corners */
& > input,
& > textarea {
& + span::before,
& + span::after {
content: "" !important;
display: block !important;
box-sizing: border-box !important;
margin-top: 6px !important;
border-top: solid 1px var(--vot-helper-safari2) !important;
min-width: 10px !important;
height: 8px !important;
pointer-events: none !important;
box-shadow: inset 0 1px transparent !important;
transition:
border 0.2s,
box-shadow 0.2s !important;
}
& + span::before {
margin-right: 4px !important;
border-left: solid 1px transparent !important;
border-radius: 4px 0 !important;
}
& + span::after {
flex-grow: 1 !important;
margin-left: 4px !important;
border-right: solid 1px transparent !important;
border-radius: 0 4px !important;
}
&:is(.vot-show-placeholder, .vot-show-placeholer) + span {
&::before {
margin-right: 0 !important;
}
&::after {
margin-left: 0 !important;
}
}
&:is(:not(:focus):placeholder-shown) {
& + span::before,
& + span::after {
border-top-color: transparent !important;
}
}
}
& > textarea {
resize: none !important;
}
&:hover {
& > input:not(:disabled),
& > textarea:not(:disabled) {
border-color: transparent var(--vot-helper-safari3)
var(--vot-helper-safari3) !important;
& + span::before,
& + span::after {
border-top-color: var(--vot-helper-safari3) !important;
}
&:not(:focus):placeholder-shown {
border-color: var(--vot-helper-safari3) !important;
}
}
}
& > input:focus,
& > textarea:focus {
border-color: transparent var(--vot-helper-theme) var(--vot-helper-theme) !important;
box-shadow:
inset 1px 0 var(--vot-helper-theme),
inset -1px 0 var(--vot-helper-theme),
inset 0 -1px var(--vot-helper-theme) !important;
outline: none !important;
& + span {
color: var(--vot-helper-theme) !important;
&::before,
&::after {
border-top-color: var(--vot-helper-theme) !important;
box-shadow: inset 0 1px var(--vot-helper-theme) !important;
}
}
}
& > input:disabled,
& > textarea:disabled {
&,
& + span {
border-color: transparent var(--vot-helper-safari1)
var(--vot-helper-safari1) !important;
color: rgba(var(--vot-onsurface-rgb, 0, 0, 0), 0.38) !important;
pointer-events: none !important;
}
& + span {
&::before,
&::after {
border-top-color: var(--vot-helper-safari1) !important;
}
}
&:placeholder-shown {
&,
& + span {
border-top-color: var(--vot-helper-safari1) !important;
}
& + span {
&::before,
&::after {
border-top-color: transparent !important;
}
}
}
}
}
/* Faster transition in Safari for less noticable fractional font-size issue */
@media not all and (min-resolution: 0.001dpcm) {
@supports (-webkit-appearance: none) {
.vot-textfield > input,
.vot-textfield > input + span,
.vot-textfield > textarea,
.vot-textfield > textarea + span,
.vot-textfield > input + span::before,
.vot-textfield > input + span::after,
.vot-textfield > textarea + span::before,
.vot-textfield > textarea + span::after {
transition-duration: 0.1s !important;
}
}
}

View file

@ -1,116 +0,0 @@
@use "../mixins" as shared;
.vot-tooltip {
--vot-helper-theme-rgb: var(--vot-onsurface-rgb, 0, 0, 0);
--vot-helper-theme: rgba(var(--vot-helper-theme-rgb), 0.87);
--vot-helper-ondialog: rgb(var(--vot-ondialog-rgb, 37, 38, 40));
--vot-helper-border: rgb(var(--vot-tooltip-border, 69, 69, 69));
overflow: hidden;
user-select: none;
display: flex;
align-items: center;
background: rgb(var(--vot-surface-rgb, 255, 255, 255));
color: var(--vot-helper-theme);
fill: var(--vot-helper-theme);
border-radius: 4px !important;
@include shared.vot-font-family;
font-size: 14px;
line-height: 1.5;
cursor: default;
transition: opacity 0.5s;
position: absolute;
z-index: 2147483647;
inset: 0;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12);
width: max-content;
max-width: calc(100vw - 10px);
height: max-content;
padding: 4px 8px !important;
opacity: 0;
&[data-trigger="click"] {
user-select: text;
}
&[data-mode="follow"] {
align-items: stretch;
pointer-events: auto;
user-select: text;
&,
& * {
user-select: text !important;
}
}
&.vot-tooltip-bordered {
border: 1px solid var(--vot-helper-border);
}
& * {
box-sizing: border-box !important;
font-family: inherit !important;
}
&.vot-tooltip--subtitles-info {
padding: 0 !important;
overflow: visible;
background: transparent !important;
box-shadow: none !important;
border-radius: 18px !important;
.vot-subtitles-info {
display: flex;
flex-direction: column;
gap: 8px;
width: max-content;
max-width: min(420px, calc(100vw - 24px));
padding: 14px 16px !important;
border-radius: 16px !important;
background: rgba(31, 32, 36, 0.96) !important;
color: rgba(255, 255, 255, 0.92) !important;
border: 1px solid rgba(255, 255, 255, 0.08) !important;
box-shadow:
0 12px 30px rgba(0, 0, 0, 0.28),
0 2px 6px rgba(0, 0, 0, 0.22) !important;
font-size: 13px !important;
line-height: 1.35 !important;
letter-spacing: 0 !important;
}
.vot-subtitles-info-title {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
min-width: 0;
font-size: 15px !important;
line-height: 1.35 !important;
font-weight: 600 !important;
}
.vot-subtitles-info-source {
color: rgba(255, 255, 255, 0.94) !important;
overflow-wrap: anywhere;
}
.vot-subtitles-info-divider {
color: rgba(255, 255, 255, 0.42) !important;
font-weight: 500 !important;
}
.vot-subtitles-info-header {
color: rgb(var(--vot-primary-rgb, 255, 83, 151)) !important;
overflow-wrap: anywhere;
}
.vot-subtitles-info-context {
max-width: 100%;
color: rgba(255, 255, 255, 0.68) !important;
font-size: 13px !important;
line-height: 1.45 !important;
font-weight: 400 !important;
overflow-wrap: anywhere;
}
}
}

View file

@ -1,319 +0,0 @@
@use "../mixins" as shared;
.vot-voice-icon {
display: block;
overflow: visible;
.vot-eq-bar {
transform-origin: 50% 100%;
transform-box: fill-box;
transform: scaleY(1);
}
&--standard .vot-eq-bar {
fill: rgba(var(--vot-onsurface-rgb, 227, 227, 227), 0.4);
}
&--live .vot-eq-bar {
fill: #e040a0;
}
}
.vot-voice-popover {
--vot-helper-surface-rgb: var(--vot-surface-rgb, 32, 33, 36);
--vot-helper-surface: rgb(var(--vot-helper-surface-rgb));
--vot-helper-onsurface-rgb: var(--vot-onsurface-rgb, 227, 227, 227);
--vot-helper-onsurface: rgba(var(--vot-helper-onsurface-rgb), 0.87);
--vot-helper-onsurface-secondary: rgba(var(--vot-helper-onsurface-rgb), 0.55);
--vot-voice-active-standard-bg: rgba(
var(--vot-primary-rgb, 139, 180, 245),
0.1
);
--vot-voice-active-standard-fg: rgb(var(--vot-primary-rgb, 139, 180, 245));
--vot-voice-active-live-bg: rgba(224, 64, 160, 0.1);
--vot-voice-active-live-fg: #e040a0;
position: absolute;
top: 0;
left: 0;
z-index: 2147483647;
display: block;
background: var(--vot-helper-surface);
border: 1px solid var(--vot-border-color) !important;
border-radius: var(--vot-radius-m) !important;
box-shadow: var(--vot-shadow-2) !important;
overflow: hidden;
min-width: 230px;
max-width: var(--vot-voice-popover-max-width, 310px);
max-height: var(--vot-voice-popover-max-height, calc(100vh - 16px));
width: max-content;
cursor: default;
user-select: none;
overflow-y: auto;
overscroll-behavior: contain;
transform-origin: top left;
@include shared.vot-font-family;
text-align: left !important;
&,
& * {
box-sizing: border-box !important;
}
opacity: 0;
transform: none;
visibility: hidden;
pointer-events: none;
transition: none;
&[hidden] {
display: none !important;
}
&:not([hidden]),
&.is-open,
&[aria-hidden="false"] {
opacity: 1;
transform: none;
visibility: visible;
pointer-events: auto;
}
&[data-placement="top"] {
transform-origin: bottom center;
}
&[data-placement="bottom"] {
transform-origin: top center;
}
&[data-placement="left"] {
transform-origin: right center;
}
&[data-placement="right"] {
transform-origin: left center;
}
&.is-closing {
opacity: 0;
transform: none;
visibility: visible;
pointer-events: none;
}
&__item {
position: relative;
overflow: hidden;
display: flex;
gap: 12px;
padding: 14px 44px 14px 16px !important;
min-height: 62px;
cursor: pointer;
color: var(--vot-helper-onsurface);
outline: none;
transition: none;
&::before {
content: "";
position: absolute;
inset: 0;
opacity: 0;
pointer-events: none;
background: linear-gradient(
180deg,
rgba(var(--vot-helper-onsurface-rgb), 0.045),
rgba(var(--vot-helper-onsurface-rgb), 0.06)
);
transition: none;
}
&:hover,
&:focus-visible {
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.03),
inset 0 -1px 0 rgba(0, 0, 0, 0.04);
}
&:hover::before,
&:focus-visible::before {
opacity: 1;
}
&:focus-visible {
box-shadow: inset 0 0 0 1px
rgba(var(--vot-primary-rgb, 139, 180, 245), 0.18);
}
&::after {
content: "";
position: absolute;
right: 14px;
top: 50%;
width: 18px;
height: 18px;
opacity: 0;
transform: translateY(-50%) scale(0.88);
transition: none;
background-color: currentColor;
-webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z'/%3E%3C/svg%3E")
center / contain no-repeat;
mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z'/%3E%3C/svg%3E")
center / contain no-repeat;
}
&--active {
font-weight: 500 !important;
&::after {
opacity: 1;
transform: translateY(-50%) scale(1);
}
}
}
&__item[data-voice="standard"].vot-voice-popover__item--active {
background-color: var(--vot-voice-active-standard-bg);
color: var(--vot-voice-active-standard-fg);
.vot-voice-popover__item-title {
color: inherit;
}
.vot-voice-icon--standard .vot-eq-bar {
fill: currentColor;
}
}
&__item[data-voice="live"].vot-voice-popover__item--active {
background-color: var(--vot-voice-active-live-bg);
color: var(--vot-voice-active-live-fg);
.vot-voice-popover__item-title {
color: inherit;
}
}
&__item-icon {
font-size: 20px;
}
&__item-text {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
&__item-title {
font-size: 15px;
font-weight: 400 !important;
line-height: 1.3;
color: inherit;
white-space: nowrap;
transition: none;
}
&__item-subtitle {
font-size: 12px;
line-height: 1.4;
color: var(--vot-helper-onsurface-secondary);
white-space: normal;
}
&__divider {
height: 1px;
background: var(--vot-border-color);
margin: 0 !important;
}
}
@media (prefers-reduced-motion: reduce) {
.vot-voice-icon .vot-eq-bar {
animation: none !important;
transform: scaleY(1) !important;
}
.vot-voice-popover,
.vot-voice-popover *,
.vot-voice-popover::before,
.vot-voice-popover::after {
transition: none !important;
animation: none !important;
}
.vot-voice-popover {
transform: none !important;
}
.vot-voice-popover.is-closing,
.vot-voice-popover:not([hidden]),
.vot-voice-popover.is-open,
.vot-voice-popover[aria-hidden="false"] {
transform: none !important;
}
}

View file

@ -1,131 +0,0 @@
@use "components/buttons/contained.scss";
@use "components/buttons/outlined.scss";
@use "components/buttons/text.scss";
@use "components/buttons/icon.scss";
@use "components/textfield.scss";
@use "components/details.scss";
@use "components/settings.scss";
@use "components/tooltip.scss";
@use "components/voice-popover.scss";
:root {
--vot-font-family: "Roboto", "Segoe UI", system-ui, sans-serif;
--vot-primary-rgb: 139, 180, 245;
--vot-onprimary-rgb: 32, 33, 36;
--vot-surface-rgb: 32, 33, 36;
--vot-onsurface-rgb: 227, 227, 227;
--vot-subtitles-color: rgb(var(--vot-onsurface-rgb, 227, 227, 227));
--vot-subtitles-passed-color: rgb(var(--vot-primary-rgb, 33, 150, 243));
/*
* UI design tokens (used by menus, dialogs and controls).
*
* NOTE: Subtitle rendering & styling is out of scope for this sprint.
* Keep subtitle variables above unchanged.
*/
--vot-space-1: 4px;
--vot-space-2: 8px;
--vot-space-3: 12px;
--vot-space-4: 16px;
--vot-space-5: 20px;
--vot-space-6: 24px;
--vot-radius-xs: 6px;
--vot-radius-s: 10px;
--vot-radius-m: 14px;
--vot-radius-l: 18px;
--vot-border-color: rgba(var(--vot-onsurface-rgb, 227, 227, 227), 0.14);
--vot-border-color-hover: rgba(var(--vot-onsurface-rgb, 227, 227, 227), 0.22);
--vot-shadow-1: 0 1px 2px rgba(0, 0, 0, 0.18), 0 8px 24px rgba(0, 0, 0, 0.14);
--vot-shadow-2:
0 2px 4px rgba(0, 0, 0, 0.22), 0 12px 32px rgba(0, 0, 0, 0.22);
--vot-duration-fast: 120ms;
--vot-duration-medium: 200ms;
--vot-duration-slow: 320ms;
--vot-easing-standard: cubic-bezier(0.4, 0, 0.2, 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
// ------------------------------
// Create an explicit stacking / blending boundary for overlays.
.vot-portal-local,
.vot-subtitles-widget {
isolation: isolate;
}
// ------------------------------
// Focus handling & interaction polish
// ------------------------------
//
// Requirements:
// - No "halo" focus outlines for pointer interactions (mouse/touch).
// - Keep a visible focus ring for keyboard navigation (Tab) for accessibility.
//
// JS toggles `html.vot-keyboard-nav` only when the user presses Tab.
// See `src/ui.ts`.
// Default: suppress native focus outlines/halos.
// (We will re-introduce an explicit ring only for keyboard navigation below.)
vot-block:focus,
vot-block *:focus {
outline: none !important;
box-shadow: none !important;
}
// Keyboard navigation: show a consistent focus ring.
html.vot-keyboard-nav vot-block:focus-visible,
html.vot-keyboard-nav vot-block *:focus-visible {
box-shadow: var(--vot-focus-ring), var(--vot-focus-ring-offset) !important;
}
// Fallback for browsers without `:focus-visible` support.
@supports not selector(:focus-visible) {
html.vot-keyboard-nav vot-block:focus,
html.vot-keyboard-nav vot-block *:focus {
box-shadow: var(--vot-focus-ring), var(--vot-focus-ring-offset) !important;
}
}
// Respect reduced-motion preference globally.
@media (prefers-reduced-motion: reduce) {
.vot-portal-local *,
.vot-portal *,
.vot-subtitles-widget * {
transition-duration: 0.001ms !important;
animation-duration: 0.001ms !important;
animation-iteration-count: 1 !important;
scroll-behavior: auto !important;
}
}
.vot-portal {
display: contents;
&-local {
position: fixed;
left: 0;
top: 0;
z-index: 2147483647;
}
}
// The overlay mount itself stays click-through so host-page controls remain
// usable around the injected UI. Explicitly re-enable hit-testing for the
// actual interactive overlay surfaces inside the shadow root.
.vot-segmented-button,
.vot-menu {
pointer-events: auto;
}

View file

@ -1,27 +0,0 @@
import type { IntervalIdleChecker } from "../../utils/intervalIdleChecker";
import type { VideoHandler } from "../../VideoHandler";
import type { LanguageSelectKey } from "../components/select";
import type { StorageData } from "../storage";
import type { OverlayMount } from "../uiManager";
export type OverlayViewProps = {
mount: OverlayMount;
data?: Partial<StorageData>;
videoHandler?: VideoHandler;
intervalIdleChecker: IntervalIdleChecker;
};
export type OverlayViewEventMap = {
"click:settings": [];
"click:pip": [];
"click:subtitles": [];
"click:downloadTranslation": [];
"click:downloadSubtitles": [];
"click:translate": [];
"input:videoVolume": [volume: number];
"input:translationVolume": [volume: number];
"select:fromLanguage": [item: LanguageSelectKey];
"select:toLanguage": [item: LanguageSelectKey];
"select:subtitles": [item: string];
"select:voiceType": [useLive: boolean];
};

View file

@ -1,42 +0,0 @@
import type { LangOverride } from "../../localization/localizationProvider";
import type { SubtitleFontFamily } from "../../types/subtitles";
import type { VideoHandler } from "../../VideoHandler";
import type {
Account,
ResponseLanguageSubtitles,
StorageData,
TranslateProxyStatus,
} from "../storage";
import type { TranslateService } from "../translateApis";
export type SettingsViewProps = {
globalPortal: HTMLElement;
data?: Partial<StorageData>;
videoHandler?: VideoHandler;
};
export type SettingsViewEventMap = {
"click:bugReport": [];
"click:resetSettings": [];
"update:account": [account: Partial<Account> | undefined];
"change:autoTranslate": [checked: boolean];
"change:autoSubtitles": [checked: boolean];
"change:showVideoVolume": [checked: boolean];
"change:audioBooster": [checked: boolean];
"change:syncVolume": [checked: boolean];
"change:subtitlesHighlightWords": [checked: boolean];
"change:subtitlesSmartLayout": [checked: boolean];
"select:responseLanguageSubtitles": [item: ResponseLanguageSubtitles];
"select:subtitlesFontFamily": [item: SubtitleFontFamily];
"change:proxyWorkerHost": [value: string];
"change:useNewAudioPlayer": [checked: boolean];
"change:onlyBypassMediaCSP": [checked: boolean];
"change:showPiPButton": [checked: boolean];
"input:subtitlesMaxLength": [value: number];
"input:subtitlesFontSize": [value: number];
"input:subtitlesBackgroundOpacity": [value: number];
"input:autoHideButtonDelay": [value: number];
"select:proxyTranslationStatus": [item: TranslateProxyStatus];
"select:translationTextService": [item: TranslateService];
"select:menuLanguage": [item: LangOverride];
};

View file

@ -1,28 +0,0 @@
export function createDomId(prefix: string): string {
const suffix =
typeof crypto !== "undefined" && "randomUUID" in crypto
? crypto.randomUUID()
: Math.random().toString(36).slice(2);
return `${prefix}-${suffix}`;
}
export function isEventInside(event: Event, element: HTMLElement): boolean {
const target = event.target;
if (target instanceof Node && element.contains(target)) {
return true;
}
return (
typeof event.composedPath === "function" &&
event.composedPath().includes(element)
);
}
export function isPrimaryPointerAction(event: PointerEvent): boolean {
return event.isPrimary && event.button === 0;
}
export function isKeyboardActivation(event: KeyboardEvent): boolean {
return event.key === "Enter" || event.key === " ";
}

View file

@ -18,6 +18,8 @@ import type { VideoHandler } from "../VideoHandler";
import type { VideoData } from "../videoHandler/shared";
import { safeSetPlayerVolume } from "../videoHandler/translationVolume";
import { applyOverlayMountUpdate } from "./mount";
import { OverlayController } from "./overlayController";
import { SettingsController } from "./settingsController";
import {
createShadowMount,
destroyShadowMount,
@ -25,8 +27,6 @@ import {
type ShadowMount,
} from "./shadowMount";
import { handleTranslationButtonCommand } from "./translationCommands";
import { OverlayView } from "./views/overlay";
import { SettingsView } from "./views/settings";
export class UIManager {
mount: OverlayMount;
@ -36,16 +36,15 @@ export class UIManager {
private readonly intervalIdleChecker: IntervalIdleChecker;
data: Partial<StorageData>;
votGlobalPortal?: HTMLElement;
private globalPortalMount?: ShadowMount;
/**
* Contains all elements over video player e.g. button, menu and etc
*/
votOverlayView?: OverlayView;
votOverlayView?: OverlayController;
/**
* Dialog settings menu
*/
votSettingsView?: SettingsView;
votSettingsView?: SettingsController;
constructor({
mount,
@ -59,22 +58,13 @@ export class UIManager {
this.intervalIdleChecker = intervalIdleChecker;
}
get root(): HTMLElement | ShadowRoot {
return this.mount.root;
}
get portalContainer(): HTMLElement {
return this.mount.portalContainer;
}
getSubtitlesMountContainer(): HTMLElement | ShadowRoot {
return this.votOverlayView?.root ?? this.mount.subtitlesMountContainer;
}
isInitialized(): this is {
votGlobalPortal: HTMLElement;
votOverlayView: OverlayView;
votSettingsView: SettingsView;
votOverlayView: OverlayController;
votSettingsView: SettingsController;
} {
return this.initialized;
}
@ -116,17 +106,16 @@ export class UIManager {
this.votOverlayView = undefined;
this.votSettingsView = undefined;
this.globalPortalMount = undefined;
this.votGlobalPortal = undefined;
}
private buildUI() {
this.globalPortalMount = createShadowMount({
const globalPortalMount = createShadowMount({
parent: this.getGlobalPortalHost(this.mount),
rootClasses: ["vot-portal"],
});
this.votGlobalPortal = this.globalPortalMount.root;
this.globalPortalMount = globalPortalMount;
this.votOverlayView = new OverlayView({
this.votOverlayView = new OverlayController({
mount: this.mount,
data: this.data,
videoHandler: this.videoHandler,
@ -136,8 +125,8 @@ export class UIManager {
// (e.g. when changing the menu language).
this.votOverlayView.initUI();
this.votSettingsView = new SettingsView({
globalPortal: this.votGlobalPortal,
this.votSettingsView = new SettingsController({
globalPortal: globalPortalMount.root,
data: this.data,
videoHandler: this.videoHandler,
});
@ -638,7 +627,7 @@ export class UIManager {
async reloadMenu() {
if (!this.votOverlayView?.isInitialized()) {
throw new Error("[VOT] OverlayView isn't initialized");
throw new Error("[VOT] OverlayController isn't initialized");
}
// Preserve overlay state across UI rebuild.
@ -699,7 +688,7 @@ export class UIManager {
async handleTranslationBtnClick() {
if (!this.votOverlayView?.isInitialized()) {
throw new Error("[VOT] OverlayView isn't initialized");
throw new Error("[VOT] OverlayController isn't initialized");
}
await handleTranslationButtonCommand({
@ -725,7 +714,7 @@ export class UIManager {
transformBtn(status: Status, text: string) {
if (!this.votOverlayView?.isInitialized()) {
throw new Error("[VOT] OverlayView isn't initialized");
throw new Error("[VOT] OverlayController isn't initialized");
}
this.votOverlayView.overlayViewControls?.setStatus(status);
@ -748,7 +737,6 @@ export class UIManager {
this.votSettingsView?.release();
if (this.globalPortalMount) destroyShadowMount(this.globalPortalMount);
this.globalPortalMount = undefined;
this.votGlobalPortal = undefined;
this.initialized = false;
return this;

View file

@ -1,30 +1,49 @@
import { FullscreenHelper } from "../../core/fullscreenHelper";
import { setSettings } from "../../stores/settings";
import type { StorageData } from "../../types/storage";
import type { OverlayMount } from "../../types/uiManager";
import type {
OverlayViewEventMap,
OverlayViewProps,
} from "../../types/views/overlay";
import { containsCrossShadow, getDeepActiveElement } from "../../utils/dom";
import { EventImpl } from "../../utils/eventImpl";
import { hasTouchScreen, isTouchFirstInput } from "../../utils/inputDevice";
import type { IntervalIdleChecker } from "../../utils/intervalIdleChecker";
import { votStorage } from "../../utils/storage";
import type { VideoHandler } from "../../VideoHandler";
import {
OverlayView as OverlayViewComponent,
type OverlayViewControls,
} from "../../views/OverlayView";
} from "../components/OverlayView/OverlayView";
import { FullscreenHelper } from "../core/fullscreenHelper";
import { setSettings } from "../stores/settings";
import type { LanguageSelectKey } from "../types/components/select";
import type { StorageData } from "../types/storage";
import type { OverlayMount } from "../types/uiManager";
import { containsCrossShadow, getDeepActiveElement } from "../utils/dom";
import { EventImpl } from "../utils/eventImpl";
import { hasTouchScreen, isTouchFirstInput } from "../utils/inputDevice";
import type { IntervalIdleChecker } from "../utils/intervalIdleChecker";
import { votStorage } from "../utils/storage";
import type { VideoHandler } from "../VideoHandler";
import {
createShadowMount,
destroyShadowMount,
reparentShadowMount,
type ShadowMount,
} from "../shadowMount";
import { type MountedComponent, mountComponent } from "../solid/mountComponent";
} from "./shadowMount";
import { render } from "./solid/renderer";
export class OverlayView {
type OverlayControllerProps = {
mount: OverlayMount;
data?: Partial<StorageData>;
videoHandler?: VideoHandler;
intervalIdleChecker: IntervalIdleChecker;
};
type OverlayControllerEventMap = {
"click:settings": [];
"click:pip": [];
"click:subtitles": [];
"click:downloadTranslation": [];
"click:downloadSubtitles": [];
"click:translate": [];
"input:videoVolume": [volume: number];
"input:translationVolume": [volume: number];
"select:fromLanguage": [item: LanguageSelectKey];
"select:toLanguage": [item: LanguageSelectKey];
"select:subtitles": [item: string];
"select:voiceType": [useLive: boolean];
};
export class OverlayController {
private static readonly BIG_CONTAINER_WIDTH_PX = 550;
private resizeObserver?: ResizeObserver;
private readonly fullscreenHelper: FullscreenHelper;
@ -39,38 +58,46 @@ export class OverlayView {
private readonly intervalIdleChecker: IntervalIdleChecker;
private overlayMount?: ShadowMount;
overlayViewControls?: OverlayViewControls;
private overlayViewComponent?: MountedComponent<HTMLDivElement>;
private disposeOverlay?: () => void;
private readonly events: {
[K in keyof OverlayViewEventMap]: EventImpl<OverlayViewEventMap[K]>;
[K in keyof OverlayControllerEventMap]: EventImpl<
OverlayControllerEventMap[K]
>;
} = {
"click:settings": new EventImpl<OverlayViewEventMap["click:settings"]>(),
"click:pip": new EventImpl<OverlayViewEventMap["click:pip"]>(),
"click:subtitles": new EventImpl<OverlayViewEventMap["click:subtitles"]>(),
"click:settings": new EventImpl<
OverlayControllerEventMap["click:settings"]
>(),
"click:pip": new EventImpl<OverlayControllerEventMap["click:pip"]>(),
"click:subtitles": new EventImpl<
OverlayControllerEventMap["click:subtitles"]
>(),
"click:downloadTranslation": new EventImpl<
OverlayViewEventMap["click:downloadTranslation"]
OverlayControllerEventMap["click:downloadTranslation"]
>(),
"click:downloadSubtitles": new EventImpl<
OverlayViewEventMap["click:downloadSubtitles"]
OverlayControllerEventMap["click:downloadSubtitles"]
>(),
"click:translate": new EventImpl<
OverlayControllerEventMap["click:translate"]
>(),
"click:translate": new EventImpl<OverlayViewEventMap["click:translate"]>(),
"input:videoVolume": new EventImpl<
OverlayViewEventMap["input:videoVolume"]
OverlayControllerEventMap["input:videoVolume"]
>(),
"input:translationVolume": new EventImpl<
OverlayViewEventMap["input:translationVolume"]
OverlayControllerEventMap["input:translationVolume"]
>(),
"select:fromLanguage": new EventImpl<
OverlayViewEventMap["select:fromLanguage"]
OverlayControllerEventMap["select:fromLanguage"]
>(),
"select:toLanguage": new EventImpl<
OverlayViewEventMap["select:toLanguage"]
OverlayControllerEventMap["select:toLanguage"]
>(),
"select:subtitles": new EventImpl<
OverlayViewEventMap["select:subtitles"]
OverlayControllerEventMap["select:subtitles"]
>(),
"select:voiceType": new EventImpl<
OverlayViewEventMap["select:voiceType"]
OverlayControllerEventMap["select:voiceType"]
>(),
};
@ -79,7 +106,7 @@ export class OverlayView {
data = {},
videoHandler,
intervalIdleChecker,
}: OverlayViewProps) {
}: OverlayControllerProps) {
this.mount = mount;
this.data = data;
this.videoHandler = videoHandler;
@ -95,10 +122,6 @@ export class OverlayView {
return this.overlayMount?.root ?? this.mount.root;
}
get portalContainer(): HTMLElement {
return this.mount.portalContainer;
}
/**
* Update mount points when the player container changes.
* Moves already-mounted UI nodes and rebinds root-bound listeners (dragging).
@ -125,17 +148,17 @@ export class OverlayView {
return this.initialized;
}
addEventListener<K extends keyof OverlayViewEventMap>(
addEventListener<K extends keyof OverlayControllerEventMap>(
type: K,
listener: (...data: OverlayViewEventMap[K]) => void,
listener: (...data: OverlayControllerEventMap[K]) => void,
): this {
this.events[type].addListener(listener);
return this;
}
removeEventListener<K extends keyof OverlayViewEventMap>(
removeEventListener<K extends keyof OverlayControllerEventMap>(
type: K,
listener: (...data: OverlayViewEventMap[K]) => void,
listener: (...data: OverlayControllerEventMap[K]) => void,
): this {
this.events[type].removeListener(listener);
return this;
@ -167,7 +190,7 @@ export class OverlayView {
initUI() {
if (this.isInitialized()) {
throw new Error("[VOT] OverlayView is already initialized");
throw new Error("[VOT] OverlayController is already initialized");
}
this.initialized = true;
@ -194,112 +217,111 @@ export class OverlayView {
? this.videoHandler.getVideoVolume() * 100
: 100;
this.overlayViewComponent = mountComponent<HTMLDivElement>((rootRef) =>
OverlayViewComponent({
ref: rootRef,
controlsRef: (controls) => {
this.overlayViewControls = controls;
},
isBigContainer: this.isBigContainer,
detectedLanguage: this.videoHandler?.videoData?.detectedLanguage,
responseLanguage: this.data.responseLanguage,
videoVolume,
onButtonDragActivity: (source) =>
this.intervalIdleChecker.markActivity(source),
onButtonDragEnd: () => this.queueButtonAutoHideAfterInteraction(),
onButtonPositionChange: (position) => {
this.data.buttonPos = position;
},
onTranslateClick: () => {
this.events["click:translate"].dispatch();
},
onPiPClick: () => {
this.events["click:pip"].dispatch();
},
onSubtitlesClick: () => {
this.events["click:subtitles"].dispatch();
},
onSubtitlesOpen: () =>
this.videoHandler?.ensureSubtitlesForCurrentLangPair(),
onSubtitlesSelect: (value) => {
this.events["select:subtitles"].dispatch(value);
},
onVoiceChange: (voice) => {
const useLive = voice === "live";
this.data.useLivelyVoice = useLive;
void votStorage.set("useLivelyVoice", useLive);
this.events["select:voiceType"].dispatch(useLive);
queueMicrotask(() => this.queueButtonAutoHideAfterInteraction());
},
onVideoVolumeInput: (volume) => {
this.events["input:videoVolume"].dispatch(volume);
},
onTranslationVolumeInput: (volume) => {
if (this.data.defaultVolume !== volume) {
this.data.defaultVolume = volume;
this.scheduleDefaultVolumePersist();
}
this.disposeOverlay = render(
() =>
OverlayViewComponent({
controlsRef: (controls) => {
this.overlayViewControls = controls;
},
isBigContainer: this.isBigContainer,
detectedLanguage: this.videoHandler?.videoData?.detectedLanguage,
responseLanguage: this.data.responseLanguage,
videoVolume,
onButtonDragActivity: (source) =>
this.intervalIdleChecker.markActivity(source),
onButtonDragEnd: () => this.queueButtonAutoHideAfterInteraction(),
onButtonPositionChange: (position) => {
this.data.buttonPos = position;
},
onTranslateClick: () => {
this.events["click:translate"].dispatch();
},
onPiPClick: () => {
this.events["click:pip"].dispatch();
},
onSubtitlesClick: () => {
this.events["click:subtitles"].dispatch();
},
onSubtitlesOpen: () =>
this.videoHandler?.ensureSubtitlesForCurrentLangPair(),
onSubtitlesSelect: (value) => {
this.events["select:subtitles"].dispatch(value);
},
onVoiceChange: (voice) => {
const useLive = voice === "live";
this.data.useLivelyVoice = useLive;
void votStorage.set("useLivelyVoice", useLive);
this.events["select:voiceType"].dispatch(useLive);
queueMicrotask(() => this.queueButtonAutoHideAfterInteraction());
},
onVideoVolumeInput: (volume) => {
this.events["input:videoVolume"].dispatch(volume);
},
onTranslationVolumeInput: (volume) => {
if (this.data.defaultVolume !== volume) {
this.data.defaultVolume = volume;
this.scheduleDefaultVolumePersist();
}
this.events["input:translationVolume"].dispatch(volume);
},
onDownloadTranslationClick: () => {
this.events["click:downloadTranslation"].dispatch();
},
onDownloadSubtitlesClick: () => {
this.events["click:downloadSubtitles"].dispatch();
},
onSettingsClick: () => {
this.events["click:settings"].dispatch();
},
onDetectedLanguageSelect: (language) => {
if (this.videoHandler?.videoData) {
this.videoHandler.videoData.detectedLanguage = language;
this.videoHandler.videoManager.rememberUserLanguageSelection(
this.videoHandler.videoData.videoId,
language,
);
}
this.events["select:fromLanguage"].dispatch(language);
},
onResponseLanguageSelect: async (language) => {
if (this.videoHandler?.videoData) {
this.videoHandler.translateToLang =
this.videoHandler.videoData.responseLanguage = language;
}
this.events["input:translationVolume"].dispatch(volume);
},
onDownloadTranslationClick: () => {
this.events["click:downloadTranslation"].dispatch();
},
onDownloadSubtitlesClick: () => {
this.events["click:downloadSubtitles"].dispatch();
},
onSettingsClick: () => {
this.events["click:settings"].dispatch();
},
onDetectedLanguageSelect: (language) => {
if (this.videoHandler?.videoData) {
this.videoHandler.videoData.detectedLanguage = language;
this.videoHandler.videoManager.rememberUserLanguageSelection(
this.videoHandler.videoData.videoId,
language,
);
}
this.events["select:fromLanguage"].dispatch(language);
},
onResponseLanguageSelect: async (language) => {
if (this.videoHandler?.videoData) {
this.videoHandler.translateToLang =
this.videoHandler.videoData.responseLanguage = language;
}
const prevResponseLanguage = this.data.responseLanguage;
if (prevResponseLanguage !== language) {
this.data.responseLanguage = language;
await votStorage.set(
"responseLanguage",
this.data.responseLanguage,
);
}
const prevResponseLanguage = this.data.responseLanguage;
if (prevResponseLanguage !== language) {
this.data.responseLanguage = language;
await votStorage.set(
"responseLanguage",
this.data.responseLanguage,
);
}
// UX: keep the "Don't translate from selected languages" list in sync
// with the selected response language, but only while the list still
// looks like the old default.
// TODO: recheck it later
if (
Array.isArray(this.data.dontTranslateLanguages) &&
this.data.dontTranslateLanguages.length === 1 &&
prevResponseLanguage !== language &&
typeof prevResponseLanguage === "string" &&
this.data.dontTranslateLanguages[0] === prevResponseLanguage
) {
setSettings("dontTranslateLanguages", [language]);
this.data.dontTranslateLanguages = [language];
await votStorage.set(
"dontTranslateLanguages",
this.data.dontTranslateLanguages,
);
}
this.events["select:toLanguage"].dispatch(language);
},
}),
// UX: keep the "Don't translate from selected languages" list in sync
// with the selected response language, but only while the list still
// looks like the old default.
// TODO: recheck it later
if (
Array.isArray(this.data.dontTranslateLanguages) &&
this.data.dontTranslateLanguages.length === 1 &&
prevResponseLanguage !== language &&
typeof prevResponseLanguage === "string" &&
this.data.dontTranslateLanguages[0] === prevResponseLanguage
) {
setSettings("dontTranslateLanguages", [language]);
this.data.dontTranslateLanguages = [language];
await votStorage.set(
"dontTranslateLanguages",
this.data.dontTranslateLanguages,
);
}
this.events["select:toLanguage"].dispatch(language);
},
}) as Node,
this.root,
);
this.root.appendChild(this.overlayViewComponent.root);
this.setupResizeObserver();
// #endregion VOT Menu
return this;
@ -442,8 +464,8 @@ export class OverlayView {
private doReleaseUI(): void {
this.resizeObserver?.disconnect();
this.resizeObserver = undefined;
this.overlayViewComponent?.dispose();
this.overlayViewComponent = undefined;
this.disposeOverlay?.();
this.disposeOverlay = undefined;
this.overlayViewControls = undefined;
this.fullscreenHelper.destroy();
@ -475,7 +497,7 @@ export class OverlayView {
get isBigContainer() {
return this.fullscreenHelper.isBigContainer(
OverlayView.BIG_CONTAINER_WIDTH_PX,
OverlayController.BIG_CONTAINER_WIDTH_PX,
);
}

View file

@ -1,11 +1,11 @@
import debug from "../utils/debug";
import { containsCrossShadow, getDeepActiveElement } from "../utils/dom";
import type { IntervalIdleChecker } from "../utils/intervalIdleChecker";
import type { OverlayView } from "./views/overlay";
import type { OverlayController } from "./overlayController";
export interface OverlayVisibilityDependencies {
checker: IntervalIdleChecker;
getOverlayView(): OverlayView | null | undefined;
getOverlayView(): OverlayController | null | undefined;
getAutoHideDelay(): number;
isInteractiveNode(node: unknown): boolean;
nowMs?: () => number;
@ -49,7 +49,7 @@ export class OverlayVisibilityController {
/**
* Ensures overlay is visible immediately and returns current view.
*/
show(): OverlayView | null {
show(): OverlayController | null {
const view = this.getView();
if (!view) {
return null;
@ -235,12 +235,14 @@ export class OverlayVisibilityController {
this.deps.checker.markActivity("overlay-focus-in");
}
private getView(): OverlayView | null {
private getView(): OverlayController | null {
const view = this.deps.getOverlayView();
return view?.isInitialized() ? view : null;
}
private isOverlayHoverActive(view: OverlayView | null = this.getView()) {
private isOverlayHoverActive(
view: OverlayController | null = this.getView(),
) {
return Boolean(
view &&
(this.pointerInsideOverlay || view.shouldKeepVisibleForInteraction()),

View file

@ -1,4 +1,4 @@
const SETTINGS_EVENT_KEYS: Array<keyof SettingsViewEventMap> = [
const SETTINGS_EVENT_KEYS: Array<keyof SettingsControllerEventMap> = [
"click:bugReport",
"click:resetSettings",
"update:account",
@ -24,10 +24,14 @@ const SETTINGS_EVENT_KEYS: Array<keyof SettingsViewEventMap> = [
"select:menuLanguage",
];
function createSettingsEvents(): {
[K in keyof SettingsViewEventMap]: EventImpl<SettingsViewEventMap[K]>;
[K in keyof SettingsControllerEventMap]: EventImpl<
SettingsControllerEventMap[K]
>;
} {
const events = {} as {
[K in keyof SettingsViewEventMap]: EventImpl<SettingsViewEventMap[K]>;
[K in keyof SettingsControllerEventMap]: EventImpl<
SettingsControllerEventMap[K]
>;
};
for (const key of SETTINGS_EVENT_KEYS) {
(events as Record<string, EventImpl<unknown[]>>)[key] = new EventImpl<
@ -38,34 +42,64 @@ function createSettingsEvents(): {
}
import { type Accessor, createSignal, type Setter } from "solid-js";
import { SettingsDialog } from "../../components/Settings/SettingsDialog";
import { PROXY_WORKER_HOST } from "../../config/config";
import { isAuthRefreshMessage } from "../../core/authRefreshMessage";
import { openAuthWindow } from "../../core/authWindow";
import { SettingsDialog } from "../components/Settings/SettingsDialog";
import { PROXY_WORKER_HOST } from "../config/config";
import { isAuthRefreshMessage } from "../core/authRefreshMessage";
import { openAuthWindow } from "../core/authWindow";
import {
type LangOverride,
localizationProvider,
} from "../../localization/localizationProvider";
import { account, resetAccount, updateAccount } from "../../stores/account";
import { setLocale } from "../../stores/locale";
import { setSettings } from "../../stores/settings";
import type { SubtitleFormat } from "../../subtitles/types";
import type { Position } from "../../types/components/votButton";
} from "../localization/localizationProvider";
import { account, resetAccount, updateAccount } from "../stores/account";
import { setLocale } from "../stores/locale";
import { setSettings } from "../stores/settings";
import type { SubtitleFormat } from "../subtitles/types";
import type { Position } from "../types/components/votButton";
import type {
Account,
ResponseLanguageSubtitles,
StorageData,
TranslateProxyStatus,
} from "../../types/storage";
import type {
SettingsViewEventMap,
SettingsViewProps,
} from "../../types/views/settings";
import debug from "../../utils/debug";
import { EventImpl } from "../../utils/eventImpl";
import { votStorage } from "../../utils/storage";
import type { VideoHandler } from "../../VideoHandler";
import { type MountedComponent, mountComponent } from "../solid/mountComponent";
} from "../types/storage";
import type { SubtitleFontFamily } from "../types/subtitles";
import type { TranslateService } from "../types/translateApis";
import debug from "../utils/debug";
import { EventImpl } from "../utils/eventImpl";
import { votStorage } from "../utils/storage";
import type { VideoHandler } from "../VideoHandler";
import { render } from "./solid/renderer";
type SettingsControllerProps = {
globalPortal: HTMLElement;
data?: Partial<StorageData>;
videoHandler?: VideoHandler;
};
type SettingsControllerEventMap = {
"click:bugReport": [];
"click:resetSettings": [];
"update:account": [account: Partial<Account> | undefined];
"change:autoTranslate": [checked: boolean];
"change:autoSubtitles": [checked: boolean];
"change:showVideoVolume": [checked: boolean];
"change:audioBooster": [checked: boolean];
"change:syncVolume": [checked: boolean];
"change:subtitlesHighlightWords": [checked: boolean];
"change:subtitlesSmartLayout": [checked: boolean];
"select:responseLanguageSubtitles": [item: ResponseLanguageSubtitles];
"select:subtitlesFontFamily": [item: SubtitleFontFamily];
"change:proxyWorkerHost": [value: string];
"change:useNewAudioPlayer": [checked: boolean];
"change:onlyBypassMediaCSP": [checked: boolean];
"change:showPiPButton": [checked: boolean];
"input:subtitlesMaxLength": [value: number];
"input:subtitlesFontSize": [value: number];
"input:subtitlesBackgroundOpacity": [value: number];
"input:autoHideButtonDelay": [value: number];
"select:proxyTranslationStatus": [item: TranslateProxyStatus];
"select:translationTextService": [item: TranslateService];
"select:menuLanguage": [item: LangOverride];
};
type BufferedNumericStorageKey =
| "autoVolume"
@ -74,14 +108,16 @@ type BufferedNumericStorageKey =
| "subtitlesOpacity"
| "autoHideButtonDelay";
export class SettingsView {
export class SettingsController {
private static readonly PERSIST_DELAY_MS = 250;
globalPortal: HTMLElement;
private initialized = false;
private readonly data: Partial<StorageData>;
private readonly videoHandler?: VideoHandler;
private readonly events: {
[K in keyof SettingsViewEventMap]: EventImpl<SettingsViewEventMap[K]>;
[K in keyof SettingsControllerEventMap]: EventImpl<
SettingsControllerEventMap[K]
>;
} = createSettingsEvents();
private persistTimerIds: Partial<
Record<BufferedNumericStorageKey, ReturnType<typeof setTimeout>>
@ -94,16 +130,20 @@ export class SettingsView {
void this.refreshAccountFromStorage();
};
root?: HTMLElement;
private settingsDialog?: MountedComponent<HTMLElement>;
private disposeSettingsDialog?: () => void;
private dialogOpen?: Accessor<boolean>;
private setDialogOpen?: Setter<boolean>;
private accountStorageListenerCleanup?: () => void;
constructor({ globalPortal, data = {}, videoHandler }: SettingsViewProps) {
constructor({
globalPortal,
data = {},
videoHandler,
}: SettingsControllerProps) {
this.globalPortal = globalPortal;
this.data = data;
this.videoHandler = videoHandler;
}
isInitialized(): this is Required<SettingsView> {
isInitialized(): this is Required<SettingsController> {
return this.initialized;
}
private scheduleStoragePersist(
@ -117,7 +157,7 @@ export class SettingsView {
this.persistTimerIds[key] = globalThis.setTimeout(() => {
this.persistTimerIds[key] = undefined;
void votStorage.set(key, value);
}, SettingsView.PERSIST_DELAY_MS);
}, SettingsController.PERSIST_DELAY_MS);
}
private flushStoragePersists(): void {
for (const key of Object.keys(this.persistTimerIds) as Array<
@ -210,14 +250,16 @@ export class SettingsView {
initUI() {
if (this.isInitialized()) {
throw new Error("[VOT] SettingsView is already initialized");
throw new Error("[VOT] SettingsController is already initialized");
}
this.settingsDialog = mountComponent<HTMLElement>((rootRef) => {
this.disposeSettingsDialog = render(() => {
const [isOpen, setIsOpen] = createSignal(false);
this.dialogOpen = isOpen;
this.setDialogOpen = setIsOpen;
return SettingsDialog({
ref: rootRef,
ref: (element) => {
this.root = element;
},
get isOpen() {
return isOpen();
},
@ -424,39 +466,42 @@ export class SettingsView {
onResetSettingsClick: () =>
this.events["click:resetSettings"].dispatch(),
},
});
});
this.root = this.settingsDialog.root;
this.globalPortal.appendChild(this.settingsDialog.root);
}) as Node;
}, this.globalPortal);
if (!this.root) {
this.disposeSettingsDialog();
this.disposeSettingsDialog = undefined;
throw new Error("[VOT] Settings dialog did not expose a root element");
}
this.initialized = true;
return this;
}
initUIEvents() {
if (!this.isInitialized()) {
throw new Error("[VOT] SettingsView isn't initialized");
throw new Error("[VOT] SettingsController isn't initialized");
}
globalThis.addEventListener("message", this.onAuthRefreshMessage);
this.bindAccountStorageListener();
return this;
}
addEventListener<K extends keyof SettingsViewEventMap>(
addEventListener<K extends keyof SettingsControllerEventMap>(
type: K,
listener: (...data: SettingsViewEventMap[K]) => void,
listener: (...data: SettingsControllerEventMap[K]) => void,
): this {
this.events[type].addListener(listener);
return this;
}
removeEventListener<K extends keyof SettingsViewEventMap>(
removeEventListener<K extends keyof SettingsControllerEventMap>(
type: K,
listener: (...data: SettingsViewEventMap[K]) => void,
listener: (...data: SettingsControllerEventMap[K]) => void,
): this {
this.events[type].removeListener(listener);
return this;
}
private doReleaseUI(): void {
this.settingsDialog?.dispose();
this.settingsDialog?.root.remove();
this.settingsDialog = undefined;
this.disposeSettingsDialog?.();
this.disposeSettingsDialog = undefined;
this.root?.remove();
this.dialogOpen = undefined;
this.setDialogOpen = undefined;
this.root = undefined;
@ -477,19 +522,19 @@ export class SettingsView {
}
updateAccountInfo() {
if (!this.isInitialized())
throw new Error("[VOT] SettingsView isn't initialized");
throw new Error("[VOT] SettingsController isn't initialized");
this.events["update:account"].dispatch(this.data.account);
return this;
}
open() {
if (!this.isInitialized())
throw new Error("[VOT] SettingsView isn't initialized");
throw new Error("[VOT] SettingsController isn't initialized");
this.setDialogOpen(true);
return this;
}
close() {
if (!this.isInitialized())
throw new Error("[VOT] SettingsView isn't initialized");
throw new Error("[VOT] SettingsController isn't initialized");
this.setDialogOpen(false);
return this;
}

View file

@ -1,5 +1,4 @@
import solidSCSS from "../components/solid.scss?inline";
import mainScss from "../styles/main.scss?inline";
type InlineStyleMap = Partial<Record<string, string>>;
type ElementConfig = {
@ -25,7 +24,7 @@ type CreateShadowMountOptions = {
delegatesFocus?: boolean;
};
const shadowScopedCssText = scopeCssForShadowRoots(`${mainScss}\n${solidSCSS}`);
const shadowScopedCssText = scopeCssForShadowRoots(solidSCSS);
let sharedShadowStyleSheet: CSSStyleSheet | null = null;
let sharedShadowStyleSheetReady = false;

View file

@ -1,28 +0,0 @@
import type { JSX } from "solid-js";
import { render } from "./renderer";
export type MountedComponent<T extends HTMLElement> = {
root: T;
dispose: () => void;
};
/**
* Compatibility bridge while callers still expect a concrete root element.
* The JSX tree remains owned by a Solid root even after the returned element
* is moved into a shadow root or portal.
*/
export function mountComponent<T extends HTMLElement>(
view: (setRoot: (element: T) => void) => JSX.Element,
): MountedComponent<T> {
const host = document.createElement("vot-block");
let root: T | undefined;
const dispose = render(
() => view((element) => (root = element)) as Node,
host,
);
if (!root) {
dispose();
throw new Error("[VOT] Solid component did not expose a root element");
}
return { root, dispose };
}

View file

@ -311,7 +311,7 @@ function bindGlobalDismissAndHotkeys(ctx: ExtraEventsContext): void {
const isSelectInner = path.some(
(element) =>
element instanceof HTMLElement &&
element.classList.contains("vot-select_new-inner"),
element.classList.contains("vot-select-inner"),
);
debug.log(
`[document click] ${isButton} ${isMenu} ${isVideo} ${isSettings}`,

View file

@ -148,6 +148,7 @@ export async function init(this: VideoHandler) {
subtitlesHotkey: this.data.subtitlesHotkey,
// subtitles
responseLanguageSubtitles: this.data.responseLanguageSubtitles,
subtitlesDownloadFormat: this.data.subtitlesDownloadFormat,
highlightWords: this.data.highlightWords,
subtitlesSmartLayout: this.data.subtitlesSmartLayout,
subtitlesFontFamily: this.data.subtitlesFontFamily,

View file

@ -1,12 +0,0 @@
{
"name": "[VOT] - Test UI",
"description": "Test ui",
"version": "1.0.0",
"author": "sodapng, mynovelhost, Toil, SashaXser, MrSoczekXD",
"namespace": "vot-test-ui",
"icon": "https://translate.yandex.ru/icons/favicon.ico",
"match": ["*://toil.cc/*"],
"require": [
"https://gist.githubusercontent.com/ilyhalight/6eb5bb4dffc7ca9e3c57d6933e2452f3/raw/7ab38af2228d0bed13912e503bc8a9ee4b11828d/gm-addstyle-polyfill.js"
]
}

View file

@ -1,49 +0,0 @@
import fs from "node:fs/promises";
import path from "node:path";
import { defineConfig, type UserConfig } from "vite";
import { buildDefine } from "./lib/env";
import { distDir, singleFileBuildOptions, testsDir } from "./lib/paths";
import { formatSimpleUserscriptHeader } from "./lib/userscript/headers";
import { createBaseViteConfig } from "./lib/vite-base-config";
const TEST_UI_ENTRY = path.resolve(testsDir, "ui.js");
const TEST_UI_HEADERS_PATH = path.resolve(testsDir, "headers.json");
export default defineConfig(async () => {
const baseConfig = createBaseViteConfig({ cacheName: "test-ui" });
const testMeta = JSON.parse(
await fs.readFile(TEST_UI_HEADERS_PATH, "utf8"),
) as Record<string, unknown>;
return {
...baseConfig,
define: buildDefine({
debug: true,
isExtension: false,
availableLocales: [],
repoBranch: "master",
version: "",
authors: "",
crxjsBuild: false,
}),
build: {
...baseConfig.build,
...singleFileBuildOptions,
outDir: distDir,
emptyOutDir: false,
sourcemap: false,
minify: false,
lib: {
entry: TEST_UI_ENTRY,
name: "testUi",
formats: ["iife"],
fileName: () => "test-ui.user.js",
},
rolldownOptions: {
output: {
postBanner: formatSimpleUserscriptHeader(testMeta),
},
},
},
} satisfies UserConfig;
});