diff --git a/docs-site/changelog.md b/docs-site/changelog.md index b5412d7..ecf3bac 100644 --- a/docs-site/changelog.md +++ b/docs-site/changelog.md @@ -1,5 +1,12 @@ # Changelog +## v0.9.3 (2026-03-25) +- Moved YouTube primary subtitle language defaults to `youtube.primarySubLanguages`. +- Removed the placeholder YouTube subtitle retime step; downloaded primary subtitle tracks are now used directly. +- Removed the old internal YouTube retime helper and its tests. +- Clarified optional `alass` / `ffsubsync` subtitle-sync setup and fallback behavior in the docs. +- Removed the legacy `youtubeSubgen.primarySubLanguages` config path from generated config and docs. + ## v0.9.2 (2026-03-25) - Fixed overlay pointer tracking so Windows click-through toggles immediately when the cursor enters or leaves subtitle regions. - Fixed Windows overlay window tracking on scaled displays by converting native tracked window bounds to Electron DIP coordinates. diff --git a/docs/architecture/README.md b/docs/architecture/README.md index ea23380..fef7a27 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -3,7 +3,7 @@ # Architecture Map Status: active -Last verified: 2026-03-13 +Last verified: 2026-03-26 Owner: Kyle Yasuda Read when: runtime ownership, composition boundaries, or layering questions @@ -27,6 +27,7 @@ The desktop app keeps `src/main.ts` as composition root and pushes behavior into - `src/core/services/` owns focused runtime services plus pure or side-effect-bounded logic. - `src/renderer/` owns overlay rendering and input behavior. - `src/config/` owns config definitions, defaults, loading, and resolution. +- `src/types/` owns shared cross-runtime contracts via domain entrypoints; `src/types.ts` stays a compatibility barrel. - `src/main/runtime/composers/` owns larger domain compositions. ## Architecture Intent diff --git a/src/anki-field-config.ts b/src/anki-field-config.ts index b87f047..5861742 100644 --- a/src/anki-field-config.ts +++ b/src/anki-field-config.ts @@ -1,4 +1,4 @@ -import type { AnkiConnectConfig } from './types'; +import type { AnkiConnectConfig } from './types/anki'; type NoteFieldValue = { value?: string } | string | null | undefined; @@ -8,7 +8,9 @@ function normalizeFieldName(value: string | null | undefined): string | null { return trimmed.length > 0 ? trimmed : null; } -export function getConfiguredWordFieldName(config?: Pick | null): string { +export function getConfiguredWordFieldName( + config?: Pick | null, +): string { return normalizeFieldName(config?.fields?.word) ?? 'Expression'; } diff --git a/src/anki-integration/ai.ts b/src/anki-integration/ai.ts index 034c420..8e39ef2 100644 --- a/src/anki-integration/ai.ts +++ b/src/anki-integration/ai.ts @@ -1,4 +1,4 @@ -import type { AiConfig } from '../types'; +import type { AiConfig } from '../types/integrations'; import { requestAiChatCompletion } from '../ai/client'; const DEFAULT_AI_SYSTEM_PROMPT = diff --git a/src/anki-integration/animated-image-sync.ts b/src/anki-integration/animated-image-sync.ts index 9a53df9..2528287 100644 --- a/src/anki-integration/animated-image-sync.ts +++ b/src/anki-integration/animated-image-sync.ts @@ -4,7 +4,7 @@ import * as os from 'node:os'; import * as path from 'node:path'; import { DEFAULT_ANKI_CONNECT_CONFIG } from '../config'; -import type { AnkiConnectConfig } from '../types'; +import type { AnkiConnectConfig } from '../types/anki'; type NoteInfoLike = { noteId: number; @@ -36,9 +36,7 @@ export function extractSoundFilenames(value: string): string[] { } function shouldSyncAnimatedImageToWordAudio(config: Pick): boolean { - return ( - config.media?.imageType === 'avif' && config.media?.syncAnimatedImageToWordAudio !== false - ); + return config.media?.imageType === 'avif' && config.media?.syncAnimatedImageToWordAudio !== false; } export async function probeAudioDurationSeconds( diff --git a/src/anki-integration/card-creation.test.ts b/src/anki-integration/card-creation.test.ts index 5cbe245..03cd42c 100644 --- a/src/anki-integration/card-creation.test.ts +++ b/src/anki-integration/card-creation.test.ts @@ -2,7 +2,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { CardCreationService } from './card-creation'; -import type { AnkiConnectConfig } from '../types'; +import type { AnkiConnectConfig } from '../types/anki'; test('CardCreationService counts locally created sentence cards', async () => { const minedCards: Array<{ count: number; noteIds?: number[] }> = []; diff --git a/src/anki-integration/card-creation.ts b/src/anki-integration/card-creation.ts index 6495fa8..4364348 100644 --- a/src/anki-integration/card-creation.ts +++ b/src/anki-integration/card-creation.ts @@ -3,10 +3,11 @@ import { getConfiguredWordFieldName, getPreferredWordValueFromExtractedFields, } from '../anki-field-config'; -import { AiConfig, AnkiConnectConfig } from '../types'; +import { AnkiConnectConfig } from '../types/anki'; import { createLogger } from '../logger'; import { SubtitleTimingTracker } from '../subtitle-timing-tracker'; -import { MpvClient } from '../types'; +import { AiConfig } from '../types/integrations'; +import { MpvClient } from '../types/runtime'; import { resolveSentenceBackText } from './ai'; import { resolveMediaGenerationInputPath } from './media-source'; diff --git a/src/anki-integration/field-grouping-merge.ts b/src/anki-integration/field-grouping-merge.ts index 4384b49..5ddcced 100644 --- a/src/anki-integration/field-grouping-merge.ts +++ b/src/anki-integration/field-grouping-merge.ts @@ -1,4 +1,4 @@ -import { AnkiConnectConfig } from '../types'; +import { AnkiConnectConfig } from '../types/anki'; import { getConfiguredWordFieldName } from '../anki-field-config'; interface FieldGroupingMergeMedia { diff --git a/src/anki-integration/field-grouping-workflow.test.ts b/src/anki-integration/field-grouping-workflow.test.ts index 1c02015..361ae04 100644 --- a/src/anki-integration/field-grouping-workflow.test.ts +++ b/src/anki-integration/field-grouping-workflow.test.ts @@ -1,7 +1,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { FieldGroupingWorkflow } from './field-grouping-workflow'; -import type { KikuDuplicateCardInfo, KikuFieldGroupingChoice } from '../types'; +import type { KikuDuplicateCardInfo, KikuFieldGroupingChoice } from '../types/anki'; type NoteInfo = { noteId: number; diff --git a/src/anki-integration/field-grouping-workflow.ts b/src/anki-integration/field-grouping-workflow.ts index 34cad8f..3369bd5 100644 --- a/src/anki-integration/field-grouping-workflow.ts +++ b/src/anki-integration/field-grouping-workflow.ts @@ -1,4 +1,4 @@ -import { KikuDuplicateCardInfo, KikuFieldGroupingChoice } from '../types'; +import { KikuDuplicateCardInfo, KikuFieldGroupingChoice } from '../types/anki'; import { getPreferredWordValueFromExtractedFields } from '../anki-field-config'; export interface FieldGroupingWorkflowNoteInfo { @@ -181,7 +181,8 @@ export class FieldGroupingWorkflow { return { noteId: noteInfo.noteId, expression: - getPreferredWordValueFromExtractedFields(fields, this.deps.getConfig()) || fallbackExpression, + getPreferredWordValueFromExtractedFields(fields, this.deps.getConfig()) || + fallbackExpression, sentencePreview: this.deps.truncateSentence( fields[(sentenceCardConfig.sentenceField || 'sentence').toLowerCase()] || (isOriginal ? '' : this.deps.getCurrentSubtitleText() || ''), diff --git a/src/anki-integration/field-grouping.ts b/src/anki-integration/field-grouping.ts index 363b9a5..cc692e8 100644 --- a/src/anki-integration/field-grouping.ts +++ b/src/anki-integration/field-grouping.ts @@ -1,4 +1,4 @@ -import { KikuMergePreviewResponse } from '../types'; +import { KikuMergePreviewResponse } from '../types/anki'; import { createLogger } from '../logger'; import { getPreferredWordValueFromExtractedFields } from '../anki-field-config'; diff --git a/src/anki-integration/known-word-cache.test.ts b/src/anki-integration/known-word-cache.test.ts index aacf46b..7e40050 100644 --- a/src/anki-integration/known-word-cache.test.ts +++ b/src/anki-integration/known-word-cache.test.ts @@ -4,7 +4,7 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import type { AnkiConnectConfig } from '../types'; +import type { AnkiConnectConfig } from '../types/anki'; import { KnownWordCacheManager } from './known-word-cache'; async function waitForCondition( @@ -351,10 +351,7 @@ test('KnownWordCacheManager preserves cache state key captured before refresh wo scope: string; words: string[]; }; - assert.equal( - persisted.scope, - '{"refreshMinutes":1,"scope":"is:note","fieldsWord":"Word"}', - ); + assert.equal(persisted.scope, '{"refreshMinutes":1,"scope":"is:note","fieldsWord":"Word"}'); assert.deepEqual(persisted.words, ['猫']); } finally { fs.rmSync(stateDir, { recursive: true, force: true }); diff --git a/src/anki-integration/known-word-cache.ts b/src/anki-integration/known-word-cache.ts index 24433d3..a4de17c 100644 --- a/src/anki-integration/known-word-cache.ts +++ b/src/anki-integration/known-word-cache.ts @@ -3,7 +3,7 @@ import path from 'path'; import { DEFAULT_ANKI_CONNECT_CONFIG } from '../config'; import { getConfiguredWordFieldName } from '../anki-field-config'; -import { AnkiConnectConfig } from '../types'; +import { AnkiConnectConfig } from '../types/anki'; import { createLogger } from '../logger'; const log = createLogger('anki').child('integration.known-word-cache'); @@ -316,9 +316,9 @@ export class KnownWordCacheManager { const currentDeck = this.deps.getConfig().deck?.trim(); const selectedDeckEntry = currentDeck !== undefined && currentDeck.length > 0 - ? trimmedDeckEntries.find(([deckName]) => deckName === currentDeck) ?? null + ? (trimmedDeckEntries.find(([deckName]) => deckName === currentDeck) ?? null) : trimmedDeckEntries.length === 1 - ? trimmedDeckEntries[0] ?? null + ? (trimmedDeckEntries[0] ?? null) : null; if (!selectedDeckEntry) { @@ -329,7 +329,10 @@ export class KnownWordCacheManager { if (Array.isArray(deckFields)) { const normalizedFields = [ ...new Set( - deckFields.map(String).map((field) => field.trim()).filter((field) => field.length > 0), + deckFields + .map(String) + .map((field) => field.trim()) + .filter((field) => field.length > 0), ), ]; if (normalizedFields.length > 0) { @@ -353,7 +356,14 @@ export class KnownWordCacheManager { continue; } const normalizedFields = Array.isArray(fields) - ? [...new Set(fields.map(String).map((field) => field.trim()).filter(Boolean))] + ? [ + ...new Set( + fields + .map(String) + .map((field) => field.trim()) + .filter(Boolean), + ), + ] : []; scopes.push({ query: `deck:"${escapeAnkiSearchValue(trimmedDeckName)}"`, @@ -402,7 +412,10 @@ export class KnownWordCacheManager { private async fetchKnownWordNoteFieldsById(): Promise> { const scopes = this.getKnownWordQueryScopes(); const noteFieldsById = new Map(); - log.debug('Refreshing known-word cache', `queries=${scopes.map((scope) => scope.query).join(' | ')}`); + log.debug( + 'Refreshing known-word cache', + `queries=${scopes.map((scope) => scope.query).join(' | ')}`, + ); for (const scope of scopes) { const noteIds = (await this.deps.client.findNotes(scope.query, { @@ -414,10 +427,7 @@ export class KnownWordCacheManager { continue; } const existingFields = noteFieldsById.get(noteId) ?? []; - noteFieldsById.set( - noteId, - [...new Set([...existingFields, ...scope.fields])], - ); + noteFieldsById.set(noteId, [...new Set([...existingFields, ...scope.fields])]); } } diff --git a/src/anki-integration/media-source.ts b/src/anki-integration/media-source.ts index 36adaa2..21f9838 100644 --- a/src/anki-integration/media-source.ts +++ b/src/anki-integration/media-source.ts @@ -1,5 +1,5 @@ import { isRemoteMediaPath } from '../jimaku/utils'; -import type { MpvClient } from '../types'; +import type { MpvClient } from '../types/runtime'; export type MediaGenerationKind = 'audio' | 'video'; @@ -50,7 +50,7 @@ function resolvePreferredUrlFromMpvEdlSource( // mpv EDL sources usually list audio streams first and video streams last, so // when classifyMediaUrl cannot identify a typed URL we fall back to stream order. - return kind === 'audio' ? urls[0] ?? null : urls[urls.length - 1] ?? null; + return kind === 'audio' ? (urls[0] ?? null) : (urls[urls.length - 1] ?? null); } export async function resolveMediaGenerationInputPath( diff --git a/src/anki-integration/runtime.test.ts b/src/anki-integration/runtime.test.ts index e234f15..99d0a36 100644 --- a/src/anki-integration/runtime.test.ts +++ b/src/anki-integration/runtime.test.ts @@ -2,7 +2,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { DEFAULT_ANKI_CONNECT_CONFIG } from '../config'; -import type { AnkiConnectConfig } from '../types'; +import type { AnkiConnectConfig } from '../types/anki'; import { AnkiIntegrationRuntime } from './runtime'; function createRuntime( diff --git a/src/anki-integration/runtime.ts b/src/anki-integration/runtime.ts index df1ef9f..c1327b9 100644 --- a/src/anki-integration/runtime.ts +++ b/src/anki-integration/runtime.ts @@ -1,5 +1,5 @@ import { DEFAULT_ANKI_CONNECT_CONFIG } from '../config'; -import type { AnkiConnectConfig } from '../types'; +import type { AnkiConnectConfig } from '../types/anki'; import { getKnownWordCacheLifecycleConfig, getKnownWordCacheRefreshIntervalMinutes, diff --git a/src/anki-integration/ui-feedback.ts b/src/anki-integration/ui-feedback.ts index ea43e70..f9f53d6 100644 --- a/src/anki-integration/ui-feedback.ts +++ b/src/anki-integration/ui-feedback.ts @@ -1,4 +1,4 @@ -import { NotificationOptions } from '../types'; +import { NotificationOptions } from '../types/anki'; export interface UiFeedbackState { progressDepth: number; diff --git a/src/config/definitions.ts b/src/config/definitions.ts index 0692e66..b51ba75 100644 --- a/src/config/definitions.ts +++ b/src/config/definitions.ts @@ -1,4 +1,4 @@ -import { RawConfig, ResolvedConfig } from '../types'; +import { RawConfig, ResolvedConfig } from '../types/config'; import { CORE_DEFAULT_CONFIG } from './definitions/defaults-core'; import { IMMERSION_DEFAULT_CONFIG } from './definitions/defaults-immersion'; import { INTEGRATIONS_DEFAULT_CONFIG } from './definitions/defaults-integrations'; diff --git a/src/config/definitions/defaults-core.ts b/src/config/definitions/defaults-core.ts index 4d42915..302cfce 100644 --- a/src/config/definitions/defaults-core.ts +++ b/src/config/definitions/defaults-core.ts @@ -1,4 +1,4 @@ -import { ResolvedConfig } from '../../types'; +import { ResolvedConfig } from '../../types/config'; export const CORE_DEFAULT_CONFIG: Pick< ResolvedConfig, diff --git a/src/config/definitions/defaults-immersion.ts b/src/config/definitions/defaults-immersion.ts index ffd04fb..2641181 100644 --- a/src/config/definitions/defaults-immersion.ts +++ b/src/config/definitions/defaults-immersion.ts @@ -1,4 +1,4 @@ -import { ResolvedConfig } from '../../types'; +import { ResolvedConfig } from '../../types/config'; export const IMMERSION_DEFAULT_CONFIG: Pick = { immersionTracking: { diff --git a/src/config/definitions/defaults-integrations.ts b/src/config/definitions/defaults-integrations.ts index 761a2e0..9d63dfe 100644 --- a/src/config/definitions/defaults-integrations.ts +++ b/src/config/definitions/defaults-integrations.ts @@ -1,4 +1,4 @@ -import { ResolvedConfig } from '../../types'; +import { ResolvedConfig } from '../../types/config'; export const INTEGRATIONS_DEFAULT_CONFIG: Pick< ResolvedConfig, diff --git a/src/config/definitions/defaults-stats.ts b/src/config/definitions/defaults-stats.ts index 3b4bb81..c3838d2 100644 --- a/src/config/definitions/defaults-stats.ts +++ b/src/config/definitions/defaults-stats.ts @@ -1,4 +1,4 @@ -import { ResolvedConfig } from '../../types.js'; +import { ResolvedConfig } from '../../types/config.js'; export const STATS_DEFAULT_CONFIG: Pick = { stats: { diff --git a/src/config/definitions/defaults-subtitle.ts b/src/config/definitions/defaults-subtitle.ts index 25bbd0f..a581169 100644 --- a/src/config/definitions/defaults-subtitle.ts +++ b/src/config/definitions/defaults-subtitle.ts @@ -1,4 +1,4 @@ -import { ResolvedConfig } from '../../types'; +import { ResolvedConfig } from '../../types/config'; export const SUBTITLE_DEFAULT_CONFIG: Pick = { subtitleStyle: { diff --git a/src/config/definitions/options-core.ts b/src/config/definitions/options-core.ts index 1ab1fd4..463eb6e 100644 --- a/src/config/definitions/options-core.ts +++ b/src/config/definitions/options-core.ts @@ -1,4 +1,4 @@ -import { ResolvedConfig } from '../../types'; +import { ResolvedConfig } from '../../types/config'; import { ConfigOptionRegistryEntry } from './shared'; export function buildCoreConfigOptionRegistry( @@ -263,7 +263,8 @@ export function buildCoreConfigOptionRegistry( { path: `controller.bindings.${binding.id}.axisIndex`, kind: 'number' as const, - defaultValue: binding.defaultValue.kind === 'axis' ? binding.defaultValue.axisIndex : undefined, + defaultValue: + binding.defaultValue.kind === 'axis' ? binding.defaultValue.axisIndex : undefined, description: 'Raw axis index captured for this discrete controller action.', }, { @@ -293,7 +294,8 @@ export function buildCoreConfigOptionRegistry( { path: `controller.bindings.${binding.id}.axisIndex`, kind: 'number' as const, - defaultValue: binding.defaultValue.kind === 'axis' ? binding.defaultValue.axisIndex : undefined, + defaultValue: + binding.defaultValue.kind === 'axis' ? binding.defaultValue.axisIndex : undefined, description: 'Raw axis index captured for this analog controller action.', }, { @@ -302,7 +304,8 @@ export function buildCoreConfigOptionRegistry( enumValues: ['none', 'horizontal', 'vertical'], defaultValue: binding.defaultValue.kind === 'axis' ? binding.defaultValue.dpadFallback : undefined, - description: 'Optional D-pad fallback used when this analog controller action should also read D-pad input.', + description: + 'Optional D-pad fallback used when this analog controller action should also read D-pad input.', }, ]), { diff --git a/src/config/definitions/options-immersion.ts b/src/config/definitions/options-immersion.ts index 6957dbb..2a5c4bd 100644 --- a/src/config/definitions/options-immersion.ts +++ b/src/config/definitions/options-immersion.ts @@ -1,4 +1,4 @@ -import { ResolvedConfig } from '../../types'; +import { ResolvedConfig } from '../../types/config'; import { ConfigOptionRegistryEntry } from './shared'; export function buildImmersionConfigOptionRegistry( diff --git a/src/config/definitions/options-integrations.ts b/src/config/definitions/options-integrations.ts index ff5feff..d6c8815 100644 --- a/src/config/definitions/options-integrations.ts +++ b/src/config/definitions/options-integrations.ts @@ -1,4 +1,4 @@ -import { ResolvedConfig } from '../../types'; +import { ResolvedConfig } from '../../types/config'; import { ConfigOptionRegistryEntry, RuntimeOptionRegistryEntry } from './shared'; export function buildIntegrationConfigOptionRegistry( @@ -369,13 +369,15 @@ export function buildIntegrationConfigOptionRegistry( path: 'youtubeSubgen.whisperBin', kind: 'string', defaultValue: defaultConfig.youtubeSubgen.whisperBin, - description: 'Legacy compatibility path kept for external subtitle fallback tools; not used by default.', + description: + 'Legacy compatibility path kept for external subtitle fallback tools; not used by default.', }, { path: 'youtubeSubgen.whisperModel', kind: 'string', defaultValue: defaultConfig.youtubeSubgen.whisperModel, - description: 'Legacy compatibility model path kept for external subtitle fallback tooling; not used by default.', + description: + 'Legacy compatibility model path kept for external subtitle fallback tooling; not used by default.', }, { path: 'youtubeSubgen.whisperVadModel', diff --git a/src/config/definitions/options-stats.ts b/src/config/definitions/options-stats.ts index 16657e6..131bea3 100644 --- a/src/config/definitions/options-stats.ts +++ b/src/config/definitions/options-stats.ts @@ -1,4 +1,4 @@ -import { ResolvedConfig } from '../../types.js'; +import { ResolvedConfig } from '../../types/config.js'; import { ConfigOptionRegistryEntry } from './shared.js'; export function buildStatsConfigOptionRegistry( @@ -15,7 +15,8 @@ export function buildStatsConfigOptionRegistry( path: 'stats.markWatchedKey', kind: 'string', defaultValue: defaultConfig.stats.markWatchedKey, - description: 'Key code to mark the current video as watched and advance to the next playlist entry.', + description: + 'Key code to mark the current video as watched and advance to the next playlist entry.', }, { path: 'stats.serverPort', diff --git a/src/config/definitions/options-subtitle.ts b/src/config/definitions/options-subtitle.ts index 9b2d294..5445eb2 100644 --- a/src/config/definitions/options-subtitle.ts +++ b/src/config/definitions/options-subtitle.ts @@ -1,4 +1,4 @@ -import { ResolvedConfig } from '../../types'; +import { ResolvedConfig } from '../../types/config'; import { ConfigOptionRegistryEntry } from './shared'; export function buildSubtitleConfigOptionRegistry( diff --git a/src/config/definitions/runtime-options.ts b/src/config/definitions/runtime-options.ts index afba727..a8ca7b9 100644 --- a/src/config/definitions/runtime-options.ts +++ b/src/config/definitions/runtime-options.ts @@ -1,4 +1,4 @@ -import { ResolvedConfig } from '../../types'; +import { ResolvedConfig } from '../../types/config'; import { RuntimeOptionRegistryEntry } from './shared'; export function buildRuntimeOptionRegistry( diff --git a/src/config/definitions/shared.ts b/src/config/definitions/shared.ts index 9a0c636..26ac978 100644 --- a/src/config/definitions/shared.ts +++ b/src/config/definitions/shared.ts @@ -1,11 +1,11 @@ -import { - AnkiConnectConfig, - ResolvedConfig, +import type { AnkiConnectConfig } from '../../types/anki'; +import type { ResolvedConfig } from '../../types/config'; +import type { RuntimeOptionId, RuntimeOptionScope, RuntimeOptionValue, RuntimeOptionValueType, -} from '../../types'; +} from '../../types/runtime-options'; export type ConfigValueKind = 'boolean' | 'number' | 'string' | 'enum' | 'array' | 'object'; diff --git a/src/config/load.ts b/src/config/load.ts index d8a4bc1..355422e 100644 --- a/src/config/load.ts +++ b/src/config/load.ts @@ -1,5 +1,5 @@ import * as fs from 'fs'; -import { RawConfig } from '../types'; +import { RawConfig } from '../types/config'; import { parseConfigContent } from './parse'; export interface ConfigPaths { diff --git a/src/config/resolve.ts b/src/config/resolve.ts index c520e7c..6e203c4 100644 --- a/src/config/resolve.ts +++ b/src/config/resolve.ts @@ -1,4 +1,4 @@ -import { ConfigValidationWarning, RawConfig, ResolvedConfig } from '../types'; +import { ConfigValidationWarning, RawConfig, ResolvedConfig } from '../types/config'; import { applyAnkiConnectResolution } from './resolve/anki-connect'; import { createResolveContext } from './resolve/context'; import { applyCoreDomainConfig } from './resolve/core-domains'; diff --git a/src/config/resolve/context.ts b/src/config/resolve/context.ts index abae21d..f88c3e6 100644 --- a/src/config/resolve/context.ts +++ b/src/config/resolve/context.ts @@ -1,4 +1,4 @@ -import { ConfigValidationWarning, RawConfig, ResolvedConfig } from '../../types'; +import { ConfigValidationWarning, RawConfig, ResolvedConfig } from '../../types/config'; import { DEFAULT_CONFIG, deepCloneConfig } from '../definitions'; import { createWarningCollector } from '../warnings'; import { isObject } from './shared'; diff --git a/src/config/resolve/core-domains.ts b/src/config/resolve/core-domains.ts index 9920e7e..6d356b8 100644 --- a/src/config/resolve/core-domains.ts +++ b/src/config/resolve/core-domains.ts @@ -8,7 +8,7 @@ import type { ControllerDiscreteBindingConfig, ResolvedControllerAxisBinding, ResolvedControllerDiscreteBinding, -} from '../../types'; +} from '../../types/runtime'; import { ResolveContext } from './context'; import { asBoolean, asNumber, asString, isObject } from './shared'; @@ -27,7 +27,12 @@ const CONTROLLER_BUTTON_BINDINGS = [ 'rightTrigger', ] as const; -const CONTROLLER_AXIS_BINDINGS = ['leftStickX', 'leftStickY', 'rightStickX', 'rightStickY'] as const; +const CONTROLLER_AXIS_BINDINGS = [ + 'leftStickX', + 'leftStickY', + 'rightStickX', + 'rightStickY', +] as const; const CONTROLLER_AXIS_INDEX_BY_BINDING: Record = { leftStickX: 0, @@ -98,7 +103,9 @@ function parseDiscreteBindingObject(value: unknown): ResolvedControllerDiscreteB return { kind: 'none' }; } if (value.kind === 'button') { - return typeof value.buttonIndex === 'number' && Number.isInteger(value.buttonIndex) && value.buttonIndex >= 0 + return typeof value.buttonIndex === 'number' && + Number.isInteger(value.buttonIndex) && + value.buttonIndex >= 0 ? { kind: 'button', buttonIndex: value.buttonIndex } : null; } @@ -121,7 +128,11 @@ function parseAxisBindingObject( return { kind: 'none' }; } if (!isObject(value) || value.kind !== 'axis') return null; - if (typeof value.axisIndex !== 'number' || !Number.isInteger(value.axisIndex) || value.axisIndex < 0) { + if ( + typeof value.axisIndex !== 'number' || + !Number.isInteger(value.axisIndex) || + value.axisIndex < 0 + ) { return null; } if (value.dpadFallback !== undefined && !isControllerDpadFallback(value.dpadFallback)) { @@ -368,7 +379,9 @@ export function applyCoreDomainConfig(context: ResolveContext): void { const legacyValue = asString(bindingValue); if ( legacyValue !== undefined && - CONTROLLER_BUTTON_BINDINGS.includes(legacyValue as (typeof CONTROLLER_BUTTON_BINDINGS)[number]) + CONTROLLER_BUTTON_BINDINGS.includes( + legacyValue as (typeof CONTROLLER_BUTTON_BINDINGS)[number], + ) ) { resolved.controller.bindings[key] = resolveLegacyDiscreteBinding( legacyValue as ControllerButtonBinding, @@ -401,7 +414,9 @@ export function applyCoreDomainConfig(context: ResolveContext): void { const legacyValue = asString(bindingValue); if ( legacyValue !== undefined && - CONTROLLER_AXIS_BINDINGS.includes(legacyValue as (typeof CONTROLLER_AXIS_BINDINGS)[number]) + CONTROLLER_AXIS_BINDINGS.includes( + legacyValue as (typeof CONTROLLER_AXIS_BINDINGS)[number], + ) ) { resolved.controller.bindings[key] = resolveLegacyAxisBinding( legacyValue as ControllerAxisBinding, diff --git a/src/config/resolve/immersion-tracking.ts b/src/config/resolve/immersion-tracking.ts index c3cf1e8..a5a399b 100644 --- a/src/config/resolve/immersion-tracking.ts +++ b/src/config/resolve/immersion-tracking.ts @@ -1,5 +1,8 @@ import { ResolveContext } from './context'; -import { ImmersionTrackingRetentionMode, ImmersionTrackingRetentionPreset } from '../../types'; +import { + ImmersionTrackingRetentionMode, + ImmersionTrackingRetentionPreset, +} from '../../types/integrations'; import { asBoolean, asNumber, asString, isObject } from './shared'; const DEFAULT_RETENTION_MODE: ImmersionTrackingRetentionMode = 'preset'; diff --git a/src/config/resolve/subtitle-domains.ts b/src/config/resolve/subtitle-domains.ts index 81d77d1..2bf032c 100644 --- a/src/config/resolve/subtitle-domains.ts +++ b/src/config/resolve/subtitle-domains.ts @@ -1,4 +1,4 @@ -import { ResolvedConfig } from '../../types'; +import { ResolvedConfig } from '../../types/config'; import { ResolveContext } from './context'; import { asBoolean, @@ -467,7 +467,9 @@ export function applySubtitleDomainConfig(context: ResolveContext): void { ); if (pauseVideoOnHover !== undefined) { resolved.subtitleSidebar.pauseVideoOnHover = pauseVideoOnHover; - } else if ((src.subtitleSidebar as { pauseVideoOnHover?: unknown }).pauseVideoOnHover !== undefined) { + } else if ( + (src.subtitleSidebar as { pauseVideoOnHover?: unknown }).pauseVideoOnHover !== undefined + ) { resolved.subtitleSidebar.pauseVideoOnHover = fallback.pauseVideoOnHover; warn( 'subtitleSidebar.pauseVideoOnHover', diff --git a/src/config/service.ts b/src/config/service.ts index 339c581..bee52e5 100644 --- a/src/config/service.ts +++ b/src/config/service.ts @@ -1,6 +1,6 @@ import * as fs from 'fs'; import * as path from 'path'; -import { ConfigValidationWarning, RawConfig, ResolvedConfig } from '../types'; +import { ConfigValidationWarning, RawConfig, ResolvedConfig } from '../types/config'; import { DEFAULT_CONFIG, deepCloneConfig, deepMergeRawConfig } from './definitions'; import { ConfigPaths, loadRawConfig, loadRawConfigStrict } from './load'; import { resolveConfig } from './resolve'; diff --git a/src/config/template.ts b/src/config/template.ts index 6c07f72..42bf448 100644 --- a/src/config/template.ts +++ b/src/config/template.ts @@ -1,4 +1,4 @@ -import { ResolvedConfig } from '../types'; +import { ResolvedConfig } from '../types/config'; import { CONFIG_OPTION_REGISTRY, CONFIG_TEMPLATE_SECTIONS, diff --git a/src/config/warnings.ts b/src/config/warnings.ts index ffa95ff..755920f 100644 --- a/src/config/warnings.ts +++ b/src/config/warnings.ts @@ -1,4 +1,4 @@ -import { ConfigValidationWarning } from '../types'; +import { ConfigValidationWarning } from '../types/config'; export interface WarningCollector { warnings: ConfigValidationWarning[]; diff --git a/src/core/services/runtime-options-ipc.ts b/src/core/services/runtime-options-ipc.ts index e87c89f..71d32ef 100644 --- a/src/core/services/runtime-options-ipc.ts +++ b/src/core/services/runtime-options-ipc.ts @@ -1,4 +1,8 @@ -import { RuntimeOptionApplyResult, RuntimeOptionId, RuntimeOptionValue } from '../../types'; +import { + RuntimeOptionApplyResult, + RuntimeOptionId, + RuntimeOptionValue, +} from '../../types/runtime-options'; export interface RuntimeOptionsManagerLike { setOptionValue: (id: RuntimeOptionId, value: RuntimeOptionValue) => RuntimeOptionApplyResult; diff --git a/src/main/runtime/overlay-runtime-options.ts b/src/main/runtime/overlay-runtime-options.ts index 7a2cea9..ce51c3f 100644 --- a/src/main/runtime/overlay-runtime-options.ts +++ b/src/main/runtime/overlay-runtime-options.ts @@ -2,9 +2,9 @@ import type { AnkiConnectConfig, KikuFieldGroupingChoice, KikuFieldGroupingRequestData, - WindowGeometry, -} from '../../types'; +} from '../../types/anki'; import type { BrowserWindow } from 'electron'; +import type { WindowGeometry } from '../../types/runtime'; import type { BaseWindowTracker } from '../../window-trackers'; type OverlayRuntimeOptions = { diff --git a/src/runtime-options.ts b/src/runtime-options.ts index abff64e..7a456e8 100644 --- a/src/runtime-options.ts +++ b/src/runtime-options.ts @@ -16,14 +16,14 @@ * along with this program. If not, see . */ +import { AnkiConnectConfig } from './types/anki'; import { - AnkiConnectConfig, RuntimeOptionApplyResult, RuntimeOptionId, RuntimeOptionState, RuntimeOptionValue, - SubtitleStyleConfig, -} from './types'; +} from './types/runtime-options'; +import { SubtitleStyleConfig } from './types/subtitle'; import { RUNTIME_OPTION_REGISTRY, RuntimeOptionRegistryEntry } from './config'; type RuntimeOverrides = Record; diff --git a/src/shared/ipc/contracts.ts b/src/shared/ipc/contracts.ts index 426ce00..468b7d5 100644 --- a/src/shared/ipc/contracts.ts +++ b/src/shared/ipc/contracts.ts @@ -1,4 +1,5 @@ -import type { OverlayContentMeasurement, RuntimeOptionId, RuntimeOptionValue } from '../../types'; +import type { OverlayContentMeasurement } from '../../types/runtime'; +import type { RuntimeOptionId, RuntimeOptionValue } from '../../types/runtime-options'; export const OVERLAY_HOSTED_MODALS = [ 'runtime-options', diff --git a/src/shared/ipc/validators.ts b/src/shared/ipc/validators.ts index 8ce1001..1de1112 100644 --- a/src/shared/ipc/validators.ts +++ b/src/shared/ipc/validators.ts @@ -1,17 +1,17 @@ +import type { KikuFieldGroupingChoice, KikuMergePreviewRequest } from '../../types/anki'; import type { - ControllerConfigUpdate, - ControllerPreferenceUpdate, JimakuDownloadQuery, JimakuFilesQuery, JimakuSearchQuery, - KikuFieldGroupingChoice, - KikuMergePreviewRequest, - RuntimeOptionId, - RuntimeOptionValue, - SubtitlePosition, - SubsyncManualRunRequest, YoutubePickerResolveRequest, -} from '../../types'; +} from '../../types/integrations'; +import type { + ControllerConfigUpdate, + ControllerPreferenceUpdate, + SubsyncManualRunRequest, +} from '../../types/runtime'; +import type { RuntimeOptionId, RuntimeOptionValue } from '../../types/runtime-options'; +import type { SubtitlePosition } from '../../types/subtitle'; import { OVERLAY_HOSTED_MODALS, type OverlayHostedModal } from './contracts'; const RUNTIME_OPTION_IDS: RuntimeOptionId[] = [ @@ -255,7 +255,9 @@ export function parseJimakuDownloadQuery(value: unknown): JimakuDownloadQuery | }; } -export function parseYoutubePickerResolveRequest(value: unknown): YoutubePickerResolveRequest | null { +export function parseYoutubePickerResolveRequest( + value: unknown, +): YoutubePickerResolveRequest | null { if (!isObject(value)) return null; if (typeof value.sessionId !== 'string' || !value.sessionId.trim()) return null; if (value.action !== 'use-selected' && value.action !== 'continue-without-subtitles') return null; @@ -270,7 +272,11 @@ export function parseYoutubePickerResolveRequest(value: unknown): YoutubePickerR secondaryTrackId: null, }; } - if (value.primaryTrackId !== null && value.primaryTrackId !== undefined && typeof value.primaryTrackId !== 'string') { + if ( + value.primaryTrackId !== null && + value.primaryTrackId !== undefined && + typeof value.primaryTrackId !== 'string' + ) { return null; } if ( diff --git a/src/types-domain-entrypoints.type-test.ts b/src/types-domain-entrypoints.type-test.ts new file mode 100644 index 0000000..60398bd --- /dev/null +++ b/src/types-domain-entrypoints.type-test.ts @@ -0,0 +1,39 @@ +import { PartOfSpeech as LegacyPartOfSpeech } from './types'; +import type { AnkiConnectConfig } from './types/anki'; +import type { ConfigValidationWarning, RawConfig, ResolvedConfig } from './types/config'; +import type { JimakuConfig, YoutubePickerOpenPayload } from './types/integrations'; +import type { ElectronAPI, MpvClient, OverlayContentMeasurement } from './types/runtime'; +import type { RuntimeOptionId, RuntimeOptionValue } from './types/runtime-options'; +import { PartOfSpeech, type SubtitleSidebarSnapshot } from './types/subtitle'; + +type Assert = T; +type IsAssignable = [From] extends [To] ? true : false; + +const runtimeEntryPointMatchesLegacyBarrel = LegacyPartOfSpeech === PartOfSpeech; +void runtimeEntryPointMatchesLegacyBarrel; + +type SubtitleEnumStillCompatible = Assert< + IsAssignable +>; + +type ConfigEntryPointContracts = [ + RawConfig, + ResolvedConfig, + ConfigValidationWarning, + RuntimeOptionId, + RuntimeOptionValue, +]; + +type IntegrationEntryPointContracts = [AnkiConnectConfig, JimakuConfig, YoutubePickerOpenPayload]; + +type RuntimeEntryPointContracts = [ + OverlayContentMeasurement, + ElectronAPI, + MpvClient, + SubtitleSidebarSnapshot, +]; + +void (null as unknown as SubtitleEnumStillCompatible); +void (null as unknown as ConfigEntryPointContracts); +void (null as unknown as IntegrationEntryPointContracts); +void (null as unknown as RuntimeEntryPointContracts); diff --git a/src/types.ts b/src/types.ts index 61e30ca..33e5adb 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,1255 +1,6 @@ -/* - * 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 type { SubtitleCue } from './core/services/subtitle-cue-parser'; -import type { YoutubeTrackKind } from './core/services/youtube/kinds'; - -export enum PartOfSpeech { - noun = 'noun', - verb = 'verb', - i_adjective = 'i_adjective', - na_adjective = 'na_adjective', - particle = 'particle', - bound_auxiliary = 'bound_auxiliary', - symbol = 'symbol', - other = 'other', -} - -export interface Token { - word: string; - partOfSpeech: PartOfSpeech; - pos1: string; - pos2: string; - pos3: string; - pos4: string; - inflectionType: string; - inflectionForm: string; - headword: string; - katakanaReading: string; - pronunciation: string; -} - -export interface MergedToken { - surface: string; - reading: string; - headword: string; - startPos: number; - endPos: number; - partOfSpeech: PartOfSpeech; - pos1?: string; - pos2?: string; - pos3?: string; - isMerged: boolean; - isKnown: boolean; - isNPlusOneTarget: boolean; - isNameMatch?: boolean; - jlptLevel?: JlptLevel; - frequencyRank?: number; -} - -export type FrequencyDictionaryLookup = (term: string) => number | null; - -export type JlptLevel = 'N1' | 'N2' | 'N3' | 'N4' | 'N5'; - -export interface WindowGeometry { - x: number; - y: number; - width: number; - height: number; -} - -export interface SubtitlePosition { - yPercent: number; -} - -export interface SubtitleStyle { - fontSize: number; -} - -export interface Keybinding { - key: string; - command: (string | number)[] | null; -} - -export type SecondarySubMode = 'hidden' | 'visible' | 'hover'; - -export interface SecondarySubConfig { - secondarySubLanguages?: string[]; - autoLoadSecondarySub?: boolean; - defaultMode?: SecondarySubMode; -} - -export type SubsyncMode = 'auto' | 'manual'; - -export interface SubsyncConfig { - defaultMode?: SubsyncMode; - alass_path?: string; - ffsubsync_path?: string; - ffmpeg_path?: string; - replace?: boolean; -} - -export interface StartupWarmupsConfig { - lowPowerMode?: boolean; - mecab?: boolean; - yomitanExtension?: boolean; - subtitleDictionaries?: boolean; - jellyfinRemoteSession?: boolean; -} - -export interface WebSocketConfig { - enabled?: boolean | 'auto'; - port?: number; -} - -export interface AnnotationWebSocketConfig { - enabled?: boolean; - port?: number; -} - -export interface TexthookerConfig { - launchAtStartup?: boolean; - openBrowser?: boolean; -} - -export interface NotificationOptions { - body?: string; - icon?: string; -} - -export interface MpvClient { - currentSubText: string; - currentVideoPath: string; - currentMediaTitle?: string | null; - currentTimePos: number; - currentSubStart: number; - currentSubEnd: number; - currentAudioStreamIndex: number | null; - requestProperty?: (name: string) => Promise; - send(command: { command: unknown[]; request_id?: number }): boolean; -} - -export interface KikuDuplicateCardInfo { - noteId: number; - expression: string; - sentencePreview: string; - hasAudio: boolean; - hasImage: boolean; - isOriginal: boolean; -} - -export interface KikuFieldGroupingRequestData { - original: KikuDuplicateCardInfo; - duplicate: KikuDuplicateCardInfo; -} - -export interface KikuFieldGroupingChoice { - keepNoteId: number; - deleteNoteId: number; - deleteDuplicate: boolean; - cancelled: boolean; -} - -export interface KikuMergePreviewRequest { - keepNoteId: number; - deleteNoteId: number; - deleteDuplicate: boolean; -} - -export interface KikuMergePreviewResponse { - ok: boolean; - compact?: Record; - full?: Record; - error?: string; -} - -export type RuntimeOptionId = - | 'anki.autoUpdateNewCards' - | 'subtitle.annotation.nPlusOne' - | 'subtitle.annotation.jlpt' - | 'subtitle.annotation.frequency' - | 'anki.kikuFieldGrouping' - | 'anki.nPlusOneMatchMode'; - -export type RuntimeOptionScope = 'ankiConnect' | 'subtitle'; - -export type RuntimeOptionValueType = 'boolean' | 'enum'; - -export type RuntimeOptionValue = boolean | string; - -export type NPlusOneMatchMode = 'headword' | 'surface'; -export type FrequencyDictionaryMatchMode = 'headword' | 'surface'; - -export interface RuntimeOptionState { - id: RuntimeOptionId; - label: string; - scope: RuntimeOptionScope; - valueType: RuntimeOptionValueType; - value: RuntimeOptionValue; - allowedValues: RuntimeOptionValue[]; - requiresRestart: boolean; -} - -export interface RuntimeOptionApplyResult { - ok: boolean; - option?: RuntimeOptionState; - osdMessage?: string; - requiresRestart?: boolean; - error?: string; -} - -export interface AnkiConnectConfig { - enabled?: boolean; - url?: string; - pollingRate?: number; - proxy?: { - enabled?: boolean; - host?: string; - port?: number; - upstreamUrl?: string; - }; - tags?: string[]; - fields?: { - word?: string; - audio?: string; - image?: string; - sentence?: string; - miscInfo?: string; - translation?: string; - }; - ai?: boolean | AiFeatureConfig; - media?: { - generateAudio?: boolean; - generateImage?: boolean; - imageType?: 'static' | 'avif'; - imageFormat?: 'jpg' | 'png' | 'webp'; - imageQuality?: number; - imageMaxWidth?: number; - imageMaxHeight?: number; - animatedFps?: number; - animatedMaxWidth?: number; - animatedMaxHeight?: number; - animatedCrf?: number; - syncAnimatedImageToWordAudio?: boolean; - audioPadding?: number; - fallbackDuration?: number; - maxMediaDuration?: number; - }; - knownWords?: { - highlightEnabled?: boolean; - refreshMinutes?: number; - addMinedWordsImmediately?: boolean; - matchMode?: NPlusOneMatchMode; - decks?: Record; - color?: string; - }; - nPlusOne?: { - nPlusOne?: string; - minSentenceWords?: number; - }; - behavior?: { - overwriteAudio?: boolean; - overwriteImage?: boolean; - mediaInsertMode?: 'append' | 'prepend'; - highlightWord?: boolean; - notificationType?: 'osd' | 'system' | 'both' | 'none'; - autoUpdateNewCards?: boolean; - }; - metadata?: { - pattern?: string; - }; - deck?: string; - isLapis?: { - enabled?: boolean; - sentenceCardModel?: string; - }; - isKiku?: { - enabled?: boolean; - fieldGrouping?: 'auto' | 'manual' | 'disabled'; - deleteDuplicateInAuto?: boolean; - }; -} - -export interface SubtitleStyleConfig { - enableJlpt?: boolean; - preserveLineBreaks?: boolean; - autoPauseVideoOnHover?: boolean; - autoPauseVideoOnYomitanPopup?: boolean; - hoverTokenColor?: string; - hoverTokenBackgroundColor?: string; - nameMatchEnabled?: boolean; - nameMatchColor?: string; - fontFamily?: string; - fontSize?: number; - fontColor?: string; - fontWeight?: string | number; - fontStyle?: string; - lineHeight?: string | number; - letterSpacing?: string; - wordSpacing?: string | number; - fontKerning?: string; - textRendering?: string; - textShadow?: string; - backdropFilter?: string; - backgroundColor?: string; - nPlusOneColor?: string; - knownWordColor?: string; - jlptColors?: { - N1: string; - N2: string; - N3: string; - N4: string; - N5: string; - }; - frequencyDictionary?: { - enabled?: boolean; - sourcePath?: string; - topX?: number; - mode?: FrequencyDictionaryMode; - matchMode?: FrequencyDictionaryMatchMode; - singleColor?: string; - bandedColors?: [string, string, string, string, string]; - }; - secondary?: { - fontFamily?: string; - fontSize?: number; - fontColor?: string; - fontWeight?: string | number; - fontStyle?: string; - lineHeight?: string | number; - letterSpacing?: string; - wordSpacing?: string | number; - fontKerning?: string; - textRendering?: string; - textShadow?: string; - backdropFilter?: string; - backgroundColor?: string; - }; -} - -export interface TokenPos1ExclusionConfig { - defaults?: string[]; - add?: string[]; - remove?: string[]; -} - -export interface ResolvedTokenPos1ExclusionConfig { - defaults: string[]; - add: string[]; - remove: string[]; -} - -export interface TokenPos2ExclusionConfig { - defaults?: string[]; - add?: string[]; - remove?: string[]; -} - -export interface ResolvedTokenPos2ExclusionConfig { - defaults: string[]; - add: string[]; - remove: string[]; -} - -export type FrequencyDictionaryMode = 'single' | 'banded'; - -export type { SubtitleCue } from './core/services/subtitle-cue-parser'; - -export type SubtitleSidebarLayout = 'overlay' | 'embedded'; - -export interface SubtitleSidebarConfig { - enabled?: boolean; - autoOpen?: boolean; - layout?: SubtitleSidebarLayout; - toggleKey?: string; - pauseVideoOnHover?: boolean; - autoScroll?: boolean; - maxWidth?: number; - opacity?: number; - backgroundColor?: string; - textColor?: string; - fontFamily?: string; - fontSize?: number; - timestampColor?: string; - activeLineColor?: string; - activeLineBackgroundColor?: string; - hoverLineBackgroundColor?: string; -} - -export interface ShortcutsConfig { - toggleVisibleOverlayGlobal?: string | null; - copySubtitle?: string | null; - copySubtitleMultiple?: string | null; - updateLastCardFromClipboard?: string | null; - triggerFieldGrouping?: string | null; - triggerSubsync?: string | null; - mineSentence?: string | null; - mineSentenceMultiple?: string | null; - multiCopyTimeoutMs?: number; - toggleSecondarySub?: string | null; - markAudioCard?: string | null; - openRuntimeOptions?: string | null; - openJimaku?: string | null; -} - -export type ControllerButtonBinding = - | 'none' - | 'select' - | 'buttonSouth' - | 'buttonEast' - | 'buttonNorth' - | 'buttonWest' - | 'leftShoulder' - | 'rightShoulder' - | 'leftStickPress' - | 'rightStickPress' - | 'leftTrigger' - | 'rightTrigger'; - -export type ControllerAxisBinding = 'leftStickX' | 'leftStickY' | 'rightStickX' | 'rightStickY'; -export type ControllerTriggerInputMode = 'auto' | 'digital' | 'analog'; -export type ControllerAxisDirection = 'negative' | 'positive'; -export type ControllerDpadFallback = 'none' | 'horizontal' | 'vertical'; - -export interface ControllerNoneBinding { - kind: 'none'; -} - -export interface ControllerButtonInputBinding { - kind: 'button'; - buttonIndex: number; -} - -export interface ControllerAxisDirectionInputBinding { - kind: 'axis'; - axisIndex: number; - direction: ControllerAxisDirection; -} - -export interface ControllerAxisInputBinding { - kind: 'axis'; - axisIndex: number; - dpadFallback?: ControllerDpadFallback; -} - -export type ControllerDiscreteBindingConfig = - | ControllerButtonBinding - | ControllerNoneBinding - | ControllerButtonInputBinding - | ControllerAxisDirectionInputBinding; - -export type ResolvedControllerDiscreteBinding = - | ControllerNoneBinding - | ControllerButtonInputBinding - | ControllerAxisDirectionInputBinding; - -export type ControllerAxisBindingConfig = - | ControllerAxisBinding - | ControllerNoneBinding - | ControllerAxisInputBinding; - -export type ResolvedControllerAxisBinding = - | ControllerNoneBinding - | { - kind: 'axis'; - axisIndex: number; - dpadFallback: ControllerDpadFallback; - }; - -export interface ControllerBindingsConfig { - toggleLookup?: ControllerDiscreteBindingConfig; - closeLookup?: ControllerDiscreteBindingConfig; - toggleKeyboardOnlyMode?: ControllerDiscreteBindingConfig; - mineCard?: ControllerDiscreteBindingConfig; - quitMpv?: ControllerDiscreteBindingConfig; - previousAudio?: ControllerDiscreteBindingConfig; - nextAudio?: ControllerDiscreteBindingConfig; - playCurrentAudio?: ControllerDiscreteBindingConfig; - toggleMpvPause?: ControllerDiscreteBindingConfig; - leftStickHorizontal?: ControllerAxisBindingConfig; - leftStickVertical?: ControllerAxisBindingConfig; - rightStickHorizontal?: ControllerAxisBindingConfig; - rightStickVertical?: ControllerAxisBindingConfig; -} - -export interface ResolvedControllerBindingsConfig { - toggleLookup?: ResolvedControllerDiscreteBinding; - closeLookup?: ResolvedControllerDiscreteBinding; - toggleKeyboardOnlyMode?: ResolvedControllerDiscreteBinding; - mineCard?: ResolvedControllerDiscreteBinding; - quitMpv?: ResolvedControllerDiscreteBinding; - previousAudio?: ResolvedControllerDiscreteBinding; - nextAudio?: ResolvedControllerDiscreteBinding; - playCurrentAudio?: ResolvedControllerDiscreteBinding; - toggleMpvPause?: ResolvedControllerDiscreteBinding; - leftStickHorizontal?: ResolvedControllerAxisBinding; - leftStickVertical?: ResolvedControllerAxisBinding; - rightStickHorizontal?: ResolvedControllerAxisBinding; - rightStickVertical?: ResolvedControllerAxisBinding; -} - -export interface ControllerButtonIndicesConfig { - select?: number; - buttonSouth?: number; - buttonEast?: number; - buttonNorth?: number; - buttonWest?: number; - leftShoulder?: number; - rightShoulder?: number; - leftStickPress?: number; - rightStickPress?: number; - leftTrigger?: number; - rightTrigger?: number; -} - -export interface ControllerConfig { - enabled?: boolean; - preferredGamepadId?: string; - preferredGamepadLabel?: string; - smoothScroll?: boolean; - scrollPixelsPerSecond?: number; - horizontalJumpPixels?: number; - stickDeadzone?: number; - triggerInputMode?: ControllerTriggerInputMode; - triggerDeadzone?: number; - repeatDelayMs?: number; - repeatIntervalMs?: number; - buttonIndices?: ControllerButtonIndicesConfig; - bindings?: ControllerBindingsConfig; -} - -export interface ControllerPreferenceUpdate { - preferredGamepadId: string; - preferredGamepadLabel: string; -} - -export type ControllerConfigUpdate = ControllerConfig; - -export interface ControllerDeviceInfo { - id: string; - index: number; - mapping: string; - connected: boolean; -} - -export interface ControllerButtonSnapshot { - value: number; - pressed: boolean; - touched?: boolean; -} - -export interface ControllerRuntimeSnapshot { - connectedGamepads: ControllerDeviceInfo[]; - activeGamepadId: string | null; - rawAxes: number[]; - rawButtons: ControllerButtonSnapshot[]; -} - -export type JimakuLanguagePreference = 'ja' | 'en' | 'none'; -export type { YoutubeTrackKind }; - -export interface YoutubeTrackOption { - id: string; - language: string; - sourceLanguage: string; - kind: YoutubeTrackKind; - label: string; - title?: string; - downloadUrl?: string; - fileExtension?: string; -} - -export interface YoutubePickerOpenPayload { - sessionId: string; - url: string; - tracks: YoutubeTrackOption[]; - defaultPrimaryTrackId: string | null; - defaultSecondaryTrackId: string | null; - hasTracks: boolean; -} - -export type YoutubePickerResolveRequest = - | { - sessionId: string; - action: 'continue-without-subtitles'; - primaryTrackId: null; - secondaryTrackId: null; - } - | { - sessionId: string; - action: 'use-selected'; - primaryTrackId: string | null; - secondaryTrackId: string | null; - }; - -export interface YoutubePickerResolveResult { - ok: boolean; - message: string; -} - -export interface JimakuConfig { - apiKey?: string; - apiKeyCommand?: string; - apiBaseUrl?: string; - languagePreference?: JimakuLanguagePreference; - maxEntryResults?: number; -} - -export type AnilistCharacterDictionaryEvictionPolicy = 'disable' | 'delete'; -export type AnilistCharacterDictionaryProfileScope = 'all' | 'active'; -export type AnilistCharacterDictionaryCollapsibleSectionKey = - | 'description' - | 'characterInformation' - | 'voicedBy'; - -export interface AnilistCharacterDictionaryCollapsibleSectionsConfig { - description?: boolean; - characterInformation?: boolean; - voicedBy?: boolean; -} - -export interface AnilistCharacterDictionaryConfig { - enabled?: boolean; - refreshTtlHours?: number; - maxLoaded?: number; - evictionPolicy?: AnilistCharacterDictionaryEvictionPolicy; - profileScope?: AnilistCharacterDictionaryProfileScope; - collapsibleSections?: AnilistCharacterDictionaryCollapsibleSectionsConfig; -} - -export interface AnilistConfig { - enabled?: boolean; - accessToken?: string; - characterDictionary?: AnilistCharacterDictionaryConfig; -} - -export interface YomitanConfig { - externalProfilePath?: string; -} - -export interface JellyfinConfig { - enabled?: boolean; - serverUrl?: string; - username?: string; - deviceId?: string; - clientName?: string; - clientVersion?: string; - defaultLibraryId?: string; - remoteControlEnabled?: boolean; - remoteControlAutoConnect?: boolean; - autoAnnounce?: boolean; - remoteControlDeviceName?: string; - pullPictures?: boolean; - iconCacheDir?: string; - directPlayPreferred?: boolean; - directPlayContainers?: string[]; - transcodeVideoCodec?: string; -} - -export interface DiscordPresenceConfig { - enabled?: boolean; - updateIntervalMs?: number; - debounceMs?: number; -} - -export interface AiFeatureConfig { - enabled?: boolean; - model?: string; - systemPrompt?: string; -} - -export interface AiConfig { - enabled?: boolean; - apiKey?: string; - apiKeyCommand?: string; - baseUrl?: string; - model?: string; - systemPrompt?: string; - requestTimeoutMs?: number; -} - -export interface YoutubeConfig { - primarySubLanguages?: string[]; -} - -export interface YoutubeSubgenConfig { - whisperBin?: string; - whisperModel?: string; - whisperVadModel?: string; - whisperThreads?: number; - fixWithAi?: boolean; - ai?: AiFeatureConfig; -} - -export interface StatsConfig { - toggleKey?: string; - markWatchedKey?: string; - serverPort?: number; - autoStartServer?: boolean; - autoOpenBrowser?: boolean; -} - -export type ImmersionTrackingRetentionMode = 'preset' | 'advanced'; -export type ImmersionTrackingRetentionPreset = 'minimal' | 'balanced' | 'deep-history'; - -export interface ImmersionTrackingConfig { - enabled?: boolean; - dbPath?: string; - batchSize?: number; - flushIntervalMs?: number; - queueCap?: number; - payloadCapBytes?: number; - maintenanceIntervalMs?: number; - retentionMode?: ImmersionTrackingRetentionMode; - retentionPreset?: ImmersionTrackingRetentionPreset; - retention?: { - eventsDays?: number; - telemetryDays?: number; - sessionsDays?: number; - dailyRollupsDays?: number; - monthlyRollupsDays?: number; - vacuumIntervalDays?: number; - }; - lifetimeSummaries?: { - global?: boolean; - anime?: boolean; - media?: boolean; - }; -} - -export interface Config { - subtitlePosition?: SubtitlePosition; - keybindings?: Keybinding[]; - websocket?: WebSocketConfig; - annotationWebsocket?: AnnotationWebSocketConfig; - texthooker?: TexthookerConfig; - controller?: ControllerConfig; - ankiConnect?: AnkiConnectConfig; - shortcuts?: ShortcutsConfig; - secondarySub?: SecondarySubConfig; - subsync?: SubsyncConfig; - startupWarmups?: StartupWarmupsConfig; - subtitleStyle?: SubtitleStyleConfig; - subtitleSidebar?: SubtitleSidebarConfig; - auto_start_overlay?: boolean; - jimaku?: JimakuConfig; - anilist?: AnilistConfig; - yomitan?: YomitanConfig; - jellyfin?: JellyfinConfig; - discordPresence?: DiscordPresenceConfig; - ai?: AiConfig; - youtube?: YoutubeConfig; - youtubeSubgen?: YoutubeSubgenConfig; - immersionTracking?: ImmersionTrackingConfig; - stats?: StatsConfig; - logging?: { - level?: 'debug' | 'info' | 'warn' | 'error'; - }; -} - -export type RawConfig = Config; - -export interface ResolvedConfig { - subtitlePosition: SubtitlePosition; - keybindings: Keybinding[]; - websocket: Required; - annotationWebsocket: Required; - texthooker: Required; - controller: { - enabled: boolean; - preferredGamepadId: string; - preferredGamepadLabel: string; - smoothScroll: boolean; - scrollPixelsPerSecond: number; - horizontalJumpPixels: number; - stickDeadzone: number; - triggerInputMode: ControllerTriggerInputMode; - triggerDeadzone: number; - repeatDelayMs: number; - repeatIntervalMs: number; - buttonIndices: Required; - bindings: Required; - }; - ankiConnect: AnkiConnectConfig & { - enabled: boolean; - url: string; - pollingRate: number; - proxy: { - enabled: boolean; - host: string; - port: number; - upstreamUrl: string; - }; - tags: string[]; - fields: { - word: string; - audio: string; - image: string; - sentence: string; - miscInfo: string; - translation: string; - }; - ai: AiFeatureConfig & { - enabled: boolean; - }; - media: { - generateAudio: boolean; - generateImage: boolean; - imageType: 'static' | 'avif'; - imageFormat: 'jpg' | 'png' | 'webp'; - imageQuality: number; - imageMaxWidth?: number; - imageMaxHeight?: number; - animatedFps: number; - animatedMaxWidth: number; - animatedMaxHeight?: number; - animatedCrf: number; - syncAnimatedImageToWordAudio: boolean; - audioPadding: number; - fallbackDuration: number; - maxMediaDuration: number; - }; - knownWords: { - highlightEnabled: boolean; - refreshMinutes: number; - addMinedWordsImmediately: boolean; - matchMode: NPlusOneMatchMode; - decks: Record; - color: string; - }; - nPlusOne: { - nPlusOne: string; - minSentenceWords: number; - }; - behavior: { - overwriteAudio: boolean; - overwriteImage: boolean; - mediaInsertMode: 'append' | 'prepend'; - highlightWord: boolean; - notificationType: 'osd' | 'system' | 'both' | 'none'; - autoUpdateNewCards: boolean; - }; - metadata: { - pattern: string; - }; - isLapis: { - enabled: boolean; - sentenceCardModel: string; - }; - isKiku: { - enabled: boolean; - fieldGrouping: 'auto' | 'manual' | 'disabled'; - deleteDuplicateInAuto: boolean; - }; - }; - shortcuts: Required; - secondarySub: Required; - subsync: Required; - startupWarmups: { - lowPowerMode: boolean; - mecab: boolean; - yomitanExtension: boolean; - subtitleDictionaries: boolean; - jellyfinRemoteSession: boolean; - }; - subtitleStyle: Required> & { - secondary: Required>; - frequencyDictionary: { - enabled: boolean; - sourcePath: string; - topX: number; - mode: FrequencyDictionaryMode; - matchMode: FrequencyDictionaryMatchMode; - singleColor: string; - bandedColors: [string, string, string, string, string]; - }; - }; - subtitleSidebar: Required; - auto_start_overlay: boolean; - jimaku: JimakuConfig & { - apiBaseUrl: string; - languagePreference: JimakuLanguagePreference; - maxEntryResults: number; - }; - anilist: { - enabled: boolean; - accessToken: string; - characterDictionary: { - enabled: boolean; - refreshTtlHours: number; - maxLoaded: number; - evictionPolicy: AnilistCharacterDictionaryEvictionPolicy; - profileScope: AnilistCharacterDictionaryProfileScope; - collapsibleSections: Required; - }; - }; - yomitan: { - externalProfilePath: string; - }; - jellyfin: { - enabled: boolean; - serverUrl: string; - username: string; - deviceId: string; - clientName: string; - clientVersion: string; - defaultLibraryId: string; - remoteControlEnabled: boolean; - remoteControlAutoConnect: boolean; - autoAnnounce: boolean; - remoteControlDeviceName: string; - pullPictures: boolean; - iconCacheDir: string; - directPlayPreferred: boolean; - directPlayContainers: string[]; - transcodeVideoCodec: string; - }; - discordPresence: { - enabled: boolean; - updateIntervalMs: number; - debounceMs: number; - }; - ai: AiConfig & { - enabled: boolean; - apiKey: string; - apiKeyCommand: string; - baseUrl: string; - model: string; - systemPrompt: string; - requestTimeoutMs: number; - }; - youtube: YoutubeConfig & { - primarySubLanguages: string[]; - }; - youtubeSubgen: YoutubeSubgenConfig & { - whisperBin: string; - whisperModel: string; - whisperVadModel: string; - whisperThreads: number; - fixWithAi: boolean; - ai: AiFeatureConfig; - }; - immersionTracking: { - enabled: boolean; - dbPath?: string; - batchSize: number; - flushIntervalMs: number; - queueCap: number; - payloadCapBytes: number; - maintenanceIntervalMs: number; - retentionMode: ImmersionTrackingRetentionMode; - retentionPreset: ImmersionTrackingRetentionPreset; - retention: { - eventsDays: number; - telemetryDays: number; - sessionsDays: number; - dailyRollupsDays: number; - monthlyRollupsDays: number; - vacuumIntervalDays: number; - }; - lifetimeSummaries: { - global: boolean; - anime: boolean; - media: boolean; - }; - }; - stats: { - toggleKey: string; - markWatchedKey: string; - serverPort: number; - autoStartServer: boolean; - autoOpenBrowser: boolean; - }; - logging: { - level: 'debug' | 'info' | 'warn' | 'error'; - }; -} - -export interface ConfigValidationWarning { - path: string; - value: unknown; - fallback: unknown; - message: string; -} - -export interface SubsyncSourceTrack { - id: number; - label: string; -} - -export interface SubsyncManualPayload { - sourceTracks: SubsyncSourceTrack[]; -} - -export interface SubsyncManualRunRequest { - engine: 'alass' | 'ffsubsync'; - sourceTrackId?: number | null; -} - -export interface SubsyncResult { - ok: boolean; - message: string; -} - -export interface ClipboardAppendResult { - ok: boolean; - message: string; -} - -export interface SubtitleData { - text: string; - tokens: MergedToken[] | null; - startTime?: number | null; - endTime?: number | null; -} - -export interface SubtitleSidebarSnapshot { - cues: SubtitleCue[]; - currentTimeSec?: number | null; - currentSubtitle: { - text: string; - startTime: number | null; - endTime: number | null; - }; - config: Required; -} - -export interface MpvSubtitleRenderMetrics { - subPos: number; - subFontSize: number; - subScale: number; - subMarginY: number; - subMarginX: number; - subFont: string; - subSpacing: number; - subBold: boolean; - subItalic: boolean; - subBorderSize: number; - subShadowOffset: number; - subAssOverride: string; - subScaleByWindow: boolean; - subUseMargins: boolean; - osdHeight: number; - osdDimensions: { - w: number; - h: number; - ml: number; - mr: number; - mt: number; - mb: number; - } | null; -} - -export type OverlayLayer = 'visible'; - -export interface OverlayContentRect { - x: number; - y: number; - width: number; - height: number; -} - -export interface OverlayContentMeasurement { - layer: OverlayLayer; - measuredAtMs: number; - viewport: { - width: number; - height: number; - }; - contentRect: OverlayContentRect | null; -} - -export interface MecabStatus { - available: boolean; - enabled: boolean; - path: string | null; -} - -export type JimakuConfidence = 'high' | 'medium' | 'low'; - -export interface JimakuMediaInfo { - title: string; - season: number | null; - episode: number | null; - confidence: JimakuConfidence; - filename: string; - rawTitle: string; -} - -export interface JimakuSearchQuery { - query: string; -} - -export interface JimakuEntryFlags { - anime?: boolean; - movie?: boolean; - adult?: boolean; - external?: boolean; - unverified?: boolean; -} - -export interface JimakuEntry { - id: number; - name: string; - english_name?: string | null; - japanese_name?: string | null; - flags?: JimakuEntryFlags; - last_modified?: string; -} - -export interface JimakuFilesQuery { - entryId: number; - episode?: number | null; -} - -export interface JimakuFileEntry { - name: string; - url: string; - size: number; - last_modified: string; -} - -export interface JimakuDownloadQuery { - entryId: number; - url: string; - name: string; -} - -export interface JimakuApiError { - error: string; - code?: number; - retryAfter?: number; -} - -export type JimakuApiResponse = { ok: true; data: T } | { ok: false; error: JimakuApiError }; - -export type JimakuDownloadResult = - | { ok: true; path: string } - | { ok: false; error: JimakuApiError }; - -export interface ConfigHotReloadPayload { - keybindings: Keybinding[]; - subtitleStyle: SubtitleStyleConfig | null; - subtitleSidebar: Required; - secondarySubMode: SecondarySubMode; -} - -export type ResolvedControllerConfig = ResolvedConfig['controller']; - -export interface SubtitleHoverTokenPayload { - tokenIndex: number | null; -} - -export interface ElectronAPI { - getOverlayLayer: () => 'visible' | 'modal' | null; - onSubtitle: (callback: (data: SubtitleData) => 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; - getPlaybackPaused: () => Promise; - onSubtitleAss: (callback: (assText: string) => void) => void; - setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => void; - openYomitanSettings: () => void; - recordYomitanLookup: () => void; - getSubtitlePosition: () => Promise; - saveSubtitlePosition: (position: SubtitlePosition) => void; - getMecabStatus: () => Promise; - setMecabEnabled: (enabled: boolean) => void; - sendMpvCommand: (command: (string | number)[]) => void; - getKeybindings: () => Promise; - getConfiguredShortcuts: () => Promise>; - getStatsToggleKey: () => Promise; - getMarkWatchedKey: () => Promise; - markActiveVideoWatched: () => Promise; - getControllerConfig: () => Promise; - saveControllerConfig: (update: ControllerConfigUpdate) => Promise; - saveControllerPreference: (update: ControllerPreferenceUpdate) => Promise; - getJimakuMediaInfo: () => Promise; - jimakuSearchEntries: (query: JimakuSearchQuery) => Promise>; - jimakuListFiles: (query: JimakuFilesQuery) => Promise>; - jimakuDownloadFile: (query: JimakuDownloadQuery) => 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; - getCurrentSecondarySub: () => Promise; - focusMainWindow: () => Promise; - getSubtitleStyle: () => Promise; - onSubsyncManualOpen: (callback: (payload: SubsyncManualPayload) => void) => void; - runSubsyncManual: (request: SubsyncManualRunRequest) => Promise; - onKikuFieldGroupingRequest: (callback: (data: KikuFieldGroupingRequestData) => void) => void; - kikuBuildMergePreview: (request: KikuMergePreviewRequest) => Promise; - kikuFieldGroupingRespond: (choice: KikuFieldGroupingChoice) => void; - getRuntimeOptions: () => Promise; - setRuntimeOptionValue: ( - id: RuntimeOptionId, - value: RuntimeOptionValue, - ) => Promise; - cycleRuntimeOption: (id: RuntimeOptionId, direction: 1 | -1) => Promise; - onRuntimeOptionsChanged: (callback: (options: RuntimeOptionState[]) => void) => void; - onOpenRuntimeOptions: (callback: () => void) => void; - onOpenJimaku: (callback: () => void) => void; - onOpenYoutubeTrackPicker: (callback: (payload: YoutubePickerOpenPayload) => void) => void; - onCancelYoutubeTrackPicker: (callback: () => void) => void; - onKeyboardModeToggleRequested: (callback: () => void) => void; - onLookupWindowToggleRequested: (callback: () => void) => void; - appendClipboardVideoToQueue: () => Promise; - youtubePickerResolve: ( - request: YoutubePickerResolveRequest, - ) => Promise; - notifyOverlayModalClosed: ( - modal: - | 'runtime-options' - | 'subsync' - | 'jimaku' - | 'youtube-track-picker' - | 'kiku' - | 'controller-select' - | 'controller-debug' - | 'subtitle-sidebar', - ) => void; - notifyOverlayModalOpened: ( - modal: - | 'runtime-options' - | 'subsync' - | 'jimaku' - | 'youtube-track-picker' - | 'kiku' - | 'controller-select' - | 'controller-debug' - | 'subtitle-sidebar', - ) => void; - reportOverlayContentBounds: (measurement: OverlayContentMeasurement) => void; - onConfigHotReload: (callback: (payload: ConfigHotReloadPayload) => void) => void; -} - -declare global { - interface Window { - electronAPI: ElectronAPI; - } -} +export * from './types/anki'; +export * from './types/config'; +export * from './types/integrations'; +export * from './types/runtime'; +export * from './types/runtime-options'; +export * from './types/subtitle'; diff --git a/src/types/anki.ts b/src/types/anki.ts new file mode 100644 index 0000000..e6b15b5 --- /dev/null +++ b/src/types/anki.ts @@ -0,0 +1,113 @@ +import type { AiFeatureConfig } from './integrations'; +import type { NPlusOneMatchMode } from './subtitle'; + +export interface NotificationOptions { + body?: string; + icon?: string; +} + +export interface KikuDuplicateCardInfo { + noteId: number; + expression: string; + sentencePreview: string; + hasAudio: boolean; + hasImage: boolean; + isOriginal: boolean; +} + +export interface KikuFieldGroupingRequestData { + original: KikuDuplicateCardInfo; + duplicate: KikuDuplicateCardInfo; +} + +export interface KikuFieldGroupingChoice { + keepNoteId: number; + deleteNoteId: number; + deleteDuplicate: boolean; + cancelled: boolean; +} + +export interface KikuMergePreviewRequest { + keepNoteId: number; + deleteNoteId: number; + deleteDuplicate: boolean; +} + +export interface KikuMergePreviewResponse { + ok: boolean; + compact?: Record; + full?: Record; + error?: string; +} + +export interface AnkiConnectConfig { + enabled?: boolean; + url?: string; + pollingRate?: number; + proxy?: { + enabled?: boolean; + host?: string; + port?: number; + upstreamUrl?: string; + }; + tags?: string[]; + fields?: { + word?: string; + audio?: string; + image?: string; + sentence?: string; + miscInfo?: string; + translation?: string; + }; + ai?: boolean | AiFeatureConfig; + media?: { + generateAudio?: boolean; + generateImage?: boolean; + imageType?: 'static' | 'avif'; + imageFormat?: 'jpg' | 'png' | 'webp'; + imageQuality?: number; + imageMaxWidth?: number; + imageMaxHeight?: number; + animatedFps?: number; + animatedMaxWidth?: number; + animatedMaxHeight?: number; + animatedCrf?: number; + syncAnimatedImageToWordAudio?: boolean; + audioPadding?: number; + fallbackDuration?: number; + maxMediaDuration?: number; + }; + knownWords?: { + highlightEnabled?: boolean; + refreshMinutes?: number; + addMinedWordsImmediately?: boolean; + matchMode?: NPlusOneMatchMode; + decks?: Record; + color?: string; + }; + nPlusOne?: { + nPlusOne?: string; + minSentenceWords?: number; + }; + behavior?: { + overwriteAudio?: boolean; + overwriteImage?: boolean; + mediaInsertMode?: 'append' | 'prepend'; + highlightWord?: boolean; + notificationType?: 'osd' | 'system' | 'both' | 'none'; + autoUpdateNewCards?: boolean; + }; + metadata?: { + pattern?: string; + }; + deck?: string; + isLapis?: { + enabled?: boolean; + sentenceCardModel?: string; + }; + isKiku?: { + enabled?: boolean; + fieldGrouping?: 'auto' | 'manual' | 'disabled'; + deleteDuplicateInAuto?: boolean; + }; +} diff --git a/src/types/config.ts b/src/types/config.ts new file mode 100644 index 0000000..527bff5 --- /dev/null +++ b/src/types/config.ts @@ -0,0 +1,340 @@ +import type { AnkiConnectConfig } from './anki'; +import type { + AiConfig, + AiFeatureConfig, + AnilistCharacterDictionaryCollapsibleSectionsConfig, + AnilistCharacterDictionaryEvictionPolicy, + AnilistCharacterDictionaryProfileScope, + AnilistConfig, + DiscordPresenceConfig, + ImmersionTrackingConfig, + ImmersionTrackingRetentionMode, + ImmersionTrackingRetentionPreset, + JellyfinConfig, + JimakuConfig, + JimakuLanguagePreference, + StatsConfig, + YomitanConfig, + YoutubeConfig, + YoutubeSubgenConfig, +} from './integrations'; +import type { + ControllerButtonIndicesConfig, + ControllerConfig, + ControllerTriggerInputMode, + Keybinding, + ResolvedControllerBindingsConfig, +} from './runtime'; +import type { + FrequencyDictionaryMatchMode, + FrequencyDictionaryMode, + NPlusOneMatchMode, + SecondarySubConfig, + SubtitlePosition, + SubtitleSidebarConfig, + SubtitleStyleConfig, +} from './subtitle'; + +export interface WebSocketConfig { + enabled?: boolean | 'auto'; + port?: number; +} + +export interface AnnotationWebSocketConfig { + enabled?: boolean; + port?: number; +} + +export interface TexthookerConfig { + launchAtStartup?: boolean; + openBrowser?: boolean; +} + +export type SubsyncMode = 'auto' | 'manual'; + +export interface SubsyncConfig { + defaultMode?: SubsyncMode; + alass_path?: string; + ffsubsync_path?: string; + ffmpeg_path?: string; + replace?: boolean; +} + +export interface StartupWarmupsConfig { + lowPowerMode?: boolean; + mecab?: boolean; + yomitanExtension?: boolean; + subtitleDictionaries?: boolean; + jellyfinRemoteSession?: boolean; +} + +export interface ShortcutsConfig { + toggleVisibleOverlayGlobal?: string | null; + copySubtitle?: string | null; + copySubtitleMultiple?: string | null; + updateLastCardFromClipboard?: string | null; + triggerFieldGrouping?: string | null; + triggerSubsync?: string | null; + mineSentence?: string | null; + mineSentenceMultiple?: string | null; + multiCopyTimeoutMs?: number; + toggleSecondarySub?: string | null; + markAudioCard?: string | null; + openRuntimeOptions?: string | null; + openJimaku?: string | null; +} + +export interface Config { + subtitlePosition?: SubtitlePosition; + keybindings?: Keybinding[]; + websocket?: WebSocketConfig; + annotationWebsocket?: AnnotationWebSocketConfig; + texthooker?: TexthookerConfig; + controller?: ControllerConfig; + ankiConnect?: AnkiConnectConfig; + shortcuts?: ShortcutsConfig; + secondarySub?: SecondarySubConfig; + subsync?: SubsyncConfig; + startupWarmups?: StartupWarmupsConfig; + subtitleStyle?: SubtitleStyleConfig; + subtitleSidebar?: SubtitleSidebarConfig; + auto_start_overlay?: boolean; + jimaku?: JimakuConfig; + anilist?: AnilistConfig; + yomitan?: YomitanConfig; + jellyfin?: JellyfinConfig; + discordPresence?: DiscordPresenceConfig; + ai?: AiConfig; + youtube?: YoutubeConfig; + youtubeSubgen?: YoutubeSubgenConfig; + immersionTracking?: ImmersionTrackingConfig; + stats?: StatsConfig; + logging?: { + level?: 'debug' | 'info' | 'warn' | 'error'; + }; +} + +export type RawConfig = Config; + +export interface ResolvedConfig { + subtitlePosition: SubtitlePosition; + keybindings: Keybinding[]; + websocket: Required; + annotationWebsocket: Required; + texthooker: Required; + controller: { + enabled: boolean; + preferredGamepadId: string; + preferredGamepadLabel: string; + smoothScroll: boolean; + scrollPixelsPerSecond: number; + horizontalJumpPixels: number; + stickDeadzone: number; + triggerInputMode: ControllerTriggerInputMode; + triggerDeadzone: number; + repeatDelayMs: number; + repeatIntervalMs: number; + buttonIndices: Required; + bindings: Required; + }; + ankiConnect: AnkiConnectConfig & { + enabled: boolean; + url: string; + pollingRate: number; + proxy: { + enabled: boolean; + host: string; + port: number; + upstreamUrl: string; + }; + tags: string[]; + fields: { + word: string; + audio: string; + image: string; + sentence: string; + miscInfo: string; + translation: string; + }; + ai: AiFeatureConfig & { + enabled: boolean; + }; + media: { + generateAudio: boolean; + generateImage: boolean; + imageType: 'static' | 'avif'; + imageFormat: 'jpg' | 'png' | 'webp'; + imageQuality: number; + imageMaxWidth?: number; + imageMaxHeight?: number; + animatedFps: number; + animatedMaxWidth: number; + animatedMaxHeight?: number; + animatedCrf: number; + syncAnimatedImageToWordAudio: boolean; + audioPadding: number; + fallbackDuration: number; + maxMediaDuration: number; + }; + knownWords: { + highlightEnabled: boolean; + refreshMinutes: number; + addMinedWordsImmediately: boolean; + matchMode: NPlusOneMatchMode; + decks: Record; + color: string; + }; + nPlusOne: { + nPlusOne: string; + minSentenceWords: number; + }; + behavior: { + overwriteAudio: boolean; + overwriteImage: boolean; + mediaInsertMode: 'append' | 'prepend'; + highlightWord: boolean; + notificationType: 'osd' | 'system' | 'both' | 'none'; + autoUpdateNewCards: boolean; + }; + metadata: { + pattern: string; + }; + isLapis: { + enabled: boolean; + sentenceCardModel: string; + }; + isKiku: { + enabled: boolean; + fieldGrouping: 'auto' | 'manual' | 'disabled'; + deleteDuplicateInAuto: boolean; + }; + }; + shortcuts: Required; + secondarySub: Required; + subsync: Required; + startupWarmups: { + lowPowerMode: boolean; + mecab: boolean; + yomitanExtension: boolean; + subtitleDictionaries: boolean; + jellyfinRemoteSession: boolean; + }; + subtitleStyle: Required> & { + secondary: Required>; + frequencyDictionary: { + enabled: boolean; + sourcePath: string; + topX: number; + mode: FrequencyDictionaryMode; + matchMode: FrequencyDictionaryMatchMode; + singleColor: string; + bandedColors: [string, string, string, string, string]; + }; + }; + subtitleSidebar: Required; + auto_start_overlay: boolean; + jimaku: JimakuConfig & { + apiBaseUrl: string; + languagePreference: JimakuLanguagePreference; + maxEntryResults: number; + }; + anilist: { + enabled: boolean; + accessToken: string; + characterDictionary: { + enabled: boolean; + refreshTtlHours: number; + maxLoaded: number; + evictionPolicy: AnilistCharacterDictionaryEvictionPolicy; + profileScope: AnilistCharacterDictionaryProfileScope; + collapsibleSections: Required; + }; + }; + yomitan: { + externalProfilePath: string; + }; + jellyfin: { + enabled: boolean; + serverUrl: string; + username: string; + deviceId: string; + clientName: string; + clientVersion: string; + defaultLibraryId: string; + remoteControlEnabled: boolean; + remoteControlAutoConnect: boolean; + autoAnnounce: boolean; + remoteControlDeviceName: string; + pullPictures: boolean; + iconCacheDir: string; + directPlayPreferred: boolean; + directPlayContainers: string[]; + transcodeVideoCodec: string; + }; + discordPresence: { + enabled: boolean; + updateIntervalMs: number; + debounceMs: number; + }; + ai: AiConfig & { + enabled: boolean; + apiKey: string; + apiKeyCommand: string; + baseUrl: string; + model: string; + systemPrompt: string; + requestTimeoutMs: number; + }; + youtube: YoutubeConfig & { + primarySubLanguages: string[]; + }; + youtubeSubgen: YoutubeSubgenConfig & { + whisperBin: string; + whisperModel: string; + whisperVadModel: string; + whisperThreads: number; + fixWithAi: boolean; + ai: AiFeatureConfig; + }; + immersionTracking: { + enabled: boolean; + dbPath?: string; + batchSize: number; + flushIntervalMs: number; + queueCap: number; + payloadCapBytes: number; + maintenanceIntervalMs: number; + retentionMode: ImmersionTrackingRetentionMode; + retentionPreset: ImmersionTrackingRetentionPreset; + retention: { + eventsDays: number; + telemetryDays: number; + sessionsDays: number; + dailyRollupsDays: number; + monthlyRollupsDays: number; + vacuumIntervalDays: number; + }; + lifetimeSummaries: { + global: boolean; + anime: boolean; + media: boolean; + }; + }; + stats: { + toggleKey: string; + markWatchedKey: string; + serverPort: number; + autoStartServer: boolean; + autoOpenBrowser: boolean; + }; + logging: { + level: 'debug' | 'info' | 'warn' | 'error'; + }; +} + +export interface ConfigValidationWarning { + path: string; + value: unknown; + fallback: unknown; + message: string; +} diff --git a/src/types/integrations.ts b/src/types/integrations.ts new file mode 100644 index 0000000..fab1d2c --- /dev/null +++ b/src/types/integrations.ts @@ -0,0 +1,235 @@ +import type { YoutubeTrackKind } from '../core/services/youtube/kinds'; + +export type JimakuLanguagePreference = 'ja' | 'en' | 'none'; +export type { YoutubeTrackKind }; + +export interface YoutubeTrackOption { + id: string; + language: string; + sourceLanguage: string; + kind: YoutubeTrackKind; + label: string; + title?: string; + downloadUrl?: string; + fileExtension?: string; +} + +export interface YoutubePickerOpenPayload { + sessionId: string; + url: string; + tracks: YoutubeTrackOption[]; + defaultPrimaryTrackId: string | null; + defaultSecondaryTrackId: string | null; + hasTracks: boolean; +} + +export type YoutubePickerResolveRequest = + | { + sessionId: string; + action: 'continue-without-subtitles'; + primaryTrackId: null; + secondaryTrackId: null; + } + | { + sessionId: string; + action: 'use-selected'; + primaryTrackId: string | null; + secondaryTrackId: string | null; + }; + +export interface YoutubePickerResolveResult { + ok: boolean; + message: string; +} + +export interface JimakuConfig { + apiKey?: string; + apiKeyCommand?: string; + apiBaseUrl?: string; + languagePreference?: JimakuLanguagePreference; + maxEntryResults?: number; +} + +export type AnilistCharacterDictionaryEvictionPolicy = 'disable' | 'delete'; +export type AnilistCharacterDictionaryProfileScope = 'all' | 'active'; +export type AnilistCharacterDictionaryCollapsibleSectionKey = + | 'description' + | 'characterInformation' + | 'voicedBy'; + +export interface AnilistCharacterDictionaryCollapsibleSectionsConfig { + description?: boolean; + characterInformation?: boolean; + voicedBy?: boolean; +} + +export interface AnilistCharacterDictionaryConfig { + enabled?: boolean; + refreshTtlHours?: number; + maxLoaded?: number; + evictionPolicy?: AnilistCharacterDictionaryEvictionPolicy; + profileScope?: AnilistCharacterDictionaryProfileScope; + collapsibleSections?: AnilistCharacterDictionaryCollapsibleSectionsConfig; +} + +export interface AnilistConfig { + enabled?: boolean; + accessToken?: string; + characterDictionary?: AnilistCharacterDictionaryConfig; +} + +export interface YomitanConfig { + externalProfilePath?: string; +} + +export interface JellyfinConfig { + enabled?: boolean; + serverUrl?: string; + username?: string; + deviceId?: string; + clientName?: string; + clientVersion?: string; + defaultLibraryId?: string; + remoteControlEnabled?: boolean; + remoteControlAutoConnect?: boolean; + autoAnnounce?: boolean; + remoteControlDeviceName?: string; + pullPictures?: boolean; + iconCacheDir?: string; + directPlayPreferred?: boolean; + directPlayContainers?: string[]; + transcodeVideoCodec?: string; +} + +export interface DiscordPresenceConfig { + enabled?: boolean; + updateIntervalMs?: number; + debounceMs?: number; +} + +export interface AiFeatureConfig { + enabled?: boolean; + model?: string; + systemPrompt?: string; +} + +export interface AiConfig { + enabled?: boolean; + apiKey?: string; + apiKeyCommand?: string; + baseUrl?: string; + model?: string; + systemPrompt?: string; + requestTimeoutMs?: number; +} + +export interface YoutubeConfig { + primarySubLanguages?: string[]; +} + +export interface YoutubeSubgenConfig { + whisperBin?: string; + whisperModel?: string; + whisperVadModel?: string; + whisperThreads?: number; + fixWithAi?: boolean; + ai?: AiFeatureConfig; +} + +export interface StatsConfig { + toggleKey?: string; + markWatchedKey?: string; + serverPort?: number; + autoStartServer?: boolean; + autoOpenBrowser?: boolean; +} + +export type ImmersionTrackingRetentionMode = 'preset' | 'advanced'; +export type ImmersionTrackingRetentionPreset = 'minimal' | 'balanced' | 'deep-history'; + +export interface ImmersionTrackingConfig { + enabled?: boolean; + dbPath?: string; + batchSize?: number; + flushIntervalMs?: number; + queueCap?: number; + payloadCapBytes?: number; + maintenanceIntervalMs?: number; + retentionMode?: ImmersionTrackingRetentionMode; + retentionPreset?: ImmersionTrackingRetentionPreset; + retention?: { + eventsDays?: number; + telemetryDays?: number; + sessionsDays?: number; + dailyRollupsDays?: number; + monthlyRollupsDays?: number; + vacuumIntervalDays?: number; + }; + lifetimeSummaries?: { + global?: boolean; + anime?: boolean; + media?: boolean; + }; +} + +export type JimakuConfidence = 'high' | 'medium' | 'low'; + +export interface JimakuMediaInfo { + title: string; + season: number | null; + episode: number | null; + confidence: JimakuConfidence; + filename: string; + rawTitle: string; +} + +export interface JimakuSearchQuery { + query: string; +} + +export interface JimakuEntryFlags { + anime?: boolean; + movie?: boolean; + adult?: boolean; + external?: boolean; + unverified?: boolean; +} + +export interface JimakuEntry { + id: number; + name: string; + english_name?: string | null; + japanese_name?: string | null; + flags?: JimakuEntryFlags; + last_modified?: string; +} + +export interface JimakuFilesQuery { + entryId: number; + episode?: number | null; +} + +export interface JimakuFileEntry { + name: string; + url: string; + size: number; + last_modified: string; +} + +export interface JimakuDownloadQuery { + entryId: number; + url: string; + name: string; +} + +export interface JimakuApiError { + error: string; + code?: number; + retryAfter?: number; +} + +export type JimakuApiResponse = { ok: true; data: T } | { ok: false; error: JimakuApiError }; + +export type JimakuDownloadResult = + | { ok: true; path: string } + | { ok: false; error: JimakuApiError }; diff --git a/src/types/runtime-options.ts b/src/types/runtime-options.ts new file mode 100644 index 0000000..18814b3 --- /dev/null +++ b/src/types/runtime-options.ts @@ -0,0 +1,31 @@ +export type RuntimeOptionId = + | 'anki.autoUpdateNewCards' + | 'subtitle.annotation.nPlusOne' + | 'subtitle.annotation.jlpt' + | 'subtitle.annotation.frequency' + | 'anki.kikuFieldGrouping' + | 'anki.nPlusOneMatchMode'; + +export type RuntimeOptionScope = 'ankiConnect' | 'subtitle'; + +export type RuntimeOptionValueType = 'boolean' | 'enum'; + +export type RuntimeOptionValue = boolean | string; + +export interface RuntimeOptionState { + id: RuntimeOptionId; + label: string; + scope: RuntimeOptionScope; + valueType: RuntimeOptionValueType; + value: RuntimeOptionValue; + allowedValues: RuntimeOptionValue[]; + requiresRestart: boolean; +} + +export interface RuntimeOptionApplyResult { + ok: boolean; + option?: RuntimeOptionState; + osdMessage?: string; + requiresRestart?: boolean; + error?: string; +} diff --git a/src/types/runtime.ts b/src/types/runtime.ts new file mode 100644 index 0000000..bf55555 --- /dev/null +++ b/src/types/runtime.ts @@ -0,0 +1,394 @@ +import type { + KikuFieldGroupingChoice, + KikuFieldGroupingRequestData, + KikuMergePreviewRequest, + KikuMergePreviewResponse, +} from './anki'; +import type { ResolvedConfig, ShortcutsConfig } from './config'; +import type { + JimakuApiResponse, + JimakuDownloadQuery, + JimakuDownloadResult, + JimakuEntry, + JimakuFileEntry, + JimakuFilesQuery, + JimakuMediaInfo, + JimakuSearchQuery, + YoutubePickerOpenPayload, + YoutubePickerResolveRequest, + YoutubePickerResolveResult, +} from './integrations'; +import type { + SecondarySubMode, + SubtitleData, + SubtitlePosition, + SubtitleSidebarConfig, + SubtitleSidebarSnapshot, + SubtitleStyleConfig, +} from './subtitle'; +import type { + RuntimeOptionApplyResult, + RuntimeOptionId, + RuntimeOptionState, + RuntimeOptionValue, +} from './runtime-options'; + +export interface WindowGeometry { + x: number; + y: number; + width: number; + height: number; +} + +export interface Keybinding { + key: string; + command: (string | number)[] | null; +} + +export interface MpvClient { + currentSubText: string; + currentVideoPath: string; + currentMediaTitle?: string | null; + currentTimePos: number; + currentSubStart: number; + currentSubEnd: number; + currentAudioStreamIndex: number | null; + requestProperty?: (name: string) => Promise; + send(command: { command: unknown[]; request_id?: number }): boolean; +} + +export interface SubsyncSourceTrack { + id: number; + label: string; +} + +export interface SubsyncManualPayload { + sourceTracks: SubsyncSourceTrack[]; +} + +export interface SubsyncManualRunRequest { + engine: 'alass' | 'ffsubsync'; + sourceTrackId?: number | null; +} + +export interface SubsyncResult { + ok: boolean; + message: string; +} + +export type ControllerButtonBinding = + | 'none' + | 'select' + | 'buttonSouth' + | 'buttonEast' + | 'buttonNorth' + | 'buttonWest' + | 'leftShoulder' + | 'rightShoulder' + | 'leftStickPress' + | 'rightStickPress' + | 'leftTrigger' + | 'rightTrigger'; + +export type ControllerAxisBinding = 'leftStickX' | 'leftStickY' | 'rightStickX' | 'rightStickY'; +export type ControllerTriggerInputMode = 'auto' | 'digital' | 'analog'; +export type ControllerAxisDirection = 'negative' | 'positive'; +export type ControllerDpadFallback = 'none' | 'horizontal' | 'vertical'; + +export interface ControllerNoneBinding { + kind: 'none'; +} + +export interface ControllerButtonInputBinding { + kind: 'button'; + buttonIndex: number; +} + +export interface ControllerAxisDirectionInputBinding { + kind: 'axis'; + axisIndex: number; + direction: ControllerAxisDirection; +} + +export interface ControllerAxisInputBinding { + kind: 'axis'; + axisIndex: number; + dpadFallback?: ControllerDpadFallback; +} + +export type ControllerDiscreteBindingConfig = + | ControllerButtonBinding + | ControllerNoneBinding + | ControllerButtonInputBinding + | ControllerAxisDirectionInputBinding; + +export type ResolvedControllerDiscreteBinding = + | ControllerNoneBinding + | ControllerButtonInputBinding + | ControllerAxisDirectionInputBinding; + +export type ControllerAxisBindingConfig = + | ControllerAxisBinding + | ControllerNoneBinding + | ControllerAxisInputBinding; + +export type ResolvedControllerAxisBinding = + | ControllerNoneBinding + | { + kind: 'axis'; + axisIndex: number; + dpadFallback: ControllerDpadFallback; + }; + +export interface ControllerBindingsConfig { + toggleLookup?: ControllerDiscreteBindingConfig; + closeLookup?: ControllerDiscreteBindingConfig; + toggleKeyboardOnlyMode?: ControllerDiscreteBindingConfig; + mineCard?: ControllerDiscreteBindingConfig; + quitMpv?: ControllerDiscreteBindingConfig; + previousAudio?: ControllerDiscreteBindingConfig; + nextAudio?: ControllerDiscreteBindingConfig; + playCurrentAudio?: ControllerDiscreteBindingConfig; + toggleMpvPause?: ControllerDiscreteBindingConfig; + leftStickHorizontal?: ControllerAxisBindingConfig; + leftStickVertical?: ControllerAxisBindingConfig; + rightStickHorizontal?: ControllerAxisBindingConfig; + rightStickVertical?: ControllerAxisBindingConfig; +} + +export interface ResolvedControllerBindingsConfig { + toggleLookup?: ResolvedControllerDiscreteBinding; + closeLookup?: ResolvedControllerDiscreteBinding; + toggleKeyboardOnlyMode?: ResolvedControllerDiscreteBinding; + mineCard?: ResolvedControllerDiscreteBinding; + quitMpv?: ResolvedControllerDiscreteBinding; + previousAudio?: ResolvedControllerDiscreteBinding; + nextAudio?: ResolvedControllerDiscreteBinding; + playCurrentAudio?: ResolvedControllerDiscreteBinding; + toggleMpvPause?: ResolvedControllerDiscreteBinding; + leftStickHorizontal?: ResolvedControllerAxisBinding; + leftStickVertical?: ResolvedControllerAxisBinding; + rightStickHorizontal?: ResolvedControllerAxisBinding; + rightStickVertical?: ResolvedControllerAxisBinding; +} + +export interface ControllerButtonIndicesConfig { + select?: number; + buttonSouth?: number; + buttonEast?: number; + buttonNorth?: number; + buttonWest?: number; + leftShoulder?: number; + rightShoulder?: number; + leftStickPress?: number; + rightStickPress?: number; + leftTrigger?: number; + rightTrigger?: number; +} + +export interface ControllerConfig { + enabled?: boolean; + preferredGamepadId?: string; + preferredGamepadLabel?: string; + smoothScroll?: boolean; + scrollPixelsPerSecond?: number; + horizontalJumpPixels?: number; + stickDeadzone?: number; + triggerInputMode?: ControllerTriggerInputMode; + triggerDeadzone?: number; + repeatDelayMs?: number; + repeatIntervalMs?: number; + buttonIndices?: ControllerButtonIndicesConfig; + bindings?: ControllerBindingsConfig; +} + +export interface ControllerPreferenceUpdate { + preferredGamepadId: string; + preferredGamepadLabel: string; +} + +export type ControllerConfigUpdate = ControllerConfig; + +export interface ControllerDeviceInfo { + id: string; + index: number; + mapping: string; + connected: boolean; +} + +export interface ControllerButtonSnapshot { + value: number; + pressed: boolean; + touched?: boolean; +} + +export interface ControllerRuntimeSnapshot { + connectedGamepads: ControllerDeviceInfo[]; + activeGamepadId: string | null; + rawAxes: number[]; + rawButtons: ControllerButtonSnapshot[]; +} + +export interface MpvSubtitleRenderMetrics { + subPos: number; + subFontSize: number; + subScale: number; + subMarginY: number; + subMarginX: number; + subFont: string; + subSpacing: number; + subBold: boolean; + subItalic: boolean; + subBorderSize: number; + subShadowOffset: number; + subAssOverride: string; + subScaleByWindow: boolean; + subUseMargins: boolean; + osdHeight: number; + osdDimensions: { + w: number; + h: number; + ml: number; + mr: number; + mt: number; + mb: number; + } | null; +} + +export type OverlayLayer = 'visible'; + +export interface OverlayContentRect { + x: number; + y: number; + width: number; + height: number; +} + +export interface OverlayContentMeasurement { + layer: OverlayLayer; + measuredAtMs: number; + viewport: { + width: number; + height: number; + }; + contentRect: OverlayContentRect | null; +} + +export interface MecabStatus { + available: boolean; + enabled: boolean; + path: string | null; +} + +export interface ClipboardAppendResult { + ok: boolean; + message: string; +} + +export interface ConfigHotReloadPayload { + keybindings: Keybinding[]; + subtitleStyle: SubtitleStyleConfig | null; + subtitleSidebar: Required; + secondarySubMode: SecondarySubMode; +} + +export type ResolvedControllerConfig = ResolvedConfig['controller']; + +export interface ElectronAPI { + getOverlayLayer: () => 'visible' | 'modal' | null; + onSubtitle: (callback: (data: SubtitleData) => 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; + getPlaybackPaused: () => Promise; + onSubtitleAss: (callback: (assText: string) => void) => void; + setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => void; + openYomitanSettings: () => void; + recordYomitanLookup: () => void; + getSubtitlePosition: () => Promise; + saveSubtitlePosition: (position: SubtitlePosition) => void; + getMecabStatus: () => Promise; + setMecabEnabled: (enabled: boolean) => void; + sendMpvCommand: (command: (string | number)[]) => void; + getKeybindings: () => Promise; + getConfiguredShortcuts: () => Promise>; + getStatsToggleKey: () => Promise; + getMarkWatchedKey: () => Promise; + markActiveVideoWatched: () => Promise; + getControllerConfig: () => Promise; + saveControllerConfig: (update: ControllerConfigUpdate) => Promise; + saveControllerPreference: (update: ControllerPreferenceUpdate) => Promise; + getJimakuMediaInfo: () => Promise; + jimakuSearchEntries: (query: JimakuSearchQuery) => Promise>; + jimakuListFiles: (query: JimakuFilesQuery) => Promise>; + jimakuDownloadFile: (query: JimakuDownloadQuery) => 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; + getCurrentSecondarySub: () => Promise; + focusMainWindow: () => Promise; + getSubtitleStyle: () => Promise; + onSubsyncManualOpen: (callback: (payload: SubsyncManualPayload) => void) => void; + runSubsyncManual: (request: SubsyncManualRunRequest) => Promise; + onKikuFieldGroupingRequest: (callback: (data: KikuFieldGroupingRequestData) => void) => void; + kikuBuildMergePreview: (request: KikuMergePreviewRequest) => Promise; + kikuFieldGroupingRespond: (choice: KikuFieldGroupingChoice) => void; + getRuntimeOptions: () => Promise; + setRuntimeOptionValue: ( + id: RuntimeOptionId, + value: RuntimeOptionValue, + ) => Promise; + cycleRuntimeOption: (id: RuntimeOptionId, direction: 1 | -1) => Promise; + onRuntimeOptionsChanged: (callback: (options: RuntimeOptionState[]) => void) => void; + onOpenRuntimeOptions: (callback: () => void) => void; + onOpenJimaku: (callback: () => void) => void; + onOpenYoutubeTrackPicker: (callback: (payload: YoutubePickerOpenPayload) => void) => void; + onCancelYoutubeTrackPicker: (callback: () => void) => void; + onKeyboardModeToggleRequested: (callback: () => void) => void; + onLookupWindowToggleRequested: (callback: () => void) => void; + appendClipboardVideoToQueue: () => Promise; + youtubePickerResolve: ( + request: YoutubePickerResolveRequest, + ) => Promise; + notifyOverlayModalClosed: ( + modal: + | 'runtime-options' + | 'subsync' + | 'jimaku' + | 'youtube-track-picker' + | 'kiku' + | 'controller-select' + | 'controller-debug' + | 'subtitle-sidebar', + ) => void; + notifyOverlayModalOpened: ( + modal: + | 'runtime-options' + | 'subsync' + | 'jimaku' + | 'youtube-track-picker' + | 'kiku' + | 'controller-select' + | 'controller-debug' + | 'subtitle-sidebar', + ) => void; + reportOverlayContentBounds: (measurement: OverlayContentMeasurement) => void; + onConfigHotReload: (callback: (payload: ConfigHotReloadPayload) => void) => void; +} + +declare global { + interface Window { + electronAPI: ElectronAPI; + } +} diff --git a/src/types/subtitle.ts b/src/types/subtitle.ts new file mode 100644 index 0000000..d744b20 --- /dev/null +++ b/src/types/subtitle.ts @@ -0,0 +1,195 @@ +import type { SubtitleCue } from '../core/services/subtitle-cue-parser'; + +export enum PartOfSpeech { + noun = 'noun', + verb = 'verb', + i_adjective = 'i_adjective', + na_adjective = 'na_adjective', + particle = 'particle', + bound_auxiliary = 'bound_auxiliary', + symbol = 'symbol', + other = 'other', +} + +export interface Token { + word: string; + partOfSpeech: PartOfSpeech; + pos1: string; + pos2: string; + pos3: string; + pos4: string; + inflectionType: string; + inflectionForm: string; + headword: string; + katakanaReading: string; + pronunciation: string; +} + +export interface MergedToken { + surface: string; + reading: string; + headword: string; + startPos: number; + endPos: number; + partOfSpeech: PartOfSpeech; + pos1?: string; + pos2?: string; + pos3?: string; + isMerged: boolean; + isKnown: boolean; + isNPlusOneTarget: boolean; + isNameMatch?: boolean; + jlptLevel?: JlptLevel; + frequencyRank?: number; +} + +export type FrequencyDictionaryLookup = (term: string) => number | null; + +export type JlptLevel = 'N1' | 'N2' | 'N3' | 'N4' | 'N5'; + +export interface SubtitlePosition { + yPercent: number; +} + +export interface SubtitleStyle { + fontSize: number; +} + +export type SecondarySubMode = 'hidden' | 'visible' | 'hover'; + +export interface SecondarySubConfig { + secondarySubLanguages?: string[]; + autoLoadSecondarySub?: boolean; + defaultMode?: SecondarySubMode; +} + +export type NPlusOneMatchMode = 'headword' | 'surface'; +export type FrequencyDictionaryMatchMode = 'headword' | 'surface'; + +export interface SubtitleStyleConfig { + enableJlpt?: boolean; + preserveLineBreaks?: boolean; + autoPauseVideoOnHover?: boolean; + autoPauseVideoOnYomitanPopup?: boolean; + hoverTokenColor?: string; + hoverTokenBackgroundColor?: string; + nameMatchEnabled?: boolean; + nameMatchColor?: string; + fontFamily?: string; + fontSize?: number; + fontColor?: string; + fontWeight?: string | number; + fontStyle?: string; + lineHeight?: string | number; + letterSpacing?: string; + wordSpacing?: string | number; + fontKerning?: string; + textRendering?: string; + textShadow?: string; + backdropFilter?: string; + backgroundColor?: string; + nPlusOneColor?: string; + knownWordColor?: string; + jlptColors?: { + N1: string; + N2: string; + N3: string; + N4: string; + N5: string; + }; + frequencyDictionary?: { + enabled?: boolean; + sourcePath?: string; + topX?: number; + mode?: FrequencyDictionaryMode; + matchMode?: FrequencyDictionaryMatchMode; + singleColor?: string; + bandedColors?: [string, string, string, string, string]; + }; + secondary?: { + fontFamily?: string; + fontSize?: number; + fontColor?: string; + fontWeight?: string | number; + fontStyle?: string; + lineHeight?: string | number; + letterSpacing?: string; + wordSpacing?: string | number; + fontKerning?: string; + textRendering?: string; + textShadow?: string; + backdropFilter?: string; + backgroundColor?: string; + }; +} + +export interface TokenPos1ExclusionConfig { + defaults?: string[]; + add?: string[]; + remove?: string[]; +} + +export interface ResolvedTokenPos1ExclusionConfig { + defaults: string[]; + add: string[]; + remove: string[]; +} + +export interface TokenPos2ExclusionConfig { + defaults?: string[]; + add?: string[]; + remove?: string[]; +} + +export interface ResolvedTokenPos2ExclusionConfig { + defaults: string[]; + add: string[]; + remove: string[]; +} + +export type FrequencyDictionaryMode = 'single' | 'banded'; + +export type { SubtitleCue }; + +export type SubtitleSidebarLayout = 'overlay' | 'embedded'; + +export interface SubtitleSidebarConfig { + enabled?: boolean; + autoOpen?: boolean; + layout?: SubtitleSidebarLayout; + toggleKey?: string; + pauseVideoOnHover?: boolean; + autoScroll?: boolean; + maxWidth?: number; + opacity?: number; + backgroundColor?: string; + textColor?: string; + fontFamily?: string; + fontSize?: number; + timestampColor?: string; + activeLineColor?: string; + activeLineBackgroundColor?: string; + hoverLineBackgroundColor?: string; +} + +export interface SubtitleData { + text: string; + tokens: MergedToken[] | null; + startTime?: number | null; + endTime?: number | null; +} + +export interface SubtitleSidebarSnapshot { + cues: SubtitleCue[]; + currentTimeSec?: number | null; + currentSubtitle: { + text: string; + startTime: number | null; + endTime: number | null; + }; + config: Required; +} + +export interface SubtitleHoverTokenPayload { + tokenIndex: number | null; +}