mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-27 06:12:05 -07:00
refactor: migrate shared type imports
This commit is contained in:
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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`),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -427,7 +427,10 @@ function withFindAppBinaryEnvSandbox(run: () => void): void {
|
||||
}
|
||||
}
|
||||
|
||||
function withAccessSyncStub(isExecutablePath: (filePath: string) => boolean, run: () => void): void {
|
||||
function withAccessSyncStub(
|
||||
isExecutablePath: (filePath: string) => boolean,
|
||||
run: () => void,
|
||||
): void {
|
||||
const originalAccessSync = fs.accessSync;
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@@ -468,10 +471,13 @@ test('findAppBinary resolves /opt/SubMiner/SubMiner.AppImage when ~/.local/bin c
|
||||
try {
|
||||
os.homedir = () => baseDir;
|
||||
withFindAppBinaryEnvSandbox(() => {
|
||||
withAccessSyncStub((filePath) => filePath === '/opt/SubMiner/SubMiner.AppImage', () => {
|
||||
withAccessSyncStub(
|
||||
(filePath) => filePath === '/opt/SubMiner/SubMiner.AppImage',
|
||||
() => {
|
||||
const result = findAppBinary('/some/other/path/subminer');
|
||||
assert.equal(result, '/opt/SubMiner/SubMiner.AppImage');
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
} finally {
|
||||
os.homedir = originalHomedir;
|
||||
@@ -492,11 +498,14 @@ test('findAppBinary finds subminer on PATH when AppImage candidates do not exist
|
||||
process.env.PATH = `${binDir}${path.delimiter}${originalPath ?? ''}`;
|
||||
|
||||
withFindAppBinaryEnvSandbox(() => {
|
||||
withAccessSyncStub((filePath) => filePath === wrapperPath, () => {
|
||||
withAccessSyncStub(
|
||||
(filePath) => filePath === wrapperPath,
|
||||
() => {
|
||||
// selfPath must differ from wrapperPath so the self-check does not exclude it
|
||||
const result = findAppBinary(path.join(baseDir, 'launcher', 'subminer'));
|
||||
assert.equal(result, wrapperPath);
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
} finally {
|
||||
os.homedir = originalHomedir;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -55,10 +55,7 @@ exit 1
|
||||
`,
|
||||
);
|
||||
|
||||
const result = spawnSync(
|
||||
'bash',
|
||||
['scripts/patch-modernz.sh', '--target', target],
|
||||
{
|
||||
const result = spawnSync('bash', ['scripts/patch-modernz.sh', '--target', target], {
|
||||
cwd: process.cwd(),
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
@@ -66,8 +63,7 @@ exit 1
|
||||
HOME: path.join(root, 'home'),
|
||||
PATH: `${binDir}:${process.env.PATH || ''}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
assert.equal(result.status, 1, result.stderr || result.stdout);
|
||||
assert.match(result.stderr, /failed to apply patch to/);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -64,7 +64,9 @@ export interface IpcServiceDeps {
|
||||
getCurrentSecondarySub: () => string;
|
||||
focusMainWindow: () => void;
|
||||
runSubsyncManual: (request: SubsyncManualRunRequest) => Promise<SubsyncResult>;
|
||||
onYoutubePickerResolve: (request: YoutubePickerResolveRequest) => Promise<YoutubePickerResolveResult>;
|
||||
onYoutubePickerResolve: (
|
||||
request: YoutubePickerResolveRequest,
|
||||
) => Promise<YoutubePickerResolveResult>;
|
||||
getAnkiConnectStatus: () => boolean;
|
||||
getRuntimeOptions: () => unknown;
|
||||
setRuntimeOption: (id: RuntimeOptionId, value: RuntimeOptionValue) => unknown;
|
||||
@@ -167,7 +169,9 @@ export interface IpcDepsRuntimeOptions {
|
||||
getMpvClient: () => MpvClientLike | null;
|
||||
focusMainWindow: () => void;
|
||||
runSubsyncManual: (request: SubsyncManualRunRequest) => Promise<SubsyncResult>;
|
||||
onYoutubePickerResolve: (request: YoutubePickerResolveRequest) => Promise<YoutubePickerResolveResult>;
|
||||
onYoutubePickerResolve: (
|
||||
request: YoutubePickerResolveRequest,
|
||||
) => Promise<YoutubePickerResolveResult>;
|
||||
getAnkiConnectStatus: () => boolean;
|
||||
getRuntimeOptions: () => unknown;
|
||||
setRuntimeOption: (id: RuntimeOptionId, value: RuntimeOptionValue) => unknown;
|
||||
@@ -291,13 +295,16 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
|
||||
deps.onOverlayModalOpened(parsedModal);
|
||||
});
|
||||
|
||||
ipc.handle(IPC_CHANNELS.request.youtubePickerResolve, async (_event: unknown, request: unknown) => {
|
||||
ipc.handle(
|
||||
IPC_CHANNELS.request.youtubePickerResolve,
|
||||
async (_event: unknown, request: unknown) => {
|
||||
const parsedRequest = parseYoutubePickerResolveRequest(request);
|
||||
if (!parsedRequest) {
|
||||
return { ok: false, message: 'Invalid YouTube picker resolve payload' };
|
||||
}
|
||||
return await deps.onYoutubePickerResolve(parsedRequest);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
ipc.on(IPC_CHANNELS.command.openYomitanSettings, () => {
|
||||
deps.openYomitanSettings();
|
||||
@@ -375,13 +382,16 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
|
||||
},
|
||||
);
|
||||
|
||||
ipc.handle(IPC_CHANNELS.command.saveControllerConfig, async (_event: unknown, update: unknown) => {
|
||||
ipc.handle(
|
||||
IPC_CHANNELS.command.saveControllerConfig,
|
||||
async (_event: unknown, update: unknown) => {
|
||||
const parsedUpdate = parseControllerConfigUpdate(update);
|
||||
if (!parsedUpdate) {
|
||||
throw new Error('Invalid controller config payload');
|
||||
}
|
||||
await deps.saveControllerConfig(parsedUpdate);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
ipc.handle(IPC_CHANNELS.request.getMecabStatus, () => {
|
||||
return deps.getMecabStatus();
|
||||
|
||||
@@ -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, []);
|
||||
});
|
||||
|
||||
|
||||
@@ -3428,7 +3428,9 @@ test('tokenizeSubtitle keeps standalone grammar-only tokens hoverable while clea
|
||||
test('tokenizeSubtitle keeps trailing quote-particle merged tokens hoverable while clearing only their annotation metadata', async () => {
|
||||
const result = await tokenizeSubtitle(
|
||||
'どうしてもって',
|
||||
makeDepsFromYomitanTokens([{ surface: 'どうしてもって', reading: 'どうしてもって', headword: 'どうしても' }], {
|
||||
makeDepsFromYomitanTokens(
|
||||
[{ surface: 'どうしてもって', reading: 'どうしてもって', headword: 'どうしても' }],
|
||||
{
|
||||
getFrequencyDictionaryEnabled: () => true,
|
||||
getFrequencyRank: (text) => (text === 'どうしても' ? 123 : null),
|
||||
getJlptLevel: (text) => (text === 'どうしても' ? 'N3' : null),
|
||||
@@ -3461,7 +3463,8 @@ test('tokenizeSubtitle keeps trailing quote-particle merged tokens hoverable whi
|
||||
},
|
||||
],
|
||||
getMinSentenceWordsForNPlusOne: () => 1,
|
||||
}),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
assert.equal(result.text, 'どうしてもって');
|
||||
@@ -3812,7 +3815,14 @@ test('tokenizeSubtitle clears all annotations for explanatory pondering endings'
|
||||
jlptLevel: token.jlptLevel,
|
||||
})),
|
||||
[
|
||||
{ surface: '俺', headword: '俺', isKnown: true, isNPlusOneTarget: false, frequencyRank: 19, jlptLevel: 'N5' },
|
||||
{
|
||||
surface: '俺',
|
||||
headword: '俺',
|
||||
isKnown: true,
|
||||
isNPlusOneTarget: false,
|
||||
frequencyRank: 19,
|
||||
jlptLevel: 'N5',
|
||||
},
|
||||
{
|
||||
surface: 'どうかしちゃった',
|
||||
headword: 'どうかしちゃう',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -46,7 +46,11 @@ const SUBTITLE_ANNOTATION_EXCLUDED_EXPLANATORY_ENDING_TRAILING_PARTICLES = [
|
||||
'かな',
|
||||
'かね',
|
||||
] as const;
|
||||
const SUBTITLE_ANNOTATION_EXCLUDED_EXPLANATORY_ENDING_THOUGHT_SUFFIXES = ['か', 'かな', 'かね'] as const;
|
||||
const SUBTITLE_ANNOTATION_EXCLUDED_EXPLANATORY_ENDING_THOUGHT_SUFFIXES = [
|
||||
'か',
|
||||
'かな',
|
||||
'かね',
|
||||
] as const;
|
||||
const SUBTITLE_ANNOTATION_EXCLUDED_EXPLANATORY_ENDINGS = new Set(
|
||||
SUBTITLE_ANNOTATION_EXCLUDED_EXPLANATORY_ENDING_PREFIXES.flatMap((prefix) =>
|
||||
SUBTITLE_ANNOTATION_EXCLUDED_EXPLANATORY_ENDING_CORES.flatMap((core) =>
|
||||
@@ -96,9 +100,7 @@ function isExcludedByTagSet(normalizedTag: string, exclusions: ReadonlySet<strin
|
||||
return parts.every((part) => exclusions.has(part));
|
||||
}
|
||||
|
||||
function resolvePos1Exclusions(
|
||||
options: SubtitleAnnotationFilterOptions = {},
|
||||
): ReadonlySet<string> {
|
||||
function resolvePos1Exclusions(options: SubtitleAnnotationFilterOptions = {}): ReadonlySet<string> {
|
||||
if (options.pos1Exclusions) {
|
||||
return options.pos1Exclusions;
|
||||
}
|
||||
@@ -106,9 +108,7 @@ function resolvePos1Exclusions(
|
||||
return resolveAnnotationPos1ExclusionSet(DEFAULT_ANNOTATION_POS1_EXCLUSION_CONFIG);
|
||||
}
|
||||
|
||||
function resolvePos2Exclusions(
|
||||
options: SubtitleAnnotationFilterOptions = {},
|
||||
): ReadonlySet<string> {
|
||||
function resolvePos2Exclusions(options: SubtitleAnnotationFilterOptions = {}): ReadonlySet<string> {
|
||||
if (options.pos2Exclusions) {
|
||||
return options.pos2Exclusions;
|
||||
}
|
||||
@@ -212,7 +212,11 @@ function isReduplicatedKanaSfxWithOptionalTrailingTo(text: string): boolean {
|
||||
function isExcludedTrailingParticleMergedToken(token: MergedToken): boolean {
|
||||
const normalizedSurface = normalizeKana(token.surface);
|
||||
const normalizedHeadword = normalizeKana(token.headword);
|
||||
if (!normalizedSurface || !normalizedHeadword || !normalizedSurface.startsWith(normalizedHeadword)) {
|
||||
if (
|
||||
!normalizedSurface ||
|
||||
!normalizedHeadword ||
|
||||
!normalizedSurface.startsWith(normalizedHeadword)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -236,7 +240,10 @@ function isExcludedTrailingParticleMergedToken(token: MergedToken): boolean {
|
||||
|
||||
function isAuxiliaryStemGrammarTailToken(token: MergedToken): boolean {
|
||||
const pos1Parts = splitNormalizedTagParts(normalizePosTag(token.pos1));
|
||||
if (pos1Parts.length === 0 || !pos1Parts.every((part) => AUXILIARY_STEM_GRAMMAR_TAIL_POS1.has(part))) {
|
||||
if (
|
||||
pos1Parts.length === 0 ||
|
||||
!pos1Parts.every((part) => AUXILIARY_STEM_GRAMMAR_TAIL_POS1.has(part))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -75,15 +75,11 @@ test('probeYoutubeVideoMetadata returns null on malformed yt-dlp JSON', async ()
|
||||
});
|
||||
});
|
||||
|
||||
test(
|
||||
'probeYoutubeVideoMetadata times out when yt-dlp hangs',
|
||||
{ timeout: 20_000 },
|
||||
async () => {
|
||||
test('probeYoutubeVideoMetadata times out when yt-dlp hangs', { timeout: 20_000 }, async () => {
|
||||
await withHangingFakeYtDlp(async () => {
|
||||
await assert.rejects(
|
||||
probeYoutubeVideoMetadata('https://www.youtube.com/watch?v=abc123'),
|
||||
/timed out after 15000ms/,
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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(/<br\s*\/?>/gi, '\n')
|
||||
.replace(/<[^>]+>/g, '');
|
||||
const inner = (match[2] ?? '').replace(/<br\s*\/?>/gi, '\n').replace(/<[^>]+>/g, '');
|
||||
const text = decodeHtmlEntities(inner).trim();
|
||||
if (!text) {
|
||||
continue;
|
||||
@@ -110,7 +106,9 @@ export function convertYoutubeTimedTextToVtt(xml: string): string {
|
||||
if (!text) {
|
||||
continue;
|
||||
}
|
||||
blocks.push(`${formatVttTimestamp(row.startMs)} --> ${formatVttTimestamp(clampedEnd)}\n${text}`);
|
||||
blocks.push(
|
||||
`${formatVttTimestamp(row.startMs)} --> ${formatVttTimestamp(clampedEnd)}\n${text}`,
|
||||
);
|
||||
}
|
||||
|
||||
return `WEBVTT\n\n${blocks.join('\n\n')}\n`;
|
||||
|
||||
@@ -115,7 +115,9 @@ async function withFakeYtDlp<T>(
|
||||
}
|
||||
|
||||
async function withFakeYtDlpExpectations<T>(
|
||||
expectations: Partial<Record<'YTDLP_EXPECT_AUTO_SUBS' | 'YTDLP_EXPECT_MANUAL_SUBS' | 'YTDLP_EXPECT_SUB_LANG', string>>,
|
||||
expectations: Partial<
|
||||
Record<'YTDLP_EXPECT_AUTO_SUBS' | 'YTDLP_EXPECT_MANUAL_SUBS' | 'YTDLP_EXPECT_SUB_LANG', string>
|
||||
>,
|
||||
fn: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
const previous = {
|
||||
@@ -144,11 +146,7 @@ async function withStubFetch<T>(
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = (async (input: string | URL | Request) => {
|
||||
const url =
|
||||
typeof input === 'string'
|
||||
? input
|
||||
: input instanceof URL
|
||||
? input.toString()
|
||||
: input.url;
|
||||
typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url;
|
||||
return await handler(url);
|
||||
}) as typeof fetch;
|
||||
try {
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
@@ -127,7 +127,10 @@ export async function probeYoutubeTracks(targetUrl: string): Promise<YoutubeTrac
|
||||
}${snippet ? `; stdout=${snippet}` : ''}`,
|
||||
);
|
||||
}
|
||||
const tracks = [...toTracks(info.subtitles, 'manual'), ...toTracks(info.automatic_captions, 'auto')];
|
||||
const tracks = [
|
||||
...toTracks(info.subtitles, 'manual'),
|
||||
...toTracks(info.automatic_captions, 'auto'),
|
||||
];
|
||||
return {
|
||||
videoId: info.id || '',
|
||||
title: info.title || '',
|
||||
|
||||
@@ -10,9 +10,10 @@ function pickTrack(
|
||||
return matching[0] ?? null;
|
||||
}
|
||||
|
||||
export function chooseDefaultYoutubeTrackIds(
|
||||
tracks: YoutubeTrackOption[],
|
||||
): { primaryTrackId: string | null; secondaryTrackId: string | null } {
|
||||
export function chooseDefaultYoutubeTrackIds(tracks: YoutubeTrackOption[]): {
|
||||
primaryTrackId: string | null;
|
||||
secondaryTrackId: string | null;
|
||||
} {
|
||||
const primary =
|
||||
pickTrack(
|
||||
tracks.filter((track) => track.kind === 'manual'),
|
||||
@@ -52,7 +53,11 @@ export function normalizeYoutubeTrackSelection(input: {
|
||||
primaryTrackId: string | null;
|
||||
secondaryTrackId: string | null;
|
||||
} {
|
||||
if (input.primaryTrackId && input.secondaryTrackId && input.primaryTrackId === input.secondaryTrackId) {
|
||||
if (
|
||||
input.primaryTrackId &&
|
||||
input.secondaryTrackId &&
|
||||
input.primaryTrackId === input.secondaryTrackId
|
||||
) {
|
||||
return {
|
||||
primaryTrackId: input.primaryTrackId,
|
||||
secondaryTrackId: null,
|
||||
@@ -60,4 +65,3 @@ export function normalizeYoutubeTrackSelection(input: {
|
||||
}
|
||||
return input;
|
||||
}
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
|
||||
26
src/main.ts
26
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<never>((_, reject) => {
|
||||
setTimeout(() => reject(new Error('Timed out waiting for AnkiConnect integration')), 2500);
|
||||
setTimeout(
|
||||
() => reject(new Error('Timed out waiting for AnkiConnect integration')),
|
||||
2500,
|
||||
);
|
||||
}),
|
||||
]);
|
||||
} catch (error) {
|
||||
@@ -3027,7 +3032,8 @@ const ensureStatsServerStarted = (): string => {
|
||||
knownWordCachePath: path.join(USER_DATA_PATH, 'known-words-cache.json'),
|
||||
mpvSocketPath: appState.mpvSocketPath,
|
||||
ankiConnectConfig: getResolvedConfig().ankiConnect,
|
||||
resolveAnkiNoteId: (noteId: number) => appState.ankiIntegration?.resolveCurrentNoteId(noteId) ?? noteId,
|
||||
resolveAnkiNoteId: (noteId: number) =>
|
||||
appState.ankiIntegration?.resolveCurrentNoteId(noteId) ?? noteId,
|
||||
addYomitanNote: async (word: string) => {
|
||||
const ankiUrl = getResolvedConfig().ankiConnect.url || 'http://127.0.0.1:8765';
|
||||
await syncYomitanDefaultAnkiServerCore(ankiUrl, yomitanDeps, yomitanLogger, {
|
||||
@@ -4589,7 +4595,8 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
||||
openYomitanSettings: () => openYomitanSettings(),
|
||||
quitApp: () => requestAppQuit(),
|
||||
toggleVisibleOverlay: () => toggleVisibleOverlay(),
|
||||
tokenizeCurrentSubtitle: async () => withCurrentSubtitleTiming(await tokenizeSubtitle(appState.currentSubText)),
|
||||
tokenizeCurrentSubtitle: async () =>
|
||||
withCurrentSubtitleTiming(await tokenizeSubtitle(appState.currentSubText)),
|
||||
getCurrentSubtitleRaw: () => appState.currentSubText,
|
||||
getCurrentSubtitleAss: () => appState.currentSubAssText,
|
||||
getSubtitleSidebarSnapshot: async () => {
|
||||
@@ -4611,13 +4618,8 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
||||
}
|
||||
|
||||
try {
|
||||
const [
|
||||
currentExternalFilenameRaw,
|
||||
currentTrackRaw,
|
||||
trackListRaw,
|
||||
sidRaw,
|
||||
videoPathRaw,
|
||||
] = await Promise.all([
|
||||
const [currentExternalFilenameRaw, currentTrackRaw, trackListRaw, sidRaw, videoPathRaw] =
|
||||
await Promise.all([
|
||||
client.requestProperty('current-tracks/sub/external-filename').catch(() => null),
|
||||
client.requestProperty('current-tracks/sub').catch(() => null),
|
||||
client.requestProperty('track-list'),
|
||||
|
||||
@@ -287,10 +287,14 @@ test('sendToActiveOverlayWindow can prefer modal window even when main overlay i
|
||||
setModalWindowBounds: () => {},
|
||||
});
|
||||
|
||||
const sent = runtime.sendToActiveOverlayWindow('youtube:picker-open', { sessionId: 'yt-1' }, {
|
||||
const sent = runtime.sendToActiveOverlayWindow(
|
||||
'youtube:picker-open',
|
||||
{ sessionId: 'yt-1' },
|
||||
{
|
||||
restoreOnModalClose: 'youtube-track-picker',
|
||||
preferModalWindow: true,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(sent, true);
|
||||
assert.deepEqual(mainWindow.sent, []);
|
||||
@@ -309,10 +313,14 @@ test('modal window path makes visible main overlay click-through until modal clo
|
||||
setModalWindowBounds: () => {},
|
||||
});
|
||||
|
||||
const sent = runtime.sendToActiveOverlayWindow('youtube:picker-open', { sessionId: 'yt-1' }, {
|
||||
const sent = runtime.sendToActiveOverlayWindow(
|
||||
'youtube:picker-open',
|
||||
{ sessionId: 'yt-1' },
|
||||
{
|
||||
restoreOnModalClose: 'youtube-track-picker',
|
||||
preferModalWindow: true,
|
||||
});
|
||||
},
|
||||
);
|
||||
runtime.notifyOverlayModalOpened('youtube-track-picker');
|
||||
|
||||
assert.equal(sent, true);
|
||||
@@ -336,10 +344,14 @@ test('modal window path hides visible main overlay until modal closes', () => {
|
||||
setModalWindowBounds: () => {},
|
||||
});
|
||||
|
||||
runtime.sendToActiveOverlayWindow('youtube:picker-open', { sessionId: 'yt-1' }, {
|
||||
runtime.sendToActiveOverlayWindow(
|
||||
'youtube:picker-open',
|
||||
{ sessionId: 'yt-1' },
|
||||
{
|
||||
restoreOnModalClose: 'youtube-track-picker',
|
||||
preferModalWindow: true,
|
||||
});
|
||||
},
|
||||
);
|
||||
runtime.notifyOverlayModalOpened('youtube-track-picker');
|
||||
|
||||
assert.equal(mainWindow.getHideCount(), 1);
|
||||
@@ -516,9 +528,13 @@ test('waitForModalOpen resolves true after modal acknowledgement', async () => {
|
||||
setModalWindowBounds: () => {},
|
||||
});
|
||||
|
||||
runtime.sendToActiveOverlayWindow('youtube:picker-open', { sessionId: 'yt-1' }, {
|
||||
runtime.sendToActiveOverlayWindow(
|
||||
'youtube:picker-open',
|
||||
{ sessionId: 'yt-1' },
|
||||
{
|
||||
restoreOnModalClose: 'youtube-track-picker',
|
||||
});
|
||||
},
|
||||
);
|
||||
const pending = runtime.waitForModalOpen('youtube-track-picker', 1000);
|
||||
runtime.notifyOverlayModalOpened('youtube-track-picker');
|
||||
|
||||
|
||||
@@ -357,10 +357,7 @@ export function createOverlayModalRuntimeService(
|
||||
showModalWindow(targetWindow);
|
||||
};
|
||||
|
||||
const waitForModalOpen = async (
|
||||
modal: OverlayHostedModal,
|
||||
timeoutMs: number,
|
||||
): Promise<boolean> =>
|
||||
const waitForModalOpen = async (modal: OverlayHostedModal, timeoutMs: number): Promise<boolean> =>
|
||||
await new Promise<boolean>((resolve) => {
|
||||
const waiters = modalOpenWaiters.get(modal) ?? [];
|
||||
const finish = (opened: boolean): void => {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -4,14 +4,12 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
||||
appState: {
|
||||
initialArgs?: { jellyfinPlay?: unknown; youtubePlay?: unknown } | null;
|
||||
overlayRuntimeInitialized: boolean;
|
||||
mpvClient:
|
||||
| {
|
||||
mpvClient: {
|
||||
connected?: boolean;
|
||||
currentSecondarySubText?: string;
|
||||
currentTimePos?: number;
|
||||
requestProperty?: (name: string) => Promise<unknown>;
|
||||
}
|
||||
| null;
|
||||
} | null;
|
||||
immersionTracker: {
|
||||
recordSubtitleLine?: (
|
||||
text: string,
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -183,7 +183,13 @@ test('prepare youtube playback accepts a non-youtube resolved path once playable
|
||||
'/videos/episode01.mkv',
|
||||
'https://rr16---sn.example.googlevideo.com/videoplayback?id=abc',
|
||||
];
|
||||
const observedTrackLists = [[], [{ type: 'video', id: 1 }, { type: 'audio', id: 2 }]];
|
||||
const observedTrackLists = [
|
||||
[],
|
||||
[
|
||||
{ type: 'video', id: 1 },
|
||||
{ type: 'audio', id: 2 },
|
||||
],
|
||||
];
|
||||
let requestCount = 0;
|
||||
const prepare = createPrepareYoutubePlaybackInMpvHandler({
|
||||
requestPath: async () => {
|
||||
@@ -256,11 +262,14 @@ test('prepare youtube playback does not accept a different youtube video after p
|
||||
|
||||
test('prepare youtube playback accepts a fresh-start path change when the direct target matches exactly', async () => {
|
||||
const commands: Array<Array<string>> = [];
|
||||
const observedPaths = [
|
||||
'',
|
||||
'https://rr16---sn.example.googlevideo.com/videoplayback?id=abc',
|
||||
const observedPaths = ['', 'https://rr16---sn.example.googlevideo.com/videoplayback?id=abc'];
|
||||
const observedTrackLists = [
|
||||
[],
|
||||
[
|
||||
{ type: 'video', id: 1 },
|
||||
{ type: 'audio', id: 2 },
|
||||
],
|
||||
];
|
||||
const observedTrackLists = [[], [{ type: 'video', id: 1 }, { type: 'audio', id: 2 }]];
|
||||
let requestCount = 0;
|
||||
const prepare = createPrepareYoutubePlaybackInMpvHandler({
|
||||
requestPath: async () => {
|
||||
|
||||
@@ -74,7 +74,9 @@ function hasPlayableMediaTracks(trackListRaw: unknown): boolean {
|
||||
if (!Array.isArray(trackListRaw)) return false;
|
||||
return trackListRaw.some((track) => {
|
||||
if (!track || typeof track !== 'object') return false;
|
||||
const type = String((track as Record<string, unknown>).type || '').trim().toLowerCase();
|
||||
const type = String((track as Record<string, unknown>).type || '')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
return type === 'video' || type === 'audio';
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { isYoutubeMediaPath } from './youtube-playback';
|
||||
import { normalizeYoutubeLangCode } from '../../core/services/youtube/labels';
|
||||
|
||||
export type YoutubePrimarySubtitleNotificationTimer = ReturnType<typeof setTimeout> | { id: number };
|
||||
export type YoutubePrimarySubtitleNotificationTimer =
|
||||
| ReturnType<typeof setTimeout>
|
||||
| { id: number };
|
||||
|
||||
type SubtitleTrackEntry = {
|
||||
id: number | null;
|
||||
@@ -82,7 +84,9 @@ function hasSelectedPrimarySubtitle(
|
||||
|
||||
const tracks = trackList.map(normalizeTrack);
|
||||
const activeTrack =
|
||||
(sid === null ? null : tracks.find((track) => track?.type === 'sub' && track.id === sid) ?? null) ??
|
||||
(sid === null
|
||||
? null
|
||||
: (tracks.find((track) => track?.type === 'sub' && track.id === sid) ?? null)) ??
|
||||
tracks.find((track) => track?.type === 'sub' && track.selected) ??
|
||||
null;
|
||||
if (!activeTrack) {
|
||||
@@ -130,7 +134,9 @@ export function createYoutubePrimarySubtitleNotificationRuntime(deps: {
|
||||
return;
|
||||
}
|
||||
lastReportedMediaPath = mediaPath;
|
||||
deps.notifyFailure('Primary subtitle failed to download or load. Try again from the subtitle modal.');
|
||||
deps.notifyFailure(
|
||||
'Primary subtitle failed to download or load. Try again from the subtitle modal.',
|
||||
);
|
||||
};
|
||||
|
||||
const schedulePendingCheck = (): void => {
|
||||
@@ -150,7 +156,8 @@ export function createYoutubePrimarySubtitleNotificationRuntime(deps: {
|
||||
|
||||
return {
|
||||
handleMediaPathChange: (path: string | null): void => {
|
||||
const normalizedPath = typeof path === 'string' && path.trim().length > 0 ? path.trim() : null;
|
||||
const normalizedPath =
|
||||
typeof path === 'string' && path.trim().length > 0 ? path.trim() : null;
|
||||
if (currentMediaPath !== normalizedPath) {
|
||||
lastReportedMediaPath = null;
|
||||
}
|
||||
|
||||
@@ -47,7 +47,10 @@ type ControllerBindingCaptureResult =
|
||||
dpadDirection: ControllerDpadFallback;
|
||||
};
|
||||
|
||||
function isActiveButton(button: ControllerButtonState | undefined, triggerDeadzone: number): boolean {
|
||||
function isActiveButton(
|
||||
button: ControllerButtonState | undefined,
|
||||
triggerDeadzone: number,
|
||||
): boolean {
|
||||
if (!button) return false;
|
||||
return Boolean(button.pressed) || button.value >= triggerDeadzone;
|
||||
}
|
||||
@@ -90,7 +93,10 @@ export function createControllerBindingCapture(options: {
|
||||
});
|
||||
}
|
||||
|
||||
function arm(nextTarget: ControllerBindingCaptureTarget, snapshot: ControllerBindingCaptureSnapshot): void {
|
||||
function arm(
|
||||
nextTarget: ControllerBindingCaptureTarget,
|
||||
snapshot: ControllerBindingCaptureSnapshot,
|
||||
): void {
|
||||
target = nextTarget;
|
||||
resetBlockedState(snapshot);
|
||||
}
|
||||
@@ -139,7 +145,10 @@ export function createControllerBindingCapture(options: {
|
||||
}
|
||||
|
||||
// After dpad early-return, only 'discrete' | 'axis' remain
|
||||
const narrowedTarget: Extract<ControllerBindingCaptureTarget, { bindingType: 'discrete' | 'axis' }> = target;
|
||||
const narrowedTarget: Extract<
|
||||
ControllerBindingCaptureTarget,
|
||||
{ bindingType: 'discrete' | 'axis' }
|
||||
> = target;
|
||||
|
||||
for (let index = 0; index < snapshot.buttons.length; index += 1) {
|
||||
if (!isActiveButton(snapshot.buttons[index], options.triggerDeadzone)) continue;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -3,10 +3,7 @@ import test from 'node:test';
|
||||
|
||||
import type { SubtitleSidebarConfig } from '../../types';
|
||||
import { createMouseHandlers } from './mouse.js';
|
||||
import {
|
||||
YOMITAN_POPUP_HIDDEN_EVENT,
|
||||
YOMITAN_POPUP_SHOWN_EVENT,
|
||||
} from '../yomitan-popup.js';
|
||||
import { YOMITAN_POPUP_HIDDEN_EVENT, YOMITAN_POPUP_SHOWN_EVENT } from '../yomitan-popup.js';
|
||||
|
||||
function createClassList() {
|
||||
const classes = new Set<string>();
|
||||
@@ -118,9 +115,15 @@ test('secondary hover pauses on enter, reveals secondary subtitle, and resumes o
|
||||
});
|
||||
|
||||
await handlers.handleSecondaryMouseEnter();
|
||||
assert.equal(ctx.dom.secondarySubContainer.classList.contains('secondary-sub-hover-active'), true);
|
||||
assert.equal(
|
||||
ctx.dom.secondarySubContainer.classList.contains('secondary-sub-hover-active'),
|
||||
true,
|
||||
);
|
||||
await handlers.handleSecondaryMouseLeave();
|
||||
assert.equal(ctx.dom.secondarySubContainer.classList.contains('secondary-sub-hover-active'), false);
|
||||
assert.equal(
|
||||
ctx.dom.secondarySubContainer.classList.contains('secondary-sub-hover-active'),
|
||||
false,
|
||||
);
|
||||
|
||||
assert.deepEqual(mpvCommands, [
|
||||
['set_property', 'pause', 'yes'],
|
||||
@@ -186,7 +189,10 @@ test('secondary leave toward primary subtitle container clears the secondary hov
|
||||
} as unknown as MouseEvent);
|
||||
|
||||
assert.equal(ctx.state.isOverSubtitle, false);
|
||||
assert.equal(ctx.dom.secondarySubContainer.classList.contains('secondary-sub-hover-active'), false);
|
||||
assert.equal(
|
||||
ctx.dom.secondarySubContainer.classList.contains('secondary-sub-hover-active'),
|
||||
false,
|
||||
);
|
||||
assert.deepEqual(mpvCommands, [['set_property', 'pause', 'yes']]);
|
||||
});
|
||||
|
||||
@@ -237,7 +243,10 @@ test('primary hover pauses on enter without revealing secondary subtitle', async
|
||||
});
|
||||
|
||||
await handlers.handlePrimaryMouseEnter();
|
||||
assert.equal(ctx.dom.secondarySubContainer.classList.contains('secondary-sub-hover-active'), false);
|
||||
assert.equal(
|
||||
ctx.dom.secondarySubContainer.classList.contains('secondary-sub-hover-active'),
|
||||
false,
|
||||
);
|
||||
await handlers.handlePrimaryMouseLeave();
|
||||
|
||||
assert.deepEqual(mpvCommands, [
|
||||
@@ -394,7 +403,10 @@ test('restorePointerInteractionState reapplies the secondary hover class from po
|
||||
mousemove?.({ clientX: 10, clientY: 20 } as MouseEvent);
|
||||
|
||||
assert.equal(ctx.state.isOverSubtitle, true);
|
||||
assert.equal(ctx.dom.secondarySubContainer.classList.contains('secondary-sub-hover-active'), true);
|
||||
assert.equal(
|
||||
ctx.dom.secondarySubContainer.classList.contains('secondary-sub-hover-active'),
|
||||
true,
|
||||
);
|
||||
} finally {
|
||||
Object.defineProperty(globalThis, 'document', { configurable: true, value: originalDocument });
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: originalWindow });
|
||||
|
||||
@@ -228,10 +228,7 @@ export function createMouseHandlers(
|
||||
syncOverlayMouseIgnoreState(ctx);
|
||||
}
|
||||
|
||||
async function handleMouseEnter(
|
||||
_event?: MouseEvent,
|
||||
showSecondaryHover = false,
|
||||
): Promise<void> {
|
||||
async function handleMouseEnter(_event?: MouseEvent, showSecondaryHover = false): Promise<void> {
|
||||
ctx.state.isOverSubtitle = true;
|
||||
if (showSecondaryHover) {
|
||||
ctx.dom.secondarySubContainer.classList.add('secondary-sub-hover-active');
|
||||
@@ -267,10 +264,7 @@ export function createMouseHandlers(
|
||||
pausedBySubtitleHover = true;
|
||||
}
|
||||
|
||||
async function handleMouseLeave(
|
||||
_event?: MouseEvent,
|
||||
hideSecondaryHover = false,
|
||||
): Promise<void> {
|
||||
async function handleMouseLeave(_event?: MouseEvent, hideSecondaryHover = false): Promise<void> {
|
||||
const relatedTarget = _event?.relatedTarget ?? null;
|
||||
const otherContainer = hideSecondaryHover
|
||||
? ctx.dom.subtitleContainer
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -31,8 +31,9 @@ export function createControllerSelectModal(
|
||||
let lastRenderedActiveGamepadId: string | null = null;
|
||||
let lastRenderedPreferredId = '';
|
||||
type ControllerBindingKey = keyof NonNullable<typeof ctx.state.controllerConfig>['bindings'];
|
||||
type ControllerBindingValue =
|
||||
NonNullable<NonNullable<typeof ctx.state.controllerConfig>['bindings']>[ControllerBindingKey];
|
||||
type ControllerBindingValue = NonNullable<
|
||||
NonNullable<typeof ctx.state.controllerConfig>['bindings']
|
||||
>[ControllerBindingKey];
|
||||
let learningActionId: ControllerBindingKey | null = null;
|
||||
let dpadLearningActionId: ControllerBindingKey | null = null;
|
||||
let bindingCapture: ReturnType<typeof createControllerBindingCapture> | null = null;
|
||||
@@ -198,7 +199,9 @@ export function createControllerSelectModal(
|
||||
lastRenderedPreferredId = preferredId;
|
||||
}
|
||||
|
||||
async function saveControllerConfig(update: Parameters<typeof window.electronAPI.saveControllerConfig>[0]) {
|
||||
async function saveControllerConfig(
|
||||
update: Parameters<typeof window.electronAPI.saveControllerConfig>[0],
|
||||
) {
|
||||
await window.electronAPI.saveControllerConfig(update);
|
||||
if (!ctx.state.controllerConfig) return;
|
||||
if (update.preferredGamepadId !== undefined) {
|
||||
@@ -304,7 +307,10 @@ export function createControllerSelectModal(
|
||||
if (result.bindingType === 'dpad') {
|
||||
void saveDpadFallback(result.actionId as ControllerBindingKey, result.dpadDirection);
|
||||
} else {
|
||||
void saveBinding(result.actionId as ControllerBindingKey, result.binding as ControllerBindingValue);
|
||||
void saveBinding(
|
||||
result.actionId as ControllerBindingKey,
|
||||
result.binding as ControllerBindingValue,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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']);
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
import type {
|
||||
SubtitleCue,
|
||||
SubtitleData,
|
||||
SubtitleSidebarSnapshot,
|
||||
} from '../../types';
|
||||
import type { SubtitleCue, SubtitleData, SubtitleSidebarSnapshot } from '../../types';
|
||||
import type { ModalStateReader, RendererContext } from '../context';
|
||||
import { syncOverlayMouseIgnoreState } from '../overlay-mouse-ignore.js';
|
||||
|
||||
@@ -76,8 +72,7 @@ export function findActiveSubtitleCueIndex(
|
||||
if (typeof currentTimeSec === 'number' && Number.isFinite(currentTimeSec)) {
|
||||
const activeOrUpcomingCue = cues.findIndex(
|
||||
(cue) =>
|
||||
cue.endTime > currentTimeSec &&
|
||||
cue.startTime <= currentTimeSec + ACTIVE_CUE_LOOKAHEAD_SEC,
|
||||
cue.endTime > currentTimeSec && cue.startTime <= currentTimeSec + ACTIVE_CUE_LOOKAHEAD_SEC,
|
||||
);
|
||||
if (activeOrUpcomingCue >= 0) {
|
||||
return activeOrUpcomingCue;
|
||||
@@ -109,8 +104,7 @@ export function findActiveSubtitleCueIndex(
|
||||
return -1;
|
||||
}
|
||||
|
||||
const hasTiming =
|
||||
typeof current.startTime === 'number' && Number.isFinite(current.startTime);
|
||||
const hasTiming = typeof current.startTime === 'number' && Number.isFinite(current.startTime);
|
||||
|
||||
if (preferredCueIndex >= 0) {
|
||||
if (!hasTiming && currentTimeSec === null) {
|
||||
@@ -213,16 +207,8 @@ export function createSubtitleSidebarModal(
|
||||
'video-margin-ratio-right',
|
||||
Number(ratio.toFixed(4)),
|
||||
]);
|
||||
window.electronAPI.sendMpvCommand([
|
||||
'set_property',
|
||||
'osd-align-x',
|
||||
'left',
|
||||
]);
|
||||
window.electronAPI.sendMpvCommand([
|
||||
'set_property',
|
||||
'osd-align-y',
|
||||
'top',
|
||||
]);
|
||||
window.electronAPI.sendMpvCommand(['set_property', 'osd-align-x', 'left']);
|
||||
window.electronAPI.sendMpvCommand(['set_property', 'osd-align-y', 'top']);
|
||||
window.electronAPI.sendMpvCommand([
|
||||
'set_property',
|
||||
'user-data/osc/margins',
|
||||
@@ -302,13 +288,14 @@ export function createSubtitleSidebarModal(
|
||||
}
|
||||
|
||||
const list = ctx.dom.subtitleSidebarList;
|
||||
const active = list.children[ctx.state.subtitleSidebarActiveCueIndex] as HTMLElement | undefined;
|
||||
const active = list.children[ctx.state.subtitleSidebarActiveCueIndex] as
|
||||
| HTMLElement
|
||||
| undefined;
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetScrollTop =
|
||||
active.offsetTop - (list.clientHeight - active.clientHeight) / 2;
|
||||
const targetScrollTop = active.offsetTop - (list.clientHeight - active.clientHeight) / 2;
|
||||
const nextScrollTop = Math.max(0, targetScrollTop);
|
||||
if (previousActiveCueIndex < 0) {
|
||||
list.scrollTop = nextScrollTop;
|
||||
@@ -363,9 +350,9 @@ export function createSubtitleSidebarModal(
|
||||
}
|
||||
|
||||
if (ctx.state.subtitleSidebarActiveCueIndex >= 0) {
|
||||
const current = ctx.dom.subtitleSidebarList.children[ctx.state.subtitleSidebarActiveCueIndex] as
|
||||
| HTMLElement
|
||||
| undefined;
|
||||
const current = ctx.dom.subtitleSidebarList.children[
|
||||
ctx.state.subtitleSidebarActiveCueIndex
|
||||
] as HTMLElement | undefined;
|
||||
current?.classList.add('active');
|
||||
}
|
||||
}
|
||||
@@ -476,7 +463,11 @@ export function createSubtitleSidebarModal(
|
||||
|
||||
async function autoOpenSubtitleSidebarOnStartup(): Promise<void> {
|
||||
const snapshot = await refreshSnapshot();
|
||||
if (!snapshot.config.enabled || !snapshot.config.autoOpen || ctx.state.subtitleSidebarModalOpen) {
|
||||
if (
|
||||
!snapshot.config.enabled ||
|
||||
!snapshot.config.autoOpen ||
|
||||
ctx.state.subtitleSidebarModalOpen
|
||||
) {
|
||||
return;
|
||||
}
|
||||
await openSubtitleSidebarModal();
|
||||
@@ -512,10 +503,7 @@ export function createSubtitleSidebarModal(
|
||||
return;
|
||||
}
|
||||
|
||||
updateActiveCue(
|
||||
{ text: data.text, startTime: data.startTime },
|
||||
data.startTime ?? null,
|
||||
);
|
||||
updateActiveCue({ text: data.text, startTime: data.startTime }, data.startTime ?? null);
|
||||
}
|
||||
|
||||
function wireDomEvents(): void {
|
||||
|
||||
@@ -28,13 +28,13 @@ export function createYoutubeTrackPickerModal(
|
||||
function setStatus(message: string, isError = false): void {
|
||||
ctx.state.youtubePickerStatus = message;
|
||||
ctx.dom.youtubePickerStatus.textContent = message;
|
||||
ctx.dom.youtubePickerStatus.style.color = isError
|
||||
? '#ed8796'
|
||||
: '#a5adcb';
|
||||
ctx.dom.youtubePickerStatus.style.color = isError ? '#ed8796' : '#a5adcb';
|
||||
}
|
||||
|
||||
function getTrackLabel(trackId: string): string {
|
||||
return ctx.state.youtubePickerPayload?.tracks.find((track) => track.id === trackId)?.label ?? '';
|
||||
return (
|
||||
ctx.state.youtubePickerPayload?.tracks.find((track) => track.id === trackId)?.label ?? ''
|
||||
);
|
||||
}
|
||||
|
||||
function renderTrackList(): void {
|
||||
@@ -82,10 +82,7 @@ export function createYoutubeTrackPickerModal(
|
||||
if (track.id === primaryTrackId) continue;
|
||||
ctx.dom.youtubePickerSecondarySelect.appendChild(createOption(track.id, track.label));
|
||||
}
|
||||
if (
|
||||
primaryTrackId &&
|
||||
ctx.dom.youtubePickerSecondarySelect.value === primaryTrackId
|
||||
) {
|
||||
if (primaryTrackId && ctx.dom.youtubePickerSecondarySelect.value === primaryTrackId) {
|
||||
ctx.dom.youtubePickerSecondarySelect.value = '';
|
||||
}
|
||||
}
|
||||
@@ -126,7 +123,9 @@ export function createYoutubeTrackPickerModal(
|
||||
setStatus('Select the subtitle tracks to download.');
|
||||
}
|
||||
|
||||
async function resolveSelection(action: 'use-selected' | 'continue-without-subtitles'): Promise<void> {
|
||||
async function resolveSelection(
|
||||
action: 'use-selected' | 'continue-without-subtitles',
|
||||
): Promise<void> {
|
||||
if (resolveSelectionInFlight) {
|
||||
return;
|
||||
}
|
||||
@@ -238,7 +237,9 @@ export function createYoutubeTrackPickerModal(
|
||||
return true;
|
||||
}
|
||||
void resolveSelection(
|
||||
payloadHasTracks(ctx.state.youtubePickerPayload) ? 'use-selected' : 'continue-without-subtitles',
|
||||
payloadHasTracks(ctx.state.youtubePickerPayload)
|
||||
? 'use-selected'
|
||||
: 'continue-without-subtitles',
|
||||
);
|
||||
return true;
|
||||
}
|
||||
@@ -269,7 +270,9 @@ export function createYoutubeTrackPickerModal(
|
||||
|
||||
ctx.dom.youtubePickerContinueButton.addEventListener('click', () => {
|
||||
void resolveSelection(
|
||||
payloadHasTracks(ctx.state.youtubePickerPayload) ? 'use-selected' : 'continue-without-subtitles',
|
||||
payloadHasTracks(ctx.state.youtubePickerPayload)
|
||||
? 'use-selected'
|
||||
: 'continue-without-subtitles',
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -552,8 +552,14 @@ async function init(): Promise<void> {
|
||||
|
||||
ctx.dom.subtitleContainer.addEventListener('mouseenter', mouseHandlers.handlePrimaryMouseEnter);
|
||||
ctx.dom.subtitleContainer.addEventListener('mouseleave', mouseHandlers.handlePrimaryMouseLeave);
|
||||
ctx.dom.secondarySubContainer.addEventListener('mouseenter', mouseHandlers.handleSecondaryMouseEnter);
|
||||
ctx.dom.secondarySubContainer.addEventListener('mouseleave', mouseHandlers.handleSecondaryMouseLeave);
|
||||
ctx.dom.secondarySubContainer.addEventListener(
|
||||
'mouseenter',
|
||||
mouseHandlers.handleSecondaryMouseEnter,
|
||||
);
|
||||
ctx.dom.secondarySubContainer.addEventListener(
|
||||
'mouseleave',
|
||||
mouseHandlers.handleSecondaryMouseLeave,
|
||||
);
|
||||
|
||||
mouseHandlers.setupResizeHandler();
|
||||
mouseHandlers.setupPointerTracking();
|
||||
|
||||
@@ -296,7 +296,7 @@ body {
|
||||
.youtube-picker-content {
|
||||
width: min(820px, 92%);
|
||||
background:
|
||||
radial-gradient(circle at top right, rgba(198, 160, 246, 0.10), transparent 34%),
|
||||
radial-gradient(circle at top right, rgba(198, 160, 246, 0.1), transparent 34%),
|
||||
linear-gradient(180deg, rgba(36, 39, 58, 0.98), rgba(30, 32, 48, 0.98));
|
||||
border-color: rgba(138, 173, 244, 0.25);
|
||||
}
|
||||
@@ -1342,8 +1342,14 @@ iframe[id^='yomitan-popup'] {
|
||||
}
|
||||
|
||||
@keyframes configEditSlideIn {
|
||||
from { max-height: 0; opacity: 0; }
|
||||
to { max-height: 120px; opacity: 1; }
|
||||
from {
|
||||
max-height: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
max-height: 120px;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.controller-config-edit-inner {
|
||||
@@ -1365,8 +1371,13 @@ iframe[id^='yomitan-popup'] {
|
||||
}
|
||||
|
||||
@keyframes configLearnPulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.6; }
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
.controller-config-edit-actions {
|
||||
@@ -1404,7 +1415,9 @@ iframe[id^='yomitan-popup'] {
|
||||
color: #6e738d;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: background 120ms ease, color 120ms ease;
|
||||
transition:
|
||||
background 120ms ease,
|
||||
color 120ms ease;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
@@ -1497,8 +1510,7 @@ body.subtitle-sidebar-embedded-open .subtitle-sidebar-modal {
|
||||
max-height: calc(100vh - 28px);
|
||||
height: auto;
|
||||
margin-left: auto;
|
||||
font-family:
|
||||
var(
|
||||
font-family: var(
|
||||
--subtitle-sidebar-font-family,
|
||||
'M PLUS 1',
|
||||
'Noto Sans CJK JP',
|
||||
|
||||
@@ -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\)\);/,
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user