mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-25 00:11:26 -07:00
feat: auto-load youtube subtitles before manual picker
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -10,6 +10,7 @@ test('composeIpcRuntimeHandlers returns callable IPC handlers and registration b
|
||||
mpvCommandMainDeps: {
|
||||
triggerSubsyncFromConfig: async () => {},
|
||||
openRuntimeOptionsPalette: () => {},
|
||||
openYoutubeTrackPicker: () => {},
|
||||
cycleRuntimeOption: () => ({ ok: true }),
|
||||
showMpvOsd: () => {},
|
||||
replayCurrentSubtitle: () => {},
|
||||
|
||||
@@ -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: () => {},
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
@@ -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}`),
|
||||
});
|
||||
|
||||
@@ -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: () => {},
|
||||
|
||||
@@ -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: () => {},
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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'));
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -7,6 +7,7 @@ export function createBuildOverlayVisibilityRuntimeMainDepsHandler(
|
||||
) {
|
||||
return (): OverlayVisibilityRuntimeDeps => ({
|
||||
getMainWindow: () => deps.getMainWindow(),
|
||||
getModalActive: () => deps.getModalActive(),
|
||||
getVisibleOverlayVisible: () => deps.getVisibleOverlayVisible(),
|
||||
getForceMousePassthrough: () => deps.getForceMousePassthrough(),
|
||||
getWindowTracker: () => deps.getWindowTracker(),
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
|
||||
101
src/main/runtime/youtube-picker-open.test.ts
Normal file
101
src/main/runtime/youtube-picker-open.test.ts
Normal 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);
|
||||
});
|
||||
42
src/main/runtime/youtube-picker-open.ts
Normal file
42
src/main/runtime/youtube-picker-open.ts
Normal 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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user