refactor: migrate shared type imports

This commit is contained in:
2026-03-27 00:33:52 -07:00
parent a92631bf52
commit 49a582b4fc
66 changed files with 793 additions and 479 deletions

View File

@@ -227,11 +227,7 @@ test('stats background command launches attached daemon control command with res
assert.equal(handled, true);
assert.deepEqual(harness.forwarded, [
[
'--stats-daemon-start',
'--stats-response-path',
'/tmp/subminer-stats-test/response.json',
],
['--stats-daemon-start', '--stats-response-path', '/tmp/subminer-stats-test/response.json'],
]);
assert.equal(harness.removedPaths.length, 1);
});
@@ -257,11 +253,7 @@ test('stats command waits for attached app exit after startup response', async (
const final = await statsCommand;
assert.equal(final, true);
assert.deepEqual(harness.forwarded, [
[
'--stats',
'--stats-response-path',
'/tmp/subminer-stats-test/response.json',
],
['--stats', '--stats-response-path', '/tmp/subminer-stats-test/response.json'],
]);
assert.equal(harness.removedPaths.length, 1);
});
@@ -317,11 +309,7 @@ test('stats stop command forwards stop flag to the app', async () => {
assert.equal(handled, true);
assert.deepEqual(harness.forwarded, [
[
'--stats-daemon-stop',
'--stats-response-path',
'/tmp/subminer-stats-test/response.json',
],
['--stats-daemon-stop', '--stats-response-path', '/tmp/subminer-stats-test/response.json'],
]);
assert.equal(harness.removedPaths.length, 1);
});

View File

@@ -209,7 +209,11 @@ export async function runPlaybackCommandWithDeps(
pluginRuntimeConfig.autoStartPauseUntilReady;
if (shouldPauseUntilOverlayReady) {
deps.log('info', args.logLevel, 'Configured to pause mpv until overlay and tokenization are ready');
deps.log(
'info',
args.logLevel,
'Configured to pause mpv until overlay and tokenization are ready',
);
}
await deps.startMpv(
@@ -250,7 +254,11 @@ export async function runPlaybackCommandWithDeps(
if (ready) {
deps.log('info', args.logLevel, 'MPV IPC socket ready, relying on mpv plugin auto-start');
} else {
deps.log('info', args.logLevel, 'MPV IPC socket not ready yet, relying on mpv plugin auto-start');
deps.log(
'info',
args.logLevel,
'MPV IPC socket not ready yet, relying on mpv plugin auto-start',
);
}
} else if (ready) {
deps.log(

View File

@@ -236,17 +236,12 @@ export function parseCliPrograms(
normalizedAction !== 'rebuild' &&
normalizedAction !== 'backfill'
) {
throw new Error(
'Invalid stats action. Valid values are cleanup, rebuild, or backfill.',
);
throw new Error('Invalid stats action. Valid values are cleanup, rebuild, or backfill.');
}
if (normalizedAction && (statsBackground || statsStop)) {
throw new Error('Stats background and stop flags cannot be combined with stats actions.');
}
if (
normalizedAction !== 'cleanup' &&
(options.vocab === true || options.lifetime === true)
) {
if (normalizedAction !== 'cleanup' && (options.vocab === true || options.lifetime === true)) {
throw new Error('Stats --vocab and --lifetime flags require the cleanup action.');
}
if (normalizedAction === 'cleanup') {

View File

@@ -14,12 +14,7 @@ test('getDefaultMpvLogFile uses APPDATA on windows', () => {
assert.equal(
path.normalize(resolved),
path.normalize(
path.join(
'C:\\Users\\tester\\AppData\\Roaming',
'SubMiner',
'logs',
`mpv-${today}.log`,
),
path.join('C:\\Users\\tester\\AppData\\Roaming', 'SubMiner', 'logs', `mpv-${today}.log`),
),
);
});
@@ -33,12 +28,6 @@ test('getDefaultLauncherLogFile uses launcher prefix', () => {
assert.equal(
resolved,
path.join(
'/home/tester',
'.config',
'SubMiner',
'logs',
`launcher-${today}.log`,
),
path.join('/home/tester', '.config', 'SubMiner', 'logs', `launcher-${today}.log`),
);
});

View File

@@ -269,10 +269,7 @@ ${bunBinary} -e "const net=require('node:net'); const fs=require('node:fs'); con
SUBMINER_APPIMAGE_PATH: appPath,
SUBMINER_TEST_MPV_ARGS: mpvArgsPath,
};
const result = runLauncher(
['--args', '--pause=yes --title="movie night"', videoPath],
env,
);
const result = runLauncher(['--args', '--pause=yes --title="movie night"', videoPath], env);
assert.equal(result.status, 0, `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`);
const argsFile = fs.readFileSync(mpvArgsPath, 'utf8');
@@ -355,10 +352,7 @@ ${bunBinary} -e "const net=require('node:net'); const fs=require('node:fs'); con
const result = runLauncher(['--log-level', 'debug', videoPath], env);
assert.equal(result.status, 0, `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`);
assert.match(
fs.readFileSync(mpvArgsPath, 'utf8'),
/--script-opts=.*subminer-log_level=debug/,
);
assert.match(fs.readFileSync(mpvArgsPath, 'utf8'), /--script-opts=.*subminer-log_level=debug/);
});
});

View File

@@ -427,7 +427,10 @@ function withFindAppBinaryEnvSandbox(run: () => void): void {
}
}
function withAccessSyncStub(isExecutablePath: (filePath: string) => boolean, run: () => void): void {
function withAccessSyncStub(
isExecutablePath: (filePath: string) => boolean,
run: () => void,
): void {
const originalAccessSync = fs.accessSync;
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -468,10 +471,13 @@ test('findAppBinary resolves /opt/SubMiner/SubMiner.AppImage when ~/.local/bin c
try {
os.homedir = () => baseDir;
withFindAppBinaryEnvSandbox(() => {
withAccessSyncStub((filePath) => filePath === '/opt/SubMiner/SubMiner.AppImage', () => {
withAccessSyncStub(
(filePath) => filePath === '/opt/SubMiner/SubMiner.AppImage',
() => {
const result = findAppBinary('/some/other/path/subminer');
assert.equal(result, '/opt/SubMiner/SubMiner.AppImage');
});
},
);
});
} finally {
os.homedir = originalHomedir;
@@ -492,11 +498,14 @@ test('findAppBinary finds subminer on PATH when AppImage candidates do not exist
process.env.PATH = `${binDir}${path.delimiter}${originalPath ?? ''}`;
withFindAppBinaryEnvSandbox(() => {
withAccessSyncStub((filePath) => filePath === wrapperPath, () => {
withAccessSyncStub(
(filePath) => filePath === wrapperPath,
() => {
// selfPath must differ from wrapperPath so the self-check does not exclude it
const result = findAppBinary(path.join(baseDir, 'launcher', 'subminer'));
assert.equal(result, wrapperPath);
});
},
);
});
} finally {
os.homedir = originalHomedir;

View File

@@ -47,7 +47,11 @@ export function parseMpvArgString(input: string): string[] {
let inDoubleQuote = false;
let escaping = false;
const canEscape = (nextChar: string | undefined): boolean =>
nextChar === undefined || nextChar === '"' || nextChar === "'" || nextChar === '\\' || /\s/.test(nextChar);
nextChar === undefined ||
nextChar === '"' ||
nextChar === "'" ||
nextChar === '\\' ||
/\s/.test(nextChar);
for (let i = 0; i < chars.length; i += 1) {
const ch = chars[i] || '';
@@ -598,7 +602,9 @@ export async function startMpv(
? await resolveAniSkipMetadataForFile(target)
: null;
const extraScriptOpts =
targetKind === 'url' && isYoutubeTarget(target) && options?.disableYoutubeSubtitleAutoLoad === true
targetKind === 'url' &&
isYoutubeTarget(target) &&
options?.disableYoutubeSubtitleAutoLoad === true
? ['subminer-auto_start_pause_until_ready=no']
: [];
const scriptOpts = buildSubminerScriptOpts(
@@ -1064,7 +1070,9 @@ export function launchMpvIdleDetached(
mpvArgs.push(...parseMpvArgString(args.mpvArgs));
}
mpvArgs.push('--idle=yes');
mpvArgs.push(`--script-opts=${buildSubminerScriptOpts(appPath, socketPath, null, args.logLevel)}`);
mpvArgs.push(
`--script-opts=${buildSubminerScriptOpts(appPath, socketPath, null, args.logLevel)}`,
);
mpvArgs.push(`--log-file=${getMpvLogPath()}`);
mpvArgs.push(`--input-ipc-server=${socketPath}`);
const mpvTarget = resolveCommandInvocation('mpv', mpvArgs);

View File

@@ -111,7 +111,11 @@ test('writeChangelogArtifacts skips changelog prepend when release section alrea
fs.mkdirSync(projectRoot, { recursive: true });
fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true });
fs.writeFileSync(path.join(projectRoot, 'CHANGELOG.md'), existingChangelog, 'utf8');
fs.writeFileSync(path.join(projectRoot, 'changes', '001.md'), ['type: added', 'area: overlay', '', '- Stale release fragment.'].join('\n'), 'utf8');
fs.writeFileSync(
path.join(projectRoot, 'changes', '001.md'),
['type: added', 'area: overlay', '', '- Stale release fragment.'].join('\n'),
'utf8',
);
try {
const result = writeChangelogArtifacts({
@@ -125,7 +129,10 @@ test('writeChangelogArtifacts skips changelog prepend when release section alrea
const changelog = fs.readFileSync(path.join(projectRoot, 'CHANGELOG.md'), 'utf8');
assert.equal(changelog, existingChangelog);
const releaseNotes = fs.readFileSync(path.join(projectRoot, 'release', 'release-notes.md'), 'utf8');
const releaseNotes = fs.readFileSync(
path.join(projectRoot, 'release', 'release-notes.md'),
'utf8',
);
assert.match(releaseNotes, /## Highlights\n### Added\n- Existing release bullet\./);
} finally {
fs.rmSync(workspace, { recursive: true, force: true });

View File

@@ -354,11 +354,7 @@ export function writeChangelogArtifacts(options?: ChangelogOptions): {
log(`Removed ${fragment.path}`);
}
const releaseNotesPath = writeReleaseNotesFile(
cwd,
existingReleaseSection,
options?.deps,
);
const releaseNotesPath = writeReleaseNotesFile(cwd, existingReleaseSection, options?.deps);
log(`Generated ${releaseNotesPath}`);
return {

View File

@@ -55,10 +55,7 @@ exit 1
`,
);
const result = spawnSync(
'bash',
['scripts/patch-modernz.sh', '--target', target],
{
const result = spawnSync('bash', ['scripts/patch-modernz.sh', '--target', target], {
cwd: process.cwd(),
encoding: 'utf8',
env: {
@@ -66,8 +63,7 @@ exit 1
HOME: path.join(root, 'home'),
PATH: `${binDir}:${process.env.PATH || ''}`,
},
},
);
});
assert.equal(result.status, 1, result.stderr || result.stdout);
assert.match(result.stderr, /failed to apply patch to/);

View File

@@ -47,8 +47,8 @@ test('update-aur-package updates PKGBUILD and .SRCINFO without makepkg', () => {
const pkgbuild = fs.readFileSync(path.join(pkgDir, 'PKGBUILD'), 'utf8');
const srcinfo = fs.readFileSync(path.join(pkgDir, '.SRCINFO'), 'utf8');
const expectedSums = [appImagePath, wrapperPath, assetsPath].map((filePath) =>
execFileSync('sha256sum', [filePath], { encoding: 'utf8' }).split(/\s+/)[0],
const expectedSums = [appImagePath, wrapperPath, assetsPath].map(
(filePath) => execFileSync('sha256sum', [filePath], { encoding: 'utf8' }).split(/\s+/)[0],
);
assert.match(pkgbuild, /^pkgver=0\.6\.3$/m);

View File

@@ -21,15 +21,15 @@ import { SubtitleTimingTracker } from './subtitle-timing-tracker';
import { MediaGenerator } from './media-generator';
import path from 'path';
import {
AiConfig,
AnkiConnectConfig,
KikuDuplicateCardInfo,
KikuFieldGroupingChoice,
KikuMergePreviewResponse,
MpvClient,
NotificationOptions,
NPlusOneMatchMode,
} from './types';
} from './types/anki';
import { AiConfig } from './types/integrations';
import { MpvClient } from './types/runtime';
import { NPlusOneMatchMode } from './types/subtitle';
import { DEFAULT_ANKI_CONNECT_CONFIG } from './config';
import {
getConfiguredWordFieldCandidates,
@@ -212,10 +212,7 @@ export class AnkiIntegration {
try {
this.recordCardsMinedCallback(count, noteIds);
} catch (error) {
log.warn(
`recordCardsMined callback failed during ${source}:`,
(error as Error).message,
);
log.warn(`recordCardsMined callback failed during ${source}:`, (error as Error).message);
}
}

View File

@@ -4,10 +4,10 @@ import test from 'node:test';
import { resolveAnimatedImageLeadInSeconds, extractSoundFilenames } from './animated-image-sync';
test('extractSoundFilenames returns ordered sound filenames from an Anki field value', () => {
assert.deepEqual(
extractSoundFilenames('before [sound:word.mp3] middle [sound:alt.ogg] after'),
['word.mp3', 'alt.ogg'],
);
assert.deepEqual(extractSoundFilenames('before [sound:word.mp3] middle [sound:alt.ogg] after'), [
'word.mp3',
'alt.ogg',
]);
});
test('resolveAnimatedImageLeadInSeconds sums configured word audio durations for animated images', async () => {

View File

@@ -179,7 +179,10 @@ function getDuplicateSourceCandidates(
const fallbackFieldName = configuredFieldNames[0]?.toLowerCase() || 'expression';
const fallbackKey = `${fallbackFieldName}:${normalizeDuplicateValue(trimmedFallback)}`;
if (!dedupeKey.has(fallbackKey)) {
candidates.push({ fieldName: configuredFieldNames[0] || 'Expression', value: trimmedFallback });
candidates.push({
fieldName: configuredFieldNames[0] || 'Expression',
value: trimmedFallback,
});
}
}

View File

@@ -1325,8 +1325,14 @@ test('controller descriptor config rejects malformed binding objects', () => {
config.controller.bindings.leftStickHorizontal,
DEFAULT_CONFIG.controller.bindings.leftStickHorizontal,
);
assert.equal(warnings.some((warning) => warning.path === 'controller.bindings.toggleLookup'), true);
assert.equal(warnings.some((warning) => warning.path === 'controller.bindings.closeLookup'), true);
assert.equal(
warnings.some((warning) => warning.path === 'controller.bindings.toggleLookup'),
true,
);
assert.equal(
warnings.some((warning) => warning.path === 'controller.bindings.closeLookup'),
true,
);
assert.equal(
warnings.some((warning) => warning.path === 'controller.bindings.leftStickHorizontal'),
true,

View File

@@ -17,7 +17,12 @@ export function applyStatsConfig(context: ResolveContext): void {
if (markWatchedKey !== undefined) {
resolved.stats.markWatchedKey = markWatchedKey;
} else if (src.stats.markWatchedKey !== undefined) {
warn('stats.markWatchedKey', src.stats.markWatchedKey, resolved.stats.markWatchedKey, 'Expected string.');
warn(
'stats.markWatchedKey',
src.stats.markWatchedKey,
resolved.stats.markWatchedKey,
'Expected string.',
);
}
const serverPort = asNumber(src.stats.serverPort);

View File

@@ -49,7 +49,10 @@ test('subtitleSidebar accepts zero opacity', () => {
applySubtitleDomainConfig(context);
assert.equal(context.resolved.subtitleSidebar.opacity, 0);
assert.equal(warnings.some((warning) => warning.path === 'subtitleSidebar.opacity'), false);
assert.equal(
warnings.some((warning) => warning.path === 'subtitleSidebar.opacity'),
false,
);
});
test('subtitleSidebar falls back and warns on invalid values', () => {

View File

@@ -185,11 +185,7 @@ test('runAppReadyRuntime uses minimal startup for texthooker-only mode', async (
await runAppReadyRuntime(deps);
assert.deepEqual(calls, [
'ensureDefaultConfigBootstrap',
'reloadConfig',
'handleInitialArgs',
]);
assert.deepEqual(calls, ['ensureDefaultConfigBootstrap', 'reloadConfig', 'handleInitialArgs']);
});
test('runAppReadyRuntime skips Jellyfin remote startup when dependency is not wired', async () => {

View File

@@ -58,7 +58,12 @@ function classifyDiff(prev: ResolvedConfig, next: ResolvedConfig): ConfigHotRelo
]);
for (const key of keys) {
if (key === 'subtitleStyle' || key === 'keybindings' || key === 'shortcuts' || key === 'subtitleSidebar') {
if (
key === 'subtitleStyle' ||
key === 'keybindings' ||
key === 'shortcuts' ||
key === 'subtitleSidebar'
) {
continue;
}

View File

@@ -79,10 +79,7 @@ export {
handleOverlayWindowBeforeInputEvent,
isTabInputForMpvForwarding,
} from './overlay-window-input';
export {
initializeOverlayAnkiIntegration,
initializeOverlayRuntime,
} from './overlay-runtime-init';
export { initializeOverlayAnkiIntegration, initializeOverlayRuntime } from './overlay-runtime-init';
export { setVisibleOverlayVisible, updateVisibleOverlayVisibility } from './overlay-visibility';
export {
MPV_REQUEST_ID_SECONDARY_SUB_VISIBILITY,

View File

@@ -70,7 +70,11 @@ function createControllerConfigFixture() {
nextAudio: { kind: 'button' as const, buttonIndex: 5 },
playCurrentAudio: { kind: 'button' as const, buttonIndex: 7 },
toggleMpvPause: { kind: 'button' as const, buttonIndex: 6 },
leftStickHorizontal: { kind: 'axis' as const, axisIndex: 0, dpadFallback: 'horizontal' as const },
leftStickHorizontal: {
kind: 'axis' as const,
axisIndex: 0,
dpadFallback: 'horizontal' as const,
},
leftStickVertical: { kind: 'axis' as const, axisIndex: 1, dpadFallback: 'vertical' as const },
rightStickHorizontal: { kind: 'axis' as const, axisIndex: 3, dpadFallback: 'none' as const },
rightStickVertical: { kind: 'axis' as const, axisIndex: 4, dpadFallback: 'none' as const },

View File

@@ -64,7 +64,9 @@ export interface IpcServiceDeps {
getCurrentSecondarySub: () => string;
focusMainWindow: () => void;
runSubsyncManual: (request: SubsyncManualRunRequest) => Promise<SubsyncResult>;
onYoutubePickerResolve: (request: YoutubePickerResolveRequest) => Promise<YoutubePickerResolveResult>;
onYoutubePickerResolve: (
request: YoutubePickerResolveRequest,
) => Promise<YoutubePickerResolveResult>;
getAnkiConnectStatus: () => boolean;
getRuntimeOptions: () => unknown;
setRuntimeOption: (id: RuntimeOptionId, value: RuntimeOptionValue) => unknown;
@@ -167,7 +169,9 @@ export interface IpcDepsRuntimeOptions {
getMpvClient: () => MpvClientLike | null;
focusMainWindow: () => void;
runSubsyncManual: (request: SubsyncManualRunRequest) => Promise<SubsyncResult>;
onYoutubePickerResolve: (request: YoutubePickerResolveRequest) => Promise<YoutubePickerResolveResult>;
onYoutubePickerResolve: (
request: YoutubePickerResolveRequest,
) => Promise<YoutubePickerResolveResult>;
getAnkiConnectStatus: () => boolean;
getRuntimeOptions: () => unknown;
setRuntimeOption: (id: RuntimeOptionId, value: RuntimeOptionValue) => unknown;
@@ -291,13 +295,16 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
deps.onOverlayModalOpened(parsedModal);
});
ipc.handle(IPC_CHANNELS.request.youtubePickerResolve, async (_event: unknown, request: unknown) => {
ipc.handle(
IPC_CHANNELS.request.youtubePickerResolve,
async (_event: unknown, request: unknown) => {
const parsedRequest = parseYoutubePickerResolveRequest(request);
if (!parsedRequest) {
return { ok: false, message: 'Invalid YouTube picker resolve payload' };
}
return await deps.onYoutubePickerResolve(parsedRequest);
});
},
);
ipc.on(IPC_CHANNELS.command.openYomitanSettings, () => {
deps.openYomitanSettings();
@@ -375,13 +382,16 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
},
);
ipc.handle(IPC_CHANNELS.command.saveControllerConfig, async (_event: unknown, update: unknown) => {
ipc.handle(
IPC_CHANNELS.command.saveControllerConfig,
async (_event: unknown, update: unknown) => {
const parsedUpdate = parseControllerConfigUpdate(update);
if (!parsedUpdate) {
throw new Error('Invalid controller config payload');
}
await deps.saveControllerConfig(parsedUpdate);
});
},
);
ipc.handle(IPC_CHANNELS.request.getMecabStatus, () => {
return deps.getMecabStatus();

View File

@@ -228,7 +228,11 @@ test('consumeCachedSubtitle returns prefetched payload and prevents reprocessing
controller.onSubtitleChange('猫\nです');
await flushMicrotasks();
assert.equal(tokenizeCalls, 0, 'same cached subtitle should not reprocess after immediate consume');
assert.equal(
tokenizeCalls,
0,
'same cached subtitle should not reprocess after immediate consume',
);
assert.deepEqual(emitted, []);
});

View File

@@ -3428,7 +3428,9 @@ test('tokenizeSubtitle keeps standalone grammar-only tokens hoverable while clea
test('tokenizeSubtitle keeps trailing quote-particle merged tokens hoverable while clearing only their annotation metadata', async () => {
const result = await tokenizeSubtitle(
'どうしてもって',
makeDepsFromYomitanTokens([{ surface: 'どうしてもって', reading: 'どうしてもって', headword: 'どうしても' }], {
makeDepsFromYomitanTokens(
[{ surface: 'どうしてもって', reading: 'どうしてもって', headword: 'どうしても' }],
{
getFrequencyDictionaryEnabled: () => true,
getFrequencyRank: (text) => (text === 'どうしても' ? 123 : null),
getJlptLevel: (text) => (text === 'どうしても' ? 'N3' : null),
@@ -3461,7 +3463,8 @@ test('tokenizeSubtitle keeps trailing quote-particle merged tokens hoverable whi
},
],
getMinSentenceWordsForNPlusOne: () => 1,
}),
},
),
);
assert.equal(result.text, 'どうしてもって');
@@ -3812,7 +3815,14 @@ test('tokenizeSubtitle clears all annotations for explanatory pondering endings'
jlptLevel: token.jlptLevel,
})),
[
{ surface: '俺', headword: '俺', isKnown: true, isNPlusOneTarget: false, frequencyRank: 19, jlptLevel: 'N5' },
{
surface: '俺',
headword: '俺',
isKnown: true,
isNPlusOneTarget: false,
frequencyRank: 19,
jlptLevel: 'N5',
},
{
surface: 'どうかしちゃった',
headword: 'どうかしちゃう',

View File

@@ -140,7 +140,11 @@ function isExcludedFromSubtitleAnnotationsByPos1(normalizedPos1: string): boolea
function isExcludedTrailingParticleMergedToken(token: MergedToken): boolean {
const normalizedSurface = normalizeJlptTextForExclusion(token.surface);
const normalizedHeadword = normalizeJlptTextForExclusion(token.headword);
if (!normalizedSurface || !normalizedHeadword || !normalizedSurface.startsWith(normalizedHeadword)) {
if (
!normalizedSurface ||
!normalizedHeadword ||
!normalizedSurface.startsWith(normalizedHeadword)
) {
return false;
}
@@ -164,7 +168,10 @@ function isExcludedTrailingParticleMergedToken(token: MergedToken): boolean {
function isAuxiliaryStemGrammarTailToken(token: MergedToken): boolean {
const pos1Parts = splitNormalizedTagParts(normalizePos1Tag(token.pos1));
if (pos1Parts.length === 0 || !pos1Parts.every((part) => AUXILIARY_STEM_GRAMMAR_TAIL_POS1.has(part))) {
if (
pos1Parts.length === 0 ||
!pos1Parts.every((part) => AUXILIARY_STEM_GRAMMAR_TAIL_POS1.has(part))
) {
return false;
}

View File

@@ -46,7 +46,11 @@ const SUBTITLE_ANNOTATION_EXCLUDED_EXPLANATORY_ENDING_TRAILING_PARTICLES = [
'かな',
'かね',
] as const;
const SUBTITLE_ANNOTATION_EXCLUDED_EXPLANATORY_ENDING_THOUGHT_SUFFIXES = ['か', 'かな', 'かね'] as const;
const SUBTITLE_ANNOTATION_EXCLUDED_EXPLANATORY_ENDING_THOUGHT_SUFFIXES = [
'か',
'かな',
'かね',
] as const;
const SUBTITLE_ANNOTATION_EXCLUDED_EXPLANATORY_ENDINGS = new Set(
SUBTITLE_ANNOTATION_EXCLUDED_EXPLANATORY_ENDING_PREFIXES.flatMap((prefix) =>
SUBTITLE_ANNOTATION_EXCLUDED_EXPLANATORY_ENDING_CORES.flatMap((core) =>
@@ -96,9 +100,7 @@ function isExcludedByTagSet(normalizedTag: string, exclusions: ReadonlySet<strin
return parts.every((part) => exclusions.has(part));
}
function resolvePos1Exclusions(
options: SubtitleAnnotationFilterOptions = {},
): ReadonlySet<string> {
function resolvePos1Exclusions(options: SubtitleAnnotationFilterOptions = {}): ReadonlySet<string> {
if (options.pos1Exclusions) {
return options.pos1Exclusions;
}
@@ -106,9 +108,7 @@ function resolvePos1Exclusions(
return resolveAnnotationPos1ExclusionSet(DEFAULT_ANNOTATION_POS1_EXCLUSION_CONFIG);
}
function resolvePos2Exclusions(
options: SubtitleAnnotationFilterOptions = {},
): ReadonlySet<string> {
function resolvePos2Exclusions(options: SubtitleAnnotationFilterOptions = {}): ReadonlySet<string> {
if (options.pos2Exclusions) {
return options.pos2Exclusions;
}
@@ -212,7 +212,11 @@ function isReduplicatedKanaSfxWithOptionalTrailingTo(text: string): boolean {
function isExcludedTrailingParticleMergedToken(token: MergedToken): boolean {
const normalizedSurface = normalizeKana(token.surface);
const normalizedHeadword = normalizeKana(token.headword);
if (!normalizedSurface || !normalizedHeadword || !normalizedSurface.startsWith(normalizedHeadword)) {
if (
!normalizedSurface ||
!normalizedHeadword ||
!normalizedSurface.startsWith(normalizedHeadword)
) {
return false;
}
@@ -236,7 +240,10 @@ function isExcludedTrailingParticleMergedToken(token: MergedToken): boolean {
function isAuxiliaryStemGrammarTailToken(token: MergedToken): boolean {
const pos1Parts = splitNormalizedTagParts(normalizePosTag(token.pos1));
if (pos1Parts.length === 0 || !pos1Parts.every((part) => AUXILIARY_STEM_GRAMMAR_TAIL_POS1.has(part))) {
if (
pos1Parts.length === 0 ||
!pos1Parts.every((part) => AUXILIARY_STEM_GRAMMAR_TAIL_POS1.has(part))
) {
return false;
}

View File

@@ -3,7 +3,11 @@ import type { YoutubeTrackKind } from './kinds';
export type { YoutubeTrackKind };
export function normalizeYoutubeLangCode(value: string): string {
return value.trim().toLowerCase().replace(/_/g, '-').replace(/[^a-z0-9-]+/g, '');
return value
.trim()
.toLowerCase()
.replace(/_/g, '-')
.replace(/[^a-z0-9-]+/g, '');
}
export function isJapaneseYoutubeLang(value: string): boolean {

View File

@@ -75,15 +75,11 @@ test('probeYoutubeVideoMetadata returns null on malformed yt-dlp JSON', async ()
});
});
test(
'probeYoutubeVideoMetadata times out when yt-dlp hangs',
{ timeout: 20_000 },
async () => {
test('probeYoutubeVideoMetadata times out when yt-dlp hangs', { timeout: 20_000 }, async () => {
await withHangingFakeYtDlp(async () => {
await assert.rejects(
probeYoutubeVideoMetadata('https://www.youtube.com/watch?v=abc123'),
/timed out after 15000ms/,
);
});
},
);
});

View File

@@ -25,9 +25,7 @@ function decodeHtmlEntities(value: string): string {
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&#(\d+);/g, (match, codePoint) =>
decodeNumericEntity(match, Number(codePoint)),
)
.replace(/&#(\d+);/g, (match, codePoint) => decodeNumericEntity(match, Number(codePoint)))
.replace(/&#x([0-9a-f]+);/gi, (match, codePoint) =>
decodeNumericEntity(match, Number.parseInt(codePoint, 16)),
);
@@ -52,9 +50,7 @@ function extractYoutubeTimedTextRows(xml: string): YoutubeTimedTextRow[] {
continue;
}
const inner = (match[2] ?? '')
.replace(/<br\s*\/?>/gi, '\n')
.replace(/<[^>]+>/g, '');
const inner = (match[2] ?? '').replace(/<br\s*\/?>/gi, '\n').replace(/<[^>]+>/g, '');
const text = decodeHtmlEntities(inner).trim();
if (!text) {
continue;
@@ -110,7 +106,9 @@ export function convertYoutubeTimedTextToVtt(xml: string): string {
if (!text) {
continue;
}
blocks.push(`${formatVttTimestamp(row.startMs)} --> ${formatVttTimestamp(clampedEnd)}\n${text}`);
blocks.push(
`${formatVttTimestamp(row.startMs)} --> ${formatVttTimestamp(clampedEnd)}\n${text}`,
);
}
return `WEBVTT\n\n${blocks.join('\n\n')}\n`;

View File

@@ -115,7 +115,9 @@ async function withFakeYtDlp<T>(
}
async function withFakeYtDlpExpectations<T>(
expectations: Partial<Record<'YTDLP_EXPECT_AUTO_SUBS' | 'YTDLP_EXPECT_MANUAL_SUBS' | 'YTDLP_EXPECT_SUB_LANG', string>>,
expectations: Partial<
Record<'YTDLP_EXPECT_AUTO_SUBS' | 'YTDLP_EXPECT_MANUAL_SUBS' | 'YTDLP_EXPECT_SUB_LANG', string>
>,
fn: () => Promise<T>,
): Promise<T> {
const previous = {
@@ -144,11 +146,7 @@ async function withStubFetch<T>(
const originalFetch = globalThis.fetch;
globalThis.fetch = (async (input: string | URL | Request) => {
const url =
typeof input === 'string'
? input
: input instanceof URL
? input.toString()
: input.url;
typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url;
return await handler(url);
}) as typeof fetch;
try {

View File

@@ -13,7 +13,10 @@ const YOUTUBE_BATCH_PREFIX = 'youtube-batch';
const YOUTUBE_DOWNLOAD_TIMEOUT_MS = 15_000;
function sanitizeFilenameSegment(value: string): string {
const sanitized = value.trim().replace(/[^a-z0-9_-]+/gi, '-').replace(/-+/g, '-');
const sanitized = value
.trim()
.replace(/[^a-z0-9_-]+/gi, '-')
.replace(/-+/g, '-');
return sanitized.replace(/^-+|-+$/g, '') || 'unknown';
}
@@ -163,10 +166,7 @@ async function downloadSubtitleFromUrl(input: {
? ext
: 'vtt';
const safeSourceLanguage = sanitizeFilenameSegment(input.track.sourceLanguage);
const targetPath = path.join(
input.outputDir,
`${input.prefix}.${safeSourceLanguage}.${safeExt}`,
);
const targetPath = path.join(input.outputDir, `${input.prefix}.${safeSourceLanguage}.${safeExt}`);
const response = await fetch(input.track.downloadUrl, {
signal: createFetchTimeoutSignal(YOUTUBE_DOWNLOAD_TIMEOUT_MS),
});

View File

@@ -127,7 +127,10 @@ export async function probeYoutubeTracks(targetUrl: string): Promise<YoutubeTrac
}${snippet ? `; stdout=${snippet}` : ''}`,
);
}
const tracks = [...toTracks(info.subtitles, 'manual'), ...toTracks(info.automatic_captions, 'auto')];
const tracks = [
...toTracks(info.subtitles, 'manual'),
...toTracks(info.automatic_captions, 'auto'),
];
return {
videoId: info.id || '',
title: info.title || '',

View File

@@ -10,9 +10,10 @@ function pickTrack(
return matching[0] ?? null;
}
export function chooseDefaultYoutubeTrackIds(
tracks: YoutubeTrackOption[],
): { primaryTrackId: string | null; secondaryTrackId: string | null } {
export function chooseDefaultYoutubeTrackIds(tracks: YoutubeTrackOption[]): {
primaryTrackId: string | null;
secondaryTrackId: string | null;
} {
const primary =
pickTrack(
tracks.filter((track) => track.kind === 'manual'),
@@ -52,7 +53,11 @@ export function normalizeYoutubeTrackSelection(input: {
primaryTrackId: string | null;
secondaryTrackId: string | null;
} {
if (input.primaryTrackId && input.secondaryTrackId && input.primaryTrackId === input.secondaryTrackId) {
if (
input.primaryTrackId &&
input.secondaryTrackId &&
input.primaryTrackId === input.secondaryTrackId
) {
return {
primaryTrackId: input.primaryTrackId,
secondaryTrackId: null,
@@ -60,4 +65,3 @@ export function normalizeYoutubeTrackSelection(input: {
}
return input;
}

View File

@@ -1,4 +1,7 @@
import { appendLogLine, resolveDefaultLogFilePath as resolveSharedDefaultLogFilePath } from './shared/log-files';
import {
appendLogLine,
resolveDefaultLogFilePath as resolveSharedDefaultLogFilePath,
} from './shared/log-files';
export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
export type LogLevelSource = 'cli' | 'config';

View File

@@ -82,10 +82,9 @@ test('stats-daemon entry helper detects internal daemon commands', () => {
true,
);
assert.equal(
shouldHandleStatsDaemonCommandAtEntry(
['SubMiner.AppImage', '--stats-daemon-start'],
{ ELECTRON_RUN_AS_NODE: '1' },
),
shouldHandleStatsDaemonCommandAtEntry(['SubMiner.AppImage', '--stats-daemon-start'], {
ELECTRON_RUN_AS_NODE: '1',
}),
false,
);
assert.equal(shouldHandleStatsDaemonCommandAtEntry(['SubMiner.AppImage', '--start'], {}), false);

View File

@@ -603,7 +603,8 @@ const isDev = process.argv.includes('--dev') || process.argv.includes('--debug')
const texthookerService = new Texthooker(() => {
const config = getResolvedConfig();
const characterDictionaryEnabled =
config.anilist.characterDictionary.enabled && yomitanProfilePolicy.isCharacterDictionaryEnabled();
config.anilist.characterDictionary.enabled &&
yomitanProfilePolicy.isCharacterDictionaryEnabled();
const knownAndNPlusOneEnabled = getRuntimeBooleanOption(
'subtitle.annotation.nPlusOne',
config.ankiConnect.knownWords.highlightEnabled,
@@ -828,7 +829,8 @@ const youtubeFlowRuntime = createYoutubeFlowRuntime({
{
sendToActiveOverlayWindow: (channel, nextPayload, runtimeOptions) =>
overlayModalRuntime.sendToActiveOverlayWindow(channel, nextPayload, runtimeOptions),
waitForModalOpen: (modal, timeoutMs) => overlayModalRuntime.waitForModalOpen(modal, timeoutMs),
waitForModalOpen: (modal, timeoutMs) =>
overlayModalRuntime.waitForModalOpen(modal, timeoutMs),
logWarn: (message) => logger.warn(message),
},
payload,
@@ -871,7 +873,10 @@ const youtubeFlowRuntime = createYoutubeFlowRuntime({
await Promise.race([
integration.waitUntilReady(),
new Promise<never>((_, reject) => {
setTimeout(() => reject(new Error('Timed out waiting for AnkiConnect integration')), 2500);
setTimeout(
() => reject(new Error('Timed out waiting for AnkiConnect integration')),
2500,
);
}),
]);
} catch (error) {
@@ -3027,7 +3032,8 @@ const ensureStatsServerStarted = (): string => {
knownWordCachePath: path.join(USER_DATA_PATH, 'known-words-cache.json'),
mpvSocketPath: appState.mpvSocketPath,
ankiConnectConfig: getResolvedConfig().ankiConnect,
resolveAnkiNoteId: (noteId: number) => appState.ankiIntegration?.resolveCurrentNoteId(noteId) ?? noteId,
resolveAnkiNoteId: (noteId: number) =>
appState.ankiIntegration?.resolveCurrentNoteId(noteId) ?? noteId,
addYomitanNote: async (word: string) => {
const ankiUrl = getResolvedConfig().ankiConnect.url || 'http://127.0.0.1:8765';
await syncYomitanDefaultAnkiServerCore(ankiUrl, yomitanDeps, yomitanLogger, {
@@ -4589,7 +4595,8 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
openYomitanSettings: () => openYomitanSettings(),
quitApp: () => requestAppQuit(),
toggleVisibleOverlay: () => toggleVisibleOverlay(),
tokenizeCurrentSubtitle: async () => withCurrentSubtitleTiming(await tokenizeSubtitle(appState.currentSubText)),
tokenizeCurrentSubtitle: async () =>
withCurrentSubtitleTiming(await tokenizeSubtitle(appState.currentSubText)),
getCurrentSubtitleRaw: () => appState.currentSubText,
getCurrentSubtitleAss: () => appState.currentSubAssText,
getSubtitleSidebarSnapshot: async () => {
@@ -4611,13 +4618,8 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
}
try {
const [
currentExternalFilenameRaw,
currentTrackRaw,
trackListRaw,
sidRaw,
videoPathRaw,
] = await Promise.all([
const [currentExternalFilenameRaw, currentTrackRaw, trackListRaw, sidRaw, videoPathRaw] =
await Promise.all([
client.requestProperty('current-tracks/sub/external-filename').catch(() => null),
client.requestProperty('current-tracks/sub').catch(() => null),
client.requestProperty('track-list'),

View File

@@ -287,10 +287,14 @@ test('sendToActiveOverlayWindow can prefer modal window even when main overlay i
setModalWindowBounds: () => {},
});
const sent = runtime.sendToActiveOverlayWindow('youtube:picker-open', { sessionId: 'yt-1' }, {
const sent = runtime.sendToActiveOverlayWindow(
'youtube:picker-open',
{ sessionId: 'yt-1' },
{
restoreOnModalClose: 'youtube-track-picker',
preferModalWindow: true,
});
},
);
assert.equal(sent, true);
assert.deepEqual(mainWindow.sent, []);
@@ -309,10 +313,14 @@ test('modal window path makes visible main overlay click-through until modal clo
setModalWindowBounds: () => {},
});
const sent = runtime.sendToActiveOverlayWindow('youtube:picker-open', { sessionId: 'yt-1' }, {
const sent = runtime.sendToActiveOverlayWindow(
'youtube:picker-open',
{ sessionId: 'yt-1' },
{
restoreOnModalClose: 'youtube-track-picker',
preferModalWindow: true,
});
},
);
runtime.notifyOverlayModalOpened('youtube-track-picker');
assert.equal(sent, true);
@@ -336,10 +344,14 @@ test('modal window path hides visible main overlay until modal closes', () => {
setModalWindowBounds: () => {},
});
runtime.sendToActiveOverlayWindow('youtube:picker-open', { sessionId: 'yt-1' }, {
runtime.sendToActiveOverlayWindow(
'youtube:picker-open',
{ sessionId: 'yt-1' },
{
restoreOnModalClose: 'youtube-track-picker',
preferModalWindow: true,
});
},
);
runtime.notifyOverlayModalOpened('youtube-track-picker');
assert.equal(mainWindow.getHideCount(), 1);
@@ -516,9 +528,13 @@ test('waitForModalOpen resolves true after modal acknowledgement', async () => {
setModalWindowBounds: () => {},
});
runtime.sendToActiveOverlayWindow('youtube:picker-open', { sessionId: 'yt-1' }, {
runtime.sendToActiveOverlayWindow(
'youtube:picker-open',
{ sessionId: 'yt-1' },
{
restoreOnModalClose: 'youtube-track-picker',
});
},
);
const pending = runtime.waitForModalOpen('youtube-track-picker', 1000);
runtime.notifyOverlayModalOpened('youtube-track-picker');

View File

@@ -357,10 +357,7 @@ export function createOverlayModalRuntimeService(
showModalWindow(targetWindow);
};
const waitForModalOpen = async (
modal: OverlayHostedModal,
timeoutMs: number,
): Promise<boolean> =>
const waitForModalOpen = async (modal: OverlayHostedModal, timeoutMs: number): Promise<boolean> =>
await new Promise<boolean>((resolve) => {
const waiters = modalOpenWaiters.get(modal) ?? [];
const finish = (opened: boolean): void => {

View File

@@ -78,8 +78,7 @@ export function createBindMpvMainEventHandlersHandler(deps: {
reportJellyfinRemoteStopped: () => deps.reportJellyfinRemoteStopped(),
refreshDiscordPresence: () => deps.refreshDiscordPresence(),
syncOverlayMpvSubtitleSuppression: () => deps.syncOverlayMpvSubtitleSuppression(),
hasInitialPlaybackQuitOnDisconnectArg: () =>
deps.hasInitialPlaybackQuitOnDisconnectArg(),
hasInitialPlaybackQuitOnDisconnectArg: () => deps.hasInitialPlaybackQuitOnDisconnectArg(),
isOverlayRuntimeInitialized: () => deps.isOverlayRuntimeInitialized(),
shouldQuitOnDisconnectWhenOverlayRuntimeInitialized: () =>
deps.shouldQuitOnDisconnectWhenOverlayRuntimeInitialized(),
@@ -88,7 +87,11 @@ export function createBindMpvMainEventHandlersHandler(deps: {
isMpvConnected: () => deps.isMpvConnected(),
quitApp: () => deps.quitApp(),
});
const handleMpvConnectionChangeWithSidebarReset = ({ connected }: { connected: boolean }): void => {
const handleMpvConnectionChangeWithSidebarReset = ({
connected,
}: {
connected: boolean;
}): void => {
if (connected) {
deps.resetSubtitleSidebarEmbeddedLayout();
}

View File

@@ -4,14 +4,12 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
appState: {
initialArgs?: { jellyfinPlay?: unknown; youtubePlay?: unknown } | null;
overlayRuntimeInitialized: boolean;
mpvClient:
| {
mpvClient: {
connected?: boolean;
currentSecondarySubText?: string;
currentTimePos?: number;
requestProperty?: (name: string) => Promise<unknown>;
}
| null;
} | null;
immersionTracker: {
recordSubtitleLine?: (
text: string,

View File

@@ -14,9 +14,7 @@ test('autoplay release keeps the short retry budget for normal playback signals'
test('autoplay release uses the full startup timeout window while paused', () => {
assert.equal(
resolveAutoplayReadyMaxReleaseAttempts({ forceWhilePaused: true }),
Math.ceil(
STARTUP_AUTOPLAY_RELEASE_TIMEOUT_MS / DEFAULT_AUTOPLAY_RELEASE_RETRY_DELAY_MS,
),
Math.ceil(STARTUP_AUTOPLAY_RELEASE_TIMEOUT_MS / DEFAULT_AUTOPLAY_RELEASE_RETRY_DELAY_MS),
);
});

View File

@@ -22,7 +22,4 @@ export function resolveAutoplayReadyMaxReleaseAttempts(options?: {
return Math.max(3, Math.ceil(startupTimeoutMs / retryDelayMs));
}
export {
DEFAULT_AUTOPLAY_RELEASE_RETRY_DELAY_MS,
STARTUP_AUTOPLAY_RELEASE_TIMEOUT_MS,
};
export { DEFAULT_AUTOPLAY_RELEASE_RETRY_DELAY_MS, STARTUP_AUTOPLAY_RELEASE_TIMEOUT_MS };

View File

@@ -33,10 +33,7 @@ export function resolveWindowsMpvPath(deps: WindowsMpvLaunchDeps): string {
return '';
}
export function buildWindowsMpvLaunchArgs(
targets: string[],
extraArgs: string[] = [],
): string[] {
export function buildWindowsMpvLaunchArgs(targets: string[], extraArgs: string[] = []): string[] {
return ['--player-operation-mode=pseudo-gui', '--profile=subminer', ...extraArgs, ...targets];
}

View File

@@ -141,9 +141,7 @@ test('youtube flow can open a manual picker session and load the selected subtit
assert.ok(
commands.some(
(command) =>
command[0] === 'set_property' &&
command[1] === 'sub-visibility' &&
command[2] === 'yes',
command[0] === 'set_property' && command[1] === 'sub-visibility' && command[2] === 'yes',
),
);
assert.ok(
@@ -263,9 +261,7 @@ test('youtube flow retries secondary after partial batch subtitle failure', asyn
assert.ok(
commands.some(
(command) =>
command[0] === 'sub-add' &&
command[1] === '/tmp/manual:en.vtt' &&
command[2] === 'cached',
command[0] === 'sub-add' && command[1] === '/tmp/manual:en.vtt' && command[2] === 'cached',
),
);
});
@@ -708,12 +704,54 @@ test('youtube flow leaves non-authoritative youtube subtitle tracks untouched af
return selectedSecondarySid;
}
return [
{ type: 'sub', id: 1, lang: 'en', title: 'English', external: true, 'external-filename': null },
{ type: 'sub', id: 2, lang: 'ja', title: 'Japanese', external: true, 'external-filename': null },
{ type: 'sub', id: 3, lang: 'ja-en', title: 'Japanese from English', external: true, 'external-filename': null },
{ type: 'sub', id: 4, lang: 'ja-ja', title: 'Japanese from Japanese', external: true, 'external-filename': null },
{ type: 'sub', id: 5, lang: 'ja-orig', title: 'auto-ja-orig.vtt', external: true, 'external-filename': '/tmp/auto-ja-orig.vtt' },
{ type: 'sub', id: 6, lang: 'en', title: 'manual-en.en.srt', external: true, 'external-filename': '/tmp/manual-en.en.srt' },
{
type: 'sub',
id: 1,
lang: 'en',
title: 'English',
external: true,
'external-filename': null,
},
{
type: 'sub',
id: 2,
lang: 'ja',
title: 'Japanese',
external: true,
'external-filename': null,
},
{
type: 'sub',
id: 3,
lang: 'ja-en',
title: 'Japanese from English',
external: true,
'external-filename': null,
},
{
type: 'sub',
id: 4,
lang: 'ja-ja',
title: 'Japanese from Japanese',
external: true,
'external-filename': null,
},
{
type: 'sub',
id: 5,
lang: 'ja-orig',
title: 'auto-ja-orig.vtt',
external: true,
'external-filename': '/tmp/auto-ja-orig.vtt',
},
{
type: 'sub',
id: 6,
lang: 'en',
title: 'manual-en.en.srt',
external: true,
'external-filename': '/tmp/manual-en.en.srt',
},
];
},
refreshCurrentSubtitle: () => {},
@@ -737,7 +775,10 @@ test('youtube flow leaves non-authoritative youtube subtitle tracks untouched af
await runtime.openManualPicker({ url: 'https://example.com' });
assert.equal(commands.some((command) => command[0] === 'sub-remove'), false);
assert.equal(
commands.some((command) => command[0] === 'sub-remove'),
false,
);
});
test('youtube flow reuses existing manual youtube subtitle tracks when both requested languages already exist', async () => {
@@ -751,8 +792,20 @@ test('youtube flow reuses existing manual youtube subtitle tracks when both requ
videoId: 'video123',
title: 'Video 123',
tracks: [
{ ...primaryTrack, id: 'manual:ja', sourceLanguage: 'ja', kind: 'manual', title: 'Japanese' },
{ ...secondaryTrack, id: 'manual:en', sourceLanguage: 'en', kind: 'manual', title: 'English' },
{
...primaryTrack,
id: 'manual:ja',
sourceLanguage: 'ja',
kind: 'manual',
title: 'Japanese',
},
{
...secondaryTrack,
id: 'manual:en',
sourceLanguage: 'en',
kind: 'manual',
title: 'English',
},
],
}),
acquireYoutubeSubtitleTracks: async () => {
@@ -801,10 +854,38 @@ test('youtube flow reuses existing manual youtube subtitle tracks when both requ
return selectedSecondarySid;
}
return [
{ type: 'sub', id: 1, lang: 'en', title: 'English', external: true, 'external-filename': null },
{ type: 'sub', id: 2, lang: 'ja', title: 'Japanese', external: true, 'external-filename': null },
{ type: 'sub', id: 3, lang: 'ja-en', title: 'Japanese from English', external: true, 'external-filename': null },
{ type: 'sub', id: 4, lang: 'ja-ja', title: 'Japanese from Japanese', external: true, 'external-filename': null },
{
type: 'sub',
id: 1,
lang: 'en',
title: 'English',
external: true,
'external-filename': null,
},
{
type: 'sub',
id: 2,
lang: 'ja',
title: 'Japanese',
external: true,
'external-filename': null,
},
{
type: 'sub',
id: 3,
lang: 'ja-en',
title: 'Japanese from English',
external: true,
'external-filename': null,
},
{
type: 'sub',
id: 4,
lang: 'ja-ja',
title: 'Japanese from Japanese',
external: true,
'external-filename': null,
},
];
},
refreshCurrentSubtitle: () => {},
@@ -833,9 +914,15 @@ test('youtube flow reuses existing manual youtube subtitle tracks when both requ
assert.equal(selectedPrimarySid, 2);
assert.equal(selectedSecondarySid, 1);
assert.equal(commands.some((command) => command[0] === 'sub-add'), false);
assert.equal(
commands.some((command) => command[0] === 'sub-add'),
false,
);
assert.deepEqual(refreshedSidebarSources, ['/tmp/manual-ja.ja.srt']);
assert.equal(commands.some((command) => command[0] === 'sub-remove'), false);
assert.equal(
commands.some((command) => command[0] === 'sub-remove'),
false,
);
});
test('youtube flow waits for manual youtube tracks to appear before falling back to injected copies', async () => {
@@ -849,8 +936,20 @@ test('youtube flow waits for manual youtube tracks to appear before falling back
videoId: 'video123',
title: 'Video 123',
tracks: [
{ ...primaryTrack, id: 'manual:ja', sourceLanguage: 'ja', kind: 'manual', title: 'Japanese' },
{ ...secondaryTrack, id: 'manual:en', sourceLanguage: 'en', kind: 'manual', title: 'English' },
{
...primaryTrack,
id: 'manual:ja',
sourceLanguage: 'ja',
kind: 'manual',
title: 'Japanese',
},
{
...secondaryTrack,
id: 'manual:en',
sourceLanguage: 'en',
kind: 'manual',
title: 'English',
},
],
}),
acquireYoutubeSubtitleTracks: async () => {
@@ -903,10 +1002,38 @@ test('youtube flow waits for manual youtube tracks to appear before falling back
return [];
}
return [
{ type: 'sub', id: 1, lang: 'en', title: 'English', external: true, 'external-filename': null },
{ type: 'sub', id: 2, lang: 'ja', title: 'Japanese', external: true, 'external-filename': null },
{ type: 'sub', id: 3, lang: 'ja-en', title: 'Japanese from English', external: true, 'external-filename': null },
{ type: 'sub', id: 4, lang: 'ja-ja', title: 'Japanese from Japanese', external: true, 'external-filename': null },
{
type: 'sub',
id: 1,
lang: 'en',
title: 'English',
external: true,
'external-filename': null,
},
{
type: 'sub',
id: 2,
lang: 'ja',
title: 'Japanese',
external: true,
'external-filename': null,
},
{
type: 'sub',
id: 3,
lang: 'ja-en',
title: 'Japanese from English',
external: true,
'external-filename': null,
},
{
type: 'sub',
id: 4,
lang: 'ja-ja',
title: 'Japanese from Japanese',
external: true,
'external-filename': null,
},
];
},
refreshCurrentSubtitle: () => {},
@@ -932,7 +1059,10 @@ test('youtube flow waits for manual youtube tracks to appear before falling back
assert.equal(selectedPrimarySid, 2);
assert.equal(selectedSecondarySid, 1);
assert.equal(commands.some((command) => command[0] === 'sub-add'), false);
assert.equal(
commands.some((command) => command[0] === 'sub-add'),
false,
);
});
test('youtube flow reuses manual youtube tracks even when mpv exposes external filenames', async () => {
@@ -970,7 +1100,9 @@ test('youtube flow reuses manual youtube tracks even when mpv exposes external f
if (track.id === 'manual:ja') {
return { path: '/tmp/manual-ja.ja.srt' };
}
throw new Error('should not download secondary track when existing manual english track is reusable');
throw new Error(
'should not download secondary track when existing manual english track is reusable',
);
},
openPicker: async () => false,
pauseMpv: () => {},
@@ -1051,7 +1183,10 @@ test('youtube flow reuses manual youtube tracks even when mpv exposes external f
assert.equal(selectedPrimarySid, 2);
assert.equal(selectedSecondarySid, 1);
assert.equal(commands.some((command) => command[0] === 'sub-add'), false);
assert.equal(
commands.some((command) => command[0] === 'sub-add'),
false,
);
});
test('youtube flow falls back to existing auto secondary track when auto secondary download fails', async () => {

View File

@@ -384,7 +384,9 @@ async function injectDownloadedSubtitles(
} else {
deps.warn(
`Unable to bind downloaded primary subtitle track in mpv: ${
primarySelection.injectedPath ? path.basename(primarySelection.injectedPath) : primarySelection.track.label
primarySelection.injectedPath
? path.basename(primarySelection.injectedPath)
: primarySelection.track.label
}`,
);
}
@@ -415,9 +417,7 @@ async function injectDownloadedSubtitles(
deps.refreshCurrentSubtitle(currentSubText);
}
deps.showMpvOsd(
secondaryTrack ? 'Primary and secondary subtitles loaded.' : 'Subtitles loaded.',
);
deps.showMpvOsd(secondaryTrack ? 'Primary and secondary subtitles loaded.' : 'Subtitles loaded.');
return true;
}
@@ -587,7 +587,8 @@ export function createYoutubeFlowRuntime(deps: YoutubeFlowDeps) {
existingPrimaryTrackId,
)
: null;
const primaryReady = input.primaryTrack.kind !== 'manual' || existingPrimaryTrackId !== null;
const primaryReady =
input.primaryTrack.kind !== 'manual' || existingPrimaryTrackId !== null;
const secondaryReady =
!input.secondaryTrack ||
input.secondaryTrack.kind !== 'manual' ||
@@ -631,7 +632,11 @@ export function createYoutubeFlowRuntime(deps: YoutubeFlowDeps) {
secondaryInjectedPath = acquired.secondaryPath;
}
if (input.secondaryTrack && existingSecondaryTrackId === null && secondaryInjectedPath === null) {
if (
input.secondaryTrack &&
existingSecondaryTrackId === null &&
secondaryInjectedPath === null
) {
try {
secondaryInjectedPath = (
await deps.acquireYoutubeSubtitleTrack({

View File

@@ -183,7 +183,13 @@ test('prepare youtube playback accepts a non-youtube resolved path once playable
'/videos/episode01.mkv',
'https://rr16---sn.example.googlevideo.com/videoplayback?id=abc',
];
const observedTrackLists = [[], [{ type: 'video', id: 1 }, { type: 'audio', id: 2 }]];
const observedTrackLists = [
[],
[
{ type: 'video', id: 1 },
{ type: 'audio', id: 2 },
],
];
let requestCount = 0;
const prepare = createPrepareYoutubePlaybackInMpvHandler({
requestPath: async () => {
@@ -256,11 +262,14 @@ test('prepare youtube playback does not accept a different youtube video after p
test('prepare youtube playback accepts a fresh-start path change when the direct target matches exactly', async () => {
const commands: Array<Array<string>> = [];
const observedPaths = [
'',
'https://rr16---sn.example.googlevideo.com/videoplayback?id=abc',
const observedPaths = ['', 'https://rr16---sn.example.googlevideo.com/videoplayback?id=abc'];
const observedTrackLists = [
[],
[
{ type: 'video', id: 1 },
{ type: 'audio', id: 2 },
],
];
const observedTrackLists = [[], [{ type: 'video', id: 1 }, { type: 'audio', id: 2 }]];
let requestCount = 0;
const prepare = createPrepareYoutubePlaybackInMpvHandler({
requestPath: async () => {

View File

@@ -74,7 +74,9 @@ function hasPlayableMediaTracks(trackListRaw: unknown): boolean {
if (!Array.isArray(trackListRaw)) return false;
return trackListRaw.some((track) => {
if (!track || typeof track !== 'object') return false;
const type = String((track as Record<string, unknown>).type || '').trim().toLowerCase();
const type = String((track as Record<string, unknown>).type || '')
.trim()
.toLowerCase();
return type === 'video' || type === 'audio';
});
}

View File

@@ -1,7 +1,9 @@
import { isYoutubeMediaPath } from './youtube-playback';
import { normalizeYoutubeLangCode } from '../../core/services/youtube/labels';
export type YoutubePrimarySubtitleNotificationTimer = ReturnType<typeof setTimeout> | { id: number };
export type YoutubePrimarySubtitleNotificationTimer =
| ReturnType<typeof setTimeout>
| { id: number };
type SubtitleTrackEntry = {
id: number | null;
@@ -82,7 +84,9 @@ function hasSelectedPrimarySubtitle(
const tracks = trackList.map(normalizeTrack);
const activeTrack =
(sid === null ? null : tracks.find((track) => track?.type === 'sub' && track.id === sid) ?? null) ??
(sid === null
? null
: (tracks.find((track) => track?.type === 'sub' && track.id === sid) ?? null)) ??
tracks.find((track) => track?.type === 'sub' && track.selected) ??
null;
if (!activeTrack) {
@@ -130,7 +134,9 @@ export function createYoutubePrimarySubtitleNotificationRuntime(deps: {
return;
}
lastReportedMediaPath = mediaPath;
deps.notifyFailure('Primary subtitle failed to download or load. Try again from the subtitle modal.');
deps.notifyFailure(
'Primary subtitle failed to download or load. Try again from the subtitle modal.',
);
};
const schedulePendingCheck = (): void => {
@@ -150,7 +156,8 @@ export function createYoutubePrimarySubtitleNotificationRuntime(deps: {
return {
handleMediaPathChange: (path: string | null): void => {
const normalizedPath = typeof path === 'string' && path.trim().length > 0 ? path.trim() : null;
const normalizedPath =
typeof path === 'string' && path.trim().length > 0 ? path.trim() : null;
if (currentMediaPath !== normalizedPath) {
lastReportedMediaPath = null;
}

View File

@@ -47,7 +47,10 @@ type ControllerBindingCaptureResult =
dpadDirection: ControllerDpadFallback;
};
function isActiveButton(button: ControllerButtonState | undefined, triggerDeadzone: number): boolean {
function isActiveButton(
button: ControllerButtonState | undefined,
triggerDeadzone: number,
): boolean {
if (!button) return false;
return Boolean(button.pressed) || button.value >= triggerDeadzone;
}
@@ -90,7 +93,10 @@ export function createControllerBindingCapture(options: {
});
}
function arm(nextTarget: ControllerBindingCaptureTarget, snapshot: ControllerBindingCaptureSnapshot): void {
function arm(
nextTarget: ControllerBindingCaptureTarget,
snapshot: ControllerBindingCaptureSnapshot,
): void {
target = nextTarget;
resetBlockedState(snapshot);
}
@@ -139,7 +145,10 @@ export function createControllerBindingCapture(options: {
}
// After dpad early-return, only 'discrete' | 'axis' remain
const narrowedTarget: Extract<ControllerBindingCaptureTarget, { bindingType: 'discrete' | 'axis' }> = target;
const narrowedTarget: Extract<
ControllerBindingCaptureTarget,
{ bindingType: 'discrete' | 'axis' }
> = target;
for (let index = 0; index < snapshot.buttons.length; index += 1) {
if (!isActiveButton(snapshot.buttons[index], options.triggerDeadzone)) continue;

View File

@@ -194,13 +194,7 @@ export function createKeyboardHandlers(
(isBackslashConfigured && e.key === '\\') ||
(toggleKey.length === 1 && e.key === toggleKey);
return (
keyMatches &&
!e.ctrlKey &&
!e.altKey &&
!e.metaKey &&
!e.repeat
);
return keyMatches && !e.ctrlKey && !e.altKey && !e.metaKey && !e.repeat;
}
function isStatsOverlayToggle(e: KeyboardEvent): boolean {

View File

@@ -3,10 +3,7 @@ import test from 'node:test';
import type { SubtitleSidebarConfig } from '../../types';
import { createMouseHandlers } from './mouse.js';
import {
YOMITAN_POPUP_HIDDEN_EVENT,
YOMITAN_POPUP_SHOWN_EVENT,
} from '../yomitan-popup.js';
import { YOMITAN_POPUP_HIDDEN_EVENT, YOMITAN_POPUP_SHOWN_EVENT } from '../yomitan-popup.js';
function createClassList() {
const classes = new Set<string>();
@@ -118,9 +115,15 @@ test('secondary hover pauses on enter, reveals secondary subtitle, and resumes o
});
await handlers.handleSecondaryMouseEnter();
assert.equal(ctx.dom.secondarySubContainer.classList.contains('secondary-sub-hover-active'), true);
assert.equal(
ctx.dom.secondarySubContainer.classList.contains('secondary-sub-hover-active'),
true,
);
await handlers.handleSecondaryMouseLeave();
assert.equal(ctx.dom.secondarySubContainer.classList.contains('secondary-sub-hover-active'), false);
assert.equal(
ctx.dom.secondarySubContainer.classList.contains('secondary-sub-hover-active'),
false,
);
assert.deepEqual(mpvCommands, [
['set_property', 'pause', 'yes'],
@@ -186,7 +189,10 @@ test('secondary leave toward primary subtitle container clears the secondary hov
} as unknown as MouseEvent);
assert.equal(ctx.state.isOverSubtitle, false);
assert.equal(ctx.dom.secondarySubContainer.classList.contains('secondary-sub-hover-active'), false);
assert.equal(
ctx.dom.secondarySubContainer.classList.contains('secondary-sub-hover-active'),
false,
);
assert.deepEqual(mpvCommands, [['set_property', 'pause', 'yes']]);
});
@@ -237,7 +243,10 @@ test('primary hover pauses on enter without revealing secondary subtitle', async
});
await handlers.handlePrimaryMouseEnter();
assert.equal(ctx.dom.secondarySubContainer.classList.contains('secondary-sub-hover-active'), false);
assert.equal(
ctx.dom.secondarySubContainer.classList.contains('secondary-sub-hover-active'),
false,
);
await handlers.handlePrimaryMouseLeave();
assert.deepEqual(mpvCommands, [
@@ -394,7 +403,10 @@ test('restorePointerInteractionState reapplies the secondary hover class from po
mousemove?.({ clientX: 10, clientY: 20 } as MouseEvent);
assert.equal(ctx.state.isOverSubtitle, true);
assert.equal(ctx.dom.secondarySubContainer.classList.contains('secondary-sub-hover-active'), true);
assert.equal(
ctx.dom.secondarySubContainer.classList.contains('secondary-sub-hover-active'),
true,
);
} finally {
Object.defineProperty(globalThis, 'document', { configurable: true, value: originalDocument });
Object.defineProperty(globalThis, 'window', { configurable: true, value: originalWindow });

View File

@@ -228,10 +228,7 @@ export function createMouseHandlers(
syncOverlayMouseIgnoreState(ctx);
}
async function handleMouseEnter(
_event?: MouseEvent,
showSecondaryHover = false,
): Promise<void> {
async function handleMouseEnter(_event?: MouseEvent, showSecondaryHover = false): Promise<void> {
ctx.state.isOverSubtitle = true;
if (showSecondaryHover) {
ctx.dom.secondarySubContainer.classList.add('secondary-sub-hover-active');
@@ -267,10 +264,7 @@ export function createMouseHandlers(
pausedBySubtitleHover = true;
}
async function handleMouseLeave(
_event?: MouseEvent,
hideSecondaryHover = false,
): Promise<void> {
async function handleMouseLeave(_event?: MouseEvent, hideSecondaryHover = false): Promise<void> {
const relatedTarget = _event?.relatedTarget ?? null;
const otherContainer = hideSecondaryHover
? ctx.dom.subtitleContainer

View File

@@ -118,10 +118,14 @@ export function getDefaultControllerBinding(actionId: ControllerBindingActionId)
if (!definition) {
return { kind: 'none' } as const;
}
return JSON.parse(JSON.stringify(definition.defaultBinding)) as ResolvedControllerConfig['bindings'][ControllerBindingActionId];
return JSON.parse(
JSON.stringify(definition.defaultBinding),
) as ResolvedControllerConfig['bindings'][ControllerBindingActionId];
}
export function getDefaultDpadFallback(actionId: ControllerBindingActionId): ControllerDpadFallback {
export function getDefaultDpadFallback(
actionId: ControllerBindingActionId,
): ControllerDpadFallback {
const definition = getControllerBindingDefinition(actionId);
if (!definition || definition.defaultBinding.kind !== 'axis') return 'none';
const binding = definition.defaultBinding;
@@ -249,7 +253,11 @@ export function createControllerConfigForm(options: {
if (definition.bindingType === 'axis') {
renderAxisStickRow(definition, binding as ResolvedControllerAxisBinding, learningActionId);
renderAxisDpadRow(definition, binding as ResolvedControllerAxisBinding, dpadLearningActionId);
renderAxisDpadRow(
definition,
binding as ResolvedControllerAxisBinding,
dpadLearningActionId,
);
} else {
renderDiscreteRow(definition, binding, learningActionId);
}
@@ -265,7 +273,12 @@ export function createControllerConfigForm(options: {
const isExpanded = expandedRowKey === rowKey;
const isLearning = learningActionId === definition.id;
const row = createRow(definition.label, formatFriendlyBindingLabel(binding), binding.kind === 'none', isExpanded);
const row = createRow(
definition.label,
formatFriendlyBindingLabel(binding),
binding.kind === 'none',
isExpanded,
);
row.addEventListener('click', () => {
expandedRowKey = expandedRowKey === rowKey ? null : rowKey;
render();
@@ -277,9 +290,18 @@ export function createControllerConfigForm(options: {
? 'Press a button, trigger, or move a stick\u2026'
: `Currently: ${formatControllerBindingSummary(binding)}`;
const panel = createEditPanel(hint, isLearning, {
onLearn: (e) => { e.stopPropagation(); options.onLearn(definition.id, definition.bindingType); },
onClear: (e) => { e.stopPropagation(); options.onClear(definition.id); },
onReset: (e) => { e.stopPropagation(); options.onReset(definition.id); },
onLearn: (e) => {
e.stopPropagation();
options.onLearn(definition.id, definition.bindingType);
},
onClear: (e) => {
e.stopPropagation();
options.onClear(definition.id);
},
onReset: (e) => {
e.stopPropagation();
options.onReset(definition.id);
},
});
options.container.appendChild(panel);
}
@@ -294,7 +316,12 @@ export function createControllerConfigForm(options: {
const isExpanded = expandedRowKey === rowKey;
const isLearning = learningActionId === definition.id;
const row = createRow(`${definition.label} (Stick)`, formatFriendlyStickLabel(binding), binding.kind === 'none', isExpanded);
const row = createRow(
`${definition.label} (Stick)`,
formatFriendlyStickLabel(binding),
binding.kind === 'none',
isExpanded,
);
row.addEventListener('click', () => {
expandedRowKey = expandedRowKey === rowKey ? null : rowKey;
render();
@@ -305,9 +332,18 @@ export function createControllerConfigForm(options: {
const summary = binding.kind === 'none' ? 'Disabled' : `Axis ${binding.axisIndex}`;
const hint = isLearning ? 'Move a stick or trigger\u2026' : `Currently: ${summary}`;
const panel = createEditPanel(hint, isLearning, {
onLearn: (e) => { e.stopPropagation(); options.onLearn(definition.id, 'axis'); },
onClear: (e) => { e.stopPropagation(); options.onClear(definition.id); },
onReset: (e) => { e.stopPropagation(); options.onReset(definition.id); },
onLearn: (e) => {
e.stopPropagation();
options.onLearn(definition.id, 'axis');
},
onClear: (e) => {
e.stopPropagation();
options.onClear(definition.id);
},
onReset: (e) => {
e.stopPropagation();
options.onReset(definition.id);
},
});
options.container.appendChild(panel);
}
@@ -322,9 +358,15 @@ export function createControllerConfigForm(options: {
const isExpanded = expandedRowKey === rowKey;
const isLearning = dpadLearningActionId === definition.id;
const dpadFallback: ControllerDpadFallback = binding.kind === 'none' ? 'none' : binding.dpadFallback;
const dpadFallback: ControllerDpadFallback =
binding.kind === 'none' ? 'none' : binding.dpadFallback;
const badgeText = DPAD_FALLBACK_LABELS[dpadFallback];
const row = createRow(`${definition.label} (D-pad)`, badgeText, dpadFallback === 'none', isExpanded);
const row = createRow(
`${definition.label} (D-pad)`,
badgeText,
dpadFallback === 'none',
isExpanded,
);
row.addEventListener('click', () => {
expandedRowKey = expandedRowKey === rowKey ? null : rowKey;
render();
@@ -336,15 +378,29 @@ export function createControllerConfigForm(options: {
? 'Press a D-pad direction\u2026'
: `Currently: ${DPAD_FALLBACK_LABELS[dpadFallback]}`;
const panel = createEditPanel(hint, isLearning, {
onLearn: (e) => { e.stopPropagation(); options.onDpadLearn(definition.id); },
onClear: (e) => { e.stopPropagation(); options.onDpadClear(definition.id); },
onReset: (e) => { e.stopPropagation(); options.onDpadReset(definition.id); },
onLearn: (e) => {
e.stopPropagation();
options.onDpadLearn(definition.id);
},
onClear: (e) => {
e.stopPropagation();
options.onDpadClear(definition.id);
},
onReset: (e) => {
e.stopPropagation();
options.onDpadReset(definition.id);
},
});
options.container.appendChild(panel);
}
}
function createRow(labelText: string, badgeText: string, isDisabled: boolean, isExpanded: boolean): HTMLDivElement {
function createRow(
labelText: string,
badgeText: string,
isDisabled: boolean,
isExpanded: boolean,
): HTMLDivElement {
const row = document.createElement('div');
row.className = 'controller-config-row';
if (isExpanded) row.classList.add('expanded');

View File

@@ -66,7 +66,10 @@ function createFakeElement() {
if (!match) return null;
const testId = match[1];
for (const child of el.children) {
if (typeof child.getAttribute === 'function' && child.getAttribute('data-testid') === testId) {
if (
typeof child.getAttribute === 'function' &&
child.getAttribute('data-testid') === testId
) {
return child;
}
if (typeof child.querySelector === 'function') {
@@ -105,7 +108,10 @@ function installFakeDom() {
return {
restore: () => {
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
Object.defineProperty(globalThis, 'document', {
configurable: true,
value: previousDocument,
});
},
};
}

View File

@@ -31,8 +31,9 @@ export function createControllerSelectModal(
let lastRenderedActiveGamepadId: string | null = null;
let lastRenderedPreferredId = '';
type ControllerBindingKey = keyof NonNullable<typeof ctx.state.controllerConfig>['bindings'];
type ControllerBindingValue =
NonNullable<NonNullable<typeof ctx.state.controllerConfig>['bindings']>[ControllerBindingKey];
type ControllerBindingValue = NonNullable<
NonNullable<typeof ctx.state.controllerConfig>['bindings']
>[ControllerBindingKey];
let learningActionId: ControllerBindingKey | null = null;
let dpadLearningActionId: ControllerBindingKey | null = null;
let bindingCapture: ReturnType<typeof createControllerBindingCapture> | null = null;
@@ -198,7 +199,9 @@ export function createControllerSelectModal(
lastRenderedPreferredId = preferredId;
}
async function saveControllerConfig(update: Parameters<typeof window.electronAPI.saveControllerConfig>[0]) {
async function saveControllerConfig(
update: Parameters<typeof window.electronAPI.saveControllerConfig>[0],
) {
await window.electronAPI.saveControllerConfig(update);
if (!ctx.state.controllerConfig) return;
if (update.preferredGamepadId !== undefined) {
@@ -304,7 +307,10 @@ export function createControllerSelectModal(
if (result.bindingType === 'dpad') {
void saveDpadFallback(result.actionId as ControllerBindingKey, result.dpadDirection);
} else {
void saveBinding(result.actionId as ControllerBindingKey, result.binding as ControllerBindingValue);
void saveBinding(
result.actionId as ControllerBindingKey,
result.binding as ControllerBindingValue,
);
}
}
}

View File

@@ -90,10 +90,7 @@ test('findActiveSubtitleCueIndex prefers current subtitle timing over near-futur
{ startTime: 233.05, endTime: 236, text: 'next' },
];
assert.equal(
findActiveSubtitleCueIndex(cues, { text: 'previous', startTime: 231 }, 233, 0),
0,
);
assert.equal(findActiveSubtitleCueIndex(cues, { text: 'previous', startTime: 231 }, 233, 0), 0);
});
test('subtitle sidebar modal opens from snapshot and clicking cue seeks playback', async () => {
@@ -1217,10 +1214,22 @@ test('subtitle sidebar polling schedules serialized timeouts instead of interval
assert.equal(timeoutCount > 0, true);
assert.equal(intervalCount, 0);
} finally {
Object.defineProperty(globalThis, 'setTimeout', { configurable: true, value: previousSetTimeout });
Object.defineProperty(globalThis, 'clearTimeout', { configurable: true, value: previousClearTimeout });
Object.defineProperty(globalThis, 'setInterval', { configurable: true, value: previousSetInterval });
Object.defineProperty(globalThis, 'clearInterval', { configurable: true, value: previousClearInterval });
Object.defineProperty(globalThis, 'setTimeout', {
configurable: true,
value: previousSetTimeout,
});
Object.defineProperty(globalThis, 'clearTimeout', {
configurable: true,
value: previousClearTimeout,
});
Object.defineProperty(globalThis, 'setInterval', {
configurable: true,
value: previousSetInterval,
});
Object.defineProperty(globalThis, 'clearInterval', {
configurable: true,
value: previousClearInterval,
});
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
}
@@ -1564,17 +1573,13 @@ test('subtitle sidebar embedded layout reserves and releases mpv right margin',
assert.ok(
mpvCommands.some(
(command) =>
command[0] === 'set_property' &&
command[1] === 'osd-align-x' &&
command[2] === 'left',
command[0] === 'set_property' && command[1] === 'osd-align-x' && command[2] === 'left',
),
);
assert.ok(
mpvCommands.some(
(command) =>
command[0] === 'set_property' &&
command[1] === 'osd-align-y' &&
command[2] === 'top',
command[0] === 'set_property' && command[1] === 'osd-align-y' && command[2] === 'top',
),
);
assert.ok(
@@ -1597,7 +1602,11 @@ test('subtitle sidebar embedded layout reserves and releases mpv right margin',
assert.deepEqual(mpvCommands.at(-5), ['set_property', 'video-margin-ratio-right', 0]);
assert.deepEqual(mpvCommands.at(-4), ['set_property', 'osd-align-x', 'left']);
assert.deepEqual(mpvCommands.at(-3), ['set_property', 'osd-align-y', 'top']);
assert.deepEqual(mpvCommands.at(-2), ['set_property', 'user-data/osc/margins', '{"l":0,"r":0,"t":0,"b":0}']);
assert.deepEqual(mpvCommands.at(-2), [
'set_property',
'user-data/osc/margins',
'{"l":0,"r":0,"t":0,"b":0}',
]);
assert.deepEqual(mpvCommands.at(-1), ['set_property', 'video-pan-x', 0]);
assert.equal(bodyClassList.contains('subtitle-sidebar-embedded-open'), false);
assert.deepEqual(rootStyleCalls.at(-1), ['--subtitle-sidebar-reserved-width', '0px']);

View File

@@ -1,8 +1,4 @@
import type {
SubtitleCue,
SubtitleData,
SubtitleSidebarSnapshot,
} from '../../types';
import type { SubtitleCue, SubtitleData, SubtitleSidebarSnapshot } from '../../types';
import type { ModalStateReader, RendererContext } from '../context';
import { syncOverlayMouseIgnoreState } from '../overlay-mouse-ignore.js';
@@ -76,8 +72,7 @@ export function findActiveSubtitleCueIndex(
if (typeof currentTimeSec === 'number' && Number.isFinite(currentTimeSec)) {
const activeOrUpcomingCue = cues.findIndex(
(cue) =>
cue.endTime > currentTimeSec &&
cue.startTime <= currentTimeSec + ACTIVE_CUE_LOOKAHEAD_SEC,
cue.endTime > currentTimeSec && cue.startTime <= currentTimeSec + ACTIVE_CUE_LOOKAHEAD_SEC,
);
if (activeOrUpcomingCue >= 0) {
return activeOrUpcomingCue;
@@ -109,8 +104,7 @@ export function findActiveSubtitleCueIndex(
return -1;
}
const hasTiming =
typeof current.startTime === 'number' && Number.isFinite(current.startTime);
const hasTiming = typeof current.startTime === 'number' && Number.isFinite(current.startTime);
if (preferredCueIndex >= 0) {
if (!hasTiming && currentTimeSec === null) {
@@ -213,16 +207,8 @@ export function createSubtitleSidebarModal(
'video-margin-ratio-right',
Number(ratio.toFixed(4)),
]);
window.electronAPI.sendMpvCommand([
'set_property',
'osd-align-x',
'left',
]);
window.electronAPI.sendMpvCommand([
'set_property',
'osd-align-y',
'top',
]);
window.electronAPI.sendMpvCommand(['set_property', 'osd-align-x', 'left']);
window.electronAPI.sendMpvCommand(['set_property', 'osd-align-y', 'top']);
window.electronAPI.sendMpvCommand([
'set_property',
'user-data/osc/margins',
@@ -302,13 +288,14 @@ export function createSubtitleSidebarModal(
}
const list = ctx.dom.subtitleSidebarList;
const active = list.children[ctx.state.subtitleSidebarActiveCueIndex] as HTMLElement | undefined;
const active = list.children[ctx.state.subtitleSidebarActiveCueIndex] as
| HTMLElement
| undefined;
if (!active) {
return;
}
const targetScrollTop =
active.offsetTop - (list.clientHeight - active.clientHeight) / 2;
const targetScrollTop = active.offsetTop - (list.clientHeight - active.clientHeight) / 2;
const nextScrollTop = Math.max(0, targetScrollTop);
if (previousActiveCueIndex < 0) {
list.scrollTop = nextScrollTop;
@@ -363,9 +350,9 @@ export function createSubtitleSidebarModal(
}
if (ctx.state.subtitleSidebarActiveCueIndex >= 0) {
const current = ctx.dom.subtitleSidebarList.children[ctx.state.subtitleSidebarActiveCueIndex] as
| HTMLElement
| undefined;
const current = ctx.dom.subtitleSidebarList.children[
ctx.state.subtitleSidebarActiveCueIndex
] as HTMLElement | undefined;
current?.classList.add('active');
}
}
@@ -476,7 +463,11 @@ export function createSubtitleSidebarModal(
async function autoOpenSubtitleSidebarOnStartup(): Promise<void> {
const snapshot = await refreshSnapshot();
if (!snapshot.config.enabled || !snapshot.config.autoOpen || ctx.state.subtitleSidebarModalOpen) {
if (
!snapshot.config.enabled ||
!snapshot.config.autoOpen ||
ctx.state.subtitleSidebarModalOpen
) {
return;
}
await openSubtitleSidebarModal();
@@ -512,10 +503,7 @@ export function createSubtitleSidebarModal(
return;
}
updateActiveCue(
{ text: data.text, startTime: data.startTime },
data.startTime ?? null,
);
updateActiveCue({ text: data.text, startTime: data.startTime }, data.startTime ?? null);
}
function wireDomEvents(): void {

View File

@@ -28,13 +28,13 @@ export function createYoutubeTrackPickerModal(
function setStatus(message: string, isError = false): void {
ctx.state.youtubePickerStatus = message;
ctx.dom.youtubePickerStatus.textContent = message;
ctx.dom.youtubePickerStatus.style.color = isError
? '#ed8796'
: '#a5adcb';
ctx.dom.youtubePickerStatus.style.color = isError ? '#ed8796' : '#a5adcb';
}
function getTrackLabel(trackId: string): string {
return ctx.state.youtubePickerPayload?.tracks.find((track) => track.id === trackId)?.label ?? '';
return (
ctx.state.youtubePickerPayload?.tracks.find((track) => track.id === trackId)?.label ?? ''
);
}
function renderTrackList(): void {
@@ -82,10 +82,7 @@ export function createYoutubeTrackPickerModal(
if (track.id === primaryTrackId) continue;
ctx.dom.youtubePickerSecondarySelect.appendChild(createOption(track.id, track.label));
}
if (
primaryTrackId &&
ctx.dom.youtubePickerSecondarySelect.value === primaryTrackId
) {
if (primaryTrackId && ctx.dom.youtubePickerSecondarySelect.value === primaryTrackId) {
ctx.dom.youtubePickerSecondarySelect.value = '';
}
}
@@ -126,7 +123,9 @@ export function createYoutubeTrackPickerModal(
setStatus('Select the subtitle tracks to download.');
}
async function resolveSelection(action: 'use-selected' | 'continue-without-subtitles'): Promise<void> {
async function resolveSelection(
action: 'use-selected' | 'continue-without-subtitles',
): Promise<void> {
if (resolveSelectionInFlight) {
return;
}
@@ -238,7 +237,9 @@ export function createYoutubeTrackPickerModal(
return true;
}
void resolveSelection(
payloadHasTracks(ctx.state.youtubePickerPayload) ? 'use-selected' : 'continue-without-subtitles',
payloadHasTracks(ctx.state.youtubePickerPayload)
? 'use-selected'
: 'continue-without-subtitles',
);
return true;
}
@@ -269,7 +270,9 @@ export function createYoutubeTrackPickerModal(
ctx.dom.youtubePickerContinueButton.addEventListener('click', () => {
void resolveSelection(
payloadHasTracks(ctx.state.youtubePickerPayload) ? 'use-selected' : 'continue-without-subtitles',
payloadHasTracks(ctx.state.youtubePickerPayload)
? 'use-selected'
: 'continue-without-subtitles',
);
});

View File

@@ -552,8 +552,14 @@ async function init(): Promise<void> {
ctx.dom.subtitleContainer.addEventListener('mouseenter', mouseHandlers.handlePrimaryMouseEnter);
ctx.dom.subtitleContainer.addEventListener('mouseleave', mouseHandlers.handlePrimaryMouseLeave);
ctx.dom.secondarySubContainer.addEventListener('mouseenter', mouseHandlers.handleSecondaryMouseEnter);
ctx.dom.secondarySubContainer.addEventListener('mouseleave', mouseHandlers.handleSecondaryMouseLeave);
ctx.dom.secondarySubContainer.addEventListener(
'mouseenter',
mouseHandlers.handleSecondaryMouseEnter,
);
ctx.dom.secondarySubContainer.addEventListener(
'mouseleave',
mouseHandlers.handleSecondaryMouseLeave,
);
mouseHandlers.setupResizeHandler();
mouseHandlers.setupPointerTracking();

View File

@@ -296,7 +296,7 @@ body {
.youtube-picker-content {
width: min(820px, 92%);
background:
radial-gradient(circle at top right, rgba(198, 160, 246, 0.10), transparent 34%),
radial-gradient(circle at top right, rgba(198, 160, 246, 0.1), transparent 34%),
linear-gradient(180deg, rgba(36, 39, 58, 0.98), rgba(30, 32, 48, 0.98));
border-color: rgba(138, 173, 244, 0.25);
}
@@ -1342,8 +1342,14 @@ iframe[id^='yomitan-popup'] {
}
@keyframes configEditSlideIn {
from { max-height: 0; opacity: 0; }
to { max-height: 120px; opacity: 1; }
from {
max-height: 0;
opacity: 0;
}
to {
max-height: 120px;
opacity: 1;
}
}
.controller-config-edit-inner {
@@ -1365,8 +1371,13 @@ iframe[id^='yomitan-popup'] {
}
@keyframes configLearnPulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.6; }
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.6;
}
}
.controller-config-edit-actions {
@@ -1404,7 +1415,9 @@ iframe[id^='yomitan-popup'] {
color: #6e738d;
font-size: 12px;
cursor: pointer;
transition: background 120ms ease, color 120ms ease;
transition:
background 120ms ease,
color 120ms ease;
}
.btn-secondary:hover {
@@ -1497,8 +1510,7 @@ body.subtitle-sidebar-embedded-open .subtitle-sidebar-modal {
max-height: calc(100vh - 28px);
height: auto;
margin-left: auto;
font-family:
var(
font-family: var(
--subtitle-sidebar-font-family,
'M PLUS 1',
'Noto Sans CJK JP',

View File

@@ -981,18 +981,9 @@ test('JLPT CSS rules use underline-only styling in renderer stylesheet', () => {
cssText,
'body.subtitle-sidebar-embedded-open #secondarySubContainer.secondary-sub-hover',
);
assert.match(
secondaryEmbeddedHoverBlock,
/right:\s*var\(--subtitle-sidebar-reserved-width\);/,
);
assert.match(
secondaryEmbeddedHoverBlock,
/max-width:\s*none;/,
);
assert.match(
secondaryEmbeddedHoverBlock,
/transform:\s*none;/,
);
assert.match(secondaryEmbeddedHoverBlock, /right:\s*var\(--subtitle-sidebar-reserved-width\);/);
assert.match(secondaryEmbeddedHoverBlock, /max-width:\s*none;/);
assert.match(secondaryEmbeddedHoverBlock, /transform:\s*none;/);
assert.doesNotMatch(
secondaryEmbeddedHoverBlock,
/transform:\s*translateX\(calc\(var\(--subtitle-sidebar-reserved-width\)\s*\*\s*-0\.5\)\);/,

View File

@@ -3,11 +3,7 @@ import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import {
appendLogLine,
pruneLogFiles,
resolveDefaultLogFilePath,
} from './log-files';
import { appendLogLine, pruneLogFiles, resolveDefaultLogFilePath } from './log-files';
test('resolveDefaultLogFilePath uses app prefix by default', () => {
const now = new Date('2026-03-22T12:00:00.000Z');
@@ -36,8 +32,16 @@ test('pruneLogFiles removes logs older than retention window', () => {
fs.writeFileSync(stalePath, 'stale\n', 'utf8');
fs.writeFileSync(freshPath, 'fresh\n', 'utf8');
const now = new Date('2026-03-22T12:00:00.000Z');
fs.utimesSync(stalePath, new Date('2026-03-01T12:00:00.000Z'), new Date('2026-03-01T12:00:00.000Z'));
fs.utimesSync(freshPath, new Date('2026-03-21T12:00:00.000Z'), new Date('2026-03-21T12:00:00.000Z'));
fs.utimesSync(
stalePath,
new Date('2026-03-01T12:00:00.000Z'),
new Date('2026-03-01T12:00:00.000Z'),
);
fs.utimesSync(
freshPath,
new Date('2026-03-21T12:00:00.000Z'),
new Date('2026-03-21T12:00:00.000Z'),
);
try {
pruneLogFiles(logsDir, { retentionDays: 7, now });

View File

@@ -69,7 +69,9 @@ test('stats daemon control clears stale state, starts daemon, and waits for resp
},
resolveUrl: (state) => `http://127.0.0.1:${state.port}`,
spawnDaemon: async (options) => {
calls.push(`spawnDaemon:${options.scriptPath}:${options.responsePath}:${options.userDataPath}`);
calls.push(
`spawnDaemon:${options.scriptPath}:${options.responsePath}:${options.userDataPath}`,
);
return 999;
},
waitForDaemonResponse: async (responsePath) => {

View File

@@ -13,7 +13,10 @@ import {
writeBackgroundStatsServerState,
} from './main/runtime/stats-daemon';
import { writeStatsCliCommandResponse } from './main/runtime/stats-cli-command';
import { createInvokeStatsWordHelperHandler, type StatsWordHelperResponse } from './stats-word-helper-client';
import {
createInvokeStatsWordHelperHandler,
type StatsWordHelperResponse,
} from './stats-word-helper-client';
const logger = createLogger('stats-daemon');
const STATS_WORD_HELPER_RESPONSE_TIMEOUT_MS = 20_000;

View File

@@ -33,7 +33,9 @@ export function createInvokeStatsWordHelperHandler(deps: {
});
const startupResult = await Promise.race([
deps.waitForResponse(responsePath).then((response) => ({ kind: 'response' as const, response })),
deps
.waitForResponse(responsePath)
.then((response) => ({ kind: 'response' as const, response })),
helperExitPromise.then((status) => ({ kind: 'exit' as const, status })),
]);
@@ -42,7 +44,9 @@ export function createInvokeStatsWordHelperHandler(deps: {
response = startupResult.response;
} else {
if (startupResult.status !== 0) {
throw new Error(`Stats word helper exited before response (status ${startupResult.status}).`);
throw new Error(
`Stats word helper exited before response (status ${startupResult.status}).`,
);
}
response = await deps.waitForResponse(responsePath);
}