feat: auto-load youtube subtitles before manual picker

This commit is contained in:
2026-03-23 14:13:53 -07:00
parent b7e0026d48
commit 0c21e36e30
48 changed files with 1564 additions and 356 deletions

View File

@@ -13,7 +13,7 @@ ${B}Session${R}
--background Start in tray/background mode
--start Connect to mpv and launch overlay
--launch-mpv ${D}[targets...]${R} Launch mpv with the SubMiner mpv profile and exit
--youtube-play ${D}URL${R} Open YouTube subtitle picker flow for a URL
--youtube-play ${D}URL${R} Start app-owned YouTube subtitle auto-load flow for a URL
--youtube-mode ${D}download|generate${R} Subtitle acquisition mode for YouTube flow
--stop Stop the running instance
--stats Open the stats dashboard in your browser

View File

@@ -77,6 +77,7 @@ test('default keybindings include primary and secondary subtitle track cycling o
);
assert.deepEqual(keybindingMap.get('KeyJ'), ['cycle', 'sid']);
assert.deepEqual(keybindingMap.get('Shift+KeyJ'), ['cycle', 'secondary-sid']);
assert.deepEqual(keybindingMap.get('Ctrl+Shift+KeyJ'), ['__youtube-picker-open']);
});
test('default keybindings include fullscreen on F', () => {

View File

@@ -46,6 +46,7 @@ export const SPECIAL_COMMANDS = {
PLAY_NEXT_SUBTITLE: '__play-next-subtitle',
SHIFT_SUB_DELAY_TO_NEXT_SUBTITLE_START: '__sub-delay-next-line',
SHIFT_SUB_DELAY_TO_PREVIOUS_SUBTITLE_START: '__sub-delay-prev-line',
YOUTUBE_PICKER_OPEN: '__youtube-picker-open',
} as const;
export const DEFAULT_KEYBINDINGS: NonNullable<ResolvedConfig['keybindings']> = [
@@ -64,6 +65,7 @@ export const DEFAULT_KEYBINDINGS: NonNullable<ResolvedConfig['keybindings']> = [
key: 'Shift+BracketLeft',
command: [SPECIAL_COMMANDS.SHIFT_SUB_DELAY_TO_PREVIOUS_SUBTITLE_START],
},
{ key: 'Ctrl+Shift+KeyJ', command: [SPECIAL_COMMANDS.YOUTUBE_PICKER_OPEN] },
{ key: 'Ctrl+Shift+KeyH', command: [SPECIAL_COMMANDS.REPLAY_SUBTITLE] },
{ key: 'Ctrl+Shift+KeyL', command: [SPECIAL_COMMANDS.PLAY_NEXT_SUBTITLE] },
{ key: 'KeyQ', command: ['quit'] },

View File

@@ -15,6 +15,7 @@ function createOptions(overrides: Partial<Parameters<typeof handleMpvCommandFrom
PLAY_NEXT_SUBTITLE: '__play-next-subtitle',
SHIFT_SUB_DELAY_TO_NEXT_SUBTITLE_START: '__sub-delay-next-line',
SHIFT_SUB_DELAY_TO_PREVIOUS_SUBTITLE_START: '__sub-delay-prev-line',
YOUTUBE_PICKER_OPEN: '__youtube-picker-open',
},
triggerSubsyncFromConfig: () => {
calls.push('subsync');
@@ -22,6 +23,9 @@ function createOptions(overrides: Partial<Parameters<typeof handleMpvCommandFrom
openRuntimeOptionsPalette: () => {
calls.push('runtime-options');
},
openYoutubeTrackPicker: () => {
calls.push('youtube-picker');
},
runtimeOptionsCycle: () => ({ ok: true }),
showMpvOsd: (text) => {
osd.push(text);
@@ -98,6 +102,14 @@ test('handleMpvCommandFromIpc dispatches special subtitle-delay shift command',
assert.deepEqual(osd, []);
});
test('handleMpvCommandFromIpc dispatches special youtube picker open command', () => {
const { options, calls, sentCommands, osd } = createOptions();
handleMpvCommandFromIpc(['__youtube-picker-open'], options);
assert.deepEqual(calls, ['youtube-picker']);
assert.deepEqual(sentCommands, []);
assert.deepEqual(osd, []);
});
test('handleMpvCommandFromIpc does not forward commands while disconnected', () => {
const { options, sentCommands, osd } = createOptions({
isMpvConnected: () => false,

View File

@@ -14,9 +14,11 @@ export interface HandleMpvCommandFromIpcOptions {
PLAY_NEXT_SUBTITLE: string;
SHIFT_SUB_DELAY_TO_NEXT_SUBTITLE_START: string;
SHIFT_SUB_DELAY_TO_PREVIOUS_SUBTITLE_START: string;
YOUTUBE_PICKER_OPEN: string;
};
triggerSubsyncFromConfig: () => void;
openRuntimeOptionsPalette: () => void;
openYoutubeTrackPicker: () => void | Promise<void>;
runtimeOptionsCycle: (id: RuntimeOptionId, direction: 1 | -1) => RuntimeOptionApplyResult;
showMpvOsd: (text: string) => void;
mpvReplaySubtitle: () => void;
@@ -90,6 +92,11 @@ export function handleMpvCommandFromIpc(
return;
}
if (first === options.specialCommands.YOUTUBE_PICKER_OPEN) {
void options.openYoutubeTrackPicker();
return;
}
if (
first === options.specialCommands.SHIFT_SUB_DELAY_TO_NEXT_SUBTITLE_START ||
first === options.specialCommands.SHIFT_SUB_DELAY_TO_PREVIOUS_SUBTITLE_START

View File

@@ -200,6 +200,44 @@ test('Windows visible overlay stays click-through and does not steal focus while
assert.ok(!calls.includes('focus'));
});
test('visible overlay stays hidden while a modal window is active', () => {
const { window, calls } = createMainWindowRecorder();
const tracker: WindowTrackerStub = {
isTracking: () => true,
getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
};
updateVisibleOverlayVisibility({
visibleOverlayVisible: true,
modalActive: true,
mainWindow: window as never,
windowTracker: tracker as never,
trackerNotReadyWarningShown: false,
setTrackerNotReadyWarningShown: () => {},
updateVisibleOverlayBounds: () => {
calls.push('update-bounds');
},
ensureOverlayWindowLevel: () => {
calls.push('ensure-level');
},
syncPrimaryOverlayWindowLayer: () => {
calls.push('sync-layer');
},
enforceOverlayLayerOrder: () => {
calls.push('enforce-order');
},
syncOverlayShortcuts: () => {
calls.push('sync-shortcuts');
},
isMacOSPlatform: true,
isWindowsPlatform: false,
} as never);
assert.ok(calls.includes('hide'));
assert.ok(!calls.includes('show'));
assert.ok(!calls.includes('update-bounds'));
});
test('macOS tracked visible overlay stays visible without passively stealing focus', () => {
const { window, calls } = createMainWindowRecorder();
const tracker: WindowTrackerStub = {

View File

@@ -4,6 +4,7 @@ import { WindowGeometry } from '../../types';
export function updateVisibleOverlayVisibility(args: {
visibleOverlayVisible: boolean;
modalActive?: boolean;
forceMousePassthrough?: boolean;
mainWindow: BrowserWindow | null;
windowTracker: BaseWindowTracker | null;
@@ -28,6 +29,12 @@ export function updateVisibleOverlayVisibility(args: {
const mainWindow = args.mainWindow;
if (args.modalActive) {
mainWindow.hide();
args.syncOverlayShortcuts();
return;
}
const showPassiveVisibleOverlay = (): void => {
const forceMousePassthrough = args.forceMousePassthrough === true;
if (args.isWindowsPlatform || forceMousePassthrough) {

View File

@@ -394,6 +394,7 @@ import { registerIpcRuntimeServices } from './main/ipc-runtime';
import { createAnkiJimakuIpcRuntimeServiceDeps } from './main/dependencies';
import { handleCliCommandRuntimeServiceWithContext } from './main/cli-runtime';
import { createOverlayModalRuntimeService } from './main/overlay-runtime';
import { openYoutubeTrackPicker } from './main/runtime/youtube-picker-open';
import type { OverlayHostedModal } from './shared/ipc/contracts';
import { createOverlayShortcutsRuntimeService } from './main/overlay-shortcuts-runtime';
import {
@@ -754,6 +755,7 @@ process.on('SIGTERM', () => {
const overlayManager = createOverlayManager();
let overlayModalInputExclusive = false;
let syncOverlayShortcutsForModal: (isActive: boolean) => void = () => {};
let syncOverlayVisibilityForModal: () => void = () => {};
const handleModalInputStateChange = (isActive: boolean): void => {
if (overlayModalInputExclusive === isActive) return;
@@ -770,6 +772,7 @@ const handleModalInputStateChange = (isActive: boolean): void => {
}
}
syncOverlayShortcutsForModal(isActive);
syncOverlayVisibilityForModal();
};
const buildOverlayContentMeasurementStoreMainDepsHandler =
@@ -820,27 +823,15 @@ const youtubeFlowRuntime = createYoutubeFlowRuntime({
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.',
return await openYoutubeTrackPicker(
{
sendToActiveOverlayWindow: (channel, nextPayload, runtimeOptions) =>
overlayModalRuntime.sendToActiveOverlayWindow(channel, nextPayload, runtimeOptions),
waitForModalOpen: (modal, timeoutMs) => overlayModalRuntime.waitForModalOpen(modal, timeoutMs),
logWarn: (message) => logger.warn(message),
},
payload,
);
if (!sendPickerOpen(!preferDedicatedModalWindow)) {
return false;
}
return await overlayModalRuntime.waitForModalOpen('youtube-track-picker', 1500);
},
pauseMpv: () => {
sendMpvCommandRuntime(appState.mpvClient, ['set_property', 'pause', 'yes']);
@@ -859,6 +850,9 @@ const youtubeFlowRuntime = createYoutubeFlowRuntime({
refreshCurrentSubtitle: (text: string) => {
subtitleProcessingController.refreshCurrentSubtitle(text);
},
refreshSubtitleSidebarSource: async (sourcePath: string) => {
await refreshSubtitleSidebarFromSource(sourcePath);
},
startTokenizationWarmups: async () => {
await startTokenizationWarmups();
},
@@ -889,14 +883,30 @@ const youtubeFlowRuntime = createYoutubeFlowRuntime({
wait: (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)),
waitForPlaybackWindowReady: async () => {
const deadline = Date.now() + 4000;
let stableGeometry: WindowGeometry | null = null;
let stableSinceMs = 0;
while (Date.now() < deadline) {
const tracker = appState.windowTracker;
if (tracker && tracker.isTracking() && tracker.getGeometry()) {
return;
const trackerGeometry = tracker?.getGeometry() ?? null;
const mediaPath =
appState.currentMediaPath?.trim() || appState.mpvClient?.currentVideoPath?.trim() || '';
const trackerFocused = tracker?.isTargetWindowFocused() ?? false;
if (tracker && tracker.isTracking() && trackerGeometry && trackerFocused && mediaPath) {
if (!geometryMatches(stableGeometry, trackerGeometry)) {
stableGeometry = trackerGeometry;
stableSinceMs = Date.now();
} else if (Date.now() - stableSinceMs >= 200) {
return;
}
} else {
stableGeometry = null;
stableSinceMs = 0;
}
await new Promise((resolve) => setTimeout(resolve, 100));
}
logger.warn('Timed out waiting for tracked playback window before opening YouTube subtitle picker.');
logger.warn(
'Timed out waiting for tracked playback window focus/media readiness before opening YouTube subtitle picker.',
);
},
waitForOverlayGeometryReady: async () => {
const deadline = Date.now() + 4000;
@@ -924,6 +934,7 @@ const youtubeFlowRuntime = createYoutubeFlowRuntime({
}
},
showMpvOsd: (text: string) => showMpvOsd(text),
reportSubtitleFailure: (message: string) => reportYoutubeSubtitleFailure(message),
warn: (message: string) => logger.warn(message),
log: (message: string) => logger.info(message),
getYoutubeOutputDir: () => path.join(os.homedir(), '.cache', 'subminer', 'youtube-subs'),
@@ -958,7 +969,6 @@ async function runYoutubePlaybackFlowMain(request: {
if (!appState.mpvClient?.connected) {
appState.mpvClient?.connect();
}
await ensureOverlayRuntimeReady();
try {
await youtubeFlowRuntime.runYoutubePlaybackFlow({
url: request.url,
@@ -973,11 +983,6 @@ async function runYoutubePlaybackFlowMain(request: {
}
}
async function ensureOverlayRuntimeReady(): Promise<void> {
await ensureYomitanExtensionLoaded();
initializeOverlayRuntime();
}
let firstRunSetupMessage: string | null = null;
const resolveWindowsMpvShortcutRuntimePaths = () =>
resolveWindowsMpvShortcutPaths({
@@ -1239,6 +1244,33 @@ function isYoutubePlaybackActiveNow(): boolean {
);
}
function reportYoutubeSubtitleFailure(message: string): void {
const type = getResolvedConfig().ankiConnect.behavior.notificationType;
if (type === 'osd' || type === 'both') {
showMpvOsd(message);
}
if (type === 'system' || type === 'both') {
try {
showDesktopNotification('SubMiner', { body: message });
} catch {
logger.warn(`Unable to show desktop notification: ${message}`);
}
}
}
async function openYoutubeTrackPickerFromPlayback(): Promise<void> {
const currentMediaPath =
appState.currentMediaPath?.trim() || appState.mpvClient?.currentVideoPath?.trim() || '';
if (!isYoutubePlaybackActiveNow() || !currentMediaPath) {
showMpvOsd('YouTube subtitle picker is only available during YouTube playback.');
return;
}
await youtubeFlowRuntime.openManualPicker({
url: currentMediaPath,
mode: 'download',
});
}
function maybeSignalPluginAutoplayReady(
payload: SubtitleData,
options?: { forceWhilePaused?: boolean },
@@ -1416,6 +1448,18 @@ const subtitlePrefetchInitController = createSubtitlePrefetchInitController({
},
});
async function refreshSubtitleSidebarFromSource(sourcePath: string): Promise<void> {
const normalizedSourcePath = resolveSubtitleSourcePath(sourcePath.trim());
if (!normalizedSourcePath) {
return;
}
await subtitlePrefetchInitController.initSubtitlePrefetch(
normalizedSourcePath,
lastObservedTimePos,
normalizedSourcePath,
);
}
async function refreshSubtitlePrefetchFromActiveTrack(): Promise<void> {
const client = appState.mpvClient;
if (!client?.connected) {
@@ -1860,6 +1904,7 @@ const characterDictionaryAutoSyncRuntime = createCharacterDictionaryAutoSyncRunt
const overlayVisibilityRuntime = createOverlayVisibilityRuntimeService(
createBuildOverlayVisibilityRuntimeMainDepsHandler({
getMainWindow: () => overlayManager.getMainWindow(),
getModalActive: () => overlayModalInputExclusive,
getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
getForceMousePassthrough: () => appState.statsOverlayVisible,
getWindowTracker: () => appState.windowTracker,
@@ -1921,6 +1966,9 @@ const buildRestorePreviousSecondarySubVisibilityMainDepsHandler =
createBuildRestorePreviousSecondarySubVisibilityMainDepsHandler({
getMpvClient: () => appState.mpvClient,
});
syncOverlayVisibilityForModal = () => {
overlayVisibilityRuntime.updateVisibleOverlayVisibility();
};
const restorePreviousSecondarySubVisibilityMainDeps =
buildRestorePreviousSecondarySubVisibilityMainDepsHandler();
const restorePreviousSecondarySubVisibilityHandler =
@@ -3377,39 +3425,7 @@ void initializeDiscordPresenceService();
const handleCliCommand = createCliCommandRuntimeHandler({
handleTexthookerOnlyModeTransitionMainDeps: {
isTexthookerOnlyMode: () => appState.texthookerOnlyMode,
ensureOverlayStartupPrereqs: () => {
if (appState.subtitlePosition === null) {
loadSubtitlePosition();
}
if (appState.keybindings.length === 0) {
appState.keybindings = resolveKeybindings(getResolvedConfig(), DEFAULT_KEYBINDINGS);
}
if (!appState.mpvClient) {
appState.mpvClient = createMpvClientRuntimeService();
}
if (!appState.runtimeOptionsManager) {
appState.runtimeOptionsManager = new RuntimeOptionsManager(
() => configService.getConfig().ankiConnect,
{
applyAnkiPatch: (patch) => {
if (appState.ankiIntegration) {
appState.ankiIntegration.applyRuntimeConfigPatch(patch);
}
},
getSubtitleStyleConfig: () => configService.getConfig().subtitleStyle,
onOptionsChanged: () => {
subtitleProcessingController.invalidateTokenizationCache();
subtitlePrefetchService?.onSeek(lastObservedTimePos);
broadcastRuntimeOptionsChanged();
refreshOverlayShortcuts();
},
},
);
}
if (!appState.subtitleTimingTracker) {
appState.subtitleTimingTracker = new SubtitleTimingTracker();
}
},
ensureOverlayStartupPrereqs: () => ensureOverlayStartupPrereqs(),
setTexthookerOnlyMode: (enabled) => {
appState.texthookerOnlyMode = enabled;
},
@@ -3422,6 +3438,40 @@ const handleCliCommand = createCliCommandRuntimeHandler({
handleCliCommandRuntimeServiceWithContext(args, source, cliContext),
});
function ensureOverlayStartupPrereqs(): void {
if (appState.subtitlePosition === null) {
loadSubtitlePosition();
}
if (appState.keybindings.length === 0) {
appState.keybindings = resolveKeybindings(getResolvedConfig(), DEFAULT_KEYBINDINGS);
}
if (!appState.mpvClient) {
appState.mpvClient = createMpvClientRuntimeService();
}
if (!appState.runtimeOptionsManager) {
appState.runtimeOptionsManager = new RuntimeOptionsManager(
() => configService.getConfig().ankiConnect,
{
applyAnkiPatch: (patch) => {
if (appState.ankiIntegration) {
appState.ankiIntegration.applyRuntimeConfigPatch(patch);
}
},
getSubtitleStyleConfig: () => configService.getConfig().subtitleStyle,
onOptionsChanged: () => {
subtitleProcessingController.invalidateTokenizationCache();
subtitlePrefetchService?.onSeek(lastObservedTimePos);
broadcastRuntimeOptionsChanged();
refreshOverlayShortcuts();
},
},
);
}
if (!appState.subtitleTimingTracker) {
appState.subtitleTimingTracker = new SubtitleTimingTracker();
}
}
const handleInitialArgsRuntimeHandler = createInitialArgsRuntimeHandler({
getInitialArgs: () => appState.initialArgs,
isBackgroundMode: () => appState.backgroundMode,
@@ -3431,6 +3481,10 @@ const handleInitialArgsRuntimeHandler = createInitialArgsRuntimeHandler({
isTexthookerOnlyMode: () => appState.texthookerOnlyMode,
hasImmersionTracker: () => Boolean(appState.immersionTracker),
getMpvClient: () => appState.mpvClient,
commandNeedsOverlayRuntime: (args) => commandNeedsOverlayRuntime(args),
ensureOverlayStartupPrereqs: () => ensureOverlayStartupPrereqs(),
isOverlayRuntimeInitialized: () => appState.overlayRuntimeInitialized,
initializeOverlayRuntime: () => initializeOverlayRuntime(),
logInfo: (message) => logger.info(message),
handleCliCommand: (args, source) => handleCliCommand(args, source),
});
@@ -4387,6 +4441,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
mpvCommandMainDeps: {
triggerSubsyncFromConfig: () => triggerSubsyncFromConfig(),
openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(),
openYoutubeTrackPicker: () => openYoutubeTrackPickerFromPlayback(),
cycleRuntimeOption: (id, direction) => {
if (!appState.runtimeOptionsManager) {
return { ok: false, error: 'Runtime options manager unavailable' };

View File

@@ -191,6 +191,7 @@ export interface MpvCommandRuntimeServiceDepsParams {
runtimeOptionsCycle: HandleMpvCommandFromIpcOptions['runtimeOptionsCycle'];
triggerSubsyncFromConfig: HandleMpvCommandFromIpcOptions['triggerSubsyncFromConfig'];
openRuntimeOptionsPalette: HandleMpvCommandFromIpcOptions['openRuntimeOptionsPalette'];
openYoutubeTrackPicker: HandleMpvCommandFromIpcOptions['openYoutubeTrackPicker'];
showMpvOsd: HandleMpvCommandFromIpcOptions['showMpvOsd'];
mpvReplaySubtitle: HandleMpvCommandFromIpcOptions['mpvReplaySubtitle'];
mpvPlayNextSubtitle: HandleMpvCommandFromIpcOptions['mpvPlayNextSubtitle'];
@@ -354,6 +355,7 @@ export function createMpvCommandRuntimeServiceDeps(
specialCommands: params.specialCommands,
triggerSubsyncFromConfig: params.triggerSubsyncFromConfig,
openRuntimeOptionsPalette: params.openRuntimeOptionsPalette,
openYoutubeTrackPicker: params.openYoutubeTrackPicker,
runtimeOptionsCycle: params.runtimeOptionsCycle,
showMpvOsd: params.showMpvOsd,
mpvReplaySubtitle: params.mpvReplaySubtitle,

View File

@@ -12,6 +12,7 @@ type MpvPropertyClientLike = {
export interface MpvCommandFromIpcRuntimeDeps {
triggerSubsyncFromConfig: () => void;
openRuntimeOptionsPalette: () => void;
openYoutubeTrackPicker: () => void | Promise<void>;
cycleRuntimeOption: (id: RuntimeOptionId, direction: 1 | -1) => RuntimeOptionApplyResult;
showMpvOsd: (text: string) => void;
replayCurrentSubtitle: () => void;
@@ -33,6 +34,7 @@ export function handleMpvCommandFromIpcRuntime(
specialCommands: SPECIAL_COMMANDS,
triggerSubsyncFromConfig: deps.triggerSubsyncFromConfig,
openRuntimeOptionsPalette: deps.openRuntimeOptionsPalette,
openYoutubeTrackPicker: deps.openYoutubeTrackPicker,
runtimeOptionsCycle: deps.cycleRuntimeOption,
showMpvOsd: deps.showMpvOsd,
mpvReplaySubtitle: deps.replayCurrentSubtitle,

View File

@@ -8,6 +8,7 @@ const OVERLAY_LOADING_OSD_COOLDOWN_MS = 30_000;
export interface OverlayVisibilityRuntimeDeps {
getMainWindow: () => BrowserWindow | null;
getModalActive: () => boolean;
getVisibleOverlayVisible: () => boolean;
getForceMousePassthrough: () => boolean;
getWindowTracker: () => BaseWindowTracker | null;
@@ -37,6 +38,7 @@ export function createOverlayVisibilityRuntimeService(
updateVisibleOverlayVisibility(): void {
updateVisibleOverlayVisibility({
visibleOverlayVisible: deps.getVisibleOverlayVisible(),
modalActive: deps.getModalActive(),
forceMousePassthrough: deps.getForceMousePassthrough(),
mainWindow: deps.getMainWindow(),
windowTracker: deps.getWindowTracker(),

View File

@@ -33,3 +33,28 @@ test('cli command runtime handler applies precheck and forwards command with con
'cli:initial:ctx',
]);
});
test('cli command runtime handler prepares overlay prerequisites before overlay runtime commands', () => {
const calls: string[] = [];
const handler = createCliCommandRuntimeHandler({
handleTexthookerOnlyModeTransitionMainDeps: {
isTexthookerOnlyMode: () => false,
setTexthookerOnlyMode: () => calls.push('set-mode'),
commandNeedsOverlayRuntime: () => true,
ensureOverlayStartupPrereqs: () => calls.push('prereqs'),
startBackgroundWarmups: () => calls.push('warmups'),
logInfo: (message) => calls.push(`log:${message}`),
},
createCliCommandContext: () => {
calls.push('context');
return { id: 'ctx' };
},
handleCliCommandRuntimeServiceWithContext: (_args, source, context) => {
calls.push(`cli:${source}:${context.id}`);
},
});
handler({ youtubePlay: 'https://www.youtube.com/watch?v=test' } as never);
assert.deepEqual(calls, ['prereqs', 'context', 'cli:initial:ctx']);
});

View File

@@ -23,6 +23,12 @@ export function createCliCommandRuntimeHandler<TCliContext>(deps: {
return (args: CliArgs, source: CliCommandSource = 'initial'): void => {
handleTexthookerOnlyModeTransitionHandler(args);
if (
!deps.handleTexthookerOnlyModeTransitionMainDeps.isTexthookerOnlyMode() &&
deps.handleTexthookerOnlyModeTransitionMainDeps.commandNeedsOverlayRuntime(args)
) {
deps.handleTexthookerOnlyModeTransitionMainDeps.ensureOverlayStartupPrereqs();
}
const cliContext = deps.createCliCommandContext();
deps.handleCliCommandRuntimeServiceWithContext(args, source, cliContext);
};

View File

@@ -10,6 +10,7 @@ test('composeIpcRuntimeHandlers returns callable IPC handlers and registration b
mpvCommandMainDeps: {
triggerSubsyncFromConfig: async () => {},
openRuntimeOptionsPalette: () => {},
openYoutubeTrackPicker: () => {},
cycleRuntimeOption: () => ({ ok: true }),
showMpvOsd: () => {},
replayCurrentSubtitle: () => {},

View File

@@ -13,6 +13,10 @@ test('initial args handler no-ops without initial args', () => {
isTexthookerOnlyMode: () => false,
hasImmersionTracker: () => false,
getMpvClient: () => null,
commandNeedsOverlayRuntime: () => false,
ensureOverlayStartupPrereqs: () => {},
isOverlayRuntimeInitialized: () => false,
initializeOverlayRuntime: () => {},
logInfo: () => {},
handleCliCommand: () => {
handled = true;
@@ -36,6 +40,10 @@ test('initial args handler ensures tray in background mode', () => {
isTexthookerOnlyMode: () => true,
hasImmersionTracker: () => false,
getMpvClient: () => null,
commandNeedsOverlayRuntime: () => false,
ensureOverlayStartupPrereqs: () => {},
isOverlayRuntimeInitialized: () => false,
initializeOverlayRuntime: () => {},
logInfo: () => {},
handleCliCommand: () => {},
});
@@ -61,6 +69,10 @@ test('initial args handler auto-connects mpv when needed', () => {
connectCalls += 1;
},
}),
commandNeedsOverlayRuntime: () => false,
ensureOverlayStartupPrereqs: () => {},
isOverlayRuntimeInitialized: () => false,
initializeOverlayRuntime: () => {},
logInfo: () => {
logged = true;
},
@@ -83,6 +95,14 @@ test('initial args handler forwards args to cli handler', () => {
isTexthookerOnlyMode: () => false,
hasImmersionTracker: () => false,
getMpvClient: () => null,
commandNeedsOverlayRuntime: () => false,
ensureOverlayStartupPrereqs: () => {
seenSources.push('prereqs');
},
isOverlayRuntimeInitialized: () => false,
initializeOverlayRuntime: () => {
seenSources.push('init-overlay');
},
logInfo: () => {},
handleCliCommand: (_args, source) => {
seenSources.push(source);
@@ -93,6 +113,37 @@ test('initial args handler forwards args to cli handler', () => {
assert.deepEqual(seenSources, ['initial']);
});
test('initial args handler bootstraps overlay before initial overlay-runtime commands', () => {
const calls: string[] = [];
const args = { youtubePlay: 'https://youtube.com/watch?v=abc' } as never;
const handleInitialArgs = createHandleInitialArgsHandler({
getInitialArgs: () => args,
isBackgroundMode: () => false,
shouldEnsureTrayOnStartup: () => false,
shouldRunHeadlessInitialCommand: () => false,
ensureTray: () => {},
isTexthookerOnlyMode: () => false,
hasImmersionTracker: () => false,
getMpvClient: () => null,
commandNeedsOverlayRuntime: (inputArgs) => inputArgs === args,
ensureOverlayStartupPrereqs: () => {
calls.push('prereqs');
},
isOverlayRuntimeInitialized: () => false,
initializeOverlayRuntime: () => {
calls.push('init-overlay');
},
logInfo: () => {},
handleCliCommand: (_args, source) => {
calls.push(`cli:${source}`);
},
});
handleInitialArgs();
assert.deepEqual(calls, ['prereqs', 'init-overlay', 'cli:initial']);
});
test('initial args handler can ensure tray outside background mode when requested', () => {
let ensuredTray = false;
const handleInitialArgs = createHandleInitialArgsHandler({
@@ -106,6 +157,10 @@ test('initial args handler can ensure tray outside background mode when requeste
isTexthookerOnlyMode: () => true,
hasImmersionTracker: () => false,
getMpvClient: () => null,
commandNeedsOverlayRuntime: () => false,
ensureOverlayStartupPrereqs: () => {},
isOverlayRuntimeInitialized: () => false,
initializeOverlayRuntime: () => {},
logInfo: () => {},
handleCliCommand: () => {},
});
@@ -133,6 +188,10 @@ test('initial args handler skips tray and mpv auto-connect for headless refresh'
connectCalls += 1;
},
}),
commandNeedsOverlayRuntime: () => true,
ensureOverlayStartupPrereqs: () => {},
isOverlayRuntimeInitialized: () => false,
initializeOverlayRuntime: () => {},
logInfo: () => {},
handleCliCommand: () => {},
});

View File

@@ -14,6 +14,10 @@ export function createHandleInitialArgsHandler(deps: {
isTexthookerOnlyMode: () => boolean;
hasImmersionTracker: () => boolean;
getMpvClient: () => MpvClientLike | null;
commandNeedsOverlayRuntime: (args: CliArgs) => boolean;
ensureOverlayStartupPrereqs: () => void;
isOverlayRuntimeInitialized: () => boolean;
initializeOverlayRuntime: () => void;
logInfo: (message: string) => void;
handleCliCommand: (args: CliArgs, source: 'initial') => void;
}) {
@@ -39,6 +43,13 @@ export function createHandleInitialArgsHandler(deps: {
mpvClient.connect();
}
if (!runHeadless && deps.commandNeedsOverlayRuntime(initialArgs)) {
deps.ensureOverlayStartupPrereqs();
if (!deps.isOverlayRuntimeInitialized()) {
deps.initializeOverlayRuntime();
}
}
deps.handleCliCommand(initialArgs, 'initial');
};
}

View File

@@ -15,6 +15,10 @@ test('initial args main deps builder maps runtime callbacks and state readers',
isTexthookerOnlyMode: () => false,
hasImmersionTracker: () => true,
getMpvClient: () => mpvClient,
commandNeedsOverlayRuntime: () => true,
ensureOverlayStartupPrereqs: () => calls.push('prereqs'),
isOverlayRuntimeInitialized: () => false,
initializeOverlayRuntime: () => calls.push('init-overlay'),
logInfo: (message) => calls.push(`info:${message}`),
handleCliCommand: (_args, source) => calls.push(`cli:${source}`),
})();
@@ -26,9 +30,13 @@ test('initial args main deps builder maps runtime callbacks and state readers',
assert.equal(deps.isTexthookerOnlyMode(), false);
assert.equal(deps.hasImmersionTracker(), true);
assert.equal(deps.getMpvClient(), mpvClient);
assert.equal(deps.commandNeedsOverlayRuntime(args), true);
assert.equal(deps.isOverlayRuntimeInitialized(), false);
deps.ensureTray();
deps.ensureOverlayStartupPrereqs();
deps.initializeOverlayRuntime();
deps.logInfo('x');
deps.handleCliCommand(args, 'initial');
assert.deepEqual(calls, ['ensure-tray', 'info:x', 'cli:initial']);
assert.deepEqual(calls, ['ensure-tray', 'prereqs', 'init-overlay', 'info:x', 'cli:initial']);
});

View File

@@ -9,6 +9,10 @@ export function createBuildHandleInitialArgsMainDepsHandler(deps: {
isTexthookerOnlyMode: () => boolean;
hasImmersionTracker: () => boolean;
getMpvClient: () => { connected: boolean; connect: () => void } | null;
commandNeedsOverlayRuntime: (args: CliArgs) => boolean;
ensureOverlayStartupPrereqs: () => void;
isOverlayRuntimeInitialized: () => boolean;
initializeOverlayRuntime: () => void;
logInfo: (message: string) => void;
handleCliCommand: (args: CliArgs, source: 'initial') => void;
}) {
@@ -21,6 +25,10 @@ export function createBuildHandleInitialArgsMainDepsHandler(deps: {
isTexthookerOnlyMode: () => deps.isTexthookerOnlyMode(),
hasImmersionTracker: () => deps.hasImmersionTracker(),
getMpvClient: () => deps.getMpvClient(),
commandNeedsOverlayRuntime: (args: CliArgs) => deps.commandNeedsOverlayRuntime(args),
ensureOverlayStartupPrereqs: () => deps.ensureOverlayStartupPrereqs(),
isOverlayRuntimeInitialized: () => deps.isOverlayRuntimeInitialized(),
initializeOverlayRuntime: () => deps.initializeOverlayRuntime(),
logInfo: (message: string) => deps.logInfo(message),
handleCliCommand: (args: CliArgs, source: 'initial') => deps.handleCliCommand(args, source),
});

View File

@@ -16,6 +16,10 @@ test('initial args runtime handler composes main deps and runs initial command f
connected: false,
connect: () => calls.push('connect'),
}),
commandNeedsOverlayRuntime: () => false,
ensureOverlayStartupPrereqs: () => calls.push('prereqs'),
isOverlayRuntimeInitialized: () => false,
initializeOverlayRuntime: () => calls.push('init-overlay'),
logInfo: (message) => calls.push(`log:${message}`),
handleCliCommand: (_args, source) => calls.push(`cli:${source}`),
});
@@ -44,6 +48,10 @@ test('initial args runtime handler skips mpv auto-connect for stats mode', () =>
connected: false,
connect: () => calls.push('connect'),
}),
commandNeedsOverlayRuntime: () => false,
ensureOverlayStartupPrereqs: () => calls.push('prereqs'),
isOverlayRuntimeInitialized: () => false,
initializeOverlayRuntime: () => calls.push('init-overlay'),
logInfo: (message) => calls.push(`log:${message}`),
handleCliCommand: (_args, source) => calls.push(`cli:${source}`),
});
@@ -67,6 +75,10 @@ test('initial args runtime handler skips tray and mpv auto-connect for headless
connected: false,
connect: () => calls.push('connect'),
}),
commandNeedsOverlayRuntime: () => true,
ensureOverlayStartupPrereqs: () => calls.push('prereqs'),
isOverlayRuntimeInitialized: () => false,
initializeOverlayRuntime: () => calls.push('init-overlay'),
logInfo: (message) => calls.push(`log:${message}`),
handleCliCommand: (_args, source) => calls.push(`cli:${source}`),
});

View File

@@ -13,6 +13,7 @@ test('ipc bridge action main deps builders map callbacks', async () => {
buildMpvCommandDeps: () => ({
triggerSubsyncFromConfig: async () => {},
openRuntimeOptionsPalette: () => {},
openYoutubeTrackPicker: () => {},
cycleRuntimeOption: () => ({ ok: false as const, error: 'x' }),
showMpvOsd: () => {},
replayCurrentSubtitle: () => {},

View File

@@ -10,6 +10,7 @@ test('handle mpv command handler forwards command and built deps', () => {
const deps = {
triggerSubsyncFromConfig: () => {},
openRuntimeOptionsPalette: () => {},
openYoutubeTrackPicker: () => {},
cycleRuntimeOption: () => ({ ok: false as const, error: 'x' }),
showMpvOsd: () => {},
replayCurrentSubtitle: () => {},

View File

@@ -7,6 +7,9 @@ test('ipc mpv command main deps builder maps callbacks', () => {
const deps = createBuildMpvCommandFromIpcRuntimeMainDepsHandler({
triggerSubsyncFromConfig: () => calls.push('subsync'),
openRuntimeOptionsPalette: () => calls.push('palette'),
openYoutubeTrackPicker: () => {
calls.push('youtube-picker');
},
cycleRuntimeOption: () => ({ ok: false as const, error: 'x' }),
showMpvOsd: (text) => calls.push(`osd:${text}`),
replayCurrentSubtitle: () => calls.push('replay'),
@@ -22,6 +25,7 @@ test('ipc mpv command main deps builder maps callbacks', () => {
deps.triggerSubsyncFromConfig();
deps.openRuntimeOptionsPalette();
void deps.openYoutubeTrackPicker();
assert.deepEqual(deps.cycleRuntimeOption('anki.nPlusOneMatchMode', 1), { ok: false, error: 'x' });
deps.showMpvOsd('hello');
deps.replayCurrentSubtitle();
@@ -34,6 +38,7 @@ test('ipc mpv command main deps builder maps callbacks', () => {
assert.deepEqual(calls, [
'subsync',
'palette',
'youtube-picker',
'osd:hello',
'replay',
'next',

View File

@@ -6,6 +6,7 @@ export function createBuildMpvCommandFromIpcRuntimeMainDepsHandler(
return (): MpvCommandFromIpcRuntimeDeps => ({
triggerSubsyncFromConfig: () => deps.triggerSubsyncFromConfig(),
openRuntimeOptionsPalette: () => deps.openRuntimeOptionsPalette(),
openYoutubeTrackPicker: () => deps.openYoutubeTrackPicker(),
cycleRuntimeOption: (id, direction) => deps.cycleRuntimeOption(id, direction),
showMpvOsd: (text: string) => deps.showMpvOsd(text),
replayCurrentSubtitle: () => deps.replayCurrentSubtitle(),

View File

@@ -1,6 +1,7 @@
import type { SubtitleData } from '../../types';
export function createHandleMpvSubtitleChangeHandler(deps: {
shouldSuppressSubtitleEvents?: () => boolean;
setCurrentSubText: (text: string) => void;
getImmediateSubtitlePayload?: (text: string) => SubtitleData | null;
emitImmediateSubtitle?: (payload: SubtitleData) => void;
@@ -10,6 +11,10 @@ export function createHandleMpvSubtitleChangeHandler(deps: {
}) {
return ({ text }: { text: string }): void => {
deps.setCurrentSubText(text);
if (deps.shouldSuppressSubtitleEvents?.()) {
deps.refreshDiscordPresence();
return;
}
const immediatePayload = deps.getImmediateSubtitlePayload?.(text) ?? null;
if (immediatePayload) {
(deps.emitImmediateSubtitle ?? deps.broadcastSubtitle)(immediatePayload);
@@ -25,19 +30,27 @@ export function createHandleMpvSubtitleChangeHandler(deps: {
}
export function createHandleMpvSubtitleAssChangeHandler(deps: {
shouldSuppressSubtitleEvents?: () => boolean;
setCurrentSubAssText: (text: string) => void;
broadcastSubtitleAss: (text: string) => void;
}) {
return ({ text }: { text: string }): void => {
deps.setCurrentSubAssText(text);
if (deps.shouldSuppressSubtitleEvents?.()) {
return;
}
deps.broadcastSubtitleAss(text);
};
}
export function createHandleMpvSecondarySubtitleChangeHandler(deps: {
shouldSuppressSubtitleEvents?: () => boolean;
broadcastSecondarySubtitle: (text: string) => void;
}) {
return ({ text }: { text: string }): void => {
if (deps.shouldSuppressSubtitleEvents?.()) {
return;
}
deps.broadcastSecondarySubtitle(text);
};
}

View File

@@ -26,6 +26,7 @@ test('main mpv event binder wires callbacks through to runtime deps', () => {
calls.push('post-watch');
},
logSubtitleTimingError: () => calls.push('subtitle-error'),
shouldSuppressSubtitleEvents: () => false,
setCurrentSubText: (text) => calls.push(`set-sub:${text}`),
broadcastSubtitle: (payload) => calls.push(`broadcast-sub:${payload.text}`),
@@ -93,3 +94,67 @@ test('main mpv event binder wires callbacks through to runtime deps', () => {
assert.ok(calls.includes('sync-immersion'));
assert.ok(calls.includes('flush-playback'));
});
test('main mpv event binder suppresses subtitle broadcasts while youtube flow is pending', () => {
const handlers = new Map<string, (payload: unknown) => void>();
const calls: string[] = [];
const bind = createBindMpvMainEventHandlersHandler({
reportJellyfinRemoteStopped: () => {},
syncOverlayMpvSubtitleSuppression: () => {},
resetSubtitleSidebarEmbeddedLayout: () => {},
hasInitialJellyfinPlayArg: () => false,
isOverlayRuntimeInitialized: () => false,
isQuitOnDisconnectArmed: () => false,
scheduleQuitCheck: () => {},
isMpvConnected: () => false,
quitApp: () => {},
recordImmersionSubtitleLine: () => {},
hasSubtitleTimingTracker: () => false,
recordSubtitleTiming: () => {},
maybeRunAnilistPostWatchUpdate: async () => {},
logSubtitleTimingError: () => {},
shouldSuppressSubtitleEvents: () => true,
setCurrentSubText: (text) => calls.push(`set-sub:${text}`),
broadcastSubtitle: (payload) => calls.push(`broadcast-sub:${payload.text}`),
onSubtitleChange: (text) => calls.push(`subtitle-change:${text}`),
refreshDiscordPresence: () => calls.push('presence-refresh'),
setCurrentSubAssText: (text) => calls.push(`set-ass:${text}`),
broadcastSubtitleAss: (text) => calls.push(`broadcast-ass:${text}`),
broadcastSecondarySubtitle: (text) => calls.push(`broadcast-secondary:${text}`),
updateCurrentMediaPath: () => {},
restoreMpvSubVisibility: () => {},
getCurrentAnilistMediaKey: () => null,
resetAnilistMediaTracking: () => {},
maybeProbeAnilistDuration: () => {},
ensureAnilistMediaGuess: () => {},
syncImmersionMediaState: () => {},
updateCurrentMediaTitle: () => {},
resetAnilistMediaGuessState: () => {},
notifyImmersionTitleUpdate: () => {},
recordPlaybackPosition: () => {},
recordMediaDuration: () => {},
reportJellyfinRemoteProgress: () => {},
recordPauseState: () => {},
updateSubtitleRenderMetrics: () => {},
setPreviousSecondarySubVisibility: () => {},
});
bind({
on: (event, handler) => {
handlers.set(event, handler as (payload: unknown) => void);
},
});
handlers.get('subtitle-change')?.({ text: 'line' });
handlers.get('subtitle-ass-change')?.({ text: 'ass' });
handlers.get('secondary-subtitle-change')?.({ text: 'sec' });
assert.ok(calls.includes('set-sub:line'));
assert.ok(calls.includes('set-ass:ass'));
assert.ok(calls.includes('presence-refresh'));
assert.ok(!calls.includes('broadcast-sub:line'));
assert.ok(!calls.includes('subtitle-change:line'));
assert.ok(!calls.includes('broadcast-ass:ass'));
assert.ok(!calls.includes('broadcast-secondary:sec'));
});

View File

@@ -35,6 +35,7 @@ export function createBindMpvMainEventHandlersHandler(deps: {
recordSubtitleTiming: (text: string, start: number, end: number) => void;
maybeRunAnilistPostWatchUpdate: () => Promise<void>;
logSubtitleTimingError: (message: string, error: unknown) => void;
shouldSuppressSubtitleEvents?: () => boolean;
setCurrentSubText: (text: string) => void;
getImmediateSubtitlePayload?: (text: string) => SubtitleData | null;
@@ -99,6 +100,7 @@ export function createBindMpvMainEventHandlersHandler(deps: {
logError: (message, error) => deps.logSubtitleTimingError(message, error),
});
const handleMpvSubtitleChange = createHandleMpvSubtitleChangeHandler({
shouldSuppressSubtitleEvents: () => deps.shouldSuppressSubtitleEvents?.() ?? false,
setCurrentSubText: (text) => deps.setCurrentSubText(text),
getImmediateSubtitlePayload: (text) => deps.getImmediateSubtitlePayload?.(text) ?? null,
emitImmediateSubtitle: (payload) => deps.emitImmediateSubtitle?.(payload),
@@ -107,10 +109,12 @@ export function createBindMpvMainEventHandlersHandler(deps: {
refreshDiscordPresence: () => deps.refreshDiscordPresence(),
});
const handleMpvSubtitleAssChange = createHandleMpvSubtitleAssChangeHandler({
shouldSuppressSubtitleEvents: () => deps.shouldSuppressSubtitleEvents?.() ?? false,
setCurrentSubAssText: (text) => deps.setCurrentSubAssText(text),
broadcastSubtitleAss: (text) => deps.broadcastSubtitleAss(text),
});
const handleMpvSecondarySubtitleChange = createHandleMpvSecondarySubtitleChangeHandler({
shouldSuppressSubtitleEvents: () => deps.shouldSuppressSubtitleEvents?.() ?? false,
broadcastSecondarySubtitle: (text) => deps.broadcastSecondarySubtitle(text),
});
const handleMpvMediaPathChange = createHandleMpvMediaPathChangeHandler({

View File

@@ -75,6 +75,7 @@ test('mpv main event main deps map app state updates and delegate callbacks', as
deps.recordSubtitleTiming('y', 0, 1);
await deps.maybeRunAnilistPostWatchUpdate();
deps.logSubtitleTimingError('err', new Error('boom'));
assert.equal(deps.shouldSuppressSubtitleEvents?.(), false);
deps.setCurrentSubText('sub');
deps.broadcastSubtitle({ text: 'sub', tokens: null });
deps.onSubtitleChange('sub');
@@ -117,3 +118,45 @@ test('mpv main event main deps map app state updates and delegate callbacks', as
assert.ok(calls.includes('restore-mpv-sub'));
assert.ok(calls.includes('reset-sidebar-layout'));
});
test('mpv main event main deps suppress subtitle events while youtube flow is pending', () => {
const deps = createBuildBindMpvMainEventHandlersMainDepsHandler({
appState: {
initialArgs: null,
overlayRuntimeInitialized: true,
mpvClient: null,
immersionTracker: null,
subtitleTimingTracker: null,
currentSubText: '',
currentSubAssText: '',
playbackPaused: null,
previousSecondarySubVisibility: false,
youtubePlaybackFlowPending: true,
},
getQuitOnDisconnectArmed: () => false,
scheduleQuitCheck: () => {},
quitApp: () => {},
reportJellyfinRemoteStopped: () => {},
syncOverlayMpvSubtitleSuppression: () => {},
maybeRunAnilistPostWatchUpdate: async () => {},
logSubtitleTimingError: () => {},
broadcastToOverlayWindows: () => {},
onSubtitleChange: () => {},
ensureImmersionTrackerInitialized: () => {},
updateCurrentMediaPath: () => {},
restoreMpvSubVisibility: () => {},
resetSubtitleSidebarEmbeddedLayout: () => {},
getCurrentAnilistMediaKey: () => null,
resetAnilistMediaTracking: () => {},
maybeProbeAnilistDuration: () => {},
ensureAnilistMediaGuess: () => {},
syncImmersionMediaState: () => {},
updateCurrentMediaTitle: () => {},
resetAnilistMediaGuessState: () => {},
reportJellyfinRemoteProgress: () => {},
updateSubtitleRenderMetrics: () => {},
refreshDiscordPresence: () => {},
})();
assert.equal(deps.shouldSuppressSubtitleEvents?.(), true);
});

View File

@@ -120,6 +120,7 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
maybeRunAnilistPostWatchUpdate: () => deps.maybeRunAnilistPostWatchUpdate(),
logSubtitleTimingError: (message: string, error: unknown) =>
deps.logSubtitleTimingError(message, error),
shouldSuppressSubtitleEvents: () => deps.appState.youtubePlaybackFlowPending,
setCurrentSubText: (text: string) => {
deps.appState.currentSubText = text;
},

View File

@@ -12,6 +12,7 @@ test('overlay visibility runtime main deps builder maps state and geometry callb
const deps = createBuildOverlayVisibilityRuntimeMainDepsHandler({
getMainWindow: () => mainWindow,
getModalActive: () => true,
getVisibleOverlayVisible: () => true,
getForceMousePassthrough: () => true,
getWindowTracker: () => tracker,
@@ -32,6 +33,7 @@ test('overlay visibility runtime main deps builder maps state and geometry callb
})();
assert.equal(deps.getMainWindow(), mainWindow);
assert.equal(deps.getModalActive(), true);
assert.equal(deps.getVisibleOverlayVisible(), true);
assert.equal(deps.getForceMousePassthrough(), true);
assert.equal(deps.getTrackerNotReadyWarningShown(), false);

View File

@@ -7,6 +7,7 @@ export function createBuildOverlayVisibilityRuntimeMainDepsHandler(
) {
return (): OverlayVisibilityRuntimeDeps => ({
getMainWindow: () => deps.getMainWindow(),
getModalActive: () => deps.getModalActive(),
getVisibleOverlayVisible: () => deps.getVisibleOverlayVisible(),
getForceMousePassthrough: () => deps.getForceMousePassthrough(),
getWindowTracker: () => deps.getWindowTracker(),

View File

@@ -19,14 +19,12 @@ const secondaryTrack: YoutubeTrackOption = {
label: 'English (manual)',
};
test('youtube flow clears internal tracks and binds external primary+secondary subtitles', async () => {
test('youtube flow auto-loads default primary+secondary subtitles without opening the picker', 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({
@@ -66,27 +64,19 @@ test('youtube flow clears internal tracks and binds external primary+secondary s
order.push('wait-anki-ready');
},
waitForPlaybackWindowReady: async () => {
order.push('wait-window-ready');
throw new Error('startup auto-load should not wait for modal window readiness');
},
waitForOverlayGeometryReady: async () => {
order.push('wait-overlay-geometry');
throw new Error('startup auto-load should not wait for modal 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;
openPicker: async () => {
throw new Error('startup auto-load should not open the picker');
},
reportSubtitleFailure: () => {
throw new Error('startup auto-load should not report failure on success');
},
pauseMpv: () => {
commands.push(['set_property', 'pause', 'yes']);
@@ -97,7 +87,7 @@ test('youtube flow clears internal tracks and binds external primary+secondary s
sendMpvCommand: (command) => {
commands.push(command);
},
requestMpvProperty: async (name) => {
requestMpvProperty: async (name: string) => {
if (name === 'sub-text') {
return '字幕です';
}
@@ -128,13 +118,11 @@ test('youtube flow clears internal tracks and binds external primary+secondary s
refreshCurrentSubtitle: (text) => {
refreshedSubtitles.push(text);
},
wait: async (ms) => {
waits.push(ms);
},
wait: async () => {},
showMpvOsd: (text) => {
osdMessages.push(text);
},
warn: (message) => {
warn: (message: string) => {
throw new Error(message);
},
log: () => {},
@@ -142,24 +130,24 @@ test('youtube flow clears internal tracks and binds external primary+secondary s
});
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-auto', 'no'],
['set_property', 'sid', 'no'],
['set_property', 'secondary-sid', 'no'],
['set_property', 'sub-visibility', 'no'],
['set_property', 'secondary-sub-visibility', 'no'],
['set_property', 'sub-delay', 0],
['set_property', 'sid', 'no'],
['set_property', 'secondary-sid', 'no'],
@@ -174,8 +162,10 @@ test('youtube flow clears internal tracks and binds external primary+secondary s
assert.deepEqual(refreshedSubtitles, ['字幕です']);
});
test('youtube flow can cancel active picker session', async () => {
const focusOverlayCalls: string[] = [];
test('youtube flow refreshes parsed subtitle cues from the resolved primary subtitle path after auto-load', async () => {
const refreshedSidebarSources: string[] = [];
let trackListRequests = 0;
const runtime = createYoutubeFlowRuntime({
probeYoutubeTracks: async () => ({
videoId: 'video123',
@@ -183,48 +173,57 @@ test('youtube flow can cancel active picker session', async () => {
tracks: [primaryTrack],
}),
acquireYoutubeSubtitleTracks: async () => {
throw new Error('should not batch download after cancel');
throw new Error('single-track auto-load should not batch acquire');
},
acquireYoutubeSubtitleTrack: async () => {
throw new Error('should not download after cancel');
},
retimeYoutubePrimaryTrack: async ({ primaryPath }) => primaryPath,
acquireYoutubeSubtitleTrack: async () => ({ path: '/tmp/auto-ja-orig.vtt' }),
retimeYoutubePrimaryTrack: async () => '/tmp/auto-ja-orig_retimed.vtt',
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;
focusOverlayWindow: () => {},
openPicker: async () => false,
reportSubtitleFailure: () => {
throw new Error('primary subtitle should load successfully');
},
pauseMpv: () => {},
resumeMpv: () => {},
sendMpvCommand: () => {},
requestMpvProperty: async () => null,
requestMpvProperty: async (name: string) => {
if (name === 'sub-text') {
return '字幕です';
}
assert.equal(name, 'track-list');
trackListRequests += 1;
return [
{
type: 'sub',
id: 5,
lang: 'ja-orig',
title: 'primary',
external: true,
'external-filename': '/tmp/auto-ja-orig_retimed.vtt',
},
];
},
refreshCurrentSubtitle: () => {},
refreshSubtitleSidebarSource: async (sourcePath: string) => {
refreshedSidebarSources.push(sourcePath);
},
wait: async () => {},
showMpvOsd: () => {},
warn: () => {},
warn: (message: string) => {
throw new Error(message);
},
log: () => {},
getYoutubeOutputDir: () => '/tmp',
});
} as never);
await runtime.runYoutubePlaybackFlow({ url: 'https://example.com', mode: 'download' });
assert.equal(runtime.hasActiveSession(), false);
assert.equal(runtime.cancelActivePicker(), false);
assert.deepEqual(focusOverlayCalls, ['focus-overlay']);
assert.equal(trackListRequests > 0, true);
assert.deepEqual(refreshedSidebarSources, ['/tmp/auto-ja-orig_retimed.vtt']);
});
test('youtube flow retries secondary after partial batch subtitle failure', async () => {
@@ -257,21 +256,20 @@ test('youtube flow retries secondary after partial batch subtitle failure', asyn
startTokenizationWarmups: async () => {},
waitForTokenizationReady: async () => {},
waitForAnkiReady: async () => {},
waitForPlaybackWindowReady: async () => {},
waitForOverlayGeometryReady: async () => {},
waitForPlaybackWindowReady: async () => {
throw new Error('startup auto-load should not wait for modal window readiness');
},
waitForOverlayGeometryReady: async () => {
throw new Error('startup auto-load should not wait for modal overlay geometry');
},
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;
openPicker: async () => {
throw new Error('startup auto-load should not open the picker');
},
reportSubtitleFailure: () => {
throw new Error('secondary retry should not report primary failure');
},
pauseMpv: () => {},
resumeMpv: () => {},
@@ -332,7 +330,7 @@ test('youtube flow retries secondary after partial batch subtitle failure', asyn
await runtime.runYoutubePlaybackFlow({ url: 'https://example.com', mode: 'download' });
assert.deepEqual(acquireSingleCalls, [secondaryTrack.id]);
assert.ok(waits.includes(150));
assert.ok(waits.includes(350));
assert.deepEqual(focusOverlayCalls, ['focus-overlay']);
assert.deepEqual(refreshedSubtitles, ['字幕です']);
assert.ok(
@@ -377,21 +375,20 @@ test('youtube flow waits for tokenization readiness before releasing playback',
waitForAnkiReady: async () => {
releaseOrder.push('wait-anki-ready');
},
waitForPlaybackWindowReady: async () => {},
waitForOverlayGeometryReady: async () => {},
waitForPlaybackWindowReady: async () => {
throw new Error('startup auto-load should not wait for modal window readiness');
},
waitForOverlayGeometryReady: async () => {
throw new Error('startup auto-load should not wait for modal overlay geometry');
},
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;
openPicker: async () => {
throw new Error('startup auto-load should not open the picker');
},
reportSubtitleFailure: () => {
throw new Error('successful auto-load should not report failure');
},
pauseMpv: () => {},
resumeMpv: () => {
@@ -450,10 +447,10 @@ test('youtube flow waits for tokenization readiness before releasing playback',
]);
});
test('youtube flow cleans up paused picker state when opening the picker throws', async () => {
test('youtube flow reports primary auto-load failure through the configured reporter when the primary subtitle never binds', async () => {
const commands: Array<Array<string | number>> = [];
const warns: string[] = [];
const focusOverlayCalls: string[] = [];
const reportedFailures: string[] = [];
const runtime = createYoutubeFlowRuntime({
probeYoutubeTracks: async () => ({
@@ -465,78 +462,22 @@ test('youtube flow cleans up paused picker state when opening the picker throws'
acquireYoutubeSubtitleTrack: async () => ({ path: '/tmp/auto-ja-orig.vtt' }),
retimeYoutubePrimaryTrack: async ({ primaryPath }) => primaryPath,
startTokenizationWarmups: async () => {},
waitForTokenizationReady: async () => {},
waitForTokenizationReady: async () => {
throw new Error('bind failure should not wait for tokenization readiness');
},
waitForAnkiReady: async () => {},
waitForPlaybackWindowReady: async () => {},
waitForOverlayGeometryReady: async () => {},
focusOverlayWindow: () => {
focusOverlayCalls.push('focus-overlay');
waitForPlaybackWindowReady: async () => {
throw new Error('startup auto-load should not wait for modal window readiness');
},
openPicker: async () => {
throw new Error('picker boom');
waitForOverlayGeometryReady: async () => {
throw new Error('startup auto-load should not wait for modal overlay geometry');
},
pauseMpv: () => {
commands.push(['set_property', 'pause', 'yes']);
},
resumeMpv: () => {
commands.push(['set_property', 'pause', 'no']);
},
sendMpvCommand: (command) => {
commands.push(command);
},
requestMpvProperty: async () => null,
refreshCurrentSubtitle: () => {},
wait: async () => {},
showMpvOsd: () => {},
warn: (message) => {
warns.push(message);
},
log: () => {},
getYoutubeOutputDir: () => '/tmp',
});
await runtime.runYoutubePlaybackFlow({ url: 'https://example.com', mode: 'download' });
assert.deepEqual(commands, [
['set_property', 'pause', 'yes'],
['script-message', 'subminer-autoplay-ready'],
['set_property', 'pause', 'no'],
]);
assert.deepEqual(focusOverlayCalls, ['focus-overlay']);
assert.equal(warns.some((message) => message.includes('picker boom')), true);
assert.equal(runtime.hasActiveSession(), false);
});
test('youtube flow reports failure when the primary subtitle never binds', async () => {
const commands: Array<Array<string | number>> = [];
const osdMessages: string[] = [];
const warns: string[] = [];
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 () => {},
waitForTokenizationReady: async () => {},
waitForAnkiReady: async () => {},
waitForPlaybackWindowReady: async () => {},
waitForOverlayGeometryReady: async () => {},
focusOverlayWindow: () => {},
openPicker: async (payload) => {
queueMicrotask(() => {
void runtime.resolveActivePicker({
sessionId: payload.sessionId,
action: 'use-selected',
primaryTrackId: primaryTrack.id,
secondaryTrackId: null,
});
});
return true;
openPicker: async () => {
throw new Error('startup auto-load should not open the picker');
},
reportSubtitleFailure: (message) => {
reportedFailures.push(message);
},
pauseMpv: () => {},
resumeMpv: () => {},
@@ -553,9 +494,7 @@ test('youtube flow reports failure when the primary subtitle never binds', async
throw new Error('should not refresh subtitle text on bind failure');
},
wait: async () => {},
showMpvOsd: (text) => {
osdMessages.push(text);
},
showMpvOsd: () => {},
warn: (message) => {
warns.push(message);
},
@@ -569,6 +508,129 @@ test('youtube flow reports failure when the primary subtitle never binds', async
commands.some((command) => command[0] === 'set_property' && command[1] === 'sid' && command[2] !== 'no'),
false,
);
assert.deepEqual(osdMessages.slice(-1), ['Primary subtitles failed to load.']);
assert.deepEqual(reportedFailures, [
'Primary subtitles failed to load. Use the YouTube subtitle picker to try manually.',
]);
assert.equal(warns.some((message) => message.includes('Unable to bind downloaded primary subtitle track')), true);
});
test('youtube flow can open a manual picker session and load the selected subtitles', async () => {
const commands: Array<Array<string | number>> = [];
const focusOverlayCalls: string[] = [];
const osdMessages: string[] = [];
const openedPayloads: YoutubePickerOpenPayload[] = [];
const waits: number[] = [];
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 }) => ({ path: `/tmp/${track.id}.vtt` }),
retimeYoutubePrimaryTrack: async ({ primaryPath }) => `${primaryPath}.retimed`,
startTokenizationWarmups: async () => {},
waitForTokenizationReady: async () => {},
waitForAnkiReady: async () => {},
waitForPlaybackWindowReady: async () => {
waits.push(1);
},
waitForOverlayGeometryReady: async () => {
waits.push(2);
},
focusOverlayWindow: () => {
focusOverlayCalls.push('focus-overlay');
},
openPicker: async (payload) => {
openedPayloads.push(payload);
queueMicrotask(() => {
void runtime.resolveActivePicker({
sessionId: payload.sessionId,
action: 'use-selected',
primaryTrackId: primaryTrack.id,
secondaryTrackId: secondaryTrack.id,
});
});
return true;
},
reportSubtitleFailure: () => {
throw new Error('manual picker success should not report failure');
},
pauseMpv: () => {
throw new Error('manual picker should not pause playback');
},
resumeMpv: () => {
throw new Error('manual picker should not resume playback');
},
sendMpvCommand: (command) => {
commands.push(command);
},
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.retimed',
},
{
type: 'sub',
id: 6,
lang: 'en',
title: 'secondary',
external: true,
'external-filename': '/tmp/manual-en.vtt',
},
];
},
refreshCurrentSubtitle: () => {},
wait: async (ms) => {
waits.push(ms);
},
showMpvOsd: (text) => {
osdMessages.push(text);
},
warn: (message) => {
throw new Error(message);
},
log: () => {},
getYoutubeOutputDir: () => '/tmp',
});
await runtime.openManualPicker({ url: 'https://example.com', mode: 'download' });
assert.equal(openedPayloads.length, 1);
assert.equal(openedPayloads[0]?.defaultPrimaryTrackId, primaryTrack.id);
assert.equal(openedPayloads[0]?.defaultSecondaryTrackId, secondaryTrack.id);
assert.ok(waits.includes(150));
assert.deepEqual(osdMessages, [
'Getting subtitles...',
'Downloading subtitles...',
'Loading subtitles...',
'Primary and secondary subtitles loaded.',
]);
assert.ok(
commands.some(
(command) =>
command[0] === 'sub-add' &&
command[1] === '/tmp/auto-ja-orig.vtt.retimed' &&
command[2] === 'select',
),
);
assert.deepEqual(focusOverlayCalls, ['focus-overlay']);
});

View File

@@ -39,6 +39,7 @@ type YoutubeFlowDeps = {
sendMpvCommand: (command: Array<string | number>) => void;
requestMpvProperty: (name: string) => Promise<unknown>;
refreshCurrentSubtitle: (text: string) => void;
refreshSubtitleSidebarSource?: (sourcePath: string) => Promise<void>;
startTokenizationWarmups: () => Promise<void>;
waitForTokenizationReady: () => Promise<void>;
waitForAnkiReady: () => Promise<void>;
@@ -47,6 +48,7 @@ type YoutubeFlowDeps = {
waitForOverlayGeometryReady: () => Promise<void>;
focusOverlayWindow: () => void;
showMpvOsd: (text: string) => void;
reportSubtitleFailure: (message: string) => void;
warn: (message: string) => void;
log: (message: string) => void;
getYoutubeOutputDir: () => string;
@@ -109,6 +111,14 @@ function releasePlaybackGate(deps: YoutubeFlowDeps): void {
deps.resumeMpv();
}
function suppressYoutubeSubtitleState(deps: YoutubeFlowDeps): void {
deps.sendMpvCommand(['set_property', 'sub-auto', 'no']);
deps.sendMpvCommand(['set_property', 'sid', 'no']);
deps.sendMpvCommand(['set_property', 'secondary-sid', 'no']);
deps.sendMpvCommand(['set_property', 'sub-visibility', 'no']);
deps.sendMpvCommand(['set_property', 'secondary-sub-visibility', 'no']);
}
function restoreOverlayInputFocus(deps: YoutubeFlowDeps): void {
deps.focusOverlayWindow();
}
@@ -259,7 +269,6 @@ async function injectDownloadedSubtitles(
}
if (primaryTrackId === null) {
deps.showMpvOsd('Primary subtitles failed to load.');
return false;
}
@@ -385,6 +394,182 @@ export function createYoutubeFlowRuntime(deps: YoutubeFlowDeps) {
activeSession = null;
});
const reportPrimarySubtitleFailure = (): void => {
deps.reportSubtitleFailure(
'Primary subtitles failed to load. Use the YouTube subtitle picker to try manually.',
);
};
const buildOpenPayload = (
input: {
url: string;
mode: YoutubeFlowMode;
},
probe: YoutubeTrackProbeResult,
): YoutubePickerOpenPayload => {
const defaults = chooseDefaultYoutubeTrackIds(probe.tracks);
return {
sessionId: createSessionId(),
url: input.url,
mode: input.mode,
tracks: probe.tracks,
defaultPrimaryTrackId: defaults.primaryTrackId,
defaultSecondaryTrackId: defaults.secondaryTrackId,
hasTracks: probe.tracks.length > 0,
};
};
const loadTracksIntoMpv = async (input: {
url: string;
mode: YoutubeFlowMode;
outputDir: string;
primaryTrack: YoutubeTrackOption;
secondaryTrack: YoutubeTrackOption | null;
secondaryFailureLabel: string;
tokenizationWarmupPromise?: Promise<void>;
showDownloadProgress: boolean;
}): Promise<boolean> => {
const osdProgress = input.showDownloadProgress
? createYoutubeFlowOsdProgress(deps.showMpvOsd)
: null;
if (osdProgress) {
osdProgress.setMessage('Downloading subtitles...');
}
try {
const acquired = await acquireSelectedTracks({
targetUrl: input.url,
outputDir: input.outputDir,
primaryTrack: input.primaryTrack,
secondaryTrack: input.secondaryTrack,
mode: input.mode,
secondaryFailureLabel: input.secondaryFailureLabel,
});
const resolvedPrimaryPath = await deps.retimeYoutubePrimaryTrack({
targetUrl: input.url,
primaryTrack: input.primaryTrack,
primaryPath: acquired.primaryPath,
secondaryTrack: input.secondaryTrack,
secondaryPath: acquired.secondaryPath,
});
deps.showMpvOsd('Loading subtitles...');
const refreshedActiveSubtitle = await injectDownloadedSubtitles(
deps,
input.primaryTrack,
resolvedPrimaryPath,
input.secondaryTrack,
acquired.secondaryPath,
);
if (!refreshedActiveSubtitle) {
return false;
}
try {
await deps.refreshSubtitleSidebarSource?.(resolvedPrimaryPath);
} catch (error) {
deps.warn(
`Failed to refresh parsed subtitle cues for sidebar: ${
error instanceof Error ? error.message : String(error)
}`,
);
}
if (input.tokenizationWarmupPromise) {
await input.tokenizationWarmupPromise;
}
await deps.waitForTokenizationReady();
await deps.waitForAnkiReady();
return true;
} finally {
osdProgress?.stop();
}
};
const openManualPicker = async (input: {
url: string;
mode: YoutubeFlowMode;
}): Promise<void> => {
let probe: YoutubeTrackProbeResult;
try {
probe = await deps.probeYoutubeTracks(input.url);
} catch (error) {
deps.warn(
`Failed to probe YouTube subtitle tracks: ${
error instanceof Error ? error.message : String(error)
}`,
);
reportPrimarySubtitleFailure();
restoreOverlayInputFocus(deps);
return;
}
const openPayload = buildOpenPayload(input, probe);
await deps.waitForPlaybackWindowReady();
await deps.waitForOverlayGeometryReady();
await deps.wait(YOUTUBE_PICKER_SETTLE_DELAY_MS);
const pickerSelection = createPickerSelectionPromise(openPayload.sessionId);
void pickerSelection.catch(() => undefined);
let opened = false;
try {
opened = await deps.openPicker(openPayload);
} catch (error) {
activeSession?.reject(error instanceof Error ? error : new Error(String(error)));
deps.warn(
`Unable to open YouTube subtitle picker: ${
error instanceof Error ? error.message : String(error)
}`,
);
restoreOverlayInputFocus(deps);
return;
}
if (!opened) {
activeSession?.reject(new Error('Unable to open YouTube subtitle picker.'));
activeSession = null;
deps.warn('Unable to open YouTube subtitle picker.');
restoreOverlayInputFocus(deps);
return;
}
const request = await pickerSelection;
if (request.action === 'continue-without-subtitles') {
restoreOverlayInputFocus(deps);
return;
}
const primaryTrack = getTrackById(probe.tracks, request.primaryTrackId);
if (!primaryTrack) {
deps.warn('No primary YouTube subtitle track selected.');
restoreOverlayInputFocus(deps);
return;
}
const selected = normalizeYoutubeTrackSelection({
primaryTrackId: primaryTrack.id,
secondaryTrackId: request.secondaryTrackId,
});
const secondaryTrack = getTrackById(probe.tracks, selected.secondaryTrackId);
try {
deps.showMpvOsd('Getting subtitles...');
await loadTracksIntoMpv({
url: input.url,
mode: input.mode,
outputDir: normalizeOutputPath(deps.getYoutubeOutputDir()),
primaryTrack,
secondaryTrack,
secondaryFailureLabel: 'Failed to download secondary YouTube subtitle track',
showDownloadProgress: true,
});
} catch (error) {
deps.warn(
`Failed to download primary YouTube subtitle track: ${
error instanceof Error ? error.message : String(error)
}`,
);
reportPrimarySubtitleFailure();
} finally {
restoreOverlayInputFocus(deps);
}
};
async function runYoutubePlaybackFlow(input: {
url: string;
mode: YoutubeFlowMode;
@@ -399,6 +584,7 @@ export function createYoutubeFlowRuntime(deps: YoutubeFlowDeps) {
});
deps.pauseMpv();
suppressYoutubeSubtitleState(deps);
const outputDir = normalizeOutputPath(deps.getYoutubeOutputDir());
let probe: YoutubeTrackProbeResult;
@@ -410,123 +596,17 @@ export function createYoutubeFlowRuntime(deps: YoutubeFlowDeps) {
error instanceof Error ? error.message : String(error)
}`,
);
reportPrimarySubtitleFailure();
releasePlaybackGate(deps);
restoreOverlayInputFocus(deps);
return;
}
const defaults = chooseDefaultYoutubeTrackIds(probe.tracks);
const sessionId = createSessionId();
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);
let opened = false;
try {
opened = await deps.openPicker(openPayload);
} catch (error) {
activeSession?.reject(
error instanceof Error ? error : new Error(String(error)),
);
deps.warn(
`Unable to open YouTube subtitle picker: ${
error instanceof Error ? error.message : String(error)
}`,
);
releasePlaybackGate(deps);
restoreOverlayInputFocus(deps);
return;
}
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.');
reportPrimarySubtitleFailure();
releasePlaybackGate(deps);
restoreOverlayInputFocus(deps);
return;
@@ -534,40 +614,31 @@ export function createYoutubeFlowRuntime(deps: YoutubeFlowDeps) {
try {
deps.showMpvOsd('Getting subtitles...');
const acquired = await acquireSelectedTracks({
targetUrl: input.url,
const loaded = await loadTracksIntoMpv({
url: input.url,
mode: input.mode,
outputDir,
primaryTrack,
secondaryTrack,
mode: input.mode,
secondaryFailureLabel: 'Failed to generate secondary YouTube subtitle track',
secondaryFailureLabel:
input.mode === 'generate'
? 'Failed to generate secondary YouTube subtitle track'
: 'Failed to download secondary YouTube subtitle track',
tokenizationWarmupPromise,
showDownloadProgress: false,
});
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();
if (!loaded) {
reportPrimarySubtitleFailure();
}
await deps.waitForAnkiReady();
} catch (error) {
deps.warn(
`Failed to generate primary YouTube subtitle track: ${
`Failed to ${
input.mode === 'generate' ? 'generate' : 'download'
} primary YouTube subtitle track: ${
error instanceof Error ? error.message : String(error)
}`,
);
reportPrimarySubtitleFailure();
} finally {
releasePlaybackGate(deps);
restoreOverlayInputFocus(deps);
@@ -576,6 +647,7 @@ export function createYoutubeFlowRuntime(deps: YoutubeFlowDeps) {
return {
runYoutubePlaybackFlow,
openManualPicker,
resolveActivePicker,
cancelActivePicker,
hasActiveSession: () => Boolean(activeSession),

View File

@@ -0,0 +1,101 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { openYoutubeTrackPicker } from './youtube-picker-open';
import type { YoutubePickerOpenPayload } from '../../types';
const payload: YoutubePickerOpenPayload = {
sessionId: 'yt-1',
url: 'https://example.com/watch?v=abc',
mode: 'download',
tracks: [],
defaultPrimaryTrackId: null,
defaultSecondaryTrackId: null,
hasTracks: false,
};
test('youtube picker open prefers dedicated modal window on first attempt', async () => {
const sends: Array<{
channel: string;
payload: YoutubePickerOpenPayload;
options: {
restoreOnModalClose: 'youtube-track-picker';
preferModalWindow: boolean;
};
}> = [];
const opened = await openYoutubeTrackPicker(
{
sendToActiveOverlayWindow: (channel, nextPayload, options) => {
sends.push({
channel,
payload: nextPayload as YoutubePickerOpenPayload,
options: options as {
restoreOnModalClose: 'youtube-track-picker';
preferModalWindow: boolean;
},
});
return true;
},
waitForModalOpen: async () => true,
logWarn: () => {},
},
payload,
);
assert.equal(opened, true);
assert.deepEqual(sends, [
{
channel: 'youtube:picker-open',
payload,
options: {
restoreOnModalClose: 'youtube-track-picker',
preferModalWindow: true,
},
},
]);
});
test('youtube picker open retries on the dedicated modal window after open timeout', async () => {
const preferModalWindowValues: boolean[] = [];
const warns: string[] = [];
let waitCalls = 0;
const opened = await openYoutubeTrackPicker(
{
sendToActiveOverlayWindow: (_channel, _payload, options) => {
preferModalWindowValues.push(Boolean(options?.preferModalWindow));
return true;
},
waitForModalOpen: async () => {
waitCalls += 1;
return waitCalls === 2;
},
logWarn: (message) => {
warns.push(message);
},
},
payload,
);
assert.equal(opened, true);
assert.deepEqual(preferModalWindowValues, [true, true]);
assert.equal(
warns.includes(
'YouTube subtitle picker did not acknowledge modal open on first attempt; retrying dedicated modal window.',
),
true,
);
});
test('youtube picker open fails when the dedicated modal window cannot be targeted', async () => {
const opened = await openYoutubeTrackPicker(
{
sendToActiveOverlayWindow: () => false,
waitForModalOpen: async () => true,
logWarn: () => {},
},
payload,
);
assert.equal(opened, false);
});

View File

@@ -0,0 +1,42 @@
import type { YoutubePickerOpenPayload } from '../../types';
import type { OverlayHostedModal } from '../../shared/ipc/contracts';
const YOUTUBE_PICKER_MODAL: OverlayHostedModal = 'youtube-track-picker';
const YOUTUBE_PICKER_OPEN_TIMEOUT_MS = 1500;
export async function openYoutubeTrackPicker(
deps: {
sendToActiveOverlayWindow: (
channel: string,
payload?: unknown,
runtimeOptions?: {
restoreOnModalClose?: OverlayHostedModal;
preferModalWindow?: boolean;
},
) => boolean;
waitForModalOpen: (modal: OverlayHostedModal, timeoutMs: number) => Promise<boolean>;
logWarn: (message: string) => void;
},
payload: YoutubePickerOpenPayload,
): Promise<boolean> {
const sendPickerOpen = (): boolean =>
deps.sendToActiveOverlayWindow('youtube:picker-open', payload, {
restoreOnModalClose: YOUTUBE_PICKER_MODAL,
preferModalWindow: true,
});
if (!sendPickerOpen()) {
return false;
}
if (await deps.waitForModalOpen(YOUTUBE_PICKER_MODAL, YOUTUBE_PICKER_OPEN_TIMEOUT_MS)) {
return true;
}
deps.logWarn(
'YouTube subtitle picker did not acknowledge modal open on first attempt; retrying dedicated modal window.',
);
if (!sendPickerOpen()) {
return false;
}
return await deps.waitForModalOpen(YOUTUBE_PICKER_MODAL, YOUTUBE_PICKER_OPEN_TIMEOUT_MS);
}

View File

@@ -1,7 +1,10 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { parseArgs } from '../cli/args';
import {
applyStartupState,
createAppState,
createInitialAnilistMediaGuessRuntimeState,
createInitialAnilistUpdateInFlightState,
transitionAnilistClientSecretState,
@@ -91,3 +94,22 @@ test('transitionAnilistUpdateInFlightState updates inFlight only', () => {
assert.deepEqual(transitioned, { inFlight: true });
assert.notEqual(transitioned, current);
});
test('applyStartupState does not mark youtube playback flow pending from startup args alone', () => {
const appState = createAppState({
mpvSocketPath: '/tmp/mpv.sock',
texthookerPort: 4000,
});
applyStartupState(appState, {
initialArgs: parseArgs(['--youtube-play', 'https://www.youtube.com/watch?v=video123']),
mpvSocketPath: '/tmp/mpv.sock',
texthookerPort: 4000,
backendOverride: null,
autoStartOverlay: false,
texthookerOnlyMode: false,
backgroundMode: false,
});
assert.equal(appState.youtubePlaybackFlowPending, false);
});

View File

@@ -293,7 +293,7 @@ export function createAppState(values: AppStateInitialValues): AppState {
export function applyStartupState(appState: AppState, startupState: StartupState): void {
appState.initialArgs = startupState.initialArgs;
appState.youtubePlaybackFlowPending = Boolean(startupState.initialArgs.youtubePlay);
appState.youtubePlaybackFlowPending = false;
appState.mpvSocketPath = startupState.mpvSocketPath;
appState.texthookerPort = startupState.texthookerPort;
appState.backendOverride = startupState.backendOverride;

View File

@@ -53,6 +53,7 @@ function createFakeElement() {
test('youtube track picker close restores focus and mouse-ignore state', () => {
const overlayFocusCalls: number[] = [];
const windowFocusCalls: number[] = [];
const focusMainWindowCalls: number[] = [];
const ignoreCalls: Array<{ ignore: boolean; forward?: boolean }> = [];
const notifications: string[] = [];
const frontendCommands: unknown[] = [];
@@ -92,6 +93,9 @@ test('youtube track picker close restores focus and mouse-ignore state', () => {
notifyOverlayModalClosed: (modal: string) => {
notifications.push(modal);
},
focusMainWindow: async () => {
focusMainWindowCalls.push(1);
},
youtubePickerResolve: async () => ({ ok: true, message: '' }),
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => {
ignoreCalls.push({ ignore, forward: options?.forward });
@@ -160,6 +164,7 @@ test('youtube track picker close restores focus and mouse-ignore state', () => {
assert.deepEqual(notifications, ['youtube-track-picker']);
assert.deepEqual(frontendCommands, [{ type: 'refreshOptions' }]);
assert.equal(overlay.classList.contains('interactive'), false);
assert.equal(focusMainWindowCalls.length > 0, true);
assert.equal(overlayFocusCalls.length > 0, true);
assert.equal(windowFocusCalls.length > 0, true);
assert.deepEqual(ignoreCalls, [{ ignore: true, forward: true }]);
@@ -556,3 +561,131 @@ test('youtube track picker only consumes handled keys', async () => {
Object.defineProperty(globalThis, 'document', { configurable: true, value: originalDocument });
}
});
test('youtube track picker ignores immediate Enter after open before allowing keyboard submit', async () => {
const resolveCalls: Array<{
sessionId: string;
action: string;
primaryTrackId: string | null;
secondaryTrackId: string | null;
}> = [];
const originalWindow = globalThis.window;
const originalDocument = globalThis.document;
const originalDateNow = Date.now;
let now = 10_000;
Object.defineProperty(globalThis, 'document', {
configurable: true,
value: {
createElement: () => createFakeElement(),
},
});
Date.now = () => now;
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
dispatchEvent: () => true,
focus: () => {},
electronAPI: {
notifyOverlayModalOpened: () => {},
notifyOverlayModalClosed: () => {},
youtubePickerResolve: async (payload: {
sessionId: string;
action: string;
primaryTrackId: string | null;
secondaryTrackId: string | null;
}) => {
resolveCalls.push(payload);
return { ok: true, message: '' };
},
setIgnoreMouseEvents: () => {},
},
},
});
try {
const state = createRendererState();
const dom = {
overlay: {
classList: createClassList(),
focus: () => {},
},
youtubePickerModal: createFakeElement(),
youtubePickerTitle: createFakeElement(),
youtubePickerPrimarySelect: createFakeElement(),
youtubePickerSecondarySelect: createFakeElement(),
youtubePickerTracks: createFakeElement(),
youtubePickerStatus: createFakeElement(),
youtubePickerContinueButton: createFakeElement(),
youtubePickerCloseButton: createFakeElement(),
};
const modal = createYoutubeTrackPickerModal(
{
state,
dom,
platform: {
shouldToggleMouseIgnore: false,
},
} as never,
{
modalStateReader: { isAnyModalOpen: () => true },
restorePointerInteractionState: () => {},
syncSettingsModalSubtitleSuppression: () => {},
},
);
modal.openYoutubePickerModal({
sessionId: 'yt-1',
url: 'https://example.com',
mode: 'download',
tracks: [
{
id: 'auto:ja-orig',
language: 'ja',
sourceLanguage: 'ja-orig',
kind: 'auto',
label: 'Japanese (auto)',
},
],
defaultPrimaryTrackId: 'auto:ja-orig',
defaultSecondaryTrackId: null,
hasTracks: true,
});
assert.equal(
modal.handleYoutubePickerKeydown({
key: 'Enter',
preventDefault: () => {},
} as KeyboardEvent),
true,
);
await Promise.resolve();
assert.deepEqual(resolveCalls, []);
assert.equal(state.youtubePickerModalOpen, true);
now += 250;
assert.equal(
modal.handleYoutubePickerKeydown({
key: 'Enter',
preventDefault: () => {},
} as KeyboardEvent),
true,
);
await Promise.resolve();
assert.deepEqual(resolveCalls, [
{
sessionId: 'yt-1',
action: 'use-selected',
primaryTrackId: 'auto:ja-orig',
secondaryTrackId: null,
},
]);
} finally {
Date.now = originalDateNow;
Object.defineProperty(globalThis, 'window', { configurable: true, value: originalWindow });
Object.defineProperty(globalThis, 'document', { configurable: true, value: originalDocument });
}
});

View File

@@ -17,7 +17,9 @@ export function createYoutubeTrackPickerModal(
syncSettingsModalSubtitleSuppression: () => void;
},
) {
const OPEN_KEY_GUARD_MS = 200;
let resolveSelectionInFlight = false;
let keyboardSubmitEnabledAtMs = 0;
function setStatus(message: string, isError = false): void {
ctx.state.youtubePickerStatus = message;
@@ -162,6 +164,7 @@ export function createYoutubeTrackPickerModal(
}
function openYoutubePickerModal(payload: YoutubePickerOpenPayload): void {
keyboardSubmitEnabledAtMs = Date.now() + OPEN_KEY_GUARD_MS;
if (ctx.state.youtubePickerModalOpen) {
options.syncSettingsModalSubtitleSuppression();
applyPayload(payload);
@@ -199,6 +202,7 @@ export function createYoutubeTrackPickerModal(
ctx.dom.overlay.classList.remove('interactive');
}
options.restorePointerInteractionState();
void window.electronAPI.focusMainWindow();
if (typeof ctx.dom.overlay.focus === 'function') {
ctx.dom.overlay.focus({ preventScroll: true });
}
@@ -223,6 +227,9 @@ export function createYoutubeTrackPickerModal(
if (e.key === 'Enter') {
e.preventDefault();
if (Date.now() < keyboardSubmitEnabledAtMs) {
return true;
}
void resolveSelection(
ctx.state.youtubePickerPayload?.hasTracks ? 'use-selected' : 'continue-without-subtitles',
);