fix(overlay): Linux X11/XWayland stacking, stale pause state, multi-copy selector (#101)

This commit is contained in:
2026-05-31 20:59:18 -07:00
committed by GitHub
parent b46b8dfa41
commit e1ea464bc9
103 changed files with 6314 additions and 353 deletions
+75
View File
@@ -103,12 +103,14 @@ function installKeyboardTestGlobals() {
const previousMutationObserver = (globalThis as { MutationObserver?: unknown }).MutationObserver;
const previousCustomEvent = (globalThis as { CustomEvent?: unknown }).CustomEvent;
const previousMouseEvent = (globalThis as { MouseEvent?: unknown }).MouseEvent;
const previousElement = (globalThis as { Element?: unknown }).Element;
const documentListeners = new Map<string, Array<(event: unknown) => void>>();
const windowListeners = new Map<string, Array<(event: unknown) => void>>();
const commandEvents: CommandEventDetail[] = [];
const mpvCommands: Array<Array<string | number>> = [];
const sessionActions: Array<{ actionId: string; payload?: unknown }> = [];
const interactionActivations: string[] = [];
let sessionBindings: CompiledSessionBinding[] = [];
let getSessionBindingsImpl: () => Promise<CompiledSessionBinding[]> = async () => sessionBindings;
let playbackPausedResponse: boolean | null = false;
@@ -179,6 +181,14 @@ function installKeyboardTestGlobals() {
}
}
class TestElement {
tagName = 'DIV';
closest(_selector: string): unknown {
return null;
}
}
Object.defineProperty(globalThis, 'CustomEvent', {
configurable: true,
value: TestCustomEvent,
@@ -189,6 +199,11 @@ function installKeyboardTestGlobals() {
value: TestMouseEvent,
});
Object.defineProperty(globalThis, 'Element', {
configurable: true,
value: TestElement,
});
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
@@ -242,6 +257,10 @@ function installKeyboardTestGlobals() {
focusMainWindowCalls += 1;
return Promise.resolve();
},
activatePlaybackWindowForOverlayInteraction: async () => {
interactionActivations.push('activate-playback-window');
return true;
},
notifyOverlayModalOpened: (modal: string) => {
openedModalNotifications.push(modal);
},
@@ -303,6 +322,18 @@ function installKeyboardTestGlobals() {
}
}
function dispatchDocumentMouseDown(event: { button: number; target?: unknown }): void {
const listeners = documentListeners.get('mousedown') ?? [];
const mouseEvent = {
button: event.button,
target: event.target ?? null,
preventDefault: () => {},
};
for (const listener of listeners) {
listener(mouseEvent);
}
}
function dispatchFocusInOnPopup(): void {
const listeners = documentListeners.get('focusin') ?? [];
const focusEvent = {
@@ -335,6 +366,10 @@ function installKeyboardTestGlobals() {
configurable: true,
value: previousMouseEvent,
});
Object.defineProperty(globalThis, 'Element', {
configurable: true,
value: previousElement,
});
}
const overlay = {
@@ -348,10 +383,12 @@ function installKeyboardTestGlobals() {
mpvCommands,
sessionActions,
overlay,
interactionActivations,
overlayFocusCalls,
focusMainWindowCalls: () => focusMainWindowCalls,
windowFocusCalls: () => windowFocusCalls,
dispatchKeydown,
dispatchDocumentMouseDown,
dispatchFocusInOnPopup,
dispatchWindowEvent,
setPopupVisible: (value: boolean) => {
@@ -369,6 +406,11 @@ function installKeyboardTestGlobals() {
setSessionBindings: (value: CompiledSessionBinding[]) => {
sessionBindings = value;
},
createInteractiveTarget: () => {
const target = new TestElement();
target.closest = (selector: string) => (selector.includes('.modal') ? target : null);
return target;
},
setGetSessionBindings: (value: () => Promise<CompiledSessionBinding[]>) => {
getSessionBindingsImpl = value;
},
@@ -565,6 +607,39 @@ test('mpv input forwarding waits for session bindings before resolving setup', a
}
});
test('right-clicking non-interactive overlay content raises playback window before toggling pause', async () => {
const { handlers, testGlobals } = createKeyboardHandlerHarness();
try {
await handlers.setupMpvInputForwarding();
testGlobals.dispatchDocumentMouseDown({ button: 2 });
await wait(0);
assert.deepEqual(testGlobals.interactionActivations, ['activate-playback-window']);
assert.deepEqual(testGlobals.mpvCommands.slice(-1), [['cycle', 'pause']]);
} finally {
testGlobals.restore();
}
});
test('right-clicking interactive overlay controls does not raise playback window or toggle pause', async () => {
const { handlers, testGlobals } = createKeyboardHandlerHarness();
const interactiveTarget = testGlobals.createInteractiveTarget();
try {
await handlers.setupMpvInputForwarding();
testGlobals.dispatchDocumentMouseDown({ button: 2, target: interactiveTarget });
await wait(0);
assert.deepEqual(testGlobals.interactionActivations, []);
assert.deepEqual(testGlobals.mpvCommands, []);
} finally {
testGlobals.restore();
}
});
test('mpv input forwarding retries a transient keyboard config IPC failure', async () => {
const { handlers, testGlobals } = createKeyboardHandlerHarness();
let calls = 0;
+6 -1
View File
@@ -1218,7 +1218,12 @@ export function createKeyboardHandlers(
document.addEventListener('mousedown', (e: MouseEvent) => {
if (e.button === 2 && !isInteractiveTarget(e.target)) {
e.preventDefault();
window.electronAPI.sendMpvCommand(['cycle', 'pause']);
void window.electronAPI
.activatePlaybackWindowForOverlayInteraction()
.catch(() => false)
.finally(() => {
window.electronAPI.sendMpvCommand(['cycle', 'pause']);
});
}
});
@@ -236,6 +236,7 @@ test('subtitle sidebar modal opens from snapshot and clicking cue seeks playback
const previousWindow = globals.window;
const previousDocument = globals.document;
const mpvCommands: Array<Array<string | number>> = [];
const modalNotifications: string[] = [];
const snapshot: SubtitleSidebarSnapshot = {
cues: [
@@ -280,6 +281,12 @@ test('subtitle sidebar modal opens from snapshot and clicking cue seeks playback
sendMpvCommand: (command: Array<string | number>) => {
mpvCommands.push(command);
},
notifyOverlayModalOpened: (modal: string) => {
modalNotifications.push(`open:${modal}`);
},
notifyOverlayModalClosed: (modal: string) => {
modalNotifications.push(`close:${modal}`);
},
} as unknown as ElectronAPI,
},
});
@@ -329,9 +336,13 @@ test('subtitle sidebar modal opens from snapshot and clicking cue seeks playback
},
state,
};
const visibilityChanges: boolean[] = [];
const modal = createSubtitleSidebarModal(ctx as never, {
modalStateReader: { isAnyModalOpen: () => false },
onVisibilityChanged: (visible) => {
visibilityChanges.push(visible);
},
});
await modal.openSubtitleSidebarModal();
@@ -345,9 +356,14 @@ test('subtitle sidebar modal opens from snapshot and clicking cue seeks playback
assert.equal(contentStyleValues.get('font-size'), '22px');
assert.equal(contentStyle.color, '#ffffff');
assert.equal(contentStyleValues.get('--subtitle-sidebar-timestamp-color'), '#aaaaaa');
assert.deepEqual(visibilityChanges, [true]);
modal.seekToCue(snapshot.cues[0]!);
assert.deepEqual(mpvCommands.at(-1), ['seek', 1.08, 'absolute+exact']);
modal.closeSubtitleSidebarModal();
assert.deepEqual(visibilityChanges, [true, false]);
assert.deepEqual(modalNotifications, ['open:subtitle-sidebar', 'close:subtitle-sidebar']);
} finally {
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
@@ -760,6 +776,104 @@ test('subtitle sidebar auto-open on startup only opens when enabled and configur
}
});
test('subtitle sidebar auto-open restores previously open sidebar after renderer replacement', async () => {
const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown };
const previousWindow = globals.window;
const previousDocument = globals.document;
const snapshot: SubtitleSidebarSnapshot = {
cues: [{ startTime: 1, endTime: 2, text: 'first' }],
currentSubtitle: {
text: 'first',
startTime: 1,
endTime: 2,
},
config: {
enabled: true,
autoOpen: false,
layout: 'overlay',
toggleKey: 'Backslash',
pauseVideoOnHover: false,
autoScroll: true,
maxWidth: 420,
opacity: 0.92,
backgroundColor: 'rgba(54, 58, 79, 0.88)',
textColor: '#cad3f5',
fontFamily: '"Iosevka Aile", sans-serif',
fontSize: 17,
timestampColor: '#a5adcb',
activeLineColor: '#f5bde6',
activeLineBackgroundColor: 'rgba(138, 173, 244, 0.22)',
hoverLineBackgroundColor: 'rgba(54, 58, 79, 0.84)',
},
};
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
electronAPI: {
getSubtitleSidebarSnapshot: async () => snapshot,
sendMpvCommand: () => {},
} as unknown as ElectronAPI,
addEventListener: () => {},
removeEventListener: () => {},
},
});
Object.defineProperty(globalThis, 'document', {
configurable: true,
value: {
createElement: () => createCueRow(),
body: {
classList: createClassList(),
},
documentElement: {
style: {
setProperty: () => {},
},
},
},
});
try {
const state = createRendererState();
const modalClassList = createClassList(['hidden']);
const cueList = createListStub();
const ctx = {
dom: {
overlay: { classList: createClassList() },
subtitleSidebarModal: {
classList: modalClassList,
setAttribute: () => {},
style: { setProperty: () => {} },
addEventListener: () => {},
},
subtitleSidebarContent: {
classList: createClassList(),
getBoundingClientRect: () => ({ width: 420 }),
},
subtitleSidebarClose: { addEventListener: () => {} },
subtitleSidebarStatus: { textContent: '' },
subtitleSidebarList: cueList,
},
state,
};
const modal = createSubtitleSidebarModal(ctx as never, {
modalStateReader: { isAnyModalOpen: () => false },
shouldRestoreOpenOnStartup: async () => true,
});
await modal.autoOpenSubtitleSidebarOnStartup();
assert.equal(state.subtitleSidebarModalOpen, true);
assert.equal(modalClassList.contains('hidden'), false);
assert.equal(cueList.children.length, 1);
} finally {
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
}
});
test('subtitle sidebar refresh closes and clears state when config becomes disabled', async () => {
const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown };
const previousWindow = globals.window;
+8 -1
View File
@@ -196,6 +196,8 @@ export function createSubtitleSidebarModal(
ctx: RendererContext,
options: {
modalStateReader: Pick<ModalStateReader, 'isAnyModalOpen'>;
onVisibilityChanged?: (visible: boolean) => void;
shouldRestoreOpenOnStartup?: () => Promise<boolean>;
},
) {
let snapshotPollInterval: ReturnType<typeof setTimeout> | null = null;
@@ -648,13 +650,16 @@ export function createSubtitleSidebarModal(
startSnapshotPolling();
syncEmbeddedSidebarLayout();
restoreEmbeddedSidebarPassthrough();
window.electronAPI.notifyOverlayModalOpened?.('subtitle-sidebar');
options.onVisibilityChanged?.(true);
}
async function autoOpenSubtitleSidebarOnStartup(): Promise<void> {
const snapshot = await refreshSnapshot();
const shouldRestoreOpen = (await options.shouldRestoreOpenOnStartup?.()) === true;
if (
!snapshot.config.enabled ||
!snapshot.config.autoOpen ||
(!snapshot.config.autoOpen && !shouldRestoreOpen) ||
ctx.state.subtitleSidebarModalOpen
) {
return;
@@ -677,6 +682,8 @@ export function createSubtitleSidebarModal(
ctx.dom.overlay.classList.remove('interactive');
}
restoreEmbeddedSidebarPassthrough();
window.electronAPI.notifyOverlayModalClosed?.('subtitle-sidebar');
options.onVisibilityChanged?.(false);
}
async function toggleSubtitleSidebarModal(): Promise<void> {
@@ -0,0 +1,168 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createOverlayContentMeasurementReporter } from './overlay-content-measurement.js';
function makeElement(textContent: string, rect: DOMRect): HTMLElement {
return {
textContent,
getBoundingClientRect: () => rect,
} as unknown as HTMLElement;
}
test('overlay measurement reports primary and secondary subtitle bars as separate interactive rects', () => {
const originalWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
const reports: unknown[] = [];
Object.defineProperty(globalThis, 'window', {
configurable: true,
writable: true,
value: {
innerWidth: 1920,
innerHeight: 1080,
electronAPI: {
reportOverlayContentBounds: (payload: unknown) => {
reports.push(payload);
},
},
},
});
try {
const reporter = createOverlayContentMeasurementReporter({
platform: { overlayLayer: 'visible' },
dom: {
subtitleRoot: makeElement('primary', {
left: 810,
top: 910,
width: 300,
height: 48,
} as DOMRect),
subtitleContainer: makeElement('primary', {
left: 760,
top: 890,
width: 400,
height: 92,
} as DOMRect),
secondarySubRoot: makeElement('English', {
left: 850,
top: 50,
width: 220,
height: 34,
} as DOMRect),
secondarySubContainer: makeElement('English', {
left: 700,
top: 40,
width: 520,
height: 70,
} as DOMRect),
},
} as never);
reporter.emitNow();
const measuredAtMs = (reports[0] as { measuredAtMs?: unknown } | undefined)?.measuredAtMs;
if (typeof measuredAtMs !== 'number') {
assert.fail('Expected report timestamp.');
}
assert.deepEqual(reports, [
{
layer: 'visible',
measuredAtMs,
viewport: { width: 1920, height: 1080 },
contentRect: { x: 700, y: 40, width: 520, height: 942 },
interactiveRects: [
{ x: 760, y: 890, width: 400, height: 92 },
{ x: 700, y: 40, width: 520, height: 70 },
],
},
]);
} finally {
if (originalWindow) {
Object.defineProperty(globalThis, 'window', originalWindow);
} else {
delete (globalThis as { window?: unknown }).window;
}
}
});
test('overlay measurement includes open subtitle sidebar bounds as an interactive rect', () => {
const originalWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
const reports: unknown[] = [];
Object.defineProperty(globalThis, 'window', {
configurable: true,
writable: true,
value: {
innerWidth: 1920,
innerHeight: 1080,
electronAPI: {
reportOverlayContentBounds: (payload: unknown) => {
reports.push(payload);
},
},
},
});
try {
const reporter = createOverlayContentMeasurementReporter({
platform: { overlayLayer: 'visible' },
state: { subtitleSidebarModalOpen: true },
dom: {
subtitleRoot: makeElement('', {
left: 0,
top: 0,
width: 0,
height: 0,
} as DOMRect),
subtitleContainer: makeElement('', {
left: 0,
top: 0,
width: 0,
height: 0,
} as DOMRect),
secondarySubRoot: makeElement('', {
left: 0,
top: 0,
width: 0,
height: 0,
} as DOMRect),
secondarySubContainer: makeElement('', {
left: 0,
top: 0,
width: 0,
height: 0,
} as DOMRect),
subtitleSidebarContent: makeElement('sidebar', {
left: 1500,
top: 60,
width: 380,
height: 900,
} as DOMRect),
},
} as never);
reporter.emitNow();
const measuredAtMs = (reports[0] as { measuredAtMs?: unknown } | undefined)?.measuredAtMs;
if (typeof measuredAtMs !== 'number') {
assert.fail('Expected report timestamp.');
}
assert.deepEqual(reports, [
{
layer: 'visible',
measuredAtMs,
viewport: { width: 1920, height: 1080 },
contentRect: { x: 1500, y: 60, width: 380, height: 900 },
interactiveRects: [{ x: 1500, y: 60, width: 380, height: 900 }],
},
]);
} finally {
if (originalWindow) {
Object.defineProperty(globalThis, 'window', originalWindow);
} else {
delete (globalThis as { window?: unknown }).window;
}
}
});
+28 -8
View File
@@ -47,25 +47,43 @@ function hasVisibleTextContent(element: HTMLElement): boolean {
return Boolean(element.textContent && element.textContent.trim().length > 0);
}
function collectContentRect(ctx: RendererContext): OverlayContentRect | null {
let combinedRect: OverlayContentRect | null = null;
function hasArea(rect: OverlayContentRect): boolean {
return rect.width > 0 && rect.height > 0;
}
function collectInteractiveRects(ctx: RendererContext): OverlayContentRect[] {
const rects: OverlayContentRect[] = [];
const subtitleHasContent = hasVisibleTextContent(ctx.dom.subtitleRoot);
if (subtitleHasContent) {
const subtitleRect = toMeasuredRect(ctx.dom.subtitleRoot.getBoundingClientRect());
if (subtitleRect) {
combinedRect = subtitleRect;
const subtitleRect = toMeasuredRect(ctx.dom.subtitleContainer.getBoundingClientRect());
if (subtitleRect && hasArea(subtitleRect)) {
rects.push(subtitleRect);
}
}
const secondaryHasContent = hasVisibleTextContent(ctx.dom.secondarySubRoot);
if (secondaryHasContent) {
const secondaryRect = toMeasuredRect(ctx.dom.secondarySubContainer.getBoundingClientRect());
if (secondaryRect) {
combinedRect = combinedRect ? unionRects(combinedRect, secondaryRect) : secondaryRect;
if (secondaryRect && hasArea(secondaryRect)) {
rects.push(secondaryRect);
}
}
if (ctx.state?.subtitleSidebarModalOpen) {
const sidebarRect = toMeasuredRect(ctx.dom.subtitleSidebarContent.getBoundingClientRect());
if (sidebarRect && hasArea(sidebarRect)) {
rects.push(sidebarRect);
}
}
return rects;
}
function collectContentRect(rects: OverlayContentRect[]): OverlayContentRect | null {
let combinedRect: OverlayContentRect | null = null;
for (const rect of rects) {
combinedRect = combinedRect ? unionRects(combinedRect, rect) : rect;
}
if (!combinedRect) {
return null;
}
@@ -86,6 +104,7 @@ export function createOverlayContentMeasurementReporter(ctx: RendererContext) {
return;
}
const interactiveRects = collectInteractiveRects(ctx);
const measurement: OverlayContentMeasurement = {
layer: ctx.platform.overlayLayer,
measuredAtMs: Date.now(),
@@ -94,7 +113,8 @@ export function createOverlayContentMeasurementReporter(ctx: RendererContext) {
height: window.innerHeight,
},
// Explicit null rect signals "no content yet", and main should use fallback bounds.
contentRect: collectContentRect(ctx),
contentRect: collectContentRect(interactiveRects),
interactiveRects,
};
window.electronAPI.reportOverlayContentBounds(measurement);
+134 -37
View File
@@ -15,17 +15,30 @@ function createClassList() {
};
}
function replaceGlobalProperty(key: 'window' | 'document', value: unknown): () => void {
const original = Object.getOwnPropertyDescriptor(globalThis, key);
Object.defineProperty(globalThis, key, {
configurable: true,
writable: true,
value,
});
return () => {
if (original) {
Object.defineProperty(globalThis, key, original);
return;
}
delete (globalThis as Record<string, unknown>)[key];
};
}
test('idle visible overlay starts click-through on platforms that toggle mouse ignore', () => {
const classList = createClassList();
const ignoreCalls: Array<{ ignore: boolean; forward?: boolean }> = [];
const originalWindow = globalThis.window;
Object.assign(globalThis, {
window: {
electronAPI: {
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => {
ignoreCalls.push({ ignore, forward: options?.forward });
},
const restoreWindow = replaceGlobalProperty('window', {
electronAPI: {
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => {
ignoreCalls.push({ ignore, forward: options?.forward });
},
},
});
@@ -58,21 +71,18 @@ test('idle visible overlay starts click-through on platforms that toggle mouse i
assert.equal(classList.contains('interactive'), false);
assert.deepEqual(ignoreCalls, [{ ignore: true, forward: true }]);
} finally {
Object.assign(globalThis, { window: originalWindow });
restoreWindow();
}
});
test('youtube picker keeps overlay interactive even when subtitle hover is inactive', () => {
const classList = createClassList();
const ignoreCalls: Array<{ ignore: boolean; forward?: boolean }> = [];
const originalWindow = globalThis.window;
Object.assign(globalThis, {
window: {
electronAPI: {
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => {
ignoreCalls.push({ ignore, forward: options?.forward });
},
const restoreWindow = replaceGlobalProperty('window', {
electronAPI: {
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => {
ignoreCalls.push({ ignore, forward: options?.forward });
},
},
});
@@ -105,36 +115,32 @@ test('youtube picker keeps overlay interactive even when subtitle hover is inact
assert.equal(classList.contains('interactive'), true);
assert.deepEqual(ignoreCalls, [{ ignore: false, forward: undefined }]);
} finally {
Object.assign(globalThis, { window: originalWindow });
restoreWindow();
}
});
test('visible yomitan popup host keeps overlay interactive even when cached popup state is false', () => {
const classList = createClassList();
const ignoreCalls: Array<{ ignore: boolean; forward?: boolean }> = [];
const originalWindow = globalThis.window;
const originalDocument = globalThis.document;
Object.assign(globalThis, {
window: {
electronAPI: {
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => {
ignoreCalls.push({ ignore, forward: options?.forward });
},
const restoreWindow = replaceGlobalProperty('window', {
electronAPI: {
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => {
ignoreCalls.push({ ignore, forward: options?.forward });
},
getComputedStyle: () => ({
visibility: 'visible',
display: 'block',
opacity: '1',
}),
},
document: {
querySelectorAll: (selector: string) =>
selector ===
'[data-subminer-yomitan-popup-host="true"][data-subminer-yomitan-popup-visible="true"]'
? [{ getAttribute: () => 'true' }]
: [],
},
getComputedStyle: () => ({
visibility: 'visible',
display: 'block',
opacity: '1',
}),
});
const restoreDocument = replaceGlobalProperty('document', {
querySelectorAll: (selector: string) =>
selector ===
'[data-subminer-yomitan-popup-host="true"][data-subminer-yomitan-popup-visible="true"]'
? [{ getAttribute: () => 'true' }]
: [],
});
try {
@@ -165,6 +171,97 @@ test('visible yomitan popup host keeps overlay interactive even when cached popu
assert.equal(classList.contains('interactive'), true);
assert.deepEqual(ignoreCalls, [{ ignore: false, forward: undefined }]);
} finally {
Object.assign(globalThis, { window: originalWindow, document: originalDocument });
restoreDocument();
restoreWindow();
}
});
test('Linux subtitle hover keeps root passive and does not report whole-window interactive hint', () => {
const classList = createClassList();
const interactiveHints: boolean[] = [];
const restoreWindow = replaceGlobalProperty('window', {
electronAPI: {
reportOverlayInteractive: (interactive: boolean) => {
interactiveHints.push(interactive);
},
},
});
try {
syncOverlayMouseIgnoreState({
dom: {
overlay: { classList },
},
platform: {
isLinuxPlatform: true,
shouldToggleMouseIgnore: false,
},
state: {
isOverSubtitle: true,
isOverSubtitleSidebar: false,
yomitanPopupVisible: false,
controllerSelectModalOpen: false,
controllerDebugModalOpen: false,
jimakuModalOpen: false,
youtubePickerModalOpen: false,
kikuModalOpen: false,
runtimeOptionsModalOpen: false,
subsyncModalOpen: false,
sessionHelpModalOpen: false,
subtitleSidebarModalOpen: false,
subtitleSidebarConfig: null,
},
} as never);
assert.equal(classList.contains('interactive'), false);
assert.deepEqual(interactiveHints, [false]);
} finally {
restoreWindow();
}
});
test('Linux modal state reports whole-window interactive hint', () => {
const classList = createClassList();
const interactiveHints: boolean[] = [];
const restoreWindow = replaceGlobalProperty('window', {
electronAPI: {
reportOverlayInteractive: (interactive: boolean) => {
interactiveHints.push(interactive);
},
},
});
try {
syncOverlayMouseIgnoreState({
dom: {
overlay: { classList },
},
platform: {
isLinuxPlatform: true,
shouldToggleMouseIgnore: false,
},
state: {
isOverSubtitle: false,
isOverSubtitleSidebar: false,
yomitanPopupVisible: false,
controllerSelectModalOpen: false,
controllerDebugModalOpen: false,
jimakuModalOpen: false,
youtubePickerModalOpen: false,
kikuModalOpen: false,
runtimeOptionsModalOpen: true,
subsyncModalOpen: false,
sessionHelpModalOpen: false,
subtitleSidebarModalOpen: false,
subtitleSidebarConfig: null,
},
} as never);
assert.equal(classList.contains('interactive'), true);
assert.deepEqual(interactiveHints, [true]);
} finally {
restoreWindow();
}
});
+13 -5
View File
@@ -26,18 +26,26 @@ function isYomitanPopupInteractionActive(state: RendererState): boolean {
}
export function syncOverlayMouseIgnoreState(ctx: RendererContext): void {
const shouldKeepWindowInteractive =
isYomitanPopupInteractionActive(ctx.state) || isBlockingOverlayModalOpen(ctx.state);
const shouldStayInteractive =
ctx.state.isOverSubtitle ||
ctx.state.isOverSubtitleSidebar ||
isYomitanPopupInteractionActive(ctx.state) ||
isBlockingOverlayModalOpen(ctx.state);
ctx.state.isOverSubtitle || ctx.state.isOverSubtitleSidebar || shouldKeepWindowInteractive;
const shouldMarkOverlayInteractive = ctx.platform?.isLinuxPlatform
? shouldKeepWindowInteractive
: shouldStayInteractive;
if (shouldStayInteractive) {
if (shouldMarkOverlayInteractive) {
ctx.dom.overlay.classList.add('interactive');
} else {
ctx.dom.overlay.classList.remove('interactive');
}
if (!ctx.platform?.shouldToggleMouseIgnore) {
// On Linux the main process owns window passthrough via a cursor poll (Electron can't
// forward mouse-move through a click-through window on X11). Report the interactive hint
// only for popups/modals that sit off measured hit rects; subtitles/sidebar use the poll.
if (ctx.platform?.isLinuxPlatform) {
window.electronAPI.reportOverlayInteractive?.(shouldKeepWindowInteractive);
}
return;
}
+83
View File
@@ -0,0 +1,83 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import test from 'node:test';
const rendererSource = fs.readFileSync(
path.join(process.cwd(), 'src/renderer/renderer.ts'),
'utf8',
);
function indexOfRequired(pattern: string): number {
const index = rendererSource.indexOf(pattern);
assert.notEqual(index, -1, `Expected renderer.ts to contain ${pattern}`);
return index;
}
test('renderer applies subtitle style and position before first subtitle paint', () => {
const styleIndex = indexOfRequired(
'const initialSubtitleStyle = await window.electronAPI.getSubtitleStyle();',
);
const positionIndex = indexOfRequired(
"await window.electronAPI.getSubtitlePosition(),\n 'startup',",
);
const listenerIndex = indexOfRequired('window.electronAPI.onSubtitle((data: SubtitleData) => {');
const currentSubtitleIndex = indexOfRequired(
'initialSubtitle = await window.electronAPI.getCurrentSubtitle();',
);
assert.ok(styleIndex < listenerIndex);
assert.ok(positionIndex < listenerIndex);
assert.ok(styleIndex < currentSubtitleIndex);
assert.ok(positionIndex < currentSubtitleIndex);
});
test('renderer renders initial subtitle snapshot before subscribing to live subtitle updates', () => {
const listenerIndex = indexOfRequired('window.electronAPI.onSubtitle((data: SubtitleData) => {');
const currentSubtitleIndex = indexOfRequired(
'initialSubtitle = await window.electronAPI.getCurrentSubtitle();',
);
const initialRenderIndex = indexOfRequired('subtitleRenderer.renderSubtitle(initialSubtitle);');
assert.ok(currentSubtitleIndex < initialRenderIndex);
assert.ok(initialRenderIndex < listenerIndex);
});
test('renderer reports subtitle bounds immediately after initial subtitle layout', () => {
const initialRenderIndex = indexOfRequired('subtitleRenderer.renderSubtitle(initialSubtitle);');
const initialLayoutIndex = indexOfRequired(
'subtitleRenderer.renderSubtitle(initialSubtitle);\n positioning.applyYPercent(positioning.getCurrentYPercent());',
);
const immediateMeasurementIndex = indexOfRequired(
'positioning.applyYPercent(positioning.getCurrentYPercent());\n measurementReporter.emitNow();',
);
const listenerIndex = indexOfRequired('window.electronAPI.onSubtitle((data: SubtitleData) => {');
assert.equal(initialRenderIndex, initialLayoutIndex);
assert.ok(initialLayoutIndex < immediateMeasurementIndex);
assert.ok(immediateMeasurementIndex < listenerIndex);
});
test('renderer reports subtitle bounds immediately after live subtitle layout', () => {
const liveRenderIndex = indexOfRequired('subtitleRenderer.renderSubtitle(data);');
const liveLayoutIndex = indexOfRequired(
'subtitleRenderer.renderSubtitle(data);\n positioning.applyYPercent(positioning.getCurrentYPercent());',
);
const immediateMeasurementIndex = indexOfRequired(
'positioning.applyYPercent(positioning.getCurrentYPercent());\n measurementReporter.emitNow();',
);
const sidebarUpdateIndex = indexOfRequired('subtitleSidebarModal.handleSubtitleUpdated(data);');
assert.equal(liveRenderIndex, liveLayoutIndex);
assert.ok(liveLayoutIndex < immediateMeasurementIndex);
assert.ok(immediateMeasurementIndex < sidebarUpdateIndex);
});
test('renderer restores subtitle sidebar open state only on visible overlay layer', () => {
const sidebarRestoreIndex = indexOfRequired(
"ctx.platform.overlayLayer === 'visible' && (await window.electronAPI.getSubtitleSidebarOpen())",
);
const sidebarModalIndex = indexOfRequired('const subtitleSidebarModal = createSubtitleSidebarModal');
assert.ok(sidebarModalIndex < sidebarRestoreIndex);
});
+32 -23
View File
@@ -142,6 +142,11 @@ const sessionHelpModal = createSessionHelpModal(ctx, {
});
const subtitleSidebarModal = createSubtitleSidebarModal(ctx, {
modalStateReader: { isAnyModalOpen },
shouldRestoreOpenOnStartup: async () =>
ctx.platform.overlayLayer === 'visible' && (await window.electronAPI.getSubtitleSidebarOpen()),
onVisibilityChanged: () => {
measurementReporter.emitNow();
},
});
const kikuModal = createKikuModal(ctx, {
modalStateReader: { isAnyModalOpen },
@@ -596,15 +601,16 @@ async function init(): Promise<void> {
syncOverlayMouseIgnoreState(ctx);
}
window.electronAPI.onSubtitle((data: SubtitleData) => {
runGuarded('subtitle:update', () => {
lastSubtitlePreview = truncateForErrorLog(getSubtitleTextForPreview(data));
keyboardHandlers.handleSubtitleContentUpdated();
subtitleRenderer.renderSubtitle(data);
subtitleSidebarModal.handleSubtitleUpdated(data);
measurementReporter.schedule();
});
});
await keyboardHandlers.setupMpvInputForwarding();
const initialSubtitleStyle = await window.electronAPI.getSubtitleStyle();
subtitleRenderer.applySubtitleStyle(initialSubtitleStyle);
subtitleRenderer.updatePrimarySubMode(initialSubtitleStyle?.primaryDefaultMode ?? 'visible');
positioning.applyStoredSubtitlePosition(
await window.electronAPI.getSubtitlePosition(),
'startup',
);
measurementReporter.schedule();
window.electronAPI.onSubtitlePosition((position: SubtitlePosition | null) => {
runGuarded('subtitle-position:update', () => {
@@ -618,8 +624,6 @@ async function init(): Promise<void> {
});
});
await keyboardHandlers.setupMpvInputForwarding();
let initialSubtitle: SubtitleData | string = '';
try {
initialSubtitle = await window.electronAPI.getCurrentSubtitle();
@@ -629,7 +633,20 @@ async function init(): Promise<void> {
lastSubtitlePreview = truncateForErrorLog(getSubtitleTextForPreview(initialSubtitle));
keyboardHandlers.handleSubtitleContentUpdated();
subtitleRenderer.renderSubtitle(initialSubtitle);
measurementReporter.schedule();
positioning.applyYPercent(positioning.getCurrentYPercent());
measurementReporter.emitNow();
window.electronAPI.onSubtitle((data: SubtitleData) => {
runGuarded('subtitle:update', () => {
lastSubtitlePreview = truncateForErrorLog(getSubtitleTextForPreview(data));
keyboardHandlers.handleSubtitleContentUpdated();
subtitleRenderer.renderSubtitle(data);
positioning.applyYPercent(positioning.getCurrentYPercent());
measurementReporter.emitNow();
subtitleSidebarModal.handleSubtitleUpdated(data);
measurementReporter.schedule();
});
});
window.electronAPI.onSecondarySub((text: string) => {
runGuarded('secondary-subtitle:update', () => {
@@ -713,18 +730,9 @@ async function init(): Promise<void> {
}
startControllerPolling();
const initialSubtitleStyle = await window.electronAPI.getSubtitleStyle();
subtitleRenderer.applySubtitleStyle(initialSubtitleStyle);
subtitleRenderer.updatePrimarySubMode(initialSubtitleStyle?.primaryDefaultMode ?? 'visible');
await subtitleSidebarModal.refreshSubtitleSidebarSnapshot();
await subtitleSidebarModal.autoOpenSubtitleSidebarOnStartup();
positioning.applyStoredSubtitlePosition(
await window.electronAPI.getSubtitlePosition(),
'startup',
);
measurementReporter.schedule();
measurementReporter.emitNow();
}
@@ -775,7 +783,8 @@ function setupDragDropToMpvQueue(): void {
const droppedVideoPaths = collectDroppedVideoPaths(event.dataTransfer);
const droppedSubtitlePaths = collectDroppedSubtitlePaths(event.dataTransfer);
const loadCommands = buildMpvLoadfileCommands(droppedVideoPaths, event.shiftKey);
const appendDroppedVideos = event.shiftKey;
const loadCommands = buildMpvLoadfileCommands(droppedVideoPaths, appendDroppedVideos);
const subtitleCommands = buildMpvSubtitleAddCommands(droppedSubtitlePaths);
for (const command of loadCommands) {
window.electronAPI.sendMpvCommand(command);
@@ -785,7 +794,7 @@ function setupDragDropToMpvQueue(): void {
}
const osdParts: string[] = [];
if (loadCommands.length > 0) {
const action = event.shiftKey ? 'Queued' : 'Loaded';
const action = appendDroppedVideos ? 'Queued' : 'Loaded';
osdParts.push(`${action} ${loadCommands.length} file${loadCommands.length === 1 ? '' : 's'}`);
}
if (subtitleCommands.length > 0) {
+5
View File
@@ -1218,6 +1218,11 @@ body.settings-modal-open #secondarySubContainer {
pointer-events: none !important;
}
body.settings-modal-open .subtitle-sidebar-modal {
display: none !important;
pointer-events: none !important;
}
.secondary-sub-hidden {
display: none !important;
}
+133
View File
@@ -57,6 +57,7 @@ class FakeElement {
dataset: Record<string, string> = {};
style = new FakeStyleDeclaration();
className = '';
replaceChildrenCalls = 0;
private ownTextContent = '';
constructor(public tagName: string) {}
@@ -97,6 +98,7 @@ class FakeElement {
}
replaceChildren(): void {
this.replaceChildrenCalls += 1;
this.childNodes = [];
this.ownTextContent = '';
}
@@ -347,6 +349,130 @@ test('renderSubtitle skips character image when name-match rendering is disabled
}
});
test('renderSubtitle skips identical primary subtitle DOM replacement', () => {
const restoreDocument = installFakeDocument();
try {
const subtitleRoot = new FakeElement('div');
const ctx = {
state: createRendererState(),
dom: {
subtitleRoot,
subtitleContainer: new FakeElement('div'),
secondarySubRoot: new FakeElement('div'),
secondarySubContainer: new FakeElement('div'),
},
} as never;
const renderer = createSubtitleRenderer(ctx);
renderer.renderSubtitle({ text: '字幕', tokens: null });
renderer.renderSubtitle({ text: '字幕', tokens: null });
renderer.renderSubtitle({ text: '字幕2', tokens: null });
assert.equal(subtitleRoot.replaceChildrenCalls, 2);
assert.equal(subtitleRoot.textContent, '字幕2');
} finally {
restoreDocument();
}
});
test('renderSubtitle keeps tokenized subtitle when stale plain payload repeats same text', () => {
const restoreDocument = installFakeDocument();
try {
const subtitleRoot = new FakeElement('div');
const ctx = {
state: createRendererState(),
dom: {
subtitleRoot,
subtitleContainer: new FakeElement('div'),
secondarySubRoot: new FakeElement('div'),
secondarySubContainer: new FakeElement('div'),
},
} as never;
const renderer = createSubtitleRenderer(ctx);
renderer.renderSubtitle({
text: 'アクア',
tokens: [createToken({ surface: 'アクア', headword: 'アクア', reading: 'あくあ' })],
});
renderer.renderSubtitle({ text: 'アクア', tokens: null });
assert.equal(subtitleRoot.replaceChildrenCalls, 1);
assert.equal(collectWordNodes(subtitleRoot).length, 1);
assert.equal(subtitleRoot.textContent, 'アクア');
} finally {
restoreDocument();
}
});
test('renderSubtitle accepts repeated plain payload after style invalidates tokenized render', () => {
const restoreDocument = installFakeDocument();
try {
const subtitleRoot = new FakeElement('div');
const ctx = {
state: createRendererState(),
dom: {
subtitleRoot,
subtitleContainer: new FakeElement('div'),
secondarySubRoot: new FakeElement('div'),
secondarySubContainer: new FakeElement('div'),
},
} as never;
const renderer = createSubtitleRenderer(ctx);
renderer.renderSubtitle({
text: 'アクア',
tokens: [createToken({ surface: 'アクア', headword: 'アクア', reading: 'あくあ' })],
});
renderer.applySubtitleStyle({ fontColor: '#fff' } as never);
renderer.renderSubtitle({ text: 'アクア', tokens: null });
assert.equal(subtitleRoot.replaceChildrenCalls, 2);
assert.equal(collectWordNodes(subtitleRoot).length, 0);
assert.equal(subtitleRoot.textContent, 'アクア');
} finally {
restoreDocument();
}
});
test('renderSubtitle re-renders identical text after style changes affect token output', () => {
const restoreDocument = installFakeDocument();
try {
const subtitleRoot = new FakeElement('div');
const ctx = {
state: {
...createRendererState(),
nameMatchEnabled: false,
},
dom: {
subtitleRoot,
subtitleContainer: new FakeElement('div'),
secondarySubRoot: new FakeElement('div'),
secondarySubContainer: new FakeElement('div'),
},
} as never;
const subtitle = {
text: 'アクア',
tokens: [
{
...createToken({ surface: 'アクア', headword: 'アクア', reading: 'あくあ' }),
isNameMatch: true,
} as MergedToken,
],
};
const renderer = createSubtitleRenderer(ctx);
renderer.renderSubtitle(subtitle);
renderer.applySubtitleStyle({ nameMatchEnabled: true } as never);
renderer.renderSubtitle(subtitle);
const [word] = collectWordNodes(subtitleRoot);
assert.equal(subtitleRoot.replaceChildrenCalls, 2);
assert.ok(word?.className.includes('word-name-match'));
} finally {
restoreDocument();
}
});
test('renderer content security policy allows data URL character images', () => {
const htmlPath = path.join(process.cwd(), 'src', 'renderer', 'index.html');
const htmlText = fs.readFileSync(htmlPath, 'utf-8');
@@ -1231,6 +1357,13 @@ test('subtitle annotation CSS underlines JLPT tokens without changing token colo
assert.match(secondaryHoverWindowsBlock, /top:\s*40px;/);
assert.match(secondaryHoverWindowsBlock, /padding-top:\s*0;/);
const sidebarSettingsModalBlock = extractClassBlock(
cssText,
'body.settings-modal-open .subtitle-sidebar-modal',
);
assert.match(sidebarSettingsModalBlock, /display:\s*none !important;/);
assert.match(sidebarSettingsModalBlock, /pointer-events:\s*none !important;/);
const subtitleSidebarListBlock = extractClassBlock(cssText, '.subtitle-sidebar-list');
assert.doesNotMatch(subtitleSidebarListBlock, /scroll-behavior:\s*smooth;/);
+48 -3
View File
@@ -653,9 +653,32 @@ function renderPlainTextPreserveLineBreaks(root: ParentNode, text: string): void
}
export function createSubtitleRenderer(ctx: RendererContext) {
function renderSubtitle(data: SubtitleData | string): void {
ctx.dom.subtitleRoot.replaceChildren();
let lastPrimarySubtitleRenderKey: string | null = null;
let lastPrimarySubtitleNormalizedText: string | null = null;
let lastPrimarySubtitleRenderedTokenized = false;
function getPrimarySubtitleRenderKey(
text: string,
normalized: string,
tokens: MergedToken[] | null,
): string {
if (!shouldRenderTokenizedSubtitle(tokens?.length ?? 0) || !tokens) {
return JSON.stringify({
mode: 'plain',
text: normalized,
});
}
return JSON.stringify({
mode: 'tokens',
text,
tokens,
settings: getTokenRenderSettings(),
preserveSubtitleLineBreaks: ctx.state.preserveSubtitleLineBreaks,
});
}
function renderSubtitle(data: SubtitleData | string): void {
let text: string;
let tokens: MergedToken[] | null;
@@ -669,9 +692,30 @@ export function createSubtitleRenderer(ctx: RendererContext) {
return;
}
const normalized = normalizeSubtitle(text, true, !ctx.state.preserveSubtitleLineBreaks);
const hasRenderableTokens =
shouldRenderTokenizedSubtitle(tokens?.length ?? 0) && Boolean(tokens);
if (
lastPrimarySubtitleRenderKey !== null &&
!hasRenderableTokens &&
lastPrimarySubtitleRenderedTokenized &&
normalized === lastPrimarySubtitleNormalizedText
) {
return;
}
const renderKey = getPrimarySubtitleRenderKey(text, normalized, tokens);
if (renderKey === lastPrimarySubtitleRenderKey) {
return;
}
lastPrimarySubtitleRenderKey = renderKey;
lastPrimarySubtitleNormalizedText = normalized;
lastPrimarySubtitleRenderedTokenized = hasRenderableTokens;
ctx.dom.subtitleRoot.replaceChildren();
if (!text) return;
const normalized = normalizeSubtitle(text, true, !ctx.state.preserveSubtitleLineBreaks);
if (shouldRenderTokenizedSubtitle(tokens?.length ?? 0) && tokens) {
renderWithTokens(
ctx.dom.subtitleRoot,
@@ -753,6 +797,7 @@ export function createSubtitleRenderer(ctx: RendererContext) {
function applySubtitleStyle(style: SubtitleRendererStyleConfig | null): void {
if (!style) return;
lastPrimarySubtitleRenderKey = null;
const styleDeclarations = style as Record<string, unknown>;
applyInlineStyleDeclarations(ctx.dom.subtitleRoot, styleDeclarations, CONTAINER_STYLE_KEYS);