mirror of
https://github.com/ilyhalight/voice-over-translation.git
synced 2026-04-28 03:20:22 +00:00
refractor: optimize smartWrap & simplify UI/events
This commit is contained in:
parent
be0a7029dd
commit
2aa70d15b3
33 changed files with 727 additions and 701 deletions
Binary file not shown.
BIN
dist-ext/vot-extension-chrome-1.11.5.7.zip
Normal file
BIN
dist-ext/vot-extension-chrome-1.11.5.7.zip
Normal file
Binary file not shown.
Binary file not shown.
BIN
dist-ext/vot-extension-firefox-1.11.5.7.xpi
Normal file
BIN
dist-ext/vot-extension-firefox-1.11.5.7.xpi
Normal file
Binary file not shown.
|
|
@ -3,8 +3,8 @@
|
||||||
"vot-extension@firefox": {
|
"vot-extension@firefox": {
|
||||||
"updates": [
|
"updates": [
|
||||||
{
|
{
|
||||||
"version": "1.11.5.6",
|
"version": "1.11.5.7",
|
||||||
"update_link": "https://raw.githubusercontent.com/ilyhalight/voice-over-translation/master/dist-ext/vot-extension-firefox-1.11.5.6.xpi"
|
"update_link": "https://raw.githubusercontent.com/ilyhalight/voice-over-translation/master/dist-ext/vot-extension-firefox-1.11.5.7.xpi"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
114
dist/vot-min.user.js
vendored
114
dist/vot-min.user.js
vendored
File diff suppressed because one or more lines are too long
479
dist/vot.user.js
vendored
479
dist/vot.user.js
vendored
File diff suppressed because it is too large
Load diff
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "[VOT] - Voice Over Translation",
|
"name": "[VOT] - Voice Over Translation",
|
||||||
"description": "A small extension that adds a Yandex Browser video translation to other browsers",
|
"description": "A small extension that adds a Yandex Browser video translation to other browsers",
|
||||||
"version": "1.11.5.6",
|
"version": "1.11.5.7",
|
||||||
"author": "Toil, SashaXser, MrSoczekXD, mynovelhost, sodapng",
|
"author": "Toil, SashaXser, MrSoczekXD, mynovelhost, sodapng",
|
||||||
"namespace": "vot",
|
"namespace": "vot",
|
||||||
"icon": "https://translate.yandex.ru/icons/favicon.ico",
|
"icon": "https://translate.yandex.ru/icons/favicon.ico",
|
||||||
|
|
|
||||||
|
|
@ -63,12 +63,57 @@ type TokenTextBuffer = {
|
||||||
|
|
||||||
const STRONG_BREAK_RE = /[.!?…:;][)"'\]»”]*\s*$/u;
|
const STRONG_BREAK_RE = /[.!?…:;][)"'\]»”]*\s*$/u;
|
||||||
const SOFT_BREAK_RE = /[,،、][)"'\]»”]*\s*$/u;
|
const SOFT_BREAK_RE = /[,،、][)"'\]»”]*\s*$/u;
|
||||||
const DISCOURAGED_LINE_START_RE = /^\s*[\p{Pe}\p{Pf},.;:!?%‰…]/u;
|
const WHITESPACE_CHAR_RE = /\s/u;
|
||||||
const DISCOURAGED_LINE_END_RE = /\s*[\p{Ps}\p{Pi}¿¡([{«“"'`-]\s*$/u;
|
const DISCOURAGED_LINE_START_CHAR_RE = /^[\p{Pe}\p{Pf},.;:!?%\u2030\u2026]$/u;
|
||||||
|
const DISCOURAGED_LINE_END_CHAR_RE =
|
||||||
|
/^[\p{Ps}\p{Pi}\u00BF\u00A1([{\u00AB\u201C"'`-]$/u;
|
||||||
|
|
||||||
const normalizeTokenText = (text: string): string =>
|
const normalizeTokenText = (text: string): string =>
|
||||||
text.replaceAll(/\s+/gu, " ").trim();
|
text.replaceAll(/\s+/gu, " ").trim();
|
||||||
|
|
||||||
|
const getNextChar = (
|
||||||
|
text: string,
|
||||||
|
index: number,
|
||||||
|
): {
|
||||||
|
char: string;
|
||||||
|
nextIndex: number;
|
||||||
|
} | null => {
|
||||||
|
if (index >= text.length) return null;
|
||||||
|
|
||||||
|
const codePoint = text.codePointAt(index);
|
||||||
|
if (codePoint === undefined) return null;
|
||||||
|
|
||||||
|
const char = String.fromCodePoint(codePoint);
|
||||||
|
return {
|
||||||
|
char,
|
||||||
|
nextIndex: index + char.length,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPreviousChar = (
|
||||||
|
text: string,
|
||||||
|
index: number,
|
||||||
|
): {
|
||||||
|
char: string;
|
||||||
|
previousIndex: number;
|
||||||
|
} | null => {
|
||||||
|
if (index <= 0) return null;
|
||||||
|
|
||||||
|
let start = index - 1;
|
||||||
|
const lastCodeUnit = text.charCodeAt(start);
|
||||||
|
if (lastCodeUnit >= 0xdc00 && lastCodeUnit <= 0xdfff && start > 0) {
|
||||||
|
start -= 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
char: text.slice(start, index),
|
||||||
|
previousIndex: start,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const isWhitespaceChar = (char: string): boolean =>
|
||||||
|
WHITESPACE_CHAR_RE.test(char);
|
||||||
|
|
||||||
const buildTokenTextBuffer = (tokens: SubtitleToken[]): TokenTextBuffer => {
|
const buildTokenTextBuffer = (tokens: SubtitleToken[]): TokenTextBuffer => {
|
||||||
const offsets = new Array(tokens.length + 1);
|
const offsets = new Array(tokens.length + 1);
|
||||||
offsets[0] = 0;
|
offsets[0] = 0;
|
||||||
|
|
@ -100,11 +145,45 @@ const resolveBoundary = (text: string): BoundaryKind => {
|
||||||
return "neutral";
|
return "neutral";
|
||||||
};
|
};
|
||||||
|
|
||||||
const startsWithDiscouragedLineStart = (text: string): boolean =>
|
const rangeStartsWithDiscouragedLineStart = (
|
||||||
DISCOURAGED_LINE_START_RE.test(text);
|
buffer: TokenTextBuffer,
|
||||||
|
startToken: number,
|
||||||
|
endToken: number,
|
||||||
|
): boolean => {
|
||||||
|
let index = buffer.offsets[startToken];
|
||||||
|
const end = buffer.offsets[endToken];
|
||||||
|
|
||||||
const endsWithDiscouragedLineEnd = (text: string): boolean =>
|
while (index < end) {
|
||||||
DISCOURAGED_LINE_END_RE.test(text);
|
const next = getNextChar(buffer.fullText, index);
|
||||||
|
if (!next) return false;
|
||||||
|
if (!isWhitespaceChar(next.char)) {
|
||||||
|
return DISCOURAGED_LINE_START_CHAR_RE.test(next.char);
|
||||||
|
}
|
||||||
|
index = next.nextIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const rangeEndsWithDiscouragedLineEnd = (
|
||||||
|
buffer: TokenTextBuffer,
|
||||||
|
startToken: number,
|
||||||
|
endToken: number,
|
||||||
|
): boolean => {
|
||||||
|
const start = buffer.offsets[startToken];
|
||||||
|
let index = buffer.offsets[endToken];
|
||||||
|
|
||||||
|
while (index > start) {
|
||||||
|
const previous = getPreviousChar(buffer.fullText, index);
|
||||||
|
if (!previous) return false;
|
||||||
|
if (!isWhitespaceChar(previous.char)) {
|
||||||
|
return DISCOURAGED_LINE_END_CHAR_RE.test(previous.char);
|
||||||
|
}
|
||||||
|
index = previous.previousIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
const isWordToken = (token: SubtitleToken | undefined): boolean =>
|
const isWordToken = (token: SubtitleToken | undefined): boolean =>
|
||||||
Boolean(token?.isWordLike && token.text.trim());
|
Boolean(token?.isWordLike && token.text.trim());
|
||||||
|
|
@ -170,6 +249,7 @@ const buildSliceFromWord = (
|
||||||
tokens: SubtitleToken[],
|
tokens: SubtitleToken[],
|
||||||
wordTokenIndex: number,
|
wordTokenIndex: number,
|
||||||
textBuffer: TokenTextBuffer,
|
textBuffer: TokenTextBuffer,
|
||||||
|
includeMetrics: boolean,
|
||||||
): WordSlice => {
|
): WordSlice => {
|
||||||
let startToken = wordTokenIndex;
|
let startToken = wordTokenIndex;
|
||||||
while (
|
while (
|
||||||
|
|
@ -197,21 +277,24 @@ const buildSliceFromWord = (
|
||||||
breakAfterTokenIndex: endToken - 1,
|
breakAfterTokenIndex: endToken - 1,
|
||||||
startToken,
|
startToken,
|
||||||
endToken,
|
endToken,
|
||||||
charLength: normalizeTokenText(text).length,
|
charLength: includeMetrics ? normalizeTokenText(text).length : 0,
|
||||||
startMs: getRangeStartMs(tokens, startToken, endToken),
|
startMs: includeMetrics ? getRangeStartMs(tokens, startToken, endToken) : 0,
|
||||||
endMs: getRangeEndMs(tokens, startToken, endToken),
|
endMs: includeMetrics ? getRangeEndMs(tokens, startToken, endToken) : 0,
|
||||||
boundary: resolveBoundary(text),
|
boundary: resolveBoundary(text),
|
||||||
forcesLineBreak: false,
|
forcesLineBreak: false,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export function buildWordSlices(tokens: SubtitleToken[]): {
|
function buildWordSlicesFromBuffer(
|
||||||
|
tokens: SubtitleToken[],
|
||||||
|
textBuffer: TokenTextBuffer,
|
||||||
|
collectKey: boolean,
|
||||||
|
): {
|
||||||
slices: WordSlice[];
|
slices: WordSlice[];
|
||||||
key: string;
|
key: string;
|
||||||
} {
|
} {
|
||||||
const textBuffer = buildTokenTextBuffer(tokens);
|
|
||||||
const slices: WordSlice[] = [];
|
const slices: WordSlice[] = [];
|
||||||
const keyParts: string[] = [];
|
const keyParts: string[] | null = collectKey ? [] : null;
|
||||||
|
|
||||||
let index = 0;
|
let index = 0;
|
||||||
while (index < tokens.length) {
|
while (index < tokens.length) {
|
||||||
|
|
@ -224,7 +307,7 @@ export function buildWordSlices(tokens: SubtitleToken[]): {
|
||||||
if (token.text === "\n") {
|
if (token.text === "\n") {
|
||||||
const slice = createForcedBreakSlice(tokens, index);
|
const slice = createForcedBreakSlice(tokens, index);
|
||||||
slices.push(slice);
|
slices.push(slice);
|
||||||
keyParts.push("\n");
|
keyParts?.push("\n");
|
||||||
index += 1;
|
index += 1;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -234,9 +317,9 @@ export function buildWordSlices(tokens: SubtitleToken[]): {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const slice = buildSliceFromWord(tokens, index, textBuffer);
|
const slice = buildSliceFromWord(tokens, index, textBuffer, collectKey);
|
||||||
slices.push(slice);
|
slices.push(slice);
|
||||||
keyParts.push(normalizeTokenText(slice.text));
|
keyParts?.push(normalizeTokenText(slice.text));
|
||||||
index = slice.breakAfterTokenIndex + 1;
|
index = slice.breakAfterTokenIndex + 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -248,21 +331,28 @@ export function buildWordSlices(tokens: SubtitleToken[]): {
|
||||||
breakAfterTokenIndex: tokens.length - 1,
|
breakAfterTokenIndex: tokens.length - 1,
|
||||||
startToken: 0,
|
startToken: 0,
|
||||||
endToken: tokens.length,
|
endToken: tokens.length,
|
||||||
charLength: normalizeTokenText(text).length,
|
charLength: collectKey ? normalizeTokenText(text).length : 0,
|
||||||
startMs: getRangeStartMs(tokens, 0, tokens.length),
|
startMs: collectKey ? getRangeStartMs(tokens, 0, tokens.length) : 0,
|
||||||
endMs: getRangeEndMs(tokens, 0, tokens.length),
|
endMs: collectKey ? getRangeEndMs(tokens, 0, tokens.length) : 0,
|
||||||
boundary: resolveBoundary(text),
|
boundary: resolveBoundary(text),
|
||||||
forcesLineBreak: false,
|
forcesLineBreak: false,
|
||||||
});
|
});
|
||||||
keyParts.push(normalizeTokenText(text));
|
keyParts?.push(normalizeTokenText(text));
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
slices,
|
slices,
|
||||||
key: keyParts.join("|"),
|
key: keyParts?.join("|") ?? "",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function buildWordSlices(tokens: SubtitleToken[]): {
|
||||||
|
slices: WordSlice[];
|
||||||
|
key: string;
|
||||||
|
} {
|
||||||
|
return buildWordSlicesFromBuffer(tokens, buildTokenTextBuffer(tokens), true);
|
||||||
|
}
|
||||||
|
|
||||||
export function measureWordSlices(
|
export function measureWordSlices(
|
||||||
wordSlices: WordSlice[],
|
wordSlices: WordSlice[],
|
||||||
measureText: MeasureText,
|
measureText: MeasureText,
|
||||||
|
|
@ -462,18 +552,18 @@ const findFallbackBreakAfterTokenIndex = (
|
||||||
tokens.length,
|
tokens.length,
|
||||||
measureText,
|
measureText,
|
||||||
);
|
);
|
||||||
const firstText = getBufferedTokenText(textBuffer, 0, firstEndToken);
|
|
||||||
const secondText = getBufferedTokenText(
|
|
||||||
textBuffer,
|
|
||||||
secondStartToken,
|
|
||||||
tokens.length,
|
|
||||||
);
|
|
||||||
const score =
|
const score =
|
||||||
Math.max(0, firstWidth - maxWidthPx) * 12 +
|
Math.max(0, firstWidth - maxWidthPx) * 12 +
|
||||||
Math.max(0, secondWidth - maxWidthPx) * 12 +
|
Math.max(0, secondWidth - maxWidthPx) * 12 +
|
||||||
Math.abs(secondWidth - firstWidth) * 0.4 +
|
Math.abs(secondWidth - firstWidth) * 0.4 +
|
||||||
(startsWithDiscouragedLineStart(secondText) ? 260 : 0) +
|
(rangeStartsWithDiscouragedLineStart(
|
||||||
(endsWithDiscouragedLineEnd(firstText) ? 70 : 0);
|
textBuffer,
|
||||||
|
secondStartToken,
|
||||||
|
tokens.length,
|
||||||
|
)
|
||||||
|
? 260
|
||||||
|
: 0) +
|
||||||
|
(rangeEndsWithDiscouragedLineEnd(textBuffer, 0, firstEndToken) ? 70 : 0);
|
||||||
|
|
||||||
if (score < bestScore) {
|
if (score < bestScore) {
|
||||||
bestScore = score;
|
bestScore = score;
|
||||||
|
|
@ -487,8 +577,8 @@ const findFallbackBreakAfterTokenIndex = (
|
||||||
const scoreBreakCandidate = ({
|
const scoreBreakCandidate = ({
|
||||||
firstWidth,
|
firstWidth,
|
||||||
secondWidth,
|
secondWidth,
|
||||||
firstText,
|
lineStartPenalty,
|
||||||
secondText,
|
lineEndPenalty,
|
||||||
firstWordCount,
|
firstWordCount,
|
||||||
secondWordCount,
|
secondWordCount,
|
||||||
maxWidthPx,
|
maxWidthPx,
|
||||||
|
|
@ -496,8 +586,8 @@ const scoreBreakCandidate = ({
|
||||||
}: {
|
}: {
|
||||||
firstWidth: number;
|
firstWidth: number;
|
||||||
secondWidth: number;
|
secondWidth: number;
|
||||||
firstText: string;
|
lineStartPenalty: number;
|
||||||
secondText: string;
|
lineEndPenalty: number;
|
||||||
firstWordCount: number;
|
firstWordCount: number;
|
||||||
secondWordCount: number;
|
secondWordCount: number;
|
||||||
maxWidthPx: number;
|
maxWidthPx: number;
|
||||||
|
|
@ -511,8 +601,6 @@ const scoreBreakCandidate = ({
|
||||||
Math.abs(secondWidth / Math.max(firstWidth, 1) - balanceTarget) * 120;
|
Math.abs(secondWidth / Math.max(firstWidth, 1) - balanceTarget) * 120;
|
||||||
const shortTopPenalty = firstWordCount < 2 ? 80 : 0;
|
const shortTopPenalty = firstWordCount < 2 ? 80 : 0;
|
||||||
const orphanPenalty = secondWordCount < 2 ? 80 : 0;
|
const orphanPenalty = secondWordCount < 2 ? 80 : 0;
|
||||||
const lineStartPenalty = startsWithDiscouragedLineStart(secondText) ? 260 : 0;
|
|
||||||
const lineEndPenalty = endsWithDiscouragedLineEnd(firstText) ? 70 : 0;
|
|
||||||
const boundaryBonus =
|
const boundaryBonus =
|
||||||
boundary === "strong" ? -28 : boundary === "soft" ? -14 : 0;
|
boundary === "strong" ? -28 : boundary === "soft" ? -14 : 0;
|
||||||
|
|
||||||
|
|
@ -554,7 +642,7 @@ export function computeTokenWrapPlan(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const { slices } = buildWordSlices(tokens);
|
const { slices } = buildWordSlicesFromBuffer(tokens, textBuffer, false);
|
||||||
const measurableSlices = slices.filter((slice) => !slice.forcesLineBreak);
|
const measurableSlices = slices.filter((slice) => !slice.forcesLineBreak);
|
||||||
if (!measurableSlices.length) {
|
if (!measurableSlices.length) {
|
||||||
return {
|
return {
|
||||||
|
|
@ -598,17 +686,23 @@ export function computeTokenWrapPlan(
|
||||||
tokens.length,
|
tokens.length,
|
||||||
measureText,
|
measureText,
|
||||||
);
|
);
|
||||||
const firstText = getBufferedTokenText(textBuffer, 0, firstEndToken);
|
|
||||||
const secondText = getBufferedTokenText(
|
|
||||||
textBuffer,
|
|
||||||
secondStartToken,
|
|
||||||
tokens.length,
|
|
||||||
);
|
|
||||||
const score = scoreBreakCandidate({
|
const score = scoreBreakCandidate({
|
||||||
firstWidth,
|
firstWidth,
|
||||||
secondWidth,
|
secondWidth,
|
||||||
firstText,
|
lineStartPenalty: rangeStartsWithDiscouragedLineStart(
|
||||||
secondText,
|
textBuffer,
|
||||||
|
secondStartToken,
|
||||||
|
tokens.length,
|
||||||
|
)
|
||||||
|
? 260
|
||||||
|
: 0,
|
||||||
|
lineEndPenalty: rangeEndsWithDiscouragedLineEnd(
|
||||||
|
textBuffer,
|
||||||
|
0,
|
||||||
|
firstEndToken,
|
||||||
|
)
|
||||||
|
? 70
|
||||||
|
: 0,
|
||||||
firstWordCount: index + 1,
|
firstWordCount: index + 1,
|
||||||
secondWordCount: measurableSlices.length - (index + 1),
|
secondWordCount: measurableSlices.length - (index + 1),
|
||||||
maxWidthPx: safeMaxWidthPx,
|
maxWidthPx: safeMaxWidthPx,
|
||||||
|
|
|
||||||
|
|
@ -140,7 +140,7 @@ export class SubtitlesWidget {
|
||||||
private readonly useVideoFrameCallbacks: boolean;
|
private readonly useVideoFrameCallbacks: boolean;
|
||||||
private videoFrameRequestId: number | null = null;
|
private videoFrameRequestId: number | null = null;
|
||||||
private lastPlaybackTimeMs: number | null = null;
|
private lastPlaybackTimeMs: number | null = null;
|
||||||
private dragDocListenersAttached = false;
|
private dragAbortController: AbortController | null = null;
|
||||||
private lastPositionRefreshTs = 0;
|
private lastPositionRefreshTs = 0;
|
||||||
private readonly positionRefreshIntervalMs = 250;
|
private readonly positionRefreshIntervalMs = 250;
|
||||||
private subtitleMaxWidthPx = 0;
|
private subtitleMaxWidthPx = 0;
|
||||||
|
|
@ -558,27 +558,19 @@ export class SubtitlesWidget {
|
||||||
private bindEvents(): void {
|
private bindEvents(): void {
|
||||||
const { signal } = this.abortController;
|
const { signal } = this.abortController;
|
||||||
const opts = { signal } as AddEventListenerOptions;
|
const opts = { signal } as AddEventListenerOptions;
|
||||||
this.video?.addEventListener("play", this.onPlaybackStateChangeBound, opts);
|
for (const eventName of [
|
||||||
this.video?.addEventListener(
|
"play",
|
||||||
"pause",
|
"pause",
|
||||||
this.onPlaybackStateChangeBound,
|
|
||||||
opts,
|
|
||||||
);
|
|
||||||
this.video?.addEventListener(
|
|
||||||
"seeking",
|
"seeking",
|
||||||
this.onPlaybackStateChangeBound,
|
|
||||||
opts,
|
|
||||||
);
|
|
||||||
this.video?.addEventListener(
|
|
||||||
"seeked",
|
"seeked",
|
||||||
this.onPlaybackStateChangeBound,
|
|
||||||
opts,
|
|
||||||
);
|
|
||||||
this.video?.addEventListener(
|
|
||||||
"ended",
|
"ended",
|
||||||
this.onPlaybackStateChangeBound,
|
] as const) {
|
||||||
opts,
|
this.video?.addEventListener(
|
||||||
);
|
eventName,
|
||||||
|
this.onPlaybackStateChangeBound,
|
||||||
|
opts,
|
||||||
|
);
|
||||||
|
}
|
||||||
this.resizeObserver = new ResizeObserver(() => this.onResize());
|
this.resizeObserver = new ResizeObserver(() => this.onResize());
|
||||||
const resizeTarget =
|
const resizeTarget =
|
||||||
this.container instanceof ShadowRoot
|
this.container instanceof ShadowRoot
|
||||||
|
|
@ -708,21 +700,27 @@ export class SubtitlesWidget {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
private attachDragDocumentListeners(): void {
|
private attachDragDocumentListeners(): void {
|
||||||
if (this.dragDocListenersAttached) return;
|
if (this.dragAbortController) return;
|
||||||
this.dragDocListenersAttached = true;
|
const dragAbortController = new AbortController();
|
||||||
|
const { signal } = dragAbortController;
|
||||||
document.addEventListener("pointermove", this.onPointerMoveBound, {
|
document.addEventListener("pointermove", this.onPointerMoveBound, {
|
||||||
|
signal,
|
||||||
passive: false,
|
passive: false,
|
||||||
capture: true,
|
capture: true,
|
||||||
});
|
});
|
||||||
document.addEventListener("pointerup", this.onPointerUpBound, true);
|
document.addEventListener("pointerup", this.onPointerUpBound, {
|
||||||
document.addEventListener("pointercancel", this.onPointerUpBound, true);
|
signal,
|
||||||
|
capture: true,
|
||||||
|
});
|
||||||
|
document.addEventListener("pointercancel", this.onPointerUpBound, {
|
||||||
|
signal,
|
||||||
|
capture: true,
|
||||||
|
});
|
||||||
|
this.dragAbortController = dragAbortController;
|
||||||
}
|
}
|
||||||
private detachDragDocumentListeners(): void {
|
private detachDragDocumentListeners(): void {
|
||||||
if (!this.dragDocListenersAttached) return;
|
this.dragAbortController?.abort();
|
||||||
this.dragDocListenersAttached = false;
|
this.dragAbortController = null;
|
||||||
document.removeEventListener("pointermove", this.onPointerMoveBound, true);
|
|
||||||
document.removeEventListener("pointerup", this.onPointerUpBound, true);
|
|
||||||
document.removeEventListener("pointercancel", this.onPointerUpBound, true);
|
|
||||||
}
|
}
|
||||||
private onResize(): void {
|
private onResize(): void {
|
||||||
this.syncWidgetMount();
|
this.syncWidgetMount();
|
||||||
|
|
@ -773,9 +771,10 @@ export class SubtitlesWidget {
|
||||||
this.insetCacheReady = true;
|
this.insetCacheReady = true;
|
||||||
}
|
}
|
||||||
private isMobileViewport(): boolean {
|
private isMobileViewport(): boolean {
|
||||||
if (typeof globalThis.matchMedia !== "function") return false;
|
return (
|
||||||
return globalThis.matchMedia("(max-width: 900px) and (pointer: coarse)")
|
globalThis.matchMedia?.("(max-width: 900px) and (pointer: coarse)")
|
||||||
.matches;
|
?.matches ?? false
|
||||||
|
);
|
||||||
}
|
}
|
||||||
private getBottomInsetPreset() {
|
private getBottomInsetPreset() {
|
||||||
const doc = document as Document & {
|
const doc = document as Document & {
|
||||||
|
|
@ -1466,55 +1465,42 @@ export class SubtitlesWidget {
|
||||||
this.strTranslatedTokens = context;
|
this.strTranslatedTokens = context;
|
||||||
return [context, current];
|
return [context, current];
|
||||||
}
|
}
|
||||||
private isTokenSpanElement(el: unknown): el is HTMLSpanElement {
|
private findTokenSpan(
|
||||||
return el instanceof HTMLSpanElement && el.dataset.votToken === "1";
|
candidate: EventTarget | null,
|
||||||
}
|
|
||||||
private findTokenSpanInPath(
|
|
||||||
path: EventTarget[],
|
|
||||||
root: HTMLElement,
|
root: HTMLElement,
|
||||||
): HTMLSpanElement | null {
|
): HTMLSpanElement | null {
|
||||||
for (const node of path) {
|
const element =
|
||||||
if (this.isTokenSpanElement(node) && root.contains(node)) {
|
candidate instanceof Element
|
||||||
return node;
|
? candidate
|
||||||
}
|
: candidate instanceof Text
|
||||||
}
|
? candidate.parentElement
|
||||||
return null;
|
: null;
|
||||||
}
|
const token = element?.closest<HTMLSpanElement>('span[data-vot-token="1"]');
|
||||||
private findTokenSpanByPoint(
|
return token instanceof HTMLSpanElement && root.contains(token)
|
||||||
x: number,
|
? token
|
||||||
y: number,
|
: null;
|
||||||
root: HTMLElement,
|
|
||||||
): HTMLSpanElement | null {
|
|
||||||
const hit = document.elementFromPoint(x, y);
|
|
||||||
if (this.isTokenSpanElement(hit) && root.contains(hit)) {
|
|
||||||
return hit;
|
|
||||||
}
|
|
||||||
if (!(hit instanceof Element)) return null;
|
|
||||||
const closest = hit.closest('span[data-vot-token="1"]');
|
|
||||||
if (closest instanceof HTMLSpanElement && root.contains(closest)) {
|
|
||||||
return closest;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
private resolveTokenSpanFromClick(event: MouseEvent): HTMLSpanElement | null {
|
private resolveTokenSpanFromClick(event: MouseEvent): HTMLSpanElement | null {
|
||||||
const root: HTMLElement | null =
|
const root: HTMLElement | null =
|
||||||
this.subtitlesBlock ?? this.subtitlesContainer;
|
this.subtitlesBlock ?? this.subtitlesContainer;
|
||||||
if (!root) return null;
|
if (!root) return null;
|
||||||
if (this.isTokenSpanElement(event.target) && root.contains(event.target)) {
|
const fromTarget = this.findTokenSpan(event.target, root);
|
||||||
return event.target;
|
if (fromTarget) {
|
||||||
|
return fromTarget;
|
||||||
}
|
}
|
||||||
const path =
|
const path =
|
||||||
typeof event.composedPath === "function" ? event.composedPath() : [];
|
typeof event.composedPath === "function" ? event.composedPath() : [];
|
||||||
const fromPath = this.findTokenSpanInPath(path, root);
|
for (const node of path) {
|
||||||
if (fromPath) {
|
const fromPath = this.findTokenSpan(node, root);
|
||||||
return fromPath;
|
if (fromPath) {
|
||||||
|
return fromPath;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const x = event.clientX;
|
const x = event.clientX;
|
||||||
const y = event.clientY;
|
const y = event.clientY;
|
||||||
if (Number.isFinite(x) && Number.isFinite(y)) {
|
return Number.isFinite(x) && Number.isFinite(y)
|
||||||
return this.findTokenSpanByPoint(x, y, root);
|
? this.findTokenSpan(document.elementFromPoint(x, y), root)
|
||||||
}
|
: null;
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
releaseTooltip(): this {
|
releaseTooltip(): this {
|
||||||
this.tooltipTranslationRequestId += 1;
|
this.tooltipTranslationRequestId += 1;
|
||||||
|
|
@ -1788,14 +1774,6 @@ export class SubtitlesWidget {
|
||||||
}
|
}
|
||||||
return this.measureCtx;
|
return this.measureCtx;
|
||||||
}
|
}
|
||||||
private arraysEqual(a: number[], b: number[]): boolean {
|
|
||||||
if (a === b) return true;
|
|
||||||
if (a.length !== b.length) return false;
|
|
||||||
for (let i = 0; i < a.length; i += 1) {
|
|
||||||
if (a[i] !== b[i]) return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
private recomputeWrapNow(): void {
|
private recomputeWrapNow(): void {
|
||||||
const tokens = this.lastWrapTokens;
|
const tokens = this.lastWrapTokens;
|
||||||
const block = this.subtitlesBlock;
|
const block = this.subtitlesBlock;
|
||||||
|
|
@ -1817,10 +1795,12 @@ export class SubtitlesWidget {
|
||||||
(text) => ctx.measureText(text).width,
|
(text) => ctx.measureText(text).width,
|
||||||
safeMaxWidthPx,
|
safeMaxWidthPx,
|
||||||
);
|
);
|
||||||
const breaksChanged = !this.arraysEqual(
|
const breaksChanged =
|
||||||
next.breakAfterTokenIndices,
|
next.breakAfterTokenIndices.length !==
|
||||||
this.breakAfterTokenIndices,
|
this.breakAfterTokenIndices.length ||
|
||||||
);
|
next.breakAfterTokenIndices.some(
|
||||||
|
(value, index) => value !== this.breakAfterTokenIndices[index],
|
||||||
|
);
|
||||||
if (breaksChanged) {
|
if (breaksChanged) {
|
||||||
this.setBreakAfterTokenIndices(next.breakAfterTokenIndices);
|
this.setBreakAfterTokenIndices(next.breakAfterTokenIndices);
|
||||||
this.resetRenderMemo();
|
this.resetRenderMemo();
|
||||||
|
|
@ -1915,11 +1895,7 @@ export class SubtitlesWidget {
|
||||||
this.applyOpacityStyle();
|
this.applyOpacityStyle();
|
||||||
}
|
}
|
||||||
private stringifyTokens(tokens: SubtitleToken[]): string {
|
private stringifyTokens(tokens: SubtitleToken[]): string {
|
||||||
let out = "";
|
return tokens.map((token) => token.text).join("");
|
||||||
for (const token of tokens) {
|
|
||||||
out += token.text;
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
}
|
||||||
private resolveActiveLine(
|
private resolveActiveLine(
|
||||||
time: number,
|
time: number,
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,6 @@ import { localizationProvider } from "../../localization/localizationProvider";
|
||||||
import type { AccountButtonProps } from "../../types/components/accountButton";
|
import type { AccountButtonProps } from "../../types/components/accountButton";
|
||||||
import UI from "../../ui";
|
import UI from "../../ui";
|
||||||
import { KEY_ICON, REFRESH_ICON } from "../icons";
|
import { KEY_ICON, REFRESH_ICON } from "../icons";
|
||||||
import {
|
|
||||||
addComponentEventListener,
|
|
||||||
getHiddenState,
|
|
||||||
removeComponentEventListener,
|
|
||||||
setHiddenState,
|
|
||||||
} from "./componentShared";
|
|
||||||
|
|
||||||
export default class AccountButton {
|
export default class AccountButton {
|
||||||
container: HTMLElement;
|
container: HTMLElement;
|
||||||
|
|
@ -114,7 +108,7 @@ export default class AccountButton {
|
||||||
type: "click" | "click:secret" | "refresh",
|
type: "click" | "click:secret" | "refresh",
|
||||||
listener: () => void,
|
listener: () => void,
|
||||||
): this {
|
): this {
|
||||||
addComponentEventListener(this.events, type, listener);
|
this.events[type].addListener(listener);
|
||||||
|
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
@ -123,7 +117,7 @@ export default class AccountButton {
|
||||||
type: "click" | "click:secret" | "refresh",
|
type: "click" | "click:secret" | "refresh",
|
||||||
listener: () => void,
|
listener: () => void,
|
||||||
): this {
|
): this {
|
||||||
removeComponentEventListener(this.events, type, listener);
|
this.events[type].removeListener(listener);
|
||||||
|
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
@ -164,10 +158,10 @@ export default class AccountButton {
|
||||||
}
|
}
|
||||||
|
|
||||||
set hidden(isHidden: boolean) {
|
set hidden(isHidden: boolean) {
|
||||||
setHiddenState(this.container, isHidden);
|
this.container.hidden = isHidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
get hidden() {
|
get hidden() {
|
||||||
return getHiddenState(this.container);
|
return this.container.hidden;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,6 @@ import { EventImpl } from "../../core/eventImpl";
|
||||||
import type { CheckboxProps } from "../../types/components/checkbox";
|
import type { CheckboxProps } from "../../types/components/checkbox";
|
||||||
import type { LitHtml } from "../../types/components/shared";
|
import type { LitHtml } from "../../types/components/shared";
|
||||||
import UI from "../../ui";
|
import UI from "../../ui";
|
||||||
import {
|
|
||||||
addComponentEventListener,
|
|
||||||
getHiddenState,
|
|
||||||
removeComponentEventListener,
|
|
||||||
setHiddenState,
|
|
||||||
} from "./componentShared";
|
|
||||||
|
|
||||||
export default class Checkbox {
|
export default class Checkbox {
|
||||||
container: HTMLElement;
|
container: HTMLElement;
|
||||||
|
|
@ -17,9 +11,6 @@ export default class Checkbox {
|
||||||
label: HTMLSpanElement;
|
label: HTMLSpanElement;
|
||||||
|
|
||||||
private readonly onChange = new EventImpl<[boolean]>();
|
private readonly onChange = new EventImpl<[boolean]>();
|
||||||
private readonly events = {
|
|
||||||
change: this.onChange,
|
|
||||||
};
|
|
||||||
|
|
||||||
private readonly _labelHtml: LitHtml;
|
private readonly _labelHtml: LitHtml;
|
||||||
private _checked: boolean;
|
private _checked: boolean;
|
||||||
|
|
@ -65,7 +56,7 @@ export default class Checkbox {
|
||||||
_type: "change",
|
_type: "change",
|
||||||
listener: (checked: boolean) => void,
|
listener: (checked: boolean) => void,
|
||||||
): this {
|
): this {
|
||||||
addComponentEventListener(this.events, "change", listener);
|
this.onChange.addListener(listener);
|
||||||
|
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
@ -74,17 +65,17 @@ export default class Checkbox {
|
||||||
_type: "change",
|
_type: "change",
|
||||||
listener: (checked: boolean) => void,
|
listener: (checked: boolean) => void,
|
||||||
): this {
|
): this {
|
||||||
removeComponentEventListener(this.events, "change", listener);
|
this.onChange.removeListener(listener);
|
||||||
|
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
set hidden(isHidden: boolean) {
|
set hidden(isHidden: boolean) {
|
||||||
setHiddenState(this.container, isHidden);
|
this.container.hidden = isHidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
get hidden() {
|
get hidden() {
|
||||||
return getHiddenState(this.container);
|
return this.container.hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
get disabled() {
|
get disabled() {
|
||||||
|
|
|
||||||
|
|
@ -1,60 +1,31 @@
|
||||||
import type { EventImpl } from "../../core/eventImpl";
|
|
||||||
|
|
||||||
type EventHandler<Args extends unknown[]> = (...args: Args) => void;
|
|
||||||
type HiddenElement = { hidden: boolean };
|
|
||||||
|
|
||||||
export function addComponentEventListener<
|
|
||||||
T extends string,
|
|
||||||
Args extends unknown[],
|
|
||||||
>(
|
|
||||||
events: Record<T, EventImpl<Args>>,
|
|
||||||
type: T,
|
|
||||||
listener: EventHandler<Args>,
|
|
||||||
): void {
|
|
||||||
events[type].addListener(listener);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function removeComponentEventListener<
|
|
||||||
T extends string,
|
|
||||||
Args extends unknown[],
|
|
||||||
>(
|
|
||||||
events: Record<T, EventImpl<Args>>,
|
|
||||||
type: T,
|
|
||||||
listener: EventHandler<Args>,
|
|
||||||
): void {
|
|
||||||
events[type].removeListener(listener);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function setHiddenState(
|
|
||||||
element: HiddenElement,
|
|
||||||
isHidden: boolean,
|
|
||||||
): void {
|
|
||||||
element.hidden = isHidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getHiddenState(element: HiddenElement): boolean {
|
|
||||||
return element.hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function setInteractiveHiddenState(
|
export function setInteractiveHiddenState(
|
||||||
element: HTMLElement,
|
element: HTMLElement,
|
||||||
isHidden: boolean,
|
isHidden: boolean,
|
||||||
): void {
|
): void {
|
||||||
setHiddenState(element, isHidden);
|
element.hidden = isHidden;
|
||||||
element.setAttribute("aria-hidden", isHidden ? "true" : "false");
|
element.setAttribute("aria-hidden", isHidden ? "true" : "false");
|
||||||
element.toggleAttribute("inert", isHidden);
|
element.toggleAttribute("inert", isHidden);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setDisabledState(
|
export function createDomId(prefix: string): string {
|
||||||
element: HTMLElement,
|
const suffix =
|
||||||
isDisabled: boolean,
|
typeof crypto !== "undefined" && "randomUUID" in crypto
|
||||||
): void {
|
? crypto.randomUUID()
|
||||||
if (isDisabled) {
|
: Math.random().toString(36).slice(2);
|
||||||
element.setAttribute("disabled", "true");
|
|
||||||
return;
|
return `${prefix}-${suffix}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isEventInside(event: Event, element: HTMLElement): boolean {
|
||||||
|
const target = event.target;
|
||||||
|
if (target instanceof Node && element.contains(target)) {
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
element.removeAttribute("disabled");
|
return (
|
||||||
|
typeof event.composedPath === "function" &&
|
||||||
|
event.composedPath().includes(element)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isPrimaryPointerAction(event: PointerEvent): boolean {
|
export function isPrimaryPointerAction(event: PointerEvent): boolean {
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,6 @@ import { EventImpl } from "../../core/eventImpl";
|
||||||
import type { DetailsProps } from "../../types/components/details";
|
import type { DetailsProps } from "../../types/components/details";
|
||||||
import UI from "../../ui";
|
import UI from "../../ui";
|
||||||
import { CHEVRON_ICON } from "../icons";
|
import { CHEVRON_ICON } from "../icons";
|
||||||
import {
|
|
||||||
addComponentEventListener,
|
|
||||||
getHiddenState,
|
|
||||||
removeComponentEventListener,
|
|
||||||
setHiddenState,
|
|
||||||
} from "./componentShared";
|
|
||||||
|
|
||||||
export default class Details {
|
export default class Details {
|
||||||
container: HTMLElement;
|
container: HTMLElement;
|
||||||
|
|
@ -17,9 +11,6 @@ export default class Details {
|
||||||
arrowIcon: HTMLElement;
|
arrowIcon: HTMLElement;
|
||||||
|
|
||||||
private readonly onClick = new EventImpl();
|
private readonly onClick = new EventImpl();
|
||||||
private readonly events = {
|
|
||||||
click: this.onClick,
|
|
||||||
};
|
|
||||||
|
|
||||||
private readonly _titleHtml: HTMLElement | string;
|
private readonly _titleHtml: HTMLElement | string;
|
||||||
|
|
||||||
|
|
@ -56,22 +47,22 @@ export default class Details {
|
||||||
}
|
}
|
||||||
|
|
||||||
addEventListener(_type: "click", listener: () => void): this {
|
addEventListener(_type: "click", listener: () => void): this {
|
||||||
addComponentEventListener(this.events, "click", listener);
|
this.onClick.addListener(listener);
|
||||||
|
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
removeEventListener(_type: "click", listener: () => void): this {
|
removeEventListener(_type: "click", listener: () => void): this {
|
||||||
removeComponentEventListener(this.events, "click", listener);
|
this.onClick.removeListener(listener);
|
||||||
|
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
set hidden(isHidden: boolean) {
|
set hidden(isHidden: boolean) {
|
||||||
setHiddenState(this.container, isHidden);
|
this.container.hidden = isHidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
get hidden() {
|
get hidden() {
|
||||||
return getHiddenState(this.container);
|
return this.container.hidden;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,7 @@ import type { DialogProps } from "../../types/components/dialog";
|
||||||
import UI from "../../ui";
|
import UI from "../../ui";
|
||||||
import { getDeepActiveElement } from "../../utils/dom";
|
import { getDeepActiveElement } from "../../utils/dom";
|
||||||
import { CLOSE_ICON } from "../icons";
|
import { CLOSE_ICON } from "../icons";
|
||||||
import {
|
import { createDomId, setInteractiveHiddenState } from "./componentShared";
|
||||||
addComponentEventListener,
|
|
||||||
getHiddenState,
|
|
||||||
removeComponentEventListener,
|
|
||||||
setInteractiveHiddenState,
|
|
||||||
} from "./componentShared";
|
|
||||||
|
|
||||||
export default class Dialog {
|
export default class Dialog {
|
||||||
container: HTMLElement;
|
container: HTMLElement;
|
||||||
|
|
@ -23,9 +18,6 @@ export default class Dialog {
|
||||||
footerContainer: HTMLElement;
|
footerContainer: HTMLElement;
|
||||||
|
|
||||||
private readonly onClose = new EventImpl();
|
private readonly onClose = new EventImpl();
|
||||||
private readonly events = {
|
|
||||||
close: this.onClose,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Focus management for accessibility.
|
// Focus management for accessibility.
|
||||||
private previouslyFocused: Element | null = null;
|
private previouslyFocused: Element | null = null;
|
||||||
|
|
@ -38,10 +30,7 @@ export default class Dialog {
|
||||||
this.scheduleAdaptiveVerticalAlign();
|
this.scheduleAdaptiveVerticalAlign();
|
||||||
};
|
};
|
||||||
|
|
||||||
private readonly titleId =
|
private readonly titleId = createDomId("vot-dialog-title");
|
||||||
typeof crypto !== "undefined" && "randomUUID" in crypto
|
|
||||||
? crypto.randomUUID()
|
|
||||||
: `vot-dialog-title-${Math.random().toString(36).slice(2)}`;
|
|
||||||
|
|
||||||
private readonly _titleHtml: HTMLElement | string;
|
private readonly _titleHtml: HTMLElement | string;
|
||||||
private readonly _isTemp: boolean;
|
private readonly _isTemp: boolean;
|
||||||
|
|
@ -143,13 +132,13 @@ export default class Dialog {
|
||||||
}
|
}
|
||||||
|
|
||||||
addEventListener(_type: "close", listener: () => void): this {
|
addEventListener(_type: "close", listener: () => void): this {
|
||||||
addComponentEventListener(this.events, "close", listener);
|
this.onClose.addListener(listener);
|
||||||
|
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
removeEventListener(_type: "close", listener: () => void): this {
|
removeEventListener(_type: "close", listener: () => void): this {
|
||||||
removeComponentEventListener(this.events, "close", listener);
|
this.onClose.removeListener(listener);
|
||||||
|
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
@ -372,7 +361,7 @@ export default class Dialog {
|
||||||
}
|
}
|
||||||
|
|
||||||
get hidden() {
|
get hidden() {
|
||||||
return getHiddenState(this.container);
|
return this.container.hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
get isDialogOpen() {
|
get isDialogOpen() {
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,7 @@
|
||||||
import { EventImpl } from "../../core/eventImpl";
|
import { EventImpl } from "../../core/eventImpl";
|
||||||
import UI from "../../ui";
|
import UI from "../../ui";
|
||||||
|
import { clampPercentInt } from "../../utils/volume";
|
||||||
import { DOWNLOAD_ICON } from "../icons";
|
import { DOWNLOAD_ICON } from "../icons";
|
||||||
import {
|
|
||||||
addComponentEventListener,
|
|
||||||
getHiddenState,
|
|
||||||
removeComponentEventListener,
|
|
||||||
setHiddenState,
|
|
||||||
} from "./componentShared";
|
|
||||||
|
|
||||||
export default class DownloadButton {
|
export default class DownloadButton {
|
||||||
button: HTMLElement;
|
button: HTMLElement;
|
||||||
|
|
@ -14,9 +9,6 @@ export default class DownloadButton {
|
||||||
loaderCircle: SVGCircleElement;
|
loaderCircle: SVGCircleElement;
|
||||||
|
|
||||||
private readonly onClick = new EventImpl();
|
private readonly onClick = new EventImpl();
|
||||||
private readonly events = {
|
|
||||||
click: this.onClick,
|
|
||||||
};
|
|
||||||
private _progress = 0;
|
private _progress = 0;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
|
|
@ -49,13 +41,13 @@ export default class DownloadButton {
|
||||||
}
|
}
|
||||||
|
|
||||||
addEventListener(_type: "click", listener: () => void): this {
|
addEventListener(_type: "click", listener: () => void): this {
|
||||||
addComponentEventListener(this.events, "click", listener);
|
this.onClick.addListener(listener);
|
||||||
|
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
removeEventListener(_type: "click", listener: () => void): this {
|
removeEventListener(_type: "click", listener: () => void): this {
|
||||||
removeComponentEventListener(this.events, "click", listener);
|
this.onClick.removeListener(listener);
|
||||||
|
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
@ -83,11 +75,11 @@ export default class DownloadButton {
|
||||||
}
|
}
|
||||||
|
|
||||||
set hidden(isHidden: boolean) {
|
set hidden(isHidden: boolean) {
|
||||||
setHiddenState(this.button, isHidden);
|
this.button.hidden = isHidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
get hidden() {
|
get hidden() {
|
||||||
return getHiddenState(this.button);
|
return this.button.hidden;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -97,5 +89,5 @@ function clampProgress(value: number): number {
|
||||||
// `1` is ambiguous (could mean 1% or 100%). Our download code reports
|
// `1` is ambiguous (could mean 1% or 100%). Our download code reports
|
||||||
// integer percentages, so `1` should be treated as 1%.
|
// integer percentages, so `1` should be treated as 1%.
|
||||||
const asPercent = value < 1 ? value * 100 : value;
|
const asPercent = value < 1 ? value * 100 : value;
|
||||||
return Math.max(0, Math.min(100, Math.round(asPercent)));
|
return clampPercentInt(asPercent);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,21 +2,12 @@ import { EventImpl } from "../../core/eventImpl";
|
||||||
import { localizationProvider } from "../../localization/localizationProvider";
|
import { localizationProvider } from "../../localization/localizationProvider";
|
||||||
import type { HotkeyButtonProps } from "../../types/components/hotkeyButton";
|
import type { HotkeyButtonProps } from "../../types/components/hotkeyButton";
|
||||||
import UI from "../../ui";
|
import UI from "../../ui";
|
||||||
import {
|
|
||||||
addComponentEventListener,
|
|
||||||
getHiddenState,
|
|
||||||
removeComponentEventListener,
|
|
||||||
setHiddenState,
|
|
||||||
} from "./componentShared";
|
|
||||||
|
|
||||||
export default class HotkeyButton {
|
export default class HotkeyButton {
|
||||||
container: HTMLElement;
|
container: HTMLElement;
|
||||||
button: HTMLElement;
|
button: HTMLElement;
|
||||||
|
|
||||||
private readonly onChange = new EventImpl<[string | null]>();
|
private readonly onChange = new EventImpl<[string | null]>();
|
||||||
private readonly events = {
|
|
||||||
change: this.onChange,
|
|
||||||
};
|
|
||||||
|
|
||||||
private readonly _labelHtml: string;
|
private readonly _labelHtml: string;
|
||||||
private _key: string | null;
|
private _key: string | null;
|
||||||
|
|
@ -130,7 +121,7 @@ export default class HotkeyButton {
|
||||||
_type: "change",
|
_type: "change",
|
||||||
listener: (key: string | null) => void,
|
listener: (key: string | null) => void,
|
||||||
): this {
|
): this {
|
||||||
addComponentEventListener(this.events, "change", listener);
|
this.onChange.addListener(listener);
|
||||||
|
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
@ -139,17 +130,17 @@ export default class HotkeyButton {
|
||||||
_type: "change",
|
_type: "change",
|
||||||
listener: (key: string | null) => void,
|
listener: (key: string | null) => void,
|
||||||
): this {
|
): this {
|
||||||
removeComponentEventListener(this.events, "change", listener);
|
this.onChange.removeListener(listener);
|
||||||
|
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
set hidden(isHidden: boolean) {
|
set hidden(isHidden: boolean) {
|
||||||
setHiddenState(this.container, isHidden);
|
this.container.hidden = isHidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
get hidden() {
|
get hidden() {
|
||||||
return getHiddenState(this.container);
|
return this.container.hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
get key() {
|
get key() {
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ import { render } from "lit-html";
|
||||||
import type { LabelProps } from "../../types/components/label";
|
import type { LabelProps } from "../../types/components/label";
|
||||||
import type { LitHtml } from "../../types/components/shared";
|
import type { LitHtml } from "../../types/components/shared";
|
||||||
import UI from "../../ui";
|
import UI from "../../ui";
|
||||||
import { getHiddenState, setHiddenState } from "./componentShared";
|
|
||||||
|
|
||||||
export default class Label {
|
export default class Label {
|
||||||
container: HTMLElement;
|
container: HTMLElement;
|
||||||
|
|
@ -51,10 +50,10 @@ export default class Label {
|
||||||
}
|
}
|
||||||
|
|
||||||
set hidden(isHidden: boolean) {
|
set hidden(isHidden: boolean) {
|
||||||
setHiddenState(this.container, isHidden);
|
this.container.hidden = isHidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
get hidden() {
|
get hidden() {
|
||||||
return getHiddenState(this.container);
|
return this.container.hidden;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,13 +11,6 @@ import type {
|
||||||
import type { Phrase } from "../../types/localization";
|
import type { Phrase } from "../../types/localization";
|
||||||
import UI from "../../ui";
|
import UI from "../../ui";
|
||||||
import { CHEVRON_ICON } from "../icons";
|
import { CHEVRON_ICON } from "../icons";
|
||||||
import {
|
|
||||||
addComponentEventListener,
|
|
||||||
getHiddenState,
|
|
||||||
removeComponentEventListener,
|
|
||||||
setDisabledState,
|
|
||||||
setHiddenState,
|
|
||||||
} from "./componentShared";
|
|
||||||
import Dialog from "./dialog";
|
import Dialog from "./dialog";
|
||||||
import Textfield from "./textfield";
|
import Textfield from "./textfield";
|
||||||
|
|
||||||
|
|
@ -314,7 +307,7 @@ export default class Select<
|
||||||
type: "beforeOpen" | "selectItem",
|
type: "beforeOpen" | "selectItem",
|
||||||
listener: (...data: any[]) => void,
|
listener: (...data: any[]) => void,
|
||||||
): this {
|
): this {
|
||||||
addComponentEventListener(this.events, type, listener);
|
this.events[type].addListener(listener);
|
||||||
|
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
@ -331,7 +324,7 @@ export default class Select<
|
||||||
type: "selectItem" | "beforeOpen",
|
type: "selectItem" | "beforeOpen",
|
||||||
listener: (...data: any[]) => void,
|
listener: (...data: any[]) => void,
|
||||||
): this {
|
): this {
|
||||||
removeComponentEventListener(this.events, type, listener);
|
this.events[type].removeListener(listener);
|
||||||
|
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
@ -421,11 +414,11 @@ export default class Select<
|
||||||
}
|
}
|
||||||
|
|
||||||
set hidden(isHidden: boolean) {
|
set hidden(isHidden: boolean) {
|
||||||
setHiddenState(this.container, isHidden);
|
this.container.hidden = isHidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
get hidden() {
|
get hidden() {
|
||||||
return getHiddenState(this.container);
|
return this.container.hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
get disabled() {
|
get disabled() {
|
||||||
|
|
@ -436,6 +429,10 @@ export default class Select<
|
||||||
}
|
}
|
||||||
|
|
||||||
set disabled(isDisabled: boolean) {
|
set disabled(isDisabled: boolean) {
|
||||||
setDisabledState(this.outer, isDisabled);
|
if (isDisabled) {
|
||||||
|
this.outer.setAttribute("disabled", "true");
|
||||||
|
} else {
|
||||||
|
this.outer.removeAttribute("disabled");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { EventImpl } from "../../core/eventImpl";
|
||||||
import type { LitHtml } from "../../types/components/shared";
|
import type { LitHtml } from "../../types/components/shared";
|
||||||
import type { SliderProps } from "../../types/components/slider";
|
import type { SliderProps } from "../../types/components/slider";
|
||||||
import UI from "../../ui";
|
import UI from "../../ui";
|
||||||
import { getHiddenState, setHiddenState } from "./componentShared";
|
import { clampNumber } from "../../utils/number";
|
||||||
|
|
||||||
export default class Slider {
|
export default class Slider {
|
||||||
container: HTMLElement;
|
container: HTMLElement;
|
||||||
|
|
@ -155,16 +155,10 @@ export default class Slider {
|
||||||
}
|
}
|
||||||
|
|
||||||
set hidden(isHidden: boolean) {
|
set hidden(isHidden: boolean) {
|
||||||
setHiddenState(this.container, isHidden);
|
this.container.hidden = isHidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
get hidden() {
|
get hidden() {
|
||||||
return getHiddenState(this.container);
|
return this.container.hidden;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function clampNumber(value: number, min: number, max: number): number {
|
|
||||||
if (!Number.isFinite(value)) return min;
|
|
||||||
if (max < min) return min;
|
|
||||||
return Math.max(min, Math.min(max, value));
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import type { SliderLabelProps } from "../../types/components/sliderLabel";
|
import type { SliderLabelProps } from "../../types/components/sliderLabel";
|
||||||
import UI from "../../ui";
|
import UI from "../../ui";
|
||||||
import { getHiddenState, setHiddenState } from "./componentShared";
|
|
||||||
|
|
||||||
export default class SliderLabel {
|
export default class SliderLabel {
|
||||||
container: HTMLSpanElement;
|
container: HTMLSpanElement;
|
||||||
|
|
@ -70,10 +69,10 @@ export default class SliderLabel {
|
||||||
}
|
}
|
||||||
|
|
||||||
set hidden(isHidden: boolean) {
|
set hidden(isHidden: boolean) {
|
||||||
setHiddenState(this.container, isHidden);
|
this.container.hidden = isHidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
get hidden() {
|
get hidden() {
|
||||||
return getHiddenState(this.container);
|
return this.container.hidden;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,6 @@
|
||||||
import { EventImpl } from "../../core/eventImpl";
|
import { EventImpl } from "../../core/eventImpl";
|
||||||
import type { TextfieldProps } from "../../types/components/textfield";
|
import type { TextfieldProps } from "../../types/components/textfield";
|
||||||
import UI from "../../ui";
|
import UI from "../../ui";
|
||||||
import {
|
|
||||||
addComponentEventListener,
|
|
||||||
getHiddenState,
|
|
||||||
removeComponentEventListener,
|
|
||||||
setHiddenState,
|
|
||||||
} from "./componentShared";
|
|
||||||
|
|
||||||
export default class Textfield {
|
export default class Textfield {
|
||||||
container: HTMLElement;
|
container: HTMLElement;
|
||||||
|
|
@ -77,7 +71,7 @@ export default class Textfield {
|
||||||
type: "input" | "change",
|
type: "input" | "change",
|
||||||
listener: (value: string) => void,
|
listener: (value: string) => void,
|
||||||
): this {
|
): this {
|
||||||
addComponentEventListener(this.events, type, listener);
|
this.events[type].addListener(listener);
|
||||||
|
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
@ -86,7 +80,7 @@ export default class Textfield {
|
||||||
type: "input" | "change",
|
type: "input" | "change",
|
||||||
listener: (value: string) => void,
|
listener: (value: string) => void,
|
||||||
): this {
|
): this {
|
||||||
removeComponentEventListener(this.events, type, listener);
|
this.events[type].removeListener(listener);
|
||||||
|
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
@ -124,10 +118,10 @@ export default class Textfield {
|
||||||
}
|
}
|
||||||
|
|
||||||
set hidden(isHidden: boolean) {
|
set hidden(isHidden: boolean) {
|
||||||
setHiddenState(this.container, isHidden);
|
this.container.hidden = isHidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
get hidden() {
|
get hidden() {
|
||||||
return getHiddenState(this.container);
|
return this.container.hidden;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import {
|
||||||
} from "../../types/components/tooltip";
|
} from "../../types/components/tooltip";
|
||||||
import UI from "../../ui";
|
import UI from "../../ui";
|
||||||
import { clamp } from "../../utils/utils";
|
import { clamp } from "../../utils/utils";
|
||||||
|
import { createDomId, isEventInside } from "./componentShared";
|
||||||
|
|
||||||
export default class Tooltip {
|
export default class Tooltip {
|
||||||
/** Whether tooltip element is currently mounted. */
|
/** Whether tooltip element is currently mounted. */
|
||||||
|
|
@ -47,10 +48,7 @@ export default class Tooltip {
|
||||||
private static readonly DESTROY_FALLBACK_MS = 700;
|
private static readonly DESTROY_FALLBACK_MS = 700;
|
||||||
|
|
||||||
// Accessibility: link trigger -> tooltip via aria-describedby.
|
// Accessibility: link trigger -> tooltip via aria-describedby.
|
||||||
private readonly tooltipId =
|
private readonly tooltipId = createDomId("vot-tooltip");
|
||||||
typeof crypto !== "undefined" && "randomUUID" in crypto
|
|
||||||
? crypto.randomUUID()
|
|
||||||
: `vot-tooltip-${Math.random().toString(36).slice(2)}`;
|
|
||||||
private prevAriaDescribedBy: string | null = null;
|
private prevAriaDescribedBy: string | null = null;
|
||||||
|
|
||||||
constructor({
|
constructor({
|
||||||
|
|
@ -164,6 +162,21 @@ export default class Tooltip {
|
||||||
this.showed ? this.destroy() : this.create();
|
this.showed ? this.destroy() : this.create();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
onDocumentPointerDown = (event: PointerEvent) => {
|
||||||
|
if (!this.showed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
isEventInside(event, this.target) ||
|
||||||
|
(this.container && isEventInside(event, this.container))
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.destroy();
|
||||||
|
};
|
||||||
|
|
||||||
onTargetKeyDown = (event: KeyboardEvent) => {
|
onTargetKeyDown = (event: KeyboardEvent) => {
|
||||||
if (event.key !== "Escape" || !this.showed) {
|
if (event.key !== "Escape" || !this.showed) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -366,6 +379,11 @@ export default class Tooltip {
|
||||||
this.container.style.opacity = "1";
|
this.container.style.opacity = "1";
|
||||||
if (this.trigger === "hover") {
|
if (this.trigger === "hover") {
|
||||||
this.container.addEventListener("mouseleave", this.onTooltipMouseLeave);
|
this.container.addEventListener("mouseleave", this.onTooltipMouseLeave);
|
||||||
|
} else {
|
||||||
|
document.addEventListener("pointerdown", this.onDocumentPointerDown, {
|
||||||
|
capture: true,
|
||||||
|
passive: true,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
this.attachScrollListener();
|
this.attachScrollListener();
|
||||||
this.onResizeObserver?.observe(this.layoutRoot);
|
this.onResizeObserver?.observe(this.layoutRoot);
|
||||||
|
|
@ -577,6 +595,7 @@ export default class Tooltip {
|
||||||
this.onResizeObserver?.disconnect();
|
this.onResizeObserver?.disconnect();
|
||||||
this.intersectionObserver?.disconnect();
|
this.intersectionObserver?.disconnect();
|
||||||
this.detachScrollListener();
|
this.detachScrollListener();
|
||||||
|
this.detachOutsidePointerListener();
|
||||||
if (instant) {
|
if (instant) {
|
||||||
container.remove();
|
container.remove();
|
||||||
this.container = undefined;
|
this.container = undefined;
|
||||||
|
|
@ -609,6 +628,12 @@ export default class Tooltip {
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private detachOutsidePointerListener() {
|
||||||
|
document.removeEventListener("pointerdown", this.onDocumentPointerDown, {
|
||||||
|
capture: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private syncAriaDescribedBy(isShowing: boolean) {
|
private syncAriaDescribedBy(isShowing: boolean) {
|
||||||
// Follow ARIA tooltip pattern: trigger references tooltip via aria-describedby.
|
// Follow ARIA tooltip pattern: trigger references tooltip via aria-describedby.
|
||||||
const existing = this.target.getAttribute("aria-describedby");
|
const existing = this.target.getAttribute("aria-describedby");
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,6 @@ import type {
|
||||||
} from "../../types/components/votButton";
|
} from "../../types/components/votButton";
|
||||||
import UI from "../../ui";
|
import UI from "../../ui";
|
||||||
import { MENU_ICON, PIP_ICON_SVG, TRANSLATE_ICON_SVG } from "../icons";
|
import { MENU_ICON, PIP_ICON_SVG, TRANSLATE_ICON_SVG } from "../icons";
|
||||||
import { getHiddenState, setHiddenState } from "./componentShared";
|
|
||||||
|
|
||||||
export default class VOTButton {
|
export default class VOTButton {
|
||||||
container: HTMLElement;
|
container: HTMLElement;
|
||||||
|
|
@ -167,11 +166,11 @@ export default class VOTButton {
|
||||||
}
|
}
|
||||||
|
|
||||||
set hidden(isHidden: boolean) {
|
set hidden(isHidden: boolean) {
|
||||||
setHiddenState(this.container, isHidden);
|
this.container.hidden = isHidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
get hidden() {
|
get hidden() {
|
||||||
return getHiddenState(this.container);
|
return this.container.hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
get position() {
|
get position() {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import type { Position } from "../../types/components/votButton";
|
import type { Position } from "../../types/components/votButton";
|
||||||
import type { VOTMenuProps } from "../../types/components/votMenu";
|
import type { VOTMenuProps } from "../../types/components/votMenu";
|
||||||
import UI from "../../ui";
|
import UI from "../../ui";
|
||||||
import { getHiddenState, setInteractiveHiddenState } from "./componentShared";
|
import { createDomId, setInteractiveHiddenState } from "./componentShared";
|
||||||
|
|
||||||
export default class VOTMenu {
|
export default class VOTMenu {
|
||||||
container: HTMLElement;
|
container: HTMLElement;
|
||||||
|
|
@ -16,15 +16,9 @@ export default class VOTMenu {
|
||||||
private _titleHtml: string;
|
private _titleHtml: string;
|
||||||
|
|
||||||
// A11y: stable ids for aria-controls / aria-labelledby.
|
// A11y: stable ids for aria-controls / aria-labelledby.
|
||||||
private readonly menuId =
|
private readonly menuId = createDomId("vot-menu");
|
||||||
typeof crypto !== "undefined" && "randomUUID" in crypto
|
|
||||||
? `vot-menu-${crypto.randomUUID()}`
|
|
||||||
: `vot-menu-${Math.random().toString(36).slice(2)}`;
|
|
||||||
|
|
||||||
private readonly titleId =
|
private readonly titleId = createDomId("vot-menu-title");
|
||||||
typeof crypto !== "undefined" && "randomUUID" in crypto
|
|
||||||
? `vot-menu-title-${crypto.randomUUID()}`
|
|
||||||
: `vot-menu-title-${Math.random().toString(36).slice(2)}`;
|
|
||||||
|
|
||||||
constructor({ position = "default", titleHtml = "" }: VOTMenuProps) {
|
constructor({ position = "default", titleHtml = "" }: VOTMenuProps) {
|
||||||
this._position = position;
|
this._position = position;
|
||||||
|
|
@ -104,7 +98,7 @@ export default class VOTMenu {
|
||||||
}
|
}
|
||||||
|
|
||||||
get hidden() {
|
get hidden() {
|
||||||
return getHiddenState(this.container);
|
return this.container.hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
get position() {
|
get position() {
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import type { VideoHandler } from "../index";
|
||||||
import { localizationProvider } from "../localization/localizationProvider";
|
import { localizationProvider } from "../localization/localizationProvider";
|
||||||
import type { Status } from "../types/components/votButton";
|
import type { Status } from "../types/components/votButton";
|
||||||
import debug from "../utils/debug";
|
import debug from "../utils/debug";
|
||||||
|
import { isAbortError } from "../utils/errors";
|
||||||
import VOTLocalizedError from "../utils/VOTLocalizedError";
|
import VOTLocalizedError from "../utils/VOTLocalizedError";
|
||||||
|
|
||||||
type TranslationButtonCommandDeps = {
|
type TranslationButtonCommandDeps = {
|
||||||
|
|
@ -11,10 +12,6 @@ type TranslationButtonCommandDeps = {
|
||||||
transformBtn(status: Status, text: string): void;
|
transformBtn(status: Status, text: string): void;
|
||||||
};
|
};
|
||||||
|
|
||||||
function isAbortError(error: unknown) {
|
|
||||||
return error instanceof Error && error.name === "AbortError";
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getVideoDataForTranslation(videoHandler: VideoHandler) {
|
async function getVideoDataForTranslation(videoHandler: VideoHandler) {
|
||||||
if (!videoHandler.videoData?.videoId) {
|
if (!videoHandler.videoData?.videoId) {
|
||||||
throw new VOTLocalizedError("VOTNoVideoIDFound");
|
throw new VOTLocalizedError("VOTNoVideoIDFound");
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ import { votStorage } from "../../utils/storage";
|
||||||
import { isPiPAvailable } from "../../utils/utils";
|
import { isPiPAvailable } from "../../utils/utils";
|
||||||
import {
|
import {
|
||||||
addKeyboardActivationListener,
|
addKeyboardActivationListener,
|
||||||
|
isEventInside,
|
||||||
isPrimaryPointerAction,
|
isPrimaryPointerAction,
|
||||||
} from "../components/componentShared";
|
} from "../components/componentShared";
|
||||||
import DownloadButton from "../components/downloadButton";
|
import DownloadButton from "../components/downloadButton";
|
||||||
|
|
@ -262,18 +263,6 @@ export class OverlayView {
|
||||||
addKeyboardActivationListener(element, handler, { signal });
|
addKeyboardActivationListener(element, handler, { signal });
|
||||||
}
|
}
|
||||||
|
|
||||||
private isEventInside(event: Event, element: HTMLElement): boolean {
|
|
||||||
const target = event.target as Node | null;
|
|
||||||
if (target && element.contains(target)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
typeof event.composedPath === "function" &&
|
|
||||||
event.composedPath().includes(element)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private flushDefaultVolumePersist(): void {
|
private flushDefaultVolumePersist(): void {
|
||||||
if (this.defaultVolumePersistTimer !== undefined) {
|
if (this.defaultVolumePersistTimer !== undefined) {
|
||||||
globalThis.clearTimeout(this.defaultVolumePersistTimer);
|
globalThis.clearTimeout(this.defaultVolumePersistTimer);
|
||||||
|
|
@ -614,9 +603,9 @@ export class OverlayView {
|
||||||
);
|
);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
this.isEventInside(e, this.votMenu.container) ||
|
isEventInside(e, this.votMenu.container) ||
|
||||||
this.isEventInside(e, this.votButton.menuButton) ||
|
isEventInside(e, this.votButton.menuButton) ||
|
||||||
this.isEventInside(e, this.votButton.container) ||
|
isEventInside(e, this.votButton.container) ||
|
||||||
isInsideDialog
|
isInsideDialog
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
|
|
|
||||||
|
|
@ -103,6 +103,7 @@ import { detectServices, translateServices } from "../../utils/translateApis";
|
||||||
import { isPiPAvailable } from "../../utils/utils";
|
import { isPiPAvailable } from "../../utils/utils";
|
||||||
import AccountButton from "../components/accountButton";
|
import AccountButton from "../components/accountButton";
|
||||||
import Checkbox from "../components/checkbox";
|
import Checkbox from "../components/checkbox";
|
||||||
|
import { createDomId } from "../components/componentShared";
|
||||||
import Details from "../components/details";
|
import Details from "../components/details";
|
||||||
import Dialog from "../components/dialog";
|
import Dialog from "../components/dialog";
|
||||||
import HotkeyButton from "../components/hotkeyButton";
|
import HotkeyButton from "../components/hotkeyButton";
|
||||||
|
|
@ -298,12 +299,9 @@ export class SettingsView {
|
||||||
const section = ui.createEl("vot-block", ["vot-settings-section"]);
|
const section = ui.createEl("vot-block", ["vot-settings-section"]);
|
||||||
const header = new Details({ titleHtml: title });
|
const header = new Details({ titleHtml: title });
|
||||||
header.container.classList.add("vot-settings-section-header");
|
header.container.classList.add("vot-settings-section-header");
|
||||||
const sectionId =
|
const sectionId = createDomId("vot-settings-section");
|
||||||
typeof crypto !== "undefined" && "randomUUID" in crypto
|
const headerId = `${sectionId}-header`;
|
||||||
? crypto.randomUUID()
|
const contentId = `${sectionId}-content`;
|
||||||
: `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
|
||||||
const headerId = `vot-settings-section-header-${sectionId}`;
|
|
||||||
const contentId = `vot-settings-section-content-${sectionId}`;
|
|
||||||
header.container.id = headerId;
|
header.container.id = headerId;
|
||||||
const content = ui.createEl("vot-block", ["vot-settings-section-content"]);
|
const content = ui.createEl("vot-block", ["vot-settings-section-content"]);
|
||||||
content.id = contentId;
|
content.id = contentId;
|
||||||
|
|
|
||||||
15
src/utils/number.ts
Normal file
15
src/utils/number.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
export function clampNumber(value: number, min: number, max: number): number {
|
||||||
|
if (!Number.isFinite(value)) return min;
|
||||||
|
if (max < min) return min;
|
||||||
|
return Math.max(min, Math.min(max, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clampNumberWithSortedBounds(
|
||||||
|
value: number,
|
||||||
|
min: number,
|
||||||
|
max: number,
|
||||||
|
): number {
|
||||||
|
const lower = Math.min(min, max);
|
||||||
|
const upper = Math.max(min, max);
|
||||||
|
return Math.min(Math.max(value, lower), upper);
|
||||||
|
}
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import { clampNumberWithSortedBounds } from "./number";
|
||||||
|
|
||||||
export { calculatedResLang } from "./localization";
|
export { calculatedResLang } from "./localization";
|
||||||
|
|
||||||
const DEFAULT_OBJECT_URL_REVOKE_DELAY_MS = 30_000;
|
const DEFAULT_OBJECT_URL_REVOKE_DELAY_MS = 30_000;
|
||||||
|
|
@ -219,9 +221,7 @@ export const getHeaders = (headers?: HeadersInit): Record<string, string> =>
|
||||||
headers ? Object.fromEntries(new Headers(headers)) : {};
|
headers ? Object.fromEntries(new Headers(headers)) : {};
|
||||||
|
|
||||||
export function clamp(value: number, min = 0, max = 100): number {
|
export function clamp(value: number, min = 0, max = 100): number {
|
||||||
const lower = Math.min(min, max);
|
return clampNumberWithSortedBounds(value, min, max);
|
||||||
const upper = Math.max(min, max);
|
|
||||||
return Math.min(Math.max(value, lower), upper);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function toFlatObj<T extends Record<string, unknown>>(
|
export function toFlatObj<T extends Record<string, unknown>>(
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import { clampNumber } from "./number";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Volume utilities.
|
* Volume utilities.
|
||||||
*
|
*
|
||||||
|
|
@ -14,12 +16,6 @@ export const VIDEO_VOLUME_STEP_01 = 0.01;
|
||||||
|
|
||||||
const EPS = 1e-6;
|
const EPS = 1e-6;
|
||||||
|
|
||||||
function clampNumber(value: number, min: number, max: number): number {
|
|
||||||
if (!Number.isFinite(value)) return min;
|
|
||||||
if (max < min) return min;
|
|
||||||
return Math.max(min, Math.min(max, value));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function clampInt(value: number, min: number, max: number): number {
|
export function clampInt(value: number, min: number, max: number): number {
|
||||||
return Math.trunc(clampNumber(value, min, max));
|
return Math.trunc(clampNumber(value, min, max));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -42,42 +42,22 @@ function mergeListenerSignals(
|
||||||
primary: AbortSignal,
|
primary: AbortSignal,
|
||||||
secondary?: AbortSignal,
|
secondary?: AbortSignal,
|
||||||
): AbortSignal {
|
): AbortSignal {
|
||||||
if (!secondary || secondary === primary) {
|
if (!secondary || secondary === primary) return primary;
|
||||||
return primary;
|
const signals = [primary, secondary];
|
||||||
}
|
if (typeof AbortSignal.any === "function") return AbortSignal.any(signals);
|
||||||
|
|
||||||
if (primary.aborted) {
|
|
||||||
return primary;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (secondary.aborted) {
|
|
||||||
return secondary;
|
|
||||||
}
|
|
||||||
|
|
||||||
const canCombine = typeof AbortSignal !== "undefined" && "any" in AbortSignal;
|
|
||||||
if (canCombine) {
|
|
||||||
return (AbortSignal as any).any([primary, secondary]) as AbortSignal;
|
|
||||||
}
|
|
||||||
|
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
|
for (const signal of signals) {
|
||||||
const cleanup = () => {
|
const abort = () => controller.abort(signal.reason);
|
||||||
primary.removeEventListener("abort", onPrimaryAbort);
|
if (signal.aborted) {
|
||||||
secondary.removeEventListener("abort", onSecondaryAbort);
|
abort();
|
||||||
};
|
break;
|
||||||
|
}
|
||||||
const onPrimaryAbort = () => {
|
signal.addEventListener("abort", abort, {
|
||||||
cleanup();
|
once: true,
|
||||||
controller.abort(primary.reason);
|
signal: controller.signal,
|
||||||
};
|
});
|
||||||
const onSecondaryAbort = () => {
|
}
|
||||||
cleanup();
|
|
||||||
controller.abort(secondary.reason);
|
|
||||||
};
|
|
||||||
|
|
||||||
primary.addEventListener("abort", onPrimaryAbort, { once: true });
|
|
||||||
secondary.addEventListener("abort", onSecondaryAbort, { once: true });
|
|
||||||
|
|
||||||
return controller.signal;
|
return controller.signal;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -86,16 +66,9 @@ function createScopedListeners(signal: AbortSignal): {
|
||||||
addMany: ScopedAddListeners;
|
addMany: ScopedAddListeners;
|
||||||
} {
|
} {
|
||||||
const add: ScopedAddListener = (element, event, handler, options) => {
|
const add: ScopedAddListener = (element, event, handler, options) => {
|
||||||
const mergedSignal = mergeListenerSignals(signal, options?.signal);
|
|
||||||
if (!options) {
|
|
||||||
element.addEventListener(event, handler, { signal: mergedSignal });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { signal: _ignoredSignal, ...restOptions } = options;
|
|
||||||
element.addEventListener(event, handler, {
|
element.addEventListener(event, handler, {
|
||||||
...restOptions,
|
...options,
|
||||||
signal: mergedSignal,
|
signal: mergeListenerSignals(signal, options?.signal),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
const addMany: ScopedAddListeners = (element, events, handler, options) => {
|
const addMany: ScopedAddListeners = (element, events, handler, options) => {
|
||||||
|
|
@ -110,29 +83,19 @@ function bindOverlayHoverFocusEvents(
|
||||||
target: EventTarget,
|
target: EventTarget,
|
||||||
overlayVisibility: NonNullable<VideoHandler["overlayVisibility"]>,
|
overlayVisibility: NonNullable<VideoHandler["overlayVisibility"]>,
|
||||||
): void {
|
): void {
|
||||||
addMany(target, ["focusin"], (event) =>
|
const handleInteraction = (event: Event) =>
|
||||||
overlayVisibility.handleOverlayInteraction(event),
|
overlayVisibility.handleOverlayInteraction(event);
|
||||||
);
|
const scheduleHide = (event: Event) => overlayVisibility.scheduleHide(event);
|
||||||
addMany(target, ["focusout"], (event) =>
|
|
||||||
overlayVisibility.scheduleHide(event),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (isIframe() && globalThis.window !== undefined) {
|
if (isIframe() && globalThis.window !== undefined) {
|
||||||
|
addMany(target, ["focusin"], handleInteraction);
|
||||||
|
addMany(target, ["focusout"], scheduleHide);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
addMany(target, ["pointerenter"], (event) =>
|
addMany(target, ["focusin", "pointerenter"], handleInteraction);
|
||||||
overlayVisibility.handleOverlayInteraction(event),
|
addMany(target, ["pointermove"], handleInteraction, { passive: true });
|
||||||
);
|
addMany(target, ["focusout", "pointerleave"], scheduleHide);
|
||||||
addMany(
|
|
||||||
target,
|
|
||||||
["pointermove"],
|
|
||||||
(event) => overlayVisibility.handleOverlayInteraction(event),
|
|
||||||
{ passive: true },
|
|
||||||
);
|
|
||||||
addMany(target, ["pointerleave"], (event) =>
|
|
||||||
overlayVisibility.scheduleHide(event),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function toPercentInt(value: unknown, fallback = 0): number {
|
function toPercentInt(value: unknown, fallback = 0): number {
|
||||||
|
|
@ -363,10 +326,13 @@ function bindGlobalDismissAndHotkeys(ctx: ExtraEventsContext): void {
|
||||||
const button = overlayView.votButton?.container;
|
const button = overlayView.votButton?.container;
|
||||||
const menu = overlayView.votMenu?.container;
|
const menu = overlayView.votMenu?.container;
|
||||||
const settings = self.uiManager.votSettingsView?.dialog?.container;
|
const settings = self.uiManager.votSettingsView?.dialog?.container;
|
||||||
const isButton = target && button ? button.contains(target) : false;
|
const path = event.composedPath();
|
||||||
const isMenu = target && menu ? menu.contains(target) : false;
|
const isInPath = (element?: EventTarget | null) =>
|
||||||
const isVideo = target ? self.container.contains(target) : false;
|
Boolean(element && path.includes(element));
|
||||||
const isSettings = target && settings ? settings.contains(target) : false;
|
const isButton = isInPath(button);
|
||||||
|
const isMenu = isInPath(menu);
|
||||||
|
const isVideo = isInPath(self.container);
|
||||||
|
const isSettings = isInPath(settings);
|
||||||
const isTempDialog =
|
const isTempDialog =
|
||||||
target instanceof Element &&
|
target instanceof Element &&
|
||||||
target.closest(".vot-dialog-temp") instanceof Element;
|
target.closest(".vot-dialog-temp") instanceof Element;
|
||||||
|
|
|
||||||
68
tests/smart-wrap.test.ts
Normal file
68
tests/smart-wrap.test.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import {
|
||||||
|
buildWordSlices,
|
||||||
|
computeTokenWrapPlan,
|
||||||
|
} from "../src/subtitles/smartWrap.ts";
|
||||||
|
import type { SubtitleToken } from "../src/types/subtitles.ts";
|
||||||
|
|
||||||
|
const token = (text: string, isWordLike: boolean): SubtitleToken => ({
|
||||||
|
text,
|
||||||
|
startMs: 0,
|
||||||
|
durationMs: 100,
|
||||||
|
isWordLike,
|
||||||
|
});
|
||||||
|
|
||||||
|
const measureText = (text: string): number => text.length * 10;
|
||||||
|
|
||||||
|
describe("subtitle smart wrap", () => {
|
||||||
|
test("returns no wrap for empty, forced-break, and invalid-width input", () => {
|
||||||
|
expect(computeTokenWrapPlan([], measureText, 100)).toEqual({
|
||||||
|
breakAfterTokenIndices: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
computeTokenWrapPlan(
|
||||||
|
[token("hello", true), token("\n", false), token("world", true)],
|
||||||
|
measureText,
|
||||||
|
100,
|
||||||
|
),
|
||||||
|
).toEqual({
|
||||||
|
breakAfterTokenIndices: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
computeTokenWrapPlan([token("hello", true)], measureText, Number.NaN),
|
||||||
|
).toEqual({
|
||||||
|
breakAfterTokenIndices: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps normalized keys and char lengths stable", () => {
|
||||||
|
const { key, slices } = buildWordSlices([
|
||||||
|
token(" hello", true),
|
||||||
|
token(" ", false),
|
||||||
|
token("world ", true),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(key).toBe("hello|world");
|
||||||
|
expect(slices.map((slice) => slice.charLength)).toEqual([5, 5]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("avoids breaking after dangling opening punctuation", () => {
|
||||||
|
const tokens = [
|
||||||
|
token("Intro", true),
|
||||||
|
token(" ", false),
|
||||||
|
token('"', false),
|
||||||
|
token(" ", false),
|
||||||
|
token("quoted", true),
|
||||||
|
token(" ", false),
|
||||||
|
token("text", true),
|
||||||
|
token(" ", false),
|
||||||
|
token("continues.", true),
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(computeTokenWrapPlan(tokens, measureText, 55)).toEqual({
|
||||||
|
breakAfterTokenIndices: [5],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue