feat(stats): wire stats server, overlay, and CLI into main process

- Stats server auto-start on immersion tracker init
- Stats overlay toggle via keybinding and IPC
- Stats CLI command (subminer stats) with cleanup mode
- mpv plugin menu integration for stats toggle
- CLI args for --stats, --stats-cleanup, --stats-response-path
This commit is contained in:
2026-03-14 22:14:32 -07:00
parent a7c294a90c
commit 6d8650994f
37 changed files with 374 additions and 23 deletions

View File

@@ -143,6 +143,12 @@ test('hasExplicitCommand and shouldStartApp preserve command intent', () => {
assert.equal(dictionaryTarget.dictionary, true); assert.equal(dictionaryTarget.dictionary, true);
assert.equal(dictionaryTarget.dictionaryTarget, '/tmp/example.mkv'); assert.equal(dictionaryTarget.dictionaryTarget, '/tmp/example.mkv');
const stats = parseArgs(['--stats', '--stats-response-path', '/tmp/subminer-stats-response.json']);
assert.equal(stats.stats, true);
assert.equal(stats.statsResponsePath, '/tmp/subminer-stats-response.json');
assert.equal(hasExplicitCommand(stats), true);
assert.equal(shouldStartApp(stats), true);
const jellyfinLibraries = parseArgs(['--jellyfin-libraries']); const jellyfinLibraries = parseArgs(['--jellyfin-libraries']);
assert.equal(jellyfinLibraries.jellyfinLibraries, true); assert.equal(jellyfinLibraries.jellyfinLibraries, true);
assert.equal(hasExplicitCommand(jellyfinLibraries), true); assert.equal(hasExplicitCommand(jellyfinLibraries), true);

View File

@@ -29,6 +29,10 @@ export interface CliArgs {
anilistRetryQueue: boolean; anilistRetryQueue: boolean;
dictionary: boolean; dictionary: boolean;
dictionaryTarget?: string; dictionaryTarget?: string;
stats: boolean;
statsCleanup?: boolean;
statsCleanupVocab?: boolean;
statsResponsePath?: string;
jellyfin: boolean; jellyfin: boolean;
jellyfinLogin: boolean; jellyfinLogin: boolean;
jellyfinLogout: boolean; jellyfinLogout: boolean;
@@ -97,6 +101,9 @@ export function parseArgs(argv: string[]): CliArgs {
anilistSetup: false, anilistSetup: false,
anilistRetryQueue: false, anilistRetryQueue: false,
dictionary: false, dictionary: false,
stats: false,
statsCleanup: false,
statsCleanupVocab: false,
jellyfin: false, jellyfin: false,
jellyfinLogin: false, jellyfinLogin: false,
jellyfinLogout: false, jellyfinLogout: false,
@@ -162,6 +169,15 @@ export function parseArgs(argv: string[]): CliArgs {
} else if (arg === '--dictionary-target') { } else if (arg === '--dictionary-target') {
const value = readValue(argv[i + 1]); const value = readValue(argv[i + 1]);
if (value) args.dictionaryTarget = value; if (value) args.dictionaryTarget = value;
} else if (arg === '--stats') args.stats = true;
else if (arg === '--stats-cleanup') args.statsCleanup = true;
else if (arg === '--stats-cleanup-vocab') args.statsCleanupVocab = true;
else if (arg.startsWith('--stats-response-path=')) {
const value = arg.split('=', 2)[1];
if (value) args.statsResponsePath = value;
} else if (arg === '--stats-response-path') {
const value = readValue(argv[i + 1]);
if (value) args.statsResponsePath = value;
} else if (arg === '--jellyfin') args.jellyfin = true; } else if (arg === '--jellyfin') args.jellyfin = true;
else if (arg === '--jellyfin-login') args.jellyfinLogin = true; else if (arg === '--jellyfin-login') args.jellyfinLogin = true;
else if (arg === '--jellyfin-logout') args.jellyfinLogout = true; else if (arg === '--jellyfin-logout') args.jellyfinLogout = true;
@@ -331,6 +347,7 @@ export function hasExplicitCommand(args: CliArgs): boolean {
args.anilistSetup || args.anilistSetup ||
args.anilistRetryQueue || args.anilistRetryQueue ||
args.dictionary || args.dictionary ||
args.stats ||
args.jellyfin || args.jellyfin ||
args.jellyfinLogin || args.jellyfinLogin ||
args.jellyfinLogout || args.jellyfinLogout ||
@@ -367,6 +384,7 @@ export function shouldStartApp(args: CliArgs): boolean {
args.markAudioCard || args.markAudioCard ||
args.openRuntimeOptions || args.openRuntimeOptions ||
args.dictionary || args.dictionary ||
args.stats ||
args.jellyfin || args.jellyfin ||
args.jellyfinPlay || args.jellyfinPlay ||
args.texthooker args.texthooker
@@ -408,6 +426,7 @@ export function shouldRunSettingsOnlyStartup(args: CliArgs): boolean {
!args.anilistSetup && !args.anilistSetup &&
!args.anilistRetryQueue && !args.anilistRetryQueue &&
!args.dictionary && !args.dictionary &&
!args.stats &&
!args.jellyfin && !args.jellyfin &&
!args.jellyfinLogin && !args.jellyfinLogin &&
!args.jellyfinLogout && !args.jellyfinLogout &&

View File

@@ -18,6 +18,7 @@ test('printHelp includes configured texthooker port', () => {
assert.match(output, /--help\s+Show this help/); assert.match(output, /--help\s+Show this help/);
assert.match(output, /default: 7777/); assert.match(output, /default: 7777/);
assert.match(output, /--launch-mpv/); assert.match(output, /--launch-mpv/);
assert.match(output, /--stats\s+Open the stats dashboard in your browser/);
assert.match(output, /--refresh-known-words/); assert.match(output, /--refresh-known-words/);
assert.match(output, /--setup\s+Open first-run setup window/); assert.match(output, /--setup\s+Open first-run setup window/);
assert.match(output, /--anilist-status/); assert.match(output, /--anilist-status/);

View File

@@ -14,6 +14,7 @@ ${B}Session${R}
--start Connect to mpv and launch overlay --start Connect to mpv and launch overlay
--launch-mpv ${D}[targets...]${R} Launch mpv with the SubMiner mpv profile and exit --launch-mpv ${D}[targets...]${R} Launch mpv with the SubMiner mpv profile and exit
--stop Stop the running instance --stop Stop the running instance
--stats Open the stats dashboard in your browser
--texthooker Start texthooker server only ${D}(no overlay)${R} --texthooker Start texthooker server only ${D}(no overlay)${R}
${B}Overlay${R} ${B}Overlay${R}

View File

@@ -303,6 +303,8 @@ import {
upsertYomitanDictionarySettings, upsertYomitanDictionarySettings,
updateLastCardFromClipboard as updateLastCardFromClipboardCore, updateLastCardFromClipboard as updateLastCardFromClipboardCore,
} from './core/services'; } from './core/services';
import { startStatsServer } from './core/services/stats-server';
import { registerStatsOverlayToggle, destroyStatsWindow } from './core/services/stats-window.js';
import { import {
createFirstRunSetupService, createFirstRunSetupService,
shouldAutoOpenFirstRunSetup, shouldAutoOpenFirstRunSetup,
@@ -325,11 +327,18 @@ import {
} from './main/runtime/windows-mpv-shortcuts'; } from './main/runtime/windows-mpv-shortcuts';
import { createImmersionTrackerStartupHandler } from './main/runtime/immersion-startup'; import { createImmersionTrackerStartupHandler } from './main/runtime/immersion-startup';
import { createBuildImmersionTrackerStartupMainDepsHandler } from './main/runtime/immersion-startup-main-deps'; import { createBuildImmersionTrackerStartupMainDepsHandler } from './main/runtime/immersion-startup-main-deps';
import {
createRunStatsCliCommandHandler,
writeStatsCliCommandResponse,
} from './main/runtime/stats-cli-command';
import { resolveLegacyVocabularyPosFromTokens } from './core/services/immersion-tracker/legacy-vocabulary-pos';
import { createAnilistUpdateQueue } from './core/services/anilist/anilist-update-queue'; import { createAnilistUpdateQueue } from './core/services/anilist/anilist-update-queue';
import { import {
guessAnilistMediaInfo, guessAnilistMediaInfo,
updateAnilistPostWatchProgress, updateAnilistPostWatchProgress,
} from './core/services/anilist/anilist-updater'; } from './core/services/anilist/anilist-updater';
import { createCoverArtFetcher } from './core/services/anilist/cover-art-fetcher';
import { createAnilistRateLimiter } from './core/services/anilist/rate-limiter';
import { createJellyfinTokenStore } from './core/services/jellyfin-token-store'; import { createJellyfinTokenStore } from './core/services/jellyfin-token-store';
import { applyRuntimeOptionResultRuntime } from './core/services/runtime-options-ipc'; import { applyRuntimeOptionResultRuntime } from './core/services/runtime-options-ipc';
import { createAnilistTokenStore } from './core/services/anilist/anilist-token-store'; import { createAnilistTokenStore } from './core/services/anilist/anilist-token-store';
@@ -614,6 +623,11 @@ app.setPath('userData', USER_DATA_PATH);
let forceQuitTimer: ReturnType<typeof setTimeout> | null = null; let forceQuitTimer: ReturnType<typeof setTimeout> | null = null;
function requestAppQuit(): void { function requestAppQuit(): void {
destroyStatsWindow();
if (appState.statsServer) {
appState.statsServer.close();
appState.statsServer = null;
}
if (!forceQuitTimer) { if (!forceQuitTimer) {
forceQuitTimer = setTimeout(() => { forceQuitTimer = setTimeout(() => {
logger.warn('App quit timed out; forcing process exit.'); logger.warn('App quit timed out; forcing process exit.');
@@ -917,6 +931,10 @@ const buildMainSubsyncRuntimeMainDepsHandler = createBuildMainSubsyncRuntimeMain
const immersionMediaRuntime = createImmersionMediaRuntime( const immersionMediaRuntime = createImmersionMediaRuntime(
buildImmersionMediaRuntimeMainDepsHandler(), buildImmersionMediaRuntimeMainDepsHandler(),
); );
const statsCoverArtFetcher = createCoverArtFetcher(
createAnilistRateLimiter(),
createLogger('main:stats-cover-art'),
);
const anilistStateRuntime = createAnilistStateRuntime(buildAnilistStateRuntimeMainDepsHandler()); const anilistStateRuntime = createAnilistStateRuntime(buildAnilistStateRuntimeMainDepsHandler());
const configDerivedRuntime = createConfigDerivedRuntime(buildConfigDerivedRuntimeMainDepsHandler()); const configDerivedRuntime = createConfigDerivedRuntime(buildConfigDerivedRuntimeMainDepsHandler());
const subsyncRuntime = createMainSubsyncRuntime(buildMainSubsyncRuntimeMainDepsHandler()); const subsyncRuntime = createMainSubsyncRuntime(buildMainSubsyncRuntimeMainDepsHandler());
@@ -1025,11 +1043,11 @@ function maybeSignalPluginAutoplayReady(
} }
let appTray: Tray | null = null; let appTray: Tray | null = null;
let tokenizeSubtitleDeferred: ((text: string) => Promise<SubtitleData>) | null = null;
const buildSubtitleProcessingControllerMainDepsHandler = const buildSubtitleProcessingControllerMainDepsHandler =
createBuildSubtitleProcessingControllerMainDepsHandler({ createBuildSubtitleProcessingControllerMainDepsHandler({
tokenizeSubtitle: async (text: string) => { tokenizeSubtitle: async (text: string) =>
return await tokenizeSubtitle(text); tokenizeSubtitleDeferred ? await tokenizeSubtitleDeferred(text) : { text, tokens: null },
},
emitSubtitle: (payload) => { emitSubtitle: (payload) => {
appState.currentSubtitleData = payload; appState.currentSubtitleData = payload;
broadcastToOverlayWindows('subtitle:set', payload); broadcastToOverlayWindows('subtitle:set', payload);
@@ -2400,16 +2418,82 @@ const {
}); });
registerProtocolUrlHandlersHandler(); registerProtocolUrlHandlersHandler();
const statsDistPath = path.join(__dirname, '..', 'stats', 'dist');
const statsPreloadPath = path.join(__dirname, 'preload-stats.js');
const ensureStatsServerStarted = (): string => {
const tracker = appState.immersionTracker;
if (!tracker) {
throw new Error('Immersion tracker failed to initialize.');
}
if (!appState.statsServer) {
appState.statsServer = startStatsServer({
port: getResolvedConfig().stats.serverPort,
staticDir: statsDistPath,
tracker,
});
}
return `http://127.0.0.1:${getResolvedConfig().stats.serverPort}`;
};
const resolveLegacyVocabularyPos = async (row: {
headword: string;
word: string;
reading: string | null;
}) => {
const tokenizer = appState.mecabTokenizer;
if (!tokenizer) {
return null;
}
const lookupTexts = [...new Set([row.headword, row.word, row.reading ?? ''])]
.map((value) => value.trim())
.filter((value) => value.length > 0);
for (const lookupText of lookupTexts) {
const tokens = await tokenizer.tokenize(lookupText);
const resolved = resolveLegacyVocabularyPosFromTokens(lookupText, tokens);
if (resolved) {
return resolved;
}
}
return null;
};
const immersionTrackerStartupMainDeps: Parameters< const immersionTrackerStartupMainDeps: Parameters<
typeof createBuildImmersionTrackerStartupMainDepsHandler typeof createBuildImmersionTrackerStartupMainDepsHandler
>[0] = { >[0] = {
getResolvedConfig: () => getResolvedConfig(), getResolvedConfig: () => getResolvedConfig(),
getConfiguredDbPath: () => immersionMediaRuntime.getConfiguredDbPath(), getConfiguredDbPath: () => immersionMediaRuntime.getConfiguredDbPath(),
createTrackerService: (params) => new ImmersionTrackerService(params), createTrackerService: (params) =>
new ImmersionTrackerService({
...params,
resolveLegacyVocabularyPos,
}),
setTracker: (tracker) => { setTracker: (tracker) => {
appState.immersionTracker = tracker as ImmersionTrackerService | null; appState.immersionTracker = tracker as ImmersionTrackerService | null;
appState.immersionTracker?.setCoverArtFetcher(statsCoverArtFetcher);
if (tracker) {
// Start HTTP stats server (once)
if (!appState.statsServer) {
const config = getResolvedConfig();
if (config.stats.autoStartServer) {
ensureStatsServerStarted();
}
}
// Register stats overlay toggle IPC handler (idempotent)
registerStatsOverlayToggle({
staticDir: statsDistPath,
preloadPath: statsPreloadPath,
getToggleKey: () => getResolvedConfig().stats.toggleKey,
resolveBounds: () => getCurrentOverlayGeometry(),
});
}
}, },
getMpvClient: () => appState.mpvClient, getMpvClient: () => appState.mpvClient,
shouldAutoConnectMpv: () => !appState.statsStartupInProgress,
seedTrackerFromCurrentMedia: () => { seedTrackerFromCurrentMedia: () => {
void immersionMediaRuntime.seedFromCurrentMedia(); void immersionMediaRuntime.seedFromCurrentMedia();
}, },
@@ -2420,6 +2504,10 @@ const immersionTrackerStartupMainDeps: Parameters<
const createImmersionTrackerStartup = createImmersionTrackerStartupHandler( const createImmersionTrackerStartup = createImmersionTrackerStartupHandler(
createBuildImmersionTrackerStartupMainDepsHandler(immersionTrackerStartupMainDeps)(), createBuildImmersionTrackerStartupMainDepsHandler(immersionTrackerStartupMainDeps)(),
); );
const recordTrackedCardsMined = (count: number, noteIds?: number[]): void => {
ensureImmersionTrackerStarted();
appState.immersionTracker?.recordCardsMined(count, noteIds);
};
let hasAttemptedImmersionTrackerStartup = false; let hasAttemptedImmersionTrackerStartup = false;
const ensureImmersionTrackerStarted = (): void => { const ensureImmersionTrackerStarted = (): void => {
if (hasAttemptedImmersionTrackerStartup || appState.immersionTracker) { if (hasAttemptedImmersionTrackerStartup || appState.immersionTracker) {
@@ -2429,6 +2517,34 @@ const ensureImmersionTrackerStarted = (): void => {
createImmersionTrackerStartup(); createImmersionTrackerStartup();
}; };
const runStatsCliCommand = createRunStatsCliCommandHandler({
getResolvedConfig: () => getResolvedConfig(),
ensureImmersionTrackerStarted: () => {
appState.statsStartupInProgress = true;
try {
ensureImmersionTrackerStarted();
} finally {
appState.statsStartupInProgress = false;
}
},
ensureVocabularyCleanupTokenizerReady: async () => {
await createMecabTokenizerAndCheck();
},
getImmersionTracker: () => appState.immersionTracker,
ensureStatsServerStarted: () => ensureStatsServerStarted(),
openExternal: (url: string) => shell.openExternal(url),
writeResponse: (responsePath, payload) => {
writeStatsCliCommandResponse(responsePath, payload);
},
exitAppWithCode: (code) => {
process.exitCode = code;
requestAppQuit();
},
logInfo: (message) => logger.info(message),
logWarn: (message, error) => logger.warn(message, error),
logError: (message, error) => logger.error(message, error),
});
const { appReadyRuntimeRunner } = composeAppReadyRuntime({ const { appReadyRuntimeRunner } = composeAppReadyRuntime({
reloadConfigMainDeps: { reloadConfigMainDeps: {
reloadConfigStrict: () => configService.reloadConfigStrict(), reloadConfigStrict: () => configService.reloadConfigStrict(),
@@ -2576,10 +2692,13 @@ const { appReadyRuntimeRunner } = composeAppReadyRuntime({
setVisibleOverlayVisible: (visible: boolean) => setVisibleOverlayVisible(visible), setVisibleOverlayVisible: (visible: boolean) => setVisibleOverlayVisible(visible),
initializeOverlayRuntime: () => initializeOverlayRuntime(), initializeOverlayRuntime: () => initializeOverlayRuntime(),
handleInitialArgs: () => handleInitialArgs(), handleInitialArgs: () => handleInitialArgs(),
shouldUseMinimalStartup: () =>
Boolean(appState.initialArgs?.stats && appState.initialArgs?.statsCleanup),
shouldSkipHeavyStartup: () => shouldSkipHeavyStartup: () =>
Boolean( Boolean(
appState.initialArgs && appState.initialArgs &&
(shouldRunSettingsOnlyStartup(appState.initialArgs) || (shouldRunSettingsOnlyStartup(appState.initialArgs) ||
appState.initialArgs.stats ||
appState.initialArgs.dictionary || appState.initialArgs.dictionary ||
appState.initialArgs.setup), appState.initialArgs.setup),
), ),
@@ -2728,6 +2847,8 @@ const {
ensureImmersionTrackerInitialized: () => { ensureImmersionTrackerInitialized: () => {
ensureImmersionTrackerStarted(); ensureImmersionTrackerStarted();
}, },
tokenizeSubtitleForImmersion: async (text): Promise<SubtitleData | null> =>
tokenizeSubtitleDeferred ? await tokenizeSubtitleDeferred(text) : null,
updateCurrentMediaPath: (path) => { updateCurrentMediaPath: (path) => {
autoPlayReadySignalMediaPath = null; autoPlayReadySignalMediaPath = null;
currentMediaTokenizationGate.updateCurrentMediaPath(path); currentMediaTokenizationGate.updateCurrentMediaPath(path);
@@ -2939,6 +3060,7 @@ const {
}, },
}, },
}); });
tokenizeSubtitleDeferred = tokenizeSubtitle;
function createMpvClientRuntimeService(): MpvIpcClient { function createMpvClientRuntimeService(): MpvIpcClient {
return createMpvClientRuntimeServiceHandler() as MpvIpcClient; return createMpvClientRuntimeServiceHandler() as MpvIpcClient;
@@ -3113,6 +3235,7 @@ function destroyTray(): void {
function initializeOverlayRuntime(): void { function initializeOverlayRuntime(): void {
initializeOverlayRuntimeHandler(); initializeOverlayRuntimeHandler();
appState.ankiIntegration?.setRecordCardsMinedCallback(recordTrackedCardsMined);
syncOverlayMpvSubtitleSuppression(); syncOverlayMpvSubtitleSuppression();
} }
@@ -3283,9 +3406,9 @@ const buildMineSentenceCardMainDepsHandler = createBuildMineSentenceCardMainDeps
getMpvClient: () => appState.mpvClient, getMpvClient: () => appState.mpvClient,
showMpvOsd: (text) => showMpvOsd(text), showMpvOsd: (text) => showMpvOsd(text),
mineSentenceCardCore, mineSentenceCardCore,
recordCardsMined: (count) => { recordCardsMined: (count, noteIds) => {
ensureImmersionTrackerStarted(); ensureImmersionTrackerStarted();
appState.immersionTracker?.recordCardsMined(count); appState.immersionTracker?.recordCardsMined(count, noteIds);
}, },
}); });
const mineSentenceCardHandler = createMineSentenceCardHandler( const mineSentenceCardHandler = createMineSentenceCardHandler(
@@ -3455,6 +3578,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
getMecabTokenizer: () => appState.mecabTokenizer, getMecabTokenizer: () => appState.mecabTokenizer,
getKeybindings: () => appState.keybindings, getKeybindings: () => appState.keybindings,
getConfiguredShortcuts: () => getConfiguredShortcuts(), getConfiguredShortcuts: () => getConfiguredShortcuts(),
getStatsToggleKey: () => getResolvedConfig().stats.toggleKey,
getControllerConfig: () => getResolvedConfig().controller, getControllerConfig: () => getResolvedConfig().controller,
saveControllerPreference: ({ preferredGamepadId, preferredGamepadLabel }) => { saveControllerPreference: ({ preferredGamepadId, preferredGamepadLabel }) => {
configService.patchRawConfig({ configService.patchRawConfig({
@@ -3477,6 +3601,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
getAnilistQueueStatus: () => anilistStateRuntime.getQueueStatusSnapshot(), getAnilistQueueStatus: () => anilistStateRuntime.getQueueStatusSnapshot(),
retryAnilistQueueNow: () => processNextAnilistRetryUpdate(), retryAnilistQueueNow: () => processNextAnilistRetryUpdate(),
appendClipboardVideoToQueue: () => appendClipboardVideoToQueue(), appendClipboardVideoToQueue: () => appendClipboardVideoToQueue(),
getImmersionTracker: () => appState.immersionTracker,
}, },
ankiJimakuDeps: createAnkiJimakuIpcRuntimeServiceDeps({ ankiJimakuDeps: createAnkiJimakuIpcRuntimeServiceDeps({
patchAnkiConnectEnabled: (enabled: boolean) => { patchAnkiConnectEnabled: (enabled: boolean) => {
@@ -3489,6 +3614,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
getAnkiIntegration: () => appState.ankiIntegration, getAnkiIntegration: () => appState.ankiIntegration,
setAnkiIntegration: (integration: AnkiIntegration | null) => { setAnkiIntegration: (integration: AnkiIntegration | null) => {
appState.ankiIntegration = integration; appState.ankiIntegration = integration;
appState.ankiIntegration?.setRecordCardsMinedCallback(recordTrackedCardsMined);
}, },
getKnownWordCacheStatePath: () => path.join(USER_DATA_PATH, 'known-words-cache.json'), getKnownWordCacheStatePath: () => path.join(USER_DATA_PATH, 'known-words-cache.json'),
showDesktopNotification, showDesktopNotification,
@@ -3551,6 +3677,8 @@ const createCliCommandContextHandler = createCliCommandContextFactory({
return await characterDictionaryRuntime.generateForCurrentMedia(targetPath); return await characterDictionaryRuntime.generateForCurrentMedia(targetPath);
}, },
runJellyfinCommand: (argsFromCommand: CliArgs) => runJellyfinCommand(argsFromCommand), runJellyfinCommand: (argsFromCommand: CliArgs) => runJellyfinCommand(argsFromCommand),
runStatsCommand: (argsFromCommand: CliArgs, source: CliCommandSource) =>
runStatsCliCommand(argsFromCommand, source),
openYomitanSettings: () => openYomitanSettings(), openYomitanSettings: () => openYomitanSettings(),
cycleSecondarySubMode: () => handleCycleSecondarySubMode(), cycleSecondarySubMode: () => handleCycleSecondarySubMode(),
openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(), openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(),

View File

@@ -55,6 +55,7 @@ export interface AppReadyRuntimeDepsFactoryInput {
onCriticalConfigErrors?: AppReadyRuntimeDeps['onCriticalConfigErrors']; onCriticalConfigErrors?: AppReadyRuntimeDeps['onCriticalConfigErrors'];
logDebug?: AppReadyRuntimeDeps['logDebug']; logDebug?: AppReadyRuntimeDeps['logDebug'];
now?: AppReadyRuntimeDeps['now']; now?: AppReadyRuntimeDeps['now'];
shouldUseMinimalStartup?: AppReadyRuntimeDeps['shouldUseMinimalStartup'];
shouldSkipHeavyStartup?: AppReadyRuntimeDeps['shouldSkipHeavyStartup']; shouldSkipHeavyStartup?: AppReadyRuntimeDeps['shouldSkipHeavyStartup'];
} }
@@ -118,6 +119,7 @@ export function createAppReadyRuntimeDeps(
onCriticalConfigErrors: params.onCriticalConfigErrors, onCriticalConfigErrors: params.onCriticalConfigErrors,
logDebug: params.logDebug, logDebug: params.logDebug,
now: params.now, now: params.now,
shouldUseMinimalStartup: params.shouldUseMinimalStartup,
shouldSkipHeavyStartup: params.shouldSkipHeavyStartup, shouldSkipHeavyStartup: params.shouldSkipHeavyStartup,
}; };
} }

View File

@@ -153,6 +153,7 @@ test('generateForCurrentMedia emits structured-content glossary so image stays w
resolveMediaPathForJimaku: (mediaPath) => mediaPath, resolveMediaPathForJimaku: (mediaPath) => mediaPath,
guessAnilistMediaInfo: async () => ({ guessAnilistMediaInfo: async () => ({
title: 'The Eminence in Shadow', title: 'The Eminence in Shadow',
season: null,
episode: 5, episode: 5,
source: 'fallback', source: 'fallback',
}), }),
@@ -361,6 +362,7 @@ test('generateForCurrentMedia applies configured open states to character dictio
resolveMediaPathForJimaku: (mediaPath) => mediaPath, resolveMediaPathForJimaku: (mediaPath) => mediaPath,
guessAnilistMediaInfo: async () => ({ guessAnilistMediaInfo: async () => ({
title: 'The Eminence in Shadow', title: 'The Eminence in Shadow',
season: null,
episode: 5, episode: 5,
source: 'fallback', source: 'fallback',
}), }),
@@ -518,6 +520,7 @@ test('generateForCurrentMedia reapplies collapsible open states when using cache
resolveMediaPathForJimaku: (mediaPath) => mediaPath, resolveMediaPathForJimaku: (mediaPath) => mediaPath,
guessAnilistMediaInfo: async () => ({ guessAnilistMediaInfo: async () => ({
title: 'The Eminence in Shadow', title: 'The Eminence in Shadow',
season: null,
episode: 5, episode: 5,
source: 'fallback', source: 'fallback',
}), }),
@@ -533,6 +536,7 @@ test('generateForCurrentMedia reapplies collapsible open states when using cache
resolveMediaPathForJimaku: (mediaPath) => mediaPath, resolveMediaPathForJimaku: (mediaPath) => mediaPath,
guessAnilistMediaInfo: async () => ({ guessAnilistMediaInfo: async () => ({
title: 'The Eminence in Shadow', title: 'The Eminence in Shadow',
season: null,
episode: 5, episode: 5,
source: 'fallback', source: 'fallback',
}), }),
@@ -661,6 +665,7 @@ test('generateForCurrentMedia adds kana aliases for romanized names when native
resolveMediaPathForJimaku: (mediaPath) => mediaPath, resolveMediaPathForJimaku: (mediaPath) => mediaPath,
guessAnilistMediaInfo: async () => ({ guessAnilistMediaInfo: async () => ({
title: 'Konosuba', title: 'Konosuba',
season: null,
episode: 5, episode: 5,
source: 'fallback', source: 'fallback',
}), }),
@@ -783,6 +788,7 @@ test('generateForCurrentMedia indexes kanji family and given names using AniList
resolveMediaPathForJimaku: (mediaPath) => mediaPath, resolveMediaPathForJimaku: (mediaPath) => mediaPath,
guessAnilistMediaInfo: async () => ({ guessAnilistMediaInfo: async () => ({
title: 'Rascal Does Not Dream of Bunny Girl Senpai', title: 'Rascal Does Not Dream of Bunny Girl Senpai',
season: null,
episode: 1, episode: 1,
source: 'fallback', source: 'fallback',
}), }),
@@ -904,6 +910,7 @@ test('generateForCurrentMedia indexes AniList alternative character names for al
resolveMediaPathForJimaku: (mediaPath) => mediaPath, resolveMediaPathForJimaku: (mediaPath) => mediaPath,
guessAnilistMediaInfo: async () => ({ guessAnilistMediaInfo: async () => ({
title: 'The Eminence in Shadow', title: 'The Eminence in Shadow',
season: null,
episode: 5, episode: 5,
source: 'fallback', source: 'fallback',
}), }),
@@ -1028,6 +1035,7 @@ test('generateForCurrentMedia skips AniList characters without a native name whe
resolveMediaPathForJimaku: (mediaPath) => mediaPath, resolveMediaPathForJimaku: (mediaPath) => mediaPath,
guessAnilistMediaInfo: async () => ({ guessAnilistMediaInfo: async () => ({
title: 'The Eminence in Shadow', title: 'The Eminence in Shadow',
season: null,
episode: 5, episode: 5,
source: 'fallback', source: 'fallback',
}), }),
@@ -1148,6 +1156,7 @@ test('generateForCurrentMedia uses AniList first and last name hints to build ka
resolveMediaPathForJimaku: (mediaPath) => mediaPath, resolveMediaPathForJimaku: (mediaPath) => mediaPath,
guessAnilistMediaInfo: async () => ({ guessAnilistMediaInfo: async () => ({
title: 'Konosuba', title: 'Konosuba',
season: null,
episode: 5, episode: 5,
source: 'fallback', source: 'fallback',
}), }),
@@ -1265,6 +1274,7 @@ test('generateForCurrentMedia includes AniList gender age birthday and blood typ
resolveMediaPathForJimaku: (mediaPath) => mediaPath, resolveMediaPathForJimaku: (mediaPath) => mediaPath,
guessAnilistMediaInfo: async () => ({ guessAnilistMediaInfo: async () => ({
title: 'The Eminence in Shadow', title: 'The Eminence in Shadow',
season: null,
episode: 5, episode: 5,
source: 'fallback', source: 'fallback',
}), }),
@@ -1408,6 +1418,7 @@ test('generateForCurrentMedia preserves duplicate surface forms across different
resolveMediaPathForJimaku: (mediaPath) => mediaPath, resolveMediaPathForJimaku: (mediaPath) => mediaPath,
guessAnilistMediaInfo: async () => ({ guessAnilistMediaInfo: async () => ({
title: 'The Eminence in Shadow', title: 'The Eminence in Shadow',
season: null,
episode: 5, episode: 5,
source: 'fallback', source: 'fallback',
}), }),
@@ -1548,6 +1559,7 @@ test('getOrCreateCurrentSnapshot persists and reuses normalized snapshot data',
resolveMediaPathForJimaku: (mediaPath) => mediaPath, resolveMediaPathForJimaku: (mediaPath) => mediaPath,
guessAnilistMediaInfo: async () => ({ guessAnilistMediaInfo: async () => ({
title: 'The Eminence in Shadow', title: 'The Eminence in Shadow',
season: null,
episode: 5, episode: 5,
source: 'fallback', source: 'fallback',
}), }),
@@ -1703,6 +1715,7 @@ test('getOrCreateCurrentSnapshot rebuilds snapshots written with an older format
resolveMediaPathForJimaku: (mediaPath) => mediaPath, resolveMediaPathForJimaku: (mediaPath) => mediaPath,
guessAnilistMediaInfo: async () => ({ guessAnilistMediaInfo: async () => ({
title: 'The Eminence in Shadow', title: 'The Eminence in Shadow',
season: null,
episode: 5, episode: 5,
source: 'fallback', source: 'fallback',
}), }),
@@ -1842,6 +1855,7 @@ test('generateForCurrentMedia logs progress while resolving and rebuilding snaps
resolveMediaPathForJimaku: (mediaPath) => mediaPath, resolveMediaPathForJimaku: (mediaPath) => mediaPath,
guessAnilistMediaInfo: async () => ({ guessAnilistMediaInfo: async () => ({
title: 'The Eminence in Shadow', title: 'The Eminence in Shadow',
season: null,
episode: 5, episode: 5,
source: 'fallback', source: 'fallback',
}), }),
@@ -2014,6 +2028,7 @@ test('generateForCurrentMedia downloads shared voice actor images once per AniLi
resolveMediaPathForJimaku: (mediaPath) => mediaPath, resolveMediaPathForJimaku: (mediaPath) => mediaPath,
guessAnilistMediaInfo: async () => ({ guessAnilistMediaInfo: async () => ({
title: 'The Eminence in Shadow', title: 'The Eminence in Shadow',
season: null,
episode: 5, episode: 5,
source: 'fallback', source: 'fallback',
}), }),
@@ -2194,6 +2209,7 @@ test('buildMergedDictionary combines stored snapshots into one stable dictionary
resolveMediaPathForJimaku: (mediaPath) => mediaPath, resolveMediaPathForJimaku: (mediaPath) => mediaPath,
guessAnilistMediaInfo: async () => ({ guessAnilistMediaInfo: async () => ({
title: current.title, title: current.title,
season: null,
episode: current.episode, episode: current.episode,
source: 'fallback', source: 'fallback',
}), }),
@@ -2481,6 +2497,7 @@ test('buildMergedDictionary reapplies collapsible open states from current confi
resolveMediaPathForJimaku: (mediaPath) => mediaPath, resolveMediaPathForJimaku: (mediaPath) => mediaPath,
guessAnilistMediaInfo: async () => ({ guessAnilistMediaInfo: async () => ({
title: current.title, title: current.title,
season: null,
episode: current.episode, episode: current.episode,
source: 'fallback', source: 'fallback',
}), }),
@@ -2500,6 +2517,7 @@ test('buildMergedDictionary reapplies collapsible open states from current confi
resolveMediaPathForJimaku: (mediaPath) => mediaPath, resolveMediaPathForJimaku: (mediaPath) => mediaPath,
guessAnilistMediaInfo: async () => ({ guessAnilistMediaInfo: async () => ({
title: current.title, title: current.title,
season: null,
episode: current.episode, episode: current.episode,
source: 'fallback', source: 'fallback',
}), }),
@@ -2663,6 +2681,7 @@ test('generateForCurrentMedia paces AniList requests and character image downloa
resolveMediaPathForJimaku: (mediaPath) => mediaPath, resolveMediaPathForJimaku: (mediaPath) => mediaPath,
guessAnilistMediaInfo: async () => ({ guessAnilistMediaInfo: async () => ({
title: 'The Eminence in Shadow', title: 'The Eminence in Shadow',
season: null,
episode: 5, episode: 5,
source: 'fallback', source: 'fallback',
}), }),

View File

@@ -36,6 +36,7 @@ export interface CliCommandRuntimeServiceContext {
retryAnilistQueueNow: CliCommandRuntimeServiceDepsParams['anilist']['retryQueueNow']; retryAnilistQueueNow: CliCommandRuntimeServiceDepsParams['anilist']['retryQueueNow'];
generateCharacterDictionary: CliCommandRuntimeServiceDepsParams['dictionary']['generate']; generateCharacterDictionary: CliCommandRuntimeServiceDepsParams['dictionary']['generate'];
openJellyfinSetup: CliCommandRuntimeServiceDepsParams['jellyfin']['openSetup']; openJellyfinSetup: CliCommandRuntimeServiceDepsParams['jellyfin']['openSetup'];
runStatsCommand: CliCommandRuntimeServiceDepsParams['jellyfin']['runStatsCommand'];
runJellyfinCommand: CliCommandRuntimeServiceDepsParams['jellyfin']['runCommand']; runJellyfinCommand: CliCommandRuntimeServiceDepsParams['jellyfin']['runCommand'];
openYomitanSettings: () => void; openYomitanSettings: () => void;
cycleSecondarySubMode: () => void; cycleSecondarySubMode: () => void;
@@ -101,6 +102,7 @@ function createCliCommandDepsFromContext(
}, },
jellyfin: { jellyfin: {
openSetup: context.openJellyfinSetup, openSetup: context.openJellyfinSetup,
runStatsCommand: context.runStatsCommand,
runCommand: context.runJellyfinCommand, runCommand: context.runJellyfinCommand,
}, },
ui: { ui: {

View File

@@ -72,6 +72,7 @@ export interface MainIpcRuntimeServiceDepsParams {
handleMpvCommand: IpcDepsRuntimeOptions['handleMpvCommand']; handleMpvCommand: IpcDepsRuntimeOptions['handleMpvCommand'];
getKeybindings: IpcDepsRuntimeOptions['getKeybindings']; getKeybindings: IpcDepsRuntimeOptions['getKeybindings'];
getConfiguredShortcuts: IpcDepsRuntimeOptions['getConfiguredShortcuts']; getConfiguredShortcuts: IpcDepsRuntimeOptions['getConfiguredShortcuts'];
getStatsToggleKey: IpcDepsRuntimeOptions['getStatsToggleKey'];
getControllerConfig: IpcDepsRuntimeOptions['getControllerConfig']; getControllerConfig: IpcDepsRuntimeOptions['getControllerConfig'];
saveControllerPreference: IpcDepsRuntimeOptions['saveControllerPreference']; saveControllerPreference: IpcDepsRuntimeOptions['saveControllerPreference'];
getSecondarySubMode: IpcDepsRuntimeOptions['getSecondarySubMode']; getSecondarySubMode: IpcDepsRuntimeOptions['getSecondarySubMode'];
@@ -88,6 +89,7 @@ export interface MainIpcRuntimeServiceDepsParams {
getAnilistQueueStatus: IpcDepsRuntimeOptions['getAnilistQueueStatus']; getAnilistQueueStatus: IpcDepsRuntimeOptions['getAnilistQueueStatus'];
retryAnilistQueueNow: IpcDepsRuntimeOptions['retryAnilistQueueNow']; retryAnilistQueueNow: IpcDepsRuntimeOptions['retryAnilistQueueNow'];
appendClipboardVideoToQueue: IpcDepsRuntimeOptions['appendClipboardVideoToQueue']; appendClipboardVideoToQueue: IpcDepsRuntimeOptions['appendClipboardVideoToQueue'];
getImmersionTracker?: IpcDepsRuntimeOptions['getImmersionTracker'];
} }
export interface AnkiJimakuIpcRuntimeServiceDepsParams { export interface AnkiJimakuIpcRuntimeServiceDepsParams {
@@ -158,6 +160,7 @@ export interface CliCommandRuntimeServiceDepsParams {
}; };
jellyfin: { jellyfin: {
openSetup: CliCommandDepsRuntimeOptions['jellyfin']['openSetup']; openSetup: CliCommandDepsRuntimeOptions['jellyfin']['openSetup'];
runStatsCommand: CliCommandDepsRuntimeOptions['jellyfin']['runStatsCommand'];
runCommand: CliCommandDepsRuntimeOptions['jellyfin']['runCommand']; runCommand: CliCommandDepsRuntimeOptions['jellyfin']['runCommand'];
}; };
ui: { ui: {
@@ -215,6 +218,7 @@ export function createMainIpcRuntimeServiceDeps(
handleMpvCommand: params.handleMpvCommand, handleMpvCommand: params.handleMpvCommand,
getKeybindings: params.getKeybindings, getKeybindings: params.getKeybindings,
getConfiguredShortcuts: params.getConfiguredShortcuts, getConfiguredShortcuts: params.getConfiguredShortcuts,
getStatsToggleKey: params.getStatsToggleKey,
getControllerConfig: params.getControllerConfig, getControllerConfig: params.getControllerConfig,
saveControllerPreference: params.saveControllerPreference, saveControllerPreference: params.saveControllerPreference,
focusMainWindow: params.focusMainWindow ?? (() => {}), focusMainWindow: params.focusMainWindow ?? (() => {}),
@@ -232,6 +236,7 @@ export function createMainIpcRuntimeServiceDeps(
getAnilistQueueStatus: params.getAnilistQueueStatus, getAnilistQueueStatus: params.getAnilistQueueStatus,
retryAnilistQueueNow: params.retryAnilistQueueNow, retryAnilistQueueNow: params.retryAnilistQueueNow,
appendClipboardVideoToQueue: params.appendClipboardVideoToQueue, appendClipboardVideoToQueue: params.appendClipboardVideoToQueue,
getImmersionTracker: params.getImmersionTracker,
}; };
} }
@@ -310,6 +315,7 @@ export function createCliCommandRuntimeServiceDeps(
}, },
jellyfin: { jellyfin: {
openSetup: params.jellyfin.openSetup, openSetup: params.jellyfin.openSetup,
runStatsCommand: params.jellyfin.runStatsCommand,
runCommand: params.jellyfin.runCommand, runCommand: params.jellyfin.runCommand,
}, },
ui: { ui: {

View File

@@ -55,7 +55,7 @@ test('ensure anilist media guess main deps builder maps callbacks', async () =>
getCurrentMediaTitle: () => 'title', getCurrentMediaTitle: () => 'title',
guessAnilistMediaInfo: async () => { guessAnilistMediaInfo: async () => {
calls.push('guess'); calls.push('guess');
return { title: 'title', episode: 1, source: 'fallback' }; return { title: 'title', season: null, episode: 1, source: 'fallback' };
}, },
})(); })();
@@ -64,6 +64,7 @@ test('ensure anilist media guess main deps builder maps callbacks', async () =>
assert.equal(deps.resolveMediaPathForJimaku('/tmp/video.mkv'), '/tmp/video.mkv'); assert.equal(deps.resolveMediaPathForJimaku('/tmp/video.mkv'), '/tmp/video.mkv');
assert.deepEqual(await deps.guessAnilistMediaInfo('/tmp/video.mkv', 'title'), { assert.deepEqual(await deps.guessAnilistMediaInfo('/tmp/video.mkv', 'title'), {
title: 'title', title: 'title',
season: null,
episode: 1, episode: 1,
source: 'fallback', source: 'fallback',
}); });

View File

@@ -49,7 +49,7 @@ test('ensureAnilistMediaGuess memoizes in-flight guess promise', async () => {
getCurrentMediaTitle: () => 'Episode 1', getCurrentMediaTitle: () => 'Episode 1',
guessAnilistMediaInfo: async () => { guessAnilistMediaInfo: async () => {
calls += 1; calls += 1;
return { title: 'Show', episode: 1, source: 'guessit' }; return { title: 'Show', season: null, episode: 1, source: 'guessit' };
}, },
}); });
@@ -57,9 +57,9 @@ test('ensureAnilistMediaGuess memoizes in-flight guess promise', async () => {
ensureGuess('/tmp/video.mkv'), ensureGuess('/tmp/video.mkv'),
ensureGuess('/tmp/video.mkv'), ensureGuess('/tmp/video.mkv'),
]); ]);
assert.deepEqual(first, { title: 'Show', episode: 1, source: 'guessit' }); assert.deepEqual(first, { title: 'Show', season: null, episode: 1, source: 'guessit' });
assert.deepEqual(second, { title: 'Show', episode: 1, source: 'guessit' }); assert.deepEqual(second, { title: 'Show', season: null, episode: 1, source: 'guessit' });
assert.equal(calls, 1); assert.equal(calls, 1);
assert.deepEqual(state.mediaGuess, { title: 'Show', episode: 1, source: 'guessit' }); assert.deepEqual(state.mediaGuess, { title: 'Show', season: null, episode: 1, source: 'guessit' });
assert.equal(state.mediaGuessPromise, null); assert.equal(state.mediaGuessPromise, null);
}); });

View File

@@ -8,7 +8,7 @@ import {
test('process next anilist retry update main deps builder maps callbacks', async () => { test('process next anilist retry update main deps builder maps callbacks', async () => {
const calls: string[] = []; const calls: string[] = [];
const deps = createBuildProcessNextAnilistRetryUpdateMainDepsHandler({ const deps = createBuildProcessNextAnilistRetryUpdateMainDepsHandler({
nextReady: () => ({ key: 'k', title: 't', episode: 1 }), nextReady: () => ({ key: 'k', title: 't', season: null, episode: 1 }),
refreshRetryQueueState: () => calls.push('refresh'), refreshRetryQueueState: () => calls.push('refresh'),
setLastAttemptAt: () => calls.push('attempt'), setLastAttemptAt: () => calls.push('attempt'),
setLastError: () => calls.push('error'), setLastError: () => calls.push('error'),
@@ -59,7 +59,7 @@ test('maybe run anilist post watch update main deps builder maps callbacks', asy
resetTrackedMedia: () => calls.push('reset'), resetTrackedMedia: () => calls.push('reset'),
getWatchedSeconds: () => 100, getWatchedSeconds: () => 100,
maybeProbeAnilistDuration: async () => 120, maybeProbeAnilistDuration: async () => 120,
ensureAnilistMediaGuess: async () => ({ title: 'x', episode: 1 }), ensureAnilistMediaGuess: async () => ({ title: 'x', season: null, episode: 1 }),
hasAttemptedUpdateKey: () => false, hasAttemptedUpdateKey: () => false,
processNextAnilistRetryUpdate: async () => ({ ok: true, message: 'ok' }), processNextAnilistRetryUpdate: async () => ({ ok: true, message: 'ok' }),
refreshAnilistClientSecretState: async () => 'token', refreshAnilistClientSecretState: async () => 'token',
@@ -85,7 +85,7 @@ test('maybe run anilist post watch update main deps builder maps callbacks', asy
deps.resetTrackedMedia('media'); deps.resetTrackedMedia('media');
assert.equal(deps.getWatchedSeconds(), 100); assert.equal(deps.getWatchedSeconds(), 100);
assert.equal(await deps.maybeProbeAnilistDuration('media'), 120); assert.equal(await deps.maybeProbeAnilistDuration('media'), 120);
assert.deepEqual(await deps.ensureAnilistMediaGuess('media'), { title: 'x', episode: 1 }); assert.deepEqual(await deps.ensureAnilistMediaGuess('media'), { title: 'x', season: null, episode: 1 });
assert.equal(deps.hasAttemptedUpdateKey('k'), false); assert.equal(deps.hasAttemptedUpdateKey('k'), false);
assert.deepEqual(await deps.processNextAnilistRetryUpdate(), { ok: true, message: 'ok' }); assert.deepEqual(await deps.processNextAnilistRetryUpdate(), { ok: true, message: 'ok' });
assert.equal(await deps.refreshAnilistClientSecretState(), 'token'); assert.equal(await deps.refreshAnilistClientSecretState(), 'token');

View File

@@ -20,7 +20,7 @@ test('rememberAnilistAttemptedUpdateKey evicts oldest beyond max size', () => {
test('createProcessNextAnilistRetryUpdateHandler handles successful retry', async () => { test('createProcessNextAnilistRetryUpdateHandler handles successful retry', async () => {
const calls: string[] = []; const calls: string[] = [];
const handler = createProcessNextAnilistRetryUpdateHandler({ const handler = createProcessNextAnilistRetryUpdateHandler({
nextReady: () => ({ key: 'k1', title: 'Show', episode: 1 }), nextReady: () => ({ key: 'k1', title: 'Show', season: null, episode: 1 }),
refreshRetryQueueState: () => calls.push('refresh'), refreshRetryQueueState: () => calls.push('refresh'),
setLastAttemptAt: () => calls.push('attempt'), setLastAttemptAt: () => calls.push('attempt'),
setLastError: (value) => calls.push(`error:${value ?? 'null'}`), setLastError: (value) => calls.push(`error:${value ?? 'null'}`),
@@ -52,7 +52,7 @@ test('createMaybeRunAnilistPostWatchUpdateHandler queues when token missing', as
resetTrackedMedia: () => {}, resetTrackedMedia: () => {},
getWatchedSeconds: () => 1000, getWatchedSeconds: () => 1000,
maybeProbeAnilistDuration: async () => 1000, maybeProbeAnilistDuration: async () => 1000,
ensureAnilistMediaGuess: async () => ({ title: 'Show', episode: 1 }), ensureAnilistMediaGuess: async () => ({ title: 'Show', season: null, episode: 1 }),
hasAttemptedUpdateKey: () => false, hasAttemptedUpdateKey: () => false,
processNextAnilistRetryUpdate: async () => ({ ok: true, message: 'noop' }), processNextAnilistRetryUpdate: async () => ({ ok: true, message: 'noop' }),
refreshAnilistClientSecretState: async () => null, refreshAnilistClientSecretState: async () => null,

View File

@@ -38,6 +38,7 @@ export function createBuildAppReadyRuntimeMainDepsHandler(deps: AppReadyRuntimeD
onCriticalConfigErrors: deps.onCriticalConfigErrors, onCriticalConfigErrors: deps.onCriticalConfigErrors,
logDebug: deps.logDebug, logDebug: deps.logDebug,
now: deps.now, now: deps.now,
shouldUseMinimalStartup: deps.shouldUseMinimalStartup,
shouldSkipHeavyStartup: deps.shouldSkipHeavyStartup, shouldSkipHeavyStartup: deps.shouldSkipHeavyStartup,
}); });
} }

View File

@@ -54,6 +54,9 @@ test('build cli command context deps maps handlers and values', () => {
mediaTitle: 'Test', mediaTitle: 'Test',
entryCount: 10, entryCount: 10,
}), }),
runStatsCommand: async () => {
calls.push('run-stats');
},
runJellyfinCommand: async () => { runJellyfinCommand: async () => {
calls.push('run-jellyfin'); calls.push('run-jellyfin');
}, },

View File

@@ -34,6 +34,7 @@ export function createBuildCliCommandContextDepsHandler(deps: {
getAnilistQueueStatus: CliCommandContextFactoryDeps['getAnilistQueueStatus']; getAnilistQueueStatus: CliCommandContextFactoryDeps['getAnilistQueueStatus'];
retryAnilistQueueNow: CliCommandContextFactoryDeps['retryAnilistQueueNow']; retryAnilistQueueNow: CliCommandContextFactoryDeps['retryAnilistQueueNow'];
generateCharacterDictionary: CliCommandContextFactoryDeps['generateCharacterDictionary']; generateCharacterDictionary: CliCommandContextFactoryDeps['generateCharacterDictionary'];
runStatsCommand: CliCommandContextFactoryDeps['runStatsCommand'];
runJellyfinCommand: (args: CliArgs) => Promise<void>; runJellyfinCommand: (args: CliArgs) => Promise<void>;
openYomitanSettings: () => void; openYomitanSettings: () => void;
cycleSecondarySubMode: () => void; cycleSecondarySubMode: () => void;
@@ -80,6 +81,7 @@ export function createBuildCliCommandContextDepsHandler(deps: {
getAnilistQueueStatus: deps.getAnilistQueueStatus, getAnilistQueueStatus: deps.getAnilistQueueStatus,
retryAnilistQueueNow: deps.retryAnilistQueueNow, retryAnilistQueueNow: deps.retryAnilistQueueNow,
generateCharacterDictionary: deps.generateCharacterDictionary, generateCharacterDictionary: deps.generateCharacterDictionary,
runStatsCommand: deps.runStatsCommand,
runJellyfinCommand: deps.runJellyfinCommand, runJellyfinCommand: deps.runJellyfinCommand,
openYomitanSettings: deps.openYomitanSettings, openYomitanSettings: deps.openYomitanSettings,
cycleSecondarySubMode: deps.cycleSecondarySubMode, cycleSecondarySubMode: deps.cycleSecondarySubMode,

View File

@@ -61,6 +61,7 @@ test('cli command context factory composes main deps and context handlers', () =
mediaTitle: 'Test', mediaTitle: 'Test',
entryCount: 10, entryCount: 10,
}), }),
runStatsCommand: async () => {},
runJellyfinCommand: async () => {}, runJellyfinCommand: async () => {},
openYomitanSettings: () => {}, openYomitanSettings: () => {},
cycleSecondarySubMode: () => {}, cycleSecondarySubMode: () => {},

View File

@@ -78,6 +78,9 @@ test('cli command context main deps builder maps state and callbacks', async ()
mediaTitle: 'Test', mediaTitle: 'Test',
entryCount: 10, entryCount: 10,
}), }),
runStatsCommand: async () => {
calls.push('run-stats');
},
runJellyfinCommand: async () => { runJellyfinCommand: async () => {
calls.push('run-jellyfin'); calls.push('run-jellyfin');
}, },

View File

@@ -39,6 +39,7 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
getAnilistQueueStatus: CliCommandContextFactoryDeps['getAnilistQueueStatus']; getAnilistQueueStatus: CliCommandContextFactoryDeps['getAnilistQueueStatus'];
processNextAnilistRetryUpdate: CliCommandContextFactoryDeps['retryAnilistQueueNow']; processNextAnilistRetryUpdate: CliCommandContextFactoryDeps['retryAnilistQueueNow'];
generateCharacterDictionary: CliCommandContextFactoryDeps['generateCharacterDictionary']; generateCharacterDictionary: CliCommandContextFactoryDeps['generateCharacterDictionary'];
runStatsCommand: CliCommandContextFactoryDeps['runStatsCommand'];
runJellyfinCommand: (args: CliArgs) => Promise<void>; runJellyfinCommand: (args: CliArgs) => Promise<void>;
openYomitanSettings: () => void; openYomitanSettings: () => void;
@@ -92,6 +93,7 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
retryAnilistQueueNow: () => deps.processNextAnilistRetryUpdate(), retryAnilistQueueNow: () => deps.processNextAnilistRetryUpdate(),
generateCharacterDictionary: (targetPath?: string) => generateCharacterDictionary: (targetPath?: string) =>
deps.generateCharacterDictionary(targetPath), deps.generateCharacterDictionary(targetPath),
runStatsCommand: (args: CliArgs, source) => deps.runStatsCommand(args, source),
runJellyfinCommand: (args: CliArgs) => deps.runJellyfinCommand(args), runJellyfinCommand: (args: CliArgs) => deps.runJellyfinCommand(args),
openYomitanSettings: () => deps.openYomitanSettings(), openYomitanSettings: () => deps.openYomitanSettings(),
cycleSecondarySubMode: () => deps.cycleSecondarySubMode(), cycleSecondarySubMode: () => deps.cycleSecondarySubMode(),

View File

@@ -48,6 +48,7 @@ function createDeps() {
mediaTitle: 'Test', mediaTitle: 'Test',
entryCount: 1, entryCount: 1,
}), }),
runStatsCommand: async () => {},
runJellyfinCommand: async () => {}, runJellyfinCommand: async () => {},
openYomitanSettings: () => {}, openYomitanSettings: () => {},
cycleSecondarySubMode: () => {}, cycleSecondarySubMode: () => {},

View File

@@ -39,6 +39,7 @@ export type CliCommandContextFactoryDeps = {
getAnilistQueueStatus: CliCommandRuntimeServiceContext['getAnilistQueueStatus']; getAnilistQueueStatus: CliCommandRuntimeServiceContext['getAnilistQueueStatus'];
retryAnilistQueueNow: CliCommandRuntimeServiceContext['retryAnilistQueueNow']; retryAnilistQueueNow: CliCommandRuntimeServiceContext['retryAnilistQueueNow'];
generateCharacterDictionary: CliCommandRuntimeServiceContext['generateCharacterDictionary']; generateCharacterDictionary: CliCommandRuntimeServiceContext['generateCharacterDictionary'];
runStatsCommand: CliCommandRuntimeServiceContext['runStatsCommand'];
runJellyfinCommand: (args: CliArgs) => Promise<void>; runJellyfinCommand: (args: CliArgs) => Promise<void>;
openYomitanSettings: () => void; openYomitanSettings: () => void;
cycleSecondarySubMode: () => void; cycleSecondarySubMode: () => void;
@@ -92,6 +93,7 @@ export function createCliCommandContext(
getAnilistQueueStatus: deps.getAnilistQueueStatus, getAnilistQueueStatus: deps.getAnilistQueueStatus,
retryAnilistQueueNow: deps.retryAnilistQueueNow, retryAnilistQueueNow: deps.retryAnilistQueueNow,
generateCharacterDictionary: deps.generateCharacterDictionary, generateCharacterDictionary: deps.generateCharacterDictionary,
runStatsCommand: deps.runStatsCommand,
runJellyfinCommand: deps.runJellyfinCommand, runJellyfinCommand: deps.runJellyfinCommand,
openYomitanSettings: deps.openYomitanSettings, openYomitanSettings: deps.openYomitanSettings,
cycleSecondarySubMode: deps.cycleSecondarySubMode, cycleSecondarySubMode: deps.cycleSecondarySubMode,

View File

@@ -131,11 +131,11 @@ test('composeAnilistTrackingHandlers returns callable handlers and forwards call
getCurrentMediaTitle: () => 'Episode title', getCurrentMediaTitle: () => 'Episode title',
guessAnilistMediaInfo: async () => { guessAnilistMediaInfo: async () => {
guessAnilistMediaInfoCalls += 1; guessAnilistMediaInfoCalls += 1;
return { title: 'Episode title', episode: 7, source: 'guessit' }; return { title: 'Episode title', season: null, episode: 7, source: 'guessit' };
}, },
}, },
processNextRetryUpdateMainDeps: { processNextRetryUpdateMainDeps: {
nextReady: () => ({ key: 'retry-key', title: 'Retry title', episode: 1 }), nextReady: () => ({ key: 'retry-key', title: 'Retry title', season: null, episode: 1 }),
refreshRetryQueueState: () => {}, refreshRetryQueueState: () => {},
setLastAttemptAt: () => {}, setLastAttemptAt: () => {},
setLastError: () => {}, setLastError: () => {},
@@ -163,6 +163,7 @@ test('composeAnilistTrackingHandlers returns callable handlers and forwards call
maybeProbeAnilistDuration: async () => 600, maybeProbeAnilistDuration: async () => 600,
ensureAnilistMediaGuess: async () => ({ ensureAnilistMediaGuess: async () => ({
title: 'Episode title', title: 'Episode title',
season: null,
episode: 2, episode: 2,
source: 'guessit', source: 'guessit',
}), }),
@@ -209,7 +210,7 @@ test('composeAnilistTrackingHandlers returns callable handlers and forwards call
composed.setAnilistMediaGuessRuntimeState({ composed.setAnilistMediaGuessRuntimeState({
mediaKey: 'media-key', mediaKey: 'media-key',
mediaDurationSec: 90, mediaDurationSec: 90,
mediaGuess: { title: 'Known', episode: 3, source: 'fallback' }, mediaGuess: { title: 'Known', season: null, episode: 3, source: 'fallback' },
mediaGuessPromise: null, mediaGuessPromise: null,
lastDurationProbeAtMs: 11, lastDurationProbeAtMs: 11,
}); });

View File

@@ -51,6 +51,7 @@ test('composeIpcRuntimeHandlers returns callable IPC handlers and registration b
getMecabTokenizer: () => null, getMecabTokenizer: () => null,
getKeybindings: () => [], getKeybindings: () => [],
getConfiguredShortcuts: () => ({}) as never, getConfiguredShortcuts: () => ({}) as never,
getStatsToggleKey: () => 'Backquote',
getControllerConfig: () => ({}) as never, getControllerConfig: () => ({}) as never,
saveControllerPreference: () => {}, saveControllerPreference: () => {},
getSecondarySubMode: () => 'hover' as never, getSecondarySubMode: () => 'hover' as never,

View File

@@ -48,6 +48,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
anilistSetup: false, anilistSetup: false,
anilistRetryQueue: false, anilistRetryQueue: false,
dictionary: false, dictionary: false,
stats: false,
jellyfin: false, jellyfin: false,
jellyfinLogin: false, jellyfinLogin: false,
jellyfinLogout: false, jellyfinLogout: false,

View File

@@ -135,3 +135,28 @@ test('createImmersionTrackerStartupHandler disables tracker on failure', () => {
calls.includes('warn:Immersion tracker startup failed; disabling tracking.:db unavailable'), calls.includes('warn:Immersion tracker startup failed; disabling tracking.:db unavailable'),
); );
}); });
test('createImmersionTrackerStartupHandler skips mpv auto-connect when disabled by caller', () => {
let connectCalls = 0;
const handler = createImmersionTrackerStartupHandler({
getResolvedConfig: () => makeConfig(),
getConfiguredDbPath: () => '/tmp/subminer.db',
createTrackerService: () => ({}),
setTracker: () => {},
getMpvClient: () => ({
connected: false,
connect: () => {
connectCalls += 1;
},
}),
shouldAutoConnectMpv: () => false,
seedTrackerFromCurrentMedia: () => {},
logInfo: () => {},
logDebug: () => {},
logWarn: () => {},
});
handler();
assert.equal(connectCalls, 0);
});

View File

@@ -38,6 +38,7 @@ export type ImmersionTrackerStartupDeps = {
createTrackerService: (params: ImmersionTrackerServiceParams) => unknown; createTrackerService: (params: ImmersionTrackerServiceParams) => unknown;
setTracker: (tracker: unknown | null) => void; setTracker: (tracker: unknown | null) => void;
getMpvClient: () => MpvClientLike | null; getMpvClient: () => MpvClientLike | null;
shouldAutoConnectMpv?: () => boolean;
seedTrackerFromCurrentMedia: () => void; seedTrackerFromCurrentMedia: () => void;
logInfo: (message: string) => void; logInfo: (message: string) => void;
logDebug: (message: string) => void; logDebug: (message: string) => void;
@@ -86,7 +87,7 @@ export function createImmersionTrackerStartupHandler(
deps.logDebug('Immersion tracker initialized successfully.'); deps.logDebug('Immersion tracker initialized successfully.');
const mpvClient = deps.getMpvClient(); const mpvClient = deps.getMpvClient();
if (mpvClient && !mpvClient.connected) { if ((deps.shouldAutoConnectMpv?.() ?? true) && mpvClient && !mpvClient.connected) {
deps.logInfo('Auto-connecting MPV client for immersion tracking'); deps.logInfo('Auto-connecting MPV client for immersion tracking');
mpvClient.connect(); mpvClient.connect();
} }

View File

@@ -27,6 +27,7 @@ export function createHandleInitialArgsHandler(deps: {
const mpvClient = deps.getMpvClient(); const mpvClient = deps.getMpvClient();
if ( if (
!deps.isTexthookerOnlyMode() && !deps.isTexthookerOnlyMode() &&
!initialArgs.stats &&
deps.hasImmersionTracker() && deps.hasImmersionTracker() &&
mpvClient && mpvClient &&
!mpvClient.connected !mpvClient.connected

View File

@@ -28,3 +28,25 @@ test('initial args runtime handler composes main deps and runs initial command f
'cli:initial', 'cli:initial',
]); ]);
}); });
test('initial args runtime handler skips mpv auto-connect for stats mode', () => {
const calls: string[] = [];
const handleInitialArgs = createInitialArgsRuntimeHandler({
getInitialArgs: () => ({ stats: true }) as never,
isBackgroundMode: () => false,
shouldEnsureTrayOnStartup: () => false,
ensureTray: () => calls.push('tray'),
isTexthookerOnlyMode: () => false,
hasImmersionTracker: () => true,
getMpvClient: () => ({
connected: false,
connect: () => calls.push('connect'),
}),
logInfo: (message) => calls.push(`log:${message}`),
handleCliCommand: (_args, source) => calls.push(`cli:${source}`),
});
handleInitialArgs();
assert.deepEqual(calls, ['cli:initial']);
});

View File

@@ -75,6 +75,7 @@ test('mpv event bindings register all expected events', () => {
onMediaPathChange: () => {}, onMediaPathChange: () => {},
onMediaTitleChange: () => {}, onMediaTitleChange: () => {},
onTimePosChange: () => {}, onTimePosChange: () => {},
onDurationChange: () => {},
onPauseChange: () => {}, onPauseChange: () => {},
onSubtitleMetricsChange: () => {}, onSubtitleMetricsChange: () => {},
onSecondarySubtitleVisibility: () => {}, onSecondarySubtitleVisibility: () => {},
@@ -95,6 +96,7 @@ test('mpv event bindings register all expected events', () => {
'media-path-change', 'media-path-change',
'media-title-change', 'media-title-change',
'time-pos-change', 'time-pos-change',
'duration-change',
'pause-change', 'pause-change',
'subtitle-metrics-change', 'subtitle-metrics-change',
'secondary-subtitle-visibility', 'secondary-subtitle-visibility',

View File

@@ -7,6 +7,7 @@ type MpvBindingEventName =
| 'media-path-change' | 'media-path-change'
| 'media-title-change' | 'media-title-change'
| 'time-pos-change' | 'time-pos-change'
| 'duration-change'
| 'pause-change' | 'pause-change'
| 'subtitle-metrics-change' | 'subtitle-metrics-change'
| 'secondary-subtitle-visibility'; | 'secondary-subtitle-visibility';
@@ -72,6 +73,7 @@ export function createBindMpvClientEventHandlers(deps: {
onMediaPathChange: (payload: { path: string | null }) => void; onMediaPathChange: (payload: { path: string | null }) => void;
onMediaTitleChange: (payload: { title: string | null }) => void; onMediaTitleChange: (payload: { title: string | null }) => void;
onTimePosChange: (payload: { time: number }) => void; onTimePosChange: (payload: { time: number }) => void;
onDurationChange: (payload: { duration: number }) => void;
onPauseChange: (payload: { paused: boolean }) => void; onPauseChange: (payload: { paused: boolean }) => void;
onSubtitleMetricsChange: (payload: { patch: Record<string, unknown> }) => void; onSubtitleMetricsChange: (payload: { patch: Record<string, unknown> }) => void;
onSecondarySubtitleVisibility: (payload: { visible: boolean }) => void; onSecondarySubtitleVisibility: (payload: { visible: boolean }) => void;
@@ -85,6 +87,7 @@ export function createBindMpvClientEventHandlers(deps: {
mpvClient.on('media-path-change', deps.onMediaPathChange); mpvClient.on('media-path-change', deps.onMediaPathChange);
mpvClient.on('media-title-change', deps.onMediaTitleChange); mpvClient.on('media-title-change', deps.onMediaTitleChange);
mpvClient.on('time-pos-change', deps.onTimePosChange); mpvClient.on('time-pos-change', deps.onTimePosChange);
mpvClient.on('duration-change', deps.onDurationChange);
mpvClient.on('pause-change', deps.onPauseChange); mpvClient.on('pause-change', deps.onPauseChange);
mpvClient.on('subtitle-metrics-change', deps.onSubtitleMetricsChange); mpvClient.on('subtitle-metrics-change', deps.onSubtitleMetricsChange);
mpvClient.on('secondary-subtitle-visibility', deps.onSecondarySubtitleVisibility); mpvClient.on('secondary-subtitle-visibility', deps.onSecondarySubtitleVisibility);

View File

@@ -48,6 +48,7 @@ test('main mpv event binder wires callbacks through to runtime deps', () => {
notifyImmersionTitleUpdate: (title) => calls.push(`notify-title:${title}`), notifyImmersionTitleUpdate: (title) => calls.push(`notify-title:${title}`),
recordPlaybackPosition: (time) => calls.push(`time-pos:${time}`), recordPlaybackPosition: (time) => calls.push(`time-pos:${time}`),
recordMediaDuration: (duration) => calls.push(`duration:${duration}`),
reportJellyfinRemoteProgress: (forceImmediate) => reportJellyfinRemoteProgress: (forceImmediate) =>
calls.push(`progress:${forceImmediate ? 'force' : 'normal'}`), calls.push(`progress:${forceImmediate ? 'force' : 'normal'}`),
recordPauseState: (paused) => calls.push(`pause:${paused ? 'yes' : 'no'}`), recordPauseState: (paused) => calls.push(`pause:${paused ? 'yes' : 'no'}`),

View File

@@ -57,6 +57,7 @@ export function createBindMpvMainEventHandlersHandler(deps: {
notifyImmersionTitleUpdate: (title: string) => void; notifyImmersionTitleUpdate: (title: string) => void;
recordPlaybackPosition: (time: number) => void; recordPlaybackPosition: (time: number) => void;
recordMediaDuration: (durationSec: number) => void;
reportJellyfinRemoteProgress: (forceImmediate: boolean) => void; reportJellyfinRemoteProgress: (forceImmediate: boolean) => void;
recordPauseState: (paused: boolean) => void; recordPauseState: (paused: boolean) => void;
@@ -147,6 +148,7 @@ export function createBindMpvMainEventHandlersHandler(deps: {
onMediaPathChange: handleMpvMediaPathChange, onMediaPathChange: handleMpvMediaPathChange,
onMediaTitleChange: handleMpvMediaTitleChange, onMediaTitleChange: handleMpvMediaTitleChange,
onTimePosChange: handleMpvTimePosChange, onTimePosChange: handleMpvTimePosChange,
onDurationChange: ({ duration }) => deps.recordMediaDuration(duration),
onPauseChange: handleMpvPauseChange, onPauseChange: handleMpvPauseChange,
onSubtitleMetricsChange: handleMpvSubtitleMetricsChange, onSubtitleMetricsChange: handleMpvSubtitleMetricsChange,
onSecondarySubtitleVisibility: handleMpvSecondarySubtitleVisibility, onSecondarySubtitleVisibility: handleMpvSecondarySubtitleVisibility,

View File

@@ -1,12 +1,20 @@
import type { MergedToken, SubtitleData } from '../../types';
export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: { export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
appState: { appState: {
initialArgs?: { jellyfinPlay?: unknown } | null; initialArgs?: { jellyfinPlay?: unknown } | null;
overlayRuntimeInitialized: boolean; overlayRuntimeInitialized: boolean;
mpvClient: { connected?: boolean } | null; mpvClient: { connected?: boolean } | null;
immersionTracker: { immersionTracker: {
recordSubtitleLine?: (text: string, start: number, end: number) => void; recordSubtitleLine?: (
text: string,
start: number,
end: number,
tokens?: MergedToken[] | null,
) => void;
handleMediaTitleUpdate?: (title: string) => void; handleMediaTitleUpdate?: (title: string) => void;
recordPlaybackPosition?: (time: number) => void; recordPlaybackPosition?: (time: number) => void;
recordMediaDuration?: (durationSec: number) => void;
recordPauseState?: (paused: boolean) => void; recordPauseState?: (paused: boolean) => void;
} | null; } | null;
subtitleTimingTracker: { subtitleTimingTracker: {
@@ -14,6 +22,7 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
} | null; } | null;
currentSubText: string; currentSubText: string;
currentSubAssText: string; currentSubAssText: string;
currentSubtitleData?: SubtitleData | null;
playbackPaused: boolean | null; playbackPaused: boolean | null;
previousSecondarySubVisibility: boolean | null; previousSecondarySubVisibility: boolean | null;
}; };
@@ -41,6 +50,7 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
updateSubtitleRenderMetrics: (patch: Record<string, unknown>) => void; updateSubtitleRenderMetrics: (patch: Record<string, unknown>) => void;
refreshDiscordPresence: () => void; refreshDiscordPresence: () => void;
ensureImmersionTrackerInitialized: () => void; ensureImmersionTrackerInitialized: () => void;
tokenizeSubtitleForImmersion?: (text: string) => Promise<SubtitleData | null>;
}) { }) {
return () => ({ return () => ({
reportJellyfinRemoteStopped: () => deps.reportJellyfinRemoteStopped(), reportJellyfinRemoteStopped: () => deps.reportJellyfinRemoteStopped(),
@@ -53,7 +63,30 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
quitApp: () => deps.quitApp(), quitApp: () => deps.quitApp(),
recordImmersionSubtitleLine: (text: string, start: number, end: number) => { recordImmersionSubtitleLine: (text: string, start: number, end: number) => {
deps.ensureImmersionTrackerInitialized(); deps.ensureImmersionTrackerInitialized();
deps.appState.immersionTracker?.recordSubtitleLine?.(text, start, end); const tracker = deps.appState.immersionTracker;
if (!tracker?.recordSubtitleLine) {
return;
}
const cachedTokens =
deps.appState.currentSubtitleData?.text === text
? deps.appState.currentSubtitleData.tokens
: null;
if (cachedTokens) {
tracker.recordSubtitleLine(text, start, end, cachedTokens);
return;
}
if (!deps.tokenizeSubtitleForImmersion) {
tracker.recordSubtitleLine(text, start, end, null);
return;
}
void deps
.tokenizeSubtitleForImmersion(text)
.then((payload) => {
tracker.recordSubtitleLine?.(text, start, end, payload?.tokens ?? null);
})
.catch(() => {
tracker.recordSubtitleLine?.(text, start, end, null);
});
}, },
hasSubtitleTimingTracker: () => Boolean(deps.appState.subtitleTimingTracker), hasSubtitleTimingTracker: () => Boolean(deps.appState.subtitleTimingTracker),
recordSubtitleTiming: (text: string, start: number, end: number) => recordSubtitleTiming: (text: string, start: number, end: number) =>
@@ -95,6 +128,10 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
deps.ensureImmersionTrackerInitialized(); deps.ensureImmersionTrackerInitialized();
deps.appState.immersionTracker?.recordPlaybackPosition?.(time); deps.appState.immersionTracker?.recordPlaybackPosition?.(time);
}, },
recordMediaDuration: (durationSec: number) => {
deps.ensureImmersionTrackerInitialized();
deps.appState.immersionTracker?.recordMediaDuration?.(durationSec);
},
reportJellyfinRemoteProgress: (forceImmediate: boolean) => reportJellyfinRemoteProgress: (forceImmediate: boolean) =>
deps.reportJellyfinRemoteProgress(forceImmediate), deps.reportJellyfinRemoteProgress(forceImmediate),
recordPauseState: (paused: boolean) => { recordPauseState: (paused: boolean) => {

View File

@@ -196,6 +196,8 @@ export interface AppState {
anilistSetupPageOpened: boolean; anilistSetupPageOpened: boolean;
anilistRetryQueueState: AnilistRetryQueueState; anilistRetryQueueState: AnilistRetryQueueState;
firstRunSetupCompleted: boolean; firstRunSetupCompleted: boolean;
statsServer: { close: () => void } | null;
statsStartupInProgress: boolean;
} }
export interface AppStateInitialValues { export interface AppStateInitialValues {
@@ -275,6 +277,8 @@ export function createAppState(values: AppStateInitialValues): AppState {
anilistSetupPageOpened: false, anilistSetupPageOpened: false,
anilistRetryQueueState: createInitialAnilistRetryQueueState(), anilistRetryQueueState: createInitialAnilistRetryQueueState(),
firstRunSetupCompleted: false, firstRunSetupCompleted: false,
statsServer: null,
statsStartupInProgress: false,
}; };
} }

View File

@@ -51,6 +51,8 @@ function installKeyboardTestGlobals() {
const commandEvents: CommandEventDetail[] = []; const commandEvents: CommandEventDetail[] = [];
const mpvCommands: Array<Array<string | number>> = []; const mpvCommands: Array<Array<string | number>> = [];
let playbackPausedResponse: boolean | null = false; let playbackPausedResponse: boolean | null = false;
let statsToggleKey = 'Backquote';
let statsToggleOverlayCalls = 0;
let selectionClearCount = 0; let selectionClearCount = 0;
let selectionAddCount = 0; let selectionAddCount = 0;
@@ -137,7 +139,11 @@ function installKeyboardTestGlobals() {
mpvCommands.push(command); mpvCommands.push(command);
}, },
getPlaybackPaused: async () => playbackPausedResponse, getPlaybackPaused: async () => playbackPausedResponse,
getStatsToggleKey: async () => statsToggleKey,
toggleDevTools: () => {}, toggleDevTools: () => {},
toggleStatsOverlay: () => {
statsToggleOverlayCalls += 1;
},
focusMainWindow: () => { focusMainWindow: () => {
focusMainWindowCalls += 1; focusMainWindowCalls += 1;
return Promise.resolve(); return Promise.resolve();
@@ -253,6 +259,10 @@ function installKeyboardTestGlobals() {
setPopupVisible: (value: boolean) => { setPopupVisible: (value: boolean) => {
popupVisible = value; popupVisible = value;
}, },
setStatsToggleKey: (value: string) => {
statsToggleKey = value;
},
statsToggleOverlayCalls: () => statsToggleOverlayCalls,
getPlaybackPaused: async () => playbackPausedResponse, getPlaybackPaused: async () => playbackPausedResponse,
setPlaybackPausedResponse: (value: boolean | null) => { setPlaybackPausedResponse: (value: boolean | null) => {
playbackPausedResponse = value; playbackPausedResponse = value;
@@ -548,6 +558,22 @@ test('keyboard mode: controller select modal handles arrow keys before yomitan p
} }
}); });
test('keyboard mode: configured stats toggle works even while popup is open', async () => {
const { handlers, testGlobals } = createKeyboardHandlerHarness();
try {
testGlobals.setPopupVisible(true);
testGlobals.setStatsToggleKey('KeyG');
await handlers.setupMpvInputForwarding();
testGlobals.dispatchKeydown({ key: 'g', code: 'KeyG' });
assert.equal(testGlobals.statsToggleOverlayCalls(), 1);
} finally {
testGlobals.restore();
}
});
test('keyboard mode: h moves left when popup is closed', async () => { test('keyboard mode: h moves left when popup is closed', async () => {
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness(); const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();

View File

@@ -181,6 +181,17 @@ export function createKeyboardHandlers(
return !e.ctrlKey && !e.metaKey && e.altKey && !e.repeat && e.code === 'KeyC'; return !e.ctrlKey && !e.metaKey && e.altKey && !e.repeat && e.code === 'KeyC';
} }
function isStatsOverlayToggle(e: KeyboardEvent): boolean {
return (
e.code === ctx.state.statsToggleKey &&
!e.ctrlKey &&
!e.altKey &&
!e.metaKey &&
!e.shiftKey &&
!e.repeat
);
}
function getSubtitleWordNodes(): HTMLElement[] { function getSubtitleWordNodes(): HTMLElement[] {
return Array.from( return Array.from(
ctx.dom.subtitleRoot.querySelectorAll<HTMLElement>('.word[data-token-index]'), ctx.dom.subtitleRoot.querySelectorAll<HTMLElement>('.word[data-token-index]'),
@@ -693,7 +704,12 @@ export function createKeyboardHandlers(
} }
async function setupMpvInputForwarding(): Promise<void> { async function setupMpvInputForwarding(): Promise<void> {
updateKeybindings(await window.electronAPI.getKeybindings()); const [keybindings, statsToggleKey] = await Promise.all([
window.electronAPI.getKeybindings(),
window.electronAPI.getStatsToggleKey(),
]);
updateKeybindings(keybindings);
ctx.state.statsToggleKey = statsToggleKey;
syncKeyboardTokenSelection(); syncKeyboardTokenSelection();
const subtitleMutationObserver = new MutationObserver(() => { const subtitleMutationObserver = new MutationObserver(() => {
@@ -789,6 +805,12 @@ export function createKeyboardHandlers(
return; return;
} }
if (isStatsOverlayToggle(e)) {
e.preventDefault();
window.electronAPI.toggleStatsOverlay();
return;
}
if ( if (
(ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document)) && (ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document)) &&
!isControllerModalShortcut(e) !isControllerModalShortcut(e)

View File

@@ -91,6 +91,7 @@ export type RendererState = {
frequencyDictionaryBand5Color: string; frequencyDictionaryBand5Color: string;
keybindingsMap: Map<string, (string | number)[]>; keybindingsMap: Map<string, (string | number)[]>;
statsToggleKey: string;
chordPending: boolean; chordPending: boolean;
chordTimeout: ReturnType<typeof setTimeout> | null; chordTimeout: ReturnType<typeof setTimeout> | null;
keyboardDrivenModeEnabled: boolean; keyboardDrivenModeEnabled: boolean;
@@ -170,6 +171,7 @@ export function createRendererState(): RendererState {
frequencyDictionaryBand5Color: '#8aadf4', frequencyDictionaryBand5Color: '#8aadf4',
keybindingsMap: new Map(), keybindingsMap: new Map(),
statsToggleKey: 'Backquote',
chordPending: false, chordPending: false,
chordTimeout: null, chordTimeout: null,
keyboardDrivenModeEnabled: false, keyboardDrivenModeEnabled: false,