diff --git a/src/cli/args.test.ts b/src/cli/args.test.ts index 0730ddb..305260c 100644 --- a/src/cli/args.test.ts +++ b/src/cli/args.test.ts @@ -143,6 +143,12 @@ test('hasExplicitCommand and shouldStartApp preserve command intent', () => { assert.equal(dictionaryTarget.dictionary, true); 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']); assert.equal(jellyfinLibraries.jellyfinLibraries, true); assert.equal(hasExplicitCommand(jellyfinLibraries), true); diff --git a/src/cli/args.ts b/src/cli/args.ts index 25cc459..7a9a3da 100644 --- a/src/cli/args.ts +++ b/src/cli/args.ts @@ -29,6 +29,10 @@ export interface CliArgs { anilistRetryQueue: boolean; dictionary: boolean; dictionaryTarget?: string; + stats: boolean; + statsCleanup?: boolean; + statsCleanupVocab?: boolean; + statsResponsePath?: string; jellyfin: boolean; jellyfinLogin: boolean; jellyfinLogout: boolean; @@ -97,6 +101,9 @@ export function parseArgs(argv: string[]): CliArgs { anilistSetup: false, anilistRetryQueue: false, dictionary: false, + stats: false, + statsCleanup: false, + statsCleanupVocab: false, jellyfin: false, jellyfinLogin: false, jellyfinLogout: false, @@ -162,6 +169,15 @@ export function parseArgs(argv: string[]): CliArgs { } else if (arg === '--dictionary-target') { const value = readValue(argv[i + 1]); 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-login') args.jellyfinLogin = true; else if (arg === '--jellyfin-logout') args.jellyfinLogout = true; @@ -331,6 +347,7 @@ export function hasExplicitCommand(args: CliArgs): boolean { args.anilistSetup || args.anilistRetryQueue || args.dictionary || + args.stats || args.jellyfin || args.jellyfinLogin || args.jellyfinLogout || @@ -367,6 +384,7 @@ export function shouldStartApp(args: CliArgs): boolean { args.markAudioCard || args.openRuntimeOptions || args.dictionary || + args.stats || args.jellyfin || args.jellyfinPlay || args.texthooker @@ -408,6 +426,7 @@ export function shouldRunSettingsOnlyStartup(args: CliArgs): boolean { !args.anilistSetup && !args.anilistRetryQueue && !args.dictionary && + !args.stats && !args.jellyfin && !args.jellyfinLogin && !args.jellyfinLogout && diff --git a/src/cli/help.test.ts b/src/cli/help.test.ts index 7638f8d..ed8b1b0 100644 --- a/src/cli/help.test.ts +++ b/src/cli/help.test.ts @@ -18,6 +18,7 @@ test('printHelp includes configured texthooker port', () => { assert.match(output, /--help\s+Show this help/); assert.match(output, /default: 7777/); 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, /--setup\s+Open first-run setup window/); assert.match(output, /--anilist-status/); diff --git a/src/cli/help.ts b/src/cli/help.ts index 9cf55bb..a7bef77 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -14,6 +14,7 @@ ${B}Session${R} --start Connect to mpv and launch overlay --launch-mpv ${D}[targets...]${R} Launch mpv with the SubMiner mpv profile and exit --stop Stop the running instance + --stats Open the stats dashboard in your browser --texthooker Start texthooker server only ${D}(no overlay)${R} ${B}Overlay${R} diff --git a/src/main.ts b/src/main.ts index ae6cf13..2efa556 100644 --- a/src/main.ts +++ b/src/main.ts @@ -303,6 +303,8 @@ import { upsertYomitanDictionarySettings, updateLastCardFromClipboard as updateLastCardFromClipboardCore, } from './core/services'; +import { startStatsServer } from './core/services/stats-server'; +import { registerStatsOverlayToggle, destroyStatsWindow } from './core/services/stats-window.js'; import { createFirstRunSetupService, shouldAutoOpenFirstRunSetup, @@ -325,11 +327,18 @@ import { } from './main/runtime/windows-mpv-shortcuts'; import { createImmersionTrackerStartupHandler } from './main/runtime/immersion-startup'; 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 { guessAnilistMediaInfo, updateAnilistPostWatchProgress, } 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 { applyRuntimeOptionResultRuntime } from './core/services/runtime-options-ipc'; import { createAnilistTokenStore } from './core/services/anilist/anilist-token-store'; @@ -614,6 +623,11 @@ app.setPath('userData', USER_DATA_PATH); let forceQuitTimer: ReturnType | null = null; function requestAppQuit(): void { + destroyStatsWindow(); + if (appState.statsServer) { + appState.statsServer.close(); + appState.statsServer = null; + } if (!forceQuitTimer) { forceQuitTimer = setTimeout(() => { logger.warn('App quit timed out; forcing process exit.'); @@ -917,6 +931,10 @@ const buildMainSubsyncRuntimeMainDepsHandler = createBuildMainSubsyncRuntimeMain const immersionMediaRuntime = createImmersionMediaRuntime( buildImmersionMediaRuntimeMainDepsHandler(), ); +const statsCoverArtFetcher = createCoverArtFetcher( + createAnilistRateLimiter(), + createLogger('main:stats-cover-art'), +); const anilistStateRuntime = createAnilistStateRuntime(buildAnilistStateRuntimeMainDepsHandler()); const configDerivedRuntime = createConfigDerivedRuntime(buildConfigDerivedRuntimeMainDepsHandler()); const subsyncRuntime = createMainSubsyncRuntime(buildMainSubsyncRuntimeMainDepsHandler()); @@ -1025,11 +1043,11 @@ function maybeSignalPluginAutoplayReady( } let appTray: Tray | null = null; +let tokenizeSubtitleDeferred: ((text: string) => Promise) | null = null; const buildSubtitleProcessingControllerMainDepsHandler = createBuildSubtitleProcessingControllerMainDepsHandler({ - tokenizeSubtitle: async (text: string) => { - return await tokenizeSubtitle(text); - }, + tokenizeSubtitle: async (text: string) => + tokenizeSubtitleDeferred ? await tokenizeSubtitleDeferred(text) : { text, tokens: null }, emitSubtitle: (payload) => { appState.currentSubtitleData = payload; broadcastToOverlayWindows('subtitle:set', payload); @@ -2400,16 +2418,82 @@ const { }); 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< typeof createBuildImmersionTrackerStartupMainDepsHandler >[0] = { getResolvedConfig: () => getResolvedConfig(), getConfiguredDbPath: () => immersionMediaRuntime.getConfiguredDbPath(), - createTrackerService: (params) => new ImmersionTrackerService(params), + createTrackerService: (params) => + new ImmersionTrackerService({ + ...params, + resolveLegacyVocabularyPos, + }), setTracker: (tracker) => { 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, + shouldAutoConnectMpv: () => !appState.statsStartupInProgress, seedTrackerFromCurrentMedia: () => { void immersionMediaRuntime.seedFromCurrentMedia(); }, @@ -2420,6 +2504,10 @@ const immersionTrackerStartupMainDeps: Parameters< const createImmersionTrackerStartup = createImmersionTrackerStartupHandler( createBuildImmersionTrackerStartupMainDepsHandler(immersionTrackerStartupMainDeps)(), ); +const recordTrackedCardsMined = (count: number, noteIds?: number[]): void => { + ensureImmersionTrackerStarted(); + appState.immersionTracker?.recordCardsMined(count, noteIds); +}; let hasAttemptedImmersionTrackerStartup = false; const ensureImmersionTrackerStarted = (): void => { if (hasAttemptedImmersionTrackerStartup || appState.immersionTracker) { @@ -2429,6 +2517,34 @@ const ensureImmersionTrackerStarted = (): void => { 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({ reloadConfigMainDeps: { reloadConfigStrict: () => configService.reloadConfigStrict(), @@ -2576,10 +2692,13 @@ const { appReadyRuntimeRunner } = composeAppReadyRuntime({ setVisibleOverlayVisible: (visible: boolean) => setVisibleOverlayVisible(visible), initializeOverlayRuntime: () => initializeOverlayRuntime(), handleInitialArgs: () => handleInitialArgs(), + shouldUseMinimalStartup: () => + Boolean(appState.initialArgs?.stats && appState.initialArgs?.statsCleanup), shouldSkipHeavyStartup: () => Boolean( appState.initialArgs && (shouldRunSettingsOnlyStartup(appState.initialArgs) || + appState.initialArgs.stats || appState.initialArgs.dictionary || appState.initialArgs.setup), ), @@ -2728,6 +2847,8 @@ const { ensureImmersionTrackerInitialized: () => { ensureImmersionTrackerStarted(); }, + tokenizeSubtitleForImmersion: async (text): Promise => + tokenizeSubtitleDeferred ? await tokenizeSubtitleDeferred(text) : null, updateCurrentMediaPath: (path) => { autoPlayReadySignalMediaPath = null; currentMediaTokenizationGate.updateCurrentMediaPath(path); @@ -2939,6 +3060,7 @@ const { }, }, }); +tokenizeSubtitleDeferred = tokenizeSubtitle; function createMpvClientRuntimeService(): MpvIpcClient { return createMpvClientRuntimeServiceHandler() as MpvIpcClient; @@ -3113,6 +3235,7 @@ function destroyTray(): void { function initializeOverlayRuntime(): void { initializeOverlayRuntimeHandler(); + appState.ankiIntegration?.setRecordCardsMinedCallback(recordTrackedCardsMined); syncOverlayMpvSubtitleSuppression(); } @@ -3283,9 +3406,9 @@ const buildMineSentenceCardMainDepsHandler = createBuildMineSentenceCardMainDeps getMpvClient: () => appState.mpvClient, showMpvOsd: (text) => showMpvOsd(text), mineSentenceCardCore, - recordCardsMined: (count) => { + recordCardsMined: (count, noteIds) => { ensureImmersionTrackerStarted(); - appState.immersionTracker?.recordCardsMined(count); + appState.immersionTracker?.recordCardsMined(count, noteIds); }, }); const mineSentenceCardHandler = createMineSentenceCardHandler( @@ -3455,6 +3578,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({ getMecabTokenizer: () => appState.mecabTokenizer, getKeybindings: () => appState.keybindings, getConfiguredShortcuts: () => getConfiguredShortcuts(), + getStatsToggleKey: () => getResolvedConfig().stats.toggleKey, getControllerConfig: () => getResolvedConfig().controller, saveControllerPreference: ({ preferredGamepadId, preferredGamepadLabel }) => { configService.patchRawConfig({ @@ -3477,6 +3601,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({ getAnilistQueueStatus: () => anilistStateRuntime.getQueueStatusSnapshot(), retryAnilistQueueNow: () => processNextAnilistRetryUpdate(), appendClipboardVideoToQueue: () => appendClipboardVideoToQueue(), + getImmersionTracker: () => appState.immersionTracker, }, ankiJimakuDeps: createAnkiJimakuIpcRuntimeServiceDeps({ patchAnkiConnectEnabled: (enabled: boolean) => { @@ -3489,6 +3614,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({ getAnkiIntegration: () => appState.ankiIntegration, setAnkiIntegration: (integration: AnkiIntegration | null) => { appState.ankiIntegration = integration; + appState.ankiIntegration?.setRecordCardsMinedCallback(recordTrackedCardsMined); }, getKnownWordCacheStatePath: () => path.join(USER_DATA_PATH, 'known-words-cache.json'), showDesktopNotification, @@ -3551,6 +3677,8 @@ const createCliCommandContextHandler = createCliCommandContextFactory({ return await characterDictionaryRuntime.generateForCurrentMedia(targetPath); }, runJellyfinCommand: (argsFromCommand: CliArgs) => runJellyfinCommand(argsFromCommand), + runStatsCommand: (argsFromCommand: CliArgs, source: CliCommandSource) => + runStatsCliCommand(argsFromCommand, source), openYomitanSettings: () => openYomitanSettings(), cycleSecondarySubMode: () => handleCycleSecondarySubMode(), openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(), diff --git a/src/main/app-lifecycle.ts b/src/main/app-lifecycle.ts index 91edb71..8b91052 100644 --- a/src/main/app-lifecycle.ts +++ b/src/main/app-lifecycle.ts @@ -55,6 +55,7 @@ export interface AppReadyRuntimeDepsFactoryInput { onCriticalConfigErrors?: AppReadyRuntimeDeps['onCriticalConfigErrors']; logDebug?: AppReadyRuntimeDeps['logDebug']; now?: AppReadyRuntimeDeps['now']; + shouldUseMinimalStartup?: AppReadyRuntimeDeps['shouldUseMinimalStartup']; shouldSkipHeavyStartup?: AppReadyRuntimeDeps['shouldSkipHeavyStartup']; } @@ -118,6 +119,7 @@ export function createAppReadyRuntimeDeps( onCriticalConfigErrors: params.onCriticalConfigErrors, logDebug: params.logDebug, now: params.now, + shouldUseMinimalStartup: params.shouldUseMinimalStartup, shouldSkipHeavyStartup: params.shouldSkipHeavyStartup, }; } diff --git a/src/main/character-dictionary-runtime.test.ts b/src/main/character-dictionary-runtime.test.ts index e628011..666e6c4 100644 --- a/src/main/character-dictionary-runtime.test.ts +++ b/src/main/character-dictionary-runtime.test.ts @@ -153,6 +153,7 @@ test('generateForCurrentMedia emits structured-content glossary so image stays w resolveMediaPathForJimaku: (mediaPath) => mediaPath, guessAnilistMediaInfo: async () => ({ title: 'The Eminence in Shadow', + season: null, episode: 5, source: 'fallback', }), @@ -361,6 +362,7 @@ test('generateForCurrentMedia applies configured open states to character dictio resolveMediaPathForJimaku: (mediaPath) => mediaPath, guessAnilistMediaInfo: async () => ({ title: 'The Eminence in Shadow', + season: null, episode: 5, source: 'fallback', }), @@ -518,6 +520,7 @@ test('generateForCurrentMedia reapplies collapsible open states when using cache resolveMediaPathForJimaku: (mediaPath) => mediaPath, guessAnilistMediaInfo: async () => ({ title: 'The Eminence in Shadow', + season: null, episode: 5, source: 'fallback', }), @@ -533,6 +536,7 @@ test('generateForCurrentMedia reapplies collapsible open states when using cache resolveMediaPathForJimaku: (mediaPath) => mediaPath, guessAnilistMediaInfo: async () => ({ title: 'The Eminence in Shadow', + season: null, episode: 5, source: 'fallback', }), @@ -661,6 +665,7 @@ test('generateForCurrentMedia adds kana aliases for romanized names when native resolveMediaPathForJimaku: (mediaPath) => mediaPath, guessAnilistMediaInfo: async () => ({ title: 'Konosuba', + season: null, episode: 5, source: 'fallback', }), @@ -783,6 +788,7 @@ test('generateForCurrentMedia indexes kanji family and given names using AniList resolveMediaPathForJimaku: (mediaPath) => mediaPath, guessAnilistMediaInfo: async () => ({ title: 'Rascal Does Not Dream of Bunny Girl Senpai', + season: null, episode: 1, source: 'fallback', }), @@ -904,6 +910,7 @@ test('generateForCurrentMedia indexes AniList alternative character names for al resolveMediaPathForJimaku: (mediaPath) => mediaPath, guessAnilistMediaInfo: async () => ({ title: 'The Eminence in Shadow', + season: null, episode: 5, source: 'fallback', }), @@ -1028,6 +1035,7 @@ test('generateForCurrentMedia skips AniList characters without a native name whe resolveMediaPathForJimaku: (mediaPath) => mediaPath, guessAnilistMediaInfo: async () => ({ title: 'The Eminence in Shadow', + season: null, episode: 5, source: 'fallback', }), @@ -1148,6 +1156,7 @@ test('generateForCurrentMedia uses AniList first and last name hints to build ka resolveMediaPathForJimaku: (mediaPath) => mediaPath, guessAnilistMediaInfo: async () => ({ title: 'Konosuba', + season: null, episode: 5, source: 'fallback', }), @@ -1265,6 +1274,7 @@ test('generateForCurrentMedia includes AniList gender age birthday and blood typ resolveMediaPathForJimaku: (mediaPath) => mediaPath, guessAnilistMediaInfo: async () => ({ title: 'The Eminence in Shadow', + season: null, episode: 5, source: 'fallback', }), @@ -1408,6 +1418,7 @@ test('generateForCurrentMedia preserves duplicate surface forms across different resolveMediaPathForJimaku: (mediaPath) => mediaPath, guessAnilistMediaInfo: async () => ({ title: 'The Eminence in Shadow', + season: null, episode: 5, source: 'fallback', }), @@ -1548,6 +1559,7 @@ test('getOrCreateCurrentSnapshot persists and reuses normalized snapshot data', resolveMediaPathForJimaku: (mediaPath) => mediaPath, guessAnilistMediaInfo: async () => ({ title: 'The Eminence in Shadow', + season: null, episode: 5, source: 'fallback', }), @@ -1703,6 +1715,7 @@ test('getOrCreateCurrentSnapshot rebuilds snapshots written with an older format resolveMediaPathForJimaku: (mediaPath) => mediaPath, guessAnilistMediaInfo: async () => ({ title: 'The Eminence in Shadow', + season: null, episode: 5, source: 'fallback', }), @@ -1842,6 +1855,7 @@ test('generateForCurrentMedia logs progress while resolving and rebuilding snaps resolveMediaPathForJimaku: (mediaPath) => mediaPath, guessAnilistMediaInfo: async () => ({ title: 'The Eminence in Shadow', + season: null, episode: 5, source: 'fallback', }), @@ -2014,6 +2028,7 @@ test('generateForCurrentMedia downloads shared voice actor images once per AniLi resolveMediaPathForJimaku: (mediaPath) => mediaPath, guessAnilistMediaInfo: async () => ({ title: 'The Eminence in Shadow', + season: null, episode: 5, source: 'fallback', }), @@ -2194,6 +2209,7 @@ test('buildMergedDictionary combines stored snapshots into one stable dictionary resolveMediaPathForJimaku: (mediaPath) => mediaPath, guessAnilistMediaInfo: async () => ({ title: current.title, + season: null, episode: current.episode, source: 'fallback', }), @@ -2481,6 +2497,7 @@ test('buildMergedDictionary reapplies collapsible open states from current confi resolveMediaPathForJimaku: (mediaPath) => mediaPath, guessAnilistMediaInfo: async () => ({ title: current.title, + season: null, episode: current.episode, source: 'fallback', }), @@ -2500,6 +2517,7 @@ test('buildMergedDictionary reapplies collapsible open states from current confi resolveMediaPathForJimaku: (mediaPath) => mediaPath, guessAnilistMediaInfo: async () => ({ title: current.title, + season: null, episode: current.episode, source: 'fallback', }), @@ -2663,6 +2681,7 @@ test('generateForCurrentMedia paces AniList requests and character image downloa resolveMediaPathForJimaku: (mediaPath) => mediaPath, guessAnilistMediaInfo: async () => ({ title: 'The Eminence in Shadow', + season: null, episode: 5, source: 'fallback', }), diff --git a/src/main/cli-runtime.ts b/src/main/cli-runtime.ts index 887cea2..7d5c7af 100644 --- a/src/main/cli-runtime.ts +++ b/src/main/cli-runtime.ts @@ -36,6 +36,7 @@ export interface CliCommandRuntimeServiceContext { retryAnilistQueueNow: CliCommandRuntimeServiceDepsParams['anilist']['retryQueueNow']; generateCharacterDictionary: CliCommandRuntimeServiceDepsParams['dictionary']['generate']; openJellyfinSetup: CliCommandRuntimeServiceDepsParams['jellyfin']['openSetup']; + runStatsCommand: CliCommandRuntimeServiceDepsParams['jellyfin']['runStatsCommand']; runJellyfinCommand: CliCommandRuntimeServiceDepsParams['jellyfin']['runCommand']; openYomitanSettings: () => void; cycleSecondarySubMode: () => void; @@ -101,6 +102,7 @@ function createCliCommandDepsFromContext( }, jellyfin: { openSetup: context.openJellyfinSetup, + runStatsCommand: context.runStatsCommand, runCommand: context.runJellyfinCommand, }, ui: { diff --git a/src/main/dependencies.ts b/src/main/dependencies.ts index d5d40bc..e5a3166 100644 --- a/src/main/dependencies.ts +++ b/src/main/dependencies.ts @@ -72,6 +72,7 @@ export interface MainIpcRuntimeServiceDepsParams { handleMpvCommand: IpcDepsRuntimeOptions['handleMpvCommand']; getKeybindings: IpcDepsRuntimeOptions['getKeybindings']; getConfiguredShortcuts: IpcDepsRuntimeOptions['getConfiguredShortcuts']; + getStatsToggleKey: IpcDepsRuntimeOptions['getStatsToggleKey']; getControllerConfig: IpcDepsRuntimeOptions['getControllerConfig']; saveControllerPreference: IpcDepsRuntimeOptions['saveControllerPreference']; getSecondarySubMode: IpcDepsRuntimeOptions['getSecondarySubMode']; @@ -88,6 +89,7 @@ export interface MainIpcRuntimeServiceDepsParams { getAnilistQueueStatus: IpcDepsRuntimeOptions['getAnilistQueueStatus']; retryAnilistQueueNow: IpcDepsRuntimeOptions['retryAnilistQueueNow']; appendClipboardVideoToQueue: IpcDepsRuntimeOptions['appendClipboardVideoToQueue']; + getImmersionTracker?: IpcDepsRuntimeOptions['getImmersionTracker']; } export interface AnkiJimakuIpcRuntimeServiceDepsParams { @@ -158,6 +160,7 @@ export interface CliCommandRuntimeServiceDepsParams { }; jellyfin: { openSetup: CliCommandDepsRuntimeOptions['jellyfin']['openSetup']; + runStatsCommand: CliCommandDepsRuntimeOptions['jellyfin']['runStatsCommand']; runCommand: CliCommandDepsRuntimeOptions['jellyfin']['runCommand']; }; ui: { @@ -215,6 +218,7 @@ export function createMainIpcRuntimeServiceDeps( handleMpvCommand: params.handleMpvCommand, getKeybindings: params.getKeybindings, getConfiguredShortcuts: params.getConfiguredShortcuts, + getStatsToggleKey: params.getStatsToggleKey, getControllerConfig: params.getControllerConfig, saveControllerPreference: params.saveControllerPreference, focusMainWindow: params.focusMainWindow ?? (() => {}), @@ -232,6 +236,7 @@ export function createMainIpcRuntimeServiceDeps( getAnilistQueueStatus: params.getAnilistQueueStatus, retryAnilistQueueNow: params.retryAnilistQueueNow, appendClipboardVideoToQueue: params.appendClipboardVideoToQueue, + getImmersionTracker: params.getImmersionTracker, }; } @@ -310,6 +315,7 @@ export function createCliCommandRuntimeServiceDeps( }, jellyfin: { openSetup: params.jellyfin.openSetup, + runStatsCommand: params.jellyfin.runStatsCommand, runCommand: params.jellyfin.runCommand, }, ui: { diff --git a/src/main/runtime/anilist-media-guess-main-deps.test.ts b/src/main/runtime/anilist-media-guess-main-deps.test.ts index 2c33486..e3e2bde 100644 --- a/src/main/runtime/anilist-media-guess-main-deps.test.ts +++ b/src/main/runtime/anilist-media-guess-main-deps.test.ts @@ -55,7 +55,7 @@ test('ensure anilist media guess main deps builder maps callbacks', async () => getCurrentMediaTitle: () => 'title', guessAnilistMediaInfo: async () => { 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.deepEqual(await deps.guessAnilistMediaInfo('/tmp/video.mkv', 'title'), { title: 'title', + season: null, episode: 1, source: 'fallback', }); diff --git a/src/main/runtime/anilist-media-guess.test.ts b/src/main/runtime/anilist-media-guess.test.ts index 6a862c6..9b47674 100644 --- a/src/main/runtime/anilist-media-guess.test.ts +++ b/src/main/runtime/anilist-media-guess.test.ts @@ -49,7 +49,7 @@ test('ensureAnilistMediaGuess memoizes in-flight guess promise', async () => { getCurrentMediaTitle: () => 'Episode 1', guessAnilistMediaInfo: async () => { 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'), ]); - assert.deepEqual(first, { title: 'Show', episode: 1, source: 'guessit' }); - assert.deepEqual(second, { title: 'Show', episode: 1, source: 'guessit' }); + assert.deepEqual(first, { title: 'Show', season: null, episode: 1, source: 'guessit' }); + assert.deepEqual(second, { title: 'Show', season: null, episode: 1, source: 'guessit' }); 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); }); diff --git a/src/main/runtime/anilist-post-watch-main-deps.test.ts b/src/main/runtime/anilist-post-watch-main-deps.test.ts index bb88e69..f8d9f2f 100644 --- a/src/main/runtime/anilist-post-watch-main-deps.test.ts +++ b/src/main/runtime/anilist-post-watch-main-deps.test.ts @@ -8,7 +8,7 @@ import { test('process next anilist retry update main deps builder maps callbacks', async () => { const calls: string[] = []; const deps = createBuildProcessNextAnilistRetryUpdateMainDepsHandler({ - nextReady: () => ({ key: 'k', title: 't', episode: 1 }), + nextReady: () => ({ key: 'k', title: 't', season: null, episode: 1 }), refreshRetryQueueState: () => calls.push('refresh'), setLastAttemptAt: () => calls.push('attempt'), 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'), getWatchedSeconds: () => 100, maybeProbeAnilistDuration: async () => 120, - ensureAnilistMediaGuess: async () => ({ title: 'x', episode: 1 }), + ensureAnilistMediaGuess: async () => ({ title: 'x', season: null, episode: 1 }), hasAttemptedUpdateKey: () => false, processNextAnilistRetryUpdate: async () => ({ ok: true, message: 'ok' }), refreshAnilistClientSecretState: async () => 'token', @@ -85,7 +85,7 @@ test('maybe run anilist post watch update main deps builder maps callbacks', asy deps.resetTrackedMedia('media'); assert.equal(deps.getWatchedSeconds(), 100); 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.deepEqual(await deps.processNextAnilistRetryUpdate(), { ok: true, message: 'ok' }); assert.equal(await deps.refreshAnilistClientSecretState(), 'token'); diff --git a/src/main/runtime/anilist-post-watch.test.ts b/src/main/runtime/anilist-post-watch.test.ts index 0b95dcf..4deac3a 100644 --- a/src/main/runtime/anilist-post-watch.test.ts +++ b/src/main/runtime/anilist-post-watch.test.ts @@ -20,7 +20,7 @@ test('rememberAnilistAttemptedUpdateKey evicts oldest beyond max size', () => { test('createProcessNextAnilistRetryUpdateHandler handles successful retry', async () => { const calls: string[] = []; const handler = createProcessNextAnilistRetryUpdateHandler({ - nextReady: () => ({ key: 'k1', title: 'Show', episode: 1 }), + nextReady: () => ({ key: 'k1', title: 'Show', season: null, episode: 1 }), refreshRetryQueueState: () => calls.push('refresh'), setLastAttemptAt: () => calls.push('attempt'), setLastError: (value) => calls.push(`error:${value ?? 'null'}`), @@ -52,7 +52,7 @@ test('createMaybeRunAnilistPostWatchUpdateHandler queues when token missing', as resetTrackedMedia: () => {}, getWatchedSeconds: () => 1000, maybeProbeAnilistDuration: async () => 1000, - ensureAnilistMediaGuess: async () => ({ title: 'Show', episode: 1 }), + ensureAnilistMediaGuess: async () => ({ title: 'Show', season: null, episode: 1 }), hasAttemptedUpdateKey: () => false, processNextAnilistRetryUpdate: async () => ({ ok: true, message: 'noop' }), refreshAnilistClientSecretState: async () => null, diff --git a/src/main/runtime/app-ready-main-deps.ts b/src/main/runtime/app-ready-main-deps.ts index 435afc2..b4ef3a4 100644 --- a/src/main/runtime/app-ready-main-deps.ts +++ b/src/main/runtime/app-ready-main-deps.ts @@ -38,6 +38,7 @@ export function createBuildAppReadyRuntimeMainDepsHandler(deps: AppReadyRuntimeD onCriticalConfigErrors: deps.onCriticalConfigErrors, logDebug: deps.logDebug, now: deps.now, + shouldUseMinimalStartup: deps.shouldUseMinimalStartup, shouldSkipHeavyStartup: deps.shouldSkipHeavyStartup, }); } diff --git a/src/main/runtime/cli-command-context-deps.test.ts b/src/main/runtime/cli-command-context-deps.test.ts index aa4099d..73e3809 100644 --- a/src/main/runtime/cli-command-context-deps.test.ts +++ b/src/main/runtime/cli-command-context-deps.test.ts @@ -54,6 +54,9 @@ test('build cli command context deps maps handlers and values', () => { mediaTitle: 'Test', entryCount: 10, }), + runStatsCommand: async () => { + calls.push('run-stats'); + }, runJellyfinCommand: async () => { calls.push('run-jellyfin'); }, diff --git a/src/main/runtime/cli-command-context-deps.ts b/src/main/runtime/cli-command-context-deps.ts index f5476d4..c8b10cd 100644 --- a/src/main/runtime/cli-command-context-deps.ts +++ b/src/main/runtime/cli-command-context-deps.ts @@ -34,6 +34,7 @@ export function createBuildCliCommandContextDepsHandler(deps: { getAnilistQueueStatus: CliCommandContextFactoryDeps['getAnilistQueueStatus']; retryAnilistQueueNow: CliCommandContextFactoryDeps['retryAnilistQueueNow']; generateCharacterDictionary: CliCommandContextFactoryDeps['generateCharacterDictionary']; + runStatsCommand: CliCommandContextFactoryDeps['runStatsCommand']; runJellyfinCommand: (args: CliArgs) => Promise; openYomitanSettings: () => void; cycleSecondarySubMode: () => void; @@ -80,6 +81,7 @@ export function createBuildCliCommandContextDepsHandler(deps: { getAnilistQueueStatus: deps.getAnilistQueueStatus, retryAnilistQueueNow: deps.retryAnilistQueueNow, generateCharacterDictionary: deps.generateCharacterDictionary, + runStatsCommand: deps.runStatsCommand, runJellyfinCommand: deps.runJellyfinCommand, openYomitanSettings: deps.openYomitanSettings, cycleSecondarySubMode: deps.cycleSecondarySubMode, diff --git a/src/main/runtime/cli-command-context-factory.test.ts b/src/main/runtime/cli-command-context-factory.test.ts index 005fd28..3d329de 100644 --- a/src/main/runtime/cli-command-context-factory.test.ts +++ b/src/main/runtime/cli-command-context-factory.test.ts @@ -61,6 +61,7 @@ test('cli command context factory composes main deps and context handlers', () = mediaTitle: 'Test', entryCount: 10, }), + runStatsCommand: async () => {}, runJellyfinCommand: async () => {}, openYomitanSettings: () => {}, cycleSecondarySubMode: () => {}, diff --git a/src/main/runtime/cli-command-context-main-deps.test.ts b/src/main/runtime/cli-command-context-main-deps.test.ts index 6e77b81..3c48ef2 100644 --- a/src/main/runtime/cli-command-context-main-deps.test.ts +++ b/src/main/runtime/cli-command-context-main-deps.test.ts @@ -78,6 +78,9 @@ test('cli command context main deps builder maps state and callbacks', async () mediaTitle: 'Test', entryCount: 10, }), + runStatsCommand: async () => { + calls.push('run-stats'); + }, runJellyfinCommand: async () => { calls.push('run-jellyfin'); }, diff --git a/src/main/runtime/cli-command-context-main-deps.ts b/src/main/runtime/cli-command-context-main-deps.ts index da6d7f5..9e6dfe7 100644 --- a/src/main/runtime/cli-command-context-main-deps.ts +++ b/src/main/runtime/cli-command-context-main-deps.ts @@ -39,6 +39,7 @@ export function createBuildCliCommandContextMainDepsHandler(deps: { getAnilistQueueStatus: CliCommandContextFactoryDeps['getAnilistQueueStatus']; processNextAnilistRetryUpdate: CliCommandContextFactoryDeps['retryAnilistQueueNow']; generateCharacterDictionary: CliCommandContextFactoryDeps['generateCharacterDictionary']; + runStatsCommand: CliCommandContextFactoryDeps['runStatsCommand']; runJellyfinCommand: (args: CliArgs) => Promise; openYomitanSettings: () => void; @@ -92,6 +93,7 @@ export function createBuildCliCommandContextMainDepsHandler(deps: { retryAnilistQueueNow: () => deps.processNextAnilistRetryUpdate(), generateCharacterDictionary: (targetPath?: string) => deps.generateCharacterDictionary(targetPath), + runStatsCommand: (args: CliArgs, source) => deps.runStatsCommand(args, source), runJellyfinCommand: (args: CliArgs) => deps.runJellyfinCommand(args), openYomitanSettings: () => deps.openYomitanSettings(), cycleSecondarySubMode: () => deps.cycleSecondarySubMode(), diff --git a/src/main/runtime/cli-command-context.test.ts b/src/main/runtime/cli-command-context.test.ts index dfae787..1eeb660 100644 --- a/src/main/runtime/cli-command-context.test.ts +++ b/src/main/runtime/cli-command-context.test.ts @@ -48,6 +48,7 @@ function createDeps() { mediaTitle: 'Test', entryCount: 1, }), + runStatsCommand: async () => {}, runJellyfinCommand: async () => {}, openYomitanSettings: () => {}, cycleSecondarySubMode: () => {}, diff --git a/src/main/runtime/cli-command-context.ts b/src/main/runtime/cli-command-context.ts index 25df822..de9d630 100644 --- a/src/main/runtime/cli-command-context.ts +++ b/src/main/runtime/cli-command-context.ts @@ -39,6 +39,7 @@ export type CliCommandContextFactoryDeps = { getAnilistQueueStatus: CliCommandRuntimeServiceContext['getAnilistQueueStatus']; retryAnilistQueueNow: CliCommandRuntimeServiceContext['retryAnilistQueueNow']; generateCharacterDictionary: CliCommandRuntimeServiceContext['generateCharacterDictionary']; + runStatsCommand: CliCommandRuntimeServiceContext['runStatsCommand']; runJellyfinCommand: (args: CliArgs) => Promise; openYomitanSettings: () => void; cycleSecondarySubMode: () => void; @@ -92,6 +93,7 @@ export function createCliCommandContext( getAnilistQueueStatus: deps.getAnilistQueueStatus, retryAnilistQueueNow: deps.retryAnilistQueueNow, generateCharacterDictionary: deps.generateCharacterDictionary, + runStatsCommand: deps.runStatsCommand, runJellyfinCommand: deps.runJellyfinCommand, openYomitanSettings: deps.openYomitanSettings, cycleSecondarySubMode: deps.cycleSecondarySubMode, diff --git a/src/main/runtime/composers/anilist-tracking-composer.test.ts b/src/main/runtime/composers/anilist-tracking-composer.test.ts index c21925a..5b6e4f8 100644 --- a/src/main/runtime/composers/anilist-tracking-composer.test.ts +++ b/src/main/runtime/composers/anilist-tracking-composer.test.ts @@ -131,11 +131,11 @@ test('composeAnilistTrackingHandlers returns callable handlers and forwards call getCurrentMediaTitle: () => 'Episode title', guessAnilistMediaInfo: async () => { guessAnilistMediaInfoCalls += 1; - return { title: 'Episode title', episode: 7, source: 'guessit' }; + return { title: 'Episode title', season: null, episode: 7, source: 'guessit' }; }, }, processNextRetryUpdateMainDeps: { - nextReady: () => ({ key: 'retry-key', title: 'Retry title', episode: 1 }), + nextReady: () => ({ key: 'retry-key', title: 'Retry title', season: null, episode: 1 }), refreshRetryQueueState: () => {}, setLastAttemptAt: () => {}, setLastError: () => {}, @@ -163,6 +163,7 @@ test('composeAnilistTrackingHandlers returns callable handlers and forwards call maybeProbeAnilistDuration: async () => 600, ensureAnilistMediaGuess: async () => ({ title: 'Episode title', + season: null, episode: 2, source: 'guessit', }), @@ -209,7 +210,7 @@ test('composeAnilistTrackingHandlers returns callable handlers and forwards call composed.setAnilistMediaGuessRuntimeState({ mediaKey: 'media-key', mediaDurationSec: 90, - mediaGuess: { title: 'Known', episode: 3, source: 'fallback' }, + mediaGuess: { title: 'Known', season: null, episode: 3, source: 'fallback' }, mediaGuessPromise: null, lastDurationProbeAtMs: 11, }); diff --git a/src/main/runtime/composers/ipc-runtime-composer.test.ts b/src/main/runtime/composers/ipc-runtime-composer.test.ts index 4665e9e..0ca1ffc 100644 --- a/src/main/runtime/composers/ipc-runtime-composer.test.ts +++ b/src/main/runtime/composers/ipc-runtime-composer.test.ts @@ -51,6 +51,7 @@ test('composeIpcRuntimeHandlers returns callable IPC handlers and registration b getMecabTokenizer: () => null, getKeybindings: () => [], getConfiguredShortcuts: () => ({}) as never, + getStatsToggleKey: () => 'Backquote', getControllerConfig: () => ({}) as never, saveControllerPreference: () => {}, getSecondarySubMode: () => 'hover' as never, diff --git a/src/main/runtime/first-run-setup-service.test.ts b/src/main/runtime/first-run-setup-service.test.ts index af59fe1..aef224c 100644 --- a/src/main/runtime/first-run-setup-service.test.ts +++ b/src/main/runtime/first-run-setup-service.test.ts @@ -48,6 +48,7 @@ function makeArgs(overrides: Partial = {}): CliArgs { anilistSetup: false, anilistRetryQueue: false, dictionary: false, + stats: false, jellyfin: false, jellyfinLogin: false, jellyfinLogout: false, diff --git a/src/main/runtime/immersion-startup.test.ts b/src/main/runtime/immersion-startup.test.ts index 9e3b6ce..4755a0a 100644 --- a/src/main/runtime/immersion-startup.test.ts +++ b/src/main/runtime/immersion-startup.test.ts @@ -135,3 +135,28 @@ test('createImmersionTrackerStartupHandler disables tracker on failure', () => { 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); +}); diff --git a/src/main/runtime/immersion-startup.ts b/src/main/runtime/immersion-startup.ts index cda2fc2..e3329dc 100644 --- a/src/main/runtime/immersion-startup.ts +++ b/src/main/runtime/immersion-startup.ts @@ -38,6 +38,7 @@ export type ImmersionTrackerStartupDeps = { createTrackerService: (params: ImmersionTrackerServiceParams) => unknown; setTracker: (tracker: unknown | null) => void; getMpvClient: () => MpvClientLike | null; + shouldAutoConnectMpv?: () => boolean; seedTrackerFromCurrentMedia: () => void; logInfo: (message: string) => void; logDebug: (message: string) => void; @@ -86,7 +87,7 @@ export function createImmersionTrackerStartupHandler( deps.logDebug('Immersion tracker initialized successfully.'); const mpvClient = deps.getMpvClient(); - if (mpvClient && !mpvClient.connected) { + if ((deps.shouldAutoConnectMpv?.() ?? true) && mpvClient && !mpvClient.connected) { deps.logInfo('Auto-connecting MPV client for immersion tracking'); mpvClient.connect(); } diff --git a/src/main/runtime/initial-args-handler.ts b/src/main/runtime/initial-args-handler.ts index dac3ae1..6d777ff 100644 --- a/src/main/runtime/initial-args-handler.ts +++ b/src/main/runtime/initial-args-handler.ts @@ -27,6 +27,7 @@ export function createHandleInitialArgsHandler(deps: { const mpvClient = deps.getMpvClient(); if ( !deps.isTexthookerOnlyMode() && + !initialArgs.stats && deps.hasImmersionTracker() && mpvClient && !mpvClient.connected diff --git a/src/main/runtime/initial-args-runtime-handler.test.ts b/src/main/runtime/initial-args-runtime-handler.test.ts index 86f77fc..b243b97 100644 --- a/src/main/runtime/initial-args-runtime-handler.test.ts +++ b/src/main/runtime/initial-args-runtime-handler.test.ts @@ -28,3 +28,25 @@ test('initial args runtime handler composes main deps and runs initial command f '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']); +}); diff --git a/src/main/runtime/mpv-client-event-bindings.test.ts b/src/main/runtime/mpv-client-event-bindings.test.ts index 5f4bae7..fa56d57 100644 --- a/src/main/runtime/mpv-client-event-bindings.test.ts +++ b/src/main/runtime/mpv-client-event-bindings.test.ts @@ -75,6 +75,7 @@ test('mpv event bindings register all expected events', () => { onMediaPathChange: () => {}, onMediaTitleChange: () => {}, onTimePosChange: () => {}, + onDurationChange: () => {}, onPauseChange: () => {}, onSubtitleMetricsChange: () => {}, onSecondarySubtitleVisibility: () => {}, @@ -95,6 +96,7 @@ test('mpv event bindings register all expected events', () => { 'media-path-change', 'media-title-change', 'time-pos-change', + 'duration-change', 'pause-change', 'subtitle-metrics-change', 'secondary-subtitle-visibility', diff --git a/src/main/runtime/mpv-client-event-bindings.ts b/src/main/runtime/mpv-client-event-bindings.ts index 64a5872..9a6b747 100644 --- a/src/main/runtime/mpv-client-event-bindings.ts +++ b/src/main/runtime/mpv-client-event-bindings.ts @@ -7,6 +7,7 @@ type MpvBindingEventName = | 'media-path-change' | 'media-title-change' | 'time-pos-change' + | 'duration-change' | 'pause-change' | 'subtitle-metrics-change' | 'secondary-subtitle-visibility'; @@ -72,6 +73,7 @@ export function createBindMpvClientEventHandlers(deps: { onMediaPathChange: (payload: { path: string | null }) => void; onMediaTitleChange: (payload: { title: string | null }) => void; onTimePosChange: (payload: { time: number }) => void; + onDurationChange: (payload: { duration: number }) => void; onPauseChange: (payload: { paused: boolean }) => void; onSubtitleMetricsChange: (payload: { patch: Record }) => void; onSecondarySubtitleVisibility: (payload: { visible: boolean }) => void; @@ -85,6 +87,7 @@ export function createBindMpvClientEventHandlers(deps: { mpvClient.on('media-path-change', deps.onMediaPathChange); mpvClient.on('media-title-change', deps.onMediaTitleChange); mpvClient.on('time-pos-change', deps.onTimePosChange); + mpvClient.on('duration-change', deps.onDurationChange); mpvClient.on('pause-change', deps.onPauseChange); mpvClient.on('subtitle-metrics-change', deps.onSubtitleMetricsChange); mpvClient.on('secondary-subtitle-visibility', deps.onSecondarySubtitleVisibility); diff --git a/src/main/runtime/mpv-main-event-bindings.test.ts b/src/main/runtime/mpv-main-event-bindings.test.ts index 79c6ca8..256b4ee 100644 --- a/src/main/runtime/mpv-main-event-bindings.test.ts +++ b/src/main/runtime/mpv-main-event-bindings.test.ts @@ -48,6 +48,7 @@ test('main mpv event binder wires callbacks through to runtime deps', () => { notifyImmersionTitleUpdate: (title) => calls.push(`notify-title:${title}`), recordPlaybackPosition: (time) => calls.push(`time-pos:${time}`), + recordMediaDuration: (duration) => calls.push(`duration:${duration}`), reportJellyfinRemoteProgress: (forceImmediate) => calls.push(`progress:${forceImmediate ? 'force' : 'normal'}`), recordPauseState: (paused) => calls.push(`pause:${paused ? 'yes' : 'no'}`), diff --git a/src/main/runtime/mpv-main-event-bindings.ts b/src/main/runtime/mpv-main-event-bindings.ts index ba7e678..63d7220 100644 --- a/src/main/runtime/mpv-main-event-bindings.ts +++ b/src/main/runtime/mpv-main-event-bindings.ts @@ -57,6 +57,7 @@ export function createBindMpvMainEventHandlersHandler(deps: { notifyImmersionTitleUpdate: (title: string) => void; recordPlaybackPosition: (time: number) => void; + recordMediaDuration: (durationSec: number) => void; reportJellyfinRemoteProgress: (forceImmediate: boolean) => void; recordPauseState: (paused: boolean) => void; @@ -147,6 +148,7 @@ export function createBindMpvMainEventHandlersHandler(deps: { onMediaPathChange: handleMpvMediaPathChange, onMediaTitleChange: handleMpvMediaTitleChange, onTimePosChange: handleMpvTimePosChange, + onDurationChange: ({ duration }) => deps.recordMediaDuration(duration), onPauseChange: handleMpvPauseChange, onSubtitleMetricsChange: handleMpvSubtitleMetricsChange, onSecondarySubtitleVisibility: handleMpvSecondarySubtitleVisibility, diff --git a/src/main/runtime/mpv-main-event-main-deps.ts b/src/main/runtime/mpv-main-event-main-deps.ts index 18e21c1..914b8be 100644 --- a/src/main/runtime/mpv-main-event-main-deps.ts +++ b/src/main/runtime/mpv-main-event-main-deps.ts @@ -1,12 +1,20 @@ +import type { MergedToken, SubtitleData } from '../../types'; + export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: { appState: { initialArgs?: { jellyfinPlay?: unknown } | null; overlayRuntimeInitialized: boolean; mpvClient: { connected?: boolean } | null; immersionTracker: { - recordSubtitleLine?: (text: string, start: number, end: number) => void; + recordSubtitleLine?: ( + text: string, + start: number, + end: number, + tokens?: MergedToken[] | null, + ) => void; handleMediaTitleUpdate?: (title: string) => void; recordPlaybackPosition?: (time: number) => void; + recordMediaDuration?: (durationSec: number) => void; recordPauseState?: (paused: boolean) => void; } | null; subtitleTimingTracker: { @@ -14,6 +22,7 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: { } | null; currentSubText: string; currentSubAssText: string; + currentSubtitleData?: SubtitleData | null; playbackPaused: boolean | null; previousSecondarySubVisibility: boolean | null; }; @@ -41,6 +50,7 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: { updateSubtitleRenderMetrics: (patch: Record) => void; refreshDiscordPresence: () => void; ensureImmersionTrackerInitialized: () => void; + tokenizeSubtitleForImmersion?: (text: string) => Promise; }) { return () => ({ reportJellyfinRemoteStopped: () => deps.reportJellyfinRemoteStopped(), @@ -53,7 +63,30 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: { quitApp: () => deps.quitApp(), recordImmersionSubtitleLine: (text: string, start: number, end: number) => { 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), recordSubtitleTiming: (text: string, start: number, end: number) => @@ -95,6 +128,10 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: { deps.ensureImmersionTrackerInitialized(); deps.appState.immersionTracker?.recordPlaybackPosition?.(time); }, + recordMediaDuration: (durationSec: number) => { + deps.ensureImmersionTrackerInitialized(); + deps.appState.immersionTracker?.recordMediaDuration?.(durationSec); + }, reportJellyfinRemoteProgress: (forceImmediate: boolean) => deps.reportJellyfinRemoteProgress(forceImmediate), recordPauseState: (paused: boolean) => { diff --git a/src/main/state.ts b/src/main/state.ts index 6dd67a7..c499bc6 100644 --- a/src/main/state.ts +++ b/src/main/state.ts @@ -196,6 +196,8 @@ export interface AppState { anilistSetupPageOpened: boolean; anilistRetryQueueState: AnilistRetryQueueState; firstRunSetupCompleted: boolean; + statsServer: { close: () => void } | null; + statsStartupInProgress: boolean; } export interface AppStateInitialValues { @@ -275,6 +277,8 @@ export function createAppState(values: AppStateInitialValues): AppState { anilistSetupPageOpened: false, anilistRetryQueueState: createInitialAnilistRetryQueueState(), firstRunSetupCompleted: false, + statsServer: null, + statsStartupInProgress: false, }; } diff --git a/src/renderer/handlers/keyboard.test.ts b/src/renderer/handlers/keyboard.test.ts index 755ddca..c2f921e 100644 --- a/src/renderer/handlers/keyboard.test.ts +++ b/src/renderer/handlers/keyboard.test.ts @@ -51,6 +51,8 @@ function installKeyboardTestGlobals() { const commandEvents: CommandEventDetail[] = []; const mpvCommands: Array> = []; let playbackPausedResponse: boolean | null = false; + let statsToggleKey = 'Backquote'; + let statsToggleOverlayCalls = 0; let selectionClearCount = 0; let selectionAddCount = 0; @@ -137,7 +139,11 @@ function installKeyboardTestGlobals() { mpvCommands.push(command); }, getPlaybackPaused: async () => playbackPausedResponse, + getStatsToggleKey: async () => statsToggleKey, toggleDevTools: () => {}, + toggleStatsOverlay: () => { + statsToggleOverlayCalls += 1; + }, focusMainWindow: () => { focusMainWindowCalls += 1; return Promise.resolve(); @@ -253,6 +259,10 @@ function installKeyboardTestGlobals() { setPopupVisible: (value: boolean) => { popupVisible = value; }, + setStatsToggleKey: (value: string) => { + statsToggleKey = value; + }, + statsToggleOverlayCalls: () => statsToggleOverlayCalls, getPlaybackPaused: async () => playbackPausedResponse, setPlaybackPausedResponse: (value: boolean | null) => { 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 () => { const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness(); diff --git a/src/renderer/handlers/keyboard.ts b/src/renderer/handlers/keyboard.ts index 413e9d2..ff085d5 100644 --- a/src/renderer/handlers/keyboard.ts +++ b/src/renderer/handlers/keyboard.ts @@ -181,6 +181,17 @@ export function createKeyboardHandlers( 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[] { return Array.from( ctx.dom.subtitleRoot.querySelectorAll('.word[data-token-index]'), @@ -693,7 +704,12 @@ export function createKeyboardHandlers( } async function setupMpvInputForwarding(): Promise { - updateKeybindings(await window.electronAPI.getKeybindings()); + const [keybindings, statsToggleKey] = await Promise.all([ + window.electronAPI.getKeybindings(), + window.electronAPI.getStatsToggleKey(), + ]); + updateKeybindings(keybindings); + ctx.state.statsToggleKey = statsToggleKey; syncKeyboardTokenSelection(); const subtitleMutationObserver = new MutationObserver(() => { @@ -789,6 +805,12 @@ export function createKeyboardHandlers( return; } + if (isStatsOverlayToggle(e)) { + e.preventDefault(); + window.electronAPI.toggleStatsOverlay(); + return; + } + if ( (ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document)) && !isControllerModalShortcut(e) diff --git a/src/renderer/state.ts b/src/renderer/state.ts index 60d9598..a9f9427 100644 --- a/src/renderer/state.ts +++ b/src/renderer/state.ts @@ -91,6 +91,7 @@ export type RendererState = { frequencyDictionaryBand5Color: string; keybindingsMap: Map; + statsToggleKey: string; chordPending: boolean; chordTimeout: ReturnType | null; keyboardDrivenModeEnabled: boolean; @@ -170,6 +171,7 @@ export function createRendererState(): RendererState { frequencyDictionaryBand5Color: '#8aadf4', keybindingsMap: new Map(), + statsToggleKey: 'Backquote', chordPending: false, chordTimeout: null, keyboardDrivenModeEnabled: false,