mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-23 00:11:28 -07:00
fix: align youtube playback with shared overlay startup
This commit is contained in:
@@ -35,6 +35,9 @@ export class AnkiConnectProxyServer {
|
|||||||
private pendingNoteIdSet = new Set<number>();
|
private pendingNoteIdSet = new Set<number>();
|
||||||
private inFlightNoteIds = new Set<number>();
|
private inFlightNoteIds = new Set<number>();
|
||||||
private processingQueue = false;
|
private processingQueue = false;
|
||||||
|
private readyPromise: Promise<void> | null = null;
|
||||||
|
private resolveReady: (() => void) | null = null;
|
||||||
|
private rejectReady: ((error: Error) => void) | null = null;
|
||||||
|
|
||||||
constructor(private readonly deps: AnkiConnectProxyServerDeps) {
|
constructor(private readonly deps: AnkiConnectProxyServerDeps) {
|
||||||
this.client = axios.create({
|
this.client = axios.create({
|
||||||
@@ -48,6 +51,13 @@ export class AnkiConnectProxyServer {
|
|||||||
return this.server !== null;
|
return this.server !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
waitUntilReady(): Promise<void> {
|
||||||
|
if (!this.server || this.server.listening) {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
return this.readyPromise ?? Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
start(options: StartProxyOptions): void {
|
start(options: StartProxyOptions): void {
|
||||||
this.stop();
|
this.stop();
|
||||||
|
|
||||||
@@ -58,15 +68,26 @@ export class AnkiConnectProxyServer {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.readyPromise = new Promise<void>((resolve, reject) => {
|
||||||
|
this.resolveReady = resolve;
|
||||||
|
this.rejectReady = reject;
|
||||||
|
});
|
||||||
|
|
||||||
this.server = http.createServer((req, res) => {
|
this.server = http.createServer((req, res) => {
|
||||||
void this.handleRequest(req, res, options.upstreamUrl);
|
void this.handleRequest(req, res, options.upstreamUrl);
|
||||||
});
|
});
|
||||||
|
|
||||||
this.server.on('error', (error) => {
|
this.server.on('error', (error) => {
|
||||||
|
this.rejectReady?.(error as Error);
|
||||||
|
this.resolveReady = null;
|
||||||
|
this.rejectReady = null;
|
||||||
this.deps.logError('[anki-proxy] Server error:', (error as Error).message);
|
this.deps.logError('[anki-proxy] Server error:', (error as Error).message);
|
||||||
});
|
});
|
||||||
|
|
||||||
this.server.listen(options.port, options.host, () => {
|
this.server.listen(options.port, options.host, () => {
|
||||||
|
this.resolveReady?.();
|
||||||
|
this.resolveReady = null;
|
||||||
|
this.rejectReady = null;
|
||||||
this.deps.logInfo(
|
this.deps.logInfo(
|
||||||
`[anki-proxy] Listening on http://${options.host}:${options.port} -> ${options.upstreamUrl}`,
|
`[anki-proxy] Listening on http://${options.host}:${options.port} -> ${options.upstreamUrl}`,
|
||||||
);
|
);
|
||||||
@@ -79,6 +100,10 @@ export class AnkiConnectProxyServer {
|
|||||||
this.server = null;
|
this.server = null;
|
||||||
this.deps.logInfo('[anki-proxy] Stopped');
|
this.deps.logInfo('[anki-proxy] Stopped');
|
||||||
}
|
}
|
||||||
|
this.rejectReady?.(new Error('AnkiConnect proxy stopped before becoming ready'));
|
||||||
|
this.readyPromise = null;
|
||||||
|
this.resolveReady = null;
|
||||||
|
this.rejectReady = null;
|
||||||
this.pendingNoteIds = [];
|
this.pendingNoteIds = [];
|
||||||
this.pendingNoteIdSet.clear();
|
this.pendingNoteIdSet.clear();
|
||||||
this.inFlightNoteIds.clear();
|
this.inFlightNoteIds.clear();
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ function createRuntime(
|
|||||||
start: ({ host, port, upstreamUrl }) =>
|
start: ({ host, port, upstreamUrl }) =>
|
||||||
calls.push(`proxy:start:${host}:${port}:${upstreamUrl}`),
|
calls.push(`proxy:start:${host}:${port}:${upstreamUrl}`),
|
||||||
stop: () => calls.push('proxy:stop'),
|
stop: () => calls.push('proxy:stop'),
|
||||||
|
waitUntilReady: async () => undefined,
|
||||||
}),
|
}),
|
||||||
logInfo: () => undefined,
|
logInfo: () => undefined,
|
||||||
logWarn: () => undefined,
|
logWarn: () => undefined,
|
||||||
@@ -80,6 +81,44 @@ test('AnkiIntegrationRuntime starts proxy transport when proxy mode is enabled',
|
|||||||
assert.deepEqual(calls, ['known:start', 'proxy:start:127.0.0.1:9999:http://upstream:8765']);
|
assert.deepEqual(calls, ['known:start', 'proxy:start:127.0.0.1:9999:http://upstream:8765']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('AnkiIntegrationRuntime waits for proxy readiness when proxy mode is enabled', async () => {
|
||||||
|
let releaseReady!: () => void;
|
||||||
|
const waitUntilReadyCalls: string[] = [];
|
||||||
|
const readyPromise = new Promise<void>((resolve) => {
|
||||||
|
releaseReady = resolve;
|
||||||
|
});
|
||||||
|
const { runtime } = createRuntime(
|
||||||
|
{
|
||||||
|
proxy: {
|
||||||
|
enabled: true,
|
||||||
|
host: '127.0.0.1',
|
||||||
|
port: 9999,
|
||||||
|
upstreamUrl: 'http://upstream:8765',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
proxyServerFactory: () => ({
|
||||||
|
start: () => undefined,
|
||||||
|
stop: () => undefined,
|
||||||
|
waitUntilReady: async () => {
|
||||||
|
waitUntilReadyCalls.push('proxy:wait-until-ready');
|
||||||
|
await readyPromise;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
runtime.start();
|
||||||
|
const waitPromise = runtime.waitUntilReady().then(() => {
|
||||||
|
waitUntilReadyCalls.push('proxy:ready');
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(waitUntilReadyCalls, ['proxy:wait-until-ready']);
|
||||||
|
releaseReady();
|
||||||
|
await waitPromise;
|
||||||
|
assert.deepEqual(waitUntilReadyCalls, ['proxy:wait-until-ready', 'proxy:ready']);
|
||||||
|
});
|
||||||
|
|
||||||
test('AnkiIntegrationRuntime switches transports and clears known words when runtime patch disables highlighting', () => {
|
test('AnkiIntegrationRuntime switches transports and clears known words when runtime patch disables highlighting', () => {
|
||||||
const { runtime, calls } = createRuntime({
|
const { runtime, calls } = createRuntime({
|
||||||
knownWords: {
|
knownWords: {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
export interface AnkiIntegrationRuntimeProxyServer {
|
export interface AnkiIntegrationRuntimeProxyServer {
|
||||||
start(options: { host: string; port: number; upstreamUrl: string }): void;
|
start(options: { host: string; port: number; upstreamUrl: string }): void;
|
||||||
stop(): void;
|
stop(): void;
|
||||||
|
waitUntilReady(): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AnkiIntegrationRuntimeDeps {
|
interface AnkiIntegrationRuntimeDeps {
|
||||||
@@ -131,6 +132,13 @@ export class AnkiIntegrationRuntime {
|
|||||||
return this.config;
|
return this.config;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
waitUntilReady(): Promise<void> {
|
||||||
|
if (!this.started || !this.isProxyTransportEnabled()) {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
return this.getOrCreateProxyServer().waitUntilReady();
|
||||||
|
}
|
||||||
|
|
||||||
start(): void {
|
start(): void {
|
||||||
if (this.started) {
|
if (this.started) {
|
||||||
this.stop();
|
this.stop();
|
||||||
|
|||||||
@@ -79,7 +79,10 @@ export {
|
|||||||
handleOverlayWindowBeforeInputEvent,
|
handleOverlayWindowBeforeInputEvent,
|
||||||
isTabInputForMpvForwarding,
|
isTabInputForMpvForwarding,
|
||||||
} from './overlay-window-input';
|
} from './overlay-window-input';
|
||||||
export { initializeOverlayRuntime } from './overlay-runtime-init';
|
export {
|
||||||
|
initializeOverlayAnkiIntegration,
|
||||||
|
initializeOverlayRuntime,
|
||||||
|
} from './overlay-runtime-init';
|
||||||
export { setVisibleOverlayVisible, updateVisibleOverlayVisibility } from './overlay-visibility';
|
export { setVisibleOverlayVisible, updateVisibleOverlayVisibility } from './overlay-visibility';
|
||||||
export {
|
export {
|
||||||
MPV_REQUEST_ID_SECONDARY_SUB_VISIBILITY,
|
MPV_REQUEST_ID_SECONDARY_SUB_VISIBILITY,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import { initializeOverlayRuntime } from './overlay-runtime-init';
|
import { initializeOverlayAnkiIntegration, initializeOverlayRuntime } from './overlay-runtime-init';
|
||||||
|
|
||||||
test('initializeOverlayRuntime skips Anki integration when ankiConnect.enabled is false', () => {
|
test('initializeOverlayRuntime skips Anki integration when ankiConnect.enabled is false', () => {
|
||||||
let createdIntegrations = 0;
|
let createdIntegrations = 0;
|
||||||
@@ -109,6 +109,49 @@ test('initializeOverlayRuntime starts Anki integration when ankiConnect.enabled
|
|||||||
assert.equal(setIntegrationCalls, 1);
|
assert.equal(setIntegrationCalls, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('initializeOverlayAnkiIntegration can initialize Anki transport after overlay runtime already exists', () => {
|
||||||
|
let createdIntegrations = 0;
|
||||||
|
let startedIntegrations = 0;
|
||||||
|
let setIntegrationCalls = 0;
|
||||||
|
|
||||||
|
initializeOverlayAnkiIntegration({
|
||||||
|
getResolvedConfig: () => ({
|
||||||
|
ankiConnect: { enabled: true } as never,
|
||||||
|
}),
|
||||||
|
getSubtitleTimingTracker: () => ({}),
|
||||||
|
getMpvClient: () => ({
|
||||||
|
send: () => {},
|
||||||
|
}),
|
||||||
|
getRuntimeOptionsManager: () => ({
|
||||||
|
getEffectiveAnkiConnectConfig: (config) => config as never,
|
||||||
|
}),
|
||||||
|
createAnkiIntegration: (args) => {
|
||||||
|
createdIntegrations += 1;
|
||||||
|
assert.equal(args.config.enabled, true);
|
||||||
|
return {
|
||||||
|
start: () => {
|
||||||
|
startedIntegrations += 1;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
setAnkiIntegration: () => {
|
||||||
|
setIntegrationCalls += 1;
|
||||||
|
},
|
||||||
|
showDesktopNotification: () => {},
|
||||||
|
createFieldGroupingCallback: () => async () => ({
|
||||||
|
keepNoteId: 11,
|
||||||
|
deleteNoteId: 12,
|
||||||
|
deleteDuplicate: false,
|
||||||
|
cancelled: false,
|
||||||
|
}),
|
||||||
|
getKnownWordCacheStatePath: () => '/tmp/known-words-cache.json',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(createdIntegrations, 1);
|
||||||
|
assert.equal(startedIntegrations, 1);
|
||||||
|
assert.equal(setIntegrationCalls, 1);
|
||||||
|
});
|
||||||
|
|
||||||
test('initializeOverlayRuntime can skip starting Anki integration transport', () => {
|
test('initializeOverlayRuntime can skip starting Anki integration transport', () => {
|
||||||
let createdIntegrations = 0;
|
let createdIntegrations = 0;
|
||||||
let startedIntegrations = 0;
|
let startedIntegrations = 0;
|
||||||
|
|||||||
@@ -47,6 +47,24 @@ function createDefaultAnkiIntegration(args: CreateAnkiIntegrationArgs): AnkiInte
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function initializeOverlayRuntime(options: {
|
export function initializeOverlayRuntime(options: {
|
||||||
|
getMpvSocketPath: () => string;
|
||||||
|
getResolvedConfig: () => { ankiConnect?: AnkiConnectConfig; ai?: AiConfig };
|
||||||
|
getSubtitleTimingTracker: () => unknown | null;
|
||||||
|
getMpvClient: () => {
|
||||||
|
send?: (payload: { command: string[] }) => void;
|
||||||
|
} | null;
|
||||||
|
getRuntimeOptionsManager: () => {
|
||||||
|
getEffectiveAnkiConnectConfig: (config?: AnkiConnectConfig) => AnkiConnectConfig;
|
||||||
|
} | null;
|
||||||
|
getAnkiIntegration?: () => unknown | null;
|
||||||
|
setAnkiIntegration: (integration: unknown | null) => void;
|
||||||
|
showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void;
|
||||||
|
createFieldGroupingCallback: () => (
|
||||||
|
data: KikuFieldGroupingRequestData,
|
||||||
|
) => Promise<KikuFieldGroupingChoice>;
|
||||||
|
getKnownWordCacheStatePath: () => string;
|
||||||
|
shouldStartAnkiIntegration?: () => boolean;
|
||||||
|
createAnkiIntegration?: (args: CreateAnkiIntegrationArgs) => AnkiIntegrationLike;
|
||||||
backendOverride: string | null;
|
backendOverride: string | null;
|
||||||
createMainWindow: () => void;
|
createMainWindow: () => void;
|
||||||
registerGlobalShortcuts: () => void;
|
registerGlobalShortcuts: () => void;
|
||||||
@@ -60,23 +78,6 @@ export function initializeOverlayRuntime(options: {
|
|||||||
override?: string | null,
|
override?: string | null,
|
||||||
targetMpvSocketPath?: string | null,
|
targetMpvSocketPath?: string | null,
|
||||||
) => BaseWindowTracker | null;
|
) => BaseWindowTracker | null;
|
||||||
getMpvSocketPath: () => string;
|
|
||||||
getResolvedConfig: () => { ankiConnect?: AnkiConnectConfig; ai?: AiConfig };
|
|
||||||
getSubtitleTimingTracker: () => unknown | null;
|
|
||||||
getMpvClient: () => {
|
|
||||||
send?: (payload: { command: string[] }) => void;
|
|
||||||
} | null;
|
|
||||||
getRuntimeOptionsManager: () => {
|
|
||||||
getEffectiveAnkiConnectConfig: (config?: AnkiConnectConfig) => AnkiConnectConfig;
|
|
||||||
} | null;
|
|
||||||
setAnkiIntegration: (integration: unknown | null) => void;
|
|
||||||
showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void;
|
|
||||||
createFieldGroupingCallback: () => (
|
|
||||||
data: KikuFieldGroupingRequestData,
|
|
||||||
) => Promise<KikuFieldGroupingChoice>;
|
|
||||||
getKnownWordCacheStatePath: () => string;
|
|
||||||
shouldStartAnkiIntegration?: () => boolean;
|
|
||||||
createAnkiIntegration?: (args: CreateAnkiIntegrationArgs) => AnkiIntegrationLike;
|
|
||||||
}): void {
|
}): void {
|
||||||
options.createMainWindow();
|
options.createMainWindow();
|
||||||
options.registerGlobalShortcuts();
|
options.registerGlobalShortcuts();
|
||||||
@@ -112,35 +113,64 @@ export function initializeOverlayRuntime(options: {
|
|||||||
windowTracker.start();
|
windowTracker.start();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
initializeOverlayAnkiIntegration(options);
|
||||||
|
|
||||||
|
options.updateVisibleOverlayVisibility();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initializeOverlayAnkiIntegration(options: {
|
||||||
|
getResolvedConfig: () => { ankiConnect?: AnkiConnectConfig; ai?: AiConfig };
|
||||||
|
getSubtitleTimingTracker: () => unknown | null;
|
||||||
|
getMpvClient: () => {
|
||||||
|
send?: (payload: { command: string[] }) => void;
|
||||||
|
} | null;
|
||||||
|
getRuntimeOptionsManager: () => {
|
||||||
|
getEffectiveAnkiConnectConfig: (config?: AnkiConnectConfig) => AnkiConnectConfig;
|
||||||
|
} | null;
|
||||||
|
getAnkiIntegration?: () => unknown | null;
|
||||||
|
setAnkiIntegration: (integration: unknown | null) => void;
|
||||||
|
showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void;
|
||||||
|
createFieldGroupingCallback: () => (
|
||||||
|
data: KikuFieldGroupingRequestData,
|
||||||
|
) => Promise<KikuFieldGroupingChoice>;
|
||||||
|
getKnownWordCacheStatePath: () => string;
|
||||||
|
shouldStartAnkiIntegration?: () => boolean;
|
||||||
|
createAnkiIntegration?: (args: CreateAnkiIntegrationArgs) => AnkiIntegrationLike;
|
||||||
|
}): boolean {
|
||||||
|
if (options.getAnkiIntegration?.()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
const config = options.getResolvedConfig();
|
const config = options.getResolvedConfig();
|
||||||
const subtitleTimingTracker = options.getSubtitleTimingTracker();
|
const subtitleTimingTracker = options.getSubtitleTimingTracker();
|
||||||
const mpvClient = options.getMpvClient();
|
const mpvClient = options.getMpvClient();
|
||||||
const runtimeOptionsManager = options.getRuntimeOptionsManager();
|
const runtimeOptionsManager = options.getRuntimeOptionsManager();
|
||||||
|
|
||||||
if (
|
if (
|
||||||
config.ankiConnect?.enabled === true &&
|
config.ankiConnect?.enabled !== true ||
|
||||||
subtitleTimingTracker &&
|
!subtitleTimingTracker ||
|
||||||
mpvClient &&
|
!mpvClient ||
|
||||||
runtimeOptionsManager
|
!runtimeOptionsManager
|
||||||
) {
|
) {
|
||||||
const effectiveAnkiConfig = runtimeOptionsManager.getEffectiveAnkiConnectConfig(
|
return false;
|
||||||
config.ankiConnect,
|
|
||||||
);
|
|
||||||
const createAnkiIntegration = options.createAnkiIntegration ?? createDefaultAnkiIntegration;
|
|
||||||
const integration = createAnkiIntegration({
|
|
||||||
config: effectiveAnkiConfig,
|
|
||||||
aiConfig: mergeAiConfig(config.ai, config.ankiConnect?.ai),
|
|
||||||
subtitleTimingTracker,
|
|
||||||
mpvClient,
|
|
||||||
showDesktopNotification: options.showDesktopNotification,
|
|
||||||
createFieldGroupingCallback: options.createFieldGroupingCallback,
|
|
||||||
knownWordCacheStatePath: options.getKnownWordCacheStatePath(),
|
|
||||||
});
|
|
||||||
if (options.shouldStartAnkiIntegration?.() !== false) {
|
|
||||||
integration.start();
|
|
||||||
}
|
|
||||||
options.setAnkiIntegration(integration);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
options.updateVisibleOverlayVisibility();
|
const effectiveAnkiConfig = runtimeOptionsManager.getEffectiveAnkiConnectConfig(
|
||||||
|
config.ankiConnect,
|
||||||
|
);
|
||||||
|
const createAnkiIntegration = options.createAnkiIntegration ?? createDefaultAnkiIntegration;
|
||||||
|
const integration = createAnkiIntegration({
|
||||||
|
config: effectiveAnkiConfig,
|
||||||
|
aiConfig: mergeAiConfig(config.ai, config.ankiConnect?.ai),
|
||||||
|
subtitleTimingTracker,
|
||||||
|
mpvClient,
|
||||||
|
showDesktopNotification: options.showDesktopNotification,
|
||||||
|
createFieldGroupingCallback: options.createFieldGroupingCallback,
|
||||||
|
knownWordCacheStatePath: options.getKnownWordCacheStatePath(),
|
||||||
|
});
|
||||||
|
if (options.shouldStartAnkiIntegration?.() !== false) {
|
||||||
|
integration.start();
|
||||||
|
}
|
||||||
|
options.setAnkiIntegration(integration);
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -194,3 +194,93 @@ test('runAppReadyRuntime headless refresh bootstraps Anki runtime without UI sta
|
|||||||
'run-headless-command',
|
'run-headless-command',
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('runAppReadyRuntime loads Yomitan before auto-initializing overlay runtime', async () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
|
||||||
|
await runAppReadyRuntime({
|
||||||
|
ensureDefaultConfigBootstrap: () => {
|
||||||
|
calls.push('bootstrap');
|
||||||
|
},
|
||||||
|
loadSubtitlePosition: () => {
|
||||||
|
calls.push('load-subtitle-position');
|
||||||
|
},
|
||||||
|
resolveKeybindings: () => {
|
||||||
|
calls.push('resolve-keybindings');
|
||||||
|
},
|
||||||
|
createMpvClient: () => {
|
||||||
|
calls.push('create-mpv');
|
||||||
|
},
|
||||||
|
reloadConfig: () => {
|
||||||
|
calls.push('reload-config');
|
||||||
|
},
|
||||||
|
getResolvedConfig: () => ({
|
||||||
|
websocket: { enabled: false },
|
||||||
|
annotationWebsocket: { enabled: false },
|
||||||
|
texthooker: { launchAtStartup: false },
|
||||||
|
}),
|
||||||
|
getConfigWarnings: () => [],
|
||||||
|
logConfigWarning: () => {},
|
||||||
|
setLogLevel: () => {
|
||||||
|
calls.push('set-log-level');
|
||||||
|
},
|
||||||
|
initRuntimeOptionsManager: () => {
|
||||||
|
calls.push('init-runtime-options');
|
||||||
|
},
|
||||||
|
setSecondarySubMode: () => {
|
||||||
|
calls.push('set-secondary-sub-mode');
|
||||||
|
},
|
||||||
|
defaultSecondarySubMode: 'hover',
|
||||||
|
defaultWebsocketPort: 0,
|
||||||
|
defaultAnnotationWebsocketPort: 0,
|
||||||
|
defaultTexthookerPort: 0,
|
||||||
|
hasMpvWebsocketPlugin: () => false,
|
||||||
|
startSubtitleWebsocket: () => {
|
||||||
|
calls.push('subtitle-ws');
|
||||||
|
},
|
||||||
|
startAnnotationWebsocket: () => {
|
||||||
|
calls.push('annotation-ws');
|
||||||
|
},
|
||||||
|
startTexthooker: () => {
|
||||||
|
calls.push('texthooker');
|
||||||
|
},
|
||||||
|
log: () => {
|
||||||
|
calls.push('log');
|
||||||
|
},
|
||||||
|
createMecabTokenizerAndCheck: async () => {},
|
||||||
|
createSubtitleTimingTracker: () => {
|
||||||
|
calls.push('subtitle-timing');
|
||||||
|
},
|
||||||
|
createImmersionTracker: () => {
|
||||||
|
calls.push('immersion');
|
||||||
|
},
|
||||||
|
startJellyfinRemoteSession: async () => {},
|
||||||
|
loadYomitanExtension: async () => {
|
||||||
|
calls.push('load-yomitan');
|
||||||
|
},
|
||||||
|
handleFirstRunSetup: async () => {
|
||||||
|
calls.push('first-run');
|
||||||
|
},
|
||||||
|
prewarmSubtitleDictionaries: async () => {},
|
||||||
|
startBackgroundWarmups: () => {
|
||||||
|
calls.push('warmups');
|
||||||
|
},
|
||||||
|
texthookerOnlyMode: false,
|
||||||
|
shouldAutoInitializeOverlayRuntimeFromConfig: () => true,
|
||||||
|
setVisibleOverlayVisible: () => {
|
||||||
|
calls.push('visible-overlay');
|
||||||
|
},
|
||||||
|
initializeOverlayRuntime: () => {
|
||||||
|
calls.push('init-overlay');
|
||||||
|
},
|
||||||
|
handleInitialArgs: () => {
|
||||||
|
calls.push('handle-initial-args');
|
||||||
|
},
|
||||||
|
shouldUseMinimalStartup: () => false,
|
||||||
|
shouldSkipHeavyStartup: () => false,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.ok(calls.indexOf('load-yomitan') !== -1);
|
||||||
|
assert.ok(calls.indexOf('init-overlay') !== -1);
|
||||||
|
assert.ok(calls.indexOf('load-yomitan') < calls.indexOf('init-overlay'));
|
||||||
|
});
|
||||||
|
|||||||
@@ -290,13 +290,14 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<voi
|
|||||||
if (deps.texthookerOnlyMode) {
|
if (deps.texthookerOnlyMode) {
|
||||||
deps.log('Texthooker-only mode enabled; skipping overlay window.');
|
deps.log('Texthooker-only mode enabled; skipping overlay window.');
|
||||||
} else if (deps.shouldAutoInitializeOverlayRuntimeFromConfig()) {
|
} else if (deps.shouldAutoInitializeOverlayRuntimeFromConfig()) {
|
||||||
|
await deps.loadYomitanExtension();
|
||||||
deps.setVisibleOverlayVisible(true);
|
deps.setVisibleOverlayVisible(true);
|
||||||
deps.initializeOverlayRuntime();
|
deps.initializeOverlayRuntime();
|
||||||
} else {
|
} else {
|
||||||
deps.log('Overlay runtime deferred: waiting for explicit overlay command.');
|
deps.log('Overlay runtime deferred: waiting for explicit overlay command.');
|
||||||
|
await deps.loadYomitanExtension();
|
||||||
}
|
}
|
||||||
|
|
||||||
await deps.loadYomitanExtension();
|
|
||||||
await deps.handleFirstRunSetup();
|
await deps.handleFirstRunSetup();
|
||||||
deps.handleInitialArgs();
|
deps.handleInitialArgs();
|
||||||
deps.logDebug?.(`App-ready critical path finished in ${now() - startupStartedAtMs}ms.`);
|
deps.logDebug?.(`App-ready critical path finished in ${now() - startupStartedAtMs}ms.`);
|
||||||
|
|||||||
25
src/core/services/youtube/generate.ts
Normal file
25
src/core/services/youtube/generate.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import type { YoutubeFlowMode } from '../../../types';
|
||||||
|
import type { YoutubeTrackOption } from './track-probe';
|
||||||
|
import { downloadYoutubeSubtitleTrack, downloadYoutubeSubtitleTracks } from './track-download';
|
||||||
|
|
||||||
|
export function isYoutubeGenerationMode(mode: YoutubeFlowMode): boolean {
|
||||||
|
return mode === 'generate';
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function acquireYoutubeSubtitleTrack(input: {
|
||||||
|
targetUrl: string;
|
||||||
|
outputDir: string;
|
||||||
|
track: YoutubeTrackOption;
|
||||||
|
mode: YoutubeFlowMode;
|
||||||
|
}): Promise<{ path: string }> {
|
||||||
|
return await downloadYoutubeSubtitleTrack(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function acquireYoutubeSubtitleTracks(input: {
|
||||||
|
targetUrl: string;
|
||||||
|
outputDir: string;
|
||||||
|
tracks: YoutubeTrackOption[];
|
||||||
|
mode: YoutubeFlowMode;
|
||||||
|
}): Promise<Map<string, string>> {
|
||||||
|
return await downloadYoutubeSubtitleTracks(input);
|
||||||
|
}
|
||||||
40
src/core/services/youtube/labels.ts
Normal file
40
src/core/services/youtube/labels.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
export type YoutubeTrackKind = 'manual' | 'auto';
|
||||||
|
|
||||||
|
export function normalizeYoutubeLangCode(value: string): string {
|
||||||
|
return value.trim().toLowerCase().replace(/_/g, '-').replace(/[^a-z0-9-]+/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isJapaneseYoutubeLang(value: string): boolean {
|
||||||
|
const normalized = normalizeYoutubeLangCode(value);
|
||||||
|
return (
|
||||||
|
normalized === 'ja' ||
|
||||||
|
normalized === 'jp' ||
|
||||||
|
normalized === 'jpn' ||
|
||||||
|
normalized === 'japanese' ||
|
||||||
|
normalized.startsWith('ja-') ||
|
||||||
|
normalized.startsWith('jp-')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isEnglishYoutubeLang(value: string): boolean {
|
||||||
|
const normalized = normalizeYoutubeLangCode(value);
|
||||||
|
return (
|
||||||
|
normalized === 'en' ||
|
||||||
|
normalized === 'eng' ||
|
||||||
|
normalized === 'english' ||
|
||||||
|
normalized === 'enus' ||
|
||||||
|
normalized === 'en-us' ||
|
||||||
|
normalized.startsWith('en-')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatYoutubeTrackLabel(input: {
|
||||||
|
language: string;
|
||||||
|
kind: YoutubeTrackKind;
|
||||||
|
title?: string;
|
||||||
|
}): string {
|
||||||
|
const language = input.language.trim() || 'unknown';
|
||||||
|
const base = input.title?.trim() || language;
|
||||||
|
return `${base} (${input.kind})`;
|
||||||
|
}
|
||||||
|
|
||||||
33
src/core/services/youtube/retime.test.ts
Normal file
33
src/core/services/youtube/retime.test.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { retimeYoutubeSubtitle } from './retime';
|
||||||
|
|
||||||
|
test('retimeYoutubeSubtitle uses the downloaded subtitle path as-is', async () => {
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-youtube-retime-'));
|
||||||
|
try {
|
||||||
|
const primaryPath = path.join(root, 'primary.vtt');
|
||||||
|
const referencePath = path.join(root, 'reference.vtt');
|
||||||
|
fs.writeFileSync(primaryPath, 'WEBVTT\n', 'utf8');
|
||||||
|
fs.writeFileSync(referencePath, 'WEBVTT\n', 'utf8');
|
||||||
|
|
||||||
|
const result = await retimeYoutubeSubtitle({
|
||||||
|
primaryPath,
|
||||||
|
secondaryPath: referencePath,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.ok, true);
|
||||||
|
assert.equal(result.strategy, 'none');
|
||||||
|
assert.equal(result.path, primaryPath);
|
||||||
|
assert.equal(result.message, 'Using downloaded subtitle as-is (no automatic retime enabled)');
|
||||||
|
assert.equal(fs.readFileSync(result.path, 'utf8'), 'WEBVTT\n');
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
11
src/core/services/youtube/retime.ts
Normal file
11
src/core/services/youtube/retime.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
export async function retimeYoutubeSubtitle(input: {
|
||||||
|
primaryPath: string;
|
||||||
|
secondaryPath: string | null;
|
||||||
|
}): Promise<{ ok: boolean; path: string; strategy: 'none'; message: string }> {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
path: input.primaryPath,
|
||||||
|
strategy: 'none',
|
||||||
|
message: `Using downloaded subtitle as-is${input.secondaryPath ? ' (no automatic retime enabled)' : ''}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
89
src/core/services/youtube/timedtext.ts
Normal file
89
src/core/services/youtube/timedtext.ts
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
interface YoutubeTimedTextRow {
|
||||||
|
startMs: number;
|
||||||
|
durationMs: number;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const YOUTUBE_TIMEDTEXT_EXTENSIONS = new Set(['srv1', 'srv2', 'srv3', 'ytsrv3']);
|
||||||
|
|
||||||
|
function decodeHtmlEntities(value: string): string {
|
||||||
|
return value
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, "'")
|
||||||
|
.replace(/&#(\d+);/g, (_match, codePoint) => String.fromCodePoint(Number(codePoint)))
|
||||||
|
.replace(/&#x([0-9a-f]+);/gi, (_match, codePoint) =>
|
||||||
|
String.fromCodePoint(Number.parseInt(codePoint, 16)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseAttributeMap(raw: string): Map<string, string> {
|
||||||
|
const attrs = new Map<string, string>();
|
||||||
|
for (const match of raw.matchAll(/([a-zA-Z0-9:_-]+)="([^"]*)"/g)) {
|
||||||
|
attrs.set(match[1]!, match[2]!);
|
||||||
|
}
|
||||||
|
return attrs;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractYoutubeTimedTextRows(xml: string): YoutubeTimedTextRow[] {
|
||||||
|
const rows: YoutubeTimedTextRow[] = [];
|
||||||
|
|
||||||
|
for (const match of xml.matchAll(/<p\b([^>]*)>([\s\S]*?)<\/p>/g)) {
|
||||||
|
const attrs = parseAttributeMap(match[1] ?? '');
|
||||||
|
const startMs = Number(attrs.get('t'));
|
||||||
|
const durationMs = Number(attrs.get('d'));
|
||||||
|
if (!Number.isFinite(startMs) || !Number.isFinite(durationMs)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const inner = (match[2] ?? '')
|
||||||
|
.replace(/<br\s*\/?>/gi, '\n')
|
||||||
|
.replace(/<[^>]+>/g, '');
|
||||||
|
const text = decodeHtmlEntities(inner).trim();
|
||||||
|
if (!text) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
rows.push({ startMs, durationMs, text });
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatVttTimestamp(ms: number): string {
|
||||||
|
const totalMs = Math.max(0, Math.floor(ms));
|
||||||
|
const hours = Math.floor(totalMs / 3_600_000);
|
||||||
|
const minutes = Math.floor((totalMs % 3_600_000) / 60_000);
|
||||||
|
const seconds = Math.floor((totalMs % 60_000) / 1_000);
|
||||||
|
const millis = totalMs % 1_000;
|
||||||
|
return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}.${String(millis).padStart(3, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isYoutubeTimedTextExtension(value: string | undefined): boolean {
|
||||||
|
if (!value) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return YOUTUBE_TIMEDTEXT_EXTENSIONS.has(value.trim().toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function convertYoutubeTimedTextToVtt(xml: string): string {
|
||||||
|
const rows = extractYoutubeTimedTextRows(xml);
|
||||||
|
if (rows.length === 0) {
|
||||||
|
return 'WEBVTT\n';
|
||||||
|
}
|
||||||
|
|
||||||
|
const blocks = rows.map((row, index) => {
|
||||||
|
const nextRow = rows[index + 1];
|
||||||
|
const unclampedEnd = row.startMs + row.durationMs;
|
||||||
|
const clampedEnd =
|
||||||
|
nextRow && unclampedEnd > nextRow.startMs
|
||||||
|
? Math.max(row.startMs, nextRow.startMs - 1)
|
||||||
|
: unclampedEnd;
|
||||||
|
|
||||||
|
return `${formatVttTimestamp(row.startMs)} --> ${formatVttTimestamp(clampedEnd)}\n${row.text}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
return `WEBVTT\n\n${blocks.join('\n\n')}\n`;
|
||||||
|
}
|
||||||
472
src/core/services/youtube/track-download.test.ts
Normal file
472
src/core/services/youtube/track-download.test.ts
Normal file
@@ -0,0 +1,472 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { downloadYoutubeSubtitleTrack, downloadYoutubeSubtitleTracks } from './track-download';
|
||||||
|
|
||||||
|
async function withTempDir<T>(fn: (dir: string) => Promise<T>): Promise<T> {
|
||||||
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-youtube-track-download-'));
|
||||||
|
try {
|
||||||
|
return await fn(dir);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeFakeYtDlpScript(dir: string): string {
|
||||||
|
const scriptPath = path.join(dir, 'yt-dlp');
|
||||||
|
const script = `#!/usr/bin/env node
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
let outputTemplate = '';
|
||||||
|
const wantsAutoSubs = args.includes('--write-auto-subs');
|
||||||
|
const wantsManualSubs = args.includes('--write-subs');
|
||||||
|
const subLangIndex = args.indexOf('--sub-langs');
|
||||||
|
const subLang = subLangIndex >= 0 ? args[subLangIndex + 1] || '' : '';
|
||||||
|
const subLangs = subLang ? subLang.split(',').filter(Boolean) : [];
|
||||||
|
for (let i = 0; i < args.length; i += 1) {
|
||||||
|
if (args[i] === '-o' && typeof args[i + 1] === 'string') {
|
||||||
|
outputTemplate = args[i + 1];
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.env.YTDLP_EXPECT_AUTO_SUBS === '1' && !wantsAutoSubs) {
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
if (process.env.YTDLP_EXPECT_MANUAL_SUBS === '1' && !wantsManualSubs) {
|
||||||
|
process.exit(3);
|
||||||
|
}
|
||||||
|
if (process.env.YTDLP_EXPECT_SUB_LANG && subLang !== process.env.YTDLP_EXPECT_SUB_LANG) {
|
||||||
|
process.exit(4);
|
||||||
|
}
|
||||||
|
|
||||||
|
const prefix = outputTemplate.replace(/\.%\([^)]+\)s$/, '');
|
||||||
|
if (!prefix) {
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
fs.mkdirSync(path.dirname(prefix), { recursive: true });
|
||||||
|
|
||||||
|
if (process.env.YTDLP_FAKE_MODE === 'multi') {
|
||||||
|
for (const lang of subLangs) {
|
||||||
|
fs.writeFileSync(\`\${prefix}.\${lang}.vtt\`, 'WEBVTT\\n');
|
||||||
|
}
|
||||||
|
} else if (process.env.YTDLP_FAKE_MODE === 'rolling-auto') {
|
||||||
|
fs.writeFileSync(
|
||||||
|
\`\${prefix}.vtt\`,
|
||||||
|
[
|
||||||
|
'WEBVTT',
|
||||||
|
'',
|
||||||
|
'00:00:01.000 --> 00:00:02.000',
|
||||||
|
'今日は',
|
||||||
|
'',
|
||||||
|
'00:00:02.000 --> 00:00:03.000',
|
||||||
|
'今日はいい天気ですね',
|
||||||
|
'',
|
||||||
|
'00:00:03.000 --> 00:00:04.000',
|
||||||
|
'今日はいい天気ですね本当に',
|
||||||
|
'',
|
||||||
|
].join('\\n'),
|
||||||
|
);
|
||||||
|
} else if (process.env.YTDLP_FAKE_MODE === 'multi-primary-only-fail') {
|
||||||
|
const primaryLang = subLangs[0];
|
||||||
|
if (primaryLang) {
|
||||||
|
fs.writeFileSync(\`\${prefix}.\${primaryLang}.vtt\`, 'WEBVTT\\n');
|
||||||
|
}
|
||||||
|
process.stderr.write("ERROR: Unable to download video subtitles for 'en': HTTP Error 429: Too Many Requests\\n");
|
||||||
|
process.exit(1);
|
||||||
|
} else if (process.env.YTDLP_FAKE_MODE === 'both') {
|
||||||
|
fs.writeFileSync(\`\${prefix}.vtt\`, 'WEBVTT\\n');
|
||||||
|
fs.writeFileSync(\`\${prefix}.orig.webp\`, 'webp');
|
||||||
|
} else if (process.env.YTDLP_FAKE_MODE === 'webp-only') {
|
||||||
|
fs.writeFileSync(\`\${prefix}.orig.webp\`, 'webp');
|
||||||
|
} else {
|
||||||
|
fs.writeFileSync(\`\${prefix}.vtt\`, 'WEBVTT\\n');
|
||||||
|
}
|
||||||
|
process.exit(0);
|
||||||
|
`;
|
||||||
|
fs.writeFileSync(scriptPath, script, 'utf8');
|
||||||
|
fs.chmodSync(scriptPath, 0o755);
|
||||||
|
return scriptPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function withFakeYtDlp<T>(
|
||||||
|
mode: 'both' | 'webp-only' | 'multi' | 'multi-primary-only-fail' | 'rolling-auto',
|
||||||
|
fn: (dir: string, binDir: string) => Promise<T>,
|
||||||
|
): Promise<T> {
|
||||||
|
return await withTempDir(async (root) => {
|
||||||
|
const binDir = path.join(root, 'bin');
|
||||||
|
fs.mkdirSync(binDir, { recursive: true });
|
||||||
|
makeFakeYtDlpScript(binDir);
|
||||||
|
|
||||||
|
const originalPath = process.env.PATH ?? '';
|
||||||
|
process.env.PATH = `${binDir}${path.delimiter}${originalPath}`;
|
||||||
|
process.env.YTDLP_FAKE_MODE = mode;
|
||||||
|
try {
|
||||||
|
return await fn(root, binDir);
|
||||||
|
} finally {
|
||||||
|
process.env.PATH = originalPath;
|
||||||
|
delete process.env.YTDLP_FAKE_MODE;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function withFakeYtDlpExpectations<T>(
|
||||||
|
expectations: Partial<Record<'YTDLP_EXPECT_AUTO_SUBS' | 'YTDLP_EXPECT_MANUAL_SUBS' | 'YTDLP_EXPECT_SUB_LANG', string>>,
|
||||||
|
fn: () => Promise<T>,
|
||||||
|
): Promise<T> {
|
||||||
|
const previous = {
|
||||||
|
YTDLP_EXPECT_AUTO_SUBS: process.env.YTDLP_EXPECT_AUTO_SUBS,
|
||||||
|
YTDLP_EXPECT_MANUAL_SUBS: process.env.YTDLP_EXPECT_MANUAL_SUBS,
|
||||||
|
YTDLP_EXPECT_SUB_LANG: process.env.YTDLP_EXPECT_SUB_LANG,
|
||||||
|
};
|
||||||
|
Object.assign(process.env, expectations);
|
||||||
|
try {
|
||||||
|
return await fn();
|
||||||
|
} finally {
|
||||||
|
for (const [key, value] of Object.entries(previous)) {
|
||||||
|
if (value === undefined) {
|
||||||
|
delete process.env[key];
|
||||||
|
} else {
|
||||||
|
process.env[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function withStubFetch<T>(
|
||||||
|
handler: (url: string) => Promise<Response> | Response,
|
||||||
|
fn: () => Promise<T>,
|
||||||
|
): Promise<T> {
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
globalThis.fetch = (async (input: string | URL | Request) => {
|
||||||
|
const url =
|
||||||
|
typeof input === 'string'
|
||||||
|
? input
|
||||||
|
: input instanceof URL
|
||||||
|
? input.toString()
|
||||||
|
: input.url;
|
||||||
|
return await handler(url);
|
||||||
|
}) as typeof fetch;
|
||||||
|
try {
|
||||||
|
return await fn();
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test('downloadYoutubeSubtitleTrack prefers subtitle files over later webp artifacts', async () => {
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await withFakeYtDlp('both', async (root) => {
|
||||||
|
const result = await downloadYoutubeSubtitleTrack({
|
||||||
|
targetUrl: 'https://www.youtube.com/watch?v=abc123',
|
||||||
|
outputDir: path.join(root, 'out'),
|
||||||
|
track: {
|
||||||
|
id: 'auto:ja-orig',
|
||||||
|
language: 'ja',
|
||||||
|
sourceLanguage: 'ja-orig',
|
||||||
|
kind: 'auto',
|
||||||
|
label: 'Japanese (auto)',
|
||||||
|
},
|
||||||
|
mode: 'download',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(path.extname(result.path), '.vtt');
|
||||||
|
assert.match(path.basename(result.path), /^auto-ja-orig\./);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('downloadYoutubeSubtitleTrack ignores stale subtitle files from prior runs', async () => {
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await withFakeYtDlp('webp-only', async (root) => {
|
||||||
|
const outputDir = path.join(root, 'out');
|
||||||
|
fs.mkdirSync(outputDir, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(outputDir, 'auto-ja.vtt'), 'stale subtitle');
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
async () =>
|
||||||
|
await downloadYoutubeSubtitleTrack({
|
||||||
|
targetUrl: 'https://www.youtube.com/watch?v=abc123',
|
||||||
|
outputDir,
|
||||||
|
track: {
|
||||||
|
id: 'auto:ja',
|
||||||
|
language: 'ja',
|
||||||
|
sourceLanguage: 'ja',
|
||||||
|
kind: 'auto',
|
||||||
|
label: 'Japanese (auto)',
|
||||||
|
},
|
||||||
|
mode: 'download',
|
||||||
|
}),
|
||||||
|
/No subtitle file was downloaded/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('downloadYoutubeSubtitleTrack uses auto subtitle flags and raw source language for auto tracks', async () => {
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await withFakeYtDlp('both', async (root) => {
|
||||||
|
await withFakeYtDlpExpectations(
|
||||||
|
{
|
||||||
|
YTDLP_EXPECT_AUTO_SUBS: '1',
|
||||||
|
YTDLP_EXPECT_SUB_LANG: 'ja-orig',
|
||||||
|
},
|
||||||
|
async () => {
|
||||||
|
const result = await downloadYoutubeSubtitleTrack({
|
||||||
|
targetUrl: 'https://www.youtube.com/watch?v=abc123',
|
||||||
|
outputDir: path.join(root, 'out'),
|
||||||
|
track: {
|
||||||
|
id: 'auto:ja-orig',
|
||||||
|
language: 'ja',
|
||||||
|
sourceLanguage: 'ja-orig',
|
||||||
|
kind: 'auto',
|
||||||
|
label: 'Japanese (auto)',
|
||||||
|
},
|
||||||
|
mode: 'download',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(path.extname(result.path), '.vtt');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('downloadYoutubeSubtitleTrack keeps manual subtitle flag for manual tracks', async () => {
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await withFakeYtDlp('both', async (root) => {
|
||||||
|
await withFakeYtDlpExpectations(
|
||||||
|
{
|
||||||
|
YTDLP_EXPECT_MANUAL_SUBS: '1',
|
||||||
|
YTDLP_EXPECT_SUB_LANG: 'ja',
|
||||||
|
},
|
||||||
|
async () => {
|
||||||
|
const result = await downloadYoutubeSubtitleTrack({
|
||||||
|
targetUrl: 'https://www.youtube.com/watch?v=abc123',
|
||||||
|
outputDir: path.join(root, 'out'),
|
||||||
|
track: {
|
||||||
|
id: 'manual:ja',
|
||||||
|
language: 'ja',
|
||||||
|
sourceLanguage: 'ja',
|
||||||
|
kind: 'manual',
|
||||||
|
label: 'Japanese (manual)',
|
||||||
|
},
|
||||||
|
mode: 'download',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(path.extname(result.path), '.vtt');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('downloadYoutubeSubtitleTrack prefers direct download URL when available', async () => {
|
||||||
|
await withTempDir(async (root) => {
|
||||||
|
await withStubFetch(
|
||||||
|
async (url) => {
|
||||||
|
assert.equal(url, 'https://example.com/subs/ja.vtt');
|
||||||
|
return new Response('WEBVTT\n', { status: 200 });
|
||||||
|
},
|
||||||
|
async () => {
|
||||||
|
const result = await downloadYoutubeSubtitleTrack({
|
||||||
|
targetUrl: 'https://www.youtube.com/watch?v=abc123',
|
||||||
|
outputDir: path.join(root, 'out'),
|
||||||
|
track: {
|
||||||
|
id: 'auto:ja-orig',
|
||||||
|
language: 'ja',
|
||||||
|
sourceLanguage: 'ja-orig',
|
||||||
|
kind: 'auto',
|
||||||
|
label: 'Japanese (auto)',
|
||||||
|
downloadUrl: 'https://example.com/subs/ja.vtt',
|
||||||
|
fileExtension: 'vtt',
|
||||||
|
},
|
||||||
|
mode: 'download',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(path.basename(result.path), 'auto-ja-orig.ja-orig.vtt');
|
||||||
|
assert.equal(fs.readFileSync(result.path, 'utf8'), 'WEBVTT\n');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('downloadYoutubeSubtitleTrack converts srv3 auto subtitles into regular vtt', async () => {
|
||||||
|
await withTempDir(async (root) => {
|
||||||
|
await withStubFetch(
|
||||||
|
async (url) => {
|
||||||
|
assert.equal(url, 'https://example.com/subs/ja.srv3');
|
||||||
|
return new Response(
|
||||||
|
[
|
||||||
|
'<timedtext><body>',
|
||||||
|
'<p t="1000" d="2500">今日は</p>',
|
||||||
|
'<p t="2000" d="2500">今日はいい天気ですね</p>',
|
||||||
|
'<p t="3500" d="2500">今日はいい天気ですね本当に</p>',
|
||||||
|
'</body></timedtext>',
|
||||||
|
].join(''),
|
||||||
|
{ status: 200 },
|
||||||
|
);
|
||||||
|
},
|
||||||
|
async () => {
|
||||||
|
const result = await downloadYoutubeSubtitleTrack({
|
||||||
|
targetUrl: 'https://www.youtube.com/watch?v=abc123',
|
||||||
|
outputDir: path.join(root, 'out'),
|
||||||
|
track: {
|
||||||
|
id: 'auto:ja-orig',
|
||||||
|
language: 'ja',
|
||||||
|
sourceLanguage: 'ja-orig',
|
||||||
|
kind: 'auto',
|
||||||
|
label: 'Japanese (auto)',
|
||||||
|
downloadUrl: 'https://example.com/subs/ja.srv3',
|
||||||
|
fileExtension: 'srv3',
|
||||||
|
},
|
||||||
|
mode: 'download',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(path.basename(result.path), 'auto-ja-orig.ja-orig.vtt');
|
||||||
|
assert.equal(
|
||||||
|
fs.readFileSync(result.path, 'utf8'),
|
||||||
|
[
|
||||||
|
'WEBVTT',
|
||||||
|
'',
|
||||||
|
'00:00:01.000 --> 00:00:01.999',
|
||||||
|
'今日は',
|
||||||
|
'',
|
||||||
|
'00:00:02.000 --> 00:00:03.499',
|
||||||
|
'いい天気ですね',
|
||||||
|
'',
|
||||||
|
'00:00:03.500 --> 00:00:06.000',
|
||||||
|
'本当に',
|
||||||
|
'',
|
||||||
|
].join('\n'),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('downloadYoutubeSubtitleTracks downloads primary and secondary in one invocation', async () => {
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await withFakeYtDlp('multi', async (root) => {
|
||||||
|
const outputDir = path.join(root, 'out');
|
||||||
|
const result = await downloadYoutubeSubtitleTracks({
|
||||||
|
targetUrl: 'https://www.youtube.com/watch?v=abc123',
|
||||||
|
outputDir,
|
||||||
|
tracks: [
|
||||||
|
{
|
||||||
|
id: 'auto:ja-orig',
|
||||||
|
language: 'ja',
|
||||||
|
sourceLanguage: 'ja-orig',
|
||||||
|
kind: 'auto',
|
||||||
|
label: 'Japanese (auto)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'auto:en',
|
||||||
|
language: 'en',
|
||||||
|
sourceLanguage: 'en',
|
||||||
|
kind: 'auto',
|
||||||
|
label: 'English (auto)',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
mode: 'download',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.match(path.basename(result.get('auto:ja-orig') ?? ''), /\.ja-orig\.vtt$/);
|
||||||
|
assert.match(path.basename(result.get('auto:en') ?? ''), /\.en\.vtt$/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('downloadYoutubeSubtitleTracks preserves successfully downloaded primary file on partial failure', async () => {
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await withFakeYtDlp('multi-primary-only-fail', async (root) => {
|
||||||
|
const outputDir = path.join(root, 'out');
|
||||||
|
const result = await downloadYoutubeSubtitleTracks({
|
||||||
|
targetUrl: 'https://www.youtube.com/watch?v=abc123',
|
||||||
|
outputDir,
|
||||||
|
tracks: [
|
||||||
|
{
|
||||||
|
id: 'auto:ja-orig',
|
||||||
|
language: 'ja',
|
||||||
|
sourceLanguage: 'ja-orig',
|
||||||
|
kind: 'auto',
|
||||||
|
label: 'Japanese (auto)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'auto:en',
|
||||||
|
language: 'en',
|
||||||
|
sourceLanguage: 'en',
|
||||||
|
kind: 'auto',
|
||||||
|
label: 'English (auto)',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
mode: 'download',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.match(path.basename(result.get('auto:ja-orig') ?? ''), /\.ja-orig\.vtt$/);
|
||||||
|
assert.equal(result.has('auto:en'), false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('downloadYoutubeSubtitleTracks prefers direct download URLs when available', async () => {
|
||||||
|
await withTempDir(async (root) => {
|
||||||
|
const seen: string[] = [];
|
||||||
|
await withStubFetch(
|
||||||
|
async (url) => {
|
||||||
|
seen.push(url);
|
||||||
|
return new Response(`WEBVTT\n${url}\n`, { status: 200 });
|
||||||
|
},
|
||||||
|
async () => {
|
||||||
|
const result = await downloadYoutubeSubtitleTracks({
|
||||||
|
targetUrl: 'https://www.youtube.com/watch?v=abc123',
|
||||||
|
outputDir: path.join(root, 'out'),
|
||||||
|
tracks: [
|
||||||
|
{
|
||||||
|
id: 'auto:ja-orig',
|
||||||
|
language: 'ja',
|
||||||
|
sourceLanguage: 'ja-orig',
|
||||||
|
kind: 'auto',
|
||||||
|
label: 'Japanese (auto)',
|
||||||
|
downloadUrl: 'https://example.com/subs/ja.vtt',
|
||||||
|
fileExtension: 'vtt',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'auto:en',
|
||||||
|
language: 'en',
|
||||||
|
sourceLanguage: 'en',
|
||||||
|
kind: 'auto',
|
||||||
|
label: 'English (auto)',
|
||||||
|
downloadUrl: 'https://example.com/subs/en.vtt',
|
||||||
|
fileExtension: 'vtt',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
mode: 'download',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(seen, [
|
||||||
|
'https://example.com/subs/ja.vtt',
|
||||||
|
'https://example.com/subs/en.vtt',
|
||||||
|
]);
|
||||||
|
assert.match(path.basename(result.get('auto:ja-orig') ?? ''), /\.ja-orig\.vtt$/);
|
||||||
|
assert.match(path.basename(result.get('auto:en') ?? ''), /\.en\.vtt$/);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
256
src/core/services/youtube/track-download.ts
Normal file
256
src/core/services/youtube/track-download.ts
Normal file
@@ -0,0 +1,256 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { spawn } from 'node:child_process';
|
||||||
|
import type { YoutubeFlowMode } from '../../../types';
|
||||||
|
import type { YoutubeTrackOption } from './track-probe';
|
||||||
|
import { convertYoutubeTimedTextToVtt, isYoutubeTimedTextExtension } from './timedtext';
|
||||||
|
|
||||||
|
const YOUTUBE_SUBTITLE_EXTENSIONS = new Set(['.srt', '.vtt', '.ass']);
|
||||||
|
const YOUTUBE_BATCH_PREFIX = 'youtube-batch';
|
||||||
|
|
||||||
|
function runCapture(command: string, args: string[]): Promise<{ stdout: string; stderr: string }> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const proc = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||||
|
let stdout = '';
|
||||||
|
let stderr = '';
|
||||||
|
proc.stdout.setEncoding('utf8');
|
||||||
|
proc.stderr.setEncoding('utf8');
|
||||||
|
proc.stdout.on('data', (chunk) => {
|
||||||
|
stdout += String(chunk);
|
||||||
|
});
|
||||||
|
proc.stderr.on('data', (chunk) => {
|
||||||
|
stderr += String(chunk);
|
||||||
|
});
|
||||||
|
proc.once('error', reject);
|
||||||
|
proc.once('close', (code) => {
|
||||||
|
if (code === 0) {
|
||||||
|
resolve({ stdout, stderr });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
reject(new Error(stderr.trim() || `yt-dlp exited with status ${code ?? 'unknown'}`));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function runCaptureDetailed(
|
||||||
|
command: string,
|
||||||
|
args: string[],
|
||||||
|
): Promise<{ stdout: string; stderr: string; code: number }> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const proc = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||||
|
let stdout = '';
|
||||||
|
let stderr = '';
|
||||||
|
proc.stdout.setEncoding('utf8');
|
||||||
|
proc.stderr.setEncoding('utf8');
|
||||||
|
proc.stdout.on('data', (chunk) => {
|
||||||
|
stdout += String(chunk);
|
||||||
|
});
|
||||||
|
proc.stderr.on('data', (chunk) => {
|
||||||
|
stderr += String(chunk);
|
||||||
|
});
|
||||||
|
proc.once('error', reject);
|
||||||
|
proc.once('close', (code) => {
|
||||||
|
resolve({ stdout, stderr, code: code ?? 1 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickLatestSubtitleFile(dir: string, prefix: string): string | null {
|
||||||
|
const entries = fs.readdirSync(dir).map((name) => path.join(dir, name));
|
||||||
|
const candidates = entries.filter((candidate) => {
|
||||||
|
const basename = path.basename(candidate);
|
||||||
|
const ext = path.extname(basename).toLowerCase();
|
||||||
|
return basename.startsWith(prefix) && YOUTUBE_SUBTITLE_EXTENSIONS.has(ext);
|
||||||
|
});
|
||||||
|
candidates.sort((a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs);
|
||||||
|
return candidates[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickLatestSubtitleFileForLanguage(
|
||||||
|
dir: string,
|
||||||
|
prefix: string,
|
||||||
|
sourceLanguage: string,
|
||||||
|
): string | null {
|
||||||
|
const entries = fs.readdirSync(dir).map((name) => path.join(dir, name));
|
||||||
|
const candidates = entries.filter((candidate) => {
|
||||||
|
const basename = path.basename(candidate);
|
||||||
|
const ext = path.extname(basename).toLowerCase();
|
||||||
|
return (
|
||||||
|
basename.startsWith(`${prefix}.`) &&
|
||||||
|
basename.includes(`.${sourceLanguage}.`) &&
|
||||||
|
YOUTUBE_SUBTITLE_EXTENSIONS.has(ext)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
candidates.sort((a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs);
|
||||||
|
return candidates[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDownloadArgs(input: {
|
||||||
|
targetUrl: string;
|
||||||
|
outputTemplate: string;
|
||||||
|
sourceLanguages: string[];
|
||||||
|
includeAutoSubs: boolean;
|
||||||
|
includeManualSubs: boolean;
|
||||||
|
}): string[] {
|
||||||
|
const args = ['--skip-download', '--no-warnings'];
|
||||||
|
if (input.includeAutoSubs) {
|
||||||
|
args.push('--write-auto-subs');
|
||||||
|
}
|
||||||
|
if (input.includeManualSubs) {
|
||||||
|
args.push('--write-subs');
|
||||||
|
}
|
||||||
|
args.push(
|
||||||
|
'--sub-format',
|
||||||
|
'srt/vtt/best',
|
||||||
|
'--sub-langs',
|
||||||
|
input.sourceLanguages.join(','),
|
||||||
|
'-o',
|
||||||
|
input.outputTemplate,
|
||||||
|
input.targetUrl,
|
||||||
|
);
|
||||||
|
return args;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadSubtitleFromUrl(input: {
|
||||||
|
outputDir: string;
|
||||||
|
prefix: string;
|
||||||
|
track: YoutubeTrackOption;
|
||||||
|
}): Promise<{ path: string }> {
|
||||||
|
if (!input.track.downloadUrl) {
|
||||||
|
throw new Error(`No direct subtitle URL available for ${input.track.sourceLanguage}`);
|
||||||
|
}
|
||||||
|
const ext = (input.track.fileExtension?.trim().toLowerCase() || 'vtt').replace(/[^a-z0-9]+/g, '');
|
||||||
|
const safeExt = isYoutubeTimedTextExtension(ext)
|
||||||
|
? 'vtt'
|
||||||
|
: YOUTUBE_SUBTITLE_EXTENSIONS.has(`.${ext}`)
|
||||||
|
? ext
|
||||||
|
: 'vtt';
|
||||||
|
const targetPath = path.join(input.outputDir, `${input.prefix}.${input.track.sourceLanguage}.${safeExt}`);
|
||||||
|
const response = await fetch(input.track.downloadUrl);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP ${response.status} while downloading ${input.track.sourceLanguage}`);
|
||||||
|
}
|
||||||
|
const body = await response.text();
|
||||||
|
const normalizedBody = isYoutubeTimedTextExtension(ext) ? convertYoutubeTimedTextToVtt(body) : body;
|
||||||
|
fs.writeFileSync(targetPath, normalizedBody, 'utf8');
|
||||||
|
return { path: targetPath };
|
||||||
|
}
|
||||||
|
|
||||||
|
function canDownloadSubtitleFromUrl(track: YoutubeTrackOption): boolean {
|
||||||
|
if (!track.downloadUrl) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ext = (track.fileExtension?.trim().toLowerCase() || 'vtt').replace(/[^a-z0-9]+/g, '');
|
||||||
|
return isYoutubeTimedTextExtension(ext) || YOUTUBE_SUBTITLE_EXTENSIONS.has(`.${ext}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function downloadYoutubeSubtitleTrack(input: {
|
||||||
|
targetUrl: string;
|
||||||
|
outputDir: string;
|
||||||
|
track: YoutubeTrackOption;
|
||||||
|
mode: YoutubeFlowMode;
|
||||||
|
}): Promise<{ path: string }> {
|
||||||
|
fs.mkdirSync(input.outputDir, { recursive: true });
|
||||||
|
const prefix = input.track.id.replace(/[^a-z0-9_-]+/gi, '-');
|
||||||
|
for (const name of fs.readdirSync(input.outputDir)) {
|
||||||
|
if (name.startsWith(prefix)) {
|
||||||
|
try {
|
||||||
|
fs.rmSync(path.join(input.outputDir, name), { force: true });
|
||||||
|
} catch {
|
||||||
|
// ignore stale files
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (canDownloadSubtitleFromUrl(input.track)) {
|
||||||
|
return await downloadSubtitleFromUrl({
|
||||||
|
outputDir: input.outputDir,
|
||||||
|
prefix,
|
||||||
|
track: input.track,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const outputTemplate = path.join(input.outputDir, `${prefix}.%(ext)s`);
|
||||||
|
const args = [
|
||||||
|
...buildDownloadArgs({
|
||||||
|
targetUrl: input.targetUrl,
|
||||||
|
outputTemplate,
|
||||||
|
sourceLanguages: [input.track.sourceLanguage],
|
||||||
|
includeAutoSubs: input.mode === 'generate' || input.track.kind === 'auto',
|
||||||
|
includeManualSubs: input.track.kind === 'manual',
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
await runCapture('yt-dlp', args);
|
||||||
|
const subtitlePath = pickLatestSubtitleFile(input.outputDir, prefix);
|
||||||
|
if (!subtitlePath) {
|
||||||
|
throw new Error(`No subtitle file was downloaded for ${input.track.sourceLanguage}`);
|
||||||
|
}
|
||||||
|
return { path: subtitlePath };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function downloadYoutubeSubtitleTracks(input: {
|
||||||
|
targetUrl: string;
|
||||||
|
outputDir: string;
|
||||||
|
tracks: YoutubeTrackOption[];
|
||||||
|
mode: YoutubeFlowMode;
|
||||||
|
}): Promise<Map<string, string>> {
|
||||||
|
fs.mkdirSync(input.outputDir, { recursive: true });
|
||||||
|
for (const name of fs.readdirSync(input.outputDir)) {
|
||||||
|
if (name.startsWith(`${YOUTUBE_BATCH_PREFIX}.`)) {
|
||||||
|
try {
|
||||||
|
fs.rmSync(path.join(input.outputDir, name), { force: true });
|
||||||
|
} catch {
|
||||||
|
// ignore stale files
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (input.tracks.every(canDownloadSubtitleFromUrl)) {
|
||||||
|
const results = new Map<string, string>();
|
||||||
|
for (const track of input.tracks) {
|
||||||
|
const download = await downloadSubtitleFromUrl({
|
||||||
|
outputDir: input.outputDir,
|
||||||
|
prefix: YOUTUBE_BATCH_PREFIX,
|
||||||
|
track,
|
||||||
|
});
|
||||||
|
results.set(track.id, download.path);
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
const outputTemplate = path.join(input.outputDir, `${YOUTUBE_BATCH_PREFIX}.%(ext)s`);
|
||||||
|
const includeAutoSubs =
|
||||||
|
input.mode === 'generate' || input.tracks.some((track) => track.kind === 'auto');
|
||||||
|
const includeManualSubs = input.tracks.some((track) => track.kind === 'manual');
|
||||||
|
|
||||||
|
const result = await runCaptureDetailed(
|
||||||
|
'yt-dlp',
|
||||||
|
buildDownloadArgs({
|
||||||
|
targetUrl: input.targetUrl,
|
||||||
|
outputTemplate,
|
||||||
|
sourceLanguages: input.tracks.map((track) => track.sourceLanguage),
|
||||||
|
includeAutoSubs,
|
||||||
|
includeManualSubs,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const results = new Map<string, string>();
|
||||||
|
for (const track of input.tracks) {
|
||||||
|
const subtitlePath = pickLatestSubtitleFileForLanguage(
|
||||||
|
input.outputDir,
|
||||||
|
YOUTUBE_BATCH_PREFIX,
|
||||||
|
track.sourceLanguage,
|
||||||
|
);
|
||||||
|
if (subtitlePath) {
|
||||||
|
results.set(track.id, subtitlePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (results.size > 0) {
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
if (result.code !== 0) {
|
||||||
|
throw new Error(result.stderr.trim() || `yt-dlp exited with status ${result.code}`);
|
||||||
|
}
|
||||||
|
throw new Error(
|
||||||
|
`No subtitle file was downloaded for ${input.tracks.map((track) => track.sourceLanguage).join(',')}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
80
src/core/services/youtube/track-probe.test.ts
Normal file
80
src/core/services/youtube/track-probe.test.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { probeYoutubeTracks } from './track-probe';
|
||||||
|
|
||||||
|
async function withTempDir<T>(fn: (dir: string) => Promise<T>): Promise<T> {
|
||||||
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-youtube-track-probe-'));
|
||||||
|
try {
|
||||||
|
return await fn(dir);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeFakeYtDlpScript(dir: string, payload: unknown): void {
|
||||||
|
const scriptPath = path.join(dir, 'yt-dlp');
|
||||||
|
const script = `#!/usr/bin/env node
|
||||||
|
process.stdout.write(${JSON.stringify(JSON.stringify(payload))});
|
||||||
|
`;
|
||||||
|
fs.writeFileSync(scriptPath, script, 'utf8');
|
||||||
|
fs.chmodSync(scriptPath, 0o755);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function withFakeYtDlp<T>(payload: unknown, fn: () => Promise<T>): Promise<T> {
|
||||||
|
return await withTempDir(async (root) => {
|
||||||
|
const binDir = path.join(root, 'bin');
|
||||||
|
fs.mkdirSync(binDir, { recursive: true });
|
||||||
|
makeFakeYtDlpScript(binDir, payload);
|
||||||
|
const originalPath = process.env.PATH ?? '';
|
||||||
|
process.env.PATH = `${binDir}${path.delimiter}${originalPath}`;
|
||||||
|
try {
|
||||||
|
return await fn();
|
||||||
|
} finally {
|
||||||
|
process.env.PATH = originalPath;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('probeYoutubeTracks prefers srv3 over vtt for automatic captions', async () => {
|
||||||
|
await withFakeYtDlp(
|
||||||
|
{
|
||||||
|
id: 'abc123',
|
||||||
|
title: 'Example',
|
||||||
|
automatic_captions: {
|
||||||
|
'ja-orig': [
|
||||||
|
{ ext: 'vtt', url: 'https://example.com/ja.vtt', name: 'Japanese auto' },
|
||||||
|
{ ext: 'srv3', url: 'https://example.com/ja.srv3', name: 'Japanese auto' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async () => {
|
||||||
|
const result = await probeYoutubeTracks('https://www.youtube.com/watch?v=abc123');
|
||||||
|
assert.equal(result.videoId, 'abc123');
|
||||||
|
assert.equal(result.tracks[0]?.downloadUrl, 'https://example.com/ja.srv3');
|
||||||
|
assert.equal(result.tracks[0]?.fileExtension, 'srv3');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('probeYoutubeTracks keeps preferring srt for manual captions', async () => {
|
||||||
|
await withFakeYtDlp(
|
||||||
|
{
|
||||||
|
id: 'abc123',
|
||||||
|
title: 'Example',
|
||||||
|
subtitles: {
|
||||||
|
ja: [
|
||||||
|
{ ext: 'srv3', url: 'https://example.com/ja.srv3', name: 'Japanese manual' },
|
||||||
|
{ ext: 'srt', url: 'https://example.com/ja.srt', name: 'Japanese manual' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async () => {
|
||||||
|
const result = await probeYoutubeTracks('https://www.youtube.com/watch?v=abc123');
|
||||||
|
assert.equal(result.tracks[0]?.downloadUrl, 'https://example.com/ja.srt');
|
||||||
|
assert.equal(result.tracks[0]?.fileExtension, 'srt');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
112
src/core/services/youtube/track-probe.ts
Normal file
112
src/core/services/youtube/track-probe.ts
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import { spawn } from 'node:child_process';
|
||||||
|
import type { YoutubeTrackOption } from '../../../types';
|
||||||
|
import {
|
||||||
|
formatYoutubeTrackLabel,
|
||||||
|
normalizeYoutubeLangCode,
|
||||||
|
type YoutubeTrackKind,
|
||||||
|
} from './labels';
|
||||||
|
|
||||||
|
export type YoutubeTrackProbeResult = {
|
||||||
|
videoId: string;
|
||||||
|
title: string;
|
||||||
|
tracks: YoutubeTrackOption[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type YtDlpSubtitleEntry = Array<{ ext?: string; name?: string; url?: string }>;
|
||||||
|
|
||||||
|
type YtDlpInfo = {
|
||||||
|
id?: string;
|
||||||
|
title?: string;
|
||||||
|
subtitles?: Record<string, YtDlpSubtitleEntry>;
|
||||||
|
automatic_captions?: Record<string, YtDlpSubtitleEntry>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function runCapture(command: string, args: string[]): Promise<{ stdout: string; stderr: string }> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const proc = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||||
|
let stdout = '';
|
||||||
|
let stderr = '';
|
||||||
|
proc.stdout.setEncoding('utf8');
|
||||||
|
proc.stderr.setEncoding('utf8');
|
||||||
|
proc.stdout.on('data', (chunk) => {
|
||||||
|
stdout += String(chunk);
|
||||||
|
});
|
||||||
|
proc.stderr.on('data', (chunk) => {
|
||||||
|
stderr += String(chunk);
|
||||||
|
});
|
||||||
|
proc.once('error', reject);
|
||||||
|
proc.once('close', (code) => {
|
||||||
|
if (code === 0) {
|
||||||
|
resolve({ stdout, stderr });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
reject(new Error(stderr.trim() || `yt-dlp exited with status ${code ?? 'unknown'}`));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function choosePreferredFormat(
|
||||||
|
formats: YtDlpSubtitleEntry,
|
||||||
|
kind: YoutubeTrackKind,
|
||||||
|
): { ext: string; url: string; title?: string } | null {
|
||||||
|
const preferredOrder =
|
||||||
|
kind === 'auto'
|
||||||
|
? ['srv3', 'srv2', 'srv1', 'vtt', 'srt', 'ttml', 'json3']
|
||||||
|
: ['srt', 'vtt', 'srv3', 'srv2', 'srv1', 'ttml', 'json3'];
|
||||||
|
for (const ext of preferredOrder) {
|
||||||
|
const match = formats.find(
|
||||||
|
(format) => typeof format.url === 'string' && format.url && format.ext === ext,
|
||||||
|
);
|
||||||
|
if (match?.url) {
|
||||||
|
return { ext, url: match.url, title: match.name?.trim() || undefined };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fallback = formats.find((format) => typeof format.url === 'string' && format.url);
|
||||||
|
if (!fallback?.url) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ext: fallback.ext?.trim() || 'vtt',
|
||||||
|
url: fallback.url,
|
||||||
|
title: fallback.name?.trim() || undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toTracks(entries: Record<string, YtDlpSubtitleEntry> | undefined, kind: YoutubeTrackKind) {
|
||||||
|
const tracks: YoutubeTrackOption[] = [];
|
||||||
|
if (!entries) return tracks;
|
||||||
|
for (const [language, formats] of Object.entries(entries)) {
|
||||||
|
if (!Array.isArray(formats) || formats.length === 0) continue;
|
||||||
|
const preferredFormat = choosePreferredFormat(formats, kind);
|
||||||
|
if (!preferredFormat) continue;
|
||||||
|
const sourceLanguage = language.trim() || language;
|
||||||
|
const normalizedLanguage = normalizeYoutubeLangCode(sourceLanguage) || sourceLanguage;
|
||||||
|
const title = preferredFormat.title;
|
||||||
|
tracks.push({
|
||||||
|
id: `${kind}:${sourceLanguage}`,
|
||||||
|
language: normalizedLanguage,
|
||||||
|
sourceLanguage,
|
||||||
|
kind,
|
||||||
|
title,
|
||||||
|
label: formatYoutubeTrackLabel({ language: normalizedLanguage, kind, title }),
|
||||||
|
downloadUrl: preferredFormat.url,
|
||||||
|
fileExtension: preferredFormat.ext,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return tracks;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { YoutubeTrackOption };
|
||||||
|
|
||||||
|
export async function probeYoutubeTracks(targetUrl: string): Promise<YoutubeTrackProbeResult> {
|
||||||
|
const { stdout } = await runCapture('yt-dlp', ['--dump-single-json', '--no-warnings', targetUrl]);
|
||||||
|
const info = JSON.parse(stdout) as YtDlpInfo;
|
||||||
|
const tracks = [...toTracks(info.subtitles, 'manual'), ...toTracks(info.automatic_captions, 'auto')];
|
||||||
|
return {
|
||||||
|
videoId: info.id || '',
|
||||||
|
title: info.title || '',
|
||||||
|
tracks,
|
||||||
|
};
|
||||||
|
}
|
||||||
63
src/core/services/youtube/track-selection.ts
Normal file
63
src/core/services/youtube/track-selection.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import { isEnglishYoutubeLang, isJapaneseYoutubeLang } from './labels';
|
||||||
|
import type { YoutubeTrackOption } from './track-probe';
|
||||||
|
|
||||||
|
function pickTrack(
|
||||||
|
tracks: YoutubeTrackOption[],
|
||||||
|
matcher: (value: string) => boolean,
|
||||||
|
excludeId?: string,
|
||||||
|
): YoutubeTrackOption | null {
|
||||||
|
const matching = tracks.filter((track) => matcher(track.language) && track.id !== excludeId);
|
||||||
|
return matching[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function chooseDefaultYoutubeTrackIds(
|
||||||
|
tracks: YoutubeTrackOption[],
|
||||||
|
): { primaryTrackId: string | null; secondaryTrackId: string | null } {
|
||||||
|
const primary =
|
||||||
|
pickTrack(
|
||||||
|
tracks.filter((track) => track.kind === 'manual'),
|
||||||
|
isJapaneseYoutubeLang,
|
||||||
|
) ||
|
||||||
|
pickTrack(
|
||||||
|
tracks.filter((track) => track.kind === 'auto'),
|
||||||
|
isJapaneseYoutubeLang,
|
||||||
|
) ||
|
||||||
|
tracks.find((track) => track.kind === 'manual') ||
|
||||||
|
tracks[0] ||
|
||||||
|
null;
|
||||||
|
|
||||||
|
const secondary =
|
||||||
|
pickTrack(
|
||||||
|
tracks.filter((track) => track.kind === 'manual'),
|
||||||
|
isEnglishYoutubeLang,
|
||||||
|
primary?.id ?? undefined,
|
||||||
|
) ||
|
||||||
|
pickTrack(
|
||||||
|
tracks.filter((track) => track.kind === 'auto'),
|
||||||
|
isEnglishYoutubeLang,
|
||||||
|
primary?.id ?? undefined,
|
||||||
|
) ||
|
||||||
|
null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
primaryTrackId: primary?.id ?? null,
|
||||||
|
secondaryTrackId: secondary?.id ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeYoutubeTrackSelection(input: {
|
||||||
|
primaryTrackId: string | null;
|
||||||
|
secondaryTrackId: string | null;
|
||||||
|
}): {
|
||||||
|
primaryTrackId: string | null;
|
||||||
|
secondaryTrackId: string | null;
|
||||||
|
} {
|
||||||
|
if (input.primaryTrackId && input.secondaryTrackId && input.primaryTrackId === input.secondaryTrackId) {
|
||||||
|
return {
|
||||||
|
primaryTrackId: input.primaryTrackId,
|
||||||
|
secondaryTrackId: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
|
||||||
241
src/main.ts
241
src/main.ts
@@ -113,6 +113,7 @@ import {
|
|||||||
} from './cli/args';
|
} from './cli/args';
|
||||||
import type { CliArgs, CliCommandSource } from './cli/args';
|
import type { CliArgs, CliCommandSource } from './cli/args';
|
||||||
import { printHelp } from './cli/help';
|
import { printHelp } from './cli/help';
|
||||||
|
import { IPC_CHANNELS } from './shared/ipc/contracts';
|
||||||
import {
|
import {
|
||||||
buildConfigParseErrorDetails,
|
buildConfigParseErrorDetails,
|
||||||
buildConfigWarningDialogDetails,
|
buildConfigWarningDialogDetails,
|
||||||
@@ -279,6 +280,7 @@ import {
|
|||||||
handleMultiCopyDigit as handleMultiCopyDigitCore,
|
handleMultiCopyDigit as handleMultiCopyDigitCore,
|
||||||
hasMpvWebsocketPlugin,
|
hasMpvWebsocketPlugin,
|
||||||
importYomitanDictionaryFromZip,
|
importYomitanDictionaryFromZip,
|
||||||
|
initializeOverlayAnkiIntegration as initializeOverlayAnkiIntegrationCore,
|
||||||
initializeOverlayRuntime as initializeOverlayRuntimeCore,
|
initializeOverlayRuntime as initializeOverlayRuntimeCore,
|
||||||
jellyfinTicksToSecondsRuntime,
|
jellyfinTicksToSecondsRuntime,
|
||||||
listJellyfinItemsRuntime,
|
listJellyfinItemsRuntime,
|
||||||
@@ -309,12 +311,19 @@ import {
|
|||||||
upsertYomitanDictionarySettings,
|
upsertYomitanDictionarySettings,
|
||||||
updateLastCardFromClipboard as updateLastCardFromClipboardCore,
|
updateLastCardFromClipboard as updateLastCardFromClipboardCore,
|
||||||
} from './core/services';
|
} from './core/services';
|
||||||
|
import {
|
||||||
|
acquireYoutubeSubtitleTrack,
|
||||||
|
acquireYoutubeSubtitleTracks,
|
||||||
|
} from './core/services/youtube/generate';
|
||||||
|
import { retimeYoutubeSubtitle } from './core/services/youtube/retime';
|
||||||
|
import { probeYoutubeTracks } from './core/services/youtube/track-probe';
|
||||||
import { startStatsServer } from './core/services/stats-server';
|
import { startStatsServer } from './core/services/stats-server';
|
||||||
import { registerStatsOverlayToggle, destroyStatsWindow } from './core/services/stats-window.js';
|
import { registerStatsOverlayToggle, destroyStatsWindow } from './core/services/stats-window.js';
|
||||||
import {
|
import {
|
||||||
createFirstRunSetupService,
|
createFirstRunSetupService,
|
||||||
shouldAutoOpenFirstRunSetup,
|
shouldAutoOpenFirstRunSetup,
|
||||||
} from './main/runtime/first-run-setup-service';
|
} from './main/runtime/first-run-setup-service';
|
||||||
|
import { createYoutubeFlowRuntime } from './main/runtime/youtube-flow';
|
||||||
import { resolveAutoplayReadyMaxReleaseAttempts } from './main/runtime/startup-autoplay-release-policy';
|
import { resolveAutoplayReadyMaxReleaseAttempts } from './main/runtime/startup-autoplay-release-policy';
|
||||||
import {
|
import {
|
||||||
buildFirstRunSetupHtml,
|
buildFirstRunSetupHtml,
|
||||||
@@ -332,6 +341,7 @@ import {
|
|||||||
detectWindowsMpvShortcuts,
|
detectWindowsMpvShortcuts,
|
||||||
resolveWindowsMpvShortcutPaths,
|
resolveWindowsMpvShortcutPaths,
|
||||||
} from './main/runtime/windows-mpv-shortcuts';
|
} from './main/runtime/windows-mpv-shortcuts';
|
||||||
|
import { createWindowsMpvLaunchDeps, launchWindowsMpv } from './main/runtime/windows-mpv-launch';
|
||||||
import { createImmersionTrackerStartupHandler } from './main/runtime/immersion-startup';
|
import { createImmersionTrackerStartupHandler } from './main/runtime/immersion-startup';
|
||||||
import { createBuildImmersionTrackerStartupMainDepsHandler } from './main/runtime/immersion-startup-main-deps';
|
import { createBuildImmersionTrackerStartupMainDepsHandler } from './main/runtime/immersion-startup-main-deps';
|
||||||
import {
|
import {
|
||||||
@@ -442,7 +452,7 @@ import {
|
|||||||
resolveSubtitleSourcePath,
|
resolveSubtitleSourcePath,
|
||||||
} from './main/runtime/subtitle-prefetch-source';
|
} from './main/runtime/subtitle-prefetch-source';
|
||||||
import { createSubtitlePrefetchInitController } from './main/runtime/subtitle-prefetch-init';
|
import { createSubtitlePrefetchInitController } from './main/runtime/subtitle-prefetch-init';
|
||||||
import { codecToExtension } from './subsync/utils';
|
import { codecToExtension, getSubsyncConfig } from './subsync/utils';
|
||||||
|
|
||||||
if (process.platform === 'linux') {
|
if (process.platform === 'linux') {
|
||||||
app.commandLine.appendSwitch('enable-features', 'GlobalShortcutsPortal');
|
app.commandLine.appendSwitch('enable-features', 'GlobalShortcutsPortal');
|
||||||
@@ -787,6 +797,185 @@ const appState = createAppState({
|
|||||||
mpvSocketPath: getDefaultSocketPath(),
|
mpvSocketPath: getDefaultSocketPath(),
|
||||||
texthookerPort: DEFAULT_TEXTHOOKER_PORT,
|
texthookerPort: DEFAULT_TEXTHOOKER_PORT,
|
||||||
});
|
});
|
||||||
|
const startBackgroundWarmupsIfAllowed = (): void => {
|
||||||
|
if (appState.youtubePlaybackFlowPending) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
startBackgroundWarmups();
|
||||||
|
};
|
||||||
|
const youtubeFlowRuntime = createYoutubeFlowRuntime({
|
||||||
|
probeYoutubeTracks: (url: string) => probeYoutubeTracks(url),
|
||||||
|
acquireYoutubeSubtitleTrack: (input) => acquireYoutubeSubtitleTrack(input),
|
||||||
|
acquireYoutubeSubtitleTracks: (input) => acquireYoutubeSubtitleTracks(input),
|
||||||
|
retimeYoutubePrimaryTrack: async ({ primaryTrack, primaryPath, secondaryTrack, secondaryPath }) => {
|
||||||
|
if (primaryTrack.kind !== 'auto') {
|
||||||
|
return primaryPath;
|
||||||
|
}
|
||||||
|
const result = await retimeYoutubeSubtitle({
|
||||||
|
primaryPath,
|
||||||
|
secondaryPath: secondaryTrack ? secondaryPath : null,
|
||||||
|
});
|
||||||
|
logger.info(`Using YouTube subtitle path: ${result.path} (${result.strategy})`);
|
||||||
|
return result.path;
|
||||||
|
},
|
||||||
|
openPicker: async (payload) => {
|
||||||
|
const preferDedicatedModalWindow = false;
|
||||||
|
const sendPickerOpen = (preferModalWindow: boolean): boolean =>
|
||||||
|
overlayModalRuntime.sendToActiveOverlayWindow('youtube:picker-open', payload, {
|
||||||
|
restoreOnModalClose: 'youtube-track-picker',
|
||||||
|
preferModalWindow,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!sendPickerOpen(preferDedicatedModalWindow)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (await overlayModalRuntime.waitForModalOpen('youtube-track-picker', 1500)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.warn(
|
||||||
|
'YouTube subtitle picker did not acknowledge modal open on first attempt; retrying on visible overlay.',
|
||||||
|
);
|
||||||
|
if (!sendPickerOpen(!preferDedicatedModalWindow)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return await overlayModalRuntime.waitForModalOpen('youtube-track-picker', 1500);
|
||||||
|
},
|
||||||
|
pauseMpv: () => {
|
||||||
|
sendMpvCommandRuntime(appState.mpvClient, ['set_property', 'pause', 'yes']);
|
||||||
|
},
|
||||||
|
resumeMpv: () => {
|
||||||
|
sendMpvCommandRuntime(appState.mpvClient, ['set_property', 'pause', 'no']);
|
||||||
|
},
|
||||||
|
sendMpvCommand: (command) => {
|
||||||
|
sendMpvCommandRuntime(appState.mpvClient, command);
|
||||||
|
},
|
||||||
|
requestMpvProperty: async (name: string) => {
|
||||||
|
const client = appState.mpvClient;
|
||||||
|
if (!client) return null;
|
||||||
|
return await client.requestProperty(name);
|
||||||
|
},
|
||||||
|
refreshCurrentSubtitle: (text: string) => {
|
||||||
|
subtitleProcessingController.refreshCurrentSubtitle(text);
|
||||||
|
},
|
||||||
|
startTokenizationWarmups: async () => {
|
||||||
|
await startTokenizationWarmups();
|
||||||
|
},
|
||||||
|
waitForTokenizationReady: async () => {
|
||||||
|
await currentMediaTokenizationGate.waitUntilReady(
|
||||||
|
appState.currentMediaPath?.trim() || appState.mpvClient?.currentVideoPath?.trim() || null,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
waitForAnkiReady: async () => {
|
||||||
|
const integration = appState.ankiIntegration;
|
||||||
|
if (!integration) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await Promise.race([
|
||||||
|
integration.waitUntilReady(),
|
||||||
|
new Promise<never>((_, reject) => {
|
||||||
|
setTimeout(() => reject(new Error('Timed out waiting for AnkiConnect integration')), 2500);
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(
|
||||||
|
'Continuing YouTube playback before AnkiConnect integration reported ready:',
|
||||||
|
error instanceof Error ? error.message : String(error),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
wait: (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)),
|
||||||
|
waitForPlaybackWindowReady: async () => {
|
||||||
|
const deadline = Date.now() + 4000;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
const tracker = appState.windowTracker;
|
||||||
|
if (tracker && tracker.isTracking() && tracker.getGeometry()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
}
|
||||||
|
logger.warn('Timed out waiting for tracked playback window before opening YouTube subtitle picker.');
|
||||||
|
},
|
||||||
|
waitForOverlayGeometryReady: async () => {
|
||||||
|
const deadline = Date.now() + 4000;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
const tracker = appState.windowTracker;
|
||||||
|
const trackerGeometry = tracker?.getGeometry() ?? null;
|
||||||
|
if (trackerGeometry && geometryMatches(lastOverlayWindowGeometry, trackerGeometry)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
}
|
||||||
|
logger.warn('Timed out waiting for overlay geometry to match tracked playback window.');
|
||||||
|
},
|
||||||
|
focusOverlayWindow: () => {
|
||||||
|
const mainWindow = overlayManager.getMainWindow();
|
||||||
|
if (!mainWindow || mainWindow.isDestroyed()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
mainWindow.setIgnoreMouseEvents(false);
|
||||||
|
if (!mainWindow.isFocused()) {
|
||||||
|
mainWindow.focus();
|
||||||
|
}
|
||||||
|
if (!mainWindow.webContents.isFocused()) {
|
||||||
|
mainWindow.webContents.focus();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
showMpvOsd: (text: string) => showMpvOsd(text),
|
||||||
|
warn: (message: string) => logger.warn(message),
|
||||||
|
log: (message: string) => logger.info(message),
|
||||||
|
getYoutubeOutputDir: () => path.join(os.homedir(), '.cache', 'subminer', 'youtube-subs'),
|
||||||
|
});
|
||||||
|
|
||||||
|
async function runYoutubePlaybackFlowMain(request: {
|
||||||
|
url: string;
|
||||||
|
mode: 'download' | 'generate';
|
||||||
|
source: CliCommandSource;
|
||||||
|
}): Promise<void> {
|
||||||
|
const shouldResumeWarmupsAfterFlow = appState.youtubePlaybackFlowPending;
|
||||||
|
if (process.platform === 'win32' && !appState.mpvClient?.connected) {
|
||||||
|
const launchResult = launchWindowsMpv(
|
||||||
|
[request.url],
|
||||||
|
createWindowsMpvLaunchDeps({
|
||||||
|
showError: (title, content) => dialog.showErrorBox(title, content),
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
'--pause=yes',
|
||||||
|
'--sub-auto=no',
|
||||||
|
'--sid=no',
|
||||||
|
'--secondary-sid=no',
|
||||||
|
'--script-opts=subminer-auto_start_pause_until_ready=no',
|
||||||
|
`--input-ipc-server=${appState.mpvSocketPath}`,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
if (!launchResult.ok) {
|
||||||
|
logger.warn('Unable to bootstrap Windows mpv for YouTube playback.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!appState.mpvClient?.connected) {
|
||||||
|
appState.mpvClient?.connect();
|
||||||
|
}
|
||||||
|
await ensureOverlayRuntimeReady();
|
||||||
|
try {
|
||||||
|
await youtubeFlowRuntime.runYoutubePlaybackFlow({
|
||||||
|
url: request.url,
|
||||||
|
mode: request.mode,
|
||||||
|
});
|
||||||
|
logger.info(`YouTube playback flow completed from ${request.source}.`);
|
||||||
|
} finally {
|
||||||
|
if (shouldResumeWarmupsAfterFlow) {
|
||||||
|
appState.youtubePlaybackFlowPending = false;
|
||||||
|
startBackgroundWarmupsIfAllowed();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureOverlayRuntimeReady(): Promise<void> {
|
||||||
|
await ensureYomitanExtensionLoaded();
|
||||||
|
initializeOverlayRuntime();
|
||||||
|
}
|
||||||
|
|
||||||
let firstRunSetupMessage: string | null = null;
|
let firstRunSetupMessage: string | null = null;
|
||||||
const resolveWindowsMpvShortcutRuntimePaths = () =>
|
const resolveWindowsMpvShortcutRuntimePaths = () =>
|
||||||
resolveWindowsMpvShortcutPaths({
|
resolveWindowsMpvShortcutPaths({
|
||||||
@@ -1045,6 +1234,9 @@ function maybeSignalPluginAutoplayReady(
|
|||||||
payload: SubtitleData,
|
payload: SubtitleData,
|
||||||
options?: { forceWhilePaused?: boolean },
|
options?: { forceWhilePaused?: boolean },
|
||||||
): void {
|
): void {
|
||||||
|
if (appState.youtubePlaybackFlowPending) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!payload.text.trim()) {
|
if (!payload.text.trim()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -3064,7 +3256,7 @@ const { appReadyRuntimeRunner } = composeAppReadyRuntime({
|
|||||||
await prewarmSubtitleDictionaries();
|
await prewarmSubtitleDictionaries();
|
||||||
},
|
},
|
||||||
startBackgroundWarmups: () => {
|
startBackgroundWarmups: () => {
|
||||||
startBackgroundWarmups();
|
startBackgroundWarmupsIfAllowed();
|
||||||
},
|
},
|
||||||
texthookerOnlyMode: appState.texthookerOnlyMode,
|
texthookerOnlyMode: appState.texthookerOnlyMode,
|
||||||
shouldAutoInitializeOverlayRuntimeFromConfig: () =>
|
shouldAutoInitializeOverlayRuntimeFromConfig: () =>
|
||||||
@@ -3242,6 +3434,7 @@ const {
|
|||||||
createMecabTokenizerAndCheck,
|
createMecabTokenizerAndCheck,
|
||||||
prewarmSubtitleDictionaries,
|
prewarmSubtitleDictionaries,
|
||||||
startBackgroundWarmups,
|
startBackgroundWarmups,
|
||||||
|
startTokenizationWarmups,
|
||||||
isTokenizationWarmupReady,
|
isTokenizationWarmupReady,
|
||||||
} = composeMpvRuntimeHandlers<
|
} = composeMpvRuntimeHandlers<
|
||||||
MpvIpcClient,
|
MpvIpcClient,
|
||||||
@@ -3312,6 +3505,9 @@ const {
|
|||||||
immersionMediaRuntime.syncFromCurrentMediaState();
|
immersionMediaRuntime.syncFromCurrentMediaState();
|
||||||
},
|
},
|
||||||
signalAutoplayReadyIfWarm: () => {
|
signalAutoplayReadyIfWarm: () => {
|
||||||
|
if (appState.youtubePlaybackFlowPending) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!isTokenizationWarmupReady()) {
|
if (!isTokenizationWarmupReady()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -3513,7 +3709,19 @@ const {
|
|||||||
tokenizeSubtitleDeferred = tokenizeSubtitle;
|
tokenizeSubtitleDeferred = tokenizeSubtitle;
|
||||||
|
|
||||||
function createMpvClientRuntimeService(): MpvIpcClient {
|
function createMpvClientRuntimeService(): MpvIpcClient {
|
||||||
return createMpvClientRuntimeServiceHandler() as MpvIpcClient;
|
const client = createMpvClientRuntimeServiceHandler() as MpvIpcClient;
|
||||||
|
client.on('connection-change', ({ connected }) => {
|
||||||
|
if (connected) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!youtubeFlowRuntime.hasActiveSession()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
youtubeFlowRuntime.cancelActivePicker();
|
||||||
|
broadcastToOverlayWindows(IPC_CHANNELS.event.youtubePickerCancel, null);
|
||||||
|
overlayModalRuntime.handleOverlayModalClosed('youtube-track-picker');
|
||||||
|
});
|
||||||
|
return client;
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetSubtitleSidebarEmbeddedLayoutRuntime(): void {
|
function resetSubtitleSidebarEmbeddedLayoutRuntime(): void {
|
||||||
@@ -3546,6 +3754,11 @@ function getCurrentOverlayGeometry(): WindowGeometry {
|
|||||||
return getOverlayGeometryFallback();
|
return getOverlayGeometryFallback();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function geometryMatches(a: WindowGeometry | null, b: WindowGeometry | null): boolean {
|
||||||
|
if (!a || !b) return false;
|
||||||
|
return a.x === b.x && a.y === b.y && a.width === b.width && a.height === b.height;
|
||||||
|
}
|
||||||
|
|
||||||
function applyOverlayRegions(geometry: WindowGeometry): void {
|
function applyOverlayRegions(geometry: WindowGeometry): void {
|
||||||
lastOverlayWindowGeometry = geometry;
|
lastOverlayWindowGeometry = geometry;
|
||||||
overlayManager.setOverlayWindowBounds(geometry);
|
overlayManager.setOverlayWindowBounds(geometry);
|
||||||
@@ -3690,6 +3903,21 @@ function destroyTray(): void {
|
|||||||
|
|
||||||
function initializeOverlayRuntime(): void {
|
function initializeOverlayRuntime(): void {
|
||||||
initializeOverlayRuntimeHandler();
|
initializeOverlayRuntimeHandler();
|
||||||
|
initializeOverlayAnkiIntegrationCore({
|
||||||
|
getResolvedConfig: () => getResolvedConfig(),
|
||||||
|
getSubtitleTimingTracker: () => appState.subtitleTimingTracker,
|
||||||
|
getMpvClient: () => appState.mpvClient,
|
||||||
|
getRuntimeOptionsManager: () => appState.runtimeOptionsManager,
|
||||||
|
getAnkiIntegration: () => appState.ankiIntegration,
|
||||||
|
setAnkiIntegration: (integration) => {
|
||||||
|
appState.ankiIntegration = integration as AnkiIntegration | null;
|
||||||
|
},
|
||||||
|
showDesktopNotification,
|
||||||
|
createFieldGroupingCallback: () => createFieldGroupingCallback(),
|
||||||
|
getKnownWordCacheStatePath: () => path.join(USER_DATA_PATH, 'known-words-cache.json'),
|
||||||
|
shouldStartAnkiIntegration: () =>
|
||||||
|
!(appState.initialArgs && isHeadlessInitialCommand(appState.initialArgs)),
|
||||||
|
});
|
||||||
appState.ankiIntegration?.setRecordCardsMinedCallback(recordTrackedCardsMined);
|
appState.ankiIntegration?.setRecordCardsMinedCallback(recordTrackedCardsMined);
|
||||||
syncOverlayMpvSubtitleSuppression();
|
syncOverlayMpvSubtitleSuppression();
|
||||||
}
|
}
|
||||||
@@ -4189,6 +4417,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
|||||||
onOverlayModalOpened: (modal) => {
|
onOverlayModalOpened: (modal) => {
|
||||||
overlayModalRuntime.notifyOverlayModalOpened(modal);
|
overlayModalRuntime.notifyOverlayModalOpened(modal);
|
||||||
},
|
},
|
||||||
|
onYoutubePickerResolve: (request) => youtubeFlowRuntime.resolveActivePicker(request),
|
||||||
openYomitanSettings: () => openYomitanSettings(),
|
openYomitanSettings: () => openYomitanSettings(),
|
||||||
quitApp: () => requestAppQuit(),
|
quitApp: () => requestAppQuit(),
|
||||||
toggleVisibleOverlay: () => toggleVisibleOverlay(),
|
toggleVisibleOverlay: () => toggleVisibleOverlay(),
|
||||||
@@ -4403,6 +4632,7 @@ const createCliCommandContextHandler = createCliCommandContextFactory({
|
|||||||
runJellyfinCommand: (argsFromCommand: CliArgs) => runJellyfinCommand(argsFromCommand),
|
runJellyfinCommand: (argsFromCommand: CliArgs) => runJellyfinCommand(argsFromCommand),
|
||||||
runStatsCommand: (argsFromCommand: CliArgs, source: CliCommandSource) =>
|
runStatsCommand: (argsFromCommand: CliArgs, source: CliCommandSource) =>
|
||||||
runStatsCliCommand(argsFromCommand, source),
|
runStatsCliCommand(argsFromCommand, source),
|
||||||
|
runYoutubePlaybackFlow: (request) => runYoutubePlaybackFlowMain(request),
|
||||||
openYomitanSettings: () => openYomitanSettings(),
|
openYomitanSettings: () => openYomitanSettings(),
|
||||||
cycleSecondarySubMode: () => handleCycleSecondarySubMode(),
|
cycleSecondarySubMode: () => handleCycleSecondarySubMode(),
|
||||||
openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(),
|
openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(),
|
||||||
@@ -4569,7 +4799,10 @@ const { initializeOverlayRuntime: initializeOverlayRuntimeHandler } =
|
|||||||
appState.overlayRuntimeInitialized = initialized;
|
appState.overlayRuntimeInitialized = initialized;
|
||||||
},
|
},
|
||||||
startBackgroundWarmups: () => {
|
startBackgroundWarmups: () => {
|
||||||
if (appState.initialArgs && isHeadlessInitialCommand(appState.initialArgs)) {
|
if (
|
||||||
|
(appState.initialArgs && isHeadlessInitialCommand(appState.initialArgs)) ||
|
||||||
|
appState.youtubePlaybackFlowPending
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
startBackgroundWarmups();
|
startBackgroundWarmups();
|
||||||
|
|||||||
451
src/main/runtime/youtube-flow.test.ts
Normal file
451
src/main/runtime/youtube-flow.test.ts
Normal file
@@ -0,0 +1,451 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { createYoutubeFlowRuntime } from './youtube-flow';
|
||||||
|
import type { YoutubePickerOpenPayload, YoutubeTrackOption } from '../../types';
|
||||||
|
|
||||||
|
const primaryTrack: YoutubeTrackOption = {
|
||||||
|
id: 'auto:ja-orig',
|
||||||
|
language: 'ja',
|
||||||
|
sourceLanguage: 'ja-orig',
|
||||||
|
kind: 'auto',
|
||||||
|
label: 'Japanese (auto)',
|
||||||
|
};
|
||||||
|
|
||||||
|
const secondaryTrack: YoutubeTrackOption = {
|
||||||
|
id: 'manual:en',
|
||||||
|
language: 'en',
|
||||||
|
sourceLanguage: 'en',
|
||||||
|
kind: 'manual',
|
||||||
|
label: 'English (manual)',
|
||||||
|
};
|
||||||
|
|
||||||
|
test('youtube flow clears internal tracks and binds external primary+secondary subtitles', async () => {
|
||||||
|
const commands: Array<Array<string | number>> = [];
|
||||||
|
const osdMessages: string[] = [];
|
||||||
|
const order: string[] = [];
|
||||||
|
const refreshedSubtitles: string[] = [];
|
||||||
|
const waits: number[] = [];
|
||||||
|
const focusOverlayCalls: string[] = [];
|
||||||
|
let pickerPayload: YoutubePickerOpenPayload | null = null;
|
||||||
|
let trackListRequests = 0;
|
||||||
|
|
||||||
|
const runtime = createYoutubeFlowRuntime({
|
||||||
|
probeYoutubeTracks: async () => ({
|
||||||
|
videoId: 'video123',
|
||||||
|
title: 'Video 123',
|
||||||
|
tracks: [primaryTrack, secondaryTrack],
|
||||||
|
}),
|
||||||
|
acquireYoutubeSubtitleTracks: async ({ tracks }) => {
|
||||||
|
assert.deepEqual(
|
||||||
|
tracks.map((track) => track.id),
|
||||||
|
[primaryTrack.id, secondaryTrack.id],
|
||||||
|
);
|
||||||
|
return new Map<string, string>([
|
||||||
|
[primaryTrack.id, '/tmp/auto-ja-orig.vtt'],
|
||||||
|
[secondaryTrack.id, '/tmp/manual-en.vtt'],
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
acquireYoutubeSubtitleTrack: async ({ track }) => {
|
||||||
|
if (track.id === primaryTrack.id) {
|
||||||
|
return { path: '/tmp/auto-ja-orig.vtt' };
|
||||||
|
}
|
||||||
|
return { path: '/tmp/manual-en.vtt' };
|
||||||
|
},
|
||||||
|
retimeYoutubePrimaryTrack: async ({ primaryPath, secondaryPath }) => {
|
||||||
|
assert.equal(primaryPath, '/tmp/auto-ja-orig.vtt');
|
||||||
|
assert.equal(secondaryPath, '/tmp/manual-en.vtt');
|
||||||
|
return '/tmp/auto-ja-orig_retimed.vtt';
|
||||||
|
},
|
||||||
|
startTokenizationWarmups: async () => {
|
||||||
|
order.push('start-tokenization-warmups');
|
||||||
|
},
|
||||||
|
waitForTokenizationReady: async () => {
|
||||||
|
order.push('wait-tokenization-ready');
|
||||||
|
},
|
||||||
|
waitForAnkiReady: async () => {
|
||||||
|
order.push('wait-anki-ready');
|
||||||
|
},
|
||||||
|
waitForPlaybackWindowReady: async () => {
|
||||||
|
order.push('wait-window-ready');
|
||||||
|
},
|
||||||
|
waitForOverlayGeometryReady: async () => {
|
||||||
|
order.push('wait-overlay-geometry');
|
||||||
|
},
|
||||||
|
focusOverlayWindow: () => {
|
||||||
|
focusOverlayCalls.push('focus-overlay');
|
||||||
|
},
|
||||||
|
openPicker: async (payload) => {
|
||||||
|
assert.deepEqual(waits, [150]);
|
||||||
|
order.push('open-picker');
|
||||||
|
pickerPayload = payload;
|
||||||
|
queueMicrotask(() => {
|
||||||
|
void runtime.resolveActivePicker({
|
||||||
|
sessionId: payload.sessionId,
|
||||||
|
action: 'use-selected',
|
||||||
|
primaryTrackId: primaryTrack.id,
|
||||||
|
secondaryTrackId: secondaryTrack.id,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
pauseMpv: () => {
|
||||||
|
commands.push(['set_property', 'pause', 'yes']);
|
||||||
|
},
|
||||||
|
resumeMpv: () => {
|
||||||
|
commands.push(['set_property', 'pause', 'no']);
|
||||||
|
},
|
||||||
|
sendMpvCommand: (command) => {
|
||||||
|
commands.push(command);
|
||||||
|
},
|
||||||
|
requestMpvProperty: async (name) => {
|
||||||
|
if (name === 'sub-text') {
|
||||||
|
return '字幕です';
|
||||||
|
}
|
||||||
|
assert.equal(name, 'track-list');
|
||||||
|
trackListRequests += 1;
|
||||||
|
if (trackListRequests === 1) {
|
||||||
|
return [{ type: 'sub', id: 1, lang: 'ja', external: false, title: 'internal' }];
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
type: 'sub',
|
||||||
|
id: 5,
|
||||||
|
lang: 'ja-orig',
|
||||||
|
title: 'primary',
|
||||||
|
external: true,
|
||||||
|
'external-filename': '/tmp/auto-ja-orig_retimed.vtt',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'sub',
|
||||||
|
id: 6,
|
||||||
|
lang: 'en',
|
||||||
|
title: 'secondary',
|
||||||
|
external: true,
|
||||||
|
'external-filename': '/tmp/manual-en.vtt',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
refreshCurrentSubtitle: (text) => {
|
||||||
|
refreshedSubtitles.push(text);
|
||||||
|
},
|
||||||
|
wait: async (ms) => {
|
||||||
|
waits.push(ms);
|
||||||
|
},
|
||||||
|
showMpvOsd: (text) => {
|
||||||
|
osdMessages.push(text);
|
||||||
|
},
|
||||||
|
warn: (message) => {
|
||||||
|
throw new Error(message);
|
||||||
|
},
|
||||||
|
log: () => {},
|
||||||
|
getYoutubeOutputDir: () => '/tmp',
|
||||||
|
});
|
||||||
|
await runtime.runYoutubePlaybackFlow({ url: 'https://example.com', mode: 'download' });
|
||||||
|
|
||||||
|
assert.ok(pickerPayload);
|
||||||
|
assert.deepEqual(order, [
|
||||||
|
'start-tokenization-warmups',
|
||||||
|
'wait-window-ready',
|
||||||
|
'wait-overlay-geometry',
|
||||||
|
'open-picker',
|
||||||
|
'wait-tokenization-ready',
|
||||||
|
'wait-anki-ready',
|
||||||
|
]);
|
||||||
|
assert.deepEqual(osdMessages, [
|
||||||
|
'Opening YouTube video',
|
||||||
|
'Getting subtitles...',
|
||||||
|
'Downloading subtitles...',
|
||||||
|
'Loading subtitles...',
|
||||||
|
'Primary and secondary subtitles loaded.',
|
||||||
|
]);
|
||||||
|
assert.deepEqual(commands, [
|
||||||
|
['set_property', 'pause', 'yes'],
|
||||||
|
['set_property', 'sub-delay', 0],
|
||||||
|
['set_property', 'sid', 'no'],
|
||||||
|
['set_property', 'secondary-sid', 'no'],
|
||||||
|
['sub-add', '/tmp/auto-ja-orig_retimed.vtt', 'select', 'auto-ja-orig_retimed.vtt', 'ja-orig'],
|
||||||
|
['sub-add', '/tmp/manual-en.vtt', 'cached', 'manual-en.vtt', 'en'],
|
||||||
|
['set_property', 'sid', 5],
|
||||||
|
['set_property', 'secondary-sid', 6],
|
||||||
|
['script-message', 'subminer-autoplay-ready'],
|
||||||
|
['set_property', 'pause', 'no'],
|
||||||
|
]);
|
||||||
|
assert.deepEqual(focusOverlayCalls, ['focus-overlay']);
|
||||||
|
assert.deepEqual(refreshedSubtitles, ['字幕です']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('youtube flow can cancel active picker session', async () => {
|
||||||
|
const focusOverlayCalls: string[] = [];
|
||||||
|
const runtime = createYoutubeFlowRuntime({
|
||||||
|
probeYoutubeTracks: async () => ({
|
||||||
|
videoId: 'video123',
|
||||||
|
title: 'Video 123',
|
||||||
|
tracks: [primaryTrack],
|
||||||
|
}),
|
||||||
|
acquireYoutubeSubtitleTracks: async () => {
|
||||||
|
throw new Error('should not batch download after cancel');
|
||||||
|
},
|
||||||
|
acquireYoutubeSubtitleTrack: async () => {
|
||||||
|
throw new Error('should not download after cancel');
|
||||||
|
},
|
||||||
|
retimeYoutubePrimaryTrack: async ({ primaryPath }) => primaryPath,
|
||||||
|
startTokenizationWarmups: async () => {},
|
||||||
|
waitForTokenizationReady: async () => {},
|
||||||
|
waitForAnkiReady: async () => {},
|
||||||
|
waitForPlaybackWindowReady: async () => {},
|
||||||
|
waitForOverlayGeometryReady: async () => {},
|
||||||
|
focusOverlayWindow: () => {
|
||||||
|
focusOverlayCalls.push('focus-overlay');
|
||||||
|
},
|
||||||
|
openPicker: async (payload) => {
|
||||||
|
queueMicrotask(() => {
|
||||||
|
assert.equal(runtime.cancelActivePicker(), true);
|
||||||
|
void runtime.resolveActivePicker({
|
||||||
|
sessionId: payload.sessionId,
|
||||||
|
action: 'use-selected',
|
||||||
|
primaryTrackId: primaryTrack.id,
|
||||||
|
secondaryTrackId: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
pauseMpv: () => {},
|
||||||
|
resumeMpv: () => {},
|
||||||
|
sendMpvCommand: () => {},
|
||||||
|
requestMpvProperty: async () => null,
|
||||||
|
refreshCurrentSubtitle: () => {},
|
||||||
|
wait: async () => {},
|
||||||
|
showMpvOsd: () => {},
|
||||||
|
warn: () => {},
|
||||||
|
log: () => {},
|
||||||
|
getYoutubeOutputDir: () => '/tmp',
|
||||||
|
});
|
||||||
|
|
||||||
|
await runtime.runYoutubePlaybackFlow({ url: 'https://example.com', mode: 'download' });
|
||||||
|
assert.equal(runtime.hasActiveSession(), false);
|
||||||
|
assert.equal(runtime.cancelActivePicker(), false);
|
||||||
|
assert.deepEqual(focusOverlayCalls, ['focus-overlay']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('youtube flow retries secondary after partial batch subtitle failure', async () => {
|
||||||
|
const acquireSingleCalls: string[] = [];
|
||||||
|
const commands: Array<Array<string | number>> = [];
|
||||||
|
const focusOverlayCalls: string[] = [];
|
||||||
|
const refreshedSubtitles: string[] = [];
|
||||||
|
const warns: string[] = [];
|
||||||
|
const waits: number[] = [];
|
||||||
|
let trackListRequests = 0;
|
||||||
|
|
||||||
|
const runtime = createYoutubeFlowRuntime({
|
||||||
|
probeYoutubeTracks: async () => ({
|
||||||
|
videoId: 'video123',
|
||||||
|
title: 'Video 123',
|
||||||
|
tracks: [primaryTrack, secondaryTrack],
|
||||||
|
}),
|
||||||
|
acquireYoutubeSubtitleTracks: async () => {
|
||||||
|
return new Map<string, string>([[primaryTrack.id, '/tmp/auto-ja-orig.vtt']]);
|
||||||
|
},
|
||||||
|
acquireYoutubeSubtitleTrack: async ({ track }) => {
|
||||||
|
acquireSingleCalls.push(track.id);
|
||||||
|
return { path: `/tmp/${track.id}.vtt` };
|
||||||
|
},
|
||||||
|
retimeYoutubePrimaryTrack: async ({ primaryPath, secondaryPath }) => {
|
||||||
|
assert.equal(primaryPath, '/tmp/auto-ja-orig.vtt');
|
||||||
|
assert.equal(secondaryPath, '/tmp/manual:en.vtt');
|
||||||
|
return primaryPath;
|
||||||
|
},
|
||||||
|
startTokenizationWarmups: async () => {},
|
||||||
|
waitForTokenizationReady: async () => {},
|
||||||
|
waitForAnkiReady: async () => {},
|
||||||
|
waitForPlaybackWindowReady: async () => {},
|
||||||
|
waitForOverlayGeometryReady: async () => {},
|
||||||
|
focusOverlayWindow: () => {
|
||||||
|
focusOverlayCalls.push('focus-overlay');
|
||||||
|
},
|
||||||
|
openPicker: async (payload) => {
|
||||||
|
queueMicrotask(() => {
|
||||||
|
void runtime.resolveActivePicker({
|
||||||
|
sessionId: payload.sessionId,
|
||||||
|
action: 'use-selected',
|
||||||
|
primaryTrackId: primaryTrack.id,
|
||||||
|
secondaryTrackId: secondaryTrack.id,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
pauseMpv: () => {},
|
||||||
|
resumeMpv: () => {},
|
||||||
|
sendMpvCommand: (command) => {
|
||||||
|
commands.push(command);
|
||||||
|
},
|
||||||
|
requestMpvProperty: async (name) => {
|
||||||
|
if (name === 'sub-text') {
|
||||||
|
return '字幕です';
|
||||||
|
}
|
||||||
|
assert.equal(name, 'track-list');
|
||||||
|
trackListRequests += 1;
|
||||||
|
if (trackListRequests === 1) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
type: 'sub',
|
||||||
|
id: 5,
|
||||||
|
lang: 'ja-orig',
|
||||||
|
title: 'primary',
|
||||||
|
external: true,
|
||||||
|
'external-filename': '/tmp/auto-ja-orig.vtt',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
type: 'sub',
|
||||||
|
id: 5,
|
||||||
|
lang: 'ja-orig',
|
||||||
|
title: 'primary',
|
||||||
|
external: true,
|
||||||
|
'external-filename': '/tmp/auto-ja-orig.vtt',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'sub',
|
||||||
|
id: 6,
|
||||||
|
lang: 'en',
|
||||||
|
title: 'secondary',
|
||||||
|
external: true,
|
||||||
|
'external-filename': '/tmp/manual:en.vtt',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
refreshCurrentSubtitle: (text) => {
|
||||||
|
refreshedSubtitles.push(text);
|
||||||
|
},
|
||||||
|
wait: async (ms) => {
|
||||||
|
waits.push(ms);
|
||||||
|
},
|
||||||
|
showMpvOsd: () => {},
|
||||||
|
warn: (message) => {
|
||||||
|
warns.push(message);
|
||||||
|
},
|
||||||
|
log: () => {},
|
||||||
|
getYoutubeOutputDir: () => '/tmp',
|
||||||
|
});
|
||||||
|
|
||||||
|
await runtime.runYoutubePlaybackFlow({ url: 'https://example.com', mode: 'download' });
|
||||||
|
|
||||||
|
assert.deepEqual(acquireSingleCalls, [secondaryTrack.id]);
|
||||||
|
assert.ok(waits.includes(150));
|
||||||
|
assert.deepEqual(focusOverlayCalls, ['focus-overlay']);
|
||||||
|
assert.deepEqual(refreshedSubtitles, ['字幕です']);
|
||||||
|
assert.ok(
|
||||||
|
commands.some(
|
||||||
|
(command) =>
|
||||||
|
command[0] === 'sub-add' &&
|
||||||
|
command[1] === '/tmp/manual:en.vtt' &&
|
||||||
|
command[2] === 'cached',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
assert.equal(warns.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('youtube flow waits for tokenization readiness before releasing playback', async () => {
|
||||||
|
const commands: Array<Array<string | number>> = [];
|
||||||
|
const releaseOrder: string[] = [];
|
||||||
|
let tokenizationReadyRegistered = false;
|
||||||
|
let resolveTokenizationReady: () => void = () => {
|
||||||
|
throw new Error('expected tokenization readiness waiter');
|
||||||
|
};
|
||||||
|
|
||||||
|
const runtime = createYoutubeFlowRuntime({
|
||||||
|
probeYoutubeTracks: async () => ({
|
||||||
|
videoId: 'video123',
|
||||||
|
title: 'Video 123',
|
||||||
|
tracks: [primaryTrack],
|
||||||
|
}),
|
||||||
|
acquireYoutubeSubtitleTracks: async () => new Map<string, string>(),
|
||||||
|
acquireYoutubeSubtitleTrack: async () => ({ path: '/tmp/auto-ja-orig.vtt' }),
|
||||||
|
retimeYoutubePrimaryTrack: async ({ primaryPath }) => primaryPath,
|
||||||
|
startTokenizationWarmups: async () => {
|
||||||
|
releaseOrder.push('start-warmups');
|
||||||
|
},
|
||||||
|
waitForTokenizationReady: async () => {
|
||||||
|
releaseOrder.push('wait-tokenization-ready:start');
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
tokenizationReadyRegistered = true;
|
||||||
|
resolveTokenizationReady = resolve;
|
||||||
|
});
|
||||||
|
releaseOrder.push('wait-tokenization-ready:end');
|
||||||
|
},
|
||||||
|
waitForAnkiReady: async () => {
|
||||||
|
releaseOrder.push('wait-anki-ready');
|
||||||
|
},
|
||||||
|
waitForPlaybackWindowReady: async () => {},
|
||||||
|
waitForOverlayGeometryReady: async () => {},
|
||||||
|
focusOverlayWindow: () => {
|
||||||
|
releaseOrder.push('focus-overlay');
|
||||||
|
},
|
||||||
|
openPicker: async (payload) => {
|
||||||
|
queueMicrotask(() => {
|
||||||
|
void runtime.resolveActivePicker({
|
||||||
|
sessionId: payload.sessionId,
|
||||||
|
action: 'use-selected',
|
||||||
|
primaryTrackId: primaryTrack.id,
|
||||||
|
secondaryTrackId: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
pauseMpv: () => {},
|
||||||
|
resumeMpv: () => {
|
||||||
|
commands.push(['set_property', 'pause', 'no']);
|
||||||
|
releaseOrder.push('resume');
|
||||||
|
},
|
||||||
|
sendMpvCommand: (command) => {
|
||||||
|
commands.push(command);
|
||||||
|
if (command[0] === 'script-message' && command[1] === 'subminer-autoplay-ready') {
|
||||||
|
releaseOrder.push('autoplay-ready');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
requestMpvProperty: async (name) => {
|
||||||
|
if (name === 'sub-text') {
|
||||||
|
return '字幕です';
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
type: 'sub',
|
||||||
|
id: 5,
|
||||||
|
lang: 'ja-orig',
|
||||||
|
title: 'primary',
|
||||||
|
external: true,
|
||||||
|
'external-filename': '/tmp/auto-ja-orig.vtt',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
refreshCurrentSubtitle: () => {},
|
||||||
|
wait: async () => {},
|
||||||
|
showMpvOsd: () => {},
|
||||||
|
warn: (message) => {
|
||||||
|
throw new Error(message);
|
||||||
|
},
|
||||||
|
log: () => {},
|
||||||
|
getYoutubeOutputDir: () => '/tmp',
|
||||||
|
});
|
||||||
|
|
||||||
|
const flowPromise = runtime.runYoutubePlaybackFlow({ url: 'https://example.com', mode: 'download' });
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
|
||||||
|
assert.equal(tokenizationReadyRegistered, true);
|
||||||
|
assert.deepEqual(releaseOrder, ['start-warmups', 'wait-tokenization-ready:start']);
|
||||||
|
assert.equal(commands.some((command) => command[1] === 'subminer-autoplay-ready'), false);
|
||||||
|
|
||||||
|
resolveTokenizationReady();
|
||||||
|
await flowPromise;
|
||||||
|
|
||||||
|
assert.deepEqual(releaseOrder, [
|
||||||
|
'start-warmups',
|
||||||
|
'wait-tokenization-ready:start',
|
||||||
|
'wait-tokenization-ready:end',
|
||||||
|
'wait-anki-ready',
|
||||||
|
'autoplay-ready',
|
||||||
|
'resume',
|
||||||
|
'focus-overlay',
|
||||||
|
]);
|
||||||
|
});
|
||||||
549
src/main/runtime/youtube-flow.ts
Normal file
549
src/main/runtime/youtube-flow.ts
Normal file
@@ -0,0 +1,549 @@
|
|||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import type {
|
||||||
|
YoutubeFlowMode,
|
||||||
|
YoutubePickerOpenPayload,
|
||||||
|
YoutubePickerResolveRequest,
|
||||||
|
YoutubePickerResolveResult,
|
||||||
|
} from '../../types';
|
||||||
|
import type {
|
||||||
|
YoutubeTrackOption,
|
||||||
|
YoutubeTrackProbeResult,
|
||||||
|
} from '../../core/services/youtube/track-probe';
|
||||||
|
import {
|
||||||
|
chooseDefaultYoutubeTrackIds,
|
||||||
|
normalizeYoutubeTrackSelection,
|
||||||
|
} from '../../core/services/youtube/track-selection';
|
||||||
|
import {
|
||||||
|
acquireYoutubeSubtitleTrack,
|
||||||
|
acquireYoutubeSubtitleTracks,
|
||||||
|
} from '../../core/services/youtube/generate';
|
||||||
|
import { resolveSubtitleSourcePath } from './subtitle-prefetch-source';
|
||||||
|
|
||||||
|
type YoutubeFlowOpenPicker = (payload: YoutubePickerOpenPayload) => Promise<boolean>;
|
||||||
|
|
||||||
|
type YoutubeFlowDeps = {
|
||||||
|
probeYoutubeTracks: (url: string) => Promise<YoutubeTrackProbeResult>;
|
||||||
|
acquireYoutubeSubtitleTrack: typeof acquireYoutubeSubtitleTrack;
|
||||||
|
acquireYoutubeSubtitleTracks: typeof acquireYoutubeSubtitleTracks;
|
||||||
|
retimeYoutubePrimaryTrack: (input: {
|
||||||
|
targetUrl: string;
|
||||||
|
primaryTrack: YoutubeTrackOption;
|
||||||
|
primaryPath: string;
|
||||||
|
secondaryTrack: YoutubeTrackOption | null;
|
||||||
|
secondaryPath: string | null;
|
||||||
|
}) => Promise<string>;
|
||||||
|
openPicker: YoutubeFlowOpenPicker;
|
||||||
|
pauseMpv: () => void;
|
||||||
|
resumeMpv: () => void;
|
||||||
|
sendMpvCommand: (command: Array<string | number>) => void;
|
||||||
|
requestMpvProperty: (name: string) => Promise<unknown>;
|
||||||
|
refreshCurrentSubtitle: (text: string) => void;
|
||||||
|
startTokenizationWarmups: () => Promise<void>;
|
||||||
|
waitForTokenizationReady: () => Promise<void>;
|
||||||
|
waitForAnkiReady: () => Promise<void>;
|
||||||
|
wait: (ms: number) => Promise<void>;
|
||||||
|
waitForPlaybackWindowReady: () => Promise<void>;
|
||||||
|
waitForOverlayGeometryReady: () => Promise<void>;
|
||||||
|
focusOverlayWindow: () => void;
|
||||||
|
showMpvOsd: (text: string) => void;
|
||||||
|
warn: (message: string) => void;
|
||||||
|
log: (message: string) => void;
|
||||||
|
getYoutubeOutputDir: () => string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type YoutubeFlowSession = {
|
||||||
|
sessionId: string;
|
||||||
|
resolve: (request: YoutubePickerResolveRequest) => void;
|
||||||
|
reject: (error: Error) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const YOUTUBE_PICKER_SETTLE_DELAY_MS = 150;
|
||||||
|
const YOUTUBE_SECONDARY_RETRY_DELAY_MS = 350;
|
||||||
|
|
||||||
|
function createSessionId(): string {
|
||||||
|
return `yt-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTrackById(tracks: YoutubeTrackOption[], id: string | null): YoutubeTrackOption | null {
|
||||||
|
if (!id) return null;
|
||||||
|
return tracks.find((track) => track.id === id) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeOutputPath(value: string): string {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
return trimmed || path.join(os.tmpdir(), 'subminer-youtube-subs');
|
||||||
|
}
|
||||||
|
|
||||||
|
function createYoutubeFlowOsdProgress(showMpvOsd: (text: string) => void) {
|
||||||
|
const frames = ['|', '/', '-', '\\'];
|
||||||
|
let timer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
let frame = 0;
|
||||||
|
|
||||||
|
const stop = (): void => {
|
||||||
|
if (!timer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
clearInterval(timer);
|
||||||
|
timer = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const setMessage = (message: string): void => {
|
||||||
|
stop();
|
||||||
|
frame = 0;
|
||||||
|
showMpvOsd(message);
|
||||||
|
timer = setInterval(() => {
|
||||||
|
showMpvOsd(`${message} ${frames[frame % frames.length]}`);
|
||||||
|
frame += 1;
|
||||||
|
}, 180);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
setMessage,
|
||||||
|
stop,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function releasePlaybackGate(deps: YoutubeFlowDeps): void {
|
||||||
|
deps.sendMpvCommand(['script-message', 'subminer-autoplay-ready']);
|
||||||
|
deps.resumeMpv();
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreOverlayInputFocus(deps: YoutubeFlowDeps): void {
|
||||||
|
deps.focusOverlayWindow();
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTrackId(value: unknown): number | null {
|
||||||
|
if (typeof value === 'number' && Number.isInteger(value)) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const parsed = Number(value.trim());
|
||||||
|
return Number.isInteger(parsed) ? parsed : null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTrackListEntry(track: Record<string, unknown>): {
|
||||||
|
id: number | null;
|
||||||
|
lang: string;
|
||||||
|
title: string;
|
||||||
|
external: boolean;
|
||||||
|
externalFilename: string | null;
|
||||||
|
} {
|
||||||
|
const externalFilenameRaw =
|
||||||
|
typeof track['external-filename'] === 'string'
|
||||||
|
? track['external-filename']
|
||||||
|
: typeof track.external_filename === 'string'
|
||||||
|
? track.external_filename
|
||||||
|
: '';
|
||||||
|
const externalFilename = externalFilenameRaw.trim()
|
||||||
|
? resolveSubtitleSourcePath(externalFilenameRaw.trim())
|
||||||
|
: null;
|
||||||
|
return {
|
||||||
|
id: parseTrackId(track.id),
|
||||||
|
lang: String(track.lang || '').trim(),
|
||||||
|
title: String(track.title || '').trim(),
|
||||||
|
external: track.external === true,
|
||||||
|
externalFilename,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchExternalTrackId(
|
||||||
|
trackListRaw: unknown,
|
||||||
|
filePath: string,
|
||||||
|
trackOption: YoutubeTrackOption,
|
||||||
|
excludeId: number | null = null,
|
||||||
|
): number | null {
|
||||||
|
if (!Array.isArray(trackListRaw)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedFilePath = resolveSubtitleSourcePath(filePath);
|
||||||
|
const basename = path.basename(normalizedFilePath);
|
||||||
|
const externalTracks = trackListRaw
|
||||||
|
.filter(
|
||||||
|
(track): track is Record<string, unknown> => Boolean(track) && typeof track === 'object',
|
||||||
|
)
|
||||||
|
.filter((track) => track.type === 'sub')
|
||||||
|
.map(normalizeTrackListEntry)
|
||||||
|
.filter((track) => track.external && track.id !== null && track.id !== excludeId);
|
||||||
|
|
||||||
|
const exactPathMatch = externalTracks.find(
|
||||||
|
(track) => track.externalFilename === normalizedFilePath,
|
||||||
|
);
|
||||||
|
if (exactPathMatch?.id !== null && exactPathMatch?.id !== undefined) {
|
||||||
|
return exactPathMatch.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const basenameMatch = externalTracks.find(
|
||||||
|
(track) => track.externalFilename && path.basename(track.externalFilename) === basename,
|
||||||
|
);
|
||||||
|
if (basenameMatch?.id !== null && basenameMatch?.id !== undefined) {
|
||||||
|
return basenameMatch.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const languageMatch = externalTracks.find((track) => track.lang === trackOption.sourceLanguage);
|
||||||
|
if (languageMatch?.id !== null && languageMatch?.id !== undefined) {
|
||||||
|
return languageMatch.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedLanguageMatch = externalTracks.find(
|
||||||
|
(track) => track.lang === trackOption.language,
|
||||||
|
);
|
||||||
|
if (normalizedLanguageMatch?.id !== null && normalizedLanguageMatch?.id !== undefined) {
|
||||||
|
return normalizedLanguageMatch.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function injectDownloadedSubtitles(
|
||||||
|
deps: YoutubeFlowDeps,
|
||||||
|
primaryTrack: YoutubeTrackOption,
|
||||||
|
primaryPath: string,
|
||||||
|
secondaryTrack: YoutubeTrackOption | null,
|
||||||
|
secondaryPath: string | null,
|
||||||
|
): Promise<boolean> {
|
||||||
|
deps.sendMpvCommand(['set_property', 'sub-delay', 0]);
|
||||||
|
deps.sendMpvCommand(['set_property', 'sid', 'no']);
|
||||||
|
deps.sendMpvCommand(['set_property', 'secondary-sid', 'no']);
|
||||||
|
deps.sendMpvCommand([
|
||||||
|
'sub-add',
|
||||||
|
primaryPath,
|
||||||
|
'select',
|
||||||
|
path.basename(primaryPath),
|
||||||
|
primaryTrack.sourceLanguage,
|
||||||
|
]);
|
||||||
|
if (secondaryPath && secondaryTrack) {
|
||||||
|
deps.sendMpvCommand([
|
||||||
|
'sub-add',
|
||||||
|
secondaryPath,
|
||||||
|
'cached',
|
||||||
|
path.basename(secondaryPath),
|
||||||
|
secondaryTrack.sourceLanguage,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
let trackListRaw: unknown = null;
|
||||||
|
let primaryTrackId: number | null = null;
|
||||||
|
let secondaryTrackId: number | null = null;
|
||||||
|
for (let attempt = 0; attempt < 12; attempt += 1) {
|
||||||
|
await deps.wait(attempt === 0 ? 150 : 100);
|
||||||
|
trackListRaw = await deps.requestMpvProperty('track-list');
|
||||||
|
primaryTrackId = matchExternalTrackId(trackListRaw, primaryPath, primaryTrack);
|
||||||
|
secondaryTrackId =
|
||||||
|
secondaryPath && secondaryTrack
|
||||||
|
? matchExternalTrackId(trackListRaw, secondaryPath, secondaryTrack, primaryTrackId)
|
||||||
|
: null;
|
||||||
|
if (primaryTrackId !== null && (!secondaryPath || secondaryTrackId !== null)) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (primaryTrackId !== null) {
|
||||||
|
deps.sendMpvCommand(['set_property', 'sid', primaryTrackId]);
|
||||||
|
} else {
|
||||||
|
deps.warn(
|
||||||
|
`Unable to bind downloaded primary subtitle track in mpv: ${path.basename(primaryPath)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (secondaryPath && secondaryTrack) {
|
||||||
|
if (secondaryTrackId !== null) {
|
||||||
|
deps.sendMpvCommand(['set_property', 'secondary-sid', secondaryTrackId]);
|
||||||
|
} else {
|
||||||
|
deps.warn(
|
||||||
|
`Unable to bind downloaded secondary subtitle track in mpv: ${path.basename(secondaryPath)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentSubText = await deps.requestMpvProperty('sub-text');
|
||||||
|
if (typeof currentSubText === 'string' && currentSubText.trim().length > 0) {
|
||||||
|
deps.refreshCurrentSubtitle(currentSubText);
|
||||||
|
}
|
||||||
|
|
||||||
|
deps.showMpvOsd(
|
||||||
|
secondaryPath && secondaryTrack ? 'Primary and secondary subtitles loaded.' : 'Subtitles loaded.',
|
||||||
|
);
|
||||||
|
return typeof currentSubText === 'string' && currentSubText.trim().length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createYoutubeFlowRuntime(deps: YoutubeFlowDeps) {
|
||||||
|
let activeSession: YoutubeFlowSession | null = null;
|
||||||
|
|
||||||
|
const acquireSelectedTracks = async (input: {
|
||||||
|
targetUrl: string;
|
||||||
|
outputDir: string;
|
||||||
|
primaryTrack: YoutubeTrackOption;
|
||||||
|
secondaryTrack: YoutubeTrackOption | null;
|
||||||
|
mode: YoutubeFlowMode;
|
||||||
|
secondaryFailureLabel: string;
|
||||||
|
}): Promise<{ primaryPath: string; secondaryPath: string | null }> => {
|
||||||
|
if (!input.secondaryTrack) {
|
||||||
|
const primaryPath = (
|
||||||
|
await deps.acquireYoutubeSubtitleTrack({
|
||||||
|
targetUrl: input.targetUrl,
|
||||||
|
outputDir: input.outputDir,
|
||||||
|
track: input.primaryTrack,
|
||||||
|
mode: input.mode,
|
||||||
|
})
|
||||||
|
).path;
|
||||||
|
return { primaryPath, secondaryPath: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const batchResult = await deps.acquireYoutubeSubtitleTracks({
|
||||||
|
targetUrl: input.targetUrl,
|
||||||
|
outputDir: input.outputDir,
|
||||||
|
tracks: [input.primaryTrack, input.secondaryTrack],
|
||||||
|
mode: input.mode,
|
||||||
|
});
|
||||||
|
const primaryPath = batchResult.get(input.primaryTrack.id) ?? null;
|
||||||
|
const secondaryPath = batchResult.get(input.secondaryTrack.id) ?? null;
|
||||||
|
if (primaryPath) {
|
||||||
|
if (secondaryPath) {
|
||||||
|
return { primaryPath, secondaryPath };
|
||||||
|
}
|
||||||
|
|
||||||
|
deps.log(
|
||||||
|
`${
|
||||||
|
input.secondaryFailureLabel
|
||||||
|
}: No subtitle file was downloaded for ${input.secondaryTrack.sourceLanguage}; retrying secondary separately after delay.`,
|
||||||
|
);
|
||||||
|
await deps.wait(YOUTUBE_SECONDARY_RETRY_DELAY_MS);
|
||||||
|
try {
|
||||||
|
const retriedSecondaryPath = (
|
||||||
|
await deps.acquireYoutubeSubtitleTrack({
|
||||||
|
targetUrl: input.targetUrl,
|
||||||
|
outputDir: input.outputDir,
|
||||||
|
track: input.secondaryTrack,
|
||||||
|
mode: input.mode,
|
||||||
|
})
|
||||||
|
).path;
|
||||||
|
return { primaryPath, secondaryPath: retriedSecondaryPath };
|
||||||
|
} catch (error) {
|
||||||
|
deps.warn(
|
||||||
|
`${input.secondaryFailureLabel}: ${
|
||||||
|
error instanceof Error ? error.message : String(error)
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
return { primaryPath, secondaryPath: null };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// fall through to primary-only recovery
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const primaryPath = (
|
||||||
|
await deps.acquireYoutubeSubtitleTrack({
|
||||||
|
targetUrl: input.targetUrl,
|
||||||
|
outputDir: input.outputDir,
|
||||||
|
track: input.primaryTrack,
|
||||||
|
mode: input.mode,
|
||||||
|
})
|
||||||
|
).path;
|
||||||
|
return { primaryPath, secondaryPath: null };
|
||||||
|
} catch (error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveActivePicker = async (
|
||||||
|
request: YoutubePickerResolveRequest,
|
||||||
|
): Promise<YoutubePickerResolveResult> => {
|
||||||
|
if (!activeSession || activeSession.sessionId !== request.sessionId) {
|
||||||
|
return { ok: false, message: 'No active YouTube subtitle picker session.' };
|
||||||
|
}
|
||||||
|
activeSession.resolve(request);
|
||||||
|
return { ok: true, message: 'Picker selection accepted.' };
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelActivePicker = (): boolean => {
|
||||||
|
if (!activeSession) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
activeSession.resolve({
|
||||||
|
sessionId: activeSession.sessionId,
|
||||||
|
action: 'continue-without-subtitles',
|
||||||
|
primaryTrackId: null,
|
||||||
|
secondaryTrackId: null,
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const createPickerSelectionPromise = (sessionId: string): Promise<YoutubePickerResolveRequest> =>
|
||||||
|
new Promise<YoutubePickerResolveRequest>((resolve, reject) => {
|
||||||
|
activeSession = { sessionId, resolve, reject };
|
||||||
|
}).finally(() => {
|
||||||
|
activeSession = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
async function runYoutubePlaybackFlow(input: {
|
||||||
|
url: string;
|
||||||
|
mode: YoutubeFlowMode;
|
||||||
|
}): Promise<void> {
|
||||||
|
deps.showMpvOsd('Opening YouTube video');
|
||||||
|
const tokenizationWarmupPromise = deps.startTokenizationWarmups().catch((error) => {
|
||||||
|
deps.warn(
|
||||||
|
`Failed to warm subtitle tokenization prerequisites: ${
|
||||||
|
error instanceof Error ? error.message : String(error)
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
const probe = await deps.probeYoutubeTracks(input.url);
|
||||||
|
const defaults = chooseDefaultYoutubeTrackIds(probe.tracks);
|
||||||
|
const sessionId = createSessionId();
|
||||||
|
const outputDir = normalizeOutputPath(deps.getYoutubeOutputDir());
|
||||||
|
|
||||||
|
deps.pauseMpv();
|
||||||
|
|
||||||
|
const openPayload: YoutubePickerOpenPayload = {
|
||||||
|
sessionId,
|
||||||
|
url: input.url,
|
||||||
|
mode: input.mode,
|
||||||
|
tracks: probe.tracks,
|
||||||
|
defaultPrimaryTrackId: defaults.primaryTrackId,
|
||||||
|
defaultSecondaryTrackId: defaults.secondaryTrackId,
|
||||||
|
hasTracks: probe.tracks.length > 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (input.mode === 'download') {
|
||||||
|
await deps.waitForPlaybackWindowReady();
|
||||||
|
await deps.waitForOverlayGeometryReady();
|
||||||
|
await deps.wait(YOUTUBE_PICKER_SETTLE_DELAY_MS);
|
||||||
|
deps.showMpvOsd('Getting subtitles...');
|
||||||
|
const pickerSelection = createPickerSelectionPromise(sessionId);
|
||||||
|
void pickerSelection.catch(() => undefined);
|
||||||
|
const opened = await deps.openPicker(openPayload);
|
||||||
|
if (!opened) {
|
||||||
|
activeSession?.reject(new Error('Unable to open YouTube subtitle picker.'));
|
||||||
|
activeSession = null;
|
||||||
|
deps.warn('Unable to open YouTube subtitle picker; continuing without subtitles.');
|
||||||
|
releasePlaybackGate(deps);
|
||||||
|
restoreOverlayInputFocus(deps);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const request = await pickerSelection;
|
||||||
|
if (request.action === 'continue-without-subtitles') {
|
||||||
|
releasePlaybackGate(deps);
|
||||||
|
restoreOverlayInputFocus(deps);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const osdProgress = createYoutubeFlowOsdProgress(deps.showMpvOsd);
|
||||||
|
osdProgress.setMessage('Downloading subtitles...');
|
||||||
|
try {
|
||||||
|
const primaryTrack = getTrackById(probe.tracks, request.primaryTrackId);
|
||||||
|
if (!primaryTrack) {
|
||||||
|
deps.warn('No primary YouTube subtitle track selected; continuing without subtitles.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selected = normalizeYoutubeTrackSelection({
|
||||||
|
primaryTrackId: primaryTrack.id,
|
||||||
|
secondaryTrackId: request.secondaryTrackId,
|
||||||
|
});
|
||||||
|
const secondaryTrack = getTrackById(probe.tracks, selected.secondaryTrackId);
|
||||||
|
|
||||||
|
const acquired = await acquireSelectedTracks({
|
||||||
|
targetUrl: input.url,
|
||||||
|
outputDir,
|
||||||
|
primaryTrack,
|
||||||
|
secondaryTrack,
|
||||||
|
mode: input.mode,
|
||||||
|
secondaryFailureLabel: 'Failed to download secondary YouTube subtitle track',
|
||||||
|
});
|
||||||
|
const resolvedPrimaryPath = await deps.retimeYoutubePrimaryTrack({
|
||||||
|
targetUrl: input.url,
|
||||||
|
primaryTrack,
|
||||||
|
primaryPath: acquired.primaryPath,
|
||||||
|
secondaryTrack,
|
||||||
|
secondaryPath: acquired.secondaryPath,
|
||||||
|
});
|
||||||
|
osdProgress.setMessage('Loading subtitles...');
|
||||||
|
const refreshedActiveSubtitle = await injectDownloadedSubtitles(
|
||||||
|
deps,
|
||||||
|
primaryTrack,
|
||||||
|
resolvedPrimaryPath,
|
||||||
|
secondaryTrack,
|
||||||
|
acquired.secondaryPath,
|
||||||
|
);
|
||||||
|
await tokenizationWarmupPromise;
|
||||||
|
if (refreshedActiveSubtitle) {
|
||||||
|
await deps.waitForTokenizationReady();
|
||||||
|
}
|
||||||
|
await deps.waitForAnkiReady();
|
||||||
|
} catch (error) {
|
||||||
|
deps.warn(
|
||||||
|
`Failed to download primary YouTube subtitle track: ${
|
||||||
|
error instanceof Error ? error.message : String(error)
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
osdProgress.stop();
|
||||||
|
releasePlaybackGate(deps);
|
||||||
|
restoreOverlayInputFocus(deps);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const primaryTrack = getTrackById(probe.tracks, defaults.primaryTrackId);
|
||||||
|
const secondaryTrack = getTrackById(probe.tracks, defaults.secondaryTrackId);
|
||||||
|
if (!primaryTrack) {
|
||||||
|
deps.showMpvOsd('No usable YouTube subtitles found.');
|
||||||
|
releasePlaybackGate(deps);
|
||||||
|
restoreOverlayInputFocus(deps);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
deps.showMpvOsd('Getting subtitles...');
|
||||||
|
const acquired = await acquireSelectedTracks({
|
||||||
|
targetUrl: input.url,
|
||||||
|
outputDir,
|
||||||
|
primaryTrack,
|
||||||
|
secondaryTrack,
|
||||||
|
mode: input.mode,
|
||||||
|
secondaryFailureLabel: 'Failed to generate secondary YouTube subtitle track',
|
||||||
|
});
|
||||||
|
const resolvedPrimaryPath = await deps.retimeYoutubePrimaryTrack({
|
||||||
|
targetUrl: input.url,
|
||||||
|
primaryTrack,
|
||||||
|
primaryPath: acquired.primaryPath,
|
||||||
|
secondaryTrack,
|
||||||
|
secondaryPath: acquired.secondaryPath,
|
||||||
|
});
|
||||||
|
deps.showMpvOsd('Loading subtitles...');
|
||||||
|
const refreshedActiveSubtitle = await injectDownloadedSubtitles(
|
||||||
|
deps,
|
||||||
|
primaryTrack,
|
||||||
|
resolvedPrimaryPath,
|
||||||
|
secondaryTrack,
|
||||||
|
acquired.secondaryPath,
|
||||||
|
);
|
||||||
|
await tokenizationWarmupPromise;
|
||||||
|
if (refreshedActiveSubtitle) {
|
||||||
|
await deps.waitForTokenizationReady();
|
||||||
|
}
|
||||||
|
await deps.waitForAnkiReady();
|
||||||
|
} catch (error) {
|
||||||
|
deps.warn(
|
||||||
|
`Failed to generate primary YouTube subtitle track: ${
|
||||||
|
error instanceof Error ? error.message : String(error)
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
releasePlaybackGate(deps);
|
||||||
|
restoreOverlayInputFocus(deps);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
runYoutubePlaybackFlow,
|
||||||
|
resolveActivePicker,
|
||||||
|
cancelActivePicker,
|
||||||
|
hasActiveSession: () => Boolean(activeSession),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -332,6 +332,7 @@ function createKeyboardHandlerHarness() {
|
|||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
handleControllerDebugKeydown: () => false,
|
handleControllerDebugKeydown: () => false,
|
||||||
|
handleYoutubePickerKeydown: () => false,
|
||||||
handleSessionHelpKeydown: () => false,
|
handleSessionHelpKeydown: () => false,
|
||||||
openSessionHelpModal: () => {},
|
openSessionHelpModal: () => {},
|
||||||
appendClipboardVideoToQueue: () => {},
|
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 () => {
|
test('keyboard mode: controller helpers dispatch popup audio play/cycle and scroll bridge commands', async () => {
|
||||||
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
|
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||||
|
|
||||||
@@ -817,6 +846,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 () => {
|
test('keyboard mode: lookup toggle closes popup when DOM visibility is the source of truth', async () => {
|
||||||
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
|
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export function createKeyboardHandlers(
|
|||||||
handleSubsyncKeydown: (e: KeyboardEvent) => boolean;
|
handleSubsyncKeydown: (e: KeyboardEvent) => boolean;
|
||||||
handleKikuKeydown: (e: KeyboardEvent) => boolean;
|
handleKikuKeydown: (e: KeyboardEvent) => boolean;
|
||||||
handleJimakuKeydown: (e: KeyboardEvent) => boolean;
|
handleJimakuKeydown: (e: KeyboardEvent) => boolean;
|
||||||
|
handleYoutubePickerKeydown: (e: KeyboardEvent) => boolean;
|
||||||
handleControllerSelectKeydown: (e: KeyboardEvent) => boolean;
|
handleControllerSelectKeydown: (e: KeyboardEvent) => boolean;
|
||||||
handleControllerDebugKeydown: (e: KeyboardEvent) => boolean;
|
handleControllerDebugKeydown: (e: KeyboardEvent) => boolean;
|
||||||
handleSessionHelpKeydown: (e: KeyboardEvent) => boolean;
|
handleSessionHelpKeydown: (e: KeyboardEvent) => boolean;
|
||||||
@@ -479,6 +480,8 @@ export function createKeyboardHandlers(
|
|||||||
|
|
||||||
function handleSubtitleContentUpdated(): void {
|
function handleSubtitleContentUpdated(): void {
|
||||||
if (!ctx.state.keyboardDrivenModeEnabled) {
|
if (!ctx.state.keyboardDrivenModeEnabled) {
|
||||||
|
dispatchYomitanFrontendClearActiveTextSource();
|
||||||
|
clearNativeSubtitleSelection();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (pendingSelectionAnchorAfterSubtitleSeek) {
|
if (pendingSelectionAnchorAfterSubtitleSeek) {
|
||||||
@@ -678,6 +681,11 @@ export function createKeyboardHandlers(
|
|||||||
]);
|
]);
|
||||||
if (modifierOnlyCodes.has(e.code)) return false;
|
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.ctrlKey && !e.metaKey && !e.altKey && e.code === 'KeyM') {
|
||||||
if (e.repeat) return false;
|
if (e.repeat) return false;
|
||||||
dispatchYomitanPopupMineSelected();
|
dispatchYomitanPopupMineSelected();
|
||||||
@@ -834,6 +842,10 @@ export function createKeyboardHandlers(
|
|||||||
options.handleJimakuKeydown(e);
|
options.handleJimakuKeydown(e);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (ctx.state.youtubePickerModalOpen) {
|
||||||
|
options.handleYoutubePickerKeydown(e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (ctx.state.controllerSelectModalOpen) {
|
if (ctx.state.controllerSelectModalOpen) {
|
||||||
options.handleControllerSelectKeydown(e);
|
options.handleControllerSelectKeydown(e);
|
||||||
return;
|
return;
|
||||||
@@ -871,8 +883,8 @@ export function createKeyboardHandlers(
|
|||||||
) {
|
) {
|
||||||
if (handleYomitanPopupKeybind(e)) {
|
if (handleYomitanPopupKeybind(e)) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ctx.state.keyboardDrivenModeEnabled && handleKeyboardDrivenModeNavigation(e)) {
|
if (ctx.state.keyboardDrivenModeEnabled && handleKeyboardDrivenModeNavigation(e)) {
|
||||||
|
|||||||
@@ -3,7 +3,10 @@ import test from 'node:test';
|
|||||||
|
|
||||||
import type { SubtitleSidebarConfig } from '../../types';
|
import type { SubtitleSidebarConfig } from '../../types';
|
||||||
import { createMouseHandlers } from './mouse.js';
|
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() {
|
function createClassList() {
|
||||||
const classes = new Set<string>();
|
const classes = new Set<string>();
|
||||||
@@ -612,3 +615,153 @@ test('popup open pauses and popup close resumes when yomitan popup auto-pause is
|
|||||||
Object.defineProperty(globalThis, 'Node', { configurable: true, value: previousNode });
|
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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -25,6 +25,73 @@ export function createMouseHandlers(
|
|||||||
let popupPauseRequestId = 0;
|
let popupPauseRequestId = 0;
|
||||||
let pausedBySubtitleHover = false;
|
let pausedBySubtitleHover = false;
|
||||||
let pausedByYomitanPopup = 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;
|
||||||
|
if (!overSecondarySubtitle) {
|
||||||
|
ctx.dom.secondarySubContainer.classList.remove('secondary-sub-hover-active');
|
||||||
|
}
|
||||||
|
|
||||||
|
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(
|
function isWithinOtherSubtitleContainer(
|
||||||
relatedTarget: EventTarget | null,
|
relatedTarget: EventTarget | null,
|
||||||
@@ -192,6 +259,7 @@ export function createMouseHandlers(
|
|||||||
});
|
});
|
||||||
|
|
||||||
document.addEventListener('mousemove', (e: MouseEvent) => {
|
document.addEventListener('mousemove', (e: MouseEvent) => {
|
||||||
|
updatePointerPosition(e);
|
||||||
if (!ctx.state.isDragging) return;
|
if (!ctx.state.isDragging) return;
|
||||||
|
|
||||||
const deltaY = ctx.state.dragStartY - e.clientY;
|
const deltaY = ctx.state.dragStartY - e.clientY;
|
||||||
@@ -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 {
|
function setupSelectionObserver(): void {
|
||||||
document.addEventListener('selectionchange', () => {
|
document.addEventListener('selectionchange', () => {
|
||||||
const selection = window.getSelection();
|
const selection = window.getSelection();
|
||||||
@@ -283,7 +362,9 @@ export function createMouseHandlers(
|
|||||||
handleSecondaryMouseLeave: (event?: MouseEvent) => handleMouseLeave(event, true),
|
handleSecondaryMouseLeave: (event?: MouseEvent) => handleMouseLeave(event, true),
|
||||||
handleMouseEnter,
|
handleMouseEnter,
|
||||||
handleMouseLeave,
|
handleMouseLeave,
|
||||||
|
restorePointerInteractionState,
|
||||||
setupDragging,
|
setupDragging,
|
||||||
|
setupPointerTracking,
|
||||||
setupResizeHandler,
|
setupResizeHandler,
|
||||||
setupSelectionObserver,
|
setupSelectionObserver,
|
||||||
setupYomitanObserver,
|
setupYomitanObserver,
|
||||||
|
|||||||
174
src/renderer/modals/youtube-track-picker.test.ts
Normal file
174
src/renderer/modals/youtube-track-picker.test.ts
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
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);
|
||||||
|
},
|
||||||
|
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: () => {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('youtube track picker close restores focus and mouse-ignore state', () => {
|
||||||
|
const overlayFocusCalls: number[] = [];
|
||||||
|
const windowFocusCalls: number[] = [];
|
||||||
|
const ignoreCalls: Array<{ ignore: boolean; forward?: boolean }> = [];
|
||||||
|
const notifications: string[] = [];
|
||||||
|
const frontendCommands: unknown[] = [];
|
||||||
|
const syncCalls: string[] = [];
|
||||||
|
const originalWindow = globalThis.window;
|
||||||
|
const originalDocument = globalThis.document;
|
||||||
|
const originalCustomEvent = globalThis.CustomEvent;
|
||||||
|
|
||||||
|
class TestCustomEvent extends Event {
|
||||||
|
detail: unknown;
|
||||||
|
|
||||||
|
constructor(type: string, init?: { detail?: unknown }) {
|
||||||
|
super(type);
|
||||||
|
this.detail = init?.detail;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.defineProperty(globalThis, 'document', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
createElement: () => createFakeElement(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
Object.defineProperty(globalThis, 'window', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
dispatchEvent: (event: Event & { detail?: unknown }) => {
|
||||||
|
frontendCommands.push(event.detail ?? null);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
focus: () => {
|
||||||
|
windowFocusCalls.push(1);
|
||||||
|
},
|
||||||
|
electronAPI: {
|
||||||
|
notifyOverlayModalOpened: () => {},
|
||||||
|
notifyOverlayModalClosed: (modal: string) => {
|
||||||
|
notifications.push(modal);
|
||||||
|
},
|
||||||
|
youtubePickerResolve: async () => ({ ok: true, message: '' }),
|
||||||
|
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => {
|
||||||
|
ignoreCalls.push({ ignore, forward: options?.forward });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
Object.defineProperty(globalThis, 'CustomEvent', {
|
||||||
|
configurable: true,
|
||||||
|
value: TestCustomEvent,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const state = createRendererState();
|
||||||
|
const overlay = {
|
||||||
|
classList: createClassList(),
|
||||||
|
focus: () => {
|
||||||
|
overlayFocusCalls.push(1);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const dom = {
|
||||||
|
overlay,
|
||||||
|
youtubePickerModal: createFakeElement(),
|
||||||
|
youtubePickerTitle: createFakeElement(),
|
||||||
|
youtubePickerPrimarySelect: createFakeElement(),
|
||||||
|
youtubePickerSecondarySelect: createFakeElement(),
|
||||||
|
youtubePickerTracks: createFakeElement(),
|
||||||
|
youtubePickerStatus: createFakeElement(),
|
||||||
|
youtubePickerContinueButton: createFakeElement(),
|
||||||
|
youtubePickerCloseButton: createFakeElement(),
|
||||||
|
};
|
||||||
|
|
||||||
|
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',
|
||||||
|
mode: 'download',
|
||||||
|
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(overlayFocusCalls.length > 0, true);
|
||||||
|
assert.equal(windowFocusCalls.length > 0, true);
|
||||||
|
assert.deepEqual(ignoreCalls, [{ ignore: true, forward: true }]);
|
||||||
|
} finally {
|
||||||
|
Object.defineProperty(globalThis, 'window', { configurable: true, value: originalWindow });
|
||||||
|
Object.defineProperty(globalThis, 'document', { configurable: true, value: originalDocument });
|
||||||
|
Object.defineProperty(globalThis, 'CustomEvent', {
|
||||||
|
configurable: true,
|
||||||
|
value: originalCustomEvent,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
235
src/renderer/modals/youtube-track-picker.ts
Normal file
235
src/renderer/modals/youtube-track-picker.ts
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createYoutubeTrackPickerModal(
|
||||||
|
ctx: RendererContext,
|
||||||
|
options: {
|
||||||
|
modalStateReader: Pick<ModalStateReader, 'isAnyModalOpen'>;
|
||||||
|
restorePointerInteractionState: () => void;
|
||||||
|
syncSettingsModalSubtitleSuppression: () => void;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
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.innerHTML = '';
|
||||||
|
const payload = ctx.state.youtubePickerPayload;
|
||||||
|
if (!payload || payload.tracks.length === 0) {
|
||||||
|
const li = document.createElement('li');
|
||||||
|
li.innerHTML = '<span>No subtitle tracks found</span><span class="youtube-picker-track-meta">Continue without subtitles</span>';
|
||||||
|
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 syncSecondaryOptions(): void {
|
||||||
|
const payload = ctx.state.youtubePickerPayload;
|
||||||
|
const primaryTrackId = ctx.dom.youtubePickerPrimarySelect.value || null;
|
||||||
|
ctx.dom.youtubePickerSecondarySelect.innerHTML = '';
|
||||||
|
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 = `${payload.mode === 'generate' ? 'Generate' : 'Download'} subtitles for ${payload.url}`;
|
||||||
|
ctx.dom.youtubePickerPrimarySelect.innerHTML = '';
|
||||||
|
ctx.dom.youtubePickerSecondarySelect.innerHTML = '';
|
||||||
|
|
||||||
|
if (payload.tracks.length === 0) {
|
||||||
|
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> {
|
||||||
|
const payload = ctx.state.youtubePickerPayload;
|
||||||
|
if (!payload) return;
|
||||||
|
if (action === 'use-selected' && payload.hasTracks && !ctx.dom.youtubePickerPrimarySelect.value) {
|
||||||
|
setStatus('Primary subtitle selection is required.', true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await window.electronAPI.youtubePickerResolve({
|
||||||
|
sessionId: payload.sessionId,
|
||||||
|
action,
|
||||||
|
primaryTrackId: action === 'use-selected' ? ctx.dom.youtubePickerPrimarySelect.value || null : null,
|
||||||
|
secondaryTrackId:
|
||||||
|
action === 'use-selected' ? ctx.dom.youtubePickerSecondarySelect.value || null : null,
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
setStatus(response.message, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
closeYoutubePickerModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
function openYoutubePickerModal(payload: YoutubePickerOpenPayload): void {
|
||||||
|
if (ctx.state.youtubePickerModalOpen) 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();
|
||||||
|
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();
|
||||||
|
void resolveSelection(
|
||||||
|
ctx.state.youtubePickerPayload?.hasTracks ? 'use-selected' : 'continue-without-subtitles',
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
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(
|
||||||
|
ctx.state.youtubePickerPayload?.hasTracks ? 'use-selected' : 'continue-without-subtitles',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
ctx.dom.youtubePickerCloseButton.addEventListener('click', () => {
|
||||||
|
void resolveSelection('continue-without-subtitles');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
closeYoutubePickerModal,
|
||||||
|
handleYoutubePickerKeydown,
|
||||||
|
openYoutubePickerModal,
|
||||||
|
wireDomEvents,
|
||||||
|
};
|
||||||
|
}
|
||||||
63
src/renderer/overlay-mouse-ignore.test.ts
Normal file
63
src/renderer/overlay-mouse-ignore.test.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -9,6 +9,7 @@ function isBlockingOverlayModalOpen(state: RendererState): boolean {
|
|||||||
state.controllerSelectModalOpen ||
|
state.controllerSelectModalOpen ||
|
||||||
state.controllerDebugModalOpen ||
|
state.controllerDebugModalOpen ||
|
||||||
state.jimakuModalOpen ||
|
state.jimakuModalOpen ||
|
||||||
|
state.youtubePickerModalOpen ||
|
||||||
state.kikuModalOpen ||
|
state.kikuModalOpen ||
|
||||||
state.runtimeOptionsModalOpen ||
|
state.runtimeOptionsModalOpen ||
|
||||||
state.subsyncModalOpen ||
|
state.subsyncModalOpen ||
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import { createSessionHelpModal } from './modals/session-help.js';
|
|||||||
import { createSubtitleSidebarModal } from './modals/subtitle-sidebar.js';
|
import { createSubtitleSidebarModal } from './modals/subtitle-sidebar.js';
|
||||||
import { createRuntimeOptionsModal } from './modals/runtime-options.js';
|
import { createRuntimeOptionsModal } from './modals/runtime-options.js';
|
||||||
import { createSubsyncModal } from './modals/subsync.js';
|
import { createSubsyncModal } from './modals/subsync.js';
|
||||||
|
import { createYoutubeTrackPickerModal } from './modals/youtube-track-picker.js';
|
||||||
import { createPositioningController } from './positioning.js';
|
import { createPositioningController } from './positioning.js';
|
||||||
import { createOverlayContentMeasurementReporter } from './overlay-content-measurement.js';
|
import { createOverlayContentMeasurementReporter } from './overlay-content-measurement.js';
|
||||||
import { syncOverlayMouseIgnoreState } from './overlay-mouse-ignore.js';
|
import { syncOverlayMouseIgnoreState } from './overlay-mouse-ignore.js';
|
||||||
@@ -68,6 +69,7 @@ function isAnySettingsModalOpen(): boolean {
|
|||||||
ctx.state.subsyncModalOpen ||
|
ctx.state.subsyncModalOpen ||
|
||||||
ctx.state.kikuModalOpen ||
|
ctx.state.kikuModalOpen ||
|
||||||
ctx.state.jimakuModalOpen ||
|
ctx.state.jimakuModalOpen ||
|
||||||
|
ctx.state.youtubePickerModalOpen ||
|
||||||
ctx.state.sessionHelpModalOpen
|
ctx.state.sessionHelpModalOpen
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -80,6 +82,7 @@ function isAnyModalOpen(): boolean {
|
|||||||
ctx.state.kikuModalOpen ||
|
ctx.state.kikuModalOpen ||
|
||||||
ctx.state.runtimeOptionsModalOpen ||
|
ctx.state.runtimeOptionsModalOpen ||
|
||||||
ctx.state.subsyncModalOpen ||
|
ctx.state.subsyncModalOpen ||
|
||||||
|
ctx.state.youtubePickerModalOpen ||
|
||||||
ctx.state.sessionHelpModalOpen ||
|
ctx.state.sessionHelpModalOpen ||
|
||||||
ctx.state.subtitleSidebarModalOpen
|
ctx.state.subtitleSidebarModalOpen
|
||||||
);
|
);
|
||||||
@@ -128,11 +131,29 @@ const jimakuModal = createJimakuModal(ctx, {
|
|||||||
modalStateReader: { isAnyModalOpen },
|
modalStateReader: { isAnyModalOpen },
|
||||||
syncSettingsModalSubtitleSuppression,
|
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, {
|
const keyboardHandlers = createKeyboardHandlers(ctx, {
|
||||||
handleRuntimeOptionsKeydown: runtimeOptionsModal.handleRuntimeOptionsKeydown,
|
handleRuntimeOptionsKeydown: runtimeOptionsModal.handleRuntimeOptionsKeydown,
|
||||||
handleSubsyncKeydown: subsyncModal.handleSubsyncKeydown,
|
handleSubsyncKeydown: subsyncModal.handleSubsyncKeydown,
|
||||||
handleKikuKeydown: kikuModal.handleKikuKeydown,
|
handleKikuKeydown: kikuModal.handleKikuKeydown,
|
||||||
handleJimakuKeydown: jimakuModal.handleJimakuKeydown,
|
handleJimakuKeydown: jimakuModal.handleJimakuKeydown,
|
||||||
|
handleYoutubePickerKeydown: youtubePickerModal.handleYoutubePickerKeydown,
|
||||||
handleControllerSelectKeydown: controllerSelectModal.handleControllerSelectKeydown,
|
handleControllerSelectKeydown: controllerSelectModal.handleControllerSelectKeydown,
|
||||||
handleControllerDebugKeydown: controllerDebugModal.handleControllerDebugKeydown,
|
handleControllerDebugKeydown: controllerDebugModal.handleControllerDebugKeydown,
|
||||||
handleSessionHelpKeydown: sessionHelpModal.handleSessionHelpKeydown,
|
handleSessionHelpKeydown: sessionHelpModal.handleSessionHelpKeydown,
|
||||||
@@ -153,18 +174,6 @@ const keyboardHandlers = createKeyboardHandlers(ctx, {
|
|||||||
void subtitleSidebarModal.toggleSubtitleSidebarModal();
|
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 lastSubtitlePreview = '';
|
||||||
let lastSecondarySubtitlePreview = '';
|
let lastSecondarySubtitlePreview = '';
|
||||||
@@ -194,6 +203,7 @@ function getActiveModal(): string | null {
|
|||||||
if (ctx.state.controllerDebugModalOpen) return 'controller-debug';
|
if (ctx.state.controllerDebugModalOpen) return 'controller-debug';
|
||||||
if (ctx.state.subtitleSidebarModalOpen) return 'subtitle-sidebar';
|
if (ctx.state.subtitleSidebarModalOpen) return 'subtitle-sidebar';
|
||||||
if (ctx.state.jimakuModalOpen) return 'jimaku';
|
if (ctx.state.jimakuModalOpen) return 'jimaku';
|
||||||
|
if (ctx.state.youtubePickerModalOpen) return 'youtube-track-picker';
|
||||||
if (ctx.state.kikuModalOpen) return 'kiku';
|
if (ctx.state.kikuModalOpen) return 'kiku';
|
||||||
if (ctx.state.runtimeOptionsModalOpen) return 'runtime-options';
|
if (ctx.state.runtimeOptionsModalOpen) return 'runtime-options';
|
||||||
if (ctx.state.subsyncModalOpen) return 'subsync';
|
if (ctx.state.subsyncModalOpen) return 'subsync';
|
||||||
@@ -214,6 +224,9 @@ function dismissActiveUiAfterError(): void {
|
|||||||
if (ctx.state.jimakuModalOpen) {
|
if (ctx.state.jimakuModalOpen) {
|
||||||
jimakuModal.closeJimakuModal();
|
jimakuModal.closeJimakuModal();
|
||||||
}
|
}
|
||||||
|
if (ctx.state.youtubePickerModalOpen) {
|
||||||
|
youtubePickerModal.closeYoutubePickerModal();
|
||||||
|
}
|
||||||
if (ctx.state.runtimeOptionsModalOpen) {
|
if (ctx.state.runtimeOptionsModalOpen) {
|
||||||
runtimeOptionsModal.closeRuntimeOptionsModal();
|
runtimeOptionsModal.closeRuntimeOptionsModal();
|
||||||
}
|
}
|
||||||
@@ -416,6 +429,16 @@ function registerModalOpenHandlers(): void {
|
|||||||
window.electronAPI.notifyOverlayModalOpened('jimaku');
|
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) => {
|
window.electronAPI.onSubsyncManualOpen((payload: SubsyncManualPayload) => {
|
||||||
runGuarded('subsync:manual-open', () => {
|
runGuarded('subsync:manual-open', () => {
|
||||||
subsyncModal.openSubsyncModal(payload);
|
subsyncModal.openSubsyncModal(payload);
|
||||||
@@ -528,6 +551,7 @@ async function init(): Promise<void> {
|
|||||||
ctx.dom.secondarySubContainer.addEventListener('mouseleave', mouseHandlers.handleSecondaryMouseLeave);
|
ctx.dom.secondarySubContainer.addEventListener('mouseleave', mouseHandlers.handleSecondaryMouseLeave);
|
||||||
|
|
||||||
mouseHandlers.setupResizeHandler();
|
mouseHandlers.setupResizeHandler();
|
||||||
|
mouseHandlers.setupPointerTracking();
|
||||||
mouseHandlers.setupSelectionObserver();
|
mouseHandlers.setupSelectionObserver();
|
||||||
mouseHandlers.setupYomitanObserver();
|
mouseHandlers.setupYomitanObserver();
|
||||||
setupDragDropToMpvQueue();
|
setupDragDropToMpvQueue();
|
||||||
@@ -536,6 +560,7 @@ async function init(): Promise<void> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
jimakuModal.wireDomEvents();
|
jimakuModal.wireDomEvents();
|
||||||
|
youtubePickerModal.wireDomEvents();
|
||||||
kikuModal.wireDomEvents();
|
kikuModal.wireDomEvents();
|
||||||
runtimeOptionsModal.wireDomEvents();
|
runtimeOptionsModal.wireDomEvents();
|
||||||
subsyncModal.wireDomEvents();
|
subsyncModal.wireDomEvents();
|
||||||
|
|||||||
43
src/types.ts
43
src/types.ts
@@ -139,6 +139,7 @@ export interface MpvClient {
|
|||||||
currentSubStart: number;
|
currentSubStart: number;
|
||||||
currentSubEnd: number;
|
currentSubEnd: number;
|
||||||
currentAudioStreamIndex: number | null;
|
currentAudioStreamIndex: number | null;
|
||||||
|
requestProperty?: (name: string) => Promise<unknown>;
|
||||||
send(command: { command: unknown[]; request_id?: number }): boolean;
|
send(command: { command: unknown[]; request_id?: number }): boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -559,6 +560,41 @@ export interface ControllerRuntimeSnapshot {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type JimakuLanguagePreference = 'ja' | 'en' | 'none';
|
export type JimakuLanguagePreference = 'ja' | 'en' | 'none';
|
||||||
|
export type YoutubeFlowMode = 'download' | 'generate';
|
||||||
|
export type YoutubeTrackKind = 'manual' | 'auto';
|
||||||
|
|
||||||
|
export interface YoutubeTrackOption {
|
||||||
|
id: string;
|
||||||
|
language: string;
|
||||||
|
sourceLanguage: string;
|
||||||
|
kind: YoutubeTrackKind;
|
||||||
|
label: string;
|
||||||
|
title?: string;
|
||||||
|
downloadUrl?: string;
|
||||||
|
fileExtension?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface YoutubePickerOpenPayload {
|
||||||
|
sessionId: string;
|
||||||
|
url: string;
|
||||||
|
mode: YoutubeFlowMode;
|
||||||
|
tracks: YoutubeTrackOption[];
|
||||||
|
defaultPrimaryTrackId: string | null;
|
||||||
|
defaultSecondaryTrackId: string | null;
|
||||||
|
hasTracks: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface YoutubePickerResolveRequest {
|
||||||
|
sessionId: string;
|
||||||
|
action: 'use-selected' | 'continue-without-subtitles';
|
||||||
|
primaryTrackId: string | null;
|
||||||
|
secondaryTrackId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface YoutubePickerResolveResult {
|
||||||
|
ok: boolean;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface JimakuConfig {
|
export interface JimakuConfig {
|
||||||
apiKey?: string;
|
apiKey?: string;
|
||||||
@@ -1166,14 +1202,20 @@ export interface ElectronAPI {
|
|||||||
onRuntimeOptionsChanged: (callback: (options: RuntimeOptionState[]) => void) => void;
|
onRuntimeOptionsChanged: (callback: (options: RuntimeOptionState[]) => void) => void;
|
||||||
onOpenRuntimeOptions: (callback: () => void) => void;
|
onOpenRuntimeOptions: (callback: () => void) => void;
|
||||||
onOpenJimaku: (callback: () => void) => void;
|
onOpenJimaku: (callback: () => void) => void;
|
||||||
|
onOpenYoutubeTrackPicker: (callback: (payload: YoutubePickerOpenPayload) => void) => void;
|
||||||
|
onCancelYoutubeTrackPicker: (callback: () => void) => void;
|
||||||
onKeyboardModeToggleRequested: (callback: () => void) => void;
|
onKeyboardModeToggleRequested: (callback: () => void) => void;
|
||||||
onLookupWindowToggleRequested: (callback: () => void) => void;
|
onLookupWindowToggleRequested: (callback: () => void) => void;
|
||||||
appendClipboardVideoToQueue: () => Promise<ClipboardAppendResult>;
|
appendClipboardVideoToQueue: () => Promise<ClipboardAppendResult>;
|
||||||
|
youtubePickerResolve: (
|
||||||
|
request: YoutubePickerResolveRequest,
|
||||||
|
) => Promise<YoutubePickerResolveResult>;
|
||||||
notifyOverlayModalClosed: (
|
notifyOverlayModalClosed: (
|
||||||
modal:
|
modal:
|
||||||
| 'runtime-options'
|
| 'runtime-options'
|
||||||
| 'subsync'
|
| 'subsync'
|
||||||
| 'jimaku'
|
| 'jimaku'
|
||||||
|
| 'youtube-track-picker'
|
||||||
| 'kiku'
|
| 'kiku'
|
||||||
| 'controller-select'
|
| 'controller-select'
|
||||||
| 'controller-debug'
|
| 'controller-debug'
|
||||||
@@ -1184,6 +1226,7 @@ export interface ElectronAPI {
|
|||||||
| 'runtime-options'
|
| 'runtime-options'
|
||||||
| 'subsync'
|
| 'subsync'
|
||||||
| 'jimaku'
|
| 'jimaku'
|
||||||
|
| 'youtube-track-picker'
|
||||||
| 'kiku'
|
| 'kiku'
|
||||||
| 'controller-select'
|
| 'controller-select'
|
||||||
| 'controller-debug'
|
| 'controller-debug'
|
||||||
|
|||||||
Reference in New Issue
Block a user