feat: add app-owned YouTube subtitle flow with absPlayer-style parsing (#31)

* fix: harden preload argv parsing for popup windows

* fix: align youtube playback with shared overlay startup

* fix: unwrap mpv youtube streams for anki media mining

* docs: update docs for youtube subtitle and mining flow

* refactor: unify cli and runtime wiring for startup and youtube flow

* feat: update subtitle sidebar overlay behavior

* chore: add shared log-file source for diagnostics

* fix(ci): add changelog fragment for immersion changes

* fix: address CodeRabbit review feedback

* fix: persist canonical title from youtube metadata

* style: format stats library tab

* fix: address latest review feedback

* style: format stats library files

* test: stub launcher youtube deps in CI

* test: isolate launcher youtube flow deps

* test: stub launcher youtube deps in failing case

* test: force x11 backend in launcher ci harness

* test: address latest review feedback

* fix(launcher): preserve user YouTube ytdl raw options

* docs(backlog): update task tracking notes

* fix(immersion): special-case youtube media paths in runtime and tracking

* feat(stats): improve YouTube media metadata and picker key handling

* fix(ci): format stats media library hook

* fix: address latest CodeRabbit review items

* docs: update youtube release notes and docs

* feat: auto-load youtube subtitles before manual picker

* fix: restore app-owned youtube subtitle flow

* docs: update youtube playback docs and config copy

* refactor: remove legacy youtube launcher mode plumbing

* fix: refine youtube subtitle startup binding

* docs: clarify youtube subtitle startup behavior

* fix: address PR #31 latest review follow-ups

* fix: address PR #31 follow-up review comments

* test: harden youtube picker test harness

* udpate backlog

* fix: add timeout to youtube metadata probe

* docs: refresh youtube and stats docs

* update backlog

* update backlog

* chore: release v0.9.0
This commit is contained in:
2026-03-24 00:01:24 -07:00
committed by GitHub
parent c17f0a4080
commit 5feed360ca
219 changed files with 12778 additions and 1052 deletions

View File

@@ -332,6 +332,7 @@ function createKeyboardHandlerHarness() {
return true;
},
handleControllerDebugKeydown: () => false,
handleYoutubePickerKeydown: () => false,
handleSessionHelpKeydown: () => false,
openSessionHelpModal: () => {},
appendClipboardVideoToQueue: () => {},
@@ -489,6 +490,34 @@ test('keyboard mode: repeated popup navigation keys are forwarded while popup is
}
});
test('popup-visible mpv keybindings still fire for bound keys', async () => {
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
try {
await handlers.setupMpvInputForwarding();
handlers.updateKeybindings([
{
key: 'Space',
command: ['cycle', 'pause'],
},
{
key: 'KeyQ',
command: ['quit'],
},
] as never);
ctx.state.yomitanPopupVisible = true;
testGlobals.setPopupVisible(true);
testGlobals.dispatchKeydown({ key: ' ', code: 'Space' });
testGlobals.dispatchKeydown({ key: 'q', code: 'KeyQ' });
assert.deepEqual(testGlobals.mpvCommands.slice(-2), [['cycle', 'pause'], ['quit']]);
} finally {
testGlobals.restore();
}
});
test('keyboard mode: controller helpers dispatch popup audio play/cycle and scroll bridge commands', async () => {
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
@@ -590,6 +619,33 @@ test('keyboard mode: configured stats toggle works even while popup is open', as
}
});
test('youtube picker: unhandled keys still dispatch mpv keybindings', async () => {
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
try {
await handlers.setupMpvInputForwarding();
handlers.updateKeybindings([
{
key: 'Space',
command: ['cycle', 'pause'],
},
{
key: 'KeyQ',
command: ['quit'],
},
] as never);
ctx.state.youtubePickerModalOpen = true;
testGlobals.dispatchKeydown({ key: ' ', code: 'Space' });
testGlobals.dispatchKeydown({ key: 'q', code: 'KeyQ' });
assert.deepEqual(testGlobals.mpvCommands.slice(-2), [['cycle', 'pause'], ['quit']]);
} finally {
testGlobals.restore();
}
});
test('keyboard mode: h moves left when popup is closed', async () => {
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
@@ -817,6 +873,22 @@ test('keyboard mode: closing lookup clears yomitan active text source so same to
}
});
test('subtitle refresh outside keyboard mode clears yomitan active text source', async () => {
const { handlers, testGlobals } = createKeyboardHandlerHarness();
try {
handlers.handleSubtitleContentUpdated();
await wait(0);
const clearCommands = testGlobals.commandEvents.filter(
(event) => event.type === 'clearActiveTextSource',
);
assert.deepEqual(clearCommands, [{ type: 'clearActiveTextSource' }]);
} finally {
testGlobals.restore();
}
});
test('keyboard mode: lookup toggle closes popup when DOM visibility is the source of truth', async () => {
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();

View File

@@ -15,6 +15,7 @@ export function createKeyboardHandlers(
handleSubsyncKeydown: (e: KeyboardEvent) => boolean;
handleKikuKeydown: (e: KeyboardEvent) => boolean;
handleJimakuKeydown: (e: KeyboardEvent) => boolean;
handleYoutubePickerKeydown: (e: KeyboardEvent) => boolean;
handleControllerSelectKeydown: (e: KeyboardEvent) => boolean;
handleControllerDebugKeydown: (e: KeyboardEvent) => boolean;
handleSessionHelpKeydown: (e: KeyboardEvent) => boolean;
@@ -479,6 +480,8 @@ export function createKeyboardHandlers(
function handleSubtitleContentUpdated(): void {
if (!ctx.state.keyboardDrivenModeEnabled) {
dispatchYomitanFrontendClearActiveTextSource();
clearNativeSubtitleSelection();
return;
}
if (pendingSelectionAnchorAfterSubtitleSeek) {
@@ -678,6 +681,11 @@ export function createKeyboardHandlers(
]);
if (modifierOnlyCodes.has(e.code)) return false;
const keyString = keyEventToString(e);
if (ctx.state.keybindingsMap.has(keyString)) {
return false;
}
if (!e.ctrlKey && !e.metaKey && !e.altKey && e.code === 'KeyM') {
if (e.repeat) return false;
dispatchYomitanPopupMineSelected();
@@ -834,6 +842,11 @@ export function createKeyboardHandlers(
options.handleJimakuKeydown(e);
return;
}
if (ctx.state.youtubePickerModalOpen) {
if (options.handleYoutubePickerKeydown(e)) {
return;
}
}
if (ctx.state.controllerSelectModalOpen) {
options.handleControllerSelectKeydown(e);
return;
@@ -871,8 +884,8 @@ export function createKeyboardHandlers(
) {
if (handleYomitanPopupKeybind(e)) {
e.preventDefault();
return;
}
return;
}
if (ctx.state.keyboardDrivenModeEnabled && handleKeyboardDrivenModeNavigation(e)) {

View File

@@ -3,7 +3,10 @@ import test from 'node:test';
import type { SubtitleSidebarConfig } from '../../types';
import { createMouseHandlers } from './mouse.js';
import { YOMITAN_POPUP_HIDDEN_EVENT, YOMITAN_POPUP_SHOWN_EVENT } from '../yomitan-popup.js';
import {
YOMITAN_POPUP_HIDDEN_EVENT,
YOMITAN_POPUP_SHOWN_EVENT,
} from '../yomitan-popup.js';
function createClassList() {
const classes = new Set<string>();
@@ -18,6 +21,22 @@ function createClassList() {
classes.delete(token);
}
},
toggle: (token: string, force?: boolean) => {
if (force === undefined) {
if (classes.has(token)) {
classes.delete(token);
return false;
}
classes.add(token);
return true;
}
if (force) {
classes.add(token);
return true;
}
classes.delete(token);
return false;
},
contains: (token: string) => classes.has(token),
};
}
@@ -314,6 +333,74 @@ test('subtitle leave restores passthrough while embedded sidebar is open but not
}
});
test('restorePointerInteractionState reapplies the secondary hover class from pointer location', async () => {
const ctx = createMouseTestContext();
ctx.platform.shouldToggleMouseIgnore = true;
const documentListeners = new Map<string, Array<(event: MouseEvent | PointerEvent) => void>>();
const originalDocument = (globalThis as { document?: unknown }).document;
const originalWindow = (globalThis as { window?: unknown }).window;
const secondarySubContainer = ctx.dom.secondarySubContainer as unknown as object;
const overlay = ctx.dom.overlay as unknown as { classList: ReturnType<typeof createClassList> };
Object.defineProperty(globalThis, 'document', {
configurable: true,
value: {
addEventListener: (type: string, listener: (event: MouseEvent | PointerEvent) => void) => {
const listeners = documentListeners.get(type) ?? [];
listeners.push(listener);
documentListeners.set(type, listeners);
},
elementFromPoint: () => secondarySubContainer,
},
});
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
electronAPI: {
setIgnoreMouseEvents: () => {},
},
innerHeight: 1000,
getSelection: () => ({ rangeCount: 0, isCollapsed: true }),
},
});
try {
const handlers = createMouseHandlers(ctx as never, {
modalStateReader: {
isAnySettingsModalOpen: () => false,
isAnyModalOpen: () => false,
},
applyYPercent: () => {},
getCurrentYPercent: () => 10,
persistSubtitlePositionPatch: () => {},
getSubtitleHoverAutoPauseEnabled: () => false,
getYomitanPopupAutoPauseEnabled: () => false,
getPlaybackPaused: async () => false,
sendMpvCommand: () => {},
});
handlers.setupPointerTracking();
await handlers.handleSecondaryMouseEnter({
clientX: 10,
clientY: 20,
} as unknown as MouseEvent);
handlers.restorePointerInteractionState();
overlay.classList.add('interactive');
const mousemove = documentListeners.get('mousemove')?.[0];
assert.ok(mousemove);
mousemove?.({ clientX: 10, clientY: 20 } as MouseEvent);
assert.equal(ctx.state.isOverSubtitle, true);
assert.equal(ctx.dom.secondarySubContainer.classList.contains('secondary-sub-hover-active'), true);
} finally {
Object.defineProperty(globalThis, 'document', { configurable: true, value: originalDocument });
Object.defineProperty(globalThis, 'window', { configurable: true, value: originalWindow });
}
});
test('pending hover pause check is ignored when mouse leaves before pause state resolves', async () => {
const ctx = createMouseTestContext();
const mpvCommands: Array<(string | number)[]> = [];
@@ -612,3 +699,153 @@ test('popup open pauses and popup close resumes when yomitan popup auto-pause is
Object.defineProperty(globalThis, 'Node', { configurable: true, value: previousNode });
}
});
test('restorePointerInteractionState re-enables subtitle hover when pointer is already over subtitles', () => {
const ctx = createMouseTestContext();
const originalWindow = globalThis.window;
const originalDocument = globalThis.document;
const ignoreCalls: Array<{ ignore: boolean; forward?: boolean }> = [];
const documentListeners = new Map<string, Array<(event: unknown) => void>>();
ctx.platform.shouldToggleMouseIgnore = true;
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
electronAPI: {
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => {
ignoreCalls.push({ ignore, forward: options?.forward });
},
},
getComputedStyle: () => ({
visibility: 'hidden',
display: 'none',
opacity: '0',
}),
focus: () => {},
},
});
Object.defineProperty(globalThis, 'document', {
configurable: true,
value: {
addEventListener: (type: string, listener: (event: unknown) => void) => {
const bucket = documentListeners.get(type) ?? [];
bucket.push(listener);
documentListeners.set(type, bucket);
},
elementFromPoint: () => ctx.dom.subtitleContainer,
querySelectorAll: () => [],
body: {},
},
});
try {
const handlers = createMouseHandlers(ctx as never, {
modalStateReader: {
isAnySettingsModalOpen: () => false,
isAnyModalOpen: () => false,
},
applyYPercent: () => {},
getCurrentYPercent: () => 10,
persistSubtitlePositionPatch: () => {},
getSubtitleHoverAutoPauseEnabled: () => false,
getYomitanPopupAutoPauseEnabled: () => false,
getPlaybackPaused: async () => false,
sendMpvCommand: () => {},
});
handlers.setupPointerTracking();
for (const listener of documentListeners.get('mousemove') ?? []) {
listener({ clientX: 120, clientY: 240 });
}
handlers.restorePointerInteractionState();
assert.equal(ctx.state.isOverSubtitle, true);
assert.equal(ctx.dom.overlay.classList.contains('interactive'), true);
assert.deepEqual(ignoreCalls, [{ ignore: false, forward: undefined }]);
} finally {
Object.defineProperty(globalThis, 'window', { configurable: true, value: originalWindow });
Object.defineProperty(globalThis, 'document', { configurable: true, value: originalDocument });
}
});
test('restorePointerInteractionState keeps overlay interactive until first real pointer move can resync hover', () => {
const ctx = createMouseTestContext();
const originalWindow = globalThis.window;
const originalDocument = globalThis.document;
const ignoreCalls: Array<{ ignore: boolean; forward?: boolean }> = [];
const documentListeners = new Map<string, Array<(event: unknown) => void>>();
let hoveredElement: unknown = null;
ctx.platform.shouldToggleMouseIgnore = true;
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
electronAPI: {
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => {
ignoreCalls.push({ ignore, forward: options?.forward });
},
},
getComputedStyle: () => ({
visibility: 'hidden',
display: 'none',
opacity: '0',
}),
focus: () => {},
},
});
Object.defineProperty(globalThis, 'document', {
configurable: true,
value: {
addEventListener: (type: string, listener: (event: unknown) => void) => {
const bucket = documentListeners.get(type) ?? [];
bucket.push(listener);
documentListeners.set(type, bucket);
},
elementFromPoint: () => hoveredElement,
querySelectorAll: () => [],
body: {},
},
});
try {
const handlers = createMouseHandlers(ctx as never, {
modalStateReader: {
isAnySettingsModalOpen: () => false,
isAnyModalOpen: () => false,
},
applyYPercent: () => {},
getCurrentYPercent: () => 10,
persistSubtitlePositionPatch: () => {},
getSubtitleHoverAutoPauseEnabled: () => false,
getYomitanPopupAutoPauseEnabled: () => false,
getPlaybackPaused: async () => false,
sendMpvCommand: () => {},
});
handlers.setupPointerTracking();
handlers.restorePointerInteractionState();
assert.equal(ctx.state.isOverSubtitle, false);
assert.equal(ctx.dom.overlay.classList.contains('interactive'), true);
assert.deepEqual(ignoreCalls, [
{ ignore: true, forward: true },
{ ignore: false, forward: undefined },
]);
hoveredElement = null;
for (const listener of documentListeners.get('mousemove') ?? []) {
listener({ clientX: 24, clientY: 48 });
}
assert.equal(ctx.state.isOverSubtitle, false);
assert.equal(ctx.dom.overlay.classList.contains('interactive'), false);
assert.deepEqual(ignoreCalls, [
{ ignore: true, forward: true },
{ ignore: false, forward: undefined },
{ ignore: true, forward: true },
]);
} finally {
Object.defineProperty(globalThis, 'window', { configurable: true, value: originalWindow });
Object.defineProperty(globalThis, 'document', { configurable: true, value: originalDocument });
}
});

View File

@@ -25,6 +25,74 @@ export function createMouseHandlers(
let popupPauseRequestId = 0;
let pausedBySubtitleHover = false;
let pausedByYomitanPopup = false;
let lastPointerPosition: { clientX: number; clientY: number } | null = null;
let pendingPointerResync = false;
function isElementWithinContainer(element: Element | null, container: HTMLElement): boolean {
if (!element) {
return false;
}
if (element === container) {
return true;
}
return typeof container.contains === 'function' ? container.contains(element) : false;
}
function updatePointerPosition(event: MouseEvent | PointerEvent): void {
lastPointerPosition = {
clientX: event.clientX,
clientY: event.clientY,
};
}
function syncHoverStateFromPoint(clientX: number, clientY: number): boolean {
const hoveredElement =
typeof document.elementFromPoint === 'function'
? document.elementFromPoint(clientX, clientY)
: null;
const overPrimarySubtitle = isElementWithinContainer(hoveredElement, ctx.dom.subtitleContainer);
const overSecondarySubtitle = isElementWithinContainer(
hoveredElement,
ctx.dom.secondarySubContainer,
);
ctx.state.isOverSubtitle = overPrimarySubtitle || overSecondarySubtitle;
ctx.dom.secondarySubContainer.classList.toggle(
'secondary-sub-hover-active',
overSecondarySubtitle,
);
return ctx.state.isOverSubtitle;
}
function restorePointerInteractionState(): void {
const pointerPosition = lastPointerPosition;
pendingPointerResync = false;
if (pointerPosition) {
syncHoverStateFromPoint(pointerPosition.clientX, pointerPosition.clientY);
} else {
ctx.state.isOverSubtitle = false;
ctx.dom.secondarySubContainer.classList.remove('secondary-sub-hover-active');
}
syncOverlayMouseIgnoreState(ctx);
if (!ctx.platform.shouldToggleMouseIgnore || ctx.state.isOverSubtitle) {
return;
}
pendingPointerResync = true;
ctx.dom.overlay.classList.add('interactive');
window.electronAPI.setIgnoreMouseEvents(false);
}
function maybeResyncPointerHoverState(event: MouseEvent | PointerEvent): void {
if (!pendingPointerResync) {
return;
}
pendingPointerResync = false;
syncHoverStateFromPoint(event.clientX, event.clientY);
syncOverlayMouseIgnoreState(ctx);
}
function isWithinOtherSubtitleContainer(
relatedTarget: EventTarget | null,
@@ -222,6 +290,17 @@ export function createMouseHandlers(
});
}
function setupPointerTracking(): void {
document.addEventListener('mousemove', (event: MouseEvent) => {
updatePointerPosition(event);
maybeResyncPointerHoverState(event);
});
document.addEventListener('pointermove', (event: PointerEvent) => {
updatePointerPosition(event);
maybeResyncPointerHoverState(event);
});
}
function setupSelectionObserver(): void {
document.addEventListener('selectionchange', () => {
const selection = window.getSelection();
@@ -283,7 +362,9 @@ export function createMouseHandlers(
handleSecondaryMouseLeave: (event?: MouseEvent) => handleMouseLeave(event, true),
handleMouseEnter,
handleMouseLeave,
restorePointerInteractionState,
setupDragging,
setupPointerTracking,
setupResizeHandler,
setupSelectionObserver,
setupYomitanObserver,

View File

@@ -85,6 +85,36 @@
</div>
</div>
</div>
<div id="youtubePickerModal" class="modal hidden" aria-hidden="true">
<div class="modal-content youtube-picker-content">
<div class="modal-header">
<div class="modal-title">YouTube Subtitle Tracks</div>
<button id="youtubePickerCloseButton" class="modal-close" type="button">
Continue without subtitles
</button>
</div>
<div class="modal-body youtube-picker-body">
<div id="youtubePickerTitle" class="youtube-picker-title"></div>
<div class="youtube-picker-grid">
<label class="youtube-picker-field">
<span>Primary subtitle</span>
<select id="youtubePickerPrimarySelect"></select>
</label>
<label class="youtube-picker-field">
<span>Secondary subtitle</span>
<select id="youtubePickerSecondarySelect"></select>
</label>
</div>
<div id="youtubePickerStatus" class="youtube-picker-status"></div>
<ul id="youtubePickerTracks" class="youtube-picker-tracks"></ul>
<div class="youtube-picker-footer">
<button id="youtubePickerContinueButton" class="kiku-confirm-button" type="button">
Use selected subtitles
</button>
</div>
</div>
</div>
</div>
<div id="kikuFieldGroupingModal" class="modal hidden" aria-hidden="true">
<div class="modal-content">
<div class="modal-header">

View File

@@ -84,6 +84,18 @@ test('findActiveSubtitleCueIndex prefers timing match before text fallback', ()
assert.equal(findActiveSubtitleCueIndex(cues, { text: 'same', startTime: null }), 0);
});
test('findActiveSubtitleCueIndex prefers current subtitle timing over near-future clock lookahead', () => {
const cues = [
{ startTime: 231, endTime: 233.2, text: 'previous' },
{ startTime: 233.05, endTime: 236, text: 'next' },
];
assert.equal(
findActiveSubtitleCueIndex(cues, { text: 'previous', startTime: 231 }, 233, 0),
0,
);
});
test('subtitle sidebar modal opens from snapshot and clicking cue seeks playback', async () => {
const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown };
const previousWindow = globals.window;

View File

@@ -61,6 +61,18 @@ export function findActiveSubtitleCueIndex(
return -1;
}
const hasCurrentTiming =
typeof current?.startTime === 'number' && Number.isFinite(current.startTime);
if (hasCurrentTiming) {
const timingMatch = cues.findIndex(
(cue) => current.startTime! >= cue.startTime && current.startTime! < cue.endTime,
);
if (timingMatch >= 0) {
return timingMatch;
}
}
if (typeof currentTimeSec === 'number' && Number.isFinite(currentTimeSec)) {
const activeOrUpcomingCue = cues.findIndex(
(cue) =>
@@ -81,15 +93,6 @@ export function findActiveSubtitleCueIndex(
return -1;
}
if (typeof current.startTime === 'number' && Number.isFinite(current.startTime)) {
const timingMatch = cues.findIndex(
(cue) => current.startTime! >= cue.startTime && current.startTime! < cue.endTime,
);
if (timingMatch >= 0) {
return timingMatch;
}
}
const normalizedText = normalizeCueText(current.text);
if (!normalizedText) {
return -1;

View File

@@ -0,0 +1,769 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createRendererState } from '../state.js';
import { createYoutubeTrackPickerModal } from './youtube-track-picker.js';
function createClassList(initialTokens: string[] = []) {
const tokens = new Set(initialTokens);
return {
add: (...entries: string[]) => {
for (const entry of entries) tokens.add(entry);
},
remove: (...entries: string[]) => {
for (const entry of entries) tokens.delete(entry);
},
contains: (entry: string) => tokens.has(entry),
};
}
function createFakeElement() {
const attributes = new Map<string, string>();
return {
textContent: '',
innerHTML: '',
value: '',
disabled: false,
children: [] as any[],
style: {} as Record<string, string>,
classList: createClassList(['hidden']),
listeners: new Map<string, Array<(event?: any) => void>>(),
appendChild(child: any) {
this.children.push(child);
return child;
},
append(...children: any[]) {
this.children.push(...children);
},
replaceChildren(...children: any[]) {
this.children = [...children];
},
addEventListener(type: string, listener: (event?: any) => void) {
const existing = this.listeners.get(type) ?? [];
existing.push(listener);
this.listeners.set(type, existing);
},
setAttribute(name: string, value: string) {
attributes.set(name, value);
},
getAttribute(name: string) {
return attributes.get(name) ?? null;
},
focus: () => {},
};
}
function createYoutubePickerDomFixture() {
return {
overlay: {
classList: createClassList(),
focus: () => {},
},
youtubePickerModal: createFakeElement(),
youtubePickerTitle: createFakeElement(),
youtubePickerPrimarySelect: createFakeElement(),
youtubePickerSecondarySelect: createFakeElement(),
youtubePickerTracks: createFakeElement(),
youtubePickerStatus: createFakeElement(),
youtubePickerContinueButton: createFakeElement(),
youtubePickerCloseButton: createFakeElement(),
};
}
type YoutubePickerTestWindow = {
dispatchEvent: (event: Event & { detail?: unknown }) => boolean;
focus: () => void;
electronAPI: Record<string, unknown>;
};
function restoreGlobalProp<K extends keyof typeof globalThis>(
key: K,
originalValue: (typeof globalThis)[K],
hadOwnProperty: boolean,
) {
if (hadOwnProperty) {
Object.defineProperty(globalThis, key, {
configurable: true,
value: originalValue,
writable: true,
});
return;
}
Reflect.deleteProperty(globalThis, key);
}
function setupYoutubePickerTestEnv(options?: {
windowValue?: YoutubePickerTestWindow;
customEventValue?: unknown;
now?: () => number;
}) {
const hadWindow = Object.prototype.hasOwnProperty.call(globalThis, 'window');
const hadDocument = Object.prototype.hasOwnProperty.call(globalThis, 'document');
const hadCustomEvent = Object.prototype.hasOwnProperty.call(globalThis, 'CustomEvent');
const originalWindow = globalThis.window;
const originalDocument = globalThis.document;
const originalCustomEvent = globalThis.CustomEvent;
const originalDateNow = Date.now;
Object.defineProperty(globalThis, 'document', {
configurable: true,
value: {
createElement: () => createFakeElement(),
},
writable: true,
});
Object.defineProperty(globalThis, 'window', {
configurable: true,
value:
options?.windowValue ??
({
dispatchEvent: (_event) => true,
focus: () => {},
electronAPI: {
notifyOverlayModalOpened: () => {},
notifyOverlayModalClosed: () => {},
youtubePickerResolve: async () => ({ ok: true, message: '' }),
setIgnoreMouseEvents: () => {},
},
} satisfies YoutubePickerTestWindow),
writable: true,
});
if (options?.customEventValue) {
Object.defineProperty(globalThis, 'CustomEvent', {
configurable: true,
value: options.customEventValue,
writable: true,
});
}
if (options?.now) {
Date.now = options.now;
}
return {
restore() {
Date.now = originalDateNow;
restoreGlobalProp('window', originalWindow, hadWindow);
restoreGlobalProp('document', originalDocument, hadDocument);
restoreGlobalProp('CustomEvent', originalCustomEvent, hadCustomEvent);
},
};
}
test('youtube picker test env restore deletes injected globals that were originally absent', () => {
assert.equal(Object.prototype.hasOwnProperty.call(globalThis, 'window'), false);
assert.equal(Object.prototype.hasOwnProperty.call(globalThis, 'document'), false);
const env = setupYoutubePickerTestEnv();
assert.equal(Object.prototype.hasOwnProperty.call(globalThis, 'window'), true);
assert.equal(Object.prototype.hasOwnProperty.call(globalThis, 'document'), true);
env.restore();
assert.equal(Object.prototype.hasOwnProperty.call(globalThis, 'window'), false);
assert.equal(Object.prototype.hasOwnProperty.call(globalThis, 'document'), false);
assert.equal(typeof globalThis.window, 'undefined');
assert.equal(typeof globalThis.document, 'undefined');
});
test('youtube track picker close restores focus and mouse-ignore state', () => {
const overlayFocusCalls: number[] = [];
const windowFocusCalls: number[] = [];
const focusMainWindowCalls: number[] = [];
const ignoreCalls: Array<{ ignore: boolean; forward?: boolean }> = [];
const notifications: string[] = [];
const frontendCommands: unknown[] = [];
const syncCalls: string[] = [];
class TestCustomEvent extends Event {
detail: unknown;
constructor(type: string, init?: { detail?: unknown }) {
super(type);
this.detail = init?.detail;
}
}
const env = setupYoutubePickerTestEnv({
windowValue: {
dispatchEvent: (event: Event & { detail?: unknown }) => {
frontendCommands.push(event.detail ?? null);
return true;
},
focus: () => {
windowFocusCalls.push(1);
},
electronAPI: {
notifyOverlayModalOpened: () => {},
notifyOverlayModalClosed: (modal: string) => {
notifications.push(modal);
},
focusMainWindow: async () => {
focusMainWindowCalls.push(1);
},
youtubePickerResolve: async () => ({ ok: true, message: '' }),
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => {
ignoreCalls.push({ ignore, forward: options?.forward });
},
},
} satisfies YoutubePickerTestWindow,
customEventValue: TestCustomEvent,
});
try {
const state = createRendererState();
const dom = createYoutubePickerDomFixture();
dom.overlay.focus = () => {
overlayFocusCalls.push(1);
};
const { overlay } = dom;
const modal = createYoutubeTrackPickerModal(
{
state,
dom,
platform: {
shouldToggleMouseIgnore: true,
},
} as never,
{
modalStateReader: { isAnyModalOpen: () => false },
restorePointerInteractionState: () => {
syncCalls.push('restore-pointer');
},
syncSettingsModalSubtitleSuppression: () => {
syncCalls.push('sync');
},
},
);
modal.openYoutubePickerModal({
sessionId: 'yt-1',
url: 'https://example.com',
tracks: [],
defaultPrimaryTrackId: null,
defaultSecondaryTrackId: null,
hasTracks: false,
});
modal.closeYoutubePickerModal();
assert.equal(state.youtubePickerModalOpen, false);
assert.deepEqual(syncCalls, ['sync', 'sync', 'restore-pointer']);
assert.deepEqual(notifications, ['youtube-track-picker']);
assert.deepEqual(frontendCommands, [{ type: 'refreshOptions' }]);
assert.equal(overlay.classList.contains('interactive'), false);
assert.equal(focusMainWindowCalls.length > 0, true);
assert.equal(overlayFocusCalls.length > 0, true);
assert.equal(windowFocusCalls.length > 0, true);
assert.deepEqual(ignoreCalls, [{ ignore: true, forward: true }]);
} finally {
env.restore();
}
});
test('youtube track picker re-acknowledges repeated open requests', () => {
const openedNotifications: string[] = [];
const env = setupYoutubePickerTestEnv({
windowValue: {
dispatchEvent: (_event) => true,
focus: () => {},
electronAPI: {
notifyOverlayModalOpened: (modal: string) => {
openedNotifications.push(modal);
},
notifyOverlayModalClosed: () => {},
youtubePickerResolve: async () => ({ ok: true, message: '' }),
setIgnoreMouseEvents: () => {},
},
} satisfies YoutubePickerTestWindow,
});
try {
const state = createRendererState();
const dom = createYoutubePickerDomFixture();
const modal = createYoutubeTrackPickerModal(
{
state,
dom,
platform: {
shouldToggleMouseIgnore: false,
},
} as never,
{
modalStateReader: { isAnyModalOpen: () => true },
restorePointerInteractionState: () => {},
syncSettingsModalSubtitleSuppression: () => {},
},
);
modal.openYoutubePickerModal({
sessionId: 'yt-1',
url: 'https://example.com/one',
tracks: [],
defaultPrimaryTrackId: null,
defaultSecondaryTrackId: null,
hasTracks: false,
});
modal.openYoutubePickerModal({
sessionId: 'yt-2',
url: 'https://example.com/two',
tracks: [],
defaultPrimaryTrackId: null,
defaultSecondaryTrackId: null,
hasTracks: false,
});
assert.deepEqual(openedNotifications, ['youtube-track-picker', 'youtube-track-picker']);
assert.equal(state.youtubePickerPayload?.sessionId, 'yt-2');
} finally {
env.restore();
}
});
test('youtube track picker surfaces rejected resolve calls as modal status', async () => {
const env = setupYoutubePickerTestEnv({
windowValue: {
dispatchEvent: (_event) => true,
focus: () => {},
electronAPI: {
notifyOverlayModalOpened: () => {},
notifyOverlayModalClosed: () => {},
youtubePickerResolve: async () => {
throw new Error('resolve failed');
},
setIgnoreMouseEvents: () => {},
},
} satisfies YoutubePickerTestWindow,
});
try {
const state = createRendererState();
const dom = createYoutubePickerDomFixture();
const modal = createYoutubeTrackPickerModal(
{
state,
dom,
platform: {
shouldToggleMouseIgnore: false,
},
} as never,
{
modalStateReader: { isAnyModalOpen: () => true },
restorePointerInteractionState: () => {},
syncSettingsModalSubtitleSuppression: () => {},
},
);
modal.openYoutubePickerModal({
sessionId: 'yt-1',
url: 'https://example.com',
tracks: [
{
id: 'auto:ja-orig',
language: 'ja',
sourceLanguage: 'ja-orig',
kind: 'auto',
label: 'Japanese (auto)',
},
],
defaultPrimaryTrackId: 'auto:ja-orig',
defaultSecondaryTrackId: null,
hasTracks: true,
});
modal.wireDomEvents();
const listeners = dom.youtubePickerContinueButton.listeners.get('click') ?? [];
await Promise.all(listeners.map((listener) => listener()));
assert.equal(state.youtubePickerModalOpen, true);
assert.equal(dom.youtubePickerStatus.textContent, 'resolve failed');
} finally {
env.restore();
}
});
test('youtube track picker ignores duplicate resolve submissions while request is in flight', async () => {
const resolveCalls: Array<{
sessionId: string;
action: string;
primaryTrackId: string | null;
secondaryTrackId: string | null;
}> = [];
let releaseResolve: (() => void) | null = null;
const env = setupYoutubePickerTestEnv({
windowValue: {
dispatchEvent: (_event) => true,
focus: () => {},
electronAPI: {
notifyOverlayModalOpened: () => {},
notifyOverlayModalClosed: () => {},
youtubePickerResolve: async (payload: {
sessionId: string;
action: string;
primaryTrackId: string | null;
secondaryTrackId: string | null;
}) => {
resolveCalls.push(payload);
await new Promise<void>((resolve) => {
releaseResolve = resolve;
});
return { ok: true, message: '' };
},
setIgnoreMouseEvents: () => {},
},
} satisfies YoutubePickerTestWindow,
});
try {
const state = createRendererState();
const dom = createYoutubePickerDomFixture();
const modal = createYoutubeTrackPickerModal(
{
state,
dom,
platform: {
shouldToggleMouseIgnore: false,
},
} as never,
{
modalStateReader: { isAnyModalOpen: () => true },
restorePointerInteractionState: () => {},
syncSettingsModalSubtitleSuppression: () => {},
},
);
modal.openYoutubePickerModal({
sessionId: 'yt-1',
url: 'https://example.com',
tracks: [
{
id: 'auto:ja-orig',
language: 'ja',
sourceLanguage: 'ja-orig',
kind: 'auto',
label: 'Japanese (auto)',
},
],
defaultPrimaryTrackId: 'auto:ja-orig',
defaultSecondaryTrackId: null,
hasTracks: true,
});
modal.wireDomEvents();
const listeners = dom.youtubePickerContinueButton.listeners.get('click') ?? [];
const first = listeners[0]?.();
const second = listeners[0]?.();
await Promise.resolve();
assert.equal(resolveCalls.length, 1);
assert.equal(dom.youtubePickerPrimarySelect.disabled, true);
assert.equal(dom.youtubePickerSecondarySelect.disabled, true);
assert.equal(dom.youtubePickerContinueButton.disabled, true);
assert.equal(dom.youtubePickerCloseButton.disabled, true);
assert.ok(releaseResolve);
const release = releaseResolve as () => void;
release();
await Promise.all([first, second]);
assert.equal(dom.youtubePickerPrimarySelect.disabled, false);
assert.equal(dom.youtubePickerSecondarySelect.disabled, false);
assert.equal(dom.youtubePickerContinueButton.disabled, false);
assert.equal(dom.youtubePickerCloseButton.disabled, false);
} finally {
env.restore();
}
});
test('youtube track picker keeps no-track controls disabled after a rejected continue request', async () => {
const env = setupYoutubePickerTestEnv({
windowValue: {
dispatchEvent: (_event) => true,
focus: () => {},
electronAPI: {
notifyOverlayModalOpened: () => {},
notifyOverlayModalClosed: () => {},
youtubePickerResolve: async () => ({ ok: false, message: 'still no tracks' }),
setIgnoreMouseEvents: () => {},
},
} satisfies YoutubePickerTestWindow,
});
try {
const state = createRendererState();
const dom = createYoutubePickerDomFixture();
const modal = createYoutubeTrackPickerModal(
{
state,
dom,
platform: {
shouldToggleMouseIgnore: false,
},
} as never,
{
modalStateReader: { isAnyModalOpen: () => true },
restorePointerInteractionState: () => {},
syncSettingsModalSubtitleSuppression: () => {},
},
);
modal.openYoutubePickerModal({
sessionId: 'yt-1',
url: 'https://example.com',
tracks: [],
defaultPrimaryTrackId: null,
defaultSecondaryTrackId: null,
hasTracks: false,
});
modal.wireDomEvents();
const listeners = dom.youtubePickerContinueButton.listeners.get('click') ?? [];
await Promise.all(listeners.map((listener) => listener()));
assert.equal(dom.youtubePickerPrimarySelect.disabled, true);
assert.equal(dom.youtubePickerSecondarySelect.disabled, true);
assert.equal(dom.youtubePickerContinueButton.disabled, true);
assert.equal(dom.youtubePickerCloseButton.disabled, true);
assert.equal(dom.youtubePickerStatus.textContent, 'still no tracks');
} finally {
env.restore();
}
});
test('youtube track picker only consumes handled keys', async () => {
const env = setupYoutubePickerTestEnv();
try {
const state = createRendererState();
const dom = createYoutubePickerDomFixture();
const modal = createYoutubeTrackPickerModal(
{
state,
dom,
platform: {
shouldToggleMouseIgnore: false,
},
} as never,
{
modalStateReader: { isAnyModalOpen: () => true },
restorePointerInteractionState: () => {},
syncSettingsModalSubtitleSuppression: () => {},
},
);
modal.openYoutubePickerModal({
sessionId: 'yt-1',
url: 'https://example.com',
tracks: [],
defaultPrimaryTrackId: null,
defaultSecondaryTrackId: null,
hasTracks: false,
});
assert.equal(
modal.handleYoutubePickerKeydown({
key: ' ',
preventDefault: () => {},
} as KeyboardEvent),
false,
);
assert.equal(
modal.handleYoutubePickerKeydown({
key: 'Escape',
preventDefault: () => {},
} as KeyboardEvent),
true,
);
await Promise.resolve();
} finally {
env.restore();
}
});
test('youtube track picker ignores immediate Enter after open before allowing keyboard submit', async () => {
const resolveCalls: Array<{
sessionId: string;
action: string;
primaryTrackId: string | null;
secondaryTrackId: string | null;
}> = [];
let now = 10_000;
const env = setupYoutubePickerTestEnv({
now: () => now,
windowValue: {
dispatchEvent: (_event) => true,
focus: () => {},
electronAPI: {
notifyOverlayModalOpened: () => {},
notifyOverlayModalClosed: () => {},
youtubePickerResolve: async (payload: {
sessionId: string;
action: string;
primaryTrackId: string | null;
secondaryTrackId: string | null;
}) => {
resolveCalls.push(payload);
return { ok: true, message: '' };
},
setIgnoreMouseEvents: () => {},
},
} satisfies YoutubePickerTestWindow,
});
try {
const state = createRendererState();
const dom = createYoutubePickerDomFixture();
const modal = createYoutubeTrackPickerModal(
{
state,
dom,
platform: {
shouldToggleMouseIgnore: false,
},
} as never,
{
modalStateReader: { isAnyModalOpen: () => true },
restorePointerInteractionState: () => {},
syncSettingsModalSubtitleSuppression: () => {},
},
);
modal.openYoutubePickerModal({
sessionId: 'yt-1',
url: 'https://example.com',
tracks: [
{
id: 'auto:ja-orig',
language: 'ja',
sourceLanguage: 'ja-orig',
kind: 'auto',
label: 'Japanese (auto)',
},
],
defaultPrimaryTrackId: 'auto:ja-orig',
defaultSecondaryTrackId: null,
hasTracks: true,
});
assert.equal(
modal.handleYoutubePickerKeydown({
key: 'Enter',
preventDefault: () => {},
} as KeyboardEvent),
true,
);
await Promise.resolve();
assert.deepEqual(resolveCalls, []);
assert.equal(state.youtubePickerModalOpen, true);
now += 250;
assert.equal(
modal.handleYoutubePickerKeydown({
key: 'Enter',
preventDefault: () => {},
} as KeyboardEvent),
true,
);
await Promise.resolve();
assert.deepEqual(resolveCalls, [
{
sessionId: 'yt-1',
action: 'use-selected',
primaryTrackId: 'auto:ja-orig',
secondaryTrackId: null,
},
]);
} finally {
env.restore();
}
});
test('youtube track picker uses track list as the source of truth for available selections', async () => {
const resolveCalls: Array<{
sessionId: string;
action: string;
primaryTrackId: string | null;
secondaryTrackId: string | null;
}> = [];
const env = setupYoutubePickerTestEnv({
windowValue: {
dispatchEvent: (_event) => true,
focus: () => {},
electronAPI: {
notifyOverlayModalOpened: () => {},
notifyOverlayModalClosed: () => {},
youtubePickerResolve: async (payload: {
sessionId: string;
action: string;
primaryTrackId: string | null;
secondaryTrackId: string | null;
}) => {
resolveCalls.push(payload);
return { ok: true, message: '' };
},
setIgnoreMouseEvents: () => {},
},
} satisfies YoutubePickerTestWindow,
});
try {
const state = createRendererState();
const dom = createYoutubePickerDomFixture();
const modal = createYoutubeTrackPickerModal(
{
state,
dom,
platform: {
shouldToggleMouseIgnore: false,
},
} as never,
{
modalStateReader: { isAnyModalOpen: () => true },
restorePointerInteractionState: () => {},
syncSettingsModalSubtitleSuppression: () => {},
},
);
modal.openYoutubePickerModal({
sessionId: 'yt-1',
url: 'https://example.com',
tracks: [
{
id: 'manual:ja',
language: 'ja',
sourceLanguage: 'ja',
kind: 'manual',
label: 'Japanese',
},
],
defaultPrimaryTrackId: 'manual:ja',
defaultSecondaryTrackId: null,
hasTracks: false,
});
modal.wireDomEvents();
const listeners = dom.youtubePickerContinueButton.listeners.get('click') ?? [];
await Promise.all(listeners.map((listener) => listener()));
assert.deepEqual(resolveCalls, [
{
sessionId: 'yt-1',
action: 'use-selected',
primaryTrackId: 'manual:ja',
secondaryTrackId: null,
},
]);
} finally {
env.restore();
}
});

View File

@@ -0,0 +1,287 @@
import type { YoutubePickerOpenPayload } from '../../types';
import type { ModalStateReader, RendererContext } from '../context';
import { YOMITAN_POPUP_COMMAND_EVENT } from '../yomitan-popup.js';
function createOption(value: string, label: string): HTMLOptionElement {
const option = document.createElement('option');
option.value = value;
option.textContent = label;
return option;
}
function payloadHasTracks(payload: YoutubePickerOpenPayload | null): boolean {
return (payload?.tracks.length ?? 0) > 0;
}
export function createYoutubeTrackPickerModal(
ctx: RendererContext,
options: {
modalStateReader: Pick<ModalStateReader, 'isAnyModalOpen'>;
restorePointerInteractionState: () => void;
syncSettingsModalSubtitleSuppression: () => void;
},
) {
const OPEN_KEY_GUARD_MS = 200;
let resolveSelectionInFlight = false;
let keyboardSubmitEnabledAtMs = 0;
function setStatus(message: string, isError = false): void {
ctx.state.youtubePickerStatus = message;
ctx.dom.youtubePickerStatus.textContent = message;
ctx.dom.youtubePickerStatus.style.color = isError
? '#ed8796'
: '#a5adcb';
}
function getTrackLabel(trackId: string): string {
return ctx.state.youtubePickerPayload?.tracks.find((track) => track.id === trackId)?.label ?? '';
}
function renderTrackList(): void {
ctx.dom.youtubePickerTracks.replaceChildren();
const payload = ctx.state.youtubePickerPayload;
if (!payload || !payloadHasTracks(payload)) {
const li = document.createElement('li');
const left = document.createElement('span');
left.textContent = 'No subtitle tracks found';
const right = document.createElement('span');
right.className = 'youtube-picker-track-meta';
right.textContent = 'Continue without subtitles';
li.append(left, right);
ctx.dom.youtubePickerTracks.appendChild(li);
return;
}
for (const track of payload.tracks) {
const li = document.createElement('li');
const left = document.createElement('span');
left.textContent = track.label;
const right = document.createElement('span');
right.className = 'youtube-picker-track-meta';
right.textContent = `${track.kind} · ${track.language}`;
li.append(left, right);
ctx.dom.youtubePickerTracks.appendChild(li);
}
}
function setResolveControlsDisabled(disabled: boolean): void {
ctx.dom.youtubePickerPrimarySelect.disabled = disabled;
ctx.dom.youtubePickerSecondarySelect.disabled = disabled;
ctx.dom.youtubePickerContinueButton.disabled = disabled;
ctx.dom.youtubePickerCloseButton.disabled = disabled;
}
function syncSecondaryOptions(): void {
const payload = ctx.state.youtubePickerPayload;
const primaryTrackId = ctx.dom.youtubePickerPrimarySelect.value || null;
ctx.dom.youtubePickerSecondarySelect.replaceChildren();
ctx.dom.youtubePickerSecondarySelect.appendChild(createOption('', 'None'));
if (!payload) return;
for (const track of payload.tracks) {
if (track.id === primaryTrackId) continue;
ctx.dom.youtubePickerSecondarySelect.appendChild(createOption(track.id, track.label));
}
if (
primaryTrackId &&
ctx.dom.youtubePickerSecondarySelect.value === primaryTrackId
) {
ctx.dom.youtubePickerSecondarySelect.value = '';
}
}
function setSelection(primaryTrackId: string | null, secondaryTrackId: string | null): void {
ctx.state.youtubePickerPrimaryTrackId = primaryTrackId;
ctx.state.youtubePickerSecondaryTrackId = secondaryTrackId;
ctx.dom.youtubePickerPrimarySelect.value = primaryTrackId ?? '';
syncSecondaryOptions();
ctx.dom.youtubePickerSecondarySelect.value = secondaryTrackId ?? '';
}
function applyPayload(payload: YoutubePickerOpenPayload): void {
ctx.state.youtubePickerPayload = payload;
ctx.dom.youtubePickerTitle.textContent = `Select YouTube subtitles for ${payload.url}`;
ctx.dom.youtubePickerPrimarySelect.replaceChildren();
ctx.dom.youtubePickerSecondarySelect.replaceChildren();
if (!payloadHasTracks(payload)) {
ctx.dom.youtubePickerPrimarySelect.appendChild(createOption('', 'No tracks available'));
ctx.dom.youtubePickerPrimarySelect.disabled = true;
ctx.dom.youtubePickerSecondarySelect.disabled = true;
ctx.dom.youtubePickerContinueButton.textContent = 'Continue without subtitles';
setSelection(null, null);
setStatus('No subtitle tracks were found. Playback will continue without subtitles.');
renderTrackList();
return;
}
ctx.dom.youtubePickerPrimarySelect.disabled = false;
ctx.dom.youtubePickerSecondarySelect.disabled = false;
ctx.dom.youtubePickerContinueButton.textContent = 'Use selected subtitles';
for (const track of payload.tracks) {
ctx.dom.youtubePickerPrimarySelect.appendChild(createOption(track.id, track.label));
}
setSelection(payload.defaultPrimaryTrackId, payload.defaultSecondaryTrackId);
renderTrackList();
setStatus('Select the subtitle tracks to download.');
}
async function resolveSelection(action: 'use-selected' | 'continue-without-subtitles'): Promise<void> {
if (resolveSelectionInFlight) {
return;
}
const payload = ctx.state.youtubePickerPayload;
if (!payload) return;
const hasTracks = payloadHasTracks(payload);
if (action === 'use-selected' && hasTracks && !ctx.dom.youtubePickerPrimarySelect.value) {
setStatus('Primary subtitle selection is required.', true);
return;
}
resolveSelectionInFlight = true;
setResolveControlsDisabled(true);
try {
const response =
action === 'use-selected'
? await window.electronAPI.youtubePickerResolve({
sessionId: payload.sessionId,
action: 'use-selected',
primaryTrackId: ctx.dom.youtubePickerPrimarySelect.value || null,
secondaryTrackId: ctx.dom.youtubePickerSecondarySelect.value || null,
})
: await window.electronAPI.youtubePickerResolve({
sessionId: payload.sessionId,
action: 'continue-without-subtitles',
primaryTrackId: null,
secondaryTrackId: null,
});
if (!response.ok) {
setStatus(response.message, true);
return;
}
closeYoutubePickerModal();
} catch (error) {
setStatus(error instanceof Error ? error.message : String(error), true);
} finally {
resolveSelectionInFlight = false;
const shouldKeepDisabled =
ctx.state.youtubePickerModalOpen && !payloadHasTracks(ctx.state.youtubePickerPayload);
setResolveControlsDisabled(shouldKeepDisabled);
}
}
function openYoutubePickerModal(payload: YoutubePickerOpenPayload): void {
keyboardSubmitEnabledAtMs = Date.now() + OPEN_KEY_GUARD_MS;
if (ctx.state.youtubePickerModalOpen) {
options.syncSettingsModalSubtitleSuppression();
applyPayload(payload);
window.electronAPI.notifyOverlayModalOpened('youtube-track-picker');
return;
}
ctx.state.youtubePickerModalOpen = true;
options.syncSettingsModalSubtitleSuppression();
applyPayload(payload);
ctx.dom.overlay.classList.add('interactive');
ctx.dom.youtubePickerModal.classList.remove('hidden');
ctx.dom.youtubePickerModal.setAttribute('aria-hidden', 'false');
window.electronAPI.notifyOverlayModalOpened('youtube-track-picker');
}
function closeYoutubePickerModal(): void {
if (!ctx.state.youtubePickerModalOpen) return;
ctx.state.youtubePickerModalOpen = false;
options.syncSettingsModalSubtitleSuppression();
ctx.state.youtubePickerPayload = null;
ctx.state.youtubePickerPrimaryTrackId = null;
ctx.state.youtubePickerSecondaryTrackId = null;
ctx.state.youtubePickerStatus = '';
ctx.dom.youtubePickerModal.classList.add('hidden');
ctx.dom.youtubePickerModal.setAttribute('aria-hidden', 'true');
window.electronAPI.notifyOverlayModalClosed('youtube-track-picker');
window.dispatchEvent(
new CustomEvent(YOMITAN_POPUP_COMMAND_EVENT, {
detail: {
type: 'refreshOptions',
},
}),
);
if (!options.modalStateReader.isAnyModalOpen()) {
ctx.dom.overlay.classList.remove('interactive');
}
options.restorePointerInteractionState();
void window.electronAPI.focusMainWindow();
if (typeof ctx.dom.overlay.focus === 'function') {
ctx.dom.overlay.focus({ preventScroll: true });
}
if (ctx.platform.shouldToggleMouseIgnore) {
if (!ctx.state.isOverSubtitle && !options.modalStateReader.isAnyModalOpen()) {
window.electronAPI.setIgnoreMouseEvents(true, { forward: true });
} else {
window.electronAPI.setIgnoreMouseEvents(false);
}
}
window.focus();
}
function handleYoutubePickerKeydown(e: KeyboardEvent): boolean {
if (!ctx.state.youtubePickerModalOpen) return false;
if (e.key === 'Escape') {
e.preventDefault();
void resolveSelection('continue-without-subtitles');
return true;
}
if (e.key === 'Enter') {
e.preventDefault();
if (Date.now() < keyboardSubmitEnabledAtMs) {
return true;
}
void resolveSelection(
payloadHasTracks(ctx.state.youtubePickerPayload) ? 'use-selected' : 'continue-without-subtitles',
);
return true;
}
return false;
}
function wireDomEvents(): void {
ctx.dom.youtubePickerPrimarySelect.addEventListener('change', () => {
const primaryTrackId = ctx.dom.youtubePickerPrimarySelect.value || null;
if (ctx.dom.youtubePickerSecondarySelect.value === primaryTrackId) {
ctx.dom.youtubePickerSecondarySelect.value = '';
}
setSelection(primaryTrackId, ctx.dom.youtubePickerSecondarySelect.value || null);
});
ctx.dom.youtubePickerSecondarySelect.addEventListener('change', () => {
const primaryTrackId = ctx.dom.youtubePickerPrimarySelect.value || null;
const secondaryTrackId = ctx.dom.youtubePickerSecondarySelect.value || null;
if (primaryTrackId && secondaryTrackId === primaryTrackId) {
ctx.dom.youtubePickerSecondarySelect.value = '';
setStatus('Primary and secondary subtitles must be different.', true);
return;
}
setSelection(primaryTrackId, secondaryTrackId);
setStatus('Select the subtitle tracks to download.');
});
ctx.dom.youtubePickerContinueButton.addEventListener('click', () => {
void resolveSelection(
payloadHasTracks(ctx.state.youtubePickerPayload) ? 'use-selected' : 'continue-without-subtitles',
);
});
ctx.dom.youtubePickerCloseButton.addEventListener('click', () => {
void resolveSelection('continue-without-subtitles');
});
}
return {
closeYoutubePickerModal,
handleYoutubePickerKeydown,
openYoutubePickerModal,
wireDomEvents,
};
}

View File

@@ -0,0 +1,63 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { syncOverlayMouseIgnoreState } from './overlay-mouse-ignore.js';
function createClassList() {
const classes = new Set<string>();
return {
add: (...tokens: string[]) => {
for (const token of tokens) classes.add(token);
},
remove: (...tokens: string[]) => {
for (const token of tokens) classes.delete(token);
},
contains: (token: string) => classes.has(token),
};
}
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 });
},
},
},
});
try {
syncOverlayMouseIgnoreState({
dom: {
overlay: { classList },
},
platform: {
shouldToggleMouseIgnore: true,
},
state: {
isOverSubtitle: false,
isOverSubtitleSidebar: false,
yomitanPopupVisible: false,
controllerSelectModalOpen: false,
controllerDebugModalOpen: false,
jimakuModalOpen: false,
youtubePickerModalOpen: true,
kikuModalOpen: false,
runtimeOptionsModalOpen: false,
subsyncModalOpen: false,
sessionHelpModalOpen: false,
subtitleSidebarModalOpen: false,
subtitleSidebarConfig: null,
},
} as never);
assert.equal(classList.contains('interactive'), true);
assert.deepEqual(ignoreCalls, [{ ignore: false, forward: undefined }]);
} finally {
Object.assign(globalThis, { window: originalWindow });
}
});

View File

@@ -9,6 +9,7 @@ function isBlockingOverlayModalOpen(state: RendererState): boolean {
state.controllerSelectModalOpen ||
state.controllerDebugModalOpen ||
state.jimakuModalOpen ||
state.youtubePickerModalOpen ||
state.kikuModalOpen ||
state.runtimeOptionsModalOpen ||
state.subsyncModalOpen ||

View File

@@ -37,6 +37,7 @@ import { createSessionHelpModal } from './modals/session-help.js';
import { createSubtitleSidebarModal } from './modals/subtitle-sidebar.js';
import { createRuntimeOptionsModal } from './modals/runtime-options.js';
import { createSubsyncModal } from './modals/subsync.js';
import { createYoutubeTrackPickerModal } from './modals/youtube-track-picker.js';
import { createPositioningController } from './positioning.js';
import { createOverlayContentMeasurementReporter } from './overlay-content-measurement.js';
import { syncOverlayMouseIgnoreState } from './overlay-mouse-ignore.js';
@@ -68,6 +69,7 @@ function isAnySettingsModalOpen(): boolean {
ctx.state.subsyncModalOpen ||
ctx.state.kikuModalOpen ||
ctx.state.jimakuModalOpen ||
ctx.state.youtubePickerModalOpen ||
ctx.state.sessionHelpModalOpen
);
}
@@ -80,6 +82,7 @@ function isAnyModalOpen(): boolean {
ctx.state.kikuModalOpen ||
ctx.state.runtimeOptionsModalOpen ||
ctx.state.subsyncModalOpen ||
ctx.state.youtubePickerModalOpen ||
ctx.state.sessionHelpModalOpen ||
ctx.state.subtitleSidebarModalOpen
);
@@ -128,11 +131,29 @@ const jimakuModal = createJimakuModal(ctx, {
modalStateReader: { isAnyModalOpen },
syncSettingsModalSubtitleSuppression,
});
const mouseHandlers = createMouseHandlers(ctx, {
modalStateReader: { isAnySettingsModalOpen, isAnyModalOpen },
applyYPercent: positioning.applyYPercent,
getCurrentYPercent: positioning.getCurrentYPercent,
persistSubtitlePositionPatch: positioning.persistSubtitlePositionPatch,
getSubtitleHoverAutoPauseEnabled: () => ctx.state.autoPauseVideoOnSubtitleHover,
getYomitanPopupAutoPauseEnabled: () => ctx.state.autoPauseVideoOnYomitanPopup,
getPlaybackPaused: () => window.electronAPI.getPlaybackPaused(),
sendMpvCommand: (command) => {
window.electronAPI.sendMpvCommand(command);
},
});
const youtubePickerModal = createYoutubeTrackPickerModal(ctx, {
modalStateReader: { isAnyModalOpen },
restorePointerInteractionState: mouseHandlers.restorePointerInteractionState,
syncSettingsModalSubtitleSuppression,
});
const keyboardHandlers = createKeyboardHandlers(ctx, {
handleRuntimeOptionsKeydown: runtimeOptionsModal.handleRuntimeOptionsKeydown,
handleSubsyncKeydown: subsyncModal.handleSubsyncKeydown,
handleKikuKeydown: kikuModal.handleKikuKeydown,
handleJimakuKeydown: jimakuModal.handleJimakuKeydown,
handleYoutubePickerKeydown: youtubePickerModal.handleYoutubePickerKeydown,
handleControllerSelectKeydown: controllerSelectModal.handleControllerSelectKeydown,
handleControllerDebugKeydown: controllerDebugModal.handleControllerDebugKeydown,
handleSessionHelpKeydown: sessionHelpModal.handleSessionHelpKeydown,
@@ -153,18 +174,6 @@ const keyboardHandlers = createKeyboardHandlers(ctx, {
void subtitleSidebarModal.toggleSubtitleSidebarModal();
},
});
const mouseHandlers = createMouseHandlers(ctx, {
modalStateReader: { isAnySettingsModalOpen, isAnyModalOpen },
applyYPercent: positioning.applyYPercent,
getCurrentYPercent: positioning.getCurrentYPercent,
persistSubtitlePositionPatch: positioning.persistSubtitlePositionPatch,
getSubtitleHoverAutoPauseEnabled: () => ctx.state.autoPauseVideoOnSubtitleHover,
getYomitanPopupAutoPauseEnabled: () => ctx.state.autoPauseVideoOnYomitanPopup,
getPlaybackPaused: () => window.electronAPI.getPlaybackPaused(),
sendMpvCommand: (command) => {
window.electronAPI.sendMpvCommand(command);
},
});
let lastSubtitlePreview = '';
let lastSecondarySubtitlePreview = '';
@@ -194,6 +203,7 @@ function getActiveModal(): string | null {
if (ctx.state.controllerDebugModalOpen) return 'controller-debug';
if (ctx.state.subtitleSidebarModalOpen) return 'subtitle-sidebar';
if (ctx.state.jimakuModalOpen) return 'jimaku';
if (ctx.state.youtubePickerModalOpen) return 'youtube-track-picker';
if (ctx.state.kikuModalOpen) return 'kiku';
if (ctx.state.runtimeOptionsModalOpen) return 'runtime-options';
if (ctx.state.subsyncModalOpen) return 'subsync';
@@ -214,6 +224,9 @@ function dismissActiveUiAfterError(): void {
if (ctx.state.jimakuModalOpen) {
jimakuModal.closeJimakuModal();
}
if (ctx.state.youtubePickerModalOpen) {
youtubePickerModal.closeYoutubePickerModal();
}
if (ctx.state.runtimeOptionsModalOpen) {
runtimeOptionsModal.closeRuntimeOptionsModal();
}
@@ -416,6 +429,16 @@ function registerModalOpenHandlers(): void {
window.electronAPI.notifyOverlayModalOpened('jimaku');
});
});
window.electronAPI.onOpenYoutubeTrackPicker((payload) => {
runGuarded('youtube:picker-open', () => {
youtubePickerModal.openYoutubePickerModal(payload);
});
});
window.electronAPI.onCancelYoutubeTrackPicker(() => {
runGuarded('youtube:picker-cancel', () => {
youtubePickerModal.closeYoutubePickerModal();
});
});
window.electronAPI.onSubsyncManualOpen((payload: SubsyncManualPayload) => {
runGuarded('subsync:manual-open', () => {
subsyncModal.openSubsyncModal(payload);
@@ -528,6 +551,7 @@ async function init(): Promise<void> {
ctx.dom.secondarySubContainer.addEventListener('mouseleave', mouseHandlers.handleSecondaryMouseLeave);
mouseHandlers.setupResizeHandler();
mouseHandlers.setupPointerTracking();
mouseHandlers.setupSelectionObserver();
mouseHandlers.setupYomitanObserver();
setupDragDropToMpvQueue();
@@ -536,6 +560,7 @@ async function init(): Promise<void> {
});
jimakuModal.wireDomEvents();
youtubePickerModal.wireDomEvents();
kikuModal.wireDomEvents();
runtimeOptionsModal.wireDomEvents();
subsyncModal.wireDomEvents();

View File

@@ -13,6 +13,7 @@ import type {
SubtitleSidebarConfig,
SubtitleCue,
SubsyncSourceTrack,
YoutubePickerOpenPayload,
} from '../types';
export type KikuModalStep = 'select' | 'preview';
@@ -40,6 +41,12 @@ export type RendererState = {
currentEpisodeFilter: number | null;
currentEntryId: number | null;
youtubePickerModalOpen: boolean;
youtubePickerPayload: YoutubePickerOpenPayload | null;
youtubePickerPrimaryTrackId: string | null;
youtubePickerSecondaryTrackId: string | null;
youtubePickerStatus: string;
kikuModalOpen: boolean;
kikuSelectedCard: 1 | 2;
kikuOriginalData: KikuDuplicateCardInfo | null;
@@ -131,6 +138,12 @@ export function createRendererState(): RendererState {
currentEpisodeFilter: null,
currentEntryId: null,
youtubePickerModalOpen: false,
youtubePickerPayload: null,
youtubePickerPrimaryTrackId: null,
youtubePickerSecondaryTrackId: null,
youtubePickerStatus: '',
kikuModalOpen: false,
kikuSelectedCard: 1,
kikuOriginalData: null,

View File

@@ -127,6 +127,10 @@ body {
z-index: 1100;
}
#youtubePickerModal {
z-index: 1110;
}
.modal.hidden {
display: none;
}
@@ -138,11 +142,11 @@ body {
.modal-content {
width: min(720px, 92%);
max-height: 80%;
background: rgba(20, 20, 20, 0.95);
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(36, 39, 58, 0.95);
border: 1px solid rgba(110, 115, 141, 0.18);
border-radius: 12px;
padding: 16px;
color: #fff;
color: #cad3f5;
display: flex;
flex-direction: column;
gap: 12px;
@@ -161,16 +165,17 @@ body {
}
.modal-close {
background: rgba(255, 255, 255, 0.1);
color: #fff;
border: 1px solid rgba(255, 255, 255, 0.2);
background: rgba(73, 77, 100, 0.5);
color: #a5adcb;
border: 1px solid rgba(110, 115, 141, 0.2);
border-radius: 6px;
padding: 6px 10px;
cursor: pointer;
}
.modal-close:hover {
background: rgba(255, 255, 255, 0.2);
background: rgba(91, 96, 120, 0.6);
color: #cad3f5;
}
.modal-body {
@@ -288,6 +293,91 @@ body {
display: none;
}
.youtube-picker-content {
width: min(820px, 92%);
background:
radial-gradient(circle at top right, rgba(198, 160, 246, 0.10), transparent 34%),
linear-gradient(180deg, rgba(36, 39, 58, 0.98), rgba(30, 32, 48, 0.98));
border-color: rgba(138, 173, 244, 0.25);
}
.youtube-picker-body {
gap: 14px;
}
.youtube-picker-title {
font-size: 13px;
color: #b8c0e0;
}
.youtube-picker-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.youtube-picker-field {
display: flex;
flex-direction: column;
gap: 6px;
font-size: 12px;
color: #a5adcb;
}
.youtube-picker-field select {
min-height: 36px;
border-radius: 8px;
border: 1px solid rgba(110, 115, 141, 0.28);
background: rgba(24, 25, 38, 0.92);
color: #cad3f5;
padding: 6px 10px;
}
.youtube-picker-status {
min-height: 20px;
font-size: 13px;
color: #a5adcb;
}
.youtube-picker-tracks {
list-style: none;
margin: 0;
padding: 0;
max-height: 220px;
overflow-y: auto;
border-radius: 10px;
border: 1px solid rgba(110, 115, 141, 0.18);
}
.youtube-picker-tracks li {
display: flex;
justify-content: space-between;
gap: 12px;
padding: 10px 12px;
border-bottom: 1px solid rgba(110, 115, 141, 0.08);
color: #cad3f5;
}
.youtube-picker-tracks li:last-child {
border-bottom: none;
}
.youtube-picker-track-meta {
color: #6e738d;
font-size: 12px;
}
.youtube-picker-footer {
display: flex;
justify-content: flex-end;
}
@media (max-width: 700px) {
.youtube-picker-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 640px) {
.jimaku-form {
grid-template-columns: 1fr 1fr;
@@ -982,15 +1072,15 @@ iframe[id^='yomitan-popup'] {
.kiku-confirm-button {
padding: 8px 20px;
border-radius: 6px;
border: 1px solid rgba(100, 180, 255, 0.4);
background: rgba(100, 180, 255, 0.15);
color: rgba(100, 180, 255, 0.95);
border: 1px solid rgba(138, 173, 244, 0.4);
background: rgba(138, 173, 244, 0.15);
color: #8aadf4;
font-weight: 600;
cursor: pointer;
}
.kiku-confirm-button:hover {
background: rgba(100, 180, 255, 0.25);
background: rgba(138, 173, 244, 0.25);
}
.subsync-modal-content {
@@ -1288,9 +1378,9 @@ iframe[id^='yomitan-popup'] {
.btn-learn {
padding: 5px 14px;
border-radius: 5px;
border: 1px solid rgba(100, 180, 255, 0.4);
background: rgba(100, 180, 255, 0.15);
color: rgba(100, 180, 255, 0.95);
border: 1px solid rgba(138, 173, 244, 0.4);
background: rgba(138, 173, 244, 0.15);
color: #8aadf4;
font-size: 12px;
font-weight: 600;
cursor: pointer;
@@ -1298,28 +1388,28 @@ iframe[id^='yomitan-popup'] {
}
.btn-learn:hover {
background: rgba(100, 180, 255, 0.25);
background: rgba(138, 173, 244, 0.25);
}
.btn-learn.active {
border-color: rgba(100, 180, 255, 0.7);
background: rgba(100, 180, 255, 0.25);
border-color: rgba(138, 173, 244, 0.7);
background: rgba(138, 173, 244, 0.25);
}
.btn-secondary {
padding: 5px 12px;
border-radius: 5px;
border: 1px solid rgba(255, 255, 255, 0.12);
border: 1px solid rgba(110, 115, 141, 0.2);
background: transparent;
color: rgba(255, 255, 255, 0.55);
color: #6e738d;
font-size: 12px;
cursor: pointer;
transition: background 120ms ease, color 120ms ease;
}
.btn-secondary:hover {
background: rgba(255, 255, 255, 0.08);
color: rgba(255, 255, 255, 0.85);
background: rgba(73, 77, 100, 0.4);
color: #a5adcb;
}
.controller-debug-content {

View File

@@ -20,6 +20,15 @@ export type RendererDom = {
jimakuFilesList: HTMLUListElement;
jimakuBroadenButton: HTMLButtonElement;
youtubePickerModal: HTMLDivElement;
youtubePickerTitle: HTMLDivElement;
youtubePickerPrimarySelect: HTMLSelectElement;
youtubePickerSecondarySelect: HTMLSelectElement;
youtubePickerContinueButton: HTMLButtonElement;
youtubePickerCloseButton: HTMLButtonElement;
youtubePickerStatus: HTMLDivElement;
youtubePickerTracks: HTMLUListElement;
kikuModal: HTMLDivElement;
kikuCard1: HTMLDivElement;
kikuCard2: HTMLDivElement;
@@ -120,6 +129,19 @@ export function resolveRendererDom(): RendererDom {
jimakuFilesList: getRequiredElement<HTMLUListElement>('jimakuFiles'),
jimakuBroadenButton: getRequiredElement<HTMLButtonElement>('jimakuBroaden'),
youtubePickerModal: getRequiredElement<HTMLDivElement>('youtubePickerModal'),
youtubePickerTitle: getRequiredElement<HTMLDivElement>('youtubePickerTitle'),
youtubePickerPrimarySelect: getRequiredElement<HTMLSelectElement>('youtubePickerPrimarySelect'),
youtubePickerSecondarySelect: getRequiredElement<HTMLSelectElement>(
'youtubePickerSecondarySelect',
),
youtubePickerContinueButton: getRequiredElement<HTMLButtonElement>(
'youtubePickerContinueButton',
),
youtubePickerCloseButton: getRequiredElement<HTMLButtonElement>('youtubePickerCloseButton'),
youtubePickerStatus: getRequiredElement<HTMLDivElement>('youtubePickerStatus'),
youtubePickerTracks: getRequiredElement<HTMLUListElement>('youtubePickerTracks'),
kikuModal: getRequiredElement<HTMLDivElement>('kikuFieldGroupingModal'),
kikuCard1: getRequiredElement<HTMLDivElement>('kikuCard1'),
kikuCard2: getRequiredElement<HTMLDivElement>('kikuCard2'),