diff --git a/launcher/commands/command-modules.test.ts b/launcher/commands/command-modules.test.ts index 751f1ac..4a912e8 100644 --- a/launcher/commands/command-modules.test.ts +++ b/launcher/commands/command-modules.test.ts @@ -227,11 +227,7 @@ test('stats background command launches attached daemon control command with res assert.equal(handled, true); assert.deepEqual(harness.forwarded, [ - [ - '--stats-daemon-start', - '--stats-response-path', - '/tmp/subminer-stats-test/response.json', - ], + ['--stats-daemon-start', '--stats-response-path', '/tmp/subminer-stats-test/response.json'], ]); assert.equal(harness.removedPaths.length, 1); }); @@ -257,11 +253,7 @@ test('stats command waits for attached app exit after startup response', async ( const final = await statsCommand; assert.equal(final, true); assert.deepEqual(harness.forwarded, [ - [ - '--stats', - '--stats-response-path', - '/tmp/subminer-stats-test/response.json', - ], + ['--stats', '--stats-response-path', '/tmp/subminer-stats-test/response.json'], ]); assert.equal(harness.removedPaths.length, 1); }); @@ -317,11 +309,7 @@ test('stats stop command forwards stop flag to the app', async () => { assert.equal(handled, true); assert.deepEqual(harness.forwarded, [ - [ - '--stats-daemon-stop', - '--stats-response-path', - '/tmp/subminer-stats-test/response.json', - ], + ['--stats-daemon-stop', '--stats-response-path', '/tmp/subminer-stats-test/response.json'], ]); assert.equal(harness.removedPaths.length, 1); }); diff --git a/launcher/commands/playback-command.ts b/launcher/commands/playback-command.ts index 81840a3..6306579 100644 --- a/launcher/commands/playback-command.ts +++ b/launcher/commands/playback-command.ts @@ -209,7 +209,11 @@ export async function runPlaybackCommandWithDeps( pluginRuntimeConfig.autoStartPauseUntilReady; if (shouldPauseUntilOverlayReady) { - deps.log('info', args.logLevel, 'Configured to pause mpv until overlay and tokenization are ready'); + deps.log( + 'info', + args.logLevel, + 'Configured to pause mpv until overlay and tokenization are ready', + ); } await deps.startMpv( @@ -250,7 +254,11 @@ export async function runPlaybackCommandWithDeps( if (ready) { deps.log('info', args.logLevel, 'MPV IPC socket ready, relying on mpv plugin auto-start'); } else { - deps.log('info', args.logLevel, 'MPV IPC socket not ready yet, relying on mpv plugin auto-start'); + deps.log( + 'info', + args.logLevel, + 'MPV IPC socket not ready yet, relying on mpv plugin auto-start', + ); } } else if (ready) { deps.log( diff --git a/launcher/config/cli-parser-builder.ts b/launcher/config/cli-parser-builder.ts index 75221f7..d871c11 100644 --- a/launcher/config/cli-parser-builder.ts +++ b/launcher/config/cli-parser-builder.ts @@ -236,17 +236,12 @@ export function parseCliPrograms( normalizedAction !== 'rebuild' && normalizedAction !== 'backfill' ) { - throw new Error( - 'Invalid stats action. Valid values are cleanup, rebuild, or backfill.', - ); + throw new Error('Invalid stats action. Valid values are cleanup, rebuild, or backfill.'); } if (normalizedAction && (statsBackground || statsStop)) { throw new Error('Stats background and stop flags cannot be combined with stats actions.'); } - if ( - normalizedAction !== 'cleanup' && - (options.vocab === true || options.lifetime === true) - ) { + if (normalizedAction !== 'cleanup' && (options.vocab === true || options.lifetime === true)) { throw new Error('Stats --vocab and --lifetime flags require the cleanup action.'); } if (normalizedAction === 'cleanup') { diff --git a/launcher/log.test.ts b/launcher/log.test.ts index 615934b..fa898d1 100644 --- a/launcher/log.test.ts +++ b/launcher/log.test.ts @@ -14,12 +14,7 @@ test('getDefaultMpvLogFile uses APPDATA on windows', () => { assert.equal( path.normalize(resolved), path.normalize( - path.join( - 'C:\\Users\\tester\\AppData\\Roaming', - 'SubMiner', - 'logs', - `mpv-${today}.log`, - ), + path.join('C:\\Users\\tester\\AppData\\Roaming', 'SubMiner', 'logs', `mpv-${today}.log`), ), ); }); @@ -33,12 +28,6 @@ test('getDefaultLauncherLogFile uses launcher prefix', () => { assert.equal( resolved, - path.join( - '/home/tester', - '.config', - 'SubMiner', - 'logs', - `launcher-${today}.log`, - ), + path.join('/home/tester', '.config', 'SubMiner', 'logs', `launcher-${today}.log`), ); }); diff --git a/launcher/main.test.ts b/launcher/main.test.ts index 7644544..2a9746f 100644 --- a/launcher/main.test.ts +++ b/launcher/main.test.ts @@ -269,10 +269,7 @@ ${bunBinary} -e "const net=require('node:net'); const fs=require('node:fs'); con SUBMINER_APPIMAGE_PATH: appPath, SUBMINER_TEST_MPV_ARGS: mpvArgsPath, }; - const result = runLauncher( - ['--args', '--pause=yes --title="movie night"', videoPath], - env, - ); + const result = runLauncher(['--args', '--pause=yes --title="movie night"', videoPath], env); assert.equal(result.status, 0, `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`); const argsFile = fs.readFileSync(mpvArgsPath, 'utf8'); @@ -355,10 +352,7 @@ ${bunBinary} -e "const net=require('node:net'); const fs=require('node:fs'); con const result = runLauncher(['--log-level', 'debug', videoPath], env); assert.equal(result.status, 0, `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`); - assert.match( - fs.readFileSync(mpvArgsPath, 'utf8'), - /--script-opts=.*subminer-log_level=debug/, - ); + assert.match(fs.readFileSync(mpvArgsPath, 'utf8'), /--script-opts=.*subminer-log_level=debug/); }); }); diff --git a/launcher/mpv.test.ts b/launcher/mpv.test.ts index be848a3..0f89416 100644 --- a/launcher/mpv.test.ts +++ b/launcher/mpv.test.ts @@ -427,7 +427,10 @@ function withFindAppBinaryEnvSandbox(run: () => void): void { } } -function withAccessSyncStub(isExecutablePath: (filePath: string) => boolean, run: () => void): void { +function withAccessSyncStub( + isExecutablePath: (filePath: string) => boolean, + run: () => void, +): void { const originalAccessSync = fs.accessSync; try { // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -468,10 +471,13 @@ test('findAppBinary resolves /opt/SubMiner/SubMiner.AppImage when ~/.local/bin c try { os.homedir = () => baseDir; withFindAppBinaryEnvSandbox(() => { - withAccessSyncStub((filePath) => filePath === '/opt/SubMiner/SubMiner.AppImage', () => { - const result = findAppBinary('/some/other/path/subminer'); - assert.equal(result, '/opt/SubMiner/SubMiner.AppImage'); - }); + withAccessSyncStub( + (filePath) => filePath === '/opt/SubMiner/SubMiner.AppImage', + () => { + const result = findAppBinary('/some/other/path/subminer'); + assert.equal(result, '/opt/SubMiner/SubMiner.AppImage'); + }, + ); }); } finally { os.homedir = originalHomedir; @@ -492,11 +498,14 @@ test('findAppBinary finds subminer on PATH when AppImage candidates do not exist process.env.PATH = `${binDir}${path.delimiter}${originalPath ?? ''}`; withFindAppBinaryEnvSandbox(() => { - withAccessSyncStub((filePath) => filePath === wrapperPath, () => { - // selfPath must differ from wrapperPath so the self-check does not exclude it - const result = findAppBinary(path.join(baseDir, 'launcher', 'subminer')); - assert.equal(result, wrapperPath); - }); + withAccessSyncStub( + (filePath) => filePath === wrapperPath, + () => { + // selfPath must differ from wrapperPath so the self-check does not exclude it + const result = findAppBinary(path.join(baseDir, 'launcher', 'subminer')); + assert.equal(result, wrapperPath); + }, + ); }); } finally { os.homedir = originalHomedir; diff --git a/launcher/mpv.ts b/launcher/mpv.ts index 6305351..e9bef83 100644 --- a/launcher/mpv.ts +++ b/launcher/mpv.ts @@ -47,7 +47,11 @@ export function parseMpvArgString(input: string): string[] { let inDoubleQuote = false; let escaping = false; const canEscape = (nextChar: string | undefined): boolean => - nextChar === undefined || nextChar === '"' || nextChar === "'" || nextChar === '\\' || /\s/.test(nextChar); + nextChar === undefined || + nextChar === '"' || + nextChar === "'" || + nextChar === '\\' || + /\s/.test(nextChar); for (let i = 0; i < chars.length; i += 1) { const ch = chars[i] || ''; @@ -598,7 +602,9 @@ export async function startMpv( ? await resolveAniSkipMetadataForFile(target) : null; const extraScriptOpts = - targetKind === 'url' && isYoutubeTarget(target) && options?.disableYoutubeSubtitleAutoLoad === true + targetKind === 'url' && + isYoutubeTarget(target) && + options?.disableYoutubeSubtitleAutoLoad === true ? ['subminer-auto_start_pause_until_ready=no'] : []; const scriptOpts = buildSubminerScriptOpts( @@ -1064,7 +1070,9 @@ export function launchMpvIdleDetached( mpvArgs.push(...parseMpvArgString(args.mpvArgs)); } mpvArgs.push('--idle=yes'); - mpvArgs.push(`--script-opts=${buildSubminerScriptOpts(appPath, socketPath, null, args.logLevel)}`); + mpvArgs.push( + `--script-opts=${buildSubminerScriptOpts(appPath, socketPath, null, args.logLevel)}`, + ); mpvArgs.push(`--log-file=${getMpvLogPath()}`); mpvArgs.push(`--input-ipc-server=${socketPath}`); const mpvTarget = resolveCommandInvocation('mpv', mpvArgs); diff --git a/scripts/build-changelog.test.ts b/scripts/build-changelog.test.ts index 6de8418..a6d7186 100644 --- a/scripts/build-changelog.test.ts +++ b/scripts/build-changelog.test.ts @@ -111,7 +111,11 @@ test('writeChangelogArtifacts skips changelog prepend when release section alrea fs.mkdirSync(projectRoot, { recursive: true }); fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true }); fs.writeFileSync(path.join(projectRoot, 'CHANGELOG.md'), existingChangelog, 'utf8'); - fs.writeFileSync(path.join(projectRoot, 'changes', '001.md'), ['type: added', 'area: overlay', '', '- Stale release fragment.'].join('\n'), 'utf8'); + fs.writeFileSync( + path.join(projectRoot, 'changes', '001.md'), + ['type: added', 'area: overlay', '', '- Stale release fragment.'].join('\n'), + 'utf8', + ); try { const result = writeChangelogArtifacts({ @@ -125,7 +129,10 @@ test('writeChangelogArtifacts skips changelog prepend when release section alrea const changelog = fs.readFileSync(path.join(projectRoot, 'CHANGELOG.md'), 'utf8'); assert.equal(changelog, existingChangelog); - const releaseNotes = fs.readFileSync(path.join(projectRoot, 'release', 'release-notes.md'), 'utf8'); + const releaseNotes = fs.readFileSync( + path.join(projectRoot, 'release', 'release-notes.md'), + 'utf8', + ); assert.match(releaseNotes, /## Highlights\n### Added\n- Existing release bullet\./); } finally { fs.rmSync(workspace, { recursive: true, force: true }); diff --git a/scripts/build-changelog.ts b/scripts/build-changelog.ts index 76197cc..ac96a61 100644 --- a/scripts/build-changelog.ts +++ b/scripts/build-changelog.ts @@ -354,11 +354,7 @@ export function writeChangelogArtifacts(options?: ChangelogOptions): { log(`Removed ${fragment.path}`); } - const releaseNotesPath = writeReleaseNotesFile( - cwd, - existingReleaseSection, - options?.deps, - ); + const releaseNotesPath = writeReleaseNotesFile(cwd, existingReleaseSection, options?.deps); log(`Generated ${releaseNotesPath}`); return { diff --git a/scripts/patch-modernz.test.ts b/scripts/patch-modernz.test.ts index 7ceadf4..f89069a 100644 --- a/scripts/patch-modernz.test.ts +++ b/scripts/patch-modernz.test.ts @@ -55,19 +55,15 @@ exit 1 `, ); - const result = spawnSync( - 'bash', - ['scripts/patch-modernz.sh', '--target', target], - { - cwd: process.cwd(), - encoding: 'utf8', - env: { - ...process.env, - HOME: path.join(root, 'home'), - PATH: `${binDir}:${process.env.PATH || ''}`, - }, + const result = spawnSync('bash', ['scripts/patch-modernz.sh', '--target', target], { + cwd: process.cwd(), + encoding: 'utf8', + env: { + ...process.env, + HOME: path.join(root, 'home'), + PATH: `${binDir}:${process.env.PATH || ''}`, }, - ); + }); assert.equal(result.status, 1, result.stderr || result.stdout); assert.match(result.stderr, /failed to apply patch to/); diff --git a/scripts/update-aur-package.test.ts b/scripts/update-aur-package.test.ts index 8189538..b9a52cc 100644 --- a/scripts/update-aur-package.test.ts +++ b/scripts/update-aur-package.test.ts @@ -47,8 +47,8 @@ test('update-aur-package updates PKGBUILD and .SRCINFO without makepkg', () => { const pkgbuild = fs.readFileSync(path.join(pkgDir, 'PKGBUILD'), 'utf8'); const srcinfo = fs.readFileSync(path.join(pkgDir, '.SRCINFO'), 'utf8'); - const expectedSums = [appImagePath, wrapperPath, assetsPath].map((filePath) => - execFileSync('sha256sum', [filePath], { encoding: 'utf8' }).split(/\s+/)[0], + const expectedSums = [appImagePath, wrapperPath, assetsPath].map( + (filePath) => execFileSync('sha256sum', [filePath], { encoding: 'utf8' }).split(/\s+/)[0], ); assert.match(pkgbuild, /^pkgver=0\.6\.3$/m); diff --git a/src/anki-integration.ts b/src/anki-integration.ts index 5ba86b7..8282d70 100644 --- a/src/anki-integration.ts +++ b/src/anki-integration.ts @@ -21,15 +21,15 @@ import { SubtitleTimingTracker } from './subtitle-timing-tracker'; import { MediaGenerator } from './media-generator'; import path from 'path'; import { - AiConfig, AnkiConnectConfig, KikuDuplicateCardInfo, KikuFieldGroupingChoice, KikuMergePreviewResponse, - MpvClient, NotificationOptions, - NPlusOneMatchMode, -} from './types'; +} from './types/anki'; +import { AiConfig } from './types/integrations'; +import { MpvClient } from './types/runtime'; +import { NPlusOneMatchMode } from './types/subtitle'; import { DEFAULT_ANKI_CONNECT_CONFIG } from './config'; import { getConfiguredWordFieldCandidates, @@ -212,10 +212,7 @@ export class AnkiIntegration { try { this.recordCardsMinedCallback(count, noteIds); } catch (error) { - log.warn( - `recordCardsMined callback failed during ${source}:`, - (error as Error).message, - ); + log.warn(`recordCardsMined callback failed during ${source}:`, (error as Error).message); } } diff --git a/src/anki-integration/animated-image-sync.test.ts b/src/anki-integration/animated-image-sync.test.ts index c0d25cf..c70d2aa 100644 --- a/src/anki-integration/animated-image-sync.test.ts +++ b/src/anki-integration/animated-image-sync.test.ts @@ -4,10 +4,10 @@ import test from 'node:test'; import { resolveAnimatedImageLeadInSeconds, extractSoundFilenames } from './animated-image-sync'; test('extractSoundFilenames returns ordered sound filenames from an Anki field value', () => { - assert.deepEqual( - extractSoundFilenames('before [sound:word.mp3] middle [sound:alt.ogg] after'), - ['word.mp3', 'alt.ogg'], - ); + assert.deepEqual(extractSoundFilenames('before [sound:word.mp3] middle [sound:alt.ogg] after'), [ + 'word.mp3', + 'alt.ogg', + ]); }); test('resolveAnimatedImageLeadInSeconds sums configured word audio durations for animated images', async () => { diff --git a/src/anki-integration/duplicate.ts b/src/anki-integration/duplicate.ts index 992390d..1e722a0 100644 --- a/src/anki-integration/duplicate.ts +++ b/src/anki-integration/duplicate.ts @@ -179,7 +179,10 @@ function getDuplicateSourceCandidates( const fallbackFieldName = configuredFieldNames[0]?.toLowerCase() || 'expression'; const fallbackKey = `${fallbackFieldName}:${normalizeDuplicateValue(trimmedFallback)}`; if (!dedupeKey.has(fallbackKey)) { - candidates.push({ fieldName: configuredFieldNames[0] || 'Expression', value: trimmedFallback }); + candidates.push({ + fieldName: configuredFieldNames[0] || 'Expression', + value: trimmedFallback, + }); } } diff --git a/src/config/config.test.ts b/src/config/config.test.ts index ada4876..41d7b4e 100644 --- a/src/config/config.test.ts +++ b/src/config/config.test.ts @@ -1325,8 +1325,14 @@ test('controller descriptor config rejects malformed binding objects', () => { config.controller.bindings.leftStickHorizontal, DEFAULT_CONFIG.controller.bindings.leftStickHorizontal, ); - assert.equal(warnings.some((warning) => warning.path === 'controller.bindings.toggleLookup'), true); - assert.equal(warnings.some((warning) => warning.path === 'controller.bindings.closeLookup'), true); + assert.equal( + warnings.some((warning) => warning.path === 'controller.bindings.toggleLookup'), + true, + ); + assert.equal( + warnings.some((warning) => warning.path === 'controller.bindings.closeLookup'), + true, + ); assert.equal( warnings.some((warning) => warning.path === 'controller.bindings.leftStickHorizontal'), true, diff --git a/src/config/resolve/stats.ts b/src/config/resolve/stats.ts index ba2641b..745b7b9 100644 --- a/src/config/resolve/stats.ts +++ b/src/config/resolve/stats.ts @@ -17,7 +17,12 @@ export function applyStatsConfig(context: ResolveContext): void { if (markWatchedKey !== undefined) { resolved.stats.markWatchedKey = markWatchedKey; } else if (src.stats.markWatchedKey !== undefined) { - warn('stats.markWatchedKey', src.stats.markWatchedKey, resolved.stats.markWatchedKey, 'Expected string.'); + warn( + 'stats.markWatchedKey', + src.stats.markWatchedKey, + resolved.stats.markWatchedKey, + 'Expected string.', + ); } const serverPort = asNumber(src.stats.serverPort); diff --git a/src/config/resolve/subtitle-sidebar.test.ts b/src/config/resolve/subtitle-sidebar.test.ts index 7c23247..6b9b9c0 100644 --- a/src/config/resolve/subtitle-sidebar.test.ts +++ b/src/config/resolve/subtitle-sidebar.test.ts @@ -49,7 +49,10 @@ test('subtitleSidebar accepts zero opacity', () => { applySubtitleDomainConfig(context); assert.equal(context.resolved.subtitleSidebar.opacity, 0); - assert.equal(warnings.some((warning) => warning.path === 'subtitleSidebar.opacity'), false); + assert.equal( + warnings.some((warning) => warning.path === 'subtitleSidebar.opacity'), + false, + ); }); test('subtitleSidebar falls back and warns on invalid values', () => { diff --git a/src/core/services/app-ready.test.ts b/src/core/services/app-ready.test.ts index 3b987bb..34a36b8 100644 --- a/src/core/services/app-ready.test.ts +++ b/src/core/services/app-ready.test.ts @@ -185,11 +185,7 @@ test('runAppReadyRuntime uses minimal startup for texthooker-only mode', async ( await runAppReadyRuntime(deps); - assert.deepEqual(calls, [ - 'ensureDefaultConfigBootstrap', - 'reloadConfig', - 'handleInitialArgs', - ]); + assert.deepEqual(calls, ['ensureDefaultConfigBootstrap', 'reloadConfig', 'handleInitialArgs']); }); test('runAppReadyRuntime skips Jellyfin remote startup when dependency is not wired', async () => { diff --git a/src/core/services/config-hot-reload.ts b/src/core/services/config-hot-reload.ts index 5a405f3..fc4ba87 100644 --- a/src/core/services/config-hot-reload.ts +++ b/src/core/services/config-hot-reload.ts @@ -58,7 +58,12 @@ function classifyDiff(prev: ResolvedConfig, next: ResolvedConfig): ConfigHotRelo ]); for (const key of keys) { - if (key === 'subtitleStyle' || key === 'keybindings' || key === 'shortcuts' || key === 'subtitleSidebar') { + if ( + key === 'subtitleStyle' || + key === 'keybindings' || + key === 'shortcuts' || + key === 'subtitleSidebar' + ) { continue; } diff --git a/src/core/services/index.ts b/src/core/services/index.ts index b63e7a7..3d0e3f7 100644 --- a/src/core/services/index.ts +++ b/src/core/services/index.ts @@ -79,10 +79,7 @@ export { handleOverlayWindowBeforeInputEvent, isTabInputForMpvForwarding, } from './overlay-window-input'; -export { - initializeOverlayAnkiIntegration, - initializeOverlayRuntime, -} from './overlay-runtime-init'; +export { initializeOverlayAnkiIntegration, initializeOverlayRuntime } from './overlay-runtime-init'; export { setVisibleOverlayVisible, updateVisibleOverlayVisibility } from './overlay-visibility'; export { MPV_REQUEST_ID_SECONDARY_SUB_VISIBILITY, diff --git a/src/core/services/ipc.test.ts b/src/core/services/ipc.test.ts index 5cb4a7c..e5dae34 100644 --- a/src/core/services/ipc.test.ts +++ b/src/core/services/ipc.test.ts @@ -70,7 +70,11 @@ function createControllerConfigFixture() { nextAudio: { kind: 'button' as const, buttonIndex: 5 }, playCurrentAudio: { kind: 'button' as const, buttonIndex: 7 }, toggleMpvPause: { kind: 'button' as const, buttonIndex: 6 }, - leftStickHorizontal: { kind: 'axis' as const, axisIndex: 0, dpadFallback: 'horizontal' as const }, + leftStickHorizontal: { + kind: 'axis' as const, + axisIndex: 0, + dpadFallback: 'horizontal' as const, + }, leftStickVertical: { kind: 'axis' as const, axisIndex: 1, dpadFallback: 'vertical' as const }, rightStickHorizontal: { kind: 'axis' as const, axisIndex: 3, dpadFallback: 'none' as const }, rightStickVertical: { kind: 'axis' as const, axisIndex: 4, dpadFallback: 'none' as const }, diff --git a/src/core/services/ipc.ts b/src/core/services/ipc.ts index 331451a..a20374f 100644 --- a/src/core/services/ipc.ts +++ b/src/core/services/ipc.ts @@ -64,7 +64,9 @@ export interface IpcServiceDeps { getCurrentSecondarySub: () => string; focusMainWindow: () => void; runSubsyncManual: (request: SubsyncManualRunRequest) => Promise; - onYoutubePickerResolve: (request: YoutubePickerResolveRequest) => Promise; + onYoutubePickerResolve: ( + request: YoutubePickerResolveRequest, + ) => Promise; getAnkiConnectStatus: () => boolean; getRuntimeOptions: () => unknown; setRuntimeOption: (id: RuntimeOptionId, value: RuntimeOptionValue) => unknown; @@ -167,7 +169,9 @@ export interface IpcDepsRuntimeOptions { getMpvClient: () => MpvClientLike | null; focusMainWindow: () => void; runSubsyncManual: (request: SubsyncManualRunRequest) => Promise; - onYoutubePickerResolve: (request: YoutubePickerResolveRequest) => Promise; + onYoutubePickerResolve: ( + request: YoutubePickerResolveRequest, + ) => Promise; getAnkiConnectStatus: () => boolean; getRuntimeOptions: () => unknown; setRuntimeOption: (id: RuntimeOptionId, value: RuntimeOptionValue) => unknown; @@ -291,13 +295,16 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar deps.onOverlayModalOpened(parsedModal); }); - ipc.handle(IPC_CHANNELS.request.youtubePickerResolve, async (_event: unknown, request: unknown) => { - const parsedRequest = parseYoutubePickerResolveRequest(request); - if (!parsedRequest) { - return { ok: false, message: 'Invalid YouTube picker resolve payload' }; - } - return await deps.onYoutubePickerResolve(parsedRequest); - }); + ipc.handle( + IPC_CHANNELS.request.youtubePickerResolve, + async (_event: unknown, request: unknown) => { + const parsedRequest = parseYoutubePickerResolveRequest(request); + if (!parsedRequest) { + return { ok: false, message: 'Invalid YouTube picker resolve payload' }; + } + return await deps.onYoutubePickerResolve(parsedRequest); + }, + ); ipc.on(IPC_CHANNELS.command.openYomitanSettings, () => { deps.openYomitanSettings(); @@ -375,13 +382,16 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar }, ); - ipc.handle(IPC_CHANNELS.command.saveControllerConfig, async (_event: unknown, update: unknown) => { - const parsedUpdate = parseControllerConfigUpdate(update); - if (!parsedUpdate) { - throw new Error('Invalid controller config payload'); - } - await deps.saveControllerConfig(parsedUpdate); - }); + ipc.handle( + IPC_CHANNELS.command.saveControllerConfig, + async (_event: unknown, update: unknown) => { + const parsedUpdate = parseControllerConfigUpdate(update); + if (!parsedUpdate) { + throw new Error('Invalid controller config payload'); + } + await deps.saveControllerConfig(parsedUpdate); + }, + ); ipc.handle(IPC_CHANNELS.request.getMecabStatus, () => { return deps.getMecabStatus(); diff --git a/src/core/services/subtitle-processing-controller.test.ts b/src/core/services/subtitle-processing-controller.test.ts index 7a32549..ef1e165 100644 --- a/src/core/services/subtitle-processing-controller.test.ts +++ b/src/core/services/subtitle-processing-controller.test.ts @@ -228,7 +228,11 @@ test('consumeCachedSubtitle returns prefetched payload and prevents reprocessing controller.onSubtitleChange('猫\nです'); await flushMicrotasks(); - assert.equal(tokenizeCalls, 0, 'same cached subtitle should not reprocess after immediate consume'); + assert.equal( + tokenizeCalls, + 0, + 'same cached subtitle should not reprocess after immediate consume', + ); assert.deepEqual(emitted, []); }); diff --git a/src/core/services/tokenizer.test.ts b/src/core/services/tokenizer.test.ts index 2fd57c0..670f005 100644 --- a/src/core/services/tokenizer.test.ts +++ b/src/core/services/tokenizer.test.ts @@ -3428,40 +3428,43 @@ test('tokenizeSubtitle keeps standalone grammar-only tokens hoverable while clea test('tokenizeSubtitle keeps trailing quote-particle merged tokens hoverable while clearing only their annotation metadata', async () => { const result = await tokenizeSubtitle( 'どうしてもって', - makeDepsFromYomitanTokens([{ surface: 'どうしてもって', reading: 'どうしてもって', headword: 'どうしても' }], { - getFrequencyDictionaryEnabled: () => true, - getFrequencyRank: (text) => (text === 'どうしても' ? 123 : null), - getJlptLevel: (text) => (text === 'どうしても' ? 'N3' : null), - tokenizeWithMecab: async () => [ - { - headword: 'どうしても', - surface: 'どうしても', - reading: 'ドウシテモ', - startPos: 0, - endPos: 5, - partOfSpeech: PartOfSpeech.other, - pos1: '副詞', - pos2: '一般', - isMerged: false, - isKnown: false, - isNPlusOneTarget: false, - }, - { - headword: 'って', - surface: 'って', - reading: 'ッテ', - startPos: 5, - endPos: 7, - partOfSpeech: PartOfSpeech.particle, - pos1: '助詞', - pos2: '格助詞', - isMerged: false, - isKnown: false, - isNPlusOneTarget: false, - }, - ], - getMinSentenceWordsForNPlusOne: () => 1, - }), + makeDepsFromYomitanTokens( + [{ surface: 'どうしてもって', reading: 'どうしてもって', headword: 'どうしても' }], + { + getFrequencyDictionaryEnabled: () => true, + getFrequencyRank: (text) => (text === 'どうしても' ? 123 : null), + getJlptLevel: (text) => (text === 'どうしても' ? 'N3' : null), + tokenizeWithMecab: async () => [ + { + headword: 'どうしても', + surface: 'どうしても', + reading: 'ドウシテモ', + startPos: 0, + endPos: 5, + partOfSpeech: PartOfSpeech.other, + pos1: '副詞', + pos2: '一般', + isMerged: false, + isKnown: false, + isNPlusOneTarget: false, + }, + { + headword: 'って', + surface: 'って', + reading: 'ッテ', + startPos: 5, + endPos: 7, + partOfSpeech: PartOfSpeech.particle, + pos1: '助詞', + pos2: '格助詞', + isMerged: false, + isKnown: false, + isNPlusOneTarget: false, + }, + ], + getMinSentenceWordsForNPlusOne: () => 1, + }, + ), ); assert.equal(result.text, 'どうしてもって'); @@ -3812,7 +3815,14 @@ test('tokenizeSubtitle clears all annotations for explanatory pondering endings' jlptLevel: token.jlptLevel, })), [ - { surface: '俺', headword: '俺', isKnown: true, isNPlusOneTarget: false, frequencyRank: 19, jlptLevel: 'N5' }, + { + surface: '俺', + headword: '俺', + isKnown: true, + isNPlusOneTarget: false, + frequencyRank: 19, + jlptLevel: 'N5', + }, { surface: 'どうかしちゃった', headword: 'どうかしちゃう', diff --git a/src/core/services/tokenizer/annotation-stage.ts b/src/core/services/tokenizer/annotation-stage.ts index 99fc5c1..c57d935 100644 --- a/src/core/services/tokenizer/annotation-stage.ts +++ b/src/core/services/tokenizer/annotation-stage.ts @@ -140,7 +140,11 @@ function isExcludedFromSubtitleAnnotationsByPos1(normalizedPos1: string): boolea function isExcludedTrailingParticleMergedToken(token: MergedToken): boolean { const normalizedSurface = normalizeJlptTextForExclusion(token.surface); const normalizedHeadword = normalizeJlptTextForExclusion(token.headword); - if (!normalizedSurface || !normalizedHeadword || !normalizedSurface.startsWith(normalizedHeadword)) { + if ( + !normalizedSurface || + !normalizedHeadword || + !normalizedSurface.startsWith(normalizedHeadword) + ) { return false; } @@ -164,7 +168,10 @@ function isExcludedTrailingParticleMergedToken(token: MergedToken): boolean { function isAuxiliaryStemGrammarTailToken(token: MergedToken): boolean { const pos1Parts = splitNormalizedTagParts(normalizePos1Tag(token.pos1)); - if (pos1Parts.length === 0 || !pos1Parts.every((part) => AUXILIARY_STEM_GRAMMAR_TAIL_POS1.has(part))) { + if ( + pos1Parts.length === 0 || + !pos1Parts.every((part) => AUXILIARY_STEM_GRAMMAR_TAIL_POS1.has(part)) + ) { return false; } diff --git a/src/core/services/tokenizer/subtitle-annotation-filter.ts b/src/core/services/tokenizer/subtitle-annotation-filter.ts index f64edde..8b2a3d4 100644 --- a/src/core/services/tokenizer/subtitle-annotation-filter.ts +++ b/src/core/services/tokenizer/subtitle-annotation-filter.ts @@ -46,7 +46,11 @@ const SUBTITLE_ANNOTATION_EXCLUDED_EXPLANATORY_ENDING_TRAILING_PARTICLES = [ 'かな', 'かね', ] as const; -const SUBTITLE_ANNOTATION_EXCLUDED_EXPLANATORY_ENDING_THOUGHT_SUFFIXES = ['か', 'かな', 'かね'] as const; +const SUBTITLE_ANNOTATION_EXCLUDED_EXPLANATORY_ENDING_THOUGHT_SUFFIXES = [ + 'か', + 'かな', + 'かね', +] as const; const SUBTITLE_ANNOTATION_EXCLUDED_EXPLANATORY_ENDINGS = new Set( SUBTITLE_ANNOTATION_EXCLUDED_EXPLANATORY_ENDING_PREFIXES.flatMap((prefix) => SUBTITLE_ANNOTATION_EXCLUDED_EXPLANATORY_ENDING_CORES.flatMap((core) => @@ -96,9 +100,7 @@ function isExcludedByTagSet(normalizedTag: string, exclusions: ReadonlySet exclusions.has(part)); } -function resolvePos1Exclusions( - options: SubtitleAnnotationFilterOptions = {}, -): ReadonlySet { +function resolvePos1Exclusions(options: SubtitleAnnotationFilterOptions = {}): ReadonlySet { if (options.pos1Exclusions) { return options.pos1Exclusions; } @@ -106,9 +108,7 @@ function resolvePos1Exclusions( return resolveAnnotationPos1ExclusionSet(DEFAULT_ANNOTATION_POS1_EXCLUSION_CONFIG); } -function resolvePos2Exclusions( - options: SubtitleAnnotationFilterOptions = {}, -): ReadonlySet { +function resolvePos2Exclusions(options: SubtitleAnnotationFilterOptions = {}): ReadonlySet { if (options.pos2Exclusions) { return options.pos2Exclusions; } @@ -212,7 +212,11 @@ function isReduplicatedKanaSfxWithOptionalTrailingTo(text: string): boolean { function isExcludedTrailingParticleMergedToken(token: MergedToken): boolean { const normalizedSurface = normalizeKana(token.surface); const normalizedHeadword = normalizeKana(token.headword); - if (!normalizedSurface || !normalizedHeadword || !normalizedSurface.startsWith(normalizedHeadword)) { + if ( + !normalizedSurface || + !normalizedHeadword || + !normalizedSurface.startsWith(normalizedHeadword) + ) { return false; } @@ -236,7 +240,10 @@ function isExcludedTrailingParticleMergedToken(token: MergedToken): boolean { function isAuxiliaryStemGrammarTailToken(token: MergedToken): boolean { const pos1Parts = splitNormalizedTagParts(normalizePosTag(token.pos1)); - if (pos1Parts.length === 0 || !pos1Parts.every((part) => AUXILIARY_STEM_GRAMMAR_TAIL_POS1.has(part))) { + if ( + pos1Parts.length === 0 || + !pos1Parts.every((part) => AUXILIARY_STEM_GRAMMAR_TAIL_POS1.has(part)) + ) { return false; } diff --git a/src/core/services/youtube/labels.ts b/src/core/services/youtube/labels.ts index 9e623f0..7a40027 100644 --- a/src/core/services/youtube/labels.ts +++ b/src/core/services/youtube/labels.ts @@ -3,7 +3,11 @@ import type { YoutubeTrackKind } from './kinds'; export type { YoutubeTrackKind }; export function normalizeYoutubeLangCode(value: string): string { - return value.trim().toLowerCase().replace(/_/g, '-').replace(/[^a-z0-9-]+/g, ''); + return value + .trim() + .toLowerCase() + .replace(/_/g, '-') + .replace(/[^a-z0-9-]+/g, ''); } export function isJapaneseYoutubeLang(value: string): boolean { diff --git a/src/core/services/youtube/metadata-probe.test.ts b/src/core/services/youtube/metadata-probe.test.ts index 5db5743..05e64c1 100644 --- a/src/core/services/youtube/metadata-probe.test.ts +++ b/src/core/services/youtube/metadata-probe.test.ts @@ -75,15 +75,11 @@ test('probeYoutubeVideoMetadata returns null on malformed yt-dlp JSON', async () }); }); -test( - 'probeYoutubeVideoMetadata times out when yt-dlp hangs', - { timeout: 20_000 }, - async () => { - await withHangingFakeYtDlp(async () => { - await assert.rejects( - probeYoutubeVideoMetadata('https://www.youtube.com/watch?v=abc123'), - /timed out after 15000ms/, - ); - }); - }, -); +test('probeYoutubeVideoMetadata times out when yt-dlp hangs', { timeout: 20_000 }, async () => { + await withHangingFakeYtDlp(async () => { + await assert.rejects( + probeYoutubeVideoMetadata('https://www.youtube.com/watch?v=abc123'), + /timed out after 15000ms/, + ); + }); +}); diff --git a/src/core/services/youtube/timedtext.ts b/src/core/services/youtube/timedtext.ts index e7d2231..fe3aaf4 100644 --- a/src/core/services/youtube/timedtext.ts +++ b/src/core/services/youtube/timedtext.ts @@ -25,9 +25,7 @@ function decodeHtmlEntities(value: string): string { .replace(/>/g, '>') .replace(/"/g, '"') .replace(/'/g, "'") - .replace(/&#(\d+);/g, (match, codePoint) => - decodeNumericEntity(match, Number(codePoint)), - ) + .replace(/&#(\d+);/g, (match, codePoint) => decodeNumericEntity(match, Number(codePoint))) .replace(/&#x([0-9a-f]+);/gi, (match, codePoint) => decodeNumericEntity(match, Number.parseInt(codePoint, 16)), ); @@ -52,9 +50,7 @@ function extractYoutubeTimedTextRows(xml: string): YoutubeTimedTextRow[] { continue; } - const inner = (match[2] ?? '') - .replace(//gi, '\n') - .replace(/<[^>]+>/g, ''); + const inner = (match[2] ?? '').replace(//gi, '\n').replace(/<[^>]+>/g, ''); const text = decodeHtmlEntities(inner).trim(); if (!text) { continue; @@ -110,7 +106,9 @@ export function convertYoutubeTimedTextToVtt(xml: string): string { if (!text) { continue; } - blocks.push(`${formatVttTimestamp(row.startMs)} --> ${formatVttTimestamp(clampedEnd)}\n${text}`); + blocks.push( + `${formatVttTimestamp(row.startMs)} --> ${formatVttTimestamp(clampedEnd)}\n${text}`, + ); } return `WEBVTT\n\n${blocks.join('\n\n')}\n`; diff --git a/src/core/services/youtube/track-download.test.ts b/src/core/services/youtube/track-download.test.ts index 7832e2e..4f7ba11 100644 --- a/src/core/services/youtube/track-download.test.ts +++ b/src/core/services/youtube/track-download.test.ts @@ -16,7 +16,7 @@ async function withTempDir(fn: (dir: string) => Promise): Promise { function makeFakeYtDlpScript(dir: string): string { const scriptPath = path.join(dir, 'yt-dlp'); -const script = `#!/usr/bin/env node + const script = `#!/usr/bin/env node const fs = require('node:fs'); const path = require('node:path'); @@ -115,7 +115,9 @@ async function withFakeYtDlp( } async function withFakeYtDlpExpectations( - expectations: Partial>, + expectations: Partial< + Record<'YTDLP_EXPECT_AUTO_SUBS' | 'YTDLP_EXPECT_MANUAL_SUBS' | 'YTDLP_EXPECT_SUB_LANG', string> + >, fn: () => Promise, ): Promise { const previous = { @@ -144,11 +146,7 @@ async function withStubFetch( const originalFetch = globalThis.fetch; globalThis.fetch = (async (input: string | URL | Request) => { const url = - typeof input === 'string' - ? input - : input instanceof URL - ? input.toString() - : input.url; + typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url; return await handler(url); }) as typeof fetch; try { diff --git a/src/core/services/youtube/track-download.ts b/src/core/services/youtube/track-download.ts index 62c4bbe..8dac62e 100644 --- a/src/core/services/youtube/track-download.ts +++ b/src/core/services/youtube/track-download.ts @@ -13,7 +13,10 @@ const YOUTUBE_BATCH_PREFIX = 'youtube-batch'; const YOUTUBE_DOWNLOAD_TIMEOUT_MS = 15_000; function sanitizeFilenameSegment(value: string): string { - const sanitized = value.trim().replace(/[^a-z0-9_-]+/gi, '-').replace(/-+/g, '-'); + const sanitized = value + .trim() + .replace(/[^a-z0-9_-]+/gi, '-') + .replace(/-+/g, '-'); return sanitized.replace(/^-+|-+$/g, '') || 'unknown'; } @@ -163,10 +166,7 @@ async function downloadSubtitleFromUrl(input: { ? ext : 'vtt'; const safeSourceLanguage = sanitizeFilenameSegment(input.track.sourceLanguage); - const targetPath = path.join( - input.outputDir, - `${input.prefix}.${safeSourceLanguage}.${safeExt}`, - ); + const targetPath = path.join(input.outputDir, `${input.prefix}.${safeSourceLanguage}.${safeExt}`); const response = await fetch(input.track.downloadUrl, { signal: createFetchTimeoutSignal(YOUTUBE_DOWNLOAD_TIMEOUT_MS), }); diff --git a/src/core/services/youtube/track-probe.ts b/src/core/services/youtube/track-probe.ts index 16d4304..615c5e2 100644 --- a/src/core/services/youtube/track-probe.ts +++ b/src/core/services/youtube/track-probe.ts @@ -127,7 +127,10 @@ export async function probeYoutubeTracks(targetUrl: string): Promise track.kind === 'manual'), @@ -52,7 +53,11 @@ export function normalizeYoutubeTrackSelection(input: { primaryTrackId: string | null; secondaryTrackId: string | null; } { - if (input.primaryTrackId && input.secondaryTrackId && input.primaryTrackId === input.secondaryTrackId) { + if ( + input.primaryTrackId && + input.secondaryTrackId && + input.primaryTrackId === input.secondaryTrackId + ) { return { primaryTrackId: input.primaryTrackId, secondaryTrackId: null, @@ -60,4 +65,3 @@ export function normalizeYoutubeTrackSelection(input: { } return input; } - diff --git a/src/logger.ts b/src/logger.ts index 64e69d3..7c299cb 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -1,4 +1,7 @@ -import { appendLogLine, resolveDefaultLogFilePath as resolveSharedDefaultLogFilePath } from './shared/log-files'; +import { + appendLogLine, + resolveDefaultLogFilePath as resolveSharedDefaultLogFilePath, +} from './shared/log-files'; export type LogLevel = 'debug' | 'info' | 'warn' | 'error'; export type LogLevelSource = 'cli' | 'config'; diff --git a/src/main-entry-runtime.test.ts b/src/main-entry-runtime.test.ts index dd1f7a2..9a8bd17 100644 --- a/src/main-entry-runtime.test.ts +++ b/src/main-entry-runtime.test.ts @@ -82,10 +82,9 @@ test('stats-daemon entry helper detects internal daemon commands', () => { true, ); assert.equal( - shouldHandleStatsDaemonCommandAtEntry( - ['SubMiner.AppImage', '--stats-daemon-start'], - { ELECTRON_RUN_AS_NODE: '1' }, - ), + shouldHandleStatsDaemonCommandAtEntry(['SubMiner.AppImage', '--stats-daemon-start'], { + ELECTRON_RUN_AS_NODE: '1', + }), false, ); assert.equal(shouldHandleStatsDaemonCommandAtEntry(['SubMiner.AppImage', '--start'], {}), false); diff --git a/src/main.ts b/src/main.ts index 0a5bc98..86e76de 100644 --- a/src/main.ts +++ b/src/main.ts @@ -603,7 +603,8 @@ const isDev = process.argv.includes('--dev') || process.argv.includes('--debug') const texthookerService = new Texthooker(() => { const config = getResolvedConfig(); const characterDictionaryEnabled = - config.anilist.characterDictionary.enabled && yomitanProfilePolicy.isCharacterDictionaryEnabled(); + config.anilist.characterDictionary.enabled && + yomitanProfilePolicy.isCharacterDictionaryEnabled(); const knownAndNPlusOneEnabled = getRuntimeBooleanOption( 'subtitle.annotation.nPlusOne', config.ankiConnect.knownWords.highlightEnabled, @@ -828,7 +829,8 @@ const youtubeFlowRuntime = createYoutubeFlowRuntime({ { sendToActiveOverlayWindow: (channel, nextPayload, runtimeOptions) => overlayModalRuntime.sendToActiveOverlayWindow(channel, nextPayload, runtimeOptions), - waitForModalOpen: (modal, timeoutMs) => overlayModalRuntime.waitForModalOpen(modal, timeoutMs), + waitForModalOpen: (modal, timeoutMs) => + overlayModalRuntime.waitForModalOpen(modal, timeoutMs), logWarn: (message) => logger.warn(message), }, payload, @@ -871,7 +873,10 @@ const youtubeFlowRuntime = createYoutubeFlowRuntime({ await Promise.race([ integration.waitUntilReady(), new Promise((_, 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) { @@ -1568,10 +1573,10 @@ async function refreshSubtitlePrefetchFromActiveTrack(): Promise { const [currentExternalFilenameRaw, currentTrackRaw, trackListRaw, sidRaw, videoPathRaw] = await Promise.all([ client.requestProperty('current-tracks/sub/external-filename').catch(() => null), - client.requestProperty('current-tracks/sub').catch(() => null), - client.requestProperty('track-list'), - client.requestProperty('sid'), - client.requestProperty('path'), + client.requestProperty('current-tracks/sub').catch(() => null), + client.requestProperty('track-list'), + client.requestProperty('sid'), + client.requestProperty('path'), ]); const videoPath = typeof videoPathRaw === 'string' ? videoPathRaw : ''; if (!videoPath) { @@ -3027,7 +3032,8 @@ const ensureStatsServerStarted = (): string => { knownWordCachePath: path.join(USER_DATA_PATH, 'known-words-cache.json'), mpvSocketPath: appState.mpvSocketPath, ankiConnectConfig: getResolvedConfig().ankiConnect, - resolveAnkiNoteId: (noteId: number) => appState.ankiIntegration?.resolveCurrentNoteId(noteId) ?? noteId, + resolveAnkiNoteId: (noteId: number) => + appState.ankiIntegration?.resolveCurrentNoteId(noteId) ?? noteId, addYomitanNote: async (word: string) => { const ankiUrl = getResolvedConfig().ankiConnect.url || 'http://127.0.0.1:8765'; await syncYomitanDefaultAnkiServerCore(ankiUrl, yomitanDeps, yomitanLogger, { @@ -3434,10 +3440,10 @@ const { appReadyRuntimeRunner } = composeAppReadyRuntime({ shouldUseMinimalStartup: () => Boolean( appState.initialArgs?.texthooker || - (appState.initialArgs?.stats && - (appState.initialArgs?.statsCleanup || - appState.initialArgs?.statsBackground || - appState.initialArgs?.statsStop)), + (appState.initialArgs?.stats && + (appState.initialArgs?.statsCleanup || + appState.initialArgs?.statsBackground || + appState.initialArgs?.statsStop)), ), shouldSkipHeavyStartup: () => Boolean( @@ -4589,7 +4595,8 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({ openYomitanSettings: () => openYomitanSettings(), quitApp: () => requestAppQuit(), toggleVisibleOverlay: () => toggleVisibleOverlay(), - tokenizeCurrentSubtitle: async () => withCurrentSubtitleTiming(await tokenizeSubtitle(appState.currentSubText)), + tokenizeCurrentSubtitle: async () => + withCurrentSubtitleTiming(await tokenizeSubtitle(appState.currentSubText)), getCurrentSubtitleRaw: () => appState.currentSubText, getCurrentSubtitleAss: () => appState.currentSubAssText, getSubtitleSidebarSnapshot: async () => { @@ -4611,19 +4618,14 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({ } try { - const [ - currentExternalFilenameRaw, - currentTrackRaw, - trackListRaw, - sidRaw, - videoPathRaw, - ] = await Promise.all([ - client.requestProperty('current-tracks/sub/external-filename').catch(() => null), - client.requestProperty('current-tracks/sub').catch(() => null), - client.requestProperty('track-list'), - client.requestProperty('sid'), - client.requestProperty('path'), - ]); + const [currentExternalFilenameRaw, currentTrackRaw, trackListRaw, sidRaw, videoPathRaw] = + await Promise.all([ + client.requestProperty('current-tracks/sub/external-filename').catch(() => null), + client.requestProperty('current-tracks/sub').catch(() => null), + client.requestProperty('track-list'), + client.requestProperty('sid'), + client.requestProperty('path'), + ]); const videoPath = typeof videoPathRaw === 'string' ? videoPathRaw : ''; if (!videoPath) { return { diff --git a/src/main/overlay-runtime.test.ts b/src/main/overlay-runtime.test.ts index a5d67a8..82c8a64 100644 --- a/src/main/overlay-runtime.test.ts +++ b/src/main/overlay-runtime.test.ts @@ -287,10 +287,14 @@ test('sendToActiveOverlayWindow can prefer modal window even when main overlay i setModalWindowBounds: () => {}, }); - const sent = runtime.sendToActiveOverlayWindow('youtube:picker-open', { sessionId: 'yt-1' }, { - restoreOnModalClose: 'youtube-track-picker', - preferModalWindow: true, - }); + const sent = runtime.sendToActiveOverlayWindow( + 'youtube:picker-open', + { sessionId: 'yt-1' }, + { + restoreOnModalClose: 'youtube-track-picker', + preferModalWindow: true, + }, + ); assert.equal(sent, true); assert.deepEqual(mainWindow.sent, []); @@ -309,10 +313,14 @@ test('modal window path makes visible main overlay click-through until modal clo setModalWindowBounds: () => {}, }); - const sent = runtime.sendToActiveOverlayWindow('youtube:picker-open', { sessionId: 'yt-1' }, { - restoreOnModalClose: 'youtube-track-picker', - preferModalWindow: true, - }); + const sent = runtime.sendToActiveOverlayWindow( + 'youtube:picker-open', + { sessionId: 'yt-1' }, + { + restoreOnModalClose: 'youtube-track-picker', + preferModalWindow: true, + }, + ); runtime.notifyOverlayModalOpened('youtube-track-picker'); assert.equal(sent, true); @@ -336,10 +344,14 @@ test('modal window path hides visible main overlay until modal closes', () => { setModalWindowBounds: () => {}, }); - runtime.sendToActiveOverlayWindow('youtube:picker-open', { sessionId: 'yt-1' }, { - restoreOnModalClose: 'youtube-track-picker', - preferModalWindow: true, - }); + runtime.sendToActiveOverlayWindow( + 'youtube:picker-open', + { sessionId: 'yt-1' }, + { + restoreOnModalClose: 'youtube-track-picker', + preferModalWindow: true, + }, + ); runtime.notifyOverlayModalOpened('youtube-track-picker'); assert.equal(mainWindow.getHideCount(), 1); @@ -516,9 +528,13 @@ test('waitForModalOpen resolves true after modal acknowledgement', async () => { setModalWindowBounds: () => {}, }); - runtime.sendToActiveOverlayWindow('youtube:picker-open', { sessionId: 'yt-1' }, { - restoreOnModalClose: 'youtube-track-picker', - }); + runtime.sendToActiveOverlayWindow( + 'youtube:picker-open', + { sessionId: 'yt-1' }, + { + restoreOnModalClose: 'youtube-track-picker', + }, + ); const pending = runtime.waitForModalOpen('youtube-track-picker', 1000); runtime.notifyOverlayModalOpened('youtube-track-picker'); diff --git a/src/main/overlay-runtime.ts b/src/main/overlay-runtime.ts index f6334b7..527c7af 100644 --- a/src/main/overlay-runtime.ts +++ b/src/main/overlay-runtime.ts @@ -357,10 +357,7 @@ export function createOverlayModalRuntimeService( showModalWindow(targetWindow); }; - const waitForModalOpen = async ( - modal: OverlayHostedModal, - timeoutMs: number, - ): Promise => + const waitForModalOpen = async (modal: OverlayHostedModal, timeoutMs: number): Promise => await new Promise((resolve) => { const waiters = modalOpenWaiters.get(modal) ?? []; const finish = (opened: boolean): void => { diff --git a/src/main/runtime/mpv-main-event-bindings.ts b/src/main/runtime/mpv-main-event-bindings.ts index a3de05e..941ef21 100644 --- a/src/main/runtime/mpv-main-event-bindings.ts +++ b/src/main/runtime/mpv-main-event-bindings.ts @@ -78,8 +78,7 @@ export function createBindMpvMainEventHandlersHandler(deps: { reportJellyfinRemoteStopped: () => deps.reportJellyfinRemoteStopped(), refreshDiscordPresence: () => deps.refreshDiscordPresence(), syncOverlayMpvSubtitleSuppression: () => deps.syncOverlayMpvSubtitleSuppression(), - hasInitialPlaybackQuitOnDisconnectArg: () => - deps.hasInitialPlaybackQuitOnDisconnectArg(), + hasInitialPlaybackQuitOnDisconnectArg: () => deps.hasInitialPlaybackQuitOnDisconnectArg(), isOverlayRuntimeInitialized: () => deps.isOverlayRuntimeInitialized(), shouldQuitOnDisconnectWhenOverlayRuntimeInitialized: () => deps.shouldQuitOnDisconnectWhenOverlayRuntimeInitialized(), @@ -88,7 +87,11 @@ export function createBindMpvMainEventHandlersHandler(deps: { isMpvConnected: () => deps.isMpvConnected(), quitApp: () => deps.quitApp(), }); - const handleMpvConnectionChangeWithSidebarReset = ({ connected }: { connected: boolean }): void => { + const handleMpvConnectionChangeWithSidebarReset = ({ + connected, + }: { + connected: boolean; + }): void => { if (connected) { deps.resetSubtitleSidebarEmbeddedLayout(); } diff --git a/src/main/runtime/mpv-main-event-main-deps.ts b/src/main/runtime/mpv-main-event-main-deps.ts index 9a74abe..f9acd77 100644 --- a/src/main/runtime/mpv-main-event-main-deps.ts +++ b/src/main/runtime/mpv-main-event-main-deps.ts @@ -4,14 +4,12 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: { appState: { initialArgs?: { jellyfinPlay?: unknown; youtubePlay?: unknown } | null; overlayRuntimeInitialized: boolean; - mpvClient: - | { - connected?: boolean; - currentSecondarySubText?: string; - currentTimePos?: number; - requestProperty?: (name: string) => Promise; - } - | null; + mpvClient: { + connected?: boolean; + currentSecondarySubText?: string; + currentTimePos?: number; + requestProperty?: (name: string) => Promise; + } | null; immersionTracker: { recordSubtitleLine?: ( text: string, diff --git a/src/main/runtime/startup-autoplay-release-policy.test.ts b/src/main/runtime/startup-autoplay-release-policy.test.ts index d9da23b..f3bb708 100644 --- a/src/main/runtime/startup-autoplay-release-policy.test.ts +++ b/src/main/runtime/startup-autoplay-release-policy.test.ts @@ -14,9 +14,7 @@ test('autoplay release keeps the short retry budget for normal playback signals' test('autoplay release uses the full startup timeout window while paused', () => { assert.equal( resolveAutoplayReadyMaxReleaseAttempts({ forceWhilePaused: true }), - Math.ceil( - STARTUP_AUTOPLAY_RELEASE_TIMEOUT_MS / DEFAULT_AUTOPLAY_RELEASE_RETRY_DELAY_MS, - ), + Math.ceil(STARTUP_AUTOPLAY_RELEASE_TIMEOUT_MS / DEFAULT_AUTOPLAY_RELEASE_RETRY_DELAY_MS), ); }); diff --git a/src/main/runtime/startup-autoplay-release-policy.ts b/src/main/runtime/startup-autoplay-release-policy.ts index e46b17f..dabe846 100644 --- a/src/main/runtime/startup-autoplay-release-policy.ts +++ b/src/main/runtime/startup-autoplay-release-policy.ts @@ -22,7 +22,4 @@ export function resolveAutoplayReadyMaxReleaseAttempts(options?: { return Math.max(3, Math.ceil(startupTimeoutMs / retryDelayMs)); } -export { - DEFAULT_AUTOPLAY_RELEASE_RETRY_DELAY_MS, - STARTUP_AUTOPLAY_RELEASE_TIMEOUT_MS, -}; +export { DEFAULT_AUTOPLAY_RELEASE_RETRY_DELAY_MS, STARTUP_AUTOPLAY_RELEASE_TIMEOUT_MS }; diff --git a/src/main/runtime/windows-mpv-launch.ts b/src/main/runtime/windows-mpv-launch.ts index 7501565..4424f2f 100644 --- a/src/main/runtime/windows-mpv-launch.ts +++ b/src/main/runtime/windows-mpv-launch.ts @@ -33,10 +33,7 @@ export function resolveWindowsMpvPath(deps: WindowsMpvLaunchDeps): string { return ''; } -export function buildWindowsMpvLaunchArgs( - targets: string[], - extraArgs: string[] = [], -): string[] { +export function buildWindowsMpvLaunchArgs(targets: string[], extraArgs: string[] = []): string[] { return ['--player-operation-mode=pseudo-gui', '--profile=subminer', ...extraArgs, ...targets]; } diff --git a/src/main/runtime/youtube-flow.test.ts b/src/main/runtime/youtube-flow.test.ts index b0125d3..6e48b85 100644 --- a/src/main/runtime/youtube-flow.test.ts +++ b/src/main/runtime/youtube-flow.test.ts @@ -141,9 +141,7 @@ test('youtube flow can open a manual picker session and load the selected subtit assert.ok( commands.some( (command) => - command[0] === 'set_property' && - command[1] === 'sub-visibility' && - command[2] === 'yes', + command[0] === 'set_property' && command[1] === 'sub-visibility' && command[2] === 'yes', ), ); assert.ok( @@ -263,9 +261,7 @@ test('youtube flow retries secondary after partial batch subtitle failure', asyn assert.ok( commands.some( (command) => - command[0] === 'sub-add' && - command[1] === '/tmp/manual:en.vtt' && - command[2] === 'cached', + command[0] === 'sub-add' && command[1] === '/tmp/manual:en.vtt' && command[2] === 'cached', ), ); }); @@ -708,12 +704,54 @@ test('youtube flow leaves non-authoritative youtube subtitle tracks untouched af return selectedSecondarySid; } return [ - { type: 'sub', id: 1, lang: 'en', title: 'English', external: true, 'external-filename': null }, - { type: 'sub', id: 2, lang: 'ja', title: 'Japanese', external: true, 'external-filename': null }, - { type: 'sub', id: 3, lang: 'ja-en', title: 'Japanese from English', external: true, 'external-filename': null }, - { type: 'sub', id: 4, lang: 'ja-ja', title: 'Japanese from Japanese', external: true, 'external-filename': null }, - { type: 'sub', id: 5, lang: 'ja-orig', title: 'auto-ja-orig.vtt', external: true, 'external-filename': '/tmp/auto-ja-orig.vtt' }, - { type: 'sub', id: 6, lang: 'en', title: 'manual-en.en.srt', external: true, 'external-filename': '/tmp/manual-en.en.srt' }, + { + type: 'sub', + id: 1, + lang: 'en', + title: 'English', + external: true, + 'external-filename': null, + }, + { + type: 'sub', + id: 2, + lang: 'ja', + title: 'Japanese', + external: true, + 'external-filename': null, + }, + { + type: 'sub', + id: 3, + lang: 'ja-en', + title: 'Japanese from English', + external: true, + 'external-filename': null, + }, + { + type: 'sub', + id: 4, + lang: 'ja-ja', + title: 'Japanese from Japanese', + external: true, + 'external-filename': null, + }, + { + type: 'sub', + id: 5, + lang: 'ja-orig', + title: 'auto-ja-orig.vtt', + external: true, + 'external-filename': '/tmp/auto-ja-orig.vtt', + }, + { + type: 'sub', + id: 6, + lang: 'en', + title: 'manual-en.en.srt', + external: true, + 'external-filename': '/tmp/manual-en.en.srt', + }, ]; }, refreshCurrentSubtitle: () => {}, @@ -737,7 +775,10 @@ test('youtube flow leaves non-authoritative youtube subtitle tracks untouched af await runtime.openManualPicker({ url: 'https://example.com' }); - assert.equal(commands.some((command) => command[0] === 'sub-remove'), false); + assert.equal( + commands.some((command) => command[0] === 'sub-remove'), + false, + ); }); test('youtube flow reuses existing manual youtube subtitle tracks when both requested languages already exist', async () => { @@ -751,8 +792,20 @@ test('youtube flow reuses existing manual youtube subtitle tracks when both requ videoId: 'video123', title: 'Video 123', tracks: [ - { ...primaryTrack, id: 'manual:ja', sourceLanguage: 'ja', kind: 'manual', title: 'Japanese' }, - { ...secondaryTrack, id: 'manual:en', sourceLanguage: 'en', kind: 'manual', title: 'English' }, + { + ...primaryTrack, + id: 'manual:ja', + sourceLanguage: 'ja', + kind: 'manual', + title: 'Japanese', + }, + { + ...secondaryTrack, + id: 'manual:en', + sourceLanguage: 'en', + kind: 'manual', + title: 'English', + }, ], }), acquireYoutubeSubtitleTracks: async () => { @@ -801,10 +854,38 @@ test('youtube flow reuses existing manual youtube subtitle tracks when both requ return selectedSecondarySid; } return [ - { type: 'sub', id: 1, lang: 'en', title: 'English', external: true, 'external-filename': null }, - { type: 'sub', id: 2, lang: 'ja', title: 'Japanese', external: true, 'external-filename': null }, - { type: 'sub', id: 3, lang: 'ja-en', title: 'Japanese from English', external: true, 'external-filename': null }, - { type: 'sub', id: 4, lang: 'ja-ja', title: 'Japanese from Japanese', external: true, 'external-filename': null }, + { + type: 'sub', + id: 1, + lang: 'en', + title: 'English', + external: true, + 'external-filename': null, + }, + { + type: 'sub', + id: 2, + lang: 'ja', + title: 'Japanese', + external: true, + 'external-filename': null, + }, + { + type: 'sub', + id: 3, + lang: 'ja-en', + title: 'Japanese from English', + external: true, + 'external-filename': null, + }, + { + type: 'sub', + id: 4, + lang: 'ja-ja', + title: 'Japanese from Japanese', + external: true, + 'external-filename': null, + }, ]; }, refreshCurrentSubtitle: () => {}, @@ -833,9 +914,15 @@ test('youtube flow reuses existing manual youtube subtitle tracks when both requ assert.equal(selectedPrimarySid, 2); assert.equal(selectedSecondarySid, 1); - assert.equal(commands.some((command) => command[0] === 'sub-add'), false); + assert.equal( + commands.some((command) => command[0] === 'sub-add'), + false, + ); assert.deepEqual(refreshedSidebarSources, ['/tmp/manual-ja.ja.srt']); - assert.equal(commands.some((command) => command[0] === 'sub-remove'), false); + assert.equal( + commands.some((command) => command[0] === 'sub-remove'), + false, + ); }); test('youtube flow waits for manual youtube tracks to appear before falling back to injected copies', async () => { @@ -849,8 +936,20 @@ test('youtube flow waits for manual youtube tracks to appear before falling back videoId: 'video123', title: 'Video 123', tracks: [ - { ...primaryTrack, id: 'manual:ja', sourceLanguage: 'ja', kind: 'manual', title: 'Japanese' }, - { ...secondaryTrack, id: 'manual:en', sourceLanguage: 'en', kind: 'manual', title: 'English' }, + { + ...primaryTrack, + id: 'manual:ja', + sourceLanguage: 'ja', + kind: 'manual', + title: 'Japanese', + }, + { + ...secondaryTrack, + id: 'manual:en', + sourceLanguage: 'en', + kind: 'manual', + title: 'English', + }, ], }), acquireYoutubeSubtitleTracks: async () => { @@ -903,10 +1002,38 @@ test('youtube flow waits for manual youtube tracks to appear before falling back return []; } return [ - { type: 'sub', id: 1, lang: 'en', title: 'English', external: true, 'external-filename': null }, - { type: 'sub', id: 2, lang: 'ja', title: 'Japanese', external: true, 'external-filename': null }, - { type: 'sub', id: 3, lang: 'ja-en', title: 'Japanese from English', external: true, 'external-filename': null }, - { type: 'sub', id: 4, lang: 'ja-ja', title: 'Japanese from Japanese', external: true, 'external-filename': null }, + { + type: 'sub', + id: 1, + lang: 'en', + title: 'English', + external: true, + 'external-filename': null, + }, + { + type: 'sub', + id: 2, + lang: 'ja', + title: 'Japanese', + external: true, + 'external-filename': null, + }, + { + type: 'sub', + id: 3, + lang: 'ja-en', + title: 'Japanese from English', + external: true, + 'external-filename': null, + }, + { + type: 'sub', + id: 4, + lang: 'ja-ja', + title: 'Japanese from Japanese', + external: true, + 'external-filename': null, + }, ]; }, refreshCurrentSubtitle: () => {}, @@ -932,7 +1059,10 @@ test('youtube flow waits for manual youtube tracks to appear before falling back assert.equal(selectedPrimarySid, 2); assert.equal(selectedSecondarySid, 1); - assert.equal(commands.some((command) => command[0] === 'sub-add'), false); + assert.equal( + commands.some((command) => command[0] === 'sub-add'), + false, + ); }); test('youtube flow reuses manual youtube tracks even when mpv exposes external filenames', async () => { @@ -970,7 +1100,9 @@ test('youtube flow reuses manual youtube tracks even when mpv exposes external f if (track.id === 'manual:ja') { return { path: '/tmp/manual-ja.ja.srt' }; } - throw new Error('should not download secondary track when existing manual english track is reusable'); + throw new Error( + 'should not download secondary track when existing manual english track is reusable', + ); }, openPicker: async () => false, pauseMpv: () => {}, @@ -1051,7 +1183,10 @@ test('youtube flow reuses manual youtube tracks even when mpv exposes external f assert.equal(selectedPrimarySid, 2); assert.equal(selectedSecondarySid, 1); - assert.equal(commands.some((command) => command[0] === 'sub-add'), false); + assert.equal( + commands.some((command) => command[0] === 'sub-add'), + false, + ); }); test('youtube flow falls back to existing auto secondary track when auto secondary download fails', async () => { diff --git a/src/main/runtime/youtube-flow.ts b/src/main/runtime/youtube-flow.ts index db72b76..d4f411e 100644 --- a/src/main/runtime/youtube-flow.ts +++ b/src/main/runtime/youtube-flow.ts @@ -384,7 +384,9 @@ async function injectDownloadedSubtitles( } else { deps.warn( `Unable to bind downloaded primary subtitle track in mpv: ${ - primarySelection.injectedPath ? path.basename(primarySelection.injectedPath) : primarySelection.track.label + primarySelection.injectedPath + ? path.basename(primarySelection.injectedPath) + : primarySelection.track.label }`, ); } @@ -415,9 +417,7 @@ async function injectDownloadedSubtitles( deps.refreshCurrentSubtitle(currentSubText); } - deps.showMpvOsd( - secondaryTrack ? 'Primary and secondary subtitles loaded.' : 'Subtitles loaded.', - ); + deps.showMpvOsd(secondaryTrack ? 'Primary and secondary subtitles loaded.' : 'Subtitles loaded.'); return true; } @@ -587,7 +587,8 @@ export function createYoutubeFlowRuntime(deps: YoutubeFlowDeps) { existingPrimaryTrackId, ) : null; - const primaryReady = input.primaryTrack.kind !== 'manual' || existingPrimaryTrackId !== null; + const primaryReady = + input.primaryTrack.kind !== 'manual' || existingPrimaryTrackId !== null; const secondaryReady = !input.secondaryTrack || input.secondaryTrack.kind !== 'manual' || @@ -631,7 +632,11 @@ export function createYoutubeFlowRuntime(deps: YoutubeFlowDeps) { secondaryInjectedPath = acquired.secondaryPath; } - if (input.secondaryTrack && existingSecondaryTrackId === null && secondaryInjectedPath === null) { + if ( + input.secondaryTrack && + existingSecondaryTrackId === null && + secondaryInjectedPath === null + ) { try { secondaryInjectedPath = ( await deps.acquireYoutubeSubtitleTrack({ diff --git a/src/main/runtime/youtube-playback-launch.test.ts b/src/main/runtime/youtube-playback-launch.test.ts index a5ae968..3bf9d9a 100644 --- a/src/main/runtime/youtube-playback-launch.test.ts +++ b/src/main/runtime/youtube-playback-launch.test.ts @@ -183,7 +183,13 @@ test('prepare youtube playback accepts a non-youtube resolved path once playable '/videos/episode01.mkv', 'https://rr16---sn.example.googlevideo.com/videoplayback?id=abc', ]; - const observedTrackLists = [[], [{ type: 'video', id: 1 }, { type: 'audio', id: 2 }]]; + const observedTrackLists = [ + [], + [ + { type: 'video', id: 1 }, + { type: 'audio', id: 2 }, + ], + ]; let requestCount = 0; const prepare = createPrepareYoutubePlaybackInMpvHandler({ requestPath: async () => { @@ -256,11 +262,14 @@ test('prepare youtube playback does not accept a different youtube video after p test('prepare youtube playback accepts a fresh-start path change when the direct target matches exactly', async () => { const commands: Array> = []; - const observedPaths = [ - '', - 'https://rr16---sn.example.googlevideo.com/videoplayback?id=abc', + const observedPaths = ['', 'https://rr16---sn.example.googlevideo.com/videoplayback?id=abc']; + const observedTrackLists = [ + [], + [ + { type: 'video', id: 1 }, + { type: 'audio', id: 2 }, + ], ]; - const observedTrackLists = [[], [{ type: 'video', id: 1 }, { type: 'audio', id: 2 }]]; let requestCount = 0; const prepare = createPrepareYoutubePlaybackInMpvHandler({ requestPath: async () => { diff --git a/src/main/runtime/youtube-playback-launch.ts b/src/main/runtime/youtube-playback-launch.ts index 0de1e16..166968b 100644 --- a/src/main/runtime/youtube-playback-launch.ts +++ b/src/main/runtime/youtube-playback-launch.ts @@ -74,7 +74,9 @@ function hasPlayableMediaTracks(trackListRaw: unknown): boolean { if (!Array.isArray(trackListRaw)) return false; return trackListRaw.some((track) => { if (!track || typeof track !== 'object') return false; - const type = String((track as Record).type || '').trim().toLowerCase(); + const type = String((track as Record).type || '') + .trim() + .toLowerCase(); return type === 'video' || type === 'audio'; }); } diff --git a/src/main/runtime/youtube-primary-subtitle-notification.ts b/src/main/runtime/youtube-primary-subtitle-notification.ts index c52804b..df07e59 100644 --- a/src/main/runtime/youtube-primary-subtitle-notification.ts +++ b/src/main/runtime/youtube-primary-subtitle-notification.ts @@ -1,7 +1,9 @@ import { isYoutubeMediaPath } from './youtube-playback'; import { normalizeYoutubeLangCode } from '../../core/services/youtube/labels'; -export type YoutubePrimarySubtitleNotificationTimer = ReturnType | { id: number }; +export type YoutubePrimarySubtitleNotificationTimer = + | ReturnType + | { id: number }; type SubtitleTrackEntry = { id: number | null; @@ -82,7 +84,9 @@ function hasSelectedPrimarySubtitle( const tracks = trackList.map(normalizeTrack); const activeTrack = - (sid === null ? null : tracks.find((track) => track?.type === 'sub' && track.id === sid) ?? null) ?? + (sid === null + ? null + : (tracks.find((track) => track?.type === 'sub' && track.id === sid) ?? null)) ?? tracks.find((track) => track?.type === 'sub' && track.selected) ?? null; if (!activeTrack) { @@ -130,7 +134,9 @@ export function createYoutubePrimarySubtitleNotificationRuntime(deps: { return; } lastReportedMediaPath = mediaPath; - deps.notifyFailure('Primary subtitle failed to download or load. Try again from the subtitle modal.'); + deps.notifyFailure( + 'Primary subtitle failed to download or load. Try again from the subtitle modal.', + ); }; const schedulePendingCheck = (): void => { @@ -150,7 +156,8 @@ export function createYoutubePrimarySubtitleNotificationRuntime(deps: { return { handleMediaPathChange: (path: string | null): void => { - const normalizedPath = typeof path === 'string' && path.trim().length > 0 ? path.trim() : null; + const normalizedPath = + typeof path === 'string' && path.trim().length > 0 ? path.trim() : null; if (currentMediaPath !== normalizedPath) { lastReportedMediaPath = null; } diff --git a/src/renderer/handlers/controller-binding-capture.ts b/src/renderer/handlers/controller-binding-capture.ts index eb033b3..9e69436 100644 --- a/src/renderer/handlers/controller-binding-capture.ts +++ b/src/renderer/handlers/controller-binding-capture.ts @@ -47,7 +47,10 @@ type ControllerBindingCaptureResult = dpadDirection: ControllerDpadFallback; }; -function isActiveButton(button: ControllerButtonState | undefined, triggerDeadzone: number): boolean { +function isActiveButton( + button: ControllerButtonState | undefined, + triggerDeadzone: number, +): boolean { if (!button) return false; return Boolean(button.pressed) || button.value >= triggerDeadzone; } @@ -90,7 +93,10 @@ export function createControllerBindingCapture(options: { }); } - function arm(nextTarget: ControllerBindingCaptureTarget, snapshot: ControllerBindingCaptureSnapshot): void { + function arm( + nextTarget: ControllerBindingCaptureTarget, + snapshot: ControllerBindingCaptureSnapshot, + ): void { target = nextTarget; resetBlockedState(snapshot); } @@ -139,7 +145,10 @@ export function createControllerBindingCapture(options: { } // After dpad early-return, only 'discrete' | 'axis' remain - const narrowedTarget: Extract = target; + const narrowedTarget: Extract< + ControllerBindingCaptureTarget, + { bindingType: 'discrete' | 'axis' } + > = target; for (let index = 0; index < snapshot.buttons.length; index += 1) { if (!isActiveButton(snapshot.buttons[index], options.triggerDeadzone)) continue; diff --git a/src/renderer/handlers/keyboard.ts b/src/renderer/handlers/keyboard.ts index 01c423e..ac4b294 100644 --- a/src/renderer/handlers/keyboard.ts +++ b/src/renderer/handlers/keyboard.ts @@ -194,13 +194,7 @@ export function createKeyboardHandlers( (isBackslashConfigured && e.key === '\\') || (toggleKey.length === 1 && e.key === toggleKey); - return ( - keyMatches && - !e.ctrlKey && - !e.altKey && - !e.metaKey && - !e.repeat - ); + return keyMatches && !e.ctrlKey && !e.altKey && !e.metaKey && !e.repeat; } function isStatsOverlayToggle(e: KeyboardEvent): boolean { diff --git a/src/renderer/handlers/mouse.test.ts b/src/renderer/handlers/mouse.test.ts index 5077ed2..6018c88 100644 --- a/src/renderer/handlers/mouse.test.ts +++ b/src/renderer/handlers/mouse.test.ts @@ -3,10 +3,7 @@ import test from 'node:test'; import type { SubtitleSidebarConfig } from '../../types'; import { createMouseHandlers } from './mouse.js'; -import { - YOMITAN_POPUP_HIDDEN_EVENT, - YOMITAN_POPUP_SHOWN_EVENT, -} from '../yomitan-popup.js'; +import { YOMITAN_POPUP_HIDDEN_EVENT, YOMITAN_POPUP_SHOWN_EVENT } from '../yomitan-popup.js'; function createClassList() { const classes = new Set(); @@ -118,9 +115,15 @@ test('secondary hover pauses on enter, reveals secondary subtitle, and resumes o }); await handlers.handleSecondaryMouseEnter(); - assert.equal(ctx.dom.secondarySubContainer.classList.contains('secondary-sub-hover-active'), true); + assert.equal( + ctx.dom.secondarySubContainer.classList.contains('secondary-sub-hover-active'), + true, + ); await handlers.handleSecondaryMouseLeave(); - assert.equal(ctx.dom.secondarySubContainer.classList.contains('secondary-sub-hover-active'), false); + assert.equal( + ctx.dom.secondarySubContainer.classList.contains('secondary-sub-hover-active'), + false, + ); assert.deepEqual(mpvCommands, [ ['set_property', 'pause', 'yes'], @@ -186,7 +189,10 @@ test('secondary leave toward primary subtitle container clears the secondary hov } as unknown as MouseEvent); assert.equal(ctx.state.isOverSubtitle, false); - assert.equal(ctx.dom.secondarySubContainer.classList.contains('secondary-sub-hover-active'), false); + assert.equal( + ctx.dom.secondarySubContainer.classList.contains('secondary-sub-hover-active'), + false, + ); assert.deepEqual(mpvCommands, [['set_property', 'pause', 'yes']]); }); @@ -237,7 +243,10 @@ test('primary hover pauses on enter without revealing secondary subtitle', async }); await handlers.handlePrimaryMouseEnter(); - assert.equal(ctx.dom.secondarySubContainer.classList.contains('secondary-sub-hover-active'), false); + assert.equal( + ctx.dom.secondarySubContainer.classList.contains('secondary-sub-hover-active'), + false, + ); await handlers.handlePrimaryMouseLeave(); assert.deepEqual(mpvCommands, [ @@ -394,7 +403,10 @@ test('restorePointerInteractionState reapplies the secondary hover class from po mousemove?.({ clientX: 10, clientY: 20 } as MouseEvent); assert.equal(ctx.state.isOverSubtitle, true); - assert.equal(ctx.dom.secondarySubContainer.classList.contains('secondary-sub-hover-active'), true); + assert.equal( + ctx.dom.secondarySubContainer.classList.contains('secondary-sub-hover-active'), + true, + ); } finally { Object.defineProperty(globalThis, 'document', { configurable: true, value: originalDocument }); Object.defineProperty(globalThis, 'window', { configurable: true, value: originalWindow }); diff --git a/src/renderer/handlers/mouse.ts b/src/renderer/handlers/mouse.ts index 52ef5ed..5c7e6e6 100644 --- a/src/renderer/handlers/mouse.ts +++ b/src/renderer/handlers/mouse.ts @@ -228,10 +228,7 @@ export function createMouseHandlers( syncOverlayMouseIgnoreState(ctx); } - async function handleMouseEnter( - _event?: MouseEvent, - showSecondaryHover = false, - ): Promise { + async function handleMouseEnter(_event?: MouseEvent, showSecondaryHover = false): Promise { ctx.state.isOverSubtitle = true; if (showSecondaryHover) { ctx.dom.secondarySubContainer.classList.add('secondary-sub-hover-active'); @@ -267,10 +264,7 @@ export function createMouseHandlers( pausedBySubtitleHover = true; } - async function handleMouseLeave( - _event?: MouseEvent, - hideSecondaryHover = false, - ): Promise { + async function handleMouseLeave(_event?: MouseEvent, hideSecondaryHover = false): Promise { const relatedTarget = _event?.relatedTarget ?? null; const otherContainer = hideSecondaryHover ? ctx.dom.subtitleContainer diff --git a/src/renderer/modals/controller-config-form.ts b/src/renderer/modals/controller-config-form.ts index 3131e6b..2b9d997 100644 --- a/src/renderer/modals/controller-config-form.ts +++ b/src/renderer/modals/controller-config-form.ts @@ -118,10 +118,14 @@ export function getDefaultControllerBinding(actionId: ControllerBindingActionId) if (!definition) { return { kind: 'none' } as const; } - return JSON.parse(JSON.stringify(definition.defaultBinding)) as ResolvedControllerConfig['bindings'][ControllerBindingActionId]; + return JSON.parse( + JSON.stringify(definition.defaultBinding), + ) as ResolvedControllerConfig['bindings'][ControllerBindingActionId]; } -export function getDefaultDpadFallback(actionId: ControllerBindingActionId): ControllerDpadFallback { +export function getDefaultDpadFallback( + actionId: ControllerBindingActionId, +): ControllerDpadFallback { const definition = getControllerBindingDefinition(actionId); if (!definition || definition.defaultBinding.kind !== 'axis') return 'none'; const binding = definition.defaultBinding; @@ -249,7 +253,11 @@ export function createControllerConfigForm(options: { if (definition.bindingType === 'axis') { renderAxisStickRow(definition, binding as ResolvedControllerAxisBinding, learningActionId); - renderAxisDpadRow(definition, binding as ResolvedControllerAxisBinding, dpadLearningActionId); + renderAxisDpadRow( + definition, + binding as ResolvedControllerAxisBinding, + dpadLearningActionId, + ); } else { renderDiscreteRow(definition, binding, learningActionId); } @@ -265,7 +273,12 @@ export function createControllerConfigForm(options: { const isExpanded = expandedRowKey === rowKey; const isLearning = learningActionId === definition.id; - const row = createRow(definition.label, formatFriendlyBindingLabel(binding), binding.kind === 'none', isExpanded); + const row = createRow( + definition.label, + formatFriendlyBindingLabel(binding), + binding.kind === 'none', + isExpanded, + ); row.addEventListener('click', () => { expandedRowKey = expandedRowKey === rowKey ? null : rowKey; render(); @@ -277,9 +290,18 @@ export function createControllerConfigForm(options: { ? 'Press a button, trigger, or move a stick\u2026' : `Currently: ${formatControllerBindingSummary(binding)}`; const panel = createEditPanel(hint, isLearning, { - onLearn: (e) => { e.stopPropagation(); options.onLearn(definition.id, definition.bindingType); }, - onClear: (e) => { e.stopPropagation(); options.onClear(definition.id); }, - onReset: (e) => { e.stopPropagation(); options.onReset(definition.id); }, + onLearn: (e) => { + e.stopPropagation(); + options.onLearn(definition.id, definition.bindingType); + }, + onClear: (e) => { + e.stopPropagation(); + options.onClear(definition.id); + }, + onReset: (e) => { + e.stopPropagation(); + options.onReset(definition.id); + }, }); options.container.appendChild(panel); } @@ -294,7 +316,12 @@ export function createControllerConfigForm(options: { const isExpanded = expandedRowKey === rowKey; const isLearning = learningActionId === definition.id; - const row = createRow(`${definition.label} (Stick)`, formatFriendlyStickLabel(binding), binding.kind === 'none', isExpanded); + const row = createRow( + `${definition.label} (Stick)`, + formatFriendlyStickLabel(binding), + binding.kind === 'none', + isExpanded, + ); row.addEventListener('click', () => { expandedRowKey = expandedRowKey === rowKey ? null : rowKey; render(); @@ -305,9 +332,18 @@ export function createControllerConfigForm(options: { const summary = binding.kind === 'none' ? 'Disabled' : `Axis ${binding.axisIndex}`; const hint = isLearning ? 'Move a stick or trigger\u2026' : `Currently: ${summary}`; const panel = createEditPanel(hint, isLearning, { - onLearn: (e) => { e.stopPropagation(); options.onLearn(definition.id, 'axis'); }, - onClear: (e) => { e.stopPropagation(); options.onClear(definition.id); }, - onReset: (e) => { e.stopPropagation(); options.onReset(definition.id); }, + onLearn: (e) => { + e.stopPropagation(); + options.onLearn(definition.id, 'axis'); + }, + onClear: (e) => { + e.stopPropagation(); + options.onClear(definition.id); + }, + onReset: (e) => { + e.stopPropagation(); + options.onReset(definition.id); + }, }); options.container.appendChild(panel); } @@ -322,9 +358,15 @@ export function createControllerConfigForm(options: { const isExpanded = expandedRowKey === rowKey; const isLearning = dpadLearningActionId === definition.id; - const dpadFallback: ControllerDpadFallback = binding.kind === 'none' ? 'none' : binding.dpadFallback; + const dpadFallback: ControllerDpadFallback = + binding.kind === 'none' ? 'none' : binding.dpadFallback; const badgeText = DPAD_FALLBACK_LABELS[dpadFallback]; - const row = createRow(`${definition.label} (D-pad)`, badgeText, dpadFallback === 'none', isExpanded); + const row = createRow( + `${definition.label} (D-pad)`, + badgeText, + dpadFallback === 'none', + isExpanded, + ); row.addEventListener('click', () => { expandedRowKey = expandedRowKey === rowKey ? null : rowKey; render(); @@ -336,15 +378,29 @@ export function createControllerConfigForm(options: { ? 'Press a D-pad direction\u2026' : `Currently: ${DPAD_FALLBACK_LABELS[dpadFallback]}`; const panel = createEditPanel(hint, isLearning, { - onLearn: (e) => { e.stopPropagation(); options.onDpadLearn(definition.id); }, - onClear: (e) => { e.stopPropagation(); options.onDpadClear(definition.id); }, - onReset: (e) => { e.stopPropagation(); options.onDpadReset(definition.id); }, + onLearn: (e) => { + e.stopPropagation(); + options.onDpadLearn(definition.id); + }, + onClear: (e) => { + e.stopPropagation(); + options.onDpadClear(definition.id); + }, + onReset: (e) => { + e.stopPropagation(); + options.onDpadReset(definition.id); + }, }); options.container.appendChild(panel); } } - function createRow(labelText: string, badgeText: string, isDisabled: boolean, isExpanded: boolean): HTMLDivElement { + function createRow( + labelText: string, + badgeText: string, + isDisabled: boolean, + isExpanded: boolean, + ): HTMLDivElement { const row = document.createElement('div'); row.className = 'controller-config-row'; if (isExpanded) row.classList.add('expanded'); diff --git a/src/renderer/modals/controller-select.test.ts b/src/renderer/modals/controller-select.test.ts index 93086bb..ef64fce 100644 --- a/src/renderer/modals/controller-select.test.ts +++ b/src/renderer/modals/controller-select.test.ts @@ -66,7 +66,10 @@ function createFakeElement() { if (!match) return null; const testId = match[1]; for (const child of el.children) { - if (typeof child.getAttribute === 'function' && child.getAttribute('data-testid') === testId) { + if ( + typeof child.getAttribute === 'function' && + child.getAttribute('data-testid') === testId + ) { return child; } if (typeof child.querySelector === 'function') { @@ -105,7 +108,10 @@ function installFakeDom() { return { restore: () => { Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow }); - Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument }); + Object.defineProperty(globalThis, 'document', { + configurable: true, + value: previousDocument, + }); }, }; } diff --git a/src/renderer/modals/controller-select.ts b/src/renderer/modals/controller-select.ts index a1f0882..a3bba8c 100644 --- a/src/renderer/modals/controller-select.ts +++ b/src/renderer/modals/controller-select.ts @@ -31,8 +31,9 @@ export function createControllerSelectModal( let lastRenderedActiveGamepadId: string | null = null; let lastRenderedPreferredId = ''; type ControllerBindingKey = keyof NonNullable['bindings']; - type ControllerBindingValue = - NonNullable['bindings']>[ControllerBindingKey]; + type ControllerBindingValue = NonNullable< + NonNullable['bindings'] + >[ControllerBindingKey]; let learningActionId: ControllerBindingKey | null = null; let dpadLearningActionId: ControllerBindingKey | null = null; let bindingCapture: ReturnType | null = null; @@ -198,7 +199,9 @@ export function createControllerSelectModal( lastRenderedPreferredId = preferredId; } - async function saveControllerConfig(update: Parameters[0]) { + async function saveControllerConfig( + update: Parameters[0], + ) { await window.electronAPI.saveControllerConfig(update); if (!ctx.state.controllerConfig) return; if (update.preferredGamepadId !== undefined) { @@ -304,7 +307,10 @@ export function createControllerSelectModal( if (result.bindingType === 'dpad') { void saveDpadFallback(result.actionId as ControllerBindingKey, result.dpadDirection); } else { - void saveBinding(result.actionId as ControllerBindingKey, result.binding as ControllerBindingValue); + void saveBinding( + result.actionId as ControllerBindingKey, + result.binding as ControllerBindingValue, + ); } } } diff --git a/src/renderer/modals/subtitle-sidebar.test.ts b/src/renderer/modals/subtitle-sidebar.test.ts index 80822f6..13de81f 100644 --- a/src/renderer/modals/subtitle-sidebar.test.ts +++ b/src/renderer/modals/subtitle-sidebar.test.ts @@ -90,10 +90,7 @@ test('findActiveSubtitleCueIndex prefers current subtitle timing over near-futur { startTime: 233.05, endTime: 236, text: 'next' }, ]; - assert.equal( - findActiveSubtitleCueIndex(cues, { text: 'previous', startTime: 231 }, 233, 0), - 0, - ); + assert.equal(findActiveSubtitleCueIndex(cues, { text: 'previous', startTime: 231 }, 233, 0), 0); }); test('subtitle sidebar modal opens from snapshot and clicking cue seeks playback', async () => { @@ -1217,10 +1214,22 @@ test('subtitle sidebar polling schedules serialized timeouts instead of interval assert.equal(timeoutCount > 0, true); assert.equal(intervalCount, 0); } finally { - Object.defineProperty(globalThis, 'setTimeout', { configurable: true, value: previousSetTimeout }); - Object.defineProperty(globalThis, 'clearTimeout', { configurable: true, value: previousClearTimeout }); - Object.defineProperty(globalThis, 'setInterval', { configurable: true, value: previousSetInterval }); - Object.defineProperty(globalThis, 'clearInterval', { configurable: true, value: previousClearInterval }); + Object.defineProperty(globalThis, 'setTimeout', { + configurable: true, + value: previousSetTimeout, + }); + Object.defineProperty(globalThis, 'clearTimeout', { + configurable: true, + value: previousClearTimeout, + }); + Object.defineProperty(globalThis, 'setInterval', { + configurable: true, + value: previousSetInterval, + }); + Object.defineProperty(globalThis, 'clearInterval', { + configurable: true, + value: previousClearInterval, + }); Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow }); Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument }); } @@ -1564,17 +1573,13 @@ test('subtitle sidebar embedded layout reserves and releases mpv right margin', assert.ok( mpvCommands.some( (command) => - command[0] === 'set_property' && - command[1] === 'osd-align-x' && - command[2] === 'left', + command[0] === 'set_property' && command[1] === 'osd-align-x' && command[2] === 'left', ), ); assert.ok( mpvCommands.some( (command) => - command[0] === 'set_property' && - command[1] === 'osd-align-y' && - command[2] === 'top', + command[0] === 'set_property' && command[1] === 'osd-align-y' && command[2] === 'top', ), ); assert.ok( @@ -1597,7 +1602,11 @@ test('subtitle sidebar embedded layout reserves and releases mpv right margin', assert.deepEqual(mpvCommands.at(-5), ['set_property', 'video-margin-ratio-right', 0]); assert.deepEqual(mpvCommands.at(-4), ['set_property', 'osd-align-x', 'left']); assert.deepEqual(mpvCommands.at(-3), ['set_property', 'osd-align-y', 'top']); - assert.deepEqual(mpvCommands.at(-2), ['set_property', 'user-data/osc/margins', '{"l":0,"r":0,"t":0,"b":0}']); + assert.deepEqual(mpvCommands.at(-2), [ + 'set_property', + 'user-data/osc/margins', + '{"l":0,"r":0,"t":0,"b":0}', + ]); assert.deepEqual(mpvCommands.at(-1), ['set_property', 'video-pan-x', 0]); assert.equal(bodyClassList.contains('subtitle-sidebar-embedded-open'), false); assert.deepEqual(rootStyleCalls.at(-1), ['--subtitle-sidebar-reserved-width', '0px']); diff --git a/src/renderer/modals/subtitle-sidebar.ts b/src/renderer/modals/subtitle-sidebar.ts index f12618b..6054e08 100644 --- a/src/renderer/modals/subtitle-sidebar.ts +++ b/src/renderer/modals/subtitle-sidebar.ts @@ -1,8 +1,4 @@ -import type { - SubtitleCue, - SubtitleData, - SubtitleSidebarSnapshot, -} from '../../types'; +import type { SubtitleCue, SubtitleData, SubtitleSidebarSnapshot } from '../../types'; import type { ModalStateReader, RendererContext } from '../context'; import { syncOverlayMouseIgnoreState } from '../overlay-mouse-ignore.js'; @@ -76,8 +72,7 @@ export function findActiveSubtitleCueIndex( if (typeof currentTimeSec === 'number' && Number.isFinite(currentTimeSec)) { const activeOrUpcomingCue = cues.findIndex( (cue) => - cue.endTime > currentTimeSec && - cue.startTime <= currentTimeSec + ACTIVE_CUE_LOOKAHEAD_SEC, + cue.endTime > currentTimeSec && cue.startTime <= currentTimeSec + ACTIVE_CUE_LOOKAHEAD_SEC, ); if (activeOrUpcomingCue >= 0) { return activeOrUpcomingCue; @@ -109,8 +104,7 @@ export function findActiveSubtitleCueIndex( return -1; } - const hasTiming = - typeof current.startTime === 'number' && Number.isFinite(current.startTime); + const hasTiming = typeof current.startTime === 'number' && Number.isFinite(current.startTime); if (preferredCueIndex >= 0) { if (!hasTiming && currentTimeSec === null) { @@ -213,16 +207,8 @@ export function createSubtitleSidebarModal( 'video-margin-ratio-right', Number(ratio.toFixed(4)), ]); - window.electronAPI.sendMpvCommand([ - 'set_property', - 'osd-align-x', - 'left', - ]); - window.electronAPI.sendMpvCommand([ - 'set_property', - 'osd-align-y', - 'top', - ]); + window.electronAPI.sendMpvCommand(['set_property', 'osd-align-x', 'left']); + window.electronAPI.sendMpvCommand(['set_property', 'osd-align-y', 'top']); window.electronAPI.sendMpvCommand([ 'set_property', 'user-data/osc/margins', @@ -302,13 +288,14 @@ export function createSubtitleSidebarModal( } const list = ctx.dom.subtitleSidebarList; - const active = list.children[ctx.state.subtitleSidebarActiveCueIndex] as HTMLElement | undefined; + const active = list.children[ctx.state.subtitleSidebarActiveCueIndex] as + | HTMLElement + | undefined; if (!active) { return; } - const targetScrollTop = - active.offsetTop - (list.clientHeight - active.clientHeight) / 2; + const targetScrollTop = active.offsetTop - (list.clientHeight - active.clientHeight) / 2; const nextScrollTop = Math.max(0, targetScrollTop); if (previousActiveCueIndex < 0) { list.scrollTop = nextScrollTop; @@ -363,9 +350,9 @@ export function createSubtitleSidebarModal( } if (ctx.state.subtitleSidebarActiveCueIndex >= 0) { - const current = ctx.dom.subtitleSidebarList.children[ctx.state.subtitleSidebarActiveCueIndex] as - | HTMLElement - | undefined; + const current = ctx.dom.subtitleSidebarList.children[ + ctx.state.subtitleSidebarActiveCueIndex + ] as HTMLElement | undefined; current?.classList.add('active'); } } @@ -476,7 +463,11 @@ export function createSubtitleSidebarModal( async function autoOpenSubtitleSidebarOnStartup(): Promise { const snapshot = await refreshSnapshot(); - if (!snapshot.config.enabled || !snapshot.config.autoOpen || ctx.state.subtitleSidebarModalOpen) { + if ( + !snapshot.config.enabled || + !snapshot.config.autoOpen || + ctx.state.subtitleSidebarModalOpen + ) { return; } await openSubtitleSidebarModal(); @@ -512,10 +503,7 @@ export function createSubtitleSidebarModal( return; } - updateActiveCue( - { text: data.text, startTime: data.startTime }, - data.startTime ?? null, - ); + updateActiveCue({ text: data.text, startTime: data.startTime }, data.startTime ?? null); } function wireDomEvents(): void { diff --git a/src/renderer/modals/youtube-track-picker.ts b/src/renderer/modals/youtube-track-picker.ts index ddb3b1d..ecbcbfd 100644 --- a/src/renderer/modals/youtube-track-picker.ts +++ b/src/renderer/modals/youtube-track-picker.ts @@ -28,13 +28,13 @@ export function createYoutubeTrackPickerModal( function setStatus(message: string, isError = false): void { ctx.state.youtubePickerStatus = message; ctx.dom.youtubePickerStatus.textContent = message; - ctx.dom.youtubePickerStatus.style.color = isError - ? '#ed8796' - : '#a5adcb'; + ctx.dom.youtubePickerStatus.style.color = isError ? '#ed8796' : '#a5adcb'; } function getTrackLabel(trackId: string): string { - return ctx.state.youtubePickerPayload?.tracks.find((track) => track.id === trackId)?.label ?? ''; + return ( + ctx.state.youtubePickerPayload?.tracks.find((track) => track.id === trackId)?.label ?? '' + ); } function renderTrackList(): void { @@ -82,10 +82,7 @@ export function createYoutubeTrackPickerModal( if (track.id === primaryTrackId) continue; ctx.dom.youtubePickerSecondarySelect.appendChild(createOption(track.id, track.label)); } - if ( - primaryTrackId && - ctx.dom.youtubePickerSecondarySelect.value === primaryTrackId - ) { + if (primaryTrackId && ctx.dom.youtubePickerSecondarySelect.value === primaryTrackId) { ctx.dom.youtubePickerSecondarySelect.value = ''; } } @@ -126,7 +123,9 @@ export function createYoutubeTrackPickerModal( setStatus('Select the subtitle tracks to download.'); } - async function resolveSelection(action: 'use-selected' | 'continue-without-subtitles'): Promise { + async function resolveSelection( + action: 'use-selected' | 'continue-without-subtitles', + ): Promise { if (resolveSelectionInFlight) { return; } @@ -238,7 +237,9 @@ export function createYoutubeTrackPickerModal( return true; } void resolveSelection( - payloadHasTracks(ctx.state.youtubePickerPayload) ? 'use-selected' : 'continue-without-subtitles', + payloadHasTracks(ctx.state.youtubePickerPayload) + ? 'use-selected' + : 'continue-without-subtitles', ); return true; } @@ -269,7 +270,9 @@ export function createYoutubeTrackPickerModal( ctx.dom.youtubePickerContinueButton.addEventListener('click', () => { void resolveSelection( - payloadHasTracks(ctx.state.youtubePickerPayload) ? 'use-selected' : 'continue-without-subtitles', + payloadHasTracks(ctx.state.youtubePickerPayload) + ? 'use-selected' + : 'continue-without-subtitles', ); }); diff --git a/src/renderer/overlay-mouse-ignore.ts b/src/renderer/overlay-mouse-ignore.ts index 683392a..4f7f845 100644 --- a/src/renderer/overlay-mouse-ignore.ts +++ b/src/renderer/overlay-mouse-ignore.ts @@ -7,14 +7,14 @@ function isBlockingOverlayModalOpen(state: RendererState): boolean { return Boolean( state.controllerSelectModalOpen || - state.controllerDebugModalOpen || - state.jimakuModalOpen || - state.youtubePickerModalOpen || - state.kikuModalOpen || - state.runtimeOptionsModalOpen || - state.subsyncModalOpen || - state.sessionHelpModalOpen || - (state.subtitleSidebarModalOpen && !embeddedSidebarOpen), + state.controllerDebugModalOpen || + state.jimakuModalOpen || + state.youtubePickerModalOpen || + state.kikuModalOpen || + state.runtimeOptionsModalOpen || + state.subsyncModalOpen || + state.sessionHelpModalOpen || + (state.subtitleSidebarModalOpen && !embeddedSidebarOpen), ); } diff --git a/src/renderer/renderer.ts b/src/renderer/renderer.ts index 20633f4..48a445a 100644 --- a/src/renderer/renderer.ts +++ b/src/renderer/renderer.ts @@ -552,8 +552,14 @@ async function init(): Promise { ctx.dom.subtitleContainer.addEventListener('mouseenter', mouseHandlers.handlePrimaryMouseEnter); ctx.dom.subtitleContainer.addEventListener('mouseleave', mouseHandlers.handlePrimaryMouseLeave); - ctx.dom.secondarySubContainer.addEventListener('mouseenter', mouseHandlers.handleSecondaryMouseEnter); - ctx.dom.secondarySubContainer.addEventListener('mouseleave', mouseHandlers.handleSecondaryMouseLeave); + ctx.dom.secondarySubContainer.addEventListener( + 'mouseenter', + mouseHandlers.handleSecondaryMouseEnter, + ); + ctx.dom.secondarySubContainer.addEventListener( + 'mouseleave', + mouseHandlers.handleSecondaryMouseLeave, + ); mouseHandlers.setupResizeHandler(); mouseHandlers.setupPointerTracking(); diff --git a/src/renderer/style.css b/src/renderer/style.css index ff93ca7..79d4d77 100644 --- a/src/renderer/style.css +++ b/src/renderer/style.css @@ -296,7 +296,7 @@ body { .youtube-picker-content { width: min(820px, 92%); background: - radial-gradient(circle at top right, rgba(198, 160, 246, 0.10), transparent 34%), + radial-gradient(circle at top right, rgba(198, 160, 246, 0.1), transparent 34%), linear-gradient(180deg, rgba(36, 39, 58, 0.98), rgba(30, 32, 48, 0.98)); border-color: rgba(138, 173, 244, 0.25); } @@ -1342,8 +1342,14 @@ iframe[id^='yomitan-popup'] { } @keyframes configEditSlideIn { - from { max-height: 0; opacity: 0; } - to { max-height: 120px; opacity: 1; } + from { + max-height: 0; + opacity: 0; + } + to { + max-height: 120px; + opacity: 1; + } } .controller-config-edit-inner { @@ -1365,8 +1371,13 @@ iframe[id^='yomitan-popup'] { } @keyframes configLearnPulse { - 0%, 100% { opacity: 1; } - 50% { opacity: 0.6; } + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.6; + } } .controller-config-edit-actions { @@ -1404,7 +1415,9 @@ iframe[id^='yomitan-popup'] { color: #6e738d; font-size: 12px; cursor: pointer; - transition: background 120ms ease, color 120ms ease; + transition: + background 120ms ease, + color 120ms ease; } .btn-secondary:hover { @@ -1497,14 +1510,13 @@ body.subtitle-sidebar-embedded-open .subtitle-sidebar-modal { max-height: calc(100vh - 28px); height: auto; margin-left: auto; - font-family: - var( - --subtitle-sidebar-font-family, - 'M PLUS 1', - 'Noto Sans CJK JP', - 'Hiragino Sans', - sans-serif - ); + font-family: var( + --subtitle-sidebar-font-family, + 'M PLUS 1', + 'Noto Sans CJK JP', + 'Hiragino Sans', + sans-serif + ); font-size: var(--subtitle-sidebar-font-size, 16px); background: var(--subtitle-sidebar-background-color, rgba(73, 77, 100, 0.9)); color: var(--subtitle-sidebar-text-color, #cad3f5); diff --git a/src/renderer/subtitle-render.test.ts b/src/renderer/subtitle-render.test.ts index f78c985..ef80f02 100644 --- a/src/renderer/subtitle-render.test.ts +++ b/src/renderer/subtitle-render.test.ts @@ -981,18 +981,9 @@ test('JLPT CSS rules use underline-only styling in renderer stylesheet', () => { cssText, 'body.subtitle-sidebar-embedded-open #secondarySubContainer.secondary-sub-hover', ); - assert.match( - secondaryEmbeddedHoverBlock, - /right:\s*var\(--subtitle-sidebar-reserved-width\);/, - ); - assert.match( - secondaryEmbeddedHoverBlock, - /max-width:\s*none;/, - ); - assert.match( - secondaryEmbeddedHoverBlock, - /transform:\s*none;/, - ); + assert.match(secondaryEmbeddedHoverBlock, /right:\s*var\(--subtitle-sidebar-reserved-width\);/); + assert.match(secondaryEmbeddedHoverBlock, /max-width:\s*none;/); + assert.match(secondaryEmbeddedHoverBlock, /transform:\s*none;/); assert.doesNotMatch( secondaryEmbeddedHoverBlock, /transform:\s*translateX\(calc\(var\(--subtitle-sidebar-reserved-width\)\s*\*\s*-0\.5\)\);/, diff --git a/src/shared/log-files.test.ts b/src/shared/log-files.test.ts index 5c4d6c3..9093ea7 100644 --- a/src/shared/log-files.test.ts +++ b/src/shared/log-files.test.ts @@ -3,11 +3,7 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import { - appendLogLine, - pruneLogFiles, - resolveDefaultLogFilePath, -} from './log-files'; +import { appendLogLine, pruneLogFiles, resolveDefaultLogFilePath } from './log-files'; test('resolveDefaultLogFilePath uses app prefix by default', () => { const now = new Date('2026-03-22T12:00:00.000Z'); @@ -36,8 +32,16 @@ test('pruneLogFiles removes logs older than retention window', () => { fs.writeFileSync(stalePath, 'stale\n', 'utf8'); fs.writeFileSync(freshPath, 'fresh\n', 'utf8'); const now = new Date('2026-03-22T12:00:00.000Z'); - fs.utimesSync(stalePath, new Date('2026-03-01T12:00:00.000Z'), new Date('2026-03-01T12:00:00.000Z')); - fs.utimesSync(freshPath, new Date('2026-03-21T12:00:00.000Z'), new Date('2026-03-21T12:00:00.000Z')); + fs.utimesSync( + stalePath, + new Date('2026-03-01T12:00:00.000Z'), + new Date('2026-03-01T12:00:00.000Z'), + ); + fs.utimesSync( + freshPath, + new Date('2026-03-21T12:00:00.000Z'), + new Date('2026-03-21T12:00:00.000Z'), + ); try { pruneLogFiles(logsDir, { retentionDays: 7, now }); diff --git a/src/stats-daemon-control.test.ts b/src/stats-daemon-control.test.ts index acacc29..acf5f53 100644 --- a/src/stats-daemon-control.test.ts +++ b/src/stats-daemon-control.test.ts @@ -69,7 +69,9 @@ test('stats daemon control clears stale state, starts daemon, and waits for resp }, resolveUrl: (state) => `http://127.0.0.1:${state.port}`, spawnDaemon: async (options) => { - calls.push(`spawnDaemon:${options.scriptPath}:${options.responsePath}:${options.userDataPath}`); + calls.push( + `spawnDaemon:${options.scriptPath}:${options.responsePath}:${options.userDataPath}`, + ); return 999; }, waitForDaemonResponse: async (responsePath) => { diff --git a/src/stats-daemon-runner.ts b/src/stats-daemon-runner.ts index 2210b01..857d02b 100644 --- a/src/stats-daemon-runner.ts +++ b/src/stats-daemon-runner.ts @@ -13,7 +13,10 @@ import { writeBackgroundStatsServerState, } from './main/runtime/stats-daemon'; import { writeStatsCliCommandResponse } from './main/runtime/stats-cli-command'; -import { createInvokeStatsWordHelperHandler, type StatsWordHelperResponse } from './stats-word-helper-client'; +import { + createInvokeStatsWordHelperHandler, + type StatsWordHelperResponse, +} from './stats-word-helper-client'; const logger = createLogger('stats-daemon'); const STATS_WORD_HELPER_RESPONSE_TIMEOUT_MS = 20_000; diff --git a/src/stats-word-helper-client.ts b/src/stats-word-helper-client.ts index ab71425..4ba068b 100644 --- a/src/stats-word-helper-client.ts +++ b/src/stats-word-helper-client.ts @@ -33,7 +33,9 @@ export function createInvokeStatsWordHelperHandler(deps: { }); const startupResult = await Promise.race([ - deps.waitForResponse(responsePath).then((response) => ({ kind: 'response' as const, response })), + deps + .waitForResponse(responsePath) + .then((response) => ({ kind: 'response' as const, response })), helperExitPromise.then((status) => ({ kind: 'exit' as const, status })), ]); @@ -42,7 +44,9 @@ export function createInvokeStatsWordHelperHandler(deps: { response = startupResult.response; } else { if (startupResult.status !== 0) { - throw new Error(`Stats word helper exited before response (status ${startupResult.status}).`); + throw new Error( + `Stats word helper exited before response (status ${startupResult.status}).`, + ); } response = await deps.waitForResponse(responsePath); }