mirror of
https://github.com/ilyhalight/voice-over-translation.git
synced 2026-04-28 11:30:27 +00:00
Bump release to 1.11.5 with several fixes and refactors: rewrite of UI mounting, simplified audio loading, and multiple site compatibility fixes (VKVideo subtitles, mobile audio downloads, archive.org, Coursehunter, Yandex.Disk /d, Reddit, Odysee, YouTube Embed, proxy auto-enable logic). Update changelog, bump userscript/extension versions and publish new Chrome/Firefox build artifacts; update firefox updates manifest. Add shadow DOM mount support and other UI/module improvements across source files, plus general dependency and build updates.
1065 lines
31 KiB
TypeScript
1065 lines
31 KiB
TypeScript
import { availableLangs, availableTTS } from "@vot.js/shared/consts";
|
|
import type { RequestLang, ResponseLang } from "@vot.js/shared/types/data";
|
|
import type { VideoHandler } from "../..";
|
|
import { maxAudioVolume } from "../../config/config";
|
|
import { EventImpl } from "../../core/eventImpl";
|
|
import { localizationProvider } from "../../localization/localizationProvider";
|
|
import type { Direction, Position } from "../../types/components/votButton";
|
|
import type { StorageData } from "../../types/storage";
|
|
import type { ButtonLayout, OverlayMount } from "../../types/uiManager";
|
|
import type {
|
|
OverlayViewEventMap,
|
|
OverlayViewProps,
|
|
} from "../../types/views/overlay";
|
|
import ui from "../../ui";
|
|
import type { IntervalIdleChecker } from "../../utils/intervalIdleChecker";
|
|
import { votStorage } from "../../utils/storage";
|
|
import { isPiPAvailable } from "../../utils/utils";
|
|
import DownloadButton from "../components/downloadButton";
|
|
import Label from "../components/label";
|
|
import LanguagePairSelect from "../components/languagePairSelect";
|
|
import Select from "../components/select";
|
|
import Slider from "../components/slider";
|
|
import SliderLabel from "../components/sliderLabel";
|
|
import Tooltip from "../components/tooltip";
|
|
import VOTButton from "../components/votButton";
|
|
import VOTMenu from "../components/votMenu";
|
|
import { SETTINGS_ICON, SUBTITLES_ICON } from "./../icons";
|
|
import {
|
|
createShadowMount,
|
|
destroyShadowMount,
|
|
reparentShadowMount,
|
|
type ShadowMount,
|
|
} from "../shadowMount";
|
|
|
|
export class OverlayView {
|
|
private static readonly BIG_CONTAINER_WIDTH_PX = 550;
|
|
|
|
mount: OverlayMount;
|
|
globalPortal: HTMLElement;
|
|
private abortController: AbortController | null = null;
|
|
private defaultVolumePersistTimer: ReturnType<typeof setTimeout> | undefined;
|
|
private readonly defaultVolumePersistDelayMs = 250;
|
|
|
|
private dragging = false;
|
|
private dragCandidate = false;
|
|
private dragDirty = false;
|
|
private dragStartX = 0;
|
|
private dragStartY = 0;
|
|
private currentClientX = 0;
|
|
private activePointerId: number | null = null;
|
|
private readonly dragThresholdPx = 6;
|
|
private containerRect: DOMRect | null = null;
|
|
private dragIsBigContainer: boolean | null = null;
|
|
private checkerUnsubscribe: (() => void) | null = null;
|
|
|
|
private initialized = false;
|
|
private readonly data: Partial<StorageData>;
|
|
private readonly videoHandler?: VideoHandler;
|
|
private readonly intervalIdleChecker: IntervalIdleChecker;
|
|
private overlayMount?: ShadowMount;
|
|
|
|
private readonly events: {
|
|
[K in keyof OverlayViewEventMap]: EventImpl<OverlayViewEventMap[K]>;
|
|
} = {
|
|
"click:settings": new EventImpl<OverlayViewEventMap["click:settings"]>(),
|
|
"click:pip": new EventImpl<OverlayViewEventMap["click:pip"]>(),
|
|
"click:downloadTranslation": new EventImpl<
|
|
OverlayViewEventMap["click:downloadTranslation"]
|
|
>(),
|
|
"click:downloadSubtitles": new EventImpl<
|
|
OverlayViewEventMap["click:downloadSubtitles"]
|
|
>(),
|
|
"click:translate": new EventImpl<OverlayViewEventMap["click:translate"]>(),
|
|
"input:videoVolume": new EventImpl<
|
|
OverlayViewEventMap["input:videoVolume"]
|
|
>(),
|
|
"input:translationVolume": new EventImpl<
|
|
OverlayViewEventMap["input:translationVolume"]
|
|
>(),
|
|
"select:fromLanguage": new EventImpl<
|
|
OverlayViewEventMap["select:fromLanguage"]
|
|
>(),
|
|
"select:toLanguage": new EventImpl<
|
|
OverlayViewEventMap["select:toLanguage"]
|
|
>(),
|
|
"select:subtitles": new EventImpl<
|
|
OverlayViewEventMap["select:subtitles"]
|
|
>(),
|
|
};
|
|
|
|
// button
|
|
votButton?: VOTButton;
|
|
votButtonTooltip?: Tooltip;
|
|
// menu
|
|
votMenu?: VOTMenu;
|
|
downloadTranslationButton?: DownloadButton;
|
|
downloadSubtitlesButton?: HTMLElement;
|
|
openSettingsButton?: HTMLElement;
|
|
languagePairSelect?: LanguagePairSelect<RequestLang, ResponseLang>;
|
|
subtitlesSelectLabel?: Label;
|
|
subtitlesSelect?: Select;
|
|
videoVolumeSliderLabel?: SliderLabel;
|
|
videoVolumeSlider?: Slider;
|
|
translationVolumeSliderLabel?: SliderLabel;
|
|
translationVolumeSlider?: Slider;
|
|
|
|
constructor({
|
|
mount,
|
|
globalPortal,
|
|
data = {},
|
|
videoHandler,
|
|
intervalIdleChecker,
|
|
}: OverlayViewProps) {
|
|
this.mount = mount;
|
|
this.globalPortal = globalPortal;
|
|
this.data = data;
|
|
this.videoHandler = videoHandler;
|
|
this.intervalIdleChecker = intervalIdleChecker;
|
|
}
|
|
|
|
get root(): HTMLElement {
|
|
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).
|
|
*/
|
|
updateMount(nextMount: OverlayMount): this {
|
|
const prevRoot = this.mount.root;
|
|
const nextRoot = nextMount.root;
|
|
|
|
this.mount = nextMount;
|
|
|
|
if (!this.isInitialized()) {
|
|
return this;
|
|
}
|
|
|
|
if (prevRoot !== nextRoot) {
|
|
if (this.overlayMount) {
|
|
reparentShadowMount(this.overlayMount, nextRoot);
|
|
} else {
|
|
if (this.votButton) {
|
|
nextRoot.appendChild(this.votButton.container);
|
|
}
|
|
if (this.votMenu) {
|
|
nextRoot.appendChild(this.votMenu.container);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (this.votButtonTooltip && prevRoot !== nextRoot) {
|
|
this.votButtonTooltip.updateMount({
|
|
parentElement: this.root,
|
|
layoutRoot: this.overlayMount?.host,
|
|
});
|
|
}
|
|
|
|
return this;
|
|
}
|
|
|
|
isInitialized(): this is {
|
|
// #region Button type
|
|
votButton: VOTButton;
|
|
votButtonTooltip: Tooltip;
|
|
// #endregion Button type
|
|
// #region Menu type
|
|
votMenu: VOTMenu;
|
|
downloadTranslationButton: DownloadButton;
|
|
downloadSubtitlesButton: HTMLElement;
|
|
openSettingsButton: HTMLElement;
|
|
languagePairSelect: LanguagePairSelect<RequestLang, ResponseLang>;
|
|
subtitlesSelectLabel: Label;
|
|
subtitlesSelect: Select;
|
|
videoVolumeSliderLabel: SliderLabel;
|
|
videoVolumeSlider: Slider;
|
|
translationVolumeSliderLabel: SliderLabel;
|
|
translationVolumeSlider: Slider;
|
|
// #endregion Menu type
|
|
} {
|
|
return this.initialized;
|
|
}
|
|
|
|
calcButtonLayout(position: Position): ButtonLayout {
|
|
if (this.isBigContainer && isSidePosition(position)) {
|
|
return {
|
|
direction: "column",
|
|
position,
|
|
};
|
|
}
|
|
|
|
return {
|
|
direction: "row",
|
|
position: "default",
|
|
};
|
|
}
|
|
|
|
addEventListener<K extends keyof OverlayViewEventMap>(
|
|
type: K,
|
|
listener: (...data: OverlayViewEventMap[K]) => void,
|
|
): this {
|
|
this.events[type].addListener(listener);
|
|
return this;
|
|
}
|
|
|
|
removeEventListener<K extends keyof OverlayViewEventMap>(
|
|
type: K,
|
|
listener: (...data: OverlayViewEventMap[K]) => void,
|
|
): this {
|
|
this.events[type].removeListener(listener);
|
|
return this;
|
|
}
|
|
|
|
private scheduleDefaultVolumePersist(): void {
|
|
if (this.defaultVolumePersistTimer !== undefined) {
|
|
globalThis.clearTimeout(this.defaultVolumePersistTimer);
|
|
}
|
|
|
|
this.defaultVolumePersistTimer = globalThis.setTimeout(() => {
|
|
this.defaultVolumePersistTimer = undefined;
|
|
this.flushDefaultVolumePersist();
|
|
}, this.defaultVolumePersistDelayMs);
|
|
}
|
|
|
|
private flushDefaultVolumePersist(): void {
|
|
if (this.defaultVolumePersistTimer !== undefined) {
|
|
globalThis.clearTimeout(this.defaultVolumePersistTimer);
|
|
this.defaultVolumePersistTimer = undefined;
|
|
}
|
|
|
|
if (typeof this.data.defaultVolume !== "number") {
|
|
return;
|
|
}
|
|
|
|
void votStorage.set("defaultVolume", this.data.defaultVolume);
|
|
}
|
|
|
|
initUI(buttonPosition: Position = "default") {
|
|
if (this.isInitialized()) {
|
|
throw new Error("[VOT] OverlayView is already initialized");
|
|
}
|
|
|
|
this.initialized = true;
|
|
this.overlayMount = createShadowMount({
|
|
parent: this.mount.root,
|
|
rootClasses: ["vot-overlay-root"],
|
|
hostStyles: {
|
|
position: "absolute",
|
|
inset: "0",
|
|
display: "block",
|
|
"pointer-events": "none",
|
|
},
|
|
rootStyles: {
|
|
position: "relative",
|
|
display: "block",
|
|
width: "100%",
|
|
height: "100%",
|
|
"pointer-events": "none",
|
|
},
|
|
});
|
|
|
|
// #region Shared logic
|
|
const { position, direction } = this.calcButtonLayout(buttonPosition);
|
|
|
|
// #endregion Shared logic
|
|
// #region VOT Button
|
|
this.votButton = new VOTButton({
|
|
position,
|
|
direction,
|
|
status: "none",
|
|
labelHtml: localizationProvider.get("translateVideo"),
|
|
});
|
|
this.votButton.opacity = 0;
|
|
if (!this.pipButtonVisible) {
|
|
this.votButton.showPiPButton(false);
|
|
}
|
|
this.root.appendChild(this.votButton.container);
|
|
this.votButtonTooltip = new Tooltip({
|
|
target: this.votButton.translateButton,
|
|
content: localizationProvider.get("translateVideo"),
|
|
position: this.votButton.tooltipPos,
|
|
// Keep side-tooltip direction stable for the moved button (left/right)
|
|
// so status/error text does not mirror to the opposite side.
|
|
autoLayout: false,
|
|
hidden: direction === "row",
|
|
bordered: false,
|
|
parentElement: this.root,
|
|
layoutRoot: this.overlayMount.host,
|
|
});
|
|
|
|
// #endregion VOT Button
|
|
// #region VOT Menu
|
|
this.votMenu = new VOTMenu({
|
|
titleHtml: localizationProvider.get("VOTSettings"),
|
|
position,
|
|
});
|
|
this.root.appendChild(this.votMenu.container);
|
|
|
|
// A11y: link the menu toggle button to the popover.
|
|
this.votButton.menuButton.setAttribute(
|
|
"aria-controls",
|
|
this.votMenu.container.id,
|
|
);
|
|
|
|
// #region VOT Menu Header
|
|
this.downloadTranslationButton = new DownloadButton();
|
|
this.downloadTranslationButton.hidden = true;
|
|
|
|
this.downloadSubtitlesButton = ui.createIconButton(SUBTITLES_ICON, {
|
|
ariaLabel: "Download subtitles",
|
|
});
|
|
this.downloadSubtitlesButton.hidden = true;
|
|
|
|
this.openSettingsButton = ui.createIconButton(SETTINGS_ICON, {
|
|
ariaLabel: localizationProvider.get("VOTSettings"),
|
|
});
|
|
|
|
this.votMenu.headerContainer.append(
|
|
this.downloadTranslationButton.button,
|
|
this.downloadSubtitlesButton,
|
|
this.openSettingsButton,
|
|
);
|
|
|
|
// #endregion VOT Menu Header
|
|
// #region VOT Menu Body
|
|
|
|
const detectedLanguage =
|
|
this.videoHandler?.videoData?.detectedLanguage ?? "en";
|
|
const responseLanguage = this.data.responseLanguage ?? "ru";
|
|
this.languagePairSelect = new LanguagePairSelect({
|
|
from: {
|
|
// `detectedLanguage` is dynamic and may include codes that aren't in
|
|
// the compile-time Phrase union.
|
|
selectTitle: localizationProvider.get(
|
|
`langs.${detectedLanguage}` as any,
|
|
),
|
|
items: Select.genLanguageItems(availableLangs, detectedLanguage),
|
|
},
|
|
to: {
|
|
selectTitle: localizationProvider.get(
|
|
`langs.${responseLanguage}` as any,
|
|
),
|
|
items: Select.genLanguageItems(availableTTS, responseLanguage),
|
|
},
|
|
dialogParent: this.globalPortal,
|
|
});
|
|
|
|
this.subtitlesSelectLabel = new Label({
|
|
labelText: localizationProvider.get("VOTSubtitles"),
|
|
});
|
|
this.subtitlesSelect = new Select({
|
|
selectTitle: localizationProvider.get("VOTSubtitlesDisabled"),
|
|
dialogTitle: localizationProvider.get("VOTSubtitles"),
|
|
labelElement: this.subtitlesSelectLabel.container,
|
|
dialogParent: this.globalPortal,
|
|
items: [
|
|
{
|
|
label: localizationProvider.get("VOTSubtitlesDisabled"),
|
|
value: "disabled",
|
|
selected: true,
|
|
},
|
|
],
|
|
});
|
|
|
|
const videoVolume = this.videoHandler
|
|
? this.videoHandler.getVideoVolume() * 100
|
|
: 100;
|
|
this.videoVolumeSliderLabel = new SliderLabel({
|
|
labelText: localizationProvider.get("VOTVolume"),
|
|
value: videoVolume,
|
|
});
|
|
|
|
this.videoVolumeSlider = new Slider({
|
|
labelHtml: this.videoVolumeSliderLabel.container,
|
|
value: videoVolume,
|
|
});
|
|
this.videoVolumeSlider.hidden =
|
|
!this.data.showVideoSlider || this.votButton.status !== "success";
|
|
|
|
const defaultVolume = this.data.defaultVolume ?? 100;
|
|
this.translationVolumeSliderLabel = new SliderLabel({
|
|
labelText: localizationProvider.get("VOTVolumeTranslation"),
|
|
value: defaultVolume,
|
|
});
|
|
|
|
this.translationVolumeSlider = new Slider({
|
|
labelHtml: this.translationVolumeSliderLabel.container,
|
|
value: defaultVolume,
|
|
max:
|
|
this.data.audioBooster && !this.data.syncVolume ? maxAudioVolume : 100,
|
|
});
|
|
this.translationVolumeSlider.hidden = this.votButton.status !== "success";
|
|
|
|
this.votMenu.bodyContainer.append(
|
|
this.languagePairSelect.container,
|
|
this.subtitlesSelect.container,
|
|
this.videoVolumeSlider.container,
|
|
this.translationVolumeSlider.container,
|
|
);
|
|
|
|
// #endregion VOT Menu Body
|
|
// #endregion VOT Menu
|
|
return this;
|
|
}
|
|
|
|
initUIEvents() {
|
|
if (!this.isInitialized()) {
|
|
throw new Error("[VOT] OverlayView isn't initialized");
|
|
}
|
|
|
|
this.abortController = new AbortController();
|
|
const signal = this.abortController.signal;
|
|
this.checkerUnsubscribe?.();
|
|
this.checkerUnsubscribe = this.intervalIdleChecker.subscribe(() => {
|
|
this.onCheckerTick();
|
|
});
|
|
|
|
// #region [Events] VOT Button
|
|
// Prevent button click events from propagating.
|
|
this.votButton.container.addEventListener(
|
|
"click",
|
|
(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
e.stopImmediatePropagation();
|
|
},
|
|
{ signal },
|
|
);
|
|
|
|
// Keyboard support for custom elements.
|
|
const activateOnKey = (handler: () => void) => (e: KeyboardEvent) => {
|
|
if (e.key === "Enter" || e.key === " ") {
|
|
e.preventDefault();
|
|
handler();
|
|
}
|
|
};
|
|
const isPrimaryActionPointer = (event: PointerEvent) =>
|
|
event.isPrimary && event.button === 0;
|
|
|
|
// Quick settings popover state helpers.
|
|
const setMenuOpen = (
|
|
open: boolean,
|
|
{ returnFocusToToggle = false }: { returnFocusToToggle?: boolean } = {},
|
|
) => {
|
|
if (!this.isInitialized()) return;
|
|
|
|
this.votMenu.hidden = !open;
|
|
this.votButton.menuButton.setAttribute("aria-expanded", open.toString());
|
|
|
|
// The translate button tooltip is helpful when the menu is closed, but
|
|
// becomes visual noise when the menu is open.
|
|
if (this.votButtonTooltip) {
|
|
this.votButtonTooltip.hidden =
|
|
open || this.votButton.direction === "row";
|
|
}
|
|
|
|
if (open) {
|
|
queueMicrotask(() => this.openSettingsButton?.focus?.());
|
|
} else if (returnFocusToToggle) {
|
|
queueMicrotask(() => this.votButton.menuButton.focus?.());
|
|
} else {
|
|
this.votButton.menuButton.blur();
|
|
}
|
|
};
|
|
|
|
const toggleMenu = () => setMenuOpen(this.votMenu.hidden);
|
|
const closeMenu = (returnFocusToToggle = false) =>
|
|
setMenuOpen(false, { returnFocusToToggle });
|
|
|
|
this.votButton.translateButton.addEventListener(
|
|
"pointerdown",
|
|
(event) => {
|
|
if (!isPrimaryActionPointer(event)) return;
|
|
closeMenu();
|
|
this.events["click:translate"].dispatch();
|
|
},
|
|
{ signal },
|
|
);
|
|
|
|
this.votButton.translateButton.addEventListener(
|
|
"keydown",
|
|
activateOnKey(() => {
|
|
closeMenu();
|
|
this.events["click:translate"].dispatch();
|
|
}),
|
|
{ signal },
|
|
);
|
|
|
|
this.votButton.pipButton.addEventListener(
|
|
"pointerdown",
|
|
(event) => {
|
|
if (!isPrimaryActionPointer(event)) return;
|
|
closeMenu();
|
|
this.events["click:pip"].dispatch();
|
|
},
|
|
{ signal },
|
|
);
|
|
this.votButton.pipButton.addEventListener(
|
|
"keydown",
|
|
activateOnKey(() => {
|
|
closeMenu();
|
|
this.events["click:pip"].dispatch();
|
|
}),
|
|
{ signal },
|
|
);
|
|
|
|
this.votButton.menuButton.addEventListener(
|
|
"pointerdown",
|
|
(e) => {
|
|
if (!isPrimaryActionPointer(e)) return;
|
|
e.preventDefault();
|
|
toggleMenu();
|
|
},
|
|
{ signal },
|
|
);
|
|
this.votButton.menuButton.addEventListener(
|
|
"keydown",
|
|
activateOnKey(toggleMenu),
|
|
{ signal },
|
|
);
|
|
|
|
// #region [Events] VOT Button Dragging
|
|
// Pointer capture keeps drag updates routed to the button even when the
|
|
// pointer leaves the overlay bounds.
|
|
const touchAction = "none";
|
|
this.votButton.container.style.touchAction = touchAction;
|
|
// `touch-action` is not inherited, so ensure child segments are also covered.
|
|
this.votButton.translateButton.style.touchAction = touchAction;
|
|
this.votButton.pipButton.style.touchAction = touchAction;
|
|
this.votButton.menuButton.style.touchAction = touchAction;
|
|
|
|
this.votButton.container.addEventListener("pointerdown", this.onDragStart, {
|
|
signal,
|
|
});
|
|
this.votButton.container.addEventListener(
|
|
"pointermove",
|
|
this.onPointerMove,
|
|
{
|
|
signal,
|
|
},
|
|
);
|
|
this.votButton.container.addEventListener("pointerup", this.onDragEnd, {
|
|
signal,
|
|
});
|
|
this.votButton.container.addEventListener("pointercancel", this.onDragEnd, {
|
|
signal,
|
|
});
|
|
this.votButton.container.addEventListener(
|
|
"lostpointercapture",
|
|
this.onDragEnd,
|
|
{ signal },
|
|
);
|
|
|
|
// #endregion [Events] VOT Button Dragging
|
|
// #endregion [Events] VOT Button
|
|
// #region [Events] VOT Menu
|
|
this.votMenu.container.addEventListener(
|
|
"click",
|
|
(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
e.stopImmediatePropagation();
|
|
},
|
|
{ signal },
|
|
);
|
|
|
|
// don't change mousedown, otherwise it may break on youtube
|
|
for (const event of ["pointerdown", "mousedown"]) {
|
|
this.votMenu.container.addEventListener(
|
|
event,
|
|
(e) => {
|
|
e.stopImmediatePropagation();
|
|
},
|
|
{ signal },
|
|
);
|
|
}
|
|
|
|
// Close the quick-settings menu when clicking outside.
|
|
// Capture phase ensures we run even if the host page stops bubbling.
|
|
document.addEventListener(
|
|
"pointerdown",
|
|
(e) => {
|
|
if (this.votMenu.hidden) return;
|
|
|
|
const target = e.target as Node | null;
|
|
const path =
|
|
typeof e.composedPath === "function"
|
|
? (e.composedPath() as unknown as EventTarget[])
|
|
: [];
|
|
|
|
const isInsideMenu =
|
|
(target && this.votMenu.container.contains(target)) ||
|
|
path.includes(this.votMenu.container);
|
|
const isInsideToggle =
|
|
(target && this.votButton.menuButton.contains(target)) ||
|
|
path.includes(this.votButton.menuButton);
|
|
const isInsideButton =
|
|
(target && this.votButton.container.contains(target)) ||
|
|
path.includes(this.votButton.container);
|
|
|
|
// Keep menu open while interacting with dialogs spawned from it
|
|
// (language picker, etc.).
|
|
const isInsideDialog = path.some(
|
|
(node) =>
|
|
node instanceof HTMLElement &&
|
|
node.classList.contains("vot-dialog-container"),
|
|
);
|
|
|
|
if (
|
|
isInsideMenu ||
|
|
isInsideToggle ||
|
|
isInsideButton ||
|
|
isInsideDialog
|
|
) {
|
|
return;
|
|
}
|
|
|
|
closeMenu(false);
|
|
},
|
|
{ signal, capture: true, passive: true },
|
|
);
|
|
|
|
// Escape closes the menu when focused inside it.
|
|
// NOTE: We keep the WAI-ARIA pattern (return focus to the toggle) only
|
|
// when the user is in *keyboard navigation* mode (Tab). Otherwise, ESC is
|
|
// treated as a quick-dismiss and we blur the toggle so the auto-hide timer
|
|
// can work as expected.
|
|
//
|
|
// This fixes: "ESC close doesn't auto-hide after delay; works only when
|
|
// using the close button".
|
|
this.votMenu.container.addEventListener(
|
|
"keydown",
|
|
(e) => {
|
|
if (e.key !== "Escape") return;
|
|
|
|
const keyboardNav =
|
|
document.documentElement.classList.contains("vot-keyboard-nav");
|
|
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
|
|
closeMenu(keyboardNav);
|
|
|
|
// Closing via keyboard doesn't trigger pointerleave/focusout reliably,
|
|
// so we manually queue overlay auto-hide when the overlay isn't hovered.
|
|
const hovered =
|
|
this.votButton.container.matches(":hover") ||
|
|
this.votMenu.container.matches(":hover");
|
|
|
|
if (!hovered) {
|
|
this.videoHandler?.overlayVisibility?.queueAutoHide?.();
|
|
}
|
|
},
|
|
{ signal },
|
|
);
|
|
|
|
// #region [Events] VOT Menu Header
|
|
this.downloadTranslationButton.addEventListener("click", () => {
|
|
this.events["click:downloadTranslation"].dispatch();
|
|
});
|
|
|
|
this.downloadSubtitlesButton.addEventListener(
|
|
"click",
|
|
() => {
|
|
this.events["click:downloadSubtitles"].dispatch();
|
|
},
|
|
{ signal },
|
|
);
|
|
|
|
this.openSettingsButton.addEventListener(
|
|
"click",
|
|
() => {
|
|
closeMenu();
|
|
this.events["click:settings"].dispatch();
|
|
},
|
|
{ signal },
|
|
);
|
|
|
|
// #endregion [Events] VOT Menu Header
|
|
// #region [Events] VOT Menu Body
|
|
this.languagePairSelect.fromSelect.addEventListener(
|
|
"selectItem",
|
|
(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);
|
|
},
|
|
);
|
|
|
|
this.languagePairSelect.toSelect.addEventListener(
|
|
"selectItem",
|
|
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);
|
|
}
|
|
|
|
// 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.
|
|
if (
|
|
this.data.enabledDontTranslateLanguages &&
|
|
Array.isArray(this.data.dontTranslateLanguages) &&
|
|
this.data.dontTranslateLanguages.length === 1 &&
|
|
prevResponseLanguage !== language &&
|
|
typeof prevResponseLanguage === "string" &&
|
|
this.data.dontTranslateLanguages[0] === prevResponseLanguage
|
|
) {
|
|
this.data.dontTranslateLanguages = [language];
|
|
await votStorage.set(
|
|
"dontTranslateLanguages",
|
|
this.data.dontTranslateLanguages,
|
|
);
|
|
}
|
|
this.events["select:toLanguage"].dispatch(language);
|
|
},
|
|
);
|
|
|
|
this.subtitlesSelect.addEventListener("beforeOpen", async (dialog) => {
|
|
if (!this.videoHandler?.videoData) {
|
|
return;
|
|
}
|
|
|
|
const subtitleLanguage = this.videoHandler.getPreferredSubtitlesLanguage(
|
|
this.videoHandler.videoData.detectedLanguage,
|
|
this.videoHandler.videoData.responseLanguage,
|
|
);
|
|
if (!subtitleLanguage) {
|
|
return;
|
|
}
|
|
|
|
const cacheKey = this.videoHandler.getSubtitlesCacheKey(
|
|
this.videoHandler.videoData.videoId,
|
|
this.videoHandler.videoData.detectedLanguage,
|
|
subtitleLanguage,
|
|
);
|
|
if (this.videoHandler.subtitlesCacheKey === cacheKey) {
|
|
return;
|
|
}
|
|
|
|
if (this.videoHandler.cacheManager.getSubtitles(cacheKey) !== undefined) {
|
|
await this.videoHandler.ensureSubtitlesForCurrentLangPair();
|
|
return;
|
|
}
|
|
|
|
const prevLoading = this.votButton?.loading ?? false;
|
|
if (this.votButton) {
|
|
this.votButton.loading = true;
|
|
}
|
|
const loadingEl = ui.createInlineLoader();
|
|
loadingEl.style.margin = "0 auto";
|
|
dialog.footerContainer.appendChild(loadingEl);
|
|
try {
|
|
await this.videoHandler.ensureSubtitlesForCurrentLangPair();
|
|
} finally {
|
|
loadingEl.remove();
|
|
if (this.votButton) {
|
|
this.votButton.loading = prevLoading;
|
|
}
|
|
}
|
|
});
|
|
|
|
this.subtitlesSelect.addEventListener("selectItem", (data) => {
|
|
this.events["select:subtitles"].dispatch(data);
|
|
});
|
|
|
|
this.videoVolumeSlider.addEventListener("input", (value, fromSetter) => {
|
|
if (this.videoVolumeSliderLabel) {
|
|
this.videoVolumeSliderLabel.value = value;
|
|
}
|
|
if (fromSetter) {
|
|
return;
|
|
}
|
|
|
|
this.events["input:videoVolume"].dispatch(value);
|
|
});
|
|
|
|
this.translationVolumeSlider.addEventListener(
|
|
"input",
|
|
(value, fromSetter) => {
|
|
if (this.translationVolumeSliderLabel) {
|
|
this.translationVolumeSliderLabel.value = value;
|
|
}
|
|
if (this.data.defaultVolume !== value) {
|
|
this.data.defaultVolume = value;
|
|
this.scheduleDefaultVolumePersist();
|
|
}
|
|
if (fromSetter) {
|
|
return;
|
|
}
|
|
|
|
this.events["input:translationVolume"].dispatch(value);
|
|
},
|
|
);
|
|
|
|
// #endregion [Events] VOT Menu Body
|
|
// #endregion [Events] VOT Menu
|
|
return this;
|
|
}
|
|
|
|
updateButtonLayout(position: Position, direction: Direction) {
|
|
if (!this.isInitialized()) {
|
|
return this;
|
|
}
|
|
|
|
this.votMenu.position = position;
|
|
|
|
this.votButton.position = position;
|
|
this.votButton.direction = direction;
|
|
|
|
this.votButtonTooltip.hidden = direction === "row";
|
|
this.votButtonTooltip.setPosition(this.votButton.tooltipPos);
|
|
|
|
return this;
|
|
}
|
|
|
|
moveButton(percentX: number) {
|
|
if (!this.isInitialized()) {
|
|
return this;
|
|
}
|
|
|
|
const isBigContainer = this.dragIsBigContainer ?? this.isBigContainer;
|
|
const position = VOTButton.calcPosition(percentX, isBigContainer);
|
|
if (position === this.votButton.position) {
|
|
return this;
|
|
}
|
|
|
|
const direction = VOTButton.calcDirection(position);
|
|
this.data.buttonPos = position;
|
|
this.updateButtonLayout(position, direction);
|
|
|
|
return this;
|
|
}
|
|
|
|
private startDragSession(
|
|
clientX: number,
|
|
clientY: number,
|
|
activitySource: string,
|
|
): void {
|
|
this.dragCandidate = true;
|
|
this.dragging = false;
|
|
this.dragStartX = clientX;
|
|
this.dragStartY = clientY;
|
|
this.currentClientX = clientX;
|
|
|
|
this.containerRect = this.root.getBoundingClientRect();
|
|
this.dragIsBigContainer = this.isBigContainer;
|
|
this.dragDirty = false;
|
|
this.intervalIdleChecker.markActivity(activitySource);
|
|
this.intervalIdleChecker.requestImmediateTick();
|
|
}
|
|
|
|
private queueDragTick(activitySource: string): void {
|
|
if (this.dragDirty) {
|
|
return;
|
|
}
|
|
|
|
this.dragDirty = true;
|
|
this.intervalIdleChecker.markActivity(activitySource);
|
|
this.intervalIdleChecker.requestImmediateTick();
|
|
}
|
|
|
|
private updateDragFromMove(
|
|
clientX: number,
|
|
clientY: number,
|
|
activitySource: string,
|
|
): void {
|
|
this.currentClientX = clientX;
|
|
|
|
if (!this.dragCandidate) return;
|
|
|
|
if (!this.dragging) {
|
|
const dx = Math.abs(this.currentClientX - this.dragStartX);
|
|
const dy = Math.abs(clientY - this.dragStartY);
|
|
if (dx + dy >= this.dragThresholdPx) {
|
|
this.dragging = true;
|
|
}
|
|
}
|
|
|
|
if (!this.dragging) {
|
|
return;
|
|
}
|
|
|
|
this.queueDragTick(activitySource);
|
|
}
|
|
|
|
onDragStart = (event: PointerEvent) => {
|
|
// Only start drag on the primary pointer and the "primary" button.
|
|
if (!event.isPrimary || event.button !== 0) return;
|
|
|
|
event.preventDefault();
|
|
this.activePointerId = event.pointerId;
|
|
|
|
this.startDragSession(event.clientX, event.clientY, "overlay-pointer-down");
|
|
};
|
|
|
|
onPointerMove = (event: PointerEvent) => {
|
|
if (this.activePointerId !== event.pointerId) {
|
|
return;
|
|
}
|
|
|
|
const wasDragging = this.dragging;
|
|
|
|
this.updateDragFromMove(
|
|
event.clientX,
|
|
event.clientY,
|
|
"overlay-pointer-move",
|
|
);
|
|
|
|
if (!wasDragging && this.dragging) {
|
|
try {
|
|
this.votButton?.container.setPointerCapture(event.pointerId);
|
|
} catch {
|
|
// ignore; drag still works via regular pointer events
|
|
}
|
|
}
|
|
|
|
if (this.dragging) {
|
|
event.preventDefault();
|
|
}
|
|
};
|
|
|
|
private readonly applyDragFromState = () => {
|
|
if (!this.dragging || !this.dragDirty || !this.containerRect) return;
|
|
|
|
const width = this.containerRect.width;
|
|
if (!(width > 0 && Number.isFinite(width))) {
|
|
return;
|
|
}
|
|
|
|
this.dragDirty = false;
|
|
const x = this.currentClientX - this.containerRect.left;
|
|
const clampedX = Math.max(0, Math.min(x, width));
|
|
const percentX = (clampedX / width) * 100;
|
|
|
|
this.moveButton(percentX);
|
|
};
|
|
|
|
private readonly onCheckerTick = () => {
|
|
this.applyDragFromState();
|
|
};
|
|
|
|
onDragEnd = (event?: PointerEvent) => {
|
|
if (
|
|
event &&
|
|
this.activePointerId !== null &&
|
|
event.pointerId !== this.activePointerId
|
|
) {
|
|
return;
|
|
}
|
|
|
|
const pointerId = this.activePointerId;
|
|
if (pointerId !== null) {
|
|
try {
|
|
if (this.votButton?.container.hasPointerCapture(pointerId)) {
|
|
this.votButton.container.releasePointerCapture(pointerId);
|
|
}
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
|
|
this.applyDragFromState();
|
|
|
|
const isBigContainer = this.dragIsBigContainer ?? this.isBigContainer;
|
|
if (this.dragging && isBigContainer && this.data.buttonPos) {
|
|
void votStorage.set("buttonPos", this.data.buttonPos);
|
|
}
|
|
|
|
this.dragging = false;
|
|
this.dragCandidate = false;
|
|
this.dragDirty = false;
|
|
this.containerRect = null;
|
|
this.dragIsBigContainer = null;
|
|
this.activePointerId = null;
|
|
};
|
|
|
|
updateButtonOpacity(opacity: number) {
|
|
if (!this.isInitialized() || !this.votMenu.hidden) {
|
|
return this;
|
|
}
|
|
|
|
// Avoid redundant style writes on high-frequency interaction events.
|
|
if (Math.abs(this.votButton.opacity - opacity) > 0.01) {
|
|
this.votButton.opacity = opacity;
|
|
}
|
|
return this;
|
|
}
|
|
|
|
private doReleaseUI(): void {
|
|
this.votButton?.remove();
|
|
this.votMenu?.remove();
|
|
this.votButtonTooltip?.release();
|
|
destroyShadowMount(this.overlayMount);
|
|
this.overlayMount = undefined;
|
|
}
|
|
|
|
private doReleaseUIEvents(): void {
|
|
this.abortController?.abort();
|
|
this.abortController = null;
|
|
this.checkerUnsubscribe?.();
|
|
this.checkerUnsubscribe = null;
|
|
|
|
this.onDragEnd();
|
|
this.flushDefaultVolumePersist();
|
|
|
|
for (const event of Object.values(this.events)) {
|
|
event.clear();
|
|
}
|
|
}
|
|
|
|
release() {
|
|
if (!this.isInitialized()) {
|
|
return this;
|
|
}
|
|
|
|
// Release events first to prevent late handlers from touching removed DOM.
|
|
this.doReleaseUIEvents();
|
|
this.doReleaseUI();
|
|
|
|
this.initialized = false;
|
|
return this;
|
|
}
|
|
|
|
get isBigContainer() {
|
|
const widthFromVideo =
|
|
this.videoHandler?.video?.getBoundingClientRect?.().width;
|
|
if (typeof widthFromVideo === "number" && Number.isFinite(widthFromVideo)) {
|
|
return widthFromVideo > OverlayView.BIG_CONTAINER_WIDTH_PX;
|
|
}
|
|
|
|
const widthFromContainer =
|
|
this.videoHandler?.container?.getBoundingClientRect?.().width;
|
|
if (
|
|
typeof widthFromContainer === "number" &&
|
|
Number.isFinite(widthFromContainer)
|
|
) {
|
|
return widthFromContainer > OverlayView.BIG_CONTAINER_WIDTH_PX;
|
|
}
|
|
|
|
return this.root.clientWidth > OverlayView.BIG_CONTAINER_WIDTH_PX;
|
|
}
|
|
|
|
get pipButtonVisible() {
|
|
return isPiPAvailable() && !!this.data.showPiPButton;
|
|
}
|
|
}
|
|
|
|
function isSidePosition(position: Position): position is "left" | "right" {
|
|
return position === "left" || position === "right";
|
|
}
|