Improve startup dictionary progress and fix overlay/plugin input handlin

- show a dedicated startup OSD "building" phase for character dictionary sync
- forward bare `Tab` from visible overlay to mpv so AniSkip works while focused
- fix Windows plugin env override resolution for `SUBMINER_BINARY_PATH`
This commit is contained in:
2026-03-09 02:35:03 -07:00
parent e0f82d28f0
commit e59192bbe1
28 changed files with 577 additions and 104 deletions

View File

@@ -331,7 +331,7 @@ test('auto sync invokes completion callback after successful sync', async () =>
test('auto sync emits progress events for start import and completion', async () => {
const userDataPath = makeTempDir();
const events: Array<{
phase: 'checking' | 'generating' | 'syncing' | 'importing' | 'ready' | 'failed';
phase: 'checking' | 'generating' | 'syncing' | 'building' | 'importing' | 'ready' | 'failed';
mediaId?: number;
mediaTitle?: string;
message: string;
@@ -406,6 +406,12 @@ test('auto sync emits progress events for start import and completion', async ()
mediaTitle: 'Rascal Does Not Dream of Bunny Girl Senpai',
message: 'Updating character dictionary for Rascal Does Not Dream of Bunny Girl Senpai...',
},
{
phase: 'building',
mediaId: 101291,
mediaTitle: 'Rascal Does Not Dream of Bunny Girl Senpai',
message: 'Building character dictionary for Rascal Does Not Dream of Bunny Girl Senpai...',
},
{
phase: 'importing',
mediaId: 101291,
@@ -425,7 +431,7 @@ test('auto sync emits progress events for start import and completion', async ()
test('auto sync emits checking before snapshot resolves and skips generating on cache hit', async () => {
const userDataPath = makeTempDir();
const events: Array<{
phase: 'checking' | 'generating' | 'syncing' | 'importing' | 'ready' | 'failed';
phase: 'checking' | 'generating' | 'syncing' | 'building' | 'importing' | 'ready' | 'failed';
mediaId?: number;
mediaTitle?: string;
message: string;
@@ -503,6 +509,77 @@ test('auto sync emits checking before snapshot resolves and skips generating on
);
});
test('auto sync emits building while merged dictionary generation is in flight', async () => {
const userDataPath = makeTempDir();
const events: Array<{
phase: 'checking' | 'generating' | 'building' | 'syncing' | 'importing' | 'ready' | 'failed';
mediaId?: number;
mediaTitle?: string;
message: string;
changed?: boolean;
}> = [];
const buildDeferred = createDeferred<{
zipPath: string;
revision: string;
dictionaryTitle: string;
entryCount: number;
}>();
let importedRevision: string | null = null;
const runtime = createCharacterDictionaryAutoSyncRuntimeService({
userDataPath,
getConfig: () => ({
enabled: true,
maxLoaded: 3,
profileScope: 'all',
}),
getOrCreateCurrentSnapshot: async (_targetPath, progress) => {
progress?.onChecking?.({
mediaId: 101291,
mediaTitle: 'Rascal Does Not Dream of Bunny Girl Senpai',
});
return {
mediaId: 101291,
mediaTitle: 'Rascal Does Not Dream of Bunny Girl Senpai',
entryCount: 2560,
fromCache: true,
updatedAt: 1000,
};
},
buildMergedDictionary: async () => await buildDeferred.promise,
getYomitanDictionaryInfo: async () =>
importedRevision
? [{ title: 'SubMiner Character Dictionary', revision: importedRevision }]
: [],
importYomitanDictionary: async () => {
importedRevision = 'rev-101291';
return true;
},
deleteYomitanDictionary: async () => true,
upsertYomitanDictionarySettings: async () => true,
now: () => 1000,
onSyncStatus: (event) => {
events.push(event);
},
});
const syncPromise = runtime.runSyncNow();
await Promise.resolve();
assert.equal(
events.some((event) => event.phase === 'building'),
true,
);
buildDeferred.resolve({
zipPath: '/tmp/merged.zip',
revision: 'rev-101291',
dictionaryTitle: 'SubMiner Character Dictionary',
entryCount: 2560,
});
await syncPromise;
});
test('auto sync waits for tokenization-ready gate before Yomitan mutations', async () => {
const userDataPath = makeTempDir();
const gate = (() => {

View File

@@ -25,7 +25,7 @@ export interface CharacterDictionaryAutoSyncConfig {
}
export interface CharacterDictionaryAutoSyncStatusEvent {
phase: 'checking' | 'generating' | 'syncing' | 'importing' | 'ready' | 'failed';
phase: 'checking' | 'generating' | 'syncing' | 'building' | 'importing' | 'ready' | 'failed';
mediaId?: number;
mediaTitle?: string;
message: string;
@@ -123,6 +123,10 @@ function buildImportingMessage(mediaTitle: string): string {
return `Importing character dictionary for ${mediaTitle}...`;
}
function buildBuildingMessage(mediaTitle: string): string {
return `Building character dictionary for ${mediaTitle}...`;
}
function buildReadyMessage(mediaTitle: string): string {
return `Character dictionary ready for ${mediaTitle}`;
}
@@ -227,6 +231,12 @@ export function createCharacterDictionaryAutoSyncRuntimeService(
!state.mergedDictionaryTitle ||
!snapshot.fromCache
) {
deps.onSyncStatus?.({
phase: 'building',
mediaId: snapshot.mediaId,
mediaTitle: snapshot.mediaTitle,
message: buildBuildingMessage(snapshot.mediaTitle),
});
deps.logInfo?.('[dictionary:auto-sync] rebuilding merged dictionary for active anime set');
merged = await deps.buildMergedDictionary(nextActiveMediaIds);
}

View File

@@ -25,7 +25,12 @@ test('createConfigHotReloadAppliedHandler runs all hot-reload effects', () => {
applyHotReload(
{
hotReloadFields: ['shortcuts', 'secondarySub.defaultMode', 'ankiConnect.ai'],
hotReloadFields: [
'shortcuts',
'secondarySub.defaultMode',
'ankiConnect.ai',
'subtitleStyle.autoPauseVideoOnHover',
],
restartRequiredFields: [],
},
config,

View File

@@ -16,12 +16,14 @@ test('overlay window factory main deps builders return mapped handlers', () => {
setOverlayDebugVisualizationEnabled: (enabled) => calls.push(`debug:${enabled}`),
isOverlayVisible: (kind) => kind === 'visible',
tryHandleOverlayShortcutLocalFallback: () => false,
forwardTabToMpv: () => calls.push('forward-tab'),
onWindowClosed: (kind) => calls.push(`closed:${kind}`),
});
const overlayDeps = buildOverlayDeps();
assert.equal(overlayDeps.isDev, true);
assert.equal(overlayDeps.isOverlayVisible('visible'), true);
overlayDeps.forwardTabToMpv();
const buildMainDeps = createBuildCreateMainWindowMainDepsHandler({
createOverlayWindow: () => ({ id: 'visible' }),
@@ -37,5 +39,5 @@ test('overlay window factory main deps builders return mapped handlers', () => {
const modalDeps = buildModalDeps();
modalDeps.setModalWindow(null);
assert.deepEqual(calls, ['set-main', 'set-modal']);
assert.deepEqual(calls, ['forward-tab', 'set-main', 'set-modal']);
});

View File

@@ -8,6 +8,7 @@ export function createBuildCreateOverlayWindowMainDepsHandler<TWindow>(deps: {
setOverlayDebugVisualizationEnabled: (enabled: boolean) => void;
isOverlayVisible: (windowKind: 'visible' | 'modal') => boolean;
tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean;
forwardTabToMpv: () => void;
onWindowClosed: (windowKind: 'visible' | 'modal') => void;
},
) => TWindow;
@@ -17,6 +18,7 @@ export function createBuildCreateOverlayWindowMainDepsHandler<TWindow>(deps: {
setOverlayDebugVisualizationEnabled: (enabled: boolean) => void;
isOverlayVisible: (windowKind: 'visible' | 'modal') => boolean;
tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean;
forwardTabToMpv: () => void;
onWindowClosed: (windowKind: 'visible' | 'modal') => void;
}) {
return () => ({
@@ -27,6 +29,7 @@ export function createBuildCreateOverlayWindowMainDepsHandler<TWindow>(deps: {
setOverlayDebugVisualizationEnabled: deps.setOverlayDebugVisualizationEnabled,
isOverlayVisible: deps.isOverlayVisible,
tryHandleOverlayShortcutLocalFallback: deps.tryHandleOverlayShortcutLocalFallback,
forwardTabToMpv: deps.forwardTabToMpv,
onWindowClosed: deps.onWindowClosed,
});
}

View File

@@ -15,6 +15,7 @@ test('create overlay window handler forwards options and kind', () => {
assert.equal(options.isDev, true);
assert.equal(options.isOverlayVisible('visible'), true);
assert.equal(options.isOverlayVisible('modal'), false);
options.forwardTabToMpv();
options.onRuntimeOptionsChanged();
options.setOverlayDebugVisualizationEnabled(true);
options.onWindowClosed(kind);
@@ -26,11 +27,18 @@ test('create overlay window handler forwards options and kind', () => {
setOverlayDebugVisualizationEnabled: (enabled) => calls.push(`debug:${enabled}`),
isOverlayVisible: (kind) => kind === 'visible',
tryHandleOverlayShortcutLocalFallback: () => false,
forwardTabToMpv: () => calls.push('forward-tab'),
onWindowClosed: (kind) => calls.push(`closed:${kind}`),
});
assert.equal(createOverlayWindow('visible'), window);
assert.deepEqual(calls, ['kind:visible', 'runtime-options', 'debug:true', 'closed:visible']);
assert.deepEqual(calls, [
'kind:visible',
'forward-tab',
'runtime-options',
'debug:true',
'closed:visible',
]);
});
test('create main window handler stores visible window', () => {

View File

@@ -10,6 +10,7 @@ export function createCreateOverlayWindowHandler<TWindow>(deps: {
setOverlayDebugVisualizationEnabled: (enabled: boolean) => void;
isOverlayVisible: (windowKind: OverlayWindowKind) => boolean;
tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean;
forwardTabToMpv: () => void;
onWindowClosed: (windowKind: OverlayWindowKind) => void;
},
) => TWindow;
@@ -19,6 +20,7 @@ export function createCreateOverlayWindowHandler<TWindow>(deps: {
setOverlayDebugVisualizationEnabled: (enabled: boolean) => void;
isOverlayVisible: (windowKind: OverlayWindowKind) => boolean;
tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean;
forwardTabToMpv: () => void;
onWindowClosed: (windowKind: OverlayWindowKind) => void;
}) {
return (kind: OverlayWindowKind): TWindow => {
@@ -29,6 +31,7 @@ export function createCreateOverlayWindowHandler<TWindow>(deps: {
setOverlayDebugVisualizationEnabled: deps.setOverlayDebugVisualizationEnabled,
isOverlayVisible: deps.isOverlayVisible,
tryHandleOverlayShortcutLocalFallback: deps.tryHandleOverlayShortcutLocalFallback,
forwardTabToMpv: deps.forwardTabToMpv,
onWindowClosed: deps.onWindowClosed,
});
};

View File

@@ -19,6 +19,7 @@ test('overlay window runtime handlers compose create/main/modal handlers', () =>
},
isOverlayVisible: (kind) => kind === 'visible',
tryHandleOverlayShortcutLocalFallback: () => false,
forwardTabToMpv: () => calls.push('forward-tab'),
onWindowClosed: (kind) => calls.push(`closed:${kind}`),
},
setMainWindow: (window) => {

View File

@@ -72,6 +72,31 @@ test('startup OSD buffers checking behind annotations and replaces it with later
]);
});
test('startup OSD replaces earlier dictionary progress with later building progress', () => {
const osdMessages: string[] = [];
const sequencer = createStartupOsdSequencer({
showOsd: (message) => {
osdMessages.push(message);
},
});
sequencer.notifyCharacterDictionaryStatus(
makeDictionaryEvent('syncing', 'Updating character dictionary for Frieren...'),
);
sequencer.showAnnotationLoading('Loading subtitle annotations |');
sequencer.markTokenizationReady();
sequencer.notifyCharacterDictionaryStatus(
makeDictionaryEvent('building', 'Building character dictionary for Frieren...'),
);
sequencer.markAnnotationLoadingComplete('Subtitle annotations loaded');
assert.deepEqual(osdMessages, [
'Loading subtitle annotations |',
'Building character dictionary for Frieren...',
]);
});
test('startup OSD skips buffered dictionary ready messages when progress completed before it became visible', () => {
const osdMessages: string[] = [];
const sequencer = createStartupOsdSequencer({

View File

@@ -1,5 +1,5 @@
export interface StartupOsdSequencerCharacterDictionaryEvent {
phase: 'checking' | 'generating' | 'syncing' | 'importing' | 'ready' | 'failed';
phase: 'checking' | 'generating' | 'syncing' | 'building' | 'importing' | 'ready' | 'failed';
message: string;
}
@@ -74,6 +74,7 @@ export function createStartupOsdSequencer(deps: { showOsd: (message: string) =>
event.phase === 'checking' ||
event.phase === 'generating' ||
event.phase === 'syncing' ||
event.phase === 'building' ||
event.phase === 'importing'
) {
pendingDictionaryProgress = event;