mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-30 06:12:06 -07:00
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:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
769
src/renderer/modals/youtube-track-picker.test.ts
Normal file
769
src/renderer/modals/youtube-track-picker.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
287
src/renderer/modals/youtube-track-picker.ts
Normal file
287
src/renderer/modals/youtube-track-picker.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user