mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-23 05:16:23 -07:00
fix(overlay): keep Hachidori attention from pausing playback
Hachidori's hachidori-popup-shown/hidden pair means "the reader needs mouse events", which includes a left press anywhere on the overlay that may start a selection. SubMiner treated it as popup visibility, so a click paused and resumed mpv, and holding the button kept it paused. The host element only exists after the first lookup, so the first attempt (gate on an open popup pane when a host exists) still paused on clicks before any lookup. Once a Hachidori event has been seen, popup auto-pause now requires an unhidden popup pane in the host's shadow root, and rechecks after each successful lookup so drag-select popups still pause.
This commit is contained in:
@@ -50,7 +50,7 @@ The dictionary backend is selected once at startup by `dictionaryBackend`. Yomit
|
||||
|
||||
`setup-state.json` records one backend's status at a time plus `completedDictionaryBackends`, the backends that finished setup before. The app projects the file onto its active backend on startup and stamps that backend into the file. The launcher gates playback on the stamped backend when an app is already running, since a config edit takes effect only after restart.
|
||||
|
||||
`vendor/hachidori/` is a submodule of `ksyasuda/hachidori`, tracking the `subminer` branch and pinned to a tested commit. Its nested HoshiDicts submodule and WASM binaries remain upstream versions. Initialize sources with `git submodule update --init --recursive`; merge upstream updates in the fork, test them, then update SubMiner's submodule commit. `SOURCE.json` records the upstream base and artifact checksums; the submodule commit identifies the integrated version. `build:hachidori` verifies recorded artifact checksums and stages the extension for development and packaging. It enables overlay mode, disables custom JavaScript, and removes the unsupported `userScripts` permission only in that staged copy; the fork keeps upstream browser defaults. Before loading the extension, its session clears service worker registrations so Electron uses the current bundled code; dictionary databases and settings remain intact. First-run setup uses Hachidori sharing messages to link or unlink external dictionary hosts and checks their live inventory. Linked dictionaries and dictionary edits use the host, while Anki configuration, pronunciation sources, custom buttons, and mining stay local to SubMiner. The parser bridge adapts its runtime messages to the existing subtitle scanner and dictionary automation. Scanning retains term-entry frequencies, and only tokens without ranks need further frequency lookups through the existing term-entry API. This requires a matching definition entry and does not preserve the frequency source's reading provenance. SubMiner consumes native `hachidori-popup-shown` and `hachidori-popup-hidden` attention events for mouse handling, keyboard focus, and the subtitle sidebar. The fork retains host attributes, hover and successful-lookup notifications, and commands that need private reader state. The Anki proxy strips local duplicate/overwrite metadata before forwarding requests and enriches only confirmed writes.
|
||||
`vendor/hachidori/` is a submodule of `ksyasuda/hachidori`, tracking the `subminer` branch and pinned to a tested commit. Its nested HoshiDicts submodule and WASM binaries remain upstream versions. Initialize sources with `git submodule update --init --recursive`; merge upstream updates in the fork, test them, then update SubMiner's submodule commit. `SOURCE.json` records the upstream base and artifact checksums; the submodule commit identifies the integrated version. `build:hachidori` verifies recorded artifact checksums and stages the extension for development and packaging. It enables overlay mode, disables custom JavaScript, and removes the unsupported `userScripts` permission only in that staged copy; the fork keeps upstream browser defaults. Before loading the extension, its session clears service worker registrations so Electron uses the current bundled code; dictionary databases and settings remain intact. First-run setup uses Hachidori sharing messages to link or unlink external dictionary hosts and checks their live inventory. Linked dictionaries and dictionary edits use the host, while Anki configuration, pronunciation sources, custom buttons, and mining stay local to SubMiner. The parser bridge adapts its runtime messages to the existing subtitle scanner and dictionary automation. Scanning retains term-entry frequencies, and only tokens without ranks need further frequency lookups through the existing term-entry API. This requires a matching definition entry and does not preserve the frequency source's reading provenance. SubMiner consumes native `hachidori-popup-shown` and `hachidori-popup-hidden` attention events for mouse handling, keyboard focus, and the subtitle sidebar. Attention also covers a left press anywhere on the overlay that may start a selection, and the host element only exists after the first lookup, so once a Hachidori event has been seen popup auto-pause requires an unhidden popup pane in the host's shadow root and rechecks after each successful lookup. The fork retains host attributes, hover and successful-lookup notifications, and commands that need private reader state. The Anki proxy strips local duplicate/overwrite metadata before forwarding requests and enriches only confirmed writes.
|
||||
|
||||
- Small units, explicit boundaries
|
||||
- Composition over monoliths
|
||||
|
||||
@@ -4,6 +4,11 @@ import test from 'node:test';
|
||||
import type { SubtitleSidebarConfig } from '../../types';
|
||||
import { createMouseHandlers } from './mouse.js';
|
||||
import {
|
||||
HACHIDORI_HOST_SELECTOR,
|
||||
HACHIDORI_POPUP_HIDDEN_EVENT,
|
||||
HACHIDORI_POPUP_SELECTOR,
|
||||
HACHIDORI_POPUP_SHOWN_EVENT,
|
||||
YOMITAN_LOOKUP_EVENT,
|
||||
YOMITAN_POPUP_HIDDEN_EVENT,
|
||||
YOMITAN_POPUP_HOST_SELECTOR,
|
||||
YOMITAN_POPUP_MOUSE_ENTER_EVENT,
|
||||
@@ -722,6 +727,210 @@ test('popup open pauses and popup close resumes when yomitan popup auto-pause is
|
||||
}
|
||||
});
|
||||
|
||||
// Hachidori publishes one attention signal for an open popup and for a press on
|
||||
// subtitle text that may become a selection, so its popup pane is the stub's
|
||||
// source of truth for what is on screen.
|
||||
async function withHachidoriReader(
|
||||
run: (reader: {
|
||||
emit: (event: string) => void;
|
||||
setAttention: (active: boolean) => void;
|
||||
setPopupOpen: (open: boolean) => void;
|
||||
setHostAttached: (attached: boolean) => void;
|
||||
}) => Promise<void>,
|
||||
): Promise<void> {
|
||||
const previousWindow = (globalThis as { window?: unknown }).window;
|
||||
const previousDocument = (globalThis as { document?: unknown }).document;
|
||||
const previousMutationObserver = (globalThis as { MutationObserver?: unknown }).MutationObserver;
|
||||
const previousNode = (globalThis as { Node?: unknown }).Node;
|
||||
const windowListeners = new Map<string, Array<() => void>>();
|
||||
const pane = { hidden: true };
|
||||
let attention = false;
|
||||
let hostAttached = true;
|
||||
const host = {
|
||||
tagName: 'HACHIDORI-HOST',
|
||||
getAttribute: (name: string) =>
|
||||
name === 'data-subminer-yomitan-popup-visible' ? String(attention) : null,
|
||||
shadowRoot: {
|
||||
querySelectorAll: (selector: string) => (selector === HACHIDORI_POPUP_SELECTOR ? [pane] : []),
|
||||
},
|
||||
};
|
||||
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: {
|
||||
addEventListener: (type: string, listener: () => void) => {
|
||||
const bucket = windowListeners.get(type) ?? [];
|
||||
bucket.push(listener);
|
||||
windowListeners.set(type, bucket);
|
||||
},
|
||||
electronAPI: {
|
||||
setIgnoreMouseEvents: () => {},
|
||||
},
|
||||
focus: () => {},
|
||||
innerHeight: 1000,
|
||||
getSelection: () => null,
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
},
|
||||
});
|
||||
Object.defineProperty(globalThis, 'document', {
|
||||
configurable: true,
|
||||
value: {
|
||||
querySelector: () => null,
|
||||
querySelectorAll: (selector: string) => {
|
||||
if (!hostAttached) return [];
|
||||
if (selector === HACHIDORI_HOST_SELECTOR || selector === YOMITAN_POPUP_HOST_SELECTOR) {
|
||||
return [host];
|
||||
}
|
||||
if (selector === YOMITAN_POPUP_VISIBLE_HOST_SELECTOR) {
|
||||
return attention ? [host] : [];
|
||||
}
|
||||
return [];
|
||||
},
|
||||
body: {},
|
||||
elementFromPoint: () => null,
|
||||
addEventListener: () => {},
|
||||
},
|
||||
});
|
||||
Object.defineProperty(globalThis, 'MutationObserver', {
|
||||
configurable: true,
|
||||
value: class {
|
||||
observe() {}
|
||||
},
|
||||
});
|
||||
Object.defineProperty(globalThis, 'Node', {
|
||||
configurable: true,
|
||||
value: {
|
||||
ELEMENT_NODE: 1,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await run({
|
||||
emit: (event) => {
|
||||
for (const listener of windowListeners.get(event) ?? []) {
|
||||
listener();
|
||||
}
|
||||
},
|
||||
setAttention: (active) => {
|
||||
attention = active;
|
||||
},
|
||||
setPopupOpen: (open) => {
|
||||
pane.hidden = !open;
|
||||
},
|
||||
setHostAttached: (attached) => {
|
||||
hostAttached = attached;
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
|
||||
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
|
||||
Object.defineProperty(globalThis, 'MutationObserver', {
|
||||
configurable: true,
|
||||
value: previousMutationObserver,
|
||||
});
|
||||
Object.defineProperty(globalThis, 'Node', { configurable: true, value: previousNode });
|
||||
}
|
||||
}
|
||||
|
||||
function createPopupAutoPauseHandlers(
|
||||
ctx: ReturnType<typeof createMouseTestContext>,
|
||||
mpvCommands: Array<(string | number)[]>,
|
||||
) {
|
||||
return createMouseHandlers(ctx as never, {
|
||||
modalStateReader: {
|
||||
isAnySettingsModalOpen: () => false,
|
||||
isAnyModalOpen: () => false,
|
||||
},
|
||||
applyYPercent: () => {},
|
||||
getCurrentYPercent: () => 10,
|
||||
persistSubtitlePositionPatch: () => {},
|
||||
getSubtitleHoverAutoPauseEnabled: () => false,
|
||||
getYomitanPopupAutoPauseEnabled: () => true,
|
||||
getPlaybackPaused: async () => false,
|
||||
sendMpvCommand: (command: (string | number)[]) => {
|
||||
mpvCommands.push(command);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
test('Hachidori press on subtitle text does not pause before a popup opens', async () => {
|
||||
await withHachidoriReader(async (reader) => {
|
||||
const mpvCommands: Array<(string | number)[]> = [];
|
||||
const handlers = createPopupAutoPauseHandlers(createMouseTestContext(), mpvCommands);
|
||||
handlers.setupYomitanObserver();
|
||||
|
||||
reader.setAttention(true);
|
||||
reader.emit(HACHIDORI_POPUP_SHOWN_EVENT);
|
||||
await waitForNextTick();
|
||||
reader.setAttention(false);
|
||||
reader.emit(HACHIDORI_POPUP_HIDDEN_EVENT);
|
||||
await waitForNextTick();
|
||||
|
||||
assert.deepEqual(mpvCommands, []);
|
||||
});
|
||||
});
|
||||
|
||||
test('Hachidori press before any lookup does not pause while its host is unattached', async () => {
|
||||
await withHachidoriReader(async (reader) => {
|
||||
const mpvCommands: Array<(string | number)[]> = [];
|
||||
const handlers = createPopupAutoPauseHandlers(createMouseTestContext(), mpvCommands);
|
||||
handlers.setupYomitanObserver();
|
||||
|
||||
// Hachidori attaches its host on the first lookup, so a press anywhere on
|
||||
// the overlay before that claims attention with nothing in the DOM.
|
||||
reader.setHostAttached(false);
|
||||
reader.emit(HACHIDORI_POPUP_SHOWN_EVENT);
|
||||
await waitForNextTick();
|
||||
reader.emit(HACHIDORI_POPUP_HIDDEN_EVENT);
|
||||
await waitForNextTick();
|
||||
|
||||
assert.deepEqual(mpvCommands, []);
|
||||
});
|
||||
});
|
||||
|
||||
test('Hachidori selection lookup pauses once its popup opens and resumes on close', async () => {
|
||||
await withHachidoriReader(async (reader) => {
|
||||
const mpvCommands: Array<(string | number)[]> = [];
|
||||
const handlers = createPopupAutoPauseHandlers(createMouseTestContext(), mpvCommands);
|
||||
handlers.setupYomitanObserver();
|
||||
|
||||
reader.setAttention(true);
|
||||
reader.emit(HACHIDORI_POPUP_SHOWN_EVENT);
|
||||
await waitForNextTick();
|
||||
assert.deepEqual(mpvCommands, []);
|
||||
|
||||
// The drag's attention carries over to its lookup, so no second shown event arrives.
|
||||
reader.setPopupOpen(true);
|
||||
reader.emit(YOMITAN_LOOKUP_EVENT);
|
||||
await waitForNextTick();
|
||||
assert.deepEqual(mpvCommands, [['set_property', 'pause', 'yes']]);
|
||||
|
||||
reader.setPopupOpen(false);
|
||||
reader.setAttention(false);
|
||||
reader.emit(HACHIDORI_POPUP_HIDDEN_EVENT);
|
||||
assert.deepEqual(mpvCommands, [
|
||||
['set_property', 'pause', 'yes'],
|
||||
['set_property', 'pause', 'no'],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
test('Hachidori hover popup pauses when shown', async () => {
|
||||
await withHachidoriReader(async (reader) => {
|
||||
const mpvCommands: Array<(string | number)[]> = [];
|
||||
const handlers = createPopupAutoPauseHandlers(createMouseTestContext(), mpvCommands);
|
||||
handlers.setupYomitanObserver();
|
||||
|
||||
reader.setPopupOpen(true);
|
||||
reader.setAttention(true);
|
||||
reader.emit(HACHIDORI_POPUP_SHOWN_EVENT);
|
||||
await waitForNextTick();
|
||||
|
||||
assert.deepEqual(mpvCommands, [['set_property', 'pause', 'yes']]);
|
||||
});
|
||||
});
|
||||
|
||||
test('nested popup close reasserts interactive state and focus when another popup remains visible on Windows', async () => {
|
||||
const ctx = createMouseTestContext();
|
||||
const previousWindow = (globalThis as { window?: unknown }).window;
|
||||
|
||||
@@ -4,7 +4,9 @@ import {
|
||||
YOMITAN_POPUP_MOUSE_ENTER_EVENT,
|
||||
YOMITAN_POPUP_MOUSE_LEAVE_EVENT,
|
||||
registerDictionaryPopupVisibilityListener,
|
||||
registerYomitanLookupListener,
|
||||
PRIMARY_SUB_VISIBLE_ON_YOMITAN_POPUP_CLASS,
|
||||
isHachidoriPopupOpen,
|
||||
isYomitanPopupVisible,
|
||||
isYomitanPopupIframe,
|
||||
} from '../yomitan-popup.js';
|
||||
@@ -34,6 +36,7 @@ export function createMouseHandlers(
|
||||
let yomitanPopupVisible = false;
|
||||
let hoverPauseRequestId = 0;
|
||||
let popupPauseRequestId = 0;
|
||||
let hachidoriReaderSeen = false;
|
||||
let pausedBySubtitleHover = false;
|
||||
let pausedByYomitanPopup = false;
|
||||
let lastPointerPosition: { clientX: number; clientY: number } | null = null;
|
||||
@@ -256,8 +259,19 @@ export function createMouseHandlers(
|
||||
options.sendMpvCommand(['set_property', 'pause', 'no']);
|
||||
}
|
||||
|
||||
// Hachidori also claims attention for a left press anywhere on the overlay
|
||||
// that may become a selection, so once its events identify the reader only
|
||||
// an open popup pane should pause playback.
|
||||
function canPauseForYomitanPopup(): boolean {
|
||||
return (
|
||||
yomitanPopupVisible &&
|
||||
options.getYomitanPopupAutoPauseEnabled() &&
|
||||
(!hachidoriReaderSeen || (typeof document !== 'undefined' && isHachidoriPopupOpen(document)))
|
||||
);
|
||||
}
|
||||
|
||||
async function maybePauseForYomitanPopup(): Promise<void> {
|
||||
if (!yomitanPopupVisible || !options.getYomitanPopupAutoPauseEnabled()) {
|
||||
if (!canPauseForYomitanPopup()) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -277,11 +291,7 @@ export function createMouseHandlers(
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
requestId !== popupPauseRequestId ||
|
||||
!yomitanPopupVisible ||
|
||||
!options.getYomitanPopupAutoPauseEnabled()
|
||||
) {
|
||||
if (requestId !== popupPauseRequestId || !canPauseForYomitanPopup()) {
|
||||
return;
|
||||
}
|
||||
if (paused !== false) return;
|
||||
@@ -469,7 +479,8 @@ export function createMouseHandlers(
|
||||
function setupYomitanObserver(): void {
|
||||
reconcilePopupInteraction({ allowPause: true });
|
||||
|
||||
registerDictionaryPopupVisibilityListener('shown', () => {
|
||||
registerDictionaryPopupVisibilityListener('shown', (reader) => {
|
||||
if (reader === 'hachidori') hachidoriReaderSeen = true;
|
||||
reconcilePopupInteraction({
|
||||
assumeVisible: true,
|
||||
allowPause: true,
|
||||
@@ -481,6 +492,12 @@ export function createMouseHandlers(
|
||||
disablePopupInteractionIfIdle();
|
||||
});
|
||||
|
||||
// A Hachidori selection opens its popup while the drag's attention claim is
|
||||
// still held, so no second shown event arrives; its lookup marks the open.
|
||||
registerYomitanLookupListener(window, () => {
|
||||
reconcilePopupInteraction({ allowPause: true });
|
||||
});
|
||||
|
||||
window.addEventListener(YOMITAN_POPUP_MOUSE_ENTER_EVENT, () => {
|
||||
ctx.state.isOverYomitanPopup = true;
|
||||
reconcilePopupInteraction({ assumeVisible: true, reclaimFocus: true });
|
||||
|
||||
@@ -10,17 +10,39 @@ export const YOMITAN_POPUP_MOUSE_LEAVE_EVENT = 'yomitan-popup-mouse-leave';
|
||||
export const YOMITAN_POPUP_COMMAND_EVENT = 'subminer-yomitan-popup-command';
|
||||
export const YOMITAN_LOOKUP_EVENT = 'subminer-yomitan-lookup';
|
||||
export const PRIMARY_SUB_VISIBLE_ON_YOMITAN_POPUP_CLASS = 'primary-sub-visible-on-yomitan-popup';
|
||||
// Hachidori's shown/hidden pair means "the reader needs mouse events", which
|
||||
// also covers a left press on subtitle text that may start a selection. Its
|
||||
// popup panes live in the host's open shadow root.
|
||||
export const HACHIDORI_POPUP_SHOWN_EVENT = 'hachidori-popup-shown';
|
||||
export const HACHIDORI_POPUP_HIDDEN_EVENT = 'hachidori-popup-hidden';
|
||||
export const HACHIDORI_HOST_SELECTOR = 'hachidori-host';
|
||||
export const HACHIDORI_POPUP_SELECTOR = '.gsm-hoshidicts-popup';
|
||||
|
||||
export type DictionaryReader = 'yomitan' | 'hachidori';
|
||||
|
||||
// Only the active backend injects a reader. Consume its native attention events.
|
||||
export function registerDictionaryPopupVisibilityListener(
|
||||
state: 'shown' | 'hidden',
|
||||
listener: () => void,
|
||||
listener: (reader: DictionaryReader) => void,
|
||||
target: EventTarget = window,
|
||||
): () => void {
|
||||
const events = [`yomitan-popup-${state}`, `hachidori-popup-${state}`];
|
||||
for (const event of events) target.addEventListener(event, listener);
|
||||
const events: Array<[string, DictionaryReader]> =
|
||||
state === 'shown'
|
||||
? [
|
||||
[YOMITAN_POPUP_SHOWN_EVENT, 'yomitan'],
|
||||
[HACHIDORI_POPUP_SHOWN_EVENT, 'hachidori'],
|
||||
]
|
||||
: [
|
||||
[YOMITAN_POPUP_HIDDEN_EVENT, 'yomitan'],
|
||||
[HACHIDORI_POPUP_HIDDEN_EVENT, 'hachidori'],
|
||||
];
|
||||
const wrapped = events.map(([event, reader]) => {
|
||||
const handler = (): void => listener(reader);
|
||||
target.addEventListener(event, handler);
|
||||
return [event, handler] as const;
|
||||
});
|
||||
return () => {
|
||||
for (const event of events) target.removeEventListener(event, listener);
|
||||
for (const [event, handler] of wrapped) target.removeEventListener(event, handler);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -86,6 +108,20 @@ function queryPopupElements<T extends Element>(
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a Hachidori popup pane is on screen. Auto-pause reads this because
|
||||
* the host's visible marker only tracks Hachidori's attention signal. Hachidori
|
||||
* attaches its host on the first lookup, so no host means no popup.
|
||||
*/
|
||||
export function isHachidoriPopupOpen(root: ParentNode | null | undefined = document): boolean {
|
||||
const hosts = queryPopupElements<HTMLElement>(root, HACHIDORI_HOST_SELECTOR);
|
||||
return hosts.some((host) =>
|
||||
queryPopupElements<HTMLElement>(host.shadowRoot, HACHIDORI_POPUP_SELECTOR).some(
|
||||
(pane) => !pane.hidden,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function isYomitanPopupVisible(root: ParentNode | null | undefined = document): boolean {
|
||||
const visiblePopupHosts = queryPopupElements<HTMLElement>(
|
||||
root,
|
||||
|
||||
Reference in New Issue
Block a user