mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-20 03:16:46 -07:00
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:
@@ -72,6 +72,10 @@ export {
|
||||
syncOverlayWindowLayer,
|
||||
updateOverlayWindowBounds,
|
||||
} from './overlay-window';
|
||||
export {
|
||||
handleOverlayWindowBeforeInputEvent,
|
||||
isTabInputForMpvForwarding,
|
||||
} from './overlay-window-input';
|
||||
export { initializeOverlayRuntime } from './overlay-runtime-init';
|
||||
export { setVisibleOverlayVisible, updateVisibleOverlayVisibility } from './overlay-visibility';
|
||||
export {
|
||||
|
||||
61
src/core/services/overlay-window-input.ts
Normal file
61
src/core/services/overlay-window-input.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
export type OverlayWindowKind = 'visible' | 'modal';
|
||||
|
||||
export function isTabInputForMpvForwarding(input: Electron.Input): boolean {
|
||||
if (input.type !== 'keyDown' || input.isAutoRepeat) return false;
|
||||
if (input.alt || input.control || input.meta || input.shift) return false;
|
||||
return input.code === 'Tab' || input.key === 'Tab';
|
||||
}
|
||||
|
||||
function isLookupWindowToggleInput(input: Electron.Input): boolean {
|
||||
if (input.type !== 'keyDown') return false;
|
||||
if (input.alt) return false;
|
||||
if (!input.control && !input.meta) return false;
|
||||
if (input.shift) return false;
|
||||
const normalizedKey = typeof input.key === 'string' ? input.key.toLowerCase() : '';
|
||||
return input.code === 'KeyY' || normalizedKey === 'y';
|
||||
}
|
||||
|
||||
function isKeyboardModeToggleInput(input: Electron.Input): boolean {
|
||||
if (input.type !== 'keyDown') return false;
|
||||
if (input.alt) return false;
|
||||
if (!input.control && !input.meta) return false;
|
||||
if (!input.shift) return false;
|
||||
const normalizedKey = typeof input.key === 'string' ? input.key.toLowerCase() : '';
|
||||
return input.code === 'KeyY' || normalizedKey === 'y';
|
||||
}
|
||||
|
||||
export function handleOverlayWindowBeforeInputEvent(options: {
|
||||
kind: OverlayWindowKind;
|
||||
windowVisible: boolean;
|
||||
input: Electron.Input;
|
||||
preventDefault: () => void;
|
||||
sendKeyboardModeToggleRequested: () => void;
|
||||
sendLookupWindowToggleRequested: () => void;
|
||||
tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean;
|
||||
forwardTabToMpv: () => void;
|
||||
}): boolean {
|
||||
if (options.kind === 'modal') return false;
|
||||
if (!options.windowVisible) return false;
|
||||
|
||||
if (isKeyboardModeToggleInput(options.input)) {
|
||||
options.preventDefault();
|
||||
options.sendKeyboardModeToggleRequested();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (isLookupWindowToggleInput(options.input)) {
|
||||
options.preventDefault();
|
||||
options.sendLookupWindowToggleRequested();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (isTabInputForMpvForwarding(options.input)) {
|
||||
options.preventDefault();
|
||||
options.forwardTabToMpv();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!options.tryHandleOverlayShortcutLocalFallback(options.input)) return false;
|
||||
options.preventDefault();
|
||||
return true;
|
||||
}
|
||||
84
src/core/services/overlay-window.test.ts
Normal file
84
src/core/services/overlay-window.test.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
handleOverlayWindowBeforeInputEvent,
|
||||
isTabInputForMpvForwarding,
|
||||
} from './overlay-window-input';
|
||||
|
||||
test('isTabInputForMpvForwarding matches bare Tab keydown only', () => {
|
||||
assert.equal(
|
||||
isTabInputForMpvForwarding({
|
||||
type: 'keyDown',
|
||||
key: 'Tab',
|
||||
code: 'Tab',
|
||||
} as Electron.Input),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
isTabInputForMpvForwarding({
|
||||
type: 'keyDown',
|
||||
key: 'Tab',
|
||||
code: 'Tab',
|
||||
shift: true,
|
||||
} as Electron.Input),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
isTabInputForMpvForwarding({
|
||||
type: 'keyUp',
|
||||
key: 'Tab',
|
||||
code: 'Tab',
|
||||
} as Electron.Input),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('handleOverlayWindowBeforeInputEvent forwards Tab to mpv for visible overlays', () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
const handled = handleOverlayWindowBeforeInputEvent({
|
||||
kind: 'visible',
|
||||
windowVisible: true,
|
||||
input: {
|
||||
type: 'keyDown',
|
||||
key: 'Tab',
|
||||
code: 'Tab',
|
||||
} as Electron.Input,
|
||||
preventDefault: () => calls.push('prevent-default'),
|
||||
sendKeyboardModeToggleRequested: () => calls.push('keyboard-mode'),
|
||||
sendLookupWindowToggleRequested: () => calls.push('lookup-toggle'),
|
||||
tryHandleOverlayShortcutLocalFallback: () => {
|
||||
calls.push('fallback');
|
||||
return false;
|
||||
},
|
||||
forwardTabToMpv: () => calls.push('forward-tab'),
|
||||
});
|
||||
|
||||
assert.equal(handled, true);
|
||||
assert.deepEqual(calls, ['prevent-default', 'forward-tab']);
|
||||
});
|
||||
|
||||
test('handleOverlayWindowBeforeInputEvent leaves modal Tab handling alone', () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
const handled = handleOverlayWindowBeforeInputEvent({
|
||||
kind: 'modal',
|
||||
windowVisible: true,
|
||||
input: {
|
||||
type: 'keyDown',
|
||||
key: 'Tab',
|
||||
code: 'Tab',
|
||||
} as Electron.Input,
|
||||
preventDefault: () => calls.push('prevent-default'),
|
||||
sendKeyboardModeToggleRequested: () => calls.push('keyboard-mode'),
|
||||
sendLookupWindowToggleRequested: () => calls.push('lookup-toggle'),
|
||||
tryHandleOverlayShortcutLocalFallback: () => {
|
||||
calls.push('fallback');
|
||||
return false;
|
||||
},
|
||||
forwardTabToMpv: () => calls.push('forward-tab'),
|
||||
});
|
||||
|
||||
assert.equal(handled, false);
|
||||
assert.deepEqual(calls, []);
|
||||
});
|
||||
@@ -3,6 +3,10 @@ import * as path from 'path';
|
||||
import { WindowGeometry } from '../../types';
|
||||
import { createLogger } from '../../logger';
|
||||
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
|
||||
import {
|
||||
handleOverlayWindowBeforeInputEvent,
|
||||
type OverlayWindowKind,
|
||||
} from './overlay-window-input';
|
||||
|
||||
const logger = createLogger('main:overlay-window');
|
||||
const overlayWindowLayerByInstance = new WeakMap<BrowserWindow, OverlayWindowKind>();
|
||||
@@ -23,26 +27,6 @@ function loadOverlayWindowLayer(window: BrowserWindow, layer: OverlayWindowKind)
|
||||
});
|
||||
}
|
||||
|
||||
export type OverlayWindowKind = 'visible' | 'modal';
|
||||
|
||||
function isLookupWindowToggleInput(input: Electron.Input): boolean {
|
||||
if (input.type !== 'keyDown') return false;
|
||||
if (input.alt) return false;
|
||||
if (!input.control && !input.meta) return false;
|
||||
if (input.shift) return false;
|
||||
const normalizedKey = typeof input.key === 'string' ? input.key.toLowerCase() : '';
|
||||
return input.code === 'KeyY' || normalizedKey === 'y';
|
||||
}
|
||||
|
||||
function isKeyboardModeToggleInput(input: Electron.Input): boolean {
|
||||
if (input.type !== 'keyDown') return false;
|
||||
if (input.alt) return false;
|
||||
if (!input.control && !input.meta) return false;
|
||||
if (!input.shift) return false;
|
||||
const normalizedKey = typeof input.key === 'string' ? input.key.toLowerCase() : '';
|
||||
return input.code === 'KeyY' || normalizedKey === 'y';
|
||||
}
|
||||
|
||||
export function updateOverlayWindowBounds(
|
||||
geometry: WindowGeometry,
|
||||
window: BrowserWindow | null,
|
||||
@@ -92,6 +76,7 @@ export function createOverlayWindow(
|
||||
setOverlayDebugVisualizationEnabled: (enabled: boolean) => void;
|
||||
isOverlayVisible: (kind: OverlayWindowKind) => boolean;
|
||||
tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean;
|
||||
forwardTabToMpv: () => void;
|
||||
onWindowClosed: (kind: OverlayWindowKind) => void;
|
||||
},
|
||||
): BrowserWindow {
|
||||
@@ -142,20 +127,19 @@ export function createOverlayWindow(
|
||||
}
|
||||
|
||||
window.webContents.on('before-input-event', (event, input) => {
|
||||
if (kind === 'modal') return;
|
||||
if (!window.isVisible()) return;
|
||||
if (isKeyboardModeToggleInput(input)) {
|
||||
event.preventDefault();
|
||||
window.webContents.send(IPC_CHANNELS.event.keyboardModeToggleRequested);
|
||||
return;
|
||||
}
|
||||
if (isLookupWindowToggleInput(input)) {
|
||||
event.preventDefault();
|
||||
window.webContents.send(IPC_CHANNELS.event.lookupWindowToggleRequested);
|
||||
return;
|
||||
}
|
||||
if (!options.tryHandleOverlayShortcutLocalFallback(input)) return;
|
||||
event.preventDefault();
|
||||
handleOverlayWindowBeforeInputEvent({
|
||||
kind,
|
||||
windowVisible: window.isVisible(),
|
||||
input,
|
||||
preventDefault: () => event.preventDefault(),
|
||||
sendKeyboardModeToggleRequested: () =>
|
||||
window.webContents.send(IPC_CHANNELS.event.keyboardModeToggleRequested),
|
||||
sendLookupWindowToggleRequested: () =>
|
||||
window.webContents.send(IPC_CHANNELS.event.lookupWindowToggleRequested),
|
||||
tryHandleOverlayShortcutLocalFallback: (nextInput) =>
|
||||
options.tryHandleOverlayShortcutLocalFallback(nextInput),
|
||||
forwardTabToMpv: () => options.forwardTabToMpv(),
|
||||
});
|
||||
});
|
||||
|
||||
window.hide();
|
||||
@@ -185,3 +169,5 @@ export function syncOverlayWindowLayer(window: BrowserWindow, layer: 'visible'):
|
||||
if (overlayWindowLayerByInstance.get(window) === layer) return;
|
||||
loadOverlayWindowLayer(window, layer);
|
||||
}
|
||||
|
||||
export type { OverlayWindowKind } from './overlay-window-input';
|
||||
|
||||
@@ -3514,6 +3514,7 @@ const { createMainWindow: createMainWindowHandler, createModalWindow: createModa
|
||||
windowKind === 'visible' ? overlayManager.getVisibleOverlayVisible() : false,
|
||||
tryHandleOverlayShortcutLocalFallback: (input) =>
|
||||
overlayShortcutsRuntime.tryHandleOverlayShortcutLocalFallback(input),
|
||||
forwardTabToMpv: () => sendMpvCommandRuntime(appState.mpvClient, ['keypress', 'TAB']),
|
||||
onWindowClosed: (windowKind) => {
|
||||
if (windowKind === 'visible') {
|
||||
overlayManager.setMainWindow(null);
|
||||
|
||||
@@ -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 = (() => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -38,6 +38,7 @@ function createContext(subtitleHeight: number) {
|
||||
state: {
|
||||
currentYPercent: null,
|
||||
persistedSubtitlePosition: { yPercent: 10 },
|
||||
isOverSubtitle: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -84,6 +84,19 @@ function getNextPersistedPosition(
|
||||
};
|
||||
}
|
||||
|
||||
function applyMarginBottom(ctx: RendererContext, yPercent: number): void {
|
||||
const clampedPercent = clampYPercent(ctx, yPercent);
|
||||
ctx.state.currentYPercent = clampedPercent;
|
||||
const marginBottom = (clampedPercent / 100) * getViewportHeight();
|
||||
|
||||
ctx.dom.subtitleContainer.style.position = '';
|
||||
ctx.dom.subtitleContainer.style.left = '';
|
||||
ctx.dom.subtitleContainer.style.top = '';
|
||||
ctx.dom.subtitleContainer.style.right = '';
|
||||
ctx.dom.subtitleContainer.style.transform = '';
|
||||
ctx.dom.subtitleContainer.style.marginBottom = `${marginBottom}px`;
|
||||
}
|
||||
|
||||
export function createInMemorySubtitlePositionController(
|
||||
ctx: RendererContext,
|
||||
): SubtitlePositionController {
|
||||
@@ -98,16 +111,7 @@ export function createInMemorySubtitlePositionController(
|
||||
}
|
||||
|
||||
function applyYPercent(yPercent: number): void {
|
||||
const clampedPercent = clampYPercent(ctx, yPercent);
|
||||
ctx.state.currentYPercent = clampedPercent;
|
||||
const marginBottom = (clampedPercent / 100) * getViewportHeight();
|
||||
|
||||
ctx.dom.subtitleContainer.style.position = '';
|
||||
ctx.dom.subtitleContainer.style.left = '';
|
||||
ctx.dom.subtitleContainer.style.top = '';
|
||||
ctx.dom.subtitleContainer.style.right = '';
|
||||
ctx.dom.subtitleContainer.style.transform = '';
|
||||
ctx.dom.subtitleContainer.style.marginBottom = `${marginBottom}px`;
|
||||
applyMarginBottom(ctx, yPercent);
|
||||
}
|
||||
|
||||
function persistSubtitlePositionPatch(patch: Partial<SubtitlePosition>): void {
|
||||
|
||||
@@ -374,7 +374,8 @@ async function init(): Promise<void> {
|
||||
|
||||
await keyboardHandlers.setupMpvInputForwarding();
|
||||
|
||||
subtitleRenderer.applySubtitleStyle(await window.electronAPI.getSubtitleStyle());
|
||||
const initialSubtitleStyle = await window.electronAPI.getSubtitleStyle();
|
||||
subtitleRenderer.applySubtitleStyle(initialSubtitleStyle);
|
||||
|
||||
positioning.applyStoredSubtitlePosition(
|
||||
await window.electronAPI.getSubtitlePosition(),
|
||||
|
||||
Reference in New Issue
Block a user