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.equal(handled, true);
assert.deepEqual(harness.forwarded, [ 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); 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; const final = await statsCommand;
assert.equal(final, true); assert.equal(final, true);
assert.deepEqual(harness.forwarded, [ 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); 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.equal(handled, true);
assert.deepEqual(harness.forwarded, [ 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); assert.equal(harness.removedPaths.length, 1);
}); });

View File

@@ -209,7 +209,11 @@ export async function runPlaybackCommandWithDeps(
pluginRuntimeConfig.autoStartPauseUntilReady; pluginRuntimeConfig.autoStartPauseUntilReady;
if (shouldPauseUntilOverlayReady) { 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( await deps.startMpv(
@@ -250,7 +254,11 @@ export async function runPlaybackCommandWithDeps(
if (ready) { if (ready) {
deps.log('info', args.logLevel, 'MPV IPC socket ready, relying on mpv plugin auto-start'); deps.log('info', args.logLevel, 'MPV IPC socket ready, relying on mpv plugin auto-start');
} else { } 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) { } else if (ready) {
deps.log( deps.log(

View File

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

View File

@@ -14,12 +14,7 @@ test('getDefaultMpvLogFile uses APPDATA on windows', () => {
assert.equal( assert.equal(
path.normalize(resolved), path.normalize(resolved),
path.normalize( path.normalize(
path.join( path.join('C:\\Users\\tester\\AppData\\Roaming', 'SubMiner', 'logs', `mpv-${today}.log`),
'C:\\Users\\tester\\AppData\\Roaming',
'SubMiner',
'logs',
`mpv-${today}.log`,
),
), ),
); );
}); });
@@ -33,12 +28,6 @@ test('getDefaultLauncherLogFile uses launcher prefix', () => {
assert.equal( assert.equal(
resolved, resolved,
path.join( path.join('/home/tester', '.config', 'SubMiner', 'logs', `launcher-${today}.log`),
'/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_APPIMAGE_PATH: appPath,
SUBMINER_TEST_MPV_ARGS: mpvArgsPath, SUBMINER_TEST_MPV_ARGS: mpvArgsPath,
}; };
const result = runLauncher( const result = runLauncher(['--args', '--pause=yes --title="movie night"', videoPath], env);
['--args', '--pause=yes --title="movie night"', videoPath],
env,
);
assert.equal(result.status, 0, `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`); assert.equal(result.status, 0, `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`);
const argsFile = fs.readFileSync(mpvArgsPath, 'utf8'); 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); const result = runLauncher(['--log-level', 'debug', videoPath], env);
assert.equal(result.status, 0, `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`); assert.equal(result.status, 0, `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`);
assert.match( assert.match(fs.readFileSync(mpvArgsPath, 'utf8'), /--script-opts=.*subminer-log_level=debug/);
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; const originalAccessSync = fs.accessSync;
try { try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any // 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 { try {
os.homedir = () => baseDir; os.homedir = () => baseDir;
withFindAppBinaryEnvSandbox(() => { withFindAppBinaryEnvSandbox(() => {
withAccessSyncStub((filePath) => filePath === '/opt/SubMiner/SubMiner.AppImage', () => { withAccessSyncStub(
(filePath) => filePath === '/opt/SubMiner/SubMiner.AppImage',
() => {
const result = findAppBinary('/some/other/path/subminer'); const result = findAppBinary('/some/other/path/subminer');
assert.equal(result, '/opt/SubMiner/SubMiner.AppImage'); assert.equal(result, '/opt/SubMiner/SubMiner.AppImage');
}); },
);
}); });
} finally { } finally {
os.homedir = originalHomedir; 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 ?? ''}`; process.env.PATH = `${binDir}${path.delimiter}${originalPath ?? ''}`;
withFindAppBinaryEnvSandbox(() => { withFindAppBinaryEnvSandbox(() => {
withAccessSyncStub((filePath) => filePath === wrapperPath, () => { withAccessSyncStub(
(filePath) => filePath === wrapperPath,
() => {
// selfPath must differ from wrapperPath so the self-check does not exclude it // selfPath must differ from wrapperPath so the self-check does not exclude it
const result = findAppBinary(path.join(baseDir, 'launcher', 'subminer')); const result = findAppBinary(path.join(baseDir, 'launcher', 'subminer'));
assert.equal(result, wrapperPath); assert.equal(result, wrapperPath);
}); },
);
}); });
} finally { } finally {
os.homedir = originalHomedir; os.homedir = originalHomedir;

View File

@@ -47,7 +47,11 @@ export function parseMpvArgString(input: string): string[] {
let inDoubleQuote = false; let inDoubleQuote = false;
let escaping = false; let escaping = false;
const canEscape = (nextChar: string | undefined): boolean => 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) { for (let i = 0; i < chars.length; i += 1) {
const ch = chars[i] || ''; const ch = chars[i] || '';
@@ -598,7 +602,9 @@ export async function startMpv(
? await resolveAniSkipMetadataForFile(target) ? await resolveAniSkipMetadataForFile(target)
: null; : null;
const extraScriptOpts = const extraScriptOpts =
targetKind === 'url' && isYoutubeTarget(target) && options?.disableYoutubeSubtitleAutoLoad === true targetKind === 'url' &&
isYoutubeTarget(target) &&
options?.disableYoutubeSubtitleAutoLoad === true
? ['subminer-auto_start_pause_until_ready=no'] ? ['subminer-auto_start_pause_until_ready=no']
: []; : [];
const scriptOpts = buildSubminerScriptOpts( const scriptOpts = buildSubminerScriptOpts(
@@ -1064,7 +1070,9 @@ export function launchMpvIdleDetached(
mpvArgs.push(...parseMpvArgString(args.mpvArgs)); mpvArgs.push(...parseMpvArgString(args.mpvArgs));
} }
mpvArgs.push('--idle=yes'); 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(`--log-file=${getMpvLogPath()}`);
mpvArgs.push(`--input-ipc-server=${socketPath}`); mpvArgs.push(`--input-ipc-server=${socketPath}`);
const mpvTarget = resolveCommandInvocation('mpv', mpvArgs); 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(projectRoot, { recursive: true });
fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true }); fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true });
fs.writeFileSync(path.join(projectRoot, 'CHANGELOG.md'), existingChangelog, 'utf8'); 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 { try {
const result = writeChangelogArtifacts({ 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'); const changelog = fs.readFileSync(path.join(projectRoot, 'CHANGELOG.md'), 'utf8');
assert.equal(changelog, existingChangelog); 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\./); assert.match(releaseNotes, /## Highlights\n### Added\n- Existing release bullet\./);
} finally { } finally {
fs.rmSync(workspace, { recursive: true, force: true }); fs.rmSync(workspace, { recursive: true, force: true });

View File

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

View File

@@ -55,10 +55,7 @@ exit 1
`, `,
); );
const result = spawnSync( const result = spawnSync('bash', ['scripts/patch-modernz.sh', '--target', target], {
'bash',
['scripts/patch-modernz.sh', '--target', target],
{
cwd: process.cwd(), cwd: process.cwd(),
encoding: 'utf8', encoding: 'utf8',
env: { env: {
@@ -66,8 +63,7 @@ exit 1
HOME: path.join(root, 'home'), HOME: path.join(root, 'home'),
PATH: `${binDir}:${process.env.PATH || ''}`, PATH: `${binDir}:${process.env.PATH || ''}`,
}, },
}, });
);
assert.equal(result.status, 1, result.stderr || result.stdout); assert.equal(result.status, 1, result.stderr || result.stdout);
assert.match(result.stderr, /failed to apply patch to/); 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 pkgbuild = fs.readFileSync(path.join(pkgDir, 'PKGBUILD'), 'utf8');
const srcinfo = fs.readFileSync(path.join(pkgDir, '.SRCINFO'), 'utf8'); const srcinfo = fs.readFileSync(path.join(pkgDir, '.SRCINFO'), 'utf8');
const expectedSums = [appImagePath, wrapperPath, assetsPath].map((filePath) => const expectedSums = [appImagePath, wrapperPath, assetsPath].map(
execFileSync('sha256sum', [filePath], { encoding: 'utf8' }).split(/\s+/)[0], (filePath) => execFileSync('sha256sum', [filePath], { encoding: 'utf8' }).split(/\s+/)[0],
); );
assert.match(pkgbuild, /^pkgver=0\.6\.3$/m); 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 { MediaGenerator } from './media-generator';
import path from 'path'; import path from 'path';
import { import {
AiConfig,
AnkiConnectConfig, AnkiConnectConfig,
KikuDuplicateCardInfo, KikuDuplicateCardInfo,
KikuFieldGroupingChoice, KikuFieldGroupingChoice,
KikuMergePreviewResponse, KikuMergePreviewResponse,
MpvClient,
NotificationOptions, NotificationOptions,
NPlusOneMatchMode, } from './types/anki';
} from './types'; import { AiConfig } from './types/integrations';
import { MpvClient } from './types/runtime';
import { NPlusOneMatchMode } from './types/subtitle';
import { DEFAULT_ANKI_CONNECT_CONFIG } from './config'; import { DEFAULT_ANKI_CONNECT_CONFIG } from './config';
import { import {
getConfiguredWordFieldCandidates, getConfiguredWordFieldCandidates,
@@ -212,10 +212,7 @@ export class AnkiIntegration {
try { try {
this.recordCardsMinedCallback(count, noteIds); this.recordCardsMinedCallback(count, noteIds);
} catch (error) { } catch (error) {
log.warn( log.warn(`recordCardsMined callback failed during ${source}:`, (error as Error).message);
`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'; import { resolveAnimatedImageLeadInSeconds, extractSoundFilenames } from './animated-image-sync';
test('extractSoundFilenames returns ordered sound filenames from an Anki field value', () => { test('extractSoundFilenames returns ordered sound filenames from an Anki field value', () => {
assert.deepEqual( assert.deepEqual(extractSoundFilenames('before [sound:word.mp3] middle [sound:alt.ogg] after'), [
extractSoundFilenames('before [sound:word.mp3] middle [sound:alt.ogg] after'), 'word.mp3',
['word.mp3', 'alt.ogg'], 'alt.ogg',
); ]);
}); });
test('resolveAnimatedImageLeadInSeconds sums configured word audio durations for animated images', async () => { 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 fallbackFieldName = configuredFieldNames[0]?.toLowerCase() || 'expression';
const fallbackKey = `${fallbackFieldName}:${normalizeDuplicateValue(trimmedFallback)}`; const fallbackKey = `${fallbackFieldName}:${normalizeDuplicateValue(trimmedFallback)}`;
if (!dedupeKey.has(fallbackKey)) { 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, config.controller.bindings.leftStickHorizontal,
DEFAULT_CONFIG.controller.bindings.leftStickHorizontal, DEFAULT_CONFIG.controller.bindings.leftStickHorizontal,
); );
assert.equal(warnings.some((warning) => warning.path === 'controller.bindings.toggleLookup'), true); assert.equal(
assert.equal(warnings.some((warning) => warning.path === 'controller.bindings.closeLookup'), true); warnings.some((warning) => warning.path === 'controller.bindings.toggleLookup'),
true,
);
assert.equal(
warnings.some((warning) => warning.path === 'controller.bindings.closeLookup'),
true,
);
assert.equal( assert.equal(
warnings.some((warning) => warning.path === 'controller.bindings.leftStickHorizontal'), warnings.some((warning) => warning.path === 'controller.bindings.leftStickHorizontal'),
true, true,

View File

@@ -17,7 +17,12 @@ export function applyStatsConfig(context: ResolveContext): void {
if (markWatchedKey !== undefined) { if (markWatchedKey !== undefined) {
resolved.stats.markWatchedKey = markWatchedKey; resolved.stats.markWatchedKey = markWatchedKey;
} else if (src.stats.markWatchedKey !== undefined) { } 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); const serverPort = asNumber(src.stats.serverPort);

View File

@@ -49,7 +49,10 @@ test('subtitleSidebar accepts zero opacity', () => {
applySubtitleDomainConfig(context); applySubtitleDomainConfig(context);
assert.equal(context.resolved.subtitleSidebar.opacity, 0); 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', () => { 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); await runAppReadyRuntime(deps);
assert.deepEqual(calls, [ assert.deepEqual(calls, ['ensureDefaultConfigBootstrap', 'reloadConfig', 'handleInitialArgs']);
'ensureDefaultConfigBootstrap',
'reloadConfig',
'handleInitialArgs',
]);
}); });
test('runAppReadyRuntime skips Jellyfin remote startup when dependency is not wired', async () => { 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) { for (const key of keys) {
if (key === 'subtitleStyle' || key === 'keybindings' || key === 'shortcuts' || key === 'subtitleSidebar') { if (
key === 'subtitleStyle' ||
key === 'keybindings' ||
key === 'shortcuts' ||
key === 'subtitleSidebar'
) {
continue; continue;
} }

View File

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

View File

@@ -70,7 +70,11 @@ function createControllerConfigFixture() {
nextAudio: { kind: 'button' as const, buttonIndex: 5 }, nextAudio: { kind: 'button' as const, buttonIndex: 5 },
playCurrentAudio: { kind: 'button' as const, buttonIndex: 7 }, playCurrentAudio: { kind: 'button' as const, buttonIndex: 7 },
toggleMpvPause: { kind: 'button' as const, buttonIndex: 6 }, 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 }, leftStickVertical: { kind: 'axis' as const, axisIndex: 1, dpadFallback: 'vertical' as const },
rightStickHorizontal: { kind: 'axis' as const, axisIndex: 3, dpadFallback: 'none' as const }, rightStickHorizontal: { kind: 'axis' as const, axisIndex: 3, dpadFallback: 'none' as const },
rightStickVertical: { kind: 'axis' as const, axisIndex: 4, 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; getCurrentSecondarySub: () => string;
focusMainWindow: () => void; focusMainWindow: () => void;
runSubsyncManual: (request: SubsyncManualRunRequest) => Promise<SubsyncResult>; runSubsyncManual: (request: SubsyncManualRunRequest) => Promise<SubsyncResult>;
onYoutubePickerResolve: (request: YoutubePickerResolveRequest) => Promise<YoutubePickerResolveResult>; onYoutubePickerResolve: (
request: YoutubePickerResolveRequest,
) => Promise<YoutubePickerResolveResult>;
getAnkiConnectStatus: () => boolean; getAnkiConnectStatus: () => boolean;
getRuntimeOptions: () => unknown; getRuntimeOptions: () => unknown;
setRuntimeOption: (id: RuntimeOptionId, value: RuntimeOptionValue) => unknown; setRuntimeOption: (id: RuntimeOptionId, value: RuntimeOptionValue) => unknown;
@@ -167,7 +169,9 @@ export interface IpcDepsRuntimeOptions {
getMpvClient: () => MpvClientLike | null; getMpvClient: () => MpvClientLike | null;
focusMainWindow: () => void; focusMainWindow: () => void;
runSubsyncManual: (request: SubsyncManualRunRequest) => Promise<SubsyncResult>; runSubsyncManual: (request: SubsyncManualRunRequest) => Promise<SubsyncResult>;
onYoutubePickerResolve: (request: YoutubePickerResolveRequest) => Promise<YoutubePickerResolveResult>; onYoutubePickerResolve: (
request: YoutubePickerResolveRequest,
) => Promise<YoutubePickerResolveResult>;
getAnkiConnectStatus: () => boolean; getAnkiConnectStatus: () => boolean;
getRuntimeOptions: () => unknown; getRuntimeOptions: () => unknown;
setRuntimeOption: (id: RuntimeOptionId, value: RuntimeOptionValue) => unknown; setRuntimeOption: (id: RuntimeOptionId, value: RuntimeOptionValue) => unknown;
@@ -291,13 +295,16 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
deps.onOverlayModalOpened(parsedModal); 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); const parsedRequest = parseYoutubePickerResolveRequest(request);
if (!parsedRequest) { if (!parsedRequest) {
return { ok: false, message: 'Invalid YouTube picker resolve payload' }; return { ok: false, message: 'Invalid YouTube picker resolve payload' };
} }
return await deps.onYoutubePickerResolve(parsedRequest); return await deps.onYoutubePickerResolve(parsedRequest);
}); },
);
ipc.on(IPC_CHANNELS.command.openYomitanSettings, () => { ipc.on(IPC_CHANNELS.command.openYomitanSettings, () => {
deps.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); const parsedUpdate = parseControllerConfigUpdate(update);
if (!parsedUpdate) { if (!parsedUpdate) {
throw new Error('Invalid controller config payload'); throw new Error('Invalid controller config payload');
} }
await deps.saveControllerConfig(parsedUpdate); await deps.saveControllerConfig(parsedUpdate);
}); },
);
ipc.handle(IPC_CHANNELS.request.getMecabStatus, () => { ipc.handle(IPC_CHANNELS.request.getMecabStatus, () => {
return deps.getMecabStatus(); return deps.getMecabStatus();

View File

@@ -228,7 +228,11 @@ test('consumeCachedSubtitle returns prefetched payload and prevents reprocessing
controller.onSubtitleChange('猫\nです'); controller.onSubtitleChange('猫\nです');
await flushMicrotasks(); 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, []); 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 () => { test('tokenizeSubtitle keeps trailing quote-particle merged tokens hoverable while clearing only their annotation metadata', async () => {
const result = await tokenizeSubtitle( const result = await tokenizeSubtitle(
'どうしてもって', 'どうしてもって',
makeDepsFromYomitanTokens([{ surface: 'どうしてもって', reading: 'どうしてもって', headword: 'どうしても' }], { makeDepsFromYomitanTokens(
[{ surface: 'どうしてもって', reading: 'どうしてもって', headword: 'どうしても' }],
{
getFrequencyDictionaryEnabled: () => true, getFrequencyDictionaryEnabled: () => true,
getFrequencyRank: (text) => (text === 'どうしても' ? 123 : null), getFrequencyRank: (text) => (text === 'どうしても' ? 123 : null),
getJlptLevel: (text) => (text === 'どうしても' ? 'N3' : null), getJlptLevel: (text) => (text === 'どうしても' ? 'N3' : null),
@@ -3461,7 +3463,8 @@ test('tokenizeSubtitle keeps trailing quote-particle merged tokens hoverable whi
}, },
], ],
getMinSentenceWordsForNPlusOne: () => 1, getMinSentenceWordsForNPlusOne: () => 1,
}), },
),
); );
assert.equal(result.text, 'どうしてもって'); assert.equal(result.text, 'どうしてもって');
@@ -3812,7 +3815,14 @@ test('tokenizeSubtitle clears all annotations for explanatory pondering endings'
jlptLevel: token.jlptLevel, jlptLevel: token.jlptLevel,
})), })),
[ [
{ surface: '俺', headword: '俺', isKnown: true, isNPlusOneTarget: false, frequencyRank: 19, jlptLevel: 'N5' }, {
surface: '俺',
headword: '俺',
isKnown: true,
isNPlusOneTarget: false,
frequencyRank: 19,
jlptLevel: 'N5',
},
{ {
surface: 'どうかしちゃった', surface: 'どうかしちゃった',
headword: 'どうかしちゃう', headword: 'どうかしちゃう',

View File

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

View File

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

View File

@@ -3,7 +3,11 @@ import type { YoutubeTrackKind } from './kinds';
export type { YoutubeTrackKind }; export type { YoutubeTrackKind };
export function normalizeYoutubeLangCode(value: string): string { 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 { export function isJapaneseYoutubeLang(value: string): boolean {

View File

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

View File

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

View File

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

View File

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

View File

@@ -127,7 +127,10 @@ export async function probeYoutubeTracks(targetUrl: string): Promise<YoutubeTrac
}${snippet ? `; stdout=${snippet}` : ''}`, }${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 { return {
videoId: info.id || '', videoId: info.id || '',
title: info.title || '', title: info.title || '',

View File

@@ -10,9 +10,10 @@ function pickTrack(
return matching[0] ?? null; return matching[0] ?? null;
} }
export function chooseDefaultYoutubeTrackIds( export function chooseDefaultYoutubeTrackIds(tracks: YoutubeTrackOption[]): {
tracks: YoutubeTrackOption[], primaryTrackId: string | null;
): { primaryTrackId: string | null; secondaryTrackId: string | null } { secondaryTrackId: string | null;
} {
const primary = const primary =
pickTrack( pickTrack(
tracks.filter((track) => track.kind === 'manual'), tracks.filter((track) => track.kind === 'manual'),
@@ -52,7 +53,11 @@ export function normalizeYoutubeTrackSelection(input: {
primaryTrackId: string | null; primaryTrackId: string | null;
secondaryTrackId: string | null; secondaryTrackId: string | null;
} { } {
if (input.primaryTrackId && input.secondaryTrackId && input.primaryTrackId === input.secondaryTrackId) { if (
input.primaryTrackId &&
input.secondaryTrackId &&
input.primaryTrackId === input.secondaryTrackId
) {
return { return {
primaryTrackId: input.primaryTrackId, primaryTrackId: input.primaryTrackId,
secondaryTrackId: null, secondaryTrackId: null,
@@ -60,4 +65,3 @@ export function normalizeYoutubeTrackSelection(input: {
} }
return 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 LogLevel = 'debug' | 'info' | 'warn' | 'error';
export type LogLevelSource = 'cli' | 'config'; export type LogLevelSource = 'cli' | 'config';

View File

@@ -82,10 +82,9 @@ test('stats-daemon entry helper detects internal daemon commands', () => {
true, true,
); );
assert.equal( assert.equal(
shouldHandleStatsDaemonCommandAtEntry( shouldHandleStatsDaemonCommandAtEntry(['SubMiner.AppImage', '--stats-daemon-start'], {
['SubMiner.AppImage', '--stats-daemon-start'], ELECTRON_RUN_AS_NODE: '1',
{ ELECTRON_RUN_AS_NODE: '1' }, }),
),
false, false,
); );
assert.equal(shouldHandleStatsDaemonCommandAtEntry(['SubMiner.AppImage', '--start'], {}), 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 texthookerService = new Texthooker(() => {
const config = getResolvedConfig(); const config = getResolvedConfig();
const characterDictionaryEnabled = const characterDictionaryEnabled =
config.anilist.characterDictionary.enabled && yomitanProfilePolicy.isCharacterDictionaryEnabled(); config.anilist.characterDictionary.enabled &&
yomitanProfilePolicy.isCharacterDictionaryEnabled();
const knownAndNPlusOneEnabled = getRuntimeBooleanOption( const knownAndNPlusOneEnabled = getRuntimeBooleanOption(
'subtitle.annotation.nPlusOne', 'subtitle.annotation.nPlusOne',
config.ankiConnect.knownWords.highlightEnabled, config.ankiConnect.knownWords.highlightEnabled,
@@ -828,7 +829,8 @@ const youtubeFlowRuntime = createYoutubeFlowRuntime({
{ {
sendToActiveOverlayWindow: (channel, nextPayload, runtimeOptions) => sendToActiveOverlayWindow: (channel, nextPayload, runtimeOptions) =>
overlayModalRuntime.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), logWarn: (message) => logger.warn(message),
}, },
payload, payload,
@@ -871,7 +873,10 @@ const youtubeFlowRuntime = createYoutubeFlowRuntime({
await Promise.race([ await Promise.race([
integration.waitUntilReady(), integration.waitUntilReady(),
new Promise<never>((_, reject) => { 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) { } catch (error) {
@@ -3027,7 +3032,8 @@ const ensureStatsServerStarted = (): string => {
knownWordCachePath: path.join(USER_DATA_PATH, 'known-words-cache.json'), knownWordCachePath: path.join(USER_DATA_PATH, 'known-words-cache.json'),
mpvSocketPath: appState.mpvSocketPath, mpvSocketPath: appState.mpvSocketPath,
ankiConnectConfig: getResolvedConfig().ankiConnect, ankiConnectConfig: getResolvedConfig().ankiConnect,
resolveAnkiNoteId: (noteId: number) => appState.ankiIntegration?.resolveCurrentNoteId(noteId) ?? noteId, resolveAnkiNoteId: (noteId: number) =>
appState.ankiIntegration?.resolveCurrentNoteId(noteId) ?? noteId,
addYomitanNote: async (word: string) => { addYomitanNote: async (word: string) => {
const ankiUrl = getResolvedConfig().ankiConnect.url || 'http://127.0.0.1:8765'; const ankiUrl = getResolvedConfig().ankiConnect.url || 'http://127.0.0.1:8765';
await syncYomitanDefaultAnkiServerCore(ankiUrl, yomitanDeps, yomitanLogger, { await syncYomitanDefaultAnkiServerCore(ankiUrl, yomitanDeps, yomitanLogger, {
@@ -4589,7 +4595,8 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
openYomitanSettings: () => openYomitanSettings(), openYomitanSettings: () => openYomitanSettings(),
quitApp: () => requestAppQuit(), quitApp: () => requestAppQuit(),
toggleVisibleOverlay: () => toggleVisibleOverlay(), toggleVisibleOverlay: () => toggleVisibleOverlay(),
tokenizeCurrentSubtitle: async () => withCurrentSubtitleTiming(await tokenizeSubtitle(appState.currentSubText)), tokenizeCurrentSubtitle: async () =>
withCurrentSubtitleTiming(await tokenizeSubtitle(appState.currentSubText)),
getCurrentSubtitleRaw: () => appState.currentSubText, getCurrentSubtitleRaw: () => appState.currentSubText,
getCurrentSubtitleAss: () => appState.currentSubAssText, getCurrentSubtitleAss: () => appState.currentSubAssText,
getSubtitleSidebarSnapshot: async () => { getSubtitleSidebarSnapshot: async () => {
@@ -4611,13 +4618,8 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
} }
try { try {
const [ const [currentExternalFilenameRaw, currentTrackRaw, trackListRaw, sidRaw, videoPathRaw] =
currentExternalFilenameRaw, await Promise.all([
currentTrackRaw,
trackListRaw,
sidRaw,
videoPathRaw,
] = await Promise.all([
client.requestProperty('current-tracks/sub/external-filename').catch(() => null), client.requestProperty('current-tracks/sub/external-filename').catch(() => null),
client.requestProperty('current-tracks/sub').catch(() => null), client.requestProperty('current-tracks/sub').catch(() => null),
client.requestProperty('track-list'), client.requestProperty('track-list'),

View File

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

View File

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

View File

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

View File

@@ -4,14 +4,12 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
appState: { appState: {
initialArgs?: { jellyfinPlay?: unknown; youtubePlay?: unknown } | null; initialArgs?: { jellyfinPlay?: unknown; youtubePlay?: unknown } | null;
overlayRuntimeInitialized: boolean; overlayRuntimeInitialized: boolean;
mpvClient: mpvClient: {
| {
connected?: boolean; connected?: boolean;
currentSecondarySubText?: string; currentSecondarySubText?: string;
currentTimePos?: number; currentTimePos?: number;
requestProperty?: (name: string) => Promise<unknown>; requestProperty?: (name: string) => Promise<unknown>;
} } | null;
| null;
immersionTracker: { immersionTracker: {
recordSubtitleLine?: ( recordSubtitleLine?: (
text: string, 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', () => { test('autoplay release uses the full startup timeout window while paused', () => {
assert.equal( assert.equal(
resolveAutoplayReadyMaxReleaseAttempts({ forceWhilePaused: true }), resolveAutoplayReadyMaxReleaseAttempts({ forceWhilePaused: true }),
Math.ceil( Math.ceil(STARTUP_AUTOPLAY_RELEASE_TIMEOUT_MS / DEFAULT_AUTOPLAY_RELEASE_RETRY_DELAY_MS),
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)); return Math.max(3, Math.ceil(startupTimeoutMs / retryDelayMs));
} }
export { export { DEFAULT_AUTOPLAY_RELEASE_RETRY_DELAY_MS, STARTUP_AUTOPLAY_RELEASE_TIMEOUT_MS };
DEFAULT_AUTOPLAY_RELEASE_RETRY_DELAY_MS,
STARTUP_AUTOPLAY_RELEASE_TIMEOUT_MS,
};

View File

@@ -33,10 +33,7 @@ export function resolveWindowsMpvPath(deps: WindowsMpvLaunchDeps): string {
return ''; return '';
} }
export function buildWindowsMpvLaunchArgs( export function buildWindowsMpvLaunchArgs(targets: string[], extraArgs: string[] = []): string[] {
targets: string[],
extraArgs: string[] = [],
): string[] {
return ['--player-operation-mode=pseudo-gui', '--profile=subminer', ...extraArgs, ...targets]; 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( assert.ok(
commands.some( commands.some(
(command) => (command) =>
command[0] === 'set_property' && command[0] === 'set_property' && command[1] === 'sub-visibility' && command[2] === 'yes',
command[1] === 'sub-visibility' &&
command[2] === 'yes',
), ),
); );
assert.ok( assert.ok(
@@ -263,9 +261,7 @@ test('youtube flow retries secondary after partial batch subtitle failure', asyn
assert.ok( assert.ok(
commands.some( commands.some(
(command) => (command) =>
command[0] === 'sub-add' && command[0] === 'sub-add' && command[1] === '/tmp/manual:en.vtt' && command[2] === 'cached',
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 selectedSecondarySid;
} }
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',
{ type: 'sub', id: 3, lang: 'ja-en', title: 'Japanese from English', external: true, 'external-filename': null }, id: 1,
{ type: 'sub', id: 4, lang: 'ja-ja', title: 'Japanese from Japanese', external: true, 'external-filename': null }, lang: 'en',
{ type: 'sub', id: 5, lang: 'ja-orig', title: 'auto-ja-orig.vtt', external: true, 'external-filename': '/tmp/auto-ja-orig.vtt' }, title: 'English',
{ type: 'sub', id: 6, lang: 'en', title: 'manual-en.en.srt', external: true, 'external-filename': '/tmp/manual-en.en.srt' }, 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: () => {}, refreshCurrentSubtitle: () => {},
@@ -737,7 +775,10 @@ test('youtube flow leaves non-authoritative youtube subtitle tracks untouched af
await runtime.openManualPicker({ url: 'https://example.com' }); 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 () => { 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', videoId: 'video123',
title: 'Video 123', title: 'Video 123',
tracks: [ 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 () => { acquireYoutubeSubtitleTracks: async () => {
@@ -801,10 +854,38 @@ test('youtube flow reuses existing manual youtube subtitle tracks when both requ
return selectedSecondarySid; return selectedSecondarySid;
} }
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',
{ type: 'sub', id: 3, lang: 'ja-en', title: 'Japanese from English', external: true, 'external-filename': null }, id: 1,
{ type: 'sub', id: 4, lang: 'ja-ja', title: 'Japanese from Japanese', external: true, 'external-filename': null }, 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: () => {}, refreshCurrentSubtitle: () => {},
@@ -833,9 +914,15 @@ test('youtube flow reuses existing manual youtube subtitle tracks when both requ
assert.equal(selectedPrimarySid, 2); assert.equal(selectedPrimarySid, 2);
assert.equal(selectedSecondarySid, 1); 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.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 () => { 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', videoId: 'video123',
title: 'Video 123', title: 'Video 123',
tracks: [ 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 () => { acquireYoutubeSubtitleTracks: async () => {
@@ -903,10 +1002,38 @@ test('youtube flow waits for manual youtube tracks to appear before falling back
return []; return [];
} }
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',
{ type: 'sub', id: 3, lang: 'ja-en', title: 'Japanese from English', external: true, 'external-filename': null }, id: 1,
{ type: 'sub', id: 4, lang: 'ja-ja', title: 'Japanese from Japanese', external: true, 'external-filename': null }, 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: () => {}, refreshCurrentSubtitle: () => {},
@@ -932,7 +1059,10 @@ test('youtube flow waits for manual youtube tracks to appear before falling back
assert.equal(selectedPrimarySid, 2); assert.equal(selectedPrimarySid, 2);
assert.equal(selectedSecondarySid, 1); 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 () => { 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') { if (track.id === 'manual:ja') {
return { path: '/tmp/manual-ja.ja.srt' }; 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, openPicker: async () => false,
pauseMpv: () => {}, pauseMpv: () => {},
@@ -1051,7 +1183,10 @@ test('youtube flow reuses manual youtube tracks even when mpv exposes external f
assert.equal(selectedPrimarySid, 2); assert.equal(selectedPrimarySid, 2);
assert.equal(selectedSecondarySid, 1); 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 () => { 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 { } else {
deps.warn( deps.warn(
`Unable to bind downloaded primary subtitle track in mpv: ${ `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.refreshCurrentSubtitle(currentSubText);
} }
deps.showMpvOsd( deps.showMpvOsd(secondaryTrack ? 'Primary and secondary subtitles loaded.' : 'Subtitles loaded.');
secondaryTrack ? 'Primary and secondary subtitles loaded.' : 'Subtitles loaded.',
);
return true; return true;
} }
@@ -587,7 +587,8 @@ export function createYoutubeFlowRuntime(deps: YoutubeFlowDeps) {
existingPrimaryTrackId, existingPrimaryTrackId,
) )
: null; : null;
const primaryReady = input.primaryTrack.kind !== 'manual' || existingPrimaryTrackId !== null; const primaryReady =
input.primaryTrack.kind !== 'manual' || existingPrimaryTrackId !== null;
const secondaryReady = const secondaryReady =
!input.secondaryTrack || !input.secondaryTrack ||
input.secondaryTrack.kind !== 'manual' || input.secondaryTrack.kind !== 'manual' ||
@@ -631,7 +632,11 @@ export function createYoutubeFlowRuntime(deps: YoutubeFlowDeps) {
secondaryInjectedPath = acquired.secondaryPath; secondaryInjectedPath = acquired.secondaryPath;
} }
if (input.secondaryTrack && existingSecondaryTrackId === null && secondaryInjectedPath === null) { if (
input.secondaryTrack &&
existingSecondaryTrackId === null &&
secondaryInjectedPath === null
) {
try { try {
secondaryInjectedPath = ( secondaryInjectedPath = (
await deps.acquireYoutubeSubtitleTrack({ await deps.acquireYoutubeSubtitleTrack({

View File

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

View File

@@ -74,7 +74,9 @@ function hasPlayableMediaTracks(trackListRaw: unknown): boolean {
if (!Array.isArray(trackListRaw)) return false; if (!Array.isArray(trackListRaw)) return false;
return trackListRaw.some((track) => { return trackListRaw.some((track) => {
if (!track || typeof track !== 'object') return false; 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'; return type === 'video' || type === 'audio';
}); });
} }

View File

@@ -1,7 +1,9 @@
import { isYoutubeMediaPath } from './youtube-playback'; import { isYoutubeMediaPath } from './youtube-playback';
import { normalizeYoutubeLangCode } from '../../core/services/youtube/labels'; 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 = { type SubtitleTrackEntry = {
id: number | null; id: number | null;
@@ -82,7 +84,9 @@ function hasSelectedPrimarySubtitle(
const tracks = trackList.map(normalizeTrack); const tracks = trackList.map(normalizeTrack);
const activeTrack = 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) ?? tracks.find((track) => track?.type === 'sub' && track.selected) ??
null; null;
if (!activeTrack) { if (!activeTrack) {
@@ -130,7 +134,9 @@ export function createYoutubePrimarySubtitleNotificationRuntime(deps: {
return; return;
} }
lastReportedMediaPath = mediaPath; 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 => { const schedulePendingCheck = (): void => {
@@ -150,7 +156,8 @@ export function createYoutubePrimarySubtitleNotificationRuntime(deps: {
return { return {
handleMediaPathChange: (path: string | null): void => { 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) { if (currentMediaPath !== normalizedPath) {
lastReportedMediaPath = null; lastReportedMediaPath = null;
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -66,7 +66,10 @@ function createFakeElement() {
if (!match) return null; if (!match) return null;
const testId = match[1]; const testId = match[1];
for (const child of el.children) { 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; return child;
} }
if (typeof child.querySelector === 'function') { if (typeof child.querySelector === 'function') {
@@ -105,7 +108,10 @@ function installFakeDom() {
return { return {
restore: () => { restore: () => {
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow }); 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 lastRenderedActiveGamepadId: string | null = null;
let lastRenderedPreferredId = ''; let lastRenderedPreferredId = '';
type ControllerBindingKey = keyof NonNullable<typeof ctx.state.controllerConfig>['bindings']; type ControllerBindingKey = keyof NonNullable<typeof ctx.state.controllerConfig>['bindings'];
type ControllerBindingValue = type ControllerBindingValue = NonNullable<
NonNullable<NonNullable<typeof ctx.state.controllerConfig>['bindings']>[ControllerBindingKey]; NonNullable<typeof ctx.state.controllerConfig>['bindings']
>[ControllerBindingKey];
let learningActionId: ControllerBindingKey | null = null; let learningActionId: ControllerBindingKey | null = null;
let dpadLearningActionId: ControllerBindingKey | null = null; let dpadLearningActionId: ControllerBindingKey | null = null;
let bindingCapture: ReturnType<typeof createControllerBindingCapture> | null = null; let bindingCapture: ReturnType<typeof createControllerBindingCapture> | null = null;
@@ -198,7 +199,9 @@ export function createControllerSelectModal(
lastRenderedPreferredId = preferredId; 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); await window.electronAPI.saveControllerConfig(update);
if (!ctx.state.controllerConfig) return; if (!ctx.state.controllerConfig) return;
if (update.preferredGamepadId !== undefined) { if (update.preferredGamepadId !== undefined) {
@@ -304,7 +307,10 @@ export function createControllerSelectModal(
if (result.bindingType === 'dpad') { if (result.bindingType === 'dpad') {
void saveDpadFallback(result.actionId as ControllerBindingKey, result.dpadDirection); void saveDpadFallback(result.actionId as ControllerBindingKey, result.dpadDirection);
} else { } 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' }, { startTime: 233.05, endTime: 236, text: 'next' },
]; ];
assert.equal( assert.equal(findActiveSubtitleCueIndex(cues, { text: 'previous', startTime: 231 }, 233, 0), 0);
findActiveSubtitleCueIndex(cues, { text: 'previous', startTime: 231 }, 233, 0),
0,
);
}); });
test('subtitle sidebar modal opens from snapshot and clicking cue seeks playback', async () => { 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(timeoutCount > 0, true);
assert.equal(intervalCount, 0); assert.equal(intervalCount, 0);
} finally { } finally {
Object.defineProperty(globalThis, 'setTimeout', { configurable: true, value: previousSetTimeout }); Object.defineProperty(globalThis, 'setTimeout', {
Object.defineProperty(globalThis, 'clearTimeout', { configurable: true, value: previousClearTimeout }); configurable: true,
Object.defineProperty(globalThis, 'setInterval', { configurable: true, value: previousSetInterval }); value: previousSetTimeout,
Object.defineProperty(globalThis, 'clearInterval', { configurable: true, value: previousClearInterval }); });
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, 'window', { configurable: true, value: previousWindow });
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument }); 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( assert.ok(
mpvCommands.some( mpvCommands.some(
(command) => (command) =>
command[0] === 'set_property' && command[0] === 'set_property' && command[1] === 'osd-align-x' && command[2] === 'left',
command[1] === 'osd-align-x' &&
command[2] === 'left',
), ),
); );
assert.ok( assert.ok(
mpvCommands.some( mpvCommands.some(
(command) => (command) =>
command[0] === 'set_property' && command[0] === 'set_property' && command[1] === 'osd-align-y' && command[2] === 'top',
command[1] === 'osd-align-y' &&
command[2] === 'top',
), ),
); );
assert.ok( 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(-5), ['set_property', 'video-margin-ratio-right', 0]);
assert.deepEqual(mpvCommands.at(-4), ['set_property', 'osd-align-x', 'left']); 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(-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.deepEqual(mpvCommands.at(-1), ['set_property', 'video-pan-x', 0]);
assert.equal(bodyClassList.contains('subtitle-sidebar-embedded-open'), false); assert.equal(bodyClassList.contains('subtitle-sidebar-embedded-open'), false);
assert.deepEqual(rootStyleCalls.at(-1), ['--subtitle-sidebar-reserved-width', '0px']); assert.deepEqual(rootStyleCalls.at(-1), ['--subtitle-sidebar-reserved-width', '0px']);

View File

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

View File

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

View File

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

View File

@@ -981,18 +981,9 @@ test('JLPT CSS rules use underline-only styling in renderer stylesheet', () => {
cssText, cssText,
'body.subtitle-sidebar-embedded-open #secondarySubContainer.secondary-sub-hover', 'body.subtitle-sidebar-embedded-open #secondarySubContainer.secondary-sub-hover',
); );
assert.match( assert.match(secondaryEmbeddedHoverBlock, /right:\s*var\(--subtitle-sidebar-reserved-width\);/);
secondaryEmbeddedHoverBlock, assert.match(secondaryEmbeddedHoverBlock, /max-width:\s*none;/);
/right:\s*var\(--subtitle-sidebar-reserved-width\);/, assert.match(secondaryEmbeddedHoverBlock, /transform:\s*none;/);
);
assert.match(
secondaryEmbeddedHoverBlock,
/max-width:\s*none;/,
);
assert.match(
secondaryEmbeddedHoverBlock,
/transform:\s*none;/,
);
assert.doesNotMatch( assert.doesNotMatch(
secondaryEmbeddedHoverBlock, secondaryEmbeddedHoverBlock,
/transform:\s*translateX\(calc\(var\(--subtitle-sidebar-reserved-width\)\s*\*\s*-0\.5\)\);/, /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 fs from 'node:fs';
import os from 'node:os'; import os from 'node:os';
import path from 'node:path'; import path from 'node:path';
import { import { appendLogLine, pruneLogFiles, resolveDefaultLogFilePath } from './log-files';
appendLogLine,
pruneLogFiles,
resolveDefaultLogFilePath,
} from './log-files';
test('resolveDefaultLogFilePath uses app prefix by default', () => { test('resolveDefaultLogFilePath uses app prefix by default', () => {
const now = new Date('2026-03-22T12:00:00.000Z'); 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(stalePath, 'stale\n', 'utf8');
fs.writeFileSync(freshPath, 'fresh\n', 'utf8'); fs.writeFileSync(freshPath, 'fresh\n', 'utf8');
const now = new Date('2026-03-22T12:00:00.000Z'); 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(
fs.utimesSync(freshPath, new Date('2026-03-21T12:00:00.000Z'), new Date('2026-03-21T12:00:00.000Z')); 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 { try {
pruneLogFiles(logsDir, { retentionDays: 7, now }); 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}`, resolveUrl: (state) => `http://127.0.0.1:${state.port}`,
spawnDaemon: async (options) => { spawnDaemon: async (options) => {
calls.push(`spawnDaemon:${options.scriptPath}:${options.responsePath}:${options.userDataPath}`); calls.push(
`spawnDaemon:${options.scriptPath}:${options.responsePath}:${options.userDataPath}`,
);
return 999; return 999;
}, },
waitForDaemonResponse: async (responsePath) => { waitForDaemonResponse: async (responsePath) => {

View File

@@ -13,7 +13,10 @@ import {
writeBackgroundStatsServerState, writeBackgroundStatsServerState,
} from './main/runtime/stats-daemon'; } from './main/runtime/stats-daemon';
import { writeStatsCliCommandResponse } from './main/runtime/stats-cli-command'; 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 logger = createLogger('stats-daemon');
const STATS_WORD_HELPER_RESPONSE_TIMEOUT_MS = 20_000; const STATS_WORD_HELPER_RESPONSE_TIMEOUT_MS = 20_000;

View File

@@ -33,7 +33,9 @@ export function createInvokeStatsWordHelperHandler(deps: {
}); });
const startupResult = await Promise.race([ 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 })), helperExitPromise.then((status) => ({ kind: 'exit' as const, status })),
]); ]);
@@ -42,7 +44,9 @@ export function createInvokeStatsWordHelperHandler(deps: {
response = startupResult.response; response = startupResult.response;
} else { } else {
if (startupResult.status !== 0) { 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); response = await deps.waitForResponse(responsePath);
} }