Restore multi-copy digit capture and add AniList selection (#56)

This commit is contained in:
2026-04-25 21:44:55 -07:00
committed by GitHub
parent 7ac51cd5e9
commit d8934647a9
140 changed files with 4097 additions and 326 deletions

View File

@@ -0,0 +1,143 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { CardCreationService } from './card-creation';
import type { AnkiConnectConfig } from '../types/anki';
type CardCreationDeps = ConstructorParameters<typeof CardCreationService>[0];
function createManualUpdateService(overrides: Partial<CardCreationDeps> = {}): {
service: CardCreationService;
updatedFields: Record<string, string>[];
mergeCalls: Array<{ existing: string; newValue: string; overwrite: boolean }>;
storedMedia: string[];
} {
const updatedFields: Record<string, string>[] = [];
const mergeCalls: Array<{ existing: string; newValue: string; overwrite: boolean }> = [];
const storedMedia: string[] = [];
const deps: CardCreationDeps = {
getConfig: () =>
({
deck: 'Mining',
fields: {
word: 'Expression',
sentence: 'Sentence',
audio: 'ExpressionAudio',
},
media: {
generateAudio: true,
generateImage: false,
maxMediaDuration: 30,
},
behavior: {
overwriteAudio: false,
overwriteImage: false,
},
ai: false,
}) as AnkiConnectConfig,
getAiConfig: () => ({}),
getTimingTracker: () =>
({
findTiming: (text: string) => (text === '字幕' ? { startTime: 12, endTime: 14 } : null),
}) as never,
getMpvClient: () =>
({
currentVideoPath: '/video.mp4',
currentAudioStreamIndex: 0,
}) as never,
client: {
addNote: async () => 0,
addTags: async () => undefined,
notesInfo: async () => [
{
noteId: 42,
fields: {
Expression: { value: '単語' },
Sentence: { value: '' },
ExpressionAudio: { value: '[sound:auto-expression.mp3]' },
SentenceAudio: { value: '[sound:auto-sentence.mp3]' },
},
},
],
updateNoteFields: async (_noteId, fields) => {
updatedFields.push(fields);
},
storeMediaFile: async (filename) => {
storedMedia.push(filename);
},
findNotes: async () => [42],
retrieveMediaFile: async () => '',
},
mediaGenerator: {
generateAudio: async () => Buffer.from('audio'),
generateScreenshot: async () => null,
generateAnimatedImage: async () => null,
},
showOsdNotification: () => undefined,
showUpdateResult: () => undefined,
showStatusNotification: () => undefined,
showNotification: async () => undefined,
beginUpdateProgress: () => undefined,
endUpdateProgress: () => undefined,
withUpdateProgress: async (_message, action) => action(),
resolveConfiguredFieldName: (noteInfo, ...preferredNames) => {
for (const preferredName of preferredNames) {
if (preferredName && preferredName in noteInfo.fields) return preferredName;
}
return null;
},
resolveNoteFieldName: (noteInfo, preferredName) =>
preferredName && preferredName in noteInfo.fields ? preferredName : null,
getAnimatedImageLeadInSeconds: async () => 0,
extractFields: (fields) =>
Object.fromEntries(
Object.entries(fields).map(([name, field]) => [name.toLowerCase(), field.value]),
),
processSentence: (sentence) => sentence,
setCardTypeFields: () => undefined,
mergeFieldValue: (existing, newValue, overwrite) => {
mergeCalls.push({ existing, newValue, overwrite });
return overwrite || !existing.trim() ? newValue : existing;
},
formatMiscInfoPattern: () => '',
getEffectiveSentenceCardConfig: () => ({
model: 'Sentence',
sentenceField: 'Sentence',
audioField: 'SentenceAudio',
lapisEnabled: false,
kikuEnabled: false,
kikuFieldGrouping: 'disabled',
kikuDeleteDuplicateInAuto: false,
}),
getFallbackDurationSeconds: () => 10,
appendKnownWordsFromNoteInfo: () => undefined,
isUpdateInProgress: () => false,
setUpdateInProgress: () => undefined,
trackLastAddedNoteId: () => undefined,
...overrides,
};
return {
service: new CardCreationService(deps),
updatedFields,
mergeCalls,
storedMedia,
};
}
test('manual clipboard subtitle update replaces expression and sentence audio even when overwriteAudio is disabled', async () => {
const { service, updatedFields, mergeCalls, storedMedia } = createManualUpdateService();
await service.updateLastAddedFromClipboard('字幕');
assert.equal(updatedFields.length, 1);
assert.equal(storedMedia.length, 1);
const audioValue = `[sound:${storedMedia[0]}]`;
assert.equal(updatedFields[0]?.ExpressionAudio, audioValue);
assert.equal(updatedFields[0]?.SentenceAudio, audioValue);
assert.deepEqual(
mergeCalls.map((call) => call.overwrite),
[true, true],
);
});

View File

@@ -219,6 +219,10 @@ export class CardCreationService {
this.deps.getConfig(),
);
const sentenceAudioField = this.getResolvedSentenceAudioFieldName(noteInfo);
const expressionAudioField = this.deps.resolveConfiguredFieldName(
noteInfo,
this.deps.getConfig().fields?.audio || 'ExpressionAudio',
);
const sentenceField = this.deps.getEffectiveSentenceCardConfig().sentenceField;
const sentence = blocks.join(' ');
@@ -248,13 +252,22 @@ export class CardCreationService {
if (audioBuffer) {
await this.deps.client.storeMediaFile(audioFilename, audioBuffer);
if (sentenceAudioField) {
const existingAudio = noteInfo.fields[sentenceAudioField]?.value || '';
updatedFields[sentenceAudioField] = this.deps.mergeFieldValue(
existingAudio,
`[sound:${audioFilename}]`,
this.deps.getConfig().behavior?.overwriteAudio !== false,
if (sentenceAudioField || expressionAudioField) {
const audioValue = `[sound:${audioFilename}]`;
const audioFields = new Set(
[sentenceAudioField, expressionAudioField].filter(
(fieldName): fieldName is string => Boolean(fieldName),
),
);
for (const audioField of audioFields) {
const existingAudio = noteInfo.fields[audioField]?.value || '';
// Manual clipboard updates intentionally replace old captured audio.
updatedFields[audioField] = this.deps.mergeFieldValue(
existingAudio,
audioValue,
true,
);
}
}
miscInfoFilename = audioFilename;
updatePerformed = true;

View File

@@ -79,6 +79,7 @@ test('parseArgs captures session action forwarding flags', () => {
'--open-jimaku',
'--open-youtube-picker',
'--open-playlist-browser',
'--toggle-primary-subtitle-bar',
'--replay-current-subtitle',
'--play-next-subtitle',
'--shift-sub-delay-prev-line',
@@ -94,6 +95,7 @@ test('parseArgs captures session action forwarding flags', () => {
assert.equal(args.openJimaku, true);
assert.equal(args.openYoutubePicker, true);
assert.equal(args.openPlaylistBrowser, true);
assert.equal(args.togglePrimarySubtitleBar, true);
assert.equal(args.replayCurrentSubtitle, true);
assert.equal(args.playNextSubtitle, true);
assert.equal(args.shiftSubDelayPrevLine, true);
@@ -212,6 +214,22 @@ test('hasExplicitCommand and shouldStartApp preserve command intent', () => {
assert.equal(hasExplicitCommand(anilistRetryQueue), true);
assert.equal(shouldStartApp(anilistRetryQueue), false);
const dictionaryCandidates = parseArgs([
'--dictionary-candidates',
'--dictionary-target',
'/tmp/a.mkv',
]);
assert.equal(dictionaryCandidates.dictionaryCandidates, true);
assert.equal(dictionaryCandidates.dictionaryTarget, '/tmp/a.mkv');
assert.equal(hasExplicitCommand(dictionaryCandidates), true);
assert.equal(shouldStartApp(dictionaryCandidates), true);
const dictionarySelect = parseArgs(['--dictionary-select', '--dictionary-anilist-id', '21355']);
assert.equal(dictionarySelect.dictionarySelect, true);
assert.equal(dictionarySelect.dictionaryAnilistId, 21355);
assert.equal(hasExplicitCommand(dictionarySelect), true);
assert.equal(shouldStartApp(dictionarySelect), true);
const toggleStatsOverlay = parseArgs(['--toggle-stats-overlay']);
assert.equal(toggleStatsOverlay.toggleStatsOverlay, true);
assert.equal(hasExplicitCommand(toggleStatsOverlay), true);
@@ -320,6 +338,12 @@ test('hasExplicitCommand and shouldStartApp preserve command intent', () => {
assert.equal(hasExplicitCommand(background), true);
assert.equal(shouldStartApp(background), true);
const managedPlayback = parseArgs(['--background', '--managed-playback']);
assert.equal(managedPlayback.background, true);
assert.equal(managedPlayback.managedPlayback, true);
assert.equal(hasExplicitCommand(managedPlayback), true);
assert.equal(shouldStartApp(managedPlayback), true);
const setup = parseArgs(['--setup']);
assert.equal((setup as typeof setup & { setup?: boolean }).setup, true);
assert.equal(hasExplicitCommand(setup), true);

View File

@@ -1,5 +1,6 @@
export interface CliArgs {
background: boolean;
managedPlayback: boolean;
start: boolean;
launchMpv: boolean;
launchMpvTargets: string[];
@@ -8,6 +9,7 @@ export interface CliArgs {
stop: boolean;
toggle: boolean;
toggleVisibleOverlay: boolean;
togglePrimarySubtitleBar: boolean;
settings: boolean;
setup: boolean;
show: boolean;
@@ -28,6 +30,7 @@ export interface CliArgs {
toggleSubtitleSidebar: boolean;
openRuntimeOptions: boolean;
openSessionHelp: boolean;
openCharacterDictionary: boolean;
openControllerSelect: boolean;
openControllerDebug: boolean;
openJimaku: boolean;
@@ -46,6 +49,9 @@ export interface CliArgs {
anilistSetup: boolean;
anilistRetryQueue: boolean;
dictionary: boolean;
dictionaryCandidates: boolean;
dictionarySelect: boolean;
dictionaryAnilistId?: number;
dictionaryTarget?: string;
stats: boolean;
statsBackground?: boolean;
@@ -94,6 +100,7 @@ export type CliCommandSource = 'initial' | 'second-instance';
export function parseArgs(argv: string[]): CliArgs {
const args: CliArgs = {
background: false,
managedPlayback: false,
start: false,
launchMpv: false,
launchMpvTargets: [],
@@ -102,6 +109,7 @@ export function parseArgs(argv: string[]): CliArgs {
stop: false,
toggle: false,
toggleVisibleOverlay: false,
togglePrimarySubtitleBar: false,
settings: false,
setup: false,
show: false,
@@ -122,6 +130,7 @@ export function parseArgs(argv: string[]): CliArgs {
toggleSubtitleSidebar: false,
openRuntimeOptions: false,
openSessionHelp: false,
openCharacterDictionary: false,
openControllerSelect: false,
openControllerDebug: false,
openJimaku: false,
@@ -136,6 +145,8 @@ export function parseArgs(argv: string[]): CliArgs {
anilistSetup: false,
anilistRetryQueue: false,
dictionary: false,
dictionaryCandidates: false,
dictionarySelect: false,
stats: false,
statsBackground: false,
statsStop: false,
@@ -192,6 +203,7 @@ export function parseArgs(argv: string[]): CliArgs {
if (!arg || !arg.startsWith('--')) continue;
if (arg === '--background') args.background = true;
else if (arg === '--managed-playback') args.managedPlayback = true;
else if (arg === '--start') args.start = true;
else if (arg.startsWith('--youtube-play=')) {
const value = arg.split('=', 2)[1];
@@ -212,6 +224,7 @@ export function parseArgs(argv: string[]): CliArgs {
} else if (arg === '--stop') args.stop = true;
else if (arg === '--toggle') args.toggle = true;
else if (arg === '--toggle-visible-overlay') args.toggleVisibleOverlay = true;
else if (arg === '--toggle-primary-subtitle-bar') args.togglePrimarySubtitleBar = true;
else if (arg === '--settings' || arg === '--yomitan') args.settings = true;
else if (arg === '--setup') args.setup = true;
else if (arg === '--show') args.show = true;
@@ -232,6 +245,7 @@ export function parseArgs(argv: string[]): CliArgs {
else if (arg === '--toggle-subtitle-sidebar') args.toggleSubtitleSidebar = true;
else if (arg === '--open-runtime-options') args.openRuntimeOptions = true;
else if (arg === '--open-session-help') args.openSessionHelp = true;
else if (arg === '--open-character-dictionary') args.openCharacterDictionary = true;
else if (arg === '--open-controller-select') args.openControllerSelect = true;
else if (arg === '--open-controller-debug') args.openControllerDebug = true;
else if (arg === '--open-jimaku') args.openJimaku = true;
@@ -270,7 +284,15 @@ export function parseArgs(argv: string[]): CliArgs {
else if (arg === '--anilist-setup') args.anilistSetup = true;
else if (arg === '--anilist-retry-queue') args.anilistRetryQueue = true;
else if (arg === '--dictionary') args.dictionary = true;
else if (arg.startsWith('--dictionary-target=')) {
else if (arg === '--dictionary-candidates') args.dictionaryCandidates = true;
else if (arg === '--dictionary-select') args.dictionarySelect = true;
else if (arg.startsWith('--dictionary-anilist-id=')) {
const value = Number(arg.split('=', 2)[1]);
if (Number.isInteger(value) && value > 0) args.dictionaryAnilistId = value;
} else if (arg === '--dictionary-anilist-id') {
const value = Number(readValue(argv[i + 1]));
if (Number.isInteger(value) && value > 0) args.dictionaryAnilistId = value;
} else if (arg.startsWith('--dictionary-target=')) {
const value = arg.split('=', 2)[1];
if (value) args.dictionaryTarget = value;
} else if (arg === '--dictionary-target') {
@@ -440,6 +462,7 @@ export function hasExplicitCommand(args: CliArgs): boolean {
args.stop ||
args.toggle ||
args.toggleVisibleOverlay ||
args.togglePrimarySubtitleBar ||
args.settings ||
args.setup ||
args.show ||
@@ -460,6 +483,7 @@ export function hasExplicitCommand(args: CliArgs): boolean {
args.toggleSubtitleSidebar ||
args.openRuntimeOptions ||
args.openSessionHelp ||
args.openCharacterDictionary ||
args.openControllerSelect ||
args.openControllerDebug ||
args.openJimaku ||
@@ -477,6 +501,8 @@ export function hasExplicitCommand(args: CliArgs): boolean {
args.anilistSetup ||
args.anilistRetryQueue ||
args.dictionary ||
args.dictionaryCandidates ||
args.dictionarySelect ||
args.stats ||
args.jellyfin ||
args.jellyfinLogin ||
@@ -507,6 +533,7 @@ export function isStandaloneTexthookerCommand(args: CliArgs): boolean {
!args.stop &&
!args.toggle &&
!args.toggleVisibleOverlay &&
!args.togglePrimarySubtitleBar &&
!args.settings &&
!args.setup &&
!args.show &&
@@ -527,6 +554,7 @@ export function isStandaloneTexthookerCommand(args: CliArgs): boolean {
!args.toggleSubtitleSidebar &&
!args.openRuntimeOptions &&
!args.openSessionHelp &&
!args.openCharacterDictionary &&
!args.openControllerSelect &&
!args.openControllerDebug &&
!args.openJimaku &&
@@ -544,6 +572,8 @@ export function isStandaloneTexthookerCommand(args: CliArgs): boolean {
!args.anilistSetup &&
!args.anilistRetryQueue &&
!args.dictionary &&
!args.dictionaryCandidates &&
!args.dictionarySelect &&
!args.stats &&
!args.jellyfin &&
!args.jellyfinLogin &&
@@ -569,6 +599,7 @@ export function shouldStartApp(args: CliArgs): boolean {
args.launchMpv ||
args.toggle ||
args.toggleVisibleOverlay ||
args.togglePrimarySubtitleBar ||
args.settings ||
args.setup ||
args.copySubtitle ||
@@ -585,6 +616,7 @@ export function shouldStartApp(args: CliArgs): boolean {
args.toggleSubtitleSidebar ||
args.openRuntimeOptions ||
args.openSessionHelp ||
args.openCharacterDictionary ||
args.openControllerSelect ||
args.openControllerDebug ||
args.openJimaku ||
@@ -598,6 +630,8 @@ export function shouldStartApp(args: CliArgs): boolean {
args.copySubtitleCount !== undefined ||
args.mineSentenceCount !== undefined ||
args.dictionary ||
args.dictionaryCandidates ||
args.dictionarySelect ||
args.stats ||
args.jellyfin ||
args.jellyfinPlay ||
@@ -619,6 +653,7 @@ export function shouldRunSettingsOnlyStartup(args: CliArgs): boolean {
!args.stop &&
!args.toggle &&
!args.toggleVisibleOverlay &&
!args.togglePrimarySubtitleBar &&
!args.show &&
!args.hide &&
!args.setup &&
@@ -638,6 +673,7 @@ export function shouldRunSettingsOnlyStartup(args: CliArgs): boolean {
!args.toggleSubtitleSidebar &&
!args.openRuntimeOptions &&
!args.openSessionHelp &&
!args.openCharacterDictionary &&
!args.openControllerSelect &&
!args.openControllerDebug &&
!args.openJimaku &&
@@ -655,6 +691,8 @@ export function shouldRunSettingsOnlyStartup(args: CliArgs): boolean {
!args.anilistSetup &&
!args.anilistRetryQueue &&
!args.dictionary &&
!args.dictionaryCandidates &&
!args.dictionarySelect &&
!args.stats &&
!args.jellyfin &&
!args.jellyfinLogin &&
@@ -679,6 +717,7 @@ export function commandNeedsOverlayRuntime(args: CliArgs): boolean {
return (
args.toggle ||
args.toggleVisibleOverlay ||
args.togglePrimarySubtitleBar ||
args.show ||
args.hide ||
args.showVisibleOverlay ||
@@ -696,6 +735,7 @@ export function commandNeedsOverlayRuntime(args: CliArgs): boolean {
args.markAudioCard ||
args.openRuntimeOptions ||
args.openSessionHelp ||
args.openCharacterDictionary ||
args.openControllerSelect ||
args.openControllerDebug ||
args.openJimaku ||

View File

@@ -19,6 +19,7 @@ ${B}Session${R}
${B}Overlay${R}
--toggle-visible-overlay Toggle subtitle overlay
--toggle-primary-subtitle-bar Toggle primary subtitle bar
--show-visible-overlay Show subtitle overlay
--hide-visible-overlay Hide subtitle overlay
--settings Open Yomitan settings window
@@ -38,6 +39,7 @@ ${B}Mining${R}
--toggle-subtitle-sidebar Toggle subtitle sidebar panel
--open-runtime-options Open runtime options palette
--open-session-help Open session help modal
--open-character-dictionary Open character dictionary anime selection modal
--open-controller-select Open controller select modal
--open-controller-debug Open controller debug modal
@@ -47,6 +49,9 @@ ${B}AniList${R}
--anilist-logout Clear stored AniList token
--anilist-retry-queue Retry next queued update
--dictionary Generate character dictionary ZIP for current anime
--dictionary-candidates Show character dictionary AniList candidates
--dictionary-select Save manual character dictionary AniList selection
--dictionary-anilist-id ${D}ID${R} AniList media ID for --dictionary-select
--dictionary-target ${D}PATH${R} Override dictionary source path (file or directory)
${B}Jellyfin${R}

View File

@@ -50,6 +50,8 @@ test('loads defaults when config is missing', () => {
assert.equal(config.startupWarmups.yomitanExtension, true);
assert.equal(config.startupWarmups.subtitleDictionaries, true);
assert.equal(config.startupWarmups.jellyfinRemoteSession, true);
assert.equal(config.shortcuts.markAudioCard, 'CommandOrControl+Shift+A');
assert.equal(config.shortcuts.openCharacterDictionary, 'CommandOrControl+Alt+A');
assert.equal(config.shortcuts.toggleSubtitleSidebar, 'Backslash');
assert.equal(config.discordPresence.enabled, true);
assert.equal(config.discordPresence.updateIntervalMs, 3_000);
@@ -435,6 +437,46 @@ test('parses subtitleStyle.hoverTokenBackgroundColor and warns on invalid values
);
});
test('parses subtitleStyle.hoverBackground as a hoverTokenBackgroundColor alias', () => {
const validDir = makeTempDir();
fs.writeFileSync(
path.join(validDir, 'config.jsonc'),
`{
"subtitleStyle": {
"hoverBackground": "transparent"
}
}`,
'utf-8',
);
const validService = new ConfigService(validDir);
assert.equal(validService.getConfig().subtitleStyle.hoverTokenBackgroundColor, 'transparent');
});
test('parses subtitleStyle.hoverTokenBackgroundColor null as invalid instead of missing', () => {
const invalidDir = makeTempDir();
fs.writeFileSync(
path.join(invalidDir, 'config.jsonc'),
`{
"subtitleStyle": {
"hoverTokenBackgroundColor": null
}
}`,
'utf-8',
);
const invalidService = new ConfigService(invalidDir);
assert.equal(
invalidService.getConfig().subtitleStyle.hoverTokenBackgroundColor,
DEFAULT_CONFIG.subtitleStyle.hoverTokenBackgroundColor,
);
assert.ok(
invalidService
.getWarnings()
.some((warning) => warning.path === 'subtitleStyle.hoverTokenBackgroundColor'),
);
});
test('parses subtitleStyle.nameMatchEnabled and warns on invalid values', () => {
const validDir = makeTempDir();
fs.writeFileSync(

View File

@@ -86,6 +86,7 @@ export const CORE_DEFAULT_CONFIG: Pick<
multiCopyTimeoutMs: 3000,
toggleSecondarySub: 'CommandOrControl+Shift+V',
markAudioCard: 'CommandOrControl+Shift+A',
openCharacterDictionary: 'CommandOrControl+Alt+A',
openRuntimeOptions: 'CommandOrControl+Shift+O',
openJimaku: 'Ctrl+Shift+J',
openSessionHelp: 'CommandOrControl+Shift+H',

View File

@@ -490,6 +490,7 @@ export function applyCoreDomainConfig(context: ResolveContext): void {
'mineSentenceMultiple',
'toggleSecondarySub',
'markAudioCard',
'openCharacterDictionary',
'openRuntimeOptions',
'openJimaku',
] as const;

View File

@@ -260,20 +260,23 @@ export function applySubtitleDomainConfig(context: ResolveContext): void {
);
}
const hoverTokenBackgroundColor = asString(
(src.subtitleStyle as { hoverTokenBackgroundColor?: unknown }).hoverTokenBackgroundColor,
);
const subtitleStyleSource = src.subtitleStyle as {
hoverBackground?: unknown;
hoverTokenBackgroundColor?: unknown;
};
const rawHoverTokenBackgroundColor =
subtitleStyleSource.hoverTokenBackgroundColor !== undefined
? subtitleStyleSource.hoverTokenBackgroundColor
: subtitleStyleSource.hoverBackground;
const hoverTokenBackgroundColor = asString(rawHoverTokenBackgroundColor);
if (hoverTokenBackgroundColor !== undefined) {
resolved.subtitleStyle.hoverTokenBackgroundColor = hoverTokenBackgroundColor;
} else if (
(src.subtitleStyle as { hoverTokenBackgroundColor?: unknown }).hoverTokenBackgroundColor !==
undefined
) {
} else if (rawHoverTokenBackgroundColor !== undefined) {
resolved.subtitleStyle.hoverTokenBackgroundColor =
fallbackSubtitleStyleHoverTokenBackgroundColor;
warn(
'subtitleStyle.hoverTokenBackgroundColor',
(src.subtitleStyle as { hoverTokenBackgroundColor?: unknown }).hoverTokenBackgroundColor,
rawHoverTokenBackgroundColor,
resolved.subtitleStyle.hoverTokenBackgroundColor,
'Expected a CSS color value (hex, rgba/hsl/hsla, named color, or var()).',
);

View File

@@ -76,6 +76,32 @@ test('guessAnilistMediaInfo joins multi-part guessit titles', async () => {
});
});
test('guessAnilistMediaInfo preserves useful guessit alternative title for ambiguous Re ZERO filenames', async () => {
const result = await guessAnilistMediaInfo(
'/tmp/Re - ZERO, Starting Life in Another World (2016) - S01E01 - - The End of the Beginning and the Beginning of the End [v2 Bluray-1080p Proper][10bit][x265][FLAC 2.0][EN+JA]-SCY.mkv',
null,
{
runGuessit: async () =>
JSON.stringify({
title: 'Re',
alternative_title: 'ZERO, Starting Life in Another World',
year: 2016,
season: 1,
episode: 1,
}),
},
);
assert.deepEqual(result, {
title: 'Re ZERO, Starting Life in Another World',
alternativeTitle: 'ZERO, Starting Life in Another World',
year: 2016,
season: 1,
episode: 1,
source: 'guessit',
});
});
test('updateAnilistPostWatchProgress updates progress when behind', async () => {
const originalFetch = globalThis.fetch;
let call = 0;

View File

@@ -7,6 +7,8 @@ const ANILIST_GRAPHQL_URL = 'https://graphql.anilist.co';
export interface AnilistMediaGuess {
title: string;
alternativeTitle?: string;
year?: number;
season: number | null;
episode: number | null;
source: 'guessit' | 'fallback';
@@ -131,6 +133,20 @@ function firstPositiveInteger(value: unknown): number | null {
return null;
}
function firstYear(value: unknown): number | undefined {
const candidate = firstPositiveInteger(value);
if (candidate === null) return undefined;
return candidate >= 1900 && candidate <= 2200 ? candidate : undefined;
}
function buildGuessitTitle(title: string, alternativeTitle: string | null): string {
if (!alternativeTitle) return title;
if (title.length <= 3) {
return `${title} ${alternativeTitle}`.replace(/\s+/g, ' ').trim();
}
return title;
}
function normalizeTitle(text: string): string {
return text.trim().toLowerCase().replace(/\s+/g, ' ');
}
@@ -215,10 +231,19 @@ export async function guessAnilistMediaInfo(
const stdout = await deps.runGuessit(guessitTarget);
const parsed = JSON.parse(stdout) as Record<string, unknown>;
const title = readGuessitTitle(parsed.title);
const alternativeTitle = readGuessitTitle(parsed.alternative_title);
const episode = firstPositiveInteger(parsed.episode);
const season = firstPositiveInteger(parsed.season);
const year = firstYear(parsed.year);
if (title) {
return { title, season, episode, source: 'guessit' };
return {
title: buildGuessitTitle(title, alternativeTitle),
...(alternativeTitle ? { alternativeTitle } : {}),
...(year ? { year } : {}),
season,
episode,
source: 'guessit',
};
}
} catch {
// Ignore guessit failures and fall back to internal parser.

View File

@@ -6,12 +6,14 @@ import { AppLifecycleServiceDeps, startAppLifecycle } from './app-lifecycle';
function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
return {
background: false,
managedPlayback: false,
start: false,
launchMpv: false,
launchMpvTargets: [],
stop: false,
toggle: false,
toggleVisibleOverlay: false,
togglePrimarySubtitleBar: false,
settings: false,
setup: false,
show: false,
@@ -37,6 +39,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
openJimaku: false,
openYoutubePicker: false,
openPlaylistBrowser: false,
openCharacterDictionary: false,
replayCurrentSubtitle: false,
playNextSubtitle: false,
shiftSubDelayPrevLine: false,
@@ -48,6 +51,9 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
anilistSetup: false,
anilistRetryQueue: false,
dictionary: false,
dictionaryCandidates: false,
dictionarySelect: false,
dictionaryAnilistId: undefined,
stats: false,
jellyfin: false,
jellyfinLogin: false,

View File

@@ -6,6 +6,7 @@ import { CliCommandServiceDeps, handleCliCommand } from './cli-command';
function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
return {
background: false,
managedPlayback: false,
start: false,
launchMpv: false,
launchMpvTargets: [],
@@ -34,11 +35,13 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
refreshKnownWords: false,
openRuntimeOptions: false,
openSessionHelp: false,
openCharacterDictionary: false,
openControllerSelect: false,
openControllerDebug: false,
openJimaku: false,
openYoutubePicker: false,
openPlaylistBrowser: false,
togglePrimarySubtitleBar: false,
replayCurrentSubtitle: false,
playNextSubtitle: false,
shiftSubDelayPrevLine: false,
@@ -50,6 +53,9 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
anilistSetup: false,
anilistRetryQueue: false,
dictionary: false,
dictionaryCandidates: false,
dictionarySelect: false,
dictionaryAnilistId: undefined,
stats: false,
jellyfin: false,
jellyfinLogin: false,
@@ -115,6 +121,9 @@ function createDeps(overrides: Partial<CliCommandServiceDeps> = {}) {
toggleVisibleOverlay: () => {
calls.push('toggleVisibleOverlay');
},
togglePrimarySubtitleBar: () => {
calls.push('togglePrimarySubtitleBar');
},
openYomitanSettingsDelayed: (delayMs) => {
calls.push(`openYomitanSettingsDelayed:${delayMs}`);
},
@@ -199,6 +208,19 @@ function createDeps(overrides: Partial<CliCommandServiceDeps> = {}) {
mediaTitle: 'Test',
entryCount: 10,
}),
getCharacterDictionarySelection: async () => ({
seriesKey: 'test',
guessTitle: 'Test',
current: { id: 1, title: 'Test', episodes: 12 },
override: null,
candidates: [{ id: 1, title: 'Test', episodes: 12 }],
}),
setCharacterDictionarySelection: async () => ({
ok: true,
seriesKey: 'test',
selected: { id: 1, title: 'Test', episodes: 12 },
staleMediaIds: [],
}),
runStatsCommand: async () => {
calls.push('runStatsCommand');
},
@@ -516,6 +538,7 @@ test('handleCliCommand handles visibility and utility command dispatches', () =>
expected: 'startPendingMineSentenceMultiple:2500',
},
{ args: { toggleSecondarySub: true }, expected: 'cycleSecondarySubMode' },
{ args: { togglePrimarySubtitleBar: true }, expected: 'togglePrimarySubtitleBar' },
{ args: { toggleStatsOverlay: true }, expected: 'dispatchSessionAction' },
{
args: { openRuntimeOptions: true },
@@ -624,6 +647,105 @@ test('handleCliCommand forwards --dictionary-target to dictionary runtime', asyn
assert.equal(receivedTarget, '/tmp/example-video.mkv');
});
test('handleCliCommand lists character dictionary AniList candidates', async () => {
const { calls, deps } = createDeps({
getCharacterDictionarySelection: async (targetPath?: string) => {
calls.push(`getCharacterDictionarySelection:${targetPath ?? ''}`);
return {
seriesKey: 're-zero-starting-life-in-another-world-2016',
guessTitle: 'Re ZERO, Starting Life in Another World',
current: { id: 10607, title: 'Rerere no Tensai Bakabon', episodes: null },
override: null,
candidates: [
{ id: 21355, title: 'Re:ZERO -Starting Life in Another World-', episodes: 25 },
{ id: 10607, title: 'Rerere no Tensai Bakabon', episodes: 24 },
],
};
},
});
handleCliCommand(
makeArgs({ dictionaryCandidates: true, dictionaryTarget: '/tmp/re-zero.mkv' }),
'initial',
deps,
);
await new Promise((resolve) => setImmediate(resolve));
assert.ok(calls.includes('getCharacterDictionarySelection:/tmp/re-zero.mkv'));
assert.ok(
calls.includes(
'log:Character dictionary series key: re-zero-starting-life-in-another-world-2016',
),
);
assert.ok(
calls.includes('log:Candidate: 21355 - Re:ZERO -Starting Life in Another World- (25 episodes)'),
);
});
test('handleCliCommand sets character dictionary manual AniList selection', async () => {
const { calls, deps } = createDeps({
setCharacterDictionarySelection: async (request) => {
calls.push(`setCharacterDictionarySelection:${request.mediaId}:${request.targetPath ?? ''}`);
return {
ok: true,
seriesKey: 're-zero-starting-life-in-another-world-2016',
selected: {
id: request.mediaId,
title: 'Re:ZERO -Starting Life in Another World-',
episodes: 25,
},
staleMediaIds: [10607],
};
},
});
handleCliCommand(
makeArgs({
dictionarySelect: true,
dictionaryAnilistId: 21355,
dictionaryTarget: '/tmp/re-zero.mkv',
}),
'initial',
deps,
);
await new Promise((resolve) => setImmediate(resolve));
assert.ok(calls.includes('setCharacterDictionarySelection:21355:/tmp/re-zero.mkv'));
assert.ok(
calls.includes(
'log:Character dictionary override saved: re-zero-starting-life-in-another-world-2016 -> 21355 - Re:ZERO -Starting Life in Another World-',
),
);
});
test('handleCliCommand does not log character dictionary selection success when result is not ok', async () => {
const { calls, deps } = createDeps({
setCharacterDictionarySelection: async () => ({
ok: false,
seriesKey: 'test',
selected: { id: 0, title: '', episodes: null },
staleMediaIds: [],
}),
});
handleCliCommand(
makeArgs({
dictionarySelect: true,
dictionaryAnilistId: 21355,
dictionaryTarget: '/tmp/re-zero.mkv',
}),
'initial',
deps,
);
await new Promise((resolve) => setImmediate(resolve));
assert.ok(calls.includes('warn:Character dictionary override was not saved.'));
assert.equal(
calls.some((call) => call.startsWith('log:Character dictionary override saved:')),
false,
);
});
test('handleCliCommand does not dispatch runJellyfinCommand for non-Jellyfin commands', () => {
const nonJellyfinArgs: Array<Partial<CliArgs>> = [
{ start: true },

View File

@@ -1,6 +1,27 @@
import { CliArgs, CliCommandSource, commandNeedsOverlayRuntime } from '../../cli/args';
import type { SessionActionDispatchRequest } from '../../types/runtime';
export type CharacterDictionaryCandidate = {
id: number;
title: string;
episodes: number | null;
};
export type CharacterDictionarySelectionSnapshot = {
seriesKey: string;
guessTitle: string | null;
current: CharacterDictionaryCandidate | null;
override: CharacterDictionaryCandidate | null;
candidates: CharacterDictionaryCandidate[];
};
export type CharacterDictionarySelectionResult = {
ok: boolean;
seriesKey: string;
selected: CharacterDictionaryCandidate;
staleMediaIds: number[];
};
export interface CliCommandServiceDeps {
setLogLevel?: (level: NonNullable<CliArgs['logLevel']>) => void;
getMpvSocketPath: () => string;
@@ -19,6 +40,7 @@ export interface CliCommandServiceDeps {
isOverlayRuntimeInitialized: () => boolean;
initializeOverlayRuntime: () => void;
toggleVisibleOverlay: () => void;
togglePrimarySubtitleBar: () => void;
openFirstRunSetup: () => void;
openYomitanSettingsDelayed: (delayMs: number) => void;
setVisibleOverlayVisible: (visible: boolean) => void;
@@ -64,6 +86,13 @@ export interface CliCommandServiceDeps {
mediaTitle: string;
entryCount: number;
}>;
getCharacterDictionarySelection: (
targetPath?: string,
) => Promise<CharacterDictionarySelectionSnapshot>;
setCharacterDictionarySelection: (request: {
targetPath?: string;
mediaId: number;
}) => Promise<CharacterDictionarySelectionResult>;
runStatsCommand: (args: CliArgs, source: CliCommandSource) => Promise<void>;
runJellyfinCommand: (args: CliArgs) => Promise<void>;
runYoutubePlaybackFlow: (request: {
@@ -110,6 +139,7 @@ interface OverlayCliRuntime {
isInitialized: () => boolean;
initialize: () => void;
toggleVisible: () => void;
togglePrimarySubtitleBar: () => void;
setVisible: (visible: boolean) => void;
}
@@ -162,6 +192,11 @@ export interface CliCommandDepsRuntimeOptions {
mediaTitle: string;
entryCount: number;
}>;
getSelection: (targetPath?: string) => Promise<CharacterDictionarySelectionSnapshot>;
setSelection: (request: {
targetPath?: string;
mediaId: number;
}) => Promise<CharacterDictionarySelectionResult>;
};
jellyfin: {
openSetup: () => void;
@@ -211,6 +246,7 @@ export function createCliCommandDepsRuntime(
isOverlayRuntimeInitialized: options.overlay.isInitialized,
initializeOverlayRuntime: options.overlay.initialize,
toggleVisibleOverlay: options.overlay.toggleVisible,
togglePrimarySubtitleBar: options.overlay.togglePrimarySubtitleBar,
openFirstRunSetup: options.ui.openFirstRunSetup,
openYomitanSettingsDelayed: (delayMs) => {
options.schedule(() => {
@@ -237,6 +273,8 @@ export function createCliCommandDepsRuntime(
getAnilistQueueStatus: options.anilist.getQueueStatus,
retryAnilistQueue: options.anilist.retryQueueNow,
generateCharacterDictionary: options.dictionary.generate,
getCharacterDictionarySelection: options.dictionary.getSelection,
setCharacterDictionarySelection: options.dictionary.setSelection,
runStatsCommand: options.jellyfin.runStatsCommand,
runJellyfinCommand: options.jellyfin.runCommand,
runYoutubePlaybackFlow: options.app.runYoutubePlaybackFlow,
@@ -267,6 +305,14 @@ function runAsyncWithOsd(
});
}
function formatCandidate(candidate: CharacterDictionaryCandidate): string {
const episodeLabel =
typeof candidate.episodes === 'number' && candidate.episodes > 0
? `${candidate.episodes} episodes`
: 'episodes unknown';
return `${candidate.id} - ${candidate.title} (${episodeLabel})`;
}
export function handleCliCommand(
args: CliArgs,
source: CliCommandSource = 'initial',
@@ -326,6 +372,8 @@ export function handleCliCommand(
if (args.toggle || args.toggleVisibleOverlay) {
deps.toggleVisibleOverlay();
} else if (args.togglePrimarySubtitleBar) {
deps.togglePrimarySubtitleBar();
} else if (args.setup) {
deps.openFirstRunSetup();
deps.log('Opened first-run setup flow.');
@@ -411,6 +459,12 @@ export function handleCliCommand(
'openSessionHelp',
'Open session help failed',
);
} else if (args.openCharacterDictionary) {
dispatchCliSessionAction(
{ actionId: 'openCharacterDictionary' },
'openCharacterDictionary',
'Open character dictionary failed',
);
} else if (args.openControllerSelect) {
dispatchCliSessionAction(
{ actionId: 'openControllerSelect' },
@@ -546,6 +600,75 @@ export function handleCliCommand(
deps.stopApp();
}
});
} else if (args.dictionaryCandidates) {
const shouldStopAfterRun = source === 'initial' && !deps.hasMainWindow();
deps
.getCharacterDictionarySelection(args.dictionaryTarget)
.then((selection) => {
deps.log(`Character dictionary series key: ${selection.seriesKey}`);
if (selection.guessTitle) {
deps.log(`Guess: ${selection.guessTitle}`);
}
if (selection.current) {
deps.log(`Current match: ${formatCandidate(selection.current)}`);
}
if (selection.override) {
deps.log(`Manual override: ${formatCandidate(selection.override)}`);
}
for (const candidate of selection.candidates) {
deps.log(`Candidate: ${formatCandidate(candidate)}`);
}
})
.catch((error) => {
deps.error('getCharacterDictionarySelection failed:', error);
deps.warn(
`Character dictionary candidate lookup failed: ${
error instanceof Error ? error.message : String(error)
}`,
);
})
.finally(() => {
if (shouldStopAfterRun) {
deps.stopApp();
}
});
} else if (args.dictionarySelect) {
const shouldStopAfterRun = source === 'initial' && !deps.hasMainWindow();
if (!args.dictionaryAnilistId) {
deps.warn('--dictionary-select requires --dictionary-anilist-id <ID>.');
if (shouldStopAfterRun) deps.stopApp();
return;
}
deps
.setCharacterDictionarySelection({
targetPath: args.dictionaryTarget,
mediaId: args.dictionaryAnilistId,
})
.then((result) => {
if (!result.ok) {
deps.warn('Character dictionary override was not saved.');
return;
}
deps.log(
`Character dictionary override saved: ${result.seriesKey} -> ${result.selected.id} - ${result.selected.title}`,
);
if (result.staleMediaIds.length > 0) {
deps.log(`Removed stale AniList IDs: ${result.staleMediaIds.join(', ')}`);
}
})
.catch((error) => {
deps.error('setCharacterDictionarySelection failed:', error);
deps.warn(
`Character dictionary override failed: ${
error instanceof Error ? error.message : String(error)
}`,
);
})
.finally(() => {
if (shouldStopAfterRun) {
deps.stopApp();
}
});
} else if (args.stats) {
void deps.runStatsCommand(args, source);
} else if (args.anilistRetryQueue) {

View File

@@ -1023,3 +1023,58 @@ test('registerIpcHandlers rejects malformed controller preference payloads', asy
await saveHandler!({}, { preferredGamepadId: 12 });
}, /Invalid controller preference payload/);
});
test('registerIpcHandlers exposes character dictionary selection handlers', async () => {
const { registrar, handlers } = createFakeIpcRegistrar();
const calls: number[] = [];
registerIpcHandlers(
createRegisterIpcDeps({
getCharacterDictionarySelection: async () => ({
seriesKey: 're-zero-starting-life-in-another-world-2016',
guessTitle: 'Re ZERO, Starting Life in Another World',
current: { id: 10607, title: 'Rerere no Tensai Bakabon', episodes: 24 },
override: null,
candidates: [
{ id: 21355, title: 'Re:ZERO -Starting Life in Another World-', episodes: 25 },
],
}),
setCharacterDictionarySelection: async (mediaId) => {
calls.push(mediaId);
return {
ok: true,
seriesKey: 're-zero-starting-life-in-another-world-2016',
selected: {
id: mediaId,
title: 'Re:ZERO -Starting Life in Another World-',
episodes: 25,
},
staleMediaIds: [10607],
};
},
}),
registrar,
);
const getHandler = handlers.handle.get(IPC_CHANNELS.request.getCharacterDictionarySelection);
const setHandler = handlers.handle.get(IPC_CHANNELS.request.setCharacterDictionarySelection);
assert.deepEqual(await getHandler!({}), {
seriesKey: 're-zero-starting-life-in-another-world-2016',
guessTitle: 'Re ZERO, Starting Life in Another World',
current: { id: 10607, title: 'Rerere no Tensai Bakabon', episodes: 24 },
override: null,
candidates: [{ id: 21355, title: 'Re:ZERO -Starting Life in Another World-', episodes: 25 }],
});
assert.deepEqual(await setHandler!({}, 0), {
ok: false,
message: 'Invalid AniList media ID.',
});
assert.deepEqual(await setHandler!({}, 21355), {
ok: true,
seriesKey: 're-zero-starting-life-in-another-world-2016',
selected: { id: 21355, title: 'Re:ZERO -Starting Life in Another World-', episodes: 25 },
staleMediaIds: [10607],
});
assert.deepEqual(calls, [21355]);
});

View File

@@ -90,6 +90,8 @@ export interface IpcServiceDeps {
openAnilistSetup: () => void;
getAnilistQueueStatus: () => unknown;
retryAnilistQueueNow: () => Promise<{ ok: boolean; message: string }>;
getCharacterDictionarySelection?: () => Promise<unknown>;
setCharacterDictionarySelection?: (mediaId: number) => Promise<unknown>;
appendClipboardVideoToQueue: () => { ok: boolean; message: string };
getPlaylistBrowserSnapshot: () => Promise<PlaylistBrowserSnapshot>;
appendPlaylistBrowserFile: (filePath: string) => Promise<PlaylistBrowserMutationResult>;
@@ -211,6 +213,8 @@ export interface IpcDepsRuntimeOptions {
openAnilistSetup: () => void;
getAnilistQueueStatus: () => unknown;
retryAnilistQueueNow: () => Promise<{ ok: boolean; message: string }>;
getCharacterDictionarySelection?: () => Promise<unknown>;
setCharacterDictionarySelection?: (mediaId: number) => Promise<unknown>;
appendClipboardVideoToQueue: () => { ok: boolean; message: string };
getPlaylistBrowserSnapshot: () => Promise<PlaylistBrowserSnapshot>;
appendPlaylistBrowserFile: (filePath: string) => Promise<PlaylistBrowserMutationResult>;
@@ -284,6 +288,23 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService
openAnilistSetup: options.openAnilistSetup,
getAnilistQueueStatus: options.getAnilistQueueStatus,
retryAnilistQueueNow: options.retryAnilistQueueNow,
getCharacterDictionarySelection:
options.getCharacterDictionarySelection ??
(async () => ({
seriesKey: '',
guessTitle: null,
current: null,
override: null,
candidates: [],
})),
setCharacterDictionarySelection:
options.setCharacterDictionarySelection ??
(async () => ({
ok: false,
seriesKey: '',
selected: { id: 0, title: '', episodes: null },
staleMediaIds: [],
})),
appendClipboardVideoToQueue: options.appendClipboardVideoToQueue,
getPlaylistBrowserSnapshot: options.getPlaylistBrowserSnapshot,
appendPlaylistBrowserFile: options.appendPlaylistBrowserFile,
@@ -570,6 +591,31 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
return await deps.retryAnilistQueueNow();
});
ipc.handle(IPC_CHANNELS.request.getCharacterDictionarySelection, async () => {
return await (deps.getCharacterDictionarySelection?.() ??
Promise.resolve({
seriesKey: '',
guessTitle: null,
current: null,
override: null,
candidates: [],
}));
});
ipc.handle(
IPC_CHANNELS.request.setCharacterDictionarySelection,
async (_event, mediaId: unknown) => {
if (!Number.isSafeInteger(mediaId) || (mediaId as number) <= 0) {
return { ok: false, message: 'Invalid AniList media ID.' };
}
return await (deps.setCharacterDictionarySelection?.(mediaId as number) ??
Promise.resolve({
ok: false,
message: 'Character dictionary selection unavailable.',
}));
},
);
ipc.handle(IPC_CHANNELS.request.appendClipboardVideoToQueue, () => {
return deps.appendClipboardVideoToQueue();
});

View File

@@ -19,6 +19,7 @@ function createDeps(overrides: Partial<MpvProtocolHandleMessageDeps> = {}): {
commands: unknown[];
mediaPath: string;
restored: number;
quitRequested: number;
};
} {
const state = {
@@ -28,6 +29,7 @@ function createDeps(overrides: Partial<MpvProtocolHandleMessageDeps> = {}): {
commands: [] as unknown[],
mediaPath: '',
restored: 0,
quitRequested: 0,
};
const metrics: MpvSubtitleRenderMetrics = {
subPos: 100,
@@ -102,6 +104,10 @@ function createDeps(overrides: Partial<MpvProtocolHandleMessageDeps> = {}): {
restorePreviousSecondarySubVisibility: () => {
state.restored += 1;
},
shouldQuitOnMpvShutdown: () => false,
requestAppQuit: () => {
state.quitRequested += 1;
},
setPreviousSecondarySubVisibility: () => {
// intentionally not tracked in this unit test
},
@@ -223,6 +229,18 @@ test('dispatchMpvProtocolMessage restores secondary visibility on shutdown', asy
await dispatchMpvProtocolMessage({ event: 'shutdown' }, deps);
assert.equal(state.restored, 1);
assert.equal(state.quitRequested, 0);
});
test('dispatchMpvProtocolMessage quits app on managed playback shutdown', async () => {
const { deps, state } = createDeps({
shouldQuitOnMpvShutdown: () => true,
});
await dispatchMpvProtocolMessage({ event: 'shutdown' }, deps);
assert.equal(state.restored, 1);
assert.equal(state.quitRequested, 1);
});
test('dispatchMpvProtocolMessage pauses on sub-end when pendingPauseAtSubEnd is set', async () => {

View File

@@ -91,6 +91,8 @@ export interface MpvProtocolHandleMessageDeps {
) => void;
sendCommand: (payload: { command: unknown[]; request_id?: number }) => boolean;
restorePreviousSecondarySubVisibility: () => void;
shouldQuitOnMpvShutdown: () => boolean;
requestAppQuit: () => void;
}
type SubtitleTrackCandidate = {
@@ -360,6 +362,9 @@ export async function dispatchMpvProtocolMessage(
}
} else if (msg.event === 'shutdown') {
deps.restorePreviousSecondarySubVisibility();
if (deps.shouldQuitOnMpvShutdown()) {
deps.requestAppQuit();
}
} else if (msg.request_id) {
if (deps.resolvePendingRequest(msg.request_id, msg)) {
return;

View File

@@ -285,6 +285,25 @@ test('MpvIpcClient onClose resolves outstanding requests and schedules reconnect
assert.equal(timers.length, 1);
});
test('MpvIpcClient onClose requests app quit for managed playback', () => {
let quitRequests = 0;
const client = new MpvIpcClient(
'/tmp/mpv.sock',
makeDeps({
shouldQuitOnMpvShutdown: () => true,
requestAppQuit: () => {
quitRequests += 1;
},
}),
);
(client as any).scheduleReconnect = () => {};
(client as any).transport.callbacks.onClose();
assert.equal(quitRequests, 1);
});
test('MpvIpcClient reconnect replays property subscriptions and initial state requests', () => {
const commands: unknown[] = [];
const client = new MpvIpcClient('/tmp/mpv.sock', makeDeps());

View File

@@ -105,6 +105,8 @@ export interface MpvIpcClientProtocolDeps {
isVisibleOverlayVisible: () => boolean;
getReconnectTimer: () => ReturnType<typeof setTimeout> | null;
setReconnectTimer: (timer: ReturnType<typeof setTimeout> | null) => void;
shouldQuitOnMpvShutdown?: () => boolean;
requestAppQuit?: () => void;
}
export interface MpvIpcClientDeps extends MpvIpcClientProtocolDeps {}
@@ -217,6 +219,10 @@ export class MpvIpcClient implements MpvClient {
this.playbackPaused = null;
this.emit('connection-change', { connected: false });
this.failPendingRequests();
if (this.deps.shouldQuitOnMpvShutdown?.() === true) {
this.deps.requestAppQuit?.();
return;
}
this.scheduleReconnect();
},
});
@@ -399,6 +405,8 @@ export class MpvIpcClient implements MpvClient {
restorePreviousSecondarySubVisibility: () => {
this.restorePreviousSecondarySubVisibility();
},
shouldQuitOnMpvShutdown: () => this.deps.shouldQuitOnMpvShutdown?.() ?? false,
requestAppQuit: () => this.deps.requestAppQuit?.(),
};
}

View File

@@ -25,6 +25,7 @@ function makeShortcuts(overrides: Partial<ConfiguredShortcuts> = {}): Configured
multiCopyTimeoutMs: 2500,
toggleSecondarySub: null,
markAudioCard: null,
openCharacterDictionary: null,
openRuntimeOptions: null,
openJimaku: null,
openSessionHelp: null,
@@ -45,6 +46,9 @@ function createDeps(overrides: Partial<OverlayShortcutRuntimeDeps> = {}) {
openRuntimeOptions: () => {
calls.push('openRuntimeOptions');
},
openCharacterDictionary: () => {
calls.push('openCharacterDictionary');
},
openJimaku: () => {
calls.push('openJimaku');
},
@@ -135,12 +139,11 @@ test('createOverlayShortcutRuntimeHandlers reports async failures via OSD', asyn
}
});
test('runOverlayShortcutLocalFallback dispatches matching actions with timeout', () => {
test('runOverlayShortcutLocalFallback dispatches matching single-step actions', () => {
const handled: string[] = [];
const matched: Array<{ accelerator: string; allowWhenRegistered: boolean }> = [];
const shortcuts = makeShortcuts({
copySubtitleMultiple: 'Ctrl+M',
multiCopyTimeoutMs: 4321,
copySubtitle: 'Ctrl+M',
});
const result = runOverlayShortcutLocalFallback(
@@ -155,6 +158,7 @@ test('runOverlayShortcutLocalFallback dispatches matching actions with timeout',
},
{
openRuntimeOptions: () => handled.push('openRuntimeOptions'),
openCharacterDictionary: () => handled.push('openCharacterDictionary'),
openJimaku: () => handled.push('openJimaku'),
markAudioCard: () => handled.push('markAudioCard'),
copySubtitleMultiple: (timeoutMs) => handled.push(`copySubtitleMultiple:${timeoutMs}`),
@@ -169,10 +173,63 @@ test('runOverlayShortcutLocalFallback dispatches matching actions with timeout',
);
assert.equal(result, true);
assert.deepEqual(handled, ['copySubtitleMultiple:4321']);
assert.deepEqual(handled, ['copySubtitle']);
assert.deepEqual(matched, [{ accelerator: 'Ctrl+M', allowWhenRegistered: false }]);
});
test('runOverlayShortcutLocalFallback leaves multi-step numeric shortcuts for renderer handling', () => {
const handled: string[] = [];
const shortcuts = makeShortcuts({
copySubtitleMultiple: 'Ctrl+M',
mineSentenceMultiple: 'Ctrl+N',
multiCopyTimeoutMs: 4321,
});
const copyResult = runOverlayShortcutLocalFallback(
{} as Electron.Input,
shortcuts,
(_input, accelerator) => accelerator === 'Ctrl+M',
{
openRuntimeOptions: () => handled.push('openRuntimeOptions'),
openCharacterDictionary: () => handled.push('openCharacterDictionary'),
openJimaku: () => handled.push('openJimaku'),
markAudioCard: () => handled.push('markAudioCard'),
copySubtitleMultiple: (timeoutMs) => handled.push(`copySubtitleMultiple:${timeoutMs}`),
copySubtitle: () => handled.push('copySubtitle'),
toggleSecondarySub: () => handled.push('toggleSecondarySub'),
updateLastCardFromClipboard: () => handled.push('updateLastCardFromClipboard'),
triggerFieldGrouping: () => handled.push('triggerFieldGrouping'),
triggerSubsync: () => handled.push('triggerSubsync'),
mineSentence: () => handled.push('mineSentence'),
mineSentenceMultiple: (timeoutMs) => handled.push(`mineSentenceMultiple:${timeoutMs}`),
},
);
const mineResult = runOverlayShortcutLocalFallback(
{} as Electron.Input,
shortcuts,
(_input, accelerator) => accelerator === 'Ctrl+N',
{
openRuntimeOptions: () => handled.push('openRuntimeOptions'),
openCharacterDictionary: () => handled.push('openCharacterDictionary'),
openJimaku: () => handled.push('openJimaku'),
markAudioCard: () => handled.push('markAudioCard'),
copySubtitleMultiple: (timeoutMs) => handled.push(`copySubtitleMultiple:${timeoutMs}`),
copySubtitle: () => handled.push('copySubtitle'),
toggleSecondarySub: () => handled.push('toggleSecondarySub'),
updateLastCardFromClipboard: () => handled.push('updateLastCardFromClipboard'),
triggerFieldGrouping: () => handled.push('triggerFieldGrouping'),
triggerSubsync: () => handled.push('triggerSubsync'),
mineSentence: () => handled.push('mineSentence'),
mineSentenceMultiple: (timeoutMs) => handled.push(`mineSentenceMultiple:${timeoutMs}`),
},
);
assert.equal(copyResult, false);
assert.equal(mineResult, false);
assert.deepEqual(handled, []);
});
test('runOverlayShortcutLocalFallback passes allowWhenRegistered for secondary-sub toggle', () => {
const matched: Array<{ accelerator: string; allowWhenRegistered: boolean }> = [];
const shortcuts = makeShortcuts({
@@ -191,6 +248,7 @@ test('runOverlayShortcutLocalFallback passes allowWhenRegistered for secondary-s
},
{
openRuntimeOptions: () => {},
openCharacterDictionary: () => {},
openJimaku: () => {},
markAudioCard: () => {},
copySubtitleMultiple: () => {},
@@ -226,6 +284,7 @@ test('runOverlayShortcutLocalFallback allows registered-global jimaku shortcut',
},
{
openRuntimeOptions: () => {},
openCharacterDictionary: () => {},
openJimaku: () => {},
markAudioCard: () => {},
copySubtitleMultiple: () => {},
@@ -253,6 +312,9 @@ test('runOverlayShortcutLocalFallback returns false when no action matches', ()
openRuntimeOptions: () => {
called = true;
},
openCharacterDictionary: () => {
called = true;
},
openJimaku: () => {
called = true;
},
@@ -335,6 +397,7 @@ test('registerOverlayShortcutsRuntime reports active shortcuts when configured',
mineSentenceMultiple: () => {},
toggleSecondarySub: () => {},
markAudioCard: () => {},
openCharacterDictionary: () => {},
openRuntimeOptions: () => {},
openJimaku: () => {},
}),
@@ -361,6 +424,7 @@ test('unregisterOverlayShortcutsRuntime clears pending shortcut work when active
mineSentenceMultiple: () => {},
toggleSecondarySub: () => {},
markAudioCard: () => {},
openCharacterDictionary: () => {},
openRuntimeOptions: () => {},
openJimaku: () => {},
}),

View File

@@ -6,6 +6,7 @@ const logger = createLogger('main:overlay-shortcut-handler');
export interface OverlayShortcutFallbackHandlers {
openRuntimeOptions: () => void;
openCharacterDictionary: () => void;
openJimaku: () => void;
markAudioCard: () => void;
copySubtitleMultiple: (timeoutMs: number) => void;
@@ -21,6 +22,7 @@ export interface OverlayShortcutFallbackHandlers {
export interface OverlayShortcutRuntimeDeps {
showMpvOsd: (text: string) => void;
openRuntimeOptions: () => void;
openCharacterDictionary: () => void;
openJimaku: () => void;
markAudioCard: () => Promise<void>;
copySubtitleMultiple: (timeoutMs: number) => void;
@@ -95,6 +97,9 @@ export function createOverlayShortcutRuntimeHandlers(deps: OverlayShortcutRuntim
openRuntimeOptions: () => {
deps.openRuntimeOptions();
},
openCharacterDictionary: () => {
deps.openCharacterDictionary();
},
openJimaku: () => {
deps.openJimaku();
},
@@ -102,6 +107,7 @@ export function createOverlayShortcutRuntimeHandlers(deps: OverlayShortcutRuntim
const fallbackHandlers: OverlayShortcutFallbackHandlers = {
openRuntimeOptions: overlayHandlers.openRuntimeOptions,
openCharacterDictionary: overlayHandlers.openCharacterDictionary,
openJimaku: overlayHandlers.openJimaku,
markAudioCard: overlayHandlers.markAudioCard,
copySubtitleMultiple: overlayHandlers.copySubtitleMultiple,
@@ -134,6 +140,12 @@ export function runOverlayShortcutLocalFallback(
handlers.openRuntimeOptions();
},
},
{
accelerator: shortcuts.openCharacterDictionary,
run: () => {
handlers.openCharacterDictionary();
},
},
{
accelerator: shortcuts.openJimaku,
run: () => {
@@ -147,12 +159,6 @@ export function runOverlayShortcutLocalFallback(
handlers.markAudioCard();
},
},
{
accelerator: shortcuts.copySubtitleMultiple,
run: () => {
handlers.copySubtitleMultiple(shortcuts.multiCopyTimeoutMs);
},
},
{
accelerator: shortcuts.copySubtitle,
run: () => {
@@ -188,12 +194,6 @@ export function runOverlayShortcutLocalFallback(
handlers.mineSentence();
},
},
{
accelerator: shortcuts.mineSentenceMultiple,
run: () => {
handlers.mineSentenceMultiple(shortcuts.multiCopyTimeoutMs);
},
},
];
for (const action of actions) {

View File

@@ -20,6 +20,7 @@ function createShortcuts(overrides: Partial<ConfiguredShortcuts> = {}): Configur
multiCopyTimeoutMs: 2500,
toggleSecondarySub: null,
markAudioCard: null,
openCharacterDictionary: null,
openRuntimeOptions: null,
openJimaku: null,
openSessionHelp: null,
@@ -42,6 +43,7 @@ test('registerOverlayShortcuts reports active overlay shortcuts when configured'
mineSentenceMultiple: () => {},
toggleSecondarySub: () => {},
markAudioCard: () => {},
openCharacterDictionary: () => {},
openRuntimeOptions: () => {},
openJimaku: () => {},
}),
@@ -61,6 +63,7 @@ test('registerOverlayShortcuts stays inactive when overlay shortcuts are absent'
mineSentenceMultiple: () => {},
toggleSecondarySub: () => {},
markAudioCard: () => {},
openCharacterDictionary: () => {},
openRuntimeOptions: () => {},
openJimaku: () => {},
}),
@@ -82,6 +85,7 @@ test('syncOverlayShortcutsRuntime deactivates cleanly when shortcuts were active
mineSentenceMultiple: () => {},
toggleSecondarySub: () => {},
markAudioCard: () => {},
openCharacterDictionary: () => {},
openRuntimeOptions: () => {},
openJimaku: () => {},
}),

View File

@@ -10,6 +10,7 @@ export interface OverlayShortcutHandlers {
mineSentenceMultiple: (timeoutMs: number) => void;
toggleSecondarySub: () => void;
markAudioCard: () => void;
openCharacterDictionary: () => void;
openRuntimeOptions: () => void;
openJimaku: () => void;
}
@@ -31,6 +32,7 @@ const OVERLAY_SHORTCUT_KEYS: Array<keyof Omit<ConfiguredShortcuts, 'multiCopyTim
'mineSentenceMultiple',
'toggleSecondarySub',
'markAudioCard',
'openCharacterDictionary',
'openRuntimeOptions',
'openJimaku',
];

View File

@@ -17,6 +17,7 @@ export interface SessionActionExecutorDeps {
markLastCardAsAudioCard: () => Promise<void>;
openRuntimeOptionsPalette: () => void;
openSessionHelp: () => void;
openCharacterDictionary: () => void;
openControllerSelect: () => void;
openControllerDebug: () => void;
openJimaku: () => void;
@@ -85,6 +86,9 @@ export async function dispatchSessionAction(
case 'openSessionHelp':
deps.openSessionHelp();
return;
case 'openCharacterDictionary':
deps.openCharacterDictionary();
return;
case 'openControllerSelect':
deps.openControllerSelect();
return;

View File

@@ -18,6 +18,7 @@ function createShortcuts(overrides: Partial<ConfiguredShortcuts> = {}): Configur
multiCopyTimeoutMs: 2500,
toggleSecondarySub: null,
markAudioCard: null,
openCharacterDictionary: null,
openRuntimeOptions: null,
openJimaku: null,
openSessionHelp: null,

View File

@@ -43,6 +43,7 @@ const SESSION_SHORTCUT_ACTIONS: Array<{
{ key: 'mineSentenceMultiple', actionId: 'mineSentenceMultiple' },
{ key: 'toggleSecondarySub', actionId: 'toggleSecondarySub' },
{ key: 'markAudioCard', actionId: 'markAudioCard' },
{ key: 'openCharacterDictionary', actionId: 'openCharacterDictionary' },
{ key: 'openRuntimeOptions', actionId: 'openRuntimeOptions' },
{ key: 'openJimaku', actionId: 'openJimaku' },
{ key: 'openSessionHelp', actionId: 'openSessionHelp' },

View File

@@ -6,12 +6,14 @@ import { CliArgs } from '../../cli/args';
function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
return {
background: false,
managedPlayback: false,
start: false,
launchMpv: false,
launchMpvTargets: [],
stop: false,
toggle: false,
toggleVisibleOverlay: false,
togglePrimarySubtitleBar: false,
settings: false,
setup: false,
show: false,
@@ -37,6 +39,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
openJimaku: false,
openYoutubePicker: false,
openPlaylistBrowser: false,
openCharacterDictionary: false,
replayCurrentSubtitle: false,
playNextSubtitle: false,
shiftSubDelayPrevLine: false,
@@ -48,6 +51,9 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
anilistSetup: false,
anilistRetryQueue: false,
dictionary: false,
dictionaryCandidates: false,
dictionarySelect: false,
dictionaryAnilistId: undefined,
stats: false,
jellyfin: false,
jellyfinLogin: false,

View File

@@ -4069,6 +4069,225 @@ test('tokenizeSubtitle clears all annotations for explanatory contrast endings',
);
});
test('tokenizeSubtitle clears annotations for ことに while preserving lexical N+1 target', async () => {
const result = await tokenizeSubtitle(
'さっきの俺と違うことに気付かないのかい?',
makeDepsFromYomitanTokens(
[
{ surface: 'さっき', reading: 'さっき', headword: 'さっき' },
{ surface: 'の', reading: 'の', headword: 'の' },
{ surface: '俺', reading: 'おれ', headword: '俺' },
{ surface: 'と', reading: 'と', headword: 'と' },
{ surface: '違う', reading: 'ちがう', headword: '違う' },
{ surface: 'ことに', reading: 'ことに', headword: '事' },
{ surface: '気付かない', reading: 'きづかない', headword: '気付く' },
{ surface: 'の', reading: 'の', headword: 'の' },
{ surface: 'かい', reading: 'かい', headword: 'かい' },
{ surface: '', reading: '', headword: '' },
],
{
getFrequencyDictionaryEnabled: () => true,
getFrequencyRank: (text) =>
text === '違う' ? 900 : text === '事' ? 81 : text === '気付く' ? 1500 : null,
getJlptLevel: (text) =>
text === '違う' ? 'N4' : text === '事' ? 'N4' : text === '気付く' ? 'N3' : null,
isKnownWord: (text) => ['さっき', 'の', '俺', 'と', '気付く', 'かい', ''].includes(text),
getMinSentenceWordsForNPlusOne: () => 1,
tokenizeWithMecab: async () => [
{
headword: 'さっき',
surface: 'さっき',
reading: 'サッキ',
startPos: 0,
endPos: 3,
partOfSpeech: PartOfSpeech.noun,
pos1: '名詞',
pos2: '副詞可能',
isMerged: false,
isKnown: false,
isNPlusOneTarget: false,
},
{
headword: 'の',
surface: 'の',
reading: '',
startPos: 3,
endPos: 4,
partOfSpeech: PartOfSpeech.particle,
pos1: '助詞',
pos2: '連体化',
isMerged: false,
isKnown: false,
isNPlusOneTarget: false,
},
{
headword: '俺',
surface: '俺',
reading: 'オレ',
startPos: 4,
endPos: 5,
partOfSpeech: PartOfSpeech.noun,
pos1: '名詞',
pos2: '代名詞',
isMerged: false,
isKnown: false,
isNPlusOneTarget: false,
},
{
headword: 'と',
surface: 'と',
reading: 'ト',
startPos: 5,
endPos: 6,
partOfSpeech: PartOfSpeech.particle,
pos1: '助詞',
pos2: '格助詞',
isMerged: false,
isKnown: false,
isNPlusOneTarget: false,
},
{
headword: '違う',
surface: '違う',
reading: 'チガウ',
startPos: 6,
endPos: 8,
partOfSpeech: PartOfSpeech.verb,
pos1: '動詞',
pos2: '自立',
isMerged: false,
isKnown: false,
isNPlusOneTarget: false,
},
{
headword: '事',
surface: 'こと',
reading: 'コト',
startPos: 8,
endPos: 10,
partOfSpeech: PartOfSpeech.noun,
pos1: '名詞',
pos2: '非自立',
isMerged: false,
isKnown: false,
isNPlusOneTarget: false,
},
{
headword: 'に',
surface: 'に',
reading: 'ニ',
startPos: 10,
endPos: 11,
partOfSpeech: PartOfSpeech.particle,
pos1: '助詞',
pos2: '格助詞',
isMerged: false,
isKnown: false,
isNPlusOneTarget: false,
},
{
headword: '気付く',
surface: '気付か',
reading: 'キヅカ',
startPos: 11,
endPos: 14,
partOfSpeech: PartOfSpeech.verb,
pos1: '動詞',
pos2: '自立',
isMerged: false,
isKnown: false,
isNPlusOneTarget: false,
},
{
headword: 'ない',
surface: 'ない',
reading: 'ナイ',
startPos: 14,
endPos: 16,
partOfSpeech: PartOfSpeech.bound_auxiliary,
pos1: '助動詞',
pos2: '*',
isMerged: false,
isKnown: false,
isNPlusOneTarget: false,
},
{
headword: 'の',
surface: 'の',
reading: '',
startPos: 16,
endPos: 17,
partOfSpeech: PartOfSpeech.particle,
pos1: '助詞',
pos2: '終助詞',
isMerged: false,
isKnown: false,
isNPlusOneTarget: false,
},
{
headword: 'かい',
surface: 'かい',
reading: 'カイ',
startPos: 17,
endPos: 19,
partOfSpeech: PartOfSpeech.particle,
pos1: '助詞',
pos2: '終助詞',
isMerged: false,
isKnown: false,
isNPlusOneTarget: false,
},
{
headword: '',
surface: '',
reading: '',
startPos: 19,
endPos: 20,
partOfSpeech: PartOfSpeech.symbol,
pos1: '記号',
pos2: '一般',
isMerged: false,
isKnown: false,
isNPlusOneTarget: false,
},
],
},
),
);
const tokenSummary = result.tokens?.map((token) => ({
surface: token.surface,
headword: token.headword,
isKnown: token.isKnown,
isNPlusOneTarget: token.isNPlusOneTarget,
frequencyRank: token.frequencyRank,
jlptLevel: token.jlptLevel,
}));
assert.deepEqual(
tokenSummary?.find((token) => token.surface === 'ことに'),
{
surface: 'ことに',
headword: '事',
isKnown: false,
isNPlusOneTarget: false,
frequencyRank: undefined,
jlptLevel: undefined,
},
);
assert.deepEqual(
tokenSummary?.find((token) => token.surface === '違う'),
{
surface: '違う',
headword: '違う',
isKnown: false,
isNPlusOneTarget: true,
frequencyRank: 900,
jlptLevel: 'N4',
},
);
});
test('tokenizeSubtitle excludes default non-independent pos2 from N+1 when JLPT/frequency are disabled', async () => {
let mecabCalls = 0;
const result = await tokenizeSubtitle(

View File

@@ -353,6 +353,19 @@ test('shouldExcludeTokenFromSubtitleAnnotations excludes kana-only demonstrative
assert.equal(shouldExcludeTokenFromSubtitleAnnotations(token), true);
});
test('shouldExcludeTokenFromSubtitleAnnotations excludes kana-only non-independent noun helper merges', () => {
const token = makeToken({
surface: 'ことに',
headword: '事',
reading: 'コトニ',
partOfSpeech: PartOfSpeech.noun,
pos1: '名詞|助詞',
pos2: '非自立|格助詞',
});
assert.equal(shouldExcludeTokenFromSubtitleAnnotations(token), true);
});
test('stripSubtitleAnnotationMetadata keeps token hover data while clearing annotation fields', () => {
const token = makeToken({
surface: 'は',
@@ -812,3 +825,69 @@ test('annotateTokens applies one shared exclusion gate across known N+1 frequenc
assert.equal(result[0]?.frequencyRank, undefined);
assert.equal(result[0]?.jlptLevel, undefined);
});
test('annotateTokens clears all annotations for kana-only non-independent noun helper merges', () => {
const tokens = [
makeToken({
surface: 'ことに',
headword: '事',
reading: 'コトニ',
partOfSpeech: PartOfSpeech.noun,
pos1: '名詞|助詞',
pos2: '非自立|格助詞',
startPos: 0,
endPos: 3,
frequencyRank: 81,
}),
];
const result = annotateTokens(
tokens,
makeDeps({
isKnownWord: (text) => text === '事',
getJlptLevel: (text) => (text === '事' ? 'N4' : null),
}),
{ minSentenceWordsForNPlusOne: 1 },
);
assert.equal(result[0]?.isKnown, false);
assert.equal(result[0]?.isNPlusOneTarget, false);
assert.equal(result[0]?.frequencyRank, undefined);
assert.equal(result[0]?.jlptLevel, undefined);
});
test('annotateTokens clears all annotations from standalone あ interjections without POS tags', () => {
const tokens = [
makeToken({
surface: 'あ',
headword: 'あ',
reading: 'あ',
partOfSpeech: PartOfSpeech.other,
pos1: '',
pos2: '',
startPos: 0,
endPos: 1,
isKnown: true,
isNPlusOneTarget: true,
frequencyRank: 522,
jlptLevel: 'N5',
}),
];
const result = annotateTokens(
tokens,
makeDeps({
isKnownWord: (text) => text === 'あ',
getJlptLevel: (text) => (text === 'あ' ? 'N5' : null),
}),
{ minSentenceWordsForNPlusOne: 1 },
);
assert.equal(result[0]?.surface, 'あ');
assert.equal(result[0]?.headword, 'あ');
assert.equal(result[0]?.reading, 'あ');
assert.equal(result[0]?.isKnown, false);
assert.equal(result[0]?.isNPlusOneTarget, false);
assert.equal(result[0]?.frequencyRank, undefined);
assert.equal(result[0]?.jlptLevel, undefined);
});

View File

@@ -14,6 +14,7 @@ const KATAKANA_CODEPOINT_START = 0x30a1;
const KATAKANA_CODEPOINT_END = 0x30f6;
const SUBTITLE_ANNOTATION_EXCLUDED_TERMS = new Set([
'あ',
'ああ',
'ええ',
'うう',
@@ -70,6 +71,7 @@ const SUBTITLE_ANNOTATION_EXCLUDED_TRAILING_PARTICLE_SUFFIXES = new Set([
'ってば',
]);
const AUXILIARY_STEM_GRAMMAR_TAIL_POS1 = new Set(['名詞', '助動詞', '助詞']);
const NON_INDEPENDENT_NOUN_HELPER_TAIL_POS1 = new Set(['助詞', '助動詞']);
export interface SubtitleAnnotationFilterOptions {
pos1Exclusions?: ReadonlySet<string>;
@@ -251,6 +253,31 @@ function isAuxiliaryStemGrammarTailToken(token: MergedToken): boolean {
return pos3Parts.includes('助動詞語幹');
}
function isKanaOnlyNonIndependentNounHelperMerge(token: MergedToken): boolean {
const normalizedSurface = normalizeKana(token.surface);
const normalizedHeadword = normalizeKana(token.headword);
if (
!normalizedSurface ||
!normalizedHeadword ||
normalizedSurface === normalizedHeadword ||
![...normalizedSurface].every(isKanaChar)
) {
return false;
}
const pos1Parts = splitNormalizedTagParts(normalizePosTag(token.pos1));
if (pos1Parts.length < 2 || pos1Parts[0] !== '名詞') {
return false;
}
const pos2Parts = splitNormalizedTagParts(normalizePosTag(token.pos2));
if (pos2Parts[0] !== '非自立') {
return false;
}
return pos1Parts.slice(1).every((part) => NON_INDEPENDENT_NOUN_HELPER_TAIL_POS1.has(part));
}
function isExcludedByTerm(token: MergedToken): boolean {
const candidates = [token.surface, token.reading, token.headword].filter(
(candidate): candidate is string => typeof candidate === 'string' && candidate.length > 0,
@@ -334,6 +361,10 @@ export function shouldExcludeTokenFromSubtitleAnnotations(
return true;
}
if (isKanaOnlyNonIndependentNounHelperMerge(token)) {
return true;
}
if (isExcludedTrailingParticleMergedToken(token)) {
return true;
}

View File

@@ -66,6 +66,7 @@ test('normalizes fallback shortcuts when AnkiConnect flag is unset', () => {
shortcuts: {
mineSentence: 'KeyQ',
openRuntimeOptions: 'Digit9',
openCharacterDictionary: 'Ctrl+Shift+KeyA',
},
};
@@ -73,4 +74,5 @@ test('normalizes fallback shortcuts when AnkiConnect flag is unset', () => {
assert.equal(resolved.mineSentence, 'Q');
assert.equal(resolved.openRuntimeOptions, '9');
assert.equal(resolved.openCharacterDictionary, 'Ctrl+Shift+A');
});

View File

@@ -12,6 +12,7 @@ export interface ConfiguredShortcuts {
multiCopyTimeoutMs: number;
toggleSecondarySub: string | null | undefined;
markAudioCard: string | null | undefined;
openCharacterDictionary: string | null | undefined;
openRuntimeOptions: string | null | undefined;
openJimaku: string | null | undefined;
openSessionHelp: string | null | undefined;
@@ -76,6 +77,9 @@ export function resolveConfiguredShortcuts(
? null
: (config.shortcuts?.markAudioCard ?? defaultConfig.shortcuts?.markAudioCard),
),
openCharacterDictionary: normalizeShortcut(
config.shortcuts?.openCharacterDictionary ?? defaultConfig.shortcuts?.openCharacterDictionary,
),
openRuntimeOptions: normalizeShortcut(
config.shortcuts?.openRuntimeOptions ?? defaultConfig.shortcuts?.openRuntimeOptions,
),

View File

@@ -458,6 +458,7 @@ import { openYoutubeTrackPicker } from './main/runtime/youtube-picker-open';
import { openRuntimeOptionsModal as openRuntimeOptionsModalRuntime } from './main/runtime/runtime-options-open';
import { openJimakuModal as openJimakuModalRuntime } from './main/runtime/jimaku-open';
import { openSessionHelpModal as openSessionHelpModalRuntime } from './main/runtime/session-help-open';
import { openCharacterDictionaryModal as openCharacterDictionaryModalRuntime } from './main/runtime/character-dictionary-open';
import { openControllerSelectModal as openControllerSelectModalRuntime } from './main/runtime/controller-select-open';
import { openControllerDebugModal as openControllerDebugModalRuntime } from './main/runtime/controller-debug-open';
import { createPlaylistBrowserIpcRuntime } from './main/runtime/playlist-browser-ipc';
@@ -533,6 +534,7 @@ import {
resolveSubtitleSourcePath,
} from './main/runtime/subtitle-prefetch-source';
import { createSubtitlePrefetchInitController } from './main/runtime/subtitle-prefetch-init';
import { applyCharacterDictionarySelection } from './main/character-dictionary-selection';
import { codecToExtension, getSubsyncConfig } from './subsync/utils';
if (process.platform === 'linux') {
@@ -1492,6 +1494,9 @@ const overlayShortcutsRuntime = createOverlayShortcutsRuntimeService(
openRuntimeOptionsPalette: () => {
openRuntimeOptionsPalette();
},
openCharacterDictionary: () => {
openCharacterDictionaryOverlay();
},
openJimaku: () => {
openJimakuOverlay();
},
@@ -2290,6 +2295,14 @@ function openSessionHelpOverlay(): void {
);
}
function openCharacterDictionaryOverlay(): void {
openOverlayHostedModalWithOsd(
openCharacterDictionaryModalRuntime,
'Character dictionary overlay unavailable.',
'Failed to open character dictionary overlay.',
);
}
function openControllerSelectOverlay(): void {
openOverlayHostedModalWithOsd(
openControllerSelectModalRuntime,
@@ -3810,6 +3823,8 @@ const {
setReconnectTimer: (timer: ReturnType<typeof setTimeout> | null) => {
appState.reconnectTimer = timer;
},
shouldQuitOnMpvShutdown: () => appState.initialArgs?.managedPlayback === true,
requestAppQuit: () => requestAppQuit(),
},
updateMpvSubtitleRenderMetricsMainDeps: {
getCurrentMetrics: () => appState.mpvSubtitleRenderMetrics,
@@ -4333,6 +4348,10 @@ function toggleSubtitleSidebar(): void {
broadcastToOverlayWindows(IPC_CHANNELS.event.subtitleSidebarToggle);
}
function togglePrimarySubtitleBar(): void {
broadcastToOverlayWindows(IPC_CHANNELS.event.primarySubtitleBarToggle);
}
async function triggerSubsyncFromConfig(): Promise<void> {
await subsyncRuntime.triggerFromConfig();
}
@@ -4622,6 +4641,7 @@ async function dispatchSessionAction(request: SessionActionDispatchRequest): Pro
openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(),
openJimaku: () => openJimakuOverlay(),
openSessionHelp: () => openSessionHelpOverlay(),
openCharacterDictionary: () => openCharacterDictionaryOverlay(),
openControllerSelect: () => openControllerSelectOverlay(),
openControllerDebug: () => openControllerDebugOverlay(),
openYoutubeTrackPicker: () => openYoutubeTrackPickerFromPlayback(),
@@ -4842,6 +4862,18 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
openAnilistSetup: () => openAnilistSetupWindow(),
getAnilistQueueStatus: () => anilistStateRuntime.getQueueStatusSnapshot(),
retryAnilistQueueNow: () => processNextAnilistRetryUpdate(),
getCharacterDictionarySelection: () =>
characterDictionaryRuntime.getManualSelectionSnapshot(),
setCharacterDictionarySelection: async (mediaId: number) =>
applyCharacterDictionarySelection(
{ mediaId },
{
setManualSelection: (request) => characterDictionaryRuntime.setManualSelection(request),
resetAnilistMediaGuessState,
runSyncNow: () => characterDictionaryAutoSyncRuntime.runSyncNow(),
warn: (message, error) => logger.warn(message, error),
},
),
appendClipboardVideoToQueue: () => appendClipboardVideoToQueue(),
...playlistBrowserMainDeps,
getImmersionTracker: () => appState.immersionTracker,
@@ -4898,6 +4930,7 @@ const { handleCliCommand, handleInitialArgs } = composeCliStartupHandlers({
showMpvOsd: (text: string) => showMpvOsd(text),
initializeOverlayRuntime: () => initializeOverlayRuntime(),
toggleVisibleOverlay: () => toggleVisibleOverlay(),
togglePrimarySubtitleBar: () => togglePrimarySubtitleBar(),
openFirstRunSetupWindow: () => openFirstRunSetupWindow(),
setVisibleOverlayVisible: (visible: boolean) => setVisibleOverlayVisible(visible),
copyCurrentSubtitle: () => copyCurrentSubtitle(),
@@ -4923,6 +4956,16 @@ const { handleCliCommand, handleInitialArgs } = composeCliStartupHandlers({
}
return await characterDictionaryRuntime.generateForCurrentMedia(targetPath);
},
getCharacterDictionarySelection: async (targetPath?: string) =>
characterDictionaryRuntime.getManualSelectionSnapshot(targetPath),
setCharacterDictionarySelection: async (request) =>
applyCharacterDictionarySelection(request, {
setManualSelection: (selectionRequest) =>
characterDictionaryRuntime.setManualSelection(selectionRequest),
resetAnilistMediaGuessState,
runSyncNow: () => characterDictionaryAutoSyncRuntime.runSyncNow(),
warn: (message, error) => logger.warn(message, error),
}),
runJellyfinCommand: (argsFromCommand: CliArgs) => runJellyfinCommand(argsFromCommand),
runStatsCommand: (argsFromCommand: CliArgs, source: CliCommandSource) =>
runStatsCliCommand(argsFromCommand, source),
@@ -5096,7 +5139,7 @@ const { ensureTray: ensureTrayHandler, destroyTray: destroyTrayHandler } =
buildTrayMenuTemplateRuntime,
initializeOverlayRuntime: () => initializeOverlayRuntime(),
isOverlayRuntimeInitialized: () => appState.overlayRuntimeInitialized,
setVisibleOverlayVisible: (visible) => setVisibleOverlayVisible(visible),
openSessionHelpModal: () => openSessionHelpOverlay(),
showFirstRunSetup: () => !firstRunSetupService.isSetupCompleted(),
openFirstRunSetupWindow: () => openFirstRunSetupWindow(),
showWindowsMpvLauncherSetup: () => process.platform === 'win32',

View File

@@ -25,12 +25,21 @@ import {
} from './character-dictionary-runtime/constants';
import {
downloadCharacterImage,
fetchAniListMediaCandidateById,
fetchCharactersForMedia,
resolveAniListMediaIdFromGuess,
searchAniListMediaCandidates,
} from './character-dictionary-runtime/fetch';
import {
buildCharacterDictionarySeriesKey,
createCharacterDictionaryManualSelectionStore,
} from './character-dictionary-runtime/manual-selection';
import type {
AniListMediaCandidate,
CharacterDictionaryBuildResult,
CharacterDictionaryGenerateOptions,
CharacterDictionaryManualSelectionResult,
CharacterDictionaryManualSelectionSnapshot,
CharacterDictionaryRuntimeDeps,
CharacterDictionarySnapshotImage,
CharacterDictionarySnapshotProgress,
@@ -136,6 +145,13 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar
progress?: CharacterDictionarySnapshotProgressCallbacks,
) => Promise<CharacterDictionarySnapshotResult>;
buildMergedDictionary: (mediaIds: number[]) => Promise<MergedCharacterDictionaryBuildResult>;
getManualSelectionSnapshot: (
targetPath?: string,
) => Promise<CharacterDictionaryManualSelectionSnapshot>;
setManualSelection: (request: {
targetPath?: string;
mediaId: number;
}) => Promise<CharacterDictionaryManualSelectionResult>;
generateForCurrentMedia: (
targetPath?: string,
options?: CharacterDictionaryGenerateOptions,
@@ -144,26 +160,56 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar
const outputDir = path.join(deps.userDataPath, 'character-dictionaries');
const sleepMs = deps.sleep ?? sleep;
const getCollapsibleSectionOpenState = deps.getCollapsibleSectionOpenState ?? (() => false);
const manualSelectionStore = createCharacterDictionaryManualSelectionStore({
userDataPath: deps.userDataPath,
});
const createAniListRequestSlot = (): (() => Promise<void>) => {
let hasAniListRequest = false;
return async (): Promise<void> => {
if (!hasAniListRequest) {
hasAniListRequest = true;
return;
}
await sleepMs(ANILIST_REQUEST_DELAY_MS);
};
};
const resolveGuessInput = (
targetPath?: string,
): { mediaPath: string | null; mediaTitle: string | null } => {
const dictionaryTarget = targetPath?.trim() || '';
return dictionaryTarget.length > 0
? resolveDictionaryGuessInputs(dictionaryTarget)
: {
mediaPath: deps.getCurrentMediaPath(),
mediaTitle: deps.getCurrentMediaTitle(),
};
};
const guessCurrentMedia = async (targetPath?: string) => {
const guessInput = resolveGuessInput(targetPath);
const mediaPathForGuess = deps.resolveMediaPathForJimaku(guessInput.mediaPath);
const guessed = await deps.guessAnilistMediaInfo(mediaPathForGuess, guessInput.mediaTitle);
if (!guessed || !guessed.title.trim()) {
throw new Error('Unable to resolve current anime from media path/title.');
}
return {
guessed,
seriesKey: buildCharacterDictionarySeriesKey({
mediaPath: mediaPathForGuess,
mediaTitle: guessInput.mediaTitle,
guess: guessed,
}),
};
};
const resolveCurrentMedia = async (
targetPath?: string,
beforeRequest?: () => Promise<void>,
): Promise<ResolvedAniListMedia> => {
deps.logInfo?.('[dictionary] resolving current anime for character dictionary generation');
const dictionaryTarget = targetPath?.trim() || '';
const guessInput =
dictionaryTarget.length > 0
? resolveDictionaryGuessInputs(dictionaryTarget)
: {
mediaPath: deps.getCurrentMediaPath(),
mediaTitle: deps.getCurrentMediaTitle(),
};
const mediaPathForGuess = deps.resolveMediaPathForJimaku(guessInput.mediaPath);
const mediaTitle = guessInput.mediaTitle;
const guessed = await deps.guessAnilistMediaInfo(mediaPathForGuess, mediaTitle);
if (!guessed || !guessed.title.trim()) {
throw new Error('Unable to resolve current anime from media path/title.');
}
const { guessed, seriesKey } = await guessCurrentMedia(targetPath);
deps.logInfo?.(
`[dictionary] current anime guess: ${guessed.title.trim()}${
typeof guessed.episode === 'number' && guessed.episode > 0
@@ -171,6 +217,17 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar
: ''
}`,
);
const override = await manualSelectionStore.getOverride(seriesKey);
if (override) {
deps.logInfo?.(
`[dictionary] manual AniList override: ${override.mediaTitle} -> AniList ${override.mediaId}`,
);
return {
id: override.mediaId,
title: override.mediaTitle,
staleMediaIds: override.staleMediaIds,
};
}
const resolved = await resolveAniListMediaIdFromGuess(guessed, beforeRequest);
deps.logInfo?.(`[dictionary] AniList match: ${resolved.title} -> AniList ${resolved.id}`);
return resolved;
@@ -283,25 +340,22 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar
targetPath?: string,
progress?: CharacterDictionarySnapshotProgressCallbacks,
) => {
let hasAniListRequest = false;
const waitForAniListRequestSlot = async (): Promise<void> => {
if (!hasAniListRequest) {
hasAniListRequest = true;
return;
}
await sleepMs(ANILIST_REQUEST_DELAY_MS);
};
const waitForAniListRequestSlot = createAniListRequestSlot();
const resolvedMedia = await resolveCurrentMedia(targetPath, waitForAniListRequestSlot);
progress?.onChecking?.({
mediaId: resolvedMedia.id,
mediaTitle: resolvedMedia.title,
});
return getOrCreateSnapshot(
const snapshot = await getOrCreateSnapshot(
resolvedMedia.id,
resolvedMedia.title,
waitForAniListRequestSlot,
progress,
);
return {
...snapshot,
staleMediaIds: resolvedMedia.staleMediaIds,
};
},
buildMergedDictionary: async (mediaIds: number[]) => {
const normalizedMediaIds = normalizeMergedMediaIds(mediaIds);
@@ -341,18 +395,58 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar
entryCount,
};
},
getManualSelectionSnapshot: async (targetPath?: string) => {
const waitForAniListRequestSlot = createAniListRequestSlot();
const { guessed, seriesKey } = await guessCurrentMedia(targetPath);
const [candidates, override] = await Promise.all([
searchAniListMediaCandidates(guessed.title, waitForAniListRequestSlot),
manualSelectionStore.getOverride(seriesKey),
]);
const current = await resolveAniListMediaIdFromGuess(guessed, waitForAniListRequestSlot)
.then(
(entry): AniListMediaCandidate => ({
id: entry.id,
title: entry.title,
episodes: candidates.find((candidate) => candidate.id === entry.id)?.episodes ?? null,
}),
)
.catch(() => null);
return {
seriesKey,
guessTitle: guessed.title,
current,
override: override
? { id: override.mediaId, title: override.mediaTitle, episodes: null }
: null,
candidates,
};
},
setManualSelection: async ({ targetPath, mediaId }) => {
const waitForAniListRequestSlot = createAniListRequestSlot();
const { guessed, seriesKey } = await guessCurrentMedia(targetPath);
const [selected, current] = await Promise.all([
fetchAniListMediaCandidateById(mediaId, waitForAniListRequestSlot),
resolveAniListMediaIdFromGuess(guessed, waitForAniListRequestSlot).catch(() => null),
]);
const staleMediaIds = current && current.id !== selected.id ? [current.id] : [];
await manualSelectionStore.setOverride({
seriesKey,
mediaId: selected.id,
mediaTitle: selected.title,
staleMediaIds,
});
return {
ok: true,
seriesKey,
selected,
staleMediaIds,
};
},
generateForCurrentMedia: async (
targetPath?: string,
_options?: CharacterDictionaryGenerateOptions,
) => {
let hasAniListRequest = false;
const waitForAniListRequestSlot = async (): Promise<void> => {
if (!hasAniListRequest) {
hasAniListRequest = true;
return;
}
await sleepMs(ANILIST_REQUEST_DELAY_MS);
};
const waitForAniListRequestSlot = createAniListRequestSlot();
const resolvedMedia = await resolveCurrentMedia(targetPath, waitForAniListRequestSlot);
const snapshot = await getOrCreateSnapshot(
resolvedMedia.id,

View File

@@ -0,0 +1,34 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { searchAniListMediaCandidates } from './fetch';
test('searchAniListMediaCandidates trims fallback candidate titles', async () => {
const previousFetchDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'fetch');
Object.defineProperty(globalThis, 'fetch', {
configurable: true,
writable: true,
value: async () =>
new Response(
JSON.stringify({
data: {
Page: {
media: [{ id: 21355, episodes: 25, title: {} }],
},
},
}),
),
});
try {
const candidates = await searchAniListMediaCandidates(' Re:ZERO ');
assert.equal(candidates[0]?.title, 'Re:ZERO');
} finally {
if (previousFetchDescriptor) {
Object.defineProperty(globalThis, 'fetch', previousFetchDescriptor);
} else {
Reflect.deleteProperty(globalThis, 'fetch');
}
}
});

View File

@@ -1,6 +1,7 @@
import type { AnilistMediaGuess } from '../../core/services/anilist/anilist-updater';
import { ANILIST_GRAPHQL_URL } from './constants';
import type {
AniListMediaCandidate,
CharacterDictionaryRole,
CharacterRecord,
ResolvedAniListMedia,
@@ -123,6 +124,30 @@ function pickAniListSearchResult(
};
}
function toAniListMediaCandidate(
entry: {
id: number;
episodes?: number | null;
title?: {
romaji?: string | null;
english?: string | null;
native?: string | null;
};
},
fallbackTitle: string,
): AniListMediaCandidate {
const normalizedFallback = fallbackTitle.trim() || `AniList ${entry.id}`;
return {
id: entry.id,
title:
entry.title?.english?.trim() ||
entry.title?.romaji?.trim() ||
entry.title?.native?.trim() ||
normalizedFallback,
episodes: typeof entry.episodes === 'number' && entry.episodes > 0 ? entry.episodes : null,
};
}
async function fetchAniList<T>(
query: string,
variables: Record<string, unknown>,
@@ -208,6 +233,69 @@ export async function resolveAniListMediaIdFromGuess(
return resolved;
}
export async function searchAniListMediaCandidates(
title: string,
beforeRequest?: () => Promise<void>,
): Promise<AniListMediaCandidate[]> {
const data = await fetchAniList<AniListSearchResponse>(
`
query($search: String!) {
Page(perPage: 10) {
media(search: $search, type: ANIME, sort: [SEARCH_MATCH, POPULARITY_DESC]) {
id
episodes
title {
romaji
english
native
}
}
}
}
`,
{ search: title },
beforeRequest,
);
return (data.Page?.media ?? []).map((entry) => toAniListMediaCandidate(entry, title));
}
export async function fetchAniListMediaCandidateById(
mediaId: number,
beforeRequest?: () => Promise<void>,
): Promise<AniListMediaCandidate> {
const data = await fetchAniList<{
Media?: {
id: number;
episodes?: number | null;
title?: {
romaji?: string | null;
english?: string | null;
native?: string | null;
};
} | null;
}>(
`
query($id: Int!) {
Media(id: $id, type: ANIME) {
id
episodes
title {
romaji
english
native
}
}
}
`,
{ id: mediaId },
beforeRequest,
);
if (!data.Media) {
throw new Error(`AniList media ${mediaId} not found.`);
}
return toAniListMediaCandidate(data.Media, `AniList ${mediaId}`);
}
export async function fetchCharactersForMedia(
mediaId: number,
beforeRequest?: () => Promise<void>,

View File

@@ -0,0 +1,81 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import {
buildCharacterDictionarySeriesKey,
createCharacterDictionaryManualSelectionStore,
} from './manual-selection';
const REZERO_EP1 =
'/anime/Re - ZERO, Starting Life in Another World (2016) - S01E01 - - The End of the Beginning and the Beginning of the End [v2 Bluray-1080p Proper][10bit][x265][FLAC 2.0][EN+JA]-SCY.mkv';
const REZERO_EP2 =
'/anime/Re - ZERO, Starting Life in Another World (2016) - S01E02 - Reunion with the Witch [Bluray-1080p][x265][JA]-SCY.mkv';
function makeTempDir(): string {
return fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-manual-selection-'));
}
test('buildCharacterDictionarySeriesKey uses guessit title, alternative title, and year for Re ZERO series scope', () => {
const key = buildCharacterDictionarySeriesKey({
mediaPath: REZERO_EP1,
mediaTitle: null,
guess: {
title: 'Re ZERO, Starting Life in Another World',
alternativeTitle: 'ZERO, Starting Life in Another World',
year: 2016,
season: 1,
episode: 1,
source: 'guessit',
},
});
assert.equal(key, 're-zero-starting-life-in-another-world-2016');
});
test('manual selection store persists overrides and matches later episodes in the same series', async () => {
const userDataPath = makeTempDir();
const store = createCharacterDictionaryManualSelectionStore({ userDataPath });
const firstKey = buildCharacterDictionarySeriesKey({
mediaPath: REZERO_EP1,
mediaTitle: null,
guess: {
title: 'Re ZERO, Starting Life in Another World',
alternativeTitle: 'ZERO, Starting Life in Another World',
year: 2016,
season: 1,
episode: 1,
source: 'guessit',
},
});
await store.setOverride({
seriesKey: firstKey,
mediaId: 21355,
mediaTitle: 'Re:ZERO -Starting Life in Another World-',
staleMediaIds: [10607],
});
const reloaded = createCharacterDictionaryManualSelectionStore({ userDataPath });
const secondKey = buildCharacterDictionarySeriesKey({
mediaPath: REZERO_EP2,
mediaTitle: null,
guess: {
title: 'Re ZERO, Starting Life in Another World',
alternativeTitle: 'ZERO, Starting Life in Another World',
year: 2016,
season: 1,
episode: 2,
source: 'guessit',
},
});
assert.equal(secondKey, firstKey);
assert.deepEqual(await reloaded.getOverride(secondKey), {
seriesKey: firstKey,
mediaId: 21355,
mediaTitle: 'Re:ZERO -Starting Life in Another World-',
staleMediaIds: [10607],
});
});

View File

@@ -0,0 +1,118 @@
import * as fs from 'fs';
import * as path from 'path';
import type { AnilistMediaGuess } from '../../core/services/anilist/anilist-updater';
import { ensureDir } from '../../shared/fs-utils';
export type CharacterDictionaryManualSelection = {
seriesKey: string;
mediaId: number;
mediaTitle: string;
staleMediaIds: number[];
};
type ManualSelectionStoreFile = {
overrides?: CharacterDictionaryManualSelection[];
};
function normalizeManualMediaId(value: unknown): number | null {
if (typeof value !== 'number' || !Number.isFinite(value)) return null;
const mediaId = Math.floor(value);
return mediaId > 0 ? mediaId : null;
}
function normalizeSeriesKeyPart(value: string): string {
return value
.normalize('NFKD')
.replace(/[':]/g, '')
.replace(/&/g, ' and ')
.replace(/[^a-zA-Z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.replace(/-{2,}/g, '-')
.toLowerCase();
}
function dedupeNumbers(values: number[]): number[] {
const seen = new Set<number>();
const result: number[] = [];
for (const value of values) {
const normalized = normalizeManualMediaId(value);
if (normalized === null || seen.has(normalized)) continue;
seen.add(normalized);
result.push(normalized);
}
return result;
}
function normalizeOverride(value: unknown): CharacterDictionaryManualSelection | null {
if (!value || typeof value !== 'object') return null;
const raw = value as Partial<CharacterDictionaryManualSelection>;
const seriesKey = typeof raw.seriesKey === 'string' ? raw.seriesKey.trim() : '';
const mediaId = normalizeManualMediaId(raw.mediaId);
const mediaTitle = typeof raw.mediaTitle === 'string' ? raw.mediaTitle.trim() : '';
if (!seriesKey || mediaId === null || !mediaTitle) return null;
return {
seriesKey,
mediaId,
mediaTitle,
staleMediaIds: dedupeNumbers(Array.isArray(raw.staleMediaIds) ? raw.staleMediaIds : []),
};
}
function readOverrides(filePath: string): CharacterDictionaryManualSelection[] {
try {
const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8')) as ManualSelectionStoreFile;
if (!Array.isArray(parsed.overrides)) return [];
const byKey = new Map<string, CharacterDictionaryManualSelection>();
for (const value of parsed.overrides) {
const normalized = normalizeOverride(value);
if (normalized) byKey.set(normalized.seriesKey, normalized);
}
return [...byKey.values()];
} catch {
return [];
}
}
function writeOverrides(filePath: string, overrides: CharacterDictionaryManualSelection[]): void {
ensureDir(path.dirname(filePath));
fs.writeFileSync(filePath, JSON.stringify({ overrides }, null, 2), 'utf8');
}
export function buildCharacterDictionarySeriesKey(input: {
mediaPath: string | null;
mediaTitle: string | null;
guess: AnilistMediaGuess | null;
}): string {
const guessedTitle = input.guess?.title.trim() || input.guess?.alternativeTitle?.trim() || '';
const sourceTitle =
guessedTitle ||
(input.mediaTitle && input.mediaTitle.trim()) ||
(input.mediaPath && path.basename(input.mediaPath).replace(/\.[^.]+$/, '')) ||
'unknown';
const withoutEpisode = sourceTitle
.replace(/\bS\d{1,2}E\d{1,3}\b/gi, ' ')
.replace(/\bepisode\s+\d+\b/gi, ' ')
.trim();
const base = normalizeSeriesKeyPart(withoutEpisode) || 'unknown';
return input.guess?.year ? `${base}-${input.guess.year}` : base;
}
export function createCharacterDictionaryManualSelectionStore(deps: { userDataPath: string }) {
const filePath = path.join(deps.userDataPath, 'character-dictionaries', 'anilist-overrides.json');
return {
getOverride: async (seriesKey: string): Promise<CharacterDictionaryManualSelection | null> => {
return readOverrides(filePath).find((entry) => entry.seriesKey === seriesKey) ?? null;
},
setOverride: async (selection: CharacterDictionaryManualSelection): Promise<void> => {
const normalized = normalizeOverride(selection);
if (!normalized) {
throw new Error('Invalid character dictionary manual selection.');
}
const remaining = readOverrides(filePath).filter(
(entry) => entry.seriesKey !== normalized.seriesKey,
);
writeOverrides(filePath, [...remaining, normalized]);
},
};
}

View File

@@ -93,6 +93,7 @@ export type CharacterDictionarySnapshotResult = {
entryCount: number;
fromCache: boolean;
updatedAt: number;
staleMediaIds?: number[];
};
export type CharacterDictionarySnapshotProgress = {
@@ -112,6 +113,27 @@ export type MergedCharacterDictionaryBuildResult = {
entryCount: number;
};
export type AniListMediaCandidate = {
id: number;
title: string;
episodes: number | null;
};
export type CharacterDictionaryManualSelectionSnapshot = {
seriesKey: string;
guessTitle: string | null;
current: AniListMediaCandidate | null;
override: AniListMediaCandidate | null;
candidates: AniListMediaCandidate[];
};
export type CharacterDictionaryManualSelectionResult = {
ok: boolean;
seriesKey: string;
selected: AniListMediaCandidate;
staleMediaIds: number[];
};
export interface CharacterDictionaryRuntimeDeps {
userDataPath: string;
getCurrentMediaPath: () => string | null;
@@ -133,4 +155,5 @@ export interface CharacterDictionaryRuntimeDeps {
export type ResolvedAniListMedia = {
id: number;
title: string;
staleMediaIds?: number[];
};

View File

@@ -0,0 +1,27 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyCharacterDictionarySelection } from './character-dictionary-selection';
test('applyCharacterDictionarySelection returns saved override when post-save sync fails', async () => {
const warnings: unknown[] = [];
const result = await applyCharacterDictionarySelection(
{ mediaId: 21355 },
{
setManualSelection: async (request) => ({
ok: true,
seriesKey: `series-${request.mediaId}`,
selected: { id: request.mediaId, title: 'Re:ZERO', episodes: 25 },
staleMediaIds: [10607],
}),
resetAnilistMediaGuessState: () => {},
runSyncNow: async () => {
throw new Error('sync failed');
},
warn: (...args) => warnings.push(args),
},
);
assert.equal(result.selected.id, 21355);
assert.equal(warnings.length, 1);
});

View File

@@ -0,0 +1,29 @@
import type { CharacterDictionaryManualSelectionResult } from './character-dictionary-runtime/types';
export type CharacterDictionarySelectionRequest = {
targetPath?: string;
mediaId: number;
};
export type CharacterDictionarySelectionDeps = {
setManualSelection: (
request: CharacterDictionarySelectionRequest,
) => Promise<CharacterDictionaryManualSelectionResult>;
resetAnilistMediaGuessState: () => void;
runSyncNow: () => Promise<void>;
warn: (message: string, error?: unknown) => void;
};
export async function applyCharacterDictionarySelection(
request: CharacterDictionarySelectionRequest,
deps: CharacterDictionarySelectionDeps,
): Promise<CharacterDictionaryManualSelectionResult> {
const result = await deps.setManualSelection(request);
deps.resetAnilistMediaGuessState();
try {
await deps.runSyncNow();
} catch (error) {
deps.warn('Character dictionary auto-sync failed after manual selection', error);
}
return result;
}

View File

@@ -19,6 +19,7 @@ export interface CliCommandRuntimeServiceContext {
isOverlayInitialized: () => boolean;
initializeOverlay: () => void;
toggleVisibleOverlay: () => void;
togglePrimarySubtitleBar: () => void;
openFirstRunSetup: () => void;
setVisibleOverlay: (visible: boolean) => void;
copyCurrentSubtitle: () => void;
@@ -37,6 +38,8 @@ export interface CliCommandRuntimeServiceContext {
getAnilistQueueStatus: CliCommandRuntimeServiceDepsParams['anilist']['getQueueStatus'];
retryAnilistQueueNow: CliCommandRuntimeServiceDepsParams['anilist']['retryQueueNow'];
generateCharacterDictionary: CliCommandRuntimeServiceDepsParams['dictionary']['generate'];
getCharacterDictionarySelection: CliCommandRuntimeServiceDepsParams['dictionary']['getSelection'];
setCharacterDictionarySelection: CliCommandRuntimeServiceDepsParams['dictionary']['setSelection'];
openJellyfinSetup: CliCommandRuntimeServiceDepsParams['jellyfin']['openSetup'];
runStatsCommand: CliCommandRuntimeServiceDepsParams['jellyfin']['runStatsCommand'];
runJellyfinCommand: CliCommandRuntimeServiceDepsParams['jellyfin']['runCommand'];
@@ -81,6 +84,7 @@ function createCliCommandDepsFromContext(
isInitialized: context.isOverlayInitialized,
initialize: context.initializeOverlay,
toggleVisible: context.toggleVisibleOverlay,
togglePrimarySubtitleBar: context.togglePrimarySubtitleBar,
setVisible: context.setVisibleOverlay,
},
mining: {
@@ -103,6 +107,8 @@ function createCliCommandDepsFromContext(
},
dictionary: {
generate: context.generateCharacterDictionary,
getSelection: context.getCharacterDictionarySelection,
setSelection: context.setCharacterDictionarySelection,
},
jellyfin: {
openSetup: context.openJellyfinSetup,

View File

@@ -94,6 +94,8 @@ export interface MainIpcRuntimeServiceDepsParams {
openAnilistSetup: IpcDepsRuntimeOptions['openAnilistSetup'];
getAnilistQueueStatus: IpcDepsRuntimeOptions['getAnilistQueueStatus'];
retryAnilistQueueNow: IpcDepsRuntimeOptions['retryAnilistQueueNow'];
getCharacterDictionarySelection?: IpcDepsRuntimeOptions['getCharacterDictionarySelection'];
setCharacterDictionarySelection?: IpcDepsRuntimeOptions['setCharacterDictionarySelection'];
appendClipboardVideoToQueue: IpcDepsRuntimeOptions['appendClipboardVideoToQueue'];
getPlaylistBrowserSnapshot: IpcDepsRuntimeOptions['getPlaylistBrowserSnapshot'];
appendPlaylistBrowserFile: IpcDepsRuntimeOptions['appendPlaylistBrowserFile'];
@@ -147,6 +149,7 @@ export interface CliCommandRuntimeServiceDepsParams {
isInitialized: CliCommandDepsRuntimeOptions['overlay']['isInitialized'];
initialize: CliCommandDepsRuntimeOptions['overlay']['initialize'];
toggleVisible: CliCommandDepsRuntimeOptions['overlay']['toggleVisible'];
togglePrimarySubtitleBar: CliCommandDepsRuntimeOptions['overlay']['togglePrimarySubtitleBar'];
setVisible: CliCommandDepsRuntimeOptions['overlay']['setVisible'];
};
mining: {
@@ -169,6 +172,8 @@ export interface CliCommandRuntimeServiceDepsParams {
};
dictionary: {
generate: CliCommandDepsRuntimeOptions['dictionary']['generate'];
getSelection: CliCommandDepsRuntimeOptions['dictionary']['getSelection'];
setSelection: CliCommandDepsRuntimeOptions['dictionary']['setSelection'];
};
jellyfin: {
openSetup: CliCommandDepsRuntimeOptions['jellyfin']['openSetup'];
@@ -258,6 +263,8 @@ export function createMainIpcRuntimeServiceDeps(
openAnilistSetup: params.openAnilistSetup,
getAnilistQueueStatus: params.getAnilistQueueStatus,
retryAnilistQueueNow: params.retryAnilistQueueNow,
getCharacterDictionarySelection: params.getCharacterDictionarySelection,
setCharacterDictionarySelection: params.setCharacterDictionarySelection,
appendClipboardVideoToQueue: params.appendClipboardVideoToQueue,
getPlaylistBrowserSnapshot: params.getPlaylistBrowserSnapshot,
appendPlaylistBrowserFile: params.appendPlaylistBrowserFile,
@@ -319,6 +326,7 @@ export function createCliCommandRuntimeServiceDeps(
isInitialized: params.overlay.isInitialized,
initialize: params.overlay.initialize,
toggleVisible: params.overlay.toggleVisible,
togglePrimarySubtitleBar: params.overlay.togglePrimarySubtitleBar,
setVisible: params.overlay.setVisible,
},
mining: {
@@ -341,6 +349,8 @@ export function createCliCommandRuntimeServiceDeps(
},
dictionary: {
generate: params.dictionary.generate,
getSelection: params.dictionary.getSelection,
setSelection: params.dictionary.setSelection,
},
jellyfin: {
openSetup: params.jellyfin.openSetup,

View File

@@ -19,6 +19,7 @@ export interface OverlayShortcutRuntimeServiceInput {
isOverlayShortcutContextActive?: () => boolean;
showMpvOsd: (text: string) => void;
openRuntimeOptionsPalette: () => void;
openCharacterDictionary: () => void;
openJimaku: () => void;
markAudioCard: () => Promise<void>;
copySubtitleMultiple: (timeoutMs: number) => void;
@@ -49,6 +50,9 @@ export function createOverlayShortcutsRuntimeService(
openRuntimeOptions: () => {
input.openRuntimeOptionsPalette();
},
openCharacterDictionary: () => {
input.openCharacterDictionary();
},
openJimaku: () => {
input.openJimaku();
},

View File

@@ -459,6 +459,66 @@ test('auto sync keeps revisited media retained when a new title is added afterwa
assert.deepEqual(state.activeMediaIds, ['1 - Title 1', '4 - Title 4', '3 - Title 3']);
});
test('auto sync removes stale manual-selection media ids when applying corrected snapshot', async () => {
const userDataPath = makeTempDir();
const dictionariesDir = path.join(userDataPath, 'character-dictionaries');
fs.mkdirSync(dictionariesDir, { recursive: true });
fs.writeFileSync(
path.join(dictionariesDir, 'auto-sync-state.json'),
JSON.stringify(
{
activeMediaIds: ['10607 - Rerere no Tensai Bakabon', '130298 - The Eminence in Shadow'],
mergedRevision: 'old',
mergedDictionaryTitle: 'SubMiner Character Dictionary',
},
null,
2,
),
);
const builtMediaIds: number[][] = [];
const runtime = createCharacterDictionaryAutoSyncRuntimeService({
userDataPath,
getConfig: () => ({
enabled: true,
maxLoaded: 5,
profileScope: 'all',
}),
getOrCreateCurrentSnapshot: async () => ({
mediaId: 21355,
mediaTitle: 'Re:ZERO -Starting Life in Another World-',
entryCount: 120,
fromCache: false,
updatedAt: 99,
staleMediaIds: [10607],
}),
buildMergedDictionary: async (mediaIds) => {
builtMediaIds.push([...mediaIds]);
return {
zipPath: path.join(dictionariesDir, 'merged.zip'),
revision: `rev-${mediaIds.join('-')}`,
dictionaryTitle: 'SubMiner Character Dictionary',
entryCount: 200,
};
},
getYomitanDictionaryInfo: async () => [],
importYomitanDictionary: async () => true,
deleteYomitanDictionary: async () => true,
upsertYomitanDictionarySettings: async () => false,
now: () => 1,
});
await runtime.runSyncNow();
assert.deepEqual(builtMediaIds, [[21355, 130298]]);
const state = JSON.parse(
fs.readFileSync(path.join(dictionariesDir, 'auto-sync-state.json'), 'utf8'),
) as { activeMediaIds: string[] };
assert.deepEqual(state.activeMediaIds, [
'21355 - Re:ZERO -Starting Life in Another World-',
'130298 - The Eminence in Shadow',
]);
});
test('auto sync persists rebuilt MRU state even if Yomitan import fails afterward', async () => {
const userDataPath = makeTempDir();
const dictionariesDir = path.join(userDataPath, 'character-dictionaries');

View File

@@ -271,12 +271,19 @@ export function createCharacterDictionaryAutoSyncRuntimeService(
currentMediaId = snapshot.mediaId;
currentMediaTitle = snapshot.mediaTitle;
const state = readAutoSyncState(statePath);
const staleMediaIds = new Set(
(snapshot.staleMediaIds ?? [])
.map((mediaId) => normalizeMediaId(mediaId))
.filter((mediaId): mediaId is number => mediaId !== null),
);
const nextActiveMediaIds = [
{
mediaId: snapshot.mediaId,
label: buildActiveMediaLabel(snapshot.mediaId, snapshot.mediaTitle),
},
...state.activeMediaIds.filter((entry) => entry.mediaId !== snapshot.mediaId),
...state.activeMediaIds.filter(
(entry) => entry.mediaId !== snapshot.mediaId && !staleMediaIds.has(entry.mediaId),
),
].slice(0, Math.max(1, Math.floor(config.maxLoaded)));
const nextActiveMediaIdValues = nextActiveMediaIds.map((entry) => entry.mediaId);
deps.logInfo?.(

View File

@@ -0,0 +1,48 @@
import type { OverlayHostedModal } from '../../shared/ipc/contracts';
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
import { openOverlayHostedModal, retryOverlayModalOpen } from './overlay-hosted-modal-open';
const CHARACTER_DICTIONARY_MODAL: OverlayHostedModal = 'character-dictionary';
const CHARACTER_DICTIONARY_OPEN_TIMEOUT_MS = 1500;
export async function openCharacterDictionaryModal(deps: {
ensureOverlayStartupPrereqs: () => void;
ensureOverlayWindowsReadyForVisibilityActions: () => void;
sendToActiveOverlayWindow: (
channel: string,
payload?: unknown,
runtimeOptions?: {
restoreOnModalClose?: OverlayHostedModal;
preferModalWindow?: boolean;
},
) => boolean;
waitForModalOpen: (modal: OverlayHostedModal, timeoutMs: number) => Promise<boolean>;
logWarn: (message: string) => void;
}): Promise<boolean> {
return await retryOverlayModalOpen(
{
waitForModalOpen: deps.waitForModalOpen,
logWarn: deps.logWarn,
},
{
modal: CHARACTER_DICTIONARY_MODAL,
timeoutMs: CHARACTER_DICTIONARY_OPEN_TIMEOUT_MS,
retryWarning:
'Character dictionary modal did not acknowledge modal open on first attempt; retrying dedicated modal window.',
sendOpen: () =>
openOverlayHostedModal(
{
ensureOverlayStartupPrereqs: deps.ensureOverlayStartupPrereqs,
ensureOverlayWindowsReadyForVisibilityActions:
deps.ensureOverlayWindowsReadyForVisibilityActions,
sendToActiveOverlayWindow: deps.sendToActiveOverlayWindow,
},
{
channel: IPC_CHANNELS.event.characterDictionaryOpen,
modal: CHARACTER_DICTIONARY_MODAL,
preferModalWindow: true,
},
),
},
);
}

View File

@@ -19,6 +19,7 @@ test('build cli command context deps maps handlers and values', () => {
isOverlayInitialized: () => true,
initializeOverlay: () => calls.push('init'),
toggleVisibleOverlay: () => calls.push('toggle-visible'),
togglePrimarySubtitleBar: () => calls.push('toggle-primary-subtitle'),
openFirstRunSetup: () => calls.push('setup'),
setVisibleOverlay: (visible) => calls.push(`set-visible:${visible}`),
copyCurrentSubtitle: () => calls.push('copy'),

View File

@@ -17,6 +17,7 @@ export function createBuildCliCommandContextDepsHandler(deps: {
isOverlayInitialized: () => boolean;
initializeOverlay: () => void;
toggleVisibleOverlay: () => void;
togglePrimarySubtitleBar: () => void;
openFirstRunSetup: () => void;
setVisibleOverlay: (visible: boolean) => void;
copyCurrentSubtitle: () => void;
@@ -36,6 +37,8 @@ export function createBuildCliCommandContextDepsHandler(deps: {
getAnilistQueueStatus: CliCommandContextFactoryDeps['getAnilistQueueStatus'];
retryAnilistQueueNow: CliCommandContextFactoryDeps['retryAnilistQueueNow'];
generateCharacterDictionary: CliCommandContextFactoryDeps['generateCharacterDictionary'];
getCharacterDictionarySelection?: CliCommandContextFactoryDeps['getCharacterDictionarySelection'];
setCharacterDictionarySelection?: CliCommandContextFactoryDeps['setCharacterDictionarySelection'];
runStatsCommand: CliCommandContextFactoryDeps['runStatsCommand'];
runJellyfinCommand: (args: CliArgs) => Promise<void>;
runYoutubePlaybackFlow: CliCommandContextFactoryDeps['runYoutubePlaybackFlow'];
@@ -67,6 +70,7 @@ export function createBuildCliCommandContextDepsHandler(deps: {
isOverlayInitialized: deps.isOverlayInitialized,
initializeOverlay: deps.initializeOverlay,
toggleVisibleOverlay: deps.toggleVisibleOverlay,
togglePrimarySubtitleBar: deps.togglePrimarySubtitleBar,
openFirstRunSetup: deps.openFirstRunSetup,
setVisibleOverlay: deps.setVisibleOverlay,
copyCurrentSubtitle: deps.copyCurrentSubtitle,
@@ -86,6 +90,8 @@ export function createBuildCliCommandContextDepsHandler(deps: {
getAnilistQueueStatus: deps.getAnilistQueueStatus,
retryAnilistQueueNow: deps.retryAnilistQueueNow,
generateCharacterDictionary: deps.generateCharacterDictionary,
getCharacterDictionarySelection: deps.getCharacterDictionarySelection,
setCharacterDictionarySelection: deps.setCharacterDictionarySelection,
runStatsCommand: deps.runStatsCommand,
runJellyfinCommand: deps.runJellyfinCommand,
runYoutubePlaybackFlow: deps.runYoutubePlaybackFlow,

View File

@@ -26,6 +26,7 @@ test('cli command context factory composes main deps and context handlers', () =
showMpvOsd: (text) => calls.push(`osd:${text}`),
initializeOverlayRuntime: () => calls.push('init-overlay'),
toggleVisibleOverlay: () => calls.push('toggle-visible'),
togglePrimarySubtitleBar: () => calls.push('toggle-primary-subtitle'),
openFirstRunSetupWindow: () => calls.push('setup'),
setVisibleOverlayVisible: (visible) => calls.push(`set-visible:${visible}`),
copyCurrentSubtitle: () => calls.push('copy-sub'),

View File

@@ -29,6 +29,7 @@ test('cli command context main deps builder maps state and callbacks', async ()
initializeOverlayRuntime: () => calls.push('init-overlay'),
toggleVisibleOverlay: () => calls.push('toggle-visible'),
togglePrimarySubtitleBar: () => calls.push('toggle-primary-subtitle'),
openFirstRunSetupWindow: () => calls.push('open-setup'),
setVisibleOverlayVisible: (visible) => calls.push(`set-visible:${visible}`),

View File

@@ -27,6 +27,7 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
initializeOverlayRuntime: () => void;
toggleVisibleOverlay: () => void;
togglePrimarySubtitleBar: () => void;
openFirstRunSetupWindow: () => void;
setVisibleOverlayVisible: (visible: boolean) => void;
@@ -48,6 +49,8 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
getAnilistQueueStatus: CliCommandContextFactoryDeps['getAnilistQueueStatus'];
processNextAnilistRetryUpdate: CliCommandContextFactoryDeps['retryAnilistQueueNow'];
generateCharacterDictionary: CliCommandContextFactoryDeps['generateCharacterDictionary'];
getCharacterDictionarySelection?: CliCommandContextFactoryDeps['getCharacterDictionarySelection'];
setCharacterDictionarySelection?: CliCommandContextFactoryDeps['setCharacterDictionarySelection'];
runStatsCommand: CliCommandContextFactoryDeps['runStatsCommand'];
runJellyfinCommand: (args: CliArgs) => Promise<void>;
runYoutubePlaybackFlow: CliCommandContextFactoryDeps['runYoutubePlaybackFlow'];
@@ -92,6 +95,7 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
isOverlayInitialized: () => deps.appState.overlayRuntimeInitialized,
initializeOverlay: () => deps.initializeOverlayRuntime(),
toggleVisibleOverlay: () => deps.toggleVisibleOverlay(),
togglePrimarySubtitleBar: () => deps.togglePrimarySubtitleBar(),
openFirstRunSetup: () => deps.openFirstRunSetupWindow(),
setVisibleOverlay: (visible: boolean) => deps.setVisibleOverlayVisible(visible),
copyCurrentSubtitle: () => deps.copyCurrentSubtitle(),
@@ -113,6 +117,8 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
retryAnilistQueueNow: () => deps.processNextAnilistRetryUpdate(),
generateCharacterDictionary: (targetPath?: string) =>
deps.generateCharacterDictionary(targetPath),
getCharacterDictionarySelection: deps.getCharacterDictionarySelection,
setCharacterDictionarySelection: deps.setCharacterDictionarySelection,
runStatsCommand: (args: CliArgs, source) => deps.runStatsCommand(args, source),
runJellyfinCommand: (args: CliArgs) => deps.runJellyfinCommand(args),
runYoutubePlaybackFlow: (request) => deps.runYoutubePlaybackFlow(request),

View File

@@ -25,6 +25,7 @@ function createDeps() {
isOverlayInitialized: () => true,
initializeOverlay: () => {},
toggleVisibleOverlay: () => {},
togglePrimarySubtitleBar: () => {},
openFirstRunSetup: () => {},
setVisibleOverlay: () => {},
copyCurrentSubtitle: () => {},

View File

@@ -22,6 +22,7 @@ export type CliCommandContextFactoryDeps = {
isOverlayInitialized: () => boolean;
initializeOverlay: () => void;
toggleVisibleOverlay: () => void;
togglePrimarySubtitleBar: () => void;
openFirstRunSetup: () => void;
setVisibleOverlay: (visible: boolean) => void;
copyCurrentSubtitle: () => void;
@@ -41,6 +42,8 @@ export type CliCommandContextFactoryDeps = {
getAnilistQueueStatus: CliCommandRuntimeServiceContext['getAnilistQueueStatus'];
retryAnilistQueueNow: CliCommandRuntimeServiceContext['retryAnilistQueueNow'];
generateCharacterDictionary: CliCommandRuntimeServiceContext['generateCharacterDictionary'];
getCharacterDictionarySelection?: CliCommandRuntimeServiceContext['getCharacterDictionarySelection'];
setCharacterDictionarySelection?: CliCommandRuntimeServiceContext['setCharacterDictionarySelection'];
runStatsCommand: CliCommandRuntimeServiceContext['runStatsCommand'];
runJellyfinCommand: (args: CliArgs) => Promise<void>;
runYoutubePlaybackFlow: CliCommandRuntimeServiceContext['runYoutubePlaybackFlow'];
@@ -79,6 +82,7 @@ export function createCliCommandContext(
isOverlayInitialized: deps.isOverlayInitialized,
initializeOverlay: deps.initializeOverlay,
toggleVisibleOverlay: deps.toggleVisibleOverlay,
togglePrimarySubtitleBar: deps.togglePrimarySubtitleBar,
openFirstRunSetup: deps.openFirstRunSetup,
setVisibleOverlay: deps.setVisibleOverlay,
copyCurrentSubtitle: deps.copyCurrentSubtitle,
@@ -98,6 +102,23 @@ export function createCliCommandContext(
getAnilistQueueStatus: deps.getAnilistQueueStatus,
retryAnilistQueueNow: deps.retryAnilistQueueNow,
generateCharacterDictionary: deps.generateCharacterDictionary,
getCharacterDictionarySelection:
deps.getCharacterDictionarySelection ??
(async () => ({
seriesKey: '',
guessTitle: null,
current: null,
override: null,
candidates: [],
})),
setCharacterDictionarySelection:
deps.setCharacterDictionarySelection ??
(async () => ({
ok: false,
seriesKey: '',
selected: { id: 0, title: '', episodes: null },
staleMediaIds: [],
})),
runStatsCommand: deps.runStatsCommand,
runJellyfinCommand: deps.runJellyfinCommand,
runYoutubePlaybackFlow: deps.runYoutubePlaybackFlow,

View File

@@ -19,6 +19,7 @@ test('composeCliStartupHandlers returns callable CLI startup handlers', () => {
showMpvOsd: () => {},
initializeOverlayRuntime: () => {},
toggleVisibleOverlay: () => {},
togglePrimarySubtitleBar: () => {},
openFirstRunSetupWindow: () => {},
setVisibleOverlayVisible: () => {},
copyCurrentSubtitle: () => {},

View File

@@ -20,12 +20,14 @@ function withTempDir(fn: (dir: string) => Promise<void> | void): Promise<void> |
function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
return {
background: false,
managedPlayback: false,
start: false,
launchMpv: false,
launchMpvTargets: [],
stop: false,
toggle: false,
toggleVisibleOverlay: false,
togglePrimarySubtitleBar: false,
settings: false,
setup: false,
show: false,
@@ -51,6 +53,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
openJimaku: false,
openYoutubePicker: false,
openPlaylistBrowser: false,
openCharacterDictionary: false,
replayCurrentSubtitle: false,
playNextSubtitle: false,
shiftSubDelayPrevLine: false,
@@ -62,6 +65,9 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
anilistSetup: false,
anilistRetryQueue: false,
dictionary: false,
dictionaryCandidates: false,
dictionarySelect: false,
dictionaryAnilistId: undefined,
stats: false,
jellyfin: false,
jellyfinLogin: false,

View File

@@ -60,6 +60,7 @@ function hasAnyStartupCommandBeyondSetup(args: CliArgs): boolean {
return Boolean(
args.toggle ||
args.toggleVisibleOverlay ||
args.togglePrimarySubtitleBar ||
args.launchMpv ||
args.settings ||
args.show ||

View File

@@ -16,6 +16,7 @@ function createShortcuts(): ConfiguredShortcuts {
multiCopyTimeoutMs: 5000,
toggleSecondarySub: null,
markAudioCard: null,
openCharacterDictionary: null,
openRuntimeOptions: null,
openJimaku: null,
openSessionHelp: null,

View File

@@ -20,6 +20,7 @@ function createShortcuts(): ConfiguredShortcuts {
multiCopyTimeoutMs: 5000,
toggleSecondarySub: null,
markAudioCard: null,
openCharacterDictionary: null,
openRuntimeOptions: null,
openJimaku: null,
openSessionHelp: null,

View File

@@ -11,6 +11,8 @@ export function createBuildMpvClientRuntimeServiceFactoryDepsHandler<
isVisibleOverlayVisible: () => boolean;
getReconnectTimer: () => ReturnType<typeof setTimeout> | null;
setReconnectTimer: (timer: ReturnType<typeof setTimeout> | null) => void;
shouldQuitOnMpvShutdown?: () => boolean;
requestAppQuit?: () => void;
bindEventHandlers: (client: TClient) => void;
}) {
return () => ({
@@ -24,6 +26,8 @@ export function createBuildMpvClientRuntimeServiceFactoryDepsHandler<
getReconnectTimer: () => deps.getReconnectTimer(),
setReconnectTimer: (timer: ReturnType<typeof setTimeout> | null) =>
deps.setReconnectTimer(timer),
shouldQuitOnMpvShutdown: () => deps.shouldQuitOnMpvShutdown?.() ?? false,
requestAppQuit: () => deps.requestAppQuit?.(),
},
bindEventHandlers: (client: TClient) => deps.bindEventHandlers(client),
});

View File

@@ -7,6 +7,8 @@ export type MpvClientRuntimeServiceOptions = {
isVisibleOverlayVisible: () => boolean;
getReconnectTimer: () => ReturnType<typeof setTimeout> | null;
setReconnectTimer: (timer: ReturnType<typeof setTimeout> | null) => void;
shouldQuitOnMpvShutdown?: () => boolean;
requestAppQuit?: () => void;
};
type MpvClientLike = {

View File

@@ -16,6 +16,7 @@ test('overlay shortcuts runtime main deps builder maps lifecycle and action call
isOverlayShortcutContextActive: () => false,
showMpvOsd: (text) => calls.push(`osd:${text}`),
openRuntimeOptionsPalette: () => calls.push('runtime-options'),
openCharacterDictionary: () => calls.push('character-dictionary'),
openJimaku: () => calls.push('jimaku'),
markAudioCard: async () => {
calls.push('mark-audio');
@@ -47,6 +48,7 @@ test('overlay shortcuts runtime main deps builder maps lifecycle and action call
assert.equal(shortcutsRegistered, true);
deps.showMpvOsd('x');
deps.openRuntimeOptionsPalette();
deps.openCharacterDictionary();
deps.openJimaku();
await deps.markAudioCard();
deps.copySubtitleMultiple(5000);
@@ -63,6 +65,7 @@ test('overlay shortcuts runtime main deps builder maps lifecycle and action call
'registered:true',
'osd:x',
'runtime-options',
'character-dictionary',
'jimaku',
'mark-audio',
'copy-multi:5000',

View File

@@ -11,6 +11,7 @@ export function createBuildOverlayShortcutsRuntimeMainDepsHandler(
isOverlayShortcutContextActive: () => deps.isOverlayShortcutContextActive?.() ?? true,
showMpvOsd: (text: string) => deps.showMpvOsd(text),
openRuntimeOptionsPalette: () => deps.openRuntimeOptionsPalette(),
openCharacterDictionary: () => deps.openCharacterDictionary(),
openJimaku: () => deps.openJimaku(),
markAudioCard: () => deps.markAudioCard(),
copySubtitleMultiple: (timeoutMs: number) => deps.copySubtitleMultiple(timeoutMs),

View File

@@ -41,7 +41,7 @@ test('build tray template handler wires actions and init guards', () => {
let initialized = false;
const buildTemplate = createBuildTrayMenuTemplateHandler({
buildTrayMenuTemplateRuntime: (handlers) => {
handlers.openOverlay();
handlers.openSessionHelp();
handlers.openFirstRunSetup();
handlers.openWindowsMpvLauncherSetup();
handlers.openYomitanSettings();
@@ -56,7 +56,7 @@ test('build tray template handler wires actions and init guards', () => {
calls.push('init');
},
isOverlayRuntimeInitialized: () => initialized,
setVisibleOverlayVisible: (visible) => calls.push(`visible:${visible}`),
openSessionHelpModal: () => calls.push('help'),
showFirstRunSetup: () => true,
openFirstRunSetupWindow: () => calls.push('setup'),
showWindowsMpvLauncherSetup: () => true,
@@ -71,7 +71,7 @@ test('build tray template handler wires actions and init guards', () => {
assert.deepEqual(template, [{ label: 'ok' }]);
assert.deepEqual(calls, [
'init',
'visible:true',
'help',
'setup',
'setup',
'yomitan',

View File

@@ -28,7 +28,7 @@ export function createResolveTrayIconPathHandler(deps: {
export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
buildTrayMenuTemplateRuntime: (handlers: {
openOverlay: () => void;
openSessionHelp: () => void;
openFirstRunSetup: () => void;
showFirstRunSetup: boolean;
openWindowsMpvLauncherSetup: () => void;
@@ -41,7 +41,7 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
}) => TMenuItem[];
initializeOverlayRuntime: () => void;
isOverlayRuntimeInitialized: () => boolean;
setVisibleOverlayVisible: (visible: boolean) => void;
openSessionHelpModal: () => void;
showFirstRunSetup: () => boolean;
openFirstRunSetupWindow: () => void;
showWindowsMpvLauncherSetup: () => boolean;
@@ -53,11 +53,11 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
}) {
return (): TMenuItem[] => {
return deps.buildTrayMenuTemplateRuntime({
openOverlay: () => {
openSessionHelp: () => {
if (!deps.isOverlayRuntimeInitialized()) {
deps.initializeOverlayRuntime();
}
deps.setVisibleOverlayVisible(true);
deps.openSessionHelpModal();
},
openFirstRunSetup: () => {
deps.openFirstRunSetupWindow();

View File

@@ -24,7 +24,7 @@ test('tray main deps builders return mapped handlers', () => {
buildTrayMenuTemplateRuntime: () => [{ label: 'tray' }] as never,
initializeOverlayRuntime: () => calls.push('init'),
isOverlayRuntimeInitialized: () => false,
setVisibleOverlayVisible: (visible) => calls.push(`visible:${visible}`),
openSessionHelpModal: () => calls.push('help'),
showFirstRunSetup: () => true,
openFirstRunSetupWindow: () => calls.push('setup'),
showWindowsMpvLauncherSetup: () => true,
@@ -36,7 +36,7 @@ test('tray main deps builders return mapped handlers', () => {
})();
const template = menuDeps.buildTrayMenuTemplateRuntime({
openOverlay: () => calls.push('open-overlay'),
openSessionHelp: () => calls.push('open-help'),
openFirstRunSetup: () => calls.push('open-setup'),
showFirstRunSetup: true,
openWindowsMpvLauncherSetup: () => calls.push('open-windows-mpv'),

View File

@@ -27,7 +27,7 @@ export function createBuildResolveTrayIconPathMainDepsHandler(deps: {
export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: {
buildTrayMenuTemplateRuntime: (handlers: {
openOverlay: () => void;
openSessionHelp: () => void;
openFirstRunSetup: () => void;
showFirstRunSetup: boolean;
openWindowsMpvLauncherSetup: () => void;
@@ -40,7 +40,7 @@ export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: {
}) => TMenuItem[];
initializeOverlayRuntime: () => void;
isOverlayRuntimeInitialized: () => boolean;
setVisibleOverlayVisible: (visible: boolean) => void;
openSessionHelpModal: () => void;
showFirstRunSetup: () => boolean;
openFirstRunSetupWindow: () => void;
showWindowsMpvLauncherSetup: () => boolean;
@@ -54,7 +54,7 @@ export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: {
buildTrayMenuTemplateRuntime: deps.buildTrayMenuTemplateRuntime,
initializeOverlayRuntime: deps.initializeOverlayRuntime,
isOverlayRuntimeInitialized: deps.isOverlayRuntimeInitialized,
setVisibleOverlayVisible: deps.setVisibleOverlayVisible,
openSessionHelpModal: deps.openSessionHelpModal,
showFirstRunSetup: deps.showFirstRunSetup,
openFirstRunSetupWindow: deps.openFirstRunSetupWindow,
showWindowsMpvLauncherSetup: deps.showWindowsMpvLauncherSetup,

View File

@@ -19,14 +19,12 @@ test('tray runtime handlers compose resolve/menu/ensure/destroy handlers', () =>
fileExists: () => true,
},
buildTrayMenuTemplateDeps: {
buildTrayMenuTemplateRuntime: () => [{ label: 'Open Overlay' }],
buildTrayMenuTemplateRuntime: () => [{ label: 'Open Help' }],
initializeOverlayRuntime: () => {
overlayInitialized = true;
},
isOverlayRuntimeInitialized: () => overlayInitialized,
setVisibleOverlayVisible: (visible) => {
visibleOverlay = visible;
},
openSessionHelpModal: () => {},
showFirstRunSetup: () => true,
openFirstRunSetupWindow: () => {},
showWindowsMpvLauncherSetup: () => true,
@@ -88,7 +86,7 @@ test('tray runtime handlers compose resolve/menu/ensure/destroy handlers', () =>
});
assert.equal(runtime.resolveTrayIconPath(), '/tmp/SubMiner.png');
assert.deepEqual(runtime.buildTrayMenu(), { template: [{ label: 'Open Overlay' }] });
assert.deepEqual(runtime.buildTrayMenu(), { template: [{ label: 'Open Help' }] });
runtime.ensureTray();
assert.equal(overlayInitialized, true);
assert.equal(visibleOverlay, true);

View File

@@ -29,7 +29,7 @@ test('resolve tray icon returns null when no asset exists', () => {
test('tray menu template contains expected entries and handlers', () => {
const calls: string[] = [];
const template = buildTrayMenuTemplateRuntime({
openOverlay: () => calls.push('overlay'),
openSessionHelp: () => calls.push('help'),
openFirstRunSetup: () => calls.push('setup'),
showFirstRunSetup: true,
openWindowsMpvLauncherSetup: () => calls.push('windows-mpv'),
@@ -42,15 +42,17 @@ test('tray menu template contains expected entries and handlers', () => {
});
assert.equal(template.length, 9);
assert.equal(template.some((entry) => entry.label === 'Open Overlay'), false);
assert.equal(template[0]!.label, 'Open Help');
template[0]!.click?.();
template[7]!.type === 'separator' ? calls.push('separator') : calls.push('bad');
template[8]!.click?.();
assert.deepEqual(calls, ['overlay', 'separator', 'quit']);
assert.deepEqual(calls, ['help', 'separator', 'quit']);
});
test('tray menu template omits first-run setup entry when setup is complete', () => {
const labels = buildTrayMenuTemplateRuntime({
openOverlay: () => undefined,
openSessionHelp: () => undefined,
openFirstRunSetup: () => undefined,
showFirstRunSetup: false,
openWindowsMpvLauncherSetup: () => undefined,

View File

@@ -30,7 +30,7 @@ export function resolveTrayIconPathRuntime(deps: {
}
export type TrayMenuActionHandlers = {
openOverlay: () => void;
openSessionHelp: () => void;
openFirstRunSetup: () => void;
showFirstRunSetup: boolean;
openWindowsMpvLauncherSetup: () => void;
@@ -49,8 +49,8 @@ export function buildTrayMenuTemplateRuntime(handlers: TrayMenuActionHandlers):
}> {
return [
{
label: 'Open Overlay',
click: handlers.openOverlay,
label: 'Open Help',
click: handlers.openSessionHelp,
},
...(handlers.showFirstRunSetup
? [

View File

@@ -124,6 +124,9 @@ function createQueuedIpcListenerWithPayload<T>(
const onOpenRuntimeOptionsEvent = createQueuedIpcListener(IPC_CHANNELS.event.runtimeOptionsOpen);
const onOpenSessionHelpEvent = createQueuedIpcListener(IPC_CHANNELS.event.sessionHelpOpen);
const onOpenCharacterDictionaryEvent = createQueuedIpcListener(
IPC_CHANNELS.event.characterDictionaryOpen,
);
const onOpenControllerSelectEvent = createQueuedIpcListener(
IPC_CHANNELS.event.controllerSelectOpen,
);
@@ -150,6 +153,9 @@ const onSubsyncManualOpenEvent = createQueuedIpcListenerWithPayload<SubsyncManua
const onSubtitleSidebarToggleEvent = createQueuedIpcListener(
IPC_CHANNELS.event.subtitleSidebarToggle,
);
const onPrimarySubtitleBarToggleEvent = createQueuedIpcListener(
IPC_CHANNELS.event.primarySubtitleBarToggle,
);
const onKikuFieldGroupingRequestEvent =
createQueuedIpcListenerWithPayload<KikuFieldGroupingRequestData>(
IPC_CHANNELS.event.kikuFieldGroupingRequest,
@@ -340,7 +346,9 @@ const electronAPI: ElectronAPI = {
onOpenJimaku: onOpenJimakuEvent,
onOpenYoutubeTrackPicker: onOpenYoutubeTrackPickerEvent,
onOpenPlaylistBrowser: onOpenPlaylistBrowserEvent,
onOpenCharacterDictionary: onOpenCharacterDictionaryEvent,
onSubtitleSidebarToggle: onSubtitleSidebarToggleEvent,
onPrimarySubtitleBarToggle: onPrimarySubtitleBarToggleEvent,
onCancelYoutubeTrackPicker: onCancelYoutubeTrackPickerEvent,
onKeyboardModeToggleRequested: onKeyboardModeToggleRequestedEvent,
onLookupWindowToggleRequested: onLookupWindowToggleRequestedEvent,
@@ -363,6 +371,10 @@ const electronAPI: ElectronAPI = {
request: YoutubePickerResolveRequest,
): Promise<YoutubePickerResolveResult> =>
ipcRenderer.invoke(IPC_CHANNELS.request.youtubePickerResolve, request),
getCharacterDictionarySelection: () =>
ipcRenderer.invoke(IPC_CHANNELS.request.getCharacterDictionarySelection),
setCharacterDictionarySelection: (mediaId: number) =>
ipcRenderer.invoke(IPC_CHANNELS.request.setCharacterDictionarySelection, mediaId),
notifyOverlayModalClosed: (modal) => {
ipcRenderer.send(IPC_CHANNELS.command.overlayModalClosed, modal);
},

View File

@@ -330,6 +330,7 @@ function installKeyboardTestGlobals() {
function createKeyboardHandlerHarness() {
const testGlobals = installKeyboardTestGlobals();
const subtitleRootClassList = createClassList();
const subtitleContainerClassList = createClassList();
let controllerSelectKeydownCount = 0;
let openControllerSelectCount = 0;
let openControllerDebugCount = 0;
@@ -349,6 +350,7 @@ function createKeyboardHandlerHarness() {
querySelectorAll: () => wordNodes,
},
subtitleContainer: {
classList: subtitleContainerClassList,
contains: () => false,
},
overlay: testGlobals.overlay,
@@ -365,6 +367,7 @@ function createKeyboardHandlerHarness() {
const handlers = createKeyboardHandlers(ctx as never, {
handleRuntimeOptionsKeydown: () => false,
handleCharacterDictionaryKeydown: () => false,
handleSubsyncKeydown: () => false,
handleKikuKeydown: () => false,
handleJimakuKeydown: () => false,
@@ -404,6 +407,26 @@ function createKeyboardHandlerHarness() {
};
}
test('primary subtitle visibility key hides and restores the subtitle bar without mpv sub-visibility', async () => {
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
try {
await handlers.setupMpvInputForwarding();
testGlobals.dispatchKeydown({ key: 'v', code: 'KeyV' });
assert.equal(ctx.dom.subtitleContainer.classList.contains('primary-sub-hidden'), true);
testGlobals.dispatchKeydown({ key: 'v', code: 'KeyV' });
assert.equal(ctx.dom.subtitleContainer.classList.contains('primary-sub-hidden'), false);
assert.equal(
testGlobals.mpvCommands.some((command) => command.includes('sub-visibility')),
false,
);
} finally {
testGlobals.restore();
}
});
test('session help chord resolver follows remapped session bindings', async () => {
const { handlers, testGlobals } = createKeyboardHandlerHarness();
@@ -1119,6 +1142,32 @@ test('session binding: Ctrl+Shift+O dispatches runtime options locally', async (
}
});
test('session binding: copy subtitle multiple captures follow-up digit locally', async () => {
const { handlers, testGlobals } = createKeyboardHandlerHarness();
try {
await handlers.setupMpvInputForwarding();
handlers.updateSessionBindings([
{
sourcePath: 'shortcuts.copySubtitleMultiple',
originalKey: 'Ctrl+M',
key: { code: 'KeyM', modifiers: ['ctrl'] },
actionType: 'session-action',
actionId: 'copySubtitleMultiple',
},
] as never);
testGlobals.dispatchKeydown({ key: 'm', code: 'KeyM', ctrlKey: true });
testGlobals.dispatchKeydown({ key: '3', code: 'Digit3' });
assert.deepEqual(testGlobals.sessionActions, [
{ actionId: 'copySubtitleMultiple', payload: { count: 3 } },
]);
} finally {
testGlobals.restore();
}
});
test('keyboard mode: h moves left when popup is closed', async () => {
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();

View File

@@ -12,6 +12,7 @@ export function createKeyboardHandlers(
ctx: RendererContext,
options: {
handleRuntimeOptionsKeydown: (e: KeyboardEvent) => boolean;
handleCharacterDictionaryKeydown: (e: KeyboardEvent) => boolean;
handleSubsyncKeydown: (e: KeyboardEvent) => boolean;
handleKikuKeydown: (e: KeyboardEvent) => boolean;
handleJimakuKeydown: (e: KeyboardEvent) => boolean;
@@ -360,6 +361,20 @@ export function createKeyboardHandlers(
);
}
function isPrimarySubtitleVisibilityToggle(e: KeyboardEvent): boolean {
return e.code === 'KeyV' && !e.ctrlKey && !e.altKey && !e.metaKey && !e.shiftKey && !e.repeat;
}
function togglePrimarySubtitleBarVisibility(): void {
const visible = !ctx.state.primarySubtitleBarVisible;
ctx.state.primarySubtitleBarVisible = visible;
if (visible) {
ctx.dom.subtitleContainer.classList.remove('primary-sub-hidden');
} else {
ctx.dom.subtitleContainer.classList.add('primary-sub-hidden');
}
}
async function handleMarkWatched(): Promise<void> {
const marked = await window.electronAPI.markActiveVideoWatched();
if (marked) {
@@ -1004,6 +1019,10 @@ export function createKeyboardHandlers(
options.handleRuntimeOptionsKeydown(e);
return;
}
if (ctx.state.characterDictionaryModalOpen) {
options.handleCharacterDictionaryKeydown(e);
return;
}
if (ctx.state.subsyncModalOpen) {
options.handleSubsyncKeydown(e);
return;
@@ -1060,6 +1079,12 @@ export function createKeyboardHandlers(
return;
}
if (isPrimarySubtitleVisibilityToggle(e)) {
e.preventDefault();
togglePrimarySubtitleBarVisibility();
return;
}
if (ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document)) {
if (handleYomitanPopupKeybind(e)) {
e.preventDefault();
@@ -1147,6 +1172,7 @@ export function createKeyboardHandlers(
updateSessionBindings,
syncKeyboardTokenSelection,
handleSubtitleContentUpdated,
togglePrimarySubtitleBarVisibility,
handleKeyboardModeToggleRequested,
handleLookupWindowToggleRequested,
closeLookupWindow,

View File

@@ -197,6 +197,20 @@
</div>
</div>
</div>
<div id="characterDictionaryModal" class="modal hidden" aria-hidden="true">
<div class="modal-content character-dictionary-content">
<div class="modal-header">
<div class="modal-title">Character Dictionary Anime</div>
<button id="characterDictionaryClose" class="modal-close" type="button">Close</button>
</div>
<div class="modal-body">
<div id="characterDictionarySummary" class="runtime-options-hint"></div>
<div id="characterDictionaryCurrent" class="character-dictionary-current"></div>
<ul id="characterDictionaryCandidates" class="character-dictionary-candidates"></ul>
<div id="characterDictionaryStatus" class="runtime-options-status"></div>
</div>
</div>
</div>
<div id="subsyncModal" class="modal hidden" aria-hidden="true">
<div class="modal-content subsync-modal-content">
<div class="modal-header">

View File

@@ -0,0 +1,215 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { CharacterDictionarySelectionSnapshot, ElectronAPI } from '../../types';
import { createRendererState } from '../state.js';
import { createCharacterDictionaryModal } from './character-dictionary.js';
function createClassList(initialTokens: string[] = []) {
const tokens = new Set(initialTokens);
return {
add: (...entries: string[]) => entries.forEach((entry) => tokens.add(entry)),
remove: (...entries: string[]) => entries.forEach((entry) => tokens.delete(entry)),
toggle: (entry: string, force?: boolean) => {
if (force === undefined) {
if (tokens.has(entry)) tokens.delete(entry);
else tokens.add(entry);
return;
}
if (force) tokens.add(entry);
else tokens.delete(entry);
},
contains: (entry: string) => tokens.has(entry),
};
}
function createElementStub() {
return {
className: '',
textContent: '',
type: '',
children: [] as unknown[],
classList: createClassList(),
append(...children: unknown[]) {
this.children.push(...children);
},
addEventListener: () => {},
};
}
function createNodeStub(hidden = false) {
const listeners = new Map<string, Array<() => void>>();
return {
textContent: '',
children: [] as unknown[],
classList: createClassList(hidden ? ['hidden'] : []),
setAttribute: () => {},
addEventListener: (event: string, listener: () => void) => {
listeners.set(event, [...(listeners.get(event) ?? []), listener]);
},
dispatchEvent: (event: string) => {
for (const listener of listeners.get(event) ?? []) listener();
},
replaceChildren(...children: unknown[]) {
this.children = [...children];
},
};
}
function flushAsyncWork(): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, 0);
});
}
test('character dictionary modal loads candidates and applies selected override', async () => {
const previousWindow = globalThis.window;
const previousDocument = globalThis.document;
const snapshot: CharacterDictionarySelectionSnapshot = {
seriesKey: 're-zero-starting-life-in-another-world-2016',
guessTitle: 'Re ZERO, Starting Life in Another World',
current: { id: 10607, title: 'Rerere no Tensai Bakabon', episodes: 24 },
override: null,
candidates: [{ id: 21355, title: 'Re:ZERO -Starting Life in Another World-', episodes: 25 }],
};
const calls: number[] = [];
const overlay = createNodeStub();
const modalNode = createNodeStub(true);
const closeButton = createNodeStub();
const candidates = createNodeStub();
const status = createNodeStub();
const state = createRendererState();
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
electronAPI: {
getCharacterDictionarySelection: async () => snapshot,
setCharacterDictionarySelection: async (mediaId: number) => {
calls.push(mediaId);
return {
ok: true,
seriesKey: snapshot.seriesKey,
selected: snapshot.candidates[0]!,
staleMediaIds: [10607],
};
},
notifyOverlayModalClosed: () => {},
} satisfies Pick<
ElectronAPI,
| 'getCharacterDictionarySelection'
| 'setCharacterDictionarySelection'
| 'notifyOverlayModalClosed'
>,
},
});
Object.defineProperty(globalThis, 'document', {
configurable: true,
value: {
createElement: () => createElementStub(),
},
});
try {
const modal = createCharacterDictionaryModal(
{
state,
dom: {
overlay,
characterDictionaryModal: modalNode,
characterDictionaryClose: closeButton,
characterDictionarySummary: createNodeStub(),
characterDictionaryCurrent: createNodeStub(),
characterDictionaryCandidates: candidates,
characterDictionaryStatus: status,
},
} as never,
{
modalStateReader: { isAnyModalOpen: () => false },
syncSettingsModalSubtitleSuppression: () => {},
},
);
modal.wireDomEvents();
await modal.openCharacterDictionaryModal();
assert.equal(state.characterDictionaryModalOpen, true);
assert.equal(overlay.classList.contains('interactive'), true);
assert.equal(modalNode.classList.contains('hidden'), false);
assert.equal(candidates.children.length, 1);
modal.handleCharacterDictionaryKeydown({
key: 'Enter',
preventDefault: () => {},
} as KeyboardEvent);
await flushAsyncWork();
assert.deepEqual(calls, [21355]);
assert.match(status.textContent, /Override saved/);
closeButton.dispatchEvent('click');
assert.equal(state.characterDictionaryModalOpen, false);
} finally {
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
}
});
test('character dictionary modal shows refresh errors without rejecting open', async () => {
const previousWindow = globalThis.window;
const overlay = createNodeStub();
const modalNode = createNodeStub(true);
const status = createNodeStub();
const state = createRendererState();
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
electronAPI: {
getCharacterDictionarySelection: async () => {
throw new Error('candidate lookup failed');
},
setCharacterDictionarySelection: async () => ({
ok: false,
seriesKey: 'test',
selected: { id: 0, title: '', episodes: null },
staleMediaIds: [],
}),
notifyOverlayModalClosed: () => {},
} satisfies Pick<
ElectronAPI,
| 'getCharacterDictionarySelection'
| 'setCharacterDictionarySelection'
| 'notifyOverlayModalClosed'
>,
},
});
try {
const modal = createCharacterDictionaryModal(
{
state,
dom: {
overlay,
characterDictionaryModal: modalNode,
characterDictionaryClose: createNodeStub(),
characterDictionarySummary: createNodeStub(),
characterDictionaryCurrent: createNodeStub(),
characterDictionaryCandidates: createNodeStub(),
characterDictionaryStatus: status,
},
} as never,
{
modalStateReader: { isAnyModalOpen: () => false },
syncSettingsModalSubtitleSuppression: () => {},
},
);
await modal.openCharacterDictionaryModal();
assert.equal(state.characterDictionaryModalOpen, true);
assert.equal(status.textContent, 'candidate lookup failed');
assert.equal(status.classList.contains('error'), true);
} finally {
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
}
});

View File

@@ -0,0 +1,231 @@
import type {
CharacterDictionaryCandidate,
CharacterDictionarySelectionSnapshot,
} from '../../types';
import type { ModalStateReader, RendererContext } from '../context';
function clampIndex(index: number, length: number): number {
if (length <= 0) return 0;
return Math.min(Math.max(index, 0), length - 1);
}
function formatCandidate(candidate: CharacterDictionaryCandidate | null): string {
if (!candidate) return 'None';
const episodes = candidate.episodes === null ? '?' : String(candidate.episodes);
return `${candidate.id} - ${candidate.title} (${episodes} episodes)`;
}
function buildSummary(snapshot: CharacterDictionarySelectionSnapshot): string {
const guess = snapshot.guessTitle ?? 'No active title';
return `Series key: ${snapshot.seriesKey} · Guess: ${guess}`;
}
export function createCharacterDictionaryModal(
ctx: RendererContext,
options: {
modalStateReader: Pick<ModalStateReader, 'isAnyModalOpen'>;
syncSettingsModalSubtitleSuppression: () => void;
},
) {
function setStatus(message: string, isError = false): void {
ctx.state.characterDictionaryStatus = message;
ctx.dom.characterDictionaryStatus.textContent = message;
ctx.dom.characterDictionaryStatus.classList.toggle('error', isError);
}
function setSelection(snapshot: CharacterDictionarySelectionSnapshot): void {
const previousId =
ctx.state.characterDictionarySelection?.candidates[ctx.state.characterDictionarySelectedIndex]
?.id;
ctx.state.characterDictionarySelection = snapshot;
const nextIndex = snapshot.candidates.findIndex((candidate) => candidate.id === previousId);
ctx.state.characterDictionarySelectedIndex = clampIndex(
nextIndex >= 0 ? nextIndex : 0,
snapshot.candidates.length,
);
render();
}
function renderCandidate(candidate: CharacterDictionaryCandidate, index: number): HTMLLIElement {
const item = document.createElement('li');
item.className = 'character-dictionary-candidate';
item.classList.toggle('active', index === ctx.state.characterDictionarySelectedIndex);
const main = document.createElement('div');
main.className = 'runtime-options-label';
main.textContent = candidate.title;
const meta = document.createElement('div');
meta.className = 'runtime-options-allowed';
const episodeLabel = candidate.episodes === null ? '?' : String(candidate.episodes);
meta.textContent = `AniList ${candidate.id} · ${episodeLabel} episodes`;
const button = document.createElement('button');
button.className = 'character-dictionary-use';
button.type = 'button';
button.textContent = 'Use';
button.addEventListener('click', (event) => {
event.stopPropagation();
ctx.state.characterDictionarySelectedIndex = index;
void applySelectedCandidate();
});
const body = document.createElement('div');
body.className = 'character-dictionary-candidate-body';
body.append(main, meta);
item.append(body, button);
item.addEventListener('click', () => {
ctx.state.characterDictionarySelectedIndex = index;
render();
});
item.addEventListener('dblclick', () => {
ctx.state.characterDictionarySelectedIndex = index;
void applySelectedCandidate();
});
return item;
}
function render(): void {
const snapshot = ctx.state.characterDictionarySelection;
ctx.dom.characterDictionaryCandidates.replaceChildren();
if (!snapshot) {
ctx.dom.characterDictionarySummary.textContent = '';
ctx.dom.characterDictionaryCurrent.textContent = '';
return;
}
ctx.dom.characterDictionarySummary.textContent = buildSummary(snapshot);
ctx.dom.characterDictionaryCurrent.textContent = `Current: ${formatCandidate(
snapshot.current,
)} · Override: ${formatCandidate(snapshot.override)}`;
if (snapshot.candidates.length === 0) {
const empty = document.createElement('li');
empty.className = 'character-dictionary-empty';
empty.textContent = 'No AniList candidates found.';
ctx.dom.characterDictionaryCandidates.append(empty);
return;
}
ctx.dom.characterDictionaryCandidates.replaceChildren(
...snapshot.candidates.map((candidate, index) => renderCandidate(candidate, index)),
);
}
async function refreshSelection(): Promise<void> {
const snapshot = await window.electronAPI.getCharacterDictionarySelection();
setSelection(snapshot);
setStatus(
snapshot.override
? `Override active: ${formatCandidate(snapshot.override)}`
: 'Select the correct AniList entry.',
);
}
async function applySelectedCandidate(): Promise<void> {
const snapshot = ctx.state.characterDictionarySelection;
const candidate = snapshot?.candidates[ctx.state.characterDictionarySelectedIndex];
if (!candidate) return;
setStatus(`Saving override for ${candidate.title}...`);
try {
const result = await window.electronAPI.setCharacterDictionarySelection(candidate.id);
if (!result.ok) {
setStatus('Failed to save override', true);
return;
}
await refreshSelection();
const staleLabel =
result.staleMediaIds.length > 0
? ` Removed stale: ${result.staleMediaIds.join(', ')}.`
: '';
setStatus(`Override saved: ${formatCandidate(result.selected)}.${staleLabel}`);
} catch (error) {
setStatus(error instanceof Error ? error.message : String(error), true);
}
}
function showShell(): void {
ctx.state.characterDictionaryModalOpen = true;
options.syncSettingsModalSubtitleSuppression();
ctx.dom.overlay.classList.add('interactive');
ctx.dom.characterDictionaryModal.classList.remove('hidden');
ctx.dom.characterDictionaryModal.setAttribute('aria-hidden', 'false');
setStatus('Loading AniList candidates...');
}
async function openCharacterDictionaryModal(): Promise<void> {
if (!ctx.state.characterDictionaryModalOpen) {
showShell();
} else {
setStatus('Refreshing AniList candidates...');
}
try {
await refreshSelection();
} catch (error) {
setStatus(error instanceof Error ? error.message : String(error), true);
}
}
function closeCharacterDictionaryModal(): void {
if (!ctx.state.characterDictionaryModalOpen) return;
ctx.state.characterDictionaryModalOpen = false;
ctx.state.characterDictionarySelection = null;
options.syncSettingsModalSubtitleSuppression();
ctx.dom.characterDictionaryModal.classList.add('hidden');
ctx.dom.characterDictionaryModal.setAttribute('aria-hidden', 'true');
ctx.dom.characterDictionaryCandidates.replaceChildren();
window.electronAPI.notifyOverlayModalClosed('character-dictionary');
setStatus('');
if (!ctx.state.isOverSubtitle && !options.modalStateReader.isAnyModalOpen()) {
ctx.dom.overlay.classList.remove('interactive');
}
}
function moveSelection(delta: -1 | 1): void {
const length = ctx.state.characterDictionarySelection?.candidates.length ?? 0;
if (length <= 0) return;
ctx.state.characterDictionarySelectedIndex = clampIndex(
ctx.state.characterDictionarySelectedIndex + delta,
length,
);
render();
}
function handleCharacterDictionaryKeydown(e: KeyboardEvent): boolean {
if (e.key === 'Escape') {
e.preventDefault();
closeCharacterDictionaryModal();
return true;
}
if (e.key === 'ArrowDown' || e.key === 'j' || e.key === 'J') {
e.preventDefault();
moveSelection(1);
return true;
}
if (e.key === 'ArrowUp' || e.key === 'k' || e.key === 'K') {
e.preventDefault();
moveSelection(-1);
return true;
}
if (e.key === 'Enter') {
e.preventDefault();
void applySelectedCandidate();
return true;
}
return false;
}
function wireDomEvents(): void {
ctx.dom.characterDictionaryClose.addEventListener('click', closeCharacterDictionaryModal);
}
return {
openCharacterDictionaryModal,
closeCharacterDictionaryModal,
handleCharacterDictionaryKeydown,
wireDomEvents,
};
}

View File

@@ -0,0 +1,29 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { SPECIAL_COMMANDS } from '../../config/definitions';
import {
describeSessionHelpCommand,
formatSessionHelpKeybinding,
} from './session-help.js';
test('session help describes sub-seek commands as subtitle-line navigation', () => {
assert.equal(describeSessionHelpCommand(['sub-seek', 1]), 'Jump to next subtitle');
assert.equal(describeSessionHelpCommand(['sub-seek', -1]), 'Jump to previous subtitle');
});
test('session help describes subtitle-delay shift special commands separately from sub-seek', () => {
assert.equal(
describeSessionHelpCommand([SPECIAL_COMMANDS.SHIFT_SUB_DELAY_TO_NEXT_SUBTITLE_START]),
'Shift subtitle delay to next cue',
);
assert.equal(
describeSessionHelpCommand([SPECIAL_COMMANDS.SHIFT_SUB_DELAY_TO_PREVIOUS_SUBTITLE_START]),
'Shift subtitle delay to previous cue',
);
});
test('session help formats bracket keybindings as physical keys', () => {
assert.equal(formatSessionHelpKeybinding('Shift+BracketRight'), 'Shift + ]');
assert.equal(formatSessionHelpKeybinding('Shift+BracketLeft'), 'Shift + [');
});

View File

@@ -44,6 +44,8 @@ const KEY_NAME_MAP: Record<string, string> = {
Escape: 'Esc',
Tab: 'Tab',
Enter: 'Enter',
BracketLeft: '[',
BracketRight: ']',
CommandOrControl: 'Cmd/Ctrl',
Ctrl: 'Ctrl',
Control: 'Ctrl',
@@ -94,6 +96,7 @@ const OVERLAY_SHORTCUTS: Array<{
{ key: 'mineSentenceMultiple', label: 'Mine sentence (multi)' },
{ key: 'toggleSecondarySub', label: 'Toggle secondary subtitle mode' },
{ key: 'markAudioCard', label: 'Mark audio card' },
{ key: 'openCharacterDictionary', label: 'Open character dictionary anime selector' },
{ key: 'openRuntimeOptions', label: 'Open runtime options' },
{ key: 'openJimaku', label: 'Open jimaku' },
{ key: 'openSessionHelp', label: 'Open session help' },
@@ -131,7 +134,9 @@ function describeCommand(command: (string | number)[]): string {
return `Seek ${command[1] > 0 ? '+' : ''}${command[1]} second(s)`;
}
if (first === 'sub-seek' && typeof command[1] === 'number') {
return `Shift subtitle by ${command[1]} ms`;
if (command[1] > 0) return 'Jump to next subtitle';
if (command[1] < 0) return 'Jump to previous subtitle';
return 'Reload current subtitle timing';
}
if (first === SPECIAL_COMMANDS.SUBSYNC_TRIGGER) return 'Open subtitle sync controls';
if (first === SPECIAL_COMMANDS.RUNTIME_OPTIONS_OPEN) return 'Open runtime options';
@@ -139,6 +144,12 @@ function describeCommand(command: (string | number)[]): string {
if (first === SPECIAL_COMMANDS.PLAYLIST_BROWSER_OPEN) return 'Open playlist browser';
if (first === SPECIAL_COMMANDS.REPLAY_SUBTITLE) return 'Replay current subtitle';
if (first === SPECIAL_COMMANDS.PLAY_NEXT_SUBTITLE) return 'Play next subtitle';
if (first === SPECIAL_COMMANDS.SHIFT_SUB_DELAY_TO_NEXT_SUBTITLE_START) {
return 'Shift subtitle delay to next cue';
}
if (first === SPECIAL_COMMANDS.SHIFT_SUB_DELAY_TO_PREVIOUS_SUBTITLE_START) {
return 'Shift subtitle delay to previous cue';
}
if (first.startsWith(SPECIAL_COMMANDS.RUNTIME_OPTION_CYCLE_PREFIX)) {
const [, rawId, rawDirection] = first.split(':');
return `Cycle runtime option ${rawId || 'option'} ${rawDirection === 'prev' ? 'previous' : 'next'}`;
@@ -147,6 +158,11 @@ function describeCommand(command: (string | number)[]): string {
return `MPV command: ${command.map((entry) => String(entry)).join(' ')}`;
}
export {
describeCommand as describeSessionHelpCommand,
formatKeybinding as formatSessionHelpKeybinding,
};
function sectionForCommand(command: (string | number)[]): string {
const first = command[0];
if (typeof first !== 'string') return 'Other shortcuts';

View File

@@ -38,6 +38,7 @@ import { createPlaylistBrowserModal } from './modals/playlist-browser.js';
import { createSessionHelpModal } from './modals/session-help.js';
import { createSubtitleSidebarModal } from './modals/subtitle-sidebar.js';
import { isControllerInteractionBlocked } from './controller-interaction-blocking.js';
import { createCharacterDictionaryModal } from './modals/character-dictionary.js';
import { createRuntimeOptionsModal } from './modals/runtime-options.js';
import { createSubsyncModal } from './modals/subsync.js';
import { createYoutubeTrackPickerModal } from './modals/youtube-track-picker.js';
@@ -71,6 +72,7 @@ function isAnySettingsModalOpen(): boolean {
ctx.state.controllerSelectModalOpen ||
ctx.state.controllerDebugModalOpen ||
ctx.state.runtimeOptionsModalOpen ||
ctx.state.characterDictionaryModalOpen ||
ctx.state.subsyncModalOpen ||
ctx.state.kikuModalOpen ||
ctx.state.jimakuModalOpen ||
@@ -87,6 +89,7 @@ function isAnyModalOpen(): boolean {
ctx.state.jimakuModalOpen ||
ctx.state.kikuModalOpen ||
ctx.state.runtimeOptionsModalOpen ||
ctx.state.characterDictionaryModalOpen ||
ctx.state.subsyncModalOpen ||
ctx.state.youtubePickerModalOpen ||
ctx.state.sessionHelpModalOpen ||
@@ -114,6 +117,10 @@ const runtimeOptionsModal = createRuntimeOptionsModal(ctx, {
modalStateReader: { isAnyModalOpen },
syncSettingsModalSubtitleSuppression,
});
const characterDictionaryModal = createCharacterDictionaryModal(ctx, {
modalStateReader: { isAnyModalOpen },
syncSettingsModalSubtitleSuppression,
});
const subsyncModal = createSubsyncModal(ctx, {
modalStateReader: { isAnyModalOpen },
syncSettingsModalSubtitleSuppression,
@@ -165,6 +172,7 @@ const playlistBrowserModal = createPlaylistBrowserModal(ctx, {
});
const keyboardHandlers = createKeyboardHandlers(ctx, {
handleRuntimeOptionsKeydown: runtimeOptionsModal.handleRuntimeOptionsKeydown,
handleCharacterDictionaryKeydown: characterDictionaryModal.handleCharacterDictionaryKeydown,
handleSubsyncKeydown: subsyncModal.handleSubsyncKeydown,
handleKikuKeydown: kikuModal.handleKikuKeydown,
handleJimakuKeydown: jimakuModal.handleJimakuKeydown,
@@ -221,6 +229,7 @@ function getActiveModal(): string | null {
if (ctx.state.playlistBrowserModalOpen) return 'playlist-browser';
if (ctx.state.kikuModalOpen) return 'kiku';
if (ctx.state.runtimeOptionsModalOpen) return 'runtime-options';
if (ctx.state.characterDictionaryModalOpen) return 'character-dictionary';
if (ctx.state.subsyncModalOpen) return 'subsync';
if (ctx.state.sessionHelpModalOpen) return 'session-help';
return null;
@@ -248,6 +257,9 @@ function dismissActiveUiAfterError(): void {
if (ctx.state.runtimeOptionsModalOpen) {
runtimeOptionsModal.closeRuntimeOptionsModal();
}
if (ctx.state.characterDictionaryModalOpen) {
characterDictionaryModal.closeCharacterDictionaryModal();
}
if (ctx.state.subsyncModalOpen) {
subsyncModal.closeSubsyncModal();
}
@@ -435,6 +447,12 @@ function registerModalOpenHandlers(): void {
window.electronAPI.notifyOverlayModalOpened('runtime-options');
});
});
window.electronAPI.onOpenCharacterDictionary(() => {
runGuardedAsync('character-dictionary:open', async () => {
await characterDictionaryModal.openCharacterDictionaryModal();
window.electronAPI.notifyOverlayModalOpened('character-dictionary');
});
});
window.electronAPI.onOpenSessionHelp(() => {
runGuarded('session-help:open', () => {
sessionHelpModal.openSessionHelpModal(keyboardHandlers.getSessionHelpOpeningInfo());
@@ -514,6 +532,12 @@ function registerKeyboardCommandHandlers(): void {
await subtitleSidebarModal.toggleSubtitleSidebarModal();
});
});
window.electronAPI.onPrimarySubtitleBarToggle(() => {
runGuarded('primary-subtitle-bar:toggle', () => {
keyboardHandlers.togglePrimarySubtitleBarVisibility();
});
});
}
function runGuarded(action: string, fn: () => void): void {
@@ -633,6 +657,7 @@ async function init(): Promise<void> {
controllerDebugModal.wireDomEvents();
sessionHelpModal.wireDomEvents();
subtitleSidebarModal.wireDomEvents();
characterDictionaryModal.wireDomEvents();
window.addEventListener('beforeunload', () => {
subtitleSidebarModal.disposeDomEvents();
});

View File

@@ -11,6 +11,7 @@ import type {
RuntimeOptionId,
RuntimeOptionState,
RuntimeOptionValue,
CharacterDictionarySelectionSnapshot,
SubtitlePosition,
SubtitleSidebarConfig,
SubtitleCue,
@@ -64,6 +65,11 @@ export type RendererState = {
runtimeOptionSelectedIndex: number;
runtimeOptionDraftValues: Map<RuntimeOptionId, RuntimeOptionValue>;
characterDictionaryModalOpen: boolean;
characterDictionarySelection: CharacterDictionarySelectionSnapshot | null;
characterDictionarySelectedIndex: number;
characterDictionaryStatus: string;
subsyncModalOpen: boolean;
subsyncSourceTracks: SubsyncSourceTrack[];
subsyncSubmitting: boolean;
@@ -128,6 +134,7 @@ export type RendererState = {
keyboardSelectionVisible: boolean;
keyboardSelectedWordIndex: number | null;
yomitanPopupVisible: boolean;
primarySubtitleBarVisible: boolean;
};
export function createRendererState(): RendererState {
@@ -169,6 +176,11 @@ export function createRendererState(): RendererState {
runtimeOptionSelectedIndex: 0,
runtimeOptionDraftValues: new Map(),
characterDictionaryModalOpen: false,
characterDictionarySelection: null,
characterDictionarySelectedIndex: 0,
characterDictionaryStatus: '',
subsyncModalOpen: false,
subsyncSourceTracks: [],
subsyncSubmitting: false,
@@ -233,5 +245,6 @@ export function createRendererState(): RendererState {
keyboardSelectionVisible: false,
keyboardSelectedWordIndex: null,
yomitanPopupVisible: false,
primarySubtitleBarVisible: true,
};
}

View File

@@ -678,6 +678,11 @@ body.subtitle-sidebar-embedded-open #subtitleContainer {
display: none;
}
#subtitleContainer.primary-sub-hidden {
display: none;
pointer-events: none;
}
body.settings-modal-open #subtitleContainer {
display: none !important;
pointer-events: none !important;
@@ -778,88 +783,121 @@ body.settings-modal-open [data-subminer-yomitan-popup-host='true'] {
#subtitleRoot .word.word-known {
color: var(--subtitle-known-word-color, #a6da95);
text-shadow: 0 0 6px rgba(166, 218, 149, 0.35);
}
#subtitleRoot .word.word-n-plus-one {
color: var(--subtitle-n-plus-one-color, #c6a0f6);
text-shadow: 0 0 6px rgba(198, 160, 246, 0.35);
}
#subtitleRoot .word.word-name-match {
color: var(--subtitle-name-match-color, #f5bde6);
text-shadow: 0 0 6px rgba(245, 189, 230, 0.35);
}
#subtitleRoot .word.word-jlpt-n1 {
--subtitle-jlpt-underline-color: var(--subtitle-jlpt-n1-color, #ed8796);
border-bottom: 2px solid var(--subtitle-jlpt-underline-color);
padding-bottom: 1px;
box-decoration-break: clone;
-webkit-box-decoration-break: clone;
#subtitleRoot
.word.word-jlpt-n1:not(
:is(
.word-known,
.word-n-plus-one,
.word-name-match,
.word-frequency-single,
.word-frequency-band-1,
.word-frequency-band-2,
.word-frequency-band-3,
.word-frequency-band-4,
.word-frequency-band-5
)
) {
color: var(--subtitle-jlpt-n1-color, #ed8796);
}
#subtitleRoot .word.word-jlpt-n1[data-jlpt-level]::after {
color: var(--subtitle-jlpt-n1-color, #ed8796);
}
#subtitleRoot .word.word-jlpt-n2 {
--subtitle-jlpt-underline-color: var(--subtitle-jlpt-n2-color, #f5a97f);
border-bottom: 2px solid var(--subtitle-jlpt-underline-color);
padding-bottom: 1px;
box-decoration-break: clone;
-webkit-box-decoration-break: clone;
#subtitleRoot
.word.word-jlpt-n2:not(
:is(
.word-known,
.word-n-plus-one,
.word-name-match,
.word-frequency-single,
.word-frequency-band-1,
.word-frequency-band-2,
.word-frequency-band-3,
.word-frequency-band-4,
.word-frequency-band-5
)
) {
color: var(--subtitle-jlpt-n2-color, #f5a97f);
}
#subtitleRoot .word.word-jlpt-n2[data-jlpt-level]::after {
color: var(--subtitle-jlpt-n2-color, #f5a97f);
}
#subtitleRoot .word.word-jlpt-n3 {
--subtitle-jlpt-underline-color: var(--subtitle-jlpt-n3-color, #f9e2af);
border-bottom: 2px solid var(--subtitle-jlpt-underline-color);
padding-bottom: 1px;
box-decoration-break: clone;
-webkit-box-decoration-break: clone;
#subtitleRoot
.word.word-jlpt-n3:not(
:is(
.word-known,
.word-n-plus-one,
.word-name-match,
.word-frequency-single,
.word-frequency-band-1,
.word-frequency-band-2,
.word-frequency-band-3,
.word-frequency-band-4,
.word-frequency-band-5
)
) {
color: var(--subtitle-jlpt-n3-color, #f9e2af);
}
#subtitleRoot .word.word-jlpt-n3[data-jlpt-level]::after {
color: var(--subtitle-jlpt-n3-color, #f9e2af);
}
#subtitleRoot .word.word-jlpt-n4 {
--subtitle-jlpt-underline-color: var(--subtitle-jlpt-n4-color, #a6e3a1);
border-bottom: 2px solid var(--subtitle-jlpt-underline-color);
padding-bottom: 1px;
box-decoration-break: clone;
-webkit-box-decoration-break: clone;
#subtitleRoot
.word.word-jlpt-n4:not(
:is(
.word-known,
.word-n-plus-one,
.word-name-match,
.word-frequency-single,
.word-frequency-band-1,
.word-frequency-band-2,
.word-frequency-band-3,
.word-frequency-band-4,
.word-frequency-band-5
)
) {
color: var(--subtitle-jlpt-n4-color, #a6e3a1);
}
#subtitleRoot .word.word-jlpt-n4[data-jlpt-level]::after {
color: var(--subtitle-jlpt-n4-color, #a6e3a1);
}
#subtitleRoot .word.word-jlpt-n5 {
--subtitle-jlpt-underline-color: var(--subtitle-jlpt-n5-color, #8aadf4);
border-bottom: 2px solid var(--subtitle-jlpt-underline-color);
padding-bottom: 1px;
box-decoration-break: clone;
-webkit-box-decoration-break: clone;
#subtitleRoot
.word.word-jlpt-n5:not(
:is(
.word-known,
.word-n-plus-one,
.word-name-match,
.word-frequency-single,
.word-frequency-band-1,
.word-frequency-band-2,
.word-frequency-band-3,
.word-frequency-band-4,
.word-frequency-band-5
)
) {
color: var(--subtitle-jlpt-n5-color, #8aadf4);
}
#subtitleRoot .word.word-jlpt-n5[data-jlpt-level]::after {
color: var(--subtitle-jlpt-n5-color, #8aadf4);
}
#subtitleRoot .word.word-frequency-single,
#subtitleRoot .word.word-frequency-band-1,
#subtitleRoot .word.word-frequency-band-2,
#subtitleRoot .word.word-frequency-band-3,
#subtitleRoot .word.word-frequency-band-4,
#subtitleRoot .word.word-frequency-band-5 {
text-shadow: 0 0 6px rgba(255, 255, 255, 0.3);
}
#subtitleRoot .word.word-frequency-single {
color: var(--subtitle-frequency-single-color, #f5a97f);
}
@@ -907,7 +945,7 @@ body.settings-modal-open [data-subminer-yomitan-popup-host='true'] {
#subtitleRoot .word.word-frequency-band-5:hover {
background: var(--subtitle-hover-token-background-color, rgba(54, 58, 79, 0.84));
border-radius: 3px;
font-weight: 800;
filter: brightness(1.18) saturate(1.08);
}
#subtitleRoot .word.word-known .c:hover,
@@ -1463,6 +1501,71 @@ iframe[id^='yomitan-popup'],
color: var(--ctp-red);
}
.character-dictionary-content {
width: min(680px, 92%);
}
.character-dictionary-current {
font-size: 12px;
color: var(--ctp-subtext1);
}
.character-dictionary-candidates {
list-style: none;
margin: 0;
padding: 0;
border: 1px solid rgba(110, 115, 141, 0.2);
border-radius: 8px;
max-height: 340px;
overflow-y: auto;
}
.character-dictionary-candidate,
.character-dictionary-empty {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 12px;
border-bottom: 1px solid rgba(110, 115, 141, 0.1);
}
.character-dictionary-candidate {
cursor: pointer;
}
.character-dictionary-candidate:last-child,
.character-dictionary-empty:last-child {
border-bottom: none;
}
.character-dictionary-candidate.active {
background: rgba(138, 173, 244, 0.15);
}
.character-dictionary-candidate-body {
min-width: 0;
}
.character-dictionary-use {
flex: 0 0 auto;
border: 1px solid rgba(138, 173, 244, 0.38);
border-radius: 6px;
background: rgba(54, 58, 79, 0.8);
color: var(--ctp-text);
padding: 6px 10px;
cursor: pointer;
}
.character-dictionary-use:hover {
background: rgba(91, 96, 120, 0.9);
}
.character-dictionary-empty {
color: var(--ctp-overlay1);
font-size: 13px;
}
.controller-select-field {
display: flex;
flex-direction: column;

View File

@@ -220,6 +220,22 @@ function normalizeCssSelector(selector: string): string {
.trim();
}
function buildJlptColorSelector(level: number): string {
const higherPriorityClasses = [
'.word-known',
'.word-n-plus-one',
'.word-name-match',
'.word-frequency-single',
'.word-frequency-band-1',
'.word-frequency-band-2',
'.word-frequency-band-3',
'.word-frequency-band-4',
'.word-frequency-band-5',
].join(', ');
return `#subtitleRoot .word.word-jlpt-n${level}:not(:is(${higherPriorityClasses}))`;
}
test('computeWordClass preserves known and n+1 classes while adding JLPT classes', () => {
const knownJlpt = createToken({
isKnown: true,
@@ -410,6 +426,96 @@ test('applySubtitleStyle stores secondary background styles in hover-aware css v
}
});
test('annotated subtitle tokens inherit configured base subtitle typography', () => {
const restoreDocument = installFakeDocument();
try {
const subtitleRoot = new FakeElement('div');
const subtitleContainer = new FakeElement('div');
const secondarySubRoot = new FakeElement('div');
const secondarySubContainer = new FakeElement('div');
const ctx = {
state: createRendererState(),
dom: {
subtitleRoot,
subtitleContainer,
secondarySubRoot,
secondarySubContainer,
},
} as never;
const renderer = createSubtitleRenderer(ctx);
renderer.applySubtitleStyle({
fontFamily: 'M PLUS 1 Medium, Source Han Sans JP, Noto Sans CJK JP',
fontSize: 35,
fontColor: '#cad3f5',
fontWeight: 700,
lineHeight: 1.35,
letterSpacing: '-0.01em',
textRendering: 'geometricPrecision',
textShadow: '3px 0 0 #000, -3px 0 0 #000, 0 3px 0 #000, 0 -3px 0 #000, 2px 2px 0 #000',
frequencyDictionary: {
enabled: true,
topX: 10000,
mode: 'single',
singleColor: '#f5a97f',
},
enableJlpt: true,
jlptColors: {
N1: '#ed8796',
N2: '#f5a97f',
N3: '#f9e2af',
N4: '#a6e3a1',
N5: '#8aadf4',
},
nPlusOneColor: '#c6a0f6',
knownWordColor: '#a6da95',
} as never);
renderer.renderSubtitle({
text: 'お礼をされるようなことしてない',
tokens: [
createToken({ surface: 'お礼', isKnown: true }),
createToken({ surface: 'を' }),
createToken({ surface: 'される', jlptLevel: 'N4' }),
createToken({ surface: 'ような', frequencyRank: 15 }),
],
});
const rootStyle = subtitleRoot.style as unknown as Record<string, string>;
assert.equal(rootStyle.fontFamily, 'M PLUS 1 Medium, Source Han Sans JP, Noto Sans CJK JP');
assert.equal(rootStyle.fontSize, '35px');
assert.equal(rootStyle.color, '#cad3f5');
assert.equal(rootStyle.fontWeight, '700');
assert.equal(rootStyle.lineHeight, '1.35');
assert.equal(rootStyle.letterSpacing, '-0.01em');
assert.equal(rootStyle.textRendering, 'geometricPrecision');
assert.match(rootStyle.textShadow ?? '', /3px 0 0 #000/);
const wordNodes = collectWordNodes(subtitleRoot);
assert.deepEqual(
wordNodes.map((node) => [node.textContent, node.className]),
[
['お礼', 'word word-known'],
['を', 'word'],
['される', 'word word-jlpt-n4'],
['ような', 'word word-frequency-single'],
],
);
for (const wordNode of wordNodes) {
const tokenStyle = wordNode.style as unknown as Record<string, string>;
assert.equal(tokenStyle.fontFamily, undefined);
assert.equal(tokenStyle.fontSize, undefined);
assert.equal(tokenStyle.fontWeight, undefined);
assert.equal(tokenStyle.lineHeight, undefined);
assert.equal(tokenStyle.letterSpacing, undefined);
assert.equal(tokenStyle.textRendering, undefined);
assert.equal(tokenStyle.textShadow, undefined);
}
} finally {
restoreDocument();
}
});
test('computeWordClass adds frequency class for single mode when rank is within topX', () => {
const token = createToken({
surface: '猫',
@@ -552,6 +658,36 @@ test('sanitizeSubtitleHoverTokenColor keeps non-black color values', () => {
assert.equal(sanitizeSubtitleHoverTokenColor(undefined), '#f4dbd6');
});
test('applySubtitleStyle keeps transparent hover token background', () => {
const restoreDocument = installFakeDocument();
try {
const subtitleRoot = new FakeElement('div');
const subtitleContainer = new FakeElement('div');
const secondarySubRoot = new FakeElement('div');
const secondarySubContainer = new FakeElement('div');
const ctx = {
state: createRendererState(),
dom: {
subtitleRoot,
subtitleContainer,
secondarySubRoot,
secondarySubContainer,
},
} as never;
const renderer = createSubtitleRenderer(ctx);
renderer.applySubtitleStyle({
hoverTokenBackgroundColor: 'transparent',
} as never);
const rootStyleValues = (subtitleRoot.style as unknown as { values?: Map<string, string> })
.values;
assert.equal(rootStyleValues?.get('--subtitle-hover-token-background-color'), 'transparent');
} finally {
restoreDocument();
}
});
test('alignTokensToSourceText preserves newline separators between adjacent token surfaces', () => {
const tokens = [
createToken({ surface: 'キリキリと', reading: 'きりきりと', headword: 'キリキリと' }),
@@ -749,7 +885,7 @@ test('shouldRenderTokenizedSubtitle enables token rendering when tokens exist',
assert.equal(shouldRenderTokenizedSubtitle(0), false);
});
test('JLPT CSS rules use underline-only styling in renderer stylesheet', () => {
test('subtitle annotation CSS changes token color without overriding typography', () => {
const distCssPath = path.join(process.cwd(), 'dist', 'renderer', 'style.css');
const srcCssPath = path.join(process.cwd(), 'src', 'renderer', 'style.css');
@@ -763,17 +899,27 @@ test('JLPT CSS rules use underline-only styling in renderer stylesheet', () => {
const cssText = fs.readFileSync(cssPath, 'utf-8');
for (let level = 1; level <= 5; level += 1) {
const block = extractClassBlock(cssText, `#subtitleRoot .word.word-jlpt-n${level}`);
const plainJlptBlock = extractClassBlock(cssText, `#subtitleRoot .word.word-jlpt-n${level}`);
assert.doesNotMatch(plainJlptBlock, /(?:^|\n)\s*color\s*:/m);
const block = extractClassBlock(cssText, buildJlptColorSelector(level));
assert.ok(block.length > 0, `word-jlpt-n${level} class should exist`);
assert.match(
block,
new RegExp(`--subtitle-jlpt-underline-color:\\s*var\\(--subtitle-jlpt-n${level}-color,`),
);
assert.match(block, /border-bottom:\s*2px solid var\(--subtitle-jlpt-underline-color\);/);
assert.match(block, /padding-bottom:\s*1px;/);
assert.match(block, /box-decoration-break:\s*clone;/);
assert.match(block, /-webkit-box-decoration-break:\s*clone;/);
assert.doesNotMatch(block, /(?:^|\n)\s*color\s*:/m);
assert.match(block, new RegExp(`color:\\s*var\\(--subtitle-jlpt-n${level}-color,`));
assert.doesNotMatch(block, /border-bottom\s*:/);
assert.doesNotMatch(block, /padding-bottom\s*:/);
assert.doesNotMatch(block, /box-decoration-break\s*:/);
assert.doesNotMatch(block, /-webkit-box-decoration-break\s*:/);
assert.doesNotMatch(block, /text-shadow\s*:/);
}
for (const selector of [
'#subtitleRoot .word.word-known',
'#subtitleRoot .word.word-n-plus-one',
'#subtitleRoot .word.word-name-match',
]) {
const block = extractClassBlock(cssText, selector);
assert.match(block, /color:\s*var\(/);
assert.doesNotMatch(block, /text-shadow\s*:/);
}
for (let band = 1; band <= 5; band += 1) {
@@ -873,7 +1019,8 @@ test('JLPT CSS rules use underline-only styling in renderer stylesheet', () => {
/background:\s*var\(--subtitle-hover-token-background-color,\s*rgba\(54,\s*58,\s*79,\s*0\.84\)\);/,
);
assert.match(coloredWordHoverBlock, /border-radius:\s*3px;/);
assert.match(coloredWordHoverBlock, /font-weight:\s*800;/);
assert.match(coloredWordHoverBlock, /filter:\s*brightness\(1\.18\) saturate\(1\.08\);/);
assert.doesNotMatch(coloredWordHoverBlock, /font-weight\s*:/);
assert.doesNotMatch(coloredWordHoverBlock, /color:\s*var\(--subtitle-hover-token-color/);
assert.doesNotMatch(
coloredWordHoverBlock,

View File

@@ -57,6 +57,13 @@ export type RendererDom = {
runtimeOptionsList: HTMLUListElement;
runtimeOptionsStatus: HTMLDivElement;
characterDictionaryModal: HTMLDivElement;
characterDictionaryClose: HTMLButtonElement;
characterDictionarySummary: HTMLDivElement;
characterDictionaryCurrent: HTMLDivElement;
characterDictionaryCandidates: HTMLUListElement;
characterDictionaryStatus: HTMLDivElement;
subsyncModal: HTMLDivElement;
subsyncCloseButton: HTMLButtonElement;
subsyncEngineAlass: HTMLInputElement;
@@ -177,6 +184,15 @@ export function resolveRendererDom(): RendererDom {
runtimeOptionsList: getRequiredElement<HTMLUListElement>('runtimeOptionsList'),
runtimeOptionsStatus: getRequiredElement<HTMLDivElement>('runtimeOptionsStatus'),
characterDictionaryModal: getRequiredElement<HTMLDivElement>('characterDictionaryModal'),
characterDictionaryClose: getRequiredElement<HTMLButtonElement>('characterDictionaryClose'),
characterDictionarySummary: getRequiredElement<HTMLDivElement>('characterDictionarySummary'),
characterDictionaryCurrent: getRequiredElement<HTMLDivElement>('characterDictionaryCurrent'),
characterDictionaryCandidates: getRequiredElement<HTMLUListElement>(
'characterDictionaryCandidates',
),
characterDictionaryStatus: getRequiredElement<HTMLDivElement>('characterDictionaryStatus'),
subsyncModal: getRequiredElement<HTMLDivElement>('subsyncModal'),
subsyncCloseButton: getRequiredElement<HTMLButtonElement>('subsyncClose'),
subsyncEngineAlass: getRequiredElement<HTMLInputElement>('subsyncEngineAlass'),

View File

@@ -12,6 +12,7 @@ export const OVERLAY_HOSTED_MODALS = [
'controller-debug',
'subtitle-sidebar',
'session-help',
'character-dictionary',
] as const;
export type OverlayHostedModal = (typeof OVERLAY_HOSTED_MODALS)[number];
@@ -70,6 +71,8 @@ export const IPC_CHANNELS = {
openAnilistSetup: 'anilist:open-setup',
getAnilistQueueStatus: 'anilist:get-queue-status',
retryAnilistNow: 'anilist:retry-now',
getCharacterDictionarySelection: 'character-dictionary:get-selection',
setCharacterDictionarySelection: 'character-dictionary:set-selection',
appendClipboardVideoToQueue: 'clipboard:append-video-to-queue',
getPlaylistBrowserSnapshot: 'playlist-browser:get-snapshot',
appendPlaylistBrowserFile: 'playlist-browser:append-file',
@@ -113,9 +116,11 @@ export const IPC_CHANNELS = {
keyboardModeToggleRequested: 'keyboard-mode-toggle:requested',
lookupWindowToggleRequested: 'lookup-window-toggle:requested',
sessionHelpOpen: 'session-help:open',
characterDictionaryOpen: 'character-dictionary:open',
controllerSelectOpen: 'controller-select:open',
controllerDebugOpen: 'controller-debug:open',
subtitleSidebarToggle: 'subtitle-sidebar:toggle',
primarySubtitleBarToggle: 'primary-subtitle-bar:toggle',
configHotReload: 'config:hot-reload',
},
} as const;

View File

@@ -31,6 +31,7 @@ const SESSION_ACTION_IDS: SessionActionId[] = [
'toggleSubtitleSidebar',
'openRuntimeOptions',
'openSessionHelp',
'openCharacterDictionary',
'openControllerSelect',
'openControllerDebug',
'openJimaku',

View File

@@ -87,6 +87,7 @@ export interface ShortcutsConfig {
multiCopyTimeoutMs?: number;
toggleSecondarySub?: string | null;
markAudioCard?: string | null;
openCharacterDictionary?: string | null;
openRuntimeOptions?: string | null;
openJimaku?: string | null;
openSessionHelp?: string | null;

View File

@@ -341,6 +341,27 @@ export interface SessionActionDispatchRequest {
export type ResolvedControllerConfig = ResolvedConfig['controller'];
export interface CharacterDictionaryCandidate {
id: number;
title: string;
episodes: number | null;
}
export interface CharacterDictionarySelectionSnapshot {
seriesKey: string;
guessTitle: string | null;
current: CharacterDictionaryCandidate | null;
override: CharacterDictionaryCandidate | null;
candidates: CharacterDictionaryCandidate[];
}
export interface CharacterDictionarySelectionResult {
ok: boolean;
seriesKey: string;
selected: CharacterDictionaryCandidate;
staleMediaIds: number[];
}
export interface ElectronAPI {
getOverlayLayer: () => 'visible' | 'modal' | null;
onSubtitle: (callback: (data: SubtitleData) => void) => void;
@@ -410,7 +431,9 @@ export interface ElectronAPI {
onOpenJimaku: (callback: () => void) => void;
onOpenYoutubeTrackPicker: (callback: (payload: YoutubePickerOpenPayload) => void) => void;
onOpenPlaylistBrowser: (callback: () => void) => void;
onOpenCharacterDictionary: (callback: () => void) => void;
onSubtitleSidebarToggle: (callback: () => void) => void;
onPrimarySubtitleBarToggle: (callback: () => void) => void;
onCancelYoutubeTrackPicker: (callback: () => void) => void;
onKeyboardModeToggleRequested: (callback: () => void) => void;
onLookupWindowToggleRequested: (callback: () => void) => void;
@@ -426,6 +449,8 @@ export interface ElectronAPI {
youtubePickerResolve: (
request: YoutubePickerResolveRequest,
) => Promise<YoutubePickerResolveResult>;
getCharacterDictionarySelection: () => Promise<CharacterDictionarySelectionSnapshot>;
setCharacterDictionarySelection: (mediaId: number) => Promise<CharacterDictionarySelectionResult>;
notifyOverlayModalClosed: (
modal:
| 'runtime-options'
@@ -437,7 +462,8 @@ export interface ElectronAPI {
| 'controller-select'
| 'controller-debug'
| 'subtitle-sidebar'
| 'session-help',
| 'session-help'
| 'character-dictionary',
) => void;
notifyOverlayModalOpened: (
modal:
@@ -450,7 +476,8 @@ export interface ElectronAPI {
| 'controller-select'
| 'controller-debug'
| 'subtitle-sidebar'
| 'session-help',
| 'session-help'
| 'character-dictionary',
) => void;
reportOverlayContentBounds: (measurement: OverlayContentMeasurement) => void;
onConfigHotReload: (callback: (payload: ConfigHotReloadPayload) => void) => void;

View File

@@ -15,6 +15,7 @@ export type SessionActionId =
| 'markAudioCard'
| 'openRuntimeOptions'
| 'openSessionHelp'
| 'openCharacterDictionary'
| 'openControllerSelect'
| 'openControllerDebug'
| 'openJimaku'