mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-05-26 12:55:16 -07:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e84674e3b5 | |||
|
6ca5cede3e
|
|||
|
4d010e6a18
|
|||
| 5250ca8214 | |||
| 49f89e6452 |
@@ -23,6 +23,7 @@ MACOS_APP_DEST ?= $(MACOS_APP_DIR)/SubMiner.app
|
||||
APPIMAGE_SRC = $(firstword $(wildcard release/SubMiner-*.AppImage))
|
||||
MACOS_APP_SRC = $(firstword $(wildcard release/*.app release/*/*.app))
|
||||
MACOS_ZIP_SRC = $(firstword $(wildcard release/SubMiner-*.zip))
|
||||
PRERELEASE_NOTES := release/prerelease-notes.md
|
||||
|
||||
UNAME_S := $(shell uname -s 2>/dev/null || echo Unknown)
|
||||
ifeq ($(OS),Windows_NT)
|
||||
@@ -161,7 +162,15 @@ build-launcher:
|
||||
|
||||
clean:
|
||||
@printf '%s\n' "[INFO] Removing build artifacts"
|
||||
@rm -rf dist release
|
||||
@if [ -f "$(PRERELEASE_NOTES)" ]; then \
|
||||
PRERELEASE_NOTES_BACKUP="$$(mktemp -t subminer-prerelease-notes.XXXXXX)" && \
|
||||
cp "$(PRERELEASE_NOTES)" "$$PRERELEASE_NOTES_BACKUP" && \
|
||||
rm -rf dist release && \
|
||||
install -d release && \
|
||||
mv "$$PRERELEASE_NOTES_BACKUP" "$(PRERELEASE_NOTES)"; \
|
||||
else \
|
||||
rm -rf dist release; \
|
||||
fi
|
||||
@rm -f "$(BINDIR)/subminer" "$(BINDIR)/SubMiner.AppImage"
|
||||
|
||||
generate-config: ensure-bun
|
||||
|
||||
+193
@@ -0,0 +1,193 @@
|
||||
"use strict";
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.resolveMacAppBundlePath = resolveMacAppBundlePath;
|
||||
exports.isMacApplicationsFolderBundle = isMacApplicationsFolderBundle;
|
||||
exports.isKnownLinuxPackageManagedAppImage = isKnownLinuxPackageManagedAppImage;
|
||||
exports.isNativeUpdaterSupported = isNativeUpdaterSupported;
|
||||
exports.configureAutoUpdater = configureAutoUpdater;
|
||||
exports.createElectronAppUpdater = createElectronAppUpdater;
|
||||
const node_fs_1 = require("node:fs");
|
||||
const node_child_process_1 = require("node:child_process");
|
||||
const node_os_1 = __importDefault(require("node:os"));
|
||||
const node_path_1 = __importDefault(require("node:path"));
|
||||
const node_util_1 = require("node:util");
|
||||
const electron_updater_1 = require("electron-updater");
|
||||
const release_assets_1 = require("./release-assets");
|
||||
const updaterErrorListeners = new WeakMap();
|
||||
const execFileAsync = (0, node_util_1.promisify)(node_child_process_1.execFile);
|
||||
function resolveMacAppBundlePath(execPath) {
|
||||
const marker = '.app/Contents/MacOS/';
|
||||
const markerIndex = execPath.indexOf(marker);
|
||||
if (markerIndex < 0)
|
||||
return null;
|
||||
return execPath.slice(0, markerIndex + '.app'.length);
|
||||
}
|
||||
async function readMacCodeSignature(appBundlePath) {
|
||||
try {
|
||||
const result = await execFileAsync('/usr/bin/codesign', ['-dv', '--verbose=4', appBundlePath], {
|
||||
encoding: 'utf8',
|
||||
});
|
||||
return `${result.stdout ?? ''}\n${result.stderr ?? ''}`;
|
||||
}
|
||||
catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
function realpathOrOriginal(filePath) {
|
||||
try {
|
||||
return (0, node_fs_1.realpathSync)(filePath);
|
||||
}
|
||||
catch {
|
||||
return filePath;
|
||||
}
|
||||
}
|
||||
function isSameOrInsideDirectory(parentPath, candidatePath) {
|
||||
const relative = node_path_1.default.relative(parentPath, candidatePath);
|
||||
return (relative === '' ||
|
||||
(relative.length > 0 && !relative.startsWith('..') && !node_path_1.default.isAbsolute(relative)));
|
||||
}
|
||||
function isMacApplicationsFolderBundle(appBundlePath, homeDir = node_os_1.default.homedir()) {
|
||||
const resolvedBundlePath = node_path_1.default.resolve(appBundlePath);
|
||||
return (isSameOrInsideDirectory('/Applications', resolvedBundlePath) ||
|
||||
isSameOrInsideDirectory(node_path_1.default.join(homeDir, 'Applications'), resolvedBundlePath));
|
||||
}
|
||||
function isKnownLinuxPackageManagedAppImage(appImagePath) {
|
||||
return realpathOrOriginal(appImagePath) === '/opt/SubMiner/SubMiner.AppImage';
|
||||
}
|
||||
async function isNativeUpdaterSupported(options) {
|
||||
if (!options.isPackaged) {
|
||||
options.log?.('Skipping native updater because this build is not packaged.');
|
||||
return false;
|
||||
}
|
||||
if (options.platform === 'linux') {
|
||||
options.log?.('Skipping native Linux updater because Linux tray checks use GitHub release assets.');
|
||||
return false;
|
||||
}
|
||||
if (options.platform !== 'darwin') {
|
||||
options.log?.('Skipping native updater because this platform uses GitHub metadata checks.');
|
||||
return false;
|
||||
}
|
||||
const appBundlePath = resolveMacAppBundlePath(options.execPath);
|
||||
if (!appBundlePath) {
|
||||
options.log?.('Skipping native macOS updater because the app bundle path could not be resolved.');
|
||||
return false;
|
||||
}
|
||||
if (!isMacApplicationsFolderBundle(appBundlePath, options.homeDir)) {
|
||||
options.log?.('Skipping native macOS updater because the app is not installed in an Applications folder.');
|
||||
return false;
|
||||
}
|
||||
const signature = await (options.readCodeSignature ?? readMacCodeSignature)(appBundlePath);
|
||||
if (!signature) {
|
||||
options.log?.('Skipping native macOS updater because the app code signature could not be read.');
|
||||
return false;
|
||||
}
|
||||
if (/Signature=adhoc\b/.test(signature) || /TeamIdentifier=not set\b/.test(signature)) {
|
||||
options.log?.('Skipping native macOS updater because this build is ad-hoc signed.');
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
function configureAutoUpdater(updater, log = () => { }, channel = 'stable') {
|
||||
updater.autoDownload = false;
|
||||
// On macOS this avoids invoking Squirrel until the explicit restart/install step.
|
||||
updater.autoInstallOnAppQuit = false;
|
||||
updater.allowPrerelease = channel === 'prerelease';
|
||||
updater.allowDowngrade = false;
|
||||
updater.logger = {
|
||||
info: () => { },
|
||||
debug: () => { },
|
||||
warn: (message) => log(message),
|
||||
error: (message) => log(message),
|
||||
};
|
||||
const previousErrorListener = updaterErrorListeners.get(updater);
|
||||
if (previousErrorListener) {
|
||||
if (updater.off) {
|
||||
updater.off('error', previousErrorListener);
|
||||
}
|
||||
else {
|
||||
updater.removeListener?.('error', previousErrorListener);
|
||||
}
|
||||
}
|
||||
if (updater.on) {
|
||||
const errorListener = (error) => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
log(`Updater error event: ${message}`);
|
||||
};
|
||||
updater.on('error', errorListener);
|
||||
updaterErrorListeners.set(updater, errorListener);
|
||||
}
|
||||
return updater;
|
||||
}
|
||||
function createElectronAppUpdater(options) {
|
||||
const getChannel = options.getChannel ?? (() => 'stable');
|
||||
const updater = configureAutoUpdater(options.updater ?? electron_updater_1.autoUpdater, options.log, getChannel());
|
||||
if (options.configureHttpExecutor) {
|
||||
// electron-updater has no public executor hook; keep the macOS cURL override localized.
|
||||
updater.httpExecutor = options.configureHttpExecutor();
|
||||
}
|
||||
if (options.disableDifferentialDownload !== undefined) {
|
||||
updater.disableDifferentialDownload = options.disableDifferentialDownload;
|
||||
}
|
||||
let nativeUpdaterSupported = null;
|
||||
async function getNativeUpdaterSupported() {
|
||||
if (!options.isNativeUpdaterSupported)
|
||||
return true;
|
||||
if (nativeUpdaterSupported === null) {
|
||||
nativeUpdaterSupported = Promise.resolve(options.isNativeUpdaterSupported());
|
||||
}
|
||||
return nativeUpdaterSupported;
|
||||
}
|
||||
return {
|
||||
async checkForUpdates(channel) {
|
||||
if (!options.isPackaged) {
|
||||
return {
|
||||
available: false,
|
||||
version: options.currentVersion,
|
||||
canUpdate: false,
|
||||
};
|
||||
}
|
||||
if (!(await getNativeUpdaterSupported())) {
|
||||
options.log('Skipping native app update check because native updater is unsupported.');
|
||||
return {
|
||||
available: false,
|
||||
version: options.currentVersion,
|
||||
canUpdate: false,
|
||||
};
|
||||
}
|
||||
configureAutoUpdater(updater, options.log, channel ?? getChannel());
|
||||
const result = await updater.checkForUpdates();
|
||||
const version = result?.updateInfo?.version ?? options.currentVersion;
|
||||
return {
|
||||
available: (0, release_assets_1.compareSemverLike)(version, options.currentVersion) > 0,
|
||||
version,
|
||||
canUpdate: true,
|
||||
};
|
||||
},
|
||||
async downloadUpdate() {
|
||||
if (!options.isPackaged) {
|
||||
options.log('Skipping app update download because this build is not packaged.');
|
||||
return;
|
||||
}
|
||||
if (!(await getNativeUpdaterSupported())) {
|
||||
options.log('Skipping app update download because native updater is unsupported.');
|
||||
return;
|
||||
}
|
||||
await updater.downloadUpdate();
|
||||
},
|
||||
async quitAndInstall() {
|
||||
if (!options.isPackaged) {
|
||||
options.log('Skipping app update install because this build is not packaged.');
|
||||
return;
|
||||
}
|
||||
if (!(await getNativeUpdaterSupported())) {
|
||||
options.log('Skipping app update install because native updater is unsupported.');
|
||||
return;
|
||||
}
|
||||
updater.quitAndInstall(false, true);
|
||||
},
|
||||
};
|
||||
}
|
||||
//# sourceMappingURL=app-updater.js.map
|
||||
@@ -0,0 +1,15 @@
|
||||
# Config Settings Window
|
||||
|
||||
Status: draft
|
||||
Owner: Kyle Yasuda
|
||||
Created: 2026-05-17
|
||||
|
||||
## Goal
|
||||
|
||||
Add a dedicated configuration window that groups settings by user workflow while saving back to the existing `config.jsonc` paths.
|
||||
|
||||
## Notes
|
||||
|
||||
- Full current config surface, excluding legacy/ignored compatibility keys.
|
||||
- Preserve JSONC comments/formatting when saving.
|
||||
- Surface hot-reload vs restart-required results.
|
||||
@@ -15,6 +15,7 @@
|
||||
"jsonc-parser": "^3.3.1",
|
||||
"koffi": "^2.15.6",
|
||||
"libsql": "^0.5.22",
|
||||
"vscode-json-languageservice": "^5.7.2",
|
||||
"ws": "^8.19.0",
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -188,6 +189,8 @@
|
||||
|
||||
"@vladfrangu/async_event_emitter": ["@vladfrangu/async_event_emitter@2.4.7", "", {}, "sha512-Xfe6rpCTxSxfbswi/W/Pz7zp1WWSNn4A0eW4mLkQUewCrXXtMj31lCg+iQyTkh/CkusZSq9eDflu7tjEDXUY6g=="],
|
||||
|
||||
"@vscode/l10n": ["@vscode/l10n@0.0.18", "", {}, "sha512-KYSIHVmslkaCDyw013pphY+d7x1qV8IZupYfeIfzNA+nsaWHbn5uPuQRvdRFsa9zFzGeudPuoGoZ1Op4jrJXIQ=="],
|
||||
|
||||
"@xhayper/discord-rpc": ["@xhayper/discord-rpc@1.3.3", "", { "dependencies": { "@discordjs/rest": "^2.6.1", "@vladfrangu/async_event_emitter": "^2.4.7", "discord-api-types": "^0.38.42", "ws": "^8.20.0" } }, "sha512-Ih48GHiua7TtZgKO+f0uZPhCeQqb84fY2qUys/oMh8UbUfiUkUJLVCmd/v2AK0/pV33euh0aqSXo7+9LiPSwGw=="],
|
||||
|
||||
"@xmldom/xmldom": ["@xmldom/xmldom@0.8.12", "", {}, "sha512-9k/gHF6n/pAi/9tqr3m3aqkuiNosYTurLLUtc7xQ9sxB/wm7WPygCv8GYa6mS0fLJEHhqMC1ATYhz++U/lRHqg=="],
|
||||
@@ -722,6 +725,14 @@
|
||||
|
||||
"verror": ["verror@1.10.1", "", { "dependencies": { "assert-plus": "^1.0.0", "core-util-is": "1.0.2", "extsprintf": "^1.2.0" } }, "sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg=="],
|
||||
|
||||
"vscode-json-languageservice": ["vscode-json-languageservice@5.7.2", "", { "dependencies": { "@vscode/l10n": "^0.0.18", "jsonc-parser": "^3.3.1", "vscode-languageserver-textdocument": "^1.0.12", "vscode-languageserver-types": "^3.17.5", "vscode-uri": "^3.1.0" } }, "sha512-WtKRDtJfFEmLrgtu+ODexOHm/6/krRF0k6t+uvkKIKW1Jh9ZIyxZQwJJwB3qhrEgvAxa37zbUg+vn+UyUK/U2w=="],
|
||||
|
||||
"vscode-languageserver-textdocument": ["vscode-languageserver-textdocument@1.0.12", "", {}, "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA=="],
|
||||
|
||||
"vscode-languageserver-types": ["vscode-languageserver-types@3.17.5", "", {}, "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg=="],
|
||||
|
||||
"vscode-uri": ["vscode-uri@3.1.0", "", {}, "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ=="],
|
||||
|
||||
"wcwidth": ["wcwidth@1.0.1", "", { "dependencies": { "defaults": "^1.0.3" } }, "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg=="],
|
||||
|
||||
"which": ["which@5.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ=="],
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: character-dictionary
|
||||
|
||||
- Reused cached character-dictionary media matches so loading a title with an existing snapshot no longer sends another AniList search request.
|
||||
@@ -0,0 +1,6 @@
|
||||
type: added
|
||||
area: config
|
||||
|
||||
- Added a dedicated Configuration window with launcher entry points via `subminer --config` and `subminer config`.
|
||||
- Fixed the Configuration window preload so launcher-opened windows can initialize even when Electron sandboxing is active.
|
||||
- Kept config-window startup lightweight by skipping AniList token refresh and automatic update polling.
|
||||
@@ -0,0 +1,6 @@
|
||||
type: fixed
|
||||
area: overlay
|
||||
|
||||
- Controller config and debug shortcuts now stay closed while controller support is disabled and show a notice to enable `controller.enabled` manually.
|
||||
- Controller binding rows now start learn mode from the edit pencil, so clicking edit and pressing a controller button saves the remap.
|
||||
- Controller remaps are now saved per controller profile, binding badges also start learn mode, and row reset buttons restore individual bindings to their defaults.
|
||||
@@ -0,0 +1,6 @@
|
||||
type: fixed
|
||||
area: anilist
|
||||
|
||||
- Used fresh mpv time-position, duration, and subtitle timing events for AniList post-watch threshold checks so progress updates still fire when playback reaches or skips past the watched threshold.
|
||||
- Prefer season-specific AniList search results for multi-season files before falling back to the base title.
|
||||
- Show a clear AniList message when the matched season is not in Planning or Watching instead of silently queueing an impossible progress update.
|
||||
@@ -0,0 +1,10 @@
|
||||
type: fixed
|
||||
area: overlay
|
||||
|
||||
- Hid the macOS visible overlay when mpv is no longer the foreground target so other apps and Spaces are not covered by SubMiner subtitles.
|
||||
- Kept the macOS overlay layered above active mpv while stats mouse passthrough is enabled, and treated the frontmost mpv app as the focus signal.
|
||||
- Opened the stats overlay inactive on macOS so it appears over fullscreen mpv instead of switching back to SubMiner's original desktop.
|
||||
- Preserved the active mpv focus state through transient macOS helper misses so subtitles do not flicker while mpv remains foreground.
|
||||
- Kept fullscreen macOS overlays stable when mpv remains frontmost but window geometry temporarily disappears from the macOS window APIs.
|
||||
- Released the macOS overlay when the helper reports mpv is no longer foreground so other apps are no longer covered.
|
||||
- Reduced macOS window-tracker background work by preferring the compiled helper and slowing polls while mpv is stably focused.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: added
|
||||
area: launcher
|
||||
|
||||
- Added `subminer --version` and `subminer -v` to print the installed SubMiner app version.
|
||||
@@ -1,4 +1,6 @@
|
||||
type: fixed
|
||||
area: updates
|
||||
|
||||
- Avoided native `electron-updater` checks where they are unsafe, so tray and background update checks continue through GitHub release metadata without crashing the app.
|
||||
- Restored the standard macOS `electron-updater`/Squirrel update path and routed supplemental GitHub updater requests through Electron networking instead of Node fetch.
|
||||
- macOS update checks now skip local build-output apps outside Applications before touching Squirrel, and macOS tray checks no longer perform the supplemental GitHub asset lookup.
|
||||
- macOS `electron-updater` metadata and full ZIP downloads now use `/usr/bin/curl` under the hood to avoid the Electron network crash seen during tray update checks while preserving Squirrel installation.
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
type: changed
|
||||
area: release
|
||||
|
||||
- Prerelease note generation now reuses existing reviewed prerelease notes and asks Claude to merge only new fragment material, while `make clean` preserves `release/prerelease-notes.md`.
|
||||
+61
-60
@@ -10,7 +10,7 @@
|
||||
// Overlay Auto-Start
|
||||
// When overlay connects to mpv, automatically show overlay and hide mpv subtitles.
|
||||
// ==========================================
|
||||
"auto_start_overlay": false, // When overlay connects to mpv, automatically show overlay and hide mpv subtitles. Values: true | false
|
||||
"auto_start_overlay": false, // Auto-start the subtitle overlay window when SubMiner launches. Values: true | false
|
||||
|
||||
// ==========================================
|
||||
// Texthooker Server
|
||||
@@ -18,7 +18,7 @@
|
||||
// ==========================================
|
||||
"texthooker": {
|
||||
"launchAtStartup": false, // Launch texthooker server automatically when SubMiner starts. Values: true | false
|
||||
"openBrowser": false // Open browser setting. Values: true | false
|
||||
"openBrowser": false // Open the texthooker page in the default browser when the server starts. Values: true | false
|
||||
}, // Configure texthooker startup launch and browser opening behavior.
|
||||
|
||||
// ==========================================
|
||||
@@ -138,7 +138,8 @@
|
||||
"axisIndex": 4, // Raw axis index captured for this analog controller action.
|
||||
"dpadFallback": "none" // Optional D-pad fallback used when this analog controller action should also read D-pad input. Values: none | horizontal | vertical
|
||||
} // Axis binding descriptor used for popup page jumps. Use Alt+C learn mode or set a raw axis descriptor manually.
|
||||
} // Raw controller binding descriptors saved by Alt+C learn mode. For discrete axis bindings, kind "axis" requires axisIndex and direction.
|
||||
}, // Raw controller binding descriptors saved by Alt+C learn mode. For discrete axis bindings, kind "axis" requires axisIndex and direction.
|
||||
"profiles": {} // Per-controller binding and button-index overrides keyed by the controller id reported by the Gamepad API.
|
||||
}, // Gamepad support for the visible overlay while keyboard-only mode is active.
|
||||
|
||||
// ==========================================
|
||||
@@ -173,24 +174,24 @@
|
||||
// Hot-reload: shortcut changes apply live and update the session help modal on reopen.
|
||||
// ==========================================
|
||||
"shortcuts": {
|
||||
"toggleVisibleOverlayGlobal": "Alt+Shift+O", // Toggle visible overlay global setting.
|
||||
"copySubtitle": "CommandOrControl+C", // Copy subtitle setting.
|
||||
"copySubtitleMultiple": "CommandOrControl+Shift+C", // Copy subtitle multiple setting.
|
||||
"updateLastCardFromClipboard": "CommandOrControl+V", // Update last card from clipboard setting.
|
||||
"triggerFieldGrouping": "CommandOrControl+G", // Trigger field grouping setting.
|
||||
"triggerSubsync": "Ctrl+Alt+S", // Trigger subsync setting.
|
||||
"mineSentence": "CommandOrControl+S", // Mine sentence setting.
|
||||
"mineSentenceMultiple": "CommandOrControl+Shift+S", // Mine sentence multiple setting.
|
||||
"toggleVisibleOverlayGlobal": "Alt+Shift+O", // Global accelerator that toggles overlay visibility from anywhere on the system. Use null to disable.
|
||||
"copySubtitle": "CommandOrControl+C", // Accelerator that copies the current subtitle line to the clipboard.
|
||||
"copySubtitleMultiple": "CommandOrControl+Shift+C", // Accelerator that copies consecutive subtitle lines while the multi-copy window stays open.
|
||||
"updateLastCardFromClipboard": "CommandOrControl+V", // Accelerator that updates the last mined Anki card using the current clipboard contents.
|
||||
"triggerFieldGrouping": "CommandOrControl+G", // Accelerator that triggers Kiku field grouping on duplicate cards.
|
||||
"triggerSubsync": "Ctrl+Alt+S", // Accelerator that triggers subsync against the active subtitle file.
|
||||
"mineSentence": "CommandOrControl+S", // Accelerator that mines the current sentence as a new Anki card.
|
||||
"mineSentenceMultiple": "CommandOrControl+Shift+S", // Accelerator that mines consecutive sentences while the multi-mine window stays open.
|
||||
"multiCopyTimeoutMs": 3000, // Timeout for multi-copy/mine modes.
|
||||
"toggleSecondarySub": "CommandOrControl+Shift+V", // Toggle secondary sub setting.
|
||||
"markAudioCard": "CommandOrControl+Shift+A", // Mark audio card setting.
|
||||
"openCharacterDictionary": "CommandOrControl+Alt+A", // Open character dictionary setting.
|
||||
"openRuntimeOptions": "CommandOrControl+Shift+O", // Open runtime options setting.
|
||||
"openJimaku": "Ctrl+Shift+J", // Open jimaku setting.
|
||||
"openSessionHelp": "CommandOrControl+Slash", // Open session help setting.
|
||||
"openControllerSelect": "Alt+C", // Open controller select setting.
|
||||
"openControllerDebug": "Alt+Shift+C", // Open controller debug setting.
|
||||
"toggleSubtitleSidebar": "Backslash" // Toggle subtitle sidebar setting.
|
||||
"toggleSecondarySub": "CommandOrControl+Shift+V", // Accelerator that toggles the secondary subtitle bar visibility.
|
||||
"markAudioCard": "CommandOrControl+Shift+A", // Accelerator that marks the last mined card as an audio card.
|
||||
"openCharacterDictionary": "CommandOrControl+Alt+A", // Accelerator that opens the character dictionary modal.
|
||||
"openRuntimeOptions": "CommandOrControl+Shift+O", // Accelerator that opens the runtime options modal.
|
||||
"openJimaku": "Ctrl+Shift+J", // Accelerator that opens the Jimaku subtitle search modal.
|
||||
"openSessionHelp": "CommandOrControl+Slash", // Accelerator that opens the session help / keybinding cheatsheet.
|
||||
"openControllerSelect": "Alt+C", // Accelerator that opens the controller selection and learn-mode modal.
|
||||
"openControllerDebug": "Alt+Shift+C", // Accelerator that opens the controller debug modal with live axis/button readouts.
|
||||
"toggleSubtitleSidebar": "Backslash" // Accelerator that toggles the subtitle sidebar visibility.
|
||||
}, // Overlay keyboard shortcuts. Set a shortcut to null to disable.
|
||||
|
||||
// ==========================================
|
||||
@@ -327,9 +328,9 @@
|
||||
// Hot-reload: defaultMode updates live while SubMiner is running.
|
||||
// ==========================================
|
||||
"secondarySub": {
|
||||
"secondarySubLanguages": [], // Secondary sub languages setting.
|
||||
"autoLoadSecondarySub": false, // Auto load secondary sub setting. Values: true | false
|
||||
"defaultMode": "hover" // Default mode setting.
|
||||
"secondarySubLanguages": [], // Language code priority list used to auto-select a secondary subtitle track when available.
|
||||
"autoLoadSecondarySub": false, // Automatically load a matching secondary subtitle when the primary subtitle loads. Values: true | false
|
||||
"defaultMode": "hover" // Default visibility mode for the secondary subtitle bar. Values: hidden | visible | hover
|
||||
}, // Dual subtitle track options.
|
||||
|
||||
// ==========================================
|
||||
@@ -338,9 +339,9 @@
|
||||
// ==========================================
|
||||
"subsync": {
|
||||
"defaultMode": "auto", // Subsync default mode. Values: auto | manual
|
||||
"alass_path": "", // Alass path setting.
|
||||
"ffsubsync_path": "", // Ffsubsync path setting.
|
||||
"ffmpeg_path": "", // Ffmpeg path setting.
|
||||
"alass_path": "", // Optional absolute path to the alass binary used by subsync. Leave empty to auto-discover from PATH.
|
||||
"ffsubsync_path": "", // Optional absolute path to the ffsubsync binary used by subsync. Leave empty to auto-discover from PATH.
|
||||
"ffmpeg_path": "", // Optional absolute path to the ffmpeg binary used by subsync. Leave empty to auto-discover from PATH.
|
||||
"replace": true // Replace the active subtitle file when sync completes. Values: true | false
|
||||
}, // Subsync engine and executable paths.
|
||||
|
||||
@@ -349,7 +350,7 @@
|
||||
// Initial vertical subtitle position from the bottom.
|
||||
// ==========================================
|
||||
"subtitlePosition": {
|
||||
"yPercent": 10 // Y percent setting.
|
||||
"yPercent": 10 // Vertical position of the subtitle overlay expressed as a percentage from the bottom of the screen.
|
||||
}, // Initial vertical subtitle position from the bottom.
|
||||
|
||||
// ==========================================
|
||||
@@ -453,9 +454,9 @@
|
||||
"enabled": false, // Enable shared OpenAI-compatible AI provider features. Values: true | false
|
||||
"apiKey": "", // Static API key for the shared OpenAI-compatible AI provider.
|
||||
"apiKeyCommand": "", // Shell command used to resolve the shared AI provider API key.
|
||||
"model": "openai/gpt-4o-mini", // Model setting.
|
||||
"model": "openai/gpt-4o-mini", // Default model identifier requested from the shared AI provider.
|
||||
"baseUrl": "https://openrouter.ai/api", // Base URL for the shared OpenAI-compatible AI provider.
|
||||
"systemPrompt": "You are a translation engine. Return only the translated text with no explanations.", // System prompt setting.
|
||||
"systemPrompt": "You are a translation engine. Return only the translated text with no explanations.", // Default system prompt sent with shared AI provider requests.
|
||||
"requestTimeoutMs": 15000 // Timeout in milliseconds for shared AI provider requests.
|
||||
}, // Canonical OpenAI-compatible provider transport settings shared by Anki and YouTube subtitle fixing.
|
||||
|
||||
@@ -468,7 +469,7 @@
|
||||
// ==========================================
|
||||
"ankiConnect": {
|
||||
"enabled": true, // Enable AnkiConnect integration. Values: true | false
|
||||
"url": "http://127.0.0.1:8765", // Url setting.
|
||||
"url": "http://127.0.0.1:8765", // Base URL of the AnkiConnect HTTP server.
|
||||
"pollingRate": 3000, // Polling interval in milliseconds.
|
||||
"proxy": {
|
||||
"enabled": true, // Enable local AnkiConnect-compatible proxy for push-based auto-enrichment. Values: true | false
|
||||
@@ -481,11 +482,11 @@
|
||||
], // Tags to add to cards mined or updated by SubMiner. Provide an empty array to disable automatic tagging.
|
||||
"fields": {
|
||||
"word": "Expression", // Card field for the mined word or expression text.
|
||||
"audio": "ExpressionAudio", // Audio setting.
|
||||
"image": "Picture", // Image setting.
|
||||
"sentence": "Sentence", // Sentence setting.
|
||||
"miscInfo": "MiscInfo", // Misc info setting.
|
||||
"translation": "SelectionText" // Translation setting.
|
||||
"audio": "ExpressionAudio", // Card field that receives generated sentence audio.
|
||||
"image": "Picture", // Card field that receives the captured screenshot or animated image.
|
||||
"sentence": "Sentence", // Card field that receives the source sentence text.
|
||||
"miscInfo": "MiscInfo", // Card field that receives the miscellaneous info pattern (see ankiConnect.metadata.pattern).
|
||||
"translation": "SelectionText" // Card field that receives the current selection or translated text.
|
||||
}, // Fields setting.
|
||||
"ai": {
|
||||
"enabled": false, // Enable AI provider usage for Anki translation/enrichment flows. Values: true | false
|
||||
@@ -493,18 +494,18 @@
|
||||
"systemPrompt": "" // Optional system prompt override for Anki AI translation/enrichment flows.
|
||||
}, // Ai setting.
|
||||
"media": {
|
||||
"generateAudio": true, // Generate audio setting. Values: true | false
|
||||
"generateImage": true, // Generate image setting. Values: true | false
|
||||
"imageType": "static", // Image type setting.
|
||||
"imageFormat": "jpg", // Image format setting.
|
||||
"imageQuality": 92, // Image quality setting.
|
||||
"animatedFps": 10, // Animated fps setting.
|
||||
"animatedMaxWidth": 640, // Animated max width setting.
|
||||
"animatedCrf": 35, // Animated crf setting.
|
||||
"generateAudio": true, // Generate sentence audio for mined cards. Values: true | false
|
||||
"generateImage": true, // Generate screenshot or animated image for mined cards. Values: true | false
|
||||
"imageType": "static", // Image capture type: "static" for a single still frame, "avif" for an animated AVIF. Values: static | avif
|
||||
"imageFormat": "jpg", // Encoding format used when imageType is "static". Values: jpg | png | webp
|
||||
"imageQuality": 92, // Quality (0-100) used for lossy static image encoders.
|
||||
"animatedFps": 10, // Target frame rate for animated AVIF captures.
|
||||
"animatedMaxWidth": 640, // Maximum width applied to animated AVIF captures.
|
||||
"animatedCrf": 35, // Animated AVIF CRF quality target. Lower values produce larger, higher-quality files.
|
||||
"syncAnimatedImageToWordAudio": true, // For animated AVIF images, prepend a frozen first frame matching the existing word-audio duration so motion starts with sentence audio. Values: true | false
|
||||
"audioPadding": 0.5, // Audio padding setting.
|
||||
"fallbackDuration": 3, // Fallback duration setting.
|
||||
"maxMediaDuration": 30 // Max media duration setting.
|
||||
"audioPadding": 0.5, // Seconds of padding appended to both ends of generated sentence audio.
|
||||
"fallbackDuration": 3, // Fallback clip duration in seconds when subtitle timing data is unavailable.
|
||||
"maxMediaDuration": 30 // Maximum allowed media clip duration in seconds.
|
||||
}, // Media setting.
|
||||
"knownWords": {
|
||||
"highlightEnabled": false, // Enable fast local highlighting for words already known in Anki. Values: true | false
|
||||
@@ -515,11 +516,11 @@
|
||||
"color": "#a6da95" // Color used for known-word highlights.
|
||||
}, // Known words setting.
|
||||
"behavior": {
|
||||
"overwriteAudio": true, // Overwrite audio setting. Values: true | false
|
||||
"overwriteImage": true, // Overwrite image setting. Values: true | false
|
||||
"mediaInsertMode": "append", // Media insert mode setting.
|
||||
"highlightWord": true, // Highlight word setting. Values: true | false
|
||||
"notificationType": "osd", // Notification type setting.
|
||||
"overwriteAudio": true, // When updating an existing card, overwrite the audio field instead of skipping it. Values: true | false
|
||||
"overwriteImage": true, // When updating an existing card, overwrite the image field instead of skipping it. Values: true | false
|
||||
"mediaInsertMode": "append", // Whether new media is appended after or prepended before existing field contents on update. Values: append | prepend
|
||||
"highlightWord": true, // Bold the mined word inside the sentence field on the saved Anki card. Values: true | false
|
||||
"notificationType": "osd", // Notification surface used to announce mining and update outcomes. Values: osd | system | both | none
|
||||
"autoUpdateNewCards": true // Automatically update newly added cards. Values: true | false
|
||||
}, // Behavior setting.
|
||||
"nPlusOne": {
|
||||
@@ -527,16 +528,16 @@
|
||||
"nPlusOne": "#c6a0f6" // Color used for the single N+1 target token highlight.
|
||||
}, // N plus one setting.
|
||||
"metadata": {
|
||||
"pattern": "[SubMiner] %f (%t)" // Pattern setting.
|
||||
"pattern": "[SubMiner] %f (%t)" // Template used to render the miscInfo field. Placeholders include %f (filename) and %t (timestamp).
|
||||
}, // Metadata setting.
|
||||
"isLapis": {
|
||||
"enabled": false, // Enabled setting. Values: true | false
|
||||
"sentenceCardModel": "Japanese sentences" // Sentence card model setting.
|
||||
"enabled": false, // Enable Lapis-specific mining behaviors and sentence card model targeting. Values: true | false
|
||||
"sentenceCardModel": "Japanese sentences" // Note type name used by Lapis sentence cards.
|
||||
}, // Is lapis setting.
|
||||
"isKiku": {
|
||||
"enabled": false, // Enabled setting. Values: true | false
|
||||
"enabled": false, // Enable Kiku-specific mining behaviors (duplicate handling, field grouping). Values: true | false
|
||||
"fieldGrouping": "disabled", // Kiku duplicate-card field grouping mode. Values: auto | manual | disabled
|
||||
"deleteDuplicateInAuto": true // Delete duplicate in auto setting. Values: true | false
|
||||
"deleteDuplicateInAuto": true // When Kiku field grouping is "auto", delete the duplicate source card after grouping completes. Values: true | false
|
||||
} // Is kiku setting.
|
||||
}, // Automatic Anki updates and media generation options.
|
||||
|
||||
@@ -545,7 +546,7 @@
|
||||
// Jimaku API configuration and defaults.
|
||||
// ==========================================
|
||||
"jimaku": {
|
||||
"apiBaseUrl": "https://jimaku.cc", // Api base url setting.
|
||||
"apiBaseUrl": "https://jimaku.cc", // Base URL of the Jimaku subtitle search API.
|
||||
"languagePreference": "ja", // Preferred language used in Jimaku search. Values: ja | en | none
|
||||
"maxEntryResults": 10 // Maximum Jimaku search results returned.
|
||||
}, // Jimaku API configuration and defaults.
|
||||
@@ -617,9 +618,9 @@
|
||||
"serverUrl": "", // Base Jellyfin server URL (for example: http://localhost:8096).
|
||||
"recentServers": [], // Recently authenticated Jellyfin server URLs shown in setup.
|
||||
"username": "", // Default Jellyfin username used during CLI login.
|
||||
"deviceId": "subminer", // Device id setting.
|
||||
"clientName": "SubMiner", // Client name setting.
|
||||
"clientVersion": "0.1.0", // Client version setting.
|
||||
"deviceId": "subminer", // Stable device identifier sent on the Jellyfin authentication handshake; primarily internal.
|
||||
"clientName": "SubMiner", // Client name sent on the Jellyfin authentication handshake; primarily internal.
|
||||
"clientVersion": "0.1.0", // Client version sent on the Jellyfin authentication handshake; primarily internal.
|
||||
"defaultLibraryId": "", // Optional default Jellyfin library ID for item listing.
|
||||
"remoteControlEnabled": true, // Enable Jellyfin remote cast control mode. Values: true | false
|
||||
"remoteControlAutoConnect": true, // Auto-connect to the configured remote control target. Values: true | false
|
||||
|
||||
@@ -17,8 +17,8 @@ AniList integration is opt-in. To enable it:
|
||||
{
|
||||
"anilist": {
|
||||
"enabled": true,
|
||||
"accessToken": ""
|
||||
}
|
||||
"accessToken": "",
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
@@ -37,20 +37,20 @@ SubMiner monitors playback and triggers an AniList progress update when an episo
|
||||
The update flow:
|
||||
|
||||
1. **Title detection** -- SubMiner extracts the anime title, season, and episode number from the media filename. It tries [`guessit`](https://github.com/guessit-io/guessit) first for accurate parsing, then falls back to an internal filename parser if guessit is unavailable.
|
||||
2. **AniList search** -- The detected title is searched against the AniList GraphQL API. SubMiner picks the best match by comparing titles (romaji, English, native) and filtering by episode count.
|
||||
3. **Progress check** -- SubMiner fetches your current list entry for the matched media. If your recorded progress already meets or exceeds the detected episode, the update is skipped.
|
||||
2. **AniList search** -- The detected title is searched against the AniList GraphQL API. For season 2 and later files, SubMiner searches the season-specific title first, then falls back to the base title. SubMiner picks the best match by comparing titles (romaji, English, native) and filtering by episode count.
|
||||
3. **Progress check** -- SubMiner fetches your current list entry for the matched media. The media must already be in Planning or Watching; otherwise SubMiner shows an MPV message explaining that the update is not possible. If your recorded progress already meets or exceeds the detected episode, the update is skipped.
|
||||
4. **Mutation** -- A `SaveMediaListEntry` mutation sets the new progress and marks the entry as `CURRENT`.
|
||||
|
||||
## Update Queue and Retry
|
||||
|
||||
Failed AniList updates are persisted to a retry queue on disk and retried with exponential backoff.
|
||||
|
||||
| Parameter | Value |
|
||||
| --- | --- |
|
||||
| Initial backoff | 30 seconds |
|
||||
| Maximum backoff | 6 hours |
|
||||
| Maximum attempts | 8 |
|
||||
| Queue capacity | 500 items |
|
||||
| Parameter | Value |
|
||||
| ---------------- | ---------- |
|
||||
| Initial backoff | 30 seconds |
|
||||
| Maximum backoff | 6 hours |
|
||||
| Maximum attempts | 8 |
|
||||
| Queue capacity | 500 items |
|
||||
|
||||
After 8 failed attempts, the update is moved to a dead-letter queue and no longer retried automatically. The queue is persisted across restarts so no updates are lost if SubMiner exits before a retry succeeds.
|
||||
|
||||
@@ -85,36 +85,37 @@ All AniList API calls go through a shared rate limiter that enforces a sliding w
|
||||
"collapsibleSections": {
|
||||
"description": false,
|
||||
"characterInformation": false,
|
||||
"voicedBy": false
|
||||
}
|
||||
}
|
||||
}
|
||||
"voicedBy": false,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
| Option | Values | Description |
|
||||
| --- | --- | --- |
|
||||
| `enabled` | `true`, `false` | Enable AniList post-watch progress updates (default: `false`) |
|
||||
| `accessToken` | string | Explicit AniList access token override; when blank, SubMiner uses the stored encrypted token (default: `""`) |
|
||||
| `characterDictionary.enabled` | `true`, `false` | Enable auto-sync of the merged character dictionary from AniList (default: `false`) |
|
||||
| `characterDictionary.maxLoaded` | number | Number of recent media snapshots kept in the merged dictionary (default: `3`) |
|
||||
| `characterDictionary.profileScope` | `"all"`, `"active"` | Apply dictionary to all Yomitan profiles or only the active one |
|
||||
| `characterDictionary.collapsibleSections.*` | `true`, `false` | Control which dictionary entry sections start expanded |
|
||||
| Option | Values | Description |
|
||||
| ------------------------------------------- | ------------------- | ------------------------------------------------------------------------------------------------------------ |
|
||||
| `enabled` | `true`, `false` | Enable AniList post-watch progress updates (default: `false`) |
|
||||
| `accessToken` | string | Explicit AniList access token override; when blank, SubMiner uses the stored encrypted token (default: `""`) |
|
||||
| `characterDictionary.enabled` | `true`, `false` | Enable auto-sync of the merged character dictionary from AniList (default: `false`) |
|
||||
| `characterDictionary.maxLoaded` | number | Number of recent media snapshots kept in the merged dictionary (default: `3`) |
|
||||
| `characterDictionary.profileScope` | `"all"`, `"active"` | Apply dictionary to all Yomitan profiles or only the active one |
|
||||
| `characterDictionary.collapsibleSections.*` | `true`, `false` | Control which dictionary entry sections start expanded |
|
||||
|
||||
See the [Character Dictionary](/character-dictionary) page for full details on the character dictionary feature, including name generation, matching, auto-sync lifecycle, and dictionary entry format.
|
||||
|
||||
## CLI Commands
|
||||
|
||||
| Command | Description |
|
||||
| --- | --- |
|
||||
| `--anilist-setup` | Open AniList setup/auth flow helper window |
|
||||
| `--anilist-status` | Print current token resolution state and retry queue counters |
|
||||
| `--anilist-logout` | Clear stored AniList token from local persisted state |
|
||||
| `--anilist-retry-queue` | Process one ready retry queue item immediately |
|
||||
| Command | Description |
|
||||
| ----------------------- | ------------------------------------------------------------- |
|
||||
| `--anilist-setup` | Open AniList setup/auth flow helper window |
|
||||
| `--anilist-status` | Print current token resolution state and retry queue counters |
|
||||
| `--anilist-logout` | Clear stored AniList token from local persisted state |
|
||||
| `--anilist-retry-queue` | Process one ready retry queue item immediately |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **Updates not triggering:** Confirm `anilist.enabled` is `true`. SubMiner requires at least 85% of the episode watched and a minimum of 10 minutes. Short episodes or partial watches will not trigger an update.
|
||||
- **Update not possible:** Add the season to your AniList Planning or Watching list first. SubMiner will not create new AniList list entries automatically.
|
||||
- **Wrong episode or title matched:** Detection quality is best when `guessit` is installed and on your `PATH`. Without it, SubMiner falls back to internal filename parsing which can be less accurate with unusual naming conventions.
|
||||
- **Token issues:** Run `--anilist-status` to check token state. If the token is invalid or expired, run `--anilist-setup` or `--anilist-logout` and re-authenticate.
|
||||
- **Updates failing repeatedly:** Run `--anilist-status` to see retry queue counters. Items that fail 8 times are moved to the dead-letter queue. Check network connectivity and AniList API status.
|
||||
|
||||
@@ -35,7 +35,7 @@ Character dictionary sync is disabled by default. To turn it on:
|
||||
```
|
||||
|
||||
::: tip
|
||||
The first sync for a media title takes a few seconds while character data and portraits are fetched from AniList. Subsequent launches reuse the cached snapshot.
|
||||
The first sync for a media title takes a few seconds while character data and portraits are fetched from AniList. Subsequent launches reuse the cached media match and snapshot without a fresh AniList lookup.
|
||||
:::
|
||||
|
||||
::: warning
|
||||
@@ -139,7 +139,7 @@ When `characterDictionary.enabled` is `true`, SubMiner runs an auto-sync routine
|
||||
5. **importing** — Push the ZIP into Yomitan. Waits for Yomitan mutation readiness (7-second timeout per operation).
|
||||
6. **ready** — Dictionary is live. Character names will match on the next subtitle line.
|
||||
|
||||
**State tracking** is persisted in `character-dictionaries/auto-sync-state.json`:
|
||||
**State tracking** is persisted in `character-dictionaries/auto-sync-state.json`. AniList media matches are cached separately in `character-dictionaries/anilist-resolution-cache.json` so snapshot hits do not need another AniList search.
|
||||
|
||||
```jsonc
|
||||
{
|
||||
|
||||
@@ -59,6 +59,28 @@ For valid JSON/JSONC with invalid option values, SubMiner uses warn-and-fallback
|
||||
|
||||
On macOS, these validation warnings also open a native dialog with full details (desktop notification banners can truncate long messages).
|
||||
|
||||
### Configuration Window
|
||||
|
||||
SubMiner also includes a dedicated **Configuration** window from the tray menu, the app `--config` flag, or launcher commands such as `subminer --config` and `subminer config`. It groups settings by workflow instead of mirroring the raw config-file shape:
|
||||
|
||||
- Viewing
|
||||
- Mining & Anki
|
||||
- Playback & Sources
|
||||
- Input
|
||||
- Integrations
|
||||
- Tracking & App
|
||||
- Advanced
|
||||
|
||||
Each field still writes to its current `config.jsonc` path. For example, subtitle hover pause appears under **Viewing** / playback behavior, but saves to `subtitleStyle.autoPauseVideoOnHover`.
|
||||
|
||||
The settings window preserves existing JSONC comments, trailing commas, unrelated keys, and unsupported legacy options. Resetting a field removes the explicit config path so the built-in default applies.
|
||||
|
||||
Secret fields do not display stored values. They show whether a value is configured; entering a new value writes it, and reset clears the explicit path. Prefer command-based secret options such as `ai.apiKeyCommand` when available.
|
||||
|
||||
Some compatibility-only or ignored legacy keys are intentionally hidden from the normal field list, including legacy top-level Anki migration fields, old N+1 aliases, the removed YouTube subtitle-generation primary-language key, `anilist.characterDictionary.refreshTtlHours`, `anilist.characterDictionary.evictionPolicy`, `jellyfin.accessToken`, `jellyfin.userId`, and normal editing for `controller.buttonIndices`. Advanced/raw JSON editing remains the escape hatch for unsupported or legacy keys.
|
||||
|
||||
Saving validates the candidate config before writing. Live-reloadable changes are applied immediately; other changes return a restart-required banner in the window.
|
||||
|
||||
### Hot-Reload Behavior
|
||||
|
||||
SubMiner watches the active config file (`config.jsonc` or `config.json`) while running and applies supported updates automatically.
|
||||
@@ -608,8 +630,12 @@ Important behavior:
|
||||
- Controller input is only active while keyboard-only mode is enabled.
|
||||
- Keyboard-only mode continues to work normally without a controller.
|
||||
- By default SubMiner uses the first connected controller.
|
||||
- Fresh installs keep controller support disabled until you set `controller.enabled` to `true`.
|
||||
- `Alt+C` opens the controller config modal by default, and you can remap that shortcut through `shortcuts.openControllerSelect`.
|
||||
- Click `Learn`, then press the next fresh button, trigger, or stick direction you want to bind for that overlay action.
|
||||
- The `Alt+C` config modal and `Alt+Shift+C` debug modal stay closed while controller support is disabled.
|
||||
- Click the binding badge, edit pencil, or `Learn`, then press the next fresh button, trigger, or stick direction you want to bind for that overlay action.
|
||||
- Click the reset button beside the edit pencil to restore one binding to the built-in default.
|
||||
- Learned bindings are saved under `controller.profiles` for the selected controller id. Global `controller.bindings` remains the fallback for controllers without a profile.
|
||||
- `Alt+Shift+C` opens the debug modal by default, and you can remap that shortcut through `shortcuts.openControllerDebug`.
|
||||
- The debug modal shows raw axes/button values plus a ready-to-copy `buttonIndices` config block.
|
||||
- `controller.buttonIndices` is a semantic reference/legacy mapping. Changing it does not rewrite the raw numeric descriptor values already stored under `controller.bindings`.
|
||||
@@ -658,6 +684,15 @@ Important behavior:
|
||||
"rightStickHorizontal": { "kind": "axis", "axisIndex": 3, "dpadFallback": "none" },
|
||||
"rightStickVertical": { "kind": "axis", "axisIndex": 4, "dpadFallback": "none" },
|
||||
},
|
||||
"profiles": {
|
||||
"Xbox Wireless Controller": {
|
||||
"label": "Xbox Wireless Controller",
|
||||
"bindings": {
|
||||
"toggleLookup": { "kind": "button", "buttonIndex": 0 },
|
||||
"mineCard": { "kind": "button", "buttonIndex": 2 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
@@ -678,7 +713,7 @@ Default logical mapping:
|
||||
- `L3`: toggle mpv pause
|
||||
- `L2` / `R2`: unbound by default
|
||||
|
||||
Discrete bindings may use raw button indices or raw axis directions, and analog bindings use raw axis indices with optional D-pad fallback. The `Alt+C` learn flow writes those descriptors for you, so manual edits are only needed when you want to script or copy exact mappings.
|
||||
Discrete bindings may use raw button indices or raw axis directions, and analog bindings use raw axis indices with optional D-pad fallback. The `Alt+C` learn flow writes those descriptors under `controller.profiles["<controller id>"]` for the selected controller. Manual edits are only needed when you want to script or copy exact mappings.
|
||||
|
||||
If you bind a discrete action to an axis manually, include `direction`:
|
||||
|
||||
@@ -692,15 +727,15 @@ If you bind a discrete action to an axis manually, include `direction`:
|
||||
}
|
||||
```
|
||||
|
||||
Treat `controller.buttonIndices` as reference-only unless you are still using legacy semantic bindings or copying values from the debug modal. Updating `controller.buttonIndices` alone does not rewrite the hardcoded raw numeric values already present in `controller.bindings`. If you need a real remap, prefer the `Alt+C` learn flow so both the source and the descriptor shape stay correct.
|
||||
Treat `controller.buttonIndices` as reference-only unless you are still using legacy semantic bindings or copying values from the debug modal. Updating `controller.buttonIndices` alone does not rewrite the hardcoded raw numeric values already present in `controller.bindings` or `controller.profiles.*.bindings`. If you need a real remap, prefer the `Alt+C` learn flow so both the source and the descriptor shape stay correct.
|
||||
|
||||
If you choose to bind `L2` or `R2` manually, set `triggerInputMode` to `analog` and tune `triggerDeadzone` when your controller reports triggers as analog values instead of digital pressed/not-pressed buttons. `auto` accepts either style and remains the default.
|
||||
|
||||
If your controller reports non-standard raw button numbers, override `controller.buttonIndices` using values from the `Alt+Shift+C` debug modal.
|
||||
If one controller reports non-standard raw button numbers, override `controller.profiles["<controller id>"].buttonIndices` using values from the `Alt+Shift+C` debug modal. Use global `controller.buttonIndices` only when the mapping should apply to every controller without a profile.
|
||||
|
||||
If you update this controller documentation or the generated controller examples, run `bun run docs:test` and `bun run docs:build` before merging.
|
||||
|
||||
Tune `scrollPixelsPerSecond`, `horizontalJumpPixels`, deadzones, repeat timing, and `buttonIndices` to match your controller. See [config.example.jsonc](/config.example.jsonc) for the full generated comments for every controller field.
|
||||
Tune `scrollPixelsPerSecond`, `horizontalJumpPixels`, deadzones, repeat timing, and profile `buttonIndices` to match your controller. See [config.example.jsonc](/config.example.jsonc) for the full generated comments for every controller field.
|
||||
|
||||
### Manual Card Update Shortcuts
|
||||
|
||||
@@ -1216,7 +1251,7 @@ Jellyfin integration is optional and disabled by default. When enabled, SubMiner
|
||||
| `directPlayContainers` | string[] | Container allowlist for direct play decisions |
|
||||
| `transcodeVideoCodec` | string | Preferred transcode video codec fallback (default: `h264`) |
|
||||
|
||||
Jellyfin auth session (`accessToken` + `userId`) is stored in local encrypted storage after login/setup.
|
||||
Jellyfin auth session (`accessToken` + `userId`) is stored in local encrypted storage after login/setup. The legacy `jellyfin.accessToken` and `jellyfin.userId` config keys are not resolver-backed settings in the current runtime and are hidden from the configuration window.
|
||||
|
||||
- On Linux, token storage defaults to `gnome-libsecret` for `safeStorage`. Override with `--password-store=<backend>` on launcher/app invocations when needed.
|
||||
|
||||
|
||||
@@ -174,9 +174,9 @@ subminer -u
|
||||
subminer --update
|
||||
```
|
||||
|
||||
SubMiner verifies launcher and Linux rofi theme downloads against `SHA256SUMS.txt`. If the launcher is installed in a protected path such as `/usr/local/bin/subminer`, SubMiner does not elevate itself; it shows the exact `sudo curl ... && sudo chmod +x ...` command to run instead.
|
||||
SubMiner verifies AppImage, launcher, and Linux rofi theme downloads against `SHA256SUMS.txt`. If the AppImage or launcher is installed in a protected path, SubMiner does not elevate itself; it shows the exact sudo command to run instead.
|
||||
|
||||
On Linux, `subminer -u` performs this update from the launcher process, so it does not need to start or IPC into the tray app.
|
||||
On Linux, `subminer -u` performs the AppImage update from the launcher process, so it does not need to start or IPC into the tray app.
|
||||
|
||||
### From Source
|
||||
|
||||
@@ -206,6 +206,8 @@ Download the **DMG** artifact from [GitHub Releases](https://github.com/ksyasuda
|
||||
|
||||
A **ZIP** artifact is also available as a fallback — unzip and drag `SubMiner.app` into `/Applications`.
|
||||
|
||||
After the first updater-enabled install, tray update checks can update the macOS app automatically through Electron's standard macOS updater. The updater uses the release ZIP as its payload even when the DMG remains the normal first-install artifact.
|
||||
|
||||
Install dependencies using Homebrew:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -99,6 +99,7 @@ Use `subminer <subcommand> -h` for command-specific help.
|
||||
| `-r, --recursive` | Search directories recursively |
|
||||
| `-R, --rofi` | Use rofi instead of fzf |
|
||||
| `--setup` | Open first-run setup popup manually |
|
||||
| `-v, --version` | Print installed SubMiner version |
|
||||
| `-u, --update` | Check for SubMiner updates and update the app/launcher when possible |
|
||||
| `--start` | Explicitly start overlay after mpv launches |
|
||||
| `-S, --start-overlay` | Explicitly start overlay after mpv launches |
|
||||
|
||||
@@ -65,7 +65,7 @@ With a gamepad connected and keyboard-only mode enabled, the full mining loop wo
|
||||
6. **Close** — press `B` to dismiss the Yomitan popup and return to subtitle navigation.
|
||||
7. **Pause/resume** — press `L3` (left stick click) to toggle mpv pause at any time.
|
||||
|
||||
The controller and keyboard can be used interchangeably — switching mid-session is seamless. Toggle keyboard-only mode on or off with `Y` on the controller.
|
||||
After controller support is enabled, the controller and keyboard can be used interchangeably — switching mid-session is seamless. Toggle keyboard-only mode on or off with `Y` on the controller.
|
||||
|
||||
See [Usage — Controller Support](/usage#controller-support) for setup details and [Configuration — Controller Support](/configuration#controller-support) for the full mapping and tuning options.
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
// Overlay Auto-Start
|
||||
// When overlay connects to mpv, automatically show overlay and hide mpv subtitles.
|
||||
// ==========================================
|
||||
"auto_start_overlay": false, // When overlay connects to mpv, automatically show overlay and hide mpv subtitles. Values: true | false
|
||||
"auto_start_overlay": false, // Auto-start the subtitle overlay window when SubMiner launches. Values: true | false
|
||||
|
||||
// ==========================================
|
||||
// Texthooker Server
|
||||
@@ -18,7 +18,7 @@
|
||||
// ==========================================
|
||||
"texthooker": {
|
||||
"launchAtStartup": false, // Launch texthooker server automatically when SubMiner starts. Values: true | false
|
||||
"openBrowser": false // Open browser setting. Values: true | false
|
||||
"openBrowser": false // Open the texthooker page in the default browser when the server starts. Values: true | false
|
||||
}, // Configure texthooker startup launch and browser opening behavior.
|
||||
|
||||
// ==========================================
|
||||
@@ -138,7 +138,8 @@
|
||||
"axisIndex": 4, // Raw axis index captured for this analog controller action.
|
||||
"dpadFallback": "none" // Optional D-pad fallback used when this analog controller action should also read D-pad input. Values: none | horizontal | vertical
|
||||
} // Axis binding descriptor used for popup page jumps. Use Alt+C learn mode or set a raw axis descriptor manually.
|
||||
} // Raw controller binding descriptors saved by Alt+C learn mode. For discrete axis bindings, kind "axis" requires axisIndex and direction.
|
||||
}, // Raw controller binding descriptors saved by Alt+C learn mode. For discrete axis bindings, kind "axis" requires axisIndex and direction.
|
||||
"profiles": {} // Per-controller binding and button-index overrides keyed by the controller id reported by the Gamepad API.
|
||||
}, // Gamepad support for the visible overlay while keyboard-only mode is active.
|
||||
|
||||
// ==========================================
|
||||
@@ -173,24 +174,24 @@
|
||||
// Hot-reload: shortcut changes apply live and update the session help modal on reopen.
|
||||
// ==========================================
|
||||
"shortcuts": {
|
||||
"toggleVisibleOverlayGlobal": "Alt+Shift+O", // Toggle visible overlay global setting.
|
||||
"copySubtitle": "CommandOrControl+C", // Copy subtitle setting.
|
||||
"copySubtitleMultiple": "CommandOrControl+Shift+C", // Copy subtitle multiple setting.
|
||||
"updateLastCardFromClipboard": "CommandOrControl+V", // Update last card from clipboard setting.
|
||||
"triggerFieldGrouping": "CommandOrControl+G", // Trigger field grouping setting.
|
||||
"triggerSubsync": "Ctrl+Alt+S", // Trigger subsync setting.
|
||||
"mineSentence": "CommandOrControl+S", // Mine sentence setting.
|
||||
"mineSentenceMultiple": "CommandOrControl+Shift+S", // Mine sentence multiple setting.
|
||||
"toggleVisibleOverlayGlobal": "Alt+Shift+O", // Global accelerator that toggles overlay visibility from anywhere on the system. Use null to disable.
|
||||
"copySubtitle": "CommandOrControl+C", // Accelerator that copies the current subtitle line to the clipboard.
|
||||
"copySubtitleMultiple": "CommandOrControl+Shift+C", // Accelerator that copies consecutive subtitle lines while the multi-copy window stays open.
|
||||
"updateLastCardFromClipboard": "CommandOrControl+V", // Accelerator that updates the last mined Anki card using the current clipboard contents.
|
||||
"triggerFieldGrouping": "CommandOrControl+G", // Accelerator that triggers Kiku field grouping on duplicate cards.
|
||||
"triggerSubsync": "Ctrl+Alt+S", // Accelerator that triggers subsync against the active subtitle file.
|
||||
"mineSentence": "CommandOrControl+S", // Accelerator that mines the current sentence as a new Anki card.
|
||||
"mineSentenceMultiple": "CommandOrControl+Shift+S", // Accelerator that mines consecutive sentences while the multi-mine window stays open.
|
||||
"multiCopyTimeoutMs": 3000, // Timeout for multi-copy/mine modes.
|
||||
"toggleSecondarySub": "CommandOrControl+Shift+V", // Toggle secondary sub setting.
|
||||
"markAudioCard": "CommandOrControl+Shift+A", // Mark audio card setting.
|
||||
"openCharacterDictionary": "CommandOrControl+Alt+A", // Open character dictionary setting.
|
||||
"openRuntimeOptions": "CommandOrControl+Shift+O", // Open runtime options setting.
|
||||
"openJimaku": "Ctrl+Shift+J", // Open jimaku setting.
|
||||
"openSessionHelp": "CommandOrControl+Slash", // Open session help setting.
|
||||
"openControllerSelect": "Alt+C", // Open controller select setting.
|
||||
"openControllerDebug": "Alt+Shift+C", // Open controller debug setting.
|
||||
"toggleSubtitleSidebar": "Backslash" // Toggle subtitle sidebar setting.
|
||||
"toggleSecondarySub": "CommandOrControl+Shift+V", // Accelerator that toggles the secondary subtitle bar visibility.
|
||||
"markAudioCard": "CommandOrControl+Shift+A", // Accelerator that marks the last mined card as an audio card.
|
||||
"openCharacterDictionary": "CommandOrControl+Alt+A", // Accelerator that opens the character dictionary modal.
|
||||
"openRuntimeOptions": "CommandOrControl+Shift+O", // Accelerator that opens the runtime options modal.
|
||||
"openJimaku": "Ctrl+Shift+J", // Accelerator that opens the Jimaku subtitle search modal.
|
||||
"openSessionHelp": "CommandOrControl+Slash", // Accelerator that opens the session help / keybinding cheatsheet.
|
||||
"openControllerSelect": "Alt+C", // Accelerator that opens the controller selection and learn-mode modal.
|
||||
"openControllerDebug": "Alt+Shift+C", // Accelerator that opens the controller debug modal with live axis/button readouts.
|
||||
"toggleSubtitleSidebar": "Backslash" // Accelerator that toggles the subtitle sidebar visibility.
|
||||
}, // Overlay keyboard shortcuts. Set a shortcut to null to disable.
|
||||
|
||||
// ==========================================
|
||||
@@ -327,9 +328,9 @@
|
||||
// Hot-reload: defaultMode updates live while SubMiner is running.
|
||||
// ==========================================
|
||||
"secondarySub": {
|
||||
"secondarySubLanguages": [], // Secondary sub languages setting.
|
||||
"autoLoadSecondarySub": false, // Auto load secondary sub setting. Values: true | false
|
||||
"defaultMode": "hover" // Default mode setting.
|
||||
"secondarySubLanguages": [], // Language code priority list used to auto-select a secondary subtitle track when available.
|
||||
"autoLoadSecondarySub": false, // Automatically load a matching secondary subtitle when the primary subtitle loads. Values: true | false
|
||||
"defaultMode": "hover" // Default visibility mode for the secondary subtitle bar. Values: hidden | visible | hover
|
||||
}, // Dual subtitle track options.
|
||||
|
||||
// ==========================================
|
||||
@@ -338,9 +339,9 @@
|
||||
// ==========================================
|
||||
"subsync": {
|
||||
"defaultMode": "auto", // Subsync default mode. Values: auto | manual
|
||||
"alass_path": "", // Alass path setting.
|
||||
"ffsubsync_path": "", // Ffsubsync path setting.
|
||||
"ffmpeg_path": "", // Ffmpeg path setting.
|
||||
"alass_path": "", // Optional absolute path to the alass binary used by subsync. Leave empty to auto-discover from PATH.
|
||||
"ffsubsync_path": "", // Optional absolute path to the ffsubsync binary used by subsync. Leave empty to auto-discover from PATH.
|
||||
"ffmpeg_path": "", // Optional absolute path to the ffmpeg binary used by subsync. Leave empty to auto-discover from PATH.
|
||||
"replace": true // Replace the active subtitle file when sync completes. Values: true | false
|
||||
}, // Subsync engine and executable paths.
|
||||
|
||||
@@ -349,7 +350,7 @@
|
||||
// Initial vertical subtitle position from the bottom.
|
||||
// ==========================================
|
||||
"subtitlePosition": {
|
||||
"yPercent": 10 // Y percent setting.
|
||||
"yPercent": 10 // Vertical position of the subtitle overlay expressed as a percentage from the bottom of the screen.
|
||||
}, // Initial vertical subtitle position from the bottom.
|
||||
|
||||
// ==========================================
|
||||
@@ -453,9 +454,9 @@
|
||||
"enabled": false, // Enable shared OpenAI-compatible AI provider features. Values: true | false
|
||||
"apiKey": "", // Static API key for the shared OpenAI-compatible AI provider.
|
||||
"apiKeyCommand": "", // Shell command used to resolve the shared AI provider API key.
|
||||
"model": "openai/gpt-4o-mini", // Model setting.
|
||||
"model": "openai/gpt-4o-mini", // Default model identifier requested from the shared AI provider.
|
||||
"baseUrl": "https://openrouter.ai/api", // Base URL for the shared OpenAI-compatible AI provider.
|
||||
"systemPrompt": "You are a translation engine. Return only the translated text with no explanations.", // System prompt setting.
|
||||
"systemPrompt": "You are a translation engine. Return only the translated text with no explanations.", // Default system prompt sent with shared AI provider requests.
|
||||
"requestTimeoutMs": 15000 // Timeout in milliseconds for shared AI provider requests.
|
||||
}, // Canonical OpenAI-compatible provider transport settings shared by Anki and YouTube subtitle fixing.
|
||||
|
||||
@@ -468,7 +469,7 @@
|
||||
// ==========================================
|
||||
"ankiConnect": {
|
||||
"enabled": true, // Enable AnkiConnect integration. Values: true | false
|
||||
"url": "http://127.0.0.1:8765", // Url setting.
|
||||
"url": "http://127.0.0.1:8765", // Base URL of the AnkiConnect HTTP server.
|
||||
"pollingRate": 3000, // Polling interval in milliseconds.
|
||||
"proxy": {
|
||||
"enabled": true, // Enable local AnkiConnect-compatible proxy for push-based auto-enrichment. Values: true | false
|
||||
@@ -481,11 +482,11 @@
|
||||
], // Tags to add to cards mined or updated by SubMiner. Provide an empty array to disable automatic tagging.
|
||||
"fields": {
|
||||
"word": "Expression", // Card field for the mined word or expression text.
|
||||
"audio": "ExpressionAudio", // Audio setting.
|
||||
"image": "Picture", // Image setting.
|
||||
"sentence": "Sentence", // Sentence setting.
|
||||
"miscInfo": "MiscInfo", // Misc info setting.
|
||||
"translation": "SelectionText" // Translation setting.
|
||||
"audio": "ExpressionAudio", // Card field that receives generated sentence audio.
|
||||
"image": "Picture", // Card field that receives the captured screenshot or animated image.
|
||||
"sentence": "Sentence", // Card field that receives the source sentence text.
|
||||
"miscInfo": "MiscInfo", // Card field that receives the miscellaneous info pattern (see ankiConnect.metadata.pattern).
|
||||
"translation": "SelectionText" // Card field that receives the current selection or translated text.
|
||||
}, // Fields setting.
|
||||
"ai": {
|
||||
"enabled": false, // Enable AI provider usage for Anki translation/enrichment flows. Values: true | false
|
||||
@@ -493,18 +494,18 @@
|
||||
"systemPrompt": "" // Optional system prompt override for Anki AI translation/enrichment flows.
|
||||
}, // Ai setting.
|
||||
"media": {
|
||||
"generateAudio": true, // Generate audio setting. Values: true | false
|
||||
"generateImage": true, // Generate image setting. Values: true | false
|
||||
"imageType": "static", // Image type setting.
|
||||
"imageFormat": "jpg", // Image format setting.
|
||||
"imageQuality": 92, // Image quality setting.
|
||||
"animatedFps": 10, // Animated fps setting.
|
||||
"animatedMaxWidth": 640, // Animated max width setting.
|
||||
"animatedCrf": 35, // Animated crf setting.
|
||||
"generateAudio": true, // Generate sentence audio for mined cards. Values: true | false
|
||||
"generateImage": true, // Generate screenshot or animated image for mined cards. Values: true | false
|
||||
"imageType": "static", // Image capture type: "static" for a single still frame, "avif" for an animated AVIF. Values: static | avif
|
||||
"imageFormat": "jpg", // Encoding format used when imageType is "static". Values: jpg | png | webp
|
||||
"imageQuality": 92, // Quality (0-100) used for lossy static image encoders.
|
||||
"animatedFps": 10, // Target frame rate for animated AVIF captures.
|
||||
"animatedMaxWidth": 640, // Maximum width applied to animated AVIF captures.
|
||||
"animatedCrf": 35, // Animated AVIF CRF quality target. Lower values produce larger, higher-quality files.
|
||||
"syncAnimatedImageToWordAudio": true, // For animated AVIF images, prepend a frozen first frame matching the existing word-audio duration so motion starts with sentence audio. Values: true | false
|
||||
"audioPadding": 0.5, // Audio padding setting.
|
||||
"fallbackDuration": 3, // Fallback duration setting.
|
||||
"maxMediaDuration": 30 // Max media duration setting.
|
||||
"audioPadding": 0.5, // Seconds of padding appended to both ends of generated sentence audio.
|
||||
"fallbackDuration": 3, // Fallback clip duration in seconds when subtitle timing data is unavailable.
|
||||
"maxMediaDuration": 30 // Maximum allowed media clip duration in seconds.
|
||||
}, // Media setting.
|
||||
"knownWords": {
|
||||
"highlightEnabled": false, // Enable fast local highlighting for words already known in Anki. Values: true | false
|
||||
@@ -515,11 +516,11 @@
|
||||
"color": "#a6da95" // Color used for known-word highlights.
|
||||
}, // Known words setting.
|
||||
"behavior": {
|
||||
"overwriteAudio": true, // Overwrite audio setting. Values: true | false
|
||||
"overwriteImage": true, // Overwrite image setting. Values: true | false
|
||||
"mediaInsertMode": "append", // Media insert mode setting.
|
||||
"highlightWord": true, // Highlight word setting. Values: true | false
|
||||
"notificationType": "osd", // Notification type setting.
|
||||
"overwriteAudio": true, // When updating an existing card, overwrite the audio field instead of skipping it. Values: true | false
|
||||
"overwriteImage": true, // When updating an existing card, overwrite the image field instead of skipping it. Values: true | false
|
||||
"mediaInsertMode": "append", // Whether new media is appended after or prepended before existing field contents on update. Values: append | prepend
|
||||
"highlightWord": true, // Bold the mined word inside the sentence field on the saved Anki card. Values: true | false
|
||||
"notificationType": "osd", // Notification surface used to announce mining and update outcomes. Values: osd | system | both | none
|
||||
"autoUpdateNewCards": true // Automatically update newly added cards. Values: true | false
|
||||
}, // Behavior setting.
|
||||
"nPlusOne": {
|
||||
@@ -527,16 +528,16 @@
|
||||
"nPlusOne": "#c6a0f6" // Color used for the single N+1 target token highlight.
|
||||
}, // N plus one setting.
|
||||
"metadata": {
|
||||
"pattern": "[SubMiner] %f (%t)" // Pattern setting.
|
||||
"pattern": "[SubMiner] %f (%t)" // Template used to render the miscInfo field. Placeholders include %f (filename) and %t (timestamp).
|
||||
}, // Metadata setting.
|
||||
"isLapis": {
|
||||
"enabled": false, // Enabled setting. Values: true | false
|
||||
"sentenceCardModel": "Japanese sentences" // Sentence card model setting.
|
||||
"enabled": false, // Enable Lapis-specific mining behaviors and sentence card model targeting. Values: true | false
|
||||
"sentenceCardModel": "Japanese sentences" // Note type name used by Lapis sentence cards.
|
||||
}, // Is lapis setting.
|
||||
"isKiku": {
|
||||
"enabled": false, // Enabled setting. Values: true | false
|
||||
"enabled": false, // Enable Kiku-specific mining behaviors (duplicate handling, field grouping). Values: true | false
|
||||
"fieldGrouping": "disabled", // Kiku duplicate-card field grouping mode. Values: auto | manual | disabled
|
||||
"deleteDuplicateInAuto": true // Delete duplicate in auto setting. Values: true | false
|
||||
"deleteDuplicateInAuto": true // When Kiku field grouping is "auto", delete the duplicate source card after grouping completes. Values: true | false
|
||||
} // Is kiku setting.
|
||||
}, // Automatic Anki updates and media generation options.
|
||||
|
||||
@@ -545,7 +546,7 @@
|
||||
// Jimaku API configuration and defaults.
|
||||
// ==========================================
|
||||
"jimaku": {
|
||||
"apiBaseUrl": "https://jimaku.cc", // Api base url setting.
|
||||
"apiBaseUrl": "https://jimaku.cc", // Base URL of the Jimaku subtitle search API.
|
||||
"languagePreference": "ja", // Preferred language used in Jimaku search. Values: ja | en | none
|
||||
"maxEntryResults": 10 // Maximum Jimaku search results returned.
|
||||
}, // Jimaku API configuration and defaults.
|
||||
@@ -617,9 +618,9 @@
|
||||
"serverUrl": "", // Base Jellyfin server URL (for example: http://localhost:8096).
|
||||
"recentServers": [], // Recently authenticated Jellyfin server URLs shown in setup.
|
||||
"username": "", // Default Jellyfin username used during CLI login.
|
||||
"deviceId": "subminer", // Device id setting.
|
||||
"clientName": "SubMiner", // Client name setting.
|
||||
"clientVersion": "0.1.0", // Client version setting.
|
||||
"deviceId": "subminer", // Stable device identifier sent on the Jellyfin authentication handshake; primarily internal.
|
||||
"clientName": "SubMiner", // Client name sent on the Jellyfin authentication handshake; primarily internal.
|
||||
"clientVersion": "0.1.0", // Client version sent on the Jellyfin authentication handshake; primarily internal.
|
||||
"defaultLibraryId": "", // Optional default Jellyfin library ID for item listing.
|
||||
"remoteControlEnabled": true, // Enable Jellyfin remote cast control mode. Values: true | false
|
||||
"remoteControlAutoConnect": true, // Auto-connect to the configured remote control target. Values: true | false
|
||||
|
||||
+9
-6
@@ -78,6 +78,8 @@ subminer -S video.mkv # Same as above via --start-overlay
|
||||
subminer https://youtu.be/... # Play a YouTube URL
|
||||
subminer ytsearch:"jp news" # Play first YouTube search result
|
||||
subminer --setup # Open first-run setup popup
|
||||
subminer --version # Print installed SubMiner version
|
||||
subminer -v # Same as above
|
||||
subminer --log-level debug video.mkv # Enable verbose logs for launch/debugging
|
||||
subminer --log-level warn video.mkv # Set logging level explicitly
|
||||
subminer --args '--fs=opengl-hq --ytdl-format=bestvideo*+bestaudio/best' video.mkv # Pass extra mpv args
|
||||
@@ -283,13 +285,14 @@ SubMiner supports gamepad/controller input for couch-friendly usage via the Chro
|
||||
### Getting Started
|
||||
|
||||
1. Connect a controller before or after launching SubMiner.
|
||||
2. Enable keyboard-only mode — press `Y` on the controller (default binding) or use the overlay keybinding.
|
||||
2. Set `controller.enabled` to `true` in your config.
|
||||
3. Press `Alt+C` in the overlay by default to pick the controller you want to save and remap any action inline.
|
||||
4. Click `Learn` on the overlay action you want, then press the matching button, trigger, or stick direction on the controller.
|
||||
5. Use the left stick to navigate subtitle tokens and scroll the popup; use the right stick vertically for popup page jumps.
|
||||
6. Press `A` to look up the selected word, `X` to mine a card, `B` to close the popup.
|
||||
4. Enable keyboard-only mode — press `Y` on the controller (default binding) or use the overlay keybinding.
|
||||
5. Click the binding badge, edit pencil, or `Learn` on the overlay action you want, then press the matching button, trigger, or stick direction on the controller.
|
||||
6. Use the left stick to navigate subtitle tokens and scroll the popup; use the right stick vertically for popup page jumps.
|
||||
7. Press `A` to look up the selected word, `X` to mine a card, `B` to close the popup.
|
||||
|
||||
By default SubMiner uses the first connected controller. `Alt+C` opens the controller config modal, where you can save the preferred controller and remap bindings inline, and `Alt+Shift+C` opens the live debug modal with raw axes/button values for non-standard pads. Both shortcuts can be changed through `shortcuts.openControllerSelect` and `shortcuts.openControllerDebug`.
|
||||
By default SubMiner uses the first connected controller after controller support is enabled. `Alt+C` opens the controller config modal, where you can save the preferred controller and remap bindings inline per controller. The reset button beside each edit pencil restores that binding to its built-in default for the selected controller. `Alt+Shift+C` opens the live debug modal with raw axes/button values for non-standard pads. Both modals stay closed while `controller.enabled` is false, and both shortcuts can be changed through `shortcuts.openControllerSelect` and `shortcuts.openControllerDebug`.
|
||||
|
||||
### Default Button Mapping
|
||||
|
||||
@@ -316,7 +319,7 @@ By default SubMiner uses the first connected controller. `Alt+C` opens the contr
|
||||
|
||||
Learn mode ignores already-held inputs and waits for the next fresh button press or axis direction, which avoids accidental captures when you open the modal mid-input.
|
||||
|
||||
All button and axis mappings are configurable under the `controller` config block. See [Configuration — Controller Support](/configuration#controller-support) for the full options.
|
||||
All button and axis mappings are configurable under the `controller` config block. Learned remaps are saved under `controller.profiles` for the selected controller id. See [Configuration — Controller Support](/configuration#controller-support) for the full options.
|
||||
|
||||
## Keybindings
|
||||
|
||||
|
||||
+10
-4
@@ -57,8 +57,11 @@
|
||||
`*.yml` and `*.blockmap` files under `release/`.
|
||||
5. Commit the prerelease prep (package.json version bump + the generated
|
||||
`release/prerelease-notes.md`). CI does not regenerate notes — it uses the
|
||||
committed file — so review it before committing. Do not run
|
||||
`bun run changelog:build`.
|
||||
committed file — so review it before committing. If you add more
|
||||
`changes/*.md` fragments for a later beta/RC, rerun
|
||||
`bun run changelog:prerelease-notes --version <version>`; the generator uses
|
||||
the existing prerelease notes as the baseline and asks Claude to merge only
|
||||
the new fragment material. Do not run `bun run changelog:build`.
|
||||
6. Tag the commit: `git tag v<version>`.
|
||||
7. Push commit + tag.
|
||||
|
||||
@@ -70,16 +73,19 @@ Notes:
|
||||
- Supported prerelease channels are `beta` and `rc`, with versions like `0.11.3-beta.1` and `0.11.3-rc.1`.
|
||||
- Pass `--date` explicitly when you want the release stamped with the local cut date; otherwise the generator uses the current ISO date, which can roll over to the next UTC day late at night.
|
||||
- `changelog:check` now rejects tag/package version mismatches.
|
||||
- `changelog:prerelease-notes` also rejects tag/package version mismatches and writes `release/prerelease-notes.md` without mutating tracked changelog files.
|
||||
- `changelog:prerelease-notes` also rejects tag/package version mismatches and writes `release/prerelease-notes.md` without mutating tracked changelog files. When that file already exists, the generator includes it in the Claude prompt so later beta/RC notes reuse the reviewed text instead of starting over.
|
||||
- `changelog:build` generates `CHANGELOG.md` + `release/release-notes.md` (both polished by `claude -p`) and removes the released `changes/*.md` fragments. The CHANGELOG keeps internal notes inside a `<details><summary>Internal changes</summary>` collapse; the release notes drop them entirely.
|
||||
- The release workflow no longer auto-runs `changelog:build`. If pending `changes/*.md` fragments are present on a tag-based run, CI exits with a clear `::error::` pointing at the local fix. Run `bun run changelog:build --version <version>` locally, commit the polished output, then tag.
|
||||
- Do not tag while `changes/*.md` fragments still exist.
|
||||
- Prerelease tags intentionally keep `changes/*.md` fragments in place so multiple prereleases can reuse the same cumulative pending notes until the final stable cut.
|
||||
- Prerelease tags intentionally keep `changes/*.md` fragments in place so multiple prereleases can reuse the same cumulative pending notes until the final stable cut. `make clean` preserves `release/prerelease-notes.md` while deleting generated build artifacts.
|
||||
- If you need to repair a published release body (for example, a prior version’s section was omitted), regenerate notes from `CHANGELOG.md` and re-edit the release with `gh release edit --notes-file`.
|
||||
- Prerelease tags are handled by `.github/workflows/prerelease.yml`, which always publishes a GitHub prerelease with all current release platforms and never runs the AUR sync job.
|
||||
- Tagged release workflow now also attempts to update `subminer-bin` on the AUR after GitHub Release publication.
|
||||
- AUR publish is best-effort: the workflow retries transient SSH clone/push failures, then warns and leaves the GitHub Release green if AUR still fails. Follow up with a manual `git push aur master` from the AUR checkout when needed.
|
||||
- Required GitHub Actions secret: `AUR_SSH_PRIVATE_KEY`. Add the matching public key to your AUR account before relying on the automation.
|
||||
- Release and prerelease workflows upload updater metadata (`*.yml`) and blockmaps (`*.blockmap`) alongside platform artifacts. Do not remove those files while `electron-updater` is enabled.
|
||||
- macOS tray app updates use the standard `electron-updater`/Squirrel path. Keep `latest-mac.yml`, the macOS ZIP, and ZIP blockmap published; Squirrel uses the ZIP payload even when the DMG remains the user-facing installer.
|
||||
- macOS update metadata and full ZIP downloads are routed through `/usr/bin/curl` before Squirrel installation to avoid Electron main-process network crashes on update checks.
|
||||
- Local macOS build-output apps outside `/Applications` or `~/Applications` skip native update checks. To validate auto-update end to end, install the signed and notarized app bundle into one of those Applications folders and point it at a published updater feed.
|
||||
- The first updater-enabled release cannot update older installs automatically. Users need one manual install to get the updater code.
|
||||
- Stable auto-update checks ignore beta/RC prereleases by default. Set `updates.channel` to `"prerelease"` on a test install when validating beta/RC updater behavior.
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
# Config Settings Window
|
||||
|
||||
read_when: changing config UI, config save behavior, or config docs
|
||||
|
||||
## Intent
|
||||
|
||||
Add a dedicated Electron settings window for editing canonical config values without exposing the historical layout mistakes in `config.jsonc`.
|
||||
|
||||
The UI groups options by workflow:
|
||||
|
||||
- Viewing
|
||||
- Mining & Anki
|
||||
- Playback & Sources
|
||||
- Input
|
||||
- Integrations
|
||||
- Tracking & App
|
||||
- Advanced
|
||||
|
||||
Each field maps back to its current raw config path. The presentation layer must stay separate from generated config-template sections.
|
||||
|
||||
## Sources
|
||||
|
||||
- Canonical defaults: `DEFAULT_CONFIG`
|
||||
- Existing option descriptions/enums: `CONFIG_OPTION_REGISTRY`
|
||||
- UI registry: `src/config/settings/registry.ts`
|
||||
- JSONC save path: `src/config/settings/jsonc-edit.ts`
|
||||
- Window runtime: `src/main/runtime/config-settings-window.ts`
|
||||
|
||||
## Save Contract
|
||||
|
||||
Settings writes use `jsonc-parser.modify`, not `JSON.stringify`.
|
||||
|
||||
Required behavior:
|
||||
|
||||
- Preserve comments, trailing commas, unrelated keys, and hidden legacy keys.
|
||||
- Reset removes the explicit path so defaults resolve normally.
|
||||
- Validate the candidate config before writing.
|
||||
- Reject warnings caused by modified fields.
|
||||
- Preserve unrelated existing warnings and return them in the snapshot.
|
||||
- Write atomically, reload `ConfigService`, classify with existing hot-reload logic, and apply live changes where supported.
|
||||
- Never return secret values to the renderer; snapshots only expose configured/not-configured state.
|
||||
|
||||
## Hidden Compatibility Keys
|
||||
|
||||
Do not expose these as first-class controls:
|
||||
|
||||
- `ankiConnect.deck`
|
||||
- Legacy top-level Anki migration fields such as `wordField`, `audioField`, media-generation aliases, and behavior aliases
|
||||
- Legacy `ankiConnect.nPlusOne.*` aliases except canonical `nPlusOne.nPlusOne` and `nPlusOne.minSentenceWords`
|
||||
- Deprecated Lapis sentence-card fields
|
||||
- `youtubeSubgen.primarySubLanguages`
|
||||
- `anilist.characterDictionary.refreshTtlHours`
|
||||
- `anilist.characterDictionary.evictionPolicy`
|
||||
- `jellyfin.accessToken`
|
||||
- `jellyfin.userId`
|
||||
- `controller.buttonIndices` as a normal editable field
|
||||
|
||||
## Verification
|
||||
|
||||
Minimum targeted checks:
|
||||
|
||||
- `bun test src/config/settings/registry.test.ts src/config/settings/jsonc-edit.test.ts src/settings/settings-model.test.ts src/main/runtime/config-settings-window.test.ts`
|
||||
- `bun run test:config`
|
||||
- `bun run typecheck`
|
||||
- `bun run build`
|
||||
|
||||
If docs changed:
|
||||
|
||||
- `bun run docs:test`
|
||||
- `bun run docs:build`
|
||||
@@ -3,7 +3,14 @@ import type { LauncherCommandContext } from './context.js';
|
||||
|
||||
export function runAppPassthroughCommand(context: LauncherCommandContext): boolean {
|
||||
const { args, appPath } = context;
|
||||
if (!args.appPassthrough || !appPath) {
|
||||
if (!appPath) {
|
||||
return false;
|
||||
}
|
||||
if (args.configSettings) {
|
||||
runAppCommandWithInherit(appPath, ['--config']);
|
||||
return true;
|
||||
}
|
||||
if (!args.appPassthrough) {
|
||||
return false;
|
||||
}
|
||||
runAppCommandWithInherit(appPath, args.appArgs);
|
||||
|
||||
@@ -52,6 +52,8 @@ function createContext(): LauncherCommandContext {
|
||||
stats: false,
|
||||
doctor: false,
|
||||
doctorRefreshKnownWords: false,
|
||||
version: false,
|
||||
configSettings: false,
|
||||
configPath: false,
|
||||
configShow: false,
|
||||
mpvIdle: false,
|
||||
|
||||
@@ -159,6 +159,41 @@ test('applyInvocationsToArgs maps config and jellyfin invocation state', () => {
|
||||
assert.equal(parsed.logLevel, 'warn');
|
||||
});
|
||||
|
||||
test('applyInvocationsToArgs maps bare config invocation to settings window', () => {
|
||||
const parsed = createDefaultArgs({});
|
||||
|
||||
applyInvocationsToArgs(parsed, {
|
||||
jellyfinInvocation: null,
|
||||
configInvocation: {
|
||||
action: undefined,
|
||||
},
|
||||
mpvInvocation: null,
|
||||
appInvocation: null,
|
||||
dictionaryTriggered: false,
|
||||
dictionaryTarget: null,
|
||||
dictionaryLogLevel: null,
|
||||
dictionaryCandidates: false,
|
||||
dictionarySelect: false,
|
||||
dictionaryAnilistId: null,
|
||||
statsTriggered: false,
|
||||
statsBackground: false,
|
||||
statsStop: false,
|
||||
statsCleanup: false,
|
||||
statsCleanupVocab: false,
|
||||
statsCleanupLifetime: false,
|
||||
statsLogLevel: null,
|
||||
doctorTriggered: false,
|
||||
doctorLogLevel: null,
|
||||
doctorRefreshKnownWords: false,
|
||||
texthookerTriggered: false,
|
||||
texthookerLogLevel: null,
|
||||
texthookerOpenBrowser: false,
|
||||
});
|
||||
|
||||
assert.equal(parsed.configSettings, true);
|
||||
assert.equal(parsed.configPath, false);
|
||||
});
|
||||
|
||||
test('applyInvocationsToArgs maps texthooker browser-open request', () => {
|
||||
const parsed = createDefaultArgs({});
|
||||
|
||||
|
||||
@@ -156,7 +156,9 @@ export function createDefaultArgs(
|
||||
statsCleanupLifetime: false,
|
||||
doctor: false,
|
||||
doctorRefreshKnownWords: false,
|
||||
version: false,
|
||||
update: false,
|
||||
configSettings: false,
|
||||
configPath: false,
|
||||
configShow: false,
|
||||
mpvIdle: false,
|
||||
@@ -219,6 +221,8 @@ export function applyRootOptionsToArgs(
|
||||
if (typeof options.passwordStore === 'string') parsed.passwordStore = options.passwordStore;
|
||||
if (options.rofi === true) parsed.useRofi = true;
|
||||
if (options.update === true) parsed.update = true;
|
||||
if (options.version === true) parsed.version = true;
|
||||
if (options.config === true) parsed.configSettings = true;
|
||||
if (options.startOverlay === true) parsed.autoStartOverlay = true;
|
||||
if (options.texthooker === false) parsed.useTexthooker = false;
|
||||
if (typeof options.args === 'string') parsed.mpvArgs = options.args;
|
||||
@@ -306,8 +310,9 @@ export function applyInvocationsToArgs(parsed: Args, invocations: CliInvocations
|
||||
if (invocations.configInvocation.logLevel) {
|
||||
parsed.logLevel = parseLogLevel(invocations.configInvocation.logLevel);
|
||||
}
|
||||
const action = (invocations.configInvocation.action || 'path').toLowerCase();
|
||||
if (action === 'path') parsed.configPath = true;
|
||||
const action = (invocations.configInvocation.action || '').toLowerCase();
|
||||
if (!action) parsed.configSettings = true;
|
||||
else if (action === 'path') parsed.configPath = true;
|
||||
else if (action === 'show') parsed.configShow = true;
|
||||
else fail(`Unknown config action: ${invocations.configInvocation.action}`);
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ export interface JellyfinInvocation {
|
||||
}
|
||||
|
||||
export interface CommandActionInvocation {
|
||||
action: string;
|
||||
action?: string;
|
||||
logLevel?: string;
|
||||
}
|
||||
|
||||
@@ -57,6 +57,8 @@ function applyRootOptions(program: Command): void {
|
||||
.option('-p, --profile <profile>', 'MPV profile')
|
||||
.option('--start', 'Explicitly start overlay')
|
||||
.option('--log-level <level>', 'Log level')
|
||||
.option('-v, --version', 'Show SubMiner version')
|
||||
.option('--config', 'Open configuration window')
|
||||
.option('-u, --update', 'Check for updates')
|
||||
.option('-R, --rofi', 'Use rofi picker')
|
||||
.option('-S, --start-overlay', 'Auto-start overlay')
|
||||
@@ -292,9 +294,9 @@ export function parseCliPrograms(
|
||||
commandProgram
|
||||
.command('config')
|
||||
.description('Config helpers')
|
||||
.argument('[action]', 'path|show', 'path')
|
||||
.argument('[action]', 'path|show')
|
||||
.option('--log-level <level>', 'Log level')
|
||||
.action((action: string, options: Record<string, unknown>) => {
|
||||
.action((action: string | undefined, options: Record<string, unknown>) => {
|
||||
configInvocation = {
|
||||
action,
|
||||
logLevel: typeof options.logLevel === 'string' ? options.logLevel : undefined,
|
||||
|
||||
@@ -99,6 +99,30 @@ test('config discovery ignores lowercase subminer candidate', () => {
|
||||
assert.equal(resolved, expected);
|
||||
});
|
||||
|
||||
test('version flag prints installed app version without requiring app binary', () => {
|
||||
withTempDir((root) => {
|
||||
const homeDir = path.join(root, 'home');
|
||||
const xdgConfigHome = path.join(root, 'xdg');
|
||||
const result = runLauncher(['--version'], makeTestEnv(homeDir, xdgConfigHome));
|
||||
|
||||
assert.equal(result.status, 0);
|
||||
assert.match(result.stdout.trim(), /^SubMiner \d+\.\d+\.\d+/);
|
||||
assert.equal(result.stderr, '');
|
||||
});
|
||||
});
|
||||
|
||||
test('short version flag prints installed app version without requiring app binary', () => {
|
||||
withTempDir((root) => {
|
||||
const homeDir = path.join(root, 'home');
|
||||
const xdgConfigHome = path.join(root, 'xdg');
|
||||
const result = runLauncher(['-v'], makeTestEnv(homeDir, xdgConfigHome));
|
||||
|
||||
assert.equal(result.status, 0);
|
||||
assert.match(result.stdout.trim(), /^SubMiner \d+\.\d+\.\d+/);
|
||||
assert.equal(result.stderr, '');
|
||||
});
|
||||
});
|
||||
|
||||
test('config path prefers jsonc over json for same directory', () => {
|
||||
withTempDir((root) => {
|
||||
const homeDir = path.join(root, 'home');
|
||||
@@ -208,6 +232,54 @@ test('doctor refresh-known-words forwards app refresh command without requiring
|
||||
});
|
||||
});
|
||||
|
||||
test('launcher config option forwards app configuration window command', () => {
|
||||
withTempDir((root) => {
|
||||
const homeDir = path.join(root, 'home');
|
||||
const xdgConfigHome = path.join(root, 'xdg');
|
||||
const appPath = path.join(root, 'fake-subminer.sh');
|
||||
const capturePath = path.join(root, 'captured-args.txt');
|
||||
fs.writeFileSync(
|
||||
appPath,
|
||||
'#!/bin/sh\nif [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n',
|
||||
);
|
||||
fs.chmodSync(appPath, 0o755);
|
||||
|
||||
const env = {
|
||||
...makeTestEnv(homeDir, xdgConfigHome),
|
||||
SUBMINER_APPIMAGE_PATH: appPath,
|
||||
SUBMINER_TEST_CAPTURE: capturePath,
|
||||
};
|
||||
const result = runLauncher(['--config'], env);
|
||||
|
||||
assert.equal(result.status, 0);
|
||||
assert.equal(fs.readFileSync(capturePath, 'utf8'), '--config\n');
|
||||
});
|
||||
});
|
||||
|
||||
test('launcher config command forwards app configuration window command', () => {
|
||||
withTempDir((root) => {
|
||||
const homeDir = path.join(root, 'home');
|
||||
const xdgConfigHome = path.join(root, 'xdg');
|
||||
const appPath = path.join(root, 'fake-subminer.sh');
|
||||
const capturePath = path.join(root, 'captured-args.txt');
|
||||
fs.writeFileSync(
|
||||
appPath,
|
||||
'#!/bin/sh\nif [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n',
|
||||
);
|
||||
fs.chmodSync(appPath, 0o755);
|
||||
|
||||
const env = {
|
||||
...makeTestEnv(homeDir, xdgConfigHome),
|
||||
SUBMINER_APPIMAGE_PATH: appPath,
|
||||
SUBMINER_TEST_CAPTURE: capturePath,
|
||||
};
|
||||
const result = runLauncher(['config'], env);
|
||||
|
||||
assert.equal(result.status, 0);
|
||||
assert.equal(fs.readFileSync(capturePath, 'utf8'), '--config\n');
|
||||
});
|
||||
});
|
||||
|
||||
test('launcher forwards --args to mpv as parsed tokens', { timeout: 15000 }, () => {
|
||||
withTempDir((root) => {
|
||||
const homeDir = path.join(root, 'home');
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import path from 'node:path';
|
||||
import packageJson from '../package.json';
|
||||
import {
|
||||
loadLauncherJellyfinConfig,
|
||||
loadLauncherMpvConfig,
|
||||
@@ -20,6 +21,11 @@ import { runJellyfinCommand } from './commands/jellyfin-command.js';
|
||||
import { runPlaybackCommand } from './commands/playback-command.js';
|
||||
import { runUpdateCommand } from './commands/update-command.js';
|
||||
|
||||
const APP_VERSION =
|
||||
typeof packageJson.version === 'string' && packageJson.version.trim()
|
||||
? packageJson.version
|
||||
: 'unknown';
|
||||
|
||||
function createCommandContext(
|
||||
args: ReturnType<typeof parseArgs>,
|
||||
scriptPath: string,
|
||||
@@ -56,6 +62,12 @@ async function main(): Promise<void> {
|
||||
const launcherConfig = loadLauncherYoutubeSubgenConfig();
|
||||
const launcherMpvConfig = loadLauncherMpvConfig();
|
||||
const args = parseArgs(process.argv.slice(2), scriptName, launcherConfig, launcherMpvConfig);
|
||||
|
||||
if (args.version) {
|
||||
console.log(`SubMiner ${APP_VERSION}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const pluginRuntimeConfig = readPluginRuntimeConfig(args.logLevel);
|
||||
const appPath = findAppBinary(scriptPath);
|
||||
|
||||
|
||||
@@ -529,6 +529,8 @@ function makeArgs(overrides: Partial<Args> = {}): Args {
|
||||
stats: false,
|
||||
doctor: false,
|
||||
doctorRefreshKnownWords: false,
|
||||
version: false,
|
||||
configSettings: false,
|
||||
configPath: false,
|
||||
configShow: false,
|
||||
mpvIdle: false,
|
||||
|
||||
@@ -57,6 +57,12 @@ test('parseArgs captures mpv args string', () => {
|
||||
assert.equal(parsed.mpvArgs, '--pause=yes --title="movie night"');
|
||||
});
|
||||
|
||||
test('parseArgs maps root config window option', () => {
|
||||
const parsed = parseArgs(['--config'], 'subminer', {});
|
||||
|
||||
assert.equal(parsed.configSettings, true);
|
||||
});
|
||||
|
||||
test('parseArgs maps root update flags without conflicting with jellyfin username', () => {
|
||||
const shortParsed = parseArgs(['-u'], 'subminer', {});
|
||||
const longParsed = parseArgs(['--update'], 'subminer', {});
|
||||
@@ -69,6 +75,17 @@ test('parseArgs maps root update flags without conflicting with jellyfin usernam
|
||||
assert.equal(jellyfinParsed.jellyfinUsername, 'kyle');
|
||||
});
|
||||
|
||||
test('parseArgs maps root version flags without conflicting with stats vocab flag', () => {
|
||||
const shortParsed = parseArgs(['-v'], 'subminer', {});
|
||||
const longParsed = parseArgs(['--version'], 'subminer', {});
|
||||
const statsParsed = parseArgs(['stats', 'cleanup', '-v'], 'subminer', {});
|
||||
|
||||
assert.equal(shortParsed.version, true);
|
||||
assert.equal(longParsed.version, true);
|
||||
assert.equal(statsParsed.version, false);
|
||||
assert.equal(statsParsed.statsCleanupVocab, true);
|
||||
});
|
||||
|
||||
test('parseArgs maps jellyfin play action and log-level override', () => {
|
||||
const parsed = parseArgs(['jellyfin', 'play', '--log-level', 'debug'], 'subminer', {});
|
||||
|
||||
@@ -90,6 +107,33 @@ test('parseArgs maps config show action', () => {
|
||||
assert.equal(parsed.configPath, false);
|
||||
});
|
||||
|
||||
test('parseArgs maps bare config command to settings window', () => {
|
||||
const parsed = parseArgs(['config'], 'subminer', {});
|
||||
|
||||
assert.equal(parsed.configSettings, true);
|
||||
assert.equal(parsed.configPath, false);
|
||||
assert.equal(parsed.configShow, false);
|
||||
});
|
||||
|
||||
test('parseArgs maps config path action to config path output', () => {
|
||||
const parsed = parseArgs(['config', 'path'], 'subminer', {});
|
||||
|
||||
assert.equal(parsed.configPath, true);
|
||||
assert.equal(parsed.configSettings, false);
|
||||
});
|
||||
|
||||
test('parseArgs rejects removed config open and launch actions', () => {
|
||||
const openExit = withProcessExitIntercept(() => {
|
||||
parseArgs(['config', 'open'], 'subminer', {});
|
||||
});
|
||||
const exit = withProcessExitIntercept(() => {
|
||||
parseArgs(['config', 'launch'], 'subminer', {});
|
||||
});
|
||||
|
||||
assert.equal(openExit.code, 1);
|
||||
assert.equal(exit.code, 1);
|
||||
});
|
||||
|
||||
test('parseArgs maps mpv idle action', () => {
|
||||
const parsed = parseArgs(['mpv', 'idle'], 'subminer', {});
|
||||
|
||||
|
||||
@@ -134,7 +134,9 @@ export interface Args {
|
||||
dictionaryTarget?: string;
|
||||
doctor: boolean;
|
||||
doctorRefreshKnownWords: boolean;
|
||||
version: boolean;
|
||||
update?: boolean;
|
||||
configSettings: boolean;
|
||||
configPath: boolean;
|
||||
configShow: boolean;
|
||||
mpvIdle: boolean;
|
||||
|
||||
Generated
+4133
File diff suppressed because it is too large
Load Diff
+5
-3
@@ -2,7 +2,7 @@
|
||||
"name": "subminer",
|
||||
"productName": "SubMiner",
|
||||
"desktopName": "SubMiner.desktop",
|
||||
"version": "0.15.0-beta.2",
|
||||
"version": "0.15.0-beta.3",
|
||||
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
|
||||
"packageManager": "bun@1.3.5",
|
||||
"main": "dist/main-entry.js",
|
||||
@@ -18,8 +18,9 @@
|
||||
"build:launcher": "bun build ./launcher/main.ts --target=bun --packages=bundle --banner='#!/usr/bin/env bun' --outfile=dist/launcher/subminer",
|
||||
"build:stats": "cd stats && bun run build",
|
||||
"dev:stats": "cd stats && bun run dev",
|
||||
"build": "bun run build:yomitan && bun run build:stats && tsc -p tsconfig.json && bun run build:renderer && bun run build:launcher && bun run build:assets",
|
||||
"build": "bun run build:yomitan && bun run build:stats && tsc -p tsconfig.json && bun run build:renderer && bun run build:settings && bun run build:launcher && bun run build:assets",
|
||||
"build:renderer": "esbuild src/renderer/renderer.ts --bundle --platform=browser --format=esm --target=es2022 --outfile=dist/renderer/renderer.js --sourcemap",
|
||||
"build:settings": "esbuild src/settings/settings.ts --bundle --platform=browser --format=esm --target=es2022 --outfile=dist/settings/settings.js --sourcemap",
|
||||
"changelog:build": "bun run scripts/build-changelog.ts build-release",
|
||||
"changelog:check": "bun run scripts/build-changelog.ts check",
|
||||
"changelog:docs": "bun run scripts/build-changelog.ts docs",
|
||||
@@ -48,7 +49,7 @@
|
||||
"test:plugin:src": "lua scripts/test-plugin-lua-compat.lua && lua scripts/test-plugin-start-gate.lua && lua scripts/test-plugin-session-bindings.lua && lua scripts/test-plugin-binary-windows.lua",
|
||||
"test:launcher:smoke:src": "bun test launcher/smoke.e2e.test.ts",
|
||||
"test:launcher:src": "bun test launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/config/cli-parser-builder.test.ts launcher/config/args-normalizer.test.ts launcher/mpv.test.ts launcher/picker.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/commands/update-command.test.ts launcher/smoke.e2e.test.ts && bun run test:plugin:src",
|
||||
"test:core:src": "bun test src/cli/args.test.ts src/cli/help.test.ts src/shared/setup-state.test.ts src/core/services/cli-command.test.ts src/core/services/field-grouping-overlay.test.ts src/core/services/numeric-shortcut-session.test.ts src/core/services/secondary-subtitle.test.ts src/core/services/mpv-render-metrics.test.ts src/core/services/overlay-content-measurement.test.ts src/core/services/mpv-control.test.ts src/core/services/mpv.test.ts src/core/services/runtime-options-ipc.test.ts src/core/services/runtime-config.test.ts src/core/services/yomitan-extension-paths.test.ts src/core/services/yomitan-extension-loader.test.ts src/core/services/yomitan-settings.test.ts src/core/services/config-hot-reload.test.ts src/core/services/discord-presence.test.ts src/core/services/tokenizer.test.ts src/core/services/tokenizer/annotation-stage.test.ts src/core/services/tokenizer/parser-selection-stage.test.ts src/core/services/tokenizer/parser-enrichment-stage.test.ts src/core/services/subsync.test.ts src/core/services/overlay-bridge.test.ts src/core/services/overlay-shortcut-handler.test.ts src/core/services/stats-window.test.ts src/core/services/__tests__/stats-server.test.ts src/main/runtime/stats-server-routing.test.ts src/core/services/mining.test.ts src/core/services/anki-jimaku.test.ts src/core/services/jimaku-download-path.test.ts src/core/services/jellyfin.test.ts src/core/services/jellyfin-remote.test.ts src/core/services/immersion-tracker-service.test.ts src/core/services/overlay-runtime-init.test.ts src/core/services/app-ready.test.ts src/core/services/startup-bootstrap.test.ts src/core/services/subtitle-processing-controller.test.ts src/core/services/anilist/anilist-update-queue.test.ts src/core/services/anilist/rate-limiter.test.ts src/core/services/jlpt-token-filter.test.ts src/core/services/subtitle-position.test.ts src/core/utils/shortcut-config.test.ts src/main/runtime/first-run-setup-plugin.test.ts src/main/runtime/first-run-setup-service.test.ts src/main/runtime/first-run-setup-window.test.ts src/main/runtime/command-line-launcher.test.ts src/main/runtime/tray-runtime.test.ts src/main/runtime/tray-main-actions.test.ts src/main/runtime/tray-main-deps.test.ts src/main/runtime/tray-runtime-handlers.test.ts src/main/runtime/cli-command-context-main-deps.test.ts src/main/runtime/app-ready-main-deps.test.ts src/main/runtime/update/appimage-updater.test.ts src/main/runtime/update/update-dialogs.test.ts src/main/runtime/update/support-assets.test.ts src/renderer/error-recovery.test.ts src/renderer/subtitle-render.test.ts src/renderer/handlers/mouse.test.ts src/renderer/handlers/keyboard.test.ts src/renderer/modals/jimaku.test.ts src/subsync/utils.test.ts src/main/anilist-url-guard.test.ts src/window-trackers/hyprland-tracker.test.ts src/window-trackers/x11-tracker.test.ts src/window-trackers/windows-helper.test.ts src/window-trackers/windows-tracker.test.ts launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/config/cli-parser-builder.test.ts launcher/config/args-normalizer.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/commands/update-command.test.ts launcher/setup-gate.test.ts stats/src/lib/api-client.test.ts stats/src/hooks/useExcludedWords.test.ts stats/src/styles/globals.test.ts",
|
||||
"test:core:src": "bun test src/preload-settings.test.ts src/cli/args.test.ts src/cli/help.test.ts src/shared/setup-state.test.ts src/core/services/cli-command.test.ts src/core/services/field-grouping-overlay.test.ts src/core/services/numeric-shortcut-session.test.ts src/core/services/secondary-subtitle.test.ts src/core/services/mpv-render-metrics.test.ts src/core/services/overlay-content-measurement.test.ts src/core/services/mpv-control.test.ts src/core/services/mpv.test.ts src/core/services/runtime-options-ipc.test.ts src/core/services/runtime-config.test.ts src/core/services/yomitan-extension-paths.test.ts src/core/services/yomitan-extension-loader.test.ts src/core/services/yomitan-settings.test.ts src/core/services/config-hot-reload.test.ts src/core/services/discord-presence.test.ts src/core/services/tokenizer.test.ts src/core/services/tokenizer/annotation-stage.test.ts src/core/services/tokenizer/parser-selection-stage.test.ts src/core/services/tokenizer/parser-enrichment-stage.test.ts src/core/services/subsync.test.ts src/core/services/overlay-bridge.test.ts src/core/services/overlay-shortcut-handler.test.ts src/core/services/stats-window.test.ts src/core/services/__tests__/stats-server.test.ts src/main/runtime/stats-server-routing.test.ts src/core/services/mining.test.ts src/core/services/anki-jimaku.test.ts src/core/services/jimaku-download-path.test.ts src/core/services/jellyfin.test.ts src/core/services/jellyfin-remote.test.ts src/core/services/immersion-tracker-service.test.ts src/core/services/overlay-runtime-init.test.ts src/core/services/app-ready.test.ts src/core/services/startup-bootstrap.test.ts src/core/services/subtitle-processing-controller.test.ts src/core/services/anilist/anilist-update-queue.test.ts src/core/services/anilist/rate-limiter.test.ts src/core/services/jlpt-token-filter.test.ts src/core/services/subtitle-position.test.ts src/core/utils/shortcut-config.test.ts src/main/runtime/startup-mode-flags.test.ts src/main/runtime/first-run-setup-plugin.test.ts src/main/runtime/first-run-setup-service.test.ts src/main/runtime/first-run-setup-window.test.ts src/main/runtime/command-line-launcher.test.ts src/main/runtime/tray-runtime.test.ts src/main/runtime/tray-main-actions.test.ts src/main/runtime/tray-main-deps.test.ts src/main/runtime/tray-runtime-handlers.test.ts src/main/runtime/cli-command-context-main-deps.test.ts src/main/runtime/app-ready-main-deps.test.ts src/main/runtime/update/appimage-updater.test.ts src/main/runtime/update/fetch-adapter.test.ts src/main/runtime/update/release-metadata-policy.test.ts src/main/runtime/update/update-dialogs.test.ts src/main/runtime/update/support-assets.test.ts src/renderer/error-recovery.test.ts src/renderer/subtitle-render.test.ts src/renderer/handlers/mouse.test.ts src/renderer/handlers/keyboard.test.ts src/renderer/modals/jimaku.test.ts src/subsync/utils.test.ts src/main/anilist-url-guard.test.ts src/window-trackers/hyprland-tracker.test.ts src/window-trackers/x11-tracker.test.ts src/window-trackers/windows-helper.test.ts src/window-trackers/windows-tracker.test.ts launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/config/cli-parser-builder.test.ts launcher/config/args-normalizer.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/commands/update-command.test.ts launcher/setup-gate.test.ts stats/src/lib/api-client.test.ts stats/src/hooks/useExcludedWords.test.ts",
|
||||
"test:core:dist": "bun test dist/cli/args.test.js dist/cli/help.test.js dist/core/services/cli-command.test.js dist/core/services/ipc.test.js dist/core/services/anki-jimaku-ipc.test.js dist/core/services/field-grouping-overlay.test.js dist/core/services/numeric-shortcut-session.test.js dist/core/services/secondary-subtitle.test.js dist/core/services/mpv-render-metrics.test.js dist/core/services/overlay-content-measurement.test.js dist/core/services/mpv-control.test.js dist/core/services/mpv.test.js dist/core/services/runtime-options-ipc.test.js dist/core/services/runtime-config.test.js dist/core/services/yomitan-extension-paths.test.js dist/core/services/config-hot-reload.test.js dist/core/services/discord-presence.test.js dist/core/services/tokenizer.test.js dist/core/services/tokenizer/annotation-stage.test.js dist/core/services/tokenizer/parser-selection-stage.test.js dist/core/services/tokenizer/parser-enrichment-stage.test.js dist/core/services/subsync.test.js dist/core/services/overlay-bridge.test.js dist/core/services/overlay-manager.test.js dist/core/services/overlay-shortcut-handler.test.js dist/core/services/mining.test.js dist/core/services/anki-jimaku.test.js dist/core/services/jimaku-download-path.test.js dist/core/services/jellyfin.test.js dist/core/services/jellyfin-remote.test.js dist/core/services/immersion-tracker-service.test.js dist/core/services/overlay-runtime-init.test.js dist/core/services/app-ready.test.js dist/core/services/startup-bootstrap.test.js dist/core/services/subtitle-processing-controller.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/anilist/anilist-update-queue.test.js dist/core/services/anilist/rate-limiter.test.js dist/core/services/jlpt-token-filter.test.js dist/core/services/subtitle-position.test.js dist/renderer/error-recovery.test.js dist/renderer/subtitle-render.test.js dist/renderer/handlers/mouse.test.js dist/renderer/handlers/keyboard.test.js dist/renderer/modals/jimaku.test.js dist/subsync/utils.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/hyprland-tracker.test.js dist/window-trackers/x11-tracker.test.js dist/window-trackers/windows-helper.test.js dist/window-trackers/windows-tracker.test.js",
|
||||
"test:core:smoke:dist": "bun test dist/cli/help.test.js dist/core/services/runtime-config.test.js dist/core/services/ipc.test.js dist/core/services/overlay-manager.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/startup-bootstrap.test.js dist/renderer/error-recovery.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/x11-tracker.test.js",
|
||||
"test:smoke:dist": "bun run test:config:smoke:dist && bun run test:core:smoke:dist",
|
||||
@@ -116,6 +117,7 @@
|
||||
"jsonc-parser": "^3.3.1",
|
||||
"koffi": "^2.15.6",
|
||||
"libsql": "^0.5.22",
|
||||
"vscode-json-languageservice": "^5.7.2",
|
||||
"ws": "^8.19.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -3,25 +3,33 @@
|
||||
## Highlights
|
||||
### Added
|
||||
|
||||
**Auto-Update:** The tray and `subminer -u` now check for SubMiner releases and prompt you to install updates, covering the app itself, the command-line launcher, and Linux rofi themes. Update notifications are configurable, downloads are checksum-verified, and an opt-in prerelease channel lets you receive beta and RC builds.
|
||||
- **Auto-Updater:** SubMiner can now check for and apply updates from the system tray or by running `subminer -u`. Checks include checksum verification, configurable notifications, and an opt-in channel for prerelease builds. The `subminer` launcher and Linux rofi theme are also updated automatically.
|
||||
|
||||
**First-Run Setup:** The app can now install Bun and the `subminer` command-line launcher during first run on Linux, macOS, and Windows. On Windows, a `subminer.cmd` PATH shim lets you type `subminer` in any terminal without manually adding `SubMiner.exe` to PATH.
|
||||
- **First-Run Setup:** A new optional setup flow installs Bun and the `subminer` command-line launcher on Linux, macOS, and Windows. Windows users get a `subminer.cmd` PATH shim so `subminer` works in any terminal without manually adding `SubMiner.exe` to PATH.
|
||||
|
||||
### Fixed
|
||||
|
||||
**macOS Overlay:** Controls on the mpv window are now clickable before you hover the subtitle bars. Transient mpv window focus misses no longer hide the overlay; minimizing mpv still hides it as expected.
|
||||
- **macOS Overlay:** Significantly improved overlay focus and stability: the overlay now hides when mpv loses focus or is minimized, stays stable through transient window-tracking misses, remains correctly layered during stats mouse passthrough, and opens over fullscreen mpv without switching Spaces. Passthrough is also fixed so mpv controls stay clickable before hovering a subtitle bar. Background tracking overhead is reduced while mpv is stably focused.
|
||||
|
||||
**Subtitle Sync:** The subtitle sync modal on macOS now opens reliably: no more flash-and-hide on the first attempt or stale modal state left after syncing.
|
||||
- **Subtitle Sync Modal:** Fixed a macOS issue where opening the subtitle sync modal would flash and disappear on the first attempt, or leave stale state after syncing.
|
||||
|
||||
**Updater:** Update checks on Linux now use GitHub release metadata instead of the native Electron updater, preventing tray app crashes. macOS builds that cannot auto-install updates show a manual install prompt instead of a non-functional restart prompt. macOS update dialogs are brought to the front when `subminer --update` is run from the launcher.
|
||||
- **Controller:** Controller config and debug shortcuts now stay closed while controller support is disabled, with a notice to enable `controller.enabled`. Learn mode can be entered from the edit pencil or binding badge, remaps are saved per controller profile, and individual bindings can be reset to their defaults.
|
||||
|
||||
**Linux Command-Line Updater:** `subminer -u` now performs release updates directly from the launcher without requiring the tray app, and reports "up to date" without downloading assets when already on the latest version.
|
||||
- **AniList Progress:** Progress threshold checks now use fresh playback position data, so updates fire correctly when playback reaches or skips past the watched threshold. Season-specific results are preferred for multi-season files, and a clear message is shown when the matched season is not in Planning or Watching status.
|
||||
|
||||
**Launcher Setup:** Linux first-run launcher installs now build with a valid Bun shebang. `subminer app --setup` correctly opens the setup flow even when SubMiner is already running in the background. On macOS, first-run setup recognizes existing launchers in Homebrew or user PATH directories, and manual installs avoid Homebrew-managed locations. The setup window now quits automatically after first-run setup completes, returning control to the terminal.
|
||||
- **Character Dictionary:** Cached media matches are reused when loading a title with an existing snapshot, avoiding redundant AniList search requests on repeat visits.
|
||||
|
||||
**Tray App:** Fixed several issues with tray-launched Yomitan settings: closing the window no longer quits the tray app, a close-only menu replaces the default native menu, and settings loading no longer blocks other tray actions. An in-page close button is now available on Hyprland, where native window controls may be absent. Disabled the embedded popup preview in the settings window to prevent renderer hangs. Fixed a startup race condition that could leave extension loading in an error state, and corrected focus handling for the session help modal when mpv is not running.
|
||||
- **Updater - Linux:** The tray app now uses GitHub release metadata for update checks instead of the native Electron updater, preventing crashes. `subminer -u` performs updates independently of any running tray instance and correctly reports "up to date" without downloading assets when no newer release exists.
|
||||
|
||||
**Build:** One-shot `make clean build install` flows now correctly pick up the AppImage built in the same invocation.
|
||||
- **Updater - macOS:** Update dialogs now come to the front when launched from `subminer --update`. Builds that cannot install native updates show a manual-install message instead of an inapplicable restart prompt. Signed macOS builds remain on the native updater path without triggering premature Squirrel install checks.
|
||||
|
||||
- **Setup - macOS:** First-run setup now recognizes existing `subminer` launcher installs in Homebrew or user PATH directories, and manual setup avoids writing into Homebrew-owned paths. `subminer app --setup` opens the setup flow even when SubMiner is already running in the background. The standalone setup app quits after completing first-run setup, returning control to the terminal.
|
||||
|
||||
- **Launcher - Linux:** First-run launcher installs are now built with a valid Bun shebang, fixing installs that previously failed silently.
|
||||
|
||||
- **Tray App:** Fixed several lifecycle issues with tray-launched Yomitan settings: the tray stays running when settings are closed; settings loading no longer blocks other tray actions; the settings window uses a close-only menu to prevent accidentally quitting the tray app; an in-page close button is provided on Hyprland where native window controls are unavailable; the embedded popup preview is disabled to prevent renderer hangs during sidebar navigation; extension refreshes at startup are serialized to prevent race conditions; and the session help modal can now close correctly without mpv running.
|
||||
|
||||
- **Build - Linux Install:** Fixed one-shot `make clean build install` flows so the install step correctly picks up the AppImage produced earlier in the same make invocation.
|
||||
|
||||
## Installation
|
||||
|
||||
|
||||
@@ -509,6 +509,72 @@ test('writePrereleaseNotesForVersion writes cumulative beta notes without mutati
|
||||
}
|
||||
});
|
||||
|
||||
test('writePrereleaseNotesForVersion reuses existing prerelease notes when adding new fragments', async () => {
|
||||
const { writePrereleaseNotesForVersion } = await loadModule();
|
||||
const workspace = createWorkspace('prerelease-reuse-existing-notes');
|
||||
const projectRoot = path.join(workspace, 'SubMiner');
|
||||
const existingNotes = [
|
||||
'> This is a prerelease build for testing. Stable changelog and docs-site updates remain pending until the final stable release.',
|
||||
'',
|
||||
'## Highlights',
|
||||
'### Added',
|
||||
'- Overlay: Previous beta entry.',
|
||||
'',
|
||||
'## Installation',
|
||||
'',
|
||||
'See the README and docs/installation guide for full setup steps.',
|
||||
'',
|
||||
'## Assets',
|
||||
'',
|
||||
'- Linux: `SubMiner.AppImage`',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true });
|
||||
fs.mkdirSync(path.join(projectRoot, 'release'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(projectRoot, 'package.json'),
|
||||
JSON.stringify({ name: 'subminer', version: '0.11.3-beta.2' }, null, 2),
|
||||
'utf8',
|
||||
);
|
||||
fs.writeFileSync(path.join(projectRoot, 'release', 'prerelease-notes.md'), existingNotes, 'utf8');
|
||||
fs.writeFileSync(
|
||||
path.join(projectRoot, 'changes', '001.md'),
|
||||
['type: fixed', 'area: launcher', '', '- Fixed launcher prerelease packaging.'].join('\n'),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
try {
|
||||
const stub = recordingRunClaude((input) => {
|
||||
if (!input.includes('Overlay: Previous beta entry.')) {
|
||||
return '### Fixed\n- Launcher: Added only the latest fix.';
|
||||
}
|
||||
return [
|
||||
'### Added',
|
||||
'- Overlay: Previous beta entry.',
|
||||
'',
|
||||
'### Fixed',
|
||||
'- Launcher: Added only the latest fix.',
|
||||
].join('\n');
|
||||
});
|
||||
|
||||
const outputPath = writePrereleaseNotesForVersion({
|
||||
cwd: projectRoot,
|
||||
version: '0.11.3-beta.2',
|
||||
deps: { runClaude: stub.runClaude },
|
||||
});
|
||||
|
||||
assert.equal(stub.calls.length, 1, 'prerelease should issue exactly one Claude call');
|
||||
assert.match(stub.calls[0]!.input, /EXISTING PRERELEASE NOTES/);
|
||||
|
||||
const prereleaseNotes = fs.readFileSync(outputPath, 'utf8');
|
||||
assert.match(prereleaseNotes, /- Overlay: Previous beta entry\./);
|
||||
assert.match(prereleaseNotes, /- Launcher: Added only the latest fix\./);
|
||||
} finally {
|
||||
fs.rmSync(workspace, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('writePrereleaseNotesForVersion supports rc prereleases', async () => {
|
||||
const { writePrereleaseNotesForVersion } = await loadModule();
|
||||
const workspace = createWorkspace('prerelease-rc-notes');
|
||||
|
||||
@@ -290,6 +290,7 @@ function serializeFragmentsForPrompt(
|
||||
mode: PolishMode,
|
||||
version: string,
|
||||
date?: string,
|
||||
existingReleaseNotes?: string,
|
||||
): string {
|
||||
const header: string[] = [`MODE: ${mode}`, `VERSION: ${version}`];
|
||||
if (date) {
|
||||
@@ -307,7 +308,11 @@ function serializeFragmentsForPrompt(
|
||||
].join('\n');
|
||||
});
|
||||
|
||||
return [...header, '', ...fragmentBlocks].join('\n\n');
|
||||
const existingNotesBlock = existingReleaseNotes?.trim()
|
||||
? ['EXISTING PRERELEASE NOTES', existingReleaseNotes.trim()]
|
||||
: [];
|
||||
|
||||
return [...header, '', ...existingNotesBlock, '', ...fragmentBlocks].join('\n\n');
|
||||
}
|
||||
|
||||
function validatePolishedOutput(
|
||||
@@ -340,10 +345,11 @@ function polishFragmentsWithClaude(
|
||||
mode: PolishMode;
|
||||
version: string;
|
||||
date?: string;
|
||||
existingReleaseNotes?: string;
|
||||
deps?: ChangelogFsDeps;
|
||||
},
|
||||
): string {
|
||||
const { mode, version, date } = options;
|
||||
const { mode, version, date, existingReleaseNotes } = options;
|
||||
const runClaude = options.deps?.runClaude ?? defaultRunClaude;
|
||||
|
||||
const filtered =
|
||||
@@ -361,8 +367,18 @@ function polishFragmentsWithClaude(
|
||||
);
|
||||
}
|
||||
|
||||
const reuseInstructions = existingReleaseNotes?.trim()
|
||||
? [
|
||||
'## Existing Prerelease Notes',
|
||||
'',
|
||||
'The input includes EXISTING PRERELEASE NOTES before the fragment list. Reuse those highlight bullets as the baseline, preserve their meaning and wording where possible, then merge in only new or changed fragment material. Deduplicate instead of restating existing bullets. Output only the final highlights body using the section headings above; do not include the prerelease disclaimer, Installation, or Assets sections.',
|
||||
'',
|
||||
].join('\n')
|
||||
: '';
|
||||
const prompt =
|
||||
POLISH_PROMPT_INSTRUCTIONS + serializeFragmentsForPrompt(filtered, mode, version, date);
|
||||
POLISH_PROMPT_INSTRUCTIONS +
|
||||
reuseInstructions +
|
||||
serializeFragmentsForPrompt(filtered, mode, version, date, existingReleaseNotes);
|
||||
const output = runClaude(prompt, CLAUDE_CLI_ARGS);
|
||||
return validatePolishedOutput(output, mode, hasInternalFragments);
|
||||
}
|
||||
@@ -780,6 +796,8 @@ export function writePrereleaseNotesForVersion(options?: ChangelogOptions): stri
|
||||
verifyRequestedVersionMatchesPackageVersion(options ?? {});
|
||||
|
||||
const cwd = options?.cwd ?? process.cwd();
|
||||
const existsSync = options?.deps?.existsSync ?? fs.existsSync;
|
||||
const readFileSync = options?.deps?.readFileSync ?? fs.readFileSync;
|
||||
const version = resolveVersion(options ?? {});
|
||||
if (!isSupportedPrereleaseVersion(version)) {
|
||||
throw new Error(
|
||||
@@ -792,9 +810,14 @@ export function writePrereleaseNotesForVersion(options?: ChangelogOptions): stri
|
||||
throw new Error('No changelog fragments found in changes/.');
|
||||
}
|
||||
|
||||
const prereleaseNotesPath = path.join(cwd, PRERELEASE_NOTES_PATH);
|
||||
const existingReleaseNotes = existsSync(prereleaseNotesPath)
|
||||
? readFileSync(prereleaseNotesPath, 'utf8')
|
||||
: undefined;
|
||||
const changes = polishFragmentsWithClaude(fragments, {
|
||||
mode: 'release-notes',
|
||||
version,
|
||||
existingReleaseNotes,
|
||||
deps: options?.deps,
|
||||
});
|
||||
return writeReleaseNotesFile(cwd, changes, options?.deps, {
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
// It works with both bundled and unbundled mpv installations.
|
||||
//
|
||||
// Usage: swift get-mpv-window-macos.swift
|
||||
// Output: "x,y,width,height" or "not-found"
|
||||
// Output: "x,y,width,height,focused", "minimized", "active", "inactive", or "not-found"
|
||||
//
|
||||
|
||||
import Cocoa
|
||||
@@ -25,9 +25,16 @@ private struct WindowState {
|
||||
let focused: Bool
|
||||
}
|
||||
|
||||
private struct FrontmostApplicationState {
|
||||
let pid: pid_t
|
||||
let isMpv: Bool
|
||||
}
|
||||
|
||||
private enum WindowLookupResult {
|
||||
case visible(WindowState)
|
||||
case minimized
|
||||
case active
|
||||
case inactive
|
||||
}
|
||||
|
||||
private let targetMpvSocketPath: String? = {
|
||||
@@ -146,8 +153,41 @@ private func geometryFromAXWindow(_ axWindow: AXUIElement) -> WindowGeometry? {
|
||||
return geometry
|
||||
}
|
||||
|
||||
private func frontmostApplicationPid() -> pid_t? {
|
||||
NSWorkspace.shared.frontmostApplication?.processIdentifier
|
||||
private func frontmostApplicationState() -> FrontmostApplicationState? {
|
||||
guard let app = NSWorkspace.shared.frontmostApplication else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return FrontmostApplicationState(
|
||||
pid: app.processIdentifier,
|
||||
isMpv: app.localizedName.map(normalizedMpvName) ?? false
|
||||
)
|
||||
}
|
||||
|
||||
private func isFocusedMpvWindow(ownerPid: pid_t, frontmost: FrontmostApplicationState?) -> Bool {
|
||||
guard let frontmost = frontmost else {
|
||||
return false
|
||||
}
|
||||
|
||||
if frontmost.pid == ownerPid {
|
||||
return true
|
||||
}
|
||||
|
||||
return frontmost.isMpv && windowHasTargetSocket(ownerPid)
|
||||
}
|
||||
|
||||
private func isFrontmostTargetMpv(_ frontmost: FrontmostApplicationState?) -> Bool {
|
||||
guard let frontmost = frontmost, frontmost.isMpv else {
|
||||
return false
|
||||
}
|
||||
|
||||
if windowHasTargetSocket(frontmost.pid) {
|
||||
return true
|
||||
}
|
||||
|
||||
// When macOS says mpv is frontmost but geometry APIs miss, keep the
|
||||
// overlay stable even if ps cannot expose the socket argument.
|
||||
return targetMpvSocketPath != nil
|
||||
}
|
||||
|
||||
private func windowStateFromAccessibilityAPI() -> WindowLookupResult? {
|
||||
@@ -158,7 +198,7 @@ private func windowStateFromAccessibilityAPI() -> WindowLookupResult? {
|
||||
return normalizedMpvName(name)
|
||||
}
|
||||
|
||||
let frontmostPid = frontmostApplicationPid()
|
||||
let frontmost = frontmostApplicationState()
|
||||
var foundMinimizedTargetWindow = false
|
||||
|
||||
for app in runningApps {
|
||||
@@ -198,7 +238,7 @@ private func windowStateFromAccessibilityAPI() -> WindowLookupResult? {
|
||||
return .visible(
|
||||
WindowState(
|
||||
geometry: geometry,
|
||||
focused: frontmostPid == windowPid
|
||||
focused: isFocusedMpvWindow(ownerPid: windowPid, frontmost: frontmost)
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -217,7 +257,7 @@ private func windowStateFromCoreGraphics() -> WindowState? {
|
||||
// Use on-screen layer-0 windows to avoid off-screen helpers/shadows.
|
||||
let options: CGWindowListOption = [.optionOnScreenOnly, .excludeDesktopElements]
|
||||
let windowList = CGWindowListCopyWindowInfo(options, kCGNullWindowID) as? [[String: Any]] ?? []
|
||||
let frontmostPid = frontmostApplicationPid()
|
||||
let frontmost = frontmostApplicationState()
|
||||
|
||||
for window in windowList {
|
||||
guard let ownerName = window[kCGWindowOwnerName as String] as? String,
|
||||
@@ -260,7 +300,7 @@ private func windowStateFromCoreGraphics() -> WindowState? {
|
||||
|
||||
return WindowState(
|
||||
geometry: geometry,
|
||||
focused: frontmostPid == ownerPid
|
||||
focused: isFocusedMpvWindow(ownerPid: ownerPid, frontmost: frontmost)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -274,6 +314,13 @@ private let lookupResult: WindowLookupResult? = {
|
||||
if let cgWindow = windowStateFromCoreGraphics() {
|
||||
return .visible(cgWindow)
|
||||
}
|
||||
let frontmost = frontmostApplicationState()
|
||||
if isFrontmostTargetMpv(frontmost) {
|
||||
return .active
|
||||
}
|
||||
if frontmost != nil {
|
||||
return .inactive
|
||||
}
|
||||
return nil
|
||||
}()
|
||||
|
||||
@@ -285,6 +332,10 @@ if let result = lookupResult {
|
||||
)
|
||||
case .minimized:
|
||||
print("minimized")
|
||||
case .active:
|
||||
print("active")
|
||||
case .inactive:
|
||||
print("inactive")
|
||||
}
|
||||
} else {
|
||||
print("not-found")
|
||||
|
||||
@@ -31,3 +31,64 @@ test('minimized Accessibility windows are validated by PID and socket before rep
|
||||
'target socket must be validated before accepting a minimized window',
|
||||
);
|
||||
});
|
||||
|
||||
test('focused mpv window follows the frontmost mpv app signal', () => {
|
||||
const focusHelperIndex = source.indexOf('private func isFocusedMpvWindow');
|
||||
assert.notEqual(focusHelperIndex, -1);
|
||||
|
||||
const nextFunctionIndex = source.indexOf('\nprivate func ', focusHelperIndex + 1);
|
||||
const focusHelperBody = source.slice(focusHelperIndex, nextFunctionIndex);
|
||||
|
||||
assert.ok(
|
||||
focusHelperBody.includes('frontmost.pid == ownerPid'),
|
||||
'matching frontmost PID should mark the mpv window focused',
|
||||
);
|
||||
assert.ok(
|
||||
focusHelperBody.includes('frontmost.isMpv && windowHasTargetSocket(ownerPid)'),
|
||||
'frontmost mpv app should mark the target mpv window focused even when PIDs differ',
|
||||
);
|
||||
assert.ok(
|
||||
source.includes('focused: isFocusedMpvWindow(ownerPid: windowPid, frontmost: frontmost)'),
|
||||
'Accessibility path should use the shared focused mpv helper',
|
||||
);
|
||||
assert.ok(
|
||||
source.includes('focused: isFocusedMpvWindow(ownerPid: ownerPid, frontmost: frontmost)'),
|
||||
'CoreGraphics path should use the shared focused mpv helper',
|
||||
);
|
||||
});
|
||||
|
||||
test('frontmost mpv app emits active state when geometry lookup misses', () => {
|
||||
assert.ok(
|
||||
/case\s+\.active:/.test(source),
|
||||
'helper should expose an active state without window geometry',
|
||||
);
|
||||
assert.ok(
|
||||
source.includes('if windowHasTargetSocket(frontmost.pid)'),
|
||||
'active state should still accept a matching target socket when available',
|
||||
);
|
||||
assert.ok(
|
||||
source.includes('return targetMpvSocketPath != nil'),
|
||||
'active state should preserve frontmost mpv even if command-line socket detection fails',
|
||||
);
|
||||
assert.ok(
|
||||
source.includes('return .active'),
|
||||
'lookup should preserve active mpv state after geometry lookup misses',
|
||||
);
|
||||
assert.ok(source.includes('print("active")'), 'active state should be printed for the tracker');
|
||||
});
|
||||
|
||||
test('frontmost non-mpv app emits inactive state when geometry lookup misses', () => {
|
||||
assert.ok(
|
||||
/case\s+\.inactive:/.test(source),
|
||||
'helper should expose an inactive state without window geometry',
|
||||
);
|
||||
assert.ok(
|
||||
source.includes('if frontmost != nil'),
|
||||
'helper should distinguish a known non-mpv frontmost app from an unknown miss',
|
||||
);
|
||||
assert.ok(source.includes('return .inactive'), 'known non-mpv focus should return inactive');
|
||||
assert.ok(
|
||||
source.includes('print("inactive")'),
|
||||
'inactive state should be printed for the tracker',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -7,6 +7,8 @@ const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = path.resolve(scriptDir, '..');
|
||||
const rendererSourceDir = path.join(repoRoot, 'src', 'renderer');
|
||||
const rendererOutputDir = path.join(repoRoot, 'dist', 'renderer');
|
||||
const settingsSourceDir = path.join(repoRoot, 'src', 'settings');
|
||||
const settingsOutputDir = path.join(repoRoot, 'dist', 'settings');
|
||||
const scriptsOutputDir = path.join(repoRoot, 'dist', 'scripts');
|
||||
const macosHelperSourcePath = path.join(scriptDir, 'get-mpv-window-macos.swift');
|
||||
const macosHelperBinaryPath = path.join(scriptsOutputDir, 'get-mpv-window-macos');
|
||||
@@ -21,14 +23,22 @@ function copyFile(sourcePath, outputPath) {
|
||||
fs.copyFileSync(sourcePath, outputPath);
|
||||
}
|
||||
|
||||
function copyRendererAssets() {
|
||||
copyFile(path.join(rendererSourceDir, 'index.html'), path.join(rendererOutputDir, 'index.html'));
|
||||
copyFile(path.join(rendererSourceDir, 'style.css'), path.join(rendererOutputDir, 'style.css'));
|
||||
fs.cpSync(path.join(rendererSourceDir, 'fonts'), path.join(rendererOutputDir, 'fonts'), {
|
||||
function copyAssets(sourceDir, outputDir, label) {
|
||||
copyFile(path.join(sourceDir, 'index.html'), path.join(outputDir, 'index.html'));
|
||||
copyFile(path.join(sourceDir, 'style.css'), path.join(outputDir, 'style.css'));
|
||||
fs.cpSync(path.join(rendererSourceDir, 'fonts'), path.join(outputDir, 'fonts'), {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
process.stdout.write(`Staged renderer assets in ${rendererOutputDir}\n`);
|
||||
process.stdout.write(`Staged ${label} assets in ${outputDir}\n`);
|
||||
}
|
||||
|
||||
function copyRendererAssets() {
|
||||
copyAssets(rendererSourceDir, rendererOutputDir, 'renderer');
|
||||
}
|
||||
|
||||
function copySettingsAssets() {
|
||||
copyAssets(settingsSourceDir, settingsOutputDir, 'settings');
|
||||
}
|
||||
|
||||
function fallbackToMacosSource() {
|
||||
@@ -70,6 +80,7 @@ function buildMacosHelper() {
|
||||
|
||||
function main() {
|
||||
copyRendererAssets();
|
||||
copySettingsAssets();
|
||||
buildMacosHelper();
|
||||
}
|
||||
|
||||
|
||||
@@ -212,6 +212,14 @@ test('hasExplicitCommand and shouldStartApp preserve command intent', () => {
|
||||
assert.equal(shouldStartApp(settings), true);
|
||||
assert.equal(shouldRunSettingsOnlyStartup(settings), true);
|
||||
|
||||
const configSettings = parseArgs(['--config']);
|
||||
assert.equal(configSettings.configSettings, true);
|
||||
assert.equal(hasExplicitCommand(configSettings), true);
|
||||
assert.equal(shouldStartApp(configSettings), true);
|
||||
assert.equal(shouldRunSettingsOnlyStartup(configSettings), false);
|
||||
assert.equal(commandNeedsOverlayRuntime(configSettings), false);
|
||||
assert.equal(commandNeedsOverlayStartupPrereqs(configSettings), false);
|
||||
|
||||
const settingsWithOverlay = parseArgs(['--settings', '--toggle-visible-overlay']);
|
||||
assert.equal(settingsWithOverlay.settings, true);
|
||||
assert.equal(settingsWithOverlay.toggleVisibleOverlay, true);
|
||||
|
||||
@@ -11,6 +11,7 @@ export interface CliArgs {
|
||||
toggleVisibleOverlay: boolean;
|
||||
togglePrimarySubtitleBar: boolean;
|
||||
settings: boolean;
|
||||
configSettings: boolean;
|
||||
setup: boolean;
|
||||
show: boolean;
|
||||
hide: boolean;
|
||||
@@ -115,6 +116,7 @@ export function parseArgs(argv: string[]): CliArgs {
|
||||
toggleVisibleOverlay: false,
|
||||
togglePrimarySubtitleBar: false,
|
||||
settings: false,
|
||||
configSettings: false,
|
||||
setup: false,
|
||||
show: false,
|
||||
hide: false,
|
||||
@@ -234,6 +236,7 @@ export function parseArgs(argv: string[]): CliArgs {
|
||||
else if (arg === '--toggle-visible-overlay') args.toggleVisibleOverlay = true;
|
||||
else if (arg === '--toggle-primary-subtitle-bar') args.togglePrimarySubtitleBar = true;
|
||||
else if (arg === '--settings' || arg === '--yomitan') args.settings = true;
|
||||
else if (arg === '--config') args.configSettings = true;
|
||||
else if (arg === '--setup') args.setup = true;
|
||||
else if (arg === '--show') args.show = true;
|
||||
else if (arg === '--hide') args.hide = true;
|
||||
@@ -486,6 +489,7 @@ export function hasExplicitCommand(args: CliArgs): boolean {
|
||||
args.toggleVisibleOverlay ||
|
||||
args.togglePrimarySubtitleBar ||
|
||||
args.settings ||
|
||||
args.configSettings ||
|
||||
args.setup ||
|
||||
args.show ||
|
||||
args.hide ||
|
||||
@@ -558,6 +562,7 @@ export function isStandaloneTexthookerCommand(args: CliArgs): boolean {
|
||||
!args.toggleVisibleOverlay &&
|
||||
!args.togglePrimarySubtitleBar &&
|
||||
!args.settings &&
|
||||
!args.configSettings &&
|
||||
!args.setup &&
|
||||
!args.show &&
|
||||
!args.hide &&
|
||||
@@ -625,6 +630,7 @@ export function shouldStartApp(args: CliArgs): boolean {
|
||||
args.toggleVisibleOverlay ||
|
||||
args.togglePrimarySubtitleBar ||
|
||||
args.settings ||
|
||||
args.configSettings ||
|
||||
args.setup ||
|
||||
args.copySubtitle ||
|
||||
args.copySubtitleMultiple ||
|
||||
@@ -679,6 +685,7 @@ export function shouldRunSettingsOnlyStartup(args: CliArgs): boolean {
|
||||
!args.toggle &&
|
||||
!args.toggleVisibleOverlay &&
|
||||
!args.togglePrimarySubtitleBar &&
|
||||
!args.configSettings &&
|
||||
!args.show &&
|
||||
!args.hide &&
|
||||
!args.setup &&
|
||||
|
||||
@@ -22,6 +22,7 @@ test('printHelp includes configured texthooker port', () => {
|
||||
assert.match(output, /--open-browser\s+Open texthooker in your default browser/);
|
||||
assert.doesNotMatch(output, /--refresh-known-words/);
|
||||
assert.match(output, /--setup\s+Open first-run setup window/);
|
||||
assert.match(output, /--config\s+Open configuration window/);
|
||||
assert.match(output, /--anilist-status/);
|
||||
assert.match(output, /--anilist-retry-queue/);
|
||||
assert.match(output, /--dictionary/);
|
||||
|
||||
@@ -25,6 +25,7 @@ ${B}Overlay${R}
|
||||
--show-visible-overlay Show subtitle overlay
|
||||
--hide-visible-overlay Hide subtitle overlay
|
||||
--settings Open Yomitan settings window
|
||||
--config Open configuration window
|
||||
--setup Open first-run setup window
|
||||
--auto-start-overlay Auto-hide mpv subs, show overlay on connect
|
||||
|
||||
|
||||
+102
-1
@@ -1453,6 +1453,104 @@ test('parses descriptor-based controller bindings', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('parses controller profiles as per-gamepad binding overrides', () => {
|
||||
const dir = makeTempDir();
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'config.jsonc'),
|
||||
`{
|
||||
"controller": {
|
||||
"buttonIndices": {
|
||||
"buttonSouth": 0,
|
||||
"leftTrigger": 6
|
||||
},
|
||||
"bindings": {
|
||||
"toggleLookup": { "kind": "button", "buttonIndex": 0 },
|
||||
"quitMpv": "leftTrigger"
|
||||
},
|
||||
"profiles": {
|
||||
"8BitDo SN30": {
|
||||
"label": "8BitDo SN30",
|
||||
"bindings": {
|
||||
"toggleLookup": { "kind": "button", "buttonIndex": 11 },
|
||||
"leftStickVertical": { "kind": "axis", "axisIndex": 7, "dpadFallback": "none" }
|
||||
}
|
||||
},
|
||||
"Xbox Wireless Controller": {
|
||||
"buttonIndices": {
|
||||
"leftTrigger": 8
|
||||
},
|
||||
"bindings": {
|
||||
"quitMpv": "leftTrigger"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`,
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const service = new ConfigService(dir);
|
||||
const config = service.getConfig();
|
||||
|
||||
assert.deepEqual(config.controller.profiles['8BitDo SN30']?.bindings.toggleLookup, {
|
||||
kind: 'button',
|
||||
buttonIndex: 11,
|
||||
});
|
||||
assert.deepEqual(config.controller.profiles['8BitDo SN30']?.bindings.closeLookup, {
|
||||
kind: 'button',
|
||||
buttonIndex: 1,
|
||||
});
|
||||
assert.deepEqual(config.controller.profiles['8BitDo SN30']?.bindings.leftStickVertical, {
|
||||
kind: 'axis',
|
||||
axisIndex: 7,
|
||||
dpadFallback: 'none',
|
||||
});
|
||||
assert.deepEqual(config.controller.profiles['Xbox Wireless Controller']?.bindings.quitMpv, {
|
||||
kind: 'button',
|
||||
buttonIndex: 8,
|
||||
});
|
||||
assert.equal(
|
||||
config.controller.profiles['Xbox Wireless Controller']?.buttonIndices.leftTrigger,
|
||||
8,
|
||||
);
|
||||
assert.deepEqual(config.controller.bindings.quitMpv, { kind: 'button', buttonIndex: 6 });
|
||||
});
|
||||
|
||||
test('rejects reserved controller profile ids from config', () => {
|
||||
const dir = makeTempDir();
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'config.jsonc'),
|
||||
`{
|
||||
"controller": {
|
||||
"profiles": {
|
||||
"__proto__": { "label": "polluted" },
|
||||
"constructor": { "label": "ctor" },
|
||||
"prototype": { "label": "proto" },
|
||||
"pad-1": { "label": "Pad 1" }
|
||||
}
|
||||
}
|
||||
}`,
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const service = new ConfigService(dir);
|
||||
const config = service.getConfig();
|
||||
const warnings = service.getWarnings();
|
||||
|
||||
assert.equal(Object.hasOwn(config.controller.profiles, '__proto__'), false);
|
||||
assert.equal(Object.hasOwn(config.controller.profiles, 'constructor'), false);
|
||||
assert.equal(Object.hasOwn(config.controller.profiles, 'prototype'), false);
|
||||
assert.equal(config.controller.profiles['pad-1']?.label, 'Pad 1');
|
||||
assert.equal(
|
||||
warnings.some((warning) => warning.path === 'controller.profiles.constructor'),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
warnings.some((warning) => warning.path === 'controller.profiles.prototype'),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('controller descriptor config rejects malformed binding objects', () => {
|
||||
const dir = makeTempDir();
|
||||
fs.writeFileSync(
|
||||
@@ -2226,7 +2324,10 @@ test('template generator includes known keys', () => {
|
||||
/"dpadFallback": "horizontal",? \/\/ Optional D-pad fallback used when this analog controller action should also read D-pad input\. Values: none \| horizontal \| vertical/,
|
||||
);
|
||||
assert.match(output, /"port": 6678,? \/\/ Annotated subtitle websocket server port\./);
|
||||
assert.match(output, /"openBrowser": false,? \/\/ Open browser setting\. Values: true \| false/);
|
||||
assert.match(
|
||||
output,
|
||||
/"openBrowser": false,? \/\/ Open the texthooker page in the default browser when the server starts\. Values: true \| false/,
|
||||
);
|
||||
assert.match(
|
||||
output,
|
||||
/"enabled": false,? \/\/ Enable overlay controller support through the Chrome Gamepad API\. Values: true \| false/,
|
||||
|
||||
@@ -74,6 +74,7 @@ export const CORE_DEFAULT_CONFIG: Pick<
|
||||
rightStickHorizontal: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' },
|
||||
rightStickVertical: { kind: 'axis', axisIndex: 4, dpadFallback: 'none' },
|
||||
},
|
||||
profiles: {},
|
||||
},
|
||||
shortcuts: {
|
||||
toggleVisibleOverlayGlobal: 'Alt+Shift+O',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { ResolvedConfig } from '../../types/config';
|
||||
import {
|
||||
CONFIG_OPTION_REGISTRY,
|
||||
CONFIG_TEMPLATE_SECTIONS,
|
||||
@@ -13,6 +14,77 @@ import { buildImmersionConfigOptionRegistry } from './options-immersion';
|
||||
import { buildIntegrationConfigOptionRegistry } from './options-integrations';
|
||||
import { buildSubtitleConfigOptionRegistry } from './options-subtitle';
|
||||
|
||||
function collectConfigLeafPaths(config: ResolvedConfig): string[] {
|
||||
const leaves: string[] = [];
|
||||
const visit = (value: unknown, prefix: string): void => {
|
||||
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
||||
leaves.push(prefix);
|
||||
return;
|
||||
}
|
||||
const entries = Object.entries(value as Record<string, unknown>);
|
||||
if (entries.length === 0) {
|
||||
leaves.push(prefix);
|
||||
return;
|
||||
}
|
||||
for (const [key, child] of entries) {
|
||||
visit(child, prefix ? `${prefix}.${key}` : key);
|
||||
}
|
||||
};
|
||||
visit(config, '');
|
||||
return leaves;
|
||||
}
|
||||
|
||||
// DEFAULT_CONFIG leaves that intentionally do not have a curated
|
||||
// CONFIG_OPTION_REGISTRY entry. The generated config.example.jsonc still
|
||||
// includes these paths, but their inline comments fall back to an auto-
|
||||
// humanized key name instead of a written description.
|
||||
//
|
||||
// Current intentional gaps:
|
||||
// - subtitleStyle.*: thin wrappers around standard CSS properties; the
|
||||
// CSS reference is the canonical documentation surface.
|
||||
// - keybindings: an array of {key, command} objects, documented at the
|
||||
// section level via CONFIG_TEMPLATE_SECTIONS rather than per-leaf.
|
||||
//
|
||||
// New leaves added to DEFAULT_CONFIG should prefer a registry entry over
|
||||
// an allowlist entry. Only allowlist a path when the registry is genuinely
|
||||
// the wrong surface for it.
|
||||
const UNDOCUMENTED_LEAVES: ReadonlySet<string> = new Set([
|
||||
'keybindings',
|
||||
'subtitleStyle.backdropFilter',
|
||||
'subtitleStyle.backgroundColor',
|
||||
'subtitleStyle.fontColor',
|
||||
'subtitleStyle.fontFamily',
|
||||
'subtitleStyle.fontKerning',
|
||||
'subtitleStyle.fontSize',
|
||||
'subtitleStyle.fontStyle',
|
||||
'subtitleStyle.fontWeight',
|
||||
'subtitleStyle.jlptColors.N1',
|
||||
'subtitleStyle.jlptColors.N2',
|
||||
'subtitleStyle.jlptColors.N3',
|
||||
'subtitleStyle.jlptColors.N4',
|
||||
'subtitleStyle.jlptColors.N5',
|
||||
'subtitleStyle.knownWordColor',
|
||||
'subtitleStyle.letterSpacing',
|
||||
'subtitleStyle.lineHeight',
|
||||
'subtitleStyle.nPlusOneColor',
|
||||
'subtitleStyle.secondary.backdropFilter',
|
||||
'subtitleStyle.secondary.backgroundColor',
|
||||
'subtitleStyle.secondary.fontColor',
|
||||
'subtitleStyle.secondary.fontFamily',
|
||||
'subtitleStyle.secondary.fontKerning',
|
||||
'subtitleStyle.secondary.fontSize',
|
||||
'subtitleStyle.secondary.fontStyle',
|
||||
'subtitleStyle.secondary.fontWeight',
|
||||
'subtitleStyle.secondary.letterSpacing',
|
||||
'subtitleStyle.secondary.lineHeight',
|
||||
'subtitleStyle.secondary.textRendering',
|
||||
'subtitleStyle.secondary.textShadow',
|
||||
'subtitleStyle.secondary.wordSpacing',
|
||||
'subtitleStyle.textRendering',
|
||||
'subtitleStyle.textShadow',
|
||||
'subtitleStyle.wordSpacing',
|
||||
]);
|
||||
|
||||
test('config option registry includes critical paths and has unique entries', () => {
|
||||
const paths = CONFIG_OPTION_REGISTRY.map((entry) => entry.path);
|
||||
|
||||
@@ -40,6 +112,35 @@ test('config option registry includes critical paths and has unique entries', ()
|
||||
assert.equal(new Set(paths).size, paths.length);
|
||||
});
|
||||
|
||||
test('every DEFAULT_CONFIG leaf is in CONFIG_OPTION_REGISTRY or UNDOCUMENTED_LEAVES', () => {
|
||||
const registryPaths = new Set(CONFIG_OPTION_REGISTRY.map((entry) => entry.path));
|
||||
const leaves = collectConfigLeafPaths(DEFAULT_CONFIG);
|
||||
|
||||
const missing = leaves
|
||||
.filter((path) => !registryPaths.has(path) && !UNDOCUMENTED_LEAVES.has(path))
|
||||
.sort();
|
||||
assert.deepEqual(
|
||||
missing,
|
||||
[],
|
||||
`Add CONFIG_OPTION_REGISTRY entries (preferred) or add to UNDOCUMENTED_LEAVES allowlist: ${missing.join(', ')}`,
|
||||
);
|
||||
|
||||
const stale = [...UNDOCUMENTED_LEAVES].filter((path) => registryPaths.has(path)).sort();
|
||||
assert.deepEqual(
|
||||
stale,
|
||||
[],
|
||||
`Remove from UNDOCUMENTED_LEAVES (now covered by CONFIG_OPTION_REGISTRY): ${stale.join(', ')}`,
|
||||
);
|
||||
|
||||
const leafSet = new Set(leaves);
|
||||
const orphaned = [...UNDOCUMENTED_LEAVES].filter((path) => !leafSet.has(path)).sort();
|
||||
assert.deepEqual(
|
||||
orphaned,
|
||||
[],
|
||||
`Remove from UNDOCUMENTED_LEAVES (no longer a DEFAULT_CONFIG leaf): ${orphaned.join(', ')}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('config template sections include expected domains and unique keys', () => {
|
||||
const keys = CONFIG_TEMPLATE_SECTIONS.map((section) => section.key);
|
||||
const requiredKeys: (typeof keys)[number][] = [
|
||||
|
||||
@@ -239,6 +239,13 @@ export function buildCoreConfigOptionRegistry(
|
||||
description:
|
||||
'Raw controller binding descriptors saved by Alt+C learn mode. For discrete axis bindings, kind "axis" requires axisIndex and direction.',
|
||||
},
|
||||
{
|
||||
path: 'controller.profiles',
|
||||
kind: 'object',
|
||||
defaultValue: defaultConfig.controller.profiles,
|
||||
description:
|
||||
'Per-controller binding and button-index overrides keyed by the controller id reported by the Gamepad API.',
|
||||
},
|
||||
...discreteBindings.flatMap((binding) => [
|
||||
{
|
||||
path: `controller.bindings.${binding.id}`,
|
||||
@@ -315,6 +322,46 @@ export function buildCoreConfigOptionRegistry(
|
||||
defaultValue: defaultConfig.texthooker.launchAtStartup,
|
||||
description: 'Launch texthooker server automatically when SubMiner starts.',
|
||||
},
|
||||
{
|
||||
path: 'texthooker.openBrowser',
|
||||
kind: 'boolean',
|
||||
defaultValue: defaultConfig.texthooker.openBrowser,
|
||||
description: 'Open the texthooker page in the default browser when the server starts.',
|
||||
},
|
||||
{
|
||||
path: 'subtitlePosition.yPercent',
|
||||
kind: 'number',
|
||||
defaultValue: defaultConfig.subtitlePosition.yPercent,
|
||||
description:
|
||||
'Vertical position of the subtitle overlay expressed as a percentage from the bottom of the screen.',
|
||||
},
|
||||
{
|
||||
path: 'auto_start_overlay',
|
||||
kind: 'boolean',
|
||||
defaultValue: defaultConfig.auto_start_overlay,
|
||||
description: 'Auto-start the subtitle overlay window when SubMiner launches.',
|
||||
},
|
||||
{
|
||||
path: 'secondarySub.secondarySubLanguages',
|
||||
kind: 'array',
|
||||
defaultValue: defaultConfig.secondarySub.secondarySubLanguages,
|
||||
description:
|
||||
'Language code priority list used to auto-select a secondary subtitle track when available.',
|
||||
},
|
||||
{
|
||||
path: 'secondarySub.autoLoadSecondarySub',
|
||||
kind: 'boolean',
|
||||
defaultValue: defaultConfig.secondarySub.autoLoadSecondarySub,
|
||||
description:
|
||||
'Automatically load a matching secondary subtitle when the primary subtitle loads.',
|
||||
},
|
||||
{
|
||||
path: 'secondarySub.defaultMode',
|
||||
kind: 'enum',
|
||||
enumValues: ['hidden', 'visible', 'hover'],
|
||||
defaultValue: defaultConfig.secondarySub.defaultMode,
|
||||
description: 'Default visibility mode for the secondary subtitle bar.',
|
||||
},
|
||||
{
|
||||
path: 'websocket.enabled',
|
||||
kind: 'enum',
|
||||
@@ -353,6 +400,27 @@ export function buildCoreConfigOptionRegistry(
|
||||
defaultValue: defaultConfig.subsync.replace,
|
||||
description: 'Replace the active subtitle file when sync completes.',
|
||||
},
|
||||
{
|
||||
path: 'subsync.alass_path',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.subsync.alass_path,
|
||||
description:
|
||||
'Optional absolute path to the alass binary used by subsync. Leave empty to auto-discover from PATH.',
|
||||
},
|
||||
{
|
||||
path: 'subsync.ffsubsync_path',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.subsync.ffsubsync_path,
|
||||
description:
|
||||
'Optional absolute path to the ffsubsync binary used by subsync. Leave empty to auto-discover from PATH.',
|
||||
},
|
||||
{
|
||||
path: 'subsync.ffmpeg_path',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.subsync.ffmpeg_path,
|
||||
description:
|
||||
'Optional absolute path to the ffmpeg binary used by subsync. Leave empty to auto-discover from PATH.',
|
||||
},
|
||||
{
|
||||
path: 'startupWarmups.lowPowerMode',
|
||||
kind: 'boolean',
|
||||
@@ -415,5 +483,112 @@ export function buildCoreConfigOptionRegistry(
|
||||
defaultValue: defaultConfig.shortcuts.multiCopyTimeoutMs,
|
||||
description: 'Timeout for multi-copy/mine modes.',
|
||||
},
|
||||
{
|
||||
path: 'shortcuts.toggleVisibleOverlayGlobal',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.shortcuts.toggleVisibleOverlayGlobal,
|
||||
description:
|
||||
'Global accelerator that toggles overlay visibility from anywhere on the system. Use null to disable.',
|
||||
},
|
||||
{
|
||||
path: 'shortcuts.copySubtitle',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.shortcuts.copySubtitle,
|
||||
description: 'Accelerator that copies the current subtitle line to the clipboard.',
|
||||
},
|
||||
{
|
||||
path: 'shortcuts.copySubtitleMultiple',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.shortcuts.copySubtitleMultiple,
|
||||
description:
|
||||
'Accelerator that copies consecutive subtitle lines while the multi-copy window stays open.',
|
||||
},
|
||||
{
|
||||
path: 'shortcuts.updateLastCardFromClipboard',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.shortcuts.updateLastCardFromClipboard,
|
||||
description:
|
||||
'Accelerator that updates the last mined Anki card using the current clipboard contents.',
|
||||
},
|
||||
{
|
||||
path: 'shortcuts.triggerFieldGrouping',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.shortcuts.triggerFieldGrouping,
|
||||
description: 'Accelerator that triggers Kiku field grouping on duplicate cards.',
|
||||
},
|
||||
{
|
||||
path: 'shortcuts.triggerSubsync',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.shortcuts.triggerSubsync,
|
||||
description: 'Accelerator that triggers subsync against the active subtitle file.',
|
||||
},
|
||||
{
|
||||
path: 'shortcuts.mineSentence',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.shortcuts.mineSentence,
|
||||
description: 'Accelerator that mines the current sentence as a new Anki card.',
|
||||
},
|
||||
{
|
||||
path: 'shortcuts.mineSentenceMultiple',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.shortcuts.mineSentenceMultiple,
|
||||
description:
|
||||
'Accelerator that mines consecutive sentences while the multi-mine window stays open.',
|
||||
},
|
||||
{
|
||||
path: 'shortcuts.toggleSecondarySub',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.shortcuts.toggleSecondarySub,
|
||||
description: 'Accelerator that toggles the secondary subtitle bar visibility.',
|
||||
},
|
||||
{
|
||||
path: 'shortcuts.markAudioCard',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.shortcuts.markAudioCard,
|
||||
description: 'Accelerator that marks the last mined card as an audio card.',
|
||||
},
|
||||
{
|
||||
path: 'shortcuts.openCharacterDictionary',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.shortcuts.openCharacterDictionary,
|
||||
description: 'Accelerator that opens the character dictionary modal.',
|
||||
},
|
||||
{
|
||||
path: 'shortcuts.openRuntimeOptions',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.shortcuts.openRuntimeOptions,
|
||||
description: 'Accelerator that opens the runtime options modal.',
|
||||
},
|
||||
{
|
||||
path: 'shortcuts.openJimaku',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.shortcuts.openJimaku,
|
||||
description: 'Accelerator that opens the Jimaku subtitle search modal.',
|
||||
},
|
||||
{
|
||||
path: 'shortcuts.openSessionHelp',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.shortcuts.openSessionHelp,
|
||||
description: 'Accelerator that opens the session help / keybinding cheatsheet.',
|
||||
},
|
||||
{
|
||||
path: 'shortcuts.openControllerSelect',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.shortcuts.openControllerSelect,
|
||||
description: 'Accelerator that opens the controller selection and learn-mode modal.',
|
||||
},
|
||||
{
|
||||
path: 'shortcuts.openControllerDebug',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.shortcuts.openControllerDebug,
|
||||
description:
|
||||
'Accelerator that opens the controller debug modal with live axis/button readouts.',
|
||||
},
|
||||
{
|
||||
path: 'shortcuts.toggleSubtitleSidebar',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.shortcuts.toggleSubtitleSidebar,
|
||||
description: 'Accelerator that toggles the subtitle sidebar visibility.',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -15,6 +15,12 @@ export function buildIntegrationConfigOptionRegistry(
|
||||
defaultValue: defaultConfig.ankiConnect.enabled,
|
||||
description: 'Enable AnkiConnect integration.',
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.url',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.ankiConnect.url,
|
||||
description: 'Base URL of the AnkiConnect HTTP server.',
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.pollingRate',
|
||||
kind: 'number',
|
||||
@@ -58,6 +64,37 @@ export function buildIntegrationConfigOptionRegistry(
|
||||
defaultValue: defaultConfig.ankiConnect.fields.word,
|
||||
description: 'Card field for the mined word or expression text.',
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.fields.audio',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.ankiConnect.fields.audio,
|
||||
description: 'Card field that receives generated sentence audio.',
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.fields.image',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.ankiConnect.fields.image,
|
||||
description: 'Card field that receives the captured screenshot or animated image.',
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.fields.sentence',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.ankiConnect.fields.sentence,
|
||||
description: 'Card field that receives the source sentence text.',
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.fields.miscInfo',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.ankiConnect.fields.miscInfo,
|
||||
description:
|
||||
'Card field that receives the miscellaneous info pattern (see ankiConnect.metadata.pattern).',
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.fields.translation',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.ankiConnect.fields.translation,
|
||||
description: 'Card field that receives the current selection or translated text.',
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.ai.enabled',
|
||||
kind: 'boolean',
|
||||
@@ -83,6 +120,41 @@ export function buildIntegrationConfigOptionRegistry(
|
||||
description: 'Automatically update newly added cards.',
|
||||
runtime: runtimeOptionById.get('anki.autoUpdateNewCards'),
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.behavior.overwriteAudio',
|
||||
kind: 'boolean',
|
||||
defaultValue: defaultConfig.ankiConnect.behavior.overwriteAudio,
|
||||
description:
|
||||
'When updating an existing card, overwrite the audio field instead of skipping it.',
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.behavior.overwriteImage',
|
||||
kind: 'boolean',
|
||||
defaultValue: defaultConfig.ankiConnect.behavior.overwriteImage,
|
||||
description:
|
||||
'When updating an existing card, overwrite the image field instead of skipping it.',
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.behavior.mediaInsertMode',
|
||||
kind: 'enum',
|
||||
enumValues: ['append', 'prepend'],
|
||||
defaultValue: defaultConfig.ankiConnect.behavior.mediaInsertMode,
|
||||
description:
|
||||
'Whether new media is appended after or prepended before existing field contents on update.',
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.behavior.highlightWord',
|
||||
kind: 'boolean',
|
||||
defaultValue: defaultConfig.ankiConnect.behavior.highlightWord,
|
||||
description: 'Bold the mined word inside the sentence field on the saved Anki card.',
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.behavior.notificationType',
|
||||
kind: 'enum',
|
||||
enumValues: ['osd', 'system', 'both', 'none'],
|
||||
defaultValue: defaultConfig.ankiConnect.behavior.notificationType,
|
||||
description: 'Notification surface used to announce mining and update outcomes.',
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.media.syncAnimatedImageToWordAudio',
|
||||
kind: 'boolean',
|
||||
@@ -90,6 +162,97 @@ export function buildIntegrationConfigOptionRegistry(
|
||||
description:
|
||||
'For animated AVIF images, prepend a frozen first frame matching the existing word-audio duration so motion starts with sentence audio.',
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.media.generateAudio',
|
||||
kind: 'boolean',
|
||||
defaultValue: defaultConfig.ankiConnect.media.generateAudio,
|
||||
description: 'Generate sentence audio for mined cards.',
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.media.generateImage',
|
||||
kind: 'boolean',
|
||||
defaultValue: defaultConfig.ankiConnect.media.generateImage,
|
||||
description: 'Generate screenshot or animated image for mined cards.',
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.media.imageType',
|
||||
kind: 'enum',
|
||||
enumValues: ['static', 'avif'],
|
||||
defaultValue: defaultConfig.ankiConnect.media.imageType,
|
||||
description:
|
||||
'Image capture type: "static" for a single still frame, "avif" for an animated AVIF.',
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.media.imageFormat',
|
||||
kind: 'enum',
|
||||
enumValues: ['jpg', 'png', 'webp'],
|
||||
defaultValue: defaultConfig.ankiConnect.media.imageFormat,
|
||||
description: 'Encoding format used when imageType is "static".',
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.media.imageQuality',
|
||||
kind: 'number',
|
||||
defaultValue: defaultConfig.ankiConnect.media.imageQuality,
|
||||
description: 'Quality (0-100) used for lossy static image encoders.',
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.media.imageMaxWidth',
|
||||
kind: 'number',
|
||||
defaultValue: defaultConfig.ankiConnect.media.imageMaxWidth,
|
||||
description:
|
||||
'Optional maximum width for static images. Leave unset to preserve the source resolution.',
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.media.imageMaxHeight',
|
||||
kind: 'number',
|
||||
defaultValue: defaultConfig.ankiConnect.media.imageMaxHeight,
|
||||
description:
|
||||
'Optional maximum height for static images. Leave unset to preserve the source resolution.',
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.media.animatedFps',
|
||||
kind: 'number',
|
||||
defaultValue: defaultConfig.ankiConnect.media.animatedFps,
|
||||
description: 'Target frame rate for animated AVIF captures.',
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.media.animatedMaxWidth',
|
||||
kind: 'number',
|
||||
defaultValue: defaultConfig.ankiConnect.media.animatedMaxWidth,
|
||||
description: 'Maximum width applied to animated AVIF captures.',
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.media.animatedMaxHeight',
|
||||
kind: 'number',
|
||||
defaultValue: defaultConfig.ankiConnect.media.animatedMaxHeight,
|
||||
description:
|
||||
'Optional maximum height for animated AVIF captures. Leave unset to preserve aspect ratio.',
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.media.animatedCrf',
|
||||
kind: 'number',
|
||||
defaultValue: defaultConfig.ankiConnect.media.animatedCrf,
|
||||
description:
|
||||
'Animated AVIF CRF quality target. Lower values produce larger, higher-quality files.',
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.media.audioPadding',
|
||||
kind: 'number',
|
||||
defaultValue: defaultConfig.ankiConnect.media.audioPadding,
|
||||
description: 'Seconds of padding appended to both ends of generated sentence audio.',
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.media.fallbackDuration',
|
||||
kind: 'number',
|
||||
defaultValue: defaultConfig.ankiConnect.media.fallbackDuration,
|
||||
description: 'Fallback clip duration in seconds when subtitle timing data is unavailable.',
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.media.maxMediaDuration',
|
||||
kind: 'number',
|
||||
defaultValue: defaultConfig.ankiConnect.media.maxMediaDuration,
|
||||
description: 'Maximum allowed media clip duration in seconds.',
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.knownWords.matchMode',
|
||||
kind: 'enum',
|
||||
@@ -148,6 +311,44 @@ export function buildIntegrationConfigOptionRegistry(
|
||||
description: 'Kiku duplicate-card field grouping mode.',
|
||||
runtime: runtimeOptionById.get('anki.kikuFieldGrouping'),
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.isKiku.enabled',
|
||||
kind: 'boolean',
|
||||
defaultValue: defaultConfig.ankiConnect.isKiku.enabled,
|
||||
description: 'Enable Kiku-specific mining behaviors (duplicate handling, field grouping).',
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.isKiku.deleteDuplicateInAuto',
|
||||
kind: 'boolean',
|
||||
defaultValue: defaultConfig.ankiConnect.isKiku.deleteDuplicateInAuto,
|
||||
description:
|
||||
'When Kiku field grouping is "auto", delete the duplicate source card after grouping completes.',
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.isLapis.enabled',
|
||||
kind: 'boolean',
|
||||
defaultValue: defaultConfig.ankiConnect.isLapis.enabled,
|
||||
description: 'Enable Lapis-specific mining behaviors and sentence card model targeting.',
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.isLapis.sentenceCardModel',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.ankiConnect.isLapis.sentenceCardModel,
|
||||
description: 'Note type name used by Lapis sentence cards.',
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.metadata.pattern',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.ankiConnect.metadata.pattern,
|
||||
description:
|
||||
'Template used to render the miscInfo field. Placeholders include %f (filename) and %t (timestamp).',
|
||||
},
|
||||
{
|
||||
path: 'jimaku.apiBaseUrl',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.jimaku.apiBaseUrl,
|
||||
description: 'Base URL of the Jimaku subtitle search API.',
|
||||
},
|
||||
{
|
||||
path: 'jimaku.languagePreference',
|
||||
kind: 'enum',
|
||||
@@ -277,6 +478,26 @@ export function buildIntegrationConfigOptionRegistry(
|
||||
defaultValue: defaultConfig.jellyfin.username,
|
||||
description: 'Default Jellyfin username used during CLI login.',
|
||||
},
|
||||
{
|
||||
path: 'jellyfin.deviceId',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.jellyfin.deviceId,
|
||||
description:
|
||||
'Stable device identifier sent on the Jellyfin authentication handshake; primarily internal.',
|
||||
},
|
||||
{
|
||||
path: 'jellyfin.clientName',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.jellyfin.clientName,
|
||||
description: 'Client name sent on the Jellyfin authentication handshake; primarily internal.',
|
||||
},
|
||||
{
|
||||
path: 'jellyfin.clientVersion',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.jellyfin.clientVersion,
|
||||
description:
|
||||
'Client version sent on the Jellyfin authentication handshake; primarily internal.',
|
||||
},
|
||||
{
|
||||
path: 'jellyfin.defaultLibraryId',
|
||||
kind: 'string',
|
||||
@@ -387,6 +608,18 @@ export function buildIntegrationConfigOptionRegistry(
|
||||
defaultValue: defaultConfig.ai.baseUrl,
|
||||
description: 'Base URL for the shared OpenAI-compatible AI provider.',
|
||||
},
|
||||
{
|
||||
path: 'ai.model',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.ai.model,
|
||||
description: 'Default model identifier requested from the shared AI provider.',
|
||||
},
|
||||
{
|
||||
path: 'ai.systemPrompt',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.ai.systemPrompt,
|
||||
description: 'Default system prompt sent with shared AI provider requests.',
|
||||
},
|
||||
{
|
||||
path: 'ai.requestTimeoutMs',
|
||||
kind: 'number',
|
||||
|
||||
@@ -0,0 +1,423 @@
|
||||
import type {
|
||||
ControllerAxisBinding,
|
||||
ControllerAxisDirection,
|
||||
ControllerButtonBinding,
|
||||
ControllerButtonIndicesConfig,
|
||||
ControllerDpadFallback,
|
||||
ResolvedControllerAxisBinding,
|
||||
ResolvedControllerBindingsConfig,
|
||||
ResolvedControllerDiscreteBinding,
|
||||
} from '../../types/runtime';
|
||||
import { ResolveContext } from './context';
|
||||
import { asBoolean, asNumber, asString, isObject } from './shared';
|
||||
|
||||
const CONTROLLER_BUTTON_BINDINGS = [
|
||||
'none',
|
||||
'select',
|
||||
'buttonSouth',
|
||||
'buttonEast',
|
||||
'buttonNorth',
|
||||
'buttonWest',
|
||||
'leftShoulder',
|
||||
'rightShoulder',
|
||||
'leftStickPress',
|
||||
'rightStickPress',
|
||||
'leftTrigger',
|
||||
'rightTrigger',
|
||||
] as const;
|
||||
|
||||
const CONTROLLER_AXIS_BINDINGS = [
|
||||
'leftStickX',
|
||||
'leftStickY',
|
||||
'rightStickX',
|
||||
'rightStickY',
|
||||
] as const;
|
||||
|
||||
const CONTROLLER_AXIS_INDEX_BY_BINDING: Record<ControllerAxisBinding, number> = {
|
||||
leftStickX: 0,
|
||||
leftStickY: 1,
|
||||
rightStickX: 3,
|
||||
rightStickY: 4,
|
||||
};
|
||||
|
||||
const CONTROLLER_BUTTON_INDEX_KEY_BY_BINDING: Record<
|
||||
Exclude<ControllerButtonBinding, 'none'>,
|
||||
keyof Required<ControllerButtonIndicesConfig>
|
||||
> = {
|
||||
select: 'select',
|
||||
buttonSouth: 'buttonSouth',
|
||||
buttonEast: 'buttonEast',
|
||||
buttonNorth: 'buttonNorth',
|
||||
buttonWest: 'buttonWest',
|
||||
leftShoulder: 'leftShoulder',
|
||||
rightShoulder: 'rightShoulder',
|
||||
leftStickPress: 'leftStickPress',
|
||||
rightStickPress: 'rightStickPress',
|
||||
leftTrigger: 'leftTrigger',
|
||||
rightTrigger: 'rightTrigger',
|
||||
};
|
||||
|
||||
const CONTROLLER_AXIS_FALLBACK_BY_SLOT = {
|
||||
leftStickHorizontal: 'horizontal',
|
||||
leftStickVertical: 'vertical',
|
||||
rightStickHorizontal: 'none',
|
||||
rightStickVertical: 'none',
|
||||
} as const satisfies Record<string, ControllerDpadFallback>;
|
||||
|
||||
const CONTROLLER_BUTTON_INDEX_KEYS = [
|
||||
'select',
|
||||
'buttonSouth',
|
||||
'buttonEast',
|
||||
'buttonNorth',
|
||||
'buttonWest',
|
||||
'leftShoulder',
|
||||
'rightShoulder',
|
||||
'leftStickPress',
|
||||
'rightStickPress',
|
||||
'leftTrigger',
|
||||
'rightTrigger',
|
||||
] as const;
|
||||
|
||||
const CONTROLLER_DISCRETE_BINDING_KEYS = [
|
||||
'toggleLookup',
|
||||
'closeLookup',
|
||||
'toggleKeyboardOnlyMode',
|
||||
'mineCard',
|
||||
'quitMpv',
|
||||
'previousAudio',
|
||||
'nextAudio',
|
||||
'playCurrentAudio',
|
||||
'toggleMpvPause',
|
||||
] as const;
|
||||
|
||||
const CONTROLLER_AXIS_BINDING_KEYS = [
|
||||
'leftStickHorizontal',
|
||||
'leftStickVertical',
|
||||
'rightStickHorizontal',
|
||||
'rightStickVertical',
|
||||
] as const;
|
||||
|
||||
const RESERVED_CONTROLLER_PROFILE_IDS = new Set(['__proto__', 'constructor', 'prototype']);
|
||||
|
||||
type ControllerBindingsTarget = Required<ResolvedControllerBindingsConfig>;
|
||||
type ControllerButtonIndicesTarget = Required<ControllerButtonIndicesConfig>;
|
||||
|
||||
function isControllerAxisDirection(value: unknown): value is ControllerAxisDirection {
|
||||
return value === 'negative' || value === 'positive';
|
||||
}
|
||||
|
||||
function isControllerDpadFallback(value: unknown): value is ControllerDpadFallback {
|
||||
return value === 'none' || value === 'horizontal' || value === 'vertical';
|
||||
}
|
||||
|
||||
function resolveLegacyDiscreteBinding(
|
||||
value: ControllerButtonBinding,
|
||||
buttonIndices: Required<ControllerButtonIndicesConfig>,
|
||||
): ResolvedControllerDiscreteBinding {
|
||||
if (value === 'none') {
|
||||
return { kind: 'none' };
|
||||
}
|
||||
return {
|
||||
kind: 'button',
|
||||
buttonIndex: buttonIndices[CONTROLLER_BUTTON_INDEX_KEY_BY_BINDING[value]],
|
||||
};
|
||||
}
|
||||
|
||||
function resolveLegacyAxisBinding(
|
||||
value: ControllerAxisBinding,
|
||||
slot: keyof typeof CONTROLLER_AXIS_FALLBACK_BY_SLOT,
|
||||
): ResolvedControllerAxisBinding {
|
||||
return {
|
||||
kind: 'axis',
|
||||
axisIndex: CONTROLLER_AXIS_INDEX_BY_BINDING[value],
|
||||
dpadFallback: CONTROLLER_AXIS_FALLBACK_BY_SLOT[slot],
|
||||
};
|
||||
}
|
||||
|
||||
function parseDiscreteBindingObject(value: unknown): ResolvedControllerDiscreteBinding | null {
|
||||
if (!isObject(value) || typeof value.kind !== 'string') return null;
|
||||
if (value.kind === 'none') {
|
||||
return { kind: 'none' };
|
||||
}
|
||||
if (value.kind === 'button') {
|
||||
return typeof value.buttonIndex === 'number' &&
|
||||
Number.isInteger(value.buttonIndex) &&
|
||||
value.buttonIndex >= 0
|
||||
? { kind: 'button', buttonIndex: value.buttonIndex }
|
||||
: null;
|
||||
}
|
||||
if (value.kind === 'axis') {
|
||||
return typeof value.axisIndex === 'number' &&
|
||||
Number.isInteger(value.axisIndex) &&
|
||||
value.axisIndex >= 0 &&
|
||||
isControllerAxisDirection(value.direction)
|
||||
? { kind: 'axis', axisIndex: value.axisIndex, direction: value.direction }
|
||||
: null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseAxisBindingObject(
|
||||
value: unknown,
|
||||
slot: keyof typeof CONTROLLER_AXIS_FALLBACK_BY_SLOT,
|
||||
): ResolvedControllerAxisBinding | null {
|
||||
if (isObject(value) && value.kind === 'none') {
|
||||
return { kind: 'none' };
|
||||
}
|
||||
if (!isObject(value) || value.kind !== 'axis') return null;
|
||||
if (
|
||||
typeof value.axisIndex !== 'number' ||
|
||||
!Number.isInteger(value.axisIndex) ||
|
||||
value.axisIndex < 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
if (value.dpadFallback !== undefined && !isControllerDpadFallback(value.dpadFallback)) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
kind: 'axis',
|
||||
axisIndex: value.axisIndex,
|
||||
dpadFallback: value.dpadFallback ?? CONTROLLER_AXIS_FALLBACK_BY_SLOT[slot],
|
||||
};
|
||||
}
|
||||
|
||||
function applyControllerButtonIndices(
|
||||
source: unknown,
|
||||
target: ControllerButtonIndicesTarget,
|
||||
pathPrefix: string,
|
||||
warn: ResolveContext['warn'],
|
||||
): void {
|
||||
if (!isObject(source)) return;
|
||||
|
||||
for (const key of CONTROLLER_BUTTON_INDEX_KEYS) {
|
||||
const value = asNumber(source[key]);
|
||||
if (value !== undefined && value >= 0 && Number.isInteger(value)) {
|
||||
target[key] = value;
|
||||
} else if (source[key] !== undefined) {
|
||||
warn(`${pathPrefix}.${key}`, source[key], target[key], 'Expected non-negative integer.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function applyControllerBindings(
|
||||
source: unknown,
|
||||
target: ControllerBindingsTarget,
|
||||
buttonIndices: ControllerButtonIndicesTarget,
|
||||
pathPrefix: string,
|
||||
warn: ResolveContext['warn'],
|
||||
): void {
|
||||
if (!isObject(source)) return;
|
||||
|
||||
for (const key of CONTROLLER_DISCRETE_BINDING_KEYS) {
|
||||
const bindingValue = source[key];
|
||||
const legacyValue = asString(bindingValue);
|
||||
if (
|
||||
legacyValue !== undefined &&
|
||||
CONTROLLER_BUTTON_BINDINGS.includes(
|
||||
legacyValue as (typeof CONTROLLER_BUTTON_BINDINGS)[number],
|
||||
)
|
||||
) {
|
||||
target[key] = resolveLegacyDiscreteBinding(
|
||||
legacyValue as ControllerButtonBinding,
|
||||
buttonIndices,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const parsedObject = parseDiscreteBindingObject(bindingValue);
|
||||
if (parsedObject) {
|
||||
target[key] = parsedObject;
|
||||
} else if (bindingValue !== undefined) {
|
||||
warn(
|
||||
`${pathPrefix}.${key}`,
|
||||
bindingValue,
|
||||
target[key],
|
||||
"Expected legacy controller button name or binding object with kind 'none', 'button', or 'axis'.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const key of CONTROLLER_AXIS_BINDING_KEYS) {
|
||||
const bindingValue = source[key];
|
||||
const legacyValue = asString(bindingValue);
|
||||
if (
|
||||
legacyValue !== undefined &&
|
||||
CONTROLLER_AXIS_BINDINGS.includes(legacyValue as (typeof CONTROLLER_AXIS_BINDINGS)[number])
|
||||
) {
|
||||
target[key] = resolveLegacyAxisBinding(legacyValue as ControllerAxisBinding, key);
|
||||
continue;
|
||||
}
|
||||
if (legacyValue === 'none') {
|
||||
target[key] = { kind: 'none' };
|
||||
continue;
|
||||
}
|
||||
const parsedObject = parseAxisBindingObject(bindingValue, key);
|
||||
if (parsedObject) {
|
||||
target[key] = parsedObject;
|
||||
} else if (bindingValue !== undefined) {
|
||||
warn(
|
||||
`${pathPrefix}.${key}`,
|
||||
bindingValue,
|
||||
target[key],
|
||||
"Expected legacy controller axis name ('none' allowed) or binding object with kind 'axis'.",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function applyControllerConfig(context: ResolveContext): void {
|
||||
const { src, resolved, warn } = context;
|
||||
if (!isObject(src.controller)) return;
|
||||
|
||||
const enabled = asBoolean(src.controller.enabled);
|
||||
if (enabled !== undefined) {
|
||||
resolved.controller.enabled = enabled;
|
||||
} else if (src.controller.enabled !== undefined) {
|
||||
warn(
|
||||
'controller.enabled',
|
||||
src.controller.enabled,
|
||||
resolved.controller.enabled,
|
||||
'Expected boolean.',
|
||||
);
|
||||
}
|
||||
|
||||
const preferredGamepadId = asString(src.controller.preferredGamepadId);
|
||||
if (preferredGamepadId !== undefined) {
|
||||
resolved.controller.preferredGamepadId = preferredGamepadId;
|
||||
}
|
||||
|
||||
const preferredGamepadLabel = asString(src.controller.preferredGamepadLabel);
|
||||
if (preferredGamepadLabel !== undefined) {
|
||||
resolved.controller.preferredGamepadLabel = preferredGamepadLabel;
|
||||
}
|
||||
|
||||
const smoothScroll = asBoolean(src.controller.smoothScroll);
|
||||
if (smoothScroll !== undefined) {
|
||||
resolved.controller.smoothScroll = smoothScroll;
|
||||
} else if (src.controller.smoothScroll !== undefined) {
|
||||
warn(
|
||||
'controller.smoothScroll',
|
||||
src.controller.smoothScroll,
|
||||
resolved.controller.smoothScroll,
|
||||
'Expected boolean.',
|
||||
);
|
||||
}
|
||||
|
||||
const triggerInputMode = asString(src.controller.triggerInputMode);
|
||||
if (
|
||||
triggerInputMode === 'auto' ||
|
||||
triggerInputMode === 'digital' ||
|
||||
triggerInputMode === 'analog'
|
||||
) {
|
||||
resolved.controller.triggerInputMode = triggerInputMode;
|
||||
} else if (src.controller.triggerInputMode !== undefined) {
|
||||
warn(
|
||||
'controller.triggerInputMode',
|
||||
src.controller.triggerInputMode,
|
||||
resolved.controller.triggerInputMode,
|
||||
"Expected 'auto', 'digital', or 'analog'.",
|
||||
);
|
||||
}
|
||||
|
||||
const boundedNumberKeys = [
|
||||
'scrollPixelsPerSecond',
|
||||
'horizontalJumpPixels',
|
||||
'repeatDelayMs',
|
||||
'repeatIntervalMs',
|
||||
] as const;
|
||||
for (const key of boundedNumberKeys) {
|
||||
const value = asNumber(src.controller[key]);
|
||||
if (value !== undefined && Math.floor(value) > 0) {
|
||||
resolved.controller[key] = Math.floor(value) as (typeof resolved.controller)[typeof key];
|
||||
} else if (src.controller[key] !== undefined) {
|
||||
warn(
|
||||
`controller.${key}`,
|
||||
src.controller[key],
|
||||
resolved.controller[key],
|
||||
'Expected positive number.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const deadzoneKeys = ['stickDeadzone', 'triggerDeadzone'] as const;
|
||||
for (const key of deadzoneKeys) {
|
||||
const value = asNumber(src.controller[key]);
|
||||
if (value !== undefined && value >= 0 && value <= 1) {
|
||||
resolved.controller[key] = value as (typeof resolved.controller)[typeof key];
|
||||
} else if (src.controller[key] !== undefined) {
|
||||
warn(
|
||||
`controller.${key}`,
|
||||
src.controller[key],
|
||||
resolved.controller[key],
|
||||
'Expected number between 0 and 1.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
applyControllerButtonIndices(
|
||||
src.controller.buttonIndices,
|
||||
resolved.controller.buttonIndices,
|
||||
'controller.buttonIndices',
|
||||
warn,
|
||||
);
|
||||
applyControllerBindings(
|
||||
src.controller.bindings,
|
||||
resolved.controller.bindings,
|
||||
resolved.controller.buttonIndices,
|
||||
'controller.bindings',
|
||||
warn,
|
||||
);
|
||||
|
||||
if (isObject(src.controller.profiles)) {
|
||||
for (const [profileId, rawProfile] of Object.entries(src.controller.profiles)) {
|
||||
if (RESERVED_CONTROLLER_PROFILE_IDS.has(profileId)) {
|
||||
warn(
|
||||
`controller.profiles.${profileId}`,
|
||||
rawProfile,
|
||||
undefined,
|
||||
'Reserved profile id is not allowed.',
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (!isObject(rawProfile)) {
|
||||
warn(
|
||||
`controller.profiles.${profileId}`,
|
||||
rawProfile,
|
||||
undefined,
|
||||
'Expected controller profile object.',
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const label = asString(rawProfile.label);
|
||||
if (rawProfile.label !== undefined && label === undefined) {
|
||||
warn(
|
||||
`controller.profiles.${profileId}.label`,
|
||||
rawProfile.label,
|
||||
profileId,
|
||||
'Expected string.',
|
||||
);
|
||||
}
|
||||
|
||||
const profile = {
|
||||
label: label ?? profileId,
|
||||
buttonIndices: structuredClone(resolved.controller.buttonIndices),
|
||||
bindings: structuredClone(resolved.controller.bindings),
|
||||
};
|
||||
applyControllerButtonIndices(
|
||||
rawProfile.buttonIndices,
|
||||
profile.buttonIndices,
|
||||
`controller.profiles.${profileId}.buttonIndices`,
|
||||
warn,
|
||||
);
|
||||
applyControllerBindings(
|
||||
rawProfile.bindings,
|
||||
profile.bindings,
|
||||
profile.buttonIndices,
|
||||
`controller.profiles.${profileId}.bindings`,
|
||||
warn,
|
||||
);
|
||||
resolved.controller.profiles[profileId] = profile;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,150 +1,7 @@
|
||||
import type {
|
||||
ControllerAxisBinding,
|
||||
ControllerAxisBindingConfig,
|
||||
ControllerAxisDirection,
|
||||
ControllerButtonBinding,
|
||||
ControllerButtonIndicesConfig,
|
||||
ControllerDpadFallback,
|
||||
ControllerDiscreteBindingConfig,
|
||||
ResolvedControllerAxisBinding,
|
||||
ResolvedControllerDiscreteBinding,
|
||||
} from '../../types/runtime';
|
||||
import { ResolveContext } from './context';
|
||||
import { applyControllerConfig } from './controller';
|
||||
import { asBoolean, asNumber, asString, isObject } from './shared';
|
||||
|
||||
const CONTROLLER_BUTTON_BINDINGS = [
|
||||
'none',
|
||||
'select',
|
||||
'buttonSouth',
|
||||
'buttonEast',
|
||||
'buttonNorth',
|
||||
'buttonWest',
|
||||
'leftShoulder',
|
||||
'rightShoulder',
|
||||
'leftStickPress',
|
||||
'rightStickPress',
|
||||
'leftTrigger',
|
||||
'rightTrigger',
|
||||
] as const;
|
||||
|
||||
const CONTROLLER_AXIS_BINDINGS = [
|
||||
'leftStickX',
|
||||
'leftStickY',
|
||||
'rightStickX',
|
||||
'rightStickY',
|
||||
] as const;
|
||||
|
||||
const CONTROLLER_AXIS_INDEX_BY_BINDING: Record<ControllerAxisBinding, number> = {
|
||||
leftStickX: 0,
|
||||
leftStickY: 1,
|
||||
rightStickX: 3,
|
||||
rightStickY: 4,
|
||||
};
|
||||
|
||||
const CONTROLLER_BUTTON_INDEX_KEY_BY_BINDING: Record<
|
||||
Exclude<ControllerButtonBinding, 'none'>,
|
||||
keyof Required<ControllerButtonIndicesConfig>
|
||||
> = {
|
||||
select: 'select',
|
||||
buttonSouth: 'buttonSouth',
|
||||
buttonEast: 'buttonEast',
|
||||
buttonNorth: 'buttonNorth',
|
||||
buttonWest: 'buttonWest',
|
||||
leftShoulder: 'leftShoulder',
|
||||
rightShoulder: 'rightShoulder',
|
||||
leftStickPress: 'leftStickPress',
|
||||
rightStickPress: 'rightStickPress',
|
||||
leftTrigger: 'leftTrigger',
|
||||
rightTrigger: 'rightTrigger',
|
||||
};
|
||||
|
||||
const CONTROLLER_AXIS_FALLBACK_BY_SLOT = {
|
||||
leftStickHorizontal: 'horizontal',
|
||||
leftStickVertical: 'vertical',
|
||||
rightStickHorizontal: 'none',
|
||||
rightStickVertical: 'none',
|
||||
} as const satisfies Record<string, ControllerDpadFallback>;
|
||||
|
||||
function isControllerAxisDirection(value: unknown): value is ControllerAxisDirection {
|
||||
return value === 'negative' || value === 'positive';
|
||||
}
|
||||
|
||||
function isControllerDpadFallback(value: unknown): value is ControllerDpadFallback {
|
||||
return value === 'none' || value === 'horizontal' || value === 'vertical';
|
||||
}
|
||||
|
||||
function resolveLegacyDiscreteBinding(
|
||||
value: ControllerButtonBinding,
|
||||
buttonIndices: Required<ControllerButtonIndicesConfig>,
|
||||
): ResolvedControllerDiscreteBinding {
|
||||
if (value === 'none') {
|
||||
return { kind: 'none' };
|
||||
}
|
||||
return {
|
||||
kind: 'button',
|
||||
buttonIndex: buttonIndices[CONTROLLER_BUTTON_INDEX_KEY_BY_BINDING[value]],
|
||||
};
|
||||
}
|
||||
|
||||
function resolveLegacyAxisBinding(
|
||||
value: ControllerAxisBinding,
|
||||
slot: keyof typeof CONTROLLER_AXIS_FALLBACK_BY_SLOT,
|
||||
): ResolvedControllerAxisBinding {
|
||||
return {
|
||||
kind: 'axis',
|
||||
axisIndex: CONTROLLER_AXIS_INDEX_BY_BINDING[value],
|
||||
dpadFallback: CONTROLLER_AXIS_FALLBACK_BY_SLOT[slot],
|
||||
};
|
||||
}
|
||||
|
||||
function parseDiscreteBindingObject(value: unknown): ResolvedControllerDiscreteBinding | null {
|
||||
if (!isObject(value) || typeof value.kind !== 'string') return null;
|
||||
if (value.kind === 'none') {
|
||||
return { kind: 'none' };
|
||||
}
|
||||
if (value.kind === 'button') {
|
||||
return typeof value.buttonIndex === 'number' &&
|
||||
Number.isInteger(value.buttonIndex) &&
|
||||
value.buttonIndex >= 0
|
||||
? { kind: 'button', buttonIndex: value.buttonIndex }
|
||||
: null;
|
||||
}
|
||||
if (value.kind === 'axis') {
|
||||
return typeof value.axisIndex === 'number' &&
|
||||
Number.isInteger(value.axisIndex) &&
|
||||
value.axisIndex >= 0 &&
|
||||
isControllerAxisDirection(value.direction)
|
||||
? { kind: 'axis', axisIndex: value.axisIndex, direction: value.direction }
|
||||
: null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseAxisBindingObject(
|
||||
value: unknown,
|
||||
slot: keyof typeof CONTROLLER_AXIS_FALLBACK_BY_SLOT,
|
||||
): ResolvedControllerAxisBinding | null {
|
||||
if (isObject(value) && value.kind === 'none') {
|
||||
return { kind: 'none' };
|
||||
}
|
||||
if (!isObject(value) || value.kind !== 'axis') return null;
|
||||
if (
|
||||
typeof value.axisIndex !== 'number' ||
|
||||
!Number.isInteger(value.axisIndex) ||
|
||||
value.axisIndex < 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
if (value.dpadFallback !== undefined && !isControllerDpadFallback(value.dpadFallback)) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
kind: 'axis',
|
||||
axisIndex: value.axisIndex,
|
||||
dpadFallback: value.dpadFallback ?? CONTROLLER_AXIS_FALLBACK_BY_SLOT[slot],
|
||||
};
|
||||
}
|
||||
|
||||
export function applyCoreDomainConfig(context: ResolveContext): void {
|
||||
const { src, resolved, warn } = context;
|
||||
|
||||
@@ -245,203 +102,7 @@ export function applyCoreDomainConfig(context: ResolveContext): void {
|
||||
}
|
||||
}
|
||||
|
||||
if (isObject(src.controller)) {
|
||||
const enabled = asBoolean(src.controller.enabled);
|
||||
if (enabled !== undefined) {
|
||||
resolved.controller.enabled = enabled;
|
||||
} else if (src.controller.enabled !== undefined) {
|
||||
warn(
|
||||
'controller.enabled',
|
||||
src.controller.enabled,
|
||||
resolved.controller.enabled,
|
||||
'Expected boolean.',
|
||||
);
|
||||
}
|
||||
|
||||
const preferredGamepadId = asString(src.controller.preferredGamepadId);
|
||||
if (preferredGamepadId !== undefined) {
|
||||
resolved.controller.preferredGamepadId = preferredGamepadId;
|
||||
}
|
||||
|
||||
const preferredGamepadLabel = asString(src.controller.preferredGamepadLabel);
|
||||
if (preferredGamepadLabel !== undefined) {
|
||||
resolved.controller.preferredGamepadLabel = preferredGamepadLabel;
|
||||
}
|
||||
|
||||
const smoothScroll = asBoolean(src.controller.smoothScroll);
|
||||
if (smoothScroll !== undefined) {
|
||||
resolved.controller.smoothScroll = smoothScroll;
|
||||
} else if (src.controller.smoothScroll !== undefined) {
|
||||
warn(
|
||||
'controller.smoothScroll',
|
||||
src.controller.smoothScroll,
|
||||
resolved.controller.smoothScroll,
|
||||
'Expected boolean.',
|
||||
);
|
||||
}
|
||||
|
||||
const triggerInputMode = asString(src.controller.triggerInputMode);
|
||||
if (
|
||||
triggerInputMode === 'auto' ||
|
||||
triggerInputMode === 'digital' ||
|
||||
triggerInputMode === 'analog'
|
||||
) {
|
||||
resolved.controller.triggerInputMode = triggerInputMode;
|
||||
} else if (src.controller.triggerInputMode !== undefined) {
|
||||
warn(
|
||||
'controller.triggerInputMode',
|
||||
src.controller.triggerInputMode,
|
||||
resolved.controller.triggerInputMode,
|
||||
"Expected 'auto', 'digital', or 'analog'.",
|
||||
);
|
||||
}
|
||||
|
||||
const boundedNumberKeys = [
|
||||
'scrollPixelsPerSecond',
|
||||
'horizontalJumpPixels',
|
||||
'repeatDelayMs',
|
||||
'repeatIntervalMs',
|
||||
] as const;
|
||||
for (const key of boundedNumberKeys) {
|
||||
const value = asNumber(src.controller[key]);
|
||||
if (value !== undefined && Math.floor(value) > 0) {
|
||||
resolved.controller[key] = Math.floor(value) as (typeof resolved.controller)[typeof key];
|
||||
} else if (src.controller[key] !== undefined) {
|
||||
warn(
|
||||
`controller.${key}`,
|
||||
src.controller[key],
|
||||
resolved.controller[key],
|
||||
'Expected positive number.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const deadzoneKeys = ['stickDeadzone', 'triggerDeadzone'] as const;
|
||||
for (const key of deadzoneKeys) {
|
||||
const value = asNumber(src.controller[key]);
|
||||
if (value !== undefined && value >= 0 && value <= 1) {
|
||||
resolved.controller[key] = value as (typeof resolved.controller)[typeof key];
|
||||
} else if (src.controller[key] !== undefined) {
|
||||
warn(
|
||||
`controller.${key}`,
|
||||
src.controller[key],
|
||||
resolved.controller[key],
|
||||
'Expected number between 0 and 1.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (isObject(src.controller.buttonIndices)) {
|
||||
const buttonIndexKeys = [
|
||||
'select',
|
||||
'buttonSouth',
|
||||
'buttonEast',
|
||||
'buttonNorth',
|
||||
'buttonWest',
|
||||
'leftShoulder',
|
||||
'rightShoulder',
|
||||
'leftStickPress',
|
||||
'rightStickPress',
|
||||
'leftTrigger',
|
||||
'rightTrigger',
|
||||
] as const;
|
||||
|
||||
for (const key of buttonIndexKeys) {
|
||||
const value = asNumber(src.controller.buttonIndices[key]);
|
||||
if (value !== undefined && value >= 0 && Number.isInteger(value)) {
|
||||
resolved.controller.buttonIndices[key] = value;
|
||||
} else if (src.controller.buttonIndices[key] !== undefined) {
|
||||
warn(
|
||||
`controller.buttonIndices.${key}`,
|
||||
src.controller.buttonIndices[key],
|
||||
resolved.controller.buttonIndices[key],
|
||||
'Expected non-negative integer.',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isObject(src.controller.bindings)) {
|
||||
const buttonBindingKeys = [
|
||||
'toggleLookup',
|
||||
'closeLookup',
|
||||
'toggleKeyboardOnlyMode',
|
||||
'mineCard',
|
||||
'quitMpv',
|
||||
'previousAudio',
|
||||
'nextAudio',
|
||||
'playCurrentAudio',
|
||||
'toggleMpvPause',
|
||||
] as const;
|
||||
|
||||
for (const key of buttonBindingKeys) {
|
||||
const bindingValue = src.controller.bindings[key];
|
||||
const legacyValue = asString(bindingValue);
|
||||
if (
|
||||
legacyValue !== undefined &&
|
||||
CONTROLLER_BUTTON_BINDINGS.includes(
|
||||
legacyValue as (typeof CONTROLLER_BUTTON_BINDINGS)[number],
|
||||
)
|
||||
) {
|
||||
resolved.controller.bindings[key] = resolveLegacyDiscreteBinding(
|
||||
legacyValue as ControllerButtonBinding,
|
||||
resolved.controller.buttonIndices,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const parsedObject = parseDiscreteBindingObject(bindingValue);
|
||||
if (parsedObject) {
|
||||
resolved.controller.bindings[key] = parsedObject;
|
||||
} else if (bindingValue !== undefined) {
|
||||
warn(
|
||||
`controller.bindings.${key}`,
|
||||
bindingValue,
|
||||
resolved.controller.bindings[key],
|
||||
"Expected legacy controller button name or binding object with kind 'none', 'button', or 'axis'.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const axisBindingKeys = [
|
||||
'leftStickHorizontal',
|
||||
'leftStickVertical',
|
||||
'rightStickHorizontal',
|
||||
'rightStickVertical',
|
||||
] as const;
|
||||
|
||||
for (const key of axisBindingKeys) {
|
||||
const bindingValue = src.controller.bindings[key];
|
||||
const legacyValue = asString(bindingValue);
|
||||
if (
|
||||
legacyValue !== undefined &&
|
||||
CONTROLLER_AXIS_BINDINGS.includes(
|
||||
legacyValue as (typeof CONTROLLER_AXIS_BINDINGS)[number],
|
||||
)
|
||||
) {
|
||||
resolved.controller.bindings[key] = resolveLegacyAxisBinding(
|
||||
legacyValue as ControllerAxisBinding,
|
||||
key,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (legacyValue === 'none') {
|
||||
resolved.controller.bindings[key] = { kind: 'none' };
|
||||
continue;
|
||||
}
|
||||
const parsedObject = parseAxisBindingObject(bindingValue, key);
|
||||
if (parsedObject) {
|
||||
resolved.controller.bindings[key] = parsedObject;
|
||||
} else if (bindingValue !== undefined) {
|
||||
warn(
|
||||
`controller.bindings.${key}`,
|
||||
bindingValue,
|
||||
resolved.controller.bindings[key],
|
||||
"Expected legacy controller axis name ('none' allowed) or binding object with kind 'axis'.",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
applyControllerConfig(context);
|
||||
|
||||
if (Array.isArray(src.keybindings)) {
|
||||
resolved.keybindings = src.keybindings.filter(
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { parse } from 'jsonc-parser';
|
||||
import { DEFAULT_CONFIG } from '../definitions';
|
||||
import { applyConfigSettingsPatchToContent, buildConfigSettingsSnapshot } from './jsonc-edit';
|
||||
import { buildConfigSettingsRegistry } from './registry';
|
||||
|
||||
test('applyConfigSettingsPatchToContent preserves JSONC comments while setting nested values', () => {
|
||||
const input = `{
|
||||
// keep this comment
|
||||
"subtitleStyle": {
|
||||
"fontSize": 35,
|
||||
},
|
||||
}`;
|
||||
|
||||
const result = applyConfigSettingsPatchToContent({
|
||||
content: input,
|
||||
operations: [
|
||||
{
|
||||
op: 'set',
|
||||
path: 'subtitleStyle.autoPauseVideoOnHover',
|
||||
value: false,
|
||||
},
|
||||
],
|
||||
previousWarnings: [],
|
||||
});
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.match(result.content, /keep this comment/);
|
||||
const parsed = parse(result.content);
|
||||
assert.equal(parsed.subtitleStyle.autoPauseVideoOnHover, false);
|
||||
assert.equal(parsed.subtitleStyle.fontSize, 35);
|
||||
});
|
||||
|
||||
test('applyConfigSettingsPatchToContent reset removes explicit path', () => {
|
||||
const input = `{
|
||||
"subtitleStyle": {
|
||||
"fontSize": 41,
|
||||
"autoPauseVideoOnHover": false
|
||||
}
|
||||
}`;
|
||||
|
||||
const result = applyConfigSettingsPatchToContent({
|
||||
content: input,
|
||||
operations: [{ op: 'reset', path: 'subtitleStyle.autoPauseVideoOnHover' }],
|
||||
previousWarnings: [],
|
||||
});
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
const parsed = parse(result.content);
|
||||
assert.equal(Object.hasOwn(parsed.subtitleStyle, 'autoPauseVideoOnHover'), false);
|
||||
assert.equal(parsed.subtitleStyle.fontSize, 41);
|
||||
});
|
||||
|
||||
test('applyConfigSettingsPatchToContent rejects warnings caused by modified fields', () => {
|
||||
const result = applyConfigSettingsPatchToContent({
|
||||
content: '{}',
|
||||
operations: [
|
||||
{
|
||||
op: 'set',
|
||||
path: 'subtitleStyle.autoPauseVideoOnHover',
|
||||
value: 'bad',
|
||||
},
|
||||
],
|
||||
previousWarnings: [],
|
||||
});
|
||||
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.warnings[0]?.path, 'subtitleStyle.autoPauseVideoOnHover');
|
||||
});
|
||||
|
||||
test('buildConfigSettingsSnapshot masks configured secret values', () => {
|
||||
const fields = buildConfigSettingsRegistry(DEFAULT_CONFIG);
|
||||
const snapshot = buildConfigSettingsSnapshot({
|
||||
configPath: '/tmp/config.jsonc',
|
||||
rawConfig: {
|
||||
ai: {
|
||||
apiKey: 'secret-key',
|
||||
},
|
||||
},
|
||||
resolvedConfig: {
|
||||
...DEFAULT_CONFIG,
|
||||
ai: {
|
||||
...DEFAULT_CONFIG.ai,
|
||||
apiKey: 'secret-key',
|
||||
},
|
||||
},
|
||||
warnings: [],
|
||||
fields,
|
||||
});
|
||||
|
||||
const apiKey = snapshot.values['ai.apiKey'];
|
||||
assert.deepEqual(apiKey, { configured: true });
|
||||
});
|
||||
@@ -0,0 +1,200 @@
|
||||
import {
|
||||
applyEdits,
|
||||
modify,
|
||||
parse as parseJsonc,
|
||||
type FormattingOptions,
|
||||
type ParseError,
|
||||
} from 'jsonc-parser';
|
||||
import type { ConfigValidationWarning, RawConfig, ResolvedConfig } from '../../types/config';
|
||||
import type {
|
||||
ConfigSettingsField,
|
||||
ConfigSettingsPatchOperation,
|
||||
ConfigSettingsSnapshot,
|
||||
} from '../../types/settings';
|
||||
import { resolveConfig } from '../resolve';
|
||||
import { getConfigValueAtPath } from './registry';
|
||||
|
||||
const JSONC_FORMATTING_OPTIONS: FormattingOptions = {
|
||||
insertSpaces: true,
|
||||
tabSize: 2,
|
||||
eol: '\n',
|
||||
};
|
||||
|
||||
export type ConfigSettingsPatchApplyResult =
|
||||
| {
|
||||
ok: true;
|
||||
content: string;
|
||||
rawConfig: RawConfig;
|
||||
resolvedConfig: ResolvedConfig;
|
||||
warnings: ConfigValidationWarning[];
|
||||
}
|
||||
| {
|
||||
ok: false;
|
||||
content: string;
|
||||
warnings: ConfigValidationWarning[];
|
||||
error: string;
|
||||
};
|
||||
|
||||
interface ApplyConfigSettingsPatchOptions {
|
||||
content: string;
|
||||
operations: ConfigSettingsPatchOperation[];
|
||||
previousWarnings: ConfigValidationWarning[];
|
||||
}
|
||||
|
||||
interface BuildConfigSettingsSnapshotOptions {
|
||||
configPath: string;
|
||||
rawConfig: RawConfig;
|
||||
resolvedConfig: ResolvedConfig;
|
||||
warnings: ConfigValidationWarning[];
|
||||
fields: ConfigSettingsField[];
|
||||
}
|
||||
|
||||
function pathToSegments(path: string): string[] {
|
||||
return path.split('.').filter(Boolean);
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function pathStartsWith(path: string, prefix: string): boolean {
|
||||
return path === prefix || path.startsWith(`${prefix}.`);
|
||||
}
|
||||
|
||||
function warningBelongsToModifiedPath(
|
||||
warning: ConfigValidationWarning,
|
||||
operation: ConfigSettingsPatchOperation,
|
||||
): boolean {
|
||||
return (
|
||||
pathStartsWith(warning.path, operation.path) || pathStartsWith(operation.path, warning.path)
|
||||
);
|
||||
}
|
||||
|
||||
function warningIdentity(warning: ConfigValidationWarning): string {
|
||||
return `${warning.path}\n${JSON.stringify(warning.value)}\n${warning.message}`;
|
||||
}
|
||||
|
||||
function parseRawConfig(content: string): RawConfig {
|
||||
const errors: ParseError[] = [];
|
||||
const parsed = parseJsonc(content || '{}', errors, {
|
||||
allowTrailingComma: true,
|
||||
disallowComments: false,
|
||||
});
|
||||
if (errors.length > 0) {
|
||||
throw new Error(`Invalid JSONC (${errors[0]?.error ?? 'unknown'})`);
|
||||
}
|
||||
return isRecord(parsed) ? (parsed as RawConfig) : {};
|
||||
}
|
||||
|
||||
function normalizeContent(content: string): string {
|
||||
return content.trim().length > 0 ? content : '{}\n';
|
||||
}
|
||||
|
||||
function applySingleOperation(content: string, operation: ConfigSettingsPatchOperation): string {
|
||||
const edits = modify(
|
||||
content,
|
||||
pathToSegments(operation.path),
|
||||
operation.op === 'reset' ? undefined : operation.value,
|
||||
{
|
||||
formattingOptions: JSONC_FORMATTING_OPTIONS,
|
||||
getInsertionIndex: (properties) => properties.length,
|
||||
},
|
||||
);
|
||||
return applyEdits(content, edits);
|
||||
}
|
||||
|
||||
function collectModifiedWarnings(
|
||||
warnings: ConfigValidationWarning[],
|
||||
operations: ConfigSettingsPatchOperation[],
|
||||
previousWarnings: ConfigValidationWarning[],
|
||||
): ConfigValidationWarning[] {
|
||||
const previous = new Set(previousWarnings.map(warningIdentity));
|
||||
return warnings.filter((warning) => {
|
||||
if (!operations.some((operation) => warningBelongsToModifiedPath(warning, operation))) {
|
||||
return false;
|
||||
}
|
||||
return !previous.has(warningIdentity(warning));
|
||||
});
|
||||
}
|
||||
|
||||
export function applyConfigSettingsPatchToContent(
|
||||
options: ApplyConfigSettingsPatchOptions,
|
||||
): ConfigSettingsPatchApplyResult {
|
||||
let content = normalizeContent(options.content);
|
||||
|
||||
try {
|
||||
parseRawConfig(content);
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
content,
|
||||
warnings: [],
|
||||
error: error instanceof Error ? error.message : 'Invalid JSONC.',
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
for (const operation of options.operations) {
|
||||
content = applySingleOperation(content, operation);
|
||||
}
|
||||
|
||||
const rawConfig = parseRawConfig(content);
|
||||
const { resolved, warnings } = resolveConfig(rawConfig);
|
||||
const modifiedWarnings = collectModifiedWarnings(
|
||||
warnings,
|
||||
options.operations,
|
||||
options.previousWarnings,
|
||||
);
|
||||
if (modifiedWarnings.length > 0) {
|
||||
return {
|
||||
ok: false,
|
||||
content,
|
||||
warnings: modifiedWarnings,
|
||||
error: 'One or more modified settings failed validation.',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
content,
|
||||
rawConfig,
|
||||
resolvedConfig: resolved,
|
||||
warnings,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
content,
|
||||
warnings: [],
|
||||
error: error instanceof Error ? error.message : 'Failed to update config content.',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function buildConfigSettingsSnapshot(
|
||||
options: BuildConfigSettingsSnapshotOptions,
|
||||
): ConfigSettingsSnapshot {
|
||||
const values: Record<string, unknown> = {};
|
||||
|
||||
for (const field of options.fields) {
|
||||
const rawValue = getConfigValueAtPath(options.rawConfig, field.configPath);
|
||||
const resolvedValue = getConfigValueAtPath(options.resolvedConfig, field.configPath);
|
||||
if (field.secret) {
|
||||
values[field.configPath] = {
|
||||
configured:
|
||||
(typeof rawValue === 'string' && rawValue.length > 0) ||
|
||||
(typeof resolvedValue === 'string' && resolvedValue.length > 0),
|
||||
};
|
||||
continue;
|
||||
}
|
||||
|
||||
values[field.configPath] = structuredClone(rawValue !== undefined ? rawValue : resolvedValue);
|
||||
}
|
||||
|
||||
return {
|
||||
configPath: options.configPath,
|
||||
fields: options.fields,
|
||||
values,
|
||||
warnings: options.warnings,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { DEFAULT_CONFIG } from '../definitions';
|
||||
import {
|
||||
buildConfigSettingsRegistry,
|
||||
getConfigSettingsCoverage,
|
||||
LEGACY_HIDDEN_CONFIG_PATHS,
|
||||
} from './registry';
|
||||
|
||||
test('config settings registry places hover pause under viewing playback behavior', () => {
|
||||
const fields = buildConfigSettingsRegistry(DEFAULT_CONFIG);
|
||||
const hoverPause = fields.find(
|
||||
(field) => field.configPath === 'subtitleStyle.autoPauseVideoOnHover',
|
||||
);
|
||||
|
||||
assert.ok(hoverPause);
|
||||
assert.equal(hoverPause.category, 'viewing');
|
||||
assert.equal(hoverPause.section, 'Playback pause behavior');
|
||||
assert.equal(hoverPause.control, 'boolean');
|
||||
});
|
||||
|
||||
test('config settings registry hides legacy and ignored paths from normal fields', () => {
|
||||
const fields = buildConfigSettingsRegistry(DEFAULT_CONFIG);
|
||||
const visiblePaths = new Set(
|
||||
fields.filter((field) => !field.legacyHidden).map((field) => field.configPath),
|
||||
);
|
||||
|
||||
for (const path of LEGACY_HIDDEN_CONFIG_PATHS) {
|
||||
assert.equal(visiblePaths.has(path), false, path);
|
||||
}
|
||||
assert.equal(visiblePaths.has('controller.buttonIndices'), false);
|
||||
});
|
||||
|
||||
test('config settings registry covers canonical defaults or marks explicit raw-only gaps', () => {
|
||||
const fields = buildConfigSettingsRegistry(DEFAULT_CONFIG);
|
||||
const coverage = getConfigSettingsCoverage(DEFAULT_CONFIG, fields);
|
||||
|
||||
assert.deepEqual(coverage.uncoveredDefaultPaths, []);
|
||||
});
|
||||
@@ -0,0 +1,346 @@
|
||||
import type { ResolvedConfig } from '../../types/config';
|
||||
import type {
|
||||
ConfigSettingsCategory,
|
||||
ConfigSettingsControl,
|
||||
ConfigSettingsField,
|
||||
ConfigSettingsRestartBehavior,
|
||||
} from '../../types/settings';
|
||||
import { CONFIG_OPTION_REGISTRY, DEFAULT_CONFIG } from '../definitions';
|
||||
|
||||
type Leaf = {
|
||||
path: string;
|
||||
value: unknown;
|
||||
};
|
||||
|
||||
export const LEGACY_HIDDEN_CONFIG_PATHS = [
|
||||
'ankiConnect.deck',
|
||||
'ankiConnect.wordField',
|
||||
'ankiConnect.audioField',
|
||||
'ankiConnect.imageField',
|
||||
'ankiConnect.sentenceField',
|
||||
'ankiConnect.miscInfoField',
|
||||
'ankiConnect.miscInfoPattern',
|
||||
'ankiConnect.generateAudio',
|
||||
'ankiConnect.generateImage',
|
||||
'ankiConnect.imageType',
|
||||
'ankiConnect.imageFormat',
|
||||
'ankiConnect.imageQuality',
|
||||
'ankiConnect.imageMaxWidth',
|
||||
'ankiConnect.imageMaxHeight',
|
||||
'ankiConnect.animatedFps',
|
||||
'ankiConnect.animatedMaxWidth',
|
||||
'ankiConnect.animatedMaxHeight',
|
||||
'ankiConnect.animatedCrf',
|
||||
'ankiConnect.syncAnimatedImageToWordAudio',
|
||||
'ankiConnect.audioPadding',
|
||||
'ankiConnect.fallbackDuration',
|
||||
'ankiConnect.maxMediaDuration',
|
||||
'ankiConnect.overwriteAudio',
|
||||
'ankiConnect.overwriteImage',
|
||||
'ankiConnect.mediaInsertMode',
|
||||
'ankiConnect.highlightWord',
|
||||
'ankiConnect.notificationType',
|
||||
'ankiConnect.autoUpdateNewCards',
|
||||
'ankiConnect.nPlusOne.highlightEnabled',
|
||||
'ankiConnect.nPlusOne.refreshMinutes',
|
||||
'ankiConnect.nPlusOne.matchMode',
|
||||
'ankiConnect.nPlusOne.decks',
|
||||
'ankiConnect.nPlusOne.knownWord',
|
||||
'ankiConnect.behavior.nPlusOneHighlightEnabled',
|
||||
'ankiConnect.behavior.nPlusOneRefreshMinutes',
|
||||
'ankiConnect.behavior.nPlusOneMatchMode',
|
||||
'ankiConnect.isLapis.sentenceCardSentenceField',
|
||||
'ankiConnect.isLapis.sentenceCardAudioField',
|
||||
'youtubeSubgen.primarySubLanguages',
|
||||
'anilist.characterDictionary.refreshTtlHours',
|
||||
'anilist.characterDictionary.evictionPolicy',
|
||||
'jellyfin.accessToken',
|
||||
'jellyfin.userId',
|
||||
'controller.buttonIndices',
|
||||
] as const;
|
||||
|
||||
const EXCLUDED_PREFIXES = ['controller.buttonIndices'] as const;
|
||||
|
||||
const JSON_OBJECT_FIELDS = new Set([
|
||||
'keybindings',
|
||||
'controller.bindings',
|
||||
'controller.profiles',
|
||||
'ankiConnect.knownWords.decks',
|
||||
]);
|
||||
|
||||
const SECRET_PATHS = new Set(['ai.apiKey', 'jimaku.apiKey', 'anilist.accessToken']);
|
||||
|
||||
const COLOR_SUFFIXES = new Set([
|
||||
'Color',
|
||||
'color',
|
||||
'backgroundColor',
|
||||
'singleColor',
|
||||
'knownWordColor',
|
||||
'nPlusOne',
|
||||
]);
|
||||
|
||||
const OPTION_BY_PATH = new Map(CONFIG_OPTION_REGISTRY.map((entry) => [entry.path, entry]));
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function pathStartsWith(path: string, prefix: string): boolean {
|
||||
return path === prefix || path.startsWith(`${prefix}.`);
|
||||
}
|
||||
|
||||
function isLegacyHidden(path: string): boolean {
|
||||
return (
|
||||
LEGACY_HIDDEN_CONFIG_PATHS.some((hiddenPath) => pathStartsWith(path, hiddenPath)) ||
|
||||
EXCLUDED_PREFIXES.some((prefix) => pathStartsWith(path, prefix))
|
||||
);
|
||||
}
|
||||
|
||||
function flattenConfigLeaves(value: unknown, prefix = ''): Leaf[] {
|
||||
if (JSON_OBJECT_FIELDS.has(prefix)) {
|
||||
return [{ path: prefix, value }];
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return [{ path: prefix, value }];
|
||||
}
|
||||
|
||||
if (isRecord(value)) {
|
||||
const entries = Object.entries(value).filter(([, child]) => child !== undefined);
|
||||
if (entries.length === 0) {
|
||||
return [{ path: prefix, value }];
|
||||
}
|
||||
return entries.flatMap(([key, child]) =>
|
||||
flattenConfigLeaves(child, prefix ? `${prefix}.${key}` : key),
|
||||
);
|
||||
}
|
||||
|
||||
return prefix ? [{ path: prefix, value }] : [];
|
||||
}
|
||||
|
||||
function humanizePath(path: string): string {
|
||||
const key = path.split('.').at(-1) ?? path;
|
||||
const spaced = key
|
||||
.replace(/_/g, ' ')
|
||||
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
||||
.replace(/\bai\b/i, 'AI')
|
||||
.replace(/\bmpv\b/i, 'mpv')
|
||||
.replace(/\byomitan\b/i, 'Yomitan')
|
||||
.replace(/\bjimaku\b/i, 'Jimaku')
|
||||
.replace(/\banilist\b/i, 'AniList')
|
||||
.replace(/\banki\b/i, 'Anki');
|
||||
return spaced.charAt(0).toUpperCase() + spaced.slice(1);
|
||||
}
|
||||
|
||||
function categoryAndSection(path: string): { category: ConfigSettingsCategory; section: string } {
|
||||
if (
|
||||
path === 'subtitleStyle.autoPauseVideoOnHover' ||
|
||||
path === 'subtitleStyle.autoPauseVideoOnYomitanPopup' ||
|
||||
path === 'subtitleSidebar.pauseVideoOnHover'
|
||||
) {
|
||||
return { category: 'viewing', section: 'Playback pause behavior' };
|
||||
}
|
||||
if (
|
||||
path.startsWith('ankiConnect.knownWords.') ||
|
||||
path.startsWith('ankiConnect.nPlusOne.') ||
|
||||
path.startsWith('subtitleStyle.frequencyDictionary.') ||
|
||||
path.startsWith('subtitleStyle.jlptColors.') ||
|
||||
path === 'subtitleStyle.enableJlpt' ||
|
||||
path === 'subtitleStyle.nameMatchEnabled' ||
|
||||
path === 'subtitleStyle.nameMatchColor'
|
||||
) {
|
||||
return { category: 'viewing', section: 'Annotation display' };
|
||||
}
|
||||
if (path.startsWith('subtitleStyle.secondary.')) {
|
||||
return { category: 'viewing', section: 'Secondary subtitle appearance' };
|
||||
}
|
||||
if (path.startsWith('subtitleStyle.')) {
|
||||
return { category: 'viewing', section: 'Primary subtitle appearance' };
|
||||
}
|
||||
if (path.startsWith('subtitleSidebar.')) {
|
||||
return { category: 'viewing', section: 'Subtitle sidebar' };
|
||||
}
|
||||
if (path.startsWith('subtitlePosition.') || path.startsWith('secondarySub.')) {
|
||||
return { category: 'viewing', section: 'Subtitle behavior' };
|
||||
}
|
||||
if (path.startsWith('ankiConnect.fields.')) {
|
||||
return { category: 'mining-anki', section: 'Note fields' };
|
||||
}
|
||||
if (path.startsWith('ankiConnect.media.')) {
|
||||
return { category: 'mining-anki', section: 'Media capture' };
|
||||
}
|
||||
if (path.startsWith('ankiConnect.isKiku.') || path.startsWith('ankiConnect.isLapis.')) {
|
||||
return { category: 'mining-anki', section: 'Kiku and Lapis' };
|
||||
}
|
||||
if (path.startsWith('ankiConnect.ai.')) {
|
||||
return { category: 'mining-anki', section: 'Anki AI' };
|
||||
}
|
||||
if (path.startsWith('ankiConnect.proxy.')) {
|
||||
return { category: 'mining-anki', section: 'AnkiConnect proxy' };
|
||||
}
|
||||
if (path.startsWith('ankiConnect.')) {
|
||||
return { category: 'mining-anki', section: 'AnkiConnect' };
|
||||
}
|
||||
if (
|
||||
path.startsWith('mpv.') ||
|
||||
path.startsWith('youtube.') ||
|
||||
path.startsWith('youtubeSubgen.') ||
|
||||
path.startsWith('jimaku.') ||
|
||||
path.startsWith('subsync.')
|
||||
) {
|
||||
return { category: 'playback-sources', section: topSection(path) };
|
||||
}
|
||||
if (path.startsWith('shortcuts.')) {
|
||||
return { category: 'input', section: 'Overlay shortcuts' };
|
||||
}
|
||||
if (path === 'keybindings') {
|
||||
return { category: 'input', section: 'MPV keybindings' };
|
||||
}
|
||||
if (path.startsWith('controller.')) {
|
||||
return { category: 'input', section: 'Controller' };
|
||||
}
|
||||
if (
|
||||
path.startsWith('ai.') ||
|
||||
path.startsWith('anilist.') ||
|
||||
path.startsWith('yomitan.') ||
|
||||
path.startsWith('jellyfin.') ||
|
||||
path.startsWith('discordPresence.') ||
|
||||
path.startsWith('websocket.') ||
|
||||
path.startsWith('annotationWebsocket.') ||
|
||||
path.startsWith('texthooker.')
|
||||
) {
|
||||
return { category: 'integrations', section: topSection(path) };
|
||||
}
|
||||
if (
|
||||
path.startsWith('immersionTracking.') ||
|
||||
path.startsWith('stats.') ||
|
||||
path.startsWith('updates.') ||
|
||||
path.startsWith('startupWarmups.') ||
|
||||
path.startsWith('logging.') ||
|
||||
path === 'auto_start_overlay'
|
||||
) {
|
||||
return { category: 'tracking-app', section: topSection(path) };
|
||||
}
|
||||
return { category: 'advanced', section: 'Advanced' };
|
||||
}
|
||||
|
||||
function topSection(path: string): string {
|
||||
const top = path.split('.')[0] ?? path;
|
||||
const labels: Record<string, string> = {
|
||||
ai: 'Shared AI provider',
|
||||
anilist: 'AniList',
|
||||
annotationWebsocket: 'Annotation WebSocket',
|
||||
discordPresence: 'Discord Rich Presence',
|
||||
immersionTracking: 'Immersion tracking',
|
||||
jimaku: 'Jimaku',
|
||||
jellyfin: 'Jellyfin',
|
||||
logging: 'Logging',
|
||||
mpv: 'mpv launcher',
|
||||
stats: 'Stats dashboard',
|
||||
startupWarmups: 'Startup warmups',
|
||||
subsync: 'Auto subtitle sync',
|
||||
texthooker: 'Texthooker',
|
||||
updates: 'Updates',
|
||||
websocket: 'WebSocket server',
|
||||
yomitan: 'Yomitan',
|
||||
youtube: 'YouTube playback',
|
||||
youtubeSubgen: 'YouTube subtitle generation',
|
||||
auto_start_overlay: 'Overlay startup',
|
||||
};
|
||||
return labels[top] ?? humanizePath(top);
|
||||
}
|
||||
|
||||
function controlForPath(path: string, value: unknown): ConfigSettingsControl {
|
||||
if (SECRET_PATHS.has(path)) return 'secret';
|
||||
if (OPTION_BY_PATH.get(path)?.enumValues?.length) return 'select';
|
||||
if (JSON_OBJECT_FIELDS.has(path)) return 'json';
|
||||
if (Array.isArray(value)) return 'string-list';
|
||||
if (typeof value === 'boolean') return 'boolean';
|
||||
if (typeof value === 'number') return 'number';
|
||||
if (typeof value === 'string') {
|
||||
const leaf = path.split('.').at(-1) ?? path;
|
||||
if ([...COLOR_SUFFIXES].some((suffix) => leaf.endsWith(suffix))) return 'color';
|
||||
if (leaf.toLowerCase().includes('prompt')) return 'textarea';
|
||||
return 'text';
|
||||
}
|
||||
return 'json';
|
||||
}
|
||||
|
||||
function restartBehaviorForPath(path: string): ConfigSettingsRestartBehavior {
|
||||
if (
|
||||
path === 'keybindings' ||
|
||||
pathStartsWith(path, 'shortcuts') ||
|
||||
pathStartsWith(path, 'subtitleStyle') ||
|
||||
pathStartsWith(path, 'subtitleSidebar') ||
|
||||
path === 'secondarySub.defaultMode' ||
|
||||
pathStartsWith(path, 'ankiConnect.ai')
|
||||
) {
|
||||
return 'hot-reload';
|
||||
}
|
||||
return 'restart';
|
||||
}
|
||||
|
||||
function fieldForLeaf(leaf: Leaf): ConfigSettingsField {
|
||||
const option = OPTION_BY_PATH.get(leaf.path);
|
||||
const { category, section } = categoryAndSection(leaf.path);
|
||||
return {
|
||||
id: leaf.path,
|
||||
label: option?.path === leaf.path ? humanizePath(leaf.path) : humanizePath(leaf.path),
|
||||
description: option?.description ?? `${humanizePath(leaf.path)} setting.`,
|
||||
configPath: leaf.path,
|
||||
category,
|
||||
section,
|
||||
control: controlForPath(leaf.path, leaf.value),
|
||||
defaultValue: leaf.value,
|
||||
...(option?.enumValues ? { enumValues: option.enumValues } : {}),
|
||||
restartBehavior: restartBehaviorForPath(leaf.path),
|
||||
advanced:
|
||||
leaf.path.startsWith('controller.') ||
|
||||
leaf.path.startsWith('immersionTracking.retention.') ||
|
||||
leaf.path.startsWith('youtubeSubgen.'),
|
||||
secret: SECRET_PATHS.has(leaf.path),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildConfigSettingsRegistry(
|
||||
defaultConfig: ResolvedConfig = DEFAULT_CONFIG,
|
||||
): ConfigSettingsField[] {
|
||||
const leaves = flattenConfigLeaves(defaultConfig).filter((leaf) => !isLegacyHidden(leaf.path));
|
||||
return leaves.map(fieldForLeaf).sort((a, b) => {
|
||||
const category = a.category.localeCompare(b.category);
|
||||
if (category !== 0) return category;
|
||||
const section = a.section.localeCompare(b.section);
|
||||
if (section !== 0) return section;
|
||||
return a.configPath.localeCompare(b.configPath);
|
||||
});
|
||||
}
|
||||
|
||||
export function getConfigSettingsCoverage(
|
||||
defaultConfig: ResolvedConfig,
|
||||
fields: ConfigSettingsField[],
|
||||
): { uncoveredDefaultPaths: string[] } {
|
||||
const visibleFields = fields.filter((field) => !field.legacyHidden);
|
||||
const uncoveredDefaultPaths = flattenConfigLeaves(defaultConfig)
|
||||
.map((leaf) => leaf.path)
|
||||
.filter((path) => !isLegacyHidden(path))
|
||||
.filter(
|
||||
(path) =>
|
||||
!visibleFields.some(
|
||||
(field) =>
|
||||
field.configPath === path ||
|
||||
(field.control === 'json' && pathStartsWith(path, field.configPath)),
|
||||
),
|
||||
)
|
||||
.sort();
|
||||
|
||||
return { uncoveredDefaultPaths };
|
||||
}
|
||||
|
||||
export function getConfigValueAtPath(root: unknown, path: string): unknown {
|
||||
let current = root;
|
||||
for (const segment of path.split('.')) {
|
||||
if (!isRecord(current)) return undefined;
|
||||
current = current[segment];
|
||||
}
|
||||
return current;
|
||||
}
|
||||
@@ -32,10 +32,16 @@ test('anilist update queue enqueues, snapshots, and dequeues success', () => {
|
||||
const loggerState = createLogger();
|
||||
const queue = createAnilistUpdateQueue(queueFile, loggerState.logger);
|
||||
|
||||
queue.enqueue('k1', 'Demo', 1);
|
||||
queue.enqueue('k1', 'Demo', 1, 2);
|
||||
const snapshot = queue.getSnapshot(Number.MAX_SAFE_INTEGER);
|
||||
assert.deepEqual(snapshot, { pending: 1, ready: 1, deadLetter: 0 });
|
||||
assert.equal(queue.nextReady(Number.MAX_SAFE_INTEGER)?.key, 'k1');
|
||||
assert.deepEqual(
|
||||
{
|
||||
key: queue.nextReady(Number.MAX_SAFE_INTEGER)?.key,
|
||||
season: queue.nextReady(Number.MAX_SAFE_INTEGER)?.season,
|
||||
},
|
||||
{ key: 'k1', season: 2 },
|
||||
);
|
||||
|
||||
queue.markSuccess('k1');
|
||||
assert.deepEqual(queue.getSnapshot(Number.MAX_SAFE_INTEGER), {
|
||||
|
||||
@@ -9,6 +9,7 @@ const MAX_ITEMS = 500;
|
||||
export interface AnilistQueuedUpdate {
|
||||
key: string;
|
||||
title: string;
|
||||
season?: number | null;
|
||||
episode: number;
|
||||
createdAt: number;
|
||||
attemptCount: number;
|
||||
@@ -28,7 +29,7 @@ export interface AnilistRetryQueueSnapshot {
|
||||
}
|
||||
|
||||
export interface AnilistUpdateQueue {
|
||||
enqueue: (key: string, title: string, episode: number) => void;
|
||||
enqueue: (key: string, title: string, episode: number, season?: number | null) => void;
|
||||
nextReady: (nowMs?: number) => AnilistQueuedUpdate | null;
|
||||
markSuccess: (key: string) => void;
|
||||
markFailure: (key: string, reason: string, nowMs?: number) => void;
|
||||
@@ -106,7 +107,7 @@ export function createAnilistUpdateQueue(
|
||||
load();
|
||||
|
||||
return {
|
||||
enqueue(key: string, title: string, episode: number): void {
|
||||
enqueue(key: string, title: string, episode: number, season: number | null = null): void {
|
||||
const existing = pending.find((item) => item.key === key);
|
||||
if (existing) {
|
||||
return;
|
||||
@@ -117,6 +118,7 @@ export function createAnilistUpdateQueue(
|
||||
pending.push({
|
||||
key,
|
||||
title,
|
||||
season,
|
||||
episode,
|
||||
createdAt: Date.now(),
|
||||
attemptCount: 0,
|
||||
|
||||
@@ -265,6 +265,125 @@ test('updateAnilistPostWatchProgress skips when progress already reached', async
|
||||
}
|
||||
});
|
||||
|
||||
test('updateAnilistPostWatchProgress returns non-retryable error when media is not planning or watching', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
let call = 0;
|
||||
globalThis.fetch = (async () => {
|
||||
call += 1;
|
||||
if (call === 1) {
|
||||
return createJsonResponse({
|
||||
data: {
|
||||
Page: {
|
||||
media: [{ id: 33, episodes: 12, title: { english: 'Missing Show' } }],
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
return createJsonResponse({
|
||||
data: {
|
||||
Media: { id: 33, mediaListEntry: null },
|
||||
},
|
||||
});
|
||||
}) as typeof fetch;
|
||||
|
||||
try {
|
||||
const result = await updateAnilistPostWatchProgress('token', 'Missing Show', 2);
|
||||
assert.equal(result.status, 'error');
|
||||
assert.equal(result.retryable, false);
|
||||
assert.match(result.message, /not in your AniList Planning or Watching list/i);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test('updateAnilistPostWatchProgress prefers season-specific AniList matches', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const searchTerms: string[] = [];
|
||||
let call = 0;
|
||||
globalThis.fetch = (async (_input, init) => {
|
||||
call += 1;
|
||||
const body = JSON.parse(String(init?.body)) as { variables?: Record<string, unknown> };
|
||||
if (call === 1) {
|
||||
searchTerms.push(String(body.variables?.search));
|
||||
return createJsonResponse({
|
||||
data: {
|
||||
Page: {
|
||||
media: [
|
||||
{ id: 202, episodes: 12, title: { english: 'Demo Show Season 2' } },
|
||||
{ id: 101, episodes: 12, title: { english: 'Demo Show' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
if (call === 2) {
|
||||
assert.equal(body.variables?.mediaId, 202);
|
||||
return createJsonResponse({
|
||||
data: {
|
||||
Media: { id: 202, mediaListEntry: null },
|
||||
},
|
||||
});
|
||||
}
|
||||
return createJsonResponse({
|
||||
data: {
|
||||
SaveMediaListEntry: { progress: 2, status: 'CURRENT' },
|
||||
},
|
||||
});
|
||||
}) as typeof fetch;
|
||||
|
||||
try {
|
||||
const result = await updateAnilistPostWatchProgress('token', 'Demo Show', 2, {
|
||||
season: 2,
|
||||
});
|
||||
assert.deepEqual(searchTerms, ['Demo Show Season 2']);
|
||||
assert.equal(result.status, 'error');
|
||||
assert.equal(result.retryable, false);
|
||||
assert.match(result.message, /not in your AniList Planning or Watching list/i);
|
||||
assert.equal(call, 2);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test('updateAnilistPostWatchProgress does not update rewatching entries', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
let call = 0;
|
||||
globalThis.fetch = (async () => {
|
||||
call += 1;
|
||||
if (call === 1) {
|
||||
return createJsonResponse({
|
||||
data: {
|
||||
Page: {
|
||||
media: [{ id: 44, episodes: 12, title: { english: 'Rewatch Show' } }],
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
if (call === 2) {
|
||||
return createJsonResponse({
|
||||
data: {
|
||||
Media: { id: 44, mediaListEntry: { progress: 0, status: 'REPEATING' } },
|
||||
},
|
||||
});
|
||||
}
|
||||
return createJsonResponse({
|
||||
data: {
|
||||
SaveMediaListEntry: { progress: 2, status: 'CURRENT' },
|
||||
},
|
||||
});
|
||||
}) as typeof fetch;
|
||||
|
||||
try {
|
||||
const result = await updateAnilistPostWatchProgress('token', 'Rewatch Show', 2);
|
||||
assert.equal(result.status, 'error');
|
||||
assert.equal(result.retryable, false);
|
||||
assert.match(result.message, /marked repeating on AniList/i);
|
||||
assert.equal(call, 2);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test('updateAnilistPostWatchProgress returns error when search fails', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = (async () =>
|
||||
|
||||
@@ -18,10 +18,12 @@ export interface AnilistMediaGuess {
|
||||
export interface AnilistPostWatchUpdateResult {
|
||||
status: 'updated' | 'skipped' | 'error';
|
||||
message: string;
|
||||
retryable?: boolean;
|
||||
}
|
||||
|
||||
export interface AnilistPostWatchUpdateOptions {
|
||||
rateLimiter?: AnilistRateLimiter;
|
||||
season?: number | null;
|
||||
}
|
||||
|
||||
interface AnilistGraphQlError {
|
||||
@@ -156,6 +158,28 @@ function normalizeTitle(text: string): string {
|
||||
return text.trim().toLowerCase().replace(/\s+/g, ' ');
|
||||
}
|
||||
|
||||
function titleMentionsSeason(title: string, season: number): boolean {
|
||||
const normalized = normalizeTitle(title);
|
||||
return (
|
||||
normalized.includes(`season ${season}`) ||
|
||||
normalized.includes(`s${String(season).padStart(2, '0')}`) ||
|
||||
normalized.includes(`s${season}`)
|
||||
);
|
||||
}
|
||||
|
||||
function buildSearchCandidates(title: string, season: number | null | undefined): string[] {
|
||||
const trimmed = title.trim();
|
||||
if (!trimmed) return [];
|
||||
const candidates =
|
||||
typeof season === 'number' &&
|
||||
Number.isInteger(season) &&
|
||||
season > 1 &&
|
||||
!titleMentionsSeason(trimmed, season)
|
||||
? [`${trimmed} Season ${season}`, trimmed]
|
||||
: [trimmed];
|
||||
return candidates.filter((candidate, index, all) => all.indexOf(candidate) === index);
|
||||
}
|
||||
|
||||
async function anilistGraphQl<T>(
|
||||
accessToken: string,
|
||||
query: string,
|
||||
@@ -226,6 +250,15 @@ function pickBestSearchResult(
|
||||
return { id: selected.id, title: selectedTitle };
|
||||
}
|
||||
|
||||
function isUpdateableListStatus(status: string | null | undefined): boolean {
|
||||
return status === 'CURRENT' || status === 'PLANNING';
|
||||
}
|
||||
|
||||
function formatListStatus(status: string | null | undefined): string {
|
||||
if (!status) return 'not in your AniList Planning or Watching list';
|
||||
return `marked ${status.toLowerCase().replace(/_/g, ' ')} on AniList`;
|
||||
}
|
||||
|
||||
export async function guessAnilistMediaInfo(
|
||||
mediaPath: string | null,
|
||||
mediaTitle: string | null,
|
||||
@@ -279,27 +312,42 @@ export async function updateAnilistPostWatchProgress(
|
||||
episode: number,
|
||||
options: AnilistPostWatchUpdateOptions = {},
|
||||
): Promise<AnilistPostWatchUpdateResult> {
|
||||
const searchResponse = await anilistGraphQl<AnilistSearchData>(
|
||||
accessToken,
|
||||
`
|
||||
query ($search: String!) {
|
||||
Page(perPage: 5) {
|
||||
media(search: $search, type: ANIME) {
|
||||
id
|
||||
episodes
|
||||
title {
|
||||
romaji
|
||||
english
|
||||
native
|
||||
let media: NonNullable<NonNullable<AnilistSearchData['Page']>['media']> = [];
|
||||
let searchError: string | null = null;
|
||||
let pickTitle = title;
|
||||
const searchCandidates = buildSearchCandidates(title, options.season);
|
||||
for (const search of searchCandidates) {
|
||||
const searchResponse = await anilistGraphQl<AnilistSearchData>(
|
||||
accessToken,
|
||||
`
|
||||
query ($search: String!) {
|
||||
Page(perPage: 5) {
|
||||
media(search: $search, type: ANIME) {
|
||||
id
|
||||
episodes
|
||||
title {
|
||||
romaji
|
||||
english
|
||||
native
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{ search: title },
|
||||
options,
|
||||
);
|
||||
const searchError = firstErrorMessage(searchResponse);
|
||||
`,
|
||||
{ search },
|
||||
options,
|
||||
);
|
||||
searchError = firstErrorMessage(searchResponse);
|
||||
if (searchError) {
|
||||
break;
|
||||
}
|
||||
media = searchResponse.data?.Page?.media ?? [];
|
||||
if (media.length > 0) {
|
||||
pickTitle = search;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (searchError) {
|
||||
return {
|
||||
status: 'error',
|
||||
@@ -307,8 +355,7 @@ export async function updateAnilistPostWatchProgress(
|
||||
};
|
||||
}
|
||||
|
||||
const media = searchResponse.data?.Page?.media ?? [];
|
||||
const picked = pickBestSearchResult(title, episode, media);
|
||||
const picked = pickBestSearchResult(pickTitle, episode, media);
|
||||
if (!picked) {
|
||||
return { status: 'error', message: 'AniList search returned no matches.' };
|
||||
}
|
||||
@@ -337,7 +384,16 @@ export async function updateAnilistPostWatchProgress(
|
||||
};
|
||||
}
|
||||
|
||||
const currentProgress = entryResponse.data?.Media?.mediaListEntry?.progress ?? 0;
|
||||
const entry = entryResponse.data?.Media?.mediaListEntry ?? null;
|
||||
if (!entry || !isUpdateableListStatus(entry.status)) {
|
||||
return {
|
||||
status: 'error',
|
||||
retryable: false,
|
||||
message: `AniList update not possible: "${picked.title}" is ${formatListStatus(entry?.status)}. Add it to Planning or Watching, then mark watched again.`,
|
||||
};
|
||||
}
|
||||
|
||||
const currentProgress = entry.progress ?? 0;
|
||||
if (typeof currentProgress === 'number' && currentProgress >= episode) {
|
||||
return {
|
||||
status: 'skipped',
|
||||
|
||||
@@ -15,6 +15,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
toggleVisibleOverlay: false,
|
||||
togglePrimarySubtitleBar: false,
|
||||
settings: false,
|
||||
configSettings: false,
|
||||
setup: false,
|
||||
show: false,
|
||||
hide: false,
|
||||
|
||||
@@ -16,6 +16,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
toggle: false,
|
||||
toggleVisibleOverlay: false,
|
||||
settings: false,
|
||||
configSettings: false,
|
||||
setup: false,
|
||||
show: false,
|
||||
hide: false,
|
||||
@@ -130,6 +131,9 @@ function createDeps(overrides: Partial<CliCommandServiceDeps> = {}) {
|
||||
openYomitanSettingsDelayed: (delayMs) => {
|
||||
calls.push(`openYomitanSettingsDelayed:${delayMs}`);
|
||||
},
|
||||
openConfigSettingsWindow: () => {
|
||||
calls.push('openConfigSettingsWindow');
|
||||
},
|
||||
openFirstRunSetup: (force?: boolean) => {
|
||||
calls.push(`openFirstRunSetup:${force === true ? 'force' : 'default'}`);
|
||||
},
|
||||
@@ -582,6 +586,7 @@ test('handleCliCommand handles visibility and utility command dispatches', () =>
|
||||
expected: string;
|
||||
}> = [
|
||||
{ args: { settings: true }, expected: 'openYomitanSettingsDelayed:1000' },
|
||||
{ args: { configSettings: true }, expected: 'openConfigSettingsWindow' },
|
||||
{
|
||||
args: { showVisibleOverlay: true },
|
||||
expected: 'setVisibleOverlayVisible:true',
|
||||
|
||||
@@ -43,6 +43,7 @@ export interface CliCommandServiceDeps {
|
||||
togglePrimarySubtitleBar: () => void;
|
||||
openFirstRunSetup: (force?: boolean) => void;
|
||||
openYomitanSettingsDelayed: (delayMs: number) => void;
|
||||
openConfigSettingsWindow: () => void;
|
||||
setVisibleOverlayVisible: (visible: boolean) => void;
|
||||
copyCurrentSubtitle: () => void;
|
||||
startPendingMultiCopy: (timeoutMs: number) => void;
|
||||
@@ -160,6 +161,7 @@ interface MiningCliRuntime {
|
||||
interface UiCliRuntime {
|
||||
openFirstRunSetup: (force?: boolean) => void;
|
||||
openYomitanSettings: () => void;
|
||||
openConfigSettingsWindow: () => void;
|
||||
cycleSecondarySubMode: () => void;
|
||||
openRuntimeOptionsPalette: () => void;
|
||||
printHelp: () => void;
|
||||
@@ -257,6 +259,7 @@ export function createCliCommandDepsRuntime(
|
||||
options.ui.openYomitanSettings();
|
||||
}, delayMs);
|
||||
},
|
||||
openConfigSettingsWindow: options.ui.openConfigSettingsWindow,
|
||||
setVisibleOverlayVisible: options.overlay.setVisible,
|
||||
copyCurrentSubtitle: options.mining.copyCurrentSubtitle,
|
||||
startPendingMultiCopy: options.mining.startPendingMultiCopy,
|
||||
@@ -385,6 +388,8 @@ export function handleCliCommand(
|
||||
deps.logDebug('Opened first-run setup flow.');
|
||||
} else if (args.settings) {
|
||||
deps.openYomitanSettingsDelayed(1000);
|
||||
} else if (args.configSettings) {
|
||||
deps.openConfigSettingsWindow();
|
||||
} else if (args.show || args.showVisibleOverlay) {
|
||||
deps.setVisibleOverlayVisible(true);
|
||||
} else if (args.hide || args.hideVisibleOverlay) {
|
||||
|
||||
@@ -83,6 +83,7 @@ function createControllerConfigFixture() {
|
||||
rightStickHorizontal: { kind: 'axis' as const, axisIndex: 3, dpadFallback: 'none' as const },
|
||||
rightStickVertical: { kind: 'axis' as const, axisIndex: 4, dpadFallback: 'none' as const },
|
||||
},
|
||||
profiles: {},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -218,6 +219,9 @@ test('createIpcDepsRuntime wires AniList handlers', async () => {
|
||||
getMainWindow: () => null,
|
||||
getVisibleOverlayVisibility: () => false,
|
||||
onOverlayModalClosed: () => {},
|
||||
onOverlayMouseInteractionChanged: (active) => {
|
||||
calls.push(`overlay-interaction:${active}`);
|
||||
},
|
||||
openYomitanSettings: () => {},
|
||||
quitApp: () => {},
|
||||
toggleVisibleOverlay: () => {},
|
||||
@@ -281,6 +285,7 @@ test('createIpcDepsRuntime wires AniList handlers', async () => {
|
||||
assert.deepEqual(deps.getAnilistStatus(), { tokenStatus: 'resolved' });
|
||||
deps.clearAnilistToken();
|
||||
deps.openAnilistSetup();
|
||||
deps.onOverlayMouseInteractionChanged?.(true, null);
|
||||
assert.deepEqual(deps.getAnilistQueueStatus(), {
|
||||
pending: 1,
|
||||
ready: 0,
|
||||
@@ -298,10 +303,37 @@ test('createIpcDepsRuntime wires AniList handlers', async () => {
|
||||
assert.deepEqual(await deps.playPlaylistBrowserIndex(2), { ok: true, message: 'play' });
|
||||
assert.deepEqual(await deps.removePlaylistBrowserIndex(2), { ok: true, message: 'remove' });
|
||||
assert.deepEqual(await deps.movePlaylistBrowserIndex(2, -1), { ok: true, message: 'move' });
|
||||
assert.deepEqual(calls, ['clearAnilistToken', 'openAnilistSetup', 'retryAnilistQueueNow']);
|
||||
assert.deepEqual(calls, [
|
||||
'clearAnilistToken',
|
||||
'openAnilistSetup',
|
||||
'overlay-interaction:true',
|
||||
'retryAnilistQueueNow',
|
||||
]);
|
||||
assert.equal(deps.getPlaybackPaused(), true);
|
||||
});
|
||||
|
||||
test('registerIpcHandlers maps setIgnoreMouseEvents to overlay interaction active state', () => {
|
||||
const { registrar, handlers } = createFakeIpcRegistrar();
|
||||
const calls: string[] = [];
|
||||
|
||||
registerIpcHandlers(
|
||||
createRegisterIpcDeps({
|
||||
onOverlayMouseInteractionChanged: (active) => {
|
||||
calls.push(`overlay-interaction:${active}`);
|
||||
},
|
||||
}),
|
||||
registrar,
|
||||
);
|
||||
|
||||
const handler = handlers.on.get(IPC_CHANNELS.command.setIgnoreMouseEvents);
|
||||
assert.equal(typeof handler, 'function');
|
||||
|
||||
handler?.({}, true, { forward: true });
|
||||
handler?.({}, false, {});
|
||||
|
||||
assert.deepEqual(calls, ['overlay-interaction:false', 'overlay-interaction:true']);
|
||||
});
|
||||
|
||||
test('registerIpcHandlers runs AniList update after manual mark watched succeeds', async () => {
|
||||
const { registrar, handlers } = createFakeIpcRegistrar();
|
||||
const calls: string[] = [];
|
||||
@@ -944,6 +976,58 @@ test('registerIpcHandlers awaits saveControllerPreference through request-respon
|
||||
]);
|
||||
});
|
||||
|
||||
test('registerIpcHandlers accepts per-controller profile config updates', async () => {
|
||||
const { registrar, handlers } = createFakeIpcRegistrar();
|
||||
const controllerSaves: unknown[] = [];
|
||||
registerIpcHandlers(
|
||||
createRegisterIpcDeps({
|
||||
saveControllerConfig: async (update) => {
|
||||
controllerSaves.push(update);
|
||||
},
|
||||
}),
|
||||
registrar,
|
||||
);
|
||||
|
||||
const saveHandler = handlers.handle.get(IPC_CHANNELS.command.saveControllerConfig);
|
||||
assert.ok(saveHandler);
|
||||
|
||||
const update = {
|
||||
profiles: {
|
||||
'pad-1': {
|
||||
label: 'Pad One',
|
||||
buttonIndices: {
|
||||
buttonSouth: 11,
|
||||
},
|
||||
bindings: {
|
||||
toggleLookup: { kind: 'button', buttonIndex: 11 },
|
||||
leftStickHorizontal: { kind: 'axis', axisIndex: 6, dpadFallback: 'horizontal' },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
await saveHandler({}, update);
|
||||
assert.deepEqual(controllerSaves, [update]);
|
||||
|
||||
await assert.rejects(async () => {
|
||||
await saveHandler(
|
||||
{},
|
||||
{
|
||||
profiles: {
|
||||
'pad-1': {
|
||||
bindings: {
|
||||
toggleLookup: { kind: 'axis', axisIndex: 0 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
}, /Invalid controller config payload/);
|
||||
|
||||
await assert.rejects(async () => {
|
||||
await saveHandler({}, JSON.parse('{"profiles":{"__proto__":{"label":"polluted"}}}'));
|
||||
}, /Invalid controller config payload/);
|
||||
});
|
||||
|
||||
test('registerIpcHandlers validates dispatchSessionAction payloads', async () => {
|
||||
const { registrar, handlers } = createFakeIpcRegistrar();
|
||||
const dispatched: SessionActionDispatchRequest[] = [];
|
||||
|
||||
@@ -44,6 +44,10 @@ export interface IpcServiceDeps {
|
||||
modal: OverlayHostedModal,
|
||||
senderWindow: ElectronBrowserWindow | null,
|
||||
) => void;
|
||||
onOverlayMouseInteractionChanged?: (
|
||||
active: boolean,
|
||||
senderWindow: ElectronBrowserWindow | null,
|
||||
) => void;
|
||||
openYomitanSettings: () => void;
|
||||
quitApp: () => void;
|
||||
toggleDevTools: () => void;
|
||||
@@ -175,6 +179,10 @@ export interface IpcDepsRuntimeOptions {
|
||||
modal: OverlayHostedModal,
|
||||
senderWindow: ElectronBrowserWindow | null,
|
||||
) => void;
|
||||
onOverlayMouseInteractionChanged?: (
|
||||
active: boolean,
|
||||
senderWindow: ElectronBrowserWindow | null,
|
||||
) => void;
|
||||
openYomitanSettings: () => void;
|
||||
quitApp: () => void;
|
||||
toggleVisibleOverlay: () => void;
|
||||
@@ -233,6 +241,7 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService
|
||||
return {
|
||||
onOverlayModalClosed: options.onOverlayModalClosed,
|
||||
onOverlayModalOpened: options.onOverlayModalOpened,
|
||||
onOverlayMouseInteractionChanged: options.onOverlayMouseInteractionChanged,
|
||||
openYomitanSettings: options.openYomitanSettings,
|
||||
quitApp: options.quitApp,
|
||||
toggleDevTools: () => {
|
||||
@@ -349,6 +358,7 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
|
||||
if (senderWindow && !senderWindow.isDestroyed()) {
|
||||
senderWindow.setIgnoreMouseEvents(ignore, parsedOptions);
|
||||
}
|
||||
deps.onOverlayMouseInteractionChanged?.(!ignore, senderWindow);
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -42,6 +42,11 @@ function createMainWindowRecorder() {
|
||||
setAlwaysOnTop: (flag: boolean) => {
|
||||
calls.push(`always-on-top:${flag}`);
|
||||
},
|
||||
setVisibleOnAllWorkspaces: (flag: boolean, options?: { visibleOnFullScreen?: boolean }) => {
|
||||
calls.push(
|
||||
`all-workspaces:${flag}:${options?.visibleOnFullScreen === true ? 'fullscreen' : 'plain'}`,
|
||||
);
|
||||
},
|
||||
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => {
|
||||
calls.push(`mouse-ignore:${ignore}:${options?.forward === true ? 'forward' : 'plain'}`);
|
||||
},
|
||||
@@ -538,11 +543,12 @@ test('forced passthrough still shows tracked overlay while bound to mpv on Windo
|
||||
assert.ok(calls.includes('sync-windows-z-order'));
|
||||
});
|
||||
|
||||
test('forced mouse passthrough drops macOS tracked overlay below higher-priority windows', () => {
|
||||
test('forced mouse passthrough keeps macOS tracked overlay above active mpv', () => {
|
||||
const { window, calls } = createMainWindowRecorder();
|
||||
const tracker: WindowTrackerStub = {
|
||||
isTracking: () => true,
|
||||
getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
|
||||
isTargetWindowFocused: () => true,
|
||||
};
|
||||
|
||||
updateVisibleOverlayVisibility({
|
||||
@@ -571,8 +577,53 @@ test('forced mouse passthrough drops macOS tracked overlay below higher-priority
|
||||
forceMousePassthrough: true,
|
||||
} as never);
|
||||
|
||||
assert.ok(calls.includes('mouse-ignore:true:forward'));
|
||||
assert.ok(calls.includes('ensure-level'));
|
||||
assert.ok(calls.includes('enforce-order'));
|
||||
assert.ok(!calls.includes('always-on-top:false'));
|
||||
});
|
||||
|
||||
test('forced mouse passthrough still hides macOS tracked overlay when mpv loses foreground', () => {
|
||||
const { window, calls } = createMainWindowRecorder();
|
||||
const tracker: WindowTrackerStub = {
|
||||
isTracking: () => true,
|
||||
getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
|
||||
isTargetWindowFocused: () => false,
|
||||
};
|
||||
|
||||
window.show();
|
||||
calls.length = 0;
|
||||
|
||||
updateVisibleOverlayVisibility({
|
||||
visibleOverlayVisible: true,
|
||||
mainWindow: window as never,
|
||||
windowTracker: tracker as never,
|
||||
trackerNotReadyWarningShown: false,
|
||||
setTrackerNotReadyWarningShown: () => {},
|
||||
updateVisibleOverlayBounds: () => {
|
||||
calls.push('update-bounds');
|
||||
},
|
||||
ensureOverlayWindowLevel: () => {
|
||||
calls.push('ensure-level');
|
||||
},
|
||||
syncPrimaryOverlayWindowLayer: () => {
|
||||
calls.push('sync-layer');
|
||||
},
|
||||
enforceOverlayLayerOrder: () => {
|
||||
calls.push('enforce-order');
|
||||
},
|
||||
syncOverlayShortcuts: () => {
|
||||
calls.push('sync-shortcuts');
|
||||
},
|
||||
isMacOSPlatform: true,
|
||||
isWindowsPlatform: false,
|
||||
forceMousePassthrough: true,
|
||||
} as never);
|
||||
|
||||
assert.ok(calls.includes('mouse-ignore:true:forward'));
|
||||
assert.ok(calls.includes('always-on-top:false'));
|
||||
assert.ok(calls.includes('all-workspaces:false:plain'));
|
||||
assert.ok(calls.includes('hide'));
|
||||
assert.ok(!calls.includes('ensure-level'));
|
||||
assert.ok(!calls.includes('enforce-order'));
|
||||
});
|
||||
@@ -916,7 +967,8 @@ test('macOS tracked visible overlay starts click-through without passively steal
|
||||
} as never);
|
||||
|
||||
assert.ok(calls.includes('mouse-ignore:true:forward'));
|
||||
assert.ok(calls.includes('show'));
|
||||
assert.ok(calls.includes('show-inactive'));
|
||||
assert.ok(!calls.includes('show'));
|
||||
assert.ok(!calls.includes('focus'));
|
||||
});
|
||||
|
||||
@@ -1009,7 +1061,7 @@ test('macOS keeps active mpv overlay visible and click-through during tracker re
|
||||
assert.deepEqual(osdMessages, []);
|
||||
});
|
||||
|
||||
test('macOS tracked overlay releases topmost level when mpv loses foreground', () => {
|
||||
test('macOS tracked overlay hides when mpv loses foreground', () => {
|
||||
const { window, calls } = createMainWindowRecorder();
|
||||
const tracker: WindowTrackerStub = {
|
||||
isTracking: () => true,
|
||||
@@ -1017,6 +1069,9 @@ test('macOS tracked overlay releases topmost level when mpv loses foreground', (
|
||||
isTargetWindowFocused: () => false,
|
||||
};
|
||||
|
||||
window.show();
|
||||
calls.length = 0;
|
||||
|
||||
updateVisibleOverlayVisibility({
|
||||
visibleOverlayVisible: true,
|
||||
mainWindow: window as never,
|
||||
@@ -1046,14 +1101,202 @@ test('macOS tracked overlay releases topmost level when mpv loses foreground', (
|
||||
assert.ok(calls.includes('sync-layer'));
|
||||
assert.ok(calls.includes('mouse-ignore:true:forward'));
|
||||
assert.ok(calls.includes('always-on-top:false'));
|
||||
assert.ok(calls.includes('show'));
|
||||
assert.ok(calls.includes('all-workspaces:false:plain'));
|
||||
assert.ok(calls.includes('hide'));
|
||||
assert.ok(calls.includes('sync-shortcuts'));
|
||||
assert.ok(!calls.includes('ensure-level'));
|
||||
assert.ok(!calls.includes('enforce-order'));
|
||||
assert.ok(!calls.includes('focus'));
|
||||
assert.ok(!calls.includes('show'));
|
||||
});
|
||||
|
||||
test('macOS keeps tracked overlay visible while overlay interaction is active after mpv loses foreground', () => {
|
||||
const { window, calls, setFocused } = createMainWindowRecorder();
|
||||
const tracker: WindowTrackerStub = {
|
||||
isTracking: () => true,
|
||||
getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
|
||||
isTargetWindowFocused: () => false,
|
||||
};
|
||||
|
||||
window.show();
|
||||
setFocused(false);
|
||||
calls.length = 0;
|
||||
|
||||
updateVisibleOverlayVisibility({
|
||||
visibleOverlayVisible: true,
|
||||
overlayInteractionActive: true,
|
||||
mainWindow: window as never,
|
||||
windowTracker: tracker as never,
|
||||
trackerNotReadyWarningShown: false,
|
||||
setTrackerNotReadyWarningShown: () => {},
|
||||
updateVisibleOverlayBounds: () => {
|
||||
calls.push('update-bounds');
|
||||
},
|
||||
ensureOverlayWindowLevel: () => {
|
||||
calls.push('ensure-level');
|
||||
},
|
||||
syncPrimaryOverlayWindowLayer: () => {
|
||||
calls.push('sync-layer');
|
||||
},
|
||||
enforceOverlayLayerOrder: () => {
|
||||
calls.push('enforce-order');
|
||||
},
|
||||
syncOverlayShortcuts: () => {
|
||||
calls.push('sync-shortcuts');
|
||||
},
|
||||
isMacOSPlatform: true,
|
||||
isWindowsPlatform: false,
|
||||
} as never);
|
||||
|
||||
assert.ok(calls.includes('update-bounds'));
|
||||
assert.ok(calls.includes('sync-layer'));
|
||||
assert.ok(calls.includes('mouse-ignore:false:plain'));
|
||||
assert.ok(calls.includes('ensure-level'));
|
||||
assert.ok(calls.includes('enforce-order'));
|
||||
assert.ok(calls.includes('sync-shortcuts'));
|
||||
assert.ok(!calls.includes('always-on-top:false'));
|
||||
assert.ok(!calls.includes('hide'));
|
||||
});
|
||||
|
||||
test('macOS lets an active overlay receive mouse input instead of forcing passthrough', () => {
|
||||
const { window, calls, setFocused } = createMainWindowRecorder();
|
||||
const tracker: WindowTrackerStub = {
|
||||
isTracking: () => true,
|
||||
getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
|
||||
isTargetWindowFocused: () => false,
|
||||
};
|
||||
|
||||
window.show();
|
||||
setFocused(false);
|
||||
calls.length = 0;
|
||||
|
||||
updateVisibleOverlayVisibility({
|
||||
visibleOverlayVisible: true,
|
||||
overlayInteractionActive: true,
|
||||
mainWindow: window as never,
|
||||
windowTracker: tracker as never,
|
||||
trackerNotReadyWarningShown: false,
|
||||
setTrackerNotReadyWarningShown: () => {},
|
||||
updateVisibleOverlayBounds: () => {
|
||||
calls.push('update-bounds');
|
||||
},
|
||||
ensureOverlayWindowLevel: () => {
|
||||
calls.push('ensure-level');
|
||||
},
|
||||
syncPrimaryOverlayWindowLayer: () => {
|
||||
calls.push('sync-layer');
|
||||
},
|
||||
enforceOverlayLayerOrder: () => {
|
||||
calls.push('enforce-order');
|
||||
},
|
||||
syncOverlayShortcuts: () => {
|
||||
calls.push('sync-shortcuts');
|
||||
},
|
||||
isMacOSPlatform: true,
|
||||
isWindowsPlatform: false,
|
||||
} as never);
|
||||
|
||||
assert.ok(calls.includes('mouse-ignore:false:plain'));
|
||||
assert.ok(!calls.includes('mouse-ignore:true:forward'));
|
||||
assert.ok(!calls.includes('hide'));
|
||||
});
|
||||
|
||||
test('macOS focuses an active overlay so lookup trigger keys reach it', () => {
|
||||
const { window, calls, setFocused } = createMainWindowRecorder();
|
||||
const tracker: WindowTrackerStub = {
|
||||
isTracking: () => true,
|
||||
getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
|
||||
isTargetWindowFocused: () => false,
|
||||
};
|
||||
|
||||
window.show();
|
||||
setFocused(false);
|
||||
calls.length = 0;
|
||||
|
||||
updateVisibleOverlayVisibility({
|
||||
visibleOverlayVisible: true,
|
||||
overlayInteractionActive: true,
|
||||
mainWindow: window as never,
|
||||
windowTracker: tracker as never,
|
||||
trackerNotReadyWarningShown: false,
|
||||
setTrackerNotReadyWarningShown: () => {},
|
||||
updateVisibleOverlayBounds: () => {
|
||||
calls.push('update-bounds');
|
||||
},
|
||||
ensureOverlayWindowLevel: () => {
|
||||
calls.push('ensure-level');
|
||||
},
|
||||
syncPrimaryOverlayWindowLayer: () => {
|
||||
calls.push('sync-layer');
|
||||
},
|
||||
enforceOverlayLayerOrder: () => {
|
||||
calls.push('enforce-order');
|
||||
},
|
||||
syncOverlayShortcuts: () => {
|
||||
calls.push('sync-shortcuts');
|
||||
},
|
||||
isMacOSPlatform: true,
|
||||
isWindowsPlatform: false,
|
||||
} as never);
|
||||
|
||||
assert.ok(calls.includes('mouse-ignore:false:plain'));
|
||||
assert.ok(calls.includes('focus'));
|
||||
assert.ok(!calls.includes('hide'));
|
||||
});
|
||||
|
||||
test('macOS tracked overlay passively reappears when mpv regains foreground', () => {
|
||||
const { window, calls } = createMainWindowRecorder();
|
||||
let targetFocused = false;
|
||||
const tracker: WindowTrackerStub = {
|
||||
isTracking: () => true,
|
||||
getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
|
||||
isTargetWindowFocused: () => targetFocused,
|
||||
};
|
||||
|
||||
window.show();
|
||||
calls.length = 0;
|
||||
|
||||
const run = () =>
|
||||
updateVisibleOverlayVisibility({
|
||||
visibleOverlayVisible: true,
|
||||
mainWindow: window as never,
|
||||
windowTracker: tracker as never,
|
||||
trackerNotReadyWarningShown: false,
|
||||
setTrackerNotReadyWarningShown: () => {},
|
||||
updateVisibleOverlayBounds: () => {
|
||||
calls.push('update-bounds');
|
||||
},
|
||||
ensureOverlayWindowLevel: () => {
|
||||
calls.push('ensure-level');
|
||||
},
|
||||
syncPrimaryOverlayWindowLayer: () => {
|
||||
calls.push('sync-layer');
|
||||
},
|
||||
enforceOverlayLayerOrder: () => {
|
||||
calls.push('enforce-order');
|
||||
},
|
||||
syncOverlayShortcuts: () => {
|
||||
calls.push('sync-shortcuts');
|
||||
},
|
||||
isMacOSPlatform: true,
|
||||
isWindowsPlatform: false,
|
||||
} as never);
|
||||
|
||||
run();
|
||||
assert.ok(calls.includes('hide'));
|
||||
|
||||
calls.length = 0;
|
||||
targetFocused = true;
|
||||
run();
|
||||
|
||||
assert.ok(calls.includes('mouse-ignore:true:forward'));
|
||||
assert.ok(calls.includes('ensure-level'));
|
||||
assert.ok(calls.includes('show-inactive'));
|
||||
assert.ok(calls.includes('enforce-order'));
|
||||
assert.ok(!calls.includes('show'));
|
||||
assert.ok(!calls.includes('focus'));
|
||||
});
|
||||
|
||||
test('macOS preserves an already visible active mpv overlay while tracker is temporarily not ready', () => {
|
||||
const { window, calls } = createMainWindowRecorder();
|
||||
const osdMessages: string[] = [];
|
||||
@@ -1141,7 +1384,8 @@ test('forced mouse passthrough keeps macOS tracked overlay passive while visible
|
||||
} as never);
|
||||
|
||||
assert.ok(calls.includes('mouse-ignore:true:forward'));
|
||||
assert.ok(calls.includes('show'));
|
||||
assert.ok(calls.includes('show-inactive'));
|
||||
assert.ok(!calls.includes('show'));
|
||||
assert.ok(!calls.includes('focus'));
|
||||
});
|
||||
|
||||
@@ -1438,7 +1682,7 @@ test('macOS preserves visible overlay during transient tracker loss with retaine
|
||||
assert.ok(!calls.includes('show'));
|
||||
});
|
||||
|
||||
test('macOS preserves visible overlay level during non-minimized tracker loss', () => {
|
||||
test('macOS hides visible overlay during tracker loss after mpv loses foreground', () => {
|
||||
const { window, calls } = createMainWindowRecorder();
|
||||
const tracker: WindowTrackerStub = {
|
||||
isTracking: () => false,
|
||||
@@ -1477,13 +1721,114 @@ test('macOS preserves visible overlay level during non-minimized tracker loss',
|
||||
},
|
||||
} as never);
|
||||
|
||||
assert.ok(calls.includes('sync-layer'));
|
||||
assert.ok(calls.includes('mouse-ignore:true:forward'));
|
||||
assert.ok(calls.includes('always-on-top:false'));
|
||||
assert.ok(calls.includes('all-workspaces:false:plain'));
|
||||
assert.ok(calls.includes('hide'));
|
||||
assert.ok(calls.includes('sync-shortcuts'));
|
||||
assert.ok(!calls.includes('ensure-level'));
|
||||
assert.ok(!calls.includes('enforce-order'));
|
||||
assert.ok(!calls.includes('loading-osd'));
|
||||
});
|
||||
|
||||
test('macOS keeps a focused overlay visible during tracker loss', () => {
|
||||
const { window, calls, setFocused } = createMainWindowRecorder();
|
||||
const tracker: WindowTrackerStub = {
|
||||
isTracking: () => false,
|
||||
getGeometry: () => null,
|
||||
isTargetWindowFocused: () => false,
|
||||
isTargetWindowMinimized: () => false,
|
||||
};
|
||||
|
||||
window.show();
|
||||
setFocused(true);
|
||||
calls.length = 0;
|
||||
|
||||
updateVisibleOverlayVisibility({
|
||||
visibleOverlayVisible: true,
|
||||
mainWindow: window as never,
|
||||
windowTracker: tracker as never,
|
||||
trackerNotReadyWarningShown: false,
|
||||
setTrackerNotReadyWarningShown: () => {},
|
||||
updateVisibleOverlayBounds: () => {
|
||||
calls.push('update-bounds');
|
||||
},
|
||||
ensureOverlayWindowLevel: () => {
|
||||
calls.push('ensure-level');
|
||||
},
|
||||
syncPrimaryOverlayWindowLayer: () => {
|
||||
calls.push('sync-layer');
|
||||
},
|
||||
enforceOverlayLayerOrder: () => {
|
||||
calls.push('enforce-order');
|
||||
},
|
||||
syncOverlayShortcuts: () => {
|
||||
calls.push('sync-shortcuts');
|
||||
},
|
||||
isMacOSPlatform: true,
|
||||
showOverlayLoadingOsd: () => {
|
||||
calls.push('loading-osd');
|
||||
},
|
||||
} as never);
|
||||
|
||||
assert.ok(calls.includes('sync-layer'));
|
||||
assert.ok(calls.includes('mouse-ignore:true:forward'));
|
||||
assert.ok(calls.includes('ensure-level'));
|
||||
assert.ok(calls.includes('enforce-order'));
|
||||
assert.ok(calls.includes('sync-shortcuts'));
|
||||
assert.ok(!calls.includes('hide'));
|
||||
assert.ok(!calls.includes('loading-osd'));
|
||||
});
|
||||
|
||||
test('macOS keeps an interactive overlay visible during tracker loss even when Electron focus drops', () => {
|
||||
const { window, calls, setFocused } = createMainWindowRecorder();
|
||||
const tracker: WindowTrackerStub = {
|
||||
isTracking: () => false,
|
||||
getGeometry: () => null,
|
||||
isTargetWindowFocused: () => false,
|
||||
isTargetWindowMinimized: () => false,
|
||||
};
|
||||
|
||||
window.show();
|
||||
setFocused(false);
|
||||
calls.length = 0;
|
||||
|
||||
updateVisibleOverlayVisibility({
|
||||
visibleOverlayVisible: true,
|
||||
overlayInteractionActive: true,
|
||||
mainWindow: window as never,
|
||||
windowTracker: tracker as never,
|
||||
trackerNotReadyWarningShown: false,
|
||||
setTrackerNotReadyWarningShown: () => {},
|
||||
updateVisibleOverlayBounds: () => {
|
||||
calls.push('update-bounds');
|
||||
},
|
||||
ensureOverlayWindowLevel: () => {
|
||||
calls.push('ensure-level');
|
||||
},
|
||||
syncPrimaryOverlayWindowLayer: () => {
|
||||
calls.push('sync-layer');
|
||||
},
|
||||
enforceOverlayLayerOrder: () => {
|
||||
calls.push('enforce-order');
|
||||
},
|
||||
syncOverlayShortcuts: () => {
|
||||
calls.push('sync-shortcuts');
|
||||
},
|
||||
isMacOSPlatform: true,
|
||||
showOverlayLoadingOsd: () => {
|
||||
calls.push('loading-osd');
|
||||
},
|
||||
} as never);
|
||||
|
||||
assert.ok(calls.includes('sync-layer'));
|
||||
assert.ok(calls.includes('mouse-ignore:false:plain'));
|
||||
assert.ok(calls.includes('ensure-level'));
|
||||
assert.ok(calls.includes('enforce-order'));
|
||||
assert.ok(calls.includes('sync-shortcuts'));
|
||||
assert.ok(!calls.includes('always-on-top:false'));
|
||||
assert.ok(!calls.includes('hide'));
|
||||
assert.ok(!calls.includes('loading-osd'));
|
||||
});
|
||||
|
||||
|
||||
@@ -15,6 +15,17 @@ function setOverlayWindowOpacity(window: BrowserWindow, opacity: number): void {
|
||||
opacityCapableWindow.setOpacity?.(opacity);
|
||||
}
|
||||
|
||||
function releaseOverlayWindowLevel(window: BrowserWindow): void {
|
||||
window.setAlwaysOnTop(false);
|
||||
const allWorkspacesWindow = window as BrowserWindow & {
|
||||
setVisibleOnAllWorkspaces?: (
|
||||
visible: boolean,
|
||||
options?: { visibleOnFullScreen?: boolean },
|
||||
) => void;
|
||||
};
|
||||
allWorkspacesWindow.setVisibleOnAllWorkspaces?.(false, { visibleOnFullScreen: false });
|
||||
}
|
||||
|
||||
function clearPendingWindowsOverlayReveal(window: BrowserWindow): void {
|
||||
const pendingTimeout = pendingWindowsOverlayRevealTimeoutByWindow.get(window);
|
||||
if (!pendingTimeout) {
|
||||
@@ -52,6 +63,7 @@ export function updateVisibleOverlayVisibility(args: {
|
||||
visibleOverlayVisible: boolean;
|
||||
modalActive?: boolean;
|
||||
forceMousePassthrough?: boolean;
|
||||
overlayInteractionActive?: boolean;
|
||||
mainWindow: BrowserWindow | null;
|
||||
windowTracker: BaseWindowTracker | null;
|
||||
lastKnownWindowsForegroundProcessName?: string | null;
|
||||
@@ -78,6 +90,7 @@ export function updateVisibleOverlayVisibility(args: {
|
||||
}
|
||||
|
||||
const mainWindow = args.mainWindow;
|
||||
const overlayInteractionActive = args.overlayInteractionActive === true;
|
||||
|
||||
if (args.modalActive) {
|
||||
if (args.isWindowsPlatform) {
|
||||
@@ -93,23 +106,26 @@ export function updateVisibleOverlayVisibility(args: {
|
||||
const forceMousePassthrough = args.forceMousePassthrough === true;
|
||||
const wasVisible = mainWindow.isVisible();
|
||||
const isVisibleOverlayFocused =
|
||||
typeof mainWindow.isFocused === 'function' && mainWindow.isFocused();
|
||||
overlayInteractionActive ||
|
||||
(typeof mainWindow.isFocused === 'function' && mainWindow.isFocused());
|
||||
const windowTracker = args.windowTracker;
|
||||
const canReportMacOSTargetMinimized =
|
||||
args.isMacOSPlatform && typeof windowTracker?.isTargetWindowMinimized === 'function';
|
||||
const isTrackedMacOSTargetMinimized =
|
||||
canReportMacOSTargetMinimized && windowTracker?.isTargetWindowMinimized() === true;
|
||||
const trackedMacOSTargetFocused = args.windowTracker?.isTargetWindowFocused?.();
|
||||
const hasTransientMacOSTrackerLoss =
|
||||
args.isMacOSPlatform &&
|
||||
canReportMacOSTargetMinimized &&
|
||||
!!windowTracker &&
|
||||
!windowTracker.isTracking() &&
|
||||
!isTrackedMacOSTargetMinimized &&
|
||||
trackedMacOSTargetFocused !== false &&
|
||||
mainWindow.isVisible();
|
||||
const isTrackedMacOSTargetFocused =
|
||||
hasTransientMacOSTrackerLoss || !args.isMacOSPlatform || !args.windowTracker
|
||||
? true
|
||||
: (args.windowTracker.isTargetWindowFocused?.() ?? true);
|
||||
: (trackedMacOSTargetFocused ?? true);
|
||||
const shouldReleaseMacOSOverlayLevel =
|
||||
args.isMacOSPlatform &&
|
||||
!!args.windowTracker &&
|
||||
@@ -117,7 +133,7 @@ export function updateVisibleOverlayVisibility(args: {
|
||||
!isVisibleOverlayFocused &&
|
||||
!isTrackedMacOSTargetFocused;
|
||||
// Renderer hover tracking temporarily disables this for subtitle and popup interaction.
|
||||
const shouldUseMacOSMousePassthrough = args.isMacOSPlatform;
|
||||
const shouldUseMacOSMousePassthrough = args.isMacOSPlatform && !overlayInteractionActive;
|
||||
const shouldDefaultToPassthrough =
|
||||
args.isWindowsPlatform || forceMousePassthrough || shouldReleaseMacOSOverlayLevel;
|
||||
const windowsForegroundProcessName =
|
||||
@@ -159,14 +175,22 @@ export function updateVisibleOverlayVisibility(args: {
|
||||
mainWindow.setIgnoreMouseEvents(false);
|
||||
}
|
||||
|
||||
if (shouldReleaseMacOSOverlayLevel) {
|
||||
releaseOverlayWindowLevel(mainWindow);
|
||||
if (wasVisible) {
|
||||
mainWindow.hide();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (shouldBindTrackedWindowsOverlay) {
|
||||
// On Windows, z-order is enforced by the OS via the owner window mechanism
|
||||
// (SetWindowLongPtr GWLP_HWNDPARENT). The overlay is always above mpv
|
||||
// without any manual z-order management.
|
||||
} else if (!forceMousePassthrough && !shouldReleaseMacOSOverlayLevel) {
|
||||
} else if (!forceMousePassthrough || args.isMacOSPlatform) {
|
||||
args.ensureOverlayWindowLevel(mainWindow);
|
||||
} else {
|
||||
mainWindow.setAlwaysOnTop(false);
|
||||
releaseOverlayWindowLevel(mainWindow);
|
||||
}
|
||||
if (!wasVisible) {
|
||||
const hasWebContents =
|
||||
@@ -179,16 +203,20 @@ export function updateVisibleOverlayVisibility(args: {
|
||||
// skip — ready-to-show hasn't fired yet; the onWindowContentReady
|
||||
// callback will trigger another visibility update when the renderer
|
||||
// has painted its first frame.
|
||||
} else if (args.isWindowsPlatform && shouldIgnoreMouseEvents) {
|
||||
setOverlayWindowOpacity(mainWindow, 0);
|
||||
} else if ((args.isWindowsPlatform || args.isMacOSPlatform) && shouldIgnoreMouseEvents) {
|
||||
if (args.isWindowsPlatform) {
|
||||
setOverlayWindowOpacity(mainWindow, 0);
|
||||
}
|
||||
mainWindow.showInactive();
|
||||
mainWindow.setIgnoreMouseEvents(true, { forward: true });
|
||||
scheduleWindowsOverlayReveal(
|
||||
mainWindow,
|
||||
shouldBindTrackedWindowsOverlay
|
||||
? (window) => args.syncWindowsOverlayToMpvZOrder?.(window)
|
||||
: undefined,
|
||||
);
|
||||
if (args.isWindowsPlatform) {
|
||||
scheduleWindowsOverlayReveal(
|
||||
mainWindow,
|
||||
shouldBindTrackedWindowsOverlay
|
||||
? (window) => args.syncWindowsOverlayToMpvZOrder?.(window)
|
||||
: undefined,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (args.isWindowsPlatform) {
|
||||
setOverlayWindowOpacity(mainWindow, 0);
|
||||
@@ -209,6 +237,16 @@ export function updateVisibleOverlayVisibility(args: {
|
||||
args.syncWindowsOverlayToMpvZOrder?.(mainWindow);
|
||||
}
|
||||
|
||||
if (
|
||||
args.isMacOSPlatform &&
|
||||
overlayInteractionActive &&
|
||||
!forceMousePassthrough &&
|
||||
typeof mainWindow.isFocused === 'function' &&
|
||||
!mainWindow.isFocused()
|
||||
) {
|
||||
mainWindow.focus();
|
||||
}
|
||||
|
||||
if (!args.isWindowsPlatform && !args.isMacOSPlatform && !forceMousePassthrough) {
|
||||
mainWindow.focus();
|
||||
}
|
||||
@@ -216,6 +254,11 @@ export function updateVisibleOverlayVisibility(args: {
|
||||
return !shouldReleaseMacOSOverlayLevel;
|
||||
};
|
||||
|
||||
const shouldEnforceVisibleOverlayLayerOrder = (shouldEnforceLayerOrder: boolean): boolean =>
|
||||
shouldEnforceLayerOrder &&
|
||||
!args.isWindowsPlatform &&
|
||||
(!args.forceMousePassthrough || args.isMacOSPlatform === true);
|
||||
|
||||
const maybeShowOverlayLoadingOsd = (): void => {
|
||||
if (!args.isMacOSPlatform || !args.showOverlayLoadingOsd) {
|
||||
return;
|
||||
@@ -258,7 +301,7 @@ export function updateVisibleOverlayVisibility(args: {
|
||||
}
|
||||
args.syncPrimaryOverlayWindowLayer('visible');
|
||||
const shouldEnforceLayerOrder = showPassiveVisibleOverlay();
|
||||
if (shouldEnforceLayerOrder && !args.forceMousePassthrough && !args.isWindowsPlatform) {
|
||||
if (shouldEnforceVisibleOverlayLayerOrder(shouldEnforceLayerOrder)) {
|
||||
args.enforceOverlayLayerOrder();
|
||||
}
|
||||
args.syncOverlayShortcuts();
|
||||
@@ -290,6 +333,7 @@ export function updateVisibleOverlayVisibility(args: {
|
||||
const hasRetainedTrackedGeometry = args.windowTracker.getGeometry() !== null;
|
||||
const hasActiveMacOSTargetSignal =
|
||||
args.isMacOSPlatform && (args.windowTracker.isTargetWindowFocused?.() ?? false);
|
||||
const hasActiveMacOSOverlaySignal = args.isMacOSPlatform && overlayInteractionActive;
|
||||
const canReportMacOSTargetMinimized =
|
||||
args.isMacOSPlatform && typeof args.windowTracker.isTargetWindowMinimized === 'function';
|
||||
const isTrackedMacOSTargetMinimized =
|
||||
@@ -298,6 +342,7 @@ export function updateVisibleOverlayVisibility(args: {
|
||||
(args.isMacOSPlatform &&
|
||||
!isTrackedMacOSTargetMinimized &&
|
||||
(hasRetainedTrackedGeometry ||
|
||||
(mainWindow.isVisible() && hasActiveMacOSOverlaySignal) ||
|
||||
(mainWindow.isVisible() && hasActiveMacOSTargetSignal) ||
|
||||
(canReportMacOSTargetMinimized && mainWindow.isVisible()))) ||
|
||||
(args.isWindowsPlatform &&
|
||||
@@ -315,7 +360,7 @@ export function updateVisibleOverlayVisibility(args: {
|
||||
}
|
||||
args.syncPrimaryOverlayWindowLayer('visible');
|
||||
const shouldEnforceLayerOrder = showPassiveVisibleOverlay();
|
||||
if (shouldEnforceLayerOrder && !args.forceMousePassthrough && !args.isWindowsPlatform) {
|
||||
if (shouldEnforceVisibleOverlayLayerOrder(shouldEnforceLayerOrder)) {
|
||||
args.enforceOverlayLayerOrder();
|
||||
}
|
||||
args.syncOverlayShortcuts();
|
||||
|
||||
@@ -66,15 +66,16 @@ export function handleOverlayWindowBlurred(options: {
|
||||
isOverlayVisible: (kind: OverlayWindowKind) => boolean;
|
||||
ensureOverlayWindowLevel: () => void;
|
||||
moveWindowTop: () => void;
|
||||
onWindowsVisibleOverlayBlur?: () => void;
|
||||
onVisibleOverlayBlur?: () => void;
|
||||
platform?: NodeJS.Platform;
|
||||
}): boolean {
|
||||
const platform = options.platform ?? process.platform;
|
||||
if (platform === 'win32' && options.kind === 'visible') {
|
||||
options.onWindowsVisibleOverlayBlur?.();
|
||||
options.onVisibleOverlayBlur?.();
|
||||
return false;
|
||||
}
|
||||
if (platform === 'darwin' && options.kind === 'visible') {
|
||||
options.onVisibleOverlayBlur?.();
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -136,14 +136,14 @@ test('handleOverlayWindowBlurred notifies Windows visible overlay blur callback
|
||||
moveWindowTop: () => {
|
||||
calls.push('move-top');
|
||||
},
|
||||
onWindowsVisibleOverlayBlur: () => {
|
||||
calls.push('windows-visible-blur');
|
||||
onVisibleOverlayBlur: () => {
|
||||
calls.push('visible-blur');
|
||||
},
|
||||
platform: 'win32',
|
||||
});
|
||||
|
||||
assert.equal(handled, false);
|
||||
assert.deepEqual(calls, ['windows-visible-blur']);
|
||||
assert.deepEqual(calls, ['visible-blur']);
|
||||
});
|
||||
|
||||
test('handleOverlayWindowBlurred skips macOS visible overlay restacking after focus loss', () => {
|
||||
@@ -166,7 +166,7 @@ test('handleOverlayWindowBlurred skips macOS visible overlay restacking after fo
|
||||
assert.deepEqual(calls, []);
|
||||
});
|
||||
|
||||
test('handleOverlayWindowBlurred leaves Windows callback inactive on macOS visible overlay blur', () => {
|
||||
test('handleOverlayWindowBlurred notifies macOS visible overlay blur callback without restacking', () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
const handled = handleOverlayWindowBlurred({
|
||||
@@ -179,14 +179,14 @@ test('handleOverlayWindowBlurred leaves Windows callback inactive on macOS visib
|
||||
moveWindowTop: () => {
|
||||
calls.push('move-top');
|
||||
},
|
||||
onWindowsVisibleOverlayBlur: () => {
|
||||
calls.push('windows-visible-blur');
|
||||
onVisibleOverlayBlur: () => {
|
||||
calls.push('visible-blur');
|
||||
},
|
||||
platform: 'darwin',
|
||||
});
|
||||
|
||||
assert.equal(handled, false);
|
||||
assert.deepEqual(calls, []);
|
||||
assert.deepEqual(calls, ['visible-blur']);
|
||||
});
|
||||
|
||||
test('handleOverlayWindowBlurred preserves active visible/modal window stacking', () => {
|
||||
|
||||
@@ -180,7 +180,7 @@ export function createOverlayWindow(
|
||||
moveWindowTop: () => {
|
||||
window.moveTop();
|
||||
},
|
||||
onWindowsVisibleOverlayBlur:
|
||||
onVisibleOverlayBlur:
|
||||
kind === 'visible' ? () => options.onVisibleWindowBlurred?.() : undefined,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,6 +15,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
toggleVisibleOverlay: false,
|
||||
togglePrimarySubtitleBar: false,
|
||||
settings: false,
|
||||
configSettings: false,
|
||||
setup: false,
|
||||
show: false,
|
||||
hide: false,
|
||||
|
||||
@@ -9,6 +9,8 @@ type StatsWindowLevelController = Pick<BrowserWindow, 'setAlwaysOnTop' | 'moveTo
|
||||
Partial<Pick<BrowserWindow, 'setVisibleOnAllWorkspaces' | 'setFullScreenable'>>;
|
||||
|
||||
type StatsWindowBoundsController = Pick<BrowserWindow, 'getBounds' | 'getContentBounds'>;
|
||||
type StatsWindowPresentationController = Pick<BrowserWindow, 'show' | 'focus'> &
|
||||
Partial<Pick<BrowserWindow, 'showInactive'>>;
|
||||
|
||||
function isBareToggleKeyInput(input: Electron.Input, toggleKey: string): boolean {
|
||||
return (
|
||||
@@ -104,6 +106,23 @@ export function promoteStatsWindowLevel(
|
||||
window.moveTop();
|
||||
}
|
||||
|
||||
export function presentStatsWindow(
|
||||
window: StatsWindowPresentationController,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): void {
|
||||
if (platform === 'darwin') {
|
||||
if (window.showInactive) {
|
||||
window.showInactive();
|
||||
} else {
|
||||
window.show();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
window.show();
|
||||
window.focus();
|
||||
}
|
||||
|
||||
export function buildStatsWindowLoadFileOptions(apiBaseUrl?: string): {
|
||||
query: Record<string, string>;
|
||||
} {
|
||||
|
||||
@@ -3,6 +3,7 @@ import test from 'node:test';
|
||||
import {
|
||||
buildStatsWindowLoadFileOptions,
|
||||
buildStatsWindowOptions,
|
||||
presentStatsWindow,
|
||||
promoteStatsWindowLevel,
|
||||
resolveStatsWindowOuterBoundsForContent,
|
||||
shouldHideStatsWindowForInput,
|
||||
@@ -230,3 +231,45 @@ test('promoteStatsWindowLevel raises stats above overlay level on Windows', () =
|
||||
|
||||
assert.deepEqual(calls, ['always-on-top:true:screen-saver:2', 'move-top']);
|
||||
});
|
||||
|
||||
test('presentStatsWindow shows inactive on macOS to stay on the fullscreen mpv Space', () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
presentStatsWindow(
|
||||
{
|
||||
show: () => {
|
||||
calls.push('show');
|
||||
},
|
||||
showInactive: () => {
|
||||
calls.push('show-inactive');
|
||||
},
|
||||
focus: () => {
|
||||
calls.push('focus');
|
||||
},
|
||||
} as never,
|
||||
'darwin',
|
||||
);
|
||||
|
||||
assert.deepEqual(calls, ['show-inactive']);
|
||||
});
|
||||
|
||||
test('presentStatsWindow shows and focuses on non-macOS platforms', () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
presentStatsWindow(
|
||||
{
|
||||
show: () => {
|
||||
calls.push('show');
|
||||
},
|
||||
showInactive: () => {
|
||||
calls.push('show-inactive');
|
||||
},
|
||||
focus: () => {
|
||||
calls.push('focus');
|
||||
},
|
||||
} as never,
|
||||
'linux',
|
||||
);
|
||||
|
||||
assert.deepEqual(calls, ['show', 'focus']);
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ import { IPC_CHANNELS } from '../../shared/ipc/contracts.js';
|
||||
import {
|
||||
buildStatsWindowLoadFileOptions,
|
||||
buildStatsWindowOptions,
|
||||
presentStatsWindow,
|
||||
promoteStatsWindowLevel,
|
||||
resolveStatsWindowOuterBoundsForContent,
|
||||
shouldHideStatsWindowForInput,
|
||||
@@ -49,14 +50,13 @@ function showStatsWindow(window: BrowserWindow, options: StatsWindowOptions): vo
|
||||
const bounds = options.resolveBounds();
|
||||
let placementBounds = syncStatsWindowBounds(window, bounds);
|
||||
promoteStatsWindowLevel(window);
|
||||
window.show();
|
||||
presentStatsWindow(window);
|
||||
placementBounds = syncStatsWindowBounds(window, bounds) ?? placementBounds;
|
||||
if (
|
||||
!ensureHyprlandWindowFloatingByTitle({ title: STATS_WINDOW_TITLE, bounds: placementBounds })
|
||||
) {
|
||||
placementBounds = syncStatsWindowBounds(window, bounds) ?? placementBounds;
|
||||
}
|
||||
window.focus();
|
||||
options.onVisibilityChanged?.(true);
|
||||
promoteStatsWindowLevel(window);
|
||||
}
|
||||
|
||||
@@ -142,10 +142,7 @@ export function shouldCopyYomitanExtension(sourceDir: string, targetDir: string)
|
||||
return sourceHash === null || targetHash === null || sourceHash !== targetHash;
|
||||
}
|
||||
|
||||
export function ensureExtensionCopy(
|
||||
sourceDir: string,
|
||||
userDataPath: string,
|
||||
): ExtensionCopyResult {
|
||||
export function ensureExtensionCopy(sourceDir: string, userDataPath: string): ExtensionCopyResult {
|
||||
if (process.platform === 'win32') {
|
||||
return { targetDir: sourceDir, copied: false };
|
||||
}
|
||||
|
||||
@@ -15,21 +15,24 @@ import {
|
||||
test('yomitan settings window uses a close-only menu without app quit', () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
configureYomitanSettingsWindowChrome({
|
||||
isDestroyed: () => false,
|
||||
close: () => calls.push('close'),
|
||||
setAutoHideMenuBar: (hide: boolean) => calls.push(`auto-hide:${hide}`),
|
||||
setMenu: (menu: unknown) => calls.push(`menu:${menu === null ? 'null' : 'custom'}`),
|
||||
} as never, (template) => {
|
||||
calls.push(`menu-label:${template[0]?.label ?? ''}`);
|
||||
const submenu = template[0]?.submenu;
|
||||
assert.ok(Array.isArray(submenu));
|
||||
const closeItem = submenu[0];
|
||||
assert.equal(closeItem?.label, 'Close');
|
||||
assert.notEqual(closeItem?.role, 'quit');
|
||||
closeItem?.click?.({} as never, {} as never, {} as never);
|
||||
return { id: 'settings-menu' } as never;
|
||||
});
|
||||
configureYomitanSettingsWindowChrome(
|
||||
{
|
||||
isDestroyed: () => false,
|
||||
close: () => calls.push('close'),
|
||||
setAutoHideMenuBar: (hide: boolean) => calls.push(`auto-hide:${hide}`),
|
||||
setMenu: (menu: unknown) => calls.push(`menu:${menu === null ? 'null' : 'custom'}`),
|
||||
} as never,
|
||||
(template) => {
|
||||
calls.push(`menu-label:${template[0]?.label ?? ''}`);
|
||||
const submenu = template[0]?.submenu;
|
||||
assert.ok(Array.isArray(submenu));
|
||||
const closeItem = submenu[0];
|
||||
assert.equal(closeItem?.label, 'Close');
|
||||
assert.notEqual(closeItem?.role, 'quit');
|
||||
closeItem?.click?.({} as never, {} as never, {} as never);
|
||||
return { id: 'settings-menu' } as never;
|
||||
},
|
||||
);
|
||||
|
||||
assert.deepEqual(calls, ['auto-hide:false', 'menu-label:File', 'close', 'menu:custom']);
|
||||
});
|
||||
|
||||
+105
-47
@@ -20,6 +20,8 @@ import {
|
||||
BrowserWindow,
|
||||
clipboard,
|
||||
globalShortcut,
|
||||
ipcMain,
|
||||
net,
|
||||
shell,
|
||||
protocol,
|
||||
Extension,
|
||||
@@ -75,28 +77,6 @@ function getDefaultPasswordStore(): string {
|
||||
return 'gnome-libsecret';
|
||||
}
|
||||
|
||||
function getStartupModeFlags(initialArgs: CliArgs | null | undefined): {
|
||||
shouldUseMinimalStartup: boolean;
|
||||
shouldSkipHeavyStartup: boolean;
|
||||
} {
|
||||
return {
|
||||
shouldUseMinimalStartup: Boolean(
|
||||
(initialArgs && isStandaloneTexthookerCommand(initialArgs)) ||
|
||||
initialArgs?.update ||
|
||||
(initialArgs?.stats &&
|
||||
(initialArgs.statsCleanup || initialArgs.statsBackground || initialArgs.statsStop)),
|
||||
),
|
||||
shouldSkipHeavyStartup: Boolean(
|
||||
initialArgs &&
|
||||
(shouldRunSettingsOnlyStartup(initialArgs) ||
|
||||
initialArgs.stats ||
|
||||
initialArgs.dictionary ||
|
||||
initialArgs.update ||
|
||||
initialArgs.setup),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
protocol.registerSchemesAsPrivileged([
|
||||
{
|
||||
scheme: 'chrome-extension',
|
||||
@@ -152,15 +132,18 @@ import {
|
||||
commandNeedsOverlayStartupPrereqs,
|
||||
commandNeedsOverlayRuntime,
|
||||
isHeadlessInitialCommand,
|
||||
isStandaloneTexthookerCommand,
|
||||
parseArgs,
|
||||
shouldRunSettingsOnlyStartup,
|
||||
shouldStartApp,
|
||||
type CliArgs,
|
||||
type CliCommandSource,
|
||||
} from './cli/args';
|
||||
import { printHelp } from './cli/help';
|
||||
import { IPC_CHANNELS, type OverlayHostedModal } from './shared/ipc/contracts';
|
||||
import {
|
||||
getStartupModeFlags,
|
||||
shouldRefreshAnilistOnConfigReload,
|
||||
shouldStartAutomaticUpdateChecks,
|
||||
} from './main/runtime/startup-mode-flags';
|
||||
import {
|
||||
buildConfigParseErrorDetails,
|
||||
buildConfigWarningDialogDetails,
|
||||
@@ -515,6 +498,8 @@ import {
|
||||
createElectronAppUpdater,
|
||||
isNativeUpdaterSupported,
|
||||
} from './main/runtime/update/app-updater';
|
||||
import { createElectronNetFetch } from './main/runtime/update/fetch-adapter';
|
||||
import { createCurlHttpExecutor } from './main/runtime/update/curl-http-executor';
|
||||
import {
|
||||
fetchLatestStableRelease,
|
||||
fetchReleaseAssetBuffer,
|
||||
@@ -523,6 +508,7 @@ import {
|
||||
parseSha256Sums,
|
||||
type GitHubRelease,
|
||||
} from './main/runtime/update/release-assets';
|
||||
import { shouldFetchReleaseMetadataForPlatform } from './main/runtime/update/release-metadata-policy';
|
||||
import { updateLauncherFromRelease } from './main/runtime/update/launcher-updater';
|
||||
import { notifyUpdateAvailable } from './main/runtime/update/update-notifications';
|
||||
import { createUpdateDialogPresenter } from './main/runtime/update/update-dialogs';
|
||||
@@ -541,9 +527,11 @@ import {
|
||||
} from './main/runtime/subtitle-prefetch-runtime';
|
||||
import {
|
||||
createCreateAnilistSetupWindowHandler,
|
||||
createCreateConfigSettingsWindowHandler,
|
||||
createCreateFirstRunSetupWindowHandler,
|
||||
createCreateJellyfinSetupWindowHandler,
|
||||
} from './main/runtime/setup-window-factory';
|
||||
import { createConfigSettingsRuntime } from './main/runtime/config-settings-runtime';
|
||||
import { isYoutubePlaybackActive } from './main/runtime/youtube-playback';
|
||||
import { createYomitanProfilePolicy } from './main/runtime/yomitan-profile-policy';
|
||||
import { formatSkippedYomitanWriteAction } from './main/runtime/yomitan-read-only-log';
|
||||
@@ -577,6 +565,7 @@ import {
|
||||
generateConfigTemplate,
|
||||
} from './config';
|
||||
import { resolveConfigDir } from './config/path-resolution';
|
||||
import { buildConfigSettingsRegistry } from './config/settings/registry';
|
||||
import { parseSubtitleCues } from './core/services/subtitle-cue-parser';
|
||||
import {
|
||||
createSubtitlePrefetchService,
|
||||
@@ -835,6 +824,7 @@ const {
|
||||
appState,
|
||||
appLifecycleApp,
|
||||
} = bootServices;
|
||||
const configSettingsFields = buildConfigSettingsRegistry(DEFAULT_CONFIG);
|
||||
notifyAnilistTokenStoreWarning = (message: string) => {
|
||||
logger.warn(`[AniList] ${message}`);
|
||||
try {
|
||||
@@ -1777,6 +1767,9 @@ const buildConfigHotReloadAppliedMainDepsHandler = createBuildConfigHotReloadApp
|
||||
},
|
||||
},
|
||||
);
|
||||
const applyConfigHotReloadDiff = createConfigHotReloadAppliedHandler(
|
||||
buildConfigHotReloadAppliedMainDepsHandler(),
|
||||
);
|
||||
const buildConfigHotReloadRuntimeMainDepsHandler = createBuildConfigHotReloadRuntimeMainDepsHandler(
|
||||
{
|
||||
getCurrentConfig: () => getResolvedConfig(),
|
||||
@@ -1785,9 +1778,7 @@ const buildConfigHotReloadRuntimeMainDepsHandler = createBuildConfigHotReloadRun
|
||||
setTimeout: (callback, delayMs) => setTimeout(callback, delayMs),
|
||||
clearTimeout: (timeout) => clearTimeout(timeout),
|
||||
debounceMs: 250,
|
||||
onHotReloadApplied: createConfigHotReloadAppliedHandler(
|
||||
buildConfigHotReloadAppliedMainDepsHandler(),
|
||||
),
|
||||
onHotReloadApplied: applyConfigHotReloadDiff,
|
||||
onRestartRequired: (fields) =>
|
||||
notifyConfigHotReloadMessage(buildRestartRequiredConfigMessage(fields)),
|
||||
onInvalidConfig: notifyConfigHotReloadMessage,
|
||||
@@ -1808,6 +1799,32 @@ const configHotReloadRuntime = createConfigHotReloadRuntime(
|
||||
buildConfigHotReloadRuntimeMainDepsHandler(),
|
||||
);
|
||||
|
||||
const configSettingsRuntime = createConfigSettingsRuntime({
|
||||
fields: configSettingsFields,
|
||||
getConfigPath: () => configService.getConfigPath(),
|
||||
getRawConfig: () => configService.getRawConfig(),
|
||||
getConfig: () => configService.getConfig(),
|
||||
getWarnings: () => configService.getWarnings(),
|
||||
reloadConfigStrict: () => configService.reloadConfigStrict(),
|
||||
applyHotReload: (diff, config) => applyConfigHotReloadDiff(diff, config),
|
||||
getSettingsWindow: () => appState.configSettingsWindow,
|
||||
setSettingsWindow: (window) => {
|
||||
appState.configSettingsWindow = window as BrowserWindow | null;
|
||||
},
|
||||
createSettingsWindow: createCreateConfigSettingsWindowHandler({
|
||||
createBrowserWindow: (options) => new BrowserWindow(options),
|
||||
preloadPath: path.join(__dirname, 'preload-settings.js'),
|
||||
}),
|
||||
settingsHtmlPath: path.join(__dirname, 'settings', 'index.html'),
|
||||
openPath: (targetPath) => shell.openPath(targetPath),
|
||||
ipcMain,
|
||||
ipcChannels: IPC_CHANNELS.request,
|
||||
log: (message) => logger.error(message),
|
||||
});
|
||||
|
||||
configSettingsRuntime.registerHandlers();
|
||||
const openConfigSettingsWindow = () => configSettingsRuntime.openWindow();
|
||||
|
||||
const buildDictionaryRootsHandler = createBuildDictionaryRootsMainHandler({
|
||||
platform: process.platform,
|
||||
dirname: __dirname,
|
||||
@@ -2069,6 +2086,7 @@ const overlayVisibilityRuntime = createOverlayVisibilityRuntimeService(
|
||||
getModalActive: () => overlayModalInputState.getModalInputExclusive(),
|
||||
getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
|
||||
getForceMousePassthrough: () => appState.statsOverlayVisible,
|
||||
getOverlayInteractionActive: () => visibleOverlayInteractionActive,
|
||||
getWindowTracker: () => appState.windowTracker,
|
||||
getLastKnownWindowsForegroundProcessName: () => lastWindowsVisibleOverlayForegroundProcessName,
|
||||
getWindowsOverlayProcessName: () => path.parse(process.execPath).name.toLowerCase(),
|
||||
@@ -2112,23 +2130,24 @@ const overlayVisibilityRuntime = createOverlayVisibilityRuntimeService(
|
||||
})(),
|
||||
);
|
||||
|
||||
const WINDOWS_VISIBLE_OVERLAY_BLUR_REFRESH_DELAYS_MS = [0, 25, 100, 250] as const;
|
||||
const VISIBLE_OVERLAY_BLUR_REFRESH_DELAYS_MS = [0, 25, 100, 250] as const;
|
||||
const WINDOWS_VISIBLE_OVERLAY_Z_ORDER_RETRY_DELAYS_MS = [0, 48, 120, 240, 480] as const;
|
||||
const WINDOWS_VISIBLE_OVERLAY_FOREGROUND_POLL_INTERVAL_MS = 75;
|
||||
const WINDOWS_VISIBLE_OVERLAY_FOCUS_HANDOFF_GRACE_MS = 200;
|
||||
let windowsVisibleOverlayBlurRefreshTimeouts: Array<ReturnType<typeof setTimeout>> = [];
|
||||
let visibleOverlayBlurRefreshTimeouts: Array<ReturnType<typeof setTimeout>> = [];
|
||||
let windowsVisibleOverlayZOrderRetryTimeouts: Array<ReturnType<typeof setTimeout>> = [];
|
||||
let windowsVisibleOverlayZOrderSyncInFlight = false;
|
||||
let windowsVisibleOverlayZOrderSyncQueued = false;
|
||||
let windowsVisibleOverlayForegroundPollInterval: ReturnType<typeof setInterval> | null = null;
|
||||
let lastWindowsVisibleOverlayForegroundProcessName: string | null = null;
|
||||
let lastWindowsVisibleOverlayBlurredAtMs = 0;
|
||||
let visibleOverlayInteractionActive = false;
|
||||
|
||||
function clearWindowsVisibleOverlayBlurRefreshTimeouts(): void {
|
||||
for (const timeout of windowsVisibleOverlayBlurRefreshTimeouts) {
|
||||
function clearVisibleOverlayBlurRefreshTimeouts(): void {
|
||||
for (const timeout of visibleOverlayBlurRefreshTimeouts) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
windowsVisibleOverlayBlurRefreshTimeouts = [];
|
||||
visibleOverlayBlurRefreshTimeouts = [];
|
||||
}
|
||||
|
||||
function clearWindowsVisibleOverlayZOrderRetryTimeouts(): void {
|
||||
@@ -2329,20 +2348,22 @@ function clearWindowsVisibleOverlayForegroundPollLoop(): void {
|
||||
}
|
||||
|
||||
function scheduleVisibleOverlayBlurRefresh(): void {
|
||||
if (process.platform !== 'win32') {
|
||||
if (process.platform !== 'win32' && process.platform !== 'darwin') {
|
||||
return;
|
||||
}
|
||||
|
||||
lastWindowsVisibleOverlayBlurredAtMs = Date.now();
|
||||
clearWindowsVisibleOverlayBlurRefreshTimeouts();
|
||||
for (const delayMs of WINDOWS_VISIBLE_OVERLAY_BLUR_REFRESH_DELAYS_MS) {
|
||||
if (process.platform === 'win32') {
|
||||
lastWindowsVisibleOverlayBlurredAtMs = Date.now();
|
||||
}
|
||||
clearVisibleOverlayBlurRefreshTimeouts();
|
||||
for (const delayMs of VISIBLE_OVERLAY_BLUR_REFRESH_DELAYS_MS) {
|
||||
const refreshTimeout = setTimeout(() => {
|
||||
windowsVisibleOverlayBlurRefreshTimeouts = windowsVisibleOverlayBlurRefreshTimeouts.filter(
|
||||
visibleOverlayBlurRefreshTimeouts = visibleOverlayBlurRefreshTimeouts.filter(
|
||||
(timeout) => timeout !== refreshTimeout,
|
||||
);
|
||||
overlayVisibilityRuntime.updateVisibleOverlayVisibility();
|
||||
}, delayMs);
|
||||
windowsVisibleOverlayBlurRefreshTimeouts.push(refreshTimeout);
|
||||
visibleOverlayBlurRefreshTimeouts.push(refreshTimeout);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3043,6 +3064,7 @@ const {
|
||||
resetAnilistMediaTracking,
|
||||
getAnilistMediaGuessRuntimeState,
|
||||
setAnilistMediaGuessRuntimeState,
|
||||
recordAnilistMediaDuration,
|
||||
resetAnilistMediaGuessState,
|
||||
maybeProbeAnilistDuration,
|
||||
ensureAnilistMediaGuess,
|
||||
@@ -3146,6 +3168,13 @@ const {
|
||||
);
|
||||
},
|
||||
},
|
||||
recordMediaDurationMainDeps: {
|
||||
getCurrentMediaKey: () => getCurrentAnilistMediaKey(),
|
||||
getState: () => getAnilistMediaGuessRuntimeState(),
|
||||
setState: (state) => {
|
||||
setAnilistMediaGuessRuntimeState(state);
|
||||
},
|
||||
},
|
||||
resetMediaGuessStateMainDeps: {
|
||||
setMediaGuess: (value) => {
|
||||
anilistMediaGuessRuntimeState = transitionAnilistMediaGuessRuntimeState(
|
||||
@@ -3197,9 +3226,10 @@ const {
|
||||
);
|
||||
},
|
||||
refreshAnilistClientSecretState: () => refreshAnilistClientSecretState(),
|
||||
updateAnilistPostWatchProgress: (accessToken, title, episode) =>
|
||||
updateAnilistPostWatchProgress: (accessToken, title, episode, season) =>
|
||||
updateAnilistPostWatchProgress(accessToken, title, episode, {
|
||||
rateLimiter: anilistRateLimiter,
|
||||
season,
|
||||
}),
|
||||
markSuccess: (key) => {
|
||||
anilistUpdateQueue.markSuccess(key);
|
||||
@@ -3230,13 +3260,13 @@ const {
|
||||
resetAnilistMediaTracking(mediaKey);
|
||||
},
|
||||
getWatchedSeconds: () => appState.mpvClient?.currentTimePos ?? Number.NaN,
|
||||
maybeProbeAnilistDuration: (mediaKey) => maybeProbeAnilistDuration(mediaKey),
|
||||
maybeProbeAnilistDuration: (mediaKey, options) => maybeProbeAnilistDuration(mediaKey, options),
|
||||
ensureAnilistMediaGuess: (mediaKey) => ensureAnilistMediaGuess(mediaKey),
|
||||
hasAttemptedUpdateKey: (key) => anilistAttemptedUpdateKeys.has(key),
|
||||
processNextAnilistRetryUpdate: () => processNextAnilistRetryUpdate(),
|
||||
refreshAnilistClientSecretState: () => refreshAnilistClientSecretState(),
|
||||
enqueueRetry: (key, title, episode) => {
|
||||
anilistUpdateQueue.enqueue(key, title, episode);
|
||||
enqueueRetry: (key, title, episode, season) => {
|
||||
anilistUpdateQueue.enqueue(key, title, episode, season);
|
||||
},
|
||||
markRetryFailure: (key, message) => {
|
||||
anilistUpdateQueue.markFailure(key, message);
|
||||
@@ -3245,9 +3275,10 @@ const {
|
||||
anilistUpdateQueue.markSuccess(key);
|
||||
},
|
||||
refreshRetryQueueState: () => anilistStateRuntime.refreshRetryQueueState(),
|
||||
updateAnilistPostWatchProgress: (accessToken, title, episode) =>
|
||||
updateAnilistPostWatchProgress: (accessToken, title, episode, season) =>
|
||||
updateAnilistPostWatchProgress(accessToken, title, episode, {
|
||||
rateLimiter: anilistRateLimiter,
|
||||
season,
|
||||
}),
|
||||
rememberAttemptedUpdateKey: (key) => {
|
||||
rememberAnilistAttemptedUpdate(key);
|
||||
@@ -3745,7 +3776,7 @@ const { appReadyRuntimeRunner } = composeAppReadyRuntime({
|
||||
showDesktopNotification: (title, options) => showDesktopNotification(title, options),
|
||||
startConfigHotReload: () => configHotReloadRuntime.start(),
|
||||
shouldRefreshAnilistClientSecretState: () =>
|
||||
!(appState.initialArgs && isHeadlessInitialCommand(appState.initialArgs)),
|
||||
shouldRefreshAnilistOnConfigReload(appState.initialArgs),
|
||||
refreshAnilistClientSecretState: (options) => refreshAnilistClientSecretStateIfEnabled(options),
|
||||
failHandlers: {
|
||||
logError: (details) => logger.error(details),
|
||||
@@ -3984,7 +4015,10 @@ const {
|
||||
reportJellyfinRemoteStopped: () => {
|
||||
void reportJellyfinRemoteStopped();
|
||||
},
|
||||
maybeRunAnilistPostWatchUpdate: () => maybeRunAnilistPostWatchUpdate(),
|
||||
maybeRunAnilistPostWatchUpdate: (options) => maybeRunAnilistPostWatchUpdate(options),
|
||||
recordAnilistMediaDuration: (durationSec) => {
|
||||
recordAnilistMediaDuration(durationSec);
|
||||
},
|
||||
logSubtitleTimingError: (message, error) => logger.error(message, error),
|
||||
broadcastToOverlayWindows: (channel, payload) => {
|
||||
broadcastToOverlayWindows(channel, payload);
|
||||
@@ -4619,9 +4653,12 @@ flushPendingMpvLogWrites = () => {
|
||||
|
||||
const updateStateStore = createFileUpdateStateStore(path.join(USER_DATA_PATH, 'update-state.json'));
|
||||
let updateService: ReturnType<typeof createUpdateService> | null = null;
|
||||
const electronNetFetch = createElectronNetFetch({
|
||||
fetch: (url, init) => net.fetch(url, init as RequestInit),
|
||||
});
|
||||
|
||||
function getFetchForUpdater() {
|
||||
return globalThis.fetch.bind(globalThis);
|
||||
return electronNetFetch;
|
||||
}
|
||||
|
||||
async function updateLauncherFromSelectedRelease(
|
||||
@@ -4668,6 +4705,9 @@ function getUpdateService() {
|
||||
isPackaged: app.isPackaged,
|
||||
log: (message) => logger.info(message),
|
||||
getChannel: () => getResolvedConfig().updates.channel,
|
||||
configureHttpExecutor:
|
||||
process.platform === 'darwin' ? () => createCurlHttpExecutor() : undefined,
|
||||
disableDifferentialDownload: process.platform === 'darwin',
|
||||
isNativeUpdaterSupported: () =>
|
||||
isNativeUpdaterSupported({
|
||||
platform: process.platform,
|
||||
@@ -4689,6 +4729,8 @@ function getUpdateService() {
|
||||
readState: () => updateStateStore.readState(),
|
||||
writeState: (state) => updateStateStore.writeState(state),
|
||||
checkAppUpdate: (channel) => appUpdater.checkForUpdates(channel),
|
||||
shouldFetchReleaseMetadata: ({ appUpdate }) =>
|
||||
shouldFetchReleaseMetadataForPlatform(process.platform, appUpdate),
|
||||
fetchLatestStableRelease: (channel) =>
|
||||
fetchLatestStableRelease({ fetch: getFetchForUpdater(), channel }),
|
||||
updateLauncher: (launcherPath, channel, release) =>
|
||||
@@ -5126,6 +5168,20 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
||||
onOverlayModalOpened: (modal) => {
|
||||
overlayModalRuntime.notifyOverlayModalOpened(modal);
|
||||
},
|
||||
onOverlayMouseInteractionChanged: (active, senderWindow) => {
|
||||
const mainWindow = overlayManager.getMainWindow();
|
||||
if (!mainWindow || senderWindow !== mainWindow) {
|
||||
return;
|
||||
}
|
||||
if (visibleOverlayInteractionActive === active) {
|
||||
if (active && process.platform === 'darwin' && !mainWindow.isFocused()) {
|
||||
overlayVisibilityRuntime.updateVisibleOverlayVisibility();
|
||||
}
|
||||
return;
|
||||
}
|
||||
visibleOverlayInteractionActive = active;
|
||||
overlayVisibilityRuntime.updateVisibleOverlayVisibility();
|
||||
},
|
||||
onYoutubePickerResolve: (request) => youtubeFlowRuntime.resolveActivePicker(request),
|
||||
openYomitanSettings: () => openYomitanSettings(),
|
||||
quitApp: () => requestAppQuit(),
|
||||
@@ -5381,6 +5437,7 @@ const { handleCliCommand, handleInitialArgs } = composeCliStartupHandlers({
|
||||
},
|
||||
runYoutubePlaybackFlow: (request) => youtubePlaybackRuntime.runYoutubePlaybackFlow(request),
|
||||
openYomitanSettings: () => openYomitanSettings(),
|
||||
openConfigSettingsWindow: () => openConfigSettingsWindow(),
|
||||
cycleSecondarySubMode: () => handleCycleSecondarySubMode(),
|
||||
openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(),
|
||||
printHelp: () => printHelp(DEFAULT_TEXTHOOKER_PORT),
|
||||
@@ -5495,7 +5552,7 @@ const { runAndApplyStartupState } = composeHeadlessStartupHandlers<
|
||||
|
||||
runAndApplyStartupState();
|
||||
void app.whenReady().then(() => {
|
||||
if (appState.initialArgs && isHeadlessInitialCommand(appState.initialArgs)) {
|
||||
if (!shouldStartAutomaticUpdateChecks(appState.initialArgs)) {
|
||||
return;
|
||||
}
|
||||
getUpdateService().startAutomaticChecks();
|
||||
@@ -5590,6 +5647,7 @@ const { ensureTray: ensureTrayHandler, destroyTray: destroyTrayHandler } =
|
||||
showWindowsMpvLauncherSetup: () => process.platform === 'win32',
|
||||
openYomitanSettings: () => openYomitanSettings(),
|
||||
openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(),
|
||||
openConfigSettingsWindow: () => openConfigSettingsWindow(),
|
||||
openJellyfinSetupWindow: () => openJellyfinSetupWindow(),
|
||||
isJellyfinConfigured: () =>
|
||||
isJellyfinConfiguredForTrayRuntime(getJellyfinTrayDiscoveryDeps()),
|
||||
|
||||
@@ -1459,7 +1459,7 @@ test('generateForCurrentMedia preserves duplicate surface forms across different
|
||||
}
|
||||
});
|
||||
|
||||
test('getOrCreateCurrentSnapshot persists and reuses normalized snapshot data', async () => {
|
||||
test('getOrCreateCurrentSnapshot reuses cached media resolution without AniList requests', async () => {
|
||||
const userDataPath = makeTempDir();
|
||||
const originalFetch = globalThis.fetch;
|
||||
let searchQueryCount = 0;
|
||||
@@ -1567,11 +1567,18 @@ test('getOrCreateCurrentSnapshot persists and reuses normalized snapshot data',
|
||||
});
|
||||
|
||||
const first = await runtime.getOrCreateCurrentSnapshot();
|
||||
assert.equal(searchQueryCount, 1);
|
||||
assert.equal(characterQueryCount, 1);
|
||||
|
||||
fs.rmSync(path.join(userDataPath, 'character-dictionaries', 'anilist-resolution-cache.json'), {
|
||||
force: true,
|
||||
});
|
||||
|
||||
const second = await runtime.getOrCreateCurrentSnapshot();
|
||||
|
||||
assert.equal(first.fromCache, false);
|
||||
assert.equal(second.fromCache, true);
|
||||
assert.equal(searchQueryCount, 2);
|
||||
assert.equal(searchQueryCount, 1);
|
||||
assert.equal(characterQueryCount, 1);
|
||||
assert.equal(
|
||||
fs.existsSync(path.join(userDataPath, 'character-dictionaries', 'cache.json')),
|
||||
|
||||
@@ -15,7 +15,10 @@ import {
|
||||
getMergedZipPath,
|
||||
getSnapshotPath,
|
||||
normalizeMergedMediaIds,
|
||||
readCachedMediaResolution,
|
||||
readCachedSnapshots,
|
||||
readSnapshot,
|
||||
writeCachedMediaResolution,
|
||||
writeSnapshot,
|
||||
} from './character-dictionary-runtime/cache';
|
||||
import {
|
||||
@@ -41,6 +44,7 @@ import type {
|
||||
CharacterDictionaryManualSelectionResult,
|
||||
CharacterDictionaryManualSelectionSnapshot,
|
||||
CharacterDictionaryRuntimeDeps,
|
||||
CharacterDictionarySnapshot,
|
||||
CharacterDictionarySnapshotImage,
|
||||
CharacterDictionarySnapshotProgress,
|
||||
CharacterDictionarySnapshotProgressCallbacks,
|
||||
@@ -204,6 +208,26 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar
|
||||
};
|
||||
};
|
||||
|
||||
const findCachedSnapshotForSeriesKey = (
|
||||
seriesKey: string,
|
||||
): CharacterDictionarySnapshot | null => {
|
||||
return (
|
||||
readCachedSnapshots(outputDir).find((snapshot) => {
|
||||
const snapshotSeriesKey = buildCharacterDictionarySeriesKey({
|
||||
mediaPath: null,
|
||||
mediaTitle: snapshot.mediaTitle,
|
||||
guess: {
|
||||
title: snapshot.mediaTitle,
|
||||
season: null,
|
||||
episode: null,
|
||||
source: 'fallback',
|
||||
},
|
||||
});
|
||||
return snapshotSeriesKey === seriesKey;
|
||||
}) ?? null
|
||||
);
|
||||
};
|
||||
|
||||
const resolveCurrentMedia = async (
|
||||
targetPath?: string,
|
||||
beforeRequest?: () => Promise<void>,
|
||||
@@ -228,7 +252,43 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar
|
||||
staleMediaIds: override.staleMediaIds,
|
||||
};
|
||||
}
|
||||
|
||||
const cachedResolution = readCachedMediaResolution(outputDir, seriesKey);
|
||||
if (cachedResolution) {
|
||||
const cachedSnapshot = readSnapshot(getSnapshotPath(outputDir, cachedResolution.mediaId));
|
||||
if (cachedSnapshot) {
|
||||
deps.logInfo?.(
|
||||
`[dictionary] cached AniList match: ${cachedSnapshot.mediaTitle} -> AniList ${cachedSnapshot.mediaId}`,
|
||||
);
|
||||
return {
|
||||
id: cachedSnapshot.mediaId,
|
||||
title: cachedSnapshot.mediaTitle,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const cachedSnapshot = findCachedSnapshotForSeriesKey(seriesKey);
|
||||
if (cachedSnapshot) {
|
||||
writeCachedMediaResolution(outputDir, {
|
||||
seriesKey,
|
||||
mediaId: cachedSnapshot.mediaId,
|
||||
mediaTitle: cachedSnapshot.mediaTitle,
|
||||
});
|
||||
deps.logInfo?.(
|
||||
`[dictionary] cached snapshot match: ${cachedSnapshot.mediaTitle} -> AniList ${cachedSnapshot.mediaId}`,
|
||||
);
|
||||
return {
|
||||
id: cachedSnapshot.mediaId,
|
||||
title: cachedSnapshot.mediaTitle,
|
||||
};
|
||||
}
|
||||
|
||||
const resolved = await resolveAniListMediaIdFromGuess(guessed, beforeRequest);
|
||||
writeCachedMediaResolution(outputDir, {
|
||||
seriesKey,
|
||||
mediaId: resolved.id,
|
||||
mediaTitle: resolved.title,
|
||||
});
|
||||
deps.logInfo?.(`[dictionary] AniList match: ${resolved.title} -> AniList ${resolved.id}`);
|
||||
return resolved;
|
||||
};
|
||||
|
||||
@@ -21,6 +21,102 @@ export function getMergedZipPath(outputDir: string): string {
|
||||
return path.join(outputDir, 'merged.zip');
|
||||
}
|
||||
|
||||
type MediaResolutionCacheEntry = {
|
||||
seriesKey: string;
|
||||
mediaId: number;
|
||||
mediaTitle: string;
|
||||
};
|
||||
|
||||
type MediaResolutionCacheFile = {
|
||||
entries?: MediaResolutionCacheEntry[];
|
||||
};
|
||||
|
||||
function getMediaResolutionCachePath(outputDir: string): string {
|
||||
return path.join(outputDir, 'anilist-resolution-cache.json');
|
||||
}
|
||||
|
||||
function normalizeMediaResolutionEntry(value: unknown): MediaResolutionCacheEntry | null {
|
||||
if (!value || typeof value !== 'object') return null;
|
||||
const raw = value as Partial<MediaResolutionCacheEntry>;
|
||||
const seriesKey = typeof raw.seriesKey === 'string' ? raw.seriesKey.trim() : '';
|
||||
const mediaTitle = typeof raw.mediaTitle === 'string' ? raw.mediaTitle.trim() : '';
|
||||
if (typeof raw.mediaId !== 'number' || !Number.isFinite(raw.mediaId)) return null;
|
||||
const mediaId = Math.floor(raw.mediaId);
|
||||
if (!seriesKey || mediaId <= 0 || !mediaTitle) return null;
|
||||
return {
|
||||
seriesKey,
|
||||
mediaId,
|
||||
mediaTitle,
|
||||
};
|
||||
}
|
||||
|
||||
function readMediaResolutionEntries(outputDir: string): MediaResolutionCacheEntry[] {
|
||||
try {
|
||||
const parsed = JSON.parse(
|
||||
fs.readFileSync(getMediaResolutionCachePath(outputDir), 'utf8'),
|
||||
) as MediaResolutionCacheFile;
|
||||
if (!Array.isArray(parsed.entries)) return [];
|
||||
const byKey = new Map<string, MediaResolutionCacheEntry>();
|
||||
for (const value of parsed.entries) {
|
||||
const normalized = normalizeMediaResolutionEntry(value);
|
||||
if (normalized) byKey.set(normalized.seriesKey, normalized);
|
||||
}
|
||||
return [...byKey.values()];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function writeMediaResolutionEntries(
|
||||
outputDir: string,
|
||||
entries: MediaResolutionCacheEntry[],
|
||||
): void {
|
||||
ensureDir(outputDir);
|
||||
fs.writeFileSync(
|
||||
getMediaResolutionCachePath(outputDir),
|
||||
JSON.stringify({ entries }, null, 2),
|
||||
'utf8',
|
||||
);
|
||||
}
|
||||
|
||||
export function readCachedMediaResolution(
|
||||
outputDir: string,
|
||||
seriesKey: string,
|
||||
): MediaResolutionCacheEntry | null {
|
||||
const normalizedKey = seriesKey.trim();
|
||||
if (!normalizedKey) return null;
|
||||
return (
|
||||
readMediaResolutionEntries(outputDir).find((entry) => entry.seriesKey === normalizedKey) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
export function writeCachedMediaResolution(
|
||||
outputDir: string,
|
||||
entry: MediaResolutionCacheEntry,
|
||||
): void {
|
||||
const normalized = normalizeMediaResolutionEntry(entry);
|
||||
if (!normalized) return;
|
||||
const remaining = readMediaResolutionEntries(outputDir).filter(
|
||||
(existing) => existing.seriesKey !== normalized.seriesKey,
|
||||
);
|
||||
writeMediaResolutionEntries(outputDir, [...remaining, normalized]);
|
||||
}
|
||||
|
||||
export function readCachedSnapshots(outputDir: string): CharacterDictionarySnapshot[] {
|
||||
let entries: fs.Dirent[] = [];
|
||||
try {
|
||||
entries = fs.readdirSync(getSnapshotsDir(outputDir), { withFileTypes: true });
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
return entries
|
||||
.filter((entry) => entry.isFile() && /^anilist-\d+\.json$/.test(entry.name))
|
||||
.sort((left, right) => left.name.localeCompare(right.name))
|
||||
.map((entry) => readSnapshot(path.join(getSnapshotsDir(outputDir), entry.name)))
|
||||
.filter((snapshot): snapshot is CharacterDictionarySnapshot => snapshot !== null);
|
||||
}
|
||||
|
||||
export function readSnapshot(snapshotPath: string): CharacterDictionarySnapshot | null {
|
||||
try {
|
||||
const raw = fs.readFileSync(snapshotPath, 'utf8');
|
||||
|
||||
@@ -46,6 +46,7 @@ export interface CliCommandRuntimeServiceContext {
|
||||
runUpdateCommand: CliCommandRuntimeServiceDepsParams['app']['runUpdateCommand'];
|
||||
runYoutubePlaybackFlow: CliCommandRuntimeServiceDepsParams['app']['runYoutubePlaybackFlow'];
|
||||
openYomitanSettings: () => void;
|
||||
openConfigSettingsWindow: () => void;
|
||||
cycleSecondarySubMode: () => void;
|
||||
openRuntimeOptionsPalette: () => void;
|
||||
printHelp: () => void;
|
||||
@@ -127,6 +128,7 @@ function createCliCommandDepsFromContext(
|
||||
ui: {
|
||||
openFirstRunSetup: context.openFirstRunSetup,
|
||||
openYomitanSettings: context.openYomitanSettings,
|
||||
openConfigSettingsWindow: context.openConfigSettingsWindow,
|
||||
cycleSecondarySubMode: context.cycleSecondarySubMode,
|
||||
openRuntimeOptionsPalette: context.openRuntimeOptionsPalette,
|
||||
printHelp: context.printHelp,
|
||||
|
||||
@@ -75,3 +75,67 @@ test('applyControllerConfigUpdate detaches updated binding values from the patch
|
||||
|
||||
assert.deepEqual(next.bindings?.toggleLookup, { kind: 'button', buttonIndex: 7 });
|
||||
});
|
||||
|
||||
test('applyControllerConfigUpdate merges per-controller profile binding leaves', () => {
|
||||
const next = applyControllerConfigUpdate(
|
||||
{
|
||||
profiles: {
|
||||
'pad-1': {
|
||||
label: 'Pad 1',
|
||||
bindings: {
|
||||
toggleLookup: { kind: 'button', buttonIndex: 0 },
|
||||
closeLookup: { kind: 'button', buttonIndex: 1 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
profiles: {
|
||||
'pad-1': {
|
||||
bindings: {
|
||||
toggleLookup: { kind: 'button', buttonIndex: 11 },
|
||||
},
|
||||
},
|
||||
'pad-2': {
|
||||
label: 'Pad 2',
|
||||
bindings: {
|
||||
mineCard: { kind: 'button', buttonIndex: 8 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
assert.deepEqual(next.profiles?.['pad-1']?.bindings?.toggleLookup, {
|
||||
kind: 'button',
|
||||
buttonIndex: 11,
|
||||
});
|
||||
assert.deepEqual(next.profiles?.['pad-1']?.bindings?.closeLookup, {
|
||||
kind: 'button',
|
||||
buttonIndex: 1,
|
||||
});
|
||||
assert.deepEqual(next.profiles?.['pad-2']?.bindings?.mineCard, {
|
||||
kind: 'button',
|
||||
buttonIndex: 8,
|
||||
});
|
||||
});
|
||||
|
||||
test('applyControllerConfigUpdate ignores reserved profile ids', () => {
|
||||
const reservedProfiles = Object.create(null) as NonNullable<
|
||||
Parameters<typeof applyControllerConfigUpdate>[1]['profiles']
|
||||
>;
|
||||
reservedProfiles.__proto__ = { label: 'polluted' };
|
||||
reservedProfiles['constructor'] = { label: 'ctor' };
|
||||
reservedProfiles['prototype'] = { label: 'proto' };
|
||||
reservedProfiles['pad-1'] = { label: 'Pad 1' };
|
||||
|
||||
const next = applyControllerConfigUpdate(undefined, {
|
||||
profiles: reservedProfiles,
|
||||
});
|
||||
|
||||
assert.equal(Object.getPrototypeOf(next.profiles), Object.prototype);
|
||||
assert.equal(Object.hasOwn(next.profiles ?? {}, '__proto__'), false);
|
||||
assert.equal(Object.hasOwn(next.profiles ?? {}, 'constructor'), false);
|
||||
assert.equal(Object.hasOwn(next.profiles ?? {}, 'prototype'), false);
|
||||
assert.equal(next.profiles?.['pad-1']?.label, 'Pad 1');
|
||||
});
|
||||
|
||||
@@ -2,6 +2,66 @@ import type { ControllerConfigUpdate, RawConfig } from '../types';
|
||||
|
||||
type RawControllerConfig = NonNullable<RawConfig['controller']>;
|
||||
type RawControllerBindings = NonNullable<RawControllerConfig['bindings']>;
|
||||
type RawControllerButtonIndices = NonNullable<RawControllerConfig['buttonIndices']>;
|
||||
type RawControllerProfiles = NonNullable<RawControllerConfig['profiles']>;
|
||||
type RawControllerProfile = NonNullable<RawControllerProfiles[string]>;
|
||||
|
||||
const RESERVED_CONTROLLER_PROFILE_IDS = new Set(['__proto__', 'constructor', 'prototype']);
|
||||
|
||||
function mergeBindingPatch(
|
||||
currentBindings: RawControllerBindings | undefined,
|
||||
updateBindings: RawControllerBindings | undefined,
|
||||
): RawControllerBindings | undefined {
|
||||
if (!currentBindings && !updateBindings) return undefined;
|
||||
const nextBindings: RawControllerBindings = {
|
||||
...(currentBindings ?? {}),
|
||||
};
|
||||
|
||||
for (const [key, value] of Object.entries(updateBindings ?? {}) as Array<
|
||||
[keyof RawControllerBindings, RawControllerBindings[keyof RawControllerBindings] | undefined]
|
||||
>) {
|
||||
if (value === undefined) continue;
|
||||
(nextBindings as Record<string, unknown>)[key] = structuredClone(value);
|
||||
}
|
||||
|
||||
return nextBindings;
|
||||
}
|
||||
|
||||
function mergeButtonIndexPatch(
|
||||
currentButtonIndices: RawControllerButtonIndices | undefined,
|
||||
updateButtonIndices: RawControllerButtonIndices | undefined,
|
||||
): RawControllerButtonIndices | undefined {
|
||||
if (!currentButtonIndices && !updateButtonIndices) return undefined;
|
||||
return {
|
||||
...(currentButtonIndices ?? {}),
|
||||
...(updateButtonIndices ?? {}),
|
||||
};
|
||||
}
|
||||
|
||||
function mergeControllerProfile(
|
||||
currentProfile: RawControllerProfile | undefined,
|
||||
updateProfile: RawControllerProfile,
|
||||
): RawControllerProfile {
|
||||
const nextProfile: RawControllerProfile = {
|
||||
...(currentProfile ?? {}),
|
||||
...updateProfile,
|
||||
};
|
||||
|
||||
const buttonIndices = mergeButtonIndexPatch(
|
||||
currentProfile?.buttonIndices,
|
||||
updateProfile.buttonIndices,
|
||||
);
|
||||
if (buttonIndices) {
|
||||
nextProfile.buttonIndices = buttonIndices;
|
||||
}
|
||||
|
||||
const bindings = mergeBindingPatch(currentProfile?.bindings, updateProfile.bindings);
|
||||
if (bindings) {
|
||||
nextProfile.bindings = bindings;
|
||||
}
|
||||
|
||||
return nextProfile;
|
||||
}
|
||||
|
||||
export function applyControllerConfigUpdate(
|
||||
currentController: RawConfig['controller'] | undefined,
|
||||
@@ -12,26 +72,38 @@ export function applyControllerConfigUpdate(
|
||||
...update,
|
||||
};
|
||||
|
||||
if (currentController?.buttonIndices || update.buttonIndices) {
|
||||
nextController.buttonIndices = {
|
||||
...(currentController?.buttonIndices ?? {}),
|
||||
...(update.buttonIndices ?? {}),
|
||||
};
|
||||
const buttonIndices = mergeButtonIndexPatch(
|
||||
currentController?.buttonIndices,
|
||||
update.buttonIndices,
|
||||
);
|
||||
if (buttonIndices) {
|
||||
nextController.buttonIndices = buttonIndices;
|
||||
}
|
||||
|
||||
if (currentController?.bindings || update.bindings) {
|
||||
const nextBindings: RawControllerBindings = {
|
||||
...(currentController?.bindings ?? {}),
|
||||
};
|
||||
const bindings = mergeBindingPatch(currentController?.bindings, update.bindings);
|
||||
if (bindings) {
|
||||
nextController.bindings = bindings;
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(update.bindings ?? {}) as Array<
|
||||
[keyof RawControllerBindings, RawControllerBindings[keyof RawControllerBindings] | undefined]
|
||||
if (currentController?.profiles || update.profiles) {
|
||||
const nextProfiles: RawControllerProfiles = {};
|
||||
for (const [profileId, profile] of Object.entries(currentController?.profiles ?? {}) as Array<
|
||||
[string, RawControllerProfile]
|
||||
>) {
|
||||
if (value === undefined) continue;
|
||||
(nextBindings as Record<string, unknown>)[key] = structuredClone(value);
|
||||
if (RESERVED_CONTROLLER_PROFILE_IDS.has(profileId)) continue;
|
||||
nextProfiles[profileId] = profile;
|
||||
}
|
||||
|
||||
nextController.bindings = nextBindings;
|
||||
for (const [profileId, profileUpdate] of Object.entries(update.profiles ?? {}) as Array<
|
||||
[string, RawControllerProfile | undefined]
|
||||
>) {
|
||||
if (RESERVED_CONTROLLER_PROFILE_IDS.has(profileId)) continue;
|
||||
if (profileUpdate === undefined) continue;
|
||||
nextProfiles[profileId] = mergeControllerProfile(
|
||||
currentController?.profiles?.[profileId],
|
||||
profileUpdate,
|
||||
);
|
||||
}
|
||||
nextController.profiles = nextProfiles;
|
||||
}
|
||||
|
||||
return nextController;
|
||||
|
||||
@@ -57,6 +57,7 @@ export interface MainIpcRuntimeServiceDepsParams {
|
||||
getVisibleOverlayVisibility: IpcDepsRuntimeOptions['getVisibleOverlayVisibility'];
|
||||
onOverlayModalClosed: IpcDepsRuntimeOptions['onOverlayModalClosed'];
|
||||
onOverlayModalOpened?: IpcDepsRuntimeOptions['onOverlayModalOpened'];
|
||||
onOverlayMouseInteractionChanged?: IpcDepsRuntimeOptions['onOverlayMouseInteractionChanged'];
|
||||
onYoutubePickerResolve: IpcDepsRuntimeOptions['onYoutubePickerResolve'];
|
||||
openYomitanSettings: IpcDepsRuntimeOptions['openYomitanSettings'];
|
||||
quitApp: IpcDepsRuntimeOptions['quitApp'];
|
||||
@@ -191,6 +192,7 @@ export interface CliCommandRuntimeServiceDepsParams {
|
||||
ui: {
|
||||
openFirstRunSetup: CliCommandDepsRuntimeOptions['ui']['openFirstRunSetup'];
|
||||
openYomitanSettings: CliCommandDepsRuntimeOptions['ui']['openYomitanSettings'];
|
||||
openConfigSettingsWindow: CliCommandDepsRuntimeOptions['ui']['openConfigSettingsWindow'];
|
||||
cycleSecondarySubMode: CliCommandDepsRuntimeOptions['ui']['cycleSecondarySubMode'];
|
||||
openRuntimeOptionsPalette: CliCommandDepsRuntimeOptions['ui']['openRuntimeOptionsPalette'];
|
||||
printHelp: CliCommandDepsRuntimeOptions['ui']['printHelp'];
|
||||
@@ -229,6 +231,7 @@ export function createMainIpcRuntimeServiceDeps(
|
||||
getVisibleOverlayVisibility: params.getVisibleOverlayVisibility,
|
||||
onOverlayModalClosed: params.onOverlayModalClosed,
|
||||
onOverlayModalOpened: params.onOverlayModalOpened,
|
||||
onOverlayMouseInteractionChanged: params.onOverlayMouseInteractionChanged,
|
||||
onYoutubePickerResolve: params.onYoutubePickerResolve,
|
||||
openYomitanSettings: params.openYomitanSettings,
|
||||
quitApp: params.quitApp,
|
||||
@@ -371,6 +374,7 @@ export function createCliCommandRuntimeServiceDeps(
|
||||
ui: {
|
||||
openFirstRunSetup: params.ui.openFirstRunSetup,
|
||||
openYomitanSettings: params.ui.openYomitanSettings,
|
||||
openConfigSettingsWindow: params.ui.openConfigSettingsWindow,
|
||||
cycleSecondarySubMode: params.ui.cycleSecondarySubMode,
|
||||
openRuntimeOptionsPalette: params.ui.openRuntimeOptionsPalette,
|
||||
printHelp: params.ui.printHelp,
|
||||
|
||||
@@ -11,6 +11,7 @@ export interface OverlayVisibilityRuntimeDeps {
|
||||
getModalActive: () => boolean;
|
||||
getVisibleOverlayVisible: () => boolean;
|
||||
getForceMousePassthrough: () => boolean;
|
||||
getOverlayInteractionActive?: () => boolean;
|
||||
getWindowTracker: () => BaseWindowTracker | null;
|
||||
getLastKnownWindowsForegroundProcessName?: () => string | null;
|
||||
getWindowsOverlayProcessName?: () => string | null;
|
||||
@@ -49,6 +50,7 @@ export function createOverlayVisibilityRuntimeService(
|
||||
visibleOverlayVisible,
|
||||
modalActive: deps.getModalActive(),
|
||||
forceMousePassthrough,
|
||||
overlayInteractionActive: deps.getOverlayInteractionActive?.() ?? false,
|
||||
mainWindow,
|
||||
windowTracker,
|
||||
lastKnownWindowsForegroundProcessName: deps.getLastKnownWindowsForegroundProcessName?.(),
|
||||
|
||||
@@ -30,6 +30,36 @@ test('maybeProbeAnilistDuration updates state with probed duration', async () =>
|
||||
assert.equal(state.mediaDurationSec, 321);
|
||||
});
|
||||
|
||||
test('maybeProbeAnilistDuration force option bypasses retry interval', async () => {
|
||||
let state: AnilistMediaGuessRuntimeState = {
|
||||
mediaKey: '/tmp/video.mkv',
|
||||
mediaDurationSec: null,
|
||||
mediaGuess: null,
|
||||
mediaGuessPromise: null,
|
||||
lastDurationProbeAtMs: 1900,
|
||||
};
|
||||
let requestCount = 0;
|
||||
const probe = createMaybeProbeAnilistDurationHandler({
|
||||
getState: () => state,
|
||||
setState: (next) => {
|
||||
state = next;
|
||||
},
|
||||
durationRetryIntervalMs: 1000,
|
||||
now: () => 2000,
|
||||
requestMpvDuration: async () => {
|
||||
requestCount += 1;
|
||||
return 321;
|
||||
},
|
||||
logWarn: () => {},
|
||||
});
|
||||
|
||||
const duration = await probe('/tmp/video.mkv', { force: true });
|
||||
|
||||
assert.equal(duration, 321);
|
||||
assert.equal(requestCount, 1);
|
||||
assert.equal(state.mediaDurationSec, 321);
|
||||
});
|
||||
|
||||
test('ensureAnilistMediaGuess memoizes in-flight guess promise', async () => {
|
||||
let state: AnilistMediaGuessRuntimeState = {
|
||||
mediaKey: '/tmp/video.mkv',
|
||||
|
||||
@@ -14,6 +14,10 @@ type GuessAnilistMediaInfo = (
|
||||
mediaTitle: string | null,
|
||||
) => Promise<AnilistMediaGuess | null>;
|
||||
|
||||
type AnilistDurationProbeOptions = {
|
||||
force?: boolean;
|
||||
};
|
||||
|
||||
export function createMaybeProbeAnilistDurationHandler(deps: {
|
||||
getState: () => AnilistMediaGuessRuntimeState;
|
||||
setState: (state: AnilistMediaGuessRuntimeState) => void;
|
||||
@@ -22,7 +26,10 @@ export function createMaybeProbeAnilistDurationHandler(deps: {
|
||||
requestMpvDuration: () => Promise<unknown>;
|
||||
logWarn: (message: string, error: unknown) => void;
|
||||
}) {
|
||||
return async (mediaKey: string): Promise<number | null> => {
|
||||
return async (
|
||||
mediaKey: string,
|
||||
options: AnilistDurationProbeOptions = {},
|
||||
): Promise<number | null> => {
|
||||
const state = deps.getState();
|
||||
if (state.mediaKey !== mediaKey) {
|
||||
return null;
|
||||
@@ -34,7 +41,7 @@ export function createMaybeProbeAnilistDurationHandler(deps: {
|
||||
return state.mediaDurationSec;
|
||||
}
|
||||
const now = deps.now();
|
||||
if (now - state.lastDurationProbeAtMs < deps.durationRetryIntervalMs) {
|
||||
if (!options.force && now - state.lastDurationProbeAtMs < deps.durationRetryIntervalMs) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import test from 'node:test';
|
||||
import {
|
||||
createBuildGetAnilistMediaGuessRuntimeStateMainDepsHandler,
|
||||
createBuildGetCurrentAnilistMediaKeyMainDepsHandler,
|
||||
createBuildRecordAnilistMediaDurationMainDepsHandler,
|
||||
createBuildResetAnilistMediaGuessStateMainDepsHandler,
|
||||
createBuildResetAnilistMediaTrackingMainDepsHandler,
|
||||
createBuildSetAnilistMediaGuessRuntimeStateMainDepsHandler,
|
||||
@@ -70,3 +71,32 @@ test('reset anilist media guess state main deps builder maps callbacks', () => {
|
||||
deps.setMediaGuessPromise(null);
|
||||
assert.deepEqual(calls, ['guess', 'promise']);
|
||||
});
|
||||
|
||||
test('record anilist media duration main deps builder maps callbacks', () => {
|
||||
const calls: string[] = [];
|
||||
const state = {
|
||||
mediaKey: '/tmp/video.mkv',
|
||||
mediaDurationSec: null,
|
||||
mediaGuess: null,
|
||||
mediaGuessPromise: null,
|
||||
lastDurationProbeAtMs: 0,
|
||||
};
|
||||
const deps = createBuildRecordAnilistMediaDurationMainDepsHandler({
|
||||
getCurrentMediaKey: () => {
|
||||
calls.push('key');
|
||||
return '/tmp/video.mkv';
|
||||
},
|
||||
getState: () => {
|
||||
calls.push('get');
|
||||
return state;
|
||||
},
|
||||
setState: () => {
|
||||
calls.push('set');
|
||||
},
|
||||
})();
|
||||
|
||||
assert.equal(deps.getCurrentMediaKey(), '/tmp/video.mkv');
|
||||
deps.getState();
|
||||
deps.setState(state);
|
||||
assert.deepEqual(calls, ['key', 'get', 'set']);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type {
|
||||
createGetAnilistMediaGuessRuntimeStateHandler,
|
||||
createGetCurrentAnilistMediaKeyHandler,
|
||||
createRecordAnilistMediaDurationHandler,
|
||||
createResetAnilistMediaGuessStateHandler,
|
||||
createResetAnilistMediaTrackingHandler,
|
||||
createSetAnilistMediaGuessRuntimeStateHandler,
|
||||
@@ -18,6 +19,9 @@ type GetAnilistMediaGuessRuntimeStateMainDeps = Parameters<
|
||||
type SetAnilistMediaGuessRuntimeStateMainDeps = Parameters<
|
||||
typeof createSetAnilistMediaGuessRuntimeStateHandler
|
||||
>[0];
|
||||
type RecordAnilistMediaDurationMainDeps = Parameters<
|
||||
typeof createRecordAnilistMediaDurationHandler
|
||||
>[0];
|
||||
type ResetAnilistMediaGuessStateMainDeps = Parameters<
|
||||
typeof createResetAnilistMediaGuessStateHandler
|
||||
>[0];
|
||||
@@ -66,6 +70,16 @@ export function createBuildSetAnilistMediaGuessRuntimeStateMainDepsHandler(
|
||||
});
|
||||
}
|
||||
|
||||
export function createBuildRecordAnilistMediaDurationMainDepsHandler(
|
||||
deps: RecordAnilistMediaDurationMainDeps,
|
||||
) {
|
||||
return (): RecordAnilistMediaDurationMainDeps => ({
|
||||
getCurrentMediaKey: () => deps.getCurrentMediaKey(),
|
||||
getState: () => deps.getState(),
|
||||
setState: (state) => deps.setState(state),
|
||||
});
|
||||
}
|
||||
|
||||
export function createBuildResetAnilistMediaGuessStateMainDepsHandler(
|
||||
deps: ResetAnilistMediaGuessStateMainDeps,
|
||||
) {
|
||||
|
||||
@@ -3,6 +3,7 @@ import test from 'node:test';
|
||||
import {
|
||||
createGetAnilistMediaGuessRuntimeStateHandler,
|
||||
createGetCurrentAnilistMediaKeyHandler,
|
||||
createRecordAnilistMediaDurationHandler,
|
||||
createResetAnilistMediaGuessStateHandler,
|
||||
createResetAnilistMediaTrackingHandler,
|
||||
createSetAnilistMediaGuessRuntimeStateHandler,
|
||||
@@ -176,3 +177,57 @@ test('reset anilist media guess state clears guess and in-flight promise', () =>
|
||||
assert.equal(state.mediaDurationSec, 240);
|
||||
assert.equal(state.lastDurationProbeAtMs, 321);
|
||||
});
|
||||
|
||||
test('record anilist media duration stores observed mpv duration for current media', () => {
|
||||
const existingPromise = Promise.resolve(null);
|
||||
let state = {
|
||||
mediaKey: '/tmp/video.mkv' as string | null,
|
||||
mediaDurationSec: null as number | null,
|
||||
mediaGuess: { title: 'guess' } as { title: string } | null,
|
||||
mediaGuessPromise: existingPromise as Promise<unknown> | null,
|
||||
lastDurationProbeAtMs: 321,
|
||||
};
|
||||
|
||||
const recordDuration = createRecordAnilistMediaDurationHandler({
|
||||
getCurrentMediaKey: () => '/tmp/video.mkv',
|
||||
getState: () => state as never,
|
||||
setState: (nextState) => {
|
||||
state = nextState as never;
|
||||
},
|
||||
});
|
||||
|
||||
recordDuration(1440);
|
||||
|
||||
assert.equal(state.mediaDurationSec, 1440);
|
||||
assert.deepEqual(state.mediaGuess, { title: 'guess' });
|
||||
assert.equal(state.mediaGuessPromise, existingPromise);
|
||||
assert.equal(state.lastDurationProbeAtMs, 321);
|
||||
});
|
||||
|
||||
test('record anilist media duration resets stale media state when media key changes', () => {
|
||||
let state = {
|
||||
mediaKey: '/tmp/old.mkv' as string | null,
|
||||
mediaDurationSec: 120 as number | null,
|
||||
mediaGuess: { title: 'old' } as { title: string } | null,
|
||||
mediaGuessPromise: Promise.resolve(null) as Promise<unknown> | null,
|
||||
lastDurationProbeAtMs: 321,
|
||||
};
|
||||
|
||||
const recordDuration = createRecordAnilistMediaDurationHandler({
|
||||
getCurrentMediaKey: () => '/tmp/new.mkv',
|
||||
getState: () => state as never,
|
||||
setState: (nextState) => {
|
||||
state = nextState as never;
|
||||
},
|
||||
});
|
||||
|
||||
recordDuration(1440);
|
||||
|
||||
assert.deepEqual(state, {
|
||||
mediaKey: '/tmp/new.mkv',
|
||||
mediaDurationSec: 1440,
|
||||
mediaGuess: null,
|
||||
mediaGuessPromise: null,
|
||||
lastDurationProbeAtMs: 0,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -61,6 +61,37 @@ export function createSetAnilistMediaGuessRuntimeStateHandler(deps: {
|
||||
};
|
||||
}
|
||||
|
||||
export function createRecordAnilistMediaDurationHandler(deps: {
|
||||
getCurrentMediaKey: () => string | null;
|
||||
getState: () => AnilistMediaGuessRuntimeState;
|
||||
setState: (state: AnilistMediaGuessRuntimeState) => void;
|
||||
}) {
|
||||
return (durationSec: number): void => {
|
||||
if (!Number.isFinite(durationSec) || durationSec <= 0) {
|
||||
return;
|
||||
}
|
||||
const mediaKey = deps.getCurrentMediaKey();
|
||||
if (!mediaKey) {
|
||||
return;
|
||||
}
|
||||
const state = deps.getState();
|
||||
if (state.mediaKey === mediaKey) {
|
||||
deps.setState({
|
||||
...state,
|
||||
mediaDurationSec: durationSec,
|
||||
});
|
||||
return;
|
||||
}
|
||||
deps.setState({
|
||||
mediaKey,
|
||||
mediaDurationSec: durationSec,
|
||||
mediaGuess: null,
|
||||
mediaGuessPromise: null,
|
||||
lastDurationProbeAtMs: 0,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export function createResetAnilistMediaGuessStateHandler(deps: {
|
||||
setMediaGuess: (value: AnilistMediaGuessRuntimeState['mediaGuess']) => void;
|
||||
setMediaGuessPromise: (value: AnilistMediaGuessRuntimeState['mediaGuessPromise']) => void;
|
||||
|
||||
@@ -13,7 +13,10 @@ test('process next anilist retry update main deps builder maps callbacks', async
|
||||
setLastAttemptAt: () => calls.push('attempt'),
|
||||
setLastError: () => calls.push('error'),
|
||||
refreshAnilistClientSecretState: async () => 'token',
|
||||
updateAnilistPostWatchProgress: async () => ({ status: 'updated', message: 'ok' }),
|
||||
updateAnilistPostWatchProgress: async (_accessToken, _title, _episode, season) => ({
|
||||
status: 'updated',
|
||||
message: `ok:${season}`,
|
||||
}),
|
||||
markSuccess: () => calls.push('success'),
|
||||
rememberAttemptedUpdateKey: () => calls.push('remember'),
|
||||
markFailure: () => calls.push('failure'),
|
||||
@@ -26,9 +29,9 @@ test('process next anilist retry update main deps builder maps callbacks', async
|
||||
deps.setLastAttemptAt(1);
|
||||
deps.setLastError('x');
|
||||
assert.equal(await deps.refreshAnilistClientSecretState(), 'token');
|
||||
assert.deepEqual(await deps.updateAnilistPostWatchProgress('token', 't', 1), {
|
||||
assert.deepEqual(await deps.updateAnilistPostWatchProgress('token', 't', 1, 2), {
|
||||
status: 'updated',
|
||||
message: 'ok',
|
||||
message: 'ok:2',
|
||||
});
|
||||
deps.markSuccess('k');
|
||||
deps.rememberAttemptedUpdateKey('k');
|
||||
@@ -58,16 +61,22 @@ test('maybe run anilist post watch update main deps builder maps callbacks', asy
|
||||
getTrackedMediaKey: () => 'media',
|
||||
resetTrackedMedia: () => calls.push('reset'),
|
||||
getWatchedSeconds: () => 100,
|
||||
maybeProbeAnilistDuration: async () => 120,
|
||||
maybeProbeAnilistDuration: async (_mediaKey, options) => {
|
||||
calls.push(`probe:${options?.force === true}`);
|
||||
return 120;
|
||||
},
|
||||
ensureAnilistMediaGuess: async () => ({ title: 'x', season: null, episode: 1 }),
|
||||
hasAttemptedUpdateKey: () => false,
|
||||
processNextAnilistRetryUpdate: async () => ({ ok: true, message: 'ok' }),
|
||||
refreshAnilistClientSecretState: async () => 'token',
|
||||
enqueueRetry: () => calls.push('enqueue'),
|
||||
enqueueRetry: (_key, _title, _episode, season) => calls.push(`enqueue:${season}`),
|
||||
markRetryFailure: () => calls.push('retry-fail'),
|
||||
markRetrySuccess: () => calls.push('retry-ok'),
|
||||
refreshRetryQueueState: () => calls.push('refresh'),
|
||||
updateAnilistPostWatchProgress: async () => ({ status: 'updated', message: 'done' }),
|
||||
updateAnilistPostWatchProgress: async (_accessToken, _title, _episode, season) => ({
|
||||
status: 'updated',
|
||||
message: `done:${season}`,
|
||||
}),
|
||||
rememberAttemptedUpdateKey: () => calls.push('remember'),
|
||||
showMpvOsd: () => calls.push('osd'),
|
||||
logInfo: (message) => calls.push(`info:${message}`),
|
||||
@@ -84,7 +93,7 @@ test('maybe run anilist post watch update main deps builder maps callbacks', asy
|
||||
assert.equal(deps.getTrackedMediaKey(), 'media');
|
||||
deps.resetTrackedMedia('media');
|
||||
assert.equal(deps.getWatchedSeconds(), 100);
|
||||
assert.equal(await deps.maybeProbeAnilistDuration('media'), 120);
|
||||
assert.equal(await deps.maybeProbeAnilistDuration('media', { force: true }), 120);
|
||||
assert.deepEqual(await deps.ensureAnilistMediaGuess('media'), {
|
||||
title: 'x',
|
||||
season: null,
|
||||
@@ -93,13 +102,13 @@ test('maybe run anilist post watch update main deps builder maps callbacks', asy
|
||||
assert.equal(deps.hasAttemptedUpdateKey('k'), false);
|
||||
assert.deepEqual(await deps.processNextAnilistRetryUpdate(), { ok: true, message: 'ok' });
|
||||
assert.equal(await deps.refreshAnilistClientSecretState(), 'token');
|
||||
deps.enqueueRetry('k', 't', 1);
|
||||
deps.enqueueRetry('k', 't', 1, 2);
|
||||
deps.markRetryFailure('k', 'bad');
|
||||
deps.markRetrySuccess('k');
|
||||
deps.refreshRetryQueueState();
|
||||
assert.deepEqual(await deps.updateAnilistPostWatchProgress('token', 't', 1), {
|
||||
assert.deepEqual(await deps.updateAnilistPostWatchProgress('token', 't', 1, 2), {
|
||||
status: 'updated',
|
||||
message: 'done',
|
||||
message: 'done:2',
|
||||
});
|
||||
deps.rememberAttemptedUpdateKey('k');
|
||||
deps.showMpvOsd('ok');
|
||||
@@ -110,7 +119,8 @@ test('maybe run anilist post watch update main deps builder maps callbacks', asy
|
||||
assert.deepEqual(calls, [
|
||||
'in-flight',
|
||||
'reset',
|
||||
'enqueue',
|
||||
'probe:true',
|
||||
'enqueue:2',
|
||||
'retry-fail',
|
||||
'retry-ok',
|
||||
'refresh',
|
||||
|
||||
@@ -19,8 +19,12 @@ export function createBuildProcessNextAnilistRetryUpdateMainDepsHandler(
|
||||
setLastAttemptAt: (value: number) => deps.setLastAttemptAt(value),
|
||||
setLastError: (value: string | null) => deps.setLastError(value),
|
||||
refreshAnilistClientSecretState: () => deps.refreshAnilistClientSecretState(),
|
||||
updateAnilistPostWatchProgress: (accessToken: string, title: string, episode: number) =>
|
||||
deps.updateAnilistPostWatchProgress(accessToken, title, episode),
|
||||
updateAnilistPostWatchProgress: (
|
||||
accessToken: string,
|
||||
title: string,
|
||||
episode: number,
|
||||
season?: number | null,
|
||||
) => deps.updateAnilistPostWatchProgress(accessToken, title, episode, season),
|
||||
markSuccess: (key: string) => deps.markSuccess(key),
|
||||
rememberAttemptedUpdateKey: (key: string) => deps.rememberAttemptedUpdateKey(key),
|
||||
markFailure: (key: string, message: string) => deps.markFailure(key, message),
|
||||
@@ -42,18 +46,23 @@ export function createBuildMaybeRunAnilistPostWatchUpdateMainDepsHandler(
|
||||
getTrackedMediaKey: () => deps.getTrackedMediaKey(),
|
||||
resetTrackedMedia: (mediaKey) => deps.resetTrackedMedia(mediaKey),
|
||||
getWatchedSeconds: () => deps.getWatchedSeconds(),
|
||||
maybeProbeAnilistDuration: (mediaKey: string) => deps.maybeProbeAnilistDuration(mediaKey),
|
||||
maybeProbeAnilistDuration: (mediaKey: string, options) =>
|
||||
deps.maybeProbeAnilistDuration(mediaKey, options),
|
||||
ensureAnilistMediaGuess: (mediaKey: string) => deps.ensureAnilistMediaGuess(mediaKey),
|
||||
hasAttemptedUpdateKey: (key: string) => deps.hasAttemptedUpdateKey(key),
|
||||
processNextAnilistRetryUpdate: () => deps.processNextAnilistRetryUpdate(),
|
||||
refreshAnilistClientSecretState: () => deps.refreshAnilistClientSecretState(),
|
||||
enqueueRetry: (key: string, title: string, episode: number) =>
|
||||
deps.enqueueRetry(key, title, episode),
|
||||
enqueueRetry: (key: string, title: string, episode: number, season?: number | null) =>
|
||||
deps.enqueueRetry(key, title, episode, season),
|
||||
markRetryFailure: (key: string, message: string) => deps.markRetryFailure(key, message),
|
||||
markRetrySuccess: (key: string) => deps.markRetrySuccess(key),
|
||||
refreshRetryQueueState: () => deps.refreshRetryQueueState(),
|
||||
updateAnilistPostWatchProgress: (accessToken: string, title: string, episode: number) =>
|
||||
deps.updateAnilistPostWatchProgress(accessToken, title, episode),
|
||||
updateAnilistPostWatchProgress: (
|
||||
accessToken: string,
|
||||
title: string,
|
||||
episode: number,
|
||||
season?: number | null,
|
||||
) => deps.updateAnilistPostWatchProgress(accessToken, title, episode, season),
|
||||
rememberAttemptedUpdateKey: (key: string) => deps.rememberAttemptedUpdateKey(key),
|
||||
showMpvOsd: (message: string) => deps.showMpvOsd(message),
|
||||
logInfo: (message: string) => deps.logInfo(message),
|
||||
|
||||
@@ -20,12 +20,15 @@ test('rememberAnilistAttemptedUpdateKey evicts oldest beyond max size', () => {
|
||||
test('createProcessNextAnilistRetryUpdateHandler handles successful retry', async () => {
|
||||
const calls: string[] = [];
|
||||
const handler = createProcessNextAnilistRetryUpdateHandler({
|
||||
nextReady: () => ({ key: 'k1', title: 'Show', season: null, episode: 1 }),
|
||||
nextReady: () => ({ key: 'k1', title: 'Show', season: 2, episode: 1 }),
|
||||
refreshRetryQueueState: () => calls.push('refresh'),
|
||||
setLastAttemptAt: () => calls.push('attempt'),
|
||||
setLastError: (value) => calls.push(`error:${value ?? 'null'}`),
|
||||
refreshAnilistClientSecretState: async () => 'token',
|
||||
updateAnilistPostWatchProgress: async () => ({ status: 'updated', message: 'updated ok' }),
|
||||
updateAnilistPostWatchProgress: async (_accessToken, _title, _episode, season) => ({
|
||||
status: 'updated',
|
||||
message: `updated ok:${season}`,
|
||||
}),
|
||||
markSuccess: () => calls.push('success'),
|
||||
rememberAttemptedUpdateKey: () => calls.push('remember'),
|
||||
markFailure: () => calls.push('failure'),
|
||||
@@ -34,7 +37,7 @@ test('createProcessNextAnilistRetryUpdateHandler handles successful retry', asyn
|
||||
});
|
||||
|
||||
const result = await handler();
|
||||
assert.deepEqual(result, { ok: true, message: 'updated ok' });
|
||||
assert.deepEqual(result, { ok: true, message: 'updated ok:2' });
|
||||
assert.ok(calls.includes('success'));
|
||||
assert.ok(calls.includes('remember'));
|
||||
});
|
||||
@@ -93,7 +96,7 @@ test('createMaybeRunAnilistPostWatchUpdateHandler force-runs manual watched upda
|
||||
calls.push('probe');
|
||||
return 1000;
|
||||
},
|
||||
ensureAnilistMediaGuess: async () => ({ title: 'Show', episode: 3 }),
|
||||
ensureAnilistMediaGuess: async () => ({ title: 'Show', season: null, episode: 3 }),
|
||||
hasAttemptedUpdateKey: () => false,
|
||||
processNextAnilistRetryUpdate: async () => ({ ok: true, message: 'noop' }),
|
||||
refreshAnilistClientSecretState: async () => 'token',
|
||||
@@ -121,6 +124,106 @@ test('createMaybeRunAnilistPostWatchUpdateHandler force-runs manual watched upda
|
||||
assert.ok(calls.includes('osd:updated ok'));
|
||||
});
|
||||
|
||||
test('createMaybeRunAnilistPostWatchUpdateHandler shows permanent AniList update errors without queueing retry', async () => {
|
||||
const calls: string[] = [];
|
||||
const attemptedKeys = new Set<string>();
|
||||
let updateCalls = 0;
|
||||
const handler = createMaybeRunAnilistPostWatchUpdateHandler({
|
||||
getInFlight: () => false,
|
||||
setInFlight: (value) => calls.push(`inflight:${value}`),
|
||||
getResolvedConfig: () => ({}),
|
||||
isAnilistTrackingEnabled: () => true,
|
||||
getCurrentMediaKey: () => '/tmp/video.mkv',
|
||||
hasMpvClient: () => true,
|
||||
getTrackedMediaKey: () => '/tmp/video.mkv',
|
||||
resetTrackedMedia: () => {},
|
||||
getWatchedSeconds: () => 1000,
|
||||
maybeProbeAnilistDuration: async () => 1000,
|
||||
ensureAnilistMediaGuess: async () => ({ title: 'Show', season: null, episode: 2 }),
|
||||
hasAttemptedUpdateKey: (key) => attemptedKeys.has(key),
|
||||
processNextAnilistRetryUpdate: async () => ({ ok: true, message: 'noop' }),
|
||||
refreshAnilistClientSecretState: async () => 'token',
|
||||
enqueueRetry: () => calls.push('enqueue'),
|
||||
markRetryFailure: () => calls.push('mark-failure'),
|
||||
markRetrySuccess: () => calls.push('mark-success'),
|
||||
refreshRetryQueueState: () => calls.push('refresh'),
|
||||
updateAnilistPostWatchProgress: async () => {
|
||||
updateCalls += 1;
|
||||
return {
|
||||
status: 'error',
|
||||
retryable: false,
|
||||
message:
|
||||
'AniList update not possible: Show is not in your AniList Planning or Watching list.',
|
||||
};
|
||||
},
|
||||
rememberAttemptedUpdateKey: (key) => {
|
||||
attemptedKeys.add(key);
|
||||
calls.push(`remember:${key}`);
|
||||
},
|
||||
showMpvOsd: (message) => calls.push(`osd:${message}`),
|
||||
logInfo: (message) => calls.push(`info:${message}`),
|
||||
logWarn: (message) => calls.push(`warn:${message}`),
|
||||
minWatchSeconds: 600,
|
||||
minWatchRatio: 0.85,
|
||||
});
|
||||
|
||||
await handler();
|
||||
await handler();
|
||||
|
||||
assert.equal(updateCalls, 1);
|
||||
assert.equal(calls.includes('enqueue'), false);
|
||||
assert.equal(calls.includes('mark-failure'), false);
|
||||
assert.ok(calls.some((call) => call.startsWith('remember:')));
|
||||
assert.ok(calls.includes('refresh'));
|
||||
assert.ok(calls.some((call) => call.startsWith('osd:AniList update not possible')));
|
||||
assert.ok(calls.some((call) => call.startsWith('warn:AniList update not possible')));
|
||||
});
|
||||
|
||||
test('createMaybeRunAnilistPostWatchUpdateHandler uses provided watched seconds from time-position events', async () => {
|
||||
const calls: string[] = [];
|
||||
let durationProbeOptions: unknown = null;
|
||||
const handler = createMaybeRunAnilistPostWatchUpdateHandler({
|
||||
getInFlight: () => false,
|
||||
setInFlight: (value) => calls.push(`inflight:${value}`),
|
||||
getResolvedConfig: () => ({}),
|
||||
isAnilistTrackingEnabled: () => true,
|
||||
getCurrentMediaKey: () => '/tmp/video.mkv',
|
||||
hasMpvClient: () => true,
|
||||
getTrackedMediaKey: () => '/tmp/video.mkv',
|
||||
resetTrackedMedia: () => {},
|
||||
getWatchedSeconds: () => 0,
|
||||
maybeProbeAnilistDuration: async (_mediaKey, options) => {
|
||||
durationProbeOptions = options;
|
||||
return 1000;
|
||||
},
|
||||
ensureAnilistMediaGuess: async () => ({ title: 'Show', season: 2, episode: 8 }),
|
||||
hasAttemptedUpdateKey: () => false,
|
||||
processNextAnilistRetryUpdate: async () => ({ ok: true, message: 'noop' }),
|
||||
refreshAnilistClientSecretState: async () => 'token',
|
||||
enqueueRetry: () => calls.push('enqueue'),
|
||||
markRetryFailure: () => calls.push('mark-failure'),
|
||||
markRetrySuccess: () => calls.push('mark-success'),
|
||||
refreshRetryQueueState: () => calls.push('refresh'),
|
||||
updateAnilistPostWatchProgress: async (_accessToken, _title, _episode, season) => {
|
||||
calls.push(`update:${season}`);
|
||||
return { status: 'updated', message: 'updated ok' };
|
||||
},
|
||||
rememberAttemptedUpdateKey: () => calls.push('remember'),
|
||||
showMpvOsd: (message) => calls.push(`osd:${message}`),
|
||||
logInfo: (message) => calls.push(`info:${message}`),
|
||||
logWarn: (message) => calls.push(`warn:${message}`),
|
||||
minWatchSeconds: 600,
|
||||
minWatchRatio: 0.85,
|
||||
});
|
||||
|
||||
await handler({ watchedSeconds: 850 });
|
||||
|
||||
assert.deepEqual(durationProbeOptions, { force: true });
|
||||
assert.ok(calls.includes('update:2'));
|
||||
assert.ok(calls.includes('remember'));
|
||||
assert.ok(calls.includes('osd:updated ok'));
|
||||
});
|
||||
|
||||
test('createMaybeRunAnilistPostWatchUpdateHandler blocks concurrent runs before async gating', async () => {
|
||||
const calls: string[] = [];
|
||||
let inFlight = false;
|
||||
|
||||
@@ -2,22 +2,30 @@ import { isYoutubeMediaPath } from './youtube-playback';
|
||||
|
||||
type AnilistGuess = {
|
||||
title: string;
|
||||
season: number | null;
|
||||
episode: number | null;
|
||||
};
|
||||
|
||||
type AnilistUpdateResult = {
|
||||
status: 'updated' | 'skipped' | 'error';
|
||||
message: string;
|
||||
retryable?: boolean;
|
||||
};
|
||||
|
||||
type RetryQueueItem = {
|
||||
key: string;
|
||||
title: string;
|
||||
season?: number | null;
|
||||
episode: number;
|
||||
};
|
||||
|
||||
type AnilistPostWatchRunOptions = {
|
||||
force?: boolean;
|
||||
watchedSeconds?: number;
|
||||
};
|
||||
|
||||
type AnilistDurationProbeOptions = {
|
||||
force?: boolean;
|
||||
};
|
||||
|
||||
export function buildAnilistAttemptKey(mediaKey: string, episode: number): string {
|
||||
@@ -49,6 +57,7 @@ export function createProcessNextAnilistRetryUpdateHandler(deps: {
|
||||
accessToken: string,
|
||||
title: string,
|
||||
episode: number,
|
||||
season?: number | null,
|
||||
) => Promise<AnilistUpdateResult>;
|
||||
markSuccess: (key: string) => void;
|
||||
rememberAttemptedUpdateKey: (key: string) => void;
|
||||
@@ -74,6 +83,7 @@ export function createProcessNextAnilistRetryUpdateHandler(deps: {
|
||||
accessToken,
|
||||
queued.title,
|
||||
queued.episode,
|
||||
queued.season ?? null,
|
||||
);
|
||||
if (result.status === 'updated' || result.status === 'skipped') {
|
||||
deps.markSuccess(queued.key);
|
||||
@@ -101,12 +111,15 @@ export function createMaybeRunAnilistPostWatchUpdateHandler(deps: {
|
||||
getTrackedMediaKey: () => string | null;
|
||||
resetTrackedMedia: (mediaKey: string | null) => void;
|
||||
getWatchedSeconds: () => number;
|
||||
maybeProbeAnilistDuration: (mediaKey: string) => Promise<number | null>;
|
||||
maybeProbeAnilistDuration: (
|
||||
mediaKey: string,
|
||||
options?: AnilistDurationProbeOptions,
|
||||
) => Promise<number | null>;
|
||||
ensureAnilistMediaGuess: (mediaKey: string) => Promise<AnilistGuess | null>;
|
||||
hasAttemptedUpdateKey: (key: string) => boolean;
|
||||
processNextAnilistRetryUpdate: () => Promise<{ ok: boolean; message: string }>;
|
||||
refreshAnilistClientSecretState: () => Promise<string | null>;
|
||||
enqueueRetry: (key: string, title: string, episode: number) => void;
|
||||
enqueueRetry: (key: string, title: string, episode: number, season?: number | null) => void;
|
||||
markRetryFailure: (key: string, message: string) => void;
|
||||
markRetrySuccess: (key: string) => void;
|
||||
refreshRetryQueueState: () => void;
|
||||
@@ -114,6 +127,7 @@ export function createMaybeRunAnilistPostWatchUpdateHandler(deps: {
|
||||
accessToken: string,
|
||||
title: string,
|
||||
episode: number,
|
||||
season?: number | null,
|
||||
) => Promise<AnilistUpdateResult>;
|
||||
rememberAttemptedUpdateKey: (key: string) => void;
|
||||
showMpvOsd: (message: string) => void;
|
||||
@@ -146,7 +160,10 @@ export function createMaybeRunAnilistPostWatchUpdateHandler(deps: {
|
||||
|
||||
let watchedSeconds = 0;
|
||||
if (!force) {
|
||||
watchedSeconds = deps.getWatchedSeconds();
|
||||
watchedSeconds =
|
||||
typeof options.watchedSeconds === 'number' && Number.isFinite(options.watchedSeconds)
|
||||
? options.watchedSeconds
|
||||
: deps.getWatchedSeconds();
|
||||
if (!Number.isFinite(watchedSeconds) || watchedSeconds < deps.minWatchSeconds) {
|
||||
return;
|
||||
}
|
||||
@@ -155,7 +172,10 @@ export function createMaybeRunAnilistPostWatchUpdateHandler(deps: {
|
||||
deps.setInFlight(true);
|
||||
try {
|
||||
if (!force) {
|
||||
const duration = await deps.maybeProbeAnilistDuration(mediaKey);
|
||||
const duration = await deps.maybeProbeAnilistDuration(mediaKey, {
|
||||
force:
|
||||
typeof options.watchedSeconds === 'number' && Number.isFinite(options.watchedSeconds),
|
||||
});
|
||||
if (!duration || duration <= 0) {
|
||||
return;
|
||||
}
|
||||
@@ -181,7 +201,7 @@ export function createMaybeRunAnilistPostWatchUpdateHandler(deps: {
|
||||
|
||||
const accessToken = await deps.refreshAnilistClientSecretState();
|
||||
if (!accessToken) {
|
||||
deps.enqueueRetry(attemptKey, guess.title, guess.episode);
|
||||
deps.enqueueRetry(attemptKey, guess.title, guess.episode, guess.season);
|
||||
deps.markRetryFailure(attemptKey, 'cannot authenticate without anilist.accessToken');
|
||||
deps.refreshRetryQueueState();
|
||||
deps.showMpvOsd('AniList: access token not configured');
|
||||
@@ -192,6 +212,7 @@ export function createMaybeRunAnilistPostWatchUpdateHandler(deps: {
|
||||
accessToken,
|
||||
guess.title,
|
||||
guess.episode,
|
||||
guess.season,
|
||||
);
|
||||
if (result.status === 'updated') {
|
||||
deps.rememberAttemptedUpdateKey(attemptKey);
|
||||
@@ -209,7 +230,15 @@ export function createMaybeRunAnilistPostWatchUpdateHandler(deps: {
|
||||
return;
|
||||
}
|
||||
|
||||
deps.enqueueRetry(attemptKey, guess.title, guess.episode);
|
||||
if (result.retryable === false) {
|
||||
deps.rememberAttemptedUpdateKey(attemptKey);
|
||||
deps.refreshRetryQueueState();
|
||||
deps.showMpvOsd(result.message);
|
||||
deps.logWarn(result.message);
|
||||
return;
|
||||
}
|
||||
|
||||
deps.enqueueRetry(attemptKey, guess.title, guess.episode, guess.season);
|
||||
deps.markRetryFailure(attemptKey, result.message);
|
||||
deps.refreshRetryQueueState();
|
||||
deps.showMpvOsd(`AniList: ${result.message}`);
|
||||
|
||||
@@ -84,8 +84,7 @@ export function createBuildOpenYomitanSettingsMainDepsHandler<TYomitanExt, TWind
|
||||
: {}),
|
||||
...(deps.getYomitanExtensionLoadInFlight
|
||||
? {
|
||||
getYomitanExtensionLoadInFlight: () =>
|
||||
deps.getYomitanExtensionLoadInFlight?.() ?? null,
|
||||
getYomitanExtensionLoadInFlight: () => deps.getYomitanExtensionLoadInFlight?.() ?? null,
|
||||
}
|
||||
: {}),
|
||||
openYomitanSettingsWindow: (params: {
|
||||
|
||||
@@ -70,6 +70,7 @@ test('build cli command context deps maps handlers and values', () => {
|
||||
calls.push('run-youtube-playback');
|
||||
},
|
||||
openYomitanSettings: () => calls.push('yomitan'),
|
||||
openConfigSettingsWindow: () => calls.push('config-settings'),
|
||||
cycleSecondarySubMode: () => calls.push('cycle-secondary'),
|
||||
openRuntimeOptionsPalette: () => calls.push('runtime-options'),
|
||||
printHelp: () => calls.push('help'),
|
||||
|
||||
@@ -44,6 +44,7 @@ export function createBuildCliCommandContextDepsHandler(deps: {
|
||||
runUpdateCommand: CliCommandContextFactoryDeps['runUpdateCommand'];
|
||||
runYoutubePlaybackFlow: CliCommandContextFactoryDeps['runYoutubePlaybackFlow'];
|
||||
openYomitanSettings: () => void;
|
||||
openConfigSettingsWindow: () => void;
|
||||
cycleSecondarySubMode: () => void;
|
||||
openRuntimeOptionsPalette: () => void;
|
||||
printHelp: () => void;
|
||||
@@ -99,6 +100,7 @@ export function createBuildCliCommandContextDepsHandler(deps: {
|
||||
runUpdateCommand: deps.runUpdateCommand,
|
||||
runYoutubePlaybackFlow: deps.runYoutubePlaybackFlow,
|
||||
openYomitanSettings: deps.openYomitanSettings,
|
||||
openConfigSettingsWindow: deps.openConfigSettingsWindow,
|
||||
cycleSecondarySubMode: deps.cycleSecondarySubMode,
|
||||
openRuntimeOptionsPalette: deps.openRuntimeOptionsPalette,
|
||||
printHelp: deps.printHelp,
|
||||
|
||||
@@ -74,6 +74,7 @@ test('cli command context factory composes main deps and context handlers', () =
|
||||
runUpdateCommand: async () => {},
|
||||
runYoutubePlaybackFlow: async () => {},
|
||||
openYomitanSettings: () => {},
|
||||
openConfigSettingsWindow: () => {},
|
||||
cycleSecondarySubMode: () => {},
|
||||
openRuntimeOptionsPalette: () => {},
|
||||
printHelp: () => {},
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user