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