From a6d96e99663292c5a333358bf0ad84d61a99ea8c Mon Sep 17 00:00:00 2001 From: sudacode Date: Sun, 11 Oct 2026 00:35:27 -0700 Subject: [PATCH 1/4] fix(overlay): poll cheap playback state in the open subtitle sidebar (#290) --- changes/sidebar-snapshot-poll.md | 4 + docs/architecture/domains.md | 6 +- e2e/playback.e2e.test.ts | 28 ++++ src/core/services/ipc.test.ts | 2 + src/core/services/ipc.ts | 11 ++ src/main.ts | 120 ++++++---------- src/main/dependencies.ts | 2 + src/preload.ts | 2 + ...itle-sidebar-selection.electron-fixture.ts | 3 + src/renderer/modals/subtitle-sidebar.test.ts | 132 ++++++++++++++++-- src/renderer/modals/subtitle-sidebar.ts | 65 ++++++--- src/shared/ipc/contracts.ts | 1 + src/types/runtime.ts | 2 + src/types/subtitle.ts | 16 ++- 14 files changed, 286 insertions(+), 108 deletions(-) create mode 100644 changes/sidebar-snapshot-poll.md diff --git a/changes/sidebar-snapshot-poll.md b/changes/sidebar-snapshot-poll.md new file mode 100644 index 00000000..d3272ab8 --- /dev/null +++ b/changes/sidebar-snapshot-poll.md @@ -0,0 +1,4 @@ +type: fixed +area: overlay + +- The open subtitle sidebar no longer re-fetches the whole subtitle list and re-queries mpv about 12 times a second. It polls only the current time and line, and reloads the list when the subtitle track, file, or parsed cues change (plus a slow ~2 s fallback), cutting idle sidebar IPC and renderer work during playback. diff --git a/docs/architecture/domains.md b/docs/architecture/domains.md index 346ecb0c..343343b9 100644 --- a/docs/architecture/domains.md +++ b/docs/architecture/domains.md @@ -63,7 +63,11 @@ the plugin artifact, persistent config, or session help. The subtitle sidebar consumes parsed cues through `SubtitleSidebarSnapshot`. Its `sourceKey` identifies the media and subtitle source so renderer selections are invalidated on source changes, -including changes whose cue text and timings are identical. Native selection and clean clipboard +including changes whose cue text and timings are identical. While open, the sidebar polls the +cheap `SubtitleSidebarPlaybackState` (cached time, current line, `sourceKey`, `cuesRevision`; no +mpv round-trips) every 80 ms for active-cue tracking. It fetches the full snapshot, which resolves +the source over mpv IPC and carries every cue plus config, only on open, config reload, a +`sourceKey`/`cuesRevision` change, and a ~2 s fallback. Native selection and clean clipboard serialization live in `src/renderer/modals/subtitle-sidebar-selection.ts`. Electron lets standard Copy input reach the renderer, where sidebar selection takes priority over the live-subtitle binding. The preload bridge writes selections through Electron's clipboard API so copying does not depend diff --git a/e2e/playback.e2e.test.ts b/e2e/playback.e2e.test.ts index fb8b88b4..547b3bff 100644 --- a/e2e/playback.e2e.test.ts +++ b/e2e/playback.e2e.test.ts @@ -156,6 +156,34 @@ test( }, ); +test( + 'open subtitle sidebar lists the parsed cues and follows playback', + { timeout: TEST_TIMEOUT_MS }, + async () => { + const { session, overlay } = await useSession(); + await showCue(FIXTURE_CUES[0]); + await session.runAppCommand('--toggle-subtitle-sidebar'); + await overlay.waitFor( + `document.querySelectorAll('#subtitleSidebarList .subtitle-sidebar-item').length === ${FIXTURE_CUES.length}`, + { description: 'sidebar to list every fixture cue' }, + ); + + for (const [index, cue] of FIXTURE_CUES.entries()) { + await session.mpv.command('seek', cue.at, 'absolute+exact'); + await overlay.waitFor( + `document.querySelector('#subtitleSidebarList .subtitle-sidebar-item.active')?.dataset.index === '${index}'`, + { description: `sidebar to highlight cue ${index}` }, + ); + } + + await session.runAppCommand('--toggle-subtitle-sidebar'); + await overlay.waitFor( + `document.getElementById('subtitleSidebarModal').classList.contains('hidden')`, + { description: 'sidebar to close' }, + ); + }, +); + // Runs last: it leaves the lookup popup open over the overlay. test( 'holding Shift over a word opens the Yomitan popup with its dictionary entry', diff --git a/src/core/services/ipc.test.ts b/src/core/services/ipc.test.ts index db262953..c6e6e118 100644 --- a/src/core/services/ipc.test.ts +++ b/src/core/services/ipc.test.ts @@ -86,7 +86,9 @@ function createControllerConfigFixture() { function createSubtitleSidebarSnapshotFixture(): SubtitleSidebarSnapshot { return { sourceKey: 'test-subtitles', + cuesRevision: 1, cues: [], + currentTimeSec: null, currentSubtitle: { text: '', startTime: null, endTime: null }, config: { enabled: false, diff --git a/src/core/services/ipc.ts b/src/core/services/ipc.ts index 3bf12a2e..113ee6dc 100644 --- a/src/core/services/ipc.ts +++ b/src/core/services/ipc.ts @@ -12,6 +12,7 @@ import type { RuntimeOptionId, RuntimeOptionValue, SubtitleMiningContext, + SubtitleSidebarPlaybackState, SubtitleSidebarSnapshot, SubtitlePosition, SubsyncManualRunRequest, @@ -79,6 +80,7 @@ export interface IpcServiceDeps { getCurrentSubtitleRaw: () => string; getCurrentSubtitleAss: () => string; getSubtitleSidebarSnapshot?: () => Promise; + getSubtitleSidebarPlaybackState?: () => SubtitleSidebarPlaybackState; getSubtitleSidebarOpen?: () => boolean; getPlaybackPaused: () => boolean | null | Promise; getSubtitlePosition: () => unknown; @@ -369,6 +371,7 @@ export interface IpcDepsRuntimeOptions { getCurrentSubtitleRaw: () => string; getCurrentSubtitleAss: () => string; getSubtitleSidebarSnapshot?: () => Promise; + getSubtitleSidebarPlaybackState?: () => SubtitleSidebarPlaybackState; getSubtitleSidebarOpen?: () => boolean; getPlaybackPaused: () => boolean | null | Promise; getSubtitlePosition: () => unknown; @@ -455,6 +458,7 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService getCurrentSubtitleRaw: options.getCurrentSubtitleRaw, getCurrentSubtitleAss: options.getCurrentSubtitleAss, getSubtitleSidebarSnapshot: options.getSubtitleSidebarSnapshot, + getSubtitleSidebarPlaybackState: options.getSubtitleSidebarPlaybackState, getSubtitleSidebarOpen: options.getSubtitleSidebarOpen ?? (() => false), getPlaybackPaused: options.getPlaybackPaused, getSubtitlePosition: options.getSubtitlePosition, @@ -742,6 +746,13 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar return await deps.getSubtitleSidebarSnapshot(); }); + ipc.handle(IPC_CHANNELS.request.getSubtitleSidebarPlaybackState, () => { + if (!deps.getSubtitleSidebarPlaybackState) { + throw new Error('Subtitle sidebar playback state is unavailable.'); + } + return deps.getSubtitleSidebarPlaybackState(); + }); + ipc.handle(IPC_CHANNELS.request.getSubtitleSidebarOpen, () => { return deps.getSubtitleSidebarOpen?.() ?? false; }); diff --git a/src/main.ts b/src/main.ts index e205e090..33097905 100644 --- a/src/main.ts +++ b/src/main.ts @@ -92,8 +92,10 @@ import type { SessionActionDispatchRequest, SecondarySubMode, SubtitleData, + SubtitleCue, SubtitleMiningContext, SubtitlePosition, + SubtitleSidebarPlaybackState, WindowGeometry, } from './types'; import { OPEN_ANKI_CARD_ACTION_ID } from './types'; @@ -2047,6 +2049,33 @@ const linuxOverlayModeRuntime = createLinuxOverlayModeRuntime({ logDebug: (message) => logger.debug(message), }); let subtitleSidebarRequestedOpen = false; +let subtitleSidebarCuesRef: SubtitleCue[] | null = null; +let subtitleSidebarCuesRevision = 0; + +/** + * Cheap sidebar state polled ~12x/s while the sidebar is open. Reads only cached state, no + * mpv round-trips. `cuesRevision` bumps whenever a new parsed cue array is swapped in, so a + * re-parse of the same source still tells the renderer to fetch the full snapshot. + */ +function getSubtitleSidebarPlaybackState(): SubtitleSidebarPlaybackState { + if (appState.activeParsedSubtitleCues !== subtitleSidebarCuesRef) { + subtitleSidebarCuesRef = appState.activeParsedSubtitleCues; + subtitleSidebarCuesRevision += 1; + } + return { + sourceKey: JSON.stringify([ + appState.activeParsedSubtitleMediaPath, + appState.activeParsedSubtitleSource, + ]), + cuesRevision: subtitleSidebarCuesRevision, + currentTimeSec: appState.mpvClient?.currentTimePos ?? null, + currentSubtitle: { + text: appState.currentSubText, + startTime: appState.mpvClient?.currentSubStart ?? null, + endTime: appState.mpvClient?.currentSubEnd ?? null, + }, + }; +} const SEEK_THRESHOLD_SECONDS = 3; const EXPLICIT_SEEK_INTENT_TTL_MS = 2000; let explicitSeekIntentExpiresAtMs = 0; @@ -5850,26 +5879,19 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({ getCurrentSubtitleRaw: () => appState.currentSubText, getCurrentSubtitleAss: () => appState.currentSubAssText, getSubtitleSidebarOpen: () => subtitleSidebarRequestedOpen, + getSubtitleSidebarPlaybackState: () => getSubtitleSidebarPlaybackState(), + // Full snapshot: resolves the active source over mpv IPC and re-parses it when it changed. + // The renderer fetches it on open, on source/cue changes, and on a slow fallback cadence. getSubtitleSidebarSnapshot: async () => { - const currentSubtitle = { - text: appState.currentSubText, - startTime: appState.mpvClient?.currentSubStart ?? null, - endTime: appState.mpvClient?.currentSubEnd ?? null, - }; - const currentTimeSec = appState.mpvClient?.currentTimePos ?? null; const config = configService.getConfig().subtitleSidebar; + const cachedSnapshot = () => ({ + ...getSubtitleSidebarPlaybackState(), + cues: appState.activeParsedSubtitleCues, + config, + }); const client = appState.mpvClient; if (!client?.connected) { - return { - sourceKey: JSON.stringify([ - appState.activeParsedSubtitleMediaPath, - appState.activeParsedSubtitleSource, - ]), - cues: appState.activeParsedSubtitleCues, - currentTimeSec, - currentSubtitle, - config, - }; + return cachedSnapshot(); } try { @@ -5883,16 +5905,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({ ]); const videoPath = typeof videoPathRaw === 'string' ? videoPathRaw : ''; if (!videoPath) { - return { - sourceKey: JSON.stringify([ - appState.activeParsedSubtitleMediaPath, - appState.activeParsedSubtitleSource, - ]), - cues: appState.activeParsedSubtitleCues, - currentTimeSec, - currentSubtitle, - config, - }; + return cachedSnapshot(); } if ( shouldUseCachedYoutubeParsedCues({ @@ -5901,16 +5914,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({ cachedCueCount: appState.activeParsedSubtitleCues.length, }) ) { - return { - sourceKey: JSON.stringify([ - appState.activeParsedSubtitleMediaPath, - appState.activeParsedSubtitleSource, - ]), - cues: appState.activeParsedSubtitleCues, - currentTimeSec, - currentSubtitle, - config, - }; + return cachedSnapshot(); } const resolvedSource = await resolveActiveSubtitleSidebarSourceHandler({ @@ -5921,30 +5925,12 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({ videoPath, }); if (!resolvedSource) { - return { - sourceKey: JSON.stringify([ - appState.activeParsedSubtitleMediaPath, - appState.activeParsedSubtitleSource, - ]), - cues: appState.activeParsedSubtitleCues, - currentTimeSec, - currentSubtitle, - config, - }; + return cachedSnapshot(); } try { if (appState.activeParsedSubtitleSource === resolvedSource.sourceKey) { - return { - sourceKey: JSON.stringify([ - appState.activeParsedSubtitleMediaPath, - appState.activeParsedSubtitleSource, - ]), - cues: appState.activeParsedSubtitleCues, - currentTimeSec, - currentSubtitle, - config, - }; + return cachedSnapshot(); } const content = await loadSubtitleSourceText(resolvedSource.path); @@ -5952,30 +5938,12 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({ appState.activeParsedSubtitleCues = cues; appState.activeParsedSubtitleSource = resolvedSource.sourceKey; appState.activeParsedSubtitleMediaPath = videoPath || null; - return { - sourceKey: JSON.stringify([ - appState.activeParsedSubtitleMediaPath, - appState.activeParsedSubtitleSource, - ]), - cues, - currentTimeSec, - currentSubtitle, - config, - }; + return cachedSnapshot(); } finally { await resolvedSource.cleanup?.(); } } catch { - return { - sourceKey: JSON.stringify([ - appState.activeParsedSubtitleMediaPath, - appState.activeParsedSubtitleSource, - ]), - cues: appState.activeParsedSubtitleCues, - currentTimeSec, - currentSubtitle, - config, - }; + return cachedSnapshot(); } }, getPlaybackPaused: () => diff --git a/src/main/dependencies.ts b/src/main/dependencies.ts index beadfcf9..da74e427 100644 --- a/src/main/dependencies.ts +++ b/src/main/dependencies.ts @@ -74,6 +74,7 @@ export interface MainIpcRuntimeServiceDepsParams { getCurrentSubtitleRaw: IpcDepsRuntimeOptions['getCurrentSubtitleRaw']; getCurrentSubtitleAss: IpcDepsRuntimeOptions['getCurrentSubtitleAss']; getSubtitleSidebarSnapshot?: IpcDepsRuntimeOptions['getSubtitleSidebarSnapshot']; + getSubtitleSidebarPlaybackState?: IpcDepsRuntimeOptions['getSubtitleSidebarPlaybackState']; getSubtitleSidebarOpen?: IpcDepsRuntimeOptions['getSubtitleSidebarOpen']; getPlaybackPaused: IpcDepsRuntimeOptions['getPlaybackPaused']; focusMainWindow?: IpcDepsRuntimeOptions['focusMainWindow']; @@ -276,6 +277,7 @@ export function createMainIpcRuntimeServiceDeps( getCurrentSubtitleRaw: params.getCurrentSubtitleRaw, getCurrentSubtitleAss: params.getCurrentSubtitleAss, getSubtitleSidebarSnapshot: params.getSubtitleSidebarSnapshot, + getSubtitleSidebarPlaybackState: params.getSubtitleSidebarPlaybackState, getSubtitleSidebarOpen: params.getSubtitleSidebarOpen, getPlaybackPaused: params.getPlaybackPaused, getSubtitlePosition: params.getSubtitlePosition, diff --git a/src/preload.ts b/src/preload.ts index f76ec273..dfcdae7f 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -256,6 +256,8 @@ const electronAPI: ElectronAPI = { ipcRenderer.invoke(IPC_CHANNELS.request.getSubtitleSidebarOpen), getSubtitleSidebarSnapshot: () => ipcRenderer.invoke(IPC_CHANNELS.request.getSubtitleSidebarSnapshot), + getSubtitleSidebarPlaybackState: () => + ipcRenderer.invoke(IPC_CHANNELS.request.getSubtitleSidebarPlaybackState), copySubtitleSidebarSelection: async (text: unknown) => { if (typeof text !== 'string') throw new TypeError('Subtitle selection must be text.'); clipboard.writeText(text); diff --git a/src/renderer/modals/subtitle-sidebar-selection.electron-fixture.ts b/src/renderer/modals/subtitle-sidebar-selection.electron-fixture.ts index e9d141ad..e920d73c 100644 --- a/src/renderer/modals/subtitle-sidebar-selection.electron-fixture.ts +++ b/src/renderer/modals/subtitle-sidebar-selection.electron-fixture.ts @@ -15,6 +15,7 @@ export async function setup() { const commands: unknown[] = []; const snapshot: SubtitleSidebarSnapshot = { sourceKey: 'episode-1:track-1', + cuesRevision: 1, cues: [ { text: '最初の台詞', startTime: 0, endTime: 1 }, { text: '同じ台詞\n二行目', startTime: 1, endTime: 2 }, @@ -39,6 +40,7 @@ export async function setup() { Object.defineProperty(window, 'electronAPI', { value: { getSubtitleSidebarSnapshot: async () => snapshot, + getSubtitleSidebarPlaybackState: async () => snapshot, getSessionBindings: async () => [ { sourcePath: 'keybindings[0].key', @@ -64,6 +66,7 @@ export async function setup() { } satisfies Pick< ElectronAPI, | 'getSubtitleSidebarSnapshot' + | 'getSubtitleSidebarPlaybackState' | 'getSessionBindings' | 'getConfiguredShortcuts' | 'getStatsToggleKey' diff --git a/src/renderer/modals/subtitle-sidebar.test.ts b/src/renderer/modals/subtitle-sidebar.test.ts index a0295ca3..ec5ecb70 100644 --- a/src/renderer/modals/subtitle-sidebar.test.ts +++ b/src/renderer/modals/subtitle-sidebar.test.ts @@ -1,7 +1,11 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import type { ElectronAPI, SubtitleSidebarSnapshot } from '../../types'; +import type { + ElectronAPI, + SubtitleSidebarPlaybackState, + SubtitleSidebarSnapshot, +} from '../../types'; import { createRendererState } from '../state.js'; import { applySidebarCssDeclarations, @@ -68,7 +72,12 @@ function createCueRow() { function createListStub() { return { - innerHTML: '', + get innerHTML() { + return ''; + }, + set innerHTML(_html: string) { + this.children.length = 0; + }, children: [] as ReturnType[], appendChild(child: ReturnType) { child.offsetTop = this.children.length * child.clientHeight; @@ -180,17 +189,20 @@ test.afterEach(() => { /** * Builds a sidebar modal over fake window/document/dom globals. The snapshot served to the - * modal can be changed with `setSnapshot`. Globals are restored (and polling stopped) after - * each test. + * modal (and the playback state derived from it) can be changed with `setSnapshot`; `poll` + * runs the next scheduled playback tick. Globals are restored after each test. */ function createSidebarHarness(options: SidebarHarnessOptions = {}) { let snapshot: SubtitleSidebarSnapshot = { sourceKey: 'test-subtitles', + cuesRevision: 1, cues: options.cues ?? [{ startTime: 1, endTime: 2, text: 'first' }], currentSubtitle: options.currentSubtitle ?? { text: 'first', startTime: 1, endTime: 2 }, - currentTimeSec: 'currentTimeSec' in options ? options.currentTimeSec : 1.1, + currentTimeSec: 'currentTimeSec' in options ? (options.currentTimeSec ?? null) : 1.1, config: { ...DEFAULT_SIDEBAR_CONFIG, ...options.config }, }; + const requests = { snapshot: 0, playback: 0 }; + let nextPoll: (() => Promise) | null = null; const mpvCommands: MpvCommand[] = []; const ignoreMouseCalls: Array<[boolean, { forward?: boolean } | undefined]> = []; @@ -208,7 +220,15 @@ function createSidebarHarness(options: SidebarHarnessOptions = {}) { addEventListener: windowEvents.addEventListener, removeEventListener: windowEvents.removeEventListener, electronAPI: { - getSubtitleSidebarSnapshot: async () => snapshot, + getSubtitleSidebarSnapshot: async () => { + requests.snapshot += 1; + return snapshot; + }, + getSubtitleSidebarPlaybackState: async (): Promise => { + requests.playback += 1; + const { sourceKey, cuesRevision, currentTimeSec, currentSubtitle } = snapshot; + return { sourceKey, cuesRevision, currentTimeSec, currentSubtitle }; + }, getPlaybackPaused: async () => false, sendMpvCommand: (command: MpvCommand) => { mpvCommands.push(command); @@ -281,11 +301,16 @@ function createSidebarHarness(options: SidebarHarnessOptions = {}) { onVisibilityChanged: (visible) => { visibilityChanges.push(visible); }, + schedule: (callback) => { + nextPoll = callback; + return () => { + if (nextPoll === callback) nextPoll = null; + }; + }, ...options.modalOptions, }); harnessCleanups.push(() => { - // Closing stops the snapshot poll so it cannot fire against the next test's globals. modal.closeSubtitleSidebarModal(); modal.disposeDomEvents(); restoreGlobals(); @@ -307,6 +332,13 @@ function createSidebarHarness(options: SidebarHarnessOptions = {}) { windowEvents, contentEvents, modalEvents, + requests, + /** Runs the pending playback poll tick, if one is scheduled. */ + async poll() { + const tick = nextPoll; + nextPoll = null; + await tick?.(); + }, setSnapshot(patch: SnapshotPatch) { snapshot = { ...snapshot, ...patch, config: { ...snapshot.config, ...patch.config } }; }, @@ -675,7 +707,7 @@ test('subtitle sidebar jumps to first resolved active cue, then resumes smooth a currentSubtitle: { text: 'line-9', startTime: 18, endTime: 19.5 }, currentTimeSec: 18.1, }); - await h.modal.refreshSubtitleSidebarSnapshot(); + await h.poll(); assert.equal(h.state.subtitleSidebarActiveCueIndex, 9); assert.equal(h.cueList.scrollTop, 260); @@ -685,12 +717,94 @@ test('subtitle sidebar jumps to first resolved active cue, then resumes smooth a currentSubtitle: { text: 'line-10', startTime: 20, endTime: 21.5 }, currentTimeSec: 20.1, }); - await h.modal.refreshSubtitleSidebarSnapshot(); + await h.poll(); assert.equal(h.state.subtitleSidebarActiveCueIndex, 10); assert.deepEqual(h.cueList.scrollToCalls.at(-1), { top: 300, behavior: 'smooth' }); }); +test('open subtitle sidebar follows playback from cheap polls without refetching cues or re-measuring', async () => { + let measurements = 0; + const h = createSidebarHarness({ + cues: Array.from({ length: 4 }, (_, index) => ({ + startTime: index * 2, + endTime: index * 2 + 1.5, + text: `line-${index}`, + })), + currentSubtitle: { text: 'line-0', startTime: 0, endTime: 1.5 }, + currentTimeSec: 0.1, + config: { layout: 'embedded', maxWidth: 360 }, + contentWidth: () => { + measurements += 1; + return 360; + }, + }); + await h.modal.openSubtitleSidebarModal(); + const snapshotsAfterOpen = h.requests.snapshot; + const measurementsAfterOpen = measurements; + + // Inside the lookahead window the upcoming cue activates before mpv reports its text. + h.setSnapshot({ + currentSubtitle: { text: '', startTime: null, endTime: null }, + currentTimeSec: 1.9, + }); + await h.poll(); + assert.equal(h.state.subtitleSidebarActiveCueIndex, 1); + + h.setSnapshot({ + currentSubtitle: { text: 'line-2', startTime: 4, endTime: 5.5 }, + currentTimeSec: 4.1, + }); + await h.poll(); + assert.equal(h.state.subtitleSidebarActiveCueIndex, 2); + assert.equal(h.cueList.children[2]!.classList.contains('active'), true); + assert.equal(h.cueList.children[1]!.classList.contains('active'), false); + + assert.equal(h.requests.playback, 2); + assert.equal(h.requests.snapshot, snapshotsAfterOpen); + assert.equal(measurements, measurementsAfterOpen); +}); + +for (const change of [ + { name: 'subtitle source', patch: { sourceKey: 'other-subtitles' } }, + { name: 'parsed cue revision', patch: { cuesRevision: 2 } }, +]) { + test(`open subtitle sidebar refetches and re-renders cues when the ${change.name} changes`, async () => { + const h = createSidebarHarness(); + await h.modal.openSubtitleSidebarModal(); + const snapshotsAfterOpen = h.requests.snapshot; + + h.setSnapshot({ + ...change.patch, + cues: [ + { startTime: 1, endTime: 2, text: 'replacement' }, + { startTime: 3, endTime: 4, text: 'second' }, + ], + }); + await h.poll(); + + assert.equal(h.requests.snapshot, snapshotsAfterOpen + 1); + assert.deepEqual( + h.cueList.children.map((row) => (row.children[1] as { textContent: string }).textContent), + ['replacement', 'second'], + ); + }); +} + +test('open subtitle sidebar picks up changes main did not flag on a slow full-snapshot fallback', async () => { + const h = createSidebarHarness(); + await h.modal.openSubtitleSidebarModal(); + h.setSnapshot({ config: { enabled: false } }); + + for (let polls = 0; h.state.subtitleSidebarModalOpen && polls < 100; polls += 1) { + await h.poll(); + } + + assert.equal(h.state.subtitleSidebarModalOpen, false); + assert.equal(h.requests.snapshot, 2); + assert.ok(h.requests.playback > 1); +}); + test('subtitle sidebar closes and resumes a hover pause', async () => { const h = createSidebarHarness({ config: { pauseVideoOnHover: true } }); h.modal.wireDomEvents(); diff --git a/src/renderer/modals/subtitle-sidebar.ts b/src/renderer/modals/subtitle-sidebar.ts index 9eab6efe..a1ae391a 100644 --- a/src/renderer/modals/subtitle-sidebar.ts +++ b/src/renderer/modals/subtitle-sidebar.ts @@ -19,7 +19,11 @@ import { const MANUAL_SCROLL_HOLD_MS = 1500; const ACTIVE_CUE_LOOKAHEAD_SEC = 0.18; -const SNAPSHOT_POLL_INTERVAL_MS = 80; +// While open, the sidebar polls cheap cached playback state for active-cue tracking and only +// fetches the full snapshot (mpv source resolution + every cue + config) when the source or +// parsed cues changed, plus this fallback in case main missed a change. +const PLAYBACK_POLL_INTERVAL_MS = 80; +const FULL_SNAPSHOT_FALLBACK_POLLS = 25; const EMBEDDED_SIDEBAR_MIN_WIDTH_PX = 240; const EMBEDDED_SIDEBAR_MAX_RATIO = 0.45; const appliedSidebarCssKeys = new WeakMap>(); @@ -206,9 +210,18 @@ export function createSubtitleSidebarModal( modalStateReader: Pick; onVisibilityChanged?: (visible: boolean) => void; shouldRestoreOpenOnStartup?: () => Promise; + /** Schedules a poll tick and returns its cancel function. Defaults to `setTimeout`. */ + schedule?: (callback: () => Promise, delayMs: number) => () => void; }, ) { - let snapshotPollInterval: ReturnType | null = null; + const schedule = + options.schedule ?? + ((callback: () => Promise, delayMs: number) => { + const timer = setTimeout(callback, delayMs); + return () => clearTimeout(timer); + }); + let cancelPlaybackPoll: (() => void) | null = null; + let pollsSinceFullSnapshot = 0; let lastAppliedVideoMarginRatio: number | null = null; let subtitleSidebarHoverRequestId = 0; let disposeDomEvents: (() => void) | null = null; @@ -218,6 +231,7 @@ export function createSubtitleSidebarModal( let subtitleSidebarPauseHeldByYomitanPopup = false; let lastSubtitleSidebarLookupCueIndex = -1; let subtitleSourceKey: string | null = null; + let subtitleCuesRevision: number | null = null; function restoreEmbeddedSidebarPassthrough(): void { syncOverlayMouseIgnoreState(ctx); @@ -576,6 +590,8 @@ export function createSubtitleSidebarModal( async function refreshSnapshot(): Promise { const snapshot = await window.electronAPI.getSubtitleSidebarSnapshot(); + pollsSinceFullSnapshot = 0; + subtitleCuesRevision = snapshot.cuesRevision; if (snapshot.sourceKey !== subtitleSourceKey) { clearSubtitleSidebarSelection(ctx.dom.subtitleSidebarList); lastSubtitleSidebarLookupCueIndex = -1; @@ -590,8 +606,8 @@ export function createSubtitleSidebarModal( ctx.state.subtitleSidebarModalOpen = false; ctx.dom.subtitleSidebarModal.classList.add('hidden'); ctx.dom.subtitleSidebarModal.setAttribute('aria-hidden', 'true'); - stopSnapshotPolling(); - updateActiveCue(null, snapshot.currentTimeSec ?? null); + stopPlaybackPolling(); + updateActiveCue(null, snapshot.currentTimeSec); setStatus('Subtitle sidebar disabled in config.'); syncEmbeddedSidebarLayout(); if (!ctx.state.isOverSubtitle && !options.modalStateReader.isAnyModalOpen()) { @@ -609,38 +625,49 @@ export function createSubtitleSidebarModal( renderCueList(); } } - updateActiveCue(snapshot.currentSubtitle, snapshot.currentTimeSec ?? null); + updateActiveCue(snapshot.currentSubtitle, snapshot.currentTimeSec); syncEmbeddedSidebarLayout(); return snapshot; } - function startSnapshotPolling(): void { - stopSnapshotPolling(); + async function pollPlaybackOnce(): Promise { + pollsSinceFullSnapshot += 1; + const playback = await window.electronAPI.getSubtitleSidebarPlaybackState(); + if ( + playback.sourceKey !== subtitleSourceKey || + playback.cuesRevision !== subtitleCuesRevision || + pollsSinceFullSnapshot >= FULL_SNAPSHOT_FALLBACK_POLLS + ) { + await refreshSnapshot(); + return; + } + updateActiveCue(playback.currentSubtitle, playback.currentTimeSec); + } + + function startPlaybackPolling(): void { + stopPlaybackPolling(); const pollOnce = async (): Promise => { try { - await refreshSnapshot(); + await pollPlaybackOnce(); } catch { // Keep polling; a transient IPC failure should not stop updates. } if (!ctx.state.subtitleSidebarModalOpen) { - snapshotPollInterval = null; + cancelPlaybackPoll = null; return; } - snapshotPollInterval = setTimeout(pollOnce, SNAPSHOT_POLL_INTERVAL_MS); + cancelPlaybackPoll = schedule(pollOnce, PLAYBACK_POLL_INTERVAL_MS); }; - snapshotPollInterval = setTimeout(pollOnce, SNAPSHOT_POLL_INTERVAL_MS); + cancelPlaybackPoll = schedule(pollOnce, PLAYBACK_POLL_INTERVAL_MS); } - function stopSnapshotPolling(): void { - if (!snapshotPollInterval) { - return; - } - clearTimeout(snapshotPollInterval); - snapshotPollInterval = null; + function stopPlaybackPolling(): void { + cancelPlaybackPoll?.(); + cancelPlaybackPoll = null; } async function openSubtitleSidebarModal(): Promise { @@ -664,7 +691,7 @@ export function createSubtitleSidebarModal( renderCueList(); syncActiveCueClasses(-1); maybeAutoScrollActiveCue(-1, 'auto', true); - startSnapshotPolling(); + startPlaybackPolling(); syncEmbeddedSidebarLayout(); restoreEmbeddedSidebarPassthrough(); window.electronAPI.notifyOverlayModalOpened?.('subtitle-sidebar'); @@ -694,7 +721,7 @@ export function createSubtitleSidebarModal( ctx.state.subtitleSidebarModalOpen = false; ctx.dom.subtitleSidebarModal.classList.add('hidden'); ctx.dom.subtitleSidebarModal.setAttribute('aria-hidden', 'true'); - stopSnapshotPolling(); + stopPlaybackPolling(); syncEmbeddedSidebarLayout(); if (!ctx.state.isOverSubtitle && !options.modalStateReader.isAnyModalOpen()) { ctx.dom.overlay.classList.remove('interactive'); diff --git a/src/shared/ipc/contracts.ts b/src/shared/ipc/contracts.ts index 00528f8a..9afd5831 100644 --- a/src/shared/ipc/contracts.ts +++ b/src/shared/ipc/contracts.ts @@ -68,6 +68,7 @@ export const IPC_CHANNELS = { getCurrentSubtitleRaw: 'get-current-subtitle-raw', getCurrentSubtitleAss: 'get-current-subtitle-ass', getSubtitleSidebarSnapshot: 'get-subtitle-sidebar-snapshot', + getSubtitleSidebarPlaybackState: 'get-subtitle-sidebar-playback-state', getSubtitleSidebarOpen: 'get-subtitle-sidebar-open', getPlaybackPaused: 'get-playback-paused', getSubtitlePosition: 'get-subtitle-position', diff --git a/src/types/runtime.ts b/src/types/runtime.ts index 41f9598a..6aea5f20 100644 --- a/src/types/runtime.ts +++ b/src/types/runtime.ts @@ -55,6 +55,7 @@ import type { SubtitleData, SubtitleMiningContext, SubtitlePosition, + SubtitleSidebarPlaybackState, SubtitleSidebarSnapshot, SubtitleRendererStyleConfig, SubtitleStyleConfig, @@ -477,6 +478,7 @@ export interface ElectronAPI { getCurrentSubtitleRaw: () => Promise; getCurrentSubtitleAss: () => Promise; getSubtitleSidebarSnapshot: () => Promise; + getSubtitleSidebarPlaybackState: () => Promise; copySubtitleSidebarSelection: (text: string) => Promise; getSubtitleSidebarOpen: () => Promise; getPlaybackPaused: () => Promise; diff --git a/src/types/subtitle.ts b/src/types/subtitle.ts index 64b0226d..0785a77a 100644 --- a/src/types/subtitle.ts +++ b/src/types/subtitle.ts @@ -226,15 +226,25 @@ export interface SubtitleData { endTime?: number | null; } -export interface SubtitleSidebarSnapshot { +/** + * Per-tick sidebar state, served from main's cached state without mpv round-trips. When + * `sourceKey` or `cuesRevision` differs from the last full snapshot's, the renderer fetches a + * new `SubtitleSidebarSnapshot`. + */ +export interface SubtitleSidebarPlaybackState { sourceKey: string | null; - cues: SubtitleCue[]; - currentTimeSec?: number | null; + /** Bumps whenever main swaps in a different parsed cue array. */ + cuesRevision: number; + currentTimeSec: number | null; currentSubtitle: { text: string; startTime: number | null; endTime: number | null; }; +} + +export interface SubtitleSidebarSnapshot extends SubtitleSidebarPlaybackState { + cues: SubtitleCue[]; config: SubtitleSidebarSnapshotConfig; } From ea2b3a9507ff03177979564efc95acf6ec4aa8c5 Mon Sep 17 00:00:00 2001 From: sudacode Date: Sun, 11 Oct 2026 01:01:36 -0700 Subject: [PATCH 2/4] fix(overlay): stop Hyprland/Sway tracker polls from blocking the main thread (#289) --- changes/tracker-async-poll.md | 5 + src/window-trackers/hyprland-tracker.test.ts | 120 +++++++++++++++++-- src/window-trackers/hyprland-tracker.ts | 118 +++++++++++------- src/window-trackers/linux-poll.ts | 113 +++++++++++++++++ src/window-trackers/sway-tracker.test.ts | 23 ++++ src/window-trackers/sway-tracker.ts | 93 ++++++++------ 6 files changed, 378 insertions(+), 94 deletions(-) create mode 100644 changes/tracker-async-poll.md create mode 100644 src/window-trackers/linux-poll.ts create mode 100644 src/window-trackers/sway-tracker.test.ts diff --git a/changes/tracker-async-poll.md b/changes/tracker-async-poll.md new file mode 100644 index 00000000..21a116de --- /dev/null +++ b/changes/tracker-async-poll.md @@ -0,0 +1,5 @@ +type: fixed +area: overlay + +- Hyprland and Sway window tracking no longer freezes the app when `hyprctl` or `swaymsg` is slow or hangs: polls now run in the background with a timeout and no longer pile up. +- The Hyprland/Sway trackers read each mpv process's command line once instead of spawning `ps` on every poll. diff --git a/src/window-trackers/hyprland-tracker.test.ts b/src/window-trackers/hyprland-tracker.test.ts index aaeace24..0683b9f6 100644 --- a/src/window-trackers/hyprland-tracker.test.ts +++ b/src/window-trackers/hyprland-tracker.test.ts @@ -179,21 +179,119 @@ test('resolveHyprlandWindowGeometry uses monitor bounds for client-requested ful }); }); -test('HyprlandWindowTracker re-emits focus callback on active window events for z-order refresh', () => { +type PendingCommand = { + command: string; + args: string[]; + resolve: (output: string) => void; +}; + +// Runner whose commands stay pending until the test resolves them. +function makeDeferredRunner() { + const pending: PendingCommand[] = []; + const runCommand = (command: string, args: string[]) => + new Promise((resolve) => { + pending.push({ command, args, resolve }); + }); + return { pending, runCommand }; +} + +function flush(): Promise { + return new Promise((resolve) => setImmediate(resolve)); +} + +test('HyprlandWindowTracker re-emits focus callback on active window events for z-order refresh', async () => { const calls: string[] = []; - const tracker = new HyprlandWindowTracker(); - const privateTracker = tracker as unknown as { - handleSocketEvent: (event: string) => void; - pollGeometry: () => void; - }; - privateTracker.pollGeometry = () => { - calls.push('poll'); - }; + const tracker = new HyprlandWindowTracker(undefined, async (command, args) => { + calls.push(`${command} ${args.join(' ')}`); + return '[]'; + }); tracker.onWindowFocusChange = (focused) => { calls.push(`focus:${focused}`); }; - privateTracker.handleSocketEvent('activewindowv2>>0xmpv'); + (tracker as unknown as { handleSocketEvent: (event: string) => void }).handleSocketEvent( + 'activewindowv2>>0xmpv', + ); + await flush(); - assert.deepEqual(calls, ['poll', 'focus:false']); + assert.deepEqual(calls, ['hyprctl -j clients', 'focus:false']); +}); + +test('HyprlandWindowTracker coalesces polls requested while hyprctl is still running', async () => { + const { pending, runCommand } = makeDeferredRunner(); + const tracker = new HyprlandWindowTracker(undefined, runCommand); + + const polls = [tracker.refreshNow(), tracker.refreshNow(), tracker.refreshNow()]; + await flush(); + assert.equal(pending.length, 1); + + pending[0]!.resolve(JSON.stringify([makeClient({ at: [10, 20] })])); + await flush(); + assert.equal(pending.length, 2); + + pending[1]!.resolve(JSON.stringify([makeClient({ at: [30, 40] })])); + await Promise.all(polls); + assert.equal(pending.length, 2); + assert.deepEqual(tracker.getGeometry(), { x: 30, y: 40, width: 1280, height: 720 }); +}); + +test('HyprlandWindowTracker ignores a poll that resolves after stop', async () => { + const { pending, runCommand } = makeDeferredRunner(); + const tracker = new HyprlandWindowTracker(undefined, runCommand); + let found = 0; + tracker.onWindowFound = () => { + found += 1; + }; + + const poll = tracker.refreshNow(); + tracker.stop(); + await flush(); + pending[0]!.resolve(JSON.stringify([makeClient()])); + await poll; + + assert.equal(found, 0); + assert.equal(tracker.getGeometry(), null); +}); + +test('HyprlandWindowTracker reads each mpv command line once and re-reads after the pid leaves', async () => { + let clients = [makeClient({ pid: 10 })]; + const psPids: string[] = []; + const tracker = new HyprlandWindowTracker('/tmp/subminer.sock', async (command, args) => { + if (command === 'hyprctl') { + return JSON.stringify(clients); + } + psPids.push(args[1]!); + return 'mpv --input-ipc-server=/tmp/subminer.sock video.mkv\n'; + }); + + await tracker.refreshNow(); + await tracker.refreshNow(); + assert.equal(tracker.isTracking(), true); + assert.deepEqual(psPids, ['10']); + + clients = []; + await tracker.refreshNow(); + clients = [makeClient({ pid: 10 })]; + await tracker.refreshNow(); + assert.deepEqual(psPids, ['10', '10']); +}); + +test('HyprlandWindowTracker still tracks mpv when ps fails for another pid, and retries that pid', async () => { + const psPids: string[] = []; + const tracker = new HyprlandWindowTracker('/tmp/subminer.sock', async (command, args) => { + if (command === 'hyprctl') { + return JSON.stringify([makeClient({ pid: 10 }), makeClient({ pid: 20 })]); + } + psPids.push(args[1]!); + if (args[1] === '20') { + throw new Error('ps exited 1'); + } + return 'mpv --input-ipc-server=/tmp/subminer.sock video.mkv\n'; + }); + + await tracker.refreshNow(); + assert.equal(tracker.isTracking(), true); + + await tracker.refreshNow(); + assert.deepEqual(psPids.sort(), ['10', '20', '20']); }); diff --git a/src/window-trackers/hyprland-tracker.ts b/src/window-trackers/hyprland-tracker.ts index 664db2fc..cad70d52 100644 --- a/src/window-trackers/hyprland-tracker.ts +++ b/src/window-trackers/hyprland-tracker.ts @@ -17,8 +17,13 @@ */ import * as net from 'net'; -import { execSync } from 'child_process'; import { BaseWindowTracker } from './base-tracker'; +import { + createCoalescedTask, + execFileWithTimeout, + ProcessCommandLineCache, + type CommandRunner, +} from './linux-poll'; import { createLogger } from '../logger'; import type { WindowGeometry } from '../types'; @@ -98,16 +103,20 @@ function isMpvClassName(value: string | undefined): boolean { return value.trim().toLowerCase().includes('mpv'); } -export function selectHyprlandMpvWindow( - clients: HyprlandClient[], - options: SelectHyprlandMpvWindowOptions, -): HyprlandClient | null { - const visibleMpvWindows = clients.filter( +function filterVisibleMpvWindows(clients: HyprlandClient[]): HyprlandClient[] { + return clients.filter( (client) => (isMpvClassName(client.class) || isMpvClassName(client.initialClass)) && client.mapped !== false && client.hidden !== true, ); +} + +export function selectHyprlandMpvWindow( + clients: HyprlandClient[], + options: SelectHyprlandMpvWindowOptions, +): HyprlandClient | null { + const visibleMpvWindows = filterVisibleMpvWindows(clients); if (!options.targetMpvSocketPath) { return preferActiveHyprlandWindow(visibleMpvWindows, options.activeWindowAddress); @@ -221,20 +230,30 @@ export class HyprlandWindowTracker extends BaseWindowTracker { private pollTimeouts: Array> = []; private eventSocket: net.Socket | null = null; private readonly targetMpvSocketPath: string | null; + private readonly runCommand: CommandRunner; + private readonly commandLines: ProcessCommandLineCache; private activeWindowAddress: string | null = null; + private stopped = false; + // Interval ticks, focus events, and geometry bursts all funnel through here, + // so a slow hyprctl never has more than one poll in flight. + private readonly pollGeometry = createCoalescedTask(() => this.pollGeometryOnce()); - constructor(targetMpvSocketPath?: string) { + constructor(targetMpvSocketPath?: string, runCommand: CommandRunner = execFileWithTimeout) { super(); this.targetMpvSocketPath = targetMpvSocketPath?.trim() || null; + this.runCommand = runCommand; + this.commandLines = new ProcessCommandLineCache(runCommand); } start(): void { - this.pollInterval = setInterval(() => this.pollGeometry(), 250); - this.pollGeometry(); + this.stopped = false; + this.pollInterval = setInterval(() => void this.pollGeometry(), 250); + void this.pollGeometry(); this.connectEventSocket(); } stop(): void { + this.stopped = true; if (this.pollInterval) { clearInterval(this.pollInterval); this.pollInterval = null; @@ -249,6 +268,10 @@ export class HyprlandWindowTracker extends BaseWindowTracker { } } + override refreshNow(): Promise { + return this.pollGeometry(); + } + private connectEventSocket(): void { const hyprlandSig = process.env.HYPRLAND_INSTANCE_SIGNATURE; if (!hyprlandSig) { @@ -297,10 +320,12 @@ export class HyprlandWindowTracker extends BaseWindowTracker { if (name === 'activewindowv2') { const wasFocused = this.isTargetWindowFocused(); this.activeWindowAddress = data || null; - this.pollGeometry(); - if (this.isTargetWindowFocused() === wasFocused) { - this.onWindowFocusChange?.(this.isTargetWindowFocused()); - } + void this.pollGeometry().then(() => { + // Re-emit unchanged focus so the overlay still refreshes its z-order. + if (!this.stopped && this.isTargetWindowFocused() === wasFocused) { + this.onWindowFocusChange?.(this.isTargetWindowFocused()); + } + }); return; } @@ -320,7 +345,7 @@ export class HyprlandWindowTracker extends BaseWindowTracker { this.pollTimeouts = [0, 50, 150, 300].map((delayMs) => { const pollTimeout = setTimeout(() => { this.pollTimeouts = this.pollTimeouts.filter((timeout) => timeout !== pollTimeout); - this.pollGeometry(); + void this.pollGeometry(); }, delayMs); return pollTimeout; }); @@ -329,56 +354,59 @@ export class HyprlandWindowTracker extends BaseWindowTracker { } } - private pollGeometry(): void { - try { - const output = execSync('hyprctl -j clients', { encoding: 'utf-8' }); - const clients = parseHyprctlClients(output); - if (!clients) { - this.updateGeometry(null); - return; - } - const mpvWindow = this.findTargetWindow(clients); + private async pollGeometryOnce(): Promise { + if (this.stopped) { + return; + } + let mpvWindow: HyprlandClient | null; + let monitors: HyprlandMonitor[] | null = null; + try { + const clients = parseHyprctlClients(await this.runCommand('hyprctl', ['-j', 'clients'])); + mpvWindow = clients ? await this.findTargetWindow(clients) : null; if (mpvWindow) { - const focused = !this.activeWindowAddress || mpvWindow.address === this.activeWindowAddress; - this.updateGeometry( - resolveHyprlandWindowGeometry(mpvWindow, this.getHyprlandMonitors(mpvWindow)), - focused, - ); - this.updateTargetWindowFocused(focused); - } else { - this.updateGeometry(null); + monitors = await this.getHyprlandMonitors(mpvWindow); } - } catch (err) { - // hyprctl not available or failed - silent fail + } catch { + // hyprctl/ps not available, timed out, or failed - keep the last geometry + return; + } + if (this.stopped) { + return; + } + + if (mpvWindow) { + const focused = !this.activeWindowAddress || mpvWindow.address === this.activeWindowAddress; + this.updateGeometry(resolveHyprlandWindowGeometry(mpvWindow, monitors), focused); + this.updateTargetWindowFocused(focused); + } else { + this.updateGeometry(null); } } - private findTargetWindow(clients: HyprlandClient[]): HyprlandClient | null { + private async findTargetWindow(clients: HyprlandClient[]): Promise { + // Resolve command lines up front so window selection itself stays synchronous. + const commandLines = this.targetMpvSocketPath + ? await this.commandLines.lookup( + filterVisibleMpvWindows(clients).flatMap((client) => (client.pid ? [client.pid] : [])), + ) + : null; return selectHyprlandMpvWindow(clients, { targetMpvSocketPath: this.targetMpvSocketPath, activeWindowAddress: this.activeWindowAddress, - getWindowCommandLine: (pid) => this.getWindowCommandLine(pid), + getWindowCommandLine: (pid) => commandLines?.get(pid) ?? null, }); } - private getHyprlandMonitors(client: HyprlandClient): HyprlandMonitor[] | null { + private async getHyprlandMonitors(client: HyprlandClient): Promise { if (!isHyprlandFullscreenClient(client)) { return null; } try { - const output = execSync('hyprctl -j monitors', { encoding: 'utf-8' }); - return parseHyprctlMonitors(output); + return parseHyprctlMonitors(await this.runCommand('hyprctl', ['-j', 'monitors'])); } catch { return null; } } - - private getWindowCommandLine(pid: number): string | null { - const commandLine = execSync(`ps -p ${pid} -o args=`, { - encoding: 'utf-8', - }).trim(); - return commandLine || null; - } } diff --git a/src/window-trackers/linux-poll.ts b/src/window-trackers/linux-poll.ts new file mode 100644 index 00000000..c485f9d4 --- /dev/null +++ b/src/window-trackers/linux-poll.ts @@ -0,0 +1,113 @@ +/* + SubMiner - All-in-one sentence mining overlay + Copyright (C) 2024 sudacode + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +import { execFile } from 'child_process'; + +// Shared polling helpers for the Hyprland and Sway trackers, which poll a +// compositor CLI on a short interval from the Electron main thread. + +export type CommandRunner = (command: string, args: string[]) => Promise; + +const COMMAND_TIMEOUT_MS = 1500; + +// Runs a command off the main thread. The timeout (SIGKILL, so a wedged client +// can't ignore it) keeps a hung compositor from stalling the poll loop forever. +export function execFileWithTimeout(command: string, args: string[]): Promise { + return new Promise((resolve, reject) => { + execFile( + command, + args, + { encoding: 'utf-8', timeout: COMMAND_TIMEOUT_MS, killSignal: 'SIGKILL' }, + (error, stdout) => { + if (error) { + reject(error); + return; + } + resolve(stdout); + }, + ); + }); +} + +// Wraps an async task so overlapping calls coalesce: at most one run is in +// flight, and every call made during it shares a single follow-up run. The +// returned promise settles after a run that started no earlier than the call. +export function createCoalescedTask(task: () => Promise): () => Promise { + let active: Promise | null = null; + let queued: Promise | null = null; + + const run = (): Promise => { + const current: Promise = task().finally(() => { + if (active === current) { + active = null; + } + }); + active = current; + return current; + }; + + return () => { + if (queued) { + return queued; + } + if (!active) { + return run(); + } + const next = (): Promise => { + queued = null; + return run(); + }; + queued = active.then(next, next); + return queued; + }; +} + +// Caches process command lines by pid. A pid's argv doesn't change, so each +// pid costs one `ps` spawn. Pids missing from the latest lookup are evicted, +// which keeps the cache bounded and re-reads a pid if it gets reused. +export class ProcessCommandLineCache { + private readonly commandLines = new Map(); + + constructor(private readonly runCommand: CommandRunner) {} + + // A pid whose `ps` call fails (e.g. it exited mid-poll) is left out of the + // result instead of failing the lookup. Empty results aren't cached, so that + // pid is retried on the next poll. + async lookup(pids: readonly number[]): Promise> { + const wanted = new Set(pids); + for (const pid of this.commandLines.keys()) { + if (!wanted.has(pid)) { + this.commandLines.delete(pid); + } + } + + await Promise.all( + [...wanted] + .filter((pid) => !this.commandLines.has(pid)) + .map(async (pid) => { + const commandLine = ( + await this.runCommand('ps', ['-p', String(pid), '-o', 'args=']).catch(() => '') + ).trim(); + if (commandLine) { + this.commandLines.set(pid, commandLine); + } + }), + ); + return this.commandLines; + } +} diff --git a/src/window-trackers/sway-tracker.test.ts b/src/window-trackers/sway-tracker.test.ts new file mode 100644 index 00000000..5199c01d --- /dev/null +++ b/src/window-trackers/sway-tracker.test.ts @@ -0,0 +1,23 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { SwayWindowTracker } from './sway-tracker'; + +test('SwayWindowTracker tracks the mpv window bound to the target socket', async () => { + const tree = { + nodes: [ + { pid: 10, app_id: 'mpv', rect: { x: 0, y: 0, width: 800, height: 600 } }, + { pid: 20, app_id: 'mpv', rect: { x: 100, y: 50, width: 1280, height: 720 } }, + ], + }; + const commandLines = new Map([ + ['10', 'mpv --input-ipc-server=/tmp/other.sock a.mkv'], + ['20', 'mpv --input-ipc-server=/tmp/subminer.sock b.mkv'], + ]); + const tracker = new SwayWindowTracker('/tmp/subminer.sock', async (command, args) => + command === 'swaymsg' ? JSON.stringify(tree) : (commandLines.get(args[1]!) ?? ''), + ); + + await tracker.refreshNow(); + + assert.deepEqual(tracker.getGeometry(), { x: 100, y: 50, width: 1280, height: 720 }); +}); diff --git a/src/window-trackers/sway-tracker.ts b/src/window-trackers/sway-tracker.ts index c63e5f1f..890e9f67 100644 --- a/src/window-trackers/sway-tracker.ts +++ b/src/window-trackers/sway-tracker.ts @@ -16,8 +16,13 @@ along with this program. If not, see . */ -import { execSync } from 'child_process'; import { BaseWindowTracker } from './base-tracker'; +import { + createCoalescedTask, + execFileWithTimeout, + ProcessCommandLineCache, + type CommandRunner, +} from './linux-poll'; interface SwayRect { x: number; @@ -38,24 +43,36 @@ interface SwayNode { export class SwayWindowTracker extends BaseWindowTracker { private pollInterval: ReturnType | null = null; private readonly targetMpvSocketPath: string | null; + private readonly runCommand: CommandRunner; + private readonly commandLines: ProcessCommandLineCache; + private stopped = false; + private readonly pollGeometry = createCoalescedTask(() => this.pollGeometryOnce()); - constructor(targetMpvSocketPath?: string) { + constructor(targetMpvSocketPath?: string, runCommand: CommandRunner = execFileWithTimeout) { super(); this.targetMpvSocketPath = targetMpvSocketPath?.trim() || null; + this.runCommand = runCommand; + this.commandLines = new ProcessCommandLineCache(runCommand); } start(): void { - this.pollInterval = setInterval(() => this.pollGeometry(), 250); - this.pollGeometry(); + this.stopped = false; + this.pollInterval = setInterval(() => void this.pollGeometry(), 250); + void this.pollGeometry(); } stop(): void { + this.stopped = true; if (this.pollInterval) { clearInterval(this.pollInterval); this.pollInterval = null; } } + override refreshNow(): Promise { + return this.pollGeometry(); + } + private collectMpvWindows(node: SwayNode): SwayNode[] { const windows: SwayNode[] = []; if (node.app_id === 'mpv' || node.window_properties?.class === 'mpv') { @@ -77,56 +94,56 @@ export class SwayWindowTracker extends BaseWindowTracker { return windows; } - private findTargetSocketWindow(node: SwayNode): SwayNode | null { + private async findTargetSocketWindow(node: SwayNode): Promise { const windows = this.collectMpvWindows(node); if (!this.targetMpvSocketPath) { return windows[0] || null; } - return windows.find((candidate) => this.isWindowForTargetSocket(candidate)) || null; + const commandLines = await this.commandLines.lookup( + windows.flatMap((window) => (window.pid ? [window.pid] : [])), + ); + return ( + windows.find((candidate) => { + const commandLine = candidate.pid ? commandLines.get(candidate.pid) : undefined; + return commandLine !== undefined && this.matchesTargetSocket(commandLine); + }) || null + ); } - private isWindowForTargetSocket(node: SwayNode): boolean { - if (!node.pid) { - return false; - } - - const commandLine = this.getWindowCommandLine(node.pid); - if (!commandLine) { - return false; - } - + private matchesTargetSocket(commandLine: string): boolean { return ( commandLine.includes(`--input-ipc-server=${this.targetMpvSocketPath}`) || commandLine.includes(`--input-ipc-server ${this.targetMpvSocketPath}`) ); } - private getWindowCommandLine(pid: number): string | null { - const commandLine = execSync(`ps -p ${pid} -o args=`, { - encoding: 'utf-8', - }).trim(); - return commandLine || null; - } + private async pollGeometryOnce(): Promise { + if (this.stopped) { + return; + } - private pollGeometry(): void { + let mpvWindow: SwayNode | null; try { - const output = execSync('swaymsg -t get_tree', { encoding: 'utf-8' }); - const tree: SwayNode = JSON.parse(output); - const mpvWindow = this.findTargetSocketWindow(tree); + const tree = JSON.parse(await this.runCommand('swaymsg', ['-t', 'get_tree'])) as SwayNode; + mpvWindow = await this.findTargetSocketWindow(tree); + } catch { + // swaymsg/ps not available, timed out, or failed - keep the last geometry + return; + } + if (this.stopped) { + return; + } - if (mpvWindow && mpvWindow.rect) { - this.updateGeometry({ - x: mpvWindow.rect.x, - y: mpvWindow.rect.y, - width: mpvWindow.rect.width, - height: mpvWindow.rect.height, - }); - } else { - this.updateGeometry(null); - } - } catch (err) { - // swaymsg not available or failed - silent fail + if (mpvWindow && mpvWindow.rect) { + this.updateGeometry({ + x: mpvWindow.rect.x, + y: mpvWindow.rect.y, + width: mpvWindow.rect.width, + height: mpvWindow.rect.height, + }); + } else { + this.updateGeometry(null); } } } From 8fc20b2499ff17b6ba33cd528e317810de1815db Mon Sep 17 00:00:00 2001 From: sudacode Date: Mon, 12 Oct 2026 22:10:54 -0700 Subject: [PATCH 3/4] refactor: remove dead code and enable noUnusedLocals (#292) * refactor: remove dead code and enable noUnusedLocals - delete launcher/jimaku.ts and the launcher Args/config jimaku* fields that only fed it (the SUBMINER_JIMAKU_* env vars had no effect) - delete plugin/subminer/hover.lua; nothing has sent subminer-hover-token since Overlay 2.0 - drop never-read anilist.characterDictionary.refreshTtlHours and evictionPolicy (still hidden in the settings UI for old configs) - drop unused root deps: eslint, @fontsource-variable/geist{,-mono} (stats/ declares its own) - delete the committed draft renderer-performance plan doc - turn on noUnusedLocals for typecheck and remove what it surfaced, including Windows focus-handoff values computed but never consumed * docs(changes): add docs fragment and note retired hover messages --- bun.lock | 115 +--- changes/remove-dead-code-docs.md | 4 + changes/remove-dead-code.md | 5 + docs-site/development.md | 3 - .../2026-03-15-renderer-performance-design.md | 298 ---------- launcher/commands/command-modules.test.ts | 2 - launcher/commands/mpv-command.ts | 2 +- launcher/config-domain-parsers.test.ts | 9 - launcher/config/args-normalizer.ts | 15 - launcher/config/youtube-subgen-config.ts | 22 - launcher/jimaku.ts | 518 ------------------ launcher/smoke.e2e.test.ts | 1 - launcher/types.ts | 12 - package.json | 3 - plugin/subminer/bootstrap.lua | 3 - plugin/subminer/hover.lua | 430 --------------- plugin/subminer/lifecycle.lua | 15 - plugin/subminer/messages.lua | 7 - plugin/subminer/state.lua | 10 - scripts/test-plugin-lua-compat.lua | 1 - scripts/test-plugin-start-gate.lua | 1 - src/anki-integration.ts | 5 - src/anki-integration/runtime.ts | 14 +- .../definitions/defaults-integrations.ts | 2 - .../definitions/domain-registry.test.ts | 2 - src/config/resolve/integrations.ts | 43 -- src/config/resolve/jellyfin.test.ts | 8 +- src/config/template.ts | 7 - src/core/services/anilist/anilist-updater.ts | 14 - .../services/immersion-tracker-service.ts | 6 - .../immersion-tracker/__tests__/query.test.ts | 1 - .../immersion-tracker/query-library.ts | 1 - .../immersion-tracker/query-maintenance.ts | 1 - .../services/immersion-tracker/storage.ts | 2 +- src/core/services/overlay-visibility.ts | 30 - src/core/services/session-actions.ts | 1 - src/core/services/stats-sync/merge-rollups.ts | 2 +- src/core/services/subtitle-cue-parser.test.ts | 1 - src/core/services/tokenizer.ts | 8 - .../services/tokenizer/annotation-stage.ts | 17 - src/main.ts | 15 - src/main/character-dictionary-runtime.ts | 1 - src/main/overlay-visibility-runtime.ts | 6 - .../overlay-visibility-runtime-main-deps.ts | 4 - .../visible-overlay-interaction-runtime.ts | 17 - src/renderer/handlers/keyboard.test.ts | 2 +- src/renderer/modals/youtube-track-picker.ts | 6 - src/types/config.ts | 3 - src/types/integrations.ts | 3 - src/types/runtime.ts | 1 - src/window-trackers/win32.ts | 3 +- tsconfig.typecheck.json | 1 + 52 files changed, 20 insertions(+), 1683 deletions(-) create mode 100644 changes/remove-dead-code-docs.md create mode 100644 changes/remove-dead-code.md delete mode 100644 docs/architecture/2026-03-15-renderer-performance-design.md delete mode 100644 launcher/jimaku.ts delete mode 100644 plugin/subminer/hover.lua diff --git a/bun.lock b/bun.lock index b9768c5a..5ca649f2 100644 --- a/bun.lock +++ b/bun.lock @@ -5,8 +5,6 @@ "": { "name": "subminer", "dependencies": { - "@fontsource-variable/geist": "^5.2.8", - "@fontsource-variable/geist-mono": "^5.2.7", "@xhayper/discord-rpc": "^1.3.4", "axios": "^1.20.0", "commander": "^14.0.3", @@ -24,7 +22,6 @@ "electron": "43.7.7", "electron-builder": "26.16.1", "esbuild": "^0.25.12", - "eslint": "^10.8.0", "prettier": "^3.8.1", "typescript": "^5.9.3", "undici": "7.29.1", @@ -127,34 +124,6 @@ "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="], - "@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ=="], - - "@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="], - - "@eslint/config-array": ["@eslint/config-array@0.23.5", "", { "dependencies": { "@eslint/object-schema": "^3.0.5", "debug": "^4.3.1", "minimatch": "^10.2.4" } }, "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA=="], - - "@eslint/config-helpers": ["@eslint/config-helpers@0.7.0", "", { "dependencies": { "@eslint/core": "^1.2.1" } }, "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw=="], - - "@eslint/core": ["@eslint/core@1.2.1", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ=="], - - "@eslint/object-schema": ["@eslint/object-schema@3.0.5", "", {}, "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw=="], - - "@eslint/plugin-kit": ["@eslint/plugin-kit@0.7.2", "", { "dependencies": { "@eslint/core": "^1.2.1", "levn": "^0.4.1" } }, "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A=="], - - "@fontsource-variable/geist": ["@fontsource-variable/geist@5.2.8", "", {}, "sha512-cJ6m9e+8MQ5dCYJsLylfZrgBh6KkG4bOLckB35Tr9J/EqdkEM6QllH5PxqP1dhTvFup+HtMRPuz9xOjxXJggxw=="], - - "@fontsource-variable/geist-mono": ["@fontsource-variable/geist-mono@5.2.7", "", {}, "sha512-ZKlZ5sjtalb2TwXKs400mAGDlt/+2ENLNySPx0wTz3bP3mWARCsUW+rpxzZc7e05d2qGch70pItt3K4qttbIYA=="], - - "@humanfs/core": ["@humanfs/core@0.19.2", "", { "dependencies": { "@humanfs/types": "^0.15.0" } }, "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA=="], - - "@humanfs/node": ["@humanfs/node@0.16.8", "", { "dependencies": { "@humanfs/core": "^0.19.2", "@humanfs/types": "^0.15.0", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ=="], - - "@humanfs/types": ["@humanfs/types@0.15.0", "", {}, "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q=="], - - "@humanwhocodes/module-importer": ["@humanwhocodes/module-importer@1.0.1", "", {}, "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="], - - "@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="], - "@isaacs/fs-minipass": ["@isaacs/fs-minipass@4.0.1", "", { "dependencies": { "minipass": "^7.0.4" } }, "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w=="], "@libsql/darwin-arm64": ["@libsql/darwin-arm64@0.5.28", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Lc/b8JXO2W2+H+5UXfw7PCHZCim1jlrB0CmLPsjfVmihMluBpdYafFImhjAHxHlWGfuZ32WzjVPUap5fGmkthw=="], @@ -203,16 +172,10 @@ "@types/debug": ["@types/debug@4.1.13", "", { "dependencies": { "@types/ms": "*" } }, "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw=="], - "@types/esrecurse": ["@types/esrecurse@4.3.1", "", {}, "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw=="], - - "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], - "@types/fs-extra": ["@types/fs-extra@9.0.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA=="], "@types/http-cache-semantics": ["@types/http-cache-semantics@4.2.0", "", {}, "sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q=="], - "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], - "@types/keyv": ["@types/keyv@3.1.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg=="], "@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="], @@ -231,13 +194,9 @@ "abbrev": ["abbrev@4.0.0", "", {}, "sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA=="], - "acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], - - "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], - "agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="], - "ajv": ["ajv@6.14.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw=="], + "ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], @@ -317,8 +276,6 @@ "decompress-response": ["decompress-response@6.0.0", "", { "dependencies": { "mimic-response": "^3.1.0" } }, "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ=="], - "deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="], - "defer-to-connect": ["defer-to-connect@2.0.1", "", {}, "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg=="], "define-data-property": ["define-data-property@1.1.4", "", { "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", "gopd": "^1.0.1" } }, "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A=="], @@ -383,44 +340,16 @@ "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], - "eslint": ["eslint@10.8.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.5", "@eslint/config-helpers": "^0.7.0", "@eslint/core": "^1.2.1", "@eslint/plugin-kit": "^0.7.2", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^9.1.2", "eslint-visitor-keys": "^5.0.1", "espree": "^11.2.0", "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "minimatch": "^10.2.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-nuKKvN+oIBO0koN7Tm7dlkmnkc21mtt0QJLwAKzjLq14y6lRTdVG36MZHJ8eQHwdJMwZbQNMlPOYedMq/oVJvQ=="], - - "eslint-scope": ["eslint-scope@9.1.2", "", { "dependencies": { "@types/esrecurse": "^4.3.1", "@types/estree": "^1.0.8", "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ=="], - - "eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], - - "espree": ["espree@11.2.0", "", { "dependencies": { "acorn": "^8.16.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^5.0.1" } }, "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw=="], - - "esquery": ["esquery@1.7.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g=="], - - "esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="], - - "estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="], - - "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], - "exponential-backoff": ["exponential-backoff@3.1.3", "", {}, "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA=="], "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], - "fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="], - - "fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="], - "fast-uri": ["fast-uri@3.1.7", "", {}, "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg=="], "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], - "file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="], - "filelist": ["filelist@1.0.6", "", { "dependencies": { "minimatch": "^5.0.1" } }, "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA=="], - "find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="], - - "flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="], - - "flatted": ["flatted@3.4.2", "", {}, "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA=="], - "follow-redirects": ["follow-redirects@1.16.0", "", {}, "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw=="], "form-data": ["form-data@4.0.6", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.4", "mime-types": "^2.1.35" } }, "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ=="], @@ -441,8 +370,6 @@ "glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], - "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], - "global-agent": ["global-agent@3.0.0", "", { "dependencies": { "boolean": "^3.0.1", "es6-error": "^4.1.1", "matcher": "^3.0.0", "roarr": "^2.15.3", "semver": "^7.3.2", "serialize-error": "^7.0.1" } }, "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q=="], "globalthis": ["globalthis@1.0.4", "", { "dependencies": { "define-properties": "^1.2.1", "gopd": "^1.0.1" } }, "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ=="], @@ -475,20 +402,12 @@ "https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="], - "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], - - "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], - "inflight": ["inflight@1.0.6", "", { "dependencies": { "once": "^1.3.0", "wrappy": "1" } }, "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA=="], "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], - "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], - "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], - "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], - "isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="], "isbinaryfile": ["isbinaryfile@5.0.7", "", {}, "sha512-gnWD14Jh3FzS3CPhF0AxNOJ8CxqeblPTADzI38r0wt8ZyQl5edpy75myt08EG2oKvpyiqSqsx+Wkz9vtkbTqYQ=="], @@ -503,9 +422,7 @@ "json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="], - "json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], - - "json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="], + "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], "json-stringify-safe": ["json-stringify-safe@5.0.1", "", {}, "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA=="], @@ -521,12 +438,8 @@ "lazy-val": ["lazy-val@1.0.5", "", {}, "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q=="], - "levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], - "libsql": ["libsql@0.5.28", "", { "dependencies": { "@neon-rs/load": "^0.0.4", "detect-libc": "2.0.2" }, "optionalDependencies": { "@libsql/darwin-arm64": "0.5.28", "@libsql/darwin-x64": "0.5.28", "@libsql/linux-arm-gnueabihf": "0.5.28", "@libsql/linux-arm-musleabihf": "0.5.28", "@libsql/linux-arm64-gnu": "0.5.28", "@libsql/linux-arm64-musl": "0.5.28", "@libsql/linux-x64-gnu": "0.5.28", "@libsql/linux-x64-musl": "0.5.28", "@libsql/win32-x64-msvc": "0.5.28" }, "os": [ "linux", "win32", "darwin", ], "cpu": [ "arm", "x64", "arm64", ] }, "sha512-wKqx9FgtPcKHdPfR/Kfm0gejsnbuf8zV+ESPmltFvsq5uXwdeN9fsWn611DmqrdXj1e94NkARcMA2f1syiAqOg=="], - "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], - "lodash": ["lodash@4.18.0", "", {}, "sha512-l1mfj2atMqndAHI3ls7XqPxEjV2J9ZkcNyHpoZA3r2T1LLwDB69jgkMWh71YKwhBbK0G2f4WSn05ahmQXVxupA=="], "lodash.escaperegexp": ["lodash.escaperegexp@4.1.2", "", {}, "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw=="], @@ -563,8 +476,6 @@ "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], - "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], - "node-abi": ["node-abi@4.28.0", "", { "dependencies": { "semver": "^7.6.3" } }, "sha512-Qfp5XZL1cJDOabOT8H5gnqMTmM4NjvYzHp4I/Kt/Sl76OVkOBBHRFlPspGV0hYvMoqQsypFjT/Yp7Km0beXW9g=="], "node-api-version": ["node-api-version@0.2.1", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-2xP/IGGMmmSQpI1+O/k72jF/ykvZ89JeuKX3TLJAYPDVLUalrshrLHkeVcCCZqG/eEa635cr8IBYzgnDvM2O8Q=="], @@ -581,16 +492,10 @@ "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], - "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], - "p-cancelable": ["p-cancelable@2.1.1", "", {}, "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg=="], "p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], - "p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="], - - "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], - "path-is-absolute": ["path-is-absolute@1.0.1", "", {}, "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg=="], "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], @@ -607,8 +512,6 @@ "postject": ["postject@1.0.0-alpha.6", "", { "dependencies": { "commander": "^9.4.0" }, "bin": { "postject": "dist/cli.js" } }, "sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A=="], - "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], - "prettier": ["prettier@3.8.1", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg=="], "proc-log": ["proc-log@6.1.0", "", {}, "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ=="], @@ -625,8 +528,6 @@ "pump": ["pump@3.0.4", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA=="], - "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], - "pvtsutils": ["pvtsutils@1.3.6", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg=="], "pvutils": ["pvutils@1.1.5", "", {}, "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA=="], @@ -711,8 +612,6 @@ "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], - "type-fest": ["type-fest@0.13.1", "", {}, "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg=="], "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], @@ -725,8 +624,6 @@ "unzipper": ["unzipper@0.12.5", "", { "dependencies": { "bluebird": "~3.7.2", "duplexer2": "~0.1.4", "fs-extra": "11.3.1", "graceful-fs": "^4.2.2", "node-int64": "^0.4.0" } }, "sha512-tXYOi9R57Uj/2Z25SOs5RRSzq886MBQj2gY8dPL+xl/kv6s6SvByoKfAtvfVeEuhntWDgjd2o9p2lb4TVPAz0A=="], - "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], - "utf8-byte-length": ["utf8-byte-length@1.0.5", "", {}, "sha512-Xn0w3MtiQ6zoz2vFyUVruaCL53O/DwUvkEeOvj+uulMm0BkUGYWmBYVyElqZaSLhY6ZD0ulfU3aBra2aVT4xfA=="], "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], @@ -735,8 +632,6 @@ "which": ["which@5.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ=="], - "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], - "wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], @@ -771,8 +666,6 @@ "@electron/windows-sign/fs-extra": ["fs-extra@11.3.4", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA=="], - "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], - "@malept/flatpak-bundler/fs-extra": ["fs-extra@9.1.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="], "@types/cacheable-request/@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="], @@ -787,8 +680,6 @@ "app-builder-lib/@electron/get": ["@electron/get@3.1.0", "", { "dependencies": { "debug": "^4.1.1", "env-paths": "^2.2.0", "fs-extra": "^8.1.0", "got": "^11.8.5", "progress": "^2.0.3", "semver": "^6.2.0", "sumchecker": "^3.0.1" }, "optionalDependencies": { "global-agent": "^3.0.0" } }, "sha512-F+nKc0xW+kVbBRhFzaMgPy3KwmuNTYX1fx6+FxxoSnNgwYX6LD7AKBTWkU0MQ6IBoe7dz069CNkR673sPAgkCQ=="], - "app-builder-lib/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], - "app-builder-lib/ci-info": ["ci-info@4.3.1", "", {}, "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA=="], "builder-util/https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], @@ -835,8 +726,6 @@ "app-builder-lib/@electron/get/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], - "app-builder-lib/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], - "builder-util/https-proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], "cross-spawn/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], diff --git a/changes/remove-dead-code-docs.md b/changes/remove-dead-code-docs.md new file mode 100644 index 00000000..dda0f1dd --- /dev/null +++ b/changes/remove-dead-code-docs.md @@ -0,0 +1,4 @@ +type: docs +area: launcher + +- Removed `SUBMINER_JIMAKU_API_KEY`, `SUBMINER_JIMAKU_API_KEY_COMMAND`, and `SUBMINER_JIMAKU_API_BASE_URL` from the development docs' environment variable table. The launcher never used them; set the Jimaku key with `jimaku.apiKey` / `jimaku.apiKeyCommand` in the config instead. diff --git a/changes/remove-dead-code.md b/changes/remove-dead-code.md new file mode 100644 index 00000000..cb0291c9 --- /dev/null +++ b/changes/remove-dead-code.md @@ -0,0 +1,5 @@ +type: internal +area: cleanup + +- Removed dead code: the unused launcher Jimaku client (the `SUBMINER_JIMAKU_*` env vars it read were documented but had no effect), the mpv plugin's hover-highlight module (no sender since Overlay 2.0; custom mpv scripts that still send `subminer-hover-token` or `yomipv-hover-token` are now ignored), the never-read `anilist.characterDictionary.refreshTtlHours` / `evictionPolicy` config keys, unused root dependencies (eslint, geist fonts), and a committed draft plan doc. +- Typecheck now runs with `noUnusedLocals`; removed the unused locals, imports, and the Windows overlay focus-handoff values they fed, which were computed but never used. diff --git a/docs-site/development.md b/docs-site/development.md index 0f90b7a8..b968d2b4 100644 --- a/docs-site/development.md +++ b/docs-site/development.md @@ -175,9 +175,6 @@ Run `make help` for the full list. | `SUBMINER_MPV_PLUGIN_PATH` | mpv plugin directory the launcher injects | | `SUBMINER_LOG_LEVEL` | App log level (`debug`, `info`, `warn`, `error`) | | `SUBMINER_MPV_LOG` | Shared mpv/app log file path | -| `SUBMINER_JIMAKU_API_KEY` | Jimaku API key for launcher subtitle downloads | -| `SUBMINER_JIMAKU_API_KEY_COMMAND` | Command that prints the Jimaku API key | -| `SUBMINER_JIMAKU_API_BASE_URL` | Jimaku API base URL | | `SUBMINER_JELLYFIN_ACCESS_TOKEN` | Jellyfin access token, used before the stored encrypted session | | `SUBMINER_JELLYFIN_USER_ID` | Jellyfin user ID | | `SUBMINER_SKIP_MACOS_HELPER_BUILD` | Set to `1` to skip the macOS helper build during `bun run build` | diff --git a/docs/architecture/2026-03-15-renderer-performance-design.md b/docs/architecture/2026-03-15-renderer-performance-design.md deleted file mode 100644 index 809d6b8b..00000000 --- a/docs/architecture/2026-03-15-renderer-performance-design.md +++ /dev/null @@ -1,298 +0,0 @@ -# Renderer Performance Optimizations - -**Date:** 2026-03-15 -**Status:** Draft - -## Goal - -Minimize the time between a subtitle line appearing and annotations being displayed. Three optimizations target different pipeline stages to achieve this. - -## Current Pipeline (Warm State) - -```text -MPV subtitle change (0ms) - -> IPC to main (5ms) - -> Cache check (2ms) - -> [CACHE MISS] Yomitan parser (35-180ms) - -> Parallel: MeCab enrichment (20-80ms) + Frequency lookup (15-50ms) - -> Annotation stage: 4 sequential passes (25-70ms) - -> IPC to renderer (10ms) - -> DOM render: createElement per token (15-50ms) - ───────────────────────────────── - Total: ~200-320ms (cache miss) - Total: ~72ms (cache hit) -``` - -## Target Pipeline - -```text -MPV subtitle change (0ms) - -> IPC to main (5ms) - -> Cache check (2ms) - -> [CACHE HIT via prefetch] (0ms) - -> IPC to renderer (10ms) - -> DOM render: cloneNode from template (10-30ms) - ───────────────────────────────── - Total: ~30-50ms (prefetch-warmed, normal playback) - - [CACHE MISS, e.g. immediate seek] - -> Yomitan parser (35-180ms) - -> Parallel: MeCab enrichment + Frequency lookup - -> Annotation stage: 1 batched pass (10-25ms) - -> IPC to renderer (10ms) - -> DOM render: cloneNode from template (10-30ms) - ───────────────────────────────── - Total: ~150-260ms (cache miss, still improved) -``` - ---- - -## Optimization 1: Subtitle Prefetching - -### Summary - -A new `SubtitlePrefetchService` parses external subtitle files and tokenizes upcoming lines in the background before they appear on screen. This converts most cache misses into cache hits during normal playback. - -### Scope - -External subtitle files only (SRT, VTT, ASS). Embedded subtitle tracks are out of scope since Japanese subtitles are virtually always external files. - -### Architecture - -#### Subtitle File Parsing - -A cue parser extracts both timing and text content from subtitle files for prefetching. - -**Parsed cue structure:** - -```typescript -interface SubtitleCue { - startTime: number; // seconds - endTime: number; // seconds - text: string; // plain text, decoded from the source format - source?: 'canonical-ass'; // recovered authored text for generated ASS animation - animationStartTime?: number; // full generated-frame envelope; entrance/exit frames - animationEndTime?: number; // run past the authored timing, live matching uses this -} -``` - -**Supported formats:** - -- SRT/VTT: Regex-based parsing of timing lines + text content between timing blocks. -- ASS: Parse the `[Events]` section, read the field order from the `Format:` row, and extract timed `Dialogue:` lines. Timed `Comment:` lines are normally ignored, but can supply canonical authored text when they match a nearby generated animation from the same style and actor. Text can itself contain commas. - -**ASS decoding.** The parser is where ASS text is decoded, once, via `assToPlainText()` in `src/core/services/ass-text.ts`. That decoder mirrors mpv's `ass_to_plaintext` so a cue read from a file reads identically to the same line arriving live on `sub-text`: `{...}` override blocks are markup, `\pN … \p0` vector drawing runs are dropped rather than shown as text, `\N`/`\n`/`\h` are the only escapes (`\{`, `\}` and `\\` are not), and an unclosed `{` is rendered verbatim. Every layer downstream — renderer, timing tracker, tokenizer, tokenization cache keys — receives plain text and uses `normalizePlainSubtitleText()` for whitespace only, so nothing decodes the same string twice and one authored line always maps to one cache key. - -**Duplicate collapsing.** Typeset scripts emit one `Dialogue:` event per animation frame, plus layered copies of the same line. The parser collapses identical text over an identical span unconditionally, and collapses contiguous same-text runs of at least three events when the run looks like an animation. For ASS that means shared style and actor plus authoring evidence: a temporal tag (`\t`, `\move`, `\k`/`\kf`/`\ko`/`\K`, or anything wrapped in `\t(...)`), an animated `Effect` column (`Karaoke`, `Banner`, `Scroll`), or override values that change across the run. Static tags shared by every event (`\pos`, an identical `\clip`) are not evidence. SRT/VTT carry no such metadata, so there collapsing needs at least five contiguous events all under 0.1s — the frame timing left behind by ASS-to-SRT conversion. The parser keeps this authoring metadata (style, actor, layer, `Effect`, parsed override commands, source order) private; `parseSubtitleCues()` returns only `SubtitleCue`. - -ASS scripts can also redraw one complete lyric for two or more long color/highlight phases. Those flush-timed phases collapse separately from short animation frames when they share text, style, actor, and layer and carry direct animation evidence, such as temporal tags or changing non-spatial overrides. Spatial command changes do not prove a phase, so separately positioned signs remain distinct. - -**Canonical animation recovery.** Some ASS producers keep the readable lyric or sign as a timed `Comment:` and generate hundreds of `Dialogue:` frames containing repeated glyphs or changing clip regions. Others retain the complete line as brief `Dialogue:` events around the generated fragments. A complete event is promoted only when nearby dialogue from the same style and actor forms a proven animation cluster and reconstructs its entire text in source order. The generated frames are then replaced by one cue marked `source: 'canonical-ass'`. This source marker lets the live primary-subtitle path prefer the clean authored text and timing for display, sidebar history, immersion recording, and mining, while unmatched editor notes and alternative translations remain ignored. Secondary selection advances to an entering canonical cue at its generated animation start when the preceding authored cue ends before the new authored span. Unrelated simultaneous cues that continue through the new span remain visible. - -**Font texture cleanup.** A clipped repeated-glyph run or frequent changes to secondary alpha marks a texture seed. Clipped runs do not need a font override because some signs build their masks from ordinary `l` glyphs. The parser removes short clipped pieces that share a no-font seed's style and timing, or pieces that share a font seed's style, timing, and font even when the actor changes. It also removes positioned text layers with at least `E0` global alpha when they overlap a seed in the same style. Opaque authored sign text stays publishable when the texture switches fonts or actors around it. - -#### Prefetch Service Lifecycle - -1. **Activation trigger:** When a subtitle track is activated (or changes), check if it's external via MPV's `track-list` property. If `external === true`, read the file via `external-filename` using the existing `loadSubtitleSourceText` infrastructure. -2. **Parse phase:** Parse all cues from the file content. Sort by start time. Store as an ordered array. -3. **Priority window:** Determine the current playback position. Identify the next 10 cues as the priority window. -4. **Priority tokenization:** Tokenize the priority window cues sequentially, storing results into the `SubtitleProcessingController`'s tokenization cache. -5. **Background tokenization:** After the priority window is done, tokenize remaining cues working forward from the current position, then wrapping around to cover earlier cues. The prefetcher stops once it has tokenized all cues or the cache is full (whichever comes first) to avoid wasteful eviction churn. For files with more cues than the cache limit, background tokenization focuses on cues ahead of the current position. -6. **Seek handling:** On seek, re-compute the priority window from the new position. A seek is detected by observing MPV's `time-pos` property and checking if the delta from the last observed position exceeds a threshold (e.g., > 3 seconds forward or any backward jump). The current in-flight tokenization finishes naturally, then the new priority window takes over. -7. **Teardown:** When the subtitle track changes or playback ends, stop all prefetch work and discard state. - -#### Live Priority - -The prefetcher and live subtitle handler share the Yomitan parser (single-threaded IPC). Live subtitle requests must always take priority. The prefetcher: - -- Checks a `paused` flag before each cue tokenization. The live handler sets `paused = true` on subtitle change and clears it after emission. -- Yields between each background cue tokenization (via `setTimeout(0)` or equivalent) so the live handler can set the pause flag between cues. -- When paused, the prefetcher waits (polling the flag on a short interval or awaiting a resume signal) before continuing with the next cue. - -#### Cache Integration - -The prefetcher calls the same `tokenizeSubtitle` function used by live processing to produce `SubtitleData` results, then stores them into the existing `SubtitleProcessingController` tokenization cache via a new method: - -```typescript -// New methods on SubtitleProcessingController -preCacheTokenization: (text: string, data: SubtitleData) => void; -isCacheFull: () => boolean; -``` - -`preCacheTokenization` uses the same `setCachedTokenization` logic internally (LRU eviction, Map-based storage). `isCacheFull` returns `true` when the cache has reached its limit, allowing the prefetcher to stop background tokenization and avoid wasteful eviction churn. - -#### Cache Invalidation - -When the user marks a word as known (or any event triggers `invalidateTokenizationCache()`), all cached results are cleared -- including prefetched ones, since they share the same cache. After invalidation, the prefetcher re-computes the priority window from the current playback position and re-tokenizes those cues to restore warm cache state. - -#### Error Handling - -If the subtitle file is malformed or partially parseable, the cue parser uses what it can extract. A file that yields zero cues disables prefetching silently (falls back to live-only processing). Encoding errors from `loadSubtitleSourceText` are caught and logged; prefetching is skipped for that track. - -#### Integration Points - -- **MPV property subscriptions:** Needs `track-list` (to detect external subtitle file path) and `time-pos` (to track playback position for window calculation and seek detection). -- **File loading:** Uses existing `loadSubtitleSourceText` dependency. -- **Tokenization:** Calls the same `tokenizeSubtitle` function used by live processing. -- **Cache:** Writes into `SubtitleProcessingController`'s cache. -- **Cache invalidation:** Listens for cache invalidation events to re-prefetch the priority window. - -### Files Affected - -- **New:** `src/core/services/subtitle-prefetch.ts` -- the prefetch service -- **New:** `src/core/services/subtitle-cue-parser.ts` -- SRT/VTT/ASS cue parser (text + timing) -- **Modified:** `src/core/services/subtitle-processing-controller.ts` -- expose `preCacheTokenization` method -- **Modified:** `src/main.ts` -- wire up the prefetch service, listen to track changes - ---- - -## Optimization 2: Batched Annotation Pass - -### Summary - -Collapse the 4 sequential annotation passes (`applyKnownWordMarking` -> `applyFrequencyMarking` -> `applyJlptMarking` -> `markNPlusOneTargets`) into a single iteration over the token array, followed by N+1 marking. - -**Important context:** Frequency rank _values_ (`token.frequencyRank`) are already assigned at the parser level by `applyFrequencyRanks()` in `tokenizer.ts`, before the annotation stage is called. The annotation stage's `applyFrequencyMarking` only performs POS-based _filtering_ -- clearing `frequencyRank` to `undefined` for tokens that should be excluded (particles, noise tokens, etc.) and normalizing valid ranks. This optimization does not change the parser-level frequency rank assignment; it only batches the annotation-level filtering. - -### Current Flow (4 passes, 4 array copies) - -```text -tokens (already have frequencyRank values from parser-level applyFrequencyRanks) - -> applyKnownWordMarking() // .map() -> new array - -> applyFrequencyMarking() // .map() -> new array (POS-based filtering only) - -> applyJlptMarking() // .map() -> new array - -> markNPlusOneTargets() // .map() -> new array -``` - -### Dependency Analysis - -All annotations either depend on MeCab POS data or benefit from running after it: - -- **Known word marking:** Needs base tokens (surface/headword). No POS dependency, but no reason to run separately. -- **Frequency filtering:** Uses `pos1Exclusions` and `pos2Exclusions` to clear frequency ranks on excluded tokens (particles, noise). Depends on MeCab POS data. -- **JLPT marking:** Uses `shouldIgnoreJlptForMecabPos1` to filter. Depends on MeCab POS data. -- **N+1 marking:** Uses POS exclusion sets to filter candidates. Depends on known word status + MeCab POS. - -Since frequency filtering and JLPT marking both depend on POS data from MeCab enrichment, and MeCab enrichment already happens before the annotation stage, all four can run in a single pass after MeCab completes. - -### New Flow (1 pass + N+1) - -```typescript -function annotateTokens(tokens, deps, options): MergedToken[] { - const pos1Exclusions = resolvePos1Exclusions(options); - const pos2Exclusions = resolvePos2Exclusions(options); - - // Single pass: known word + frequency filtering + JLPT computed together - const annotated = tokens.map((token) => { - const isKnown = nPlusOneEnabled ? token.isKnown || computeIsKnown(token, deps) : false; - - // Filter frequency rank using POS exclusions (rank values already set at parser level) - const frequencyRank = frequencyEnabled - ? filterFrequencyRank(token, pos1Exclusions, pos2Exclusions) - : undefined; - - const jlptLevel = jlptEnabled ? computeJlptLevel(token, deps.getJlptLevel) : undefined; - - return { ...token, isKnown, frequencyRank, jlptLevel }; - }); - - // N+1 must run after known word status is set for all tokens - if (nPlusOneEnabled) { - return markNPlusOneTargets(annotated, minSentenceWords, pos1Exclusions, pos2Exclusions); - } - - return annotated; -} -``` - -### What Changes - -- The individual `applyKnownWordMarking`, `applyFrequencyMarking`, `applyJlptMarking` functions are refactored into per-token computation helpers (pure functions that compute a single field). The frequency helper is named `filterFrequencyRank` to clarify it performs POS-based exclusion, not rank computation. -- The `annotateTokens` orchestrator runs one `.map()` call that invokes all three helpers per token. -- `markNPlusOneTargets` remains a separate pass because it needs the full array with `isKnown` set (it examines sentence-level context). -- The parser-level `applyFrequencyRanks()` call in `tokenizer.ts` is unchanged -- it remains a separate step outside the annotation stage. -- Net: 4 array copies + 4 iterations become 1 array copy + 1 iteration + N+1 pass. - -### Expected Savings - -~15-45ms saved (3 fewer array allocations + 3 fewer full iterations). Annotation drops from ~25-70ms to ~10-25ms. - -### Files Affected - -- **Modified:** `src/core/services/tokenizer/annotation-stage.ts` -- refactor into batched single-pass - ---- - -## Optimization 3: DOM Template Pooling - -### Summary - -Replace `document.createElement('span')` calls in the renderer with `templateSpan.cloneNode(false)` from a pre-created template element. - -### Current Behavior - -In `renderWithTokens` (`subtitle-render.ts`), each render cycle: - -1. Clears DOM with `innerHTML = ''` -2. Creates a `DocumentFragment` -3. Calls `document.createElement('span')` for each token (~10-15 per subtitle) -4. Sets `className`, `textContent`, `dataset.*` individually -5. Appends fragment to root - -### New Behavior - -1. At renderer initialization (`createSubtitleRenderer`), create a single template: - ```typescript - const templateSpan = document.createElement('span'); - ``` -2. In `renderWithTokens`, replace every `document.createElement('span')` with: - ```typescript - const span = templateSpan.cloneNode(false) as HTMLSpanElement; - ``` -3. Replace all `innerHTML = ''` calls with `root.replaceChildren()` to avoid the HTML parser invocation on clear. This applies to `renderSubtitle` (primary subtitle root), `renderSecondarySub` (secondary subtitle root), and `renderCharacterLevel` if applicable. -4. Everything else stays the same (setting className, textContent, dataset, appending to fragment). - -### Why cloneNode Over Full Node Recycling - -Full recycling (collecting old nodes, clearing attributes, reusing them) requires carefully resetting every `dataset.*` property that might have been set on a previous render. This is error-prone -- a stale `data-frequency-rank` from a previous subtitle appearing on a new token would cause incorrect styling. `cloneNode(false)` on a bare template is nearly as fast and produces a clean node every time. - -### Expected Savings - -`cloneNode(false)` is ~2-3x faster than `createElement` in most browser engines. For 10-15 tokens per subtitle: ~3-8ms saved per render cycle. - -### Files Affected - -- **Modified:** `src/renderer/subtitle-render.ts` -- template creation + cloneNode usage - ---- - -## Combined Impact Summary - -| Scenario | Before | After | Improvement | -| --------------------------------- | ---------- | ---------- | ----------- | -| Normal playback (prefetch-warmed) | ~200-320ms | ~30-50ms | ~80-85% | -| Cache hit (repeated subtitle) | ~72ms | ~55-65ms | ~10-20% | -| Cache miss (immediate seek) | ~200-320ms | ~150-260ms | ~20-25% | - ---- - -## Files Summary - -### New Files - -- `src/core/services/subtitle-prefetch.ts` -- `src/core/services/subtitle-cue-parser.ts` - -### Modified Files - -- `src/core/services/subtitle-processing-controller.ts` (expose `preCacheTokenization`) -- `src/core/services/tokenizer/annotation-stage.ts` (batched single-pass) -- `src/renderer/subtitle-render.ts` (template cloneNode) -- `src/main.ts` (wire up prefetch service) - -### Test Files - -- New tests for subtitle cue parser (SRT, VTT, ASS formats) -- New tests for subtitle prefetch service (priority window, seek, pause/resume) -- Updated tests for annotation stage (same behavior, new implementation) -- Updated tests for subtitle render (template cloning) diff --git a/launcher/commands/command-modules.test.ts b/launcher/commands/command-modules.test.ts index 2b8b96fc..55dcfdab 100644 --- a/launcher/commands/command-modules.test.ts +++ b/launcher/commands/command-modules.test.ts @@ -6,11 +6,9 @@ import type { LauncherCommandContext } from './context.js'; import { runConfigCommand } from './config-command.js'; import { runDictionaryCommand } from './dictionary-command.js'; import { runDoctorCommand } from './doctor-command.js'; -import { runLogsCommand } from './logs-command.js'; import { runMpvPostAppCommand, runMpvPreAppCommand } from './mpv-command.js'; import { runAppPassthroughCommand } from './app-command.js'; import { runStatsCommand } from './stats-command.js'; -import { runUpdateCommand } from './update-command.js'; class ExitSignal extends Error { code: number; diff --git a/launcher/commands/mpv-command.ts b/launcher/commands/mpv-command.ts index 0cd9e158..3eaf4fd5 100644 --- a/launcher/commands/mpv-command.ts +++ b/launcher/commands/mpv-command.ts @@ -65,7 +65,7 @@ export async function runMpvPostAppCommand( context: LauncherCommandContext, deps: MpvCommandDeps = defaultDeps, ): Promise { - const { args, appPath, scriptPath, mpvSocketPath, pluginRuntimeConfig } = context; + const { args, appPath, mpvSocketPath, pluginRuntimeConfig } = context; if (!args.mpvIdle) { return false; } diff --git a/launcher/config-domain-parsers.test.ts b/launcher/config-domain-parsers.test.ts index 1d85686e..16852cf5 100644 --- a/launcher/config-domain-parsers.test.ts +++ b/launcher/config-domain-parsers.test.ts @@ -17,20 +17,11 @@ test('parseLauncherYoutubeSubgenConfig keeps only valid typed values', () => { secondarySub: { secondarySubLanguages: ['eng', true, 'deu'], }, - jimaku: { - apiKey: 'abc', - apiKeyCommand: 'pass show key', - apiBaseUrl: 'https://jimaku.cc', - languagePreference: 'ja', - maxEntryResults: 8.7, - }, }); assert.equal('mode' in parsed, false); assert.deepEqual(parsed.primarySubLanguages, ['ja', 'en']); assert.deepEqual(parsed.secondarySubLanguages, ['eng', 'deu']); - assert.equal(parsed.jimakuLanguagePreference, 'ja'); - assert.equal(parsed.jimakuMaxEntryResults, 8); }); test('parseLauncherJellyfinConfig omits legacy token and user id fields', () => { diff --git a/launcher/config/args-normalizer.ts b/launcher/config/args-normalizer.ts index 9e7138bb..8f0d3c9f 100644 --- a/launcher/config/args-normalizer.ts +++ b/launcher/config/args-normalizer.ts @@ -10,7 +10,6 @@ import type { LogLevel, } from '../types.js'; import { - DEFAULT_JIMAKU_API_BASE_URL, DEFAULT_YOUTUBE_PRIMARY_SUB_LANGS, DEFAULT_YOUTUBE_SECONDARY_SUB_LANGS, } from '../types.js'; @@ -125,11 +124,6 @@ export function createDefaultArgs( recursive: false, profile: mpvConfig.profile ?? '', startOverlay: false, - jimakuApiKey: process.env.SUBMINER_JIMAKU_API_KEY || '', - jimakuApiKeyCommand: process.env.SUBMINER_JIMAKU_API_KEY_COMMAND || '', - jimakuApiBaseUrl: process.env.SUBMINER_JIMAKU_API_BASE_URL || DEFAULT_JIMAKU_API_BASE_URL, - jimakuLanguagePreference: launcherConfig.jimakuLanguagePreference || 'ja', - jimakuMaxEntryResults: launcherConfig.jimakuMaxEntryResults || 10, jellyfin: false, jellyfinLogin: false, jellyfinLogout: false, @@ -184,15 +178,6 @@ export function createDefaultArgs( targetKind: '', }; - if (launcherConfig.jimakuApiKey) parsed.jimakuApiKey = launcherConfig.jimakuApiKey; - if (launcherConfig.jimakuApiKeyCommand) - parsed.jimakuApiKeyCommand = launcherConfig.jimakuApiKeyCommand; - if (launcherConfig.jimakuApiBaseUrl) parsed.jimakuApiBaseUrl = launcherConfig.jimakuApiBaseUrl; - if (launcherConfig.jimakuLanguagePreference) - parsed.jimakuLanguagePreference = launcherConfig.jimakuLanguagePreference; - if (launcherConfig.jimakuMaxEntryResults !== undefined) - parsed.jimakuMaxEntryResults = launcherConfig.jimakuMaxEntryResults; - return parsed; } diff --git a/launcher/config/youtube-subgen-config.ts b/launcher/config/youtube-subgen-config.ts index 46ef1022..eccc1810 100644 --- a/launcher/config/youtube-subgen-config.ts +++ b/launcher/config/youtube-subgen-config.ts @@ -16,31 +16,9 @@ export function parseLauncherYoutubeSubgenConfig( secondarySubRaw && typeof secondarySubRaw === 'object' ? (secondarySubRaw as Record) : null; - const jimakuRaw = root.jimaku; - const jimaku = - jimakuRaw && typeof jimakuRaw === 'object' ? (jimakuRaw as Record) : null; - - const jimakuLanguagePreference = jimaku?.languagePreference; - const jimakuMaxEntryResults = jimaku?.maxEntryResults; return { primarySubLanguages: asStringArray(youtube?.primarySubLanguages), secondarySubLanguages: asStringArray(secondarySub?.secondarySubLanguages), - jimakuApiKey: typeof jimaku?.apiKey === 'string' ? jimaku.apiKey : undefined, - jimakuApiKeyCommand: - typeof jimaku?.apiKeyCommand === 'string' ? jimaku.apiKeyCommand : undefined, - jimakuApiBaseUrl: typeof jimaku?.apiBaseUrl === 'string' ? jimaku.apiBaseUrl : undefined, - jimakuLanguagePreference: - jimakuLanguagePreference === 'ja' || - jimakuLanguagePreference === 'en' || - jimakuLanguagePreference === 'none' - ? jimakuLanguagePreference - : undefined, - jimakuMaxEntryResults: - typeof jimakuMaxEntryResults === 'number' && - Number.isFinite(jimakuMaxEntryResults) && - jimakuMaxEntryResults > 0 - ? Math.floor(jimakuMaxEntryResults) - : undefined, }; } diff --git a/launcher/jimaku.ts b/launcher/jimaku.ts deleted file mode 100644 index 3bc5b48f..00000000 --- a/launcher/jimaku.ts +++ /dev/null @@ -1,518 +0,0 @@ -import fs from 'node:fs'; -import path from 'node:path'; -import http from 'node:http'; -import https from 'node:https'; -import { spawnSync } from 'node:child_process'; -import type { Args, JimakuLanguagePreference } from './types.js'; -import { DEFAULT_JIMAKU_API_BASE_URL } from './types.js'; -import { commandExists } from './util.js'; - -export interface JimakuEntry { - id: number; - name: string; - english_name?: string | null; - japanese_name?: string | null; - flags?: { - anime?: boolean; - movie?: boolean; - adult?: boolean; - external?: boolean; - unverified?: boolean; - }; -} - -interface JimakuFileEntry { - name: string; - url: string; - size: number; - last_modified: string; -} - -interface JimakuApiError { - error: string; - code?: number; - retryAfter?: number; -} - -type JimakuApiResponse = { ok: true; data: T } | { ok: false; error: JimakuApiError }; - -type JimakuDownloadResult = { ok: true; path: string } | { ok: false; error: JimakuApiError }; - -interface JimakuConfig { - apiKey: string; - apiKeyCommand: string; - apiBaseUrl: string; - languagePreference: JimakuLanguagePreference; - maxEntryResults: number; -} - -interface JimakuMediaInfo { - title: string; - season: number | null; - episode: number | null; - confidence: 'high' | 'medium' | 'low'; - filename: string; - rawTitle: string; -} - -function getRetryAfter(headers: http.IncomingHttpHeaders): number | undefined { - const value = headers['x-ratelimit-reset-after']; - if (!value) return undefined; - const raw = Array.isArray(value) ? value[0] : value; - if (!raw) return undefined; - const parsed = Number.parseFloat(raw); - if (!Number.isFinite(parsed)) return undefined; - return parsed; -} - -export function matchEpisodeFromName(name: string): { - season: number | null; - episode: number | null; - index: number | null; - confidence: 'high' | 'medium' | 'low'; -} { - const seasonEpisode = name.match(/S(\d{1,2})E(\d{1,3})/i); - if (seasonEpisode && seasonEpisode.index !== undefined) { - const seasonText = seasonEpisode[1]; - const episodeText = seasonEpisode[2]; - if (!seasonText || !episodeText) { - return { season: null, episode: null, index: null, confidence: 'low' }; - } - return { - season: Number.parseInt(seasonText, 10), - episode: Number.parseInt(episodeText, 10), - index: seasonEpisode.index, - confidence: 'high', - }; - } - - const alt = name.match(/(\d{1,2})x(\d{1,3})/i); - if (alt && alt.index !== undefined) { - const seasonText = alt[1]; - const episodeText = alt[2]; - if (!seasonText || !episodeText) { - return { season: null, episode: null, index: null, confidence: 'low' }; - } - return { - season: Number.parseInt(seasonText, 10), - episode: Number.parseInt(episodeText, 10), - index: alt.index, - confidence: 'high', - }; - } - - const epOnly = name.match(/(?:^|[\s._-])E(?:P)?(\d{1,3})(?:\b|[\s._-])/i); - if (epOnly && epOnly.index !== undefined) { - const episodeText = epOnly[1]; - if (!episodeText) { - return { season: null, episode: null, index: null, confidence: 'low' }; - } - return { - season: null, - episode: Number.parseInt(episodeText, 10), - index: epOnly.index, - confidence: 'medium', - }; - } - - const numeric = name.match(/(?:^|[-–—]\s*)(\d{1,3})\s*[-–—]/); - if (numeric && numeric.index !== undefined) { - const episodeText = numeric[1]; - if (!episodeText) { - return { season: null, episode: null, index: null, confidence: 'low' }; - } - return { - season: null, - episode: Number.parseInt(episodeText, 10), - index: numeric.index, - confidence: 'medium', - }; - } - - return { season: null, episode: null, index: null, confidence: 'low' }; -} - -function detectSeasonFromDir(mediaPath: string): number | null { - const parent = path.basename(path.dirname(mediaPath)); - const match = parent.match(/(?:Season|S)\s*(\d{1,2})/i); - if (!match) return null; - const seasonText = match[1]; - if (!seasonText) return null; - const parsed = Number.parseInt(seasonText, 10); - return Number.isFinite(parsed) ? parsed : null; -} - -function parseGuessitOutput(mediaPath: string, stdout: string): JimakuMediaInfo | null { - const payload = stdout.trim(); - if (!payload) return null; - - try { - const parsed = JSON.parse(payload) as { - title?: string; - title_original?: string; - series?: string; - season?: number | string; - episode?: number | string; - episode_list?: Array; - }; - const season = - typeof parsed.season === 'number' - ? parsed.season - : typeof parsed.season === 'string' - ? Number.parseInt(parsed.season, 10) - : null; - const directEpisode = - typeof parsed.episode === 'number' - ? parsed.episode - : typeof parsed.episode === 'string' - ? Number.parseInt(parsed.episode, 10) - : null; - const episodeFromList = - parsed.episode_list && parsed.episode_list.length > 0 - ? Number.parseInt(String(parsed.episode_list[0]), 10) - : null; - const episodeValue = - directEpisode !== null && Number.isFinite(directEpisode) ? directEpisode : episodeFromList; - const episode = Number.isFinite(episodeValue as number) ? (episodeValue as number) : null; - const title = (parsed.title || parsed.title_original || parsed.series || '').trim(); - const hasStructuredData = - title.length > 0 || - Number.isFinite(season as number) || - Number.isFinite(episodeValue as number); - - if (!hasStructuredData) return null; - - return { - title: title || '', - season: Number.isFinite(season as number) ? season : detectSeasonFromDir(mediaPath), - episode: episode, - confidence: 'high', - filename: path.basename(mediaPath), - rawTitle: path.basename(mediaPath).replace(/\.[^/.]+$/, ''), - }; - } catch { - return null; - } -} - -function parseMediaInfoWithGuessit(mediaPath: string): JimakuMediaInfo | null { - if (!commandExists('guessit')) return null; - - try { - const fileName = path.basename(mediaPath); - const result = spawnSync('guessit', ['--json', fileName], { - cwd: path.dirname(mediaPath), - encoding: 'utf8', - maxBuffer: 2_000_000, - windowsHide: true, - }); - if (result.error || result.status !== 0) return null; - return parseGuessitOutput(mediaPath, result.stdout || ''); - } catch { - return null; - } -} - -function cleanupTitle(value: string): string { - return value - .replace(/^[\s-–—]+/, '') - .replace(/[\s-–—]+$/, '') - .replace(/\s+/g, ' ') - .trim(); -} - -function formatLangScore(name: string, pref: JimakuLanguagePreference): number { - if (pref === 'none') return 0; - const upper = name.toUpperCase(); - const hasJa = - /(^|[\W_])JA([\W_]|$)/.test(upper) || - /(^|[\W_])JPN([\W_]|$)/.test(upper) || - upper.includes('.JA.'); - const hasEn = - /(^|[\W_])EN([\W_]|$)/.test(upper) || - /(^|[\W_])ENG([\W_]|$)/.test(upper) || - upper.includes('.EN.'); - if (pref === 'ja') { - if (hasJa) return 2; - if (hasEn) return 1; - } else if (pref === 'en') { - if (hasEn) return 2; - if (hasJa) return 1; - } - return 0; -} - -export async function resolveJimakuApiKey(config: JimakuConfig): Promise { - if (config.apiKey && config.apiKey.trim()) { - return config.apiKey.trim(); - } - if (config.apiKeyCommand && config.apiKeyCommand.trim()) { - try { - const commandResult = spawnSync(config.apiKeyCommand, { - shell: true, - encoding: 'utf8', - timeout: 10000, - }); - if (commandResult.error) return null; - const key = (commandResult.stdout || '').trim(); - return key.length > 0 ? key : null; - } catch { - return null; - } - } - return null; -} - -export function jimakuFetchJson( - endpoint: string, - query: Record, - options: { baseUrl: string; apiKey: string }, -): Promise> { - const url = new URL(endpoint, options.baseUrl); - for (const [key, value] of Object.entries(query)) { - if (value === null || value === undefined) continue; - url.searchParams.set(key, String(value)); - } - - return new Promise((resolve) => { - const requestUrl = new URL(url.toString()); - const transport = requestUrl.protocol === 'https:' ? https : http; - const req = transport.request( - requestUrl, - { - method: 'GET', - headers: { - Authorization: options.apiKey, - 'User-Agent': 'SubMiner', - }, - }, - (res) => { - let data = ''; - res.on('data', (chunk) => { - data += chunk.toString(); - }); - res.on('end', () => { - const status = res.statusCode || 0; - if (status >= 200 && status < 300) { - try { - const parsed = JSON.parse(data) as T; - resolve({ ok: true, data: parsed }); - } catch { - resolve({ - ok: false, - error: { error: 'Failed to parse Jimaku response JSON.' }, - }); - } - return; - } - - let errorMessage = `Jimaku API error (HTTP ${status})`; - try { - const parsed = JSON.parse(data) as { error?: string }; - if (parsed && parsed.error) { - errorMessage = parsed.error; - } - } catch { - // ignore parse errors - } - - resolve({ - ok: false, - error: { - error: errorMessage, - code: status || undefined, - retryAfter: status === 429 ? getRetryAfter(res.headers) : undefined, - }, - }); - }); - }, - ); - - req.on('error', (error) => { - resolve({ - ok: false, - error: { error: `Jimaku request failed: ${(error as Error).message}` }, - }); - }); - - req.end(); - }); -} - -export function parseMediaInfo(mediaPath: string | null): JimakuMediaInfo { - if (!mediaPath) { - return { - title: '', - season: null, - episode: null, - confidence: 'low', - filename: '', - rawTitle: '', - }; - } - - const guessitInfo = parseMediaInfoWithGuessit(mediaPath); - if (guessitInfo) return guessitInfo; - - const filename = path.basename(mediaPath); - let name = filename.replace(/\.[^/.]+$/, ''); - name = name.replace(/\[[^\]]*]/g, ' '); - name = name.replace(/\(\d{4}\)/g, ' '); - name = name.replace(/[._]/g, ' '); - name = name.replace(/[–—]/g, '-'); - name = name.replace(/\s+/g, ' ').trim(); - - const parsed = matchEpisodeFromName(name); - let titlePart = name; - if (parsed.index !== null) { - titlePart = name.slice(0, parsed.index); - } - - const seasonFromDir = parsed.season ?? detectSeasonFromDir(mediaPath); - const title = cleanupTitle(titlePart || name); - - return { - title, - season: seasonFromDir, - episode: parsed.episode, - confidence: parsed.confidence, - filename, - rawTitle: name, - }; -} - -export function sortJimakuFiles( - files: JimakuFileEntry[], - pref: JimakuLanguagePreference, -): JimakuFileEntry[] { - if (pref === 'none') return files; - return [...files].sort((a, b) => { - const scoreDiff = formatLangScore(b.name, pref) - formatLangScore(a.name, pref); - if (scoreDiff !== 0) return scoreDiff; - return a.name.localeCompare(b.name); - }); -} - -export async function downloadToFile( - url: string, - destPath: string, - headers: Record, - redirectCount = 0, -): Promise { - if (redirectCount > 3) { - return { - ok: false, - error: { error: 'Too many redirects while downloading subtitle.' }, - }; - } - - return new Promise((resolve) => { - const parsedUrl = new URL(url); - const transport = parsedUrl.protocol === 'https:' ? https : http; - - const req = transport.get(parsedUrl, { headers }, (res) => { - const status = res.statusCode || 0; - if ([301, 302, 303, 307, 308].includes(status) && res.headers.location) { - const redirectUrl = new URL(res.headers.location, parsedUrl).toString(); - res.resume(); - downloadToFile(redirectUrl, destPath, headers, redirectCount + 1).then(resolve); - return; - } - - if (status < 200 || status >= 300) { - res.resume(); - resolve({ - ok: false, - error: { - error: `Failed to download subtitle (HTTP ${status}).`, - code: status, - }, - }); - return; - } - - const fileStream = fs.createWriteStream(destPath); - res.pipe(fileStream); - fileStream.on('finish', () => { - fileStream.close(() => { - resolve({ ok: true, path: destPath }); - }); - }); - fileStream.on('error', (err: Error) => { - resolve({ - ok: false, - error: { error: `Failed to save subtitle: ${err.message}` }, - }); - }); - }); - - req.on('error', (err) => { - resolve({ - ok: false, - error: { - error: `Download request failed: ${(err as Error).message}`, - }, - }); - }); - }); -} - -export function isValidSubtitleCandidateFile(filename: string): boolean { - const ext = path.extname(filename).toLowerCase(); - return ext === '.srt' || ext === '.vtt' || ext === '.ass' || ext === '.ssa' || ext === '.sub'; -} - -export function mapPreferenceToLanguages(preference: JimakuLanguagePreference): string[] { - if (preference === 'en') return ['en', 'eng']; - if (preference === 'none') return []; - return ['ja', 'jpn']; -} - -export function normalizeJimakuSearchInput(mediaPath: string): string { - const trimmed = (mediaPath || '').trim(); - if (!trimmed) return ''; - if (!/^https?:\/\/.*/.test(trimmed)) return trimmed; - - try { - const url = new URL(trimmed); - const titleParam = - url.searchParams.get('title') || url.searchParams.get('name') || url.searchParams.get('q'); - if (titleParam && titleParam.trim()) return titleParam.trim(); - - const pathParts = url.pathname.split('/').filter(Boolean).reverse(); - const candidate = pathParts.find((part) => { - const decoded = decodeURIComponent(part || '').replace(/\.[^/.]+$/, ''); - const lowered = decoded.toLowerCase(); - return lowered.length > 2 && !/^[0-9.]+$/.test(lowered) && !/^[a-f0-9]{16,}$/i.test(lowered); - }); - - const fallback = candidate || url.hostname.replace(/^www\./, ''); - return sanitizeJimakuQueryInput(decodeURIComponent(fallback)); - } catch { - return trimmed; - } -} - -export function sanitizeJimakuQueryInput(value: string): string { - return value - .replace(/^\s*-\s*/, '') - .replace(/[^\w\s\-'".:(),]/g, ' ') - .replace(/\s+/g, ' ') - .trim(); -} - -export function buildJimakuConfig(args: Args): { - apiKey: string; - apiKeyCommand: string; - apiBaseUrl: string; - languagePreference: JimakuLanguagePreference; - maxEntryResults: number; -} { - return { - apiKey: args.jimakuApiKey, - apiKeyCommand: args.jimakuApiKeyCommand, - apiBaseUrl: args.jimakuApiBaseUrl || DEFAULT_JIMAKU_API_BASE_URL, - languagePreference: args.jimakuLanguagePreference, - maxEntryResults: args.jimakuMaxEntryResults || 10, - }; -} diff --git a/launcher/smoke.e2e.test.ts b/launcher/smoke.e2e.test.ts index b695e176..18dbfb66 100644 --- a/launcher/smoke.e2e.test.ts +++ b/launcher/smoke.e2e.test.ts @@ -543,7 +543,6 @@ test( const appStartEntries = readJsonLines(appStartPath); const appStopEntries = readJsonLines(appStopPath); const controlEntries = readJsonLines(controlServer.logPath); - const mpvEntries = readJsonLines(path.join(smokeCase.artifactsDir, 'fake-mpv.log')); assert.equal(result.status, 0); if (process.platform === 'linux') { diff --git a/launcher/types.ts b/launcher/types.ts index 5ea7d951..2e4f7204 100644 --- a/launcher/types.ts +++ b/launcher/types.ts @@ -52,7 +52,6 @@ export function getDefaultMpvLogFile(options?: { export const DEFAULT_MPV_LOG_FILE = getDefaultMpvLogFile(); export const DEFAULT_YOUTUBE_YTDL_FORMAT = 'bestvideo*+bestaudio/best'; -export const DEFAULT_JIMAKU_API_BASE_URL = 'https://jimaku.cc'; export const DEFAULT_MPV_SUBMINER_ARGS = [ '--sub-auto=fuzzy', '--sub-file-paths=.;subs;subtitles', @@ -69,7 +68,6 @@ export function shouldForwardLogLevel(level: LogLevel): boolean { return level === 'debug' || level === 'error'; } export type Backend = 'auto' | 'hyprland' | 'sway' | 'x11' | 'macos' | 'windows'; -export type JimakuLanguagePreference = 'ja' | 'en' | 'none'; export interface Args { generateSubtitles?: { @@ -103,11 +101,6 @@ export interface Args { passwordStore: string; target: string; targetKind: '' | 'file' | 'url'; - jimakuApiKey: string; - jimakuApiKeyCommand: string; - jimakuApiBaseUrl: string; - jimakuLanguagePreference: JimakuLanguagePreference; - jimakuMaxEntryResults: number; jellyfin: boolean; jellyfinLogin: boolean; jellyfinLogout: boolean; @@ -151,11 +144,6 @@ export interface Args { export interface LauncherYoutubeSubgenConfig { primarySubLanguages?: string[]; secondarySubLanguages?: string[]; - jimakuApiKey?: string; - jimakuApiKeyCommand?: string; - jimakuApiBaseUrl?: string; - jimakuLanguagePreference?: JimakuLanguagePreference; - jimakuMaxEntryResults?: number; } export interface LauncherJellyfinConfig { diff --git a/package.json b/package.json index eaa783c9..d06b5470 100644 --- a/package.json +++ b/package.json @@ -114,8 +114,6 @@ "author": "", "license": "GPL-3.0-or-later", "dependencies": { - "@fontsource-variable/geist": "^5.2.8", - "@fontsource-variable/geist-mono": "^5.2.7", "@xhayper/discord-rpc": "^1.3.4", "axios": "^1.20.0", "commander": "^14.0.3", @@ -133,7 +131,6 @@ "electron": "43.7.7", "electron-builder": "26.16.1", "esbuild": "^0.25.12", - "eslint": "^10.8.0", "prettier": "^3.8.1", "typescript": "^5.9.3", "undici": "7.29.1" diff --git a/plugin/subminer/bootstrap.lua b/plugin/subminer/bootstrap.lua index d6324ab8..ec84accd 100644 --- a/plugin/subminer/bootstrap.lua +++ b/plugin/subminer/bootstrap.lua @@ -56,9 +56,6 @@ function M.init() ctx.binary = make_lazy_proxy("binary", function() return require("binary").create(ctx) end) - ctx.hover = make_lazy_proxy("hover", function() - return require("hover").create(ctx) - end) ctx.process = make_lazy_proxy("process", function() return require("process").create(ctx) end) diff --git a/plugin/subminer/hover.lua b/plugin/subminer/hover.lua deleted file mode 100644 index d5287bc5..00000000 --- a/plugin/subminer/hover.lua +++ /dev/null @@ -1,430 +0,0 @@ -local M = {} - -local DEFAULT_HOVER_BASE_COLOR = "FFFFFF" -local DEFAULT_HOVER_COLOR = "C6A0F6" - -function M.create(ctx) - local mp = ctx.mp - local msg = ctx.msg - local utils = ctx.utils - local state = ctx.state - - local function to_hex_color(input) - if type(input) ~= "string" then - return nil - end - - local hex = input:gsub("[%#%']", ""):gsub("^0x", "") - if #hex ~= 6 and #hex ~= 3 then - return nil - end - if #hex == 3 then - return hex:sub(1, 1) .. hex:sub(1, 1) .. hex:sub(2, 2) .. hex:sub(2, 2) .. hex:sub(3, 3) .. hex:sub(3, 3) - end - return hex - end - - local function fix_ass_color(input, fallback) - local hex = to_hex_color(input) - if not hex then - return fallback or DEFAULT_HOVER_BASE_COLOR - end - local r, g, b = hex:sub(1, 2), hex:sub(3, 4), hex:sub(5, 6) - return b .. g .. r - end - - local function sanitize_hover_ass_color(input, fallback_rgb) - local fallback = fix_ass_color(fallback_rgb or DEFAULT_HOVER_COLOR, DEFAULT_HOVER_COLOR) - local converted = fix_ass_color(input, fallback) - if converted == "000000" then - return fallback - end - return converted - end - - local function escape_ass_text(text) - return (text or ""):gsub("\\", "\\\\"):gsub("{", "\\{"):gsub("}", "\\}"):gsub("\n", "\\N") - end - - local function resolve_osd_dimensions() - local width = mp.get_property_number("osd-width", 0) or 0 - local height = mp.get_property_number("osd-height", 0) or 0 - - if width <= 0 or height <= 0 then - local osd_dims = mp.get_property_native("osd-dimensions") - if type(osd_dims) == "table" and type(osd_dims.w) == "number" and osd_dims.w > 0 then - width = osd_dims.w - end - if type(osd_dims) == "table" and type(osd_dims.h) == "number" and osd_dims.h > 0 then - height = osd_dims.h - end - end - - if width <= 0 then - width = 1280 - end - if height <= 0 then - height = 720 - end - - return width, height - end - - local function resolve_metrics() - local sub_font_size = mp.get_property_number("sub-font-size", 36) or 36 - local sub_scale = mp.get_property_number("sub-scale", 1) or 1 - local sub_scale_by_window = mp.get_property_bool("sub-scale-by-window", true) == true - local sub_pos = mp.get_property_number("sub-pos", 100) or 100 - local sub_margin_y = mp.get_property_number("sub-margin-y", 0) or 0 - local sub_font = mp.get_property("sub-font", "sans-serif") or "sans-serif" - local sub_spacing = mp.get_property_number("sub-spacing", 0) or 0 - local sub_bold = mp.get_property_bool("sub-bold", false) == true - local sub_italic = mp.get_property_bool("sub-italic", false) == true - local sub_border_size = mp.get_property_number("sub-border-size", 2) or 2 - local sub_shadow_offset = mp.get_property_number("sub-shadow-offset", 0) or 0 - local osd_w, osd_h = resolve_osd_dimensions() - local window_scale = 1 - if sub_scale_by_window and osd_h > 0 then - window_scale = osd_h / 720 - end - local effective_margin_y = sub_margin_y * window_scale - - return { - font_size = sub_font_size * (sub_scale > 0 and sub_scale or 1) * window_scale, - pos = sub_pos, - margin_y = effective_margin_y, - font = sub_font, - spacing = sub_spacing, - bold = sub_bold, - italic = sub_italic, - border = sub_border_size * window_scale, - shadow = sub_shadow_offset * window_scale, - base_color = fix_ass_color(mp.get_property("sub-color"), DEFAULT_HOVER_BASE_COLOR), - hover_color = sanitize_hover_ass_color(nil, DEFAULT_HOVER_COLOR), - } - end - - local function get_subtitle_ass_property() - local ass_text = mp.get_property("sub-text/ass") - if ass_text ~= nil then - if type(ass_text) == "string" and ass_text ~= "" then - return ass_text - end - return nil - end - ass_text = mp.get_property("sub-text-ass") - if type(ass_text) == "string" and ass_text ~= "" then - return ass_text - end - return nil - end - - local function plain_text_and_ass_map(text) - local plain = {} - local map = {} - local plain_len = 0 - local i = 1 - local text_len = #text - - while i <= text_len do - local ch = text:sub(i, i) - if ch == "{" then - local close = text:find("}", i + 1, true) - if not close then - break - end - i = close + 1 - elseif ch == "\\" then - local esc = text:sub(i + 1, i + 1) - if esc == "N" or esc == "n" then - plain_len = plain_len + 1 - plain[plain_len] = "\n" - map[plain_len] = i - i = i + 2 - elseif esc == "h" then - plain_len = plain_len + 1 - plain[plain_len] = " " - map[plain_len] = i - i = i + 2 - elseif esc == "{" then - plain_len = plain_len + 1 - plain[plain_len] = "{" - map[plain_len] = i - i = i + 2 - elseif esc == "}" then - plain_len = plain_len + 1 - plain[plain_len] = "}" - map[plain_len] = i - i = i + 2 - elseif esc == "\\" then - plain_len = plain_len + 1 - plain[plain_len] = "\\" - map[plain_len] = i - i = i + 2 - else - local seq_end = i + 1 - while seq_end <= text_len and text:sub(seq_end, seq_end):match("[%a]") do - seq_end = seq_end + 1 - end - if text:sub(seq_end, seq_end) == "(" then - local close = text:find(")", seq_end, true) - if close then - i = close + 1 - else - i = seq_end + 1 - end - else - i = seq_end + 1 - end - end - else - plain_len = plain_len + 1 - plain[plain_len] = ch - map[plain_len] = i - i = i + 1 - end - end - - return table.concat(plain), map - end - - local function find_hover_span(payload, plain) - local source_len = #plain - local cursor = 1 - for _, token in ipairs(payload.tokens or {}) do - if type(token) == "table" and type(token.text) == "string" and token.text ~= "" then - local token_text = token.text - local start_pos = nil - local end_pos = nil - - if type(token.startPos) == "number" and type(token.endPos) == "number" then - if token.startPos >= 0 and token.endPos >= token.startPos then - local candidate_start = token.startPos + 1 - local candidate_stop = token.endPos - if candidate_start >= 1 and candidate_stop <= source_len and candidate_stop >= candidate_start and plain:sub(candidate_start, candidate_stop) == token_text then - start_pos = candidate_start - end_pos = candidate_stop - end - end - end - - if not start_pos or not end_pos then - local fallback_start, fallback_stop = plain:find(token_text, cursor, true) - if not fallback_start then - fallback_start, fallback_stop = plain:find(token_text, 1, true) - end - start_pos, end_pos = fallback_start, fallback_stop - end - - if start_pos and end_pos then - if token.index == payload.hoveredTokenIndex then - return start_pos, end_pos - end - cursor = end_pos + 1 - end - end - end - - return nil - end - - local function inject_hover_color_to_ass(raw_ass, plain_map, hover_start, hover_end, hover_color, base_color) - if hover_start == nil or hover_end == nil then - return raw_ass - end - - local raw_open_idx = plain_map[hover_start] or 1 - local raw_close_idx = plain_map[hover_end + 1] or (#raw_ass + 1) - if raw_open_idx < 1 then - raw_open_idx = 1 - end - if raw_close_idx < 1 then - raw_close_idx = 1 - end - if raw_open_idx > #raw_ass + 1 then - raw_open_idx = #raw_ass + 1 - end - if raw_close_idx > #raw_ass + 1 then - raw_close_idx = #raw_ass + 1 - end - - local before = raw_ass:sub(1, raw_open_idx - 1) - local hovered = raw_ass:sub(raw_open_idx, raw_close_idx - 1) - local after = raw_ass:sub(raw_close_idx) - local hover_suffix = string.format("\\1c&H%s&", hover_color) - - -- Keep hover foreground stable even when inline ASS override tags (\1c/\c/\r) appear inside token. - hovered = hovered:gsub("{([^}]*)}", function(inner) - if inner:find("\\1c&H", 1, true) or inner:find("\\c&H", 1, true) or inner:find("\\r", 1, true) then - return "{" .. inner .. hover_suffix .. "}" - end - return "{" .. inner .. "}" - end) - - local open_tag = string.format("{\\1c&H%s&}", hover_color) - local close_tag = string.format("{\\1c&H%s&}", base_color) - return before .. open_tag .. hovered .. close_tag .. after - end - - local function build_hover_subtitle_content(payload) - local source_ass = get_subtitle_ass_property() - if type(source_ass) == "string" and source_ass ~= "" then - state.hover_highlight.cached_ass = source_ass - else - source_ass = state.hover_highlight.cached_ass - end - if type(source_ass) ~= "string" or source_ass == "" then - return nil - end - - local plain_source, plain_map = plain_text_and_ass_map(source_ass) - if type(plain_source) ~= "string" or plain_source == "" then - return nil - end - - local hover_start, hover_end = find_hover_span(payload, plain_source) - if not hover_start or not hover_end then - return nil - end - - local metrics = resolve_metrics() - local hover_color = sanitize_hover_ass_color(payload.colors and payload.colors.hover or nil, DEFAULT_HOVER_COLOR) - local base_color = fix_ass_color(payload.colors and payload.colors.base or nil, metrics.base_color) - return inject_hover_color_to_ass(source_ass, plain_map, hover_start, hover_end, hover_color, base_color) - end - - local function clear_hover_overlay() - if state.hover_highlight.clear_timer then - state.hover_highlight.clear_timer:kill() - state.hover_highlight.clear_timer = nil - end - if state.hover_highlight.overlay_active then - if type(state.hover_highlight.saved_sub_visibility) == "string" then - mp.set_property("sub-visibility", state.hover_highlight.saved_sub_visibility) - else - mp.set_property("sub-visibility", "yes") - end - if type(state.hover_highlight.saved_secondary_sub_visibility) == "string" then - mp.set_property("secondary-sub-visibility", state.hover_highlight.saved_secondary_sub_visibility) - end - state.hover_highlight.saved_sub_visibility = nil - state.hover_highlight.saved_secondary_sub_visibility = nil - state.hover_highlight.overlay_active = false - end - mp.set_osd_ass(0, 0, "") - state.hover_highlight.payload = nil - state.hover_highlight.revision = -1 - state.hover_highlight.cached_ass = nil - state.hover_highlight.last_hover_update_ts = 0 - end - - local function schedule_hover_clear(delay_seconds) - if state.hover_highlight.clear_timer then - state.hover_highlight.clear_timer:kill() - state.hover_highlight.clear_timer = nil - end - state.hover_highlight.clear_timer = mp.add_timeout(delay_seconds or 0.08, function() - state.hover_highlight.clear_timer = nil - clear_hover_overlay() - end) - end - - local function render_hover_overlay(payload) - if not payload or payload.hoveredTokenIndex == nil or payload.subtitle == nil then - clear_hover_overlay() - return - end - - local ass = build_hover_subtitle_content(payload) - if not ass then - return - end - - local osd_w, osd_h = resolve_osd_dimensions() - local metrics = resolve_metrics() - local osd_dims = mp.get_property_native("osd-dimensions") - local ml = (type(osd_dims) == "table" and type(osd_dims.ml) == "number") and osd_dims.ml or 0 - local mr = (type(osd_dims) == "table" and type(osd_dims.mr) == "number") and osd_dims.mr or 0 - local mt = (type(osd_dims) == "table" and type(osd_dims.mt) == "number") and osd_dims.mt or 0 - local mb = (type(osd_dims) == "table" and type(osd_dims.mb) == "number") and osd_dims.mb or 0 - local usable_w = math.max(1, osd_w - ml - mr) - local usable_h = math.max(1, osd_h - mt - mb) - local anchor_x = math.floor(ml + usable_w / 2) - local baseline_adjust = (metrics.border + metrics.shadow) * 5 - local anchor_y = math.floor(mt + (usable_h * metrics.pos / 100) - metrics.margin_y + baseline_adjust) - local font_size = math.max(8, metrics.font_size) - local anchor_tag = string.format( - "{\\an2\\q2\\pos(%d,%d)\\fn%s\\fs%g\\b%d\\i%d\\fsp%g\\bord%g\\shad%g\\1c&H%s&}", - anchor_x, - anchor_y, - escape_ass_text(metrics.font), - font_size, - metrics.bold and 1 or 0, - metrics.italic and 1 or 0, - metrics.spacing, - metrics.border, - metrics.shadow, - metrics.base_color - ) - if not state.hover_highlight.overlay_active then - state.hover_highlight.saved_sub_visibility = mp.get_property("sub-visibility") - state.hover_highlight.saved_secondary_sub_visibility = mp.get_property("secondary-sub-visibility") - mp.set_property("sub-visibility", "no") - mp.set_property("secondary-sub-visibility", "no") - state.hover_highlight.overlay_active = true - end - mp.set_osd_ass(osd_w, osd_h, anchor_tag .. ass) - end - - local function handle_hover_message(payload_json) - local parsed, parse_error = utils.parse_json(payload_json) - if not parsed then - msg.warn("Invalid hover-highlight payload: " .. tostring(parse_error)) - clear_hover_overlay() - return - end - - if type(parsed.revision) ~= "number" then - clear_hover_overlay() - return - end - - if parsed.revision < state.hover_highlight.revision then - return - end - - if type(parsed.hoveredTokenIndex) == "number" and type(parsed.tokens) == "table" then - if state.hover_highlight.clear_timer then - state.hover_highlight.clear_timer:kill() - state.hover_highlight.clear_timer = nil - end - state.hover_highlight.revision = parsed.revision - state.hover_highlight.payload = parsed - state.hover_highlight.last_hover_update_ts = mp.get_time() or 0 - render_hover_overlay(state.hover_highlight.payload) - return - end - - local now = mp.get_time() or 0 - local elapsed_since_hover = now - (state.hover_highlight.last_hover_update_ts or 0) - state.hover_highlight.revision = parsed.revision - state.hover_highlight.payload = nil - if state.hover_highlight.overlay_active then - if elapsed_since_hover > 0.35 then - return - end - schedule_hover_clear(0.08) - else - clear_hover_overlay() - end - end - - return { - HOVER_MESSAGE_NAME = "subminer-hover-token", - HOVER_MESSAGE_NAME_LEGACY = "yomipv-hover-token", - handle_hover_message = handle_hover_message, - clear_hover_overlay = clear_hover_overlay, - } -end - -return M diff --git a/plugin/subminer/lifecycle.lua b/plugin/subminer/lifecycle.lua index 18f001ef..9c3cd60d 100644 --- a/plugin/subminer/lifecycle.lua +++ b/plugin/subminer/lifecycle.lua @@ -10,7 +10,6 @@ function M.create(ctx) local state = ctx.state local options_helper = ctx.options_helper local process = ctx.process - local hover = ctx.hover local subminer_log = ctx.log.subminer_log local show_osd = ctx.log.show_osd @@ -308,7 +307,6 @@ function M.create(ctx) local function on_shutdown() next_auto_start_retry_generation() - hover.clear_hover_overlay() process.disarm_auto_play_ready_gate() process.stop_overlay_loading_osd() clear_pending_visible_overlay_hide() @@ -326,13 +324,9 @@ function M.create(ctx) mp.register_event("start-file", on_start_file) mp.register_event("file-loaded", on_file_loaded) mp.register_event("shutdown", on_shutdown) - mp.register_event("file-loaded", function() - hover.clear_hover_overlay() - end) mp.register_event("end-file", function(event) process.stop_overlay_loading_osd() process.disarm_auto_play_ready_gate() - hover.clear_hover_overlay() local reason = type(event) == "table" and event.reason or nil if is_reload_end_file(reason) then state.pending_reload_media_identity = state.current_media_identity or resolve_media_identity() @@ -352,15 +346,6 @@ function M.create(ctx) hide_visible_overlay_after_end_file() end end) - mp.register_event("shutdown", function() - hover.clear_hover_overlay() - end) - mp.add_hook("on_unload", 10, function() - hover.clear_hover_overlay() - end) - mp.observe_property("sub-start", "native", function() - hover.clear_hover_overlay() - end) end return { diff --git a/plugin/subminer/messages.lua b/plugin/subminer/messages.lua index c6e1c63e..0d79d4bd 100644 --- a/plugin/subminer/messages.lua +++ b/plugin/subminer/messages.lua @@ -4,7 +4,6 @@ function M.create(ctx) local mp = ctx.mp local opts = ctx.opts local process = ctx.process - local hover = ctx.hover local ui = ctx.ui local state = ctx.state @@ -49,12 +48,6 @@ function M.create(ctx) mp.register_script_message("subminer-overlay-loading-ready", function() process.stop_overlay_loading_osd() end) - mp.register_script_message(hover.HOVER_MESSAGE_NAME, function(payload_json) - hover.handle_hover_message(payload_json) - end) - mp.register_script_message(hover.HOVER_MESSAGE_NAME_LEGACY, function(payload_json) - hover.handle_hover_message(payload_json) - end) mp.register_script_message("subminer-stats-toggle", function() if opts.osd_messages then mp.osd_message("Stats: press ` (backtick) in overlay", 3) diff --git a/plugin/subminer/state.lua b/plugin/subminer/state.lua index 784054f7..e2ec942e 100644 --- a/plugin/subminer/state.lua +++ b/plugin/subminer/state.lua @@ -8,16 +8,6 @@ function M.new() binary_available = false, binary_path = nil, detected_backend = nil, - hover_highlight = { - revision = -1, - payload = nil, - saved_sub_visibility = nil, - saved_secondary_sub_visibility = nil, - overlay_active = false, - cached_ass = nil, - clear_timer = nil, - last_hover_update_ts = 0, - }, auto_play_ready_gate_armed = false, auto_play_ready_should_resume_playback = false, auto_play_ready_timeout = nil, diff --git a/scripts/test-plugin-lua-compat.lua b/scripts/test-plugin-lua-compat.lua index e0549176..4cbec1ad 100644 --- a/scripts/test-plugin-lua-compat.lua +++ b/scripts/test-plugin-lua-compat.lua @@ -1,6 +1,5 @@ local MODULE_PATHS = { "plugin/subminer/bootstrap.lua", - "plugin/subminer/hover.lua", "plugin/subminer/environment.lua", "plugin/subminer/version.lua", } diff --git a/scripts/test-plugin-start-gate.lua b/scripts/test-plugin-start-gate.lua index 73be4376..76132c48 100644 --- a/scripts/test-plugin-start-gate.lua +++ b/scripts/test-plugin-start-gate.lua @@ -267,7 +267,6 @@ local function run_plugin_scenario(config) package.loaded["binary"] = nil package.loaded["bootstrap"] = nil package.loaded["environment"] = nil - package.loaded["hover"] = nil package.loaded["init"] = nil package.loaded["lifecycle"] = nil package.loaded["log"] = nil diff --git a/src/anki-integration.ts b/src/anki-integration.ts index 495539b2..ce8001c6 100644 --- a/src/anki-integration.ts +++ b/src/anki-integration.ts @@ -41,7 +41,6 @@ import type { NPlusOneMatchMode, SubtitleMiningContext } from './types/subtitle' import { DEFAULT_ANKI_CONNECT_CONFIG } from './config'; import { getConfiguredWordFieldCandidates, - getConfiguredWordFieldName, getPreferredWordValueFromExtractedFields, } from './anki-field-config'; import { createLogger } from './logger'; @@ -1632,10 +1631,6 @@ export class AnkiIntegration { return sentenceCardConfig.audioField || 'SentenceAudio'; } - private getConfiguredWordFieldName(): string { - return getConfiguredWordFieldName(this.config); - } - private getConfiguredWordFieldCandidates(): string[] { return getConfiguredWordFieldCandidates(this.config); } diff --git a/src/anki-integration/runtime.ts b/src/anki-integration/runtime.ts index d00d9b48..013bbd3b 100644 --- a/src/anki-integration/runtime.ts +++ b/src/anki-integration/runtime.ts @@ -1,10 +1,6 @@ import { DEFAULT_ANKI_CONNECT_CONFIG } from '../config'; import type { AnkiConnectConfig } from '../types/anki'; -import { - getKnownWordCacheLifecycleConfig, - getKnownWordCacheRefreshIntervalMinutes, - getKnownWordCacheScopeForConfig, -} from './known-word-cache'; +import { getKnownWordCacheLifecycleConfig } from './known-word-cache'; export interface AnkiIntegrationRuntimeProxyServer { start(options: { host: string; port: number; upstreamUrl: string }): void; @@ -255,14 +251,6 @@ export class AnkiIntegrationRuntime { return getKnownWordCacheLifecycleConfig(config); } - private getKnownWordRefreshIntervalMinutes(config: AnkiConnectConfig): number { - return getKnownWordCacheRefreshIntervalMinutes(config); - } - - private getKnownWordCacheScopeForConfig(config: AnkiConnectConfig): string { - return getKnownWordCacheScopeForConfig(config); - } - getOrCreateProxyServer(): AnkiIntegrationRuntimeProxyServer { if (!this.proxyServer) { this.proxyServer = this.deps.proxyServerFactory(); diff --git a/src/config/definitions/defaults-integrations.ts b/src/config/definitions/defaults-integrations.ts index c0531f40..5622ef59 100644 --- a/src/config/definitions/defaults-integrations.ts +++ b/src/config/definitions/defaults-integrations.ts @@ -133,9 +133,7 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick< enabled: false, accessToken: '', characterDictionary: { - refreshTtlHours: 168, maxLoaded: 3, - evictionPolicy: 'delete', profileScope: 'all', collapsibleSections: { description: false, diff --git a/src/config/definitions/domain-registry.test.ts b/src/config/definitions/domain-registry.test.ts index f96bff6b..35094a4d 100644 --- a/src/config/definitions/domain-registry.test.ts +++ b/src/config/definitions/domain-registry.test.ts @@ -37,8 +37,6 @@ function collectConfigLeafPaths(config: ResolvedConfig): string[] { // an allowlist entry. Only allowlist a path when the registry is genuinely // the wrong surface for it. const UNDOCUMENTED_LEAVES: ReadonlySet = new Set([ - 'anilist.characterDictionary.evictionPolicy', - 'anilist.characterDictionary.refreshTtlHours', 'keybindings', 'subtitleStyle.backdropFilter', 'subtitleStyle.backgroundColor', diff --git a/src/config/resolve/integrations.ts b/src/config/resolve/integrations.ts index 952e6362..a2faddbd 100644 --- a/src/config/resolve/integrations.ts +++ b/src/config/resolve/integrations.ts @@ -94,27 +94,6 @@ export function applyIntegrationConfig(context: ResolveContext): void { if (isObject(src.anilist.characterDictionary)) { const characterDictionary = src.anilist.characterDictionary; - const refreshTtlHours = asNumber(characterDictionary.refreshTtlHours); - if (refreshTtlHours !== undefined) { - const normalized = Math.min(24 * 365, Math.max(1, Math.floor(refreshTtlHours))); - if (normalized !== refreshTtlHours) { - warn( - 'anilist.characterDictionary.refreshTtlHours', - characterDictionary.refreshTtlHours, - normalized, - 'Out of range; clamped to 1..8760 hours.', - ); - } - resolved.anilist.characterDictionary.refreshTtlHours = normalized; - } else if (characterDictionary.refreshTtlHours !== undefined) { - warn( - 'anilist.characterDictionary.refreshTtlHours', - characterDictionary.refreshTtlHours, - resolved.anilist.characterDictionary.refreshTtlHours, - 'Expected number.', - ); - } - const maxLoaded = asNumber(characterDictionary.maxLoaded); if (maxLoaded !== undefined) { const normalized = Math.min(20, Math.max(1, Math.floor(maxLoaded))); @@ -136,28 +115,6 @@ export function applyIntegrationConfig(context: ResolveContext): void { ); } - const evictionPolicyRaw = asString(characterDictionary.evictionPolicy); - if (evictionPolicyRaw !== undefined) { - const evictionPolicy = evictionPolicyRaw.trim().toLowerCase(); - if (evictionPolicy === 'disable' || evictionPolicy === 'delete') { - resolved.anilist.characterDictionary.evictionPolicy = evictionPolicy; - } else { - warn( - 'anilist.characterDictionary.evictionPolicy', - characterDictionary.evictionPolicy, - resolved.anilist.characterDictionary.evictionPolicy, - "Expected one of: 'disable', 'delete'.", - ); - } - } else if (characterDictionary.evictionPolicy !== undefined) { - warn( - 'anilist.characterDictionary.evictionPolicy', - characterDictionary.evictionPolicy, - resolved.anilist.characterDictionary.evictionPolicy, - 'Expected string.', - ); - } - const profileScopeRaw = asString(characterDictionary.profileScope); if (profileScopeRaw !== undefined) { const profileScope = profileScopeRaw.trim().toLowerCase(); diff --git a/src/config/resolve/jellyfin.test.ts b/src/config/resolve/jellyfin.test.ts index d8949be8..451d0852 100644 --- a/src/config/resolve/jellyfin.test.ts +++ b/src/config/resolve/jellyfin.test.ts @@ -97,9 +97,7 @@ test('anilist character dictionary fields are parsed, clamped, and enum-validate const { context, warnings } = createResolveContext({ anilist: { characterDictionary: { - refreshTtlHours: 0, maxLoaded: 99, - evictionPolicy: 'purge' as never, profileScope: 'global' as never, collapsibleSections: { description: true, @@ -112,9 +110,7 @@ test('anilist character dictionary fields are parsed, clamped, and enum-validate applyIntegrationConfig(context); - assert.equal(context.resolved.anilist.characterDictionary.refreshTtlHours, 1); assert.equal(context.resolved.anilist.characterDictionary.maxLoaded, 20); - assert.equal(context.resolved.anilist.characterDictionary.evictionPolicy, 'delete'); assert.equal(context.resolved.anilist.characterDictionary.profileScope, 'all'); assert.equal(context.resolved.anilist.characterDictionary.collapsibleSections.description, true); assert.equal( @@ -124,9 +120,7 @@ test('anilist character dictionary fields are parsed, clamped, and enum-validate assert.equal(context.resolved.anilist.characterDictionary.collapsibleSections.voicedBy, true); const warnedPaths = warnings.map((warning) => warning.path); - assert.ok(warnedPaths.includes('anilist.characterDictionary.refreshTtlHours')); assert.ok(warnedPaths.includes('anilist.characterDictionary.maxLoaded')); - assert.ok(warnedPaths.includes('anilist.characterDictionary.evictionPolicy')); assert.ok(warnedPaths.includes('anilist.characterDictionary.profileScope')); assert.ok( warnedPaths.includes('anilist.characterDictionary.collapsibleSections.characterInformation'), @@ -134,7 +128,7 @@ test('anilist character dictionary fields are parsed, clamped, and enum-validate }); test('yomitan externalProfilePath is trimmed and invalid values warn', () => { - const { context, warnings } = createResolveContext({ + const { context } = createResolveContext({ yomitan: { externalProfilePath: ' /tmp/gsm-profile ', }, diff --git a/src/config/template.ts b/src/config/template.ts index c177fc5b..6f9e5613 100644 --- a/src/config/template.ts +++ b/src/config/template.ts @@ -18,10 +18,6 @@ const TOP_LEVEL_SECTION_DESCRIPTION_BY_KEY = new Map( CONFIG_TEMPLATE_SECTIONS.map((section) => [String(section.key), section.description[0] ?? '']), ); const SUBTITLE_CSS_SCOPES: SubtitleCssScope[] = ['primary', 'secondary', 'sidebar']; -const HIDDEN_TEMPLATE_PATHS = [ - 'anilist.characterDictionary.evictionPolicy', - 'anilist.characterDictionary.refreshTtlHours', -]; function normalizeCommentText(value: string): string { return value.replace(/\s+/g, ' ').replace(/\*\//g, '*\\/').trim(); @@ -176,9 +172,6 @@ function renderSection( function createTemplateConfig(config: ResolvedConfig): ResolvedConfig { const templateConfig = deepCloneConfig(config); foldSubtitleCssManagedDefaults(templateConfig); - for (const hiddenPath of HIDDEN_TEMPLATE_PATHS) { - deleteValueAtPath(templateConfig, hiddenPath); - } if (templateConfig.keybindings.length === 0) { templateConfig.keybindings = DEFAULT_KEYBINDINGS.map((binding) => ({ key: binding.key, diff --git a/src/core/services/anilist/anilist-updater.ts b/src/core/services/anilist/anilist-updater.ts index 4e0e35c8..0b80435f 100644 --- a/src/core/services/anilist/anilist-updater.ts +++ b/src/core/services/anilist/anilist-updater.ts @@ -87,20 +87,6 @@ export interface GuessAnilistMediaInfoDeps { runGuessit: (target: string) => Promise; } -function firstString(value: unknown): string | null { - if (typeof value === 'string') { - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : null; - } - if (Array.isArray(value)) { - for (const item of value) { - const candidate = firstString(item); - if (candidate) return candidate; - } - } - return null; -} - function normalizeGuessitTitlePart(value: string): string { return value.replace(/[._]+/g, ' ').replace(/-/g, ' ').replace(/\s+/g, ' ').trim(); } diff --git a/src/core/services/immersion-tracker-service.ts b/src/core/services/immersion-tracker-service.ts index 98f44b0b..97381d8b 100644 --- a/src/core/services/immersion-tracker-service.ts +++ b/src/core/services/immersion-tracker-service.ts @@ -147,16 +147,10 @@ import { enqueueWrite } from './immersion-tracker/queue'; import { nowMs } from './immersion-tracker/time'; import { DEFAULT_BATCH_SIZE, - DEFAULT_DAILY_ROLLUP_RETENTION_MS, - DEFAULT_EVENTS_RETENTION_MS, DEFAULT_FLUSH_INTERVAL_MS, DEFAULT_MAINTENANCE_INTERVAL_MS, DEFAULT_MAX_PAYLOAD_BYTES, - DEFAULT_MONTHLY_ROLLUP_RETENTION_MS, DEFAULT_QUEUE_CAP, - DEFAULT_SESSIONS_RETENTION_MS, - DEFAULT_TELEMETRY_RETENTION_MS, - DEFAULT_VACUUM_INTERVAL_MS, EVENT_CARD_MINED, EVENT_LOOKUP, EVENT_MEDIA_BUFFER, diff --git a/src/core/services/immersion-tracker/__tests__/query.test.ts b/src/core/services/immersion-tracker/__tests__/query.test.ts index eecaac2d..54b242ef 100644 --- a/src/core/services/immersion-tracker/__tests__/query.test.ts +++ b/src/core/services/immersion-tracker/__tests__/query.test.ts @@ -2769,7 +2769,6 @@ test('getSessionWordsByLine joins word occurrences through imm_words.id', () => try { ensureSchema(db); - const stmts = createTrackerPreparedStatements(db); const startedAtMs = 1_735_732_800_000; const videoId = getOrCreateVideoRecord(db, '/tmp/session-words-by-line.mkv', { canonicalTitle: 'Episode', diff --git a/src/core/services/immersion-tracker/query-library.ts b/src/core/services/immersion-tracker/query-library.ts index bfa4a672..a496ff45 100644 --- a/src/core/services/immersion-tracker/query-library.ts +++ b/src/core/services/immersion-tracker/query-library.ts @@ -18,7 +18,6 @@ import type { } from './types'; import { ACTIVE_SESSION_METRICS_CTE, - SESSION_WORD_COUNTS_CTE, SESSION_WORD_COUNTS_SELECT, fromDbTimestamp, resolvedCoverBlobExpr, diff --git a/src/core/services/immersion-tracker/query-maintenance.ts b/src/core/services/immersion-tracker/query-maintenance.ts index b91e9615..98dc43be 100644 --- a/src/core/services/immersion-tracker/query-maintenance.ts +++ b/src/core/services/immersion-tracker/query-maintenance.ts @@ -16,7 +16,6 @@ import { deriveStoredPartOfSpeech } from '../tokenizer/part-of-speech'; import { cleanupUnusedCoverArtBlobHash, findSharedCoverBlobHash, - toDbMs, toDbTimestamp, } from './query-shared'; diff --git a/src/core/services/immersion-tracker/storage.ts b/src/core/services/immersion-tracker/storage.ts index 4651731d..493d945f 100644 --- a/src/core/services/immersion-tracker/storage.ts +++ b/src/core/services/immersion-tracker/storage.ts @@ -9,7 +9,7 @@ import { ensureLexicalDailyRollupTables, markLexicalDailyRollupsReady } from './ import { isVocabularyStatsRowVisible, type VocabularyVisibilityRow } from './vocabulary-visibility'; import { SCHEMA_VERSION } from './types'; import type { QueuedWrite, VideoMetadata, YoutubeVideoMetadata } from './types'; -import { toDbMs, toDbTimestamp } from './query-shared'; +import { toDbTimestamp } from './query-shared'; export interface TrackerPreparedStatements { telemetryInsertStmt: ReturnType; diff --git a/src/core/services/overlay-visibility.ts b/src/core/services/overlay-visibility.ts index 49a3316b..5d540b21 100644 --- a/src/core/services/overlay-visibility.ts +++ b/src/core/services/overlay-visibility.ts @@ -74,9 +74,6 @@ export function updateVisibleOverlayVisibility(args: { overlayInteractionActive?: boolean; mainWindow: BrowserWindow | null; windowTracker: BaseWindowTracker | null; - lastKnownWindowsForegroundProcessName?: string | null; - windowsOverlayProcessName?: string | null; - windowsFocusHandoffGraceActive?: boolean; macOSForegroundProbeActive?: boolean; trackerNotReadyWarningShown: boolean; setTrackerNotReadyWarningShown: (shown: boolean) => void; @@ -168,26 +165,6 @@ export function updateVisibleOverlayVisibility(args: { const shouldUseMacOSMousePassthrough = args.isMacOSPlatform && !overlayInteractionActive; const shouldDefaultToPassthrough = args.isWindowsPlatform || forceMousePassthrough || shouldReleaseMacOSOverlayLevel; - const windowsForegroundProcessName = - args.lastKnownWindowsForegroundProcessName?.trim().toLowerCase() ?? null; - const windowsOverlayProcessName = args.windowsOverlayProcessName?.trim().toLowerCase() ?? null; - const hasWindowsForegroundProcessSignal = - args.isWindowsPlatform && windowsForegroundProcessName !== null; - const isTrackedWindowsTargetFocused = args.windowTracker?.isTargetWindowFocused?.() ?? true; - const isTrackedWindowsTargetMinimized = - args.isWindowsPlatform && - typeof args.windowTracker?.isTargetWindowMinimized === 'function' && - args.windowTracker.isTargetWindowMinimized(); - const shouldPreserveWindowsOverlayDuringFocusHandoff = - args.isWindowsPlatform && - args.windowsFocusHandoffGraceActive === true && - !!args.windowTracker && - (!hasWindowsForegroundProcessSignal || - windowsForegroundProcessName === 'mpv' || - (windowsOverlayProcessName !== null && - windowsForegroundProcessName === windowsOverlayProcessName)) && - !isTrackedWindowsTargetMinimized && - (args.windowTracker.isTracking() || args.windowTracker.getGeometry() !== null); const shouldForcePassiveReshow = args.isWindowsPlatform && !wasVisible; const isNonNativeOverlay = !args.isWindowsPlatform && !args.isMacOSPlatform; const isNonNativePassiveOverlay = isNonNativeOverlay && !overlayInteractionActive; @@ -208,13 +185,6 @@ export function updateVisibleOverlayVisibility(args: { (isNonNativePassiveOverlay && !hasNonNativeInputRegion) || (shouldDefaultToPassthrough && (!isVisibleOverlayFocused || shouldForcePassiveReshow)); const shouldBindTrackedWindowsOverlay = args.isWindowsPlatform && !!args.windowTracker; - const shouldKeepTrackedWindowsOverlayTopmost = - !args.isWindowsPlatform || - !args.windowTracker || - isVisibleOverlayFocused || - isTrackedWindowsTargetFocused || - shouldPreserveWindowsOverlayDuringFocusHandoff || - (hasWindowsForegroundProcessSignal && windowsForegroundProcessName === 'mpv'); if (shouldIgnoreMouseEvents) { applyOverlayClickThrough(mainWindow, args.isWindowsPlatform); } else { diff --git a/src/core/services/session-actions.ts b/src/core/services/session-actions.ts index 5c257be5..b1815380 100644 --- a/src/core/services/session-actions.ts +++ b/src/core/services/session-actions.ts @@ -1,5 +1,4 @@ import type { RuntimeOptionApplyResult, RuntimeOptionId } from '../../types'; -import type { SessionActionId } from '../../types/session-bindings'; import type { SessionActionDispatchRequest } from '../../types/runtime'; export interface SessionActionExecutorDeps { diff --git a/src/core/services/stats-sync/merge-rollups.ts b/src/core/services/stats-sync/merge-rollups.ts index ec36906f..cb5b4f79 100644 --- a/src/core/services/stats-sync/merge-rollups.ts +++ b/src/core/services/stats-sync/merge-rollups.ts @@ -1,4 +1,4 @@ -import { selectAll, type SqlRow, type SyncDb } from './libsql-driver'; +import { selectAll, type SyncDb } from './libsql-driver'; import { nowDbTimestamp, tableExists, type SyncMergeSummary } from './shared'; const LOCAL_DAY_EXPR = `CAST(julianday(CAST(started_at_ms AS REAL) / 1000, 'unixepoch', 'localtime') - 2440587.5 AS INTEGER)`; diff --git a/src/core/services/subtitle-cue-parser.test.ts b/src/core/services/subtitle-cue-parser.test.ts index 99f3b3c9..f924f4ff 100644 --- a/src/core/services/subtitle-cue-parser.test.ts +++ b/src/core/services/subtitle-cue-parser.test.ts @@ -1,7 +1,6 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { parseSrtCues, parseAssCues, parseSubtitleCues } from './subtitle-cue-parser'; -import type { SubtitleCue } from '../../types'; test('parseSrtCues parses basic SRT content', () => { const content = [ diff --git a/src/core/services/tokenizer.ts b/src/core/services/tokenizer.ts index d5cd5e12..1a8e8de0 100644 --- a/src/core/services/tokenizer.ts +++ b/src/core/services/tokenizer.ts @@ -608,14 +608,6 @@ function getYomitanFrequencyRank( return null; } - const normalizedHeadword = token.headword.trim(); - const normalizedSurface = token.surface.trim(); - const isInflectedHeadwordFallback = - matchMode === 'headword' && - normalizedCandidateText === normalizedHeadword && - normalizedSurface.length > 0 && - normalizedSurface !== normalizedHeadword; - return selectBestYomitanFrequencyRank(candidateEntries); } diff --git a/src/core/services/tokenizer/annotation-stage.ts b/src/core/services/tokenizer/annotation-stage.ts index 74145287..11a29417 100644 --- a/src/core/services/tokenizer/annotation-stage.ts +++ b/src/core/services/tokenizer/annotation-stage.ts @@ -390,23 +390,6 @@ function isReduplicatedKanaSfx(text: string): boolean { return chars.slice(0, half).join('') === chars.slice(half).join(''); } -function isReduplicatedKanaSfxWithOptionalTrailingTo(text: string): boolean { - const normalized = normalizeKana(text); - if (!normalized) { - return false; - } - - if (isReduplicatedKanaSfx(normalized)) { - return true; - } - - if (normalized.length <= 1 || !normalized.endsWith('と')) { - return false; - } - - return isReduplicatedKanaSfx(normalized.slice(0, -1)); -} - function hasAdjacentKanaRepeat(text: string): boolean { const normalized = normalizeKana(text); if (!normalized) { diff --git a/src/main.ts b/src/main.ts index 33097905..bbf544bc 100644 --- a/src/main.ts +++ b/src/main.ts @@ -193,14 +193,9 @@ import { createBuildSetOverlayDebugVisualizationEnabledMainDepsHandler, createTrayRuntimeHandlers, createOverlayVisibilityRuntime, - createBroadcastRuntimeOptionsChangedHandler, createGetRuntimeOptionsStateHandler, createGetFieldGroupingResolverHandler, createSetFieldGroupingResolverHandler, - createOpenRuntimeOptionsPaletteHandler, - createRestorePreviousSecondarySubVisibilityHandler, - createSendToActiveOverlayWindowHandler, - createSetOverlayDebugVisualizationEnabledHandler, createLoadSubtitlePositionHandler, createSaveSubtitlePositionHandler, createAppendClipboardVideoToQueueHandler, @@ -297,7 +292,6 @@ import { handleMultiCopyDigit as handleMultiCopyDigitCore, hasMpvWebsocketPlugin, importYomitanDictionaryFromZip, - initializeOverlayAnkiIntegration as initializeOverlayAnkiIntegrationCore, initializeOverlayRuntime as initializeOverlayRuntimeCore, isSubtitleAnnotationUpgrade, isOverlayWindowContentReady, @@ -603,7 +597,6 @@ import { } from './main/anilist-url-guard'; import { ConfigService, - ConfigStartupParseError, DEFAULT_CONFIG, DEFAULT_KEYBINDINGS, generateConfigTemplate, @@ -615,7 +608,6 @@ import { createSubtitlePrefetchService, type SubtitlePrefetchService, } from './core/services/subtitle-prefetch'; -import { buildSubtitleSidebarSourceKey } from './main/runtime/subtitle-prefetch-source'; import { createSubtitlePrefetchInitController } from './main/runtime/subtitle-prefetch-init'; import { createCachedInternalSubtitleTrackExtractor, @@ -623,7 +615,6 @@ import { } from './main/runtime/internal-subtitle-extraction'; import { createRemoteMediaPathDetector } from './main/runtime/network-media-path'; import { applyCharacterDictionarySelection } from './main/character-dictionary-selection'; -import { getSubsyncConfig } from './subsync/utils'; if (process.platform === 'linux') { app.commandLine.appendSwitch('enable-features', 'GlobalShortcutsPortal'); @@ -2798,11 +2789,6 @@ const overlayVisibilityRuntime = createOverlayVisibilityRuntimeService( getOverlayInteractionActive: () => visibleOverlayInteractionRuntime.getVisibleOverlayInteractionActive(), getWindowTracker: () => appState.windowTracker, - getLastKnownWindowsForegroundProcessName: () => - visibleOverlayInteractionRuntime.getLastWindowsVisibleOverlayForegroundProcessName(), - getWindowsOverlayProcessName: () => path.parse(process.execPath).name.toLowerCase(), - getWindowsFocusHandoffGraceActive: () => - visibleOverlayInteractionRuntime.hasWindowsVisibleOverlayFocusHandoffGrace(), getMacOSForegroundProbeActive: () => visibleOverlayInteractionRuntime.getMacOSVisibleOverlayForegroundProbeActive(), getTrackerNotReadyWarningShown: () => appState.trackerNotReadyWarningShown, @@ -3233,7 +3219,6 @@ const { cleanupJellyfinSubtitleCache, runJellyfinCommand, openJellyfinSetupWindow, - getJellyfinClientInfo, ensureMpvConnectedForPlayback, } = composeJellyfinRuntimeHandlers({ getResolvedJellyfinConfigMainDeps: { diff --git a/src/main/character-dictionary-runtime.ts b/src/main/character-dictionary-runtime.ts index 1186775b..77410d8a 100644 --- a/src/main/character-dictionary-runtime.ts +++ b/src/main/character-dictionary-runtime.ts @@ -49,7 +49,6 @@ import type { CharacterDictionaryRuntimeDeps, CharacterDictionarySnapshot, CharacterDictionarySnapshotImage, - CharacterDictionarySnapshotProgress, CharacterDictionarySnapshotProgressCallbacks, CharacterDictionarySnapshotResult, MergedCharacterDictionaryBuildResult, diff --git a/src/main/overlay-visibility-runtime.ts b/src/main/overlay-visibility-runtime.ts index 1a35d503..23b55e78 100644 --- a/src/main/overlay-visibility-runtime.ts +++ b/src/main/overlay-visibility-runtime.ts @@ -15,9 +15,6 @@ export interface OverlayVisibilityRuntimeDeps { getSuspendVisibleOverlay?: () => boolean; getOverlayInteractionActive?: () => boolean; getWindowTracker: () => BaseWindowTracker | null; - getLastKnownWindowsForegroundProcessName?: () => string | null; - getWindowsOverlayProcessName?: () => string | null; - getWindowsFocusHandoffGraceActive?: () => boolean; getMacOSForegroundProbeActive?: () => boolean; getTrackerNotReadyWarningShown: () => boolean; setTrackerNotReadyWarningShown: (shown: boolean) => void; @@ -61,9 +58,6 @@ export function createOverlayVisibilityRuntimeService( overlayInteractionActive: deps.getOverlayInteractionActive?.() ?? false, mainWindow, windowTracker, - lastKnownWindowsForegroundProcessName: deps.getLastKnownWindowsForegroundProcessName?.(), - windowsOverlayProcessName: deps.getWindowsOverlayProcessName?.() ?? null, - windowsFocusHandoffGraceActive: deps.getWindowsFocusHandoffGraceActive?.() ?? false, macOSForegroundProbeActive: deps.getMacOSForegroundProbeActive?.() ?? false, trackerNotReadyWarningShown: deps.getTrackerNotReadyWarningShown(), setTrackerNotReadyWarningShown: (shown: boolean) => { diff --git a/src/main/runtime/overlay-visibility-runtime-main-deps.ts b/src/main/runtime/overlay-visibility-runtime-main-deps.ts index 63549e04..7c151717 100644 --- a/src/main/runtime/overlay-visibility-runtime-main-deps.ts +++ b/src/main/runtime/overlay-visibility-runtime-main-deps.ts @@ -14,10 +14,6 @@ export function createBuildOverlayVisibilityRuntimeMainDepsHandler( getSuspendVisibleOverlay: () => deps.getSuspendVisibleOverlay?.() ?? false, getOverlayInteractionActive: () => deps.getOverlayInteractionActive?.() ?? false, getWindowTracker: () => deps.getWindowTracker(), - getLastKnownWindowsForegroundProcessName: () => - deps.getLastKnownWindowsForegroundProcessName?.() ?? null, - getWindowsOverlayProcessName: () => deps.getWindowsOverlayProcessName?.() ?? null, - getWindowsFocusHandoffGraceActive: () => deps.getWindowsFocusHandoffGraceActive?.() ?? false, getMacOSForegroundProbeActive: () => deps.getMacOSForegroundProbeActive?.() ?? false, getTrackerNotReadyWarningShown: () => deps.getTrackerNotReadyWarningShown(), setTrackerNotReadyWarningShown: (shown: boolean) => deps.setTrackerNotReadyWarningShown(shown), diff --git a/src/main/runtime/visible-overlay-interaction-runtime.ts b/src/main/runtime/visible-overlay-interaction-runtime.ts index 1dd58edd..dc131ec8 100644 --- a/src/main/runtime/visible-overlay-interaction-runtime.ts +++ b/src/main/runtime/visible-overlay-interaction-runtime.ts @@ -78,7 +78,6 @@ export function createVisibleOverlayInteractionRuntime(deps: VisibleOverlayInter const VISIBLE_OVERLAY_BLUR_REFRESH_DELAYS_MS = [0, 25, 100, 250] as const; const WINDOWS_VISIBLE_OVERLAY_Z_ORDER_RETRY_DELAYS_MS = [0, 48, 120, 240, 480] as const; const WINDOWS_VISIBLE_OVERLAY_FOREGROUND_POLL_INTERVAL_MS = 75; - const WINDOWS_VISIBLE_OVERLAY_FOCUS_HANDOFF_GRACE_MS = 200; const LINUX_VISIBLE_OVERLAY_FOCUS_HANDOFF_GRACE_MS = 1_500; // Ignore transient "neither mpv nor overlay is the active window" blips before suppressing // subtitle pointer interaction. Right after playback starts the overlay can briefly become the @@ -93,7 +92,6 @@ export function createVisibleOverlayInteractionRuntime(deps: VisibleOverlayInter let windowsVisibleOverlayForegroundPollInterval: ReturnType | null = null; let windowsOverlayPointerInteractionActive = false; let lastWindowsVisibleOverlayForegroundProcessName: string | null = null; - let lastWindowsVisibleOverlayBlurredAtMs = 0; let lastLinuxVisibleOverlayFollowedMpvAtMs = 0; const linuxPointerForegroundSuppressionGrace: ForegroundSuppressionGraceState = { lossSinceMs: null, @@ -482,15 +480,6 @@ export function createVisibleOverlayInteractionRuntime(deps: VisibleOverlayInter } } - function hasWindowsVisibleOverlayFocusHandoffGrace(): boolean { - return ( - process.platform === 'win32' && - lastWindowsVisibleOverlayBlurredAtMs > 0 && - Date.now() - lastWindowsVisibleOverlayBlurredAtMs <= - WINDOWS_VISIBLE_OVERLAY_FOCUS_HANDOFF_GRACE_MS - ); - } - function shouldPollWindowsVisibleOverlayForegroundProcess(): boolean { if (process.platform !== 'win32' || !overlayManager.getVisibleOverlayVisible()) { return false; @@ -562,9 +551,6 @@ export function createVisibleOverlayInteractionRuntime(deps: VisibleOverlayInter return; } - if (process.platform === 'win32') { - lastWindowsVisibleOverlayBlurredAtMs = Date.now(); - } startMacOSVisibleOverlayForegroundProbe(); clearVisibleOverlayBlurRefreshTimeouts(); for (const delayMs of VISIBLE_OVERLAY_BLUR_REFRESH_DELAYS_MS) { @@ -860,7 +846,6 @@ export function createVisibleOverlayInteractionRuntime(deps: VisibleOverlayInter retargetOverlayWindowTrackerForMpvSocket, requestWindowsVisibleOverlayZOrderSync, scheduleWindowsVisibleOverlayZOrderSyncBurst, - hasWindowsVisibleOverlayFocusHandoffGrace, ensureWindowsVisibleOverlayForegroundPollLoop, clearWindowsVisibleOverlayForegroundPollLoop, scheduleVisibleOverlayBlurRefresh, @@ -881,8 +866,6 @@ export function createVisibleOverlayInteractionRuntime(deps: VisibleOverlayInter windowsOverlayPointerInteractionActive = false; }, getLinuxOverlayInputShapeActive: () => linuxOverlayInputShapeActive, - getLastWindowsVisibleOverlayForegroundProcessName: () => - lastWindowsVisibleOverlayForegroundProcessName, getMacOSVisibleOverlayForegroundProbeActive: () => macOSVisibleOverlayForegroundProbeActive, setLinuxOverlayInteractiveHint: (interactive: boolean) => { linuxOverlayInteractiveHint = interactive; diff --git a/src/renderer/handlers/keyboard.test.ts b/src/renderer/handlers/keyboard.test.ts index 1a685437..cca4b7c1 100644 --- a/src/renderer/handlers/keyboard.test.ts +++ b/src/renderer/handlers/keyboard.test.ts @@ -1464,7 +1464,7 @@ test('keyboard mode: an open modal receives h/l/arrows instead of lookup control }); test('keyboard mode: h/l/arrows typed into a text field are not consumed by lookup controls', async () => { - const { ctx, testGlobals, handlers, setWordCount } = createKeyboardHandlerHarness(); + const { testGlobals, handlers, setWordCount } = createKeyboardHandlerHarness(); try { await handlers.setupMpvInputForwarding(); diff --git a/src/renderer/modals/youtube-track-picker.ts b/src/renderer/modals/youtube-track-picker.ts index ecbcbfdc..efade404 100644 --- a/src/renderer/modals/youtube-track-picker.ts +++ b/src/renderer/modals/youtube-track-picker.ts @@ -31,12 +31,6 @@ export function createYoutubeTrackPickerModal( ctx.dom.youtubePickerStatus.style.color = isError ? '#ed8796' : '#a5adcb'; } - function getTrackLabel(trackId: string): string { - return ( - ctx.state.youtubePickerPayload?.tracks.find((track) => track.id === trackId)?.label ?? '' - ); - } - function renderTrackList(): void { ctx.dom.youtubePickerTracks.replaceChildren(); const payload = ctx.state.youtubePickerPayload; diff --git a/src/types/config.ts b/src/types/config.ts index f5671f62..d3752f1b 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -4,7 +4,6 @@ import type { AiConfig, AiFeatureConfig, AnilistCharacterDictionaryCollapsibleSectionsConfig, - AnilistCharacterDictionaryEvictionPolicy, AnilistCharacterDictionaryProfileScope, AnilistConfig, DiscordPresenceConfig, @@ -345,9 +344,7 @@ export interface ResolvedConfig { enabled: boolean; accessToken: string; characterDictionary: { - refreshTtlHours: number; maxLoaded: number; - evictionPolicy: AnilistCharacterDictionaryEvictionPolicy; profileScope: AnilistCharacterDictionaryProfileScope; collapsibleSections: Required; }; diff --git a/src/types/integrations.ts b/src/types/integrations.ts index 94d4f0e5..c028c974 100644 --- a/src/types/integrations.ts +++ b/src/types/integrations.ts @@ -50,7 +50,6 @@ export interface JimakuConfig { maxEntryResults?: number; } -export type AnilistCharacterDictionaryEvictionPolicy = 'disable' | 'delete'; export type AnilistCharacterDictionaryProfileScope = 'all' | 'active'; export type AnilistCharacterDictionaryCollapsibleSectionKey = | 'description' @@ -64,9 +63,7 @@ export interface AnilistCharacterDictionaryCollapsibleSectionsConfig { } export interface AnilistCharacterDictionaryConfig { - refreshTtlHours?: number; maxLoaded?: number; - evictionPolicy?: AnilistCharacterDictionaryEvictionPolicy; profileScope?: AnilistCharacterDictionaryProfileScope; collapsibleSections?: AnilistCharacterDictionaryCollapsibleSectionsConfig; } diff --git a/src/types/runtime.ts b/src/types/runtime.ts index 6aea5f20..54bd36f5 100644 --- a/src/types/runtime.ts +++ b/src/types/runtime.ts @@ -58,7 +58,6 @@ import type { SubtitleSidebarPlaybackState, SubtitleSidebarSnapshot, SubtitleRendererStyleConfig, - SubtitleStyleConfig, } from './subtitle'; import type { RuntimeOptionApplyResult, diff --git a/src/window-trackers/win32.ts b/src/window-trackers/win32.ts index 2ae25d4b..9468eaa5 100644 --- a/src/window-trackers/win32.ts +++ b/src/window-trackers/win32.ts @@ -13,7 +13,8 @@ const RECT = koffi.struct('RECT', { Bottom: 'int', }); -const MARGINS = koffi.struct('MARGINS', { +// Registered by name for the DwmExtendFrameIntoClientArea prototype below. +koffi.struct('MARGINS', { cxLeftWidth: 'int', cxRightWidth: 'int', cyTopHeight: 'int', diff --git a/tsconfig.typecheck.json b/tsconfig.typecheck.json index 7e91144e..8e283b18 100644 --- a/tsconfig.typecheck.json +++ b/tsconfig.typecheck.json @@ -3,6 +3,7 @@ "compilerOptions": { "rootDir": ".", "noEmit": true, + "noUnusedLocals": true, "declaration": false, "declarationMap": false, "sourceMap": false From 7909fcd2cfde9a9a023d03a306628fe0aeeed395 Mon Sep 17 00:00:00 2001 From: sudacode Date: Mon, 12 Oct 2026 22:11:17 -0700 Subject: [PATCH 4/4] refactor(ipc): remove dead preload methods and the ASS line broadcast (#293) Drop 16 overlay preload methods with no caller (onSubtitleAss, getCurrentSubtitleAss, onVisibility, getOverlayVisibility, openYomitanSettings, quitApp, toggleOverlay, getMecabStatus, setMecabEnabled, getKeybindings, saveControllerPreference, getAnkiConnectStatus, setAnkiConnectEnabled, clearAnkiConnectHistory, cycleRuntimeOption, appendClipboardVideoToQueue) along with their IPC_CHANNELS keys, handlers, validators and dep plumbing. With both ASS consumers gone, stop observing sub-text/ass in mpv and remove the subtitle-ass-change event chain and appState.currentSubAssText. The main-process functions these wrapped stay; tray, CLI, shortcuts and session actions still use them. --- changes/remove-dead-ipc.md | 4 + src/core/services/anki-jimaku-ipc.test.ts | 13 -- src/core/services/anki-jimaku-ipc.ts | 11 - src/core/services/anki-jimaku.test.ts | 55 +---- src/core/services/anki-jimaku.ts | 87 +------ src/core/services/ipc.test.ts | 213 ------------------ src/core/services/ipc.ts | 124 ---------- src/core/services/mpv-properties.ts | 6 - src/core/services/mpv-protocol.test.ts | 23 -- src/core/services/mpv-protocol.ts | 6 - src/core/services/mpv.test.ts | 13 -- src/core/services/mpv.ts | 4 - src/core/services/runtime-options-ipc.test.ts | 19 -- src/core/services/runtime-options-ipc.ts | 16 -- src/main.ts | 50 ---- src/main/dependencies.ts | 63 +----- src/main/ipc-runtime.ts | 3 +- src/main/main-wiring.test.ts | 1 - .../composers/mpv-runtime-composer.test.ts | 1 - src/main/runtime/mpv-client-event-bindings.ts | 3 - src/main/runtime/mpv-main-event-actions.ts | 10 - .../runtime/mpv-main-event-bindings.test.ts | 2 - src/main/runtime/mpv-main-event-bindings.ts | 8 - .../runtime/mpv-main-event-main-deps.test.ts | 1 - src/main/runtime/mpv-main-event-main-deps.ts | 6 - src/main/state.ts | 2 - src/mecab-tokenizer.ts | 23 -- src/preload.ts | 59 ----- src/renderer/handlers/keyboard.test.ts | 1 - src/shared/ipc/contracts.ts | 21 -- src/shared/ipc/validators.ts | 15 -- src/types/runtime.ts | 21 -- 32 files changed, 8 insertions(+), 876 deletions(-) create mode 100644 changes/remove-dead-ipc.md diff --git a/changes/remove-dead-ipc.md b/changes/remove-dead-ipc.md new file mode 100644 index 00000000..5b8146e7 --- /dev/null +++ b/changes/remove-dead-ipc.md @@ -0,0 +1,4 @@ +type: internal +area: overlay + +- Removed 16 overlay preload/IPC methods that nothing called, plus their main-process handlers. SubMiner no longer observes mpv's `sub-text/ass` or sends every ASS line to overlay windows that never listened. diff --git a/src/core/services/anki-jimaku-ipc.test.ts b/src/core/services/anki-jimaku-ipc.test.ts index b189206d..0765ab4e 100644 --- a/src/core/services/anki-jimaku-ipc.test.ts +++ b/src/core/services/anki-jimaku-ipc.test.ts @@ -32,8 +32,6 @@ test('anki/jimaku IPC handlers reject malformed invoke payloads', async () => { let previewCalls = 0; registerAnkiJimakuIpcHandlers( { - setAnkiConnectEnabled: () => {}, - clearAnkiHistory: () => {}, refreshKnownWords: async () => {}, respondFieldGrouping: () => {}, buildKikuMergePreview: async () => { @@ -138,8 +136,6 @@ test('tsukihime downloads always route Japanese as primary', async () => { const secondaryLoads: string[] = []; registerAnkiJimakuIpcHandlers( { - setAnkiConnectEnabled: () => {}, - clearAnkiHistory: () => {}, refreshKnownWords: async () => {}, respondFieldGrouping: () => {}, buildKikuMergePreview: async () => ({ ok: true }), @@ -205,13 +201,8 @@ test('tsukihime downloads always route Japanese as primary', async () => { test('anki/jimaku IPC command handlers ignore malformed payloads', () => { const { registrar, onHandlers } = createFakeRegistrar(); const fieldGroupingChoices: unknown[] = []; - const enabledStates: boolean[] = []; registerAnkiJimakuIpcHandlers( { - setAnkiConnectEnabled: (enabled) => { - enabledStates.push(enabled); - }, - clearAnkiHistory: () => {}, refreshKnownWords: async () => {}, respondFieldGrouping: (choice) => { fieldGroupingChoices.push(choice); @@ -241,10 +232,6 @@ test('anki/jimaku IPC command handlers ignore malformed payloads', () => { registrar, ); - onHandlers.get(IPC_CHANNELS.command.setAnkiConnectEnabled)!({}, 'true'); - onHandlers.get(IPC_CHANNELS.command.setAnkiConnectEnabled)!({}, true); - assert.deepEqual(enabledStates, [true]); - onHandlers.get(IPC_CHANNELS.command.kikuFieldGroupingRespond)!({}, null); onHandlers.get(IPC_CHANNELS.command.kikuFieldGroupingRespond)!( {}, diff --git a/src/core/services/anki-jimaku-ipc.ts b/src/core/services/anki-jimaku-ipc.ts index d4270234..d409e33f 100644 --- a/src/core/services/anki-jimaku-ipc.ts +++ b/src/core/services/anki-jimaku-ipc.ts @@ -40,8 +40,6 @@ const { ipcMain } = electron; const logger = createLogger('main:anki-jimaku-ipc'); export interface AnkiJimakuIpcDeps { - setAnkiConnectEnabled: (enabled: boolean) => void; - clearAnkiHistory: () => void; refreshKnownWords: () => Promise | void; respondFieldGrouping: (choice: KikuFieldGroupingChoice) => void; buildKikuMergePreview: (request: KikuMergePreviewRequest) => Promise; @@ -77,15 +75,6 @@ export function registerAnkiJimakuIpcHandlers( deps: AnkiJimakuIpcDeps, ipc: IpcMainRegistrar = ipcMain, ): void { - ipc.on(IPC_CHANNELS.command.setAnkiConnectEnabled, (_event: unknown, enabled: unknown) => { - if (typeof enabled !== 'boolean') return; - deps.setAnkiConnectEnabled(enabled); - }); - - ipc.on(IPC_CHANNELS.command.clearAnkiConnectHistory, () => { - deps.clearAnkiHistory(); - }); - ipc.on(IPC_CHANNELS.command.refreshKnownWords, async () => { await deps.refreshKnownWords(); }); diff --git a/src/core/services/anki-jimaku.test.ts b/src/core/services/anki-jimaku.test.ts index c1bf42e0..800163d5 100644 --- a/src/core/services/anki-jimaku.test.ts +++ b/src/core/services/anki-jimaku.test.ts @@ -8,8 +8,6 @@ interface RuntimeHarness { state: { ankiIntegration: unknown; fieldGroupingResolver: ((choice: unknown) => void) | null; - patches: boolean[]; - broadcasts: number; fetchCalls: Array<{ endpoint: string; query?: Record }>; tsukihimeFetchCalls: Array<{ endpoint: string; query?: Record }>; sentCommands: Array<{ command: (string | number)[] }>; @@ -20,8 +18,6 @@ function createHarness(): RuntimeHarness { const state = { ankiIntegration: null as unknown, fieldGroupingResolver: null as ((choice: unknown) => void) | null, - patches: [] as boolean[], - broadcasts: 0, fetchCalls: [] as Array<{ endpoint: string; query?: Record; @@ -34,11 +30,7 @@ function createHarness(): RuntimeHarness { }; const options: AnkiJimakuIpcRuntimeOptions = { - patchAnkiConnectEnabled: (enabled) => { - state.patches.push(enabled); - }, getResolvedConfig: () => ({ tsukihime: { maxSearchResults: 2 } }), - getRuntimeOptionsManager: () => null, tsukihimeFetchJson: async (endpoint, query) => { state.tsukihimeFetchCalls.push({ endpoint, @@ -76,7 +68,6 @@ function createHarness(): RuntimeHarness { } as never, }; }, - getSubtitleTimingTracker: () => null, getMpvClient: () => ({ connected: true, send: (payload) => { @@ -99,20 +90,6 @@ function createHarness(): RuntimeHarness { }, }), getAnkiIntegration: () => state.ankiIntegration as never, - setAnkiIntegration: (integration) => { - state.ankiIntegration = integration; - }, - getKnownWordCacheStatePath: () => '/tmp/subminer-known-words-cache.json', - showDesktopNotification: () => {}, - createFieldGroupingCallback: () => async () => ({ - keepNoteId: 1, - deleteNoteId: 2, - deleteDuplicate: false, - cancelled: false, - }), - broadcastRuntimeOptionsChanged: () => { - state.broadcasts += 1; - }, getFieldGroupingResolver: () => state.fieldGroupingResolver as never, setFieldGroupingResolver: (resolver) => { state.fieldGroupingResolver = resolver as never; @@ -169,51 +146,21 @@ test('refreshKnownWords throws when integration is unavailable', async () => { ); }); -test('setAnkiConnectEnabled disables active integration and broadcasts changes', () => { +test('respondFieldGrouping resolves and clears the pending resolver', () => { const { registered, state } = createHarness(); - let destroyed = 0; - state.ankiIntegration = { - destroy: () => { - destroyed += 1; - }, - }; - - registered.setAnkiConnectEnabled!(false); - - assert.deepEqual(state.patches, [false]); - assert.equal(destroyed, 1); - assert.equal(state.ankiIntegration, null); - assert.equal(state.broadcasts, 1); -}); - -test('clearAnkiHistory and respondFieldGrouping execute runtime callbacks', () => { - const { registered, state, options } = createHarness(); - let cleaned = 0; let resolvedChoice: unknown = null; state.fieldGroupingResolver = (choice) => { resolvedChoice = choice; }; - const originalGetTracker = options.getSubtitleTimingTracker; - options.getSubtitleTimingTracker = () => - ({ - cleanup: () => { - cleaned += 1; - }, - }) as never; - const choice = { keepNoteId: 10, deleteNoteId: 11, deleteDuplicate: true, cancelled: false, }; - registered.clearAnkiHistory!(); registered.respondFieldGrouping!(choice); - options.getSubtitleTimingTracker = originalGetTracker; - - assert.equal(cleaned, 1); assert.deepEqual(resolvedChoice, choice); assert.equal(state.fieldGroupingResolver, null); }); diff --git a/src/core/services/anki-jimaku.ts b/src/core/services/anki-jimaku.ts index 8ac3e47b..2295d565 100644 --- a/src/core/services/anki-jimaku.ts +++ b/src/core/services/anki-jimaku.ts @@ -1,19 +1,14 @@ import * as fs from 'fs'; -import { AnkiIntegration } from '../../anki-integration'; -import { mergeAiConfig } from '../../ai/config'; +import type { AnkiIntegration } from '../../anki-integration'; import { - AiConfig, TsukihimeApiResponse, TsukihimeConfig, - AnkiConnectConfig, JimakuApiResponse, JimakuEntry, JimakuFileEntry, JimakuLanguagePreference, JimakuMediaInfo, KikuFieldGroupingChoice, - KikuFieldGroupingRequestData, - OverlayNotificationPayload, } from '../../types'; import { sortJimakuFiles } from '../../jimaku/utils'; import { @@ -35,41 +30,13 @@ interface MpvClientLike { request?: (command: unknown[]) => Promise<{ data?: unknown }>; } -interface RuntimeOptionsManagerLike { - getEffectiveAnkiConnectConfig: (config?: AnkiConnectConfig) => AnkiConnectConfig; -} - -interface SubtitleTimingTrackerLike { - cleanup: () => void; -} - export interface AnkiJimakuIpcRuntimeOptions { - patchAnkiConnectEnabled: (enabled: boolean) => void; getResolvedConfig: () => { - ankiConnect?: AnkiConnectConfig; - ai?: AiConfig; tsukihime?: TsukihimeConfig; secondarySub?: { secondarySubLanguages?: string[] }; }; - getRuntimeOptionsManager: () => RuntimeOptionsManagerLike | null; - getSubtitleTimingTracker: () => SubtitleTimingTrackerLike | null; getMpvClient: () => MpvClientLike | null; getAnkiIntegration: () => AnkiIntegration | null; - setAnkiIntegration: (integration: AnkiIntegration | null) => void; - getKnownWordCacheStatePath: () => string; - getCachedMediaPath?: ( - currentVideoPath: string, - kind: 'audio' | 'video', - ) => Promise; - shouldRequireRemoteMediaCache?: () => boolean; - getYoutubeMediaSourceUrl?: () => Promise | string | null | undefined; - showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void; - showOverlayNotification?: (payload: OverlayNotificationPayload) => void; - dismissOverlayNotification?: (id: string) => void; - createFieldGroupingCallback: () => ( - data: KikuFieldGroupingRequestData, - ) => Promise; - broadcastRuntimeOptionsChanged: () => void; getFieldGroupingResolver: () => ((choice: KikuFieldGroupingChoice) => void) | null; setFieldGroupingResolver: (resolver: ((choice: KikuFieldGroupingChoice) => void) | null) => void; parseMediaInfo: (mediaPath: string | null) => JimakuMediaInfo; @@ -135,58 +102,6 @@ export function registerAnkiJimakuIpcRuntime( registerHandlers: RegisterAnkiJimakuIpcRuntimeHandler, ): void { registerHandlers({ - setAnkiConnectEnabled: (enabled) => { - options.patchAnkiConnectEnabled(enabled); - const config = options.getResolvedConfig(); - const subtitleTimingTracker = options.getSubtitleTimingTracker(); - const mpvClient = options.getMpvClient(); - const ankiIntegration = options.getAnkiIntegration(); - - if (enabled && !ankiIntegration && subtitleTimingTracker && mpvClient) { - const runtimeOptionsManager = options.getRuntimeOptionsManager(); - const effectiveAnkiConfig = runtimeOptionsManager - ? runtimeOptionsManager.getEffectiveAnkiConnectConfig(config.ankiConnect) - : config.ankiConnect; - const integration = new AnkiIntegration( - effectiveAnkiConfig as never, - subtitleTimingTracker as never, - mpvClient as never, - (text: string) => { - if (mpvClient) { - mpvClient.send({ - command: ['show-text', text, '3000'], - }); - } - }, - options.showDesktopNotification, - options.createFieldGroupingCallback(), - options.getKnownWordCacheStatePath(), - mergeAiConfig(config.ai, config.ankiConnect?.ai) as AiConfig, - undefined, - options.showOverlayNotification, - options.getCachedMediaPath, - options.shouldRequireRemoteMediaCache, - options.getYoutubeMediaSourceUrl, - options.dismissOverlayNotification, - ); - integration.start(); - options.setAnkiIntegration(integration); - logger.info('AnkiConnect integration enabled'); - } else if (!enabled && ankiIntegration) { - ankiIntegration.destroy(); - options.setAnkiIntegration(null); - logger.info('AnkiConnect integration disabled'); - } - - options.broadcastRuntimeOptionsChanged(); - }, - clearAnkiHistory: () => { - const subtitleTimingTracker = options.getSubtitleTimingTracker(); - if (subtitleTimingTracker) { - subtitleTimingTracker.cleanup(); - logger.info('AnkiConnect subtitle timing history cleared'); - } - }, refreshKnownWords: async () => { const integration = options.getAnkiIntegration(); if (!integration) { diff --git a/src/core/services/ipc.test.ts b/src/core/services/ipc.test.ts index c6e6e118..f6d8192e 100644 --- a/src/core/services/ipc.test.ts +++ b/src/core/services/ipc.test.ts @@ -114,23 +114,15 @@ function createSubtitleSidebarSnapshotFixture(): SubtitleSidebarSnapshot { function createRegisterIpcDeps(overrides: Partial = {}): IpcServiceDeps { return { onOverlayModalClosed: () => {}, - openYomitanSettings: () => {}, - quitApp: () => {}, toggleDevTools: () => {}, - getVisibleOverlayVisibility: () => false, - toggleVisibleOverlay: () => {}, tokenizeCurrentSubtitle: async () => null, getCurrentSubtitleRaw: () => '', - getCurrentSubtitleAss: () => '', getSubtitleSidebarSnapshot: async () => createSubtitleSidebarSnapshotFixture(), getPlaybackPaused: () => false, getSubtitlePosition: () => null, getSubtitleStyle: () => null, saveSubtitlePosition: () => {}, - getMecabStatus: () => ({ available: false, enabled: false, path: null }), - setMecabEnabled: () => {}, handleMpvCommand: () => {}, - getKeybindings: () => [], getSessionBindings: () => [], getConfiguredShortcuts: () => ({}), dispatchSessionAction: async () => {}, @@ -139,23 +131,19 @@ function createRegisterIpcDeps(overrides: Partial = {}): IpcServ getOverlayNotificationPosition: () => 'top-right', getControllerConfig: () => createControllerConfigFixture(), saveControllerConfig: async () => {}, - saveControllerPreference: async () => {}, getSecondarySubMode: () => 'hover', getCurrentSecondarySub: () => '', focusMainWindow: () => {}, activatePlaybackWindowForOverlayInteraction: () => false, runSubsyncManual: async () => ({ ok: true, message: 'ok' }), - getAnkiConnectStatus: () => false, getRuntimeOptions: () => [], setRuntimeOption: () => ({ ok: true }), - cycleRuntimeOption: () => ({ ok: true }), reportOverlayContentBounds: () => {}, getAnilistStatus: () => ({}), clearAnilistToken: () => {}, openAnilistSetup: () => {}, getAnilistQueueStatus: () => ({}), retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }), - appendClipboardVideoToQueue: () => ({ ok: true, message: 'ok' }), getPlaylistBrowserSnapshot: async () => ({ directoryPath: null, directoryAvailable: false, @@ -312,26 +300,17 @@ test('registerIpcHandlers skips AniList update when manual mark watched has no a test('registerIpcHandlers rejects malformed runtime-option payloads', async () => { const { registrar, handlers } = createFakeIpcRegistrar(); const calls: Array<{ id: string; value: unknown }> = []; - const cycles: Array<{ id: string; direction: 1 | -1 }> = []; registerIpcHandlers( { onOverlayModalClosed: () => {}, - openYomitanSettings: () => {}, - quitApp: () => {}, toggleDevTools: () => {}, - getVisibleOverlayVisibility: () => false, - toggleVisibleOverlay: () => {}, tokenizeCurrentSubtitle: async () => null, getCurrentSubtitleRaw: () => '', - getCurrentSubtitleAss: () => '', getPlaybackPaused: () => null, getSubtitlePosition: () => null, getSubtitleStyle: () => null, saveSubtitlePosition: () => {}, - getMecabStatus: () => ({ available: false, enabled: false, path: null }), - setMecabEnabled: () => {}, handleMpvCommand: () => {}, - getKeybindings: () => [], getSessionBindings: () => [], getConfiguredShortcuts: () => ({}), dispatchSessionAction: async () => {}, @@ -340,21 +319,15 @@ test('registerIpcHandlers rejects malformed runtime-option payloads', async () = getOverlayNotificationPosition: () => 'top-right', getControllerConfig: () => createControllerConfigFixture(), saveControllerConfig: () => {}, - saveControllerPreference: () => {}, getSecondarySubMode: () => 'hover', getCurrentSecondarySub: () => '', focusMainWindow: () => {}, runSubsyncManual: async () => ({ ok: true, message: 'ok' }), - getAnkiConnectStatus: () => false, getRuntimeOptions: () => [], setRuntimeOption: (id, value) => { calls.push({ id, value }); return { ok: true }; }, - cycleRuntimeOption: (id, direction) => { - cycles.push({ id, direction }); - return { ok: true }; - }, getSubtitleSidebarSnapshot: async () => createSubtitleSidebarSnapshotFixture(), reportOverlayContentBounds: () => {}, getAnilistStatus: () => ({}), @@ -362,7 +335,6 @@ test('registerIpcHandlers rejects malformed runtime-option payloads', async () = openAnilistSetup: () => {}, getAnilistQueueStatus: () => ({}), retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }), - appendClipboardVideoToQueue: () => ({ ok: true, message: 'ok' }), getPlaylistBrowserSnapshot: async () => ({ directoryPath: null, directoryAvailable: false, @@ -399,16 +371,6 @@ test('registerIpcHandlers rejects malformed runtime-option payloads', async () = { id: 'subtitle.annotation.jlpt', value: false }, ]); - const cycleHandler = handlers.handle.get(IPC_CHANNELS.request.cycleRuntimeOption); - assert.ok(cycleHandler); - const invalidDirection = await cycleHandler!({}, 'anki.kikuFieldGrouping', 2); - assert.deepEqual(invalidDirection, { - ok: false, - error: 'Invalid runtime option cycle direction', - }); - await cycleHandler!({}, 'anki.kikuFieldGrouping', -1); - assert.deepEqual(cycles, [{ id: 'anki.kikuFieldGrouping', direction: -1 }]); - const getPlaybackPausedHandler = handlers.handle.get(IPC_CHANNELS.request.getPlaybackPaused); assert.ok(getPlaybackPausedHandler); assert.equal(getPlaybackPausedHandler!({}), null); @@ -609,7 +571,6 @@ test('registerIpcHandlers records yomitan lookup when subtitle context recording test('registerIpcHandlers ignores malformed fire-and-forget payloads', () => { const { registrar, handlers } = createFakeIpcRegistrar(); const saves: unknown[] = []; - const controllerSaves: unknown[] = []; const closedModals: unknown[] = []; const openedModals: unknown[] = []; registerIpcHandlers( @@ -620,14 +581,9 @@ test('registerIpcHandlers ignores malformed fire-and-forget payloads', () => { onOverlayModalOpened: (modal) => { openedModals.push(modal); }, - openYomitanSettings: () => {}, - quitApp: () => {}, toggleDevTools: () => {}, - getVisibleOverlayVisibility: () => false, - toggleVisibleOverlay: () => {}, tokenizeCurrentSubtitle: async () => null, getCurrentSubtitleRaw: () => '', - getCurrentSubtitleAss: () => '', getSubtitleSidebarSnapshot: async () => createSubtitleSidebarSnapshotFixture(), getPlaybackPaused: () => false, getSubtitlePosition: () => null, @@ -635,10 +591,7 @@ test('registerIpcHandlers ignores malformed fire-and-forget payloads', () => { saveSubtitlePosition: (position) => { saves.push(position); }, - getMecabStatus: () => ({ available: false, enabled: false, path: null }), - setMecabEnabled: () => {}, handleMpvCommand: () => {}, - getKeybindings: () => [], getSessionBindings: () => [], getConfiguredShortcuts: () => ({}), dispatchSessionAction: async () => {}, @@ -647,24 +600,18 @@ test('registerIpcHandlers ignores malformed fire-and-forget payloads', () => { getOverlayNotificationPosition: () => 'top-right', getControllerConfig: () => createControllerConfigFixture(), saveControllerConfig: () => {}, - saveControllerPreference: (update) => { - controllerSaves.push(update); - }, getSecondarySubMode: () => 'hover', getCurrentSecondarySub: () => '', focusMainWindow: () => {}, runSubsyncManual: async () => ({ ok: true, message: 'ok' }), - getAnkiConnectStatus: () => false, getRuntimeOptions: () => [], setRuntimeOption: () => ({ ok: true }), - cycleRuntimeOption: () => ({ ok: true }), reportOverlayContentBounds: () => {}, getAnilistStatus: () => ({}), clearAnilistToken: () => {}, openAnilistSetup: () => {}, getAnilistQueueStatus: () => ({}), retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }), - appendClipboardVideoToQueue: () => ({ ok: true, message: 'ok' }), getPlaylistBrowserSnapshot: async () => ({ directoryPath: null, directoryAvailable: false, @@ -698,96 +645,6 @@ test('registerIpcHandlers ignores malformed fire-and-forget payloads', () => { assert.deepEqual(openedModals, ['subsync', 'runtime-options']); }); -test('registerIpcHandlers awaits saveControllerPreference through request-response IPC', async () => { - const { registrar, handlers } = createFakeIpcRegistrar(); - const controllerSaves: unknown[] = []; - registerIpcHandlers( - { - onOverlayModalClosed: () => {}, - openYomitanSettings: () => {}, - quitApp: () => {}, - toggleDevTools: () => {}, - getVisibleOverlayVisibility: () => false, - toggleVisibleOverlay: () => {}, - tokenizeCurrentSubtitle: async () => null, - getCurrentSubtitleRaw: () => '', - getCurrentSubtitleAss: () => '', - getSubtitleSidebarSnapshot: async () => createSubtitleSidebarSnapshotFixture(), - getPlaybackPaused: () => false, - getSubtitlePosition: () => null, - getSubtitleStyle: () => null, - saveSubtitlePosition: () => {}, - getMecabStatus: () => ({ available: false, enabled: false, path: null }), - setMecabEnabled: () => {}, - handleMpvCommand: () => {}, - getKeybindings: () => [], - getSessionBindings: () => [], - getConfiguredShortcuts: () => ({}), - dispatchSessionAction: async () => {}, - getStatsToggleKey: () => 'Backquote', - getMarkWatchedKey: () => 'KeyW', - getOverlayNotificationPosition: () => 'top-right', - getControllerConfig: () => createControllerConfigFixture(), - saveControllerConfig: async () => {}, - saveControllerPreference: async (update) => { - await Promise.resolve(); - controllerSaves.push(update); - }, - getSecondarySubMode: () => 'hover', - getCurrentSecondarySub: () => '', - focusMainWindow: () => {}, - runSubsyncManual: async () => ({ ok: true, message: 'ok' }), - getAnkiConnectStatus: () => false, - getRuntimeOptions: () => [], - setRuntimeOption: () => ({ ok: true }), - cycleRuntimeOption: () => ({ ok: true }), - reportOverlayContentBounds: () => {}, - getAnilistStatus: () => ({}), - clearAnilistToken: () => {}, - openAnilistSetup: () => {}, - getAnilistQueueStatus: () => ({}), - retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }), - appendClipboardVideoToQueue: () => ({ ok: true, message: 'ok' }), - getPlaylistBrowserSnapshot: async () => ({ - directoryPath: null, - directoryAvailable: false, - directoryStatus: '', - directoryItems: [], - playlistItems: [], - playingIndex: null, - currentFilePath: null, - }), - appendPlaylistBrowserFile: async () => ({ ok: true, message: 'ok' }), - playPlaylistBrowserIndex: async () => ({ ok: true, message: 'ok' }), - removePlaylistBrowserIndex: async () => ({ ok: true, message: 'ok' }), - movePlaylistBrowserIndex: async () => ({ ok: true, message: 'ok' }), - onYoutubePickerResolve: async () => ({ ok: true, message: 'ok' }), - }, - registrar, - ); - - const saveHandler = handlers.handle.get(IPC_CHANNELS.command.saveControllerPreference); - assert.ok(saveHandler); - - await assert.rejects(async () => { - await saveHandler!({}, { preferredGamepadId: 12 }); - }, /Invalid controller preference payload/); - await saveHandler!( - {}, - { - preferredGamepadId: 'pad-1', - preferredGamepadLabel: 'Pad 1', - }, - ); - - assert.deepEqual(controllerSaves, [ - { - preferredGamepadId: 'pad-1', - preferredGamepadLabel: 'Pad 1', - }, - ]); -}); - test('registerIpcHandlers accepts per-controller profile config updates', async () => { const { registrar, handlers } = createFakeIpcRegistrar(); const controllerSaves: unknown[] = []; @@ -975,76 +832,6 @@ test('registerIpcHandlers forwards valid overlay notification actions', () => { ]); }); -test('registerIpcHandlers rejects malformed controller preference payloads', async () => { - const { registrar, handlers } = createFakeIpcRegistrar(); - registerIpcHandlers( - { - onOverlayModalClosed: () => {}, - openYomitanSettings: () => {}, - quitApp: () => {}, - toggleDevTools: () => {}, - getVisibleOverlayVisibility: () => false, - toggleVisibleOverlay: () => {}, - tokenizeCurrentSubtitle: async () => null, - getCurrentSubtitleRaw: () => '', - getCurrentSubtitleAss: () => '', - getSubtitleSidebarSnapshot: async () => createSubtitleSidebarSnapshotFixture(), - getPlaybackPaused: () => false, - getSubtitlePosition: () => null, - getSubtitleStyle: () => null, - saveSubtitlePosition: () => {}, - getMecabStatus: () => ({ available: false, enabled: false, path: null }), - setMecabEnabled: () => {}, - handleMpvCommand: () => {}, - getKeybindings: () => [], - getSessionBindings: () => [], - getConfiguredShortcuts: () => ({}), - dispatchSessionAction: async () => {}, - getStatsToggleKey: () => 'Backquote', - getMarkWatchedKey: () => 'KeyW', - getOverlayNotificationPosition: () => 'top-right', - getControllerConfig: () => createControllerConfigFixture(), - saveControllerConfig: async () => {}, - saveControllerPreference: async () => {}, - getSecondarySubMode: () => 'hover', - getCurrentSecondarySub: () => '', - focusMainWindow: () => {}, - runSubsyncManual: async () => ({ ok: true, message: 'ok' }), - getAnkiConnectStatus: () => false, - getRuntimeOptions: () => [], - setRuntimeOption: () => ({ ok: true }), - cycleRuntimeOption: () => ({ ok: true }), - reportOverlayContentBounds: () => {}, - getAnilistStatus: () => ({}), - clearAnilistToken: () => {}, - openAnilistSetup: () => {}, - getAnilistQueueStatus: () => ({}), - retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }), - appendClipboardVideoToQueue: () => ({ ok: true, message: 'ok' }), - getPlaylistBrowserSnapshot: async () => ({ - directoryPath: null, - directoryAvailable: false, - directoryStatus: '', - directoryItems: [], - playlistItems: [], - playingIndex: null, - currentFilePath: null, - }), - appendPlaylistBrowserFile: async () => ({ ok: true, message: 'ok' }), - playPlaylistBrowserIndex: async () => ({ ok: true, message: 'ok' }), - removePlaylistBrowserIndex: async () => ({ ok: true, message: 'ok' }), - movePlaylistBrowserIndex: async () => ({ ok: true, message: 'ok' }), - onYoutubePickerResolve: async () => ({ ok: true, message: 'ok' }), - }, - registrar, - ); - - const saveHandler = handlers.handle.get(IPC_CHANNELS.command.saveControllerPreference); - await assert.rejects(async () => { - await saveHandler!({}, { preferredGamepadId: 12 }); - }, /Invalid controller preference payload/); -}); - test('registerIpcHandlers exposes character dictionary selection handlers', async () => { const { registrar, handlers } = createFakeIpcRegistrar(); const calls: number[] = []; diff --git a/src/core/services/ipc.ts b/src/core/services/ipc.ts index 113ee6dc..ae9cc6f8 100644 --- a/src/core/services/ipc.ts +++ b/src/core/services/ipc.ts @@ -7,7 +7,6 @@ import type { ControllerConfigUpdate, PlaylistBrowserMutationResult, PlaylistBrowserSnapshot, - ControllerPreferenceUpdate, ResolvedControllerConfig, RuntimeOptionId, RuntimeOptionValue, @@ -34,10 +33,8 @@ import { IPC_CHANNELS, type OverlayHostedModal } from '../../shared/ipc/contract import { parseMpvCommand, parseControllerConfigUpdate, - parseControllerPreferenceUpdate, parseOptionalForwardingOptions, parseOverlayHostedModal, - parseRuntimeOptionDirection, parseRuntimeOptionId, parseRuntimeOptionValue, parseSessionActionDispatchRequest, @@ -71,14 +68,9 @@ export interface IpcServiceDeps { actionId: string, noteId?: number, ) => void | Promise; - openYomitanSettings: () => void; - quitApp: () => void; toggleDevTools: () => void; - getVisibleOverlayVisibility: () => boolean; - toggleVisibleOverlay: () => void; tokenizeCurrentSubtitle: () => Promise; getCurrentSubtitleRaw: () => string; - getCurrentSubtitleAss: () => string; getSubtitleSidebarSnapshot?: () => Promise; getSubtitleSidebarPlaybackState?: () => SubtitleSidebarPlaybackState; getSubtitleSidebarOpen?: () => boolean; @@ -86,14 +78,7 @@ export interface IpcServiceDeps { getSubtitlePosition: () => unknown; getSubtitleStyle: () => unknown; saveSubtitlePosition: (position: SubtitlePosition) => void; - getMecabStatus: () => { - available: boolean; - enabled: boolean; - path: string | null; - }; - setMecabEnabled: (enabled: boolean) => void; handleMpvCommand: (command: Array) => void; - getKeybindings: () => unknown; getMpvInputBindings?: () => Promise; getSessionBindings?: () => CompiledSessionBinding[] | Promise; getConfiguredShortcuts: () => unknown; @@ -103,7 +88,6 @@ export interface IpcServiceDeps { getOverlayNotificationPosition: () => string; getControllerConfig: () => ResolvedControllerConfig; saveControllerConfig: (update: ControllerConfigUpdate) => void | Promise; - saveControllerPreference: (update: ControllerPreferenceUpdate) => void | Promise; getSecondarySubMode: () => unknown; getCurrentSecondarySub: () => string; focusMainWindow: () => void; @@ -125,10 +109,8 @@ export interface IpcServiceDeps { resolveMediaTimingReview?: ( request: MediaTimingReviewResolveRequest, ) => MediaTimingReviewActionResult | Promise; - getAnkiConnectStatus: () => boolean; getRuntimeOptions: () => unknown; setRuntimeOption: (id: RuntimeOptionId, value: RuntimeOptionValue) => unknown; - cycleRuntimeOption: (id: RuntimeOptionId, direction: 1 | -1) => unknown; reportOverlayContentBounds: ( payload: unknown, senderWindow: ElectronBrowserWindow | null, @@ -149,7 +131,6 @@ export interface IpcServiceDeps { getCharacterDictionaryManagerSnapshot?: () => Promise; removeCharacterDictionaryManagedEntry?: (mediaId: number) => Promise; moveCharacterDictionaryManagedEntry?: (mediaId: number, direction: 1 | -1) => Promise; - appendClipboardVideoToQueue: () => { ok: boolean; message: string }; getChangelogSnapshot?: (options?: { refresh?: boolean }) => Promise; getPlaylistBrowserSnapshot: () => Promise; appendPlaylistBrowserFile: (filePath: string) => Promise; @@ -174,15 +155,6 @@ interface WindowLike { }; } -interface MecabTokenizerLike { - getStatus: () => { - available: boolean; - enabled: boolean; - path: string | null; - }; - setEnabled: (enabled: boolean) => void; -} - interface MpvClientLike { currentSecondarySubText?: string; } @@ -342,7 +314,6 @@ function parseMediaTimingReviewResolveRequest( export interface IpcDepsRuntimeOptions { getMainWindow: () => WindowLike | null; - getVisibleOverlayVisibility: () => boolean; onOverlayModalClosed: ( modal: OverlayHostedModal, senderWindow: ElectronBrowserWindow | null, @@ -364,12 +335,8 @@ export interface IpcDepsRuntimeOptions { actionId: string, noteId?: number, ) => void | Promise; - openYomitanSettings: () => void; - quitApp: () => void; - toggleVisibleOverlay: () => void; tokenizeCurrentSubtitle: () => Promise; getCurrentSubtitleRaw: () => string; - getCurrentSubtitleAss: () => string; getSubtitleSidebarSnapshot?: () => Promise; getSubtitleSidebarPlaybackState?: () => SubtitleSidebarPlaybackState; getSubtitleSidebarOpen?: () => boolean; @@ -377,9 +344,7 @@ export interface IpcDepsRuntimeOptions { getSubtitlePosition: () => unknown; getSubtitleStyle: () => unknown; saveSubtitlePosition: (position: SubtitlePosition) => void; - getMecabTokenizer: () => MecabTokenizerLike | null; handleMpvCommand: (command: Array) => void; - getKeybindings: () => unknown; getMpvInputBindings?: () => Promise; getSessionBindings?: () => CompiledSessionBinding[] | Promise; getConfiguredShortcuts: () => unknown; @@ -389,7 +354,6 @@ export interface IpcDepsRuntimeOptions { getOverlayNotificationPosition: () => string; getControllerConfig: () => ResolvedControllerConfig; saveControllerConfig: (update: ControllerConfigUpdate) => void | Promise; - saveControllerPreference: (update: ControllerPreferenceUpdate) => void | Promise; getSecondarySubMode: () => unknown; getMpvClient: () => MpvClientLike | null; focusMainWindow: () => void; @@ -403,10 +367,8 @@ export interface IpcDepsRuntimeOptions { getMediaTimingReviewWaveform?: IpcServiceDeps['getMediaTimingReviewWaveform']; stopMediaTimingReviewPreview?: IpcServiceDeps['stopMediaTimingReviewPreview']; resolveMediaTimingReview?: IpcServiceDeps['resolveMediaTimingReview']; - getAnkiConnectStatus: () => boolean; getRuntimeOptions: () => unknown; setRuntimeOption: (id: RuntimeOptionId, value: RuntimeOptionValue) => unknown; - cycleRuntimeOption: (id: RuntimeOptionId, direction: 1 | -1) => unknown; reportOverlayContentBounds: (payload: unknown) => void; getAnilistStatus: () => unknown; clearAnilistToken: () => void; @@ -424,7 +386,6 @@ export interface IpcDepsRuntimeOptions { getCharacterDictionaryManagerSnapshot?: () => Promise; removeCharacterDictionaryManagedEntry?: (mediaId: number) => Promise; moveCharacterDictionaryManagedEntry?: (mediaId: number, direction: 1 | -1) => Promise; - appendClipboardVideoToQueue: () => { ok: boolean; message: string }; getChangelogSnapshot?: (options?: { refresh?: boolean }) => Promise; getPlaylistBrowserSnapshot: () => Promise; appendPlaylistBrowserFile: (filePath: string) => Promise; @@ -444,19 +405,14 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService onOverlayMouseInteractionChanged: options.onOverlayMouseInteractionChanged, onOverlayInteractiveHint: options.onOverlayInteractiveHint, handleOverlayNotificationAction: options.handleOverlayNotificationAction, - openYomitanSettings: options.openYomitanSettings, recordSubtitleMiningContext: options.recordSubtitleMiningContext, - quitApp: options.quitApp, toggleDevTools: () => { const mainWindow = options.getMainWindow(); if (!mainWindow || mainWindow.isDestroyed()) return; mainWindow.webContents.toggleDevTools(); }, - getVisibleOverlayVisibility: options.getVisibleOverlayVisibility, - toggleVisibleOverlay: options.toggleVisibleOverlay, tokenizeCurrentSubtitle: options.tokenizeCurrentSubtitle, getCurrentSubtitleRaw: options.getCurrentSubtitleRaw, - getCurrentSubtitleAss: options.getCurrentSubtitleAss, getSubtitleSidebarSnapshot: options.getSubtitleSidebarSnapshot, getSubtitleSidebarPlaybackState: options.getSubtitleSidebarPlaybackState, getSubtitleSidebarOpen: options.getSubtitleSidebarOpen ?? (() => false), @@ -464,19 +420,7 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService getSubtitlePosition: options.getSubtitlePosition, getSubtitleStyle: options.getSubtitleStyle, saveSubtitlePosition: options.saveSubtitlePosition, - getMecabStatus: () => { - const mecabTokenizer = options.getMecabTokenizer(); - return mecabTokenizer - ? mecabTokenizer.getStatus() - : { available: false, enabled: false, path: null }; - }, - setMecabEnabled: (enabled) => { - const mecabTokenizer = options.getMecabTokenizer(); - if (!mecabTokenizer) return; - mecabTokenizer.setEnabled(enabled); - }, handleMpvCommand: options.handleMpvCommand, - getKeybindings: options.getKeybindings, getMpvInputBindings: options.getMpvInputBindings, getSessionBindings: options.getSessionBindings ?? (() => []), getConfiguredShortcuts: options.getConfiguredShortcuts, @@ -486,7 +430,6 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService getOverlayNotificationPosition: options.getOverlayNotificationPosition, getControllerConfig: options.getControllerConfig, saveControllerConfig: options.saveControllerConfig, - saveControllerPreference: options.saveControllerPreference, getSecondarySubMode: options.getSecondarySubMode, getCurrentSecondarySub: () => options.getMpvClient()?.currentSecondarySubText || '', focusMainWindow: () => { @@ -503,10 +446,8 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService getMediaTimingReviewWaveform: options.getMediaTimingReviewWaveform, stopMediaTimingReviewPreview: options.stopMediaTimingReviewPreview, resolveMediaTimingReview: options.resolveMediaTimingReview, - getAnkiConnectStatus: options.getAnkiConnectStatus, getRuntimeOptions: options.getRuntimeOptions, setRuntimeOption: options.setRuntimeOption, - cycleRuntimeOption: options.cycleRuntimeOption, reportOverlayContentBounds: (payload, senderWindow) => { const mainWindow = options.getMainWindow(); if (!mainWindow || mainWindow.isDestroyed()) return; @@ -553,7 +494,6 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService message: 'Character dictionary manager unavailable.', entries: [], })), - appendClipboardVideoToQueue: options.appendClipboardVideoToQueue, getChangelogSnapshot: options.getChangelogSnapshot, getPlaylistBrowserSnapshot: options.getPlaylistBrowserSnapshot, appendPlaylistBrowserFile: options.appendPlaylistBrowserFile, @@ -680,10 +620,6 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar }, ); - ipc.on(IPC_CHANNELS.command.openYomitanSettings, () => { - deps.openYomitanSettings(); - }); - ipc.on(IPC_CHANNELS.command.recordYomitanLookup, (_event: unknown, payload: unknown) => { try { deps.recordSubtitleMiningContext?.(parseSubtitleMiningContext(payload)); @@ -711,22 +647,10 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar return marked; }); - ipc.on(IPC_CHANNELS.command.quitApp, () => { - deps.quitApp(); - }); - ipc.on(IPC_CHANNELS.command.toggleDevTools, () => { deps.toggleDevTools(); }); - ipc.on(IPC_CHANNELS.command.toggleOverlay, () => { - deps.toggleVisibleOverlay(); - }); - - ipc.handle(IPC_CHANNELS.request.getVisibleOverlayVisibility, () => { - return deps.getVisibleOverlayVisibility(); - }); - ipc.handle(IPC_CHANNELS.request.getCurrentSubtitle, async () => { return await deps.tokenizeCurrentSubtitle(); }); @@ -735,10 +659,6 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar return deps.getCurrentSubtitleRaw(); }); - ipc.handle(IPC_CHANNELS.request.getCurrentSubtitleAss, () => { - return deps.getCurrentSubtitleAss(); - }); - ipc.handle(IPC_CHANNELS.request.getSubtitleSidebarSnapshot, async () => { if (!deps.getSubtitleSidebarSnapshot) { throw new Error('Subtitle sidebar snapshot is unavailable.'); @@ -775,17 +695,6 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar deps.saveSubtitlePosition(parsedPosition); }); - ipc.handle( - IPC_CHANNELS.command.saveControllerPreference, - async (_event: unknown, update: unknown) => { - const parsedUpdate = parseControllerPreferenceUpdate(update); - if (!parsedUpdate) { - throw new Error('Invalid controller preference payload'); - } - await deps.saveControllerPreference(parsedUpdate); - }, - ); - ipc.handle( IPC_CHANNELS.command.saveControllerConfig, async (_event: unknown, update: unknown) => { @@ -797,15 +706,6 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar }, ); - ipc.handle(IPC_CHANNELS.request.getMecabStatus, () => { - return deps.getMecabStatus(); - }); - - ipc.on(IPC_CHANNELS.command.setMecabEnabled, (_event: unknown, enabled: unknown) => { - if (typeof enabled !== 'boolean') return; - deps.setMecabEnabled(enabled); - }); - ipc.on(IPC_CHANNELS.command.mpvCommand, (_event: unknown, command: unknown) => { const parsedCommand = parseMpvCommand(command); if (!parsedCommand) return; @@ -823,10 +723,6 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar }, ); - ipc.handle(IPC_CHANNELS.request.getKeybindings, () => { - return deps.getKeybindings(); - }); - ipc.handle(IPC_CHANNELS.request.getMpvInputBindings, () => { return deps.getMpvInputBindings?.() ?? { keys: [], blockedKeys: [] }; }); @@ -879,10 +775,6 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar return await deps.runSubsyncManual(parsedRequest); }); - ipc.handle(IPC_CHANNELS.request.getAnkiConnectStatus, () => { - return deps.getAnkiConnectStatus(); - }); - ipc.handle(IPC_CHANNELS.request.getRuntimeOptions, () => { return deps.getRuntimeOptions(); }); @@ -899,18 +791,6 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar return deps.setRuntimeOption(parsedId, parsedValue); }); - ipc.handle(IPC_CHANNELS.request.cycleRuntimeOption, (_event, id: unknown, direction: unknown) => { - const parsedId = parseRuntimeOptionId(id); - if (!parsedId) { - return { ok: false, error: 'Invalid runtime option id' }; - } - const parsedDirection = parseRuntimeOptionDirection(direction); - if (!parsedDirection) { - return { ok: false, error: 'Invalid runtime option cycle direction' }; - } - return deps.cycleRuntimeOption(parsedId, parsedDirection); - }); - ipc.on(IPC_CHANNELS.command.reportOverlayContentBounds, (event: unknown, payload: unknown) => { const senderWindow = electron.BrowserWindow?.fromWebContents((event as IpcMainEvent).sender) ?? null; @@ -1020,10 +900,6 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar }, ); - ipc.handle(IPC_CHANNELS.request.appendClipboardVideoToQueue, () => { - return deps.appendClipboardVideoToQueue(); - }); - ipc.handle(IPC_CHANNELS.request.getChangelogSnapshot, async (_event, payload: unknown) => { const refresh = typeof payload === 'object' && payload !== null && 'refresh' in payload diff --git a/src/core/services/mpv-properties.ts b/src/core/services/mpv-properties.ts index 6891872e..158ffdc3 100644 --- a/src/core/services/mpv-properties.ts +++ b/src/core/services/mpv-properties.ts @@ -20,7 +20,6 @@ import { MPV_REQUEST_ID_SUB_SHADOW_OFFSET, MPV_REQUEST_ID_SUB_SPACING, MPV_REQUEST_ID_SUBTEXT, - MPV_REQUEST_ID_SUBTEXT_ASS, MPV_REQUEST_ID_SUB_USE_MARGINS, MPV_REQUEST_ID_PAUSE, } from './mpv-protocol'; @@ -54,7 +53,6 @@ const MPV_SUBTITLE_PROPERTY_OBSERVATIONS: string[] = [ 'sub-scale-by-window', 'osd-height', 'osd-dimensions', - 'sub-text/ass', 'sub-border-size', 'sub-shadow-offset', 'sub-ass-override', @@ -76,10 +74,6 @@ const MPV_INITIAL_PROPERTY_REQUESTS: Array = [ command: ['get_property', 'sub-text'], request_id: MPV_REQUEST_ID_SUBTEXT, }, - { - command: ['get_property', 'sub-text/ass'], - request_id: MPV_REQUEST_ID_SUBTEXT_ASS, - }, { command: ['get_property', 'path'], request_id: MPV_REQUEST_ID_PATH, diff --git a/src/core/services/mpv-protocol.test.ts b/src/core/services/mpv-protocol.test.ts index a1f0a791..6ca1b827 100644 --- a/src/core/services/mpv-protocol.test.ts +++ b/src/core/services/mpv-protocol.test.ts @@ -59,7 +59,6 @@ function createDeps(overrides: Partial = {}): { getSubtitleMetrics: () => metrics, isVisibleOverlayVisible: () => false, emitSubtitleChange: (payload) => state.events.push(payload), - emitSubtitleAssChange: (payload) => state.events.push(payload), emitSubtitleTiming: (payload) => state.events.push(payload), emitSecondarySubtitleChange: (payload) => state.events.push(payload), emitSubtitleTrackChange: (payload) => state.events.push(payload), @@ -132,28 +131,6 @@ test('dispatchMpvProtocolMessage emits subtitle text on property change', async assert.deepEqual(state.events, [{ text: '字幕', isOverlayVisible: false }]); }); -test('dispatchMpvProtocolMessage emits ASS subtitle text from the current mpv property', async () => { - const { deps, state } = createDeps(); - - await dispatchMpvProtocolMessage( - { event: 'property-change', name: 'sub-text/ass', data: '{\\b1}字幕' }, - deps, - ); - - assert.deepEqual(state.events, [{ text: '{\\b1}字幕' }]); -}); - -test('dispatchMpvProtocolMessage emits ASS subtitle text from the legacy mpv property', async () => { - const { deps, state } = createDeps(); - - await dispatchMpvProtocolMessage( - { event: 'property-change', name: 'sub-text-ass', data: '{\\b1}字幕' }, - deps, - ); - - assert.deepEqual(state.events, [{ text: '{\\b1}字幕' }]); -}); - test('dispatchMpvProtocolMessage emits subtitle track changes', async () => { const { deps, state } = createDeps({ emitSubtitleTrackChange: (payload) => state.events.push(payload), diff --git a/src/core/services/mpv-protocol.ts b/src/core/services/mpv-protocol.ts index da3b1c9a..0ea185dc 100644 --- a/src/core/services/mpv-protocol.ts +++ b/src/core/services/mpv-protocol.ts @@ -24,7 +24,6 @@ export const MPV_REQUEST_ID_SUB_FONT = 111; export const MPV_REQUEST_ID_SUB_SCALE_BY_WINDOW = 112; export const MPV_REQUEST_ID_OSD_HEIGHT = 113; export const MPV_REQUEST_ID_OSD_DIMENSIONS = 114; -export const MPV_REQUEST_ID_SUBTEXT_ASS = 115; export const MPV_REQUEST_ID_SUB_SPACING = 116; export const MPV_REQUEST_ID_SUB_BOLD = 117; export const MPV_REQUEST_ID_SUB_ITALIC = 118; @@ -52,7 +51,6 @@ export interface MpvProtocolHandleMessageDeps { getSubtitleMetrics: () => MpvSubtitleRenderMetrics; isVisibleOverlayVisible: () => boolean; emitSubtitleChange: (payload: { text: string; isOverlayVisible: boolean }) => void; - emitSubtitleAssChange: (payload: { text: string }) => void; emitSubtitleTiming: (payload: { text: string; start: number; end: number }) => void; emitSecondarySubtitleChange: (payload: { text: string }) => void; emitSubtitleTrackChange: (payload: { sid: number | null }) => void; @@ -253,8 +251,6 @@ export async function dispatchMpvProtocolMessage( isOverlayVisible: overlayVisible, }); deps.setCurrentSubText(nextSubText); - } else if (msg.name === 'sub-text/ass' || msg.name === 'sub-text-ass') { - deps.emitSubtitleAssChange({ text: (msg.data as string) || '' }); } else if (msg.name === 'sub-start') { deps.setCurrentSubStart((msg.data as number) || 0); deps.emitSubtitleTiming({ @@ -468,8 +464,6 @@ export async function dispatchMpvProtocolMessage( text: nextSubText, isOverlayVisible: deps.isVisibleOverlayVisible(), }); - } else if (msg.request_id === MPV_REQUEST_ID_SUBTEXT_ASS) { - deps.emitSubtitleAssChange({ text: (msg.data as string) || '' }); } else if (msg.request_id === MPV_REQUEST_ID_PATH) { deps.emitMediaPathChange({ path: (msg.data as string) || '' }); } else if (msg.request_id === MPV_REQUEST_ID_MEDIA_TITLE) { diff --git a/src/core/services/mpv.test.ts b/src/core/services/mpv.test.ts index 0072dbaa..734c6582 100644 --- a/src/core/services/mpv.test.ts +++ b/src/core/services/mpv.test.ts @@ -444,17 +444,6 @@ test('MpvIpcClient reconnect replays property subscriptions and initial state re (command as { command: unknown[] }).command[1] === 1 && (command as { command: unknown[] }).command[2] === 'sub-text', ); - const hasAssSubtitleSubscription = commands.some( - (command) => - Array.isArray((command as { command: unknown[] }).command) && - (command as { command: unknown[] }).command[0] === 'observe_property' && - (command as { command: unknown[] }).command[2] === 'sub-text/ass', - ); - const hasDeprecatedAssSubtitleProperty = commands.some( - (command) => - Array.isArray((command as { command: unknown[] }).command) && - (command as { command: unknown[] }).command.includes('sub-text-ass'), - ); const hasPathRequest = commands.some( (command) => Array.isArray((command as { command: unknown[] }).command) && @@ -464,8 +453,6 @@ test('MpvIpcClient reconnect replays property subscriptions and initial state re assert.equal(hasSecondaryVisibilityReset, true); assert.equal(hasTrackSubscription, true); - assert.equal(hasAssSubtitleSubscription, true); - assert.equal(hasDeprecatedAssSubtitleProperty, false); assert.equal(hasPathRequest, true); }); diff --git a/src/core/services/mpv.ts b/src/core/services/mpv.ts index 3345733d..5f3e46f7 100644 --- a/src/core/services/mpv.ts +++ b/src/core/services/mpv.ts @@ -123,7 +123,6 @@ export interface MpvIpcClientDeps extends MpvIpcClientProtocolDeps {} export interface MpvIpcClientEventMap { 'connection-change': { connected: boolean }; 'subtitle-change': { text: string; isOverlayVisible: boolean }; - 'subtitle-ass-change': { text: string }; 'subtitle-timing': { text: string; start: number; end: number }; 'time-pos-change': { time: number }; 'duration-change': { duration: number }; @@ -417,9 +416,6 @@ export class MpvIpcClient implements MpvClient { emitSubtitleChange: (payload) => { this.emit('subtitle-change', payload); }, - emitSubtitleAssChange: (payload) => { - this.emit('subtitle-ass-change', payload); - }, emitSubtitleTiming: (payload) => { this.emit('subtitle-timing', payload); }, diff --git a/src/core/services/runtime-options-ipc.test.ts b/src/core/services/runtime-options-ipc.test.ts index fa659cf9..6c74ea6b 100644 --- a/src/core/services/runtime-options-ipc.test.ts +++ b/src/core/services/runtime-options-ipc.test.ts @@ -2,7 +2,6 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { applyRuntimeOptionResultRuntime, - cycleRuntimeOptionFromIpcRuntime, setRuntimeOptionFromIpcRuntime, } from './runtime-options-ipc'; @@ -25,21 +24,3 @@ test('setRuntimeOptionFromIpcRuntime returns unavailable when manager missing', assert.equal(result.error, 'Runtime options manager unavailable'); assert.deepEqual(osd, []); }); - -test('cycleRuntimeOptionFromIpcRuntime reports errors once', () => { - const osd: string[] = []; - const result = cycleRuntimeOptionFromIpcRuntime( - { - setOptionValue: () => ({ ok: true }), - cycleOption: () => ({ ok: false, error: 'bad option' }), - }, - 'anki.kikuFieldGrouping', - 1, - (text) => { - osd.push(text); - }, - ); - assert.equal(result.ok, false); - assert.equal(result.error, 'bad option'); - assert.deepEqual(osd, ['bad option']); -}); diff --git a/src/core/services/runtime-options-ipc.ts b/src/core/services/runtime-options-ipc.ts index 71d32ef5..39509ff9 100644 --- a/src/core/services/runtime-options-ipc.ts +++ b/src/core/services/runtime-options-ipc.ts @@ -34,19 +34,3 @@ export function setRuntimeOptionFromIpcRuntime( } return result; } - -export function cycleRuntimeOptionFromIpcRuntime( - manager: RuntimeOptionsManagerLike | null, - id: RuntimeOptionId, - direction: 1 | -1, - showMpvOsd: (text: string) => void, -): RuntimeOptionApplyResult { - if (!manager) { - return { ok: false, error: 'Runtime options manager unavailable' }; - } - const result = applyRuntimeOptionResultRuntime(manager.cycleOption(id, direction), showMpvOsd); - if (!result.ok && result.error) { - showMpvOsd(result.error); - } - return result; -} diff --git a/src/main.ts b/src/main.ts index bbf544bc..3242d719 100644 --- a/src/main.ts +++ b/src/main.ts @@ -4767,7 +4767,6 @@ const { autoplaySubtitlePrimingRuntime.resetAutoplaySubtitlePrime(); lastObservedTimePos = 0; appState.currentSubText = ''; - appState.currentSubAssText = ''; appState.currentSubtitleData = null; if (!preserveParsedSubtitleCues) { appState.activeParsedSubtitleCues = []; @@ -5723,7 +5722,6 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({ stopMediaTimingReviewPreview: (reviewId) => mediaTimingReviewRuntime.stopPreview(reviewId), resolveMediaTimingReview: (request) => mediaTimingReviewRuntime.resolveReview(request), getMainWindow: () => overlayManager.getMainWindow(), - getVisibleOverlayVisibility: () => overlayManager.getVisibleOverlayVisible(), focusMainWindow: () => { const mainWindow = overlayManager.getMainWindow(); if (!mainWindow || mainWindow.isDestroyed()) return; @@ -5837,14 +5835,11 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({ } }, onYoutubePickerResolve: (request) => youtubeFlowRuntime.resolveActivePicker(request), - openYomitanSettings: () => openYomitanSettings(), // Overlay lookups carry no cue context of their own; fall back to snapshotting the // live mpv sub timings at lookup time so media generation clips the mined line even // when extraction finishes long after playback has moved on. recordSubtitleMiningContext: (context) => recordSubtitleMiningContext(context ?? captureCurrentPrimarySubtitleMiningContext()), - quitApp: () => requestAppQuit(), - toggleVisibleOverlay: () => toggleVisibleOverlay(), tokenizeCurrentSubtitle: async () => { const tokenizeSubtitleForCurrent = tokenizeSubtitleDeferred; return resolveCurrentSubtitleForRenderer({ @@ -5862,7 +5857,6 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({ }); }, getCurrentSubtitleRaw: () => appState.currentSubText, - getCurrentSubtitleAss: () => appState.currentSubAssText, getSubtitleSidebarOpen: () => subtitleSidebarRequestedOpen, getSubtitleSidebarPlaybackState: () => getSubtitleSidebarPlaybackState(), // Full snapshot: resolves the active source over mpv IPC and re-parses it when it changed. @@ -5942,8 +5936,6 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({ return resolveSubtitleStyleForRenderer(resolvedConfig); }, saveSubtitlePosition: (position) => saveSubtitlePosition(position), - getMecabTokenizer: () => appState.mecabTokenizer, - getKeybindings: () => appState.keybindings, getMpvInputBindings: async () => { await refreshMpvSessionBindings(); return readMpvInputBindings({ @@ -5973,17 +5965,8 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({ controller: applyControllerConfigUpdate(currentRawConfig.controller, update), }); }, - saveControllerPreference: ({ preferredGamepadId, preferredGamepadLabel }) => { - configService.patchRawConfig({ - controller: { - preferredGamepadId, - preferredGamepadLabel, - }, - }); - }, getSecondarySubMode: () => appState.secondarySubMode, getMpvClient: () => appState.mpvClient, - getAnkiConnectStatus: () => appState.ankiIntegration !== null, getRuntimeOptions: () => getRuntimeOptionsStateHandler(), reportOverlayContentBounds: (payload: unknown) => { if (overlayContentMeasurementStore.report(payload)) { @@ -6071,47 +6054,14 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({ } return result; }, - appendClipboardVideoToQueue: () => appendClipboardVideoToQueueHandler(), getChangelogSnapshot: (options) => getChangelogSnapshot(options), ...playlistBrowserMainDeps, getImmersionTracker: () => appState.immersionTracker, }, ankiJimakuDeps: createAnkiJimakuIpcRuntimeServiceDeps({ - patchAnkiConnectEnabled: (enabled: boolean) => { - configService.patchRawConfig({ ankiConnect: { enabled } }); - }, getResolvedConfig: () => configService.getConfig(), - getRuntimeOptionsManager: () => appState.runtimeOptionsManager, - getSubtitleTimingTracker: () => appState.subtitleTimingTracker, getMpvClient: () => appState.mpvClient, getAnkiIntegration: () => appState.ankiIntegration, - setAnkiIntegration: (integration: AnkiIntegration | null) => { - appState.ankiIntegration = integration; - appState.ankiIntegration?.setRecordCardsMinedCallback(recordTrackedCardsMined); - appState.ankiIntegration?.setKnownWordCacheUpdatedCallback( - refreshCurrentSubtitleAnnotations, - ); - appState.ankiIntegration?.setSubtitleMiningContextConsumer( - consumePendingSubtitleMiningContext, - ); - appState.ankiIntegration?.setMediaTimingReviewCallback( - mediaTimingReviewRuntime.requestReview, - ); - appState.ankiIntegration?.setSentenceFuriganaGenerator(generateMiningSentenceFurigana); - }, - getKnownWordCacheStatePath: () => path.join(USER_DATA_PATH, 'known-words-cache.json'), - getCachedMediaPath: (currentVideoPath, kind) => - getCachedYoutubeMediaPathForCurrentPlayback(currentVideoPath, kind), - shouldRequireRemoteMediaCache: () => shouldRequireYoutubeMediaCacheForCurrentPlayback(), - getYoutubeMediaSourceUrl: () => getCurrentYoutubeMediaCacheSourceUrl(), - showDesktopNotification, - showOverlayNotification: (payload) => - overlayNotificationsRuntime.showOverlayNotification(payload), - dismissOverlayNotification: (id) => - overlayNotificationsRuntime.dismissOverlayNotification(id), - createFieldGroupingCallback: () => createFieldGroupingCallback(), - broadcastRuntimeOptionsChanged: () => - overlayVisibilityComposer.broadcastRuntimeOptionsChanged(), getFieldGroupingResolver: () => getFieldGroupingResolverHandler(), setFieldGroupingResolver: (resolver: ((choice: KikuFieldGroupingChoice) => void) | null) => setFieldGroupingResolverHandler(resolver), diff --git a/src/main/dependencies.ts b/src/main/dependencies.ts index da74e427..9aebcec8 100644 --- a/src/main/dependencies.ts +++ b/src/main/dependencies.ts @@ -1,15 +1,11 @@ import { RuntimeOptionId, RuntimeOptionValue, SubsyncManualPayload } from '../types'; -import type { OverlayNotificationPayload } from '../types/notification'; import { SubsyncResolvedConfig } from '../subsync/utils'; import type { SubsyncRuntimeDeps } from '../core/services/subsync-runner'; import type { IpcDepsRuntimeOptions } from '../core/services/ipc'; import type { AnkiJimakuIpcRuntimeOptions } from '../core/services/anki-jimaku'; import type { CliCommandDepsRuntimeOptions } from '../core/services/cli-command'; import type { HandleMpvCommandFromIpcOptions } from '../core/services/ipc-command'; -import { - cycleRuntimeOptionFromIpcRuntime, - setRuntimeOptionFromIpcRuntime, -} from '../core/services/runtime-options-ipc'; +import { setRuntimeOptionFromIpcRuntime } from '../core/services/runtime-options-ipc'; import { RuntimeOptionsManager } from '../runtime-options'; export interface RuntimeOptionsIpcDepsParams { @@ -28,17 +24,12 @@ export interface SubsyncRuntimeDepsParams { export function createRuntimeOptionsIpcDeps(params: RuntimeOptionsIpcDepsParams): { setRuntimeOption: (id: RuntimeOptionId, value: RuntimeOptionValue) => unknown; - cycleRuntimeOption: (id: RuntimeOptionId, direction: 1 | -1) => unknown; } { return { setRuntimeOption: (id, value) => setRuntimeOptionFromIpcRuntime(params.getRuntimeOptionsManager(), id, value, (text) => params.showMpvOsd(text), ), - cycleRuntimeOption: (id, direction) => - cycleRuntimeOptionFromIpcRuntime(params.getRuntimeOptionsManager(), id, direction, (text) => - params.showMpvOsd(text), - ), }; } @@ -55,7 +46,6 @@ export function createSubsyncRuntimeDeps(params: SubsyncRuntimeDepsParams): Subs export interface MainIpcRuntimeServiceDepsParams { getMainWindow: IpcDepsRuntimeOptions['getMainWindow']; - getVisibleOverlayVisibility: IpcDepsRuntimeOptions['getVisibleOverlayVisibility']; onOverlayModalClosed: IpcDepsRuntimeOptions['onOverlayModalClosed']; onOverlayModalOpened?: IpcDepsRuntimeOptions['onOverlayModalOpened']; onOverlayMouseInteractionChanged?: IpcDepsRuntimeOptions['onOverlayMouseInteractionChanged']; @@ -67,12 +57,8 @@ export interface MainIpcRuntimeServiceDepsParams { getMediaTimingReviewWaveform?: IpcDepsRuntimeOptions['getMediaTimingReviewWaveform']; stopMediaTimingReviewPreview?: IpcDepsRuntimeOptions['stopMediaTimingReviewPreview']; resolveMediaTimingReview?: IpcDepsRuntimeOptions['resolveMediaTimingReview']; - openYomitanSettings: IpcDepsRuntimeOptions['openYomitanSettings']; - quitApp: IpcDepsRuntimeOptions['quitApp']; - toggleVisibleOverlay: IpcDepsRuntimeOptions['toggleVisibleOverlay']; tokenizeCurrentSubtitle: IpcDepsRuntimeOptions['tokenizeCurrentSubtitle']; getCurrentSubtitleRaw: IpcDepsRuntimeOptions['getCurrentSubtitleRaw']; - getCurrentSubtitleAss: IpcDepsRuntimeOptions['getCurrentSubtitleAss']; getSubtitleSidebarSnapshot?: IpcDepsRuntimeOptions['getSubtitleSidebarSnapshot']; getSubtitleSidebarPlaybackState?: IpcDepsRuntimeOptions['getSubtitleSidebarPlaybackState']; getSubtitleSidebarOpen?: IpcDepsRuntimeOptions['getSubtitleSidebarOpen']; @@ -82,9 +68,7 @@ export interface MainIpcRuntimeServiceDepsParams { getSubtitlePosition: IpcDepsRuntimeOptions['getSubtitlePosition']; getSubtitleStyle: IpcDepsRuntimeOptions['getSubtitleStyle']; saveSubtitlePosition: IpcDepsRuntimeOptions['saveSubtitlePosition']; - getMecabTokenizer: IpcDepsRuntimeOptions['getMecabTokenizer']; handleMpvCommand: IpcDepsRuntimeOptions['handleMpvCommand']; - getKeybindings: IpcDepsRuntimeOptions['getKeybindings']; getMpvInputBindings?: IpcDepsRuntimeOptions['getMpvInputBindings']; getSessionBindings: IpcDepsRuntimeOptions['getSessionBindings']; getConfiguredShortcuts: IpcDepsRuntimeOptions['getConfiguredShortcuts']; @@ -94,14 +78,11 @@ export interface MainIpcRuntimeServiceDepsParams { getOverlayNotificationPosition: IpcDepsRuntimeOptions['getOverlayNotificationPosition']; getControllerConfig: IpcDepsRuntimeOptions['getControllerConfig']; saveControllerConfig: IpcDepsRuntimeOptions['saveControllerConfig']; - saveControllerPreference: IpcDepsRuntimeOptions['saveControllerPreference']; getSecondarySubMode: IpcDepsRuntimeOptions['getSecondarySubMode']; getMpvClient: IpcDepsRuntimeOptions['getMpvClient']; runSubsyncManual: IpcDepsRuntimeOptions['runSubsyncManual']; - getAnkiConnectStatus: IpcDepsRuntimeOptions['getAnkiConnectStatus']; getRuntimeOptions: IpcDepsRuntimeOptions['getRuntimeOptions']; setRuntimeOption: IpcDepsRuntimeOptions['setRuntimeOption']; - cycleRuntimeOption: IpcDepsRuntimeOptions['cycleRuntimeOption']; reportOverlayContentBounds: IpcDepsRuntimeOptions['reportOverlayContentBounds']; getAnilistStatus: IpcDepsRuntimeOptions['getAnilistStatus']; clearAnilistToken: IpcDepsRuntimeOptions['clearAnilistToken']; @@ -115,7 +96,6 @@ export interface MainIpcRuntimeServiceDepsParams { getCharacterDictionaryManagerSnapshot?: IpcDepsRuntimeOptions['getCharacterDictionaryManagerSnapshot']; removeCharacterDictionaryManagedEntry?: IpcDepsRuntimeOptions['removeCharacterDictionaryManagedEntry']; moveCharacterDictionaryManagedEntry?: IpcDepsRuntimeOptions['moveCharacterDictionaryManagedEntry']; - appendClipboardVideoToQueue: IpcDepsRuntimeOptions['appendClipboardVideoToQueue']; getChangelogSnapshot?: IpcDepsRuntimeOptions['getChangelogSnapshot']; getPlaylistBrowserSnapshot: IpcDepsRuntimeOptions['getPlaylistBrowserSnapshot']; appendPlaylistBrowserFile: IpcDepsRuntimeOptions['appendPlaylistBrowserFile']; @@ -126,22 +106,9 @@ export interface MainIpcRuntimeServiceDepsParams { } export interface AnkiJimakuIpcRuntimeServiceDepsParams { - patchAnkiConnectEnabled: AnkiJimakuIpcRuntimeOptions['patchAnkiConnectEnabled']; getResolvedConfig: AnkiJimakuIpcRuntimeOptions['getResolvedConfig']; - getRuntimeOptionsManager: AnkiJimakuIpcRuntimeOptions['getRuntimeOptionsManager']; - getSubtitleTimingTracker: AnkiJimakuIpcRuntimeOptions['getSubtitleTimingTracker']; getMpvClient: AnkiJimakuIpcRuntimeOptions['getMpvClient']; getAnkiIntegration: AnkiJimakuIpcRuntimeOptions['getAnkiIntegration']; - setAnkiIntegration: AnkiJimakuIpcRuntimeOptions['setAnkiIntegration']; - getKnownWordCacheStatePath: AnkiJimakuIpcRuntimeOptions['getKnownWordCacheStatePath']; - getCachedMediaPath?: AnkiJimakuIpcRuntimeOptions['getCachedMediaPath']; - shouldRequireRemoteMediaCache?: AnkiJimakuIpcRuntimeOptions['shouldRequireRemoteMediaCache']; - getYoutubeMediaSourceUrl?: AnkiJimakuIpcRuntimeOptions['getYoutubeMediaSourceUrl']; - showDesktopNotification: AnkiJimakuIpcRuntimeOptions['showDesktopNotification']; - showOverlayNotification?: (payload: OverlayNotificationPayload) => void; - dismissOverlayNotification?: (id: string) => void; - createFieldGroupingCallback: AnkiJimakuIpcRuntimeOptions['createFieldGroupingCallback']; - broadcastRuntimeOptionsChanged: AnkiJimakuIpcRuntimeOptions['broadcastRuntimeOptionsChanged']; getFieldGroupingResolver: AnkiJimakuIpcRuntimeOptions['getFieldGroupingResolver']; setFieldGroupingResolver: AnkiJimakuIpcRuntimeOptions['setFieldGroupingResolver']; parseMediaInfo: AnkiJimakuIpcRuntimeOptions['parseMediaInfo']; @@ -258,7 +225,6 @@ export function createMainIpcRuntimeServiceDeps( ): IpcDepsRuntimeOptions { return { getMainWindow: params.getMainWindow, - getVisibleOverlayVisibility: params.getVisibleOverlayVisibility, onOverlayModalClosed: params.onOverlayModalClosed, onOverlayModalOpened: params.onOverlayModalOpened, onOverlayMouseInteractionChanged: params.onOverlayMouseInteractionChanged, @@ -270,12 +236,8 @@ export function createMainIpcRuntimeServiceDeps( getMediaTimingReviewWaveform: params.getMediaTimingReviewWaveform, stopMediaTimingReviewPreview: params.stopMediaTimingReviewPreview, resolveMediaTimingReview: params.resolveMediaTimingReview, - openYomitanSettings: params.openYomitanSettings, - quitApp: params.quitApp, - toggleVisibleOverlay: params.toggleVisibleOverlay, tokenizeCurrentSubtitle: params.tokenizeCurrentSubtitle, getCurrentSubtitleRaw: params.getCurrentSubtitleRaw, - getCurrentSubtitleAss: params.getCurrentSubtitleAss, getSubtitleSidebarSnapshot: params.getSubtitleSidebarSnapshot, getSubtitleSidebarPlaybackState: params.getSubtitleSidebarPlaybackState, getSubtitleSidebarOpen: params.getSubtitleSidebarOpen, @@ -283,9 +245,7 @@ export function createMainIpcRuntimeServiceDeps( getSubtitlePosition: params.getSubtitlePosition, getSubtitleStyle: params.getSubtitleStyle, saveSubtitlePosition: params.saveSubtitlePosition, - getMecabTokenizer: params.getMecabTokenizer, handleMpvCommand: params.handleMpvCommand, - getKeybindings: params.getKeybindings, getMpvInputBindings: params.getMpvInputBindings, getSessionBindings: params.getSessionBindings, getConfiguredShortcuts: params.getConfiguredShortcuts, @@ -295,16 +255,13 @@ export function createMainIpcRuntimeServiceDeps( getOverlayNotificationPosition: params.getOverlayNotificationPosition, getControllerConfig: params.getControllerConfig, saveControllerConfig: params.saveControllerConfig, - saveControllerPreference: params.saveControllerPreference, focusMainWindow: params.focusMainWindow ?? (() => {}), activatePlaybackWindowForOverlayInteraction: params.activatePlaybackWindowForOverlayInteraction, getSecondarySubMode: params.getSecondarySubMode, getMpvClient: params.getMpvClient, runSubsyncManual: params.runSubsyncManual, - getAnkiConnectStatus: params.getAnkiConnectStatus, getRuntimeOptions: params.getRuntimeOptions, setRuntimeOption: params.setRuntimeOption, - cycleRuntimeOption: params.cycleRuntimeOption, reportOverlayContentBounds: params.reportOverlayContentBounds, getAnilistStatus: params.getAnilistStatus, clearAnilistToken: params.clearAnilistToken, @@ -318,7 +275,6 @@ export function createMainIpcRuntimeServiceDeps( getCharacterDictionaryManagerSnapshot: params.getCharacterDictionaryManagerSnapshot, removeCharacterDictionaryManagedEntry: params.removeCharacterDictionaryManagedEntry, moveCharacterDictionaryManagedEntry: params.moveCharacterDictionaryManagedEntry, - appendClipboardVideoToQueue: params.appendClipboardVideoToQueue, getChangelogSnapshot: params.getChangelogSnapshot, getPlaylistBrowserSnapshot: params.getPlaylistBrowserSnapshot, appendPlaylistBrowserFile: params.appendPlaylistBrowserFile, @@ -333,26 +289,9 @@ export function createAnkiJimakuIpcRuntimeServiceDeps( params: AnkiJimakuIpcRuntimeServiceDepsParams, ): AnkiJimakuIpcRuntimeOptions { return { - patchAnkiConnectEnabled: params.patchAnkiConnectEnabled, getResolvedConfig: params.getResolvedConfig, - getRuntimeOptionsManager: params.getRuntimeOptionsManager, - getSubtitleTimingTracker: params.getSubtitleTimingTracker, getMpvClient: params.getMpvClient, getAnkiIntegration: params.getAnkiIntegration, - setAnkiIntegration: params.setAnkiIntegration, - getKnownWordCacheStatePath: params.getKnownWordCacheStatePath, - ...(params.getCachedMediaPath ? { getCachedMediaPath: params.getCachedMediaPath } : {}), - ...(params.shouldRequireRemoteMediaCache - ? { shouldRequireRemoteMediaCache: params.shouldRequireRemoteMediaCache } - : {}), - ...(params.getYoutubeMediaSourceUrl - ? { getYoutubeMediaSourceUrl: params.getYoutubeMediaSourceUrl } - : {}), - showDesktopNotification: params.showDesktopNotification, - showOverlayNotification: params.showOverlayNotification, - dismissOverlayNotification: params.dismissOverlayNotification, - createFieldGroupingCallback: params.createFieldGroupingCallback, - broadcastRuntimeOptionsChanged: params.broadcastRuntimeOptionsChanged, getFieldGroupingResolver: params.getFieldGroupingResolver, setFieldGroupingResolver: params.setFieldGroupingResolver, parseMediaInfo: params.parseMediaInfo, diff --git a/src/main/ipc-runtime.ts b/src/main/ipc-runtime.ts index 4e5f7f6a..3ac27410 100644 --- a/src/main/ipc-runtime.ts +++ b/src/main/ipc-runtime.ts @@ -15,7 +15,7 @@ import { export interface RegisterIpcRuntimeServicesParams { runtimeOptions: RuntimeOptionsIpcDepsParams; - mainDeps: Omit; + mainDeps: Omit; ankiJimakuDeps: AnkiJimakuIpcRuntimeServiceDepsParams; } @@ -40,7 +40,6 @@ export function registerIpcRuntimeServices(params: RegisterIpcRuntimeServicesPar registerMainIpcRuntimeServices({ ...params.mainDeps, setRuntimeOption: runtimeOptionsIpcDeps.setRuntimeOption, - cycleRuntimeOption: runtimeOptionsIpcDeps.cycleRuntimeOption, }); registerAnkiJimakuIpcRuntimeServices(params.ankiJimakuDeps); } diff --git a/src/main/main-wiring.test.ts b/src/main/main-wiring.test.ts index 031db5ea..51953089 100644 --- a/src/main/main-wiring.test.ts +++ b/src/main/main-wiring.test.ts @@ -37,7 +37,6 @@ test('media path changes clear rendered subtitle state without clearing same-you assert.ok(actionBlock); assert.match(actionBlock, /appState\.currentSubText = '';/); - assert.match(actionBlock, /appState\.currentSubAssText = '';/); assert.match(actionBlock, /appState\.currentSubtitleData = null;/); assert.match(actionBlock, /isSameYoutubeMediaPath\(/); assert.match(actionBlock, /if \(!preserveParsedSubtitleCues\)/); diff --git a/src/main/runtime/composers/mpv-runtime-composer.test.ts b/src/main/runtime/composers/mpv-runtime-composer.test.ts index e83683cc..c42e9718 100644 --- a/src/main/runtime/composers/mpv-runtime-composer.test.ts +++ b/src/main/runtime/composers/mpv-runtime-composer.test.ts @@ -45,7 +45,6 @@ function createDefaultMpvFixture() { immersionTracker: null, subtitleTimingTracker: null, currentSubText: '', - currentSubAssText: '', playbackPaused: null, previousSecondarySubVisibility: null, }, diff --git a/src/main/runtime/mpv-client-event-bindings.ts b/src/main/runtime/mpv-client-event-bindings.ts index cb0d7c83..1b761028 100644 --- a/src/main/runtime/mpv-client-event-bindings.ts +++ b/src/main/runtime/mpv-client-event-bindings.ts @@ -1,7 +1,6 @@ type MpvBindingEventName = | 'connection-change' | 'subtitle-change' - | 'subtitle-ass-change' | 'secondary-subtitle-change' | 'subtitle-track-change' | 'secondary-subtitle-track-change' @@ -89,7 +88,6 @@ export function createHandleMpvSubtitleTimingHandler(deps: { export function createBindMpvClientEventHandlers(deps: { onConnectionChange: (payload: { connected: boolean }) => void; onSubtitleChange: (payload: { text: string }) => void; - onSubtitleAssChange: (payload: { text: string }) => void; onSecondarySubtitleChange: (payload: { text: string }) => void; onSubtitleTrackChange: (payload: { sid: number | null }) => void; onSecondarySubtitleTrackChange: (payload: { sid: number | null }) => void; @@ -108,7 +106,6 @@ export function createBindMpvClientEventHandlers(deps: { return (mpvClient: MpvEventClient): void => { mpvClient.on('connection-change', deps.onConnectionChange); mpvClient.on('subtitle-change', deps.onSubtitleChange); - mpvClient.on('subtitle-ass-change', deps.onSubtitleAssChange); mpvClient.on('secondary-subtitle-change', deps.onSecondarySubtitleChange); mpvClient.on('subtitle-track-change', deps.onSubtitleTrackChange); mpvClient.on('secondary-subtitle-track-change', deps.onSecondarySubtitleTrackChange); diff --git a/src/main/runtime/mpv-main-event-actions.ts b/src/main/runtime/mpv-main-event-actions.ts index ab0b4577..01100375 100644 --- a/src/main/runtime/mpv-main-event-actions.ts +++ b/src/main/runtime/mpv-main-event-actions.ts @@ -49,16 +49,6 @@ export function createHandleMpvSubtitleChangeHandler(deps: { }; } -export function createHandleMpvSubtitleAssChangeHandler(deps: { - setCurrentSubAssText: (text: string) => void; - broadcastSubtitleAss: (text: string) => void; -}) { - return ({ text }: { text: string }): void => { - deps.setCurrentSubAssText(text); - deps.broadcastSubtitleAss(text); - }; -} - export function createHandleMpvSecondarySubtitleChangeHandler(deps: { broadcastSecondarySubtitle: (text: string) => void; }) { diff --git a/src/main/runtime/mpv-main-event-bindings.test.ts b/src/main/runtime/mpv-main-event-bindings.test.ts index 2ab395e7..6ffa091d 100644 --- a/src/main/runtime/mpv-main-event-bindings.test.ts +++ b/src/main/runtime/mpv-main-event-bindings.test.ts @@ -29,8 +29,6 @@ function createDeps(overrides: Partial = {}): Deps { onSubtitleChange: () => {}, refreshDiscordPresence: () => {}, - setCurrentSubAssText: () => {}, - broadcastSubtitleAss: () => {}, broadcastSecondarySubtitle: () => {}, updateCurrentMediaPath: () => {}, diff --git a/src/main/runtime/mpv-main-event-bindings.ts b/src/main/runtime/mpv-main-event-bindings.ts index f4eee996..608adb28 100644 --- a/src/main/runtime/mpv-main-event-bindings.ts +++ b/src/main/runtime/mpv-main-event-bindings.ts @@ -10,7 +10,6 @@ import { createHandleMpvPauseChangeHandler, createHandleMpvSecondarySubtitleChangeHandler, createHandleMpvSecondarySubtitleVisibilityHandler, - createHandleMpvSubtitleAssChangeHandler, createHandleMpvSubtitleChangeHandler, createHandleMpvSubtitleMetricsChangeHandler, createHandleMpvTimePosChangeHandler, @@ -52,8 +51,6 @@ export function createBindMpvMainEventHandlersHandler(deps: { logSubtitleProcessingDebug?: (message: string) => void; refreshDiscordPresence: () => void; - setCurrentSubAssText: (text: string) => void; - broadcastSubtitleAss: (text: string) => void; broadcastSecondarySubtitle: (text: string) => void; onSubtitleTrackChange?: (sid: number | null) => void; onSecondarySubtitleTrackChange?: (sid: number | null) => void; @@ -135,10 +132,6 @@ export function createBindMpvMainEventHandlersHandler(deps: { : undefined, refreshDiscordPresence: () => deps.refreshDiscordPresence(), }); - const handleMpvSubtitleAssChange = createHandleMpvSubtitleAssChangeHandler({ - setCurrentSubAssText: (text) => deps.setCurrentSubAssText(text), - broadcastSubtitleAss: (text) => deps.broadcastSubtitleAss(text), - }); const handleMpvSecondarySubtitleChange = createHandleMpvSecondarySubtitleChangeHandler({ broadcastSecondarySubtitle: (text) => deps.broadcastSecondarySubtitle(text), }); @@ -200,7 +193,6 @@ export function createBindMpvMainEventHandlersHandler(deps: { createBindMpvClientEventHandlers({ onConnectionChange: handleMpvConnectionChangeWithSidebarReset, onSubtitleChange: handleMpvSubtitleChange, - onSubtitleAssChange: handleMpvSubtitleAssChange, onSecondarySubtitleChange: handleMpvSecondarySubtitleChange, onSubtitleTrackChange: ({ sid }) => deps.onSubtitleTrackChange?.(sid), onSecondarySubtitleTrackChange: ({ sid }) => deps.onSecondarySubtitleTrackChange?.(sid), diff --git a/src/main/runtime/mpv-main-event-main-deps.test.ts b/src/main/runtime/mpv-main-event-main-deps.test.ts index 0a15f19c..d46d4357 100644 --- a/src/main/runtime/mpv-main-event-main-deps.test.ts +++ b/src/main/runtime/mpv-main-event-main-deps.test.ts @@ -20,7 +20,6 @@ function buildMainDeps( immersionTracker: null, subtitleTimingTracker: null, currentSubText: '', - currentSubAssText: '', playbackPaused: null, previousSecondarySubVisibility: false, ...appStateOverrides, diff --git a/src/main/runtime/mpv-main-event-main-deps.ts b/src/main/runtime/mpv-main-event-main-deps.ts index a7bd5d6c..ac30fd73 100644 --- a/src/main/runtime/mpv-main-event-main-deps.ts +++ b/src/main/runtime/mpv-main-event-main-deps.ts @@ -46,7 +46,6 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: { activeParsedSubtitleSource?: string | null; currentMediaPath?: string | null; currentSubText: string; - currentSubAssText: string; currentSubtitleData?: SubtitleData | null; playbackPaused: boolean | null; previousSecondarySubVisibility: boolean | null; @@ -300,11 +299,6 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: { ? (trackList: unknown[] | null) => deps.onSubtitleTrackListChange!(trackList) : undefined, refreshDiscordPresence: () => deps.refreshDiscordPresence(), - setCurrentSubAssText: (text: string) => { - deps.appState.currentSubAssText = text; - }, - broadcastSubtitleAss: (text: string) => - deps.broadcastToOverlayWindows('subtitle-ass:set', text), broadcastSecondarySubtitle: (text: string) => { if (deps.onSecondarySubtitleChange) { deps.onSecondarySubtitleChange(text); diff --git a/src/main/state.ts b/src/main/state.ts index e9a963dd..c7af0e70 100644 --- a/src/main/state.ts +++ b/src/main/state.ts @@ -160,7 +160,6 @@ export interface AppState { discordPresenceService: ReturnType | null; reconnectTimer: ReturnType | null; currentSubText: string; - currentSubAssText: string; currentSubtitleData: SubtitleData | null; activeParsedSubtitleCues: SubtitleCue[]; activeParsedSubtitleSource: string | null; @@ -247,7 +246,6 @@ export function createAppState(values: AppStateInitialValues): AppState { discordPresenceService: null, reconnectTimer: null, currentSubText: '', - currentSubAssText: '', currentSubtitleData: null, activeParsedSubtitleCues: [], activeParsedSubtitleSource: null, diff --git a/src/mecab-tokenizer.ts b/src/mecab-tokenizer.ts index d2e1bdbc..db7bef3f 100644 --- a/src/mecab-tokenizer.ts +++ b/src/mecab-tokenizer.ts @@ -399,29 +399,6 @@ export class MecabTokenizer { path: this.mecabPath, }; } - - setEnabled(enabled: boolean): void { - this.enabled = enabled; - if (!enabled) { - const pending: MecabQueuedRequest[] = []; - if (this.activeRequest) { - pending.push({ - text: this.activeRequest.text, - retryCount: MecabTokenizer.MAX_RETRY_COUNT, - resolve: this.activeRequest.resolve, - }); - } - if (this.requestQueue.length > 0) { - pending.push(...this.requestQueue); - } - this.activeRequest = null; - this.requestQueue = []; - for (const request of pending) { - request.resolve(null); - } - this.stopPersistentProcess(); - } - } } export { mapMecabPos1ToPartOfSpeech as mapPartOfSpeech }; diff --git a/src/preload.ts b/src/preload.ts index dfcdae7f..22abbaa8 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -22,8 +22,6 @@ import { createIpcListenerFactories } from './preload-ipc-listeners'; import type { SubtitleData, SubtitlePosition, - MecabStatus, - Keybinding, ElectronAPI, SecondarySubMode, SubtitleStyleConfig, @@ -45,7 +43,6 @@ import type { SubsyncManualPayload, SubsyncManualRunRequest, SubsyncResult, - ClipboardAppendResult, PlaylistBrowserMutationResult, PlaylistBrowserSnapshot, KikuFieldGroupingRequestData, @@ -60,7 +57,6 @@ import type { ShortcutsConfig, ConfigHotReloadPayload, ControllerConfigUpdate, - ControllerPreferenceUpdate, ResolvedControllerConfig, SessionHelpOpenPayload, SessionNumericSelectionStartPayload, @@ -176,10 +172,6 @@ const onOverlayNotificationEvent = const onNotificationHistoryToggleEvent = createQueuedIpcListener( IPC_CHANNELS.event.notificationHistoryToggle, ); -const onSubtitleVisibilityEvent = createLatestValueIpcListenerWithPayload( - IPC_CHANNELS.event.subtitleVisibility, - (payload) => payload === true, -); const onSubtitlePositionSetEvent = createLatestValueIpcListenerWithPayload( IPC_CHANNELS.event.subtitlePositionSet, (payload) => payload as SubtitlePosition | null, @@ -236,22 +228,14 @@ const electronAPI: ElectronAPI = { }, onNotificationHistoryToggle: onNotificationHistoryToggleEvent, - onVisibility: (callback: (visible: boolean) => void) => { - onSubtitleVisibilityEvent(callback); - }, - onSubtitlePosition: (callback: (position: SubtitlePosition | null) => void) => { onSubtitlePositionSetEvent(callback); }, - getOverlayVisibility: (): Promise => - ipcRenderer.invoke(IPC_CHANNELS.request.getVisibleOverlayVisibility), getCurrentSubtitle: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.request.getCurrentSubtitle), getCurrentSubtitleRaw: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.request.getCurrentSubtitleRaw), - getCurrentSubtitleAss: (): Promise => - ipcRenderer.invoke(IPC_CHANNELS.request.getCurrentSubtitleAss), getSubtitleSidebarOpen: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.request.getSubtitleSidebarOpen), getSubtitleSidebarSnapshot: () => @@ -264,14 +248,6 @@ const electronAPI: ElectronAPI = { }, getPlaybackPaused: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.request.getPlaybackPaused), - onSubtitleAss: (callback: (assText: string) => void) => { - ipcRenderer.on( - IPC_CHANNELS.event.subtitleAssSet, - (_event: IpcRendererEvent, assText: string) => { - callback(assText); - }, - ); - }, setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => { ipcRenderer.send(IPC_CHANNELS.command.setIgnoreMouseEvents, ignore, options); @@ -281,10 +257,6 @@ const electronAPI: ElectronAPI = { ipcRenderer.send(IPC_CHANNELS.command.reportOverlayInteractive, interactive); }, - openYomitanSettings: () => { - ipcRenderer.send(IPC_CHANNELS.command.openYomitanSettings); - }, - recordYomitanLookup: (context?: SubtitleMiningContext | null) => { ipcRenderer.send(IPC_CHANNELS.command.recordYomitanLookup, context ?? null); }, @@ -295,18 +267,10 @@ const electronAPI: ElectronAPI = { ipcRenderer.send(IPC_CHANNELS.command.saveSubtitlePosition, position); }, - getMecabStatus: (): Promise => - ipcRenderer.invoke(IPC_CHANNELS.request.getMecabStatus), - setMecabEnabled: (enabled: boolean) => { - ipcRenderer.send(IPC_CHANNELS.command.setMecabEnabled, enabled); - }, - sendMpvCommand: (command: (string | number)[]) => { ipcRenderer.send(IPC_CHANNELS.command.mpvCommand, command); }, - getKeybindings: (): Promise => - ipcRenderer.invoke(IPC_CHANNELS.request.getKeybindings), getMpvInputBindings: () => ipcRenderer.invoke(IPC_CHANNELS.request.getMpvInputBindings), getSessionBindings: () => ipcRenderer.invoke(IPC_CHANNELS.request.getSessionBindings), getConfiguredShortcuts: (): Promise> => @@ -325,8 +289,6 @@ const electronAPI: ElectronAPI = { ipcRenderer.invoke(IPC_CHANNELS.request.getControllerConfig), saveControllerConfig: (update: ControllerConfigUpdate): Promise => ipcRenderer.invoke(IPC_CHANNELS.command.saveControllerConfig, update), - saveControllerPreference: (update: ControllerPreferenceUpdate): Promise => - ipcRenderer.invoke(IPC_CHANNELS.command.saveControllerPreference, update), getJimakuMediaInfo: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.request.jimakuGetMediaInfo), @@ -350,31 +312,14 @@ const electronAPI: ElectronAPI = { tsukihimeGetSecondaryLanguages: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.request.tsukihimeGetSecondaryLanguages), - quitApp: () => { - ipcRenderer.send(IPC_CHANNELS.command.quitApp); - }, - toggleDevTools: () => { ipcRenderer.send(IPC_CHANNELS.command.toggleDevTools); }, - toggleOverlay: () => { - ipcRenderer.send(IPC_CHANNELS.command.toggleOverlay); - }, - toggleStatsOverlay: () => { ipcRenderer.send(IPC_CHANNELS.command.toggleStatsOverlay); }, - getAnkiConnectStatus: (): Promise => - ipcRenderer.invoke(IPC_CHANNELS.request.getAnkiConnectStatus), - setAnkiConnectEnabled: (enabled: boolean) => { - ipcRenderer.send(IPC_CHANNELS.command.setAnkiConnectEnabled, enabled); - }, - clearAnkiConnectHistory: () => { - ipcRenderer.send(IPC_CHANNELS.command.clearAnkiConnectHistory); - }, - onSecondarySub: (callback: (text: string) => void) => { onSecondarySubtitleSetEvent(callback); }, @@ -419,8 +364,6 @@ const electronAPI: ElectronAPI = { value: RuntimeOptionValue, ): Promise => ipcRenderer.invoke(IPC_CHANNELS.request.setRuntimeOption, id, value), - cycleRuntimeOption: (id: RuntimeOptionId, direction: 1 | -1): Promise => - ipcRenderer.invoke(IPC_CHANNELS.request.cycleRuntimeOption, id, direction), onRuntimeOptionsChanged: (callback: (options: RuntimeOptionState[]) => void) => { ipcRenderer.on( IPC_CHANNELS.event.runtimeOptionsChanged, @@ -485,8 +428,6 @@ const electronAPI: ElectronAPI = { onSessionNumericSelectionStart: onSessionNumericSelectionStartEvent, onKeyboardModeToggleRequested: onKeyboardModeToggleRequestedEvent, onLookupWindowToggleRequested: onLookupWindowToggleRequestedEvent, - appendClipboardVideoToQueue: (): Promise => - ipcRenderer.invoke(IPC_CHANNELS.request.appendClipboardVideoToQueue), getPlaylistBrowserSnapshot: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.request.getPlaylistBrowserSnapshot), appendPlaylistBrowserFile: (pathValue: string): Promise => diff --git a/src/renderer/handlers/keyboard.test.ts b/src/renderer/handlers/keyboard.test.ts index cca4b7c1..0dff8e0f 100644 --- a/src/renderer/handlers/keyboard.test.ts +++ b/src/renderer/handlers/keyboard.test.ts @@ -242,7 +242,6 @@ function installKeyboardTestGlobals() { windowFocusCalls += 1; }, electronAPI: { - getKeybindings: async () => [], getMpvInputBindings: () => getMpvInputBindings(), getSessionBindings: () => getSessionBindingsImpl(), getConfiguredShortcuts: async () => configuredShortcuts, diff --git a/src/shared/ipc/contracts.ts b/src/shared/ipc/contracts.ts index 9afd5831..c7e32116 100644 --- a/src/shared/ipc/contracts.ts +++ b/src/shared/ipc/contracts.ts @@ -25,22 +25,15 @@ export const IPC_CHANNELS = { command: { setIgnoreMouseEvents: 'set-ignore-mouse-events', overlayModalClosed: 'overlay:modal-closed', - openYomitanSettings: 'open-yomitan-settings', recordYomitanLookup: 'record-yomitan-lookup', - quitApp: 'quit-app', youtubePickerResolve: 'youtube:picker-resolve', toggleDevTools: 'toggle-dev-tools', - toggleOverlay: 'toggle-overlay', saveSubtitlePosition: 'save-subtitle-position', statsNativeConfirmDialog: 'stats:native-confirm-dialog', statsNativeDialogOpened: 'stats:native-dialog-opened', statsNativeDialogClosed: 'stats:native-dialog-closed', saveControllerConfig: 'save-controller-config', - saveControllerPreference: 'save-controller-preference', - setMecabEnabled: 'set-mecab-enabled', mpvCommand: 'mpv-command', - setAnkiConnectEnabled: 'set-anki-connect-enabled', - clearAnkiConnectHistory: 'clear-anki-connect-history', refreshKnownWords: 'anki:refresh-known-words', kikuFieldGroupingRespond: 'kiku:field-grouping-respond', reportOverlayContentBounds: 'overlay-content-bounds:report', @@ -63,18 +56,14 @@ export const IPC_CHANNELS = { downloadSubtitleGenerationVadModel: 'subtitle-generation:download-vad', setSubtitleGenerationVadEnabled: 'subtitle-generation:set-vad-enabled', cancelSubtitleGeneration: 'subtitle-generation:cancel', - getVisibleOverlayVisibility: 'get-visible-overlay-visibility', getCurrentSubtitle: 'get-current-subtitle', getCurrentSubtitleRaw: 'get-current-subtitle-raw', - getCurrentSubtitleAss: 'get-current-subtitle-ass', getSubtitleSidebarSnapshot: 'get-subtitle-sidebar-snapshot', getSubtitleSidebarPlaybackState: 'get-subtitle-sidebar-playback-state', getSubtitleSidebarOpen: 'get-subtitle-sidebar-open', getPlaybackPaused: 'get-playback-paused', getSubtitlePosition: 'get-subtitle-position', getSubtitleStyle: 'get-subtitle-style', - getMecabStatus: 'get-mecab-status', - getKeybindings: 'get-keybindings', getMpvInputBindings: 'get-mpv-input-bindings', getSessionBindings: 'get-session-bindings', getConfigShortcuts: 'get-config-shortcuts', @@ -88,10 +77,8 @@ export const IPC_CHANNELS = { focusMainWindow: 'focus-main-window', activatePlaybackWindowForOverlayInteraction: 'overlay:activate-playback-window', runSubsyncManual: 'subsync:run-manual', - getAnkiConnectStatus: 'get-anki-connect-status', getRuntimeOptions: 'runtime-options:get', setRuntimeOption: 'runtime-options:set', - cycleRuntimeOption: 'runtime-options:cycle', getAnilistStatus: 'anilist:get-status', clearAnilistToken: 'anilist:clear-token', openAnilistSetup: 'anilist:open-setup', @@ -103,7 +90,6 @@ export const IPC_CHANNELS = { getCharacterDictionaryManagerSnapshot: 'character-dictionary:get-manager-snapshot', removeCharacterDictionaryManagedEntry: 'character-dictionary:remove-managed-entry', moveCharacterDictionaryManagedEntry: 'character-dictionary:move-managed-entry', - appendClipboardVideoToQueue: 'clipboard:append-video-to-queue', getPlaylistBrowserSnapshot: 'playlist-browser:get-snapshot', appendPlaylistBrowserFile: 'playlist-browser:append-file', playPlaylistBrowserIndex: 'playlist-browser:play-index', @@ -153,9 +139,7 @@ export const IPC_CHANNELS = { subtitleGenerationProgress: 'subtitle-generation:progress', subtitleSet: 'subtitle:set', overlayPointerRecoveryRequest: 'overlay:pointer-recovery-request', - subtitleVisibility: 'mpv:subVisibility', subtitlePositionSet: 'subtitle-position:set', - subtitleAssSet: 'subtitle-ass:set', secondarySubtitleSet: 'secondary-subtitle:set', secondarySubtitleMode: 'secondary-subtitle:mode', subsyncOpenManual: 'subsync:open-manual', @@ -197,11 +181,6 @@ export type RuntimeOptionsSetRequest = { value: RuntimeOptionValue; }; -export type RuntimeOptionsCycleRequest = { - id: RuntimeOptionId; - direction: 1 | -1; -}; - export type OverlayContentBoundsReportRequest = { measurement: OverlayContentMeasurement; }; diff --git a/src/shared/ipc/validators.ts b/src/shared/ipc/validators.ts index 26dca4fb..be4b37cd 100644 --- a/src/shared/ipc/validators.ts +++ b/src/shared/ipc/validators.ts @@ -10,7 +10,6 @@ import type { } from '../../types/integrations'; import type { ControllerConfigUpdate, - ControllerPreferenceUpdate, SessionActionDispatchRequest, SubsyncManualRunRequest, } from '../../types/runtime'; @@ -131,16 +130,6 @@ export function parseSubtitlePosition(value: unknown): SubtitlePosition | null { }; } -export function parseControllerPreferenceUpdate(value: unknown): ControllerPreferenceUpdate | null { - if (!isObject(value)) return null; - if (typeof value.preferredGamepadId !== 'string') return null; - if (typeof value.preferredGamepadLabel !== 'string') return null; - return { - preferredGamepadId: value.preferredGamepadId, - preferredGamepadLabel: value.preferredGamepadLabel, - }; -} - function parseDiscreteBinding(value: unknown) { if (!isObject(value) || typeof value.kind !== 'string') return null; if (value.kind === 'none') { @@ -328,10 +317,6 @@ export function parseRuntimeOptionId(value: unknown): RuntimeOptionId | null { return RUNTIME_OPTION_IDS.includes(value as RuntimeOptionId) ? (value as RuntimeOptionId) : null; } -export function parseRuntimeOptionDirection(value: unknown): 1 | -1 | null { - return value === 1 || value === -1 ? value : null; -} - export function parseRuntimeOptionValue(value: unknown): RuntimeOptionValue | null { return typeof value === 'boolean' || typeof value === 'string' ? (value as RuntimeOptionValue) diff --git a/src/types/runtime.ts b/src/types/runtime.ts index 54bd36f5..6d9a7253 100644 --- a/src/types/runtime.ts +++ b/src/types/runtime.ts @@ -297,11 +297,6 @@ export interface ControllerConfig { profiles?: Record; } -export interface ControllerPreferenceUpdate { - preferredGamepadId: string; - preferredGamepadLabel: string; -} - export type ControllerConfigUpdate = ControllerConfig; export interface ControllerDeviceInfo { @@ -470,28 +465,20 @@ export interface ElectronAPI { options?: Pick, ) => void; onNotificationHistoryToggle: (callback: () => void) => void; - onVisibility: (callback: (visible: boolean) => void) => void; onSubtitlePosition: (callback: (position: SubtitlePosition | null) => void) => void; - getOverlayVisibility: () => Promise; getCurrentSubtitle: () => Promise; getCurrentSubtitleRaw: () => Promise; - getCurrentSubtitleAss: () => Promise; getSubtitleSidebarSnapshot: () => Promise; getSubtitleSidebarPlaybackState: () => Promise; copySubtitleSidebarSelection: (text: string) => Promise; getSubtitleSidebarOpen: () => Promise; getPlaybackPaused: () => Promise; - onSubtitleAss: (callback: (assText: string) => void) => void; setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => void; reportOverlayInteractive: (interactive: boolean) => void; - openYomitanSettings: () => void; recordYomitanLookup: (context?: SubtitleMiningContext | null) => void; getSubtitlePosition: () => Promise; saveSubtitlePosition: (position: SubtitlePosition) => void; - getMecabStatus: () => Promise; - setMecabEnabled: (enabled: boolean) => void; sendMpvCommand: (command: (string | number)[]) => void; - getKeybindings: () => Promise; getMpvInputBindings: () => Promise; getSessionBindings: () => Promise; getConfiguredShortcuts: () => Promise>; @@ -505,7 +492,6 @@ export interface ElectronAPI { markActiveVideoWatched: () => Promise; getControllerConfig: () => Promise; saveControllerConfig: (update: ControllerConfigUpdate) => Promise; - saveControllerPreference: (update: ControllerPreferenceUpdate) => Promise; getJimakuMediaInfo: () => Promise; jimakuSearchEntries: (query: JimakuSearchQuery) => Promise>; jimakuListFiles: (query: JimakuFilesQuery) => Promise>; @@ -518,13 +504,8 @@ export interface ElectronAPI { ) => Promise>; tsukihimeDownloadFile: (query: TsukihimeDownloadQuery) => Promise; tsukihimeGetSecondaryLanguages: () => Promise; - quitApp: () => void; toggleDevTools: () => void; - toggleOverlay: () => void; toggleStatsOverlay: () => void; - getAnkiConnectStatus: () => Promise; - setAnkiConnectEnabled: (enabled: boolean) => void; - clearAnkiConnectHistory: () => void; onSecondarySub: (callback: (text: string) => void) => void; onSecondarySubMode: (callback: (mode: SecondarySubMode) => void) => void; getSecondarySubMode: () => Promise; @@ -550,7 +531,6 @@ export interface ElectronAPI { id: RuntimeOptionId, value: RuntimeOptionValue, ) => Promise; - cycleRuntimeOption: (id: RuntimeOptionId, direction: 1 | -1) => Promise; onRuntimeOptionsChanged: (callback: (options: RuntimeOptionState[]) => void) => void; onOpenRuntimeOptions: (callback: () => void) => void; onOpenSessionHelp: (callback: (payload: SessionHelpOpenPayload) => void) => void; @@ -589,7 +569,6 @@ export interface ElectronAPI { ) => void; onKeyboardModeToggleRequested: (callback: () => void) => void; onLookupWindowToggleRequested: (callback: () => void) => void; - appendClipboardVideoToQueue: () => Promise; getPlaylistBrowserSnapshot: () => Promise; appendPlaylistBrowserFile: (path: string) => Promise; playPlaylistBrowserIndex: (index: number) => Promise;