mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-02-27 18:22:41 -08:00
Compare commits
10 Commits
v0.1.0
...
refactor-m
| Author | SHA1 | Date | |
|---|---|---|---|
|
21c76c5097
|
|||
|
d10fda7136
|
|||
|
|
60cd1c8ac2 | ||
|
|
3da9d9e0e0 | ||
|
6eda768261
|
|||
|
ceea10cba1
|
|||
|
9d73971f3b
|
|||
|
a2735eaedc
|
|||
|
b989508ece
|
|||
|
978cb8c401
|
2
.gitignore
vendored
2
.gitignore
vendored
@@ -8,6 +8,8 @@ release/
|
|||||||
|
|
||||||
# Launcher build artifact (produced by make build-launcher)
|
# Launcher build artifact (produced by make build-launcher)
|
||||||
subminer
|
subminer
|
||||||
|
!plugin/subminer/
|
||||||
|
!plugin/subminer/*.lua
|
||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
*.log
|
*.log
|
||||||
|
|||||||
19
Makefile
19
Makefile
@@ -1,10 +1,9 @@
|
|||||||
.PHONY: help deps build build-launcher install build-linux build-macos build-macos-unsigned clean install-linux install-macos install-plugin uninstall uninstall-linux uninstall-macos print-dirs pretty ensure-bun generate-config generate-example-config docs-dev docs docs-preview dev-start dev-start-macos dev-toggle dev-stop
|
.PHONY: help deps build build-launcher install build-linux build-macos build-macos-unsigned clean install-linux install-macos install-plugin uninstall uninstall-linux uninstall-macos uninstall-plugin print-dirs pretty ensure-bun generate-config generate-example-config docs-dev docs docs-preview dev-start dev-start-macos dev-toggle dev-stop
|
||||||
|
|
||||||
APP_NAME := subminer
|
APP_NAME := subminer
|
||||||
THEME_SOURCE := assets/themes/subminer.rasi
|
THEME_SOURCE := assets/themes/subminer.rasi
|
||||||
LAUNCHER_OUT := dist/launcher/$(APP_NAME)
|
LAUNCHER_OUT := dist/launcher/$(APP_NAME)
|
||||||
THEME_FILE := subminer.rasi
|
THEME_FILE := subminer.rasi
|
||||||
PLUGIN_LUA := plugin/subminer.lua
|
|
||||||
PLUGIN_CONF := plugin/subminer.conf
|
PLUGIN_CONF := plugin/subminer.conf
|
||||||
|
|
||||||
# Default install prefix for the wrapper script.
|
# Default install prefix for the wrapper script.
|
||||||
@@ -67,6 +66,7 @@ help:
|
|||||||
" deps Install JS dependencies (root + texthooker-ui)" \
|
" deps Install JS dependencies (root + texthooker-ui)" \
|
||||||
" uninstall-linux Remove Linux install artifacts" \
|
" uninstall-linux Remove Linux install artifacts" \
|
||||||
" uninstall-macos Remove macOS install artifacts" \
|
" uninstall-macos Remove macOS install artifacts" \
|
||||||
|
" uninstall-plugin Remove mpv Lua plugin and plugin config" \
|
||||||
" print-dirs Show resolved install locations" \
|
" print-dirs Show resolved install locations" \
|
||||||
"" \
|
"" \
|
||||||
"Variables:" \
|
"Variables:" \
|
||||||
@@ -218,20 +218,25 @@ install-macos: build-launcher
|
|||||||
install-plugin:
|
install-plugin:
|
||||||
@printf '%s\n' "[INFO] Installing mpv plugin artifacts"
|
@printf '%s\n' "[INFO] Installing mpv plugin artifacts"
|
||||||
@install -d "$(MPV_SCRIPTS_DIR)"
|
@install -d "$(MPV_SCRIPTS_DIR)"
|
||||||
|
@install -d "$(MPV_SCRIPTS_DIR)/subminer"
|
||||||
@install -d "$(MPV_SCRIPT_OPTS_DIR)"
|
@install -d "$(MPV_SCRIPT_OPTS_DIR)"
|
||||||
@install -m 0644 "./$(PLUGIN_LUA)" "$(MPV_SCRIPTS_DIR)/subminer.lua"
|
@cp -R ./plugin/subminer/. "$(MPV_SCRIPTS_DIR)/subminer/"
|
||||||
@install -m 0644 "./$(PLUGIN_CONF)" "$(MPV_SCRIPT_OPTS_DIR)/subminer.conf"
|
@install -m 0644 "./$(PLUGIN_CONF)" "$(MPV_SCRIPT_OPTS_DIR)/subminer.conf"
|
||||||
@printf '%s\n' "Installed to:" " $(MPV_SCRIPTS_DIR)/subminer.lua" " $(MPV_SCRIPT_OPTS_DIR)/subminer.conf"
|
@printf '%s\n' "Installed to:" " $(MPV_SCRIPTS_DIR)/subminer/main.lua" " $(MPV_SCRIPTS_DIR)/subminer/" " $(MPV_SCRIPT_OPTS_DIR)/subminer.conf"
|
||||||
|
|
||||||
# Uninstall behavior kept unchanged by default.
|
|
||||||
uninstall: uninstall-linux
|
uninstall: uninstall-linux
|
||||||
|
|
||||||
uninstall-linux:
|
uninstall-plugin:
|
||||||
|
@rm -rf "$(MPV_SCRIPTS_DIR)/subminer"
|
||||||
|
@rm -f "$(MPV_SCRIPT_OPTS_DIR)/subminer.conf"
|
||||||
|
@printf '%s\n' "Removed:" " $(MPV_SCRIPTS_DIR)/subminer/" " $(MPV_SCRIPT_OPTS_DIR)/subminer.conf"
|
||||||
|
|
||||||
|
uninstall-linux: uninstall-plugin
|
||||||
@rm -f "$(BINDIR)/subminer" "$(BINDIR)/SubMiner.AppImage"
|
@rm -f "$(BINDIR)/subminer" "$(BINDIR)/SubMiner.AppImage"
|
||||||
@rm -f "$(LINUX_DATA_DIR)/themes/$(THEME_FILE)"
|
@rm -f "$(LINUX_DATA_DIR)/themes/$(THEME_FILE)"
|
||||||
@printf '%s\n' "Removed:" " $(BINDIR)/subminer" " $(BINDIR)/SubMiner.AppImage" " $(LINUX_DATA_DIR)/themes/$(THEME_FILE)"
|
@printf '%s\n' "Removed:" " $(BINDIR)/subminer" " $(BINDIR)/SubMiner.AppImage" " $(LINUX_DATA_DIR)/themes/$(THEME_FILE)"
|
||||||
|
|
||||||
uninstall-macos:
|
uninstall-macos: uninstall-plugin
|
||||||
@rm -f "$(BINDIR)/subminer"
|
@rm -f "$(BINDIR)/subminer"
|
||||||
@rm -f "$(MACOS_DATA_DIR)/themes/$(THEME_FILE)"
|
@rm -f "$(MACOS_DATA_DIR)/themes/$(THEME_FILE)"
|
||||||
@rm -rf "$(MACOS_APP_DEST)"
|
@rm -rf "$(MACOS_APP_DEST)"
|
||||||
|
|||||||
@@ -57,7 +57,8 @@ chmod +x ~/.local/bin/subminer
|
|||||||
```bash
|
```bash
|
||||||
wget https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer-assets.tar.gz -O /tmp/subminer-assets.tar.gz
|
wget https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer-assets.tar.gz -O /tmp/subminer-assets.tar.gz
|
||||||
tar -xzf /tmp/subminer-assets.tar.gz -C /tmp
|
tar -xzf /tmp/subminer-assets.tar.gz -C /tmp
|
||||||
cp /tmp/plugin/subminer.lua ~/.config/mpv/scripts/
|
mkdir -p ~/.config/mpv/scripts/subminer
|
||||||
|
cp -R /tmp/plugin/subminer/. ~/.config/mpv/scripts/subminer/
|
||||||
cp /tmp/plugin/subminer.conf ~/.config/mpv/script-opts/
|
cp /tmp/plugin/subminer.conf ~/.config/mpv/script-opts/
|
||||||
mkdir -p ~/.config/SubMiner && cp /tmp/config.example.jsonc ~/.config/SubMiner/config.jsonc
|
mkdir -p ~/.config/SubMiner && cp /tmp/config.example.jsonc ~/.config/SubMiner/config.jsonc
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -2,9 +2,11 @@ project_name: "SubMiner"
|
|||||||
default_status: "To Do"
|
default_status: "To Do"
|
||||||
statuses: ["To Do", "In Progress", "Done"]
|
statuses: ["To Do", "In Progress", "Done"]
|
||||||
labels: []
|
labels: []
|
||||||
|
definition_of_done: []
|
||||||
date_format: yyyy-mm-dd
|
date_format: yyyy-mm-dd
|
||||||
max_column_width: 20
|
max_column_width: 20
|
||||||
auto_open_browser: true
|
default_editor: "nvim"
|
||||||
|
auto_open_browser: false
|
||||||
default_port: 6420
|
default_port: 6420
|
||||||
remote_operations: true
|
remote_operations: true
|
||||||
auto_commit: false
|
auto_commit: false
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
---
|
||||||
|
id: TASK-69
|
||||||
|
title: Refactor mpv plugin into modular script components
|
||||||
|
status: To Do
|
||||||
|
assignee: []
|
||||||
|
created_date: '2026-02-24 17:09'
|
||||||
|
labels: []
|
||||||
|
dependencies: []
|
||||||
|
priority: medium
|
||||||
|
---
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||||
|
Break plugin/subminer.lua into smaller Lua modules under mpv scripts subdirectory while preserving user-visible behavior and keybindings. Include migration + docs updates for install paths and smoke/regression checks.
|
||||||
|
<!-- SECTION:DESCRIPTION:END -->
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
<!-- AC:BEGIN -->
|
||||||
|
- [ ] #1 Plugin entrypoint stays at scripts/subminer.lua and loads modules from scripts/subminer/
|
||||||
|
- [ ] #2 No behavior change for keybindings, script messages, auto-start, AniSkip, hover highlight
|
||||||
|
- [ ] #3 Install/docs updated for recursive plugin copy
|
||||||
|
- [ ] #4 Add or update regression checks for start/stop + module loading
|
||||||
|
<!-- AC:END -->
|
||||||
@@ -37,6 +37,8 @@ export default {
|
|||||||
],
|
],
|
||||||
appearance: 'dark',
|
appearance: 'dark',
|
||||||
cleanUrls: true,
|
cleanUrls: true,
|
||||||
|
metaChunk: true,
|
||||||
|
sitemap: { hostname: 'https://docs.subminer.moe' },
|
||||||
lastUpdated: true,
|
lastUpdated: true,
|
||||||
srcExclude: ['subagents/**'],
|
srcExclude: ['subagents/**'],
|
||||||
markdown: {
|
markdown: {
|
||||||
@@ -94,6 +96,18 @@ export default {
|
|||||||
search: {
|
search: {
|
||||||
provider: 'local',
|
provider: 'local',
|
||||||
},
|
},
|
||||||
|
footer: {
|
||||||
|
message: 'Released under the GPL-3.0 License.',
|
||||||
|
copyright: 'Copyright © 2026-present sudacode',
|
||||||
|
},
|
||||||
|
editLink: {
|
||||||
|
pattern: 'https://github.com/ksyasuda/SubMiner/edit/main/docs/:path',
|
||||||
|
text: 'Edit this page on GitHub',
|
||||||
|
},
|
||||||
|
outline: { level: [2, 3], label: 'On this page' },
|
||||||
|
externalLinkIcon: true,
|
||||||
|
docFooter: { prev: 'Previous', next: 'Next' },
|
||||||
|
returnToTopLabel: 'Back to top',
|
||||||
socialLinks: [{ icon: 'github', link: 'https://github.com/ksyasuda/SubMiner' }],
|
socialLinks: [{ icon: 'github', link: 'https://github.com/ksyasuda/SubMiner' }],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -450,6 +450,8 @@ Setup flow details:
|
|||||||
2. Leave `anilist.accessToken` empty and restart SubMiner (or run `--anilist-setup`) to trigger setup.
|
2. Leave `anilist.accessToken` empty and restart SubMiner (or run `--anilist-setup`) to trigger setup.
|
||||||
3. Approve access in AniList.
|
3. Approve access in AniList.
|
||||||
4. Callback flow returns to SubMiner via `subminer://anilist-setup?...`, and SubMiner stores the token automatically.
|
4. Callback flow returns to SubMiner via `subminer://anilist-setup?...`, and SubMiner stores the token automatically.
|
||||||
|
- Encryption backend: Linux defaults to `gnome-libsecret`.
|
||||||
|
Override with `--password-store=<backend>` (for example `--password-store=basic_text`).
|
||||||
|
|
||||||
Token + detection notes:
|
Token + detection notes:
|
||||||
|
|
||||||
@@ -506,6 +508,7 @@ Jellyfin integration is optional and disabled by default. When enabled, SubMiner
|
|||||||
| `transcodeVideoCodec` | string | Preferred transcode video codec fallback (default: `h264`) |
|
| `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.
|
||||||
|
- On Linux, token storage defaults to `gnome-libsecret` for `safeStorage`. Override with `--password-store=<backend>` on launcher/app invocations when needed.
|
||||||
|
|
||||||
Launcher subcommands:
|
Launcher subcommands:
|
||||||
|
|
||||||
@@ -514,6 +517,7 @@ Launcher subcommands:
|
|||||||
- `subminer jellyfin --logout` clears stored credentials.
|
- `subminer jellyfin --logout` clears stored credentials.
|
||||||
- `subminer jellyfin -p` opens play picker.
|
- `subminer jellyfin -p` opens play picker.
|
||||||
- `subminer jellyfin -d` starts cast discovery mode.
|
- `subminer jellyfin -d` starts cast discovery mode.
|
||||||
|
- These launcher commands also accept `--password-store=<backend>` to override the launcher-app forwarded Electron switch.
|
||||||
|
|
||||||
See [Jellyfin Integration](/jellyfin-integration) for the full setup and cast-to-device guide.
|
See [Jellyfin Integration](/jellyfin-integration) for the full setup and cast-to-device guide.
|
||||||
|
|
||||||
|
|||||||
@@ -150,7 +150,8 @@ wget https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer-asse
|
|||||||
tar -xzf /tmp/subminer-assets.tar.gz -C /tmp
|
tar -xzf /tmp/subminer-assets.tar.gz -C /tmp
|
||||||
mkdir -p ~/.config/SubMiner
|
mkdir -p ~/.config/SubMiner
|
||||||
cp /tmp/config.example.jsonc ~/.config/SubMiner/config.jsonc
|
cp /tmp/config.example.jsonc ~/.config/SubMiner/config.jsonc
|
||||||
cp /tmp/plugin/subminer.lua ~/.config/mpv/scripts/
|
mkdir -p ~/.config/mpv/scripts/subminer
|
||||||
|
cp -R /tmp/plugin/subminer/. ~/.config/mpv/scripts/subminer/
|
||||||
cp /tmp/plugin/subminer.conf ~/.config/mpv/script-opts/
|
cp /tmp/plugin/subminer.conf ~/.config/mpv/script-opts/
|
||||||
|
|
||||||
# Option 2: from source checkout
|
# Option 2: from source checkout
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ SubMiner includes an optional Jellyfin CLI integration for:
|
|||||||
|
|
||||||
- Jellyfin server URL and user credentials
|
- Jellyfin server URL and user credentials
|
||||||
- For `--jellyfin-play`: connected mpv IPC socket (`--start` or existing mpv plugin workflow)
|
- For `--jellyfin-play`: connected mpv IPC socket (`--start` or existing mpv plugin workflow)
|
||||||
|
- On Linux, token encryption defaults to `gnome-libsecret`; pass `--password-store=<backend>` to override.
|
||||||
|
|
||||||
## Setup
|
## Setup
|
||||||
|
|
||||||
@@ -150,6 +151,7 @@ User-visible errors are shown through CLI logs and mpv OSD for:
|
|||||||
## Security Notes and Limitations
|
## Security Notes and Limitations
|
||||||
|
|
||||||
- Jellyfin auth session (`accessToken` + `userId`) is stored in local encrypted token storage after login/setup.
|
- Jellyfin auth session (`accessToken` + `userId`) is stored in local encrypted token storage after login/setup.
|
||||||
|
- Launcher wrappers support `--password-store=<backend>` and forward it through to the app process.
|
||||||
- Optional environment overrides are supported: `SUBMINER_JELLYFIN_ACCESS_TOKEN` and `SUBMINER_JELLYFIN_USER_ID`.
|
- Optional environment overrides are supported: `SUBMINER_JELLYFIN_ACCESS_TOKEN` and `SUBMINER_JELLYFIN_USER_ID`.
|
||||||
- Treat both token storage and config files as secrets and avoid committing them.
|
- Treat both token storage and config files as secrets and avoid committing them.
|
||||||
- Password is used only for login and is not stored.
|
- Password is used only for login and is not stored.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# MPV Plugin
|
# MPV Plugin
|
||||||
|
|
||||||
The SubMiner mpv plugin (`subminer.lua`) provides in-player keybindings to control the overlay without leaving mpv. It communicates with SubMiner by invoking the AppImage (or binary) with CLI flags.
|
The SubMiner mpv plugin (`subminer/main.lua`) provides in-player keybindings to control the overlay without leaving mpv. It communicates with SubMiner by invoking the AppImage (or binary) with CLI flags.
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
@@ -10,7 +10,8 @@ wget https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer-asse
|
|||||||
tar -xzf /tmp/subminer-assets.tar.gz -C /tmp
|
tar -xzf /tmp/subminer-assets.tar.gz -C /tmp
|
||||||
mkdir -p ~/.config/SubMiner
|
mkdir -p ~/.config/SubMiner
|
||||||
cp /tmp/config.example.jsonc ~/.config/SubMiner/config.jsonc
|
cp /tmp/config.example.jsonc ~/.config/SubMiner/config.jsonc
|
||||||
cp /tmp/plugin/subminer.lua ~/.config/mpv/scripts/
|
mkdir -p ~/.config/mpv/scripts/subminer
|
||||||
|
cp -R /tmp/plugin/subminer/. ~/.config/mpv/scripts/subminer/
|
||||||
cp /tmp/plugin/subminer.conf ~/.config/mpv/script-opts/
|
cp /tmp/plugin/subminer.conf ~/.config/mpv/script-opts/
|
||||||
|
|
||||||
# Or from source checkout: make install-plugin
|
# Or from source checkout: make install-plugin
|
||||||
|
|||||||
@@ -94,6 +94,9 @@ SubMiner.AppImage --help # Show all options
|
|||||||
- `--background` defaults to quieter logging (`warn`) unless `--log-level` is set.
|
- `--background` defaults to quieter logging (`warn`) unless `--log-level` is set.
|
||||||
- `--background` launched from a terminal detaches and returns the prompt; stop it with tray Quit or `SubMiner.AppImage --stop`.
|
- `--background` launched from a terminal detaches and returns the prompt; stop it with tray Quit or `SubMiner.AppImage --stop`.
|
||||||
- Linux desktop launcher starts SubMiner with `--background` by default (via electron-builder `linux.executableArgs`).
|
- Linux desktop launcher starts SubMiner with `--background` by default (via electron-builder `linux.executableArgs`).
|
||||||
|
- On Linux, the app now defaults `safeStorage` to `gnome-libsecret` for encrypted token persistence.
|
||||||
|
Launcher pass-through commands also support `--password-store=<backend>` and forward it to the app when present.
|
||||||
|
Override with e.g. `--password-store=basic_text`.
|
||||||
- Use both when needed, for example `SubMiner.AppImage --start --dev --log-level debug`.
|
- Use both when needed, for example `SubMiner.AppImage --start --dev --log-level debug`.
|
||||||
|
|
||||||
### Launcher Subcommands
|
### Launcher Subcommands
|
||||||
|
|||||||
@@ -10,9 +10,16 @@ export async function runJellyfinCommand(context: LauncherCommandContext): Promi
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const appendPasswordStore = (forwarded: string[]): void => {
|
||||||
|
if (args.passwordStore) {
|
||||||
|
forwarded.push('--password-store', args.passwordStore);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (args.jellyfin) {
|
if (args.jellyfin) {
|
||||||
const forwarded = ['--jellyfin'];
|
const forwarded = ['--jellyfin'];
|
||||||
if (args.logLevel !== 'info') forwarded.push('--log-level', args.logLevel);
|
if (args.logLevel !== 'info') forwarded.push('--log-level', args.logLevel);
|
||||||
|
appendPasswordStore(forwarded);
|
||||||
runAppCommandWithInherit(appPath, forwarded);
|
runAppCommandWithInherit(appPath, forwarded);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,12 +42,14 @@ export async function runJellyfinCommand(context: LauncherCommandContext): Promi
|
|||||||
password,
|
password,
|
||||||
];
|
];
|
||||||
if (args.logLevel !== 'info') forwarded.push('--log-level', args.logLevel);
|
if (args.logLevel !== 'info') forwarded.push('--log-level', args.logLevel);
|
||||||
|
appendPasswordStore(forwarded);
|
||||||
runAppCommandWithInherit(appPath, forwarded);
|
runAppCommandWithInherit(appPath, forwarded);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (args.jellyfinLogout) {
|
if (args.jellyfinLogout) {
|
||||||
const forwarded = ['--jellyfin-logout'];
|
const forwarded = ['--jellyfin-logout'];
|
||||||
if (args.logLevel !== 'info') forwarded.push('--log-level', args.logLevel);
|
if (args.logLevel !== 'info') forwarded.push('--log-level', args.logLevel);
|
||||||
|
appendPasswordStore(forwarded);
|
||||||
runAppCommandWithInherit(appPath, forwarded);
|
runAppCommandWithInherit(appPath, forwarded);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,6 +67,7 @@ export async function runJellyfinCommand(context: LauncherCommandContext): Promi
|
|||||||
if (args.jellyfinDiscovery) {
|
if (args.jellyfinDiscovery) {
|
||||||
const forwarded = ['--start'];
|
const forwarded = ['--start'];
|
||||||
if (args.logLevel !== 'info') forwarded.push('--log-level', args.logLevel);
|
if (args.logLevel !== 'info') forwarded.push('--log-level', args.logLevel);
|
||||||
|
appendPasswordStore(forwarded);
|
||||||
runAppCommandWithInherit(appPath, forwarded);
|
runAppCommandWithInherit(appPath, forwarded);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -134,6 +134,7 @@ export function createDefaultArgs(launcherConfig: LauncherYoutubeSubgenConfig):
|
|||||||
texthookerOnly: false,
|
texthookerOnly: false,
|
||||||
useRofi: false,
|
useRofi: false,
|
||||||
logLevel: 'info',
|
logLevel: 'info',
|
||||||
|
passwordStore: '',
|
||||||
target: '',
|
target: '',
|
||||||
targetKind: '',
|
targetKind: '',
|
||||||
};
|
};
|
||||||
@@ -161,6 +162,7 @@ export function applyRootOptionsToArgs(
|
|||||||
if (typeof options.profile === 'string') parsed.profile = options.profile;
|
if (typeof options.profile === 'string') parsed.profile = options.profile;
|
||||||
if (options.start === true) parsed.startOverlay = true;
|
if (options.start === true) parsed.startOverlay = true;
|
||||||
if (typeof options.logLevel === 'string') parsed.logLevel = parseLogLevel(options.logLevel);
|
if (typeof options.logLevel === 'string') parsed.logLevel = parseLogLevel(options.logLevel);
|
||||||
|
if (typeof options.passwordStore === 'string') parsed.passwordStore = options.passwordStore;
|
||||||
if (options.rofi === true) parsed.useRofi = true;
|
if (options.rofi === true) parsed.useRofi = true;
|
||||||
if (options.startOverlay === true) parsed.autoStartOverlay = true;
|
if (options.startOverlay === true) parsed.autoStartOverlay = true;
|
||||||
if (options.texthooker === false) parsed.useTexthooker = false;
|
if (options.texthooker === false) parsed.useTexthooker = false;
|
||||||
@@ -175,6 +177,9 @@ export function applyInvocationsToArgs(parsed: Args, invocations: CliInvocations
|
|||||||
if (invocations.jellyfinInvocation.logLevel) {
|
if (invocations.jellyfinInvocation.logLevel) {
|
||||||
parsed.logLevel = parseLogLevel(invocations.jellyfinInvocation.logLevel);
|
parsed.logLevel = parseLogLevel(invocations.jellyfinInvocation.logLevel);
|
||||||
}
|
}
|
||||||
|
if (typeof invocations.jellyfinInvocation.passwordStore === 'string') {
|
||||||
|
parsed.passwordStore = invocations.jellyfinInvocation.passwordStore;
|
||||||
|
}
|
||||||
const action = (invocations.jellyfinInvocation.action || '').toLowerCase();
|
const action = (invocations.jellyfinInvocation.action || '').toLowerCase();
|
||||||
if (action && !['setup', 'discovery', 'play', 'login', 'logout'].includes(action)) {
|
if (action && !['setup', 'discovery', 'play', 'login', 'logout'].includes(action)) {
|
||||||
fail(`Unknown jellyfin action: ${invocations.jellyfinInvocation.action}`);
|
fail(`Unknown jellyfin action: ${invocations.jellyfinInvocation.action}`);
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export interface JellyfinInvocation {
|
|||||||
server?: string;
|
server?: string;
|
||||||
username?: string;
|
username?: string;
|
||||||
password?: string;
|
password?: string;
|
||||||
|
passwordStore?: string;
|
||||||
logLevel?: string;
|
logLevel?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,6 +169,7 @@ export function parseCliPrograms(
|
|||||||
.option('-s, --server <url>', 'Jellyfin server URL')
|
.option('-s, --server <url>', 'Jellyfin server URL')
|
||||||
.option('-u, --username <name>', 'Jellyfin username')
|
.option('-u, --username <name>', 'Jellyfin username')
|
||||||
.option('-w, --password <pass>', 'Jellyfin password')
|
.option('-w, --password <pass>', 'Jellyfin password')
|
||||||
|
.option('--password-store <backend>', 'Pass through Electron safeStorage backend')
|
||||||
.option('--log-level <level>', 'Log level')
|
.option('--log-level <level>', 'Log level')
|
||||||
.action((action: string | undefined, options: Record<string, unknown>) => {
|
.action((action: string | undefined, options: Record<string, unknown>) => {
|
||||||
jellyfinInvocation = {
|
jellyfinInvocation = {
|
||||||
@@ -180,6 +182,7 @@ export function parseCliPrograms(
|
|||||||
server: typeof options.server === 'string' ? options.server : undefined,
|
server: typeof options.server === 'string' ? options.server : undefined,
|
||||||
username: typeof options.username === 'string' ? options.username : undefined,
|
username: typeof options.username === 'string' ? options.username : undefined,
|
||||||
password: typeof options.password === 'string' ? options.password : undefined,
|
password: typeof options.password === 'string' ? options.password : undefined,
|
||||||
|
passwordStore: typeof options.passwordStore === 'string' ? options.passwordStore : undefined,
|
||||||
logLevel: typeof options.logLevel === 'string' ? options.logLevel : undefined,
|
logLevel: typeof options.logLevel === 'string' ? options.logLevel : undefined,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -395,5 +395,6 @@ export async function runJellyfinPlayMenu(
|
|||||||
}
|
}
|
||||||
const forwarded = ['--start', '--jellyfin-play', '--jellyfin-item-id', itemId];
|
const forwarded = ['--start', '--jellyfin-play', '--jellyfin-item-id', itemId];
|
||||||
if (args.logLevel !== 'info') forwarded.push('--log-level', args.logLevel);
|
if (args.logLevel !== 'info') forwarded.push('--log-level', args.logLevel);
|
||||||
|
if (args.passwordStore) forwarded.push('--password-store', args.passwordStore);
|
||||||
runAppCommandWithInheritLogged(appPath, forwarded, args.logLevel, 'jellyfin-play');
|
runAppCommandWithInheritLogged(appPath, forwarded, args.logLevel, 'jellyfin-play');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -207,3 +207,33 @@ test('jellyfin login routes credentials to app command', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('jellyfin setup forwards password-store to app 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(
|
||||||
|
['jf', 'setup', '--password-store', 'gnome-libsecret'],
|
||||||
|
env,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(result.status, 0);
|
||||||
|
assert.equal(
|
||||||
|
fs.readFileSync(capturePath, 'utf8'),
|
||||||
|
'--jellyfin\n--password-store\ngnome-libsecret\n',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -30,6 +30,17 @@ test('parseArgs maps jellyfin play action and log-level override', () => {
|
|||||||
assert.equal(parsed.logLevel, 'debug');
|
assert.equal(parsed.logLevel, 'debug');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('parseArgs forwards jellyfin password-store option', () => {
|
||||||
|
const parsed = parseArgs(
|
||||||
|
['jf', 'setup', '--password-store', 'gnome-libsecret'],
|
||||||
|
'subminer',
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(parsed.jellyfin, true);
|
||||||
|
assert.equal(parsed.passwordStore, 'gnome-libsecret');
|
||||||
|
});
|
||||||
|
|
||||||
test('parseArgs maps config show action', () => {
|
test('parseArgs maps config show action', () => {
|
||||||
const parsed = parseArgs(['config', 'show'], 'subminer', {});
|
const parsed = parseArgs(['config', 'show'], 'subminer', {});
|
||||||
|
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ export interface Args {
|
|||||||
texthookerOnly: boolean;
|
texthookerOnly: boolean;
|
||||||
useRofi: boolean;
|
useRofi: boolean;
|
||||||
logLevel: LogLevel;
|
logLevel: LogLevel;
|
||||||
|
passwordStore: string;
|
||||||
target: string;
|
target: string;
|
||||||
targetKind: '' | 'file' | 'url';
|
targetKind: '' | 'file' | 'url';
|
||||||
jimakuApiKey: string;
|
jimakuApiKey: string;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "subminer",
|
"name": "subminer",
|
||||||
"version": "0.1.0",
|
"version": "0.1.2",
|
||||||
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
|
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
|
||||||
"packageManager": "bun@1.3.5",
|
"packageManager": "bun@1.3.5",
|
||||||
"main": "dist/main-entry.js",
|
"main": "dist/main-entry.js",
|
||||||
|
|||||||
1959
plugin/subminer.lua
1959
plugin/subminer.lua
File diff suppressed because it is too large
Load Diff
412
plugin/subminer/aniskip.lua
Normal file
412
plugin/subminer/aniskip.lua
Normal file
@@ -0,0 +1,412 @@
|
|||||||
|
local M = {}
|
||||||
|
local matcher = require("aniskip_match")
|
||||||
|
|
||||||
|
function M.create(ctx)
|
||||||
|
local mp = ctx.mp
|
||||||
|
local utils = ctx.utils
|
||||||
|
local opts = ctx.opts
|
||||||
|
local state = ctx.state
|
||||||
|
local environment = ctx.environment
|
||||||
|
local subminer_log = ctx.log.subminer_log
|
||||||
|
local show_osd = ctx.log.show_osd
|
||||||
|
|
||||||
|
local function url_encode(text)
|
||||||
|
if type(text) ~= "string" then
|
||||||
|
return ""
|
||||||
|
end
|
||||||
|
local encoded = text:gsub("\n", " ")
|
||||||
|
encoded = encoded:gsub("([^%w%-_%.~ ])", function(char)
|
||||||
|
return string.format("%%%02X", string.byte(char))
|
||||||
|
end)
|
||||||
|
return encoded:gsub(" ", "%%20")
|
||||||
|
end
|
||||||
|
|
||||||
|
local function run_json_curl(url)
|
||||||
|
local result = mp.command_native({
|
||||||
|
name = "subprocess",
|
||||||
|
args = { "curl", "-sL", "--connect-timeout", "5", "-A", "SubMiner-mpv/ani-skip", url },
|
||||||
|
playback_only = false,
|
||||||
|
capture_stdout = true,
|
||||||
|
capture_stderr = true,
|
||||||
|
})
|
||||||
|
if not result or result.status ~= 0 or type(result.stdout) ~= "string" or result.stdout == "" then
|
||||||
|
return nil, result and result.stderr or "curl failed"
|
||||||
|
end
|
||||||
|
local parsed, parse_error = utils.parse_json(result.stdout)
|
||||||
|
if type(parsed) ~= "table" then
|
||||||
|
return nil, parse_error or "invalid json"
|
||||||
|
end
|
||||||
|
return parsed, nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local function parse_episode_hint(text)
|
||||||
|
if type(text) ~= "string" or text == "" then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
local patterns = {
|
||||||
|
"[Ss]%d+[Ee](%d+)",
|
||||||
|
"[Ee][Pp]?[%s%._%-]*(%d+)",
|
||||||
|
"[%s%._%-]+(%d+)[%s%._%-]+",
|
||||||
|
}
|
||||||
|
for _, pattern in ipairs(patterns) do
|
||||||
|
local token = text:match(pattern)
|
||||||
|
if token then
|
||||||
|
local episode = tonumber(token)
|
||||||
|
if episode and episode > 0 and episode < 10000 then
|
||||||
|
return episode
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local function cleanup_title(raw)
|
||||||
|
if type(raw) ~= "string" then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
local cleaned = raw
|
||||||
|
cleaned = cleaned:gsub("%b[]", " ")
|
||||||
|
cleaned = cleaned:gsub("%b()", " ")
|
||||||
|
cleaned = cleaned:gsub("[Ss]%d+[Ee]%d+", " ")
|
||||||
|
cleaned = cleaned:gsub("[Ee][Pp]?[%s%._%-]*%d+", " ")
|
||||||
|
cleaned = cleaned:gsub("[%._%-]+", " ")
|
||||||
|
cleaned = cleaned:gsub("%s+", " ")
|
||||||
|
cleaned = cleaned:match("^%s*(.-)%s*$") or ""
|
||||||
|
if cleaned == "" then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
return cleaned
|
||||||
|
end
|
||||||
|
|
||||||
|
local function extract_show_title_from_path(media_path)
|
||||||
|
if type(media_path) ~= "string" or media_path == "" then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
local normalized = media_path:gsub("\\", "/")
|
||||||
|
local segments = {}
|
||||||
|
for segment in normalized:gmatch("[^/]+") do
|
||||||
|
segments[#segments + 1] = segment
|
||||||
|
end
|
||||||
|
for index = 1, #segments do
|
||||||
|
local segment = segments[index] or ""
|
||||||
|
if segment:match("^[Ss]eason[%s%._%-]*%d+$") or segment:match("^[Ss][%s%._%-]*%d+$") then
|
||||||
|
local prior = segments[index - 1]
|
||||||
|
local cleaned = cleanup_title(prior or "")
|
||||||
|
if cleaned and cleaned ~= "" then
|
||||||
|
return cleaned
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local function resolve_title_and_episode()
|
||||||
|
local forced_title = type(opts.aniskip_title) == "string" and (opts.aniskip_title:match("^%s*(.-)%s*$") or "") or ""
|
||||||
|
local forced_season = tonumber(opts.aniskip_season)
|
||||||
|
local forced_episode = tonumber(opts.aniskip_episode)
|
||||||
|
local media_title = mp.get_property("media-title")
|
||||||
|
local filename = mp.get_property("filename/no-ext") or mp.get_property("filename") or ""
|
||||||
|
local path = mp.get_property("path") or ""
|
||||||
|
local path_show_title = extract_show_title_from_path(path)
|
||||||
|
local candidate_title = nil
|
||||||
|
if path_show_title and path_show_title ~= "" then
|
||||||
|
candidate_title = path_show_title
|
||||||
|
elseif forced_title ~= "" then
|
||||||
|
candidate_title = forced_title
|
||||||
|
else
|
||||||
|
candidate_title = cleanup_title(media_title) or cleanup_title(filename) or cleanup_title(path)
|
||||||
|
end
|
||||||
|
local episode = forced_episode or parse_episode_hint(media_title) or parse_episode_hint(filename) or parse_episode_hint(path) or 1
|
||||||
|
return candidate_title, episode, forced_season
|
||||||
|
end
|
||||||
|
|
||||||
|
local function select_best_mal_item(items, title, season)
|
||||||
|
if type(items) ~= "table" then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
local best_item = nil
|
||||||
|
local best_score = -math.huge
|
||||||
|
for _, item in ipairs(items) do
|
||||||
|
if type(item) == "table" and tonumber(item.id) then
|
||||||
|
local candidate_name = tostring(item.name or "")
|
||||||
|
local score = matcher.title_overlap_score(title, candidate_name) + matcher.season_signal_score(season, candidate_name)
|
||||||
|
if score > best_score then
|
||||||
|
best_score = score
|
||||||
|
best_item = item
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return best_item
|
||||||
|
end
|
||||||
|
|
||||||
|
local function resolve_mal_id(title, season)
|
||||||
|
local forced_mal_id = tonumber(opts.aniskip_mal_id)
|
||||||
|
if forced_mal_id and forced_mal_id > 0 then
|
||||||
|
return forced_mal_id, "(forced-mal-id)"
|
||||||
|
end
|
||||||
|
if type(title) == "string" and title:match("^%d+$") then
|
||||||
|
local numeric = tonumber(title)
|
||||||
|
if numeric and numeric > 0 then
|
||||||
|
return numeric, title
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if type(title) ~= "string" or title == "" then
|
||||||
|
return nil, nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local lookup = title
|
||||||
|
if season and season > 1 then
|
||||||
|
lookup = string.format("%s Season %d", lookup, season)
|
||||||
|
end
|
||||||
|
local mal_url = "https://myanimelist.net/search/prefix.json?type=anime&keyword=" .. url_encode(lookup)
|
||||||
|
local mal_json, mal_error = run_json_curl(mal_url)
|
||||||
|
if not mal_json then
|
||||||
|
subminer_log("warn", "aniskip", "MAL lookup failed: " .. tostring(mal_error))
|
||||||
|
return nil, lookup
|
||||||
|
end
|
||||||
|
local categories = mal_json.categories
|
||||||
|
if type(categories) ~= "table" then
|
||||||
|
return nil, lookup
|
||||||
|
end
|
||||||
|
|
||||||
|
local all_items = {}
|
||||||
|
for _, category in ipairs(categories) do
|
||||||
|
if type(category) == "table" and type(category.items) == "table" then
|
||||||
|
for _, item in ipairs(category.items) do
|
||||||
|
all_items[#all_items + 1] = item
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
local best_item = select_best_mal_item(all_items, title, season)
|
||||||
|
if best_item and tonumber(best_item.id) then
|
||||||
|
subminer_log(
|
||||||
|
"info",
|
||||||
|
"aniskip",
|
||||||
|
string.format(
|
||||||
|
'MAL candidate selected (score-based): id=%s name="%s" season_hint=%s',
|
||||||
|
tostring(best_item.id),
|
||||||
|
tostring(best_item.name or ""),
|
||||||
|
tostring(season or "-")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return tonumber(best_item.id), lookup
|
||||||
|
end
|
||||||
|
return nil, lookup
|
||||||
|
end
|
||||||
|
|
||||||
|
local function set_intro_chapters(intro_start, intro_end)
|
||||||
|
if type(intro_start) ~= "number" or type(intro_end) ~= "number" then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local current = mp.get_property_native("chapter-list")
|
||||||
|
local chapters = {}
|
||||||
|
if type(current) == "table" then
|
||||||
|
for _, chapter in ipairs(current) do
|
||||||
|
local title = type(chapter) == "table" and chapter.title or nil
|
||||||
|
if type(title) ~= "string" or not title:match("^AniSkip ") then
|
||||||
|
chapters[#chapters + 1] = chapter
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
chapters[#chapters + 1] = { time = intro_start, title = "AniSkip Intro Start" }
|
||||||
|
chapters[#chapters + 1] = { time = intro_end, title = "AniSkip Intro End" }
|
||||||
|
table.sort(chapters, function(a, b)
|
||||||
|
local a_time = type(a) == "table" and tonumber(a.time) or 0
|
||||||
|
local b_time = type(b) == "table" and tonumber(b.time) or 0
|
||||||
|
return a_time < b_time
|
||||||
|
end)
|
||||||
|
mp.set_property_native("chapter-list", chapters)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function remove_aniskip_chapters()
|
||||||
|
local current = mp.get_property_native("chapter-list")
|
||||||
|
if type(current) ~= "table" then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local chapters = {}
|
||||||
|
local changed = false
|
||||||
|
for _, chapter in ipairs(current) do
|
||||||
|
local title = type(chapter) == "table" and chapter.title or nil
|
||||||
|
if type(title) == "string" and title:match("^AniSkip ") then
|
||||||
|
changed = true
|
||||||
|
else
|
||||||
|
chapters[#chapters + 1] = chapter
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if changed then
|
||||||
|
mp.set_property_native("chapter-list", chapters)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function clear_aniskip_state()
|
||||||
|
state.aniskip.prompt_shown = false
|
||||||
|
state.aniskip.found = false
|
||||||
|
state.aniskip.mal_id = nil
|
||||||
|
state.aniskip.title = nil
|
||||||
|
state.aniskip.episode = nil
|
||||||
|
state.aniskip.intro_start = nil
|
||||||
|
state.aniskip.intro_end = nil
|
||||||
|
remove_aniskip_chapters()
|
||||||
|
end
|
||||||
|
|
||||||
|
local function skip_intro_now()
|
||||||
|
if not state.aniskip.found then
|
||||||
|
show_osd("Intro skip unavailable")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local intro_start = state.aniskip.intro_start
|
||||||
|
local intro_end = state.aniskip.intro_end
|
||||||
|
if type(intro_start) ~= "number" or type(intro_end) ~= "number" then
|
||||||
|
show_osd("Intro markers missing")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local now = mp.get_property_number("time-pos")
|
||||||
|
if type(now) ~= "number" then
|
||||||
|
show_osd("Skip unavailable")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local epsilon = 0.35
|
||||||
|
if now < (intro_start - epsilon) or now > (intro_end + epsilon) then
|
||||||
|
show_osd("Skip intro only during intro")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
mp.set_property_number("time-pos", intro_end)
|
||||||
|
show_osd("Skipped intro")
|
||||||
|
end
|
||||||
|
|
||||||
|
local function update_intro_button_visibility()
|
||||||
|
if not opts.aniskip_enabled or not opts.aniskip_show_button or not state.aniskip.found then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local now = mp.get_property_number("time-pos")
|
||||||
|
if type(now) ~= "number" then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local in_intro = now >= (state.aniskip.intro_start or -1) and now < (state.aniskip.intro_end or -1)
|
||||||
|
local intro_start = state.aniskip.intro_start or -1
|
||||||
|
local hint_window_end = intro_start + 3
|
||||||
|
if in_intro and not state.aniskip.prompt_shown and now >= intro_start and now < hint_window_end then
|
||||||
|
local key = opts.aniskip_button_key ~= "" and opts.aniskip_button_key or "y-k"
|
||||||
|
local message = string.format(opts.aniskip_button_text, key)
|
||||||
|
mp.osd_message(message, tonumber(opts.aniskip_button_duration) or 3)
|
||||||
|
state.aniskip.prompt_shown = true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function apply_aniskip_payload(mal_id, title, episode, payload)
|
||||||
|
local results = payload and payload.results
|
||||||
|
if type(results) ~= "table" then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
for _, item in ipairs(results) do
|
||||||
|
if type(item) == "table" and item.skip_type == "op" and type(item.interval) == "table" then
|
||||||
|
local intro_start = tonumber(item.interval.start_time)
|
||||||
|
local intro_end = tonumber(item.interval.end_time)
|
||||||
|
if intro_start and intro_end and intro_end > intro_start then
|
||||||
|
state.aniskip.found = true
|
||||||
|
state.aniskip.mal_id = mal_id
|
||||||
|
state.aniskip.title = title
|
||||||
|
state.aniskip.episode = episode
|
||||||
|
state.aniskip.intro_start = intro_start
|
||||||
|
state.aniskip.intro_end = intro_end
|
||||||
|
state.aniskip.prompt_shown = false
|
||||||
|
set_intro_chapters(intro_start, intro_end)
|
||||||
|
subminer_log("info", "aniskip", string.format("Intro window %.3f -> %.3f (MAL %d, ep %d)", intro_start, intro_end, mal_id, episode))
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
local function fetch_aniskip_for_current_media()
|
||||||
|
if not environment.is_subminer_app_running() then
|
||||||
|
subminer_log("debug", "lifecycle", "Skipping aniskip lookup: SubMiner app not running")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
clear_aniskip_state()
|
||||||
|
if not opts.aniskip_enabled then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local title, episode, season = resolve_title_and_episode()
|
||||||
|
local media_title_fallback = cleanup_title(mp.get_property("media-title"))
|
||||||
|
local filename_fallback = cleanup_title(mp.get_property("filename/no-ext") or mp.get_property("filename") or "")
|
||||||
|
local path_fallback = cleanup_title(mp.get_property("path") or "")
|
||||||
|
local lookup_titles = {}
|
||||||
|
local seen_titles = {}
|
||||||
|
local function push_lookup_title(candidate)
|
||||||
|
if type(candidate) ~= "string" then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local trimmed = candidate:match("^%s*(.-)%s*$") or ""
|
||||||
|
if trimmed == "" then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local key = trimmed:lower()
|
||||||
|
if seen_titles[key] then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
seen_titles[key] = true
|
||||||
|
lookup_titles[#lookup_titles + 1] = trimmed
|
||||||
|
end
|
||||||
|
push_lookup_title(title)
|
||||||
|
push_lookup_title(media_title_fallback)
|
||||||
|
push_lookup_title(filename_fallback)
|
||||||
|
push_lookup_title(path_fallback)
|
||||||
|
|
||||||
|
subminer_log(
|
||||||
|
"info",
|
||||||
|
"aniskip",
|
||||||
|
string.format(
|
||||||
|
'Query context: title="%s" season=%s episode=%s (opts: title="%s" season=%s episode=%s mal_id=%s; fallback_titles=%d)',
|
||||||
|
tostring(title or ""),
|
||||||
|
tostring(season or "-"),
|
||||||
|
tostring(episode or "-"),
|
||||||
|
tostring(opts.aniskip_title or ""),
|
||||||
|
tostring(opts.aniskip_season or "-"),
|
||||||
|
tostring(opts.aniskip_episode or "-"),
|
||||||
|
tostring(opts.aniskip_mal_id or "-"),
|
||||||
|
#lookup_titles
|
||||||
|
)
|
||||||
|
)
|
||||||
|
local mal_id, mal_lookup = nil, nil
|
||||||
|
for index, lookup_title in ipairs(lookup_titles) do
|
||||||
|
subminer_log("info", "aniskip", string.format('MAL lookup attempt %d/%d using title="%s"', index, #lookup_titles, lookup_title))
|
||||||
|
local attempt_mal_id, attempt_lookup = resolve_mal_id(lookup_title, season)
|
||||||
|
if attempt_mal_id then
|
||||||
|
mal_id = attempt_mal_id
|
||||||
|
mal_lookup = attempt_lookup
|
||||||
|
break
|
||||||
|
end
|
||||||
|
mal_lookup = attempt_lookup or mal_lookup
|
||||||
|
end
|
||||||
|
if not mal_id then
|
||||||
|
subminer_log("info", "aniskip", string.format('Skipped: MAL id unavailable for query="%s"', tostring(mal_lookup or "")))
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local url = string.format("https://api.aniskip.com/v1/skip-times/%d/%d?types=op&types=ed", mal_id, episode)
|
||||||
|
subminer_log("info", "aniskip", string.format('Resolved MAL id=%d using query="%s"; AniSkip URL=%s', mal_id, tostring(mal_lookup or ""), url))
|
||||||
|
local payload, fetch_error = run_json_curl(url)
|
||||||
|
if not payload then
|
||||||
|
subminer_log("warn", "aniskip", "AniSkip fetch failed: " .. tostring(fetch_error))
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if payload.found ~= true then
|
||||||
|
subminer_log("info", "aniskip", "AniSkip: no skip windows found")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if not apply_aniskip_payload(mal_id, title, episode, payload) then
|
||||||
|
subminer_log("info", "aniskip", "AniSkip payload did not include OP interval")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return {
|
||||||
|
clear_aniskip_state = clear_aniskip_state,
|
||||||
|
skip_intro_now = skip_intro_now,
|
||||||
|
update_intro_button_visibility = update_intro_button_visibility,
|
||||||
|
fetch_aniskip_for_current_media = fetch_aniskip_for_current_media,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
return M
|
||||||
150
plugin/subminer/aniskip_match.lua
Normal file
150
plugin/subminer/aniskip_match.lua
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
local M = {}
|
||||||
|
|
||||||
|
local function normalize_for_match(value)
|
||||||
|
if type(value) ~= "string" then
|
||||||
|
return ""
|
||||||
|
end
|
||||||
|
return value:lower():gsub("[^%w]+", " "):gsub("%s+", " "):match("^%s*(.-)%s*$") or ""
|
||||||
|
end
|
||||||
|
|
||||||
|
local MATCH_STOPWORDS = {
|
||||||
|
the = true,
|
||||||
|
this = true,
|
||||||
|
that = true,
|
||||||
|
world = true,
|
||||||
|
animated = true,
|
||||||
|
series = true,
|
||||||
|
season = true,
|
||||||
|
no = true,
|
||||||
|
on = true,
|
||||||
|
["and"] = true,
|
||||||
|
}
|
||||||
|
|
||||||
|
local function tokenize_match_words(value)
|
||||||
|
local normalized = normalize_for_match(value)
|
||||||
|
local tokens = {}
|
||||||
|
for token in normalized:gmatch("%S+") do
|
||||||
|
if #token >= 3 and not MATCH_STOPWORDS[token] then
|
||||||
|
tokens[#tokens + 1] = token
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return tokens
|
||||||
|
end
|
||||||
|
|
||||||
|
local function token_set(tokens)
|
||||||
|
local set = {}
|
||||||
|
for _, token in ipairs(tokens) do
|
||||||
|
set[token] = true
|
||||||
|
end
|
||||||
|
return set
|
||||||
|
end
|
||||||
|
|
||||||
|
function M.title_overlap_score(expected_title, candidate_title)
|
||||||
|
local expected = normalize_for_match(expected_title)
|
||||||
|
local candidate = normalize_for_match(candidate_title)
|
||||||
|
if expected == "" or candidate == "" then
|
||||||
|
return 0
|
||||||
|
end
|
||||||
|
if candidate:find(expected, 1, true) then
|
||||||
|
return 120
|
||||||
|
end
|
||||||
|
local expected_tokens = tokenize_match_words(expected_title)
|
||||||
|
local candidate_tokens = token_set(tokenize_match_words(candidate_title))
|
||||||
|
if #expected_tokens == 0 then
|
||||||
|
return 0
|
||||||
|
end
|
||||||
|
local score = 0
|
||||||
|
local matched = 0
|
||||||
|
for _, token in ipairs(expected_tokens) do
|
||||||
|
if candidate_tokens[token] then
|
||||||
|
score = score + 30
|
||||||
|
matched = matched + 1
|
||||||
|
else
|
||||||
|
score = score - 20
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if matched == 0 then
|
||||||
|
score = score - 80
|
||||||
|
end
|
||||||
|
local coverage = matched / #expected_tokens
|
||||||
|
if #expected_tokens >= 2 then
|
||||||
|
if coverage >= 0.8 then
|
||||||
|
score = score + 30
|
||||||
|
elseif coverage >= 0.6 then
|
||||||
|
score = score + 10
|
||||||
|
else
|
||||||
|
score = score - 50
|
||||||
|
end
|
||||||
|
elseif coverage >= 1 then
|
||||||
|
score = score + 10
|
||||||
|
end
|
||||||
|
return score
|
||||||
|
end
|
||||||
|
|
||||||
|
local function has_any_sequel_marker(candidate_title)
|
||||||
|
local normalized = normalize_for_match(candidate_title)
|
||||||
|
if normalized == "" then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
local markers = {
|
||||||
|
"season 2",
|
||||||
|
"season 3",
|
||||||
|
"season 4",
|
||||||
|
"2nd season",
|
||||||
|
"3rd season",
|
||||||
|
"4th season",
|
||||||
|
"second season",
|
||||||
|
"third season",
|
||||||
|
"fourth season",
|
||||||
|
" ii ",
|
||||||
|
" iii ",
|
||||||
|
" iv ",
|
||||||
|
}
|
||||||
|
local padded = " " .. normalized .. " "
|
||||||
|
for _, marker in ipairs(markers) do
|
||||||
|
if padded:find(marker, 1, true) then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
function M.season_signal_score(requested_season, candidate_title)
|
||||||
|
local season = tonumber(requested_season)
|
||||||
|
if not season or season < 1 then
|
||||||
|
return 0
|
||||||
|
end
|
||||||
|
local normalized = " " .. normalize_for_match(candidate_title) .. " "
|
||||||
|
if normalized == " " then
|
||||||
|
return 0
|
||||||
|
end
|
||||||
|
|
||||||
|
if season == 1 then
|
||||||
|
return has_any_sequel_marker(candidate_title) and -60 or 20
|
||||||
|
end
|
||||||
|
|
||||||
|
local numeric_marker = string.format(" season %d ", season)
|
||||||
|
local ordinal_marker = string.format(" %dth season ", season)
|
||||||
|
local roman_markers = {
|
||||||
|
[2] = { " ii ", " second season ", " 2nd season " },
|
||||||
|
[3] = { " iii ", " third season ", " 3rd season " },
|
||||||
|
[4] = { " iv ", " fourth season ", " 4th season " },
|
||||||
|
[5] = { " v ", " fifth season ", " 5th season " },
|
||||||
|
}
|
||||||
|
|
||||||
|
if normalized:find(numeric_marker, 1, true) or normalized:find(ordinal_marker, 1, true) then
|
||||||
|
return 40
|
||||||
|
end
|
||||||
|
local aliases = roman_markers[season] or {}
|
||||||
|
for _, marker in ipairs(aliases) do
|
||||||
|
if normalized:find(marker, 1, true) then
|
||||||
|
return 40
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if has_any_sequel_marker(candidate_title) then
|
||||||
|
return -20
|
||||||
|
end
|
||||||
|
return 5
|
||||||
|
end
|
||||||
|
|
||||||
|
return M
|
||||||
151
plugin/subminer/binary.lua
Normal file
151
plugin/subminer/binary.lua
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
local M = {}
|
||||||
|
|
||||||
|
function M.create(ctx)
|
||||||
|
local utils = ctx.utils
|
||||||
|
local opts = ctx.opts
|
||||||
|
local state = ctx.state
|
||||||
|
local environment = ctx.environment
|
||||||
|
local subminer_log = ctx.log.subminer_log
|
||||||
|
|
||||||
|
local function normalize_binary_path_candidate(candidate)
|
||||||
|
if type(candidate) ~= "string" then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
local trimmed = candidate:match("^%s*(.-)%s*$") or ""
|
||||||
|
if trimmed == "" then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
if #trimmed >= 2 then
|
||||||
|
local first = trimmed:sub(1, 1)
|
||||||
|
local last = trimmed:sub(-1)
|
||||||
|
if (first == '"' and last == '"') or (first == "'" and last == "'") then
|
||||||
|
trimmed = trimmed:sub(2, -2)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return trimmed ~= "" and trimmed or nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local function binary_candidates_from_app_path(app_path)
|
||||||
|
return {
|
||||||
|
utils.join_path(app_path, "Contents", "MacOS", "SubMiner"),
|
||||||
|
utils.join_path(app_path, "Contents", "MacOS", "subminer"),
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
local function file_exists(path)
|
||||||
|
local info = utils.file_info(path)
|
||||||
|
if not info then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
if info.is_dir ~= nil then
|
||||||
|
return not info.is_dir
|
||||||
|
end
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
local function resolve_binary_candidate(candidate)
|
||||||
|
local normalized = normalize_binary_path_candidate(candidate)
|
||||||
|
if not normalized then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
if file_exists(normalized) then
|
||||||
|
return normalized
|
||||||
|
end
|
||||||
|
|
||||||
|
if not normalized:lower():find("%.app") then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local app_root = normalized
|
||||||
|
if not app_root:lower():match("%.app$") then
|
||||||
|
app_root = normalized:match("(.+%.app)")
|
||||||
|
end
|
||||||
|
if not app_root then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
for _, path in ipairs(binary_candidates_from_app_path(app_root)) do
|
||||||
|
if file_exists(path) then
|
||||||
|
return path
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local function find_binary_override()
|
||||||
|
local candidates = {
|
||||||
|
resolve_binary_candidate(os.getenv("SUBMINER_APPIMAGE_PATH")),
|
||||||
|
resolve_binary_candidate(os.getenv("SUBMINER_BINARY_PATH")),
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, path in ipairs(candidates) do
|
||||||
|
if path and path ~= "" then
|
||||||
|
return path
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local function find_binary()
|
||||||
|
local override = find_binary_override()
|
||||||
|
if override then
|
||||||
|
return override
|
||||||
|
end
|
||||||
|
|
||||||
|
local configured = resolve_binary_candidate(opts.binary_path)
|
||||||
|
if configured then
|
||||||
|
return configured
|
||||||
|
end
|
||||||
|
|
||||||
|
local search_paths = {
|
||||||
|
"/Applications/SubMiner.app/Contents/MacOS/SubMiner",
|
||||||
|
utils.join_path(os.getenv("HOME") or "", "Applications/SubMiner.app/Contents/MacOS/SubMiner"),
|
||||||
|
"C:\\Program Files\\SubMiner\\SubMiner.exe",
|
||||||
|
"C:\\Program Files (x86)\\SubMiner\\SubMiner.exe",
|
||||||
|
"C:\\SubMiner\\SubMiner.exe",
|
||||||
|
utils.join_path(os.getenv("HOME") or "", ".local/bin/SubMiner.AppImage"),
|
||||||
|
"/opt/SubMiner/SubMiner.AppImage",
|
||||||
|
"/usr/local/bin/SubMiner",
|
||||||
|
"/usr/bin/SubMiner",
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, path in ipairs(search_paths) do
|
||||||
|
if file_exists(path) then
|
||||||
|
subminer_log("info", "binary", "Found binary at: " .. path)
|
||||||
|
return path
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local function ensure_binary_available()
|
||||||
|
if state.binary_available and state.binary_path and file_exists(state.binary_path) then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
local discovered = find_binary()
|
||||||
|
if discovered then
|
||||||
|
state.binary_path = discovered
|
||||||
|
state.binary_available = true
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
state.binary_path = nil
|
||||||
|
state.binary_available = false
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
return {
|
||||||
|
normalize_binary_path_candidate = normalize_binary_path_candidate,
|
||||||
|
file_exists = file_exists,
|
||||||
|
find_binary = find_binary,
|
||||||
|
ensure_binary_available = ensure_binary_available,
|
||||||
|
is_windows = environment.is_windows,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
return M
|
||||||
41
plugin/subminer/bootstrap.lua
Normal file
41
plugin/subminer/bootstrap.lua
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
local M = {}
|
||||||
|
|
||||||
|
function M.init()
|
||||||
|
local input = require("mp.input")
|
||||||
|
local mp = require("mp")
|
||||||
|
local msg = require("mp.msg")
|
||||||
|
local options_lib = require("mp.options")
|
||||||
|
local utils = require("mp.utils")
|
||||||
|
|
||||||
|
local options_helper = require("options")
|
||||||
|
local environment = require("environment").create({ mp = mp })
|
||||||
|
local opts = options_helper.load(options_lib, environment.default_socket_path())
|
||||||
|
local state = require("state").new()
|
||||||
|
|
||||||
|
local ctx = {
|
||||||
|
input = input,
|
||||||
|
mp = mp,
|
||||||
|
msg = msg,
|
||||||
|
utils = utils,
|
||||||
|
opts = opts,
|
||||||
|
state = state,
|
||||||
|
options_helper = options_helper,
|
||||||
|
environment = environment,
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.log = require("log").create(ctx)
|
||||||
|
ctx.binary = require("binary").create(ctx)
|
||||||
|
ctx.aniskip = require("aniskip").create(ctx)
|
||||||
|
ctx.hover = require("hover").create(ctx)
|
||||||
|
ctx.process = require("process").create(ctx)
|
||||||
|
ctx.ui = require("ui").create(ctx)
|
||||||
|
ctx.messages = require("messages").create(ctx)
|
||||||
|
ctx.lifecycle = require("lifecycle").create(ctx)
|
||||||
|
|
||||||
|
ctx.ui.register_keybindings()
|
||||||
|
ctx.messages.register_script_messages()
|
||||||
|
ctx.lifecycle.register_lifecycle_hooks()
|
||||||
|
ctx.log.subminer_log("info", "lifecycle", "SubMiner plugin loaded")
|
||||||
|
end
|
||||||
|
|
||||||
|
return M
|
||||||
130
plugin/subminer/environment.lua
Normal file
130
plugin/subminer/environment.lua
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
local M = {}
|
||||||
|
|
||||||
|
function M.create(ctx)
|
||||||
|
local mp = ctx.mp
|
||||||
|
|
||||||
|
local detected_backend = nil
|
||||||
|
|
||||||
|
local function is_windows()
|
||||||
|
return package.config:sub(1, 1) == "\\"
|
||||||
|
end
|
||||||
|
|
||||||
|
local function is_macos()
|
||||||
|
local platform = mp.get_property("platform") or ""
|
||||||
|
if platform == "macos" or platform == "darwin" then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
local ostype = os.getenv("OSTYPE") or ""
|
||||||
|
return ostype:find("darwin") ~= nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local function default_socket_path()
|
||||||
|
if is_windows() then
|
||||||
|
return "\\\\.\\pipe\\subminer-socket"
|
||||||
|
end
|
||||||
|
return "/tmp/subminer-socket"
|
||||||
|
end
|
||||||
|
|
||||||
|
local function is_linux()
|
||||||
|
return not is_windows() and not is_macos()
|
||||||
|
end
|
||||||
|
|
||||||
|
local function is_subminer_process_running()
|
||||||
|
local command = is_windows() and { "tasklist", "/FO", "CSV", "/NH" } or { "ps", "-A", "-o", "args=" }
|
||||||
|
local result = mp.command_native({
|
||||||
|
name = "subprocess",
|
||||||
|
args = command,
|
||||||
|
playback_only = false,
|
||||||
|
capture_stdout = true,
|
||||||
|
capture_stderr = false,
|
||||||
|
})
|
||||||
|
if not result or type(result.stdout) ~= "string" or result.status ~= 0 then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
local process_list = result.stdout:lower()
|
||||||
|
for line in process_list:gmatch("[^\n]+") do
|
||||||
|
if is_windows() then
|
||||||
|
local image = line:match('^"([^"]+)","')
|
||||||
|
if not image then
|
||||||
|
image = line:match('^"([^"]+)"')
|
||||||
|
end
|
||||||
|
if not image then
|
||||||
|
goto continue
|
||||||
|
end
|
||||||
|
if image == "subminer" or image == "subminer.exe" or image == "subminer.appimage" or image == "subminer.app" then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
if image:find("subminer", 1, true) and not image:find(".lua", 1, true) then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
else
|
||||||
|
local argv0 = line:match('^"([^"]+)"') or line:match("^%s*([^%s]+)")
|
||||||
|
if not argv0 then
|
||||||
|
goto continue
|
||||||
|
end
|
||||||
|
if argv0:find("subminer.lua", 1, true) or argv0:find("subminer.conf", 1, true) then
|
||||||
|
goto continue
|
||||||
|
end
|
||||||
|
local exe = argv0:match("([^/\\]+)$") or argv0
|
||||||
|
if exe == "SubMiner" or exe == "SubMiner.AppImage" or exe == "SubMiner.exe" or exe == "subminer" or exe == "subminer.appimage" or exe == "subminer.exe" then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
if exe:find("subminer", 1, true) and exe:find("%.lua", 1, true) == nil and exe:find("%.app", 1, true) == nil then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
::continue::
|
||||||
|
end
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
local function is_subminer_app_running()
|
||||||
|
return is_subminer_process_running()
|
||||||
|
end
|
||||||
|
|
||||||
|
local function detect_backend()
|
||||||
|
if detected_backend then
|
||||||
|
return detected_backend
|
||||||
|
end
|
||||||
|
|
||||||
|
local backend = nil
|
||||||
|
local subminer_log = ctx.log and ctx.log.subminer_log or function() end
|
||||||
|
|
||||||
|
if is_macos() then
|
||||||
|
backend = "macos"
|
||||||
|
elseif is_windows() then
|
||||||
|
backend = nil
|
||||||
|
elseif os.getenv("HYPRLAND_INSTANCE_SIGNATURE") then
|
||||||
|
backend = "hyprland"
|
||||||
|
elseif os.getenv("SWAYSOCK") then
|
||||||
|
backend = "sway"
|
||||||
|
elseif os.getenv("XDG_SESSION_TYPE") == "x11" or os.getenv("DISPLAY") then
|
||||||
|
backend = "x11"
|
||||||
|
else
|
||||||
|
subminer_log("warn", "backend", "Could not detect window manager, falling back to x11")
|
||||||
|
backend = "x11"
|
||||||
|
end
|
||||||
|
|
||||||
|
detected_backend = backend
|
||||||
|
if backend then
|
||||||
|
subminer_log("info", "backend", "Detected backend: " .. backend)
|
||||||
|
else
|
||||||
|
subminer_log("info", "backend", "No backend detected")
|
||||||
|
end
|
||||||
|
return backend
|
||||||
|
end
|
||||||
|
|
||||||
|
return {
|
||||||
|
is_windows = is_windows,
|
||||||
|
is_macos = is_macos,
|
||||||
|
is_linux = is_linux,
|
||||||
|
default_socket_path = default_socket_path,
|
||||||
|
is_subminer_process_running = is_subminer_process_running,
|
||||||
|
is_subminer_app_running = is_subminer_app_running,
|
||||||
|
detect_backend = detect_backend,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
return M
|
||||||
436
plugin/subminer/hover.lua
Normal file
436
plugin/subminer/hover.lua
Normal file
@@ -0,0 +1,436 @@
|
|||||||
|
local M = {}
|
||||||
|
|
||||||
|
local DEFAULT_HOVER_BASE_COLOR = "FFFFFF"
|
||||||
|
local DEFAULT_HOVER_COLOR = "C6A0F6"
|
||||||
|
|
||||||
|
function M.create(ctx)
|
||||||
|
local mp = ctx.mp
|
||||||
|
local msg = ctx.msg
|
||||||
|
local utils = ctx.utils
|
||||||
|
local state = ctx.state
|
||||||
|
|
||||||
|
local function to_hex_color(input)
|
||||||
|
if type(input) ~= "string" then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local hex = input:gsub("[%#%']", ""):gsub("^0x", "")
|
||||||
|
if #hex ~= 6 and #hex ~= 3 then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
if #hex == 3 then
|
||||||
|
return hex:sub(1, 1) .. hex:sub(1, 1) .. hex:sub(2, 2) .. hex:sub(2, 2) .. hex:sub(3, 3) .. hex:sub(3, 3)
|
||||||
|
end
|
||||||
|
return hex
|
||||||
|
end
|
||||||
|
|
||||||
|
local function fix_ass_color(input, fallback)
|
||||||
|
local hex = to_hex_color(input)
|
||||||
|
if not hex then
|
||||||
|
return fallback or DEFAULT_HOVER_BASE_COLOR
|
||||||
|
end
|
||||||
|
local r, g, b = hex:sub(1, 2), hex:sub(3, 4), hex:sub(5, 6)
|
||||||
|
return b .. g .. r
|
||||||
|
end
|
||||||
|
|
||||||
|
local function escape_ass_text(text)
|
||||||
|
return (text or ""):gsub("\\", "\\\\"):gsub("{", "\\{"):gsub("}", "\\}"):gsub("\n", "\\N")
|
||||||
|
end
|
||||||
|
|
||||||
|
local function resolve_osd_dimensions()
|
||||||
|
local width = mp.get_property_number("osd-width", 0) or 0
|
||||||
|
local height = mp.get_property_number("osd-height", 0) or 0
|
||||||
|
|
||||||
|
if width <= 0 or height <= 0 then
|
||||||
|
local osd_dims = mp.get_property_native("osd-dimensions")
|
||||||
|
if type(osd_dims) == "table" and type(osd_dims.w) == "number" and osd_dims.w > 0 then
|
||||||
|
width = osd_dims.w
|
||||||
|
end
|
||||||
|
if type(osd_dims) == "table" and type(osd_dims.h) == "number" and osd_dims.h > 0 then
|
||||||
|
height = osd_dims.h
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if width <= 0 then
|
||||||
|
width = 1280
|
||||||
|
end
|
||||||
|
if height <= 0 then
|
||||||
|
height = 720
|
||||||
|
end
|
||||||
|
|
||||||
|
return width, height
|
||||||
|
end
|
||||||
|
|
||||||
|
local function resolve_metrics()
|
||||||
|
local sub_font_size = mp.get_property_number("sub-font-size", 36) or 36
|
||||||
|
local sub_scale = mp.get_property_number("sub-scale", 1) or 1
|
||||||
|
local sub_scale_by_window = mp.get_property_bool("sub-scale-by-window", true) == true
|
||||||
|
local sub_pos = mp.get_property_number("sub-pos", 100) or 100
|
||||||
|
local sub_margin_y = mp.get_property_number("sub-margin-y", 0) or 0
|
||||||
|
local sub_font = mp.get_property("sub-font", "sans-serif") or "sans-serif"
|
||||||
|
local sub_spacing = mp.get_property_number("sub-spacing", 0) or 0
|
||||||
|
local sub_bold = mp.get_property_bool("sub-bold", false) == true
|
||||||
|
local sub_italic = mp.get_property_bool("sub-italic", false) == true
|
||||||
|
local sub_border_size = mp.get_property_number("sub-border-size", 2) or 2
|
||||||
|
local sub_shadow_offset = mp.get_property_number("sub-shadow-offset", 0) or 0
|
||||||
|
local osd_w, osd_h = resolve_osd_dimensions()
|
||||||
|
local window_scale = 1
|
||||||
|
if sub_scale_by_window and osd_h > 0 then
|
||||||
|
window_scale = osd_h / 720
|
||||||
|
end
|
||||||
|
local effective_margin_y = sub_margin_y * window_scale
|
||||||
|
|
||||||
|
return {
|
||||||
|
font_size = sub_font_size * (sub_scale > 0 and sub_scale or 1) * window_scale,
|
||||||
|
pos = sub_pos,
|
||||||
|
margin_y = effective_margin_y,
|
||||||
|
font = sub_font,
|
||||||
|
spacing = sub_spacing,
|
||||||
|
bold = sub_bold,
|
||||||
|
italic = sub_italic,
|
||||||
|
border = sub_border_size * window_scale,
|
||||||
|
shadow = sub_shadow_offset * window_scale,
|
||||||
|
base_color = fix_ass_color(mp.get_property("sub-color"), DEFAULT_HOVER_BASE_COLOR),
|
||||||
|
hover_color = fix_ass_color(DEFAULT_HOVER_COLOR, DEFAULT_HOVER_COLOR),
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
local function get_subtitle_ass_property()
|
||||||
|
local ass_text = mp.get_property("sub-text/ass")
|
||||||
|
if type(ass_text) == "string" and ass_text ~= "" then
|
||||||
|
return ass_text
|
||||||
|
end
|
||||||
|
ass_text = mp.get_property("sub-text-ass")
|
||||||
|
if type(ass_text) == "string" and ass_text ~= "" then
|
||||||
|
return ass_text
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local function plain_text_and_ass_map(text)
|
||||||
|
local plain = {}
|
||||||
|
local map = {}
|
||||||
|
local plain_len = 0
|
||||||
|
local i = 1
|
||||||
|
local text_len = #text
|
||||||
|
|
||||||
|
while i <= text_len do
|
||||||
|
local ch = text:sub(i, i)
|
||||||
|
if ch == "{" then
|
||||||
|
local close = text:find("}", i + 1, true)
|
||||||
|
if not close then
|
||||||
|
break
|
||||||
|
end
|
||||||
|
i = close + 1
|
||||||
|
elseif ch == "\\" then
|
||||||
|
local esc = text:sub(i + 1, i + 1)
|
||||||
|
if esc == "N" or esc == "n" then
|
||||||
|
plain_len = plain_len + 1
|
||||||
|
plain[plain_len] = "\n"
|
||||||
|
map[plain_len] = i
|
||||||
|
i = i + 2
|
||||||
|
elseif esc == "h" then
|
||||||
|
plain_len = plain_len + 1
|
||||||
|
plain[plain_len] = " "
|
||||||
|
map[plain_len] = i
|
||||||
|
i = i + 2
|
||||||
|
elseif esc == "{" then
|
||||||
|
plain_len = plain_len + 1
|
||||||
|
plain[plain_len] = "{"
|
||||||
|
map[plain_len] = i
|
||||||
|
i = i + 2
|
||||||
|
elseif esc == "}" then
|
||||||
|
plain_len = plain_len + 1
|
||||||
|
plain[plain_len] = "}"
|
||||||
|
map[plain_len] = i
|
||||||
|
i = i + 2
|
||||||
|
elseif esc == "\\" then
|
||||||
|
plain_len = plain_len + 1
|
||||||
|
plain[plain_len] = "\\"
|
||||||
|
map[plain_len] = i
|
||||||
|
i = i + 2
|
||||||
|
else
|
||||||
|
local seq_end = i + 1
|
||||||
|
while seq_end <= text_len and text:sub(seq_end, seq_end):match("[%a]") do
|
||||||
|
seq_end = seq_end + 1
|
||||||
|
end
|
||||||
|
if text:sub(seq_end, seq_end) == "(" then
|
||||||
|
local close = text:find(")", seq_end, true)
|
||||||
|
if close then
|
||||||
|
i = close + 1
|
||||||
|
else
|
||||||
|
i = seq_end + 1
|
||||||
|
end
|
||||||
|
else
|
||||||
|
i = seq_end + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
else
|
||||||
|
plain_len = plain_len + 1
|
||||||
|
plain[plain_len] = ch
|
||||||
|
map[plain_len] = i
|
||||||
|
i = i + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return table.concat(plain), map
|
||||||
|
end
|
||||||
|
|
||||||
|
local function find_hover_span(payload, plain)
|
||||||
|
local source_len = #plain
|
||||||
|
local cursor = 1
|
||||||
|
for _, token in ipairs(payload.tokens or {}) do
|
||||||
|
if type(token) ~= "table" or type(token.text) ~= "string" or token.text == "" then
|
||||||
|
goto continue
|
||||||
|
end
|
||||||
|
|
||||||
|
local token_text = token.text
|
||||||
|
local start_pos = nil
|
||||||
|
local end_pos = nil
|
||||||
|
|
||||||
|
if type(token.startPos) == "number" and type(token.endPos) == "number" then
|
||||||
|
if token.startPos >= 0 and token.endPos >= token.startPos then
|
||||||
|
local candidate_start = token.startPos + 1
|
||||||
|
local candidate_stop = token.endPos
|
||||||
|
if candidate_start >= 1 and candidate_stop <= source_len and candidate_stop >= candidate_start and plain:sub(candidate_start, candidate_stop) == token_text then
|
||||||
|
start_pos = candidate_start
|
||||||
|
end_pos = candidate_stop
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if not start_pos or not end_pos then
|
||||||
|
local fallback_start, fallback_stop = plain:find(token_text, cursor, true)
|
||||||
|
if not fallback_start then
|
||||||
|
fallback_start, fallback_stop = plain:find(token_text, 1, true)
|
||||||
|
end
|
||||||
|
start_pos, end_pos = fallback_start, fallback_stop
|
||||||
|
end
|
||||||
|
|
||||||
|
if start_pos and end_pos then
|
||||||
|
if token.index == payload.hoveredTokenIndex then
|
||||||
|
return start_pos, end_pos
|
||||||
|
end
|
||||||
|
cursor = end_pos + 1
|
||||||
|
end
|
||||||
|
|
||||||
|
::continue::
|
||||||
|
end
|
||||||
|
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local function inject_hover_color_to_ass(raw_ass, plain_map, hover_start, hover_end, hover_color, base_color)
|
||||||
|
if hover_start == nil or hover_end == nil then
|
||||||
|
return raw_ass
|
||||||
|
end
|
||||||
|
|
||||||
|
local raw_open_idx = plain_map[hover_start] or 1
|
||||||
|
local raw_close_idx = plain_map[hover_end + 1] or (#raw_ass + 1)
|
||||||
|
if raw_open_idx < 1 then
|
||||||
|
raw_open_idx = 1
|
||||||
|
end
|
||||||
|
if raw_close_idx < 1 then
|
||||||
|
raw_close_idx = 1
|
||||||
|
end
|
||||||
|
if raw_open_idx > #raw_ass + 1 then
|
||||||
|
raw_open_idx = #raw_ass + 1
|
||||||
|
end
|
||||||
|
if raw_close_idx > #raw_ass + 1 then
|
||||||
|
raw_close_idx = #raw_ass + 1
|
||||||
|
end
|
||||||
|
|
||||||
|
local open_tag = string.format("{\\1c&H%s&}", hover_color)
|
||||||
|
local close_tag = string.format("{\\1c&H%s&}", base_color)
|
||||||
|
local changes = {
|
||||||
|
{ idx = raw_open_idx, tag = open_tag },
|
||||||
|
{ idx = raw_close_idx, tag = close_tag },
|
||||||
|
}
|
||||||
|
table.sort(changes, function(a, b)
|
||||||
|
return a.idx < b.idx
|
||||||
|
end)
|
||||||
|
|
||||||
|
local output = {}
|
||||||
|
local cursor = 1
|
||||||
|
for _, change in ipairs(changes) do
|
||||||
|
if change.idx > #raw_ass + 1 then
|
||||||
|
change.idx = #raw_ass + 1
|
||||||
|
end
|
||||||
|
if change.idx < 1 then
|
||||||
|
change.idx = 1
|
||||||
|
end
|
||||||
|
if change.idx > cursor then
|
||||||
|
output[#output + 1] = raw_ass:sub(cursor, change.idx - 1)
|
||||||
|
end
|
||||||
|
output[#output + 1] = change.tag
|
||||||
|
cursor = change.idx
|
||||||
|
end
|
||||||
|
if cursor <= #raw_ass then
|
||||||
|
output[#output + 1] = raw_ass:sub(cursor)
|
||||||
|
end
|
||||||
|
|
||||||
|
return table.concat(output)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function build_hover_subtitle_content(payload)
|
||||||
|
local source_ass = get_subtitle_ass_property()
|
||||||
|
if type(source_ass) == "string" and source_ass ~= "" then
|
||||||
|
state.hover_highlight.cached_ass = source_ass
|
||||||
|
else
|
||||||
|
source_ass = state.hover_highlight.cached_ass
|
||||||
|
end
|
||||||
|
if type(source_ass) ~= "string" or source_ass == "" then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local plain_source, plain_map = plain_text_and_ass_map(source_ass)
|
||||||
|
if type(plain_source) ~= "string" or plain_source == "" then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local hover_start, hover_end = find_hover_span(payload, plain_source)
|
||||||
|
if not hover_start or not hover_end then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local metrics = resolve_metrics()
|
||||||
|
local hover_color = fix_ass_color(payload.colors and payload.colors.hover or nil, metrics.hover_color)
|
||||||
|
local base_color = fix_ass_color(payload.colors and payload.colors.base or nil, metrics.base_color)
|
||||||
|
return inject_hover_color_to_ass(source_ass, plain_map, hover_start, hover_end, hover_color, base_color)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function clear_hover_overlay()
|
||||||
|
if state.hover_highlight.clear_timer then
|
||||||
|
state.hover_highlight.clear_timer:kill()
|
||||||
|
state.hover_highlight.clear_timer = nil
|
||||||
|
end
|
||||||
|
if state.hover_highlight.overlay_active then
|
||||||
|
if type(state.hover_highlight.saved_sub_visibility) == "string" then
|
||||||
|
mp.set_property("sub-visibility", state.hover_highlight.saved_sub_visibility)
|
||||||
|
else
|
||||||
|
mp.set_property("sub-visibility", "yes")
|
||||||
|
end
|
||||||
|
if type(state.hover_highlight.saved_secondary_sub_visibility) == "string" then
|
||||||
|
mp.set_property("secondary-sub-visibility", state.hover_highlight.saved_secondary_sub_visibility)
|
||||||
|
end
|
||||||
|
state.hover_highlight.saved_sub_visibility = nil
|
||||||
|
state.hover_highlight.saved_secondary_sub_visibility = nil
|
||||||
|
state.hover_highlight.overlay_active = false
|
||||||
|
end
|
||||||
|
mp.set_osd_ass(0, 0, "")
|
||||||
|
state.hover_highlight.payload = nil
|
||||||
|
state.hover_highlight.revision = -1
|
||||||
|
state.hover_highlight.cached_ass = nil
|
||||||
|
state.hover_highlight.last_hover_update_ts = 0
|
||||||
|
end
|
||||||
|
|
||||||
|
local function schedule_hover_clear(delay_seconds)
|
||||||
|
if state.hover_highlight.clear_timer then
|
||||||
|
state.hover_highlight.clear_timer:kill()
|
||||||
|
state.hover_highlight.clear_timer = nil
|
||||||
|
end
|
||||||
|
state.hover_highlight.clear_timer = mp.add_timeout(delay_seconds or 0.08, function()
|
||||||
|
state.hover_highlight.clear_timer = nil
|
||||||
|
clear_hover_overlay()
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function render_hover_overlay(payload)
|
||||||
|
if not payload or payload.hoveredTokenIndex == nil or payload.subtitle == nil then
|
||||||
|
clear_hover_overlay()
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local ass = build_hover_subtitle_content(payload)
|
||||||
|
if not ass then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local osd_w, osd_h = resolve_osd_dimensions()
|
||||||
|
local metrics = resolve_metrics()
|
||||||
|
local osd_dims = mp.get_property_native("osd-dimensions")
|
||||||
|
local ml = (type(osd_dims) == "table" and type(osd_dims.ml) == "number") and osd_dims.ml or 0
|
||||||
|
local mr = (type(osd_dims) == "table" and type(osd_dims.mr) == "number") and osd_dims.mr or 0
|
||||||
|
local mt = (type(osd_dims) == "table" and type(osd_dims.mt) == "number") and osd_dims.mt or 0
|
||||||
|
local mb = (type(osd_dims) == "table" and type(osd_dims.mb) == "number") and osd_dims.mb or 0
|
||||||
|
local usable_w = math.max(1, osd_w - ml - mr)
|
||||||
|
local usable_h = math.max(1, osd_h - mt - mb)
|
||||||
|
local anchor_x = math.floor(ml + usable_w / 2)
|
||||||
|
local baseline_adjust = (metrics.border + metrics.shadow) * 5
|
||||||
|
local anchor_y = math.floor(mt + (usable_h * metrics.pos / 100) - metrics.margin_y + baseline_adjust)
|
||||||
|
local font_size = math.max(8, metrics.font_size)
|
||||||
|
local anchor_tag = string.format(
|
||||||
|
"{\\an2\\q2\\pos(%d,%d)\\fn%s\\fs%g\\b%d\\i%d\\fsp%g\\bord%g\\shad%g\\1c&H%s&}",
|
||||||
|
anchor_x,
|
||||||
|
anchor_y,
|
||||||
|
escape_ass_text(metrics.font),
|
||||||
|
font_size,
|
||||||
|
metrics.bold and 1 or 0,
|
||||||
|
metrics.italic and 1 or 0,
|
||||||
|
metrics.spacing,
|
||||||
|
metrics.border,
|
||||||
|
metrics.shadow,
|
||||||
|
metrics.base_color
|
||||||
|
)
|
||||||
|
if not state.hover_highlight.overlay_active then
|
||||||
|
state.hover_highlight.saved_sub_visibility = mp.get_property("sub-visibility")
|
||||||
|
state.hover_highlight.saved_secondary_sub_visibility = mp.get_property("secondary-sub-visibility")
|
||||||
|
mp.set_property("sub-visibility", "no")
|
||||||
|
mp.set_property("secondary-sub-visibility", "no")
|
||||||
|
state.hover_highlight.overlay_active = true
|
||||||
|
end
|
||||||
|
mp.set_osd_ass(osd_w, osd_h, anchor_tag .. ass)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function handle_hover_message(payload_json)
|
||||||
|
local parsed, parse_error = utils.parse_json(payload_json)
|
||||||
|
if not parsed then
|
||||||
|
msg.warn("Invalid hover-highlight payload: " .. tostring(parse_error))
|
||||||
|
clear_hover_overlay()
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
if type(parsed.revision) ~= "number" then
|
||||||
|
clear_hover_overlay()
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
if parsed.revision < state.hover_highlight.revision then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
if type(parsed.hoveredTokenIndex) == "number" and type(parsed.tokens) == "table" then
|
||||||
|
if state.hover_highlight.clear_timer then
|
||||||
|
state.hover_highlight.clear_timer:kill()
|
||||||
|
state.hover_highlight.clear_timer = nil
|
||||||
|
end
|
||||||
|
state.hover_highlight.revision = parsed.revision
|
||||||
|
state.hover_highlight.payload = parsed
|
||||||
|
state.hover_highlight.last_hover_update_ts = mp.get_time() or 0
|
||||||
|
render_hover_overlay(state.hover_highlight.payload)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local now = mp.get_time() or 0
|
||||||
|
local elapsed_since_hover = now - (state.hover_highlight.last_hover_update_ts or 0)
|
||||||
|
state.hover_highlight.revision = parsed.revision
|
||||||
|
state.hover_highlight.payload = nil
|
||||||
|
if state.hover_highlight.overlay_active then
|
||||||
|
if elapsed_since_hover > 0.35 then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
schedule_hover_clear(0.08)
|
||||||
|
else
|
||||||
|
clear_hover_overlay()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return {
|
||||||
|
HOVER_MESSAGE_NAME = "subminer-hover-token",
|
||||||
|
HOVER_MESSAGE_NAME_LEGACY = "yomipv-hover-token",
|
||||||
|
handle_hover_message = handle_hover_message,
|
||||||
|
clear_hover_overlay = clear_hover_overlay,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
return M
|
||||||
79
plugin/subminer/lifecycle.lua
Normal file
79
plugin/subminer/lifecycle.lua
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
local M = {}
|
||||||
|
|
||||||
|
function M.create(ctx)
|
||||||
|
local mp = ctx.mp
|
||||||
|
local opts = ctx.opts
|
||||||
|
local state = ctx.state
|
||||||
|
local environment = ctx.environment
|
||||||
|
local binary = ctx.binary
|
||||||
|
local options_helper = ctx.options_helper
|
||||||
|
local process = ctx.process
|
||||||
|
local aniskip = ctx.aniskip
|
||||||
|
local hover = ctx.hover
|
||||||
|
local subminer_log = ctx.log.subminer_log
|
||||||
|
local show_osd = ctx.log.show_osd
|
||||||
|
|
||||||
|
local function on_file_loaded()
|
||||||
|
if not environment.is_subminer_app_running() then
|
||||||
|
aniskip.clear_aniskip_state()
|
||||||
|
subminer_log("debug", "lifecycle", "Skipping file load hooks: SubMiner app not running")
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
aniskip.clear_aniskip_state()
|
||||||
|
aniskip.fetch_aniskip_for_current_media()
|
||||||
|
state.binary_path = binary.find_binary()
|
||||||
|
if state.binary_path then
|
||||||
|
state.binary_available = true
|
||||||
|
subminer_log("info", "lifecycle", "SubMiner ready (binary: " .. state.binary_path .. ")")
|
||||||
|
local should_auto_start = options_helper.coerce_bool(opts.auto_start, false)
|
||||||
|
if should_auto_start then
|
||||||
|
process.start_overlay()
|
||||||
|
end
|
||||||
|
else
|
||||||
|
state.binary_available = false
|
||||||
|
subminer_log("warn", "binary", "SubMiner binary not found - overlay features disabled")
|
||||||
|
if opts.binary_path ~= "" then
|
||||||
|
subminer_log("warn", "binary", "Configured path '" .. opts.binary_path .. "' does not exist")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function on_shutdown()
|
||||||
|
aniskip.clear_aniskip_state()
|
||||||
|
hover.clear_hover_overlay()
|
||||||
|
if (state.overlay_running or state.texthooker_running) and state.binary_available then
|
||||||
|
subminer_log("info", "lifecycle", "mpv shutting down, stopping SubMiner process")
|
||||||
|
show_osd("Shutting down...")
|
||||||
|
process.stop_overlay()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function register_lifecycle_hooks()
|
||||||
|
mp.register_event("file-loaded", on_file_loaded)
|
||||||
|
mp.register_event("shutdown", on_shutdown)
|
||||||
|
mp.register_event("file-loaded", hover.clear_hover_overlay)
|
||||||
|
mp.register_event("end-file", hover.clear_hover_overlay)
|
||||||
|
mp.register_event("shutdown", hover.clear_hover_overlay)
|
||||||
|
mp.register_event("end-file", aniskip.clear_aniskip_state)
|
||||||
|
mp.register_event("shutdown", aniskip.clear_aniskip_state)
|
||||||
|
mp.add_hook("on_unload", 10, function()
|
||||||
|
hover.clear_hover_overlay()
|
||||||
|
aniskip.clear_aniskip_state()
|
||||||
|
end)
|
||||||
|
mp.observe_property("sub-start", "native", function()
|
||||||
|
hover.clear_hover_overlay()
|
||||||
|
end)
|
||||||
|
mp.observe_property("time-pos", "number", function()
|
||||||
|
aniskip.update_intro_button_visibility()
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
return {
|
||||||
|
on_file_loaded = on_file_loaded,
|
||||||
|
on_shutdown = on_shutdown,
|
||||||
|
register_lifecycle_hooks = register_lifecycle_hooks,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
return M
|
||||||
60
plugin/subminer/log.lua
Normal file
60
plugin/subminer/log.lua
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
local M = {}
|
||||||
|
|
||||||
|
local LOG_LEVEL_PRIORITY = {
|
||||||
|
debug = 10,
|
||||||
|
info = 20,
|
||||||
|
warn = 30,
|
||||||
|
error = 40,
|
||||||
|
}
|
||||||
|
|
||||||
|
function M.create(ctx)
|
||||||
|
local mp = ctx.mp
|
||||||
|
local msg = ctx.msg
|
||||||
|
local opts = ctx.opts
|
||||||
|
|
||||||
|
local function normalize_log_level(level)
|
||||||
|
local normalized = (level or "info"):lower()
|
||||||
|
if LOG_LEVEL_PRIORITY[normalized] then
|
||||||
|
return normalized
|
||||||
|
end
|
||||||
|
return "info"
|
||||||
|
end
|
||||||
|
|
||||||
|
local function should_log(level)
|
||||||
|
local current = normalize_log_level(opts.log_level)
|
||||||
|
local target = normalize_log_level(level)
|
||||||
|
return LOG_LEVEL_PRIORITY[target] >= LOG_LEVEL_PRIORITY[current]
|
||||||
|
end
|
||||||
|
|
||||||
|
local function subminer_log(level, scope, message)
|
||||||
|
if not should_log(level) then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local timestamp = os.date("%Y-%m-%d %H:%M:%S")
|
||||||
|
local line = string.format("[subminer] - %s - %s - [%s] %s", timestamp, string.upper(level), scope, message)
|
||||||
|
if level == "error" then
|
||||||
|
msg.error(line)
|
||||||
|
elseif level == "warn" then
|
||||||
|
msg.warn(line)
|
||||||
|
elseif level == "debug" then
|
||||||
|
msg.debug(line)
|
||||||
|
else
|
||||||
|
msg.info(line)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function show_osd(message)
|
||||||
|
if opts.osd_messages then
|
||||||
|
mp.osd_message("SubMiner: " .. message, 3)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return {
|
||||||
|
normalize_log_level = normalize_log_level,
|
||||||
|
should_log = should_log,
|
||||||
|
subminer_log = subminer_log,
|
||||||
|
show_osd = show_osd,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
return M
|
||||||
9
plugin/subminer/main.lua
Normal file
9
plugin/subminer/main.lua
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
local mp = require("mp")
|
||||||
|
|
||||||
|
local script_dir = mp.get_script_directory() or "."
|
||||||
|
local module_patterns = script_dir .. "/?.lua;" .. script_dir .. "/?/init.lua;"
|
||||||
|
if not package.path:find(module_patterns, 1, true) then
|
||||||
|
package.path = module_patterns .. package.path
|
||||||
|
end
|
||||||
|
|
||||||
|
require("bootstrap").init()
|
||||||
36
plugin/subminer/messages.lua
Normal file
36
plugin/subminer/messages.lua
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
local M = {}
|
||||||
|
|
||||||
|
function M.create(ctx)
|
||||||
|
local mp = ctx.mp
|
||||||
|
local process = ctx.process
|
||||||
|
local aniskip = ctx.aniskip
|
||||||
|
local hover = ctx.hover
|
||||||
|
local ui = ctx.ui
|
||||||
|
|
||||||
|
local function register_script_messages()
|
||||||
|
mp.register_script_message("subminer-start", process.start_overlay_from_script_message)
|
||||||
|
mp.register_script_message("subminer-stop", process.stop_overlay)
|
||||||
|
mp.register_script_message("subminer-toggle", process.toggle_overlay)
|
||||||
|
mp.register_script_message("subminer-toggle-invisible", process.toggle_invisible_overlay)
|
||||||
|
mp.register_script_message("subminer-show-invisible", process.show_invisible_overlay)
|
||||||
|
mp.register_script_message("subminer-hide-invisible", process.hide_invisible_overlay)
|
||||||
|
mp.register_script_message("subminer-menu", ui.show_menu)
|
||||||
|
mp.register_script_message("subminer-options", process.open_options)
|
||||||
|
mp.register_script_message("subminer-restart", process.restart_overlay)
|
||||||
|
mp.register_script_message("subminer-status", process.check_status)
|
||||||
|
mp.register_script_message("subminer-aniskip-refresh", aniskip.fetch_aniskip_for_current_media)
|
||||||
|
mp.register_script_message("subminer-skip-intro", aniskip.skip_intro_now)
|
||||||
|
mp.register_script_message(hover.HOVER_MESSAGE_NAME, function(payload_json)
|
||||||
|
hover.handle_hover_message(payload_json)
|
||||||
|
end)
|
||||||
|
mp.register_script_message(hover.HOVER_MESSAGE_NAME_LEGACY, function(payload_json)
|
||||||
|
hover.handle_hover_message(payload_json)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
return {
|
||||||
|
register_script_messages = register_script_messages,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
return M
|
||||||
47
plugin/subminer/options.lua
Normal file
47
plugin/subminer/options.lua
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
local M = {}
|
||||||
|
|
||||||
|
function M.load(options_lib, default_socket_path)
|
||||||
|
local opts = {
|
||||||
|
binary_path = "",
|
||||||
|
socket_path = default_socket_path,
|
||||||
|
texthooker_enabled = true,
|
||||||
|
texthooker_port = 5174,
|
||||||
|
backend = "auto",
|
||||||
|
auto_start = true,
|
||||||
|
auto_start_overlay = false,
|
||||||
|
auto_start_visible_overlay = false,
|
||||||
|
auto_start_invisible_overlay = "platform-default",
|
||||||
|
osd_messages = true,
|
||||||
|
log_level = "info",
|
||||||
|
aniskip_enabled = true,
|
||||||
|
aniskip_title = "",
|
||||||
|
aniskip_season = "",
|
||||||
|
aniskip_mal_id = "",
|
||||||
|
aniskip_episode = "",
|
||||||
|
aniskip_show_button = true,
|
||||||
|
aniskip_button_text = "You can skip by pressing %s",
|
||||||
|
aniskip_button_key = "y-k",
|
||||||
|
aniskip_button_duration = 3,
|
||||||
|
}
|
||||||
|
|
||||||
|
options_lib.read_options(opts, "subminer")
|
||||||
|
return opts
|
||||||
|
end
|
||||||
|
|
||||||
|
function M.coerce_bool(value, fallback)
|
||||||
|
if type(value) == "boolean" then
|
||||||
|
return value
|
||||||
|
end
|
||||||
|
if type(value) == "string" then
|
||||||
|
local normalized = value:lower()
|
||||||
|
if normalized == "yes" or normalized == "true" or normalized == "1" or normalized == "on" then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
if normalized == "no" or normalized == "false" or normalized == "0" or normalized == "off" then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return fallback
|
||||||
|
end
|
||||||
|
|
||||||
|
return M
|
||||||
449
plugin/subminer/process.lua
Normal file
449
plugin/subminer/process.lua
Normal file
@@ -0,0 +1,449 @@
|
|||||||
|
local M = {}
|
||||||
|
|
||||||
|
function M.create(ctx)
|
||||||
|
local mp = ctx.mp
|
||||||
|
local opts = ctx.opts
|
||||||
|
local state = ctx.state
|
||||||
|
local binary = ctx.binary
|
||||||
|
local environment = ctx.environment
|
||||||
|
local options_helper = ctx.options_helper
|
||||||
|
local subminer_log = ctx.log.subminer_log
|
||||||
|
local show_osd = ctx.log.show_osd
|
||||||
|
local normalize_log_level = ctx.log.normalize_log_level
|
||||||
|
|
||||||
|
local function resolve_backend(override_backend)
|
||||||
|
local selected = override_backend
|
||||||
|
if selected == nil or selected == "" then
|
||||||
|
selected = opts.backend
|
||||||
|
end
|
||||||
|
if selected == "auto" then
|
||||||
|
return environment.detect_backend()
|
||||||
|
end
|
||||||
|
return selected
|
||||||
|
end
|
||||||
|
|
||||||
|
local function build_command_args(action, overrides)
|
||||||
|
overrides = overrides or {}
|
||||||
|
local args = { state.binary_path }
|
||||||
|
|
||||||
|
table.insert(args, "--" .. action)
|
||||||
|
local log_level = normalize_log_level(overrides.log_level or opts.log_level)
|
||||||
|
if log_level ~= "info" then
|
||||||
|
table.insert(args, "--log-level")
|
||||||
|
table.insert(args, log_level)
|
||||||
|
end
|
||||||
|
|
||||||
|
local needs_start_context = action == "start"
|
||||||
|
if needs_start_context then
|
||||||
|
local backend = resolve_backend(overrides.backend)
|
||||||
|
if backend and backend ~= "" then
|
||||||
|
table.insert(args, "--backend")
|
||||||
|
table.insert(args, backend)
|
||||||
|
end
|
||||||
|
|
||||||
|
local socket_path = overrides.socket_path or opts.socket_path
|
||||||
|
table.insert(args, "--socket")
|
||||||
|
table.insert(args, socket_path)
|
||||||
|
end
|
||||||
|
|
||||||
|
return args
|
||||||
|
end
|
||||||
|
|
||||||
|
local function run_control_command(action)
|
||||||
|
local args = build_command_args(action)
|
||||||
|
subminer_log("debug", "process", "Control command: " .. table.concat(args, " "))
|
||||||
|
local result = mp.command_native({
|
||||||
|
name = "subprocess",
|
||||||
|
args = args,
|
||||||
|
playback_only = false,
|
||||||
|
capture_stdout = true,
|
||||||
|
capture_stderr = true,
|
||||||
|
})
|
||||||
|
return result and result.status == 0
|
||||||
|
end
|
||||||
|
|
||||||
|
local function parse_start_script_message_overrides(...)
|
||||||
|
local overrides = {}
|
||||||
|
for i = 1, select("#", ...) do
|
||||||
|
local token = select(i, ...)
|
||||||
|
if type(token) == "string" and token ~= "" then
|
||||||
|
local key, value = token:match("^([%w_%-]+)=(.+)$")
|
||||||
|
if key and value then
|
||||||
|
local normalized_key = key:lower()
|
||||||
|
if normalized_key == "backend" then
|
||||||
|
local backend = value:lower()
|
||||||
|
if backend == "auto" or backend == "hyprland" or backend == "sway" or backend == "x11" or backend == "macos" then
|
||||||
|
overrides.backend = backend
|
||||||
|
end
|
||||||
|
elseif normalized_key == "socket" or normalized_key == "socket_path" then
|
||||||
|
overrides.socket_path = value
|
||||||
|
elseif normalized_key == "texthooker" or normalized_key == "texthooker_enabled" then
|
||||||
|
local parsed = options_helper.coerce_bool(value, nil)
|
||||||
|
if parsed ~= nil then
|
||||||
|
overrides.texthooker_enabled = parsed
|
||||||
|
end
|
||||||
|
elseif normalized_key == "log-level" or normalized_key == "log_level" then
|
||||||
|
overrides.log_level = normalize_log_level(value)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return overrides
|
||||||
|
end
|
||||||
|
|
||||||
|
local function resolve_visible_overlay_startup()
|
||||||
|
local visible = options_helper.coerce_bool(opts.auto_start_visible_overlay, false)
|
||||||
|
if options_helper.coerce_bool(opts.auto_start_overlay, false) then
|
||||||
|
visible = true
|
||||||
|
end
|
||||||
|
return visible
|
||||||
|
end
|
||||||
|
|
||||||
|
local function resolve_invisible_overlay_startup()
|
||||||
|
local raw = opts.auto_start_invisible_overlay
|
||||||
|
if type(raw) == "boolean" then
|
||||||
|
return raw
|
||||||
|
end
|
||||||
|
|
||||||
|
local mode = type(raw) == "string" and raw:lower() or "platform-default"
|
||||||
|
if mode == "visible" or mode == "show" or mode == "yes" or mode == "true" or mode == "on" then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
if mode == "hidden" or mode == "hide" or mode == "no" or mode == "false" or mode == "off" then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
return not environment.is_linux()
|
||||||
|
end
|
||||||
|
|
||||||
|
local function apply_startup_overlay_preferences()
|
||||||
|
local should_show_visible = resolve_visible_overlay_startup()
|
||||||
|
local should_show_invisible = resolve_invisible_overlay_startup()
|
||||||
|
|
||||||
|
local visible_action = should_show_visible and "show-visible-overlay" or "hide-visible-overlay"
|
||||||
|
if not run_control_command(visible_action) then
|
||||||
|
subminer_log("warn", "process", "Failed to apply visible startup action: " .. visible_action)
|
||||||
|
end
|
||||||
|
|
||||||
|
local invisible_action = should_show_invisible and "show-invisible-overlay" or "hide-invisible-overlay"
|
||||||
|
if not run_control_command(invisible_action) then
|
||||||
|
subminer_log("warn", "process", "Failed to apply invisible startup action: " .. invisible_action)
|
||||||
|
end
|
||||||
|
|
||||||
|
state.invisible_overlay_visible = should_show_invisible
|
||||||
|
end
|
||||||
|
|
||||||
|
local function build_texthooker_args()
|
||||||
|
local args = { state.binary_path, "--texthooker", "--port", tostring(opts.texthooker_port) }
|
||||||
|
local log_level = normalize_log_level(opts.log_level)
|
||||||
|
if log_level ~= "info" then
|
||||||
|
table.insert(args, "--log-level")
|
||||||
|
table.insert(args, log_level)
|
||||||
|
end
|
||||||
|
return args
|
||||||
|
end
|
||||||
|
|
||||||
|
local function ensure_texthooker_running(callback)
|
||||||
|
if not opts.texthooker_enabled then
|
||||||
|
callback()
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if state.texthooker_running then
|
||||||
|
callback()
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local args = build_texthooker_args()
|
||||||
|
subminer_log("info", "texthooker", "Starting texthooker process: " .. table.concat(args, " "))
|
||||||
|
state.texthooker_running = true
|
||||||
|
|
||||||
|
mp.command_native_async({
|
||||||
|
name = "subprocess",
|
||||||
|
args = args,
|
||||||
|
playback_only = false,
|
||||||
|
capture_stdout = true,
|
||||||
|
capture_stderr = true,
|
||||||
|
}, function(success, result, error)
|
||||||
|
if not success or (result and result.status ~= 0) then
|
||||||
|
state.texthooker_running = false
|
||||||
|
subminer_log("warn", "texthooker", "Texthooker process exited unexpectedly: " .. (error or (result and result.stderr) or "unknown error"))
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
mp.add_timeout(0.35, callback)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function start_overlay(overrides)
|
||||||
|
if not binary.ensure_binary_available() then
|
||||||
|
subminer_log("error", "binary", "SubMiner binary not found")
|
||||||
|
show_osd("Error: binary not found")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if state.overlay_running then
|
||||||
|
subminer_log("info", "process", "Overlay already running")
|
||||||
|
show_osd("Already running")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
overrides = overrides or {}
|
||||||
|
local texthooker_enabled = overrides.texthooker_enabled
|
||||||
|
if texthooker_enabled == nil then
|
||||||
|
texthooker_enabled = opts.texthooker_enabled
|
||||||
|
end
|
||||||
|
|
||||||
|
local function launch_overlay()
|
||||||
|
local args = build_command_args("start", overrides)
|
||||||
|
subminer_log("info", "process", "Starting overlay: " .. table.concat(args, " "))
|
||||||
|
show_osd("Starting...")
|
||||||
|
state.overlay_running = true
|
||||||
|
|
||||||
|
mp.command_native_async({
|
||||||
|
name = "subprocess",
|
||||||
|
args = args,
|
||||||
|
playback_only = false,
|
||||||
|
capture_stdout = true,
|
||||||
|
capture_stderr = true,
|
||||||
|
}, function(success, result, error)
|
||||||
|
if not success or (result and result.status ~= 0) then
|
||||||
|
state.overlay_running = false
|
||||||
|
subminer_log("error", "process", "Overlay start failed: " .. (error or (result and result.stderr) or "unknown error"))
|
||||||
|
show_osd("Overlay start failed")
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
mp.add_timeout(0.6, function()
|
||||||
|
apply_startup_overlay_preferences()
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
if texthooker_enabled then
|
||||||
|
ensure_texthooker_running(launch_overlay)
|
||||||
|
else
|
||||||
|
launch_overlay()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function start_overlay_from_script_message(...)
|
||||||
|
local overrides = parse_start_script_message_overrides(...)
|
||||||
|
start_overlay(overrides)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function stop_overlay()
|
||||||
|
if not binary.ensure_binary_available() then
|
||||||
|
subminer_log("error", "binary", "SubMiner binary not found")
|
||||||
|
show_osd("Error: binary not found")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local args = build_command_args("stop")
|
||||||
|
subminer_log("info", "process", "Stopping overlay: " .. table.concat(args, " "))
|
||||||
|
local result = mp.command_native({
|
||||||
|
name = "subprocess",
|
||||||
|
args = args,
|
||||||
|
playback_only = false,
|
||||||
|
capture_stdout = true,
|
||||||
|
capture_stderr = true,
|
||||||
|
})
|
||||||
|
|
||||||
|
state.overlay_running = false
|
||||||
|
state.texthooker_running = false
|
||||||
|
if result.status == 0 then
|
||||||
|
subminer_log("info", "process", "Overlay stopped")
|
||||||
|
else
|
||||||
|
subminer_log("warn", "process", "Stop command returned non-zero status: " .. tostring(result.status))
|
||||||
|
end
|
||||||
|
show_osd("Stopped")
|
||||||
|
end
|
||||||
|
|
||||||
|
local function toggle_overlay()
|
||||||
|
if not binary.ensure_binary_available() then
|
||||||
|
subminer_log("error", "binary", "SubMiner binary not found")
|
||||||
|
show_osd("Error: binary not found")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local args = build_command_args("toggle")
|
||||||
|
subminer_log("info", "process", "Toggling overlay: " .. table.concat(args, " "))
|
||||||
|
local result = mp.command_native({
|
||||||
|
name = "subprocess",
|
||||||
|
args = args,
|
||||||
|
playback_only = false,
|
||||||
|
capture_stdout = true,
|
||||||
|
capture_stderr = true,
|
||||||
|
})
|
||||||
|
if result and result.status ~= 0 then
|
||||||
|
subminer_log("warn", "process", "Toggle command failed")
|
||||||
|
show_osd("Toggle failed")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function toggle_invisible_overlay()
|
||||||
|
if not binary.ensure_binary_available() then
|
||||||
|
subminer_log("error", "binary", "SubMiner binary not found")
|
||||||
|
show_osd("Error: binary not found")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local args = build_command_args("toggle-invisible-overlay")
|
||||||
|
subminer_log("info", "process", "Toggling invisible overlay: " .. table.concat(args, " "))
|
||||||
|
local result = mp.command_native({
|
||||||
|
name = "subprocess",
|
||||||
|
args = args,
|
||||||
|
playback_only = false,
|
||||||
|
capture_stdout = true,
|
||||||
|
capture_stderr = true,
|
||||||
|
})
|
||||||
|
if result and result.status ~= 0 then
|
||||||
|
subminer_log("warn", "process", "Invisible toggle command failed")
|
||||||
|
show_osd("Invisible toggle failed")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
state.invisible_overlay_visible = not state.invisible_overlay_visible
|
||||||
|
show_osd("Invisible overlay: " .. (state.invisible_overlay_visible and "visible" or "hidden"))
|
||||||
|
end
|
||||||
|
|
||||||
|
local function show_invisible_overlay()
|
||||||
|
if not binary.ensure_binary_available() then
|
||||||
|
subminer_log("error", "binary", "SubMiner binary not found")
|
||||||
|
show_osd("Error: binary not found")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local args = build_command_args("show-invisible-overlay")
|
||||||
|
subminer_log("info", "process", "Showing invisible overlay: " .. table.concat(args, " "))
|
||||||
|
local result = mp.command_native({
|
||||||
|
name = "subprocess",
|
||||||
|
args = args,
|
||||||
|
playback_only = false,
|
||||||
|
capture_stdout = true,
|
||||||
|
capture_stderr = true,
|
||||||
|
})
|
||||||
|
if result and result.status ~= 0 then
|
||||||
|
subminer_log("warn", "process", "Show invisible command failed")
|
||||||
|
show_osd("Show invisible failed")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
state.invisible_overlay_visible = true
|
||||||
|
show_osd("Invisible overlay: visible")
|
||||||
|
end
|
||||||
|
|
||||||
|
local function hide_invisible_overlay()
|
||||||
|
if not binary.ensure_binary_available() then
|
||||||
|
subminer_log("error", "binary", "SubMiner binary not found")
|
||||||
|
show_osd("Error: binary not found")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local args = build_command_args("hide-invisible-overlay")
|
||||||
|
subminer_log("info", "process", "Hiding invisible overlay: " .. table.concat(args, " "))
|
||||||
|
local result = mp.command_native({
|
||||||
|
name = "subprocess",
|
||||||
|
args = args,
|
||||||
|
playback_only = false,
|
||||||
|
capture_stdout = true,
|
||||||
|
capture_stderr = true,
|
||||||
|
})
|
||||||
|
if result and result.status ~= 0 then
|
||||||
|
subminer_log("warn", "process", "Hide invisible command failed")
|
||||||
|
show_osd("Hide invisible failed")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
state.invisible_overlay_visible = false
|
||||||
|
show_osd("Invisible overlay: hidden")
|
||||||
|
end
|
||||||
|
|
||||||
|
local function open_options()
|
||||||
|
if not state.binary_available then
|
||||||
|
subminer_log("error", "binary", "SubMiner binary not found")
|
||||||
|
show_osd("Error: binary not found")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local args = build_command_args("settings")
|
||||||
|
subminer_log("info", "process", "Opening options: " .. table.concat(args, " "))
|
||||||
|
local result = mp.command_native({
|
||||||
|
name = "subprocess",
|
||||||
|
args = args,
|
||||||
|
playback_only = false,
|
||||||
|
capture_stdout = true,
|
||||||
|
capture_stderr = true,
|
||||||
|
})
|
||||||
|
if result.status == 0 then
|
||||||
|
subminer_log("info", "process", "Options window opened")
|
||||||
|
show_osd("Options opened")
|
||||||
|
else
|
||||||
|
subminer_log("warn", "process", "Failed to open options")
|
||||||
|
show_osd("Failed to open options")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function restart_overlay()
|
||||||
|
if not binary.ensure_binary_available() then
|
||||||
|
subminer_log("error", "binary", "SubMiner binary not found")
|
||||||
|
show_osd("Error: binary not found")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
subminer_log("info", "process", "Restarting overlay...")
|
||||||
|
show_osd("Restarting...")
|
||||||
|
|
||||||
|
local stop_args = build_command_args("stop")
|
||||||
|
mp.command_native({
|
||||||
|
name = "subprocess",
|
||||||
|
args = stop_args,
|
||||||
|
playback_only = false,
|
||||||
|
capture_stdout = true,
|
||||||
|
capture_stderr = true,
|
||||||
|
})
|
||||||
|
|
||||||
|
state.overlay_running = false
|
||||||
|
state.texthooker_running = false
|
||||||
|
|
||||||
|
ensure_texthooker_running(function()
|
||||||
|
local start_args = build_command_args("start")
|
||||||
|
subminer_log("info", "process", "Starting overlay: " .. table.concat(start_args, " "))
|
||||||
|
|
||||||
|
state.overlay_running = true
|
||||||
|
mp.command_native_async({
|
||||||
|
name = "subprocess",
|
||||||
|
args = start_args,
|
||||||
|
playback_only = false,
|
||||||
|
capture_stdout = true,
|
||||||
|
capture_stderr = true,
|
||||||
|
}, function(success, result, error)
|
||||||
|
if not success or (result and result.status ~= 0) then
|
||||||
|
state.overlay_running = false
|
||||||
|
subminer_log("error", "process", "Overlay start failed: " .. (error or (result and result.stderr) or "unknown error"))
|
||||||
|
show_osd("Restart failed")
|
||||||
|
else
|
||||||
|
show_osd("Restarted successfully")
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function check_status()
|
||||||
|
if not state.binary_available then
|
||||||
|
show_osd("Status: binary not found")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local status = state.overlay_running and "running" or "stopped"
|
||||||
|
show_osd("Status: overlay is " .. status)
|
||||||
|
subminer_log("info", "process", "Status check: overlay is " .. status)
|
||||||
|
end
|
||||||
|
|
||||||
|
return {
|
||||||
|
build_command_args = build_command_args,
|
||||||
|
run_control_command = run_control_command,
|
||||||
|
parse_start_script_message_overrides = parse_start_script_message_overrides,
|
||||||
|
apply_startup_overlay_preferences = apply_startup_overlay_preferences,
|
||||||
|
ensure_texthooker_running = ensure_texthooker_running,
|
||||||
|
start_overlay = start_overlay,
|
||||||
|
start_overlay_from_script_message = start_overlay_from_script_message,
|
||||||
|
stop_overlay = stop_overlay,
|
||||||
|
toggle_overlay = toggle_overlay,
|
||||||
|
toggle_invisible_overlay = toggle_invisible_overlay,
|
||||||
|
show_invisible_overlay = show_invisible_overlay,
|
||||||
|
hide_invisible_overlay = hide_invisible_overlay,
|
||||||
|
open_options = open_options,
|
||||||
|
restart_overlay = restart_overlay,
|
||||||
|
check_status = check_status,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
return M
|
||||||
33
plugin/subminer/state.lua
Normal file
33
plugin/subminer/state.lua
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
local M = {}
|
||||||
|
|
||||||
|
function M.new()
|
||||||
|
return {
|
||||||
|
overlay_running = false,
|
||||||
|
texthooker_running = false,
|
||||||
|
overlay_process = nil,
|
||||||
|
binary_available = false,
|
||||||
|
binary_path = nil,
|
||||||
|
invisible_overlay_visible = false,
|
||||||
|
hover_highlight = {
|
||||||
|
revision = -1,
|
||||||
|
payload = nil,
|
||||||
|
saved_sub_visibility = nil,
|
||||||
|
saved_secondary_sub_visibility = nil,
|
||||||
|
overlay_active = false,
|
||||||
|
cached_ass = nil,
|
||||||
|
clear_timer = nil,
|
||||||
|
last_hover_update_ts = 0,
|
||||||
|
},
|
||||||
|
aniskip = {
|
||||||
|
mal_id = nil,
|
||||||
|
title = nil,
|
||||||
|
episode = nil,
|
||||||
|
intro_start = nil,
|
||||||
|
intro_end = nil,
|
||||||
|
found = false,
|
||||||
|
prompt_shown = false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
return M
|
||||||
76
plugin/subminer/ui.lua
Normal file
76
plugin/subminer/ui.lua
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
local M = {}
|
||||||
|
|
||||||
|
function M.create(ctx)
|
||||||
|
local mp = ctx.mp
|
||||||
|
local input = ctx.input
|
||||||
|
local opts = ctx.opts
|
||||||
|
local state = ctx.state
|
||||||
|
local process = ctx.process
|
||||||
|
local aniskip = ctx.aniskip
|
||||||
|
local subminer_log = ctx.log.subminer_log
|
||||||
|
local show_osd = ctx.log.show_osd
|
||||||
|
|
||||||
|
local function show_menu()
|
||||||
|
if not state.binary_available then
|
||||||
|
subminer_log("error", "binary", "SubMiner binary not found")
|
||||||
|
show_osd("Error: binary not found")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local items = {
|
||||||
|
"Start overlay",
|
||||||
|
"Stop overlay",
|
||||||
|
"Toggle overlay",
|
||||||
|
"Toggle invisible overlay",
|
||||||
|
"Open options",
|
||||||
|
"Restart overlay",
|
||||||
|
"Check status",
|
||||||
|
}
|
||||||
|
|
||||||
|
local actions = {
|
||||||
|
process.start_overlay,
|
||||||
|
process.stop_overlay,
|
||||||
|
process.toggle_overlay,
|
||||||
|
process.toggle_invisible_overlay,
|
||||||
|
process.open_options,
|
||||||
|
process.restart_overlay,
|
||||||
|
process.check_status,
|
||||||
|
}
|
||||||
|
|
||||||
|
input.select({
|
||||||
|
prompt = "SubMiner: ",
|
||||||
|
items = items,
|
||||||
|
submit = function(index)
|
||||||
|
if index and actions[index] then
|
||||||
|
actions[index]()
|
||||||
|
end
|
||||||
|
end,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
local function register_keybindings()
|
||||||
|
mp.add_key_binding("y-s", "subminer-start", process.start_overlay)
|
||||||
|
mp.add_key_binding("y-S", "subminer-stop", process.stop_overlay)
|
||||||
|
mp.add_key_binding("y-t", "subminer-toggle", process.toggle_overlay)
|
||||||
|
mp.add_key_binding("y-i", "subminer-toggle-invisible", process.toggle_invisible_overlay)
|
||||||
|
mp.add_key_binding("y-I", "subminer-show-invisible", process.show_invisible_overlay)
|
||||||
|
mp.add_key_binding("y-u", "subminer-hide-invisible", process.hide_invisible_overlay)
|
||||||
|
mp.add_key_binding("y-y", "subminer-menu", show_menu)
|
||||||
|
mp.add_key_binding("y-o", "subminer-options", process.open_options)
|
||||||
|
mp.add_key_binding("y-r", "subminer-restart", process.restart_overlay)
|
||||||
|
mp.add_key_binding("y-c", "subminer-status", process.check_status)
|
||||||
|
if type(opts.aniskip_button_key) == "string" and opts.aniskip_button_key ~= "" then
|
||||||
|
mp.add_key_binding(opts.aniskip_button_key, "subminer-skip-intro", aniskip.skip_intro_now)
|
||||||
|
end
|
||||||
|
if opts.aniskip_button_key ~= "y-k" then
|
||||||
|
mp.add_key_binding("y-k", "subminer-skip-intro-fallback", aniskip.skip_intro_now)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return {
|
||||||
|
show_menu = show_menu,
|
||||||
|
register_keybindings = register_keybindings,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
return M
|
||||||
@@ -3,7 +3,9 @@ local function run_plugin_scenario(config)
|
|||||||
|
|
||||||
local recorded = {
|
local recorded = {
|
||||||
async_calls = {},
|
async_calls = {},
|
||||||
|
sync_calls = {},
|
||||||
script_messages = {},
|
script_messages = {},
|
||||||
|
events = {},
|
||||||
osd = {},
|
osd = {},
|
||||||
logs = {},
|
logs = {},
|
||||||
}
|
}
|
||||||
@@ -35,6 +37,7 @@ local function run_plugin_scenario(config)
|
|||||||
end
|
end
|
||||||
|
|
||||||
function mp.command_native(command)
|
function mp.command_native(command)
|
||||||
|
recorded.sync_calls[#recorded.sync_calls + 1] = command
|
||||||
local args = command.args or {}
|
local args = command.args or {}
|
||||||
if args[1] == "ps" then
|
if args[1] == "ps" then
|
||||||
return {
|
return {
|
||||||
@@ -67,7 +70,12 @@ local function run_plugin_scenario(config)
|
|||||||
end
|
end
|
||||||
|
|
||||||
function mp.add_key_binding(_keys, _name, _fn) end
|
function mp.add_key_binding(_keys, _name, _fn) end
|
||||||
function mp.register_event(_name, _fn) end
|
function mp.register_event(name, fn)
|
||||||
|
if not recorded.events[name] then
|
||||||
|
recorded.events[name] = {}
|
||||||
|
end
|
||||||
|
recorded.events[name][#recorded.events[name] + 1] = fn
|
||||||
|
end
|
||||||
function mp.add_hook(_name, _prio, _fn) end
|
function mp.add_hook(_name, _prio, _fn) end
|
||||||
function mp.observe_property(_name, _kind, _fn) end
|
function mp.observe_property(_name, _kind, _fn) end
|
||||||
function mp.osd_message(message, _duration)
|
function mp.osd_message(message, _duration)
|
||||||
@@ -78,9 +86,20 @@ local function run_plugin_scenario(config)
|
|||||||
end
|
end
|
||||||
function mp.commandv(...) end
|
function mp.commandv(...) end
|
||||||
function mp.set_property_native(...) end
|
function mp.set_property_native(...) end
|
||||||
|
function mp.set_property(...) end
|
||||||
|
function mp.set_osd_ass(...) end
|
||||||
|
function mp.get_property_number(_name, default)
|
||||||
|
return default
|
||||||
|
end
|
||||||
|
function mp.get_property_bool(_name, default)
|
||||||
|
return default
|
||||||
|
end
|
||||||
function mp.get_script_name()
|
function mp.get_script_name()
|
||||||
return "subminer"
|
return "subminer"
|
||||||
end
|
end
|
||||||
|
function mp.get_script_directory()
|
||||||
|
return "plugin/subminer"
|
||||||
|
end
|
||||||
|
|
||||||
return mp
|
return mp
|
||||||
end
|
end
|
||||||
@@ -93,6 +112,9 @@ local function run_plugin_scenario(config)
|
|||||||
if config.socket_path then
|
if config.socket_path then
|
||||||
target.socket_path = config.socket_path
|
target.socket_path = config.socket_path
|
||||||
end
|
end
|
||||||
|
if config.binary_path then
|
||||||
|
target.binary_path = config.binary_path
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
function utils.file_info(path)
|
function utils.file_info(path)
|
||||||
@@ -117,6 +139,30 @@ local function run_plugin_scenario(config)
|
|||||||
package.loaded["mp.msg"] = nil
|
package.loaded["mp.msg"] = nil
|
||||||
package.loaded["mp.options"] = nil
|
package.loaded["mp.options"] = nil
|
||||||
package.loaded["mp.utils"] = nil
|
package.loaded["mp.utils"] = nil
|
||||||
|
for key, _ in pairs(package.loaded) do
|
||||||
|
if key:match("^subminer") then
|
||||||
|
package.loaded[key] = nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
local plugin_modules = {
|
||||||
|
"init",
|
||||||
|
"bootstrap",
|
||||||
|
"options",
|
||||||
|
"state",
|
||||||
|
"log",
|
||||||
|
"binary",
|
||||||
|
"environment",
|
||||||
|
"process",
|
||||||
|
"aniskip",
|
||||||
|
"aniskip_match",
|
||||||
|
"hover",
|
||||||
|
"ui",
|
||||||
|
"messages",
|
||||||
|
"lifecycle",
|
||||||
|
}
|
||||||
|
for _, module_name in ipairs(plugin_modules) do
|
||||||
|
package.loaded[module_name] = nil
|
||||||
|
end
|
||||||
|
|
||||||
package.preload["mp"] = function()
|
package.preload["mp"] = function()
|
||||||
return mp
|
return mp
|
||||||
@@ -149,10 +195,15 @@ local function run_plugin_scenario(config)
|
|||||||
return utils
|
return utils
|
||||||
end
|
end
|
||||||
|
|
||||||
local ok, err = pcall(dofile, "plugin/subminer.lua")
|
local ok, err = pcall(dofile, "plugin/subminer/main.lua")
|
||||||
if not ok then
|
if not ok then
|
||||||
return nil, err, recorded
|
return nil, err, recorded
|
||||||
end
|
end
|
||||||
|
if config.trigger_file_loaded and recorded.events["file-loaded"] then
|
||||||
|
for _, callback in ipairs(recorded.events["file-loaded"]) do
|
||||||
|
callback()
|
||||||
|
end
|
||||||
|
end
|
||||||
return recorded, nil, recorded
|
return recorded, nil, recorded
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -168,6 +219,18 @@ local function find_start_call(async_calls)
|
|||||||
local args = call.args or {}
|
local args = call.args or {}
|
||||||
for i = 1, #args do
|
for i = 1, #args do
|
||||||
if args[i] == "--start" then
|
if args[i] == "--start" then
|
||||||
|
return call
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local function has_command_flag(calls, flag)
|
||||||
|
for _, call in ipairs(calls) do
|
||||||
|
local args = call.args or {}
|
||||||
|
for i = 1, #args do
|
||||||
|
if args[i] == flag then
|
||||||
return true
|
return true
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -175,19 +238,151 @@ local function find_start_call(async_calls)
|
|||||||
return false
|
return false
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local function has_sync_command(sync_calls, executable)
|
||||||
|
for _, call in ipairs(sync_calls) do
|
||||||
|
local args = call.args or {}
|
||||||
|
if args[1] == executable then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
local function make_hover_context(config)
|
||||||
|
local state = require("state").new()
|
||||||
|
local captured = {
|
||||||
|
osd_ass = nil,
|
||||||
|
}
|
||||||
|
local mp = {}
|
||||||
|
|
||||||
|
function mp.get_property(name)
|
||||||
|
if name == "sub-text/ass" then
|
||||||
|
return config.ass_text or ""
|
||||||
|
end
|
||||||
|
if name == "sub-text-ass" then
|
||||||
|
return ""
|
||||||
|
end
|
||||||
|
if name == "sub-color" then
|
||||||
|
return config.sub_color
|
||||||
|
end
|
||||||
|
if name == "sub-font" then
|
||||||
|
return "sans-serif"
|
||||||
|
end
|
||||||
|
if name == "sub-visibility" or name == "secondary-sub-visibility" then
|
||||||
|
return "yes"
|
||||||
|
end
|
||||||
|
return ""
|
||||||
|
end
|
||||||
|
|
||||||
|
function mp.get_property_number(_name, default)
|
||||||
|
return default
|
||||||
|
end
|
||||||
|
|
||||||
|
function mp.get_property_bool(_name, default)
|
||||||
|
return default
|
||||||
|
end
|
||||||
|
|
||||||
|
function mp.get_property_native(name)
|
||||||
|
if name == "osd-dimensions" then
|
||||||
|
return { w = 1280, h = 720, ml = 0, mr = 0, mt = 0, mb = 0 }
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
function mp.set_property(_name, _value) end
|
||||||
|
|
||||||
|
function mp.set_osd_ass(_w, _h, text)
|
||||||
|
captured.osd_ass = text
|
||||||
|
end
|
||||||
|
|
||||||
|
function mp.get_time()
|
||||||
|
return 0
|
||||||
|
end
|
||||||
|
|
||||||
|
function mp.add_timeout(_seconds, callback)
|
||||||
|
if callback then
|
||||||
|
callback()
|
||||||
|
end
|
||||||
|
return {
|
||||||
|
kill = function() end,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
return {
|
||||||
|
ctx = {
|
||||||
|
mp = mp,
|
||||||
|
msg = { warn = function(_) end },
|
||||||
|
utils = {
|
||||||
|
parse_json = function(_)
|
||||||
|
return {
|
||||||
|
revision = 1,
|
||||||
|
hoveredTokenIndex = 0,
|
||||||
|
subtitle = "hello world",
|
||||||
|
tokens = {
|
||||||
|
{ index = 0, text = "hello", startPos = 0, endPos = 5 },
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
end,
|
||||||
|
},
|
||||||
|
state = state,
|
||||||
|
},
|
||||||
|
captured = captured,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
local binary_path = "/tmp/subminer-binary"
|
local binary_path = "/tmp/subminer-binary"
|
||||||
|
|
||||||
do
|
do
|
||||||
local recorded, err = run_plugin_scenario({
|
local recorded, err = run_plugin_scenario({
|
||||||
process_list = "",
|
process_list = "",
|
||||||
|
binary_path = binary_path,
|
||||||
files = {
|
files = {
|
||||||
[binary_path] = true,
|
[binary_path] = true,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
assert_true(recorded ~= nil, "plugin failed to load for cold-start scenario: " .. tostring(err))
|
assert_true(recorded ~= nil, "plugin failed to load for cold-start scenario: " .. tostring(err))
|
||||||
assert_true(recorded.script_messages["subminer-start"] ~= nil, "subminer-start script message not registered")
|
assert_true(recorded.script_messages["subminer-start"] ~= nil, "subminer-start script message not registered")
|
||||||
|
assert_true(recorded.script_messages["subminer-stop"] ~= nil, "subminer-stop script message not registered")
|
||||||
recorded.script_messages["subminer-start"]("texthooker=no")
|
recorded.script_messages["subminer-start"]("texthooker=no")
|
||||||
assert_true(find_start_call(recorded.async_calls), "expected cold-start to invoke --start command when process is absent")
|
assert_true(find_start_call(recorded.async_calls) ~= nil, "expected cold-start to invoke --start command when process is absent")
|
||||||
|
recorded.script_messages["subminer-stop"]()
|
||||||
|
assert_true(has_command_flag(recorded.sync_calls, "--stop"), "expected stop message to invoke --stop command")
|
||||||
|
assert_true(
|
||||||
|
not has_sync_command(recorded.sync_calls, "ps"),
|
||||||
|
"expected cold-start start command to avoid synchronous process list scan"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
do
|
||||||
|
local recorded, err = run_plugin_scenario({
|
||||||
|
process_list = "python\nSubMiner\n",
|
||||||
|
filename_no_ext = "Some Show - S01E01",
|
||||||
|
trigger_file_loaded = true,
|
||||||
|
binary_path = binary_path,
|
||||||
|
files = {
|
||||||
|
[binary_path] = true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
assert_true(recorded ~= nil, "plugin failed to load for process split scenario: " .. tostring(err))
|
||||||
|
assert_true(has_sync_command(recorded.sync_calls, "ps"), "expected file-loaded hook to read process list")
|
||||||
|
assert_true(
|
||||||
|
has_sync_command(recorded.sync_calls, "curl"),
|
||||||
|
"expected file-loaded hook to run AniSkip lookup when SubMiner process is present in ps output"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
do
|
||||||
|
local hover_context = make_hover_context({
|
||||||
|
ass_text = "hello world",
|
||||||
|
sub_color = "112233",
|
||||||
|
})
|
||||||
|
local hover = require("hover").create(hover_context.ctx)
|
||||||
|
hover.handle_hover_message("{}")
|
||||||
|
assert_true(type(hover_context.captured.osd_ass) == "string", "expected hover overlay render to write ASS output")
|
||||||
|
assert_true(
|
||||||
|
hover_context.captured.osd_ass:find("\\1c&HF6A0C6&", 1, true) ~= nil,
|
||||||
|
"expected hover render to keep accent hover color when sub-color is configured"
|
||||||
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
print("plugin start gate regression tests: OK")
|
print("plugin start gate regression tests: OK")
|
||||||
|
|||||||
@@ -30,10 +30,18 @@ function createStorage(encryptionAvailable: boolean): SafeStorageLike {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createPassthroughStorage(): SafeStorageLike {
|
||||||
|
return {
|
||||||
|
isEncryptionAvailable: () => true,
|
||||||
|
encryptString: (value: string) => Buffer.from(value, 'utf-8'),
|
||||||
|
decryptString: (value: Buffer) => value.toString('utf-8'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
test('anilist token store saves and loads encrypted token', () => {
|
test('anilist token store saves and loads encrypted token', () => {
|
||||||
const filePath = createTempTokenFile();
|
const filePath = createTempTokenFile();
|
||||||
const store = createAnilistTokenStore(filePath, createLogger(), createStorage(true));
|
const store = createAnilistTokenStore(filePath, createLogger(), createStorage(true));
|
||||||
store.saveToken(' demo-token ');
|
assert.equal(store.saveToken(' demo-token '), true);
|
||||||
|
|
||||||
const payload = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as {
|
const payload = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as {
|
||||||
encryptedToken?: string;
|
encryptedToken?: string;
|
||||||
@@ -44,16 +52,13 @@ test('anilist token store saves and loads encrypted token', () => {
|
|||||||
assert.equal(store.loadToken(), 'demo-token');
|
assert.equal(store.loadToken(), 'demo-token');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('anilist token store falls back to plaintext when encryption unavailable', () => {
|
test('anilist token store refuses to persist token when encryption unavailable', () => {
|
||||||
const filePath = createTempTokenFile();
|
const filePath = createTempTokenFile();
|
||||||
const store = createAnilistTokenStore(filePath, createLogger(), createStorage(false));
|
const store = createAnilistTokenStore(filePath, createLogger(), createStorage(false));
|
||||||
store.saveToken('plain-token');
|
assert.equal(store.saveToken('plain-token'), false);
|
||||||
|
|
||||||
const payload = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as {
|
assert.equal(fs.existsSync(filePath), false);
|
||||||
plaintextToken?: string;
|
assert.equal(store.loadToken(), null);
|
||||||
};
|
|
||||||
assert.equal(payload.plaintextToken, 'plain-token');
|
|
||||||
assert.equal(store.loadToken(), 'plain-token');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('anilist token store migrates legacy plaintext to encrypted', () => {
|
test('anilist token store migrates legacy plaintext to encrypted', () => {
|
||||||
@@ -75,6 +80,13 @@ test('anilist token store migrates legacy plaintext to encrypted', () => {
|
|||||||
assert.equal(payload.plaintextToken, undefined);
|
assert.equal(payload.plaintextToken, undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('anilist token store refuses passthrough safeStorage implementation', () => {
|
||||||
|
const filePath = createTempTokenFile();
|
||||||
|
const store = createAnilistTokenStore(filePath, createLogger(), createPassthroughStorage());
|
||||||
|
assert.equal(store.saveToken('demo-token'), false);
|
||||||
|
assert.equal(store.loadToken(), null);
|
||||||
|
});
|
||||||
|
|
||||||
test('anilist token store clears persisted token file', () => {
|
test('anilist token store clears persisted token file', () => {
|
||||||
const filePath = createTempTokenFile();
|
const filePath = createTempTokenFile();
|
||||||
const store = createAnilistTokenStore(filePath, createLogger(), createStorage(true));
|
const store = createAnilistTokenStore(filePath, createLogger(), createStorage(true));
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ interface PersistedTokenPayload {
|
|||||||
|
|
||||||
export interface AnilistTokenStore {
|
export interface AnilistTokenStore {
|
||||||
loadToken: () => string | null;
|
loadToken: () => string | null;
|
||||||
saveToken: (token: string) => void;
|
saveToken: (token: string) => boolean;
|
||||||
clearToken: () => void;
|
clearToken: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -18,6 +18,7 @@ export interface SafeStorageLike {
|
|||||||
isEncryptionAvailable: () => boolean;
|
isEncryptionAvailable: () => boolean;
|
||||||
encryptString: (value: string) => Buffer;
|
encryptString: (value: string) => Buffer;
|
||||||
decryptString: (value: Buffer) => string;
|
decryptString: (value: Buffer) => string;
|
||||||
|
getSelectedStorageBackend?: () => string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ensureDirectory(filePath: string): void {
|
function ensureDirectory(filePath: string): void {
|
||||||
@@ -38,9 +39,80 @@ export function createAnilistTokenStore(
|
|||||||
info: (message: string) => void;
|
info: (message: string) => void;
|
||||||
warn: (message: string, details?: unknown) => void;
|
warn: (message: string, details?: unknown) => void;
|
||||||
error: (message: string, details?: unknown) => void;
|
error: (message: string, details?: unknown) => void;
|
||||||
|
warnUser?: (message: string) => void;
|
||||||
},
|
},
|
||||||
storage: SafeStorageLike = electron.safeStorage,
|
storage: SafeStorageLike = electron.safeStorage,
|
||||||
): AnilistTokenStore {
|
): AnilistTokenStore {
|
||||||
|
let safeStorageUsable: boolean | null = null;
|
||||||
|
|
||||||
|
const getSelectedBackend = (): string => {
|
||||||
|
if (typeof storage.getSelectedStorageBackend !== 'function') {
|
||||||
|
return 'unsupported';
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return storage.getSelectedStorageBackend();
|
||||||
|
} catch {
|
||||||
|
return 'error';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSafeStorageDebugContext = (): string =>
|
||||||
|
JSON.stringify({
|
||||||
|
platform: process.platform,
|
||||||
|
dbusSession: process.env.DBUS_SESSION_BUS_ADDRESS,
|
||||||
|
xdgRuntimeDir: process.env.XDG_RUNTIME_DIR,
|
||||||
|
display: process.env.DISPLAY,
|
||||||
|
waylandDisplay: process.env.WAYLAND_DISPLAY,
|
||||||
|
hasDefaultApp: Boolean(process.defaultApp),
|
||||||
|
selectedSafeStorageBackend: getSelectedBackend(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const isSafeStorageUsable = (): boolean => {
|
||||||
|
if (safeStorageUsable != null) return safeStorageUsable;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!storage.isEncryptionAvailable()) {
|
||||||
|
notifyUser(
|
||||||
|
`AniList token encryption unavailable: safeStorage.isEncryptionAvailable() is false. ` +
|
||||||
|
`Context: ${getSafeStorageDebugContext()}`,
|
||||||
|
);
|
||||||
|
safeStorageUsable = false;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const probe = storage.encryptString('__subminer_anilist_probe__');
|
||||||
|
if (probe.equals(Buffer.from('__subminer_anilist_probe__'))) {
|
||||||
|
notifyUser(
|
||||||
|
'AniList token encryption probe failed: safeStorage.encryptString() returned plaintext bytes.',
|
||||||
|
);
|
||||||
|
safeStorageUsable = false;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const roundTrip = storage.decryptString(probe);
|
||||||
|
if (roundTrip !== '__subminer_anilist_probe__') {
|
||||||
|
notifyUser(
|
||||||
|
'AniList token encryption probe failed: encrypt/decrypt round trip returned unexpected content.',
|
||||||
|
);
|
||||||
|
safeStorageUsable = false;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
safeStorageUsable = true;
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('AniList token encryption probe failed.', error);
|
||||||
|
notifyUser(
|
||||||
|
`AniList token encryption unavailable: safeStorage probe threw an error. ` +
|
||||||
|
`Context: ${getSafeStorageDebugContext()}`,
|
||||||
|
);
|
||||||
|
safeStorageUsable = false;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const notifyUser = (message: string): void => {
|
||||||
|
logger.warn(message);
|
||||||
|
logger.warnUser?.(message);
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
loadToken(): string | null {
|
loadToken(): string | null {
|
||||||
if (!fs.existsSync(filePath)) {
|
if (!fs.existsSync(filePath)) {
|
||||||
@@ -51,47 +123,62 @@ export function createAnilistTokenStore(
|
|||||||
const parsed = JSON.parse(raw) as PersistedTokenPayload;
|
const parsed = JSON.parse(raw) as PersistedTokenPayload;
|
||||||
if (typeof parsed.encryptedToken === 'string' && parsed.encryptedToken.length > 0) {
|
if (typeof parsed.encryptedToken === 'string' && parsed.encryptedToken.length > 0) {
|
||||||
const encrypted = Buffer.from(parsed.encryptedToken, 'base64');
|
const encrypted = Buffer.from(parsed.encryptedToken, 'base64');
|
||||||
if (!storage.isEncryptionAvailable()) {
|
if (!isSafeStorageUsable()) {
|
||||||
logger.warn('AniList token encryption is not available on this system.');
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const decrypted = storage.decryptString(encrypted).trim();
|
const decrypted = storage.decryptString(encrypted).trim();
|
||||||
return decrypted.length > 0 ? decrypted : null;
|
if (decrypted.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return decrypted;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
typeof parsed.plaintextToken === 'string' &&
|
||||||
|
parsed.plaintextToken.trim().length > 0
|
||||||
|
) {
|
||||||
|
if (storage.isEncryptionAvailable()) {
|
||||||
|
if (!isSafeStorageUsable()) {
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
if (typeof parsed.plaintextToken === 'string' && parsed.plaintextToken.trim().length > 0) {
|
|
||||||
// Legacy fallback: migrate plaintext token to encrypted storage on load.
|
|
||||||
const plaintext = parsed.plaintextToken.trim();
|
const plaintext = parsed.plaintextToken.trim();
|
||||||
|
notifyUser('AniList token plaintext fallback payload found. Migrating to encrypted storage.');
|
||||||
this.saveToken(plaintext);
|
this.saveToken(plaintext);
|
||||||
return plaintext;
|
return plaintext;
|
||||||
}
|
}
|
||||||
|
notifyUser(
|
||||||
|
'AniList token plaintext was found but ignored because safe storage is unavailable.',
|
||||||
|
);
|
||||||
|
this.clearToken();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to read AniList token store.', error);
|
logger.error('Failed to read AniList token store.', error);
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
|
|
||||||
saveToken(token: string): void {
|
saveToken(token: string): boolean {
|
||||||
const trimmed = token.trim();
|
const trimmed = token.trim();
|
||||||
if (trimmed.length === 0) {
|
if (trimmed.length === 0) {
|
||||||
this.clearToken();
|
this.clearToken();
|
||||||
return;
|
return true;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
if (!storage.isEncryptionAvailable()) {
|
if (!isSafeStorageUsable()) {
|
||||||
logger.warn('AniList token encryption unavailable; storing token in plaintext fallback.');
|
notifyUser(
|
||||||
writePayload(filePath, {
|
'AniList token encryption is unavailable; refusing to store access token. Re-login required after restart.',
|
||||||
plaintextToken: trimmed,
|
);
|
||||||
updatedAt: Date.now(),
|
return false;
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
const encrypted = storage.encryptString(trimmed);
|
const encrypted = storage.encryptString(trimmed);
|
||||||
writePayload(filePath, {
|
writePayload(filePath, {
|
||||||
encryptedToken: encrypted.toString('base64'),
|
encryptedToken: encrypted.toString('base64'),
|
||||||
updatedAt: Date.now(),
|
updatedAt: Date.now(),
|
||||||
});
|
});
|
||||||
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to persist AniList token.', error);
|
logger.error('Failed to persist AniList token.', error);
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -233,5 +233,6 @@ test('registerIpcHandlers ignores malformed fire-and-forget payloads', () => {
|
|||||||
|
|
||||||
handlers.on.get(IPC_CHANNELS.command.overlayModalClosed)!({}, 'not-a-modal');
|
handlers.on.get(IPC_CHANNELS.command.overlayModalClosed)!({}, 'not-a-modal');
|
||||||
handlers.on.get(IPC_CHANNELS.command.overlayModalClosed)!({}, 'subsync');
|
handlers.on.get(IPC_CHANNELS.command.overlayModalClosed)!({}, 'subsync');
|
||||||
assert.deepEqual(modals, ['subsync']);
|
handlers.on.get(IPC_CHANNELS.command.overlayModalClosed)!({}, 'kiku');
|
||||||
|
assert.deepEqual(modals, ['subsync', 'kiku']);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -63,6 +63,8 @@ export function createFieldGroupingCallbackRuntime<T extends string>(options: {
|
|||||||
getResolver: options.getResolver,
|
getResolver: options.getResolver,
|
||||||
setResolver: options.setResolver,
|
setResolver: options.setResolver,
|
||||||
sendRequestToVisibleOverlay: (data) =>
|
sendRequestToVisibleOverlay: (data) =>
|
||||||
options.sendToVisibleOverlay('kiku:field-grouping-request', data),
|
options.sendToVisibleOverlay('kiku:field-grouping-request', data, {
|
||||||
|
restoreOnModalClose: 'kiku' as T,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ test('overlay manager initializes with empty windows and hidden overlays', () =>
|
|||||||
assert.equal(manager.getMainWindow(), null);
|
assert.equal(manager.getMainWindow(), null);
|
||||||
assert.equal(manager.getInvisibleWindow(), null);
|
assert.equal(manager.getInvisibleWindow(), null);
|
||||||
assert.equal(manager.getSecondaryWindow(), null);
|
assert.equal(manager.getSecondaryWindow(), null);
|
||||||
|
assert.equal(manager.getModalWindow(), null);
|
||||||
assert.equal(manager.getVisibleOverlayVisible(), false);
|
assert.equal(manager.getVisibleOverlayVisible(), false);
|
||||||
assert.equal(manager.getInvisibleOverlayVisible(), false);
|
assert.equal(manager.getInvisibleOverlayVisible(), false);
|
||||||
assert.deepEqual(manager.getOverlayWindows(), []);
|
assert.deepEqual(manager.getOverlayWindows(), []);
|
||||||
@@ -27,14 +28,19 @@ test('overlay manager stores window references and returns stable window order',
|
|||||||
const secondaryWindow = {
|
const secondaryWindow = {
|
||||||
isDestroyed: () => false,
|
isDestroyed: () => false,
|
||||||
} as unknown as Electron.BrowserWindow;
|
} as unknown as Electron.BrowserWindow;
|
||||||
|
const modalWindow = {
|
||||||
|
isDestroyed: () => false,
|
||||||
|
} as unknown as Electron.BrowserWindow;
|
||||||
|
|
||||||
manager.setMainWindow(visibleWindow);
|
manager.setMainWindow(visibleWindow);
|
||||||
manager.setInvisibleWindow(invisibleWindow);
|
manager.setInvisibleWindow(invisibleWindow);
|
||||||
manager.setSecondaryWindow(secondaryWindow);
|
manager.setSecondaryWindow(secondaryWindow);
|
||||||
|
manager.setModalWindow(modalWindow);
|
||||||
|
|
||||||
assert.equal(manager.getMainWindow(), visibleWindow);
|
assert.equal(manager.getMainWindow(), visibleWindow);
|
||||||
assert.equal(manager.getInvisibleWindow(), invisibleWindow);
|
assert.equal(manager.getInvisibleWindow(), invisibleWindow);
|
||||||
assert.equal(manager.getSecondaryWindow(), secondaryWindow);
|
assert.equal(manager.getSecondaryWindow(), secondaryWindow);
|
||||||
|
assert.equal(manager.getModalWindow(), modalWindow);
|
||||||
assert.equal(manager.getOverlayWindow('visible'), visibleWindow);
|
assert.equal(manager.getOverlayWindow('visible'), visibleWindow);
|
||||||
assert.equal(manager.getOverlayWindow('invisible'), invisibleWindow);
|
assert.equal(manager.getOverlayWindow('invisible'), invisibleWindow);
|
||||||
assert.deepEqual(manager.getOverlayWindows(), [visibleWindow, invisibleWindow, secondaryWindow]);
|
assert.deepEqual(manager.getOverlayWindows(), [visibleWindow, invisibleWindow, secondaryWindow]);
|
||||||
@@ -51,6 +57,9 @@ test('overlay manager excludes destroyed windows', () => {
|
|||||||
manager.setSecondaryWindow({
|
manager.setSecondaryWindow({
|
||||||
isDestroyed: () => true,
|
isDestroyed: () => true,
|
||||||
} as unknown as Electron.BrowserWindow);
|
} as unknown as Electron.BrowserWindow);
|
||||||
|
manager.setModalWindow({
|
||||||
|
isDestroyed: () => false,
|
||||||
|
} as unknown as Electron.BrowserWindow);
|
||||||
|
|
||||||
assert.equal(manager.getOverlayWindows().length, 1);
|
assert.equal(manager.getOverlayWindows().length, 1);
|
||||||
});
|
});
|
||||||
@@ -93,6 +102,10 @@ test('overlay manager broadcasts to non-destroyed windows', () => {
|
|||||||
manager.setMainWindow(aliveWindow);
|
manager.setMainWindow(aliveWindow);
|
||||||
manager.setInvisibleWindow(deadWindow);
|
manager.setInvisibleWindow(deadWindow);
|
||||||
manager.setSecondaryWindow(secondaryWindow);
|
manager.setSecondaryWindow(secondaryWindow);
|
||||||
|
manager.setModalWindow({
|
||||||
|
isDestroyed: () => false,
|
||||||
|
webContents: { send: () => {} },
|
||||||
|
} as unknown as Electron.BrowserWindow);
|
||||||
manager.broadcastToOverlayWindows('x', 1, 'a');
|
manager.broadcastToOverlayWindows('x', 1, 'a');
|
||||||
|
|
||||||
assert.deepEqual(calls, [
|
assert.deepEqual(calls, [
|
||||||
@@ -123,9 +136,17 @@ test('overlay manager applies bounds by layer', () => {
|
|||||||
invisibleCalls.push(bounds);
|
invisibleCalls.push(bounds);
|
||||||
},
|
},
|
||||||
} as unknown as Electron.BrowserWindow;
|
} as unknown as Electron.BrowserWindow;
|
||||||
|
const modalCalls: Electron.Rectangle[] = [];
|
||||||
|
const modalWindow = {
|
||||||
|
isDestroyed: () => false,
|
||||||
|
setBounds: (bounds: Electron.Rectangle) => {
|
||||||
|
modalCalls.push(bounds);
|
||||||
|
},
|
||||||
|
} as unknown as Electron.BrowserWindow;
|
||||||
manager.setMainWindow(visibleWindow);
|
manager.setMainWindow(visibleWindow);
|
||||||
manager.setInvisibleWindow(invisibleWindow);
|
manager.setInvisibleWindow(invisibleWindow);
|
||||||
manager.setSecondaryWindow(secondaryWindow);
|
manager.setSecondaryWindow(secondaryWindow);
|
||||||
|
manager.setModalWindow(modalWindow);
|
||||||
|
|
||||||
manager.setOverlayWindowBounds('visible', {
|
manager.setOverlayWindowBounds('visible', {
|
||||||
x: 10,
|
x: 10,
|
||||||
@@ -145,12 +166,19 @@ test('overlay manager applies bounds by layer', () => {
|
|||||||
width: 10,
|
width: 10,
|
||||||
height: 11,
|
height: 11,
|
||||||
});
|
});
|
||||||
|
manager.setModalWindowBounds({
|
||||||
|
x: 80,
|
||||||
|
y: 90,
|
||||||
|
width: 100,
|
||||||
|
height: 110,
|
||||||
|
});
|
||||||
|
|
||||||
assert.deepEqual(visibleCalls, [{ x: 10, y: 20, width: 30, height: 40 }]);
|
assert.deepEqual(visibleCalls, [{ x: 10, y: 20, width: 30, height: 40 }]);
|
||||||
assert.deepEqual(invisibleCalls, [
|
assert.deepEqual(invisibleCalls, [
|
||||||
{ x: 1, y: 2, width: 3, height: 4 },
|
{ x: 1, y: 2, width: 3, height: 4 },
|
||||||
{ x: 8, y: 9, width: 10, height: 11 },
|
{ x: 8, y: 9, width: 10, height: 11 },
|
||||||
]);
|
]);
|
||||||
|
assert.deepEqual(modalCalls, [{ x: 80, y: 90, width: 100, height: 110 }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('runtime-option and debug broadcasts use expected channels', () => {
|
test('runtime-option and debug broadcasts use expected channels', () => {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { BrowserWindow } from 'electron';
|
import type { BrowserWindow } from 'electron';
|
||||||
import { RuntimeOptionState, WindowGeometry } from '../../types';
|
import { RuntimeOptionState, WindowGeometry } from '../../types';
|
||||||
import { updateOverlayWindowBounds } from './overlay-window';
|
import { updateOverlayWindowBounds } from './overlay-window';
|
||||||
|
|
||||||
@@ -11,9 +11,12 @@ export interface OverlayManager {
|
|||||||
setInvisibleWindow: (window: BrowserWindow | null) => void;
|
setInvisibleWindow: (window: BrowserWindow | null) => void;
|
||||||
getSecondaryWindow: () => BrowserWindow | null;
|
getSecondaryWindow: () => BrowserWindow | null;
|
||||||
setSecondaryWindow: (window: BrowserWindow | null) => void;
|
setSecondaryWindow: (window: BrowserWindow | null) => void;
|
||||||
|
getModalWindow: () => BrowserWindow | null;
|
||||||
|
setModalWindow: (window: BrowserWindow | null) => void;
|
||||||
getOverlayWindow: (layer: OverlayLayer) => BrowserWindow | null;
|
getOverlayWindow: (layer: OverlayLayer) => BrowserWindow | null;
|
||||||
setOverlayWindowBounds: (layer: OverlayLayer, geometry: WindowGeometry) => void;
|
setOverlayWindowBounds: (layer: OverlayLayer, geometry: WindowGeometry) => void;
|
||||||
setSecondaryWindowBounds: (geometry: WindowGeometry) => void;
|
setSecondaryWindowBounds: (geometry: WindowGeometry) => void;
|
||||||
|
setModalWindowBounds: (geometry: WindowGeometry) => void;
|
||||||
getVisibleOverlayVisible: () => boolean;
|
getVisibleOverlayVisible: () => boolean;
|
||||||
setVisibleOverlayVisible: (visible: boolean) => void;
|
setVisibleOverlayVisible: (visible: boolean) => void;
|
||||||
getInvisibleOverlayVisible: () => boolean;
|
getInvisibleOverlayVisible: () => boolean;
|
||||||
@@ -26,6 +29,7 @@ export function createOverlayManager(): OverlayManager {
|
|||||||
let mainWindow: BrowserWindow | null = null;
|
let mainWindow: BrowserWindow | null = null;
|
||||||
let invisibleWindow: BrowserWindow | null = null;
|
let invisibleWindow: BrowserWindow | null = null;
|
||||||
let secondaryWindow: BrowserWindow | null = null;
|
let secondaryWindow: BrowserWindow | null = null;
|
||||||
|
let modalWindow: BrowserWindow | null = null;
|
||||||
let visibleOverlayVisible = false;
|
let visibleOverlayVisible = false;
|
||||||
let invisibleOverlayVisible = false;
|
let invisibleOverlayVisible = false;
|
||||||
|
|
||||||
@@ -42,6 +46,10 @@ export function createOverlayManager(): OverlayManager {
|
|||||||
setSecondaryWindow: (window) => {
|
setSecondaryWindow: (window) => {
|
||||||
secondaryWindow = window;
|
secondaryWindow = window;
|
||||||
},
|
},
|
||||||
|
getModalWindow: () => modalWindow,
|
||||||
|
setModalWindow: (window) => {
|
||||||
|
modalWindow = window;
|
||||||
|
},
|
||||||
getOverlayWindow: (layer) => (layer === 'visible' ? mainWindow : invisibleWindow),
|
getOverlayWindow: (layer) => (layer === 'visible' ? mainWindow : invisibleWindow),
|
||||||
setOverlayWindowBounds: (layer, geometry) => {
|
setOverlayWindowBounds: (layer, geometry) => {
|
||||||
updateOverlayWindowBounds(geometry, layer === 'visible' ? mainWindow : invisibleWindow);
|
updateOverlayWindowBounds(geometry, layer === 'visible' ? mainWindow : invisibleWindow);
|
||||||
@@ -49,6 +57,9 @@ export function createOverlayManager(): OverlayManager {
|
|||||||
setSecondaryWindowBounds: (geometry) => {
|
setSecondaryWindowBounds: (geometry) => {
|
||||||
updateOverlayWindowBounds(geometry, secondaryWindow);
|
updateOverlayWindowBounds(geometry, secondaryWindow);
|
||||||
},
|
},
|
||||||
|
setModalWindowBounds: (geometry) => {
|
||||||
|
updateOverlayWindowBounds(geometry, modalWindow);
|
||||||
|
},
|
||||||
getVisibleOverlayVisible: () => visibleOverlayVisible,
|
getVisibleOverlayVisible: () => visibleOverlayVisible,
|
||||||
setVisibleOverlayVisible: (visible) => {
|
setVisibleOverlayVisible: (visible) => {
|
||||||
visibleOverlayVisible = visible;
|
visibleOverlayVisible = visible;
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { createLogger } from '../../logger';
|
|||||||
|
|
||||||
const logger = createLogger('main:overlay-window');
|
const logger = createLogger('main:overlay-window');
|
||||||
|
|
||||||
export type OverlayWindowKind = 'visible' | 'invisible' | 'secondary';
|
export type OverlayWindowKind = 'visible' | 'invisible' | 'secondary' | 'modal';
|
||||||
|
|
||||||
export function updateOverlayWindowBounds(
|
export function updateOverlayWindowBounds(
|
||||||
geometry: WindowGeometry,
|
geometry: WindowGeometry,
|
||||||
@@ -71,6 +71,7 @@ export function createOverlayWindow(
|
|||||||
resizable: false,
|
resizable: false,
|
||||||
hasShadow: false,
|
hasShadow: false,
|
||||||
focusable: true,
|
focusable: true,
|
||||||
|
acceptFirstMouse: true,
|
||||||
webPreferences: {
|
webPreferences: {
|
||||||
preload: path.join(__dirname, '..', '..', 'preload.js'),
|
preload: path.join(__dirname, '..', '..', 'preload.js'),
|
||||||
contextIsolation: true,
|
contextIsolation: true,
|
||||||
@@ -115,6 +116,7 @@ export function createOverlayWindow(
|
|||||||
}
|
}
|
||||||
|
|
||||||
window.webContents.on('before-input-event', (event, input) => {
|
window.webContents.on('before-input-event', (event, input) => {
|
||||||
|
if (kind === 'modal') return;
|
||||||
if (!options.isOverlayVisible(kind)) return;
|
if (!options.isOverlayVisible(kind)) return;
|
||||||
if (!options.tryHandleOverlayShortcutLocalFallback(input)) return;
|
if (!options.tryHandleOverlayShortcutLocalFallback(input)) return;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|||||||
103
src/main.ts
103
src/main.ts
@@ -30,6 +30,41 @@ import {
|
|||||||
screen,
|
screen,
|
||||||
} from 'electron';
|
} from 'electron';
|
||||||
|
|
||||||
|
function getPasswordStoreArg(argv: string[]): string | null {
|
||||||
|
for (let i = 0; i < argv.length; i += 1) {
|
||||||
|
const arg = argv[i];
|
||||||
|
if (!arg?.startsWith('--password-store')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (arg === '--password-store') {
|
||||||
|
const value = argv[i + 1];
|
||||||
|
if (value && !value.startsWith('--')) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [prefix, value] = arg.split('=', 2);
|
||||||
|
if (prefix === '--password-store' && value && value.trim().length > 0) {
|
||||||
|
return value.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePasswordStoreArg(value: string): string {
|
||||||
|
const normalized = value.trim();
|
||||||
|
if (normalized.toLowerCase() === 'gnome') {
|
||||||
|
return 'gnome-libsecret';
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDefaultPasswordStore(): string {
|
||||||
|
return 'gnome-libsecret';
|
||||||
|
}
|
||||||
|
|
||||||
protocol.registerSchemesAsPrivileged([
|
protocol.registerSchemesAsPrivileged([
|
||||||
{
|
{
|
||||||
scheme: 'chrome-extension',
|
scheme: 'chrome-extension',
|
||||||
@@ -400,6 +435,9 @@ import { resolveConfigDir } from './config/path-resolution';
|
|||||||
|
|
||||||
if (process.platform === 'linux') {
|
if (process.platform === 'linux') {
|
||||||
app.commandLine.appendSwitch('enable-features', 'GlobalShortcutsPortal');
|
app.commandLine.appendSwitch('enable-features', 'GlobalShortcutsPortal');
|
||||||
|
const passwordStore = normalizePasswordStoreArg(getPasswordStoreArg(process.argv) ?? getDefaultPasswordStore());
|
||||||
|
app.commandLine.appendSwitch('password-store', passwordStore);
|
||||||
|
console.debug(`[main] Applied --password-store ${passwordStore}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
app.setName('SubMiner');
|
app.setName('SubMiner');
|
||||||
@@ -447,6 +485,7 @@ let jellyfinRemoteLastProgressAtMs = 0;
|
|||||||
let jellyfinMpvAutoLaunchInFlight: Promise<boolean> | null = null;
|
let jellyfinMpvAutoLaunchInFlight: Promise<boolean> | null = null;
|
||||||
let backgroundWarmupsStarted = false;
|
let backgroundWarmupsStarted = false;
|
||||||
let yomitanLoadInFlight: Promise<Extension | null> | null = null;
|
let yomitanLoadInFlight: Promise<Extension | null> | null = null;
|
||||||
|
let notifyAnilistTokenStoreWarning: (message: string) => void = () => {};
|
||||||
|
|
||||||
const buildApplyJellyfinMpvDefaultsMainDepsHandler =
|
const buildApplyJellyfinMpvDefaultsMainDepsHandler =
|
||||||
createBuildApplyJellyfinMpvDefaultsMainDepsHandler({
|
createBuildApplyJellyfinMpvDefaultsMainDepsHandler({
|
||||||
@@ -496,6 +535,7 @@ const anilistTokenStore = createAnilistTokenStore(
|
|||||||
info: (message: string) => console.info(message),
|
info: (message: string) => console.info(message),
|
||||||
warn: (message: string, details?: unknown) => console.warn(message, details),
|
warn: (message: string, details?: unknown) => console.warn(message, details),
|
||||||
error: (message: string, details?: unknown) => console.error(message, details),
|
error: (message: string, details?: unknown) => console.error(message, details),
|
||||||
|
warnUser: (message: string) => notifyAnilistTokenStoreWarning(message),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
const jellyfinTokenStore = createJellyfinTokenStore(
|
const jellyfinTokenStore = createJellyfinTokenStore(
|
||||||
@@ -518,6 +558,16 @@ const isDev = process.argv.includes('--dev') || process.argv.includes('--debug')
|
|||||||
const texthookerService = new Texthooker();
|
const texthookerService = new Texthooker();
|
||||||
const subtitleWsService = new SubtitleWebSocket();
|
const subtitleWsService = new SubtitleWebSocket();
|
||||||
const logger = createLogger('main');
|
const logger = createLogger('main');
|
||||||
|
notifyAnilistTokenStoreWarning = (message: string) => {
|
||||||
|
logger.warn(`[AniList] ${message}`);
|
||||||
|
try {
|
||||||
|
showDesktopNotification('SubMiner AniList', {
|
||||||
|
body: message,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Notification may fail if desktop notifications are unavailable early in startup.
|
||||||
|
}
|
||||||
|
};
|
||||||
const appLogger = {
|
const appLogger = {
|
||||||
logInfo: (message: string) => {
|
logInfo: (message: string) => {
|
||||||
logger.info(message);
|
logger.info(message);
|
||||||
@@ -567,6 +617,26 @@ process.on('SIGTERM', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const overlayManager = createOverlayManager();
|
const overlayManager = createOverlayManager();
|
||||||
|
let overlayModalInputExclusive = false;
|
||||||
|
let syncOverlayShortcutsForModal: (isActive: boolean) => void = () => {};
|
||||||
|
|
||||||
|
const handleModalInputStateChange = (isActive: boolean): void => {
|
||||||
|
if (overlayModalInputExclusive === isActive) return;
|
||||||
|
overlayModalInputExclusive = isActive;
|
||||||
|
if (isActive) {
|
||||||
|
const modalWindow = overlayManager.getModalWindow();
|
||||||
|
if (modalWindow && !modalWindow.isDestroyed()) {
|
||||||
|
modalWindow.setIgnoreMouseEvents(false);
|
||||||
|
modalWindow.setAlwaysOnTop(true, 'screen-saver', 1);
|
||||||
|
modalWindow.focus();
|
||||||
|
if (!modalWindow.webContents.isFocused()) {
|
||||||
|
modalWindow.webContents.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
syncOverlayShortcutsForModal(isActive);
|
||||||
|
};
|
||||||
|
|
||||||
const buildOverlayContentMeasurementStoreMainDepsHandler =
|
const buildOverlayContentMeasurementStoreMainDepsHandler =
|
||||||
createBuildOverlayContentMeasurementStoreMainDepsHandler({
|
createBuildOverlayContentMeasurementStoreMainDepsHandler({
|
||||||
now: () => Date.now(),
|
now: () => Date.now(),
|
||||||
@@ -575,6 +645,10 @@ const buildOverlayContentMeasurementStoreMainDepsHandler =
|
|||||||
const buildOverlayModalRuntimeMainDepsHandler = createBuildOverlayModalRuntimeMainDepsHandler({
|
const buildOverlayModalRuntimeMainDepsHandler = createBuildOverlayModalRuntimeMainDepsHandler({
|
||||||
getMainWindow: () => overlayManager.getMainWindow(),
|
getMainWindow: () => overlayManager.getMainWindow(),
|
||||||
getInvisibleWindow: () => overlayManager.getInvisibleWindow(),
|
getInvisibleWindow: () => overlayManager.getInvisibleWindow(),
|
||||||
|
getModalWindow: () => overlayManager.getModalWindow(),
|
||||||
|
createModalWindow: () => createModalWindow(),
|
||||||
|
getModalGeometry: () => getCurrentOverlayGeometry(),
|
||||||
|
setModalWindowBounds: (geometry) => overlayManager.setModalWindowBounds(geometry),
|
||||||
});
|
});
|
||||||
const overlayContentMeasurementStoreMainDeps = buildOverlayContentMeasurementStoreMainDepsHandler();
|
const overlayContentMeasurementStoreMainDeps = buildOverlayContentMeasurementStoreMainDepsHandler();
|
||||||
const overlayContentMeasurementStore = createOverlayContentMeasurementStore(
|
const overlayContentMeasurementStore = createOverlayContentMeasurementStore(
|
||||||
@@ -582,6 +656,9 @@ const overlayContentMeasurementStore = createOverlayContentMeasurementStore(
|
|||||||
);
|
);
|
||||||
const overlayModalRuntime = createOverlayModalRuntimeService(
|
const overlayModalRuntime = createOverlayModalRuntimeService(
|
||||||
buildOverlayModalRuntimeMainDepsHandler(),
|
buildOverlayModalRuntimeMainDepsHandler(),
|
||||||
|
{
|
||||||
|
onModalStateChange: (isActive: boolean) => handleModalInputStateChange(isActive),
|
||||||
|
},
|
||||||
);
|
);
|
||||||
const appState = createAppState({
|
const appState = createAppState({
|
||||||
mpvSocketPath: getDefaultSocketPath(),
|
mpvSocketPath: getDefaultSocketPath(),
|
||||||
@@ -789,6 +866,13 @@ const overlayShortcutsRuntime = createOverlayShortcutsRuntimeService(
|
|||||||
},
|
},
|
||||||
})(),
|
})(),
|
||||||
);
|
);
|
||||||
|
syncOverlayShortcutsForModal = (isActive: boolean): void => {
|
||||||
|
if (isActive) {
|
||||||
|
overlayShortcutsRuntime.unregisterOverlayShortcuts();
|
||||||
|
} else {
|
||||||
|
overlayShortcutsRuntime.syncOverlayShortcuts();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const buildConfigHotReloadMessageMainDepsHandler = createBuildConfigHotReloadMessageMainDepsHandler(
|
const buildConfigHotReloadMessageMainDepsHandler = createBuildConfigHotReloadMessageMainDepsHandler(
|
||||||
{
|
{
|
||||||
@@ -2216,6 +2300,7 @@ function applyOverlayRegions(layer: 'visible' | 'invisible', geometry: WindowGeo
|
|||||||
const regions = splitOverlayGeometryForSecondaryBar(geometry);
|
const regions = splitOverlayGeometryForSecondaryBar(geometry);
|
||||||
overlayManager.setOverlayWindowBounds(layer, regions.primary);
|
overlayManager.setOverlayWindowBounds(layer, regions.primary);
|
||||||
overlayManager.setSecondaryWindowBounds(regions.secondary);
|
overlayManager.setSecondaryWindowBounds(regions.secondary);
|
||||||
|
overlayManager.setModalWindowBounds(geometry);
|
||||||
syncSecondaryOverlayWindowVisibility();
|
syncSecondaryOverlayWindowVisibility();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2276,10 +2361,20 @@ async function ensureYomitanExtensionLoaded(): Promise<Extension | null> {
|
|||||||
return yomitanExtensionRuntime.ensureYomitanExtensionLoaded();
|
return yomitanExtensionRuntime.ensureYomitanExtensionLoaded();
|
||||||
}
|
}
|
||||||
|
|
||||||
function createOverlayWindow(kind: 'visible' | 'invisible' | 'secondary'): BrowserWindow {
|
function createOverlayWindow(kind: 'visible' | 'invisible' | 'secondary' | 'modal'): BrowserWindow {
|
||||||
return createOverlayWindowHandler(kind);
|
return createOverlayWindowHandler(kind);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createModalWindow(): BrowserWindow {
|
||||||
|
const existingWindow = overlayManager.getModalWindow();
|
||||||
|
if (existingWindow && !existingWindow.isDestroyed()) {
|
||||||
|
return existingWindow;
|
||||||
|
}
|
||||||
|
const window = createModalWindowHandler();
|
||||||
|
overlayManager.setModalWindowBounds(getCurrentOverlayGeometry());
|
||||||
|
return window;
|
||||||
|
}
|
||||||
|
|
||||||
function createSecondaryWindow(): BrowserWindow {
|
function createSecondaryWindow(): BrowserWindow {
|
||||||
const existingWindow = overlayManager.getSecondaryWindow();
|
const existingWindow = overlayManager.getSecondaryWindow();
|
||||||
if (existingWindow && !existingWindow.isDestroyed()) {
|
if (existingWindow && !existingWindow.isDestroyed()) {
|
||||||
@@ -2742,6 +2837,7 @@ const {
|
|||||||
createMainWindow: createMainWindowHandler,
|
createMainWindow: createMainWindowHandler,
|
||||||
createInvisibleWindow: createInvisibleWindowHandler,
|
createInvisibleWindow: createInvisibleWindowHandler,
|
||||||
createSecondaryWindow: createSecondaryWindowHandler,
|
createSecondaryWindow: createSecondaryWindowHandler,
|
||||||
|
createModalWindow: createModalWindowHandler,
|
||||||
} = createOverlayWindowRuntimeHandlers<BrowserWindow>({
|
} = createOverlayWindowRuntimeHandlers<BrowserWindow>({
|
||||||
createOverlayWindowDeps: {
|
createOverlayWindowDeps: {
|
||||||
createOverlayWindowCore: (kind, options) => createOverlayWindowCore(kind, options),
|
createOverlayWindowCore: (kind, options) => createOverlayWindowCore(kind, options),
|
||||||
@@ -2763,14 +2859,17 @@ const {
|
|||||||
overlayManager.setMainWindow(null);
|
overlayManager.setMainWindow(null);
|
||||||
} else if (windowKind === 'invisible') {
|
} else if (windowKind === 'invisible') {
|
||||||
overlayManager.setInvisibleWindow(null);
|
overlayManager.setInvisibleWindow(null);
|
||||||
} else {
|
} else if (windowKind === 'secondary') {
|
||||||
overlayManager.setSecondaryWindow(null);
|
overlayManager.setSecondaryWindow(null);
|
||||||
|
} else {
|
||||||
|
overlayManager.setModalWindow(null);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
setMainWindow: (window) => overlayManager.setMainWindow(window),
|
setMainWindow: (window) => overlayManager.setMainWindow(window),
|
||||||
setInvisibleWindow: (window) => overlayManager.setInvisibleWindow(window),
|
setInvisibleWindow: (window) => overlayManager.setInvisibleWindow(window),
|
||||||
setSecondaryWindow: (window) => overlayManager.setSecondaryWindow(window),
|
setSecondaryWindow: (window) => overlayManager.setSecondaryWindow(window),
|
||||||
|
setModalWindow: (window) => overlayManager.setModalWindow(window),
|
||||||
});
|
});
|
||||||
const {
|
const {
|
||||||
resolveTrayIconPath: resolveTrayIconPathHandler,
|
resolveTrayIconPath: resolveTrayIconPathHandler,
|
||||||
|
|||||||
218
src/main/overlay-runtime.test.ts
Normal file
218
src/main/overlay-runtime.test.ts
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { createOverlayModalRuntimeService } from './overlay-runtime';
|
||||||
|
|
||||||
|
type MockWindow = {
|
||||||
|
destroyed: boolean;
|
||||||
|
visible: boolean;
|
||||||
|
focused: boolean;
|
||||||
|
ignoreMouseEvents: boolean;
|
||||||
|
webContentsFocused: boolean;
|
||||||
|
showCount: number;
|
||||||
|
hideCount: number;
|
||||||
|
sent: unknown[][];
|
||||||
|
loading: boolean;
|
||||||
|
loadCallbacks: Array<() => void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function createMockWindow(): MockWindow & {
|
||||||
|
isDestroyed: () => boolean;
|
||||||
|
isVisible: () => boolean;
|
||||||
|
isFocused: () => boolean;
|
||||||
|
setIgnoreMouseEvents: (ignore: boolean) => void;
|
||||||
|
getShowCount: () => number;
|
||||||
|
getHideCount: () => number;
|
||||||
|
show: () => void;
|
||||||
|
hide: () => void;
|
||||||
|
focus: () => void;
|
||||||
|
webContents: {
|
||||||
|
focused: boolean;
|
||||||
|
isLoading: () => boolean;
|
||||||
|
send: (channel: string, payload?: unknown) => void;
|
||||||
|
isFocused: () => boolean;
|
||||||
|
once: (event: 'did-finish-load', cb: () => void) => void;
|
||||||
|
focus: () => void;
|
||||||
|
};
|
||||||
|
} {
|
||||||
|
const state: MockWindow = {
|
||||||
|
destroyed: false,
|
||||||
|
visible: false,
|
||||||
|
focused: false,
|
||||||
|
ignoreMouseEvents: false,
|
||||||
|
webContentsFocused: false,
|
||||||
|
showCount: 0,
|
||||||
|
hideCount: 0,
|
||||||
|
sent: [],
|
||||||
|
loading: false,
|
||||||
|
loadCallbacks: [],
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
isDestroyed: () => state.destroyed,
|
||||||
|
isVisible: () => state.visible,
|
||||||
|
isFocused: () => state.focused,
|
||||||
|
setIgnoreMouseEvents: (ignore: boolean) => {
|
||||||
|
state.ignoreMouseEvents = ignore;
|
||||||
|
},
|
||||||
|
getShowCount: () => state.showCount,
|
||||||
|
getHideCount: () => state.hideCount,
|
||||||
|
show: () => {
|
||||||
|
state.visible = true;
|
||||||
|
state.showCount += 1;
|
||||||
|
},
|
||||||
|
hide: () => {
|
||||||
|
state.visible = false;
|
||||||
|
state.hideCount += 1;
|
||||||
|
},
|
||||||
|
focus: () => {
|
||||||
|
state.focused = true;
|
||||||
|
},
|
||||||
|
webContents: {
|
||||||
|
isLoading: () => state.loading,
|
||||||
|
send: (channel, payload) => {
|
||||||
|
if (payload === undefined) {
|
||||||
|
state.sent.push([channel]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.sent.push([channel, payload]);
|
||||||
|
},
|
||||||
|
focused: false,
|
||||||
|
isFocused: () => state.webContentsFocused,
|
||||||
|
once: (_event, cb) => {
|
||||||
|
state.loadCallbacks.push(cb);
|
||||||
|
},
|
||||||
|
focus: () => {
|
||||||
|
state.webContentsFocused = true;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('sendToActiveOverlayWindow targets modal window with full geometry and tracks close restore', () => {
|
||||||
|
const window = createMockWindow();
|
||||||
|
const calls: string[] = [];
|
||||||
|
const runtime = createOverlayModalRuntimeService({
|
||||||
|
getMainWindow: () => null,
|
||||||
|
getInvisibleWindow: () => null,
|
||||||
|
getModalWindow: () => window as never,
|
||||||
|
createModalWindow: () => {
|
||||||
|
calls.push('create-modal-window');
|
||||||
|
return window as never;
|
||||||
|
},
|
||||||
|
getModalGeometry: () => ({ x: 10, y: 20, width: 300, height: 200 }),
|
||||||
|
setModalWindowBounds: (geometry) => {
|
||||||
|
calls.push(`bounds:${geometry.x},${geometry.y},${geometry.width},${geometry.height}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const sent = runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, {
|
||||||
|
restoreOnModalClose: 'runtime-options',
|
||||||
|
});
|
||||||
|
assert.equal(sent, true);
|
||||||
|
assert.equal(runtime.getRestoreVisibleOverlayOnModalClose().has('runtime-options'), true);
|
||||||
|
assert.deepEqual(calls, ['bounds:10,20,300,200']);
|
||||||
|
assert.equal(window.getShowCount(), 1);
|
||||||
|
assert.equal(window.isFocused(), true);
|
||||||
|
assert.deepEqual(window.sent, [['runtime-options:open']]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sendToActiveOverlayWindow creates modal window lazily when absent', () => {
|
||||||
|
const window = createMockWindow();
|
||||||
|
let modalWindow: ReturnType<typeof createMockWindow> | null = null;
|
||||||
|
const runtime = createOverlayModalRuntimeService({
|
||||||
|
getMainWindow: () => null,
|
||||||
|
getInvisibleWindow: () => null,
|
||||||
|
getModalWindow: () => modalWindow as never,
|
||||||
|
createModalWindow: () => {
|
||||||
|
modalWindow = window;
|
||||||
|
return modalWindow as never;
|
||||||
|
},
|
||||||
|
getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }),
|
||||||
|
setModalWindowBounds: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
runtime.sendToActiveOverlayWindow('jimaku:open', undefined, { restoreOnModalClose: 'jimaku' }),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
assert.deepEqual(window.sent, [['jimaku:open']]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handleOverlayModalClosed hides modal window only after all pending modals close', () => {
|
||||||
|
const window = createMockWindow();
|
||||||
|
const runtime = createOverlayModalRuntimeService({
|
||||||
|
getMainWindow: () => null,
|
||||||
|
getInvisibleWindow: () => null,
|
||||||
|
getModalWindow: () => window as never,
|
||||||
|
createModalWindow: () => window as never,
|
||||||
|
getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }),
|
||||||
|
setModalWindowBounds: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, {
|
||||||
|
restoreOnModalClose: 'runtime-options',
|
||||||
|
});
|
||||||
|
runtime.sendToActiveOverlayWindow('subsync:open-manual', { sourceTracks: [] }, {
|
||||||
|
restoreOnModalClose: 'subsync',
|
||||||
|
});
|
||||||
|
|
||||||
|
runtime.handleOverlayModalClosed('runtime-options');
|
||||||
|
assert.equal(window.getHideCount(), 0);
|
||||||
|
|
||||||
|
runtime.handleOverlayModalClosed('subsync');
|
||||||
|
assert.equal(window.getHideCount(), 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('modal runtime notifies callers when modal input state becomes active/inactive', () => {
|
||||||
|
const window = createMockWindow();
|
||||||
|
const state: boolean[] = [];
|
||||||
|
const runtime = createOverlayModalRuntimeService(
|
||||||
|
{
|
||||||
|
getMainWindow: () => null,
|
||||||
|
getInvisibleWindow: () => null,
|
||||||
|
getModalWindow: () => window as never,
|
||||||
|
createModalWindow: () => window as never,
|
||||||
|
getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }),
|
||||||
|
setModalWindowBounds: () => {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onModalStateChange: (active: boolean): void => {
|
||||||
|
state.push(active);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, {
|
||||||
|
restoreOnModalClose: 'runtime-options',
|
||||||
|
});
|
||||||
|
runtime.sendToActiveOverlayWindow('subsync:open-manual', { sourceTracks: [] }, {
|
||||||
|
restoreOnModalClose: 'subsync',
|
||||||
|
});
|
||||||
|
assert.deepEqual(state, [true]);
|
||||||
|
|
||||||
|
runtime.handleOverlayModalClosed('runtime-options');
|
||||||
|
assert.deepEqual(state, [true]);
|
||||||
|
|
||||||
|
runtime.handleOverlayModalClosed('subsync');
|
||||||
|
assert.deepEqual(state, [true, false]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handleOverlayModalClosed hides modal window for single kiku modal', () => {
|
||||||
|
const window = createMockWindow();
|
||||||
|
const runtime = createOverlayModalRuntimeService({
|
||||||
|
getMainWindow: () => null,
|
||||||
|
getInvisibleWindow: () => null,
|
||||||
|
getModalWindow: () => window as never,
|
||||||
|
createModalWindow: () => window as never,
|
||||||
|
getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }),
|
||||||
|
setModalWindowBounds: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
runtime.sendToActiveOverlayWindow('kiku:field-grouping-open', { test: true }, {
|
||||||
|
restoreOnModalClose: 'kiku',
|
||||||
|
});
|
||||||
|
runtime.handleOverlayModalClosed('kiku');
|
||||||
|
|
||||||
|
assert.equal(window.getHideCount(), 1);
|
||||||
|
assert.equal(runtime.getRestoreVisibleOverlayOnModalClose().size, 0);
|
||||||
|
});
|
||||||
@@ -1,11 +1,16 @@
|
|||||||
import type { BrowserWindow } from 'electron';
|
import type { BrowserWindow } from 'electron';
|
||||||
|
import type { WindowGeometry } from '../types';
|
||||||
|
|
||||||
type OverlayHostedModal = 'runtime-options' | 'subsync' | 'jimaku';
|
type OverlayHostedModal = 'runtime-options' | 'subsync' | 'jimaku' | 'kiku';
|
||||||
type OverlayHostLayer = 'visible' | 'invisible';
|
type OverlayHostLayer = 'visible' | 'invisible';
|
||||||
|
|
||||||
export interface OverlayWindowResolver {
|
export interface OverlayWindowResolver {
|
||||||
getMainWindow: () => BrowserWindow | null;
|
getMainWindow: () => BrowserWindow | null;
|
||||||
getInvisibleWindow: () => BrowserWindow | null;
|
getInvisibleWindow: () => BrowserWindow | null;
|
||||||
|
getModalWindow: () => BrowserWindow | null;
|
||||||
|
createModalWindow: () => BrowserWindow | null;
|
||||||
|
getModalGeometry: () => WindowGeometry;
|
||||||
|
setModalWindowBounds: (geometry: WindowGeometry) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OverlayModalRuntime {
|
export interface OverlayModalRuntime {
|
||||||
@@ -19,9 +24,34 @@ export interface OverlayModalRuntime {
|
|||||||
getRestoreVisibleOverlayOnModalClose: () => Set<OverlayHostedModal>;
|
getRestoreVisibleOverlayOnModalClose: () => Set<OverlayHostedModal>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createOverlayModalRuntimeService(deps: OverlayWindowResolver): OverlayModalRuntime {
|
export interface OverlayModalRuntimeOptions {
|
||||||
|
onModalStateChange?: (isActive: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createOverlayModalRuntimeService(
|
||||||
|
deps: OverlayWindowResolver,
|
||||||
|
options: OverlayModalRuntimeOptions = {},
|
||||||
|
): OverlayModalRuntime {
|
||||||
const restoreVisibleOverlayOnModalClose = new Set<OverlayHostedModal>();
|
const restoreVisibleOverlayOnModalClose = new Set<OverlayHostedModal>();
|
||||||
const overlayModalAutoShownLayer = new Map<OverlayHostedModal, OverlayHostLayer>();
|
let modalActive = false;
|
||||||
|
|
||||||
|
const notifyModalStateChange = (nextState: boolean): void => {
|
||||||
|
if (modalActive === nextState) return;
|
||||||
|
modalActive = nextState;
|
||||||
|
options.onModalStateChange?.(nextState);
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveModalWindow = (): BrowserWindow | null => {
|
||||||
|
const existingWindow = deps.getModalWindow();
|
||||||
|
if (existingWindow && !existingWindow.isDestroyed()) {
|
||||||
|
return existingWindow;
|
||||||
|
}
|
||||||
|
const createdWindow = deps.createModalWindow();
|
||||||
|
if (!createdWindow || createdWindow.isDestroyed()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return createdWindow;
|
||||||
|
};
|
||||||
|
|
||||||
const getTargetOverlayWindow = (): {
|
const getTargetOverlayWindow = (): {
|
||||||
window: BrowserWindow;
|
window: BrowserWindow;
|
||||||
@@ -41,6 +71,15 @@ export function createOverlayModalRuntimeService(deps: OverlayWindowResolver): O
|
|||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const showModalWindow = (window: BrowserWindow): void => {
|
||||||
|
window.show();
|
||||||
|
window.setIgnoreMouseEvents(false);
|
||||||
|
window.focus();
|
||||||
|
if (!window.webContents.isFocused()) {
|
||||||
|
window.webContents.focus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const showOverlayWindowForModal = (window: BrowserWindow, layer: OverlayHostLayer): void => {
|
const showOverlayWindowForModal = (window: BrowserWindow, layer: OverlayHostLayer): void => {
|
||||||
if (layer === 'invisible' && typeof window.showInactive === 'function') {
|
if (layer === 'invisible' && typeof window.showInactive === 'function') {
|
||||||
window.showInactive();
|
window.showInactive();
|
||||||
@@ -57,39 +96,66 @@ export function createOverlayModalRuntimeService(deps: OverlayWindowResolver): O
|
|||||||
payload?: unknown,
|
payload?: unknown,
|
||||||
runtimeOptions?: { restoreOnModalClose?: OverlayHostedModal },
|
runtimeOptions?: { restoreOnModalClose?: OverlayHostedModal },
|
||||||
): boolean => {
|
): boolean => {
|
||||||
const target = getTargetOverlayWindow();
|
|
||||||
if (!target) return false;
|
|
||||||
|
|
||||||
const { window: targetWindow, layer } = target;
|
|
||||||
const wasVisible = targetWindow.isVisible();
|
|
||||||
const restoreOnModalClose = runtimeOptions?.restoreOnModalClose;
|
const restoreOnModalClose = runtimeOptions?.restoreOnModalClose;
|
||||||
|
|
||||||
const sendNow = (): void => {
|
const sendNow = (window: BrowserWindow): void => {
|
||||||
if (payload === undefined) {
|
if (payload === undefined) {
|
||||||
targetWindow.webContents.send(channel);
|
window.webContents.send(channel);
|
||||||
} else {
|
} else {
|
||||||
targetWindow.webContents.send(channel, payload);
|
window.webContents.send(channel, payload);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!wasVisible) {
|
if (restoreOnModalClose) {
|
||||||
showOverlayWindowForModal(targetWindow, layer);
|
const modalWindow = resolveModalWindow();
|
||||||
}
|
if (!modalWindow) return false;
|
||||||
if (!wasVisible && restoreOnModalClose) {
|
|
||||||
|
deps.setModalWindowBounds(deps.getModalGeometry());
|
||||||
|
const wasVisible = modalWindow.isVisible();
|
||||||
|
const wasModalActive = restoreVisibleOverlayOnModalClose.size > 0;
|
||||||
restoreVisibleOverlayOnModalClose.add(restoreOnModalClose);
|
restoreVisibleOverlayOnModalClose.add(restoreOnModalClose);
|
||||||
overlayModalAutoShownLayer.set(restoreOnModalClose, layer);
|
if (!wasModalActive) {
|
||||||
|
notifyModalStateChange(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (targetWindow.webContents.isLoading()) {
|
if (!wasVisible) {
|
||||||
targetWindow.webContents.once('did-finish-load', () => {
|
showModalWindow(modalWindow);
|
||||||
if (targetWindow && !targetWindow.isDestroyed() && !targetWindow.webContents.isLoading()) {
|
} else if (!modalWindow.isFocused()) {
|
||||||
sendNow();
|
showModalWindow(modalWindow);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (modalWindow.webContents.isLoading()) {
|
||||||
|
modalWindow.webContents.once('did-finish-load', () => {
|
||||||
|
if (modalWindow && !modalWindow.isDestroyed() && !modalWindow.webContents.isLoading()) {
|
||||||
|
sendNow(modalWindow);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
sendNow();
|
sendNow(modalWindow);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = getTargetOverlayWindow();
|
||||||
|
if (!target) return false;
|
||||||
|
|
||||||
|
const { window: targetWindow, layer } = target;
|
||||||
|
const wasVisible = targetWindow.isVisible();
|
||||||
|
if (!wasVisible) {
|
||||||
|
showOverlayWindowForModal(targetWindow, layer);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetWindow.webContents.isLoading()) {
|
||||||
|
targetWindow.webContents.once('did-finish-load', () => {
|
||||||
|
if (targetWindow && !targetWindow.isDestroyed() && !targetWindow.webContents.isLoading()) {
|
||||||
|
sendNow(targetWindow);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
sendNow(targetWindow);
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -102,24 +168,13 @@ export function createOverlayModalRuntimeService(deps: OverlayWindowResolver): O
|
|||||||
const handleOverlayModalClosed = (modal: OverlayHostedModal): void => {
|
const handleOverlayModalClosed = (modal: OverlayHostedModal): void => {
|
||||||
if (!restoreVisibleOverlayOnModalClose.has(modal)) return;
|
if (!restoreVisibleOverlayOnModalClose.has(modal)) return;
|
||||||
restoreVisibleOverlayOnModalClose.delete(modal);
|
restoreVisibleOverlayOnModalClose.delete(modal);
|
||||||
const layer = overlayModalAutoShownLayer.get(modal);
|
const modalWindow = deps.getModalWindow();
|
||||||
overlayModalAutoShownLayer.delete(modal);
|
if (!modalWindow || modalWindow.isDestroyed()) return;
|
||||||
if (!layer) return;
|
if (restoreVisibleOverlayOnModalClose.size === 0) {
|
||||||
const shouldKeepLayerVisible = [...restoreVisibleOverlayOnModalClose].some(
|
notifyModalStateChange(false);
|
||||||
(pendingModal) => overlayModalAutoShownLayer.get(pendingModal) === layer,
|
|
||||||
);
|
|
||||||
if (shouldKeepLayerVisible) return;
|
|
||||||
|
|
||||||
if (layer === 'visible') {
|
|
||||||
const mainWindow = deps.getMainWindow();
|
|
||||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
|
||||||
mainWindow.hide();
|
|
||||||
}
|
}
|
||||||
return;
|
if (restoreVisibleOverlayOnModalClose.size === 0) {
|
||||||
}
|
modalWindow.hide();
|
||||||
const invisibleWindow = deps.getInvisibleWindow();
|
|
||||||
if (invisibleWindow && !invisibleWindow.isDestroyed()) {
|
|
||||||
invisibleWindow.hide();
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -20,11 +20,23 @@ test('overlay content measurement store main deps builder maps callbacks', () =>
|
|||||||
test('overlay modal runtime main deps builder maps window resolvers', () => {
|
test('overlay modal runtime main deps builder maps window resolvers', () => {
|
||||||
const mainWindow = { id: 'main' };
|
const mainWindow = { id: 'main' };
|
||||||
const invisibleWindow = { id: 'invisible' };
|
const invisibleWindow = { id: 'invisible' };
|
||||||
|
const modalWindow = { id: 'modal' };
|
||||||
|
const calls: string[] = [];
|
||||||
const deps = createBuildOverlayModalRuntimeMainDepsHandler({
|
const deps = createBuildOverlayModalRuntimeMainDepsHandler({
|
||||||
getMainWindow: () => mainWindow as never,
|
getMainWindow: () => mainWindow as never,
|
||||||
getInvisibleWindow: () => invisibleWindow as never,
|
getInvisibleWindow: () => invisibleWindow as never,
|
||||||
|
getModalWindow: () => modalWindow as never,
|
||||||
|
createModalWindow: () => modalWindow as never,
|
||||||
|
getModalGeometry: () => ({ x: 1, y: 2, width: 3, height: 4 }),
|
||||||
|
setModalWindowBounds: (geometry) =>
|
||||||
|
calls.push(`modal-bounds:${geometry.x},${geometry.y},${geometry.width},${geometry.height}`),
|
||||||
})();
|
})();
|
||||||
|
|
||||||
assert.equal(deps.getMainWindow(), mainWindow);
|
assert.equal(deps.getMainWindow(), mainWindow);
|
||||||
assert.equal(deps.getInvisibleWindow(), invisibleWindow);
|
assert.equal(deps.getInvisibleWindow(), invisibleWindow);
|
||||||
|
assert.equal(deps.getModalWindow(), modalWindow);
|
||||||
|
assert.equal(deps.createModalWindow(), modalWindow);
|
||||||
|
assert.deepEqual(deps.getModalGeometry(), { x: 1, y: 2, width: 3, height: 4 });
|
||||||
|
deps.setModalWindowBounds({ x: 10, y: 20, width: 30, height: 40 });
|
||||||
|
assert.deepEqual(calls, ['modal-bounds:10,20,30,40']);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -14,9 +14,15 @@ export function createBuildOverlayContentMeasurementStoreMainDepsHandler(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createBuildOverlayModalRuntimeMainDepsHandler(deps: OverlayWindowResolver) {
|
export function createBuildOverlayModalRuntimeMainDepsHandler(
|
||||||
|
deps: OverlayWindowResolver,
|
||||||
|
) {
|
||||||
return (): OverlayWindowResolver => ({
|
return (): OverlayWindowResolver => ({
|
||||||
getMainWindow: () => deps.getMainWindow(),
|
getMainWindow: () => deps.getMainWindow(),
|
||||||
getInvisibleWindow: () => deps.getInvisibleWindow(),
|
getInvisibleWindow: () => deps.getInvisibleWindow(),
|
||||||
|
getModalWindow: () => deps.getModalWindow(),
|
||||||
|
createModalWindow: () => deps.createModalWindow(),
|
||||||
|
getModalGeometry: () => deps.getModalGeometry(),
|
||||||
|
setModalWindowBounds: (geometry) => deps.setModalWindowBounds(geometry),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import test from 'node:test';
|
|||||||
import {
|
import {
|
||||||
createBuildCreateInvisibleWindowMainDepsHandler,
|
createBuildCreateInvisibleWindowMainDepsHandler,
|
||||||
createBuildCreateMainWindowMainDepsHandler,
|
createBuildCreateMainWindowMainDepsHandler,
|
||||||
|
createBuildCreateModalWindowMainDepsHandler,
|
||||||
createBuildCreateOverlayWindowMainDepsHandler,
|
createBuildCreateOverlayWindowMainDepsHandler,
|
||||||
createBuildCreateSecondaryWindowMainDepsHandler,
|
createBuildCreateSecondaryWindowMainDepsHandler,
|
||||||
} from './overlay-window-factory-main-deps';
|
} from './overlay-window-factory-main-deps';
|
||||||
@@ -47,5 +48,12 @@ test('overlay window factory main deps builders return mapped handlers', () => {
|
|||||||
const secondaryDeps = buildSecondaryDeps();
|
const secondaryDeps = buildSecondaryDeps();
|
||||||
secondaryDeps.setSecondaryWindow(null);
|
secondaryDeps.setSecondaryWindow(null);
|
||||||
|
|
||||||
assert.deepEqual(calls, ['set-main', 'set-invisible', 'set-secondary']);
|
const buildModalDeps = createBuildCreateModalWindowMainDepsHandler({
|
||||||
|
createOverlayWindow: () => ({ id: 'modal' }),
|
||||||
|
setModalWindow: () => calls.push('set-modal'),
|
||||||
|
});
|
||||||
|
const modalDeps = buildModalDeps();
|
||||||
|
modalDeps.setModalWindow(null);
|
||||||
|
|
||||||
|
assert.deepEqual(calls, ['set-main', 'set-invisible', 'set-secondary', 'set-modal']);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
export function createBuildCreateOverlayWindowMainDepsHandler<TWindow>(deps: {
|
export function createBuildCreateOverlayWindowMainDepsHandler<TWindow>(deps: {
|
||||||
createOverlayWindowCore: (
|
createOverlayWindowCore: (
|
||||||
kind: 'visible' | 'invisible' | 'secondary',
|
kind: 'visible' | 'invisible' | 'secondary' | 'modal',
|
||||||
options: {
|
options: {
|
||||||
isDev: boolean;
|
isDev: boolean;
|
||||||
overlayDebugVisualizationEnabled: boolean;
|
overlayDebugVisualizationEnabled: boolean;
|
||||||
ensureOverlayWindowLevel: (window: TWindow) => void;
|
ensureOverlayWindowLevel: (window: TWindow) => void;
|
||||||
onRuntimeOptionsChanged: () => void;
|
onRuntimeOptionsChanged: () => void;
|
||||||
setOverlayDebugVisualizationEnabled: (enabled: boolean) => void;
|
setOverlayDebugVisualizationEnabled: (enabled: boolean) => void;
|
||||||
isOverlayVisible: (windowKind: 'visible' | 'invisible' | 'secondary') => boolean;
|
isOverlayVisible: (windowKind: 'visible' | 'invisible' | 'secondary' | 'modal') => boolean;
|
||||||
tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean;
|
tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean;
|
||||||
onWindowClosed: (windowKind: 'visible' | 'invisible' | 'secondary') => void;
|
onWindowClosed: (windowKind: 'visible' | 'invisible' | 'secondary' | 'modal') => void;
|
||||||
},
|
},
|
||||||
) => TWindow;
|
) => TWindow;
|
||||||
isDev: boolean;
|
isDev: boolean;
|
||||||
@@ -17,9 +17,9 @@ export function createBuildCreateOverlayWindowMainDepsHandler<TWindow>(deps: {
|
|||||||
ensureOverlayWindowLevel: (window: TWindow) => void;
|
ensureOverlayWindowLevel: (window: TWindow) => void;
|
||||||
onRuntimeOptionsChanged: () => void;
|
onRuntimeOptionsChanged: () => void;
|
||||||
setOverlayDebugVisualizationEnabled: (enabled: boolean) => void;
|
setOverlayDebugVisualizationEnabled: (enabled: boolean) => void;
|
||||||
isOverlayVisible: (windowKind: 'visible' | 'invisible' | 'secondary') => boolean;
|
isOverlayVisible: (windowKind: 'visible' | 'invisible' | 'secondary' | 'modal') => boolean;
|
||||||
tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean;
|
tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean;
|
||||||
onWindowClosed: (windowKind: 'visible' | 'invisible' | 'secondary') => void;
|
onWindowClosed: (windowKind: 'visible' | 'invisible' | 'secondary' | 'modal') => void;
|
||||||
}) {
|
}) {
|
||||||
return () => ({
|
return () => ({
|
||||||
createOverlayWindowCore: deps.createOverlayWindowCore,
|
createOverlayWindowCore: deps.createOverlayWindowCore,
|
||||||
@@ -35,7 +35,7 @@ export function createBuildCreateOverlayWindowMainDepsHandler<TWindow>(deps: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function createBuildCreateMainWindowMainDepsHandler<TWindow>(deps: {
|
export function createBuildCreateMainWindowMainDepsHandler<TWindow>(deps: {
|
||||||
createOverlayWindow: (kind: 'visible' | 'invisible' | 'secondary') => TWindow;
|
createOverlayWindow: (kind: 'visible' | 'invisible' | 'secondary' | 'modal') => TWindow;
|
||||||
setMainWindow: (window: TWindow | null) => void;
|
setMainWindow: (window: TWindow | null) => void;
|
||||||
}) {
|
}) {
|
||||||
return () => ({
|
return () => ({
|
||||||
@@ -45,7 +45,7 @@ export function createBuildCreateMainWindowMainDepsHandler<TWindow>(deps: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function createBuildCreateInvisibleWindowMainDepsHandler<TWindow>(deps: {
|
export function createBuildCreateInvisibleWindowMainDepsHandler<TWindow>(deps: {
|
||||||
createOverlayWindow: (kind: 'visible' | 'invisible' | 'secondary') => TWindow;
|
createOverlayWindow: (kind: 'visible' | 'invisible' | 'secondary' | 'modal') => TWindow;
|
||||||
setInvisibleWindow: (window: TWindow | null) => void;
|
setInvisibleWindow: (window: TWindow | null) => void;
|
||||||
}) {
|
}) {
|
||||||
return () => ({
|
return () => ({
|
||||||
@@ -55,7 +55,7 @@ export function createBuildCreateInvisibleWindowMainDepsHandler<TWindow>(deps: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function createBuildCreateSecondaryWindowMainDepsHandler<TWindow>(deps: {
|
export function createBuildCreateSecondaryWindowMainDepsHandler<TWindow>(deps: {
|
||||||
createOverlayWindow: (kind: 'visible' | 'invisible' | 'secondary') => TWindow;
|
createOverlayWindow: (kind: 'visible' | 'invisible' | 'secondary' | 'modal') => TWindow;
|
||||||
setSecondaryWindow: (window: TWindow | null) => void;
|
setSecondaryWindow: (window: TWindow | null) => void;
|
||||||
}) {
|
}) {
|
||||||
return () => ({
|
return () => ({
|
||||||
@@ -63,3 +63,13 @@ export function createBuildCreateSecondaryWindowMainDepsHandler<TWindow>(deps: {
|
|||||||
setSecondaryWindow: deps.setSecondaryWindow,
|
setSecondaryWindow: deps.setSecondaryWindow,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function createBuildCreateModalWindowMainDepsHandler<TWindow>(deps: {
|
||||||
|
createOverlayWindow: (kind: 'visible' | 'invisible' | 'secondary' | 'modal') => TWindow;
|
||||||
|
setModalWindow: (window: TWindow | null) => void;
|
||||||
|
}) {
|
||||||
|
return () => ({
|
||||||
|
createOverlayWindow: deps.createOverlayWindow,
|
||||||
|
setModalWindow: deps.setModalWindow,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import assert from 'node:assert/strict';
|
|||||||
import {
|
import {
|
||||||
createCreateInvisibleWindowHandler,
|
createCreateInvisibleWindowHandler,
|
||||||
createCreateMainWindowHandler,
|
createCreateMainWindowHandler,
|
||||||
|
createCreateModalWindowHandler,
|
||||||
createCreateOverlayWindowHandler,
|
createCreateOverlayWindowHandler,
|
||||||
createCreateSecondaryWindowHandler,
|
createCreateSecondaryWindowHandler,
|
||||||
} from './overlay-window-factory';
|
} from './overlay-window-factory';
|
||||||
@@ -80,3 +81,18 @@ test('create secondary window handler stores secondary window', () => {
|
|||||||
assert.equal(createSecondaryWindow(), secondaryWindow);
|
assert.equal(createSecondaryWindow(), secondaryWindow);
|
||||||
assert.deepEqual(calls, ['create:secondary', 'set:secondary']);
|
assert.deepEqual(calls, ['create:secondary', 'set:secondary']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('create modal window handler stores modal window', () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
const modalWindow = { id: 'modal' };
|
||||||
|
const createModalWindow = createCreateModalWindowHandler({
|
||||||
|
createOverlayWindow: (kind) => {
|
||||||
|
calls.push(`create:${kind}`);
|
||||||
|
return modalWindow;
|
||||||
|
},
|
||||||
|
setModalWindow: (window) => calls.push(`set:${(window as { id: string }).id}`),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(createModalWindow(), modalWindow);
|
||||||
|
assert.deepEqual(calls, ['create:modal', 'set:modal']);
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
type OverlayWindowKind = 'visible' | 'invisible' | 'secondary';
|
type OverlayWindowKind = 'visible' | 'invisible' | 'secondary' | 'modal';
|
||||||
|
|
||||||
export function createCreateOverlayWindowHandler<TWindow>(deps: {
|
export function createCreateOverlayWindowHandler<TWindow>(deps: {
|
||||||
createOverlayWindowCore: (
|
createOverlayWindowCore: (
|
||||||
@@ -69,3 +69,14 @@ export function createCreateSecondaryWindowHandler<TWindow>(deps: {
|
|||||||
return window;
|
return window;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function createCreateModalWindowHandler<TWindow>(deps: {
|
||||||
|
createOverlayWindow: (kind: OverlayWindowKind) => TWindow;
|
||||||
|
setModalWindow: (window: TWindow | null) => void;
|
||||||
|
}) {
|
||||||
|
return (): TWindow => {
|
||||||
|
const window = deps.createOverlayWindow('modal');
|
||||||
|
deps.setModalWindow(window);
|
||||||
|
return window;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ test('overlay window runtime handlers compose create/main/invisible handlers', (
|
|||||||
let mainWindow: { kind: string } | null = null;
|
let mainWindow: { kind: string } | null = null;
|
||||||
let invisibleWindow: { kind: string } | null = null;
|
let invisibleWindow: { kind: string } | null = null;
|
||||||
let secondaryWindow: { kind: string } | null = null;
|
let secondaryWindow: { kind: string } | null = null;
|
||||||
|
let modalWindow: { kind: string } | null = null;
|
||||||
let debugEnabled = false;
|
let debugEnabled = false;
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
|
|
||||||
@@ -32,6 +33,9 @@ test('overlay window runtime handlers compose create/main/invisible handlers', (
|
|||||||
setSecondaryWindow: (window) => {
|
setSecondaryWindow: (window) => {
|
||||||
secondaryWindow = window;
|
secondaryWindow = window;
|
||||||
},
|
},
|
||||||
|
setModalWindow: (window) => {
|
||||||
|
modalWindow = window;
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.deepEqual(runtime.createOverlayWindow('visible'), { kind: 'visible' });
|
assert.deepEqual(runtime.createOverlayWindow('visible'), { kind: 'visible' });
|
||||||
@@ -46,6 +50,8 @@ test('overlay window runtime handlers compose create/main/invisible handlers', (
|
|||||||
|
|
||||||
assert.deepEqual(runtime.createSecondaryWindow(), { kind: 'secondary' });
|
assert.deepEqual(runtime.createSecondaryWindow(), { kind: 'secondary' });
|
||||||
assert.deepEqual(secondaryWindow, { kind: 'secondary' });
|
assert.deepEqual(secondaryWindow, { kind: 'secondary' });
|
||||||
|
assert.deepEqual(runtime.createModalWindow(), { kind: 'modal' });
|
||||||
|
assert.deepEqual(modalWindow, { kind: 'modal' });
|
||||||
|
|
||||||
assert.equal(debugEnabled, false);
|
assert.equal(debugEnabled, false);
|
||||||
assert.deepEqual(calls, []);
|
assert.deepEqual(calls, []);
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import {
|
import {
|
||||||
createCreateInvisibleWindowHandler,
|
createCreateInvisibleWindowHandler,
|
||||||
createCreateMainWindowHandler,
|
createCreateMainWindowHandler,
|
||||||
|
createCreateModalWindowHandler,
|
||||||
createCreateOverlayWindowHandler,
|
createCreateOverlayWindowHandler,
|
||||||
createCreateSecondaryWindowHandler,
|
createCreateSecondaryWindowHandler,
|
||||||
} from './overlay-window-factory';
|
} from './overlay-window-factory';
|
||||||
import {
|
import {
|
||||||
createBuildCreateInvisibleWindowMainDepsHandler,
|
createBuildCreateInvisibleWindowMainDepsHandler,
|
||||||
createBuildCreateMainWindowMainDepsHandler,
|
createBuildCreateMainWindowMainDepsHandler,
|
||||||
|
createBuildCreateModalWindowMainDepsHandler,
|
||||||
createBuildCreateOverlayWindowMainDepsHandler,
|
createBuildCreateOverlayWindowMainDepsHandler,
|
||||||
createBuildCreateSecondaryWindowMainDepsHandler,
|
createBuildCreateSecondaryWindowMainDepsHandler,
|
||||||
} from './overlay-window-factory-main-deps';
|
} from './overlay-window-factory-main-deps';
|
||||||
@@ -20,6 +22,7 @@ export function createOverlayWindowRuntimeHandlers<TWindow>(deps: {
|
|||||||
setMainWindow: (window: TWindow | null) => void;
|
setMainWindow: (window: TWindow | null) => void;
|
||||||
setInvisibleWindow: (window: TWindow | null) => void;
|
setInvisibleWindow: (window: TWindow | null) => void;
|
||||||
setSecondaryWindow: (window: TWindow | null) => void;
|
setSecondaryWindow: (window: TWindow | null) => void;
|
||||||
|
setModalWindow: (window: TWindow | null) => void;
|
||||||
}) {
|
}) {
|
||||||
const createOverlayWindow = createCreateOverlayWindowHandler<TWindow>(
|
const createOverlayWindow = createCreateOverlayWindowHandler<TWindow>(
|
||||||
createBuildCreateOverlayWindowMainDepsHandler<TWindow>(deps.createOverlayWindowDeps)(),
|
createBuildCreateOverlayWindowMainDepsHandler<TWindow>(deps.createOverlayWindowDeps)(),
|
||||||
@@ -42,11 +45,18 @@ export function createOverlayWindowRuntimeHandlers<TWindow>(deps: {
|
|||||||
setSecondaryWindow: (window) => deps.setSecondaryWindow(window),
|
setSecondaryWindow: (window) => deps.setSecondaryWindow(window),
|
||||||
})(),
|
})(),
|
||||||
);
|
);
|
||||||
|
const createModalWindow = createCreateModalWindowHandler<TWindow>(
|
||||||
|
createBuildCreateModalWindowMainDepsHandler<TWindow>({
|
||||||
|
createOverlayWindow: (kind) => createOverlayWindow(kind),
|
||||||
|
setModalWindow: (window) => deps.setModalWindow(window),
|
||||||
|
})(),
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
createOverlayWindow,
|
createOverlayWindow,
|
||||||
createMainWindow,
|
createMainWindow,
|
||||||
createInvisibleWindow,
|
createInvisibleWindow,
|
||||||
createSecondaryWindow,
|
createSecondaryWindow,
|
||||||
|
createModalWindow,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,7 +57,8 @@ const overlayLayerFromArg = overlayLayerArg?.slice('--overlay-layer='.length);
|
|||||||
const overlayLayer =
|
const overlayLayer =
|
||||||
overlayLayerFromArg === 'visible' ||
|
overlayLayerFromArg === 'visible' ||
|
||||||
overlayLayerFromArg === 'invisible' ||
|
overlayLayerFromArg === 'invisible' ||
|
||||||
overlayLayerFromArg === 'secondary'
|
overlayLayerFromArg === 'secondary' ||
|
||||||
|
overlayLayerFromArg === 'modal'
|
||||||
? overlayLayerFromArg
|
? overlayLayerFromArg
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
@@ -253,7 +254,7 @@ const electronAPI: ElectronAPI = {
|
|||||||
},
|
},
|
||||||
appendClipboardVideoToQueue: (): Promise<ClipboardAppendResult> =>
|
appendClipboardVideoToQueue: (): Promise<ClipboardAppendResult> =>
|
||||||
ipcRenderer.invoke(IPC_CHANNELS.request.appendClipboardVideoToQueue),
|
ipcRenderer.invoke(IPC_CHANNELS.request.appendClipboardVideoToQueue),
|
||||||
notifyOverlayModalClosed: (modal: 'runtime-options' | 'subsync' | 'jimaku') => {
|
notifyOverlayModalClosed: (modal: 'runtime-options' | 'subsync' | 'jimaku' | 'kiku') => {
|
||||||
ipcRenderer.send(IPC_CHANNELS.command.overlayModalClosed, modal);
|
ipcRenderer.send(IPC_CHANNELS.command.overlayModalClosed, modal);
|
||||||
},
|
},
|
||||||
reportOverlayContentBounds: (measurement: OverlayContentMeasurement) => {
|
reportOverlayContentBounds: (measurement: OverlayContentMeasurement) => {
|
||||||
|
|||||||
@@ -190,3 +190,38 @@ test('resolvePlatformInfo supports secondary layer and disables mouse-ignore tog
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('resolvePlatformInfo supports modal layer and disables mouse-ignore toggles', () => {
|
||||||
|
const previousWindow = (globalThis as { window?: unknown }).window;
|
||||||
|
const previousNavigator = (globalThis as { navigator?: unknown }).navigator;
|
||||||
|
|
||||||
|
Object.defineProperty(globalThis, 'window', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
electronAPI: {
|
||||||
|
getOverlayLayer: () => 'modal',
|
||||||
|
},
|
||||||
|
location: { search: '' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
Object.defineProperty(globalThis, 'navigator', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
platform: 'MacIntel',
|
||||||
|
userAgent: 'Mozilla/5.0 (Macintosh)',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const info = resolvePlatformInfo();
|
||||||
|
assert.equal(info.overlayLayer, 'modal');
|
||||||
|
assert.equal(info.isModalLayer, true);
|
||||||
|
assert.equal(info.shouldToggleMouseIgnore, false);
|
||||||
|
} finally {
|
||||||
|
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
|
||||||
|
Object.defineProperty(globalThis, 'navigator', {
|
||||||
|
configurable: true,
|
||||||
|
value: previousNavigator,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export type RendererRecoverySnapshot = {
|
|||||||
isOverlayInteractive: boolean;
|
isOverlayInteractive: boolean;
|
||||||
isOverSubtitle: boolean;
|
isOverSubtitle: boolean;
|
||||||
invisiblePositionEditMode: boolean;
|
invisiblePositionEditMode: boolean;
|
||||||
overlayLayer: 'visible' | 'invisible' | 'secondary';
|
overlayLayer: 'visible' | 'invisible' | 'secondary' | 'modal';
|
||||||
};
|
};
|
||||||
|
|
||||||
type NormalizedRendererError = {
|
type NormalizedRendererError = {
|
||||||
|
|||||||
@@ -110,6 +110,7 @@ export function createKikuModal(
|
|||||||
|
|
||||||
setKikuPreviewError(null);
|
setKikuPreviewError(null);
|
||||||
ctx.dom.kikuPreviewJson.textContent = '';
|
ctx.dom.kikuPreviewJson.textContent = '';
|
||||||
|
window.electronAPI.notifyOverlayModalClosed('kiku');
|
||||||
|
|
||||||
ctx.state.kikuPendingChoice = null;
|
ctx.state.kikuPendingChoice = null;
|
||||||
ctx.state.kikuPreviewCompactData = null;
|
ctx.state.kikuPreviewCompactData = null;
|
||||||
|
|||||||
@@ -567,6 +567,16 @@ body.layer-secondary #secondarySubContainer {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
body.layer-modal #subtitleContainer,
|
||||||
|
body.layer-modal #secondarySubContainer {
|
||||||
|
display: none !important;
|
||||||
|
pointer-events: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.layer-modal #overlay {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
#secondarySubRoot {
|
#secondarySubRoot {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-size: 24px;
|
font-size: 24px;
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
export type OverlayLayer = 'visible' | 'invisible' | 'secondary';
|
export type OverlayLayer = 'visible' | 'invisible' | 'secondary' | 'modal';
|
||||||
|
|
||||||
export type PlatformInfo = {
|
export type PlatformInfo = {
|
||||||
overlayLayer: OverlayLayer;
|
overlayLayer: OverlayLayer;
|
||||||
isInvisibleLayer: boolean;
|
isInvisibleLayer: boolean;
|
||||||
isSecondaryLayer: boolean;
|
isSecondaryLayer: boolean;
|
||||||
|
isModalLayer: boolean;
|
||||||
isLinuxPlatform: boolean;
|
isLinuxPlatform: boolean;
|
||||||
isMacOSPlatform: boolean;
|
isMacOSPlatform: boolean;
|
||||||
shouldToggleMouseIgnore: boolean;
|
shouldToggleMouseIgnore: boolean;
|
||||||
@@ -16,7 +17,10 @@ export function resolvePlatformInfo(): PlatformInfo {
|
|||||||
const overlayLayerFromPreload = window.electronAPI.getOverlayLayer();
|
const overlayLayerFromPreload = window.electronAPI.getOverlayLayer();
|
||||||
const queryLayer = new URLSearchParams(window.location.search).get('layer');
|
const queryLayer = new URLSearchParams(window.location.search).get('layer');
|
||||||
const overlayLayerFromQuery: OverlayLayer | null =
|
const overlayLayerFromQuery: OverlayLayer | null =
|
||||||
queryLayer === 'visible' || queryLayer === 'invisible' || queryLayer === 'secondary'
|
queryLayer === 'visible' ||
|
||||||
|
queryLayer === 'invisible' ||
|
||||||
|
queryLayer === 'secondary' ||
|
||||||
|
queryLayer === 'modal'
|
||||||
? queryLayer
|
? queryLayer
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
@@ -24,12 +28,14 @@ export function resolvePlatformInfo(): PlatformInfo {
|
|||||||
overlayLayerFromQuery ??
|
overlayLayerFromQuery ??
|
||||||
(overlayLayerFromPreload === 'visible' ||
|
(overlayLayerFromPreload === 'visible' ||
|
||||||
overlayLayerFromPreload === 'invisible' ||
|
overlayLayerFromPreload === 'invisible' ||
|
||||||
overlayLayerFromPreload === 'secondary'
|
overlayLayerFromPreload === 'secondary' ||
|
||||||
|
overlayLayerFromPreload === 'modal'
|
||||||
? overlayLayerFromPreload
|
? overlayLayerFromPreload
|
||||||
: 'visible');
|
: 'visible');
|
||||||
|
|
||||||
const isInvisibleLayer = overlayLayer === 'invisible';
|
const isInvisibleLayer = overlayLayer === 'invisible';
|
||||||
const isSecondaryLayer = overlayLayer === 'secondary';
|
const isSecondaryLayer = overlayLayer === 'secondary';
|
||||||
|
const isModalLayer = overlayLayer === 'modal';
|
||||||
const isLinuxPlatform = navigator.platform.toLowerCase().includes('linux');
|
const isLinuxPlatform = navigator.platform.toLowerCase().includes('linux');
|
||||||
const isMacOSPlatform =
|
const isMacOSPlatform =
|
||||||
navigator.platform.toLowerCase().includes('mac') || /mac/i.test(navigator.userAgent);
|
navigator.platform.toLowerCase().includes('mac') || /mac/i.test(navigator.userAgent);
|
||||||
@@ -38,9 +44,10 @@ export function resolvePlatformInfo(): PlatformInfo {
|
|||||||
overlayLayer,
|
overlayLayer,
|
||||||
isInvisibleLayer,
|
isInvisibleLayer,
|
||||||
isSecondaryLayer,
|
isSecondaryLayer,
|
||||||
|
isModalLayer,
|
||||||
isLinuxPlatform,
|
isLinuxPlatform,
|
||||||
isMacOSPlatform,
|
isMacOSPlatform,
|
||||||
shouldToggleMouseIgnore: !isLinuxPlatform && !isSecondaryLayer,
|
shouldToggleMouseIgnore: !isLinuxPlatform && !isSecondaryLayer && !isModalLayer,
|
||||||
invisiblePositionEditToggleCode: 'KeyP',
|
invisiblePositionEditToggleCode: 'KeyP',
|
||||||
invisiblePositionStepPx: 1,
|
invisiblePositionStepPx: 1,
|
||||||
invisiblePositionStepFastPx: 4,
|
invisiblePositionStepFastPx: 4,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { OverlayContentMeasurement, RuntimeOptionId, RuntimeOptionValue } from '../../types';
|
import type { OverlayContentMeasurement, RuntimeOptionId, RuntimeOptionValue } from '../../types';
|
||||||
|
|
||||||
export const OVERLAY_HOSTED_MODALS = ['runtime-options', 'subsync', 'jimaku'] as const;
|
export const OVERLAY_HOSTED_MODALS = ['runtime-options', 'subsync', 'jimaku', 'kiku'] as const;
|
||||||
export type OverlayHostedModal = (typeof OVERLAY_HOSTED_MODALS)[number];
|
export type OverlayHostedModal = (typeof OVERLAY_HOSTED_MODALS)[number];
|
||||||
|
|
||||||
export const IPC_CHANNELS = {
|
export const IPC_CHANNELS = {
|
||||||
|
|||||||
@@ -728,7 +728,7 @@ export interface SubtitleHoverTokenPayload {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ElectronAPI {
|
export interface ElectronAPI {
|
||||||
getOverlayLayer: () => 'visible' | 'invisible' | 'secondary' | null;
|
getOverlayLayer: () => 'visible' | 'invisible' | 'secondary' | 'modal' | null;
|
||||||
onSubtitle: (callback: (data: SubtitleData) => void) => void;
|
onSubtitle: (callback: (data: SubtitleData) => void) => void;
|
||||||
onVisibility: (callback: (visible: boolean) => void) => void;
|
onVisibility: (callback: (visible: boolean) => void) => void;
|
||||||
onSubtitlePosition: (callback: (position: SubtitlePosition | null) => void) => void;
|
onSubtitlePosition: (callback: (position: SubtitlePosition | null) => void) => void;
|
||||||
@@ -780,7 +780,7 @@ export interface ElectronAPI {
|
|||||||
onOpenRuntimeOptions: (callback: () => void) => void;
|
onOpenRuntimeOptions: (callback: () => void) => void;
|
||||||
onOpenJimaku: (callback: () => void) => void;
|
onOpenJimaku: (callback: () => void) => void;
|
||||||
appendClipboardVideoToQueue: () => Promise<ClipboardAppendResult>;
|
appendClipboardVideoToQueue: () => Promise<ClipboardAppendResult>;
|
||||||
notifyOverlayModalClosed: (modal: 'runtime-options' | 'subsync' | 'jimaku') => void;
|
notifyOverlayModalClosed: (modal: 'runtime-options' | 'subsync' | 'jimaku' | 'kiku') => void;
|
||||||
reportOverlayContentBounds: (measurement: OverlayContentMeasurement) => void;
|
reportOverlayContentBounds: (measurement: OverlayContentMeasurement) => void;
|
||||||
reportHoveredSubtitleToken: (tokenIndex: number | null) => void;
|
reportHoveredSubtitleToken: (tokenIndex: number | null) => void;
|
||||||
onConfigHotReload: (callback: (payload: ConfigHotReloadPayload) => void) => void;
|
onConfigHotReload: (callback: (payload: ConfigHotReloadPayload) => void) => void;
|
||||||
|
|||||||
Reference in New Issue
Block a user